diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-09-25 13:46:21 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-09-25 13:51:54 +0900 |
| commit | ae2df2c450dd655b93a950124b6ab16e2b4d967b (patch) | |
| tree | 849ab40049228e17891351d97097f8c9d0e18c10 /internal/app/finalise.go | |
| parent | 6e3f34f2ecfe0f3cd024a6e73bf6560080feac53 (diff) | |
internal: remove sys package
This package is replaced by container/stub. Remove and replace it with unexported implementation for the upcoming test suite rewrite.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/app/finalise.go')
| -rw-r--r-- | internal/app/finalise.go | 605 |
1 files changed, 605 insertions, 0 deletions
diff --git a/internal/app/finalise.go b/internal/app/finalise.go new file mode 100644 index 00000000..2a0e5e6e --- /dev/null +++ b/internal/app/finalise.go @@ -0,0 +1,605 @@ +package app + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/user" + "slices" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/internal/app/state" + "hakurei.app/internal/hlog" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" + "hakurei.app/system/wayland" +) + +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} +} + +// 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 + // dump dbus proxy message buffer + dbusMsg func() + + user hsuUser + sys *system.I + ctx context.Context + + waitDelay time.Duration + container *container.Params + env map[string]string + sync *os.File + active atomic.Bool + + syscallDispatcher +} + +// 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 + + seal *outcome + sc hst.Paths +} + +// 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.String(), 0700) + share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath.String(), acl.Execute) + share.seal.sys.Ensure(share.sc.RuntimePath.String(), 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset + share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath.String(), acl.Execute) +} + +// 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.String(), 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.String(), 0700) + share.seal.sys.UpdatePerm(share.runtimeSharePath.String(), 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 +} + +func (k *outcome) finalise(ctx context.Context, 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" + ) + + if ctx == nil { + // unreachable + panic("invalid call to finalise") + } + if k.ctx != nil { + // unreachable + panic("attempting to finalise twice") + } + k.ctx = ctx + + if config == nil { + return newWithMessage("invalid configuration") + } + if config.Home == nil { + return newWithMessage("invalid path to home directory") + } + + { + // encode initial configuration for state tracking + ct := new(bytes.Buffer) + if err := gob.NewEncoder(ct).Encode(config); err != nil { + return &hst.AppError{Step: "encode initial config", Err: err} + } + k.ct = ct + } + + // allowed identity range 0 to 9999, this is checked again in hsu + if config.Identity < 0 || config.Identity > 9999 { + 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.MustID(), k.user.identity.unwrap())) + + k.user.supp = make([]string, len(config.Groups)) + for i, name := range config.Groups { + if gid, err := k.lookupGroupId(name); err != nil { + var unknownGroupError user.UnknownGroupError + if errors.As(err, &unknownGroupError) { + return newWithMessageError(fmt.Sprintf("unknown group %q", name), unknownGroupError) + } else { + return &hst.AppError{Step: "look up group by name", Err: err} + } + } else { + k.user.supp[i] = gid + } + } + + // permissive defaults + if config.Container == nil { + hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") + + if config.Shell == nil { + config.Shell = container.AbsFHSRoot.Append("bin", "sh") + s, _ := k.lookupEnv(shell) + if a, err := container.NewAbs(s); err == nil { + config.Shell = a + } + } + + // hsu clears the environment so resolve paths early + if config.Path == nil { + if len(config.Args) > 0 { + if p, err := k.lookPath(config.Args[0]); err != nil { + return &hst.AppError{Step: "look up executable file", Err: err} + } else if config.Path, err = container.NewAbs(p); err != nil { + return newWithMessageError(err.Error(), err) + } + } else { + config.Path = config.Shell + } + } + + conf := &hst.ContainerConfig{ + Userns: true, + HostNet: true, + HostAbstract: true, + Tty: true, + + Filesystem: []hst.FilesystemConfigJSON{ + // autoroot, includes the home directory + {FilesystemConfig: &hst.FSBind{ + Target: container.AbsFHSRoot, + Source: container.AbsFHSRoot, + Write: true, + Special: true, + }}, + }, + } + + // bind GPU stuff + if config.Enablements.Unwrap()&(system.EX11|system.EWayland) != 0 { + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("dri"), Device: true, Optional: true}}) + } + // opportunistically bind kvm + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("kvm"), Device: true, Optional: true}}) + + // hide nscd from container if present + nscd := container.AbsFHSVar.Append("run/nscd") + if _, err := k.stat(nscd.String()); !errors.Is(err, fs.ErrNotExist) { + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSEphemeral{Target: nscd}}) + } + + // do autoetc last + conf.Filesystem = append(conf.Filesystem, + hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{ + Target: container.AbsFHSEtc, + Source: container.AbsFHSEtc, + Special: true, + }}, + ) + + config.Container = conf + } + + // late nil checks for pd behaviour + if config.Shell == nil { + return newWithMessage("invalid shell path") + } + if config.Path == nil { + return newWithMessage("invalid program path") + } + + // TODO(ophestra): revert this after params to shim + share := &shareHost{seal: k} + copyPaths(k.syscallDispatcher, &share.sc, hsu.MustID()) + + var mapuid, mapgid *stringPair[int] + { + var uid, gid int + var err error + k.container, k.env, err = newContainer(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) + } + } + + // 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, k.user.uid.unwrap()) + k.sys.Ensure(share.sc.SharePath.String(), 0711) + + { + runtimeDir := share.sc.SharePath.Append("runtime") + k.sys.Ensure(runtimeDir.String(), 0700) + k.sys.UpdatePermType(system.User, runtimeDir.String(), acl.Execute) + runtimeDirInst := runtimeDir.Append(k.user.identity.String()) + k.sys.Ensure(runtimeDirInst.String(), 0700) + k.sys.UpdatePermType(system.User, runtimeDirInst.String(), acl.Read, acl.Write, acl.Execute) + k.container.Tmpfs(container.AbsFHSRunUser, 1<<12, 0755) + k.container.Bind(runtimeDirInst, innerRuntimeDir, container.BindWritable) + } + + { + tmpdir := share.sc.SharePath.Append("tmpdir") + k.sys.Ensure(tmpdir.String(), 0700) + k.sys.UpdatePermType(system.User, tmpdir.String(), acl.Execute) + tmpdirInst := tmpdir.Append(k.user.identity.String()) + k.sys.Ensure(tmpdirInst.String(), 01700) + k.sys.UpdatePermType(system.User, tmpdirInst.String(), 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) + } + + { + 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() + + 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 + } + + if config.Enablements.Unwrap()&system.EWayland != 0 { + // outer wayland socket (usually `/run/user/%d/wayland-%d`) + var socketPath *container.Absolute + if name, ok := k.lookupEnv(wayland.WaylandDisplay); !ok { + hlog.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 + } + + 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.String(), socketPath.String(), appID, k.id.String()) + k.container.Bind(outerPath, innerPath, 0) + } else { // bind mount wayland socket (insecure) + hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") + share.ensureRuntimeDir() + k.container.Bind(socketPath, innerPath, 0) + k.sys.UpdatePermType(system.EWayland, socketPath.String(), acl.Read, acl.Write, acl.Execute) + } + } + + if config.Enablements.Unwrap()&system.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(system.EX11, socketPath.String(), 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 config.Enablements.Unwrap()&system.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 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)) + } + } + + // hard link pulse socket into target-executable share + innerPulseRuntimeDir := share.runtime().Append("pulse") + innerPulseSocket := innerRuntimeDir.Append("pulse", "native") + k.sys.Link(pulseSocket.String(), innerPulseRuntimeDir.String()) + 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 + } + } + + // $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.String(), 256, 256) + } else { + hlog.Verbose("cannot locate PulseAudio cookie (tried " + + "$PULSE_COOKIE, " + + "$XDG_CONFIG_HOME/pulse/cookie, " + + "$HOME/.pulse-cookie)") + } + } + + if config.Enablements.Unwrap()&system.EDBus != 0 { + // ensure dbus session bus defaults + if config.SessionBus == nil { + config.SessionBus = dbus.NewConfig(config.ID, true, true) + } + + // downstream socket paths + sessionPath, systemPath := share.instance().Append("bus"), share.instance().Append("system_bus_socket") + + // configure dbus proxy + if f, err := k.sys.ProxyDBus( + config.SessionBus, config.SystemBus, + sessionPath.String(), systemPath.String(), + ); err != nil { + return err + } else { + k.dbusMsg = f + } + + // share proxy sockets + sessionInner := innerRuntimeDir.Append("bus") + k.env[dbusSessionBusAddress] = "unix:path=" + sessionInner.String() + k.container.Bind(sessionPath, sessionInner, 0) + k.sys.UpdatePerm(sessionPath.String(), 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.String(), acl.Read, acl.Write) + } + } + + // mount root read-only as the final setup Op + k.container.Remount(container.AbsFHSRoot, syscall.MS_RDONLY) + + // append ExtraPerms last + for _, p := range config.ExtraPerms { + if p == nil || p.Path == nil { + continue + } + + if p.Ensure { + k.sys.Ensure(p.Path.String(), 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) + } + k.sys.UpdatePermType(system.User, p.Path.String(), 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 hlog.Load() { + hlog.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)) + } + + return nil +} |
