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