diff --git a/internal/app/app.go b/internal/app/app.go index c5a3e0f..102c273 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,8 +18,8 @@ func Main(ctx context.Context, msg container.Msg, config *hst.Config) { log.Fatal(err) } - seal := outcome{id: &stringPair[state.ID]{id, id.String()}, syscallDispatcher: direct{}} - if err := seal.finalise(ctx, msg, config); err != nil { + seal := outcome{syscallDispatcher: direct{}} + if err := seal.finalise(ctx, msg, &id, config); err != nil { printMessageError("cannot seal app:", err) os.Exit(1) } diff --git a/internal/app/app_stub_test.go b/internal/app/app_stub_test.go index 8d33cd1..fc0030b 100644 --- a/internal/app/app_stub_test.go +++ b/internal/app/app_stub_test.go @@ -1,7 +1,9 @@ package app import ( + "bytes" "fmt" + "io" "io/fs" "log" "os/exec" @@ -52,12 +54,21 @@ func (k *stubNixOS) stat(name string) (fs.FileInfo, error) { case "/home/ophestra/.pulse-cookie": return stubFileInfoIsDir(true), nil case "/home/ophestra/xdg/config/pulse/cookie": - return stubFileInfoIsDir(false), nil + return stubFileInfoPulseCookie{false}, nil default: panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) } } +func (k *stubNixOS) open(name string) (osFile, error) { + switch name { + case "/home/ophestra/xdg/config/pulse/cookie": + return stubOsFileReadCloser{io.NopCloser(bytes.NewReader(bytes.Repeat([]byte{0}, pulseCookieSizeMax)))}, nil + default: + panic(fmt.Sprintf("attempted to open unexpected path %q", name)) + } +} + func (k *stubNixOS) readdir(name string) ([]fs.DirEntry, error) { switch name { case "/": diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5c8e9e7..d06e3df 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,7 +1,9 @@ package app import ( + "bytes" "encoding/json" + "io" "io/fs" "os" "reflect" @@ -19,6 +21,9 @@ import ( ) func TestApp(t *testing.T) { + msg := container.NewMsg(nil) + msg.SwapVerbose(testing.Verbose()) + testCases := []struct { name string k syscallDispatcher @@ -36,7 +41,7 @@ func TestApp(t *testing.T) { 0xbd, 0x01, 0x78, 0x0e, 0xb9, 0xa6, 0x07, 0xac, }, - system.New(t.Context(), container.NewMsg(nil), 1000000). + system.New(t.Context(), msg, 1000000). Ensure(m("/tmp/hakurei.0"), 0711). Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). Ensure(m("/tmp/hakurei.0/runtime/0"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/0"), acl.Read, acl.Write, acl.Execute). @@ -128,7 +133,7 @@ func TestApp(t *testing.T) { 0x82, 0xd4, 0x13, 0x36, 0x9b, 0x64, 0xce, 0x7c, }, - system.New(t.Context(), container.NewMsg(nil), 1000009). + system.New(t.Context(), msg, 1000009). Ensure(m("/tmp/hakurei.0"), 0711). Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). Ensure(m("/tmp/hakurei.0/runtime/9"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute). @@ -140,7 +145,6 @@ func TestApp(t *testing.T) { Ensure(m("/run/user/1971"), 0700).UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset Ephemeral(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), acl.Execute). Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse")). - CopyFile(new([]byte), m("/home/ophestra/xdg/config/pulse/cookie"), 256, 256). MustProxyDBus(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), &dbus.Config{ Talk: []string{ "org.freedesktop.Notifications", @@ -211,7 +215,7 @@ func TestApp(t *testing.T) { Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/65534/wayland-0"), 0). Bind(m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"), m("/run/user/65534/pulse/native"), 0). - Place(m(hst.Tmp+"/pulse-cookie"), nil). + Place(m(hst.Tmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), m("/run/user/65534/bus"), 0). Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). Remount(m("/"), syscall.MS_RDONLY), @@ -279,7 +283,7 @@ func TestApp(t *testing.T) { 0x4c, 0xf0, 0x73, 0xbd, 0xb4, 0x6e, 0xb5, 0xc1, }, - system.New(t.Context(), container.NewMsg(nil), 1000001). + system.New(t.Context(), msg, 1000001). Ensure(m("/tmp/hakurei.0"), 0711). Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). Ensure(m("/tmp/hakurei.0/runtime/1"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/1"), acl.Read, acl.Write, acl.Execute). @@ -290,7 +294,6 @@ func TestApp(t *testing.T) { UpdatePermType(hst.EWayland, m("/run/user/1971/wayland-0"), acl.Read, acl.Write, acl.Execute). Ephemeral(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), acl.Execute). Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse")). - CopyFile(nil, m("/home/ophestra/xdg/config/pulse/cookie"), 256, 256). Ephemeral(system.Process, m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1"), 0711). MustProxyDBus(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), &dbus.Config{ Talk: []string{ @@ -361,7 +364,7 @@ func TestApp(t *testing.T) { Place(m("/etc/group"), []byte("hakurei:x:100:\n")). Bind(m("/run/user/1971/wayland-0"), m("/run/user/1971/wayland-0"), 0). Bind(m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"), m("/run/user/1971/pulse/native"), 0). - Place(m(hst.Tmp+"/pulse-cookie"), nil). + Place(m(hst.Tmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), m("/run/user/1971/bus"), 0). Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). Remount(m("/"), syscall.MS_RDONLY), @@ -375,8 +378,8 @@ func TestApp(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Run("finalise", func(t *testing.T) { - seal := outcome{syscallDispatcher: tc.k, id: &stringPair[state.ID]{tc.id, tc.id.String()}} - err := seal.finalise(t.Context(), container.NewMsg(nil), tc.config) + seal := outcome{syscallDispatcher: tc.k} + err := seal.finalise(t.Context(), msg, &tc.id, tc.config) if err != nil { if s, ok := container.GetErrorMessage(err); !ok { t.Fatalf("Seal: error = %v", err) @@ -392,8 +395,8 @@ func TestApp(t *testing.T) { }) t.Run("params", func(t *testing.T) { - if !reflect.DeepEqual(seal.container, tc.wantParams) { - t.Errorf("seal: container =\n%s\n, want\n%s", mustMarshal(seal.container), mustMarshal(tc.wantParams)) + if !reflect.DeepEqual(&seal.container, tc.wantParams) { + t.Errorf("seal: container =\n%s\n, want\n%s", mustMarshal(&seal.container), mustMarshal(tc.wantParams)) } }) }) @@ -442,6 +445,16 @@ func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTim func (s stubFileInfoIsDir) IsDir() bool { return bool(s) } func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") } +type stubFileInfoPulseCookie struct{ stubFileInfoIsDir } + +func (s stubFileInfoPulseCookie) Size() int64 { return pulseCookieSizeMax } + +type stubOsFileReadCloser struct{ io.ReadCloser } + +func (s stubOsFileReadCloser) Name() string { panic("attempting to call Name") } +func (s stubOsFileReadCloser) Write([]byte) (int, error) { panic("attempting to call Write") } +func (s stubOsFileReadCloser) Stat() (fs.FileInfo, error) { panic("attempting to call Stat") } + func m(pathname string) *container.Absolute { return container.MustAbs(pathname) } diff --git a/internal/app/container.go b/internal/app/container.go deleted file mode 100644 index eef4e22..0000000 --- a/internal/app/container.go +++ /dev/null @@ -1,253 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "io/fs" - "maps" - "path" - "syscall" - - "hakurei.app/container" - "hakurei.app/container/seccomp" - "hakurei.app/hst" - "hakurei.app/system/dbus" -) - -// in practice there should be less than 30 system mount points -const preallocateOpsCount = 1 << 5 - -// newContainer initialises [container.Params] via [hst.ContainerConfig]. -// Note that remaining container setup must be queued by the caller. -func newContainer( - msg container.Msg, - k syscallDispatcher, - s *hst.ContainerConfig, - prefix string, - sc *hst.Paths, - uid, gid *int, -) (*container.Params, map[string]string, error) { - if s == nil { - return nil, nil, newWithMessage("invalid container configuration") - } - - params := &container.Params{ - Hostname: s.Hostname, - RetainSession: s.Tty, - HostNet: s.HostNet, - HostAbstract: s.HostAbstract, - - // the container is canceled when shim is requested to exit or receives an interrupt or termination signal; - // this behaviour is implemented in the shim - ForwardCancel: s.WaitDelay >= 0, - } - - as := &hst.ApplyState{AutoEtcPrefix: prefix} - { - ops := make(container.Ops, 0, preallocateOpsCount+len(s.Filesystem)) - params.Ops = &ops - as.Ops = &ops - } - - if s.Multiarch { - params.SeccompFlags |= seccomp.AllowMultiarch - } - - if !s.SeccompCompat { - params.SeccompPresets |= seccomp.PresetExt - } - if !s.Devel { - params.SeccompPresets |= seccomp.PresetDenyDevel - } - if !s.Userns { - params.SeccompPresets |= seccomp.PresetDenyNS - } - if !s.Tty { - params.SeccompPresets |= seccomp.PresetDenyTTY - } - - if s.MapRealUID { - params.Uid = k.getuid() - *uid = params.Uid - params.Gid = k.getgid() - *gid = params.Gid - } else { - *uid = k.overflowUid(msg) - *gid = k.overflowGid(msg) - } - - filesystem := s.Filesystem - var autoroot *hst.FSBind - // valid happens late, so root mount gets it here - if len(filesystem) > 0 && filesystem[0].Valid() && filesystem[0].Path().String() == container.FHSRoot { - // if the first element targets /, it is inserted early and excluded from path hiding - rootfs := filesystem[0].FilesystemConfig - filesystem = filesystem[1:] - rootfs.Apply(as) - - // autoroot requires special handling during path hiding - if b, ok := rootfs.(*hst.FSBind); ok && b.IsAutoRoot() { - autoroot = b - } - } - - params. - Proc(container.AbsFHSProc). - Tmpfs(hst.AbsTmp, 1<<12, 0755) - - if !s.Device { - params.DevWritable(container.AbsFHSDev, true) - } else { - params.Bind(container.AbsFHSDev, container.AbsFHSDev, container.BindWritable|container.BindDevice) - } - // /dev is mounted readonly later on, this prevents /dev/shm from going readonly with it - params.Tmpfs(container.AbsFHSDev.Append("shm"), 0, 01777) - - /* 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 */ - var hidePaths []string - hidePaths = append(hidePaths, sc.RuntimePath.String(), sc.SharePath.String()) - _, systemBusAddr := dbus.Address() - if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { - return nil, nil, 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 == container.FHSRoot { - msg.Verbosef("dbus socket %q is in an unusual location", pair[1]) - } - hidePaths = append(hidePaths, dir) - } else { - msg.Verbosef("dbus socket %q is not absolute", pair[1]) - } - } - } - } - } - hidePathMatch := make([]bool, len(hidePaths)) - for i := range hidePaths { - if err := evalSymlinks(msg, k, &hidePaths[i]); err != nil { - return nil, nil, err - } - } - - var hidePathSourceCount int - for i, c := range filesystem { - if !c.Valid() { - return nil, nil, fmt.Errorf("invalid filesystem at index %d", i) - } - c.Apply(as) - - // fs counter - hidePathSourceCount += len(c.Host()) - } - - // AutoRootOp is a collection of many BindMountOp internally - var autoRootEntries []fs.DirEntry - if autoroot != nil { - if d, err := k.readdir(autoroot.Source.String()); err != nil { - return nil, nil, err - } else { - // autoroot counter - hidePathSourceCount += len(d) - autoRootEntries = d - } - } - - hidePathSource := make([]*container.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(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 nil, nil, syscall.ENOTRECOVERABLE - } - - hidePathSourceEval[i] = [2]string{a.String(), a.String()} - if err := evalSymlinks(msg, k, &hidePathSourceEval[i][0]); err != nil { - return nil, nil, err - } - } - - for _, p := range hidePathSourceEval { - for i := range hidePaths { - // skip matched entries - if hidePathMatch[i] { - continue - } - - if ok, err := deepContainsH(p[0], hidePaths[i]); err != nil { - return nil, nil, err - } else if ok { - hidePathMatch[i] = true - msg.Verbosef("hiding path %q from %q", hidePaths[i], p[1]) - } - } - } - - // cover matched paths - for i, ok := range hidePathMatch { - if ok { - if a, err := container.NewAbs(hidePaths[i]); err != nil { - var absoluteError *container.AbsoluteError - if !errors.As(err, &absoluteError) { - return nil, nil, err - } - if absoluteError == nil { - return nil, nil, syscall.ENOTRECOVERABLE - } - return nil, nil, fmt.Errorf("invalid path hiding candidate %q", absoluteError.Pathname) - } else { - params.Tmpfs(a, 1<<13, 0755) - } - } - } - - // no more ContainerConfig paths beyond this point - if !s.Device { - params.Remount(container.AbsFHSDev, syscall.MS_RDONLY) - } - - return params, maps.Clone(s.Env), nil -} - -// evalSymlinks calls syscallDispatcher.evalSymlinks but discards errors unwrapping to [fs.ErrNotExist]. -func evalSymlinks(msg container.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 -} diff --git a/internal/app/dispatcher.go b/internal/app/dispatcher.go index 8ab6073..075b2ad 100644 --- a/internal/app/dispatcher.go +++ b/internal/app/dispatcher.go @@ -1,6 +1,8 @@ package app import ( + "io" + "io/fs" "log" "os" "os/exec" @@ -11,6 +13,13 @@ import ( "hakurei.app/internal" ) +// osFile represents [os.File]. +type osFile interface { + Name() string + io.Writer + fs.File +} + // syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour. type syscallDispatcher interface { // new starts a goroutine with a new instance of syscallDispatcher. @@ -26,6 +35,8 @@ type syscallDispatcher interface { lookupEnv(key string) (string, bool) // stat provides [os.Stat]. stat(name string) (os.FileInfo, error) + // open provides [os.Open]. + open(name string) (osFile, error) // readdir provides [os.ReadDir]. readdir(name string) ([]os.DirEntry, error) // tempdir provides [os.TempDir]. @@ -64,6 +75,7 @@ func (direct) getuid() int { return os.Getuid() } func (direct) getgid() int { return os.Getgid() } func (direct) lookupEnv(key string) (string, bool) { return os.LookupEnv(key) } func (direct) stat(name string) (os.FileInfo, error) { return os.Stat(name) } +func (direct) open(name string) (osFile, error) { return os.Open(name) } func (direct) readdir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } func (direct) tempdir() string { return os.TempDir() } diff --git a/internal/app/dispatcher_test.go b/internal/app/dispatcher_test.go index 2c7d926..3a7b21b 100644 --- a/internal/app/dispatcher_test.go +++ b/internal/app/dispatcher_test.go @@ -14,6 +14,7 @@ func (panicDispatcher) getuid() int { panic("unreachab func (panicDispatcher) getgid() int { panic("unreachable") } func (panicDispatcher) lookupEnv(string) (string, bool) { panic("unreachable") } func (panicDispatcher) stat(string) (os.FileInfo, error) { panic("unreachable") } +func (panicDispatcher) open(string) (osFile, error) { panic("unreachable") } func (panicDispatcher) readdir(string) ([]os.DirEntry, error) { panic("unreachable") } func (panicDispatcher) tempdir() string { panic("unreachable") } func (panicDispatcher) evalSymlinks(string) (string, error) { panic("unreachable") } diff --git a/internal/app/finalise.go b/internal/app/finalise.go index 39e1649..46edc95 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 + sys *system.I + ctx context.Context - waitDelay time.Duration - container *container.Params - env map[string]string - sync *os.File - active atomic.Bool + container container.Params + + // TODO(ophestra): move this to the system op + sync *os.File + + // Populated during outcome.finalise. + proc *finaliseProcess + + // Whether the current process is in outcome.main. + 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, 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() *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 -} - -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" + s := outcomeState{ + ID: id, + Identity: config.Identity, + UserID: (&Hsu{k: k}).MustIDMsg(msg), + EnvPaths: copyPaths(k.syscallDispatcher), - k.runDirPath = share.sc.RunDirPath - k.sys = system.New(k.ctx, msg, k.user.uid.unwrap()) - k.sys.Ensure(share.sc.SharePath, 0711) + // 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) + } + + // 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()) { - 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) - } + ops := []outcomeOp{ + // must run first + &spParamsOp{Path: config.Path, Args: config.Args}, - { - 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): move this late for #8 and #9 + spFilesystemOp{}, - { - username := "chronos" - if k.user.username != "" { - username = k.user.username + spRuntimeOp{}, + spTmpdirOp{}, + &spAccountOp{Home: config.Home, Username: config.Username, Shell: config.Shell}, } - 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")) - } + et := config.Enablements.Unwrap() + if et&hst.EWayland != 0 { + ops = append(ops, &spWaylandOp{sync: &k.sync}) + } + if et&hst.EX11 != 0 { + ops = append(ops, &spX11Op{}) + } + if et&hst.EPulse != 0 { + ops = append(ops, &spPulseOp{}) + } + if et&hst.EDBus != 0 { + ops = append(ops, &spDBusOp{}) + } - // pass TERM for proper terminal I/O in initial process - if t, ok := k.lookupEnv(term); ok { - k.env[term] = t - } + stateSys := outcomeStateSys{sys: k.sys, outcomeState: &s} + for _, op := range ops { + if err := op.toSystem(&stateSys, config); err != nil { + return err + } + } - 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) + // 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 { - socketPath = a + stateParams.env = maps.Clone(s.Container.Env) } - - 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) - } - } - - 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 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 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)) + for _, op := range ops { + if err := op.toContainer(&stateParams); err != nil { + return err } } - - // 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 - } + // 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)} } - - // $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, 256, 256) - } 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) - } - - // 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 - } - - // 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) + 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 } diff --git a/internal/app/outcome.go b/internal/app/outcome.go new file mode 100644 index 0000000..f850d77 --- /dev/null +++ b/internal/app/outcome.go @@ -0,0 +1,191 @@ +package app + +import ( + "strconv" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/internal/app/state" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +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 } + +// outcomeState is copied to the shim process and available while applying outcomeOp. +// This is transmitted from the priv side to the shim, so exported fields should be kept to a minimum. +type outcomeState struct { + // Generated and accounted for by the caller. + ID *state.ID + // Copied from ID. + id *stringPair[state.ID] + + // Copied from the [hst.Config] field of the same name. + Identity int + // Copied from Identity. + identity *stringPair[int] + // Returned by [Hsu.MustIDMsg]. + UserID int + // Target init namespace uid resolved from UserID and identity. + uid *stringPair[int] + + // Included as part of [hst.Config], transmitted as-is unless permissive defaults. + Container *hst.ContainerConfig + + // Mapped credentials within container user namespace. + Mapuid, Mapgid int + // Copied from their respective exported values. + mapuid, mapgid *stringPair[int] + + // Copied from [EnvPaths] per-process. + sc hst.Paths + *EnvPaths + + // Matched paths to cover. Populated by spFilesystemOp. + HidePaths []*container.Absolute + + // Copied via populateLocal. + k syscallDispatcher + // Copied via populateLocal. + msg container.Msg +} + +// valid checks outcomeState to be safe for use with outcomeOp. +func (s *outcomeState) valid() bool { + return s != nil && + s.ID != nil && + s.Container != nil && + s.EnvPaths != nil +} + +// populateLocal populates unexported fields from transmitted exported fields. +// These fields are cheaper to recompute per-process. +func (s *outcomeState) populateLocal(k syscallDispatcher, msg container.Msg) error { + if !s.valid() || k == nil || msg == nil { + return newWithMessage("impossible outcome state reached") + } + + if s.k != nil || s.msg != nil { + panic("attempting to call populateLocal twice") + } + s.k = k + s.msg = msg + + s.id = &stringPair[state.ID]{*s.ID, s.ID.String()} + + s.Copy(&s.sc, s.UserID) + msg.Verbosef("process share directory at %q, runtime directory at %q", s.sc.SharePath, s.sc.RunDirPath) + + s.identity = newInt(s.Identity) + s.mapuid, s.mapgid = newInt(s.Mapuid), newInt(s.Mapgid) + s.uid = newInt(HsuUid(s.UserID, s.identity.unwrap())) + + return nil +} + +// instancePath returns a path formatted for outcomeStateSys.instance. +// This method must only be called from outcomeOp.toContainer if +// outcomeOp.toSystem has already called outcomeStateSys.instance. +func (s *outcomeState) instancePath() *container.Absolute { + return s.sc.SharePath.Append(s.id.String()) +} + +// runtimePath returns a path formatted for outcomeStateSys.runtime. +// This method must only be called from outcomeOp.toContainer if +// outcomeOp.toSystem has already called outcomeStateSys.runtime. +func (s *outcomeState) runtimePath() *container.Absolute { + return s.sc.RunDirPath.Append(s.id.String()) +} + +// outcomeStateSys wraps outcomeState and [system.I]. Used on the priv side only. +// Implementations of outcomeOp must not access fields other than sys unless explicitly stated. +type outcomeStateSys struct { + // Whether XDG_RUNTIME_DIR is used post hsu. + useRuntimeDir bool + // Process-specific directory in TMPDIR, nil if unused. + sharePath *container.Absolute + // Process-specific directory in XDG_RUNTIME_DIR, nil if unused. + runtimeSharePath *container.Absolute + + sys *system.I + *outcomeState +} + +// ensureRuntimeDir must be called if access to paths within XDG_RUNTIME_DIR is required. +func (state *outcomeStateSys) ensureRuntimeDir() { + if state.useRuntimeDir { + return + } + state.useRuntimeDir = true + state.sys.Ensure(state.sc.RunDirPath, 0700) + state.sys.UpdatePermType(system.User, state.sc.RunDirPath, acl.Execute) + state.sys.Ensure(state.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset + state.sys.UpdatePermType(system.User, state.sc.RuntimePath, acl.Execute) +} + +// instance returns the pathname to a process-specific directory within TMPDIR. +// This directory must only hold entries bound to [system.Process]. +func (state *outcomeStateSys) instance() *container.Absolute { + if state.sharePath != nil { + return state.sharePath + } + state.sharePath = state.instancePath() + state.sys.Ephemeral(system.Process, state.sharePath, 0711) + return state.sharePath +} + +// runtime returns the pathname to a process-specific directory within XDG_RUNTIME_DIR. +// This directory must only hold entries bound to [system.Process]. +func (state *outcomeStateSys) runtime() *container.Absolute { + if state.runtimeSharePath != nil { + return state.runtimeSharePath + } + state.ensureRuntimeDir() + state.runtimeSharePath = state.runtimePath() + state.sys.Ephemeral(system.Process, state.runtimeSharePath, 0700) + state.sys.UpdatePerm(state.runtimeSharePath, acl.Execute) + return state.runtimeSharePath +} + +// outcomeStateParams wraps outcomeState and [container.Params]. Used on the shim side only. +type outcomeStateParams struct { + // Overrides the embedded [container.Params] in [container.Container]. The Env field must not be used. + params *container.Params + // Collapsed into the Env slice in [container.Params] after every call to outcomeOp.toContainer completes. + env map[string]string + + // Filesystems with the optional root sliced off if present. Populated by spParamsOp. + // Safe for use by spFilesystemOp. + filesystem []hst.FilesystemConfigJSON + + // Inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` via mapped uid. + // Populated by spRuntimeOp. + runtimeDir *container.Absolute + + as hst.ApplyState + *outcomeState +} + +// TODO(ophestra): register outcomeOp implementations (params to shim) + +// An outcomeOp inflicts an outcome on [system.I] and contains enough information to +// inflict it on [container.Params] in a separate process. +// An implementation of outcomeOp must store cross-process states in exported fields only. +type outcomeOp interface { + // toSystem inflicts the current outcome on [system.I] in the priv side process. + toSystem(state *outcomeStateSys, config *hst.Config) error + + // toContainer inflicts the current outcome on [container.Params] in the shim process. + // The implementation must not write to the Env field of [container.Params] as it will be overwritten + // by flattened env map. + toContainer(state *outcomeStateParams) error +} diff --git a/internal/app/process.go b/internal/app/process.go index 29181ea..2046ddf 100644 --- a/internal/app/process.go +++ b/internal/app/process.go @@ -41,6 +41,7 @@ type mainState struct { k *outcome container.Msg uintptr + *finaliseProcess } const ( @@ -78,15 +79,9 @@ func (ms mainState) beforeExit(isFault bool) { // this also handles wait for a non-fault termination if ms.cmd != nil && ms.cmdWait != nil { waitDone := make(chan struct{}) - // TODO(ophestra): enforce this limit early so it does not have to be done twice - shimTimeoutCompensated := shimWaitTimeout - if ms.k.waitDelay > MaxShimWaitDelay { - shimTimeoutCompensated += MaxShimWaitDelay - } else { - shimTimeoutCompensated += ms.k.waitDelay - } + // this ties waitDone to ctx with the additional compensated timeout duration - go func() { <-ms.k.ctx.Done(); time.Sleep(shimTimeoutCompensated); close(waitDone) }() + go func() { <-ms.k.ctx.Done(); time.Sleep(ms.waitDelay); close(waitDone) }() select { case err := <-ms.cmdWait: @@ -137,9 +132,9 @@ func (ms mainState) beforeExit(isFault bool) { } if ms.uintptr&mainNeedsRevert != 0 { - if ok, err := ms.store.Do(ms.k.user.identity.unwrap(), func(c state.Cursor) { + if ok, err := ms.store.Do(ms.identity.unwrap(), func(c state.Cursor) { if ms.uintptr&mainNeedsDestroy != 0 { - if err := c.Destroy(ms.k.id.unwrap()); err != nil { + if err := c.Destroy(ms.id.unwrap()); err != nil { perror(err, "destroy state entry") } } @@ -216,23 +211,45 @@ func (ms mainState) fatal(fallback string, ferr error) { os.Exit(1) } +// finaliseProcess contains information collected during outcome.finalise used in outcome.main. +type finaliseProcess struct { + // Supplementary group ids. + supp []string + + // Copied from [hst.ContainerConfig], without exceeding [MaxShimWaitDelay]. + waitDelay time.Duration + + // Copied from the RunDirPath field of [hst.Paths]. + runDirPath *container.Absolute + + // Copied from outcomeState. + identity *stringPair[int] + + // Copied from outcomeState. + id *stringPair[state.ID] +} + // main carries out outcome and terminates. main does not return. func (k *outcome) main(msg container.Msg) { if !k.active.CompareAndSwap(false, true) { panic("outcome: attempted to run twice") } + if k.proc == nil { + panic("outcome: did not finalise") + } + // read comp value early for early failure hsuPath := internal.MustHsuPath() // ms.beforeExit required beyond this point - ms := &mainState{Msg: msg, k: k} + ms := &mainState{Msg: msg, k: k, finaliseProcess: k.proc} if err := k.sys.Commit(); err != nil { ms.fatal("cannot commit system setup:", err) } ms.uintptr |= mainNeedsRevert - ms.store = state.NewMulti(msg, k.runDirPath.String()) + ms.store = state.NewMulti(msg, ms.runDirPath.String()) ctx, cancel := context.WithCancel(k.ctx) defer cancel() @@ -253,14 +270,14 @@ func (k *outcome) main(msg container.Msg) { // passed through to shim by hsu shimEnv + "=" + strconv.Itoa(fd), // interpreted by hsu - "HAKUREI_IDENTITY=" + k.user.identity.String(), + "HAKUREI_IDENTITY=" + ms.identity.String(), } } - if len(k.user.supp) > 0 { - msg.Verbosef("attaching supplementary group ids %s", k.user.supp) + if len(ms.supp) > 0 { + msg.Verbosef("attaching supplementary group ids %s", ms.supp) // interpreted by hsu - ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.user.supp, " ")) + ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(ms.supp, " ")) } msg.Verbosef("setuid helper at %s", hsuPath) @@ -282,8 +299,8 @@ func (k *outcome) main(msg container.Msg) { go func() { setupErr <- e.Encode(&shimParams{ os.Getpid(), - k.waitDelay, - k.container, + ms.waitDelay, + &k.container, msg.IsVerbose(), }) }() @@ -300,9 +317,9 @@ func (k *outcome) main(msg container.Msg) { } // shim accepted setup payload, create process state - if ok, err := ms.store.Do(k.user.identity.unwrap(), func(c state.Cursor) { + if ok, err := ms.store.Do(ms.identity.unwrap(), func(c state.Cursor) { if err := c.Save(&state.State{ - ID: k.id.unwrap(), + ID: ms.id.unwrap(), PID: ms.cmd.Process.Pid, Time: *ms.Time, }, k.ct); err != nil { diff --git a/internal/app/shim.go b/internal/app/shim.go index 8c860e1..417f793 100644 --- a/internal/app/shim.go +++ b/internal/app/shim.go @@ -148,13 +148,8 @@ func ShimMain() { z.Params = *params.Container z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr + // bounds and default enforced in finalise.go z.WaitDelay = params.WaitDelay - if z.WaitDelay == 0 { - z.WaitDelay = DefaultShimWaitDelay - } - if z.WaitDelay > MaxShimWaitDelay { - z.WaitDelay = MaxShimWaitDelay - } if err := z.Start(); err != nil { printMessageError("cannot start container:", err) diff --git a/internal/app/spaccount.go b/internal/app/spaccount.go new file mode 100644 index 0000000..dbe3b03 --- /dev/null +++ b/internal/app/spaccount.go @@ -0,0 +1,55 @@ +package app + +import ( + "fmt" + "syscall" + + "hakurei.app/container" + "hakurei.app/hst" +) + +// spAccountOp sets up user account emulation inside the container. +type spAccountOp struct { + // Inner directory to use as the home directory of the emulated user. + Home *container.Absolute + // String matching the default NAME_REGEX value from adduser to use as the username of the emulated user. + Username string + // Pathname of shell to use for the emulated user. + Shell *container.Absolute +} + +func (s *spAccountOp) toSystem(*outcomeStateSys, *hst.Config) error { + const fallbackUsername = "chronos" + + // do checks here to fail before fork/exec + if s.Home == nil || s.Shell == nil { + // unreachable + return syscall.ENOTRECOVERABLE + } + if s.Username == "" { + s.Username = fallbackUsername + } else if !isValidUsername(s.Username) { + return newWithMessage(fmt.Sprintf("invalid user name %q", s.Username)) + } + return nil +} + +func (s *spAccountOp) toContainer(state *outcomeStateParams) error { + state.params.Dir = s.Home + state.env["HOME"] = s.Home.String() + state.env["USER"] = s.Username + state.env["SHELL"] = s.Shell.String() + + state.params. + Place(container.AbsFHSEtc.Append("passwd"), + []byte(s.Username+":x:"+ + state.mapuid.String()+":"+ + state.mapgid.String()+ + ":Hakurei:"+ + s.Home.String()+":"+ + s.Shell.String()+"\n")). + Place(container.AbsFHSEtc.Append("group"), + []byte("hakurei:x:"+state.mapgid.String()+":\n")) + + return nil +} diff --git a/internal/app/spcontainer.go b/internal/app/spcontainer.go new file mode 100644 index 0000000..ee18cec --- /dev/null +++ b/internal/app/spcontainer.go @@ -0,0 +1,300 @@ +package app + +import ( + "errors" + "io/fs" + "path" + "strconv" + "syscall" + + "hakurei.app/container" + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/system/dbus" +) + +// spParamsOp initialises unordered fields of [container.Params] and the optional root filesystem. +// This outcomeOp is hardcoded to always run first. +type spParamsOp struct { + // Copied from the [hst.Config] field of the same name. + Path *container.Absolute `json:"path,omitempty"` + // Copied from the [hst.Config] field of the same name. + Args []string `json:"args"` + + // Value of $TERM, stored during toSystem. + Term string + // Whether $TERM is set, stored during toSystem. + TermSet bool +} + +func (s *spParamsOp) toSystem(state *outcomeStateSys, _ *hst.Config) 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.Tty + state.params.HostNet = state.Container.HostNet + state.params.HostAbstract = state.Container.HostAbstract + + if s.Path == nil { + return newWithMessage("invalid program path") + } + state.params.Path = s.Path + + if len(s.Args) == 0 { + state.params.Args = []string{s.Path.String()} + } else { + state.params.Args = s.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.Container.WaitDelay >= 0 + + if state.Container.Multiarch { + state.params.SeccompFlags |= seccomp.AllowMultiarch + } + + if !state.Container.SeccompCompat { + state.params.SeccompPresets |= seccomp.PresetExt + } + if !state.Container.Devel { + state.params.SeccompPresets |= seccomp.PresetDenyDevel + } + if !state.Container.Userns { + state.params.SeccompPresets |= seccomp.PresetDenyNS + } + if !state.Container.Tty { + state.params.SeccompPresets |= seccomp.PresetDenyTTY + } + + if state.Container.MapRealUID { + 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 = &ops + } + + rootfs, filesystem, _ := resolveRoot(state.Container) + state.filesystem = filesystem + if rootfs != nil { + rootfs.Apply(&state.as) + } + + // early mount points + state.params. + Proc(container.AbsFHSProc). + Tmpfs(hst.AbsTmp, 1<<12, 0755) + if !state.Container.Device { + state.params.DevWritable(container.AbsFHSDev, true) + } else { + state.params.Bind(container.AbsFHSDev, container.AbsFHSDev, container.BindWritable|container.BindDevice) + } + // /dev is mounted readonly later on, this prevents /dev/shm from going readonly with it + state.params.Tmpfs(container.AbsFHSDev.Append("shm"), 0, 01777) + + return nil +} + +// spFilesystemOp applies configured filesystems to [container.Params], excluding the optional root filesystem. +type spFilesystemOp struct{} + +func (s spFilesystemOp) toSystem(state *outcomeStateSys, _ *hst.Config) 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 */ + var hidePaths []string + hidePaths = append(hidePaths, state.sc.RuntimePath.String(), state.sc.SharePath.String()) + _, systemBusAddr := dbus.Address() + 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 == container.FHSRoot { + 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([]*container.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 := 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 := container.NewAbs(hidePaths[i]); err != nil { + var absoluteError *container.AbsoluteError + if !errors.As(err, &absoluteError) { + return newWithMessageError(absoluteError.Error(), absoluteError) + } + if absoluteError == nil { + return newWithMessage("impossible path checking state reached") + } + return newWithMessage("invalid path hiding candidate " + strconv.Quote(absoluteError.Pathname)) + } else { + state.HidePaths = append(state.HidePaths, a) + } + } + } + + 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 state.HidePaths { + state.params.Tmpfs(a, 1<<13, 0755) + } + + // no more configured paths beyond this point + if !state.Container.Device { + state.params.Remount(container.AbsFHSDev, syscall.MS_RDONLY) + } + 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() == container.FHSRoot { + // 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 container.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 +} diff --git a/internal/app/spdbus.go b/internal/app/spdbus.go new file mode 100644 index 0000000..42d523d --- /dev/null +++ b/internal/app/spdbus.go @@ -0,0 +1,50 @@ +package app + +import ( + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +// spDBusOp maintains an xdg-dbus-proxy instance for the container. +type spDBusOp struct { + // Whether to bind the system bus socket. + // Populated during toSystem. + ProxySystem bool +} + +func (s *spDBusOp) toSystem(state *outcomeStateSys, config *hst.Config) error { + if config.SessionBus == nil { + config.SessionBus = dbus.NewConfig(config.ID, true, true) + } + + // downstream socket paths + sessionPath, systemPath := state.instance().Append("bus"), state.instance().Append("system_bus_socket") + + if err := state.sys.ProxyDBus( + config.SessionBus, config.SystemBus, + sessionPath, systemPath, + ); err != nil { + return err + } + + state.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) + if config.SystemBus != nil { + s.ProxySystem = true + state.sys.UpdatePerm(systemPath, acl.Read, acl.Write) + } + return nil +} + +func (s *spDBusOp) toContainer(state *outcomeStateParams) error { + sessionInner := state.runtimeDir.Append("bus") + state.env["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=" + sessionInner.String() + state.params.Bind(state.instancePath().Append("bus"), sessionInner, 0) + if s.ProxySystem { + systemInner := container.AbsFHSRun.Append("dbus/system_bus_socket") + state.env["DBUS_SYSTEM_BUS_ADDRESS"] = "unix:path=" + systemInner.String() + state.params.Bind(state.instancePath().Append("system_bus_socket"), systemInner, 0) + } + return nil +} diff --git a/internal/app/sppulse.go b/internal/app/sppulse.go new file mode 100644 index 0000000..d3742db --- /dev/null +++ b/internal/app/sppulse.go @@ -0,0 +1,170 @@ +package app + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "syscall" + + "hakurei.app/container" + "hakurei.app/hst" +) + +const pulseCookieSizeMax = 1 << 8 + +// spPulseOp exports the PulseAudio server to the container. +type spPulseOp struct { + // PulseAudio cookie data, populated during toSystem if a cookie is present. + Cookie *[pulseCookieSizeMax]byte +} + +func (s *spPulseOp) toSystem(state *outcomeStateSys, _ *hst.Config) error { + pulseRuntimeDir, pulseSocket := s.commonPaths(state.outcomeState) + + if _, err := state.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 fi, err := state.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 := fi.Mode(); m&0o006 != 0o006 { + return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) + } + } + + // hard link pulse socket into target-executable share + state.sys.Link(pulseSocket, state.runtime().Append("pulse")) + + // publish current user's pulse cookie for target user + var paCookiePath *container.Absolute + { + const paLocateStep = "locate PulseAudio cookie" + + // from environment + if p, ok := state.k.lookupEnv("PULSE_COOKIE"); 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 := state.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 fi, err := state.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 fi.IsDir() { + paCookiePath = nil + } else { + goto out + } + } + + // $XDG_CONFIG_HOME/pulse/cookie + if p, ok := state.k.lookupEnv("XDG_CONFIG_HOME"); ok { + if a, err := container.NewAbs(p); err != nil { + return &hst.AppError{Step: paLocateStep, Err: err} + } else { + paCookiePath = a.Append("pulse", "cookie") + } + if fi, err := state.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 fi.IsDir() { + paCookiePath = nil + } else { + goto out + } + } + out: + } + + if paCookiePath != nil { + if b, err := state.k.stat(paCookiePath.String()); err != nil { + return &hst.AppError{Step: "access PulseAudio cookie", Err: err} + } else { + if b.IsDir() { + return &hst.AppError{Step: "read PulseAudio cookie", Err: &os.PathError{Op: "stat", Path: paCookiePath.String(), Err: syscall.EISDIR}} + } + if b.Size() > pulseCookieSizeMax { + return newWithMessageError( + fmt.Sprintf("PulseAudio cookie at %q exceeds maximum expected size", paCookiePath), + &os.PathError{Op: "stat", Path: paCookiePath.String(), Err: syscall.ENOMEM}, + ) + } + } + + var r io.ReadCloser + if f, err := state.k.open(paCookiePath.String()); err != nil { + return &hst.AppError{Step: "open PulseAudio cookie", Err: err} + } else { + r = f + } + + s.Cookie = new([pulseCookieSizeMax]byte) + if n, err := r.Read(s.Cookie[:]); err != nil { + if !errors.Is(err, io.EOF) { + _ = r.Close() + return &hst.AppError{Step: "read PulseAudio cookie", Err: err} + } + state.msg.Verbosef("copied %d bytes from %q", n, paCookiePath) + } + + if err := r.Close(); err != nil { + return &hst.AppError{Step: "close PulseAudio cookie", Err: err} + } + } else { + state.msg.Verbose("cannot locate PulseAudio cookie (tried " + + "$PULSE_COOKIE, " + + "$XDG_CONFIG_HOME/pulse/cookie, " + + "$HOME/.pulse-cookie)") + } + + return nil +} + +func (s *spPulseOp) toContainer(state *outcomeStateParams) error { + innerPulseSocket := state.runtimeDir.Append("pulse", "native") + state.params.Bind(state.runtimePath().Append("pulse"), innerPulseSocket, 0) + state.env["PULSE_SERVER"] = "unix:" + innerPulseSocket.String() + + if s.Cookie != nil { + innerDst := hst.AbsTmp.Append("/pulse-cookie") + state.env["PULSE_COOKIE"] = innerDst.String() + state.params.Place(innerDst, s.Cookie[:]) + } + + return nil +} + +func (s *spPulseOp) commonPaths(state *outcomeState) (pulseRuntimeDir, pulseSocket *container.Absolute) { + // PulseAudio runtime directory (usually `/run/user/%d/pulse`) + pulseRuntimeDir = state.sc.RuntimePath.Append("pulse") + // PulseAudio socket (usually `/run/user/%d/pulse/native`) + pulseSocket = pulseRuntimeDir.Append("native") + return +} diff --git a/internal/app/spruntime.go b/internal/app/spruntime.go new file mode 100644 index 0000000..c2e5518 --- /dev/null +++ b/internal/app/spruntime.go @@ -0,0 +1,44 @@ +package app + +import ( + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +// spRuntimeOp sets up XDG_RUNTIME_DIR inside the container. +type spRuntimeOp struct{} + +func (s spRuntimeOp) toSystem(state *outcomeStateSys, _ *hst.Config) error { + runtimeDir, runtimeDirInst := s.commonPaths(state.outcomeState) + state.sys.Ensure(runtimeDir, 0700) + state.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) + state.sys.Ensure(runtimeDirInst, 0700) + state.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) + return nil +} + +func (s spRuntimeOp) toContainer(state *outcomeStateParams) error { + const ( + xdgRuntimeDir = "XDG_RUNTIME_DIR" + xdgSessionClass = "XDG_SESSION_CLASS" + xdgSessionType = "XDG_SESSION_TYPE" + ) + + state.runtimeDir = container.AbsFHSRunUser.Append(state.mapuid.String()) + state.env[xdgRuntimeDir] = state.runtimeDir.String() + state.env[xdgSessionClass] = "user" + state.env[xdgSessionType] = "tty" + + _, runtimeDirInst := s.commonPaths(state.outcomeState) + state.params.Tmpfs(container.AbsFHSRunUser, 1<<12, 0755) + state.params.Bind(runtimeDirInst, state.runtimeDir, container.BindWritable) + return nil +} + +func (s spRuntimeOp) commonPaths(state *outcomeState) (runtimeDir, runtimeDirInst *container.Absolute) { + runtimeDir = state.sc.SharePath.Append("runtime") + runtimeDirInst = runtimeDir.Append(state.identity.String()) + return +} diff --git a/internal/app/sptmpdir.go b/internal/app/sptmpdir.go new file mode 100644 index 0000000..00680ff --- /dev/null +++ b/internal/app/sptmpdir.go @@ -0,0 +1,33 @@ +package app + +import ( + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +// spTmpdirOp sets up TMPDIR inside the container. +type spTmpdirOp struct{} + +func (s spTmpdirOp) toSystem(state *outcomeStateSys, _ *hst.Config) error { + tmpdir, tmpdirInst := s.commonPaths(state.outcomeState) + state.sys.Ensure(tmpdir, 0700) + state.sys.UpdatePermType(system.User, tmpdir, acl.Execute) + state.sys.Ensure(tmpdirInst, 01700) + state.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) + return nil +} + +func (s spTmpdirOp) toContainer(state *outcomeStateParams) error { + // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp + _, tmpdirInst := s.commonPaths(state.outcomeState) + state.params.Bind(tmpdirInst, container.AbsFHSTmp, container.BindWritable) + return nil +} + +func (s spTmpdirOp) commonPaths(state *outcomeState) (tmpdir, tmpdirInst *container.Absolute) { + tmpdir = state.sc.SharePath.Append("tmpdir") + tmpdirInst = tmpdir.Append(state.identity.String()) + return +} diff --git a/internal/app/spwayland.go b/internal/app/spwayland.go new file mode 100644 index 0000000..cf93c8d --- /dev/null +++ b/internal/app/spwayland.go @@ -0,0 +1,60 @@ +package app + +import ( + "os" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/system/acl" + "hakurei.app/system/wayland" +) + +// spWaylandOp exports the Wayland display server to the container. +type spWaylandOp struct { + // Path to host wayland socket. Populated during toSystem if DirectWayland is true. + SocketPath *container.Absolute + + // Address to write the security-context-v1 synchronisation fd [os.File] address to. + // Only populated for toSystem. + sync **os.File +} + +func (s *spWaylandOp) toSystem(state *outcomeStateSys, config *hst.Config) error { + // outer wayland socket (usually `/run/user/%d/wayland-%d`) + var socketPath *container.Absolute + if name, ok := state.k.lookupEnv(wayland.WaylandDisplay); !ok { + state.msg.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) + socketPath = state.sc.RuntimePath.Append(wayland.FallbackName) + } else if a, err := container.NewAbs(name); err != nil { + socketPath = state.sc.RuntimePath.Append(name) + } else { + socketPath = a + } + + 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." + state.id.String() + } + // downstream socket paths + state.sys.Wayland(s.sync, state.instance().Append("wayland"), socketPath, appID, state.id.String()) + } else { // bind mount wayland socket (insecure) + state.msg.Verbose("direct wayland access, PROCEED WITH CAUTION") + state.ensureRuntimeDir() + s.SocketPath = socketPath + state.sys.UpdatePermType(hst.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) + } + return nil +} + +func (s *spWaylandOp) toContainer(state *outcomeStateParams) error { + innerPath := state.runtimeDir.Append(wayland.FallbackName) + state.env[wayland.WaylandDisplay] = wayland.FallbackName + if s.SocketPath == nil { + state.params.Bind(state.instancePath().Append("wayland"), innerPath, 0) + } else { + state.params.Bind(s.SocketPath, innerPath, 0) + } + return nil +} diff --git a/internal/app/spx11.go b/internal/app/spx11.go new file mode 100644 index 0000000..2e515cd --- /dev/null +++ b/internal/app/spx11.go @@ -0,0 +1,63 @@ +package app + +import ( + "errors" + "fmt" + "io/fs" + "strconv" + "strings" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/system/acl" +) + +var absX11SocketDir = container.AbsFHSTmp.Append(".X11-unix") + +// spX11Op exports the X11 display server to the container. +type spX11Op struct { + // Value of $DISPLAY, stored during toSystem + Display string +} + +func (s *spX11Op) toSystem(state *outcomeStateSys, config *hst.Config) error { + if d, ok := state.k.lookupEnv("DISPLAY"); !ok { + return newWithMessage("DISPLAY is not set") + } else { + s.Display = d + } + + // 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(s.Display) > 1 && s.Display[0] == ':' { // `:%d` + if n, err := strconv.Atoi(s.Display[1:]); err == nil && n >= 0 { + socketPath = absX11SocketDir.Append("X" + strconv.Itoa(n)) + } + } else if len(s.Display) > 5 && strings.HasPrefix(s.Display, "unix:") { // `unix:%s` + if a, err := container.NewAbs(s.Display[5:]); err == nil { + socketPath = a + } + } + if socketPath != nil { + if _, err := state.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 { + state.sys.UpdatePermType(hst.EX11, socketPath, acl.Read, acl.Write, acl.Execute) + if !config.Container.HostAbstract { + s.Display = "unix:" + socketPath.String() + } + } + } + + state.sys.ChangeHosts("#" + state.uid.String()) + return nil +} + +func (s *spX11Op) toContainer(state *outcomeStateParams) error { + state.env["DISPLAY"] = s.Display + state.params.Bind(absX11SocketDir, absX11SocketDir, 0) + return nil +}