aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/app/seal_linux.go
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-07-03 04:11:38 +0900
committerOphestra <cat@gensokyo.uk>2025-07-03 04:36:59 +0900
commit087959e81bcd52104676ccacedd605e6491b4376 (patch)
tree11cd6eccbfd9278f6c99d33191b320bb4e728888 /internal/app/seal_linux.go
parente6967b8bbb5ceec3abbd002f52cff1167a969e9e (diff)
app: remove split implementation
It is completely nonsensical and highly error-prone to have multiple implementations of this in the same build. This should be switched at compile time instead therefore the split packages are pointless. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/app/seal_linux.go')
-rw-r--r--internal/app/seal_linux.go585
1 files changed, 585 insertions, 0 deletions
diff --git a/internal/app/seal_linux.go b/internal/app/seal_linux.go
new file mode 100644
index 00000000..40c68c1b
--- /dev/null
+++ b/internal/app/seal_linux.go
@@ -0,0 +1,585 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "regexp"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "syscall"
+
+ "hakurei.app/container"
+ "hakurei.app/hst"
+ "hakurei.app/internal"
+ "hakurei.app/internal/app/state"
+ "hakurei.app/internal/hlog"
+ "hakurei.app/internal/sys"
+ "hakurei.app/system"
+ "hakurei.app/system/acl"
+ "hakurei.app/system/dbus"
+ "hakurei.app/system/wayland"
+)
+
+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"
+)
+
+var (
+ ErrConfig = errors.New("no configuration to seal")
+ ErrUser = errors.New("invalid aid")
+ ErrHome = errors.New("invalid home directory")
+ ErrName = errors.New("invalid username")
+
+ ErrXDisplay = errors.New(display + " unset")
+
+ ErrPulseCookie = errors.New("pulse cookie not present")
+ ErrPulseSocket = errors.New("pulse socket not present")
+ ErrPulseMode = errors.New("unexpected pulse socket mode")
+)
+
+var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$")
+
+// outcome stores copies of various parts of [hst.Config]
+type outcome struct {
+ // copied from initialising [app]
+ id *stringPair[state.ID]
+ // copied from [sys.State] response
+ runDirPath string
+
+ // 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
+
+ container *container.Params
+ env map[string]string
+ sync *os.File
+
+ f atomic.Bool
+}
+
+// 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 string
+ // process-specific directory in XDG_RUNTIME_DIR, empty if unused
+ runtimeSharePath string
+
+ 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, 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)
+}
+
+// instance returns a process-specific share path within tmpdir
+func (share *shareHost) instance() string {
+ if share.sharePath != "" {
+ return share.sharePath
+ }
+ share.sharePath = path.Join(share.sc.SharePath, 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() string {
+ if share.runtimeSharePath != "" {
+ return share.runtimeSharePath
+ }
+ share.ensureRuntimeDir()
+ share.runtimeSharePath = path.Join(share.sc.RunDirPath, 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 {
+ // application id
+ aid *stringPair[int]
+ // target uid resolved by fid:aid
+ uid *stringPair[int]
+
+ // supplementary group ids
+ supp []string
+
+ // home directory host path
+ data string
+ // app user home directory
+ home string
+ // passwd database username
+ username string
+}
+
+func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Config) error {
+ if seal.ctx != nil {
+ panic("finalise called twice")
+ }
+ seal.ctx = ctx
+
+ {
+ // encode initial configuration for state tracking
+ ct := new(bytes.Buffer)
+ if err := gob.NewEncoder(ct).Encode(config); err != nil {
+ return hlog.WrapErrSuffix(err,
+ "cannot encode initial config:")
+ }
+ seal.ct = ct
+ }
+
+ // allowed aid range 0 to 9999, this is checked again in hsu
+ if config.Identity < 0 || config.Identity > 9999 {
+ return hlog.WrapErr(ErrUser,
+ fmt.Sprintf("identity %d out of range", config.Identity))
+ }
+
+ seal.user = hsuUser{
+ aid: newInt(config.Identity),
+ data: config.Data,
+ home: config.Dir,
+ username: config.Username,
+ }
+ if seal.user.username == "" {
+ seal.user.username = "chronos"
+ } else if !posixUsername.MatchString(seal.user.username) ||
+ len(seal.user.username) >= internal.Sysconf(internal.SC_LOGIN_NAME_MAX) {
+ return hlog.WrapErr(ErrName,
+ fmt.Sprintf("invalid user name %q", seal.user.username))
+ }
+ if seal.user.data == "" || !path.IsAbs(seal.user.data) {
+ return hlog.WrapErr(ErrHome,
+ fmt.Sprintf("invalid home directory %q", seal.user.data))
+ }
+ if seal.user.home == "" {
+ seal.user.home = seal.user.data
+ }
+ if u, err := sys.Uid(seal.user.aid.unwrap()); err != nil {
+ return err
+ } else {
+ seal.user.uid = newInt(u)
+ }
+ seal.user.supp = make([]string, len(config.Groups))
+ for i, name := range config.Groups {
+ if g, err := sys.LookupGroup(name); err != nil {
+ return hlog.WrapErr(err,
+ fmt.Sprintf("unknown group %q", name))
+ } else {
+ seal.user.supp[i] = g.Gid
+ }
+ }
+
+ // this also falls back to host path if encountering an invalid path
+ if !path.IsAbs(config.Shell) {
+ config.Shell = "/bin/sh"
+ if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
+ config.Shell = s
+ }
+ }
+ // do not use the value of shell before this point
+
+ // permissive defaults
+ if config.Container == nil {
+ hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION")
+
+ // hsu clears the environment so resolve paths early
+ if !path.IsAbs(config.Path) {
+ if len(config.Args) > 0 {
+ if p, err := sys.LookPath(config.Args[0]); err != nil {
+ return hlog.WrapErr(err, err.Error())
+ } else {
+ config.Path = p
+ }
+ } else {
+ config.Path = config.Shell
+ }
+ }
+
+ conf := &hst.ContainerConfig{
+ Userns: true,
+ Net: true,
+ Tty: true,
+ AutoEtc: true,
+ }
+ // bind entries in /
+ if d, err := sys.ReadDir("/"); err != nil {
+ return err
+ } else {
+ b := make([]*hst.FilesystemConfig, 0, len(d))
+ for _, ent := range d {
+ p := "/" + ent.Name()
+ switch p {
+ case "/proc":
+ case "/dev":
+ case "/tmp":
+ case "/mnt":
+ case "/etc":
+
+ default:
+ b = append(b, &hst.FilesystemConfig{Src: p, Write: true, Must: true})
+ }
+ }
+ conf.Filesystem = append(conf.Filesystem, b...)
+ }
+
+ // hide nscd from sandbox if present
+ nscd := "/var/run/nscd"
+ if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
+ conf.Cover = append(conf.Cover, nscd)
+ }
+ // bind GPU stuff
+ if config.Enablements&(system.EX11|system.EWayland) != 0 {
+ conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/dri", Device: true})
+ }
+ // opportunistically bind kvm
+ conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/kvm", Device: true})
+
+ config.Container = conf
+ }
+
+ var mapuid, mapgid *stringPair[int]
+ {
+ var uid, gid int
+ var err error
+ seal.container, seal.env, err = newContainer(config.Container, sys, &uid, &gid)
+ if err != nil {
+ return hlog.WrapErrSuffix(err,
+ "cannot initialise container configuration:")
+ }
+ if !path.IsAbs(config.Path) {
+ return hlog.WrapErr(syscall.EINVAL,
+ "invalid program path")
+ }
+ if len(config.Args) == 0 {
+ config.Args = []string{config.Path}
+ }
+ seal.container.Path = config.Path
+ seal.container.Args = config.Args
+
+ mapuid = newInt(uid)
+ mapgid = newInt(gid)
+ if seal.env == nil {
+ seal.env = make(map[string]string, 1<<6)
+ }
+ }
+
+ if !config.Container.AutoEtc {
+ if config.Container.Etc != "" {
+ seal.container.Bind(config.Container.Etc, "/etc", 0)
+ }
+ } else {
+ etcPath := config.Container.Etc
+ if etcPath == "" {
+ etcPath = "/etc"
+ }
+ seal.container.Etc(etcPath, seal.id.String())
+ }
+
+ // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid
+ innerRuntimeDir := path.Join("/run/user", mapuid.String())
+ seal.env[xdgRuntimeDir] = innerRuntimeDir
+ seal.env[xdgSessionClass] = "user"
+ seal.env[xdgSessionType] = "tty"
+
+ share := &shareHost{seal: seal, sc: sys.Paths()}
+ seal.runDirPath = share.sc.RunDirPath
+ seal.sys = system.New(seal.user.uid.unwrap())
+ seal.sys.Ensure(share.sc.SharePath, 0711)
+
+ {
+ runtimeDir := path.Join(share.sc.SharePath, "runtime")
+ seal.sys.Ensure(runtimeDir, 0700)
+ seal.sys.UpdatePermType(system.User, runtimeDir, acl.Execute)
+ runtimeDirInst := path.Join(runtimeDir, seal.user.aid.String())
+ seal.sys.Ensure(runtimeDirInst, 0700)
+ seal.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute)
+ seal.container.Tmpfs("/run/user", 1<<12, 0755)
+ seal.container.Bind(runtimeDirInst, innerRuntimeDir, container.BindWritable)
+ }
+
+ {
+ tmpdir := path.Join(share.sc.SharePath, "tmpdir")
+ seal.sys.Ensure(tmpdir, 0700)
+ seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
+ tmpdirInst := path.Join(tmpdir, seal.user.aid.String())
+ seal.sys.Ensure(tmpdirInst, 01700)
+ seal.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
+ seal.container.Bind(tmpdirInst, "/tmp", container.BindWritable)
+ }
+
+ {
+ homeDir := "/var/empty"
+ if seal.user.home != "" {
+ homeDir = seal.user.home
+ }
+ username := "chronos"
+ if seal.user.username != "" {
+ username = seal.user.username
+ }
+ seal.container.Bind(seal.user.data, homeDir, container.BindWritable)
+ seal.container.Dir = homeDir
+ seal.env["HOME"] = homeDir
+ seal.env["USER"] = username
+ seal.env[shell] = config.Shell
+
+ seal.container.Place("/etc/passwd",
+ []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+homeDir+":"+config.Shell+"\n"))
+ seal.container.Place("/etc/group",
+ []byte("hakurei:x:"+mapgid.String()+":\n"))
+ }
+
+ // pass TERM for proper terminal I/O in initial process
+ if t, ok := sys.LookupEnv(term); ok {
+ seal.env[term] = t
+ }
+
+ if config.Enablements&system.EWayland != 0 {
+ // outer wayland socket (usually `/run/user/%d/wayland-%d`)
+ var socketPath string
+ if name, ok := sys.LookupEnv(wayland.WaylandDisplay); !ok {
+ hlog.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName)
+ socketPath = path.Join(share.sc.RuntimePath, wayland.FallbackName)
+ } else if !path.IsAbs(name) {
+ socketPath = path.Join(share.sc.RuntimePath, name)
+ } else {
+ socketPath = name
+ }
+
+ innerPath := path.Join(innerRuntimeDir, wayland.FallbackName)
+ seal.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." + seal.id.String()
+ }
+ // downstream socket paths
+ outerPath := path.Join(share.instance(), "wayland")
+ seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String())
+ seal.container.Bind(outerPath, innerPath, 0)
+ } else { // bind mount wayland socket (insecure)
+ hlog.Verbose("direct wayland access, PROCEED WITH CAUTION")
+ share.ensureRuntimeDir()
+ seal.container.Bind(socketPath, innerPath, 0)
+ seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
+ }
+ }
+
+ if config.Enablements&system.EX11 != 0 {
+ if d, ok := sys.LookupEnv(display); !ok {
+ return hlog.WrapErr(ErrXDisplay,
+ "DISPLAY is not set")
+ } else {
+ seal.sys.ChangeHosts("#" + seal.user.uid.String())
+ seal.env[display] = d
+ seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix", 0)
+ }
+ }
+
+ if config.Enablements&system.EPulse != 0 {
+ // PulseAudio runtime directory (usually `/run/user/%d/pulse`)
+ pulseRuntimeDir := path.Join(share.sc.RuntimePath, "pulse")
+ // PulseAudio socket (usually `/run/user/%d/pulse/native`)
+ pulseSocket := path.Join(pulseRuntimeDir, "native")
+
+ if _, err := sys.Stat(pulseRuntimeDir); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return hlog.WrapErrSuffix(err,
+ fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir))
+ }
+ return hlog.WrapErr(ErrPulseSocket,
+ fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir))
+ }
+
+ if s, err := sys.Stat(pulseSocket); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return hlog.WrapErrSuffix(err,
+ fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket))
+ }
+ return hlog.WrapErr(ErrPulseSocket,
+ fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir))
+ } else {
+ if m := s.Mode(); m&0o006 != 0o006 {
+ return hlog.WrapErr(ErrPulseMode,
+ fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m)
+ }
+ }
+
+ // hard link pulse socket into target-executable share
+ innerPulseRuntimeDir := path.Join(share.runtime(), "pulse")
+ innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
+ seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
+ seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0)
+ seal.env[pulseServer] = "unix:" + innerPulseSocket
+
+ // publish current user's pulse cookie for target user
+ if src, err := discoverPulseCookie(sys); err != nil {
+ // not fatal
+ hlog.Verbose(strings.TrimSpace(err.(*hlog.BaseError).Message()))
+ } else {
+ innerDst := hst.Tmp + "/pulse-cookie"
+ seal.env[pulseCookie] = innerDst
+ var payload *[]byte
+ seal.container.PlaceP(innerDst, &payload)
+ seal.sys.CopyFile(payload, src, 256, 256)
+ }
+ }
+
+ if config.Enablements&system.EDBus != 0 {
+ // ensure dbus session bus defaults
+ if config.SessionBus == nil {
+ config.SessionBus = dbus.NewConfig(config.ID, true, true)
+ }
+
+ // downstream socket paths
+ sharePath := share.instance()
+ sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket")
+
+ // configure dbus proxy
+ if f, err := seal.sys.ProxyDBus(
+ config.SessionBus, config.SystemBus,
+ sessionPath, systemPath,
+ ); err != nil {
+ return err
+ } else {
+ seal.dbusMsg = f
+ }
+
+ // share proxy sockets
+ sessionInner := path.Join(innerRuntimeDir, "bus")
+ seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner
+ seal.container.Bind(sessionPath, sessionInner, 0)
+ seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
+ if config.SystemBus != nil {
+ systemInner := "/run/dbus/system_bus_socket"
+ seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner
+ seal.container.Bind(systemPath, systemInner, 0)
+ seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
+ }
+ }
+
+ for _, dest := range config.Container.Cover {
+ seal.container.Tmpfs(dest, 1<<13, 0755)
+ }
+
+ // append ExtraPerms last
+ for _, p := range config.ExtraPerms {
+ if p == nil {
+ continue
+ }
+
+ if p.Ensure {
+ seal.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)
+ }
+ seal.sys.UpdatePermType(system.User, p.Path, perms...)
+ }
+
+ // flatten and sort env for deterministic behaviour
+ seal.container.Env = make([]string, 0, len(seal.env))
+ for k, v := range seal.env {
+ if strings.IndexByte(k, '=') != -1 {
+ return hlog.WrapErr(syscall.EINVAL,
+ fmt.Sprintf("invalid environment variable %s", k))
+ }
+ seal.container.Env = append(seal.container.Env, k+"="+v)
+ }
+ slices.Sort(seal.container.Env)
+
+ if hlog.Load() {
+ hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d",
+ seal.user.uid, seal.user.username, config.Groups, seal.container.Args, len(*seal.container.Ops))
+ }
+
+ return nil
+}
+
+// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
+func discoverPulseCookie(sys sys.State) (string, error) {
+ if p, ok := sys.LookupEnv(pulseCookie); ok {
+ return p, nil
+ }
+
+ // dotfile $HOME/.pulse-cookie
+ if p, ok := sys.LookupEnv(home); ok {
+ p = path.Join(p, ".pulse-cookie")
+ if s, err := sys.Stat(p); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return p, hlog.WrapErrSuffix(err,
+ fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
+ }
+ // not found, try next method
+ } else if !s.IsDir() {
+ return p, nil
+ }
+ }
+
+ // $XDG_CONFIG_HOME/pulse/cookie
+ if p, ok := sys.LookupEnv(xdgConfigHome); ok {
+ p = path.Join(p, "pulse", "cookie")
+ if s, err := sys.Stat(p); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return p, hlog.WrapErrSuffix(err,
+ fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
+ }
+ // not found, try next method
+ } else if !s.IsDir() {
+ return p, nil
+ }
+ }
+
+ return "", hlog.WrapErr(ErrPulseCookie,
+ fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
+ pulseCookie, xdgConfigHome, home))
+}