diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-10-29 04:32:43 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-10-29 04:33:13 +0900 |
| commit | a0b4e47acc228e48165966d8e139277b8f6f450e (patch) | |
| tree | d363a6781536ce946aa445271ca1d3df587e6031 /internal/outcome/spcontainer.go | |
| parent | a52f7038e5a607dd0901244abb791c96deaf9c2d (diff) | |
internal/outcome: rename from app
This is less ambiguous, and more accurately describes the purpose of the package.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/outcome/spcontainer.go')
| -rw-r--r-- | internal/outcome/spcontainer.go | 391 |
1 files changed, 391 insertions, 0 deletions
diff --git a/internal/outcome/spcontainer.go b/internal/outcome/spcontainer.go new file mode 100644 index 00000000..13808ca1 --- /dev/null +++ b/internal/outcome/spcontainer.go @@ -0,0 +1,391 @@ +package outcome + +import ( + "encoding/gob" + "errors" + "io/fs" + "os" + "path" + "slices" + "strconv" + "syscall" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/internal/validate" + "hakurei.app/message" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +const varRunNscd = fhs.Var + "run/nscd" + +func init() { gob.Register(new(spParamsOp)) } + +// spParamsOp initialises unordered fields of [container.Params] and the optional root filesystem. +// This outcomeOp is hardcoded to always run first. +type spParamsOp struct { + // Value of $TERM, stored during toSystem. + Term string + // Whether $TERM is set, stored during toSystem. + TermSet bool +} + +func (s *spParamsOp) toSystem(state *outcomeStateSys) error { + s.Term, s.TermSet = state.k.lookupEnv("TERM") + state.sys.Ensure(state.sc.SharePath, 0711) + return nil +} + +func (s *spParamsOp) toContainer(state *outcomeStateParams) error { + // pass $TERM for proper terminal I/O in initial process + if s.TermSet { + state.env["TERM"] = s.Term + } + + // in practice there should be less than 30 system mount points + const preallocateOpsCount = 1 << 5 + + state.params.Hostname = state.Container.Hostname + state.params.RetainSession = state.Container.Flags&hst.FTty != 0 + state.params.HostNet = state.Container.Flags&hst.FHostNet != 0 + state.params.HostAbstract = state.Container.Flags&hst.FHostAbstract != 0 + + if state.Container.Path == nil { + return newWithMessage("invalid program path") + } + state.params.Path = state.Container.Path + + if len(state.Container.Args) == 0 { + state.params.Args = []string{state.Container.Path.String()} + } else { + state.params.Args = state.Container.Args + } + + // the container is canceled when shim is requested to exit or receives an interrupt or termination signal; + // this behaviour is implemented in the shim + state.params.ForwardCancel = state.Shim.WaitDelay > 0 + + if state.Container.Flags&hst.FMultiarch != 0 { + state.params.SeccompFlags |= seccomp.AllowMultiarch + } + + if state.Container.Flags&hst.FSeccompCompat == 0 { + state.params.SeccompPresets |= comp.PresetExt + } + if state.Container.Flags&hst.FDevel == 0 { + state.params.SeccompPresets |= comp.PresetDenyDevel + } + if state.Container.Flags&hst.FUserns == 0 { + state.params.SeccompPresets |= comp.PresetDenyNS + } + if state.Container.Flags&hst.FTty == 0 { + state.params.SeccompPresets |= comp.PresetDenyTTY + } + + if state.Container.Flags&hst.FMapRealUID != 0 { + state.params.Uid = state.Mapuid + state.params.Gid = state.Mapgid + } + + { + state.as.AutoEtcPrefix = state.id.String() + ops := make(container.Ops, 0, preallocateOpsCount+len(state.Container.Filesystem)) + state.params.Ops = &ops + state.as.Ops = opsAdapter{&ops} + } + + rootfs, filesystem, _ := resolveRoot(state.Container) + state.filesystem = filesystem + if rootfs != nil { + rootfs.Apply(&state.as) + } + + // early mount points + state.params. + Proc(fhs.AbsProc). + Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755) + if state.Container.Flags&hst.FDevice == 0 { + state.params.DevWritable(fhs.AbsDev, true) + } else { + state.params.Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice) + } + // /dev is mounted readonly later on, this prevents /dev/shm from going readonly with it + state.params.Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777) + + return nil +} + +func init() { gob.Register(new(spFilesystemOp)) } + +// spFilesystemOp applies configured filesystems to [container.Params], excluding the optional root filesystem. +// This outcomeOp is hardcoded to always run last. +type spFilesystemOp struct { + // Matched paths to cover. Stored during toSystem. + HidePaths []*check.Absolute +} + +func (s *spFilesystemOp) toSystem(state *outcomeStateSys) error { + /* retrieve paths and hide them if they're made available in the sandbox; + + this feature tries to improve user experience of permissive defaults, and + to warn about issues in custom configuration; it is NOT a security feature + and should not be treated as such, ALWAYS be careful with what you bind */ + hidePaths := []string{ + state.sc.RuntimePath.String(), + state.sc.SharePath.String(), + + // this causes emulated passwd database to be bypassed on some /etc/ setups + varRunNscd, + } + + // dbus.Address does not go through syscallDispatcher + systemBusAddr := dbus.FallbackSystemBusAddress + if addr, ok := state.k.lookupEnv(dbus.SystemBusAddress); ok { + systemBusAddr = addr + } + + if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { + return &hst.AppError{Step: "parse dbus address", Err: err} + } else { + // there is usually only one, do not preallocate + for _, entry := range entries { + if entry.Method != "unix" { + continue + } + for _, pair := range entry.Values { + if pair[0] == "path" { + if path.IsAbs(pair[1]) { + // get parent dir of socket + dir := path.Dir(pair[1]) + if dir == "." || dir == fhs.Root { + state.msg.Verbosef("dbus socket %q is in an unusual location", pair[1]) + } + hidePaths = append(hidePaths, dir) + } else { + state.msg.Verbosef("dbus socket %q is not absolute", pair[1]) + } + } + } + } + } + hidePathMatch := make([]bool, len(hidePaths)) + for i := range hidePaths { + if err := evalSymlinks(state.msg, state.k, &hidePaths[i]); err != nil { + return &hst.AppError{Step: "evaluate path hiding target", Err: err} + } + } + + _, filesystem, autoroot := resolveRoot(state.Container) + + var hidePathSourceCount int + for i, c := range filesystem { + if !c.Valid() { + return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) + } + + // fs counter + hidePathSourceCount += len(c.Host()) + } + + // AutoRootOp is a collection of many BindMountOp internally + var autoRootEntries []fs.DirEntry + if autoroot != nil { + if d, err := state.k.readdir(autoroot.Source.String()); err != nil { + return &hst.AppError{Step: "access autoroot source", Err: err} + } else { + // autoroot counter + hidePathSourceCount += len(d) + autoRootEntries = d + } + } + + hidePathSource := make([]*check.Absolute, 0, hidePathSourceCount) + + // fs append + for _, c := range filesystem { + // all entries already checked above + hidePathSource = append(hidePathSource, c.Host()...) + } + + // autoroot append + if autoroot != nil { + for _, ent := range autoRootEntries { + name := ent.Name() + if container.IsAutoRootBindable(state.msg, name) { + hidePathSource = append(hidePathSource, autoroot.Source.Append(name)) + } + } + } + + // evaluated path, input path + hidePathSourceEval := make([][2]string, len(hidePathSource)) + for i, a := range hidePathSource { + if a == nil { + // unreachable + return newWithMessage("impossible path hiding state reached") + } + + hidePathSourceEval[i] = [2]string{a.String(), a.String()} + if err := evalSymlinks(state.msg, state.k, &hidePathSourceEval[i][0]); err != nil { + return &hst.AppError{Step: "evaluate path hiding source", Err: err} + } + } + + for _, p := range hidePathSourceEval { + for i := range hidePaths { + // skip matched entries + if hidePathMatch[i] { + continue + } + + if ok, err := validate.DeepContainsH(p[0], hidePaths[i]); err != nil { + return &hst.AppError{Step: "determine path hiding outcome", Err: err} + } else if ok { + hidePathMatch[i] = true + state.msg.Verbosef("hiding path %q from %q", hidePaths[i], p[1]) + } + } + } + + // copy matched paths for shim + for i, ok := range hidePathMatch { + if ok { + if a, err := check.NewAbs(hidePaths[i]); err != nil { + return newWithMessage("invalid path hiding candidate " + strconv.Quote(hidePaths[i])) + } else { + s.HidePaths = append(s.HidePaths, a) + } + } + } + + // append ExtraPerms last + flattenExtraPerms(state.sys, state.extraPerms) + return nil +} + +func (s *spFilesystemOp) toContainer(state *outcomeStateParams) error { + for i, c := range state.filesystem { + if !c.Valid() { + return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) + } + c.Apply(&state.as) + } + + for _, a := range s.HidePaths { + state.params.Tmpfs(a, 1<<13, 0755) + } + + // no more configured paths beyond this point + if state.Container.Flags&hst.FDevice == 0 { + state.params.Remount(fhs.AbsDev, syscall.MS_RDONLY) + } + state.params.Remount(fhs.AbsRoot, syscall.MS_RDONLY) + + state.params.Env = make([]string, 0, len(state.env)) + for key, value := range state.env { + // key validated early via hst + state.params.Env = append(state.params.Env, key+"="+value) + } + slices.Sort(state.params.Env) + + return nil +} + +// resolveRoot handles the root filesystem special case for [hst.FilesystemConfig] and additionally resolves autoroot +// as it requires special handling during path hiding. +func resolveRoot(c *hst.ContainerConfig) (rootfs hst.FilesystemConfig, filesystem []hst.FilesystemConfigJSON, autoroot *hst.FSBind) { + // root filesystem special case + filesystem = c.Filesystem + // valid happens late, so root gets it here + if len(filesystem) > 0 && filesystem[0].Valid() && filesystem[0].Path().String() == fhs.Root { + // if the first element targets /, it is inserted early and excluded from path hiding + rootfs = filesystem[0].FilesystemConfig + filesystem = filesystem[1:] + + // autoroot requires special handling during path hiding + if b, ok := rootfs.(*hst.FSBind); ok && b.IsAutoRoot() { + autoroot = b + } + } + return +} + +// evalSymlinks calls syscallDispatcher.evalSymlinks but discards errors unwrapping to [fs.ErrNotExist]. +func evalSymlinks(msg message.Msg, k syscallDispatcher, v *string) error { + if p, err := k.evalSymlinks(*v); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + msg.Verbosef("path %q does not yet exist", *v) + } else { + *v = p + } + return nil +} + +// flattenExtraPerms expands a slice of [hst.ExtraPermConfig] into [system.I]. +func flattenExtraPerms(sys *system.I, extraPerms []hst.ExtraPermConfig) { + for i := range extraPerms { + p := &extraPerms[i] + if p.Path == nil { + continue + } + + if p.Ensure { + sys.Ensure(p.Path, 0700) + } + + perms := make(acl.Perms, 0, 3) + if p.Read { + perms = append(perms, acl.Read) + } + if p.Write { + perms = append(perms, acl.Write) + } + if p.Execute { + perms = append(perms, acl.Execute) + } + sys.UpdatePermType(system.User, p.Path, perms...) + } +} + +// opsAdapter implements [hst.Ops] on [container.Ops]. +type opsAdapter struct{ *container.Ops } + +func (p opsAdapter) Tmpfs(target *check.Absolute, size int, perm os.FileMode) hst.Ops { + return opsAdapter{p.Ops.Tmpfs(target, size, perm)} +} + +func (p opsAdapter) Readonly(target *check.Absolute, perm os.FileMode) hst.Ops { + return opsAdapter{p.Ops.Readonly(target, perm)} +} + +func (p opsAdapter) Bind(source, target *check.Absolute, flags int) hst.Ops { + return opsAdapter{p.Ops.Bind(source, target, flags)} +} + +func (p opsAdapter) Overlay(target, state, work *check.Absolute, layers ...*check.Absolute) hst.Ops { + return opsAdapter{p.Ops.Overlay(target, state, work, layers...)} +} + +func (p opsAdapter) OverlayReadonly(target *check.Absolute, layers ...*check.Absolute) hst.Ops { + return opsAdapter{p.Ops.OverlayReadonly(target, layers...)} +} + +func (p opsAdapter) Link(target *check.Absolute, linkName string, dereference bool) hst.Ops { + return opsAdapter{p.Ops.Link(target, linkName, dereference)} +} + +func (p opsAdapter) Root(host *check.Absolute, flags int) hst.Ops { + return opsAdapter{p.Ops.Root(host, flags)} +} + +func (p opsAdapter) Etc(host *check.Absolute, prefix string) hst.Ops { + return opsAdapter{p.Ops.Etc(host, prefix)} +} |
