From eb22a8bcc1ac03357d4073e5d3ed6d0ab5246a37 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 2 Jul 2025 20:42:51 +0900 Subject: cmd/hakurei: move to cmd Having it at the project root never made sense since the "ego" name was deprecated. This change finally addresses it. Signed-off-by: Ophestra --- .gitignore | 1 + LICENSE | 2 +- cmd/hakurei/internal/app/app.go | 49 ++ cmd/hakurei/internal/app/id.go | 48 ++ cmd/hakurei/internal/app/id_test.go | 63 ++ .../internal/app/instance/common/container.go | 192 ++++++ cmd/hakurei/internal/app/instance/common/path.go | 11 + .../internal/app/instance/common/path_test.go | 85 +++ cmd/hakurei/internal/app/instance/errors.go | 17 + cmd/hakurei/internal/app/instance/new.go | 33 ++ cmd/hakurei/internal/app/instance/shim.go | 6 + cmd/hakurei/internal/app/internal/setuid/app.go | 74 +++ .../internal/app/internal/setuid/app_nixos_test.go | 149 +++++ .../internal/app/internal/setuid/app_pd_test.go | 225 +++++++ .../internal/app/internal/setuid/app_stub_test.go | 134 +++++ .../internal/app/internal/setuid/app_test.go | 104 ++++ cmd/hakurei/internal/app/internal/setuid/errors.go | 182 ++++++ .../internal/app/internal/setuid/export_test.go | 24 + .../internal/app/internal/setuid/process.go | 195 +++++++ cmd/hakurei/internal/app/internal/setuid/seal.go | 586 +++++++++++++++++++ cmd/hakurei/internal/app/internal/setuid/shim.go | 184 ++++++ .../internal/app/internal/setuid/strings.go | 19 + cmd/hakurei/internal/state/join.go | 60 ++ cmd/hakurei/internal/state/multi.go | 373 ++++++++++++ cmd/hakurei/internal/state/multi_test.go | 9 + cmd/hakurei/internal/state/state.go | 49 ++ cmd/hakurei/internal/state/state_test.go | 145 +++++ cmd/hakurei/main.go | 299 ++++++++++ cmd/hakurei/main_test.go | 81 +++ cmd/hakurei/parse.go | 110 ++++ cmd/hakurei/print.go | 319 ++++++++++ cmd/hakurei/print_test.go | 644 +++++++++++++++++++++ cmd/planterette/main.go | 2 +- dbus/dbus_test.go | 2 +- helper/container_test.go | 2 +- hst/paths.go | 11 + internal/app/app.go | 59 -- internal/app/id.go | 48 -- internal/app/id_test.go | 63 -- internal/app/instance/common/container.go | 192 ------ internal/app/instance/common/path.go | 11 - internal/app/instance/common/path_test.go | 85 --- internal/app/instance/errors.go | 17 - internal/app/instance/new.go | 33 -- internal/app/instance/shim.go | 6 - internal/app/internal/setuid/app.go | 74 --- internal/app/internal/setuid/app_nixos_test.go | 149 ----- internal/app/internal/setuid/app_pd_test.go | 225 ------- internal/app/internal/setuid/app_stub_test.go | 134 ----- internal/app/internal/setuid/app_test.go | 104 ---- internal/app/internal/setuid/errors.go | 182 ------ internal/app/internal/setuid/export_test.go | 24 - internal/app/internal/setuid/process.go | 195 ------- internal/app/internal/setuid/seal.go | 586 ------------------- internal/app/internal/setuid/shim.go | 184 ------ internal/app/internal/setuid/strings.go | 19 - internal/output.go | 2 +- internal/state/join.go | 60 -- internal/state/multi.go | 373 ------------ internal/state/multi_test.go | 11 - internal/state/state.go | 49 -- internal/state/state_test.go | 145 ----- internal/sys/interface.go | 9 +- internal/sys/std.go | 6 +- internal/sysconf.go | 4 +- main.go | 296 ---------- main_test.go | 81 --- parse.go | 110 ---- print.go | 319 ---------- print_test.go | 644 --------------------- sandbox/container_test.go | 4 +- 71 files changed, 4499 insertions(+), 4493 deletions(-) create mode 100644 cmd/hakurei/internal/app/app.go create mode 100644 cmd/hakurei/internal/app/id.go create mode 100644 cmd/hakurei/internal/app/id_test.go create mode 100644 cmd/hakurei/internal/app/instance/common/container.go create mode 100644 cmd/hakurei/internal/app/instance/common/path.go create mode 100644 cmd/hakurei/internal/app/instance/common/path_test.go create mode 100644 cmd/hakurei/internal/app/instance/errors.go create mode 100644 cmd/hakurei/internal/app/instance/new.go create mode 100644 cmd/hakurei/internal/app/instance/shim.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/app.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/app_nixos_test.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/app_pd_test.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/app_stub_test.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/app_test.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/errors.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/export_test.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/process.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/seal.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/shim.go create mode 100644 cmd/hakurei/internal/app/internal/setuid/strings.go create mode 100644 cmd/hakurei/internal/state/join.go create mode 100644 cmd/hakurei/internal/state/multi.go create mode 100644 cmd/hakurei/internal/state/multi_test.go create mode 100644 cmd/hakurei/internal/state/state.go create mode 100644 cmd/hakurei/internal/state/state_test.go create mode 100644 cmd/hakurei/main.go create mode 100644 cmd/hakurei/main_test.go create mode 100644 cmd/hakurei/parse.go create mode 100644 cmd/hakurei/print.go create mode 100644 cmd/hakurei/print_test.go create mode 100644 hst/paths.go delete mode 100644 internal/app/app.go delete mode 100644 internal/app/id.go delete mode 100644 internal/app/id_test.go delete mode 100644 internal/app/instance/common/container.go delete mode 100644 internal/app/instance/common/path.go delete mode 100644 internal/app/instance/common/path_test.go delete mode 100644 internal/app/instance/errors.go delete mode 100644 internal/app/instance/new.go delete mode 100644 internal/app/instance/shim.go delete mode 100644 internal/app/internal/setuid/app.go delete mode 100644 internal/app/internal/setuid/app_nixos_test.go delete mode 100644 internal/app/internal/setuid/app_pd_test.go delete mode 100644 internal/app/internal/setuid/app_stub_test.go delete mode 100644 internal/app/internal/setuid/app_test.go delete mode 100644 internal/app/internal/setuid/errors.go delete mode 100644 internal/app/internal/setuid/export_test.go delete mode 100644 internal/app/internal/setuid/process.go delete mode 100644 internal/app/internal/setuid/seal.go delete mode 100644 internal/app/internal/setuid/shim.go delete mode 100644 internal/app/internal/setuid/strings.go delete mode 100644 internal/state/join.go delete mode 100644 internal/state/multi.go delete mode 100644 internal/state/multi_test.go delete mode 100644 internal/state/state.go delete mode 100644 internal/state/state_test.go delete mode 100644 main.go delete mode 100644 main_test.go delete mode 100644 parse.go delete mode 100644 print.go delete mode 100644 print_test.go diff --git a/.gitignore b/.gitignore index 2b915710..89781e77 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ go.work.sum # go generate security-context-v1-protocol.* +/cmd/hakurei/LICENSE # release /dist/hakurei-* \ No newline at end of file diff --git a/LICENSE b/LICENSE index b1d47606..7f53ce8c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 Ophestra Umiker +Copyright (c) 2024-2025 Ophestra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/cmd/hakurei/internal/app/app.go b/cmd/hakurei/internal/app/app.go new file mode 100644 index 00000000..94b49079 --- /dev/null +++ b/cmd/hakurei/internal/app/app.go @@ -0,0 +1,49 @@ +// Package app defines the generic [App] interface. +package app + +import ( + "syscall" + "time" + + "git.gensokyo.uk/security/hakurei/hst" +) + +type App interface { + // ID returns a copy of [ID] held by App. + ID() ID + + // Seal determines the outcome of config as a [SealedApp]. + // The value of config might be overwritten and must not be used again. + Seal(config *hst.Config) (SealedApp, error) + + String() string +} + +type SealedApp interface { + // Run commits sealed system setup and starts the app process. + Run(rs *RunState) error +} + +// RunState stores the outcome of a call to [SealedApp.Run]. +type RunState struct { + // Time is the exact point in time where the process was created. + // Location must be set to UTC. + // + // Time is nil if no process was ever created. + Time *time.Time + // RevertErr is stored by the deferred revert call. + RevertErr error + // WaitErr is the generic error value created by the standard library. + WaitErr error + + syscall.WaitStatus +} + +// SetStart stores the current time in [RunState] once. +func (rs *RunState) SetStart() { + if rs.Time != nil { + panic("attempted to store time twice") + } + now := time.Now().UTC() + rs.Time = &now +} diff --git a/cmd/hakurei/internal/app/id.go b/cmd/hakurei/internal/app/id.go new file mode 100644 index 00000000..e674c7dd --- /dev/null +++ b/cmd/hakurei/internal/app/id.go @@ -0,0 +1,48 @@ +package app + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" +) + +type ID [16]byte + +var ( + ErrInvalidLength = errors.New("string representation must have a length of 32") +) + +func (a *ID) String() string { + return hex.EncodeToString(a[:]) +} + +func NewAppID(id *ID) error { + _, err := rand.Read(id[:]) + return err +} + +func ParseAppID(id *ID, s string) error { + if len(s) != 32 { + return ErrInvalidLength + } + + for i, b := range s { + if b < '0' || b > 'f' { + return fmt.Errorf("invalid char %q at byte %d", b, i) + } + + v := uint8(b) + if v > '9' { + v = 10 + v - 'a' + } else { + v -= '0' + } + if i%2 == 0 { + v <<= 4 + } + id[i/2] += v + } + + return nil +} diff --git a/cmd/hakurei/internal/app/id_test.go b/cmd/hakurei/internal/app/id_test.go new file mode 100644 index 00000000..232421da --- /dev/null +++ b/cmd/hakurei/internal/app/id_test.go @@ -0,0 +1,63 @@ +package app_test + +import ( + "errors" + "testing" + + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" +) + +func TestParseAppID(t *testing.T) { + t.Run("bad length", func(t *testing.T) { + if err := ParseAppID(new(ID), "meow"); !errors.Is(err, ErrInvalidLength) { + t.Errorf("ParseAppID: error = %v, wantErr = %v", err, ErrInvalidLength) + } + }) + + t.Run("bad byte", func(t *testing.T) { + wantErr := "invalid char '\\n' at byte 15" + if err := ParseAppID(new(ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr { + t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr) + } + }) + + t.Run("fuzz 16 iterations", func(t *testing.T) { + for i := 0; i < 16; i++ { + testParseAppIDWithRandom(t) + } + }) +} + +func FuzzParseAppID(f *testing.F) { + for i := 0; i < 16; i++ { + id := new(ID) + if err := NewAppID(id); err != nil { + panic(err.Error()) + } + f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15]) + } + + f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) { + testParseAppID(t, &ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15}) + }) +} + +func testParseAppIDWithRandom(t *testing.T) { + id := new(ID) + if err := NewAppID(id); err != nil { + t.Fatalf("cannot generate app ID: %v", err) + } + testParseAppID(t, id) +} + +func testParseAppID(t *testing.T, id *ID) { + s := id.String() + got := new(ID) + if err := ParseAppID(got, s); err != nil { + t.Fatalf("cannot parse app ID: %v", err) + } + + if *got != *id { + t.Fatalf("ParseAppID(%#v) = \n%#v, want \n%#v", s, got, id) + } +} diff --git a/cmd/hakurei/internal/app/instance/common/container.go b/cmd/hakurei/internal/app/instance/common/container.go new file mode 100644 index 00000000..d6342382 --- /dev/null +++ b/cmd/hakurei/internal/app/instance/common/container.go @@ -0,0 +1,192 @@ +package common + +import ( + "errors" + "fmt" + "io/fs" + "maps" + "path" + "syscall" + + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/sys" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" +) + +// in practice there should be less than 30 entries added by the runtime; +// allocating slightly more as a margin for future expansion +const preallocateOpsCount = 1 << 5 + +// NewContainer initialises [sandbox.Params] via [hst.ContainerConfig]. +// Note that remaining container setup must be queued by the caller. +func NewContainer(s *hst.ContainerConfig, os sys.State, uid, gid *int) (*sandbox.Params, map[string]string, error) { + if s == nil { + return nil, nil, syscall.EBADE + } + + container := &sandbox.Params{ + Hostname: s.Hostname, + SeccompFlags: s.SeccompFlags, + SeccompPresets: s.SeccompPresets, + RetainSession: s.Tty, + HostNet: s.Net, + } + + { + ops := make(sandbox.Ops, 0, preallocateOpsCount+len(s.Filesystem)+len(s.Link)+len(s.Cover)) + container.Ops = &ops + } + + if s.Multiarch { + container.SeccompFlags |= seccomp.AllowMultiarch + } + + if !s.SeccompCompat { + container.SeccompPresets |= seccomp.PresetExt + } + if !s.Devel { + container.SeccompPresets |= seccomp.PresetDenyDevel + } + if !s.Userns { + container.SeccompPresets |= seccomp.PresetDenyNS + } + if !s.Tty { + container.SeccompPresets |= seccomp.PresetDenyTTY + } + + if s.MapRealUID { + /* some programs fail to connect to dbus session running as a different uid + so this workaround is introduced to map priv-side caller uid in container */ + container.Uid = os.Getuid() + *uid = container.Uid + container.Gid = os.Getgid() + *gid = container.Gid + } else { + *uid = sandbox.OverflowUid() + *gid = sandbox.OverflowGid() + } + + container. + Proc("/proc"). + Tmpfs(hst.Tmp, 1<<12, 0755) + + if !s.Device { + container.Dev("/dev").Mqueue("/dev/mqueue") + } else { + container.Bind("/dev", "/dev", sandbox.BindWritable|sandbox.BindDevice) + } + + /* 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 + sc := os.Paths() + hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath) + _, 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 == "/" { + os.Printf("dbus socket %q is in an unusual location", pair[1]) + } + hidePaths = append(hidePaths, dir) + } else { + os.Printf("dbus socket %q is not absolute", pair[1]) + } + } + } + } + } + hidePathMatch := make([]bool, len(hidePaths)) + for i := range hidePaths { + if err := evalSymlinks(os, &hidePaths[i]); err != nil { + return nil, nil, err + } + } + + for _, c := range s.Filesystem { + if c == nil { + continue + } + + if !path.IsAbs(c.Src) { + return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src) + } + + dest := c.Dst + if c.Dst == "" { + dest = c.Src + } else if !path.IsAbs(dest) { + return nil, nil, fmt.Errorf("dst path %q is not absolute", dest) + } + + srcH := c.Src + if err := evalSymlinks(os, &srcH); err != nil { + return nil, nil, err + } + + for i := range hidePaths { + // skip matched entries + if hidePathMatch[i] { + continue + } + + if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil { + return nil, nil, err + } else if ok { + hidePathMatch[i] = true + os.Printf("hiding paths from %q", c.Src) + } + } + + var flags int + if c.Write { + flags |= sandbox.BindWritable + } + if c.Device { + flags |= sandbox.BindDevice | sandbox.BindWritable + } + if !c.Must { + flags |= sandbox.BindOptional + } + container.Bind(c.Src, dest, flags) + } + + // cover matched paths + for i, ok := range hidePathMatch { + if ok { + container.Tmpfs(hidePaths[i], 1<<13, 0755) + } + } + + for _, l := range s.Link { + container.Link(l[0], l[1]) + } + + return container, maps.Clone(s.Env), nil +} + +func evalSymlinks(os sys.State, v *string) error { + if p, err := os.EvalSymlinks(*v); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + os.Printf("path %q does not yet exist", *v) + } else { + *v = p + } + return nil +} diff --git a/cmd/hakurei/internal/app/instance/common/path.go b/cmd/hakurei/internal/app/instance/common/path.go new file mode 100644 index 00000000..ce40f4f1 --- /dev/null +++ b/cmd/hakurei/internal/app/instance/common/path.go @@ -0,0 +1,11 @@ +package common + +import ( + "path/filepath" + "strings" +) + +func deepContainsH(basepath, targpath string) (bool, error) { + rel, err := filepath.Rel(basepath, targpath) + return err == nil && rel != ".." && !strings.HasPrefix(rel, string([]byte{'.', '.', filepath.Separator})), err +} diff --git a/cmd/hakurei/internal/app/instance/common/path_test.go b/cmd/hakurei/internal/app/instance/common/path_test.go new file mode 100644 index 00000000..b14f24df --- /dev/null +++ b/cmd/hakurei/internal/app/instance/common/path_test.go @@ -0,0 +1,85 @@ +package common + +import ( + "testing" +) + +func TestDeepContainsH(t *testing.T) { + testCases := []struct { + name string + basepath string + targpath string + want bool + wantErr bool + }{ + { + name: "empty", + want: true, + }, + { + name: "equal abs", + basepath: "/run", + targpath: "/run", + want: true, + }, + { + name: "equal rel", + basepath: "./run", + targpath: "run", + want: true, + }, + { + name: "contains abs", + basepath: "/run", + targpath: "/run/dbus", + want: true, + }, + { + name: "inverse contains abs", + basepath: "/run/dbus", + targpath: "/run", + want: false, + }, + { + name: "contains rel", + basepath: "../run", + targpath: "../run/dbus", + want: true, + }, + { + name: "inverse contains rel", + basepath: "../run/dbus", + targpath: "../run", + want: false, + }, + { + name: "weird abs", + basepath: "/run/dbus", + targpath: "/run/dbus/../current-system", + want: false, + }, + { + name: "weird rel", + basepath: "../run/dbus", + targpath: "../run/dbus/../current-system", + want: false, + }, + + { + name: "invalid mix", + basepath: "/run", + targpath: "./run", + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got, err := deepContainsH(tc.basepath, tc.targpath); (err != nil) != tc.wantErr { + t.Errorf("deepContainsH() error = %v, wantErr %v", err, tc.wantErr) + } else if got != tc.want { + t.Errorf("deepContainsH() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/cmd/hakurei/internal/app/instance/errors.go b/cmd/hakurei/internal/app/instance/errors.go new file mode 100644 index 00000000..9e67208b --- /dev/null +++ b/cmd/hakurei/internal/app/instance/errors.go @@ -0,0 +1,17 @@ +package instance + +import ( + "syscall" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/internal/setuid" +) + +func PrintRunStateErr(whence int, rs *app.RunState, runErr error) (code int) { + switch whence { + case ISetuid: + return setuid.PrintRunStateErr(rs, runErr) + default: + panic(syscall.EINVAL) + } +} diff --git a/cmd/hakurei/internal/app/instance/new.go b/cmd/hakurei/internal/app/instance/new.go new file mode 100644 index 00000000..4c4a9c63 --- /dev/null +++ b/cmd/hakurei/internal/app/instance/new.go @@ -0,0 +1,33 @@ +// Package instance exposes cross-package implementation details and provides constructors for builtin implementations. +package instance + +import ( + "context" + "log" + "syscall" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/internal/setuid" + "git.gensokyo.uk/security/hakurei/internal/sys" +) + +const ( + ISetuid = iota +) + +func New(whence int, ctx context.Context, os sys.State) (app.App, error) { + switch whence { + case ISetuid: + return setuid.New(ctx, os) + default: + return nil, syscall.EINVAL + } +} + +func MustNew(whence int, ctx context.Context, os sys.State) app.App { + a, err := New(whence, ctx, os) + if err != nil { + log.Fatalf("cannot create app: %v", err) + } + return a +} diff --git a/cmd/hakurei/internal/app/instance/shim.go b/cmd/hakurei/internal/app/instance/shim.go new file mode 100644 index 00000000..b10fae10 --- /dev/null +++ b/cmd/hakurei/internal/app/instance/shim.go @@ -0,0 +1,6 @@ +package instance + +import "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/internal/setuid" + +// ShimMain is the main function of the shim process and runs as the unconstrained target user. +func ShimMain() { setuid.ShimMain() } diff --git a/cmd/hakurei/internal/app/internal/setuid/app.go b/cmd/hakurei/internal/app/internal/setuid/app.go new file mode 100644 index 00000000..b06c3b09 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/app.go @@ -0,0 +1,74 @@ +package setuid + +import ( + "context" + "fmt" + "sync" + + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/internal/sys" +) + +func New(ctx context.Context, os sys.State) (App, error) { + a := new(app) + a.sys = os + a.ctx = ctx + + id := new(ID) + err := NewAppID(id) + a.id = newID(id) + + return a, err +} + +type app struct { + id *stringPair[ID] + sys sys.State + ctx context.Context + + *outcome + mu sync.RWMutex +} + +func (a *app) ID() ID { a.mu.RLock(); defer a.mu.RUnlock(); return a.id.unwrap() } + +func (a *app) String() string { + if a == nil { + return "(invalid app)" + } + + a.mu.RLock() + defer a.mu.RUnlock() + + if a.outcome != nil { + if a.outcome.user.uid == nil { + return fmt.Sprintf("(sealed app %s with invalid uid)", a.id) + } + return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.outcome.user.uid) + } + + return fmt.Sprintf("(unsealed app %s)", a.id) +} + +func (a *app) Seal(config *hst.Config) (SealedApp, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.outcome != nil { + panic("app sealed twice") + } + if config == nil { + return nil, hlog.WrapErr(ErrConfig, + "attempted to seal app with nil config") + } + + seal := new(outcome) + seal.id = a.id + err := seal.finalise(a.ctx, a.sys, config) + if err == nil { + a.outcome = seal + } + return seal, err +} diff --git a/cmd/hakurei/internal/app/internal/setuid/app_nixos_test.go b/cmd/hakurei/internal/app/internal/setuid/app_nixos_test.go new file mode 100644 index 00000000..6dd01e99 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/app_nixos_test.go @@ -0,0 +1,149 @@ +package setuid_test + +import ( + "git.gensokyo.uk/security/hakurei/acl" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" + "git.gensokyo.uk/security/hakurei/system" +) + +var testCasesNixos = []sealTestCase{ + { + "nixos chromium direct wayland", new(stubNixOS), + &hst.Config{ + ID: "org.chromium.Chromium", + Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", + Enablements: system.EWayland | system.EDBus | system.EPulse, + + Container: &hst.ContainerConfig{ + Userns: true, Net: true, MapRealUID: true, Env: nil, AutoEtc: true, + Filesystem: []*hst.FilesystemConfig{ + {Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true}, + {Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true}, + {Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"}, + {Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true}, + }, + Cover: []string{"/var/run/nscd"}, + }, + SystemBus: &dbus.Config{ + Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"}, + Filter: true, + }, + SessionBus: &dbus.Config{ + Talk: []string{ + "org.freedesktop.FileManager1", "org.freedesktop.Notifications", + "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", + "org.kde.kwalletd5", "org.kde.kwalletd6", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{}, Broadcast: map[string]string{}, + Filter: true, + }, + DirectWayland: true, + + Username: "u0_a1", + Data: "/var/lib/persist/module/hakurei/0/1", + Identity: 1, Groups: []string{}, + }, + app.ID{ + 0x8e, 0x2c, 0x76, 0xb0, + 0x66, 0xda, 0xbe, 0x57, + 0x4c, 0xf0, 0x73, 0xbd, + 0xb4, 0x6e, 0xb5, 0xc1, + }, + system.New(1000001). + Ensure("/tmp/hakurei.1971", 0711). + Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). + Ensure("/tmp/hakurei.1971/runtime/1", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/1", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute). + Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). + Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset + UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute). + Ephemeral(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute). + Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"). + CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256). + Ephemeral(system.Process, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711). + MustProxyDBus("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{ + Talk: []string{ + "org.freedesktop.FileManager1", "org.freedesktop.Notifications", + "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", + "org.kde.kwalletd5", "org.kde.kwalletd6", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{}, Broadcast: map[string]string{}, + Filter: true, + }, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }). + UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write). + UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write), + &sandbox.Params{ + Uid: 1971, + Gid: 100, + Dir: "/var/lib/persist/module/hakurei/0/1", + Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", + Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + "HOME=/var/lib/persist/module/hakurei/0/1", + "PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie", + "PULSE_SERVER=unix:/run/user/1971/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=u0_a1", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/1971", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=tty", + }, + Ops: new(sandbox.Ops). + Proc("/proc"). + Tmpfs(hst.Tmp, 4096, 0755). + Dev("/dev").Mqueue("/dev/mqueue"). + Bind("/bin", "/bin", 0). + Bind("/usr/bin", "/usr/bin", 0). + Bind("/nix/store", "/nix/store", 0). + Bind("/run/current-system", "/run/current-system", 0). + Bind("/sys/block", "/sys/block", sandbox.BindOptional). + Bind("/sys/bus", "/sys/bus", sandbox.BindOptional). + Bind("/sys/class", "/sys/class", sandbox.BindOptional). + Bind("/sys/dev", "/sys/dev", sandbox.BindOptional). + Bind("/sys/devices", "/sys/devices", sandbox.BindOptional). + Bind("/run/opengl-driver", "/run/opengl-driver", 0). + Bind("/dev/dri", "/dev/dri", sandbox.BindDevice|sandbox.BindWritable|sandbox.BindOptional). + Etc("/etc", "8e2c76b066dabe574cf073bdb46eb5c1"). + Tmpfs("/run/user", 4096, 0755). + Bind("/tmp/hakurei.1971/runtime/1", "/run/user/1971", sandbox.BindWritable). + Bind("/tmp/hakurei.1971/tmpdir/1", "/tmp", sandbox.BindWritable). + Bind("/var/lib/persist/module/hakurei/0/1", "/var/lib/persist/module/hakurei/0/1", sandbox.BindWritable). + Place("/etc/passwd", []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")). + Place("/etc/group", []byte("hakurei:x:100:\n")). + Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0", 0). + Bind("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native", 0). + Place(hst.Tmp+"/pulse-cookie", nil). + Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus", 0). + Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket", 0). + Tmpfs("/var/run/nscd", 8192, 0755), + SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyTTY | seccomp.PresetDenyDevel, + HostNet: true, + }, + }, +} diff --git a/cmd/hakurei/internal/app/internal/setuid/app_pd_test.go b/cmd/hakurei/internal/app/internal/setuid/app_pd_test.go new file mode 100644 index 00000000..49759aa3 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/app_pd_test.go @@ -0,0 +1,225 @@ +package setuid_test + +import ( + "os" + + "git.gensokyo.uk/security/hakurei/acl" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" + "git.gensokyo.uk/security/hakurei/system" +) + +var testCasesPd = []sealTestCase{ + { + "nixos permissive defaults no enablements", new(stubNixOS), + &hst.Config{Username: "chronos", Data: "/home/chronos"}, + app.ID{ + 0x4a, 0x45, 0x0b, 0x65, + 0x96, 0xd7, 0xbc, 0x15, + 0xbd, 0x01, 0x78, 0x0e, + 0xb9, 0xa6, 0x07, 0xac, + }, + system.New(1000000). + Ensure("/tmp/hakurei.1971", 0711). + Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). + Ensure("/tmp/hakurei.1971/runtime/0", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/0", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute), + &sandbox.Params{ + Dir: "/home/chronos", + Path: "/run/current-system/sw/bin/zsh", + Args: []string{"/run/current-system/sw/bin/zsh"}, + Env: []string{ + "HOME=/home/chronos", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=tty", + }, + Ops: new(sandbox.Ops). + Proc("/proc"). + Tmpfs(hst.Tmp, 4096, 0755). + Dev("/dev").Mqueue("/dev/mqueue"). + Bind("/bin", "/bin", sandbox.BindWritable). + Bind("/boot", "/boot", sandbox.BindWritable). + Bind("/home", "/home", sandbox.BindWritable). + Bind("/lib", "/lib", sandbox.BindWritable). + Bind("/lib64", "/lib64", sandbox.BindWritable). + Bind("/nix", "/nix", sandbox.BindWritable). + Bind("/root", "/root", sandbox.BindWritable). + Bind("/run", "/run", sandbox.BindWritable). + Bind("/srv", "/srv", sandbox.BindWritable). + Bind("/sys", "/sys", sandbox.BindWritable). + Bind("/usr", "/usr", sandbox.BindWritable). + Bind("/var", "/var", sandbox.BindWritable). + Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). + Tmpfs("/run/user/1971", 8192, 0755). + Tmpfs("/run/dbus", 8192, 0755). + Etc("/etc", "4a450b6596d7bc15bd01780eb9a607ac"). + Tmpfs("/run/user", 4096, 0755). + Bind("/tmp/hakurei.1971/runtime/0", "/run/user/65534", sandbox.BindWritable). + Bind("/tmp/hakurei.1971/tmpdir/0", "/tmp", sandbox.BindWritable). + Bind("/home/chronos", "/home/chronos", sandbox.BindWritable). + Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). + Place("/etc/group", []byte("hakurei:x:65534:\n")). + Tmpfs("/var/run/nscd", 8192, 0755), + SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyDevel, + HostNet: true, + RetainSession: true, + }, + }, + { + "nixos permissive defaults chromium", new(stubNixOS), + &hst.Config{ + ID: "org.chromium.Chromium", + Args: []string{"zsh", "-c", "exec chromium "}, + Identity: 9, + Groups: []string{"video"}, + Username: "chronos", + Data: "/home/chronos", + SessionBus: &dbus.Config{ + Talk: []string{ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{ + "org.freedesktop.portal.*": "*", + }, + Broadcast: map[string]string{ + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", + }, + Filter: true, + }, + SystemBus: &dbus.Config{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }, + Enablements: system.EWayland | system.EDBus | system.EPulse, + }, + app.ID{ + 0xeb, 0xf0, 0x83, 0xd1, + 0xb1, 0x75, 0x91, 0x17, + 0x82, 0xd4, 0x13, 0x36, + 0x9b, 0x64, 0xce, 0x7c, + }, + system.New(1000009). + Ensure("/tmp/hakurei.1971", 0711). + Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). + Ensure("/tmp/hakurei.1971/runtime/9", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/9", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute). + Ephemeral(system.Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c", 0711). + Wayland(new(*os.File), "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"). + Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). + Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/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, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", acl.Execute). + Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"). + CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256). + MustProxyDBus("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{ + Talk: []string{ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{ + "org.freedesktop.portal.*": "*", + }, + Broadcast: map[string]string{ + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", + }, + Filter: true, + }, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }). + UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write). + UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write), + &sandbox.Params{ + Dir: "/home/chronos", + Path: "/run/current-system/sw/bin/zsh", + Args: []string{"zsh", "-c", "exec chromium "}, + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + "HOME=/home/chronos", + "PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=tty", + }, + Ops: new(sandbox.Ops). + Proc("/proc"). + Tmpfs(hst.Tmp, 4096, 0755). + Dev("/dev").Mqueue("/dev/mqueue"). + Bind("/bin", "/bin", sandbox.BindWritable). + Bind("/boot", "/boot", sandbox.BindWritable). + Bind("/home", "/home", sandbox.BindWritable). + Bind("/lib", "/lib", sandbox.BindWritable). + Bind("/lib64", "/lib64", sandbox.BindWritable). + Bind("/nix", "/nix", sandbox.BindWritable). + Bind("/root", "/root", sandbox.BindWritable). + Bind("/run", "/run", sandbox.BindWritable). + Bind("/srv", "/srv", sandbox.BindWritable). + Bind("/sys", "/sys", sandbox.BindWritable). + Bind("/usr", "/usr", sandbox.BindWritable). + Bind("/var", "/var", sandbox.BindWritable). + Bind("/dev/dri", "/dev/dri", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). + Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). + Tmpfs("/run/user/1971", 8192, 0755). + Tmpfs("/run/dbus", 8192, 0755). + Etc("/etc", "ebf083d1b175911782d413369b64ce7c"). + Tmpfs("/run/user", 4096, 0755). + Bind("/tmp/hakurei.1971/runtime/9", "/run/user/65534", sandbox.BindWritable). + Bind("/tmp/hakurei.1971/tmpdir/9", "/tmp", sandbox.BindWritable). + Bind("/home/chronos", "/home/chronos", sandbox.BindWritable). + Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). + Place("/etc/group", []byte("hakurei:x:65534:\n")). + Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0", 0). + Bind("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0). + Place(hst.Tmp+"/pulse-cookie", nil). + Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0). + Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket", 0). + Tmpfs("/var/run/nscd", 8192, 0755), + SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyDevel, + HostNet: true, + RetainSession: true, + }, + }, +} diff --git a/cmd/hakurei/internal/app/internal/setuid/app_stub_test.go b/cmd/hakurei/internal/app/internal/setuid/app_stub_test.go new file mode 100644 index 00000000..f3b851d8 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/app_stub_test.go @@ -0,0 +1,134 @@ +package setuid_test + +import ( + "fmt" + "io/fs" + "log" + "os/user" + "strconv" + + "git.gensokyo.uk/security/hakurei/hst" +) + +// fs methods are not implemented using a real FS +// to help better understand filesystem access behaviour +type stubNixOS struct { + lookPathErr map[string]error + usernameErr map[string]error +} + +func (s *stubNixOS) Getuid() int { return 1971 } +func (s *stubNixOS) Getgid() int { return 100 } +func (s *stubNixOS) TempDir() string { return "/tmp" } +func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/hakurei" } +func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) } +func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil } +func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil } + +func (s *stubNixOS) Println(v ...any) { log.Println(v...) } +func (s *stubNixOS) Printf(format string, v ...any) { log.Printf(format, v...) } + +func (s *stubNixOS) LookupEnv(key string) (string, bool) { + switch key { + case "SHELL": + return "/run/current-system/sw/bin/zsh", true + case "TERM": + return "xterm-256color", true + case "WAYLAND_DISPLAY": + return "wayland-0", true + case "PULSE_COOKIE": + return "", false + case "HOME": + return "/home/ophestra", true + case "XDG_CONFIG_HOME": + return "/home/ophestra/xdg/config", true + default: + panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key)) + } +} + +func (s *stubNixOS) LookPath(file string) (string, error) { + if s.lookPathErr != nil { + if err, ok := s.lookPathErr[file]; ok { + return "", err + } + } + + switch file { + case "zsh": + return "/run/current-system/sw/bin/zsh", nil + default: + panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) + } +} + +func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) { + switch name { + case "video": + return &user.Group{Gid: "26", Name: "video"}, nil + default: + return nil, user.UnknownGroupError(name) + } +} + +func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) { + switch name { + case "/": + return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib", + "lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") + case "/run": + return stubDirEntries("agetty.reload", "binfmt", "booted-system", + "credentials", "cryptsetup", "current-system", "dbus", "host", "keys", + "libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager", + "nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm", + "store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2", + "user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state") + case "/etc": + return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default", + "ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid", + "hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd", + "libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm", + "machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc", + "netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf", + "opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1", + "profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba", + "sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh", + "ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo", + "tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc", + "zoneinfo", "zprofile", "zshenv", "zshrc") + default: + panic(fmt.Sprintf("attempted to read unexpected directory %q", name)) + } +} + +func (s *stubNixOS) Stat(name string) (fs.FileInfo, error) { + switch name { + case "/var/run/nscd": + return nil, nil + case "/run/user/1971/pulse": + return nil, nil + case "/run/user/1971/pulse/native": + return stubFileInfoMode(0666), nil + case "/home/ophestra/.pulse-cookie": + return stubFileInfoIsDir(true), nil + case "/home/ophestra/xdg/config/pulse/cookie": + return stubFileInfoIsDir(false), nil + default: + panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) + } +} + +func (s *stubNixOS) Open(name string) (fs.File, error) { + switch name { + default: + panic(fmt.Sprintf("attempted to open unexpected file %q", name)) + } +} + +func (s *stubNixOS) Paths() hst.Paths { + return hst.Paths{ + SharePath: "/tmp/hakurei.1971", + RuntimePath: "/run/user/1971", + RunDirPath: "/run/user/1971/hakurei", + } +} diff --git a/cmd/hakurei/internal/app/internal/setuid/app_test.go b/cmd/hakurei/internal/app/internal/setuid/app_test.go new file mode 100644 index 00000000..072f4c10 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/app_test.go @@ -0,0 +1,104 @@ +package setuid_test + +import ( + "encoding/json" + "io/fs" + "reflect" + "testing" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/internal/setuid" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/sys" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/system" +) + +type sealTestCase struct { + name string + os sys.State + config *hst.Config + id app.ID + wantSys *system.I + wantContainer *sandbox.Params +} + +func TestApp(t *testing.T) { + testCases := append(testCasesPd, testCasesNixos...) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + a := setuid.NewWithID(tc.id, tc.os) + var ( + gotSys *system.I + gotContainer *sandbox.Params + ) + if !t.Run("seal", func(t *testing.T) { + if sa, err := a.Seal(tc.config); err != nil { + t.Errorf("Seal: error = %v", err) + return + } else { + gotSys, gotContainer = setuid.AppIParams(a, sa) + } + }) { + return + } + + t.Run("compare sys", func(t *testing.T) { + if !gotSys.Equal(tc.wantSys) { + t.Errorf("Seal: sys = %#v, want %#v", + gotSys, tc.wantSys) + } + }) + + t.Run("compare params", func(t *testing.T) { + if !reflect.DeepEqual(gotContainer, tc.wantContainer) { + t.Errorf("seal: params =\n%s\n, want\n%s", + mustMarshal(gotContainer), mustMarshal(tc.wantContainer)) + } + }) + }) + } +} + +func mustMarshal(v any) string { + if b, err := json.Marshal(v); err != nil { + panic(err.Error()) + } else { + return string(b) + } +} + +func stubDirEntries(names ...string) (e []fs.DirEntry, err error) { + e = make([]fs.DirEntry, len(names)) + for i, name := range names { + e[i] = stubDirEntryPath(name) + } + return +} + +type stubDirEntryPath string + +func (p stubDirEntryPath) Name() string { return string(p) } +func (p stubDirEntryPath) IsDir() bool { panic("attempted to call IsDir") } +func (p stubDirEntryPath) Type() fs.FileMode { panic("attempted to call Type") } +func (p stubDirEntryPath) Info() (fs.FileInfo, error) { panic("attempted to call Info") } + +type stubFileInfoMode fs.FileMode + +func (s stubFileInfoMode) Name() string { panic("attempted to call Name") } +func (s stubFileInfoMode) Size() int64 { panic("attempted to call Size") } +func (s stubFileInfoMode) Mode() fs.FileMode { return fs.FileMode(s) } +func (s stubFileInfoMode) ModTime() time.Time { panic("attempted to call ModTime") } +func (s stubFileInfoMode) IsDir() bool { panic("attempted to call IsDir") } +func (s stubFileInfoMode) Sys() any { panic("attempted to call Sys") } + +type stubFileInfoIsDir bool + +func (s stubFileInfoIsDir) Name() string { panic("attempted to call Name") } +func (s stubFileInfoIsDir) Size() int64 { panic("attempted to call Size") } +func (s stubFileInfoIsDir) Mode() fs.FileMode { panic("attempted to call Mode") } +func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTime") } +func (s stubFileInfoIsDir) IsDir() bool { return bool(s) } +func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") } diff --git a/cmd/hakurei/internal/app/internal/setuid/errors.go b/cmd/hakurei/internal/app/internal/setuid/errors.go new file mode 100644 index 00000000..bb7dd891 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/errors.go @@ -0,0 +1,182 @@ +package setuid + +import ( + "errors" + "log" + + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +func PrintRunStateErr(rs *RunState, runErr error) (code int) { + code = rs.ExitStatus() + + if runErr != nil { + if rs.Time == nil { + hlog.PrintBaseError(runErr, "cannot start app:") + } else { + var e *hlog.BaseError + if !hlog.AsBaseError(runErr, &e) { + log.Println("wait failed:", runErr) + } else { + // Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError + var se *StateStoreError + if !errors.As(runErr, &se) { + // does not need special handling + log.Print(e.Message()) + } else { + // inner error are either unwrapped store errors + // or joined errors returned by *appSealTx revert + // wrapped in *app.BaseError + var ej RevertCompoundError + if !errors.As(se.InnerErr, &ej) { + // does not require special handling + log.Print(e.Message()) + } else { + errs := ej.Unwrap() + + // every error here is wrapped in *app.BaseError + for _, ei := range errs { + var eb *hlog.BaseError + if !errors.As(ei, &eb) { + // unreachable + log.Println("invalid error type returned by revert:", ei) + } else { + // print inner *app.BaseError message + log.Print(eb.Message()) + } + } + } + } + } + } + + if code == 0 { + code = 126 + } + } + + if rs.RevertErr != nil { + var stateStoreError *StateStoreError + if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil { + hlog.PrintBaseError(rs.RevertErr, "generic fault during cleanup:") + goto out + } + + if stateStoreError.Err != nil { + if len(stateStoreError.Err) == 2 { + if stateStoreError.Err[0] != nil { + if joinedErrs, ok := stateStoreError.Err[0].(interface{ Unwrap() []error }); !ok { + hlog.PrintBaseError(stateStoreError.Err[0], "generic fault during revert:") + } else { + for _, err := range joinedErrs.Unwrap() { + if err != nil { + hlog.PrintBaseError(err, "fault during revert:") + } + } + } + } + if stateStoreError.Err[1] != nil { + log.Printf("cannot close store: %v", stateStoreError.Err[1]) + } + } else { + log.Printf("fault during cleanup: %v", + errors.Join(stateStoreError.Err...)) + } + } + + if stateStoreError.OpErr != nil { + log.Printf("blind revert due to store fault: %v", + stateStoreError.OpErr) + } + + if stateStoreError.DoErr != nil { + hlog.PrintBaseError(stateStoreError.DoErr, "state store operation unsuccessful:") + } + + if stateStoreError.Inner && stateStoreError.InnerErr != nil { + hlog.PrintBaseError(stateStoreError.InnerErr, "cannot destroy state entry:") + } + + out: + if code == 0 { + code = 128 + } + } + if rs.WaitErr != nil { + hlog.Verbosef("wait: %v", rs.WaitErr) + } + return +} + +// StateStoreError is returned for a failed state save +type StateStoreError struct { + // whether inner function was called + Inner bool + // returned by the Save/Destroy method of [state.Cursor] + InnerErr error + // returned by the Do method of [state.Store] + DoErr error + // stores an arbitrary store operation error + OpErr error + // stores arbitrary errors + Err []error +} + +// save saves arbitrary errors in [StateStoreError] once. +func (e *StateStoreError) save(errs ...error) { + if len(errs) == 0 || e.Err != nil { + panic("invalid call to save") + } + e.Err = errs +} + +func (e *StateStoreError) equiv(a ...any) error { + if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Err...) == nil { + return nil + } else { + return hlog.WrapErrSuffix(e, a...) + } +} + +func (e *StateStoreError) Error() string { + if e.Inner && e.InnerErr != nil { + return e.InnerErr.Error() + } + if e.DoErr != nil { + return e.DoErr.Error() + } + if e.OpErr != nil { + return e.OpErr.Error() + } + if err := errors.Join(e.Err...); err != nil { + return err.Error() + } + + // equiv nullifies e for values where this is reached + panic("unreachable") +} + +func (e *StateStoreError) Unwrap() (errs []error) { + errs = make([]error, 0, 3) + if e.InnerErr != nil { + errs = append(errs, e.InnerErr) + } + if e.DoErr != nil { + errs = append(errs, e.DoErr) + } + if e.OpErr != nil { + errs = append(errs, e.OpErr) + } + if err := errors.Join(e.Err...); err != nil { + errs = append(errs, err) + } + return +} + +// A RevertCompoundError encapsulates errors returned by +// the Revert method of [system.I]. +type RevertCompoundError interface { + Error() string + Unwrap() []error +} diff --git a/cmd/hakurei/internal/app/internal/setuid/export_test.go b/cmd/hakurei/internal/app/internal/setuid/export_test.go new file mode 100644 index 00000000..2606fc7f --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/export_test.go @@ -0,0 +1,24 @@ +package setuid + +import ( + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/internal/sys" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/system" +) + +func NewWithID(id ID, os sys.State) App { + a := new(app) + a.id = newID(&id) + a.sys = os + return a +} + +func AppIParams(a App, sa SealedApp) (*system.I, *sandbox.Params) { + v := a.(*app) + seal := sa.(*outcome) + if v.outcome != seal || v.id != seal.id { + panic("broken app/outcome link") + } + return seal.sys, seal.container +} diff --git a/cmd/hakurei/internal/app/internal/setuid/process.go b/cmd/hakurei/internal/app/internal/setuid/process.go new file mode 100644 index 00000000..c5597d1d --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/process.go @@ -0,0 +1,195 @@ +package setuid + +import ( + "context" + "encoding/gob" + "errors" + "log" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/system" +) + +const shimWaitTimeout = 5 * time.Second + +func (seal *outcome) Run(rs *RunState) error { + if !seal.f.CompareAndSwap(false, true) { + // run does much more than just starting a process; calling it twice, even if the first call fails, will result + // in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the + // other Run a chance to return + return errors.New("outcome: attempted to run twice") + } + + if rs == nil { + panic("invalid state") + } + + // read comp value early to allow for early failure + hsuPath := internal.MustHsuPath() + + if err := seal.sys.Commit(seal.ctx); err != nil { + return err + } + store := state.NewMulti(seal.runDirPath) + deferredStoreFunc := func(c state.Cursor) error { return nil } // noop until state in store + defer func() { + var revertErr error + storeErr := new(StateStoreError) + storeErr.Inner, storeErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { + revertErr = func() error { + storeErr.InnerErr = deferredStoreFunc(c) + + var rt system.Enablement + ec := system.Process + if states, err := c.Load(); err != nil { + // revert per-process state here to limit damage + storeErr.OpErr = err + return seal.sys.Revert((*system.Criteria)(&ec)) + } else { + if l := len(states); l == 0 { + ec |= system.User + } else { + hlog.Verbosef("found %d instances, cleaning up without user-scoped operations", l) + } + + // accumulate enablements of remaining launchers + for i, s := range states { + if s.Config != nil { + rt |= s.Config.Enablements + } else { + log.Printf("state entry %d does not contain config", i) + } + } + } + ec |= rt ^ (system.EWayland | system.EX11 | system.EDBus | system.EPulse) + if hlog.Load() { + if ec > 0 { + hlog.Verbose("reverting operations scope", system.TypeString(ec)) + } + } + + return seal.sys.Revert((*system.Criteria)(&ec)) + }() + }) + storeErr.save(revertErr, store.Close()) + rs.RevertErr = storeErr.equiv("error during cleanup:") + }() + + ctx, cancel := context.WithCancel(seal.ctx) + defer cancel() + cmd := exec.CommandContext(ctx, hsuPath) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Dir = "/" // container init enters final working directory + // shim runs in the same session as monitor; see shim.go for behaviour + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGCONT) } + + var e *gob.Encoder + if fd, encoder, err := sandbox.Setup(&cmd.ExtraFiles); err != nil { + return hlog.WrapErrSuffix(err, + "cannot create shim setup pipe:") + } else { + e = encoder + cmd.Env = []string{ + // passed through to shim by hsu + shimEnv + "=" + strconv.Itoa(fd), + // interpreted by hsu + "HAKUREI_APP_ID=" + seal.user.aid.String(), + } + } + + if len(seal.user.supp) > 0 { + hlog.Verbosef("attaching supplementary group ids %s", seal.user.supp) + // interpreted by hsu + cmd.Env = append(cmd.Env, "HAKUREI_GROUPS="+strings.Join(seal.user.supp, " ")) + } + + hlog.Verbosef("setuid helper at %s", hsuPath) + hlog.Suspend() + if err := cmd.Start(); err != nil { + return hlog.WrapErrSuffix(err, + "cannot start setuid wrapper:") + } + rs.SetStart() + + // this prevents blocking forever on an early failure + waitErr, setupErr := make(chan error, 1), make(chan error, 1) + go func() { waitErr <- cmd.Wait(); cancel() }() + go func() { setupErr <- e.Encode(&shimParams{os.Getpid(), seal.container, seal.user.data, hlog.Load()}) }() + + select { + case err := <-setupErr: + if err != nil { + hlog.Resume() + return hlog.WrapErrSuffix(err, + "cannot transmit shim config:") + } + + case <-ctx.Done(): + hlog.Resume() + return hlog.WrapErr(syscall.ECANCELED, + "shim setup canceled") + } + + // returned after blocking on waitErr + var earlyStoreErr = new(StateStoreError) + { + // shim accepted setup payload, create process state + sd := state.State{ + ID: seal.id.unwrap(), + PID: cmd.Process.Pid, + Time: *rs.Time, + } + earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { + earlyStoreErr.InnerErr = c.Save(&sd, seal.ct) + }) + } + + // state in store at this point, destroy defunct state entry on return + deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) } + + waitTimeout := make(chan struct{}) + go func() { <-seal.ctx.Done(); time.Sleep(shimWaitTimeout); close(waitTimeout) }() + + select { + case rs.WaitErr = <-waitErr: + rs.WaitStatus = cmd.ProcessState.Sys().(syscall.WaitStatus) + if hlog.Load() { + switch { + case rs.Exited(): + hlog.Verbosef("process %d exited with code %d", cmd.Process.Pid, rs.ExitStatus()) + case rs.CoreDump(): + hlog.Verbosef("process %d dumped core", cmd.Process.Pid) + case rs.Signaled(): + hlog.Verbosef("process %d got %s", cmd.Process.Pid, rs.Signal()) + default: + hlog.Verbosef("process %d exited with status %#x", cmd.Process.Pid, rs.WaitStatus) + } + } + case <-waitTimeout: + rs.WaitErr = syscall.ETIMEDOUT + hlog.Resume() + log.Printf("process %d did not terminate", cmd.Process.Pid) + } + + hlog.Resume() + if seal.sync != nil { + if err := seal.sync.Close(); err != nil { + log.Printf("cannot close wayland security context: %v", err) + } + } + if seal.dbusMsg != nil { + seal.dbusMsg() + } + + return earlyStoreErr.equiv("cannot save process state:") +} diff --git a/cmd/hakurei/internal/app/internal/setuid/seal.go b/cmd/hakurei/internal/app/internal/setuid/seal.go new file mode 100644 index 00000000..2ec26105 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/seal.go @@ -0,0 +1,586 @@ +package setuid + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "regexp" + "slices" + "strings" + "sync/atomic" + "syscall" + + "git.gensokyo.uk/security/hakurei/acl" + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/instance/common" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/internal/sys" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/sandbox/wl" + "git.gensokyo.uk/security/hakurei/system" +) + +const ( + home = "HOME" + shell = "SHELL" + + xdgConfigHome = "XDG_CONFIG_HOME" + xdgRuntimeDir = "XDG_RUNTIME_DIR" + xdgSessionClass = "XDG_SESSION_CLASS" + xdgSessionType = "XDG_SESSION_TYPE" + + term = "TERM" + display = "DISPLAY" + + pulseServer = "PULSE_SERVER" + pulseCookie = "PULSE_COOKIE" + + dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" + dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" +) + +var ( + ErrConfig = errors.New("no configuration to seal") + ErrUser = errors.New("invalid aid") + ErrHome = errors.New("invalid home directory") + ErrName = errors.New("invalid username") + + ErrXDisplay = errors.New(display + " unset") + + ErrPulseCookie = errors.New("pulse cookie not present") + ErrPulseSocket = errors.New("pulse socket not present") + ErrPulseMode = errors.New("unexpected pulse socket mode") +) + +var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$") + +// outcome stores copies of various parts of [hst.Config] +type outcome struct { + // copied from initialising [app] + id *stringPair[ID] + // copied from [sys.State] response + runDirPath string + + // initial [hst.Config] gob stream for state data; + // this is prepared ahead of time as config is clobbered during seal creation + ct io.WriterTo + // dump dbus proxy message buffer + dbusMsg func() + + user hsuUser + sys *system.I + ctx context.Context + + container *sandbox.Params + env map[string]string + sync *os.File + + f atomic.Bool +} + +// shareHost holds optional share directory state that must not be accessed directly +type shareHost struct { + // whether XDG_RUNTIME_DIR is used post hsu + useRuntimeDir bool + // process-specific directory in tmpdir, empty if unused + sharePath string + // process-specific directory in XDG_RUNTIME_DIR, empty if unused + runtimeSharePath string + + seal *outcome + sc hst.Paths +} + +// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required +func (share *shareHost) ensureRuntimeDir() { + if share.useRuntimeDir { + return + } + share.useRuntimeDir = true + share.seal.sys.Ensure(share.sc.RunDirPath, 0700) + share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath, acl.Execute) + share.seal.sys.Ensure(share.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset + share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath, acl.Execute) +} + +// instance returns a process-specific share path within tmpdir +func (share *shareHost) instance() string { + if share.sharePath != "" { + return share.sharePath + } + share.sharePath = path.Join(share.sc.SharePath, share.seal.id.String()) + share.seal.sys.Ephemeral(system.Process, share.sharePath, 0711) + return share.sharePath +} + +// runtime returns a process-specific share path within XDG_RUNTIME_DIR +func (share *shareHost) runtime() string { + if share.runtimeSharePath != "" { + return share.runtimeSharePath + } + share.ensureRuntimeDir() + share.runtimeSharePath = path.Join(share.sc.RunDirPath, share.seal.id.String()) + share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath, 0700) + share.seal.sys.UpdatePerm(share.runtimeSharePath, acl.Execute) + return share.runtimeSharePath +} + +// hsuUser stores post-hsu credentials and metadata +type hsuUser struct { + // application id + aid *stringPair[int] + // target uid resolved by fid:aid + uid *stringPair[int] + + // supplementary group ids + supp []string + + // home directory host path + data string + // app user home directory + home string + // passwd database username + username string +} + +func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Config) error { + if seal.ctx != nil { + panic("finalise called twice") + } + seal.ctx = ctx + + { + // encode initial configuration for state tracking + ct := new(bytes.Buffer) + if err := gob.NewEncoder(ct).Encode(config); err != nil { + return hlog.WrapErrSuffix(err, + "cannot encode initial config:") + } + seal.ct = ct + } + + // allowed aid range 0 to 9999, this is checked again in hsu + if config.Identity < 0 || config.Identity > 9999 { + return hlog.WrapErr(ErrUser, + fmt.Sprintf("identity %d out of range", config.Identity)) + } + + seal.user = hsuUser{ + aid: newInt(config.Identity), + data: config.Data, + home: config.Dir, + username: config.Username, + } + if seal.user.username == "" { + seal.user.username = "chronos" + } else if !posixUsername.MatchString(seal.user.username) || + len(seal.user.username) >= internal.Sysconf(internal.SC_LOGIN_NAME_MAX) { + return hlog.WrapErr(ErrName, + fmt.Sprintf("invalid user name %q", seal.user.username)) + } + if seal.user.data == "" || !path.IsAbs(seal.user.data) { + return hlog.WrapErr(ErrHome, + fmt.Sprintf("invalid home directory %q", seal.user.data)) + } + if seal.user.home == "" { + seal.user.home = seal.user.data + } + if u, err := sys.Uid(seal.user.aid.unwrap()); err != nil { + return err + } else { + seal.user.uid = newInt(u) + } + seal.user.supp = make([]string, len(config.Groups)) + for i, name := range config.Groups { + if g, err := sys.LookupGroup(name); err != nil { + return hlog.WrapErr(err, + fmt.Sprintf("unknown group %q", name)) + } else { + seal.user.supp[i] = g.Gid + } + } + + // this also falls back to host path if encountering an invalid path + if !path.IsAbs(config.Shell) { + config.Shell = "/bin/sh" + if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) { + config.Shell = s + } + } + // do not use the value of shell before this point + + // permissive defaults + if config.Container == nil { + hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") + + // hsu clears the environment so resolve paths early + if !path.IsAbs(config.Path) { + if len(config.Args) > 0 { + if p, err := sys.LookPath(config.Args[0]); err != nil { + return hlog.WrapErr(err, err.Error()) + } else { + config.Path = p + } + } else { + config.Path = config.Shell + } + } + + conf := &hst.ContainerConfig{ + Userns: true, + Net: true, + Tty: true, + AutoEtc: true, + } + // bind entries in / + if d, err := sys.ReadDir("/"); err != nil { + return err + } else { + b := make([]*hst.FilesystemConfig, 0, len(d)) + for _, ent := range d { + p := "/" + ent.Name() + switch p { + case "/proc": + case "/dev": + case "/tmp": + case "/mnt": + case "/etc": + + default: + b = append(b, &hst.FilesystemConfig{Src: p, Write: true, Must: true}) + } + } + conf.Filesystem = append(conf.Filesystem, b...) + } + + // hide nscd from sandbox if present + nscd := "/var/run/nscd" + if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) { + conf.Cover = append(conf.Cover, nscd) + } + // bind GPU stuff + if config.Enablements&(system.EX11|system.EWayland) != 0 { + conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/dri", Device: true}) + } + // opportunistically bind kvm + conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/kvm", Device: true}) + + config.Container = conf + } + + var mapuid, mapgid *stringPair[int] + { + var uid, gid int + var err error + seal.container, seal.env, err = common.NewContainer(config.Container, sys, &uid, &gid) + if err != nil { + return hlog.WrapErrSuffix(err, + "cannot initialise container configuration:") + } + if !path.IsAbs(config.Path) { + return hlog.WrapErr(syscall.EINVAL, + "invalid program path") + } + if len(config.Args) == 0 { + config.Args = []string{config.Path} + } + seal.container.Path = config.Path + seal.container.Args = config.Args + + mapuid = newInt(uid) + mapgid = newInt(gid) + if seal.env == nil { + seal.env = make(map[string]string, 1<<6) + } + } + + if !config.Container.AutoEtc { + if config.Container.Etc != "" { + seal.container.Bind(config.Container.Etc, "/etc", 0) + } + } else { + etcPath := config.Container.Etc + if etcPath == "" { + etcPath = "/etc" + } + seal.container.Etc(etcPath, seal.id.String()) + } + + // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid + innerRuntimeDir := path.Join("/run/user", mapuid.String()) + seal.env[xdgRuntimeDir] = innerRuntimeDir + seal.env[xdgSessionClass] = "user" + seal.env[xdgSessionType] = "tty" + + share := &shareHost{seal: seal, sc: sys.Paths()} + seal.runDirPath = share.sc.RunDirPath + seal.sys = system.New(seal.user.uid.unwrap()) + seal.sys.Ensure(share.sc.SharePath, 0711) + + { + runtimeDir := path.Join(share.sc.SharePath, "runtime") + seal.sys.Ensure(runtimeDir, 0700) + seal.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) + runtimeDirInst := path.Join(runtimeDir, seal.user.aid.String()) + seal.sys.Ensure(runtimeDirInst, 0700) + seal.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) + seal.container.Tmpfs("/run/user", 1<<12, 0755) + seal.container.Bind(runtimeDirInst, innerRuntimeDir, sandbox.BindWritable) + } + + { + tmpdir := path.Join(share.sc.SharePath, "tmpdir") + seal.sys.Ensure(tmpdir, 0700) + seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute) + tmpdirInst := path.Join(tmpdir, seal.user.aid.String()) + seal.sys.Ensure(tmpdirInst, 01700) + seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) + // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp + seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable) + } + + { + homeDir := "/var/empty" + if seal.user.home != "" { + homeDir = seal.user.home + } + username := "chronos" + if seal.user.username != "" { + username = seal.user.username + } + seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable) + seal.container.Dir = homeDir + seal.env["HOME"] = homeDir + seal.env["USER"] = username + seal.env[shell] = config.Shell + + seal.container.Place("/etc/passwd", + []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+homeDir+":"+config.Shell+"\n")) + seal.container.Place("/etc/group", + []byte("hakurei:x:"+mapgid.String()+":\n")) + } + + // pass TERM for proper terminal I/O in initial process + if t, ok := sys.LookupEnv(term); ok { + seal.env[term] = t + } + + if config.Enablements&system.EWayland != 0 { + // outer wayland socket (usually `/run/user/%d/wayland-%d`) + var socketPath string + if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok { + hlog.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName) + socketPath = path.Join(share.sc.RuntimePath, wl.FallbackName) + } else if !path.IsAbs(name) { + socketPath = path.Join(share.sc.RuntimePath, name) + } else { + socketPath = name + } + + innerPath := path.Join(innerRuntimeDir, wl.FallbackName) + seal.env[wl.WaylandDisplay] = wl.FallbackName + + if !config.DirectWayland { // set up security-context-v1 + appID := config.ID + if appID == "" { + // use instance ID in case app id is not set + appID = "app.hakurei." + seal.id.String() + } + // downstream socket paths + outerPath := path.Join(share.instance(), "wayland") + seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String()) + seal.container.Bind(outerPath, innerPath, 0) + } else { // bind mount wayland socket (insecure) + hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") + share.ensureRuntimeDir() + seal.container.Bind(socketPath, innerPath, 0) + seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) + } + } + + if config.Enablements&system.EX11 != 0 { + if d, ok := sys.LookupEnv(display); !ok { + return hlog.WrapErr(ErrXDisplay, + "DISPLAY is not set") + } else { + seal.sys.ChangeHosts("#" + seal.user.uid.String()) + seal.env[display] = d + seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix", 0) + } + } + + if config.Enablements&system.EPulse != 0 { + // PulseAudio runtime directory (usually `/run/user/%d/pulse`) + pulseRuntimeDir := path.Join(share.sc.RuntimePath, "pulse") + // PulseAudio socket (usually `/run/user/%d/pulse/native`) + pulseSocket := path.Join(pulseRuntimeDir, "native") + + if _, err := sys.Stat(pulseRuntimeDir); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return hlog.WrapErrSuffix(err, + fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir)) + } + return hlog.WrapErr(ErrPulseSocket, + fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) + } + + if s, err := sys.Stat(pulseSocket); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return hlog.WrapErrSuffix(err, + fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket)) + } + return hlog.WrapErr(ErrPulseSocket, + fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) + } else { + if m := s.Mode(); m&0o006 != 0o006 { + return hlog.WrapErr(ErrPulseMode, + fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m) + } + } + + // hard link pulse socket into target-executable share + innerPulseRuntimeDir := path.Join(share.runtime(), "pulse") + innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native") + seal.sys.Link(pulseSocket, innerPulseRuntimeDir) + seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) + seal.env[pulseServer] = "unix:" + innerPulseSocket + + // publish current user's pulse cookie for target user + if src, err := discoverPulseCookie(sys); err != nil { + // not fatal + hlog.Verbose(strings.TrimSpace(err.(*hlog.BaseError).Message())) + } else { + innerDst := hst.Tmp + "/pulse-cookie" + seal.env[pulseCookie] = innerDst + var payload *[]byte + seal.container.PlaceP(innerDst, &payload) + seal.sys.CopyFile(payload, src, 256, 256) + } + } + + if config.Enablements&system.EDBus != 0 { + // ensure dbus session bus defaults + if config.SessionBus == nil { + config.SessionBus = dbus.NewConfig(config.ID, true, true) + } + + // downstream socket paths + sharePath := share.instance() + sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket") + + // configure dbus proxy + if f, err := seal.sys.ProxyDBus( + config.SessionBus, config.SystemBus, + sessionPath, systemPath, + ); err != nil { + return err + } else { + seal.dbusMsg = f + } + + // share proxy sockets + sessionInner := path.Join(innerRuntimeDir, "bus") + seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner + seal.container.Bind(sessionPath, sessionInner, 0) + seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) + if config.SystemBus != nil { + systemInner := "/run/dbus/system_bus_socket" + seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner + seal.container.Bind(systemPath, systemInner, 0) + seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write) + } + } + + for _, dest := range config.Container.Cover { + seal.container.Tmpfs(dest, 1<<13, 0755) + } + + // append ExtraPerms last + for _, p := range config.ExtraPerms { + if p == nil { + continue + } + + if p.Ensure { + seal.sys.Ensure(p.Path, 0700) + } + + perms := make(acl.Perms, 0, 3) + if p.Read { + perms = append(perms, acl.Read) + } + if p.Write { + perms = append(perms, acl.Write) + } + if p.Execute { + perms = append(perms, acl.Execute) + } + seal.sys.UpdatePermType(system.User, p.Path, perms...) + } + + // flatten and sort env for deterministic behaviour + seal.container.Env = make([]string, 0, len(seal.env)) + for k, v := range seal.env { + if strings.IndexByte(k, '=') != -1 { + return hlog.WrapErr(syscall.EINVAL, + fmt.Sprintf("invalid environment variable %s", k)) + } + seal.container.Env = append(seal.container.Env, k+"="+v) + } + slices.Sort(seal.container.Env) + + if hlog.Load() { + hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", + seal.user.uid, seal.user.username, config.Groups, seal.container.Args, len(*seal.container.Ops)) + } + + return nil +} + +// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie +func discoverPulseCookie(sys sys.State) (string, error) { + if p, ok := sys.LookupEnv(pulseCookie); ok { + return p, nil + } + + // dotfile $HOME/.pulse-cookie + if p, ok := sys.LookupEnv(home); ok { + p = path.Join(p, ".pulse-cookie") + if s, err := sys.Stat(p); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return p, hlog.WrapErrSuffix(err, + fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) + } + // not found, try next method + } else if !s.IsDir() { + return p, nil + } + } + + // $XDG_CONFIG_HOME/pulse/cookie + if p, ok := sys.LookupEnv(xdgConfigHome); ok { + p = path.Join(p, "pulse", "cookie") + if s, err := sys.Stat(p); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return p, hlog.WrapErrSuffix(err, + fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) + } + // not found, try next method + } else if !s.IsDir() { + return p, nil + } + } + + return "", hlog.WrapErr(ErrPulseCookie, + fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", + pulseCookie, xdgConfigHome, home)) +} diff --git a/cmd/hakurei/internal/app/internal/setuid/shim.go b/cmd/hakurei/internal/app/internal/setuid/shim.go new file mode 100644 index 00000000..1646cd5b --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/shim.go @@ -0,0 +1,184 @@ +package setuid + +import ( + "context" + "errors" + "log" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" +) + +/* +#include +#include +#include +#include +#include + +static pid_t hakurei_shim_param_ppid = -1; + +// this cannot unblock hlog since Go code is not async-signal-safe +static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) { + if (sig != SIGCONT || si == NULL) { + // unreachable + fprintf(stderr, "sigaction: sa_sigaction got invalid siginfo\n"); + return; + } + + // monitor requests shim exit + if (si->si_pid == hakurei_shim_param_ppid) + exit(254); + + fprintf(stderr, "sigaction: got SIGCONT from process %d\n", si->si_pid); + + // shim orphaned before monitor delivers a signal + if (getppid() != hakurei_shim_param_ppid) + exit(3); +} + +void hakurei_shim_setup_cont_signal(pid_t ppid) { + struct sigaction new_action = {0}, old_action = {0}; + if (sigaction(SIGCONT, NULL, &old_action) != 0) + return; + if (old_action.sa_handler != SIG_DFL) { + errno = ENOTRECOVERABLE; + return; + } + + new_action.sa_sigaction = hakurei_shim_sigaction; + if (sigemptyset(&new_action.sa_mask) != 0) + return; + new_action.sa_flags = SA_ONSTACK | SA_SIGINFO; + + if (sigaction(SIGCONT, &new_action, NULL) != 0) + return; + + errno = 0; + hakurei_shim_param_ppid = ppid; +} +*/ +import "C" + +const shimEnv = "HAKUREI_SHIM" + +type shimParams struct { + // monitor pid, checked against ppid in signal handler + Monitor int + + // finalised container params + Container *sandbox.Params + // path to outer home directory + Home string + + // verbosity pass through + Verbose bool +} + +// ShimMain is the main function of the shim process and runs as the unconstrained target user. +func ShimMain() { + hlog.Prepare("shim") + + if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { + log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err) + } + + var ( + params shimParams + closeSetup func() error + ) + if f, err := sandbox.Receive(shimEnv, ¶ms, nil); err != nil { + if errors.Is(err, sandbox.ErrInvalid) { + log.Fatal("invalid config descriptor") + } + if errors.Is(err, sandbox.ErrNotSet) { + log.Fatal("HAKUREI_SHIM not set") + } + + log.Fatalf("cannot receive shim setup params: %v", err) + } else { + internal.InstallOutput(params.Verbose) + closeSetup = f + + // the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid + if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(params.Monitor)); err != nil { + log.Fatalf("cannot install SIGCONT handler: %v", err) + } + + // pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c + if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); errno != 0 { + log.Fatalf("cannot set parent-death signal: %v", errno) + } + } + + if params.Container == nil || params.Container.Ops == nil { + log.Fatal("invalid container params") + } + + // close setup socket + if err := closeSetup(); err != nil { + log.Printf("cannot close setup pipe: %v", err) + // not fatal + } + + // ensure home directory as target user + if s, err := os.Stat(params.Home); err != nil { + if os.IsNotExist(err) { + if err = os.Mkdir(params.Home, 0700); err != nil { + log.Fatalf("cannot create home directory: %v", err) + } + } else { + log.Fatalf("cannot access home directory: %v", err) + } + + // home directory is created, proceed + } else if !s.IsDir() { + log.Fatalf("path %q is not a directory", params.Home) + } + + var name string + if len(params.Container.Args) > 0 { + name = params.Container.Args[0] + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() // unreachable + container := sandbox.New(ctx, name) + container.Params = *params.Container + container.Stdin, container.Stdout, container.Stderr = os.Stdin, os.Stdout, os.Stderr + container.Cancel = func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) } + container.WaitDelay = 2 * time.Second + + if err := container.Start(); err != nil { + hlog.PrintBaseError(err, "cannot start container:") + os.Exit(1) + } + if err := container.Serve(); err != nil { + hlog.PrintBaseError(err, "cannot configure container:") + } + + if err := seccomp.Load( + seccomp.Preset(seccomp.PresetStrict, seccomp.AllowMultiarch), + seccomp.AllowMultiarch, + ); err != nil { + log.Fatalf("cannot load syscall filter: %v", err) + } + + if err := container.Wait(); err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + if errors.Is(err, context.Canceled) { + os.Exit(2) + } + log.Printf("wait: %v", err) + os.Exit(127) + } + os.Exit(exitError.ExitCode()) + } +} diff --git a/cmd/hakurei/internal/app/internal/setuid/strings.go b/cmd/hakurei/internal/app/internal/setuid/strings.go new file mode 100644 index 00000000..6521def1 --- /dev/null +++ b/cmd/hakurei/internal/app/internal/setuid/strings.go @@ -0,0 +1,19 @@ +package setuid + +import ( + "strconv" + + . "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" +) + +func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } +func newID(id *ID) *stringPair[ID] { return &stringPair[ID]{*id, id.String()} } + +// 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 } diff --git a/cmd/hakurei/internal/state/join.go b/cmd/hakurei/internal/state/join.go new file mode 100644 index 00000000..2b4011fe --- /dev/null +++ b/cmd/hakurei/internal/state/join.go @@ -0,0 +1,60 @@ +package state + +import ( + "errors" + "maps" +) + +var ( + ErrDuplicate = errors.New("store contains duplicates") +) + +/* +Joiner is the interface that wraps the Join method. + +The Join function uses Joiner if available. +*/ +type Joiner interface{ Join() (Entries, error) } + +// Join returns joined state entries of all active aids. +func Join(s Store) (Entries, error) { + if j, ok := s.(Joiner); ok { + return j.Join() + } + + var ( + aids []int + entries = make(Entries) + + el int + res Entries + loadErr error + ) + + if ln, err := s.List(); err != nil { + return nil, err + } else { + aids = ln + } + + for _, aid := range aids { + if _, err := s.Do(aid, func(c Cursor) { + res, loadErr = c.Load() + }); err != nil { + return nil, err + } + + if loadErr != nil { + return nil, loadErr + } + + // save expected length + el = len(entries) + len(res) + maps.Copy(entries, res) + if len(entries) != el { + return nil, ErrDuplicate + } + } + + return entries, nil +} diff --git a/cmd/hakurei/internal/state/multi.go b/cmd/hakurei/internal/state/multi.go new file mode 100644 index 00000000..cd66c8c8 --- /dev/null +++ b/cmd/hakurei/internal/state/multi.go @@ -0,0 +1,373 @@ +package state + +import ( + "encoding/binary" + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "strconv" + "sync" + "syscall" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +// fine-grained locking and access +type multiStore struct { + base string + + // initialised backends + backends *sync.Map + + lock sync.RWMutex +} + +func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + // load or initialise new backend + b := new(multiBackend) + b.lock.Lock() + if v, ok := s.backends.LoadOrStore(aid, b); ok { + b = v.(*multiBackend) + } else { + b.path = path.Join(s.base, strconv.Itoa(aid)) + + // ensure directory + if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) { + s.backends.CompareAndDelete(aid, b) + return false, err + } + + // open locker file + if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil { + s.backends.CompareAndDelete(aid, b) + return false, err + } else { + b.lockfile = l + } + b.lock.Unlock() + } + + // lock backend + if err := b.lockFile(); err != nil { + return false, err + } + + // expose backend methods without exporting the pointer + c := new(struct{ *multiBackend }) + c.multiBackend = b + f(b) + // disable access to the backend on a best-effort basis + c.multiBackend = nil + + // unlock backend + return true, b.unlockFile() +} + +func (s *multiStore) List() ([]int, error) { + var entries []os.DirEntry + + // read base directory to get all aids + if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } else { + entries = v + } + + aidsBuf := make([]int, 0, len(entries)) + for _, e := range entries { + // skip non-directories + if !e.IsDir() { + hlog.Verbosef("skipped non-directory entry %q", e.Name()) + continue + } + + // skip non-numerical names + if v, err := strconv.Atoi(e.Name()); err != nil { + hlog.Verbosef("skipped non-aid entry %q", e.Name()) + continue + } else { + if v < 0 || v > 9999 { + hlog.Verbosef("skipped out of bounds entry %q", e.Name()) + continue + } + + aidsBuf = append(aidsBuf, v) + } + } + + return append([]int(nil), aidsBuf...), nil +} + +func (s *multiStore) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + var errs []error + s.backends.Range(func(_, value any) bool { + b := value.(*multiBackend) + errs = append(errs, b.close()) + return true + }) + + return errors.Join(errs...) +} + +type multiBackend struct { + path string + + // created/opened by prepare + lockfile *os.File + + lock sync.RWMutex +} + +func (b *multiBackend) filename(id *app.ID) string { + return path.Join(b.path, id.String()) +} + +func (b *multiBackend) lockFileAct(lt int) (err error) { + op := "LockAct" + switch lt { + case syscall.LOCK_EX: + op = "Lock" + case syscall.LOCK_UN: + op = "Unlock" + } + + for { + err = syscall.Flock(int(b.lockfile.Fd()), lt) + if !errors.Is(err, syscall.EINTR) { + break + } + } + if err != nil { + return &fs.PathError{ + Op: op, + Path: b.lockfile.Name(), + Err: err, + } + } + return nil +} + +func (b *multiBackend) lockFile() error { + return b.lockFileAct(syscall.LOCK_EX) +} + +func (b *multiBackend) unlockFile() error { + return b.lockFileAct(syscall.LOCK_UN) +} + +// reads all launchers in simpleBackend +// file contents are ignored if decode is false +func (b *multiBackend) load(decode bool) (Entries, error) { + b.lock.RLock() + defer b.lock.RUnlock() + + // read directory contents, should only contain files named after ids + var entries []os.DirEntry + if pl, err := os.ReadDir(b.path); err != nil { + return nil, err + } else { + entries = pl + } + + // allocate as if every entry is valid + // since that should be the case assuming no external interference happens + r := make(Entries, len(entries)) + + for _, e := range entries { + if e.IsDir() { + return nil, fmt.Errorf("unexpected directory %q in store", e.Name()) + } + + id := new(app.ID) + if err := app.ParseAppID(id, e.Name()); err != nil { + return nil, err + } + + // run in a function to better handle file closing + if err := func() error { + // open state file for reading + if f, err := os.Open(path.Join(b.path, e.Name())); err != nil { + return err + } else { + defer func() { + if f.Close() != nil { + // unreachable + panic("foreign state file closed prematurely") + } + }() + + s := new(State) + r[*id] = s + + // append regardless, but only parse if required, implements Len + if decode { + if err = b.decodeState(f, s); err != nil { + return err + } + if s.ID != *id { + return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID) + } + } + + return nil + } + }(); err != nil { + return nil, err + } + } + + return r, nil +} + +// state file consists of an eight byte header, followed by concatenated gobs +// of [hst.Config] and [State], if [State.Config] is not nil or offset < 0, +// the first gob is skipped +func (b *multiBackend) decodeState(r io.ReadSeeker, state *State) error { + offset := make([]byte, 8) + if l, err := r.Read(offset); err != nil { + if errors.Is(err, io.EOF) { + return fmt.Errorf("state file too short: %d bytes", l) + } + return err + } + + // decode volatile state first + var skipConfig bool + { + o := int64(binary.LittleEndian.Uint64(offset)) + skipConfig = o < 0 + + if !skipConfig { + if l, err := r.Seek(o, io.SeekCurrent); err != nil { + return err + } else if l != 8+o { + return fmt.Errorf("invalid seek offset %d", l) + } + } + } + if err := gob.NewDecoder(r).Decode(state); err != nil { + return err + } + + // decode sealed config + if state.Config == nil { + // config must be provided either as part of volatile state, + // or in the config segment + if skipConfig { + return ErrNoConfig + } + + state.Config = new(hst.Config) + if _, err := r.Seek(8, io.SeekStart); err != nil { + return err + } + return gob.NewDecoder(r).Decode(state.Config) + } else { + return nil + } +} + +// Save writes process state to filesystem +func (b *multiBackend) Save(state *State, configWriter io.WriterTo) error { + b.lock.Lock() + defer b.lock.Unlock() + + if configWriter == nil && state.Config == nil { + return ErrNoConfig + } + + statePath := b.filename(&state.ID) + + if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { + return err + } else { + defer func() { + if f.Close() != nil { + // unreachable + panic("state file closed prematurely") + } + }() + return b.encodeState(f, state, configWriter) + } +} + +func (b *multiBackend) encodeState(w io.WriteSeeker, state *State, configWriter io.WriterTo) error { + offset := make([]byte, 8) + + // skip header bytes + if _, err := w.Seek(8, io.SeekStart); err != nil { + return err + } + + if configWriter != nil { + // write config gob and encode header + if l, err := configWriter.WriteTo(w); err != nil { + return err + } else { + binary.LittleEndian.PutUint64(offset, uint64(l)) + } + } else { + // offset == -1 indicates absence of config gob + binary.LittleEndian.PutUint64(offset, 0xffffffffffffffff) + } + + // encode volatile state + if err := gob.NewEncoder(w).Encode(state); err != nil { + return err + } + + // write header + if _, err := w.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := w.Write(offset) + return err +} + +func (b *multiBackend) Destroy(id app.ID) error { + b.lock.Lock() + defer b.lock.Unlock() + + return os.Remove(b.filename(&id)) +} + +func (b *multiBackend) Load() (Entries, error) { + return b.load(true) +} + +func (b *multiBackend) Len() (int, error) { + // rn consists of only nil entries but has the correct length + rn, err := b.load(false) + return len(rn), err +} + +func (b *multiBackend) close() error { + b.lock.Lock() + defer b.lock.Unlock() + + err := b.lockfile.Close() + if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) { + return nil + } + return err +} + +// NewMulti returns an instance of the multi-file store. +func NewMulti(runDir string) Store { + b := new(multiStore) + b.base = path.Join(runDir, "state") + b.backends = new(sync.Map) + return b +} diff --git a/cmd/hakurei/internal/state/multi_test.go b/cmd/hakurei/internal/state/multi_test.go new file mode 100644 index 00000000..6097b0a4 --- /dev/null +++ b/cmd/hakurei/internal/state/multi_test.go @@ -0,0 +1,9 @@ +package state_test + +import ( + "testing" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" +) + +func TestMulti(t *testing.T) { testStore(t, state.NewMulti(t.TempDir())) } diff --git a/cmd/hakurei/internal/state/state.go b/cmd/hakurei/internal/state/state.go new file mode 100644 index 00000000..ad92ff0f --- /dev/null +++ b/cmd/hakurei/internal/state/state.go @@ -0,0 +1,49 @@ +package state + +import ( + "errors" + "io" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/hst" +) + +var ErrNoConfig = errors.New("state does not contain config") + +type Entries map[app.ID]*State + +type Store interface { + // Do calls f exactly once and ensures store exclusivity until f returns. + // Returns whether f is called and any errors during the locking process. + // Cursor provided to f becomes invalid as soon as f returns. + Do(aid int, f func(c Cursor)) (ok bool, err error) + + // List queries the store and returns a list of aids known to the store. + // Note that some or all returned aids might not have any active apps. + List() (aids []int, err error) + + // Close releases any resources held by Store. + Close() error +} + +// Cursor provides access to the store +type Cursor interface { + Save(state *State, configWriter io.WriterTo) error + Destroy(id app.ID) error + Load() (Entries, error) + Len() (int, error) +} + +// State is an instance state +type State struct { + // hakurei instance id + ID app.ID `json:"instance"` + // child process PID value + PID int `json:"pid"` + // sealed app configuration + Config *hst.Config `json:"config"` + + // process start time + Time time.Time `json:"time"` +} diff --git a/cmd/hakurei/internal/state/state_test.go b/cmd/hakurei/internal/state/state_test.go new file mode 100644 index 00000000..6c49fc4b --- /dev/null +++ b/cmd/hakurei/internal/state/state_test.go @@ -0,0 +1,145 @@ +package state_test + +import ( + "bytes" + "encoding/gob" + "io" + "math/rand/v2" + "reflect" + "slices" + "testing" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/hst" +) + +func testStore(t *testing.T, s state.Store) { + t.Run("list empty store", func(t *testing.T) { + if aids, err := s.List(); err != nil { + t.Fatalf("List: error = %v", err) + } else if len(aids) != 0 { + t.Fatalf("List: aids = %#v", aids) + } + }) + + const ( + insertEntryChecked = iota + insertEntryNoCheck + insertEntryOtherApp + + tl + ) + + var tc [tl]struct { + state state.State + ct bytes.Buffer + } + for i := 0; i < tl; i++ { + makeState(t, &tc[i].state, &tc[i].ct) + } + + do := func(aid int, f func(c state.Cursor)) { + if ok, err := s.Do(aid, f); err != nil { + t.Fatalf("Do: ok = %v, error = %v", ok, err) + } + } + + insert := func(i, aid int) { + do(aid, func(c state.Cursor) { + if err := c.Save(&tc[i].state, &tc[i].ct); err != nil { + t.Fatalf("Save(&tc[%v]): error = %v", i, err) + } + }) + } + + check := func(i, aid int) { + do(aid, func(c state.Cursor) { + if entries, err := c.Load(); err != nil { + t.Fatalf("Load: error = %v", err) + } else if got, ok := entries[tc[i].state.ID]; !ok { + t.Fatalf("Load: entry %s missing", + &tc[i].state.ID) + } else { + got.Time = tc[i].state.Time + tc[i].state.Config = hst.Template() + if !reflect.DeepEqual(got, &tc[i].state) { + t.Fatalf("Load: entry %s got %#v, want %#v", + &tc[i].state.ID, got, &tc[i].state) + } + tc[i].state.Config = nil + } + }) + } + + t.Run("insert entry checked", func(t *testing.T) { + insert(insertEntryChecked, 0) + check(insertEntryChecked, 0) + }) + + t.Run("insert entry unchecked", func(t *testing.T) { + insert(insertEntryNoCheck, 0) + }) + + t.Run("insert entry different aid", func(t *testing.T) { + insert(insertEntryOtherApp, 1) + check(insertEntryOtherApp, 1) + }) + + t.Run("check previous insertion", func(t *testing.T) { + check(insertEntryNoCheck, 0) + }) + + t.Run("list aids", func(t *testing.T) { + if aids, err := s.List(); err != nil { + t.Fatalf("List: error = %v", err) + } else { + slices.Sort(aids) + want := []int{0, 1} + if !slices.Equal(aids, want) { + t.Fatalf("List() = %#v, want %#v", aids, want) + } + } + }) + + t.Run("join store", func(t *testing.T) { + if entries, err := state.Join(s); err != nil { + t.Fatalf("Join: error = %v", err) + } else if len(entries) != 3 { + t.Fatalf("Join(s) = %#v", entries) + } + }) + + t.Run("clear aid 1", func(t *testing.T) { + do(1, func(c state.Cursor) { + if err := c.Destroy(tc[insertEntryOtherApp].state.ID); err != nil { + t.Fatalf("Destroy: error = %v", err) + } + }) + do(1, func(c state.Cursor) { + if l, err := c.Len(); err != nil { + t.Fatalf("Len: error = %v", err) + } else if l != 0 { + t.Fatalf("Len() = %d, want 0", l) + } + }) + }) + + t.Run("close store", func(t *testing.T) { + if err := s.Close(); err != nil { + t.Fatalf("Close: error = %v", err) + } + }) +} + +func makeState(t *testing.T, s *state.State, ct io.Writer) { + if err := app.NewAppID(&s.ID); err != nil { + t.Fatalf("cannot create dummy state: %v", err) + } + if err := gob.NewEncoder(ct).Encode(hst.Template()); err != nil { + t.Fatalf("cannot encode dummy config: %v", err) + } + s.PID = rand.Int() + s.Time = time.Now() +} diff --git a/cmd/hakurei/main.go b/cmd/hakurei/main.go new file mode 100644 index 00000000..143ce9c2 --- /dev/null +++ b/cmd/hakurei/main.go @@ -0,0 +1,299 @@ +package main + +// this works around go:embed '..' limitation +//go:generate cp ../../LICENSE . + +import ( + "context" + _ "embed" + "errors" + "fmt" + "io" + "log" + "os" + "os/signal" + "os/user" + "strconv" + "sync" + "syscall" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/instance" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/command" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/internal/sys" + "git.gensokyo.uk/security/hakurei/sandbox" + "git.gensokyo.uk/security/hakurei/system" +) + +var ( + errSuccess = errors.New("success") + + //go:embed LICENSE + license string +) + +func init() { hlog.Prepare("hakurei") } + +var std sys.State = new(sys.Std) + +func main() { + // early init path, skips root check and duplicate PR_SET_DUMPABLE + sandbox.TryArgv0(hlog.Output{}, hlog.Prepare, internal.InstallOutput) + + if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { + log.Printf("cannot set SUID_DUMP_DISABLE: %s", err) + // not fatal: this program runs as the privileged user + } + + if os.Geteuid() == 0 { + log.Fatal("this program must not run as root") + } + + buildCommand(os.Stderr).MustParse(os.Args[1:], func(err error) { + hlog.Verbosef("command returned %v", err) + if errors.Is(err, errSuccess) { + hlog.BeforeExit() + os.Exit(0) + } + }) + log.Fatal("unreachable") +} + +func buildCommand(out io.Writer) command.Command { + var ( + flagVerbose bool + flagJSON bool + ) + c := command.New(out, log.Printf, "hakurei", func([]string) error { internal.InstallOutput(flagVerbose); return nil }). + Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity"). + Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable") + + c.Command("shim", command.UsageInternal, func([]string) error { instance.ShimMain(); return errSuccess }) + + c.Command("app", "Load app from configuration file", func(args []string) error { + if len(args) < 1 { + log.Fatal("app requires at least 1 argument") + } + + // config extraArgs... + config := tryPath(args[0]) + config.Args = append(config.Args, args[1:]...) + + runApp(config) + panic("unreachable") + }) + + { + var ( + dbusConfigSession string + dbusConfigSystem string + mpris bool + dbusVerbose bool + + fid string + aid int + groups command.RepeatableFlag + homeDir string + userName string + + wayland, x11, dBus, pulse bool + ) + + c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { + // initialise config from flags + config := &hst.Config{ + ID: fid, + Args: args, + } + + if aid < 0 || aid > 9999 { + log.Fatalf("aid %d out of range", aid) + } + + // resolve home/username from os when flag is unset + var ( + passwd *user.User + passwdOnce sync.Once + passwdFunc = func() { + var us string + if uid, err := std.Uid(aid); err != nil { + hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") + os.Exit(1) + } else { + us = strconv.Itoa(uid) + } + + if u, err := user.LookupId(us); err != nil { + hlog.Verbosef("cannot look up uid %s", us) + passwd = &user.User{ + Uid: us, + Gid: us, + Username: "chronos", + Name: "Hakurei Permissive Default", + HomeDir: "/var/empty", + } + } else { + passwd = u + } + } + ) + + if homeDir == "os" { + passwdOnce.Do(passwdFunc) + homeDir = passwd.HomeDir + } + + if userName == "chronos" { + passwdOnce.Do(passwdFunc) + userName = passwd.Username + } + + config.Identity = aid + config.Groups = groups + config.Data = homeDir + config.Username = userName + + if wayland { + config.Enablements |= system.EWayland + } + if x11 { + config.Enablements |= system.EX11 + } + if dBus { + config.Enablements |= system.EDBus + } + if pulse { + config.Enablements |= system.EPulse + } + + // parse D-Bus config file from flags if applicable + if dBus { + if dbusConfigSession == "builtin" { + config.SessionBus = dbus.NewConfig(fid, true, mpris) + } else { + if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { + log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) + } else { + config.SessionBus = conf + } + } + + // system bus proxy is optional + if dbusConfigSystem != "nil" { + if conf, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil { + log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err) + } else { + config.SystemBus = conf + } + } + + // override log from configuration + if dbusVerbose { + config.SessionBus.Log = true + config.SystemBus.Log = true + } + } + + // invoke app + runApp(config) + panic("unreachable") + }). + Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), + "Path to session bus proxy config file, or \"builtin\" for defaults"). + Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"), + "Path to system bus proxy config file, or \"nil\" to disable"). + Flag(&mpris, "mpris", command.BoolFlag(false), + "Allow owning MPRIS D-Bus path, has no effect if custom config is available"). + Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false), + "Force buffered logging in the D-Bus proxy"). + Flag(&fid, "id", command.StringFlag(""), + "Reverse-DNS style Application identifier, leave empty to inherit instance identifier"). + Flag(&aid, "a", command.IntFlag(0), + "Application identity"). + Flag(nil, "g", &groups, + "Groups inherited by all container processes"). + Flag(&homeDir, "d", command.StringFlag("os"), + "Container home directory"). + Flag(&userName, "u", command.StringFlag("chronos"), + "Passwd user name within sandbox"). + Flag(&wayland, "wayland", command.BoolFlag(false), + "Enable connection to Wayland via security-context-v1"). + Flag(&x11, "X", command.BoolFlag(false), + "Enable direct connection to X11"). + Flag(&dBus, "dbus", command.BoolFlag(false), + "Enable proxied connection to D-Bus"). + Flag(&pulse, "pulse", command.BoolFlag(false), + "Enable direct connection to PulseAudio") + } + + var showFlagShort bool + c.NewCommand("show", "Show live or local app configuration", func(args []string) error { + switch len(args) { + case 0: // system + printShowSystem(os.Stdout, showFlagShort, flagJSON) + + case 1: // instance + name := args[0] + config, entry := tryShort(name) + if config == nil { + config = tryPath(name) + } + printShowInstance(os.Stdout, time.Now().UTC(), entry, config, showFlagShort, flagJSON) + + default: + log.Fatal("show requires 1 argument") + } + return errSuccess + }).Flag(&showFlagShort, "short", command.BoolFlag(false), "Omit filesystem information") + + var psFlagShort bool + c.NewCommand("ps", "List active instances", func(args []string) error { + printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), psFlagShort, flagJSON) + return errSuccess + }).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id") + + c.Command("version", "Display version information", func(args []string) error { + fmt.Println(internal.Version()) + return errSuccess + }) + + c.Command("license", "Show full license text", func(args []string) error { + fmt.Println(license) + return errSuccess + }) + + c.Command("template", "Produce a config template", func(args []string) error { + printJSON(os.Stdout, false, hst.Template()) + return errSuccess + }) + + c.Command("help", "Show this help message", func([]string) error { + c.PrintHelp() + return errSuccess + }) + + return c +} + +func runApp(config *hst.Config) { + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer stop() // unreachable + a := instance.MustNew(instance.ISetuid, ctx, std) + + rs := new(app.RunState) + if sa, err := a.Seal(config); err != nil { + hlog.PrintBaseError(err, "cannot seal app:") + internal.Exit(1) + } else { + internal.Exit(instance.PrintRunStateErr(instance.ISetuid, rs, sa.Run(rs))) + } + + *(*int)(nil) = 0 // not reached +} diff --git a/cmd/hakurei/main_test.go b/cmd/hakurei/main_test.go new file mode 100644 index 00000000..7e2b792e --- /dev/null +++ b/cmd/hakurei/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "testing" + + "git.gensokyo.uk/security/hakurei/command" +) + +func TestHelp(t *testing.T) { + testCases := []struct { + name string + args []string + want string + }{ + { + "main", []string{}, ` +Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS] + +Commands: + app Load app from configuration file + run Configure and start a permissive default sandbox + show Show live or local app configuration + ps List active instances + version Display version information + license Show full license text + template Produce a config template + help Show this help message + +`, + }, + { + "run", []string{"run", "-h"}, ` +Usage: hakurei run [-h | --help] [--dbus-config ] [--dbus-system ] [--mpris] [--dbus-log] [--id ] [-a ] [-g ] [-d ] [-u ] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS] + +Flags: + -X Enable direct connection to X11 + -a int + Application identity + -d string + Container home directory (default "os") + -dbus + Enable proxied connection to D-Bus + -dbus-config string + Path to session bus proxy config file, or "builtin" for defaults (default "builtin") + -dbus-log + Force buffered logging in the D-Bus proxy + -dbus-system string + Path to system bus proxy config file, or "nil" to disable (default "nil") + -g value + Groups inherited by all container processes + -id string + Reverse-DNS style Application identifier, leave empty to inherit instance identifier + -mpris + Allow owning MPRIS D-Bus path, has no effect if custom config is available + -pulse + Enable direct connection to PulseAudio + -u string + Passwd user name within sandbox (default "chronos") + -wayland + Enable connection to Wayland via security-context-v1 + +`, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := new(bytes.Buffer) + c := buildCommand(out) + if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) { + t.Errorf("Parse: error = %v; want %v", + err, command.ErrHelp) + } + if got := out.String(); got != tc.want { + t.Errorf("Parse: %s want %s", got, tc.want) + } + }) + } +} diff --git a/cmd/hakurei/parse.go b/cmd/hakurei/parse.go new file mode 100644 index 00000000..aee71c25 --- /dev/null +++ b/cmd/hakurei/parse.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "log" + "os" + "strconv" + "strings" + "syscall" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +func tryPath(name string) (config *hst.Config) { + var r io.Reader + config = new(hst.Config) + + if name != "-" { + r = tryFd(name) + if r == nil { + hlog.Verbose("load configuration from file") + + if f, err := os.Open(name); err != nil { + log.Fatalf("cannot access configuration file %q: %s", name, err) + } else { + // finalizer closes f + r = f + } + } else { + defer func() { + if err := r.(io.ReadCloser).Close(); err != nil { + log.Printf("cannot close config fd: %v", err) + } + }() + } + } else { + r = os.Stdin + } + + if err := json.NewDecoder(r).Decode(&config); err != nil { + log.Fatalf("cannot load configuration: %v", err) + } + + return +} + +func tryFd(name string) io.ReadCloser { + if v, err := strconv.Atoi(name); err != nil { + if !errors.Is(err, strconv.ErrSyntax) { + hlog.Verbosef("name cannot be interpreted as int64: %v", err) + } + return nil + } else { + hlog.Verbosef("trying config stream from %d", v) + fd := uintptr(v) + if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 { + if errors.Is(errno, syscall.EBADF) { + return nil + } + log.Fatalf("cannot get fd %d: %v", fd, errno) + } + return os.NewFile(fd, strconv.Itoa(v)) + } +} + +func tryShort(name string) (config *hst.Config, entry *state.State) { + likePrefix := false + if len(name) <= 32 { + likePrefix = true + for _, c := range name { + if c >= '0' && c <= '9' { + continue + } + if c >= 'a' && c <= 'f' { + continue + } + likePrefix = false + break + } + } + + // try to match from state store + if likePrefix && len(name) >= 8 { + hlog.Verbose("argument looks like prefix") + + s := state.NewMulti(std.Paths().RunDirPath) + if entries, err := state.Join(s); err != nil { + log.Printf("cannot join store: %v", err) + // drop to fetch from file + } else { + for id := range entries { + v := id.String() + if strings.HasPrefix(v, name) { + // match, use config from this state entry + entry = entries[id] + config = entry.Config + break + } + + hlog.Verbosef("instance %s skipped", v) + } + } + } + + return +} diff --git a/cmd/hakurei/print.go b/cmd/hakurei/print.go new file mode 100644 index 00000000..5df2e0c6 --- /dev/null +++ b/cmd/hakurei/print.go @@ -0,0 +1,319 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +func printShowSystem(output io.Writer, short, flagJSON bool) { + t := newPrinter(output) + defer t.MustFlush() + + info := new(hst.Info) + + // get fid by querying uid of aid 0 + if uid, err := std.Uid(0); err != nil { + hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") + os.Exit(1) + } else { + info.User = (uid / 10000) - 100 + } + + if flagJSON { + printJSON(output, short, info) + return + } + + t.Printf("User:\t%d\n", info.User) +} + +func printShowInstance( + output io.Writer, now time.Time, + instance *state.State, config *hst.Config, + short, flagJSON bool) { + if flagJSON { + if instance != nil { + printJSON(output, short, instance) + } else { + printJSON(output, short, config) + } + return + } + + t := newPrinter(output) + defer t.MustFlush() + + if config.Container == nil { + mustPrint(output, "Warning: this configuration uses permissive defaults!\n\n") + } + + if instance != nil { + t.Printf("State\n") + t.Printf(" Instance:\t%s (%d)\n", instance.ID.String(), instance.PID) + t.Printf(" Uptime:\t%s\n", now.Sub(instance.Time).Round(time.Second).String()) + t.Printf("\n") + } + + t.Printf("App\n") + if config.ID != "" { + t.Printf(" Identity:\t%d (%s)\n", config.Identity, config.ID) + } else { + t.Printf(" Identity:\t%d\n", config.Identity) + } + t.Printf(" Enablements:\t%s\n", config.Enablements.String()) + if len(config.Groups) > 0 { + t.Printf(" Groups:\t%s\n", strings.Join(config.Groups, ", ")) + } + if config.Data != "" { + t.Printf(" Data:\t%s\n", config.Data) + } + if config.Container != nil { + container := config.Container + if container.Hostname != "" { + t.Printf(" Hostname:\t%s\n", container.Hostname) + } + flags := make([]string, 0, 7) + writeFlag := func(name string, value bool) { + if value { + flags = append(flags, name) + } + } + writeFlag("userns", container.Userns) + writeFlag("devel", container.Devel) + writeFlag("net", container.Net) + writeFlag("device", container.Device) + writeFlag("tty", container.Tty) + writeFlag("mapuid", container.MapRealUID) + writeFlag("directwl", config.DirectWayland) + writeFlag("autoetc", container.AutoEtc) + if len(flags) == 0 { + flags = append(flags, "none") + } + t.Printf(" Flags:\t%s\n", strings.Join(flags, " ")) + + etc := container.Etc + if etc == "" { + etc = "/etc" + } + t.Printf(" Etc:\t%s\n", etc) + + if len(container.Cover) > 0 { + t.Printf(" Cover:\t%s\n", strings.Join(container.Cover, " ")) + } + + t.Printf(" Path:\t%s\n", config.Path) + } + if len(config.Args) > 0 { + t.Printf(" Arguments:\t%s\n", strings.Join(config.Args, " ")) + } + t.Printf("\n") + + if !short { + if config.Container != nil && len(config.Container.Filesystem) > 0 { + t.Printf("Filesystem\n") + for _, f := range config.Container.Filesystem { + if f == nil { + continue + } + + expr := new(strings.Builder) + expr.Grow(3 + len(f.Src) + 1 + len(f.Dst)) + + if f.Device { + expr.WriteString(" d") + } else if f.Write { + expr.WriteString(" w") + } else { + expr.WriteString(" ") + } + if f.Must { + expr.WriteString("*") + } else { + expr.WriteString("+") + } + expr.WriteString(f.Src) + if f.Dst != "" { + expr.WriteString(":" + f.Dst) + } + t.Printf("%s\n", expr.String()) + } + t.Printf("\n") + } + if len(config.ExtraPerms) > 0 { + t.Printf("Extra ACL\n") + for _, p := range config.ExtraPerms { + if p == nil { + continue + } + t.Printf(" %s\n", p.String()) + } + t.Printf("\n") + } + } + + printDBus := func(c *dbus.Config) { + t.Printf(" Filter:\t%v\n", c.Filter) + if len(c.See) > 0 { + t.Printf(" See:\t%q\n", c.See) + } + if len(c.Talk) > 0 { + t.Printf(" Talk:\t%q\n", c.Talk) + } + if len(c.Own) > 0 { + t.Printf(" Own:\t%q\n", c.Own) + } + if len(c.Call) > 0 { + t.Printf(" Call:\t%q\n", c.Call) + } + if len(c.Broadcast) > 0 { + t.Printf(" Broadcast:\t%q\n", c.Broadcast) + } + } + if config.SessionBus != nil { + t.Printf("Session bus\n") + printDBus(config.SessionBus) + t.Printf("\n") + } + if config.SystemBus != nil { + t.Printf("System bus\n") + printDBus(config.SystemBus) + t.Printf("\n") + } +} + +func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) { + var entries state.Entries + if e, err := state.Join(s); err != nil { + log.Fatalf("cannot join store: %v", err) + } else { + entries = e + } + if err := s.Close(); err != nil { + log.Printf("cannot close store: %v", err) + } + + if !short && flagJSON { + es := make(map[string]*state.State, len(entries)) + for id, instance := range entries { + es[id.String()] = instance + } + printJSON(output, short, es) + return + } + + // sort state entries by id string to ensure consistency between runs + exp := make([]*expandedStateEntry, 0, len(entries)) + for id, instance := range entries { + // gracefully skip nil states + if instance == nil { + log.Printf("got invalid state entry %s", id.String()) + continue + } + + // gracefully skip inconsistent states + if id != instance.ID { + log.Printf("possible store corruption: entry %s has id %s", + id.String(), instance.ID.String()) + continue + } + exp = append(exp, &expandedStateEntry{s: id.String(), State: instance}) + } + slices.SortFunc(exp, func(a, b *expandedStateEntry) int { return a.Time.Compare(b.Time) }) + + if short { + if flagJSON { + v := make([]string, len(exp)) + for i, e := range exp { + v[i] = e.s + } + printJSON(output, short, v) + } else { + for _, e := range exp { + mustPrintln(output, e.s[:8]) + } + } + return + } + + t := newPrinter(output) + defer t.MustFlush() + + t.Println("\tInstance\tPID\tApplication\tUptime") + for _, e := range exp { + if len(e.s) != 1<<5 { + // unreachable + log.Printf("possible store corruption: invalid instance string %s", e.s) + continue + } + + as := "(No configuration information)" + if e.Config != nil { + as = strconv.Itoa(e.Config.Identity) + id := e.Config.ID + if id == "" { + id = "app.hakurei." + e.s[:8] + } + as += " (" + id + ")" + } + t.Printf("\t%s\t%d\t%s\t%s\n", + e.s[:8], e.PID, as, now.Sub(e.Time).Round(time.Second).String()) + } +} + +type expandedStateEntry struct { + s string + *state.State +} + +func printJSON(output io.Writer, short bool, v any) { + encoder := json.NewEncoder(output) + if !short { + encoder.SetIndent("", " ") + } + if err := encoder.Encode(v); err != nil { + log.Fatalf("cannot serialise: %v", err) + } +} + +func newPrinter(output io.Writer) *tp { return &tp{tabwriter.NewWriter(output, 0, 1, 4, ' ', 0)} } + +type tp struct{ *tabwriter.Writer } + +func (p *tp) Printf(format string, a ...any) { + if _, err := fmt.Fprintf(p, format, a...); err != nil { + log.Fatalf("cannot write to tabwriter: %v", err) + } +} +func (p *tp) Println(a ...any) { + if _, err := fmt.Fprintln(p, a...); err != nil { + log.Fatalf("cannot write to tabwriter: %v", err) + } +} +func (p *tp) MustFlush() { + if err := p.Writer.Flush(); err != nil { + log.Fatalf("cannot flush tabwriter: %v", err) + } +} +func mustPrint(output io.Writer, a ...any) { + if _, err := fmt.Fprint(output, a...); err != nil { + log.Fatalf("cannot print: %v", err) + } +} +func mustPrintln(output io.Writer, a ...any) { + if _, err := fmt.Fprintln(output, a...); err != nil { + log.Fatalf("cannot print: %v", err) + } +} diff --git a/cmd/hakurei/print_test.go b/cmd/hakurei/print_test.go new file mode 100644 index 00000000..d0cf1632 --- /dev/null +++ b/cmd/hakurei/print_test.go @@ -0,0 +1,644 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" +) + +var ( + testID = app.ID{ + 0x8e, 0x2c, 0x76, 0xb0, + 0x66, 0xda, 0xbe, 0x57, + 0x4c, 0xf0, 0x73, 0xbd, + 0xb4, 0x6e, 0xb5, 0xc1, + } + testState = &state.State{ + ID: testID, + PID: 0xDEADBEEF, + Config: hst.Template(), + Time: testAppTime, + } + testTime = time.Unix(3752, 1).UTC() + testAppTime = time.Unix(0, 9).UTC() +) + +func Test_printShowInstance(t *testing.T) { + testCases := []struct { + name string + instance *state.State + config *hst.Config + short, json bool + want string + }{ + {"config", nil, hst.Template(), false, false, `App + Identity: 9 (org.chromium.Chromium) + Enablements: wayland, dbus, pulseaudio + Groups: video, dialout, plugdev + Data: /var/lib/hakurei/u0/org.chromium.Chromium + Hostname: localhost + Flags: userns devel net device tty mapuid autoetc + Etc: /etc + Cover: /var/run/nscd + Path: /run/current-system/sw/bin/chromium + Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland + +Filesystem + +/nix/store + +/run/current-system + +/run/opengl-driver + +/var/db/nix-channels + w*/var/lib/hakurei/u0/org.chromium.Chromium:/data/data/org.chromium.Chromium + d+/dev/dri + +Extra ACL + --x+:/var/lib/hakurei/u0 + rwx:/var/lib/hakurei/u0/org.chromium.Chromium + +Session bus + Filter: true + Talk: ["org.freedesktop.Notifications" "org.freedesktop.FileManager1" "org.freedesktop.ScreenSaver" "org.freedesktop.secrets" "org.kde.kwalletd5" "org.kde.kwalletd6" "org.gnome.SessionManager"] + Own: ["org.chromium.Chromium.*" "org.mpris.MediaPlayer2.org.chromium.Chromium.*" "org.mpris.MediaPlayer2.chromium.*"] + Call: map["org.freedesktop.portal.*":"*"] + Broadcast: map["org.freedesktop.portal.*":"@/org/freedesktop/portal/*"] + +System bus + Filter: true + Talk: ["org.bluez" "org.freedesktop.Avahi" "org.freedesktop.UPower"] + +`}, + {"config pd", nil, new(hst.Config), false, false, `Warning: this configuration uses permissive defaults! + +App + Identity: 0 + Enablements: (no enablements) + +`}, + {"config flag none", nil, &hst.Config{Container: new(hst.ContainerConfig)}, false, false, `App + Identity: 0 + Enablements: (no enablements) + Flags: none + Etc: /etc + Path: + +`}, + {"config nil entries", nil, &hst.Config{Container: &hst.ContainerConfig{Filesystem: make([]*hst.FilesystemConfig, 1)}, ExtraPerms: make([]*hst.ExtraPermConfig, 1)}, false, false, `App + Identity: 0 + Enablements: (no enablements) + Flags: none + Etc: /etc + Path: + +Filesystem + +Extra ACL + +`}, + {"config pd dbus see", nil, &hst.Config{SessionBus: &dbus.Config{See: []string{"org.example.test"}}}, false, false, `Warning: this configuration uses permissive defaults! + +App + Identity: 0 + Enablements: (no enablements) + +Session bus + Filter: false + See: ["org.example.test"] + +`}, + + {"instance", testState, hst.Template(), false, false, `State + Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559) + Uptime: 1h2m32s + +App + Identity: 9 (org.chromium.Chromium) + Enablements: wayland, dbus, pulseaudio + Groups: video, dialout, plugdev + Data: /var/lib/hakurei/u0/org.chromium.Chromium + Hostname: localhost + Flags: userns devel net device tty mapuid autoetc + Etc: /etc + Cover: /var/run/nscd + Path: /run/current-system/sw/bin/chromium + Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland + +Filesystem + +/nix/store + +/run/current-system + +/run/opengl-driver + +/var/db/nix-channels + w*/var/lib/hakurei/u0/org.chromium.Chromium:/data/data/org.chromium.Chromium + d+/dev/dri + +Extra ACL + --x+:/var/lib/hakurei/u0 + rwx:/var/lib/hakurei/u0/org.chromium.Chromium + +Session bus + Filter: true + Talk: ["org.freedesktop.Notifications" "org.freedesktop.FileManager1" "org.freedesktop.ScreenSaver" "org.freedesktop.secrets" "org.kde.kwalletd5" "org.kde.kwalletd6" "org.gnome.SessionManager"] + Own: ["org.chromium.Chromium.*" "org.mpris.MediaPlayer2.org.chromium.Chromium.*" "org.mpris.MediaPlayer2.chromium.*"] + Call: map["org.freedesktop.portal.*":"*"] + Broadcast: map["org.freedesktop.portal.*":"@/org/freedesktop/portal/*"] + +System bus + Filter: true + Talk: ["org.bluez" "org.freedesktop.Avahi" "org.freedesktop.UPower"] + +`}, + {"instance pd", testState, new(hst.Config), false, false, `Warning: this configuration uses permissive defaults! + +State + Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559) + Uptime: 1h2m32s + +App + Identity: 0 + Enablements: (no enablements) + +`}, + + {"json nil", nil, nil, false, true, `null +`}, + {"json instance", testState, nil, false, true, `{ + "instance": [ + 142, + 44, + 118, + 176, + 102, + 218, + 190, + 87, + 76, + 240, + 115, + 189, + 180, + 110, + 181, + 193 + ], + "pid": 3735928559, + "config": { + "id": "org.chromium.Chromium", + "path": "/run/current-system/sw/bin/chromium", + "args": [ + "chromium", + "--ignore-gpu-blocklist", + "--disable-smooth-scrolling", + "--enable-features=UseOzonePlatform", + "--ozone-platform=wayland" + ], + "enablements": 13, + "session_bus": { + "see": null, + "talk": [ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager" + ], + "own": [ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*" + ], + "call": { + "org.freedesktop.portal.*": "*" + }, + "broadcast": { + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" + }, + "filter": true + }, + "system_bus": { + "see": null, + "talk": [ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower" + ], + "own": null, + "call": null, + "broadcast": null, + "filter": true + }, + "username": "chronos", + "shell": "/run/current-system/sw/bin/zsh", + "data": "/var/lib/hakurei/u0/org.chromium.Chromium", + "dir": "/data/data/org.chromium.Chromium", + "extra_perms": [ + { + "ensure": true, + "path": "/var/lib/hakurei/u0", + "x": true + }, + { + "path": "/var/lib/hakurei/u0/org.chromium.Chromium", + "r": true, + "w": true, + "x": true + } + ], + "identity": 9, + "groups": [ + "video", + "dialout", + "plugdev" + ], + "container": { + "hostname": "localhost", + "seccomp_flags": 1, + "seccomp_presets": 1, + "devel": true, + "userns": true, + "net": true, + "tty": true, + "multiarch": true, + "env": { + "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" + }, + "map_real_uid": true, + "device": true, + "filesystem": [ + { + "src": "/nix/store" + }, + { + "src": "/run/current-system" + }, + { + "src": "/run/opengl-driver" + }, + { + "src": "/var/db/nix-channels" + }, + { + "dst": "/data/data/org.chromium.Chromium", + "src": "/var/lib/hakurei/u0/org.chromium.Chromium", + "write": true, + "require": true + }, + { + "src": "/dev/dri", + "dev": true + } + ], + "symlink": [ + [ + "/run/user/65534", + "/run/user/150" + ] + ], + "etc": "/etc", + "auto_etc": true, + "cover": [ + "/var/run/nscd" + ] + } + }, + "time": "1970-01-01T00:00:00.000000009Z" +} +`}, + {"json config", nil, hst.Template(), false, true, `{ + "id": "org.chromium.Chromium", + "path": "/run/current-system/sw/bin/chromium", + "args": [ + "chromium", + "--ignore-gpu-blocklist", + "--disable-smooth-scrolling", + "--enable-features=UseOzonePlatform", + "--ozone-platform=wayland" + ], + "enablements": 13, + "session_bus": { + "see": null, + "talk": [ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager" + ], + "own": [ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*" + ], + "call": { + "org.freedesktop.portal.*": "*" + }, + "broadcast": { + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" + }, + "filter": true + }, + "system_bus": { + "see": null, + "talk": [ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower" + ], + "own": null, + "call": null, + "broadcast": null, + "filter": true + }, + "username": "chronos", + "shell": "/run/current-system/sw/bin/zsh", + "data": "/var/lib/hakurei/u0/org.chromium.Chromium", + "dir": "/data/data/org.chromium.Chromium", + "extra_perms": [ + { + "ensure": true, + "path": "/var/lib/hakurei/u0", + "x": true + }, + { + "path": "/var/lib/hakurei/u0/org.chromium.Chromium", + "r": true, + "w": true, + "x": true + } + ], + "identity": 9, + "groups": [ + "video", + "dialout", + "plugdev" + ], + "container": { + "hostname": "localhost", + "seccomp_flags": 1, + "seccomp_presets": 1, + "devel": true, + "userns": true, + "net": true, + "tty": true, + "multiarch": true, + "env": { + "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" + }, + "map_real_uid": true, + "device": true, + "filesystem": [ + { + "src": "/nix/store" + }, + { + "src": "/run/current-system" + }, + { + "src": "/run/opengl-driver" + }, + { + "src": "/var/db/nix-channels" + }, + { + "dst": "/data/data/org.chromium.Chromium", + "src": "/var/lib/hakurei/u0/org.chromium.Chromium", + "write": true, + "require": true + }, + { + "src": "/dev/dri", + "dev": true + } + ], + "symlink": [ + [ + "/run/user/65534", + "/run/user/150" + ] + ], + "etc": "/etc", + "auto_etc": true, + "cover": [ + "/var/run/nscd" + ] + } +} +`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := new(strings.Builder) + printShowInstance(output, testTime, tc.instance, tc.config, tc.short, tc.json) + if got := output.String(); got != tc.want { + t.Errorf("printShowInstance: got\n%s\nwant\n%s", + got, tc.want) + return + } + }) + } +} + +func Test_printPs(t *testing.T) { + testCases := []struct { + name string + entries state.Entries + short, json bool + want string + }{ + {"no entries", make(state.Entries), false, false, " Instance PID Application Uptime\n"}, + {"no entries short", make(state.Entries), true, false, ""}, + {"nil instance", state.Entries{testID: nil}, false, false, " Instance PID Application Uptime\n"}, + {"state corruption", state.Entries{app.ID{}: testState}, false, false, " Instance PID Application Uptime\n"}, + + {"valid pd", state.Entries{testID: &state.State{ID: testID, PID: 1 << 8, Config: new(hst.Config), Time: testAppTime}}, false, false, ` Instance PID Application Uptime + 8e2c76b0 256 0 (app.hakurei.8e2c76b0) 1h2m32s +`}, + + {"valid", state.Entries{testID: testState}, false, false, ` Instance PID Application Uptime + 8e2c76b0 3735928559 9 (org.chromium.Chromium) 1h2m32s +`}, + {"valid short", state.Entries{testID: testState}, true, false, "8e2c76b0\n"}, + {"valid json", state.Entries{testID: testState}, false, true, `{ + "8e2c76b066dabe574cf073bdb46eb5c1": { + "instance": [ + 142, + 44, + 118, + 176, + 102, + 218, + 190, + 87, + 76, + 240, + 115, + 189, + 180, + 110, + 181, + 193 + ], + "pid": 3735928559, + "config": { + "id": "org.chromium.Chromium", + "path": "/run/current-system/sw/bin/chromium", + "args": [ + "chromium", + "--ignore-gpu-blocklist", + "--disable-smooth-scrolling", + "--enable-features=UseOzonePlatform", + "--ozone-platform=wayland" + ], + "enablements": 13, + "session_bus": { + "see": null, + "talk": [ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager" + ], + "own": [ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*" + ], + "call": { + "org.freedesktop.portal.*": "*" + }, + "broadcast": { + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" + }, + "filter": true + }, + "system_bus": { + "see": null, + "talk": [ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower" + ], + "own": null, + "call": null, + "broadcast": null, + "filter": true + }, + "username": "chronos", + "shell": "/run/current-system/sw/bin/zsh", + "data": "/var/lib/hakurei/u0/org.chromium.Chromium", + "dir": "/data/data/org.chromium.Chromium", + "extra_perms": [ + { + "ensure": true, + "path": "/var/lib/hakurei/u0", + "x": true + }, + { + "path": "/var/lib/hakurei/u0/org.chromium.Chromium", + "r": true, + "w": true, + "x": true + } + ], + "identity": 9, + "groups": [ + "video", + "dialout", + "plugdev" + ], + "container": { + "hostname": "localhost", + "seccomp_flags": 1, + "seccomp_presets": 1, + "devel": true, + "userns": true, + "net": true, + "tty": true, + "multiarch": true, + "env": { + "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" + }, + "map_real_uid": true, + "device": true, + "filesystem": [ + { + "src": "/nix/store" + }, + { + "src": "/run/current-system" + }, + { + "src": "/run/opengl-driver" + }, + { + "src": "/var/db/nix-channels" + }, + { + "dst": "/data/data/org.chromium.Chromium", + "src": "/var/lib/hakurei/u0/org.chromium.Chromium", + "write": true, + "require": true + }, + { + "src": "/dev/dri", + "dev": true + } + ], + "symlink": [ + [ + "/run/user/65534", + "/run/user/150" + ] + ], + "etc": "/etc", + "auto_etc": true, + "cover": [ + "/var/run/nscd" + ] + } + }, + "time": "1970-01-01T00:00:00.000000009Z" + } +} +`}, + {"valid short json", state.Entries{testID: testState}, true, true, `["8e2c76b066dabe574cf073bdb46eb5c1"] +`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := new(strings.Builder) + printPs(output, testTime, stubStore(tc.entries), tc.short, tc.json) + if got := output.String(); got != tc.want { + t.Errorf("printPs: got\n%s\nwant\n%s", + got, tc.want) + return + } + }) + } +} + +// stubStore implements [state.Store] and returns test samples via [state.Joiner]. +type stubStore state.Entries + +func (s stubStore) Join() (state.Entries, error) { return state.Entries(s), nil } +func (s stubStore) Do(int, func(c state.Cursor)) (bool, error) { panic("unreachable") } +func (s stubStore) List() ([]int, error) { panic("unreachable") } +func (s stubStore) Close() error { return nil } diff --git a/cmd/planterette/main.go b/cmd/planterette/main.go index 8988d27a..a9f45d82 100644 --- a/cmd/planterette/main.go +++ b/cmd/planterette/main.go @@ -42,7 +42,7 @@ func main() { flagVerbose bool flagDropShell bool ) - c := command.New(os.Stderr, log.Printf, "planterette", func([]string) error { internal.InstallFmsg(flagVerbose); return nil }). + c := command.New(os.Stderr, log.Printf, "planterette", func([]string) error { internal.InstallOutput(flagVerbose); return nil }). Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next hakurei action") diff --git a/dbus/dbus_test.go b/dbus/dbus_test.go index 77a3e4f6..8965e0f4 100644 --- a/dbus/dbus_test.go +++ b/dbus/dbus_test.go @@ -209,5 +209,5 @@ func TestHelperInit(t *testing.T) { return } sandbox.SetOutput(hlog.Output{}) - sandbox.Init(hlog.Prepare, internal.InstallFmsg) + sandbox.Init(hlog.Prepare, internal.InstallOutput) } diff --git a/helper/container_test.go b/helper/container_test.go index c6341c68..e8ee7f32 100644 --- a/helper/container_test.go +++ b/helper/container_test.go @@ -53,5 +53,5 @@ func TestHelperInit(t *testing.T) { return } sandbox.SetOutput(hlog.Output{}) - sandbox.Init(hlog.Prepare, func(bool) { internal.InstallFmsg(false) }) + sandbox.Init(hlog.Prepare, func(bool) { internal.InstallOutput(false) }) } diff --git a/hst/paths.go b/hst/paths.go new file mode 100644 index 00000000..31c14df2 --- /dev/null +++ b/hst/paths.go @@ -0,0 +1,11 @@ +package hst + +// Paths contains environment-dependent paths used by hakurei. +type Paths struct { + // path to shared directory (usually `/tmp/hakurei.%d`) + SharePath string `json:"share_path"` + // XDG_RUNTIME_DIR value (usually `/run/user/%d`) + RuntimePath string `json:"runtime_path"` + // application runtime directory (usually `/run/user/%d/hakurei`) + RunDirPath string `json:"run_dir_path"` +} diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 902df10f..00000000 --- a/internal/app/app.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package app defines the generic [App] interface. -package app - -import ( - "syscall" - "time" - - "git.gensokyo.uk/security/hakurei/hst" -) - -type App interface { - // ID returns a copy of [ID] held by App. - ID() ID - - // Seal determines the outcome of config as a [SealedApp]. - // The value of config might be overwritten and must not be used again. - Seal(config *hst.Config) (SealedApp, error) - - String() string -} - -type SealedApp interface { - // Run commits sealed system setup and starts the app process. - Run(rs *RunState) error -} - -// RunState stores the outcome of a call to [SealedApp.Run]. -type RunState struct { - // Time is the exact point in time where the process was created. - // Location must be set to UTC. - // - // Time is nil if no process was ever created. - Time *time.Time - // RevertErr is stored by the deferred revert call. - RevertErr error - // WaitErr is the generic error value created by the standard library. - WaitErr error - - syscall.WaitStatus -} - -// SetStart stores the current time in [RunState] once. -func (rs *RunState) SetStart() { - if rs.Time != nil { - panic("attempted to store time twice") - } - now := time.Now().UTC() - rs.Time = &now -} - -// Paths contains environment-dependent paths used by hakurei. -type Paths struct { - // path to shared directory (usually `/tmp/hakurei.%d`) - SharePath string `json:"share_path"` - // XDG_RUNTIME_DIR value (usually `/run/user/%d`) - RuntimePath string `json:"runtime_path"` - // application runtime directory (usually `/run/user/%d/hakurei`) - RunDirPath string `json:"run_dir_path"` -} diff --git a/internal/app/id.go b/internal/app/id.go deleted file mode 100644 index e674c7dd..00000000 --- a/internal/app/id.go +++ /dev/null @@ -1,48 +0,0 @@ -package app - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" -) - -type ID [16]byte - -var ( - ErrInvalidLength = errors.New("string representation must have a length of 32") -) - -func (a *ID) String() string { - return hex.EncodeToString(a[:]) -} - -func NewAppID(id *ID) error { - _, err := rand.Read(id[:]) - return err -} - -func ParseAppID(id *ID, s string) error { - if len(s) != 32 { - return ErrInvalidLength - } - - for i, b := range s { - if b < '0' || b > 'f' { - return fmt.Errorf("invalid char %q at byte %d", b, i) - } - - v := uint8(b) - if v > '9' { - v = 10 + v - 'a' - } else { - v -= '0' - } - if i%2 == 0 { - v <<= 4 - } - id[i/2] += v - } - - return nil -} diff --git a/internal/app/id_test.go b/internal/app/id_test.go deleted file mode 100644 index f8acdabe..00000000 --- a/internal/app/id_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package app_test - -import ( - "errors" - "testing" - - . "git.gensokyo.uk/security/hakurei/internal/app" -) - -func TestParseAppID(t *testing.T) { - t.Run("bad length", func(t *testing.T) { - if err := ParseAppID(new(ID), "meow"); !errors.Is(err, ErrInvalidLength) { - t.Errorf("ParseAppID: error = %v, wantErr = %v", err, ErrInvalidLength) - } - }) - - t.Run("bad byte", func(t *testing.T) { - wantErr := "invalid char '\\n' at byte 15" - if err := ParseAppID(new(ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr { - t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr) - } - }) - - t.Run("fuzz 16 iterations", func(t *testing.T) { - for i := 0; i < 16; i++ { - testParseAppIDWithRandom(t) - } - }) -} - -func FuzzParseAppID(f *testing.F) { - for i := 0; i < 16; i++ { - id := new(ID) - if err := NewAppID(id); err != nil { - panic(err.Error()) - } - f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15]) - } - - f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) { - testParseAppID(t, &ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15}) - }) -} - -func testParseAppIDWithRandom(t *testing.T) { - id := new(ID) - if err := NewAppID(id); err != nil { - t.Fatalf("cannot generate app ID: %v", err) - } - testParseAppID(t, id) -} - -func testParseAppID(t *testing.T, id *ID) { - s := id.String() - got := new(ID) - if err := ParseAppID(got, s); err != nil { - t.Fatalf("cannot parse app ID: %v", err) - } - - if *got != *id { - t.Fatalf("ParseAppID(%#v) = \n%#v, want \n%#v", s, got, id) - } -} diff --git a/internal/app/instance/common/container.go b/internal/app/instance/common/container.go deleted file mode 100644 index d6342382..00000000 --- a/internal/app/instance/common/container.go +++ /dev/null @@ -1,192 +0,0 @@ -package common - -import ( - "errors" - "fmt" - "io/fs" - "maps" - "path" - "syscall" - - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" -) - -// in practice there should be less than 30 entries added by the runtime; -// allocating slightly more as a margin for future expansion -const preallocateOpsCount = 1 << 5 - -// NewContainer initialises [sandbox.Params] via [hst.ContainerConfig]. -// Note that remaining container setup must be queued by the caller. -func NewContainer(s *hst.ContainerConfig, os sys.State, uid, gid *int) (*sandbox.Params, map[string]string, error) { - if s == nil { - return nil, nil, syscall.EBADE - } - - container := &sandbox.Params{ - Hostname: s.Hostname, - SeccompFlags: s.SeccompFlags, - SeccompPresets: s.SeccompPresets, - RetainSession: s.Tty, - HostNet: s.Net, - } - - { - ops := make(sandbox.Ops, 0, preallocateOpsCount+len(s.Filesystem)+len(s.Link)+len(s.Cover)) - container.Ops = &ops - } - - if s.Multiarch { - container.SeccompFlags |= seccomp.AllowMultiarch - } - - if !s.SeccompCompat { - container.SeccompPresets |= seccomp.PresetExt - } - if !s.Devel { - container.SeccompPresets |= seccomp.PresetDenyDevel - } - if !s.Userns { - container.SeccompPresets |= seccomp.PresetDenyNS - } - if !s.Tty { - container.SeccompPresets |= seccomp.PresetDenyTTY - } - - if s.MapRealUID { - /* some programs fail to connect to dbus session running as a different uid - so this workaround is introduced to map priv-side caller uid in container */ - container.Uid = os.Getuid() - *uid = container.Uid - container.Gid = os.Getgid() - *gid = container.Gid - } else { - *uid = sandbox.OverflowUid() - *gid = sandbox.OverflowGid() - } - - container. - Proc("/proc"). - Tmpfs(hst.Tmp, 1<<12, 0755) - - if !s.Device { - container.Dev("/dev").Mqueue("/dev/mqueue") - } else { - container.Bind("/dev", "/dev", sandbox.BindWritable|sandbox.BindDevice) - } - - /* 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 - sc := os.Paths() - hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath) - _, 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 == "/" { - os.Printf("dbus socket %q is in an unusual location", pair[1]) - } - hidePaths = append(hidePaths, dir) - } else { - os.Printf("dbus socket %q is not absolute", pair[1]) - } - } - } - } - } - hidePathMatch := make([]bool, len(hidePaths)) - for i := range hidePaths { - if err := evalSymlinks(os, &hidePaths[i]); err != nil { - return nil, nil, err - } - } - - for _, c := range s.Filesystem { - if c == nil { - continue - } - - if !path.IsAbs(c.Src) { - return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src) - } - - dest := c.Dst - if c.Dst == "" { - dest = c.Src - } else if !path.IsAbs(dest) { - return nil, nil, fmt.Errorf("dst path %q is not absolute", dest) - } - - srcH := c.Src - if err := evalSymlinks(os, &srcH); err != nil { - return nil, nil, err - } - - for i := range hidePaths { - // skip matched entries - if hidePathMatch[i] { - continue - } - - if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil { - return nil, nil, err - } else if ok { - hidePathMatch[i] = true - os.Printf("hiding paths from %q", c.Src) - } - } - - var flags int - if c.Write { - flags |= sandbox.BindWritable - } - if c.Device { - flags |= sandbox.BindDevice | sandbox.BindWritable - } - if !c.Must { - flags |= sandbox.BindOptional - } - container.Bind(c.Src, dest, flags) - } - - // cover matched paths - for i, ok := range hidePathMatch { - if ok { - container.Tmpfs(hidePaths[i], 1<<13, 0755) - } - } - - for _, l := range s.Link { - container.Link(l[0], l[1]) - } - - return container, maps.Clone(s.Env), nil -} - -func evalSymlinks(os sys.State, v *string) error { - if p, err := os.EvalSymlinks(*v); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return err - } - os.Printf("path %q does not yet exist", *v) - } else { - *v = p - } - return nil -} diff --git a/internal/app/instance/common/path.go b/internal/app/instance/common/path.go deleted file mode 100644 index ce40f4f1..00000000 --- a/internal/app/instance/common/path.go +++ /dev/null @@ -1,11 +0,0 @@ -package common - -import ( - "path/filepath" - "strings" -) - -func deepContainsH(basepath, targpath string) (bool, error) { - rel, err := filepath.Rel(basepath, targpath) - return err == nil && rel != ".." && !strings.HasPrefix(rel, string([]byte{'.', '.', filepath.Separator})), err -} diff --git a/internal/app/instance/common/path_test.go b/internal/app/instance/common/path_test.go deleted file mode 100644 index b14f24df..00000000 --- a/internal/app/instance/common/path_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package common - -import ( - "testing" -) - -func TestDeepContainsH(t *testing.T) { - testCases := []struct { - name string - basepath string - targpath string - want bool - wantErr bool - }{ - { - name: "empty", - want: true, - }, - { - name: "equal abs", - basepath: "/run", - targpath: "/run", - want: true, - }, - { - name: "equal rel", - basepath: "./run", - targpath: "run", - want: true, - }, - { - name: "contains abs", - basepath: "/run", - targpath: "/run/dbus", - want: true, - }, - { - name: "inverse contains abs", - basepath: "/run/dbus", - targpath: "/run", - want: false, - }, - { - name: "contains rel", - basepath: "../run", - targpath: "../run/dbus", - want: true, - }, - { - name: "inverse contains rel", - basepath: "../run/dbus", - targpath: "../run", - want: false, - }, - { - name: "weird abs", - basepath: "/run/dbus", - targpath: "/run/dbus/../current-system", - want: false, - }, - { - name: "weird rel", - basepath: "../run/dbus", - targpath: "../run/dbus/../current-system", - want: false, - }, - - { - name: "invalid mix", - basepath: "/run", - targpath: "./run", - wantErr: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if got, err := deepContainsH(tc.basepath, tc.targpath); (err != nil) != tc.wantErr { - t.Errorf("deepContainsH() error = %v, wantErr %v", err, tc.wantErr) - } else if got != tc.want { - t.Errorf("deepContainsH() = %v, want %v", got, tc.want) - } - }) - } -} diff --git a/internal/app/instance/errors.go b/internal/app/instance/errors.go deleted file mode 100644 index 51d9cdbf..00000000 --- a/internal/app/instance/errors.go +++ /dev/null @@ -1,17 +0,0 @@ -package instance - -import ( - "syscall" - - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/internal/setuid" -) - -func PrintRunStateErr(whence int, rs *app.RunState, runErr error) (code int) { - switch whence { - case ISetuid: - return setuid.PrintRunStateErr(rs, runErr) - default: - panic(syscall.EINVAL) - } -} diff --git a/internal/app/instance/new.go b/internal/app/instance/new.go deleted file mode 100644 index d1a6230b..00000000 --- a/internal/app/instance/new.go +++ /dev/null @@ -1,33 +0,0 @@ -// Package instance exposes cross-package implementation details and provides constructors for builtin implementations. -package instance - -import ( - "context" - "log" - "syscall" - - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/internal/setuid" - "git.gensokyo.uk/security/hakurei/internal/sys" -) - -const ( - ISetuid = iota -) - -func New(whence int, ctx context.Context, os sys.State) (app.App, error) { - switch whence { - case ISetuid: - return setuid.New(ctx, os) - default: - return nil, syscall.EINVAL - } -} - -func MustNew(whence int, ctx context.Context, os sys.State) app.App { - a, err := New(whence, ctx, os) - if err != nil { - log.Fatalf("cannot create app: %v", err) - } - return a -} diff --git a/internal/app/instance/shim.go b/internal/app/instance/shim.go deleted file mode 100644 index 8147b7c5..00000000 --- a/internal/app/instance/shim.go +++ /dev/null @@ -1,6 +0,0 @@ -package instance - -import "git.gensokyo.uk/security/hakurei/internal/app/internal/setuid" - -// ShimMain is the main function of the shim process and runs as the unconstrained target user. -func ShimMain() { setuid.ShimMain() } diff --git a/internal/app/internal/setuid/app.go b/internal/app/internal/setuid/app.go deleted file mode 100644 index 92c90fb4..00000000 --- a/internal/app/internal/setuid/app.go +++ /dev/null @@ -1,74 +0,0 @@ -package setuid - -import ( - "context" - "fmt" - "sync" - - "git.gensokyo.uk/security/hakurei/hst" - . "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/sys" -) - -func New(ctx context.Context, os sys.State) (App, error) { - a := new(app) - a.sys = os - a.ctx = ctx - - id := new(ID) - err := NewAppID(id) - a.id = newID(id) - - return a, err -} - -type app struct { - id *stringPair[ID] - sys sys.State - ctx context.Context - - *outcome - mu sync.RWMutex -} - -func (a *app) ID() ID { a.mu.RLock(); defer a.mu.RUnlock(); return a.id.unwrap() } - -func (a *app) String() string { - if a == nil { - return "(invalid app)" - } - - a.mu.RLock() - defer a.mu.RUnlock() - - if a.outcome != nil { - if a.outcome.user.uid == nil { - return fmt.Sprintf("(sealed app %s with invalid uid)", a.id) - } - return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.outcome.user.uid) - } - - return fmt.Sprintf("(unsealed app %s)", a.id) -} - -func (a *app) Seal(config *hst.Config) (SealedApp, error) { - a.mu.Lock() - defer a.mu.Unlock() - - if a.outcome != nil { - panic("app sealed twice") - } - if config == nil { - return nil, hlog.WrapErr(ErrConfig, - "attempted to seal app with nil config") - } - - seal := new(outcome) - seal.id = a.id - err := seal.finalise(a.ctx, a.sys, config) - if err == nil { - a.outcome = seal - } - return seal, err -} diff --git a/internal/app/internal/setuid/app_nixos_test.go b/internal/app/internal/setuid/app_nixos_test.go deleted file mode 100644 index a516252e..00000000 --- a/internal/app/internal/setuid/app_nixos_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package setuid_test - -import ( - "git.gensokyo.uk/security/hakurei/acl" - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" - "git.gensokyo.uk/security/hakurei/system" -) - -var testCasesNixos = []sealTestCase{ - { - "nixos chromium direct wayland", new(stubNixOS), - &hst.Config{ - ID: "org.chromium.Chromium", - Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", - Enablements: system.EWayland | system.EDBus | system.EPulse, - - Container: &hst.ContainerConfig{ - Userns: true, Net: true, MapRealUID: true, Env: nil, AutoEtc: true, - Filesystem: []*hst.FilesystemConfig{ - {Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true}, - {Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true}, - {Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"}, - {Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true}, - }, - Cover: []string{"/var/run/nscd"}, - }, - SystemBus: &dbus.Config{ - Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"}, - Filter: true, - }, - SessionBus: &dbus.Config{ - Talk: []string{ - "org.freedesktop.FileManager1", "org.freedesktop.Notifications", - "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", - "org.kde.kwalletd5", "org.kde.kwalletd6", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{}, Broadcast: map[string]string{}, - Filter: true, - }, - DirectWayland: true, - - Username: "u0_a1", - Data: "/var/lib/persist/module/hakurei/0/1", - Identity: 1, Groups: []string{}, - }, - app.ID{ - 0x8e, 0x2c, 0x76, 0xb0, - 0x66, 0xda, 0xbe, 0x57, - 0x4c, 0xf0, 0x73, 0xbd, - 0xb4, 0x6e, 0xb5, 0xc1, - }, - system.New(1000001). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/1", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/1", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute). - Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). - Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset - UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute). - Ephemeral(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute). - Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"). - CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256). - Ephemeral(system.Process, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711). - MustProxyDBus("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{ - Talk: []string{ - "org.freedesktop.FileManager1", "org.freedesktop.Notifications", - "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", - "org.kde.kwalletd5", "org.kde.kwalletd6", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{}, Broadcast: map[string]string{}, - Filter: true, - }, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }). - UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write). - UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write), - &sandbox.Params{ - Uid: 1971, - Gid: 100, - Dir: "/var/lib/persist/module/hakurei/0/1", - Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", - Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", - "HOME=/var/lib/persist/module/hakurei/0/1", - "PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie", - "PULSE_SERVER=unix:/run/user/1971/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=u0_a1", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/1971", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=tty", - }, - Ops: new(sandbox.Ops). - Proc("/proc"). - Tmpfs(hst.Tmp, 4096, 0755). - Dev("/dev").Mqueue("/dev/mqueue"). - Bind("/bin", "/bin", 0). - Bind("/usr/bin", "/usr/bin", 0). - Bind("/nix/store", "/nix/store", 0). - Bind("/run/current-system", "/run/current-system", 0). - Bind("/sys/block", "/sys/block", sandbox.BindOptional). - Bind("/sys/bus", "/sys/bus", sandbox.BindOptional). - Bind("/sys/class", "/sys/class", sandbox.BindOptional). - Bind("/sys/dev", "/sys/dev", sandbox.BindOptional). - Bind("/sys/devices", "/sys/devices", sandbox.BindOptional). - Bind("/run/opengl-driver", "/run/opengl-driver", 0). - Bind("/dev/dri", "/dev/dri", sandbox.BindDevice|sandbox.BindWritable|sandbox.BindOptional). - Etc("/etc", "8e2c76b066dabe574cf073bdb46eb5c1"). - Tmpfs("/run/user", 4096, 0755). - Bind("/tmp/hakurei.1971/runtime/1", "/run/user/1971", sandbox.BindWritable). - Bind("/tmp/hakurei.1971/tmpdir/1", "/tmp", sandbox.BindWritable). - Bind("/var/lib/persist/module/hakurei/0/1", "/var/lib/persist/module/hakurei/0/1", sandbox.BindWritable). - Place("/etc/passwd", []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")). - Place("/etc/group", []byte("hakurei:x:100:\n")). - Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0", 0). - Bind("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native", 0). - Place(hst.Tmp+"/pulse-cookie", nil). - Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus", 0). - Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket", 0). - Tmpfs("/var/run/nscd", 8192, 0755), - SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyTTY | seccomp.PresetDenyDevel, - HostNet: true, - }, - }, -} diff --git a/internal/app/internal/setuid/app_pd_test.go b/internal/app/internal/setuid/app_pd_test.go deleted file mode 100644 index 75133012..00000000 --- a/internal/app/internal/setuid/app_pd_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package setuid_test - -import ( - "os" - - "git.gensokyo.uk/security/hakurei/acl" - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" - "git.gensokyo.uk/security/hakurei/system" -) - -var testCasesPd = []sealTestCase{ - { - "nixos permissive defaults no enablements", new(stubNixOS), - &hst.Config{Username: "chronos", Data: "/home/chronos"}, - app.ID{ - 0x4a, 0x45, 0x0b, 0x65, - 0x96, 0xd7, 0xbc, 0x15, - 0xbd, 0x01, 0x78, 0x0e, - 0xb9, 0xa6, 0x07, 0xac, - }, - system.New(1000000). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/0", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/0", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute), - &sandbox.Params{ - Dir: "/home/chronos", - Path: "/run/current-system/sw/bin/zsh", - Args: []string{"/run/current-system/sw/bin/zsh"}, - Env: []string{ - "HOME=/home/chronos", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "XDG_RUNTIME_DIR=/run/user/65534", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=tty", - }, - Ops: new(sandbox.Ops). - Proc("/proc"). - Tmpfs(hst.Tmp, 4096, 0755). - Dev("/dev").Mqueue("/dev/mqueue"). - Bind("/bin", "/bin", sandbox.BindWritable). - Bind("/boot", "/boot", sandbox.BindWritable). - Bind("/home", "/home", sandbox.BindWritable). - Bind("/lib", "/lib", sandbox.BindWritable). - Bind("/lib64", "/lib64", sandbox.BindWritable). - Bind("/nix", "/nix", sandbox.BindWritable). - Bind("/root", "/root", sandbox.BindWritable). - Bind("/run", "/run", sandbox.BindWritable). - Bind("/srv", "/srv", sandbox.BindWritable). - Bind("/sys", "/sys", sandbox.BindWritable). - Bind("/usr", "/usr", sandbox.BindWritable). - Bind("/var", "/var", sandbox.BindWritable). - Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). - Tmpfs("/run/user/1971", 8192, 0755). - Tmpfs("/run/dbus", 8192, 0755). - Etc("/etc", "4a450b6596d7bc15bd01780eb9a607ac"). - Tmpfs("/run/user", 4096, 0755). - Bind("/tmp/hakurei.1971/runtime/0", "/run/user/65534", sandbox.BindWritable). - Bind("/tmp/hakurei.1971/tmpdir/0", "/tmp", sandbox.BindWritable). - Bind("/home/chronos", "/home/chronos", sandbox.BindWritable). - Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). - Place("/etc/group", []byte("hakurei:x:65534:\n")). - Tmpfs("/var/run/nscd", 8192, 0755), - SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyDevel, - HostNet: true, - RetainSession: true, - }, - }, - { - "nixos permissive defaults chromium", new(stubNixOS), - &hst.Config{ - ID: "org.chromium.Chromium", - Args: []string{"zsh", "-c", "exec chromium "}, - Identity: 9, - Groups: []string{"video"}, - Username: "chronos", - Data: "/home/chronos", - SessionBus: &dbus.Config{ - Talk: []string{ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{ - "org.freedesktop.portal.*": "*", - }, - Broadcast: map[string]string{ - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", - }, - Filter: true, - }, - SystemBus: &dbus.Config{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }, - Enablements: system.EWayland | system.EDBus | system.EPulse, - }, - app.ID{ - 0xeb, 0xf0, 0x83, 0xd1, - 0xb1, 0x75, 0x91, 0x17, - 0x82, 0xd4, 0x13, 0x36, - 0x9b, 0x64, 0xce, 0x7c, - }, - system.New(1000009). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/9", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/9", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute). - Ephemeral(system.Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c", 0711). - Wayland(new(*os.File), "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"). - Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). - Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/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, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", acl.Execute). - Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"). - CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256). - MustProxyDBus("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{ - Talk: []string{ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{ - "org.freedesktop.portal.*": "*", - }, - Broadcast: map[string]string{ - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", - }, - Filter: true, - }, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }). - UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write). - UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write), - &sandbox.Params{ - Dir: "/home/chronos", - Path: "/run/current-system/sw/bin/zsh", - Args: []string{"zsh", "-c", "exec chromium "}, - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", - "HOME=/home/chronos", - "PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie", - "PULSE_SERVER=unix:/run/user/65534/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/65534", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=tty", - }, - Ops: new(sandbox.Ops). - Proc("/proc"). - Tmpfs(hst.Tmp, 4096, 0755). - Dev("/dev").Mqueue("/dev/mqueue"). - Bind("/bin", "/bin", sandbox.BindWritable). - Bind("/boot", "/boot", sandbox.BindWritable). - Bind("/home", "/home", sandbox.BindWritable). - Bind("/lib", "/lib", sandbox.BindWritable). - Bind("/lib64", "/lib64", sandbox.BindWritable). - Bind("/nix", "/nix", sandbox.BindWritable). - Bind("/root", "/root", sandbox.BindWritable). - Bind("/run", "/run", sandbox.BindWritable). - Bind("/srv", "/srv", sandbox.BindWritable). - Bind("/sys", "/sys", sandbox.BindWritable). - Bind("/usr", "/usr", sandbox.BindWritable). - Bind("/var", "/var", sandbox.BindWritable). - Bind("/dev/dri", "/dev/dri", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). - Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). - Tmpfs("/run/user/1971", 8192, 0755). - Tmpfs("/run/dbus", 8192, 0755). - Etc("/etc", "ebf083d1b175911782d413369b64ce7c"). - Tmpfs("/run/user", 4096, 0755). - Bind("/tmp/hakurei.1971/runtime/9", "/run/user/65534", sandbox.BindWritable). - Bind("/tmp/hakurei.1971/tmpdir/9", "/tmp", sandbox.BindWritable). - Bind("/home/chronos", "/home/chronos", sandbox.BindWritable). - Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). - Place("/etc/group", []byte("hakurei:x:65534:\n")). - Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0", 0). - Bind("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0). - Place(hst.Tmp+"/pulse-cookie", nil). - Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0). - Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket", 0). - Tmpfs("/var/run/nscd", 8192, 0755), - SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyDevel, - HostNet: true, - RetainSession: true, - }, - }, -} diff --git a/internal/app/internal/setuid/app_stub_test.go b/internal/app/internal/setuid/app_stub_test.go deleted file mode 100644 index 690948fa..00000000 --- a/internal/app/internal/setuid/app_stub_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package setuid_test - -import ( - "fmt" - "io/fs" - "log" - "os/user" - "strconv" - - "git.gensokyo.uk/security/hakurei/internal/app" -) - -// fs methods are not implemented using a real FS -// to help better understand filesystem access behaviour -type stubNixOS struct { - lookPathErr map[string]error - usernameErr map[string]error -} - -func (s *stubNixOS) Getuid() int { return 1971 } -func (s *stubNixOS) Getgid() int { return 100 } -func (s *stubNixOS) TempDir() string { return "/tmp" } -func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/hakurei" } -func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) } -func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil } -func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil } - -func (s *stubNixOS) Println(v ...any) { log.Println(v...) } -func (s *stubNixOS) Printf(format string, v ...any) { log.Printf(format, v...) } - -func (s *stubNixOS) LookupEnv(key string) (string, bool) { - switch key { - case "SHELL": - return "/run/current-system/sw/bin/zsh", true - case "TERM": - return "xterm-256color", true - case "WAYLAND_DISPLAY": - return "wayland-0", true - case "PULSE_COOKIE": - return "", false - case "HOME": - return "/home/ophestra", true - case "XDG_CONFIG_HOME": - return "/home/ophestra/xdg/config", true - default: - panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key)) - } -} - -func (s *stubNixOS) LookPath(file string) (string, error) { - if s.lookPathErr != nil { - if err, ok := s.lookPathErr[file]; ok { - return "", err - } - } - - switch file { - case "zsh": - return "/run/current-system/sw/bin/zsh", nil - default: - panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) - } -} - -func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) { - switch name { - case "video": - return &user.Group{Gid: "26", Name: "video"}, nil - default: - return nil, user.UnknownGroupError(name) - } -} - -func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) { - switch name { - case "/": - return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib", - "lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") - case "/run": - return stubDirEntries("agetty.reload", "binfmt", "booted-system", - "credentials", "cryptsetup", "current-system", "dbus", "host", "keys", - "libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager", - "nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm", - "store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2", - "user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state") - case "/etc": - return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default", - "ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid", - "hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd", - "libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm", - "machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc", - "netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf", - "opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1", - "profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba", - "sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh", - "ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo", - "tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc", - "zoneinfo", "zprofile", "zshenv", "zshrc") - default: - panic(fmt.Sprintf("attempted to read unexpected directory %q", name)) - } -} - -func (s *stubNixOS) Stat(name string) (fs.FileInfo, error) { - switch name { - case "/var/run/nscd": - return nil, nil - case "/run/user/1971/pulse": - return nil, nil - case "/run/user/1971/pulse/native": - return stubFileInfoMode(0666), nil - case "/home/ophestra/.pulse-cookie": - return stubFileInfoIsDir(true), nil - case "/home/ophestra/xdg/config/pulse/cookie": - return stubFileInfoIsDir(false), nil - default: - panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) - } -} - -func (s *stubNixOS) Open(name string) (fs.File, error) { - switch name { - default: - panic(fmt.Sprintf("attempted to open unexpected file %q", name)) - } -} - -func (s *stubNixOS) Paths() app.Paths { - return app.Paths{ - SharePath: "/tmp/hakurei.1971", - RuntimePath: "/run/user/1971", - RunDirPath: "/run/user/1971/hakurei", - } -} diff --git a/internal/app/internal/setuid/app_test.go b/internal/app/internal/setuid/app_test.go deleted file mode 100644 index 82df7c30..00000000 --- a/internal/app/internal/setuid/app_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package setuid_test - -import ( - "encoding/json" - "io/fs" - "reflect" - "testing" - "time" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/internal/setuid" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/system" -) - -type sealTestCase struct { - name string - os sys.State - config *hst.Config - id app.ID - wantSys *system.I - wantContainer *sandbox.Params -} - -func TestApp(t *testing.T) { - testCases := append(testCasesPd, testCasesNixos...) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - a := setuid.NewWithID(tc.id, tc.os) - var ( - gotSys *system.I - gotContainer *sandbox.Params - ) - if !t.Run("seal", func(t *testing.T) { - if sa, err := a.Seal(tc.config); err != nil { - t.Errorf("Seal: error = %v", err) - return - } else { - gotSys, gotContainer = setuid.AppIParams(a, sa) - } - }) { - return - } - - t.Run("compare sys", func(t *testing.T) { - if !gotSys.Equal(tc.wantSys) { - t.Errorf("Seal: sys = %#v, want %#v", - gotSys, tc.wantSys) - } - }) - - t.Run("compare params", func(t *testing.T) { - if !reflect.DeepEqual(gotContainer, tc.wantContainer) { - t.Errorf("seal: params =\n%s\n, want\n%s", - mustMarshal(gotContainer), mustMarshal(tc.wantContainer)) - } - }) - }) - } -} - -func mustMarshal(v any) string { - if b, err := json.Marshal(v); err != nil { - panic(err.Error()) - } else { - return string(b) - } -} - -func stubDirEntries(names ...string) (e []fs.DirEntry, err error) { - e = make([]fs.DirEntry, len(names)) - for i, name := range names { - e[i] = stubDirEntryPath(name) - } - return -} - -type stubDirEntryPath string - -func (p stubDirEntryPath) Name() string { return string(p) } -func (p stubDirEntryPath) IsDir() bool { panic("attempted to call IsDir") } -func (p stubDirEntryPath) Type() fs.FileMode { panic("attempted to call Type") } -func (p stubDirEntryPath) Info() (fs.FileInfo, error) { panic("attempted to call Info") } - -type stubFileInfoMode fs.FileMode - -func (s stubFileInfoMode) Name() string { panic("attempted to call Name") } -func (s stubFileInfoMode) Size() int64 { panic("attempted to call Size") } -func (s stubFileInfoMode) Mode() fs.FileMode { return fs.FileMode(s) } -func (s stubFileInfoMode) ModTime() time.Time { panic("attempted to call ModTime") } -func (s stubFileInfoMode) IsDir() bool { panic("attempted to call IsDir") } -func (s stubFileInfoMode) Sys() any { panic("attempted to call Sys") } - -type stubFileInfoIsDir bool - -func (s stubFileInfoIsDir) Name() string { panic("attempted to call Name") } -func (s stubFileInfoIsDir) Size() int64 { panic("attempted to call Size") } -func (s stubFileInfoIsDir) Mode() fs.FileMode { panic("attempted to call Mode") } -func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTime") } -func (s stubFileInfoIsDir) IsDir() bool { return bool(s) } -func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") } diff --git a/internal/app/internal/setuid/errors.go b/internal/app/internal/setuid/errors.go deleted file mode 100644 index cce96c4a..00000000 --- a/internal/app/internal/setuid/errors.go +++ /dev/null @@ -1,182 +0,0 @@ -package setuid - -import ( - "errors" - "log" - - . "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/hlog" -) - -func PrintRunStateErr(rs *RunState, runErr error) (code int) { - code = rs.ExitStatus() - - if runErr != nil { - if rs.Time == nil { - hlog.PrintBaseError(runErr, "cannot start app:") - } else { - var e *hlog.BaseError - if !hlog.AsBaseError(runErr, &e) { - log.Println("wait failed:", runErr) - } else { - // Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError - var se *StateStoreError - if !errors.As(runErr, &se) { - // does not need special handling - log.Print(e.Message()) - } else { - // inner error are either unwrapped store errors - // or joined errors returned by *appSealTx revert - // wrapped in *app.BaseError - var ej RevertCompoundError - if !errors.As(se.InnerErr, &ej) { - // does not require special handling - log.Print(e.Message()) - } else { - errs := ej.Unwrap() - - // every error here is wrapped in *app.BaseError - for _, ei := range errs { - var eb *hlog.BaseError - if !errors.As(ei, &eb) { - // unreachable - log.Println("invalid error type returned by revert:", ei) - } else { - // print inner *app.BaseError message - log.Print(eb.Message()) - } - } - } - } - } - } - - if code == 0 { - code = 126 - } - } - - if rs.RevertErr != nil { - var stateStoreError *StateStoreError - if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil { - hlog.PrintBaseError(rs.RevertErr, "generic fault during cleanup:") - goto out - } - - if stateStoreError.Err != nil { - if len(stateStoreError.Err) == 2 { - if stateStoreError.Err[0] != nil { - if joinedErrs, ok := stateStoreError.Err[0].(interface{ Unwrap() []error }); !ok { - hlog.PrintBaseError(stateStoreError.Err[0], "generic fault during revert:") - } else { - for _, err := range joinedErrs.Unwrap() { - if err != nil { - hlog.PrintBaseError(err, "fault during revert:") - } - } - } - } - if stateStoreError.Err[1] != nil { - log.Printf("cannot close store: %v", stateStoreError.Err[1]) - } - } else { - log.Printf("fault during cleanup: %v", - errors.Join(stateStoreError.Err...)) - } - } - - if stateStoreError.OpErr != nil { - log.Printf("blind revert due to store fault: %v", - stateStoreError.OpErr) - } - - if stateStoreError.DoErr != nil { - hlog.PrintBaseError(stateStoreError.DoErr, "state store operation unsuccessful:") - } - - if stateStoreError.Inner && stateStoreError.InnerErr != nil { - hlog.PrintBaseError(stateStoreError.InnerErr, "cannot destroy state entry:") - } - - out: - if code == 0 { - code = 128 - } - } - if rs.WaitErr != nil { - hlog.Verbosef("wait: %v", rs.WaitErr) - } - return -} - -// StateStoreError is returned for a failed state save -type StateStoreError struct { - // whether inner function was called - Inner bool - // returned by the Save/Destroy method of [state.Cursor] - InnerErr error - // returned by the Do method of [state.Store] - DoErr error - // stores an arbitrary store operation error - OpErr error - // stores arbitrary errors - Err []error -} - -// save saves arbitrary errors in [StateStoreError] once. -func (e *StateStoreError) save(errs ...error) { - if len(errs) == 0 || e.Err != nil { - panic("invalid call to save") - } - e.Err = errs -} - -func (e *StateStoreError) equiv(a ...any) error { - if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Err...) == nil { - return nil - } else { - return hlog.WrapErrSuffix(e, a...) - } -} - -func (e *StateStoreError) Error() string { - if e.Inner && e.InnerErr != nil { - return e.InnerErr.Error() - } - if e.DoErr != nil { - return e.DoErr.Error() - } - if e.OpErr != nil { - return e.OpErr.Error() - } - if err := errors.Join(e.Err...); err != nil { - return err.Error() - } - - // equiv nullifies e for values where this is reached - panic("unreachable") -} - -func (e *StateStoreError) Unwrap() (errs []error) { - errs = make([]error, 0, 3) - if e.InnerErr != nil { - errs = append(errs, e.InnerErr) - } - if e.DoErr != nil { - errs = append(errs, e.DoErr) - } - if e.OpErr != nil { - errs = append(errs, e.OpErr) - } - if err := errors.Join(e.Err...); err != nil { - errs = append(errs, err) - } - return -} - -// A RevertCompoundError encapsulates errors returned by -// the Revert method of [system.I]. -type RevertCompoundError interface { - Error() string - Unwrap() []error -} diff --git a/internal/app/internal/setuid/export_test.go b/internal/app/internal/setuid/export_test.go deleted file mode 100644 index 1a7f9704..00000000 --- a/internal/app/internal/setuid/export_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package setuid - -import ( - . "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/system" -) - -func NewWithID(id ID, os sys.State) App { - a := new(app) - a.id = newID(&id) - a.sys = os - return a -} - -func AppIParams(a App, sa SealedApp) (*system.I, *sandbox.Params) { - v := a.(*app) - seal := sa.(*outcome) - if v.outcome != seal || v.id != seal.id { - panic("broken app/outcome link") - } - return seal.sys, seal.container -} diff --git a/internal/app/internal/setuid/process.go b/internal/app/internal/setuid/process.go deleted file mode 100644 index 8779d65f..00000000 --- a/internal/app/internal/setuid/process.go +++ /dev/null @@ -1,195 +0,0 @@ -package setuid - -import ( - "context" - "encoding/gob" - "errors" - "log" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - "time" - - "git.gensokyo.uk/security/hakurei/internal" - . "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/state" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/system" -) - -const shimWaitTimeout = 5 * time.Second - -func (seal *outcome) Run(rs *RunState) error { - if !seal.f.CompareAndSwap(false, true) { - // run does much more than just starting a process; calling it twice, even if the first call fails, will result - // in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the - // other Run a chance to return - return errors.New("outcome: attempted to run twice") - } - - if rs == nil { - panic("invalid state") - } - - // read comp value early to allow for early failure - hsuPath := internal.MustHsuPath() - - if err := seal.sys.Commit(seal.ctx); err != nil { - return err - } - store := state.NewMulti(seal.runDirPath) - deferredStoreFunc := func(c state.Cursor) error { return nil } // noop until state in store - defer func() { - var revertErr error - storeErr := new(StateStoreError) - storeErr.Inner, storeErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { - revertErr = func() error { - storeErr.InnerErr = deferredStoreFunc(c) - - var rt system.Enablement - ec := system.Process - if states, err := c.Load(); err != nil { - // revert per-process state here to limit damage - storeErr.OpErr = err - return seal.sys.Revert((*system.Criteria)(&ec)) - } else { - if l := len(states); l == 0 { - ec |= system.User - } else { - hlog.Verbosef("found %d instances, cleaning up without user-scoped operations", l) - } - - // accumulate enablements of remaining launchers - for i, s := range states { - if s.Config != nil { - rt |= s.Config.Enablements - } else { - log.Printf("state entry %d does not contain config", i) - } - } - } - ec |= rt ^ (system.EWayland | system.EX11 | system.EDBus | system.EPulse) - if hlog.Load() { - if ec > 0 { - hlog.Verbose("reverting operations scope", system.TypeString(ec)) - } - } - - return seal.sys.Revert((*system.Criteria)(&ec)) - }() - }) - storeErr.save(revertErr, store.Close()) - rs.RevertErr = storeErr.equiv("error during cleanup:") - }() - - ctx, cancel := context.WithCancel(seal.ctx) - defer cancel() - cmd := exec.CommandContext(ctx, hsuPath) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - cmd.Dir = "/" // container init enters final working directory - // shim runs in the same session as monitor; see shim.go for behaviour - cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGCONT) } - - var e *gob.Encoder - if fd, encoder, err := sandbox.Setup(&cmd.ExtraFiles); err != nil { - return hlog.WrapErrSuffix(err, - "cannot create shim setup pipe:") - } else { - e = encoder - cmd.Env = []string{ - // passed through to shim by hsu - shimEnv + "=" + strconv.Itoa(fd), - // interpreted by hsu - "HAKUREI_APP_ID=" + seal.user.aid.String(), - } - } - - if len(seal.user.supp) > 0 { - hlog.Verbosef("attaching supplementary group ids %s", seal.user.supp) - // interpreted by hsu - cmd.Env = append(cmd.Env, "HAKUREI_GROUPS="+strings.Join(seal.user.supp, " ")) - } - - hlog.Verbosef("setuid helper at %s", hsuPath) - hlog.Suspend() - if err := cmd.Start(); err != nil { - return hlog.WrapErrSuffix(err, - "cannot start setuid wrapper:") - } - rs.SetStart() - - // this prevents blocking forever on an early failure - waitErr, setupErr := make(chan error, 1), make(chan error, 1) - go func() { waitErr <- cmd.Wait(); cancel() }() - go func() { setupErr <- e.Encode(&shimParams{os.Getpid(), seal.container, seal.user.data, hlog.Load()}) }() - - select { - case err := <-setupErr: - if err != nil { - hlog.Resume() - return hlog.WrapErrSuffix(err, - "cannot transmit shim config:") - } - - case <-ctx.Done(): - hlog.Resume() - return hlog.WrapErr(syscall.ECANCELED, - "shim setup canceled") - } - - // returned after blocking on waitErr - var earlyStoreErr = new(StateStoreError) - { - // shim accepted setup payload, create process state - sd := state.State{ - ID: seal.id.unwrap(), - PID: cmd.Process.Pid, - Time: *rs.Time, - } - earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { - earlyStoreErr.InnerErr = c.Save(&sd, seal.ct) - }) - } - - // state in store at this point, destroy defunct state entry on return - deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) } - - waitTimeout := make(chan struct{}) - go func() { <-seal.ctx.Done(); time.Sleep(shimWaitTimeout); close(waitTimeout) }() - - select { - case rs.WaitErr = <-waitErr: - rs.WaitStatus = cmd.ProcessState.Sys().(syscall.WaitStatus) - if hlog.Load() { - switch { - case rs.Exited(): - hlog.Verbosef("process %d exited with code %d", cmd.Process.Pid, rs.ExitStatus()) - case rs.CoreDump(): - hlog.Verbosef("process %d dumped core", cmd.Process.Pid) - case rs.Signaled(): - hlog.Verbosef("process %d got %s", cmd.Process.Pid, rs.Signal()) - default: - hlog.Verbosef("process %d exited with status %#x", cmd.Process.Pid, rs.WaitStatus) - } - } - case <-waitTimeout: - rs.WaitErr = syscall.ETIMEDOUT - hlog.Resume() - log.Printf("process %d did not terminate", cmd.Process.Pid) - } - - hlog.Resume() - if seal.sync != nil { - if err := seal.sync.Close(); err != nil { - log.Printf("cannot close wayland security context: %v", err) - } - } - if seal.dbusMsg != nil { - seal.dbusMsg() - } - - return earlyStoreErr.equiv("cannot save process state:") -} diff --git a/internal/app/internal/setuid/seal.go b/internal/app/internal/setuid/seal.go deleted file mode 100644 index 69b242a8..00000000 --- a/internal/app/internal/setuid/seal.go +++ /dev/null @@ -1,586 +0,0 @@ -package setuid - -import ( - "bytes" - "context" - "encoding/gob" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "regexp" - "slices" - "strings" - "sync/atomic" - "syscall" - - "git.gensokyo.uk/security/hakurei/acl" - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal" - . "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/instance/common" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/sandbox/wl" - "git.gensokyo.uk/security/hakurei/system" -) - -const ( - home = "HOME" - shell = "SHELL" - - xdgConfigHome = "XDG_CONFIG_HOME" - xdgRuntimeDir = "XDG_RUNTIME_DIR" - xdgSessionClass = "XDG_SESSION_CLASS" - xdgSessionType = "XDG_SESSION_TYPE" - - term = "TERM" - display = "DISPLAY" - - pulseServer = "PULSE_SERVER" - pulseCookie = "PULSE_COOKIE" - - dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" - dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" -) - -var ( - ErrConfig = errors.New("no configuration to seal") - ErrUser = errors.New("invalid aid") - ErrHome = errors.New("invalid home directory") - ErrName = errors.New("invalid username") - - ErrXDisplay = errors.New(display + " unset") - - ErrPulseCookie = errors.New("pulse cookie not present") - ErrPulseSocket = errors.New("pulse socket not present") - ErrPulseMode = errors.New("unexpected pulse socket mode") -) - -var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$") - -// outcome stores copies of various parts of [hst.Config] -type outcome struct { - // copied from initialising [app] - id *stringPair[ID] - // copied from [sys.State] response - runDirPath string - - // initial [hst.Config] gob stream for state data; - // this is prepared ahead of time as config is clobbered during seal creation - ct io.WriterTo - // dump dbus proxy message buffer - dbusMsg func() - - user hsuUser - sys *system.I - ctx context.Context - - container *sandbox.Params - env map[string]string - sync *os.File - - f atomic.Bool -} - -// shareHost holds optional share directory state that must not be accessed directly -type shareHost struct { - // whether XDG_RUNTIME_DIR is used post hsu - useRuntimeDir bool - // process-specific directory in tmpdir, empty if unused - sharePath string - // process-specific directory in XDG_RUNTIME_DIR, empty if unused - runtimeSharePath string - - seal *outcome - sc Paths -} - -// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required -func (share *shareHost) ensureRuntimeDir() { - if share.useRuntimeDir { - return - } - share.useRuntimeDir = true - share.seal.sys.Ensure(share.sc.RunDirPath, 0700) - share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath, acl.Execute) - share.seal.sys.Ensure(share.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset - share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath, acl.Execute) -} - -// instance returns a process-specific share path within tmpdir -func (share *shareHost) instance() string { - if share.sharePath != "" { - return share.sharePath - } - share.sharePath = path.Join(share.sc.SharePath, share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.sharePath, 0711) - return share.sharePath -} - -// runtime returns a process-specific share path within XDG_RUNTIME_DIR -func (share *shareHost) runtime() string { - if share.runtimeSharePath != "" { - return share.runtimeSharePath - } - share.ensureRuntimeDir() - share.runtimeSharePath = path.Join(share.sc.RunDirPath, share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath, 0700) - share.seal.sys.UpdatePerm(share.runtimeSharePath, acl.Execute) - return share.runtimeSharePath -} - -// hsuUser stores post-hsu credentials and metadata -type hsuUser struct { - // application id - aid *stringPair[int] - // target uid resolved by fid:aid - uid *stringPair[int] - - // supplementary group ids - supp []string - - // home directory host path - data string - // app user home directory - home string - // passwd database username - username string -} - -func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Config) error { - if seal.ctx != nil { - panic("finalise called twice") - } - seal.ctx = ctx - - { - // encode initial configuration for state tracking - ct := new(bytes.Buffer) - if err := gob.NewEncoder(ct).Encode(config); err != nil { - return hlog.WrapErrSuffix(err, - "cannot encode initial config:") - } - seal.ct = ct - } - - // allowed aid range 0 to 9999, this is checked again in hsu - if config.Identity < 0 || config.Identity > 9999 { - return hlog.WrapErr(ErrUser, - fmt.Sprintf("identity %d out of range", config.Identity)) - } - - seal.user = hsuUser{ - aid: newInt(config.Identity), - data: config.Data, - home: config.Dir, - username: config.Username, - } - if seal.user.username == "" { - seal.user.username = "chronos" - } else if !posixUsername.MatchString(seal.user.username) || - len(seal.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() { - return hlog.WrapErr(ErrName, - fmt.Sprintf("invalid user name %q", seal.user.username)) - } - if seal.user.data == "" || !path.IsAbs(seal.user.data) { - return hlog.WrapErr(ErrHome, - fmt.Sprintf("invalid home directory %q", seal.user.data)) - } - if seal.user.home == "" { - seal.user.home = seal.user.data - } - if u, err := sys.Uid(seal.user.aid.unwrap()); err != nil { - return err - } else { - seal.user.uid = newInt(u) - } - seal.user.supp = make([]string, len(config.Groups)) - for i, name := range config.Groups { - if g, err := sys.LookupGroup(name); err != nil { - return hlog.WrapErr(err, - fmt.Sprintf("unknown group %q", name)) - } else { - seal.user.supp[i] = g.Gid - } - } - - // this also falls back to host path if encountering an invalid path - if !path.IsAbs(config.Shell) { - config.Shell = "/bin/sh" - if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) { - config.Shell = s - } - } - // do not use the value of shell before this point - - // permissive defaults - if config.Container == nil { - hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") - - // hsu clears the environment so resolve paths early - if !path.IsAbs(config.Path) { - if len(config.Args) > 0 { - if p, err := sys.LookPath(config.Args[0]); err != nil { - return hlog.WrapErr(err, err.Error()) - } else { - config.Path = p - } - } else { - config.Path = config.Shell - } - } - - conf := &hst.ContainerConfig{ - Userns: true, - Net: true, - Tty: true, - AutoEtc: true, - } - // bind entries in / - if d, err := sys.ReadDir("/"); err != nil { - return err - } else { - b := make([]*hst.FilesystemConfig, 0, len(d)) - for _, ent := range d { - p := "/" + ent.Name() - switch p { - case "/proc": - case "/dev": - case "/tmp": - case "/mnt": - case "/etc": - - default: - b = append(b, &hst.FilesystemConfig{Src: p, Write: true, Must: true}) - } - } - conf.Filesystem = append(conf.Filesystem, b...) - } - - // hide nscd from sandbox if present - nscd := "/var/run/nscd" - if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) { - conf.Cover = append(conf.Cover, nscd) - } - // bind GPU stuff - if config.Enablements&(system.EX11|system.EWayland) != 0 { - conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/dri", Device: true}) - } - // opportunistically bind kvm - conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/kvm", Device: true}) - - config.Container = conf - } - - var mapuid, mapgid *stringPair[int] - { - var uid, gid int - var err error - seal.container, seal.env, err = common.NewContainer(config.Container, sys, &uid, &gid) - if err != nil { - return hlog.WrapErrSuffix(err, - "cannot initialise container configuration:") - } - if !path.IsAbs(config.Path) { - return hlog.WrapErr(syscall.EINVAL, - "invalid program path") - } - if len(config.Args) == 0 { - config.Args = []string{config.Path} - } - seal.container.Path = config.Path - seal.container.Args = config.Args - - mapuid = newInt(uid) - mapgid = newInt(gid) - if seal.env == nil { - seal.env = make(map[string]string, 1<<6) - } - } - - if !config.Container.AutoEtc { - if config.Container.Etc != "" { - seal.container.Bind(config.Container.Etc, "/etc", 0) - } - } else { - etcPath := config.Container.Etc - if etcPath == "" { - etcPath = "/etc" - } - seal.container.Etc(etcPath, seal.id.String()) - } - - // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid - innerRuntimeDir := path.Join("/run/user", mapuid.String()) - seal.env[xdgRuntimeDir] = innerRuntimeDir - seal.env[xdgSessionClass] = "user" - seal.env[xdgSessionType] = "tty" - - share := &shareHost{seal: seal, sc: sys.Paths()} - seal.runDirPath = share.sc.RunDirPath - seal.sys = system.New(seal.user.uid.unwrap()) - seal.sys.Ensure(share.sc.SharePath, 0711) - - { - runtimeDir := path.Join(share.sc.SharePath, "runtime") - seal.sys.Ensure(runtimeDir, 0700) - seal.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) - runtimeDirInst := path.Join(runtimeDir, seal.user.aid.String()) - seal.sys.Ensure(runtimeDirInst, 0700) - seal.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) - seal.container.Tmpfs("/run/user", 1<<12, 0755) - seal.container.Bind(runtimeDirInst, innerRuntimeDir, sandbox.BindWritable) - } - - { - tmpdir := path.Join(share.sc.SharePath, "tmpdir") - seal.sys.Ensure(tmpdir, 0700) - seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute) - tmpdirInst := path.Join(tmpdir, seal.user.aid.String()) - seal.sys.Ensure(tmpdirInst, 01700) - seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) - // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp - seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable) - } - - { - homeDir := "/var/empty" - if seal.user.home != "" { - homeDir = seal.user.home - } - username := "chronos" - if seal.user.username != "" { - username = seal.user.username - } - seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable) - seal.container.Dir = homeDir - seal.env["HOME"] = homeDir - seal.env["USER"] = username - seal.env[shell] = config.Shell - - seal.container.Place("/etc/passwd", - []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+homeDir+":"+config.Shell+"\n")) - seal.container.Place("/etc/group", - []byte("hakurei:x:"+mapgid.String()+":\n")) - } - - // pass TERM for proper terminal I/O in initial process - if t, ok := sys.LookupEnv(term); ok { - seal.env[term] = t - } - - if config.Enablements&system.EWayland != 0 { - // outer wayland socket (usually `/run/user/%d/wayland-%d`) - var socketPath string - if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok { - hlog.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName) - socketPath = path.Join(share.sc.RuntimePath, wl.FallbackName) - } else if !path.IsAbs(name) { - socketPath = path.Join(share.sc.RuntimePath, name) - } else { - socketPath = name - } - - innerPath := path.Join(innerRuntimeDir, wl.FallbackName) - seal.env[wl.WaylandDisplay] = wl.FallbackName - - if !config.DirectWayland { // set up security-context-v1 - appID := config.ID - if appID == "" { - // use instance ID in case app id is not set - appID = "app.hakurei." + seal.id.String() - } - // downstream socket paths - outerPath := path.Join(share.instance(), "wayland") - seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String()) - seal.container.Bind(outerPath, innerPath, 0) - } else { // bind mount wayland socket (insecure) - hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") - share.ensureRuntimeDir() - seal.container.Bind(socketPath, innerPath, 0) - seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) - } - } - - if config.Enablements&system.EX11 != 0 { - if d, ok := sys.LookupEnv(display); !ok { - return hlog.WrapErr(ErrXDisplay, - "DISPLAY is not set") - } else { - seal.sys.ChangeHosts("#" + seal.user.uid.String()) - seal.env[display] = d - seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix", 0) - } - } - - if config.Enablements&system.EPulse != 0 { - // PulseAudio runtime directory (usually `/run/user/%d/pulse`) - pulseRuntimeDir := path.Join(share.sc.RuntimePath, "pulse") - // PulseAudio socket (usually `/run/user/%d/pulse/native`) - pulseSocket := path.Join(pulseRuntimeDir, "native") - - if _, err := sys.Stat(pulseRuntimeDir); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return hlog.WrapErrSuffix(err, - fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir)) - } - return hlog.WrapErr(ErrPulseSocket, - fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) - } - - if s, err := sys.Stat(pulseSocket); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return hlog.WrapErrSuffix(err, - fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket)) - } - return hlog.WrapErr(ErrPulseSocket, - fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) - } else { - if m := s.Mode(); m&0o006 != 0o006 { - return hlog.WrapErr(ErrPulseMode, - fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m) - } - } - - // hard link pulse socket into target-executable share - innerPulseRuntimeDir := path.Join(share.runtime(), "pulse") - innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native") - seal.sys.Link(pulseSocket, innerPulseRuntimeDir) - seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) - seal.env[pulseServer] = "unix:" + innerPulseSocket - - // publish current user's pulse cookie for target user - if src, err := discoverPulseCookie(sys); err != nil { - // not fatal - hlog.Verbose(strings.TrimSpace(err.(*hlog.BaseError).Message())) - } else { - innerDst := hst.Tmp + "/pulse-cookie" - seal.env[pulseCookie] = innerDst - var payload *[]byte - seal.container.PlaceP(innerDst, &payload) - seal.sys.CopyFile(payload, src, 256, 256) - } - } - - if config.Enablements&system.EDBus != 0 { - // ensure dbus session bus defaults - if config.SessionBus == nil { - config.SessionBus = dbus.NewConfig(config.ID, true, true) - } - - // downstream socket paths - sharePath := share.instance() - sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket") - - // configure dbus proxy - if f, err := seal.sys.ProxyDBus( - config.SessionBus, config.SystemBus, - sessionPath, systemPath, - ); err != nil { - return err - } else { - seal.dbusMsg = f - } - - // share proxy sockets - sessionInner := path.Join(innerRuntimeDir, "bus") - seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner - seal.container.Bind(sessionPath, sessionInner, 0) - seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) - if config.SystemBus != nil { - systemInner := "/run/dbus/system_bus_socket" - seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner - seal.container.Bind(systemPath, systemInner, 0) - seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write) - } - } - - for _, dest := range config.Container.Cover { - seal.container.Tmpfs(dest, 1<<13, 0755) - } - - // append ExtraPerms last - for _, p := range config.ExtraPerms { - if p == nil { - continue - } - - if p.Ensure { - seal.sys.Ensure(p.Path, 0700) - } - - perms := make(acl.Perms, 0, 3) - if p.Read { - perms = append(perms, acl.Read) - } - if p.Write { - perms = append(perms, acl.Write) - } - if p.Execute { - perms = append(perms, acl.Execute) - } - seal.sys.UpdatePermType(system.User, p.Path, perms...) - } - - // flatten and sort env for deterministic behaviour - seal.container.Env = make([]string, 0, len(seal.env)) - for k, v := range seal.env { - if strings.IndexByte(k, '=') != -1 { - return hlog.WrapErr(syscall.EINVAL, - fmt.Sprintf("invalid environment variable %s", k)) - } - seal.container.Env = append(seal.container.Env, k+"="+v) - } - slices.Sort(seal.container.Env) - - if hlog.Load() { - hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", - seal.user.uid, seal.user.username, config.Groups, seal.container.Args, len(*seal.container.Ops)) - } - - return nil -} - -// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie -func discoverPulseCookie(sys sys.State) (string, error) { - if p, ok := sys.LookupEnv(pulseCookie); ok { - return p, nil - } - - // dotfile $HOME/.pulse-cookie - if p, ok := sys.LookupEnv(home); ok { - p = path.Join(p, ".pulse-cookie") - if s, err := sys.Stat(p); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return p, hlog.WrapErrSuffix(err, - fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) - } - // not found, try next method - } else if !s.IsDir() { - return p, nil - } - } - - // $XDG_CONFIG_HOME/pulse/cookie - if p, ok := sys.LookupEnv(xdgConfigHome); ok { - p = path.Join(p, "pulse", "cookie") - if s, err := sys.Stat(p); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return p, hlog.WrapErrSuffix(err, - fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) - } - // not found, try next method - } else if !s.IsDir() { - return p, nil - } - } - - return "", hlog.WrapErr(ErrPulseCookie, - fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", - pulseCookie, xdgConfigHome, home)) -} diff --git a/internal/app/internal/setuid/shim.go b/internal/app/internal/setuid/shim.go deleted file mode 100644 index 100cf1cc..00000000 --- a/internal/app/internal/setuid/shim.go +++ /dev/null @@ -1,184 +0,0 @@ -package setuid - -import ( - "context" - "errors" - "log" - "os" - "os/exec" - "os/signal" - "syscall" - "time" - - "git.gensokyo.uk/security/hakurei/internal" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" -) - -/* -#include -#include -#include -#include -#include - -static pid_t hakurei_shim_param_ppid = -1; - -// this cannot unblock hlog since Go code is not async-signal-safe -static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) { - if (sig != SIGCONT || si == NULL) { - // unreachable - fprintf(stderr, "sigaction: sa_sigaction got invalid siginfo\n"); - return; - } - - // monitor requests shim exit - if (si->si_pid == hakurei_shim_param_ppid) - exit(254); - - fprintf(stderr, "sigaction: got SIGCONT from process %d\n", si->si_pid); - - // shim orphaned before monitor delivers a signal - if (getppid() != hakurei_shim_param_ppid) - exit(3); -} - -void hakurei_shim_setup_cont_signal(pid_t ppid) { - struct sigaction new_action = {0}, old_action = {0}; - if (sigaction(SIGCONT, NULL, &old_action) != 0) - return; - if (old_action.sa_handler != SIG_DFL) { - errno = ENOTRECOVERABLE; - return; - } - - new_action.sa_sigaction = hakurei_shim_sigaction; - if (sigemptyset(&new_action.sa_mask) != 0) - return; - new_action.sa_flags = SA_ONSTACK | SA_SIGINFO; - - if (sigaction(SIGCONT, &new_action, NULL) != 0) - return; - - errno = 0; - hakurei_shim_param_ppid = ppid; -} -*/ -import "C" - -const shimEnv = "HAKUREI_SHIM" - -type shimParams struct { - // monitor pid, checked against ppid in signal handler - Monitor int - - // finalised container params - Container *sandbox.Params - // path to outer home directory - Home string - - // verbosity pass through - Verbose bool -} - -// ShimMain is the main function of the shim process and runs as the unconstrained target user. -func ShimMain() { - hlog.Prepare("shim") - - if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { - log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err) - } - - var ( - params shimParams - closeSetup func() error - ) - if f, err := sandbox.Receive(shimEnv, ¶ms, nil); err != nil { - if errors.Is(err, sandbox.ErrInvalid) { - log.Fatal("invalid config descriptor") - } - if errors.Is(err, sandbox.ErrNotSet) { - log.Fatal("HAKUREI_SHIM not set") - } - - log.Fatalf("cannot receive shim setup params: %v", err) - } else { - internal.InstallFmsg(params.Verbose) - closeSetup = f - - // the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid - if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(params.Monitor)); err != nil { - log.Fatalf("cannot install SIGCONT handler: %v", err) - } - - // pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c - if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); errno != 0 { - log.Fatalf("cannot set parent-death signal: %v", errno) - } - } - - if params.Container == nil || params.Container.Ops == nil { - log.Fatal("invalid container params") - } - - // close setup socket - if err := closeSetup(); err != nil { - log.Printf("cannot close setup pipe: %v", err) - // not fatal - } - - // ensure home directory as target user - if s, err := os.Stat(params.Home); err != nil { - if os.IsNotExist(err) { - if err = os.Mkdir(params.Home, 0700); err != nil { - log.Fatalf("cannot create home directory: %v", err) - } - } else { - log.Fatalf("cannot access home directory: %v", err) - } - - // home directory is created, proceed - } else if !s.IsDir() { - log.Fatalf("path %q is not a directory", params.Home) - } - - var name string - if len(params.Container.Args) > 0 { - name = params.Container.Args[0] - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() // unreachable - container := sandbox.New(ctx, name) - container.Params = *params.Container - container.Stdin, container.Stdout, container.Stderr = os.Stdin, os.Stdout, os.Stderr - container.Cancel = func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) } - container.WaitDelay = 2 * time.Second - - if err := container.Start(); err != nil { - hlog.PrintBaseError(err, "cannot start container:") - os.Exit(1) - } - if err := container.Serve(); err != nil { - hlog.PrintBaseError(err, "cannot configure container:") - } - - if err := seccomp.Load( - seccomp.Preset(seccomp.PresetStrict, seccomp.AllowMultiarch), - seccomp.AllowMultiarch, - ); err != nil { - log.Fatalf("cannot load syscall filter: %v", err) - } - - if err := container.Wait(); err != nil { - var exitError *exec.ExitError - if !errors.As(err, &exitError) { - if errors.Is(err, context.Canceled) { - os.Exit(2) - } - log.Printf("wait: %v", err) - os.Exit(127) - } - os.Exit(exitError.ExitCode()) - } -} diff --git a/internal/app/internal/setuid/strings.go b/internal/app/internal/setuid/strings.go deleted file mode 100644 index 7e9df5f0..00000000 --- a/internal/app/internal/setuid/strings.go +++ /dev/null @@ -1,19 +0,0 @@ -package setuid - -import ( - "strconv" - - . "git.gensokyo.uk/security/hakurei/internal/app" -) - -func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } -func newID(id *ID) *stringPair[ID] { return &stringPair[ID]{*id, id.String()} } - -// 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 } diff --git a/internal/output.go b/internal/output.go index a00cc21e..36eb8105 100644 --- a/internal/output.go +++ b/internal/output.go @@ -6,7 +6,7 @@ import ( "git.gensokyo.uk/security/hakurei/system" ) -func InstallFmsg(verbose bool) { +func InstallOutput(verbose bool) { hlog.Store(verbose) sandbox.SetOutput(hlog.Output{}) system.SetOutput(hlog.Output{}) diff --git a/internal/state/join.go b/internal/state/join.go deleted file mode 100644 index 2b4011fe..00000000 --- a/internal/state/join.go +++ /dev/null @@ -1,60 +0,0 @@ -package state - -import ( - "errors" - "maps" -) - -var ( - ErrDuplicate = errors.New("store contains duplicates") -) - -/* -Joiner is the interface that wraps the Join method. - -The Join function uses Joiner if available. -*/ -type Joiner interface{ Join() (Entries, error) } - -// Join returns joined state entries of all active aids. -func Join(s Store) (Entries, error) { - if j, ok := s.(Joiner); ok { - return j.Join() - } - - var ( - aids []int - entries = make(Entries) - - el int - res Entries - loadErr error - ) - - if ln, err := s.List(); err != nil { - return nil, err - } else { - aids = ln - } - - for _, aid := range aids { - if _, err := s.Do(aid, func(c Cursor) { - res, loadErr = c.Load() - }); err != nil { - return nil, err - } - - if loadErr != nil { - return nil, loadErr - } - - // save expected length - el = len(entries) + len(res) - maps.Copy(entries, res) - if len(entries) != el { - return nil, ErrDuplicate - } - } - - return entries, nil -} diff --git a/internal/state/multi.go b/internal/state/multi.go deleted file mode 100644 index fac5b389..00000000 --- a/internal/state/multi.go +++ /dev/null @@ -1,373 +0,0 @@ -package state - -import ( - "encoding/binary" - "encoding/gob" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "strconv" - "sync" - "syscall" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/hlog" -) - -// fine-grained locking and access -type multiStore struct { - base string - - // initialised backends - backends *sync.Map - - lock sync.RWMutex -} - -func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - // load or initialise new backend - b := new(multiBackend) - b.lock.Lock() - if v, ok := s.backends.LoadOrStore(aid, b); ok { - b = v.(*multiBackend) - } else { - b.path = path.Join(s.base, strconv.Itoa(aid)) - - // ensure directory - if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) { - s.backends.CompareAndDelete(aid, b) - return false, err - } - - // open locker file - if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil { - s.backends.CompareAndDelete(aid, b) - return false, err - } else { - b.lockfile = l - } - b.lock.Unlock() - } - - // lock backend - if err := b.lockFile(); err != nil { - return false, err - } - - // expose backend methods without exporting the pointer - c := new(struct{ *multiBackend }) - c.multiBackend = b - f(b) - // disable access to the backend on a best-effort basis - c.multiBackend = nil - - // unlock backend - return true, b.unlockFile() -} - -func (s *multiStore) List() ([]int, error) { - var entries []os.DirEntry - - // read base directory to get all aids - if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err - } else { - entries = v - } - - aidsBuf := make([]int, 0, len(entries)) - for _, e := range entries { - // skip non-directories - if !e.IsDir() { - hlog.Verbosef("skipped non-directory entry %q", e.Name()) - continue - } - - // skip non-numerical names - if v, err := strconv.Atoi(e.Name()); err != nil { - hlog.Verbosef("skipped non-aid entry %q", e.Name()) - continue - } else { - if v < 0 || v > 9999 { - hlog.Verbosef("skipped out of bounds entry %q", e.Name()) - continue - } - - aidsBuf = append(aidsBuf, v) - } - } - - return append([]int(nil), aidsBuf...), nil -} - -func (s *multiStore) Close() error { - s.lock.Lock() - defer s.lock.Unlock() - - var errs []error - s.backends.Range(func(_, value any) bool { - b := value.(*multiBackend) - errs = append(errs, b.close()) - return true - }) - - return errors.Join(errs...) -} - -type multiBackend struct { - path string - - // created/opened by prepare - lockfile *os.File - - lock sync.RWMutex -} - -func (b *multiBackend) filename(id *app.ID) string { - return path.Join(b.path, id.String()) -} - -func (b *multiBackend) lockFileAct(lt int) (err error) { - op := "LockAct" - switch lt { - case syscall.LOCK_EX: - op = "Lock" - case syscall.LOCK_UN: - op = "Unlock" - } - - for { - err = syscall.Flock(int(b.lockfile.Fd()), lt) - if !errors.Is(err, syscall.EINTR) { - break - } - } - if err != nil { - return &fs.PathError{ - Op: op, - Path: b.lockfile.Name(), - Err: err, - } - } - return nil -} - -func (b *multiBackend) lockFile() error { - return b.lockFileAct(syscall.LOCK_EX) -} - -func (b *multiBackend) unlockFile() error { - return b.lockFileAct(syscall.LOCK_UN) -} - -// reads all launchers in simpleBackend -// file contents are ignored if decode is false -func (b *multiBackend) load(decode bool) (Entries, error) { - b.lock.RLock() - defer b.lock.RUnlock() - - // read directory contents, should only contain files named after ids - var entries []os.DirEntry - if pl, err := os.ReadDir(b.path); err != nil { - return nil, err - } else { - entries = pl - } - - // allocate as if every entry is valid - // since that should be the case assuming no external interference happens - r := make(Entries, len(entries)) - - for _, e := range entries { - if e.IsDir() { - return nil, fmt.Errorf("unexpected directory %q in store", e.Name()) - } - - id := new(app.ID) - if err := app.ParseAppID(id, e.Name()); err != nil { - return nil, err - } - - // run in a function to better handle file closing - if err := func() error { - // open state file for reading - if f, err := os.Open(path.Join(b.path, e.Name())); err != nil { - return err - } else { - defer func() { - if f.Close() != nil { - // unreachable - panic("foreign state file closed prematurely") - } - }() - - s := new(State) - r[*id] = s - - // append regardless, but only parse if required, implements Len - if decode { - if err = b.decodeState(f, s); err != nil { - return err - } - if s.ID != *id { - return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID) - } - } - - return nil - } - }(); err != nil { - return nil, err - } - } - - return r, nil -} - -// state file consists of an eight byte header, followed by concatenated gobs -// of [hst.Config] and [State], if [State.Config] is not nil or offset < 0, -// the first gob is skipped -func (b *multiBackend) decodeState(r io.ReadSeeker, state *State) error { - offset := make([]byte, 8) - if l, err := r.Read(offset); err != nil { - if errors.Is(err, io.EOF) { - return fmt.Errorf("state file too short: %d bytes", l) - } - return err - } - - // decode volatile state first - var skipConfig bool - { - o := int64(binary.LittleEndian.Uint64(offset)) - skipConfig = o < 0 - - if !skipConfig { - if l, err := r.Seek(o, io.SeekCurrent); err != nil { - return err - } else if l != 8+o { - return fmt.Errorf("invalid seek offset %d", l) - } - } - } - if err := gob.NewDecoder(r).Decode(state); err != nil { - return err - } - - // decode sealed config - if state.Config == nil { - // config must be provided either as part of volatile state, - // or in the config segment - if skipConfig { - return ErrNoConfig - } - - state.Config = new(hst.Config) - if _, err := r.Seek(8, io.SeekStart); err != nil { - return err - } - return gob.NewDecoder(r).Decode(state.Config) - } else { - return nil - } -} - -// Save writes process state to filesystem -func (b *multiBackend) Save(state *State, configWriter io.WriterTo) error { - b.lock.Lock() - defer b.lock.Unlock() - - if configWriter == nil && state.Config == nil { - return ErrNoConfig - } - - statePath := b.filename(&state.ID) - - if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { - return err - } else { - defer func() { - if f.Close() != nil { - // unreachable - panic("state file closed prematurely") - } - }() - return b.encodeState(f, state, configWriter) - } -} - -func (b *multiBackend) encodeState(w io.WriteSeeker, state *State, configWriter io.WriterTo) error { - offset := make([]byte, 8) - - // skip header bytes - if _, err := w.Seek(8, io.SeekStart); err != nil { - return err - } - - if configWriter != nil { - // write config gob and encode header - if l, err := configWriter.WriteTo(w); err != nil { - return err - } else { - binary.LittleEndian.PutUint64(offset, uint64(l)) - } - } else { - // offset == -1 indicates absence of config gob - binary.LittleEndian.PutUint64(offset, 0xffffffffffffffff) - } - - // encode volatile state - if err := gob.NewEncoder(w).Encode(state); err != nil { - return err - } - - // write header - if _, err := w.Seek(0, io.SeekStart); err != nil { - return err - } - _, err := w.Write(offset) - return err -} - -func (b *multiBackend) Destroy(id app.ID) error { - b.lock.Lock() - defer b.lock.Unlock() - - return os.Remove(b.filename(&id)) -} - -func (b *multiBackend) Load() (Entries, error) { - return b.load(true) -} - -func (b *multiBackend) Len() (int, error) { - // rn consists of only nil entries but has the correct length - rn, err := b.load(false) - return len(rn), err -} - -func (b *multiBackend) close() error { - b.lock.Lock() - defer b.lock.Unlock() - - err := b.lockfile.Close() - if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) { - return nil - } - return err -} - -// NewMulti returns an instance of the multi-file store. -func NewMulti(runDir string) Store { - b := new(multiStore) - b.base = path.Join(runDir, "state") - b.backends = new(sync.Map) - return b -} diff --git a/internal/state/multi_test.go b/internal/state/multi_test.go deleted file mode 100644 index 2549034d..00000000 --- a/internal/state/multi_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package state_test - -import ( - "testing" - - "git.gensokyo.uk/security/hakurei/internal/state" -) - -func TestMulti(t *testing.T) { - testStore(t, state.NewMulti(t.TempDir())) -} diff --git a/internal/state/state.go b/internal/state/state.go deleted file mode 100644 index 0c07b371..00000000 --- a/internal/state/state.go +++ /dev/null @@ -1,49 +0,0 @@ -package state - -import ( - "errors" - "io" - "time" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" -) - -var ErrNoConfig = errors.New("state does not contain config") - -type Entries map[app.ID]*State - -type Store interface { - // Do calls f exactly once and ensures store exclusivity until f returns. - // Returns whether f is called and any errors during the locking process. - // Cursor provided to f becomes invalid as soon as f returns. - Do(aid int, f func(c Cursor)) (ok bool, err error) - - // List queries the store and returns a list of aids known to the store. - // Note that some or all returned aids might not have any active apps. - List() (aids []int, err error) - - // Close releases any resources held by Store. - Close() error -} - -// Cursor provides access to the store -type Cursor interface { - Save(state *State, configWriter io.WriterTo) error - Destroy(id app.ID) error - Load() (Entries, error) - Len() (int, error) -} - -// State is an instance state -type State struct { - // hakurei instance id - ID app.ID `json:"instance"` - // child process PID value - PID int `json:"pid"` - // sealed app configuration - Config *hst.Config `json:"config"` - - // process start time - Time time.Time `json:"time"` -} diff --git a/internal/state/state_test.go b/internal/state/state_test.go deleted file mode 100644 index caf5c43a..00000000 --- a/internal/state/state_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package state_test - -import ( - "bytes" - "encoding/gob" - "io" - "math/rand/v2" - "reflect" - "slices" - "testing" - "time" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/state" -) - -func testStore(t *testing.T, s state.Store) { - t.Run("list empty store", func(t *testing.T) { - if aids, err := s.List(); err != nil { - t.Fatalf("List: error = %v", err) - } else if len(aids) != 0 { - t.Fatalf("List: aids = %#v", aids) - } - }) - - const ( - insertEntryChecked = iota - insertEntryNoCheck - insertEntryOtherApp - - tl - ) - - var tc [tl]struct { - state state.State - ct bytes.Buffer - } - for i := 0; i < tl; i++ { - makeState(t, &tc[i].state, &tc[i].ct) - } - - do := func(aid int, f func(c state.Cursor)) { - if ok, err := s.Do(aid, f); err != nil { - t.Fatalf("Do: ok = %v, error = %v", ok, err) - } - } - - insert := func(i, aid int) { - do(aid, func(c state.Cursor) { - if err := c.Save(&tc[i].state, &tc[i].ct); err != nil { - t.Fatalf("Save(&tc[%v]): error = %v", i, err) - } - }) - } - - check := func(i, aid int) { - do(aid, func(c state.Cursor) { - if entries, err := c.Load(); err != nil { - t.Fatalf("Load: error = %v", err) - } else if got, ok := entries[tc[i].state.ID]; !ok { - t.Fatalf("Load: entry %s missing", - &tc[i].state.ID) - } else { - got.Time = tc[i].state.Time - tc[i].state.Config = hst.Template() - if !reflect.DeepEqual(got, &tc[i].state) { - t.Fatalf("Load: entry %s got %#v, want %#v", - &tc[i].state.ID, got, &tc[i].state) - } - tc[i].state.Config = nil - } - }) - } - - t.Run("insert entry checked", func(t *testing.T) { - insert(insertEntryChecked, 0) - check(insertEntryChecked, 0) - }) - - t.Run("insert entry unchecked", func(t *testing.T) { - insert(insertEntryNoCheck, 0) - }) - - t.Run("insert entry different aid", func(t *testing.T) { - insert(insertEntryOtherApp, 1) - check(insertEntryOtherApp, 1) - }) - - t.Run("check previous insertion", func(t *testing.T) { - check(insertEntryNoCheck, 0) - }) - - t.Run("list aids", func(t *testing.T) { - if aids, err := s.List(); err != nil { - t.Fatalf("List: error = %v", err) - } else { - slices.Sort(aids) - want := []int{0, 1} - if !slices.Equal(aids, want) { - t.Fatalf("List() = %#v, want %#v", aids, want) - } - } - }) - - t.Run("join store", func(t *testing.T) { - if entries, err := state.Join(s); err != nil { - t.Fatalf("Join: error = %v", err) - } else if len(entries) != 3 { - t.Fatalf("Join(s) = %#v", entries) - } - }) - - t.Run("clear aid 1", func(t *testing.T) { - do(1, func(c state.Cursor) { - if err := c.Destroy(tc[insertEntryOtherApp].state.ID); err != nil { - t.Fatalf("Destroy: error = %v", err) - } - }) - do(1, func(c state.Cursor) { - if l, err := c.Len(); err != nil { - t.Fatalf("Len: error = %v", err) - } else if l != 0 { - t.Fatalf("Len() = %d, want 0", l) - } - }) - }) - - t.Run("close store", func(t *testing.T) { - if err := s.Close(); err != nil { - t.Fatalf("Close: error = %v", err) - } - }) -} - -func makeState(t *testing.T, s *state.State, ct io.Writer) { - if err := app.NewAppID(&s.ID); err != nil { - t.Fatalf("cannot create dummy state: %v", err) - } - if err := gob.NewEncoder(ct).Encode(hst.Template()); err != nil { - t.Fatalf("cannot encode dummy config: %v", err) - } - s.PID = rand.Int() - s.Time = time.Now() -} diff --git a/internal/sys/interface.go b/internal/sys/interface.go index 57c5a7ec..06cb450c 100644 --- a/internal/sys/interface.go +++ b/internal/sys/interface.go @@ -1,3 +1,4 @@ +// Package sys wraps OS interaction library functions. package sys import ( @@ -6,7 +7,7 @@ import ( "path" "strconv" - "git.gensokyo.uk/security/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/hakurei/internal/hlog" ) @@ -40,15 +41,15 @@ type State interface { Println(v ...any) Printf(format string, v ...any) - // Paths returns a populated [Paths] struct. - Paths() app.Paths + // Paths returns a populated [hst.Paths] struct. + Paths() hst.Paths // Uid invokes hsu and returns target uid. // Any errors returned by Uid is already wrapped [fmsg.BaseError]. Uid(aid int) (int, error) } // CopyPaths is a generic implementation of [hst.Paths]. -func CopyPaths(os State, v *app.Paths) { +func CopyPaths(os State, v *hst.Paths) { v.SharePath = path.Join(os.TempDir(), "hakurei."+strconv.Itoa(os.Getuid())) hlog.Verbosef("process share directory at %q", v.SharePath) diff --git a/internal/sys/std.go b/internal/sys/std.go index 6db4bae2..c3913a75 100644 --- a/internal/sys/std.go +++ b/internal/sys/std.go @@ -12,15 +12,15 @@ import ( "sync" "syscall" + "git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/hakurei/internal" - "git.gensokyo.uk/security/hakurei/internal/app" "git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/hakurei/sandbox" ) // Std implements System using the standard library. type Std struct { - paths app.Paths + paths hst.Paths pathsOnce sync.Once uidOnce sync.Once @@ -48,7 +48,7 @@ func (s *Std) Printf(format string, v ...any) { hlog.Verbosef(form const xdgRuntimeDir = "XDG_RUNTIME_DIR" -func (s *Std) Paths() app.Paths { +func (s *Std) Paths() hst.Paths { s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) }) return s.paths } diff --git a/internal/sysconf.go b/internal/sysconf.go index 03b236f4..712dce78 100644 --- a/internal/sysconf.go +++ b/internal/sysconf.go @@ -3,4 +3,6 @@ package internal //#include import "C" -func Sysconf_SC_LOGIN_NAME_MAX() int { return int(C.sysconf(C._SC_LOGIN_NAME_MAX)) } +const SC_LOGIN_NAME_MAX = C._SC_LOGIN_NAME_MAX + +func Sysconf(name C.int) int { return int(C.sysconf(name)) } diff --git a/main.go b/main.go deleted file mode 100644 index e5f54eeb..00000000 --- a/main.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "context" - _ "embed" - "errors" - "fmt" - "io" - "log" - "os" - "os/signal" - "os/user" - "strconv" - "sync" - "syscall" - "time" - - "git.gensokyo.uk/security/hakurei/command" - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/instance" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/state" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/system" -) - -var ( - errSuccess = errors.New("success") - - //go:embed LICENSE - license string -) - -func init() { hlog.Prepare("hakurei") } - -var std sys.State = new(sys.Std) - -func main() { - // early init path, skips root check and duplicate PR_SET_DUMPABLE - sandbox.TryArgv0(hlog.Output{}, hlog.Prepare, internal.InstallFmsg) - - if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { - log.Printf("cannot set SUID_DUMP_DISABLE: %s", err) - // not fatal: this program runs as the privileged user - } - - if os.Geteuid() == 0 { - log.Fatal("this program must not run as root") - } - - buildCommand(os.Stderr).MustParse(os.Args[1:], func(err error) { - hlog.Verbosef("command returned %v", err) - if errors.Is(err, errSuccess) { - hlog.BeforeExit() - os.Exit(0) - } - }) - log.Fatal("unreachable") -} - -func buildCommand(out io.Writer) command.Command { - var ( - flagVerbose bool - flagJSON bool - ) - c := command.New(out, log.Printf, "hakurei", func([]string) error { internal.InstallFmsg(flagVerbose); return nil }). - Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity"). - Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable") - - c.Command("shim", command.UsageInternal, func([]string) error { instance.ShimMain(); return errSuccess }) - - c.Command("app", "Load app from configuration file", func(args []string) error { - if len(args) < 1 { - log.Fatal("app requires at least 1 argument") - } - - // config extraArgs... - config := tryPath(args[0]) - config.Args = append(config.Args, args[1:]...) - - runApp(config) - panic("unreachable") - }) - - { - var ( - dbusConfigSession string - dbusConfigSystem string - mpris bool - dbusVerbose bool - - fid string - aid int - groups command.RepeatableFlag - homeDir string - userName string - - wayland, x11, dBus, pulse bool - ) - - c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { - // initialise config from flags - config := &hst.Config{ - ID: fid, - Args: args, - } - - if aid < 0 || aid > 9999 { - log.Fatalf("aid %d out of range", aid) - } - - // resolve home/username from os when flag is unset - var ( - passwd *user.User - passwdOnce sync.Once - passwdFunc = func() { - var us string - if uid, err := std.Uid(aid); err != nil { - hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") - os.Exit(1) - } else { - us = strconv.Itoa(uid) - } - - if u, err := user.LookupId(us); err != nil { - hlog.Verbosef("cannot look up uid %s", us) - passwd = &user.User{ - Uid: us, - Gid: us, - Username: "chronos", - Name: "Hakurei Permissive Default", - HomeDir: "/var/empty", - } - } else { - passwd = u - } - } - ) - - if homeDir == "os" { - passwdOnce.Do(passwdFunc) - homeDir = passwd.HomeDir - } - - if userName == "chronos" { - passwdOnce.Do(passwdFunc) - userName = passwd.Username - } - - config.Identity = aid - config.Groups = groups - config.Data = homeDir - config.Username = userName - - if wayland { - config.Enablements |= system.EWayland - } - if x11 { - config.Enablements |= system.EX11 - } - if dBus { - config.Enablements |= system.EDBus - } - if pulse { - config.Enablements |= system.EPulse - } - - // parse D-Bus config file from flags if applicable - if dBus { - if dbusConfigSession == "builtin" { - config.SessionBus = dbus.NewConfig(fid, true, mpris) - } else { - if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { - log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) - } else { - config.SessionBus = conf - } - } - - // system bus proxy is optional - if dbusConfigSystem != "nil" { - if conf, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil { - log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err) - } else { - config.SystemBus = conf - } - } - - // override log from configuration - if dbusVerbose { - config.SessionBus.Log = true - config.SystemBus.Log = true - } - } - - // invoke app - runApp(config) - panic("unreachable") - }). - Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), - "Path to session bus proxy config file, or \"builtin\" for defaults"). - Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"), - "Path to system bus proxy config file, or \"nil\" to disable"). - Flag(&mpris, "mpris", command.BoolFlag(false), - "Allow owning MPRIS D-Bus path, has no effect if custom config is available"). - Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false), - "Force buffered logging in the D-Bus proxy"). - Flag(&fid, "id", command.StringFlag(""), - "Reverse-DNS style Application identifier, leave empty to inherit instance identifier"). - Flag(&aid, "a", command.IntFlag(0), - "Application identity"). - Flag(nil, "g", &groups, - "Groups inherited by all container processes"). - Flag(&homeDir, "d", command.StringFlag("os"), - "Container home directory"). - Flag(&userName, "u", command.StringFlag("chronos"), - "Passwd user name within sandbox"). - Flag(&wayland, "wayland", command.BoolFlag(false), - "Enable connection to Wayland via security-context-v1"). - Flag(&x11, "X", command.BoolFlag(false), - "Enable direct connection to X11"). - Flag(&dBus, "dbus", command.BoolFlag(false), - "Enable proxied connection to D-Bus"). - Flag(&pulse, "pulse", command.BoolFlag(false), - "Enable direct connection to PulseAudio") - } - - var showFlagShort bool - c.NewCommand("show", "Show live or local app configuration", func(args []string) error { - switch len(args) { - case 0: // system - printShowSystem(os.Stdout, showFlagShort, flagJSON) - - case 1: // instance - name := args[0] - config, entry := tryShort(name) - if config == nil { - config = tryPath(name) - } - printShowInstance(os.Stdout, time.Now().UTC(), entry, config, showFlagShort, flagJSON) - - default: - log.Fatal("show requires 1 argument") - } - return errSuccess - }).Flag(&showFlagShort, "short", command.BoolFlag(false), "Omit filesystem information") - - var psFlagShort bool - c.NewCommand("ps", "List active instances", func(args []string) error { - printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), psFlagShort, flagJSON) - return errSuccess - }).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id") - - c.Command("version", "Display version information", func(args []string) error { - fmt.Println(internal.Version()) - return errSuccess - }) - - c.Command("license", "Show full license text", func(args []string) error { - fmt.Println(license) - return errSuccess - }) - - c.Command("template", "Produce a config template", func(args []string) error { - printJSON(os.Stdout, false, hst.Template()) - return errSuccess - }) - - c.Command("help", "Show this help message", func([]string) error { - c.PrintHelp() - return errSuccess - }) - - return c -} - -func runApp(config *hst.Config) { - ctx, stop := signal.NotifyContext(context.Background(), - syscall.SIGINT, syscall.SIGTERM) - defer stop() // unreachable - a := instance.MustNew(instance.ISetuid, ctx, std) - - rs := new(app.RunState) - if sa, err := a.Seal(config); err != nil { - hlog.PrintBaseError(err, "cannot seal app:") - internal.Exit(1) - } else { - internal.Exit(instance.PrintRunStateErr(instance.ISetuid, rs, sa.Run(rs))) - } - - *(*int)(nil) = 0 // not reached -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 7e2b792e..00000000 --- a/main_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "flag" - "testing" - - "git.gensokyo.uk/security/hakurei/command" -) - -func TestHelp(t *testing.T) { - testCases := []struct { - name string - args []string - want string - }{ - { - "main", []string{}, ` -Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS] - -Commands: - app Load app from configuration file - run Configure and start a permissive default sandbox - show Show live or local app configuration - ps List active instances - version Display version information - license Show full license text - template Produce a config template - help Show this help message - -`, - }, - { - "run", []string{"run", "-h"}, ` -Usage: hakurei run [-h | --help] [--dbus-config ] [--dbus-system ] [--mpris] [--dbus-log] [--id ] [-a ] [-g ] [-d ] [-u ] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS] - -Flags: - -X Enable direct connection to X11 - -a int - Application identity - -d string - Container home directory (default "os") - -dbus - Enable proxied connection to D-Bus - -dbus-config string - Path to session bus proxy config file, or "builtin" for defaults (default "builtin") - -dbus-log - Force buffered logging in the D-Bus proxy - -dbus-system string - Path to system bus proxy config file, or "nil" to disable (default "nil") - -g value - Groups inherited by all container processes - -id string - Reverse-DNS style Application identifier, leave empty to inherit instance identifier - -mpris - Allow owning MPRIS D-Bus path, has no effect if custom config is available - -pulse - Enable direct connection to PulseAudio - -u string - Passwd user name within sandbox (default "chronos") - -wayland - Enable connection to Wayland via security-context-v1 - -`, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - out := new(bytes.Buffer) - c := buildCommand(out) - if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) { - t.Errorf("Parse: error = %v; want %v", - err, command.ErrHelp) - } - if got := out.String(); got != tc.want { - t.Errorf("Parse: %s want %s", got, tc.want) - } - }) - } -} diff --git a/parse.go b/parse.go deleted file mode 100644 index 2e7f507c..00000000 --- a/parse.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "io" - "log" - "os" - "strconv" - "strings" - "syscall" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/state" -) - -func tryPath(name string) (config *hst.Config) { - var r io.Reader - config = new(hst.Config) - - if name != "-" { - r = tryFd(name) - if r == nil { - hlog.Verbose("load configuration from file") - - if f, err := os.Open(name); err != nil { - log.Fatalf("cannot access configuration file %q: %s", name, err) - } else { - // finalizer closes f - r = f - } - } else { - defer func() { - if err := r.(io.ReadCloser).Close(); err != nil { - log.Printf("cannot close config fd: %v", err) - } - }() - } - } else { - r = os.Stdin - } - - if err := json.NewDecoder(r).Decode(&config); err != nil { - log.Fatalf("cannot load configuration: %v", err) - } - - return -} - -func tryFd(name string) io.ReadCloser { - if v, err := strconv.Atoi(name); err != nil { - if !errors.Is(err, strconv.ErrSyntax) { - hlog.Verbosef("name cannot be interpreted as int64: %v", err) - } - return nil - } else { - hlog.Verbosef("trying config stream from %d", v) - fd := uintptr(v) - if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 { - if errors.Is(errno, syscall.EBADF) { - return nil - } - log.Fatalf("cannot get fd %d: %v", fd, errno) - } - return os.NewFile(fd, strconv.Itoa(v)) - } -} - -func tryShort(name string) (config *hst.Config, entry *state.State) { - likePrefix := false - if len(name) <= 32 { - likePrefix = true - for _, c := range name { - if c >= '0' && c <= '9' { - continue - } - if c >= 'a' && c <= 'f' { - continue - } - likePrefix = false - break - } - } - - // try to match from state store - if likePrefix && len(name) >= 8 { - hlog.Verbose("argument looks like prefix") - - s := state.NewMulti(std.Paths().RunDirPath) - if entries, err := state.Join(s); err != nil { - log.Printf("cannot join store: %v", err) - // drop to fetch from file - } else { - for id := range entries { - v := id.String() - if strings.HasPrefix(v, name) { - // match, use config from this state entry - entry = entries[id] - config = entry.Config - break - } - - hlog.Verbosef("instance %s skipped", v) - } - } - } - - return -} diff --git a/print.go b/print.go deleted file mode 100644 index 4227d46c..00000000 --- a/print.go +++ /dev/null @@ -1,319 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "os" - "slices" - "strconv" - "strings" - "text/tabwriter" - "time" - - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/state" -) - -func printShowSystem(output io.Writer, short, flagJSON bool) { - t := newPrinter(output) - defer t.MustFlush() - - info := new(hst.Info) - - // get fid by querying uid of aid 0 - if uid, err := std.Uid(0); err != nil { - hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") - os.Exit(1) - } else { - info.User = (uid / 10000) - 100 - } - - if flagJSON { - printJSON(output, short, info) - return - } - - t.Printf("User:\t%d\n", info.User) -} - -func printShowInstance( - output io.Writer, now time.Time, - instance *state.State, config *hst.Config, - short, flagJSON bool) { - if flagJSON { - if instance != nil { - printJSON(output, short, instance) - } else { - printJSON(output, short, config) - } - return - } - - t := newPrinter(output) - defer t.MustFlush() - - if config.Container == nil { - mustPrint(output, "Warning: this configuration uses permissive defaults!\n\n") - } - - if instance != nil { - t.Printf("State\n") - t.Printf(" Instance:\t%s (%d)\n", instance.ID.String(), instance.PID) - t.Printf(" Uptime:\t%s\n", now.Sub(instance.Time).Round(time.Second).String()) - t.Printf("\n") - } - - t.Printf("App\n") - if config.ID != "" { - t.Printf(" Identity:\t%d (%s)\n", config.Identity, config.ID) - } else { - t.Printf(" Identity:\t%d\n", config.Identity) - } - t.Printf(" Enablements:\t%s\n", config.Enablements.String()) - if len(config.Groups) > 0 { - t.Printf(" Groups:\t%s\n", strings.Join(config.Groups, ", ")) - } - if config.Data != "" { - t.Printf(" Data:\t%s\n", config.Data) - } - if config.Container != nil { - container := config.Container - if container.Hostname != "" { - t.Printf(" Hostname:\t%s\n", container.Hostname) - } - flags := make([]string, 0, 7) - writeFlag := func(name string, value bool) { - if value { - flags = append(flags, name) - } - } - writeFlag("userns", container.Userns) - writeFlag("devel", container.Devel) - writeFlag("net", container.Net) - writeFlag("device", container.Device) - writeFlag("tty", container.Tty) - writeFlag("mapuid", container.MapRealUID) - writeFlag("directwl", config.DirectWayland) - writeFlag("autoetc", container.AutoEtc) - if len(flags) == 0 { - flags = append(flags, "none") - } - t.Printf(" Flags:\t%s\n", strings.Join(flags, " ")) - - etc := container.Etc - if etc == "" { - etc = "/etc" - } - t.Printf(" Etc:\t%s\n", etc) - - if len(container.Cover) > 0 { - t.Printf(" Cover:\t%s\n", strings.Join(container.Cover, " ")) - } - - t.Printf(" Path:\t%s\n", config.Path) - } - if len(config.Args) > 0 { - t.Printf(" Arguments:\t%s\n", strings.Join(config.Args, " ")) - } - t.Printf("\n") - - if !short { - if config.Container != nil && len(config.Container.Filesystem) > 0 { - t.Printf("Filesystem\n") - for _, f := range config.Container.Filesystem { - if f == nil { - continue - } - - expr := new(strings.Builder) - expr.Grow(3 + len(f.Src) + 1 + len(f.Dst)) - - if f.Device { - expr.WriteString(" d") - } else if f.Write { - expr.WriteString(" w") - } else { - expr.WriteString(" ") - } - if f.Must { - expr.WriteString("*") - } else { - expr.WriteString("+") - } - expr.WriteString(f.Src) - if f.Dst != "" { - expr.WriteString(":" + f.Dst) - } - t.Printf("%s\n", expr.String()) - } - t.Printf("\n") - } - if len(config.ExtraPerms) > 0 { - t.Printf("Extra ACL\n") - for _, p := range config.ExtraPerms { - if p == nil { - continue - } - t.Printf(" %s\n", p.String()) - } - t.Printf("\n") - } - } - - printDBus := func(c *dbus.Config) { - t.Printf(" Filter:\t%v\n", c.Filter) - if len(c.See) > 0 { - t.Printf(" See:\t%q\n", c.See) - } - if len(c.Talk) > 0 { - t.Printf(" Talk:\t%q\n", c.Talk) - } - if len(c.Own) > 0 { - t.Printf(" Own:\t%q\n", c.Own) - } - if len(c.Call) > 0 { - t.Printf(" Call:\t%q\n", c.Call) - } - if len(c.Broadcast) > 0 { - t.Printf(" Broadcast:\t%q\n", c.Broadcast) - } - } - if config.SessionBus != nil { - t.Printf("Session bus\n") - printDBus(config.SessionBus) - t.Printf("\n") - } - if config.SystemBus != nil { - t.Printf("System bus\n") - printDBus(config.SystemBus) - t.Printf("\n") - } -} - -func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) { - var entries state.Entries - if e, err := state.Join(s); err != nil { - log.Fatalf("cannot join store: %v", err) - } else { - entries = e - } - if err := s.Close(); err != nil { - log.Printf("cannot close store: %v", err) - } - - if !short && flagJSON { - es := make(map[string]*state.State, len(entries)) - for id, instance := range entries { - es[id.String()] = instance - } - printJSON(output, short, es) - return - } - - // sort state entries by id string to ensure consistency between runs - exp := make([]*expandedStateEntry, 0, len(entries)) - for id, instance := range entries { - // gracefully skip nil states - if instance == nil { - log.Printf("got invalid state entry %s", id.String()) - continue - } - - // gracefully skip inconsistent states - if id != instance.ID { - log.Printf("possible store corruption: entry %s has id %s", - id.String(), instance.ID.String()) - continue - } - exp = append(exp, &expandedStateEntry{s: id.String(), State: instance}) - } - slices.SortFunc(exp, func(a, b *expandedStateEntry) int { return a.Time.Compare(b.Time) }) - - if short { - if flagJSON { - v := make([]string, len(exp)) - for i, e := range exp { - v[i] = e.s - } - printJSON(output, short, v) - } else { - for _, e := range exp { - mustPrintln(output, e.s[:8]) - } - } - return - } - - t := newPrinter(output) - defer t.MustFlush() - - t.Println("\tInstance\tPID\tApplication\tUptime") - for _, e := range exp { - if len(e.s) != 1<<5 { - // unreachable - log.Printf("possible store corruption: invalid instance string %s", e.s) - continue - } - - as := "(No configuration information)" - if e.Config != nil { - as = strconv.Itoa(e.Config.Identity) - id := e.Config.ID - if id == "" { - id = "app.hakurei." + e.s[:8] - } - as += " (" + id + ")" - } - t.Printf("\t%s\t%d\t%s\t%s\n", - e.s[:8], e.PID, as, now.Sub(e.Time).Round(time.Second).String()) - } -} - -type expandedStateEntry struct { - s string - *state.State -} - -func printJSON(output io.Writer, short bool, v any) { - encoder := json.NewEncoder(output) - if !short { - encoder.SetIndent("", " ") - } - if err := encoder.Encode(v); err != nil { - log.Fatalf("cannot serialise: %v", err) - } -} - -func newPrinter(output io.Writer) *tp { return &tp{tabwriter.NewWriter(output, 0, 1, 4, ' ', 0)} } - -type tp struct{ *tabwriter.Writer } - -func (p *tp) Printf(format string, a ...any) { - if _, err := fmt.Fprintf(p, format, a...); err != nil { - log.Fatalf("cannot write to tabwriter: %v", err) - } -} -func (p *tp) Println(a ...any) { - if _, err := fmt.Fprintln(p, a...); err != nil { - log.Fatalf("cannot write to tabwriter: %v", err) - } -} -func (p *tp) MustFlush() { - if err := p.Writer.Flush(); err != nil { - log.Fatalf("cannot flush tabwriter: %v", err) - } -} -func mustPrint(output io.Writer, a ...any) { - if _, err := fmt.Fprint(output, a...); err != nil { - log.Fatalf("cannot print: %v", err) - } -} -func mustPrintln(output io.Writer, a ...any) { - if _, err := fmt.Fprintln(output, a...); err != nil { - log.Fatalf("cannot print: %v", err) - } -} diff --git a/print_test.go b/print_test.go deleted file mode 100644 index 012a48dc..00000000 --- a/print_test.go +++ /dev/null @@ -1,644 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/state" -) - -var ( - testID = app.ID{ - 0x8e, 0x2c, 0x76, 0xb0, - 0x66, 0xda, 0xbe, 0x57, - 0x4c, 0xf0, 0x73, 0xbd, - 0xb4, 0x6e, 0xb5, 0xc1, - } - testState = &state.State{ - ID: testID, - PID: 0xDEADBEEF, - Config: hst.Template(), - Time: testAppTime, - } - testTime = time.Unix(3752, 1).UTC() - testAppTime = time.Unix(0, 9).UTC() -) - -func Test_printShowInstance(t *testing.T) { - testCases := []struct { - name string - instance *state.State - config *hst.Config - short, json bool - want string - }{ - {"config", nil, hst.Template(), false, false, `App - Identity: 9 (org.chromium.Chromium) - Enablements: wayland, dbus, pulseaudio - Groups: video, dialout, plugdev - Data: /var/lib/hakurei/u0/org.chromium.Chromium - Hostname: localhost - Flags: userns devel net device tty mapuid autoetc - Etc: /etc - Cover: /var/run/nscd - Path: /run/current-system/sw/bin/chromium - Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland - -Filesystem - +/nix/store - +/run/current-system - +/run/opengl-driver - +/var/db/nix-channels - w*/var/lib/hakurei/u0/org.chromium.Chromium:/data/data/org.chromium.Chromium - d+/dev/dri - -Extra ACL - --x+:/var/lib/hakurei/u0 - rwx:/var/lib/hakurei/u0/org.chromium.Chromium - -Session bus - Filter: true - Talk: ["org.freedesktop.Notifications" "org.freedesktop.FileManager1" "org.freedesktop.ScreenSaver" "org.freedesktop.secrets" "org.kde.kwalletd5" "org.kde.kwalletd6" "org.gnome.SessionManager"] - Own: ["org.chromium.Chromium.*" "org.mpris.MediaPlayer2.org.chromium.Chromium.*" "org.mpris.MediaPlayer2.chromium.*"] - Call: map["org.freedesktop.portal.*":"*"] - Broadcast: map["org.freedesktop.portal.*":"@/org/freedesktop/portal/*"] - -System bus - Filter: true - Talk: ["org.bluez" "org.freedesktop.Avahi" "org.freedesktop.UPower"] - -`}, - {"config pd", nil, new(hst.Config), false, false, `Warning: this configuration uses permissive defaults! - -App - Identity: 0 - Enablements: (no enablements) - -`}, - {"config flag none", nil, &hst.Config{Container: new(hst.ContainerConfig)}, false, false, `App - Identity: 0 - Enablements: (no enablements) - Flags: none - Etc: /etc - Path: - -`}, - {"config nil entries", nil, &hst.Config{Container: &hst.ContainerConfig{Filesystem: make([]*hst.FilesystemConfig, 1)}, ExtraPerms: make([]*hst.ExtraPermConfig, 1)}, false, false, `App - Identity: 0 - Enablements: (no enablements) - Flags: none - Etc: /etc - Path: - -Filesystem - -Extra ACL - -`}, - {"config pd dbus see", nil, &hst.Config{SessionBus: &dbus.Config{See: []string{"org.example.test"}}}, false, false, `Warning: this configuration uses permissive defaults! - -App - Identity: 0 - Enablements: (no enablements) - -Session bus - Filter: false - See: ["org.example.test"] - -`}, - - {"instance", testState, hst.Template(), false, false, `State - Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559) - Uptime: 1h2m32s - -App - Identity: 9 (org.chromium.Chromium) - Enablements: wayland, dbus, pulseaudio - Groups: video, dialout, plugdev - Data: /var/lib/hakurei/u0/org.chromium.Chromium - Hostname: localhost - Flags: userns devel net device tty mapuid autoetc - Etc: /etc - Cover: /var/run/nscd - Path: /run/current-system/sw/bin/chromium - Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland - -Filesystem - +/nix/store - +/run/current-system - +/run/opengl-driver - +/var/db/nix-channels - w*/var/lib/hakurei/u0/org.chromium.Chromium:/data/data/org.chromium.Chromium - d+/dev/dri - -Extra ACL - --x+:/var/lib/hakurei/u0 - rwx:/var/lib/hakurei/u0/org.chromium.Chromium - -Session bus - Filter: true - Talk: ["org.freedesktop.Notifications" "org.freedesktop.FileManager1" "org.freedesktop.ScreenSaver" "org.freedesktop.secrets" "org.kde.kwalletd5" "org.kde.kwalletd6" "org.gnome.SessionManager"] - Own: ["org.chromium.Chromium.*" "org.mpris.MediaPlayer2.org.chromium.Chromium.*" "org.mpris.MediaPlayer2.chromium.*"] - Call: map["org.freedesktop.portal.*":"*"] - Broadcast: map["org.freedesktop.portal.*":"@/org/freedesktop/portal/*"] - -System bus - Filter: true - Talk: ["org.bluez" "org.freedesktop.Avahi" "org.freedesktop.UPower"] - -`}, - {"instance pd", testState, new(hst.Config), false, false, `Warning: this configuration uses permissive defaults! - -State - Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559) - Uptime: 1h2m32s - -App - Identity: 0 - Enablements: (no enablements) - -`}, - - {"json nil", nil, nil, false, true, `null -`}, - {"json instance", testState, nil, false, true, `{ - "instance": [ - 142, - 44, - 118, - 176, - 102, - 218, - 190, - 87, - 76, - 240, - 115, - 189, - 180, - 110, - 181, - 193 - ], - "pid": 3735928559, - "config": { - "id": "org.chromium.Chromium", - "path": "/run/current-system/sw/bin/chromium", - "args": [ - "chromium", - "--ignore-gpu-blocklist", - "--disable-smooth-scrolling", - "--enable-features=UseOzonePlatform", - "--ozone-platform=wayland" - ], - "enablements": 13, - "session_bus": { - "see": null, - "talk": [ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager" - ], - "own": [ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*" - ], - "call": { - "org.freedesktop.portal.*": "*" - }, - "broadcast": { - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" - }, - "filter": true - }, - "system_bus": { - "see": null, - "talk": [ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower" - ], - "own": null, - "call": null, - "broadcast": null, - "filter": true - }, - "username": "chronos", - "shell": "/run/current-system/sw/bin/zsh", - "data": "/var/lib/hakurei/u0/org.chromium.Chromium", - "dir": "/data/data/org.chromium.Chromium", - "extra_perms": [ - { - "ensure": true, - "path": "/var/lib/hakurei/u0", - "x": true - }, - { - "path": "/var/lib/hakurei/u0/org.chromium.Chromium", - "r": true, - "w": true, - "x": true - } - ], - "identity": 9, - "groups": [ - "video", - "dialout", - "plugdev" - ], - "container": { - "hostname": "localhost", - "seccomp_flags": 1, - "seccomp_presets": 1, - "devel": true, - "userns": true, - "net": true, - "tty": true, - "multiarch": true, - "env": { - "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" - }, - "map_real_uid": true, - "device": true, - "filesystem": [ - { - "src": "/nix/store" - }, - { - "src": "/run/current-system" - }, - { - "src": "/run/opengl-driver" - }, - { - "src": "/var/db/nix-channels" - }, - { - "dst": "/data/data/org.chromium.Chromium", - "src": "/var/lib/hakurei/u0/org.chromium.Chromium", - "write": true, - "require": true - }, - { - "src": "/dev/dri", - "dev": true - } - ], - "symlink": [ - [ - "/run/user/65534", - "/run/user/150" - ] - ], - "etc": "/etc", - "auto_etc": true, - "cover": [ - "/var/run/nscd" - ] - } - }, - "time": "1970-01-01T00:00:00.000000009Z" -} -`}, - {"json config", nil, hst.Template(), false, true, `{ - "id": "org.chromium.Chromium", - "path": "/run/current-system/sw/bin/chromium", - "args": [ - "chromium", - "--ignore-gpu-blocklist", - "--disable-smooth-scrolling", - "--enable-features=UseOzonePlatform", - "--ozone-platform=wayland" - ], - "enablements": 13, - "session_bus": { - "see": null, - "talk": [ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager" - ], - "own": [ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*" - ], - "call": { - "org.freedesktop.portal.*": "*" - }, - "broadcast": { - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" - }, - "filter": true - }, - "system_bus": { - "see": null, - "talk": [ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower" - ], - "own": null, - "call": null, - "broadcast": null, - "filter": true - }, - "username": "chronos", - "shell": "/run/current-system/sw/bin/zsh", - "data": "/var/lib/hakurei/u0/org.chromium.Chromium", - "dir": "/data/data/org.chromium.Chromium", - "extra_perms": [ - { - "ensure": true, - "path": "/var/lib/hakurei/u0", - "x": true - }, - { - "path": "/var/lib/hakurei/u0/org.chromium.Chromium", - "r": true, - "w": true, - "x": true - } - ], - "identity": 9, - "groups": [ - "video", - "dialout", - "plugdev" - ], - "container": { - "hostname": "localhost", - "seccomp_flags": 1, - "seccomp_presets": 1, - "devel": true, - "userns": true, - "net": true, - "tty": true, - "multiarch": true, - "env": { - "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" - }, - "map_real_uid": true, - "device": true, - "filesystem": [ - { - "src": "/nix/store" - }, - { - "src": "/run/current-system" - }, - { - "src": "/run/opengl-driver" - }, - { - "src": "/var/db/nix-channels" - }, - { - "dst": "/data/data/org.chromium.Chromium", - "src": "/var/lib/hakurei/u0/org.chromium.Chromium", - "write": true, - "require": true - }, - { - "src": "/dev/dri", - "dev": true - } - ], - "symlink": [ - [ - "/run/user/65534", - "/run/user/150" - ] - ], - "etc": "/etc", - "auto_etc": true, - "cover": [ - "/var/run/nscd" - ] - } -} -`}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - output := new(strings.Builder) - printShowInstance(output, testTime, tc.instance, tc.config, tc.short, tc.json) - if got := output.String(); got != tc.want { - t.Errorf("printShowInstance: got\n%s\nwant\n%s", - got, tc.want) - return - } - }) - } -} - -func Test_printPs(t *testing.T) { - testCases := []struct { - name string - entries state.Entries - short, json bool - want string - }{ - {"no entries", make(state.Entries), false, false, " Instance PID Application Uptime\n"}, - {"no entries short", make(state.Entries), true, false, ""}, - {"nil instance", state.Entries{testID: nil}, false, false, " Instance PID Application Uptime\n"}, - {"state corruption", state.Entries{app.ID{}: testState}, false, false, " Instance PID Application Uptime\n"}, - - {"valid pd", state.Entries{testID: &state.State{ID: testID, PID: 1 << 8, Config: new(hst.Config), Time: testAppTime}}, false, false, ` Instance PID Application Uptime - 8e2c76b0 256 0 (app.hakurei.8e2c76b0) 1h2m32s -`}, - - {"valid", state.Entries{testID: testState}, false, false, ` Instance PID Application Uptime - 8e2c76b0 3735928559 9 (org.chromium.Chromium) 1h2m32s -`}, - {"valid short", state.Entries{testID: testState}, true, false, "8e2c76b0\n"}, - {"valid json", state.Entries{testID: testState}, false, true, `{ - "8e2c76b066dabe574cf073bdb46eb5c1": { - "instance": [ - 142, - 44, - 118, - 176, - 102, - 218, - 190, - 87, - 76, - 240, - 115, - 189, - 180, - 110, - 181, - 193 - ], - "pid": 3735928559, - "config": { - "id": "org.chromium.Chromium", - "path": "/run/current-system/sw/bin/chromium", - "args": [ - "chromium", - "--ignore-gpu-blocklist", - "--disable-smooth-scrolling", - "--enable-features=UseOzonePlatform", - "--ozone-platform=wayland" - ], - "enablements": 13, - "session_bus": { - "see": null, - "talk": [ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager" - ], - "own": [ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*" - ], - "call": { - "org.freedesktop.portal.*": "*" - }, - "broadcast": { - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*" - }, - "filter": true - }, - "system_bus": { - "see": null, - "talk": [ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower" - ], - "own": null, - "call": null, - "broadcast": null, - "filter": true - }, - "username": "chronos", - "shell": "/run/current-system/sw/bin/zsh", - "data": "/var/lib/hakurei/u0/org.chromium.Chromium", - "dir": "/data/data/org.chromium.Chromium", - "extra_perms": [ - { - "ensure": true, - "path": "/var/lib/hakurei/u0", - "x": true - }, - { - "path": "/var/lib/hakurei/u0/org.chromium.Chromium", - "r": true, - "w": true, - "x": true - } - ], - "identity": 9, - "groups": [ - "video", - "dialout", - "plugdev" - ], - "container": { - "hostname": "localhost", - "seccomp_flags": 1, - "seccomp_presets": 1, - "devel": true, - "userns": true, - "net": true, - "tty": true, - "multiarch": true, - "env": { - "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" - }, - "map_real_uid": true, - "device": true, - "filesystem": [ - { - "src": "/nix/store" - }, - { - "src": "/run/current-system" - }, - { - "src": "/run/opengl-driver" - }, - { - "src": "/var/db/nix-channels" - }, - { - "dst": "/data/data/org.chromium.Chromium", - "src": "/var/lib/hakurei/u0/org.chromium.Chromium", - "write": true, - "require": true - }, - { - "src": "/dev/dri", - "dev": true - } - ], - "symlink": [ - [ - "/run/user/65534", - "/run/user/150" - ] - ], - "etc": "/etc", - "auto_etc": true, - "cover": [ - "/var/run/nscd" - ] - } - }, - "time": "1970-01-01T00:00:00.000000009Z" - } -} -`}, - {"valid short json", state.Entries{testID: testState}, true, true, `["8e2c76b066dabe574cf073bdb46eb5c1"] -`}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - output := new(strings.Builder) - printPs(output, testTime, stubStore(tc.entries), tc.short, tc.json) - if got := output.String(); got != tc.want { - t.Errorf("printPs: got\n%s\nwant\n%s", - got, tc.want) - return - } - }) - } -} - -// stubStore implements [state.Store] and returns test samples via [state.Joiner]. -type stubStore state.Entries - -func (s stubStore) Join() (state.Entries, error) { return state.Entries(s), nil } -func (s stubStore) Do(int, func(c state.Cursor)) (bool, error) { panic("unreachable") } -func (s stubStore) List() ([]int, error) { panic("unreachable") } -func (s stubStore) Close() error { return nil } diff --git a/sandbox/container_test.go b/sandbox/container_test.go index d7b774f7..72c50699 100644 --- a/sandbox/container_test.go +++ b/sandbox/container_test.go @@ -30,7 +30,7 @@ func TestContainer(t *testing.T) { { oldVerbose := hlog.Load() oldOutput := sandbox.GetOutput() - internal.InstallFmsg(true) + internal.InstallOutput(true) t.Cleanup(func() { hlog.Store(oldVerbose) }) t.Cleanup(func() { sandbox.SetOutput(oldOutput) }) } @@ -202,7 +202,7 @@ func TestHelperInit(t *testing.T) { return } sandbox.SetOutput(hlog.Output{}) - sandbox.Init(hlog.Prepare, internal.InstallFmsg) + sandbox.Init(hlog.Prepare, internal.InstallOutput) } func TestHelperCheckContainer(t *testing.T) { -- cgit v1.3.1