diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-10-05 02:42:41 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-10-05 02:52:50 +0900 |
| commit | eb5ee4fece0327b1183ae823d68e4cf4e3e1ced0 (patch) | |
| tree | 64e1c411e9d0a29637982519b9bd3a5ff6642d1a /internal/app/finalise.go | |
| parent | 9462af08f38fefe40d985383d8825258a6d1f0a8 (diff) | |
internal/app: modularise outcome finalise
This is the initial effort of splitting up host and container side of finalisation for params to shim. The new layout also enables much finer grained unit testing of each step, as well as partition access to per-app state for each step.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/app/finalise.go')
| -rw-r--r-- | internal/app/finalise.go | 486 |
1 files changed, 88 insertions, 398 deletions
diff --git a/internal/app/finalise.go b/internal/app/finalise.go index 39e1649c..46edc950 100644 --- a/internal/app/finalise.go +++ b/internal/app/finalise.go @@ -8,35 +8,21 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "os/user" "slices" - "strconv" "strings" "sync/atomic" "syscall" - "time" "hakurei.app/container" "hakurei.app/hst" "hakurei.app/internal/app/state" "hakurei.app/system" "hakurei.app/system/acl" - "hakurei.app/system/dbus" - "hakurei.app/system/wayland" ) -func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } - -// stringPair stores a value and its string representation. -type stringPair[T comparable] struct { - v T - s string -} - -func (s *stringPair[T]) unwrap() T { return s.v } -func (s *stringPair[T]) String() string { return s.s } - func newWithMessage(msg string) error { return newWithMessageError(msg, os.ErrInvalid) } func newWithMessageError(msg string, err error) error { return &hst.AppError{Step: "finalise", Err: err, Msg: msg} @@ -44,115 +30,40 @@ func newWithMessageError(msg string, err error) error { // An outcome is the runnable state of a hakurei container via [hst.Config]. type outcome struct { - // copied from initialising [app] - id *stringPair[state.ID] - // copied from [sys.State] - runDirPath *container.Absolute - // initial [hst.Config] gob stream for state data; // this is prepared ahead of time as config is clobbered during seal creation ct io.WriterTo - user hsuUser - sys *system.I - ctx context.Context - - waitDelay time.Duration - container *container.Params - env map[string]string - sync *os.File - active atomic.Bool + sys *system.I + ctx context.Context - syscallDispatcher -} + container container.Params -// shareHost holds optional share directory state that must not be accessed directly -type shareHost struct { - // whether XDG_RUNTIME_DIR is used post hsu - useRuntimeDir bool - // process-specific directory in tmpdir, empty if unused - sharePath *container.Absolute - // process-specific directory in XDG_RUNTIME_DIR, empty if unused - runtimeSharePath *container.Absolute + // TODO(ophestra): move this to the system op + sync *os.File - seal *outcome - sc hst.Paths -} + // Populated during outcome.finalise. + proc *finaliseProcess -// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required -func (share *shareHost) ensureRuntimeDir() { - if share.useRuntimeDir { - return - } - share.useRuntimeDir = true - share.seal.sys.Ensure(share.sc.RunDirPath, 0700) - share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath, acl.Execute) - share.seal.sys.Ensure(share.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset - share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath, acl.Execute) -} + // Whether the current process is in outcome.main. + active atomic.Bool -// instance returns a process-specific share path within tmpdir -func (share *shareHost) instance() *container.Absolute { - if share.sharePath != nil { - return share.sharePath - } - share.sharePath = share.sc.SharePath.Append(share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.sharePath, 0711) - return share.sharePath -} - -// runtime returns a process-specific share path within XDG_RUNTIME_DIR -func (share *shareHost) runtime() *container.Absolute { - if share.runtimeSharePath != nil { - return share.runtimeSharePath - } - share.ensureRuntimeDir() - share.runtimeSharePath = share.sc.RunDirPath.Append(share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath, 0700) - share.seal.sys.UpdatePerm(share.runtimeSharePath, acl.Execute) - return share.runtimeSharePath -} - -// hsuUser stores post-hsu credentials and metadata -type hsuUser struct { - identity *stringPair[int] - // target uid resolved by hid:aid - uid *stringPair[int] - - // supplementary group ids - supp []string - - // app user home directory - home *container.Absolute - // passwd database username - username string + syscallDispatcher } -func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.Config) error { +func (k *outcome) finalise(ctx context.Context, msg container.Msg, id *state.ID, config *hst.Config) error { const ( - home = "HOME" - shell = "SHELL" - - xdgConfigHome = "XDG_CONFIG_HOME" - xdgRuntimeDir = "XDG_RUNTIME_DIR" - xdgSessionClass = "XDG_SESSION_CLASS" - xdgSessionType = "XDG_SESSION_TYPE" - - term = "TERM" - display = "DISPLAY" - - pulseServer = "PULSE_SERVER" - pulseCookie = "PULSE_COOKIE" - - dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" - dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" + // only used for a nil configured env map + envAllocSize = 1 << 6 ) - if ctx == nil { + var kp finaliseProcess + + if ctx == nil || id == nil { // unreachable panic("invalid call to finalise") } - if k.ctx != nil { + if k.ctx != nil || k.proc != nil { // unreachable panic("attempting to finalise twice") } @@ -165,6 +76,7 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C return newWithMessage("invalid path to home directory") } + // TODO(ophestra): do not clobber during finalise { // encode initial configuration for state tracking ct := new(bytes.Buffer) @@ -179,21 +91,7 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C return newWithMessage(fmt.Sprintf("identity %d out of range", config.Identity)) } - k.user = hsuUser{ - identity: newInt(config.Identity), - home: config.Home, - username: config.Username, - } - - hsu := Hsu{k: k} - if k.user.username == "" { - k.user.username = "chronos" - } else if !isValidUsername(k.user.username) { - return newWithMessage(fmt.Sprintf("invalid user name %q", k.user.username)) - } - k.user.uid = newInt(HsuUid(hsu.MustIDMsg(msg), k.user.identity.unwrap())) - - k.user.supp = make([]string, len(config.Groups)) + kp.supp = make([]string, len(config.Groups)) for i, name := range config.Groups { if gid, err := k.lookupGroupId(name); err != nil { var unknownGroupError user.UnknownGroupError @@ -203,7 +101,7 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C return &hst.AppError{Step: "look up group by name", Err: err} } } else { - k.user.supp[i] = gid + kp.supp[i] = gid } } @@ -213,7 +111,7 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C if config.Shell == nil { config.Shell = container.AbsFHSRoot.Append("bin", "sh") - s, _ := k.lookupEnv(shell) + s, _ := k.lookupEnv("SHELL") if a, err := container.NewAbs(s); err == nil { config.Shell = a } @@ -282,291 +180,98 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C return newWithMessage("invalid program path") } - // TODO(ophestra): revert this after params to shim - share := &shareHost{seal: k} - copyPaths(k.syscallDispatcher).Copy(&share.sc, hsu.MustIDMsg(msg)) - msg.Verbosef("process share directory at %q, runtime directory at %q", share.sc.SharePath, share.sc.RunDirPath) - - var mapuid, mapgid *stringPair[int] - { - var uid, gid int - var err error - k.container, k.env, err = newContainer(msg, k, config.Container, k.id.String(), &share.sc, &uid, &gid) - k.waitDelay = config.Container.WaitDelay - if err != nil { - return &hst.AppError{Step: "initialise container configuration", Err: err} - } - if len(config.Args) == 0 { - config.Args = []string{config.Path.String()} - } - k.container.Path = config.Path - k.container.Args = config.Args - - mapuid = newInt(uid) - mapgid = newInt(gid) - if k.env == nil { - k.env = make(map[string]string, 1<<6) - } + // enforce bounds and default early + kp.waitDelay = shimWaitTimeout + if config.Container.WaitDelay <= 0 { + kp.waitDelay += DefaultShimWaitDelay + } else if config.Container.WaitDelay > MaxShimWaitDelay { + kp.waitDelay += MaxShimWaitDelay + } else { + kp.waitDelay += config.Container.WaitDelay } - // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid - innerRuntimeDir := container.AbsFHSRunUser.Append(mapuid.String()) - k.env[xdgRuntimeDir] = innerRuntimeDir.String() - k.env[xdgSessionClass] = "user" - k.env[xdgSessionType] = "tty" - - k.runDirPath = share.sc.RunDirPath - k.sys = system.New(k.ctx, msg, k.user.uid.unwrap()) - k.sys.Ensure(share.sc.SharePath, 0711) + s := outcomeState{ + ID: id, + Identity: config.Identity, + UserID: (&Hsu{k: k}).MustIDMsg(msg), + EnvPaths: copyPaths(k.syscallDispatcher), - { - runtimeDir := share.sc.SharePath.Append("runtime") - k.sys.Ensure(runtimeDir, 0700) - k.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) - runtimeDirInst := runtimeDir.Append(k.user.identity.String()) - k.sys.Ensure(runtimeDirInst, 0700) - k.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) - k.container.Tmpfs(container.AbsFHSRunUser, 1<<12, 0755) - k.container.Bind(runtimeDirInst, innerRuntimeDir, container.BindWritable) + // TODO(ophestra): apply pd behaviour here instead of clobbering hst.Config + Container: config.Container, + } + if s.Container.MapRealUID { + s.Mapuid, s.Mapgid = k.getuid(), k.getgid() + } else { + s.Mapuid, s.Mapgid = k.overflowUid(msg), k.overflowGid(msg) } - { - tmpdir := share.sc.SharePath.Append("tmpdir") - k.sys.Ensure(tmpdir, 0700) - k.sys.UpdatePermType(system.User, tmpdir, acl.Execute) - tmpdirInst := tmpdir.Append(k.user.identity.String()) - k.sys.Ensure(tmpdirInst, 01700) - k.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) - // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp - k.container.Bind(tmpdirInst, container.AbsFHSTmp, container.BindWritable) + // TODO(ophestra): duplicate in shim (params to shim) + if err := s.populateLocal(k.syscallDispatcher, msg); err != nil { + return err } + kp.runDirPath, kp.identity, kp.id = s.sc.RunDirPath, s.identity, s.id + k.sys = system.New(k.ctx, msg, s.uid.unwrap()) { - username := "chronos" - if k.user.username != "" { - username = k.user.username - } - k.container.Dir = k.user.home - k.env["HOME"] = k.user.home.String() - k.env["USER"] = username - k.env[shell] = config.Shell.String() + ops := []outcomeOp{ + // must run first + &spParamsOp{Path: config.Path, Args: config.Args}, - k.container.Place(container.AbsFHSEtc.Append("passwd"), - []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+k.user.home.String()+":"+config.Shell.String()+"\n")) - k.container.Place(container.AbsFHSEtc.Append("group"), - []byte("hakurei:x:"+mapgid.String()+":\n")) - } - - // pass TERM for proper terminal I/O in initial process - if t, ok := k.lookupEnv(term); ok { - k.env[term] = t - } + // TODO(ophestra): move this late for #8 and #9 + spFilesystemOp{}, - if config.Enablements.Unwrap()&hst.EWayland != 0 { - // outer wayland socket (usually `/run/user/%d/wayland-%d`) - var socketPath *container.Absolute - if name, ok := k.lookupEnv(wayland.WaylandDisplay); !ok { - msg.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) - socketPath = share.sc.RuntimePath.Append(wayland.FallbackName) - } else if a, err := container.NewAbs(name); err != nil { - socketPath = share.sc.RuntimePath.Append(name) - } else { - socketPath = a + spRuntimeOp{}, + spTmpdirOp{}, + &spAccountOp{Home: config.Home, Username: config.Username, Shell: config.Shell}, } - innerPath := innerRuntimeDir.Append(wayland.FallbackName) - k.env[wayland.WaylandDisplay] = wayland.FallbackName - - if !config.DirectWayland { // set up security-context-v1 - appID := config.ID - if appID == "" { - // use instance ID in case app id is not set - appID = "app.hakurei." + k.id.String() - } - // downstream socket paths - outerPath := share.instance().Append("wayland") - k.sys.Wayland(&k.sync, outerPath, socketPath, appID, k.id.String()) - k.container.Bind(outerPath, innerPath, 0) - } else { // bind mount wayland socket (insecure) - msg.Verbose("direct wayland access, PROCEED WITH CAUTION") - share.ensureRuntimeDir() - k.container.Bind(socketPath, innerPath, 0) - k.sys.UpdatePermType(hst.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) + et := config.Enablements.Unwrap() + if et&hst.EWayland != 0 { + ops = append(ops, &spWaylandOp{sync: &k.sync}) } - } - - if config.Enablements.Unwrap()&hst.EX11 != 0 { - if d, ok := k.lookupEnv(display); !ok { - return newWithMessage("DISPLAY is not set") - } else { - socketDir := container.AbsFHSTmp.Append(".X11-unix") - - // the socket file at `/tmp/.X11-unix/X%d` is typically owned by the priv user - // and not accessible by the target user - var socketPath *container.Absolute - if len(d) > 1 && d[0] == ':' { // `:%d` - if n, err := strconv.Atoi(d[1:]); err == nil && n >= 0 { - socketPath = socketDir.Append("X" + strconv.Itoa(n)) - } - } else if len(d) > 5 && strings.HasPrefix(d, "unix:") { // `unix:%s` - if a, err := container.NewAbs(d[5:]); err == nil { - socketPath = a - } - } - if socketPath != nil { - if _, err := k.stat(socketPath.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} - } - } else { - k.sys.UpdatePermType(hst.EX11, socketPath, acl.Read, acl.Write, acl.Execute) - if !config.Container.HostAbstract { - d = "unix:" + socketPath.String() - } - } - } - - k.sys.ChangeHosts("#" + k.user.uid.String()) - k.env[display] = d - k.container.Bind(socketDir, socketDir, 0) + if et&hst.EX11 != 0 { + ops = append(ops, &spX11Op{}) } - } - - if config.Enablements.Unwrap()&hst.EPulse != 0 { - // PulseAudio runtime directory (usually `/run/user/%d/pulse`) - pulseRuntimeDir := share.sc.RuntimePath.Append("pulse") - // PulseAudio socket (usually `/run/user/%d/pulse/native`) - pulseSocket := pulseRuntimeDir.Append("native") - - if _, err := k.stat(pulseRuntimeDir.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} - } - return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) + if et&hst.EPulse != 0 { + ops = append(ops, &spPulseOp{}) } - - if s, err := k.stat(pulseSocket.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} - } - return newWithMessage(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) - } else { - if m := s.Mode(); m&0o006 != 0o006 { - return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) - } + if et&hst.EDBus != 0 { + ops = append(ops, &spDBusOp{}) } - // hard link pulse socket into target-executable share - innerPulseRuntimeDir := share.runtime().Append("pulse") - innerPulseSocket := innerRuntimeDir.Append("pulse", "native") - k.sys.Link(pulseSocket, innerPulseRuntimeDir) - k.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) - k.env[pulseServer] = "unix:" + innerPulseSocket.String() - - // publish current user's pulse cookie for target user - var paCookiePath *container.Absolute - { - const paLocateStep = "locate PulseAudio cookie" - - // from environment - if p, ok := k.lookupEnv(pulseCookie); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - // this takes precedence, do not verify whether the file is accessible - paCookiePath = a - goto out - } - } - - // $HOME/.pulse-cookie - if p, ok := k.lookupEnv(home); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - paCookiePath = a.Append(".pulse-cookie") - } - - if s, err := k.stat(paCookiePath.String()); err != nil { - paCookiePath = nil - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if s.IsDir() { - paCookiePath = nil - } else { - goto out - } + stateSys := outcomeStateSys{sys: k.sys, outcomeState: &s} + for _, op := range ops { + if err := op.toSystem(&stateSys, config); err != nil { + return err } - - // $XDG_CONFIG_HOME/pulse/cookie - if p, ok := k.lookupEnv(xdgConfigHome); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - paCookiePath = a.Append("pulse", "cookie") - } - if s, err := k.stat(paCookiePath.String()); err != nil { - paCookiePath = nil - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if s.IsDir() { - paCookiePath = nil - } else { - goto out - } - } - out: } - if paCookiePath != nil { - innerDst := hst.AbsTmp.Append("/pulse-cookie") - k.env[pulseCookie] = innerDst.String() - var payload *[]byte - k.container.PlaceP(innerDst, &payload) - k.sys.CopyFile(payload, paCookiePath, 256, 256) + // TODO(ophestra): move to shim + stateParams := outcomeStateParams{params: &k.container, outcomeState: &s} + if s.Container.Env == nil { + stateParams.env = make(map[string]string, envAllocSize) } else { - msg.Verbose("cannot locate PulseAudio cookie (tried " + - "$PULSE_COOKIE, " + - "$XDG_CONFIG_HOME/pulse/cookie, " + - "$HOME/.pulse-cookie)") - } - } - - if config.Enablements.Unwrap()&hst.EDBus != 0 { - // ensure dbus session bus defaults - if config.SessionBus == nil { - config.SessionBus = dbus.NewConfig(config.ID, true, true) + stateParams.env = maps.Clone(s.Container.Env) } - - // downstream socket paths - sessionPath, systemPath := share.instance().Append("bus"), share.instance().Append("system_bus_socket") - - // configure dbus proxy - if err := k.sys.ProxyDBus( - config.SessionBus, config.SystemBus, - sessionPath, systemPath, - ); err != nil { - return err + for _, op := range ops { + if err := op.toContainer(&stateParams); err != nil { + return err + } } - - // share proxy sockets - sessionInner := innerRuntimeDir.Append("bus") - k.env[dbusSessionBusAddress] = "unix:path=" + sessionInner.String() - k.container.Bind(sessionPath, sessionInner, 0) - k.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) - if config.SystemBus != nil { - systemInner := container.AbsFHSRun.Append("dbus/system_bus_socket") - k.env[dbusSystemBusAddress] = "unix:path=" + systemInner.String() - k.container.Bind(systemPath, systemInner, 0) - k.sys.UpdatePerm(systemPath, acl.Read, acl.Write) + // flatten and sort env for deterministic behaviour + k.container.Env = make([]string, 0, len(stateParams.env)) + for key, value := range stateParams.env { + if strings.IndexByte(key, '=') != -1 { + return &hst.AppError{Step: "flatten environment", Err: syscall.EINVAL, + Msg: fmt.Sprintf("invalid environment variable %s", key)} + } + k.container.Env = append(k.container.Env, key+"="+value) } + slices.Sort(k.container.Env) } // mount root read-only as the final setup Op + // TODO(ophestra): move this to spFilesystemOp after #8 and #9 k.container.Remount(container.AbsFHSRoot, syscall.MS_RDONLY) // append ExtraPerms last @@ -592,21 +297,6 @@ func (k *outcome) finalise(ctx context.Context, msg container.Msg, config *hst.C k.sys.UpdatePermType(system.User, p.Path, perms...) } - // flatten and sort env for deterministic behaviour - k.container.Env = make([]string, 0, len(k.env)) - for key, value := range k.env { - if strings.IndexByte(key, '=') != -1 { - return &hst.AppError{Step: "flatten environment", Err: syscall.EINVAL, - Msg: fmt.Sprintf("invalid environment variable %s", key)} - } - k.container.Env = append(k.container.Env, key+"="+value) - } - slices.Sort(k.container.Env) - - if msg.IsVerbose() { - msg.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", - k.user.uid, k.user.username, config.Groups, k.container.Args, len(*k.container.Ops)) - } - + k.proc = &kp return nil } |
