From b99c63337df4068b36d7d8de2ae79fd274172977 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 24 Sep 2025 13:26:30 +0900 Subject: [PATCH] internal/app: do not return from shim start The whole RunState ugliness and the other horrendous error handling conditions for internal/app come from an old design proposal for maintaining all app containers under the same daemon process for a user. The proposal was ultimately rejected but the implementation remained. It is removed here to alleviate internal/app from much of its ugliness and unreadability. Signed-off-by: Ophestra --- cmd/hakurei/command.go | 184 ++++++++--------- cmd/hakurei/print.go | 11 +- internal/app/errors.go | 184 ----------------- internal/app/process.go | 401 ++++++++++++++++++++++-------------- internal/app/seal.go | 44 ++-- internal/app/state/multi.go | 10 +- internal/app/state/state.go | 2 +- internal/sys/hsu.go | 23 +++ internal/sys/interface.go | 10 +- internal/sys/std.go | 28 +-- 10 files changed, 388 insertions(+), 509 deletions(-) delete mode 100644 internal/app/errors.go diff --git a/cmd/hakurei/command.go b/cmd/hakurei/command.go index eaefb89..f1032f9 100644 --- a/cmd/hakurei/command.go +++ b/cmd/hakurei/command.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "io" "log" @@ -52,29 +51,29 @@ func buildCommand(out io.Writer) command.Command { { var ( - dbusConfigSession string - dbusConfigSystem string - mpris bool - dbusVerbose bool + flagDBusConfigSession string + flagDBusConfigSystem string + flagDBusMpris bool + flagDBusVerbose bool - fid string - aid int - groups command.RepeatableFlag - homeDir string - userName string + flagID string + flagIdentity int + flagGroups command.RepeatableFlag + flagHomeDir string + flagUserName string - wayland, x11, dBus, pulse bool + flagWayland, flagX11, flagDBus, flagPulse bool ) c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { // initialise config from flags config := &hst.Config{ - ID: fid, + ID: flagID, Args: args, } - if aid < 0 || aid > 9999 { - log.Fatalf("aid %d out of range", aid) + if flagIdentity < 0 || flagIdentity > 9999 { + log.Fatalf("identity %d out of range", flagIdentity) } // resolve home/username from os when flag is unset @@ -82,13 +81,7 @@ func buildCommand(out io.Writer) command.Command { passwd *user.User passwdOnce sync.Once passwdFunc = func() { - var us string - if uid, err := std.Uid(aid); err != nil { - fatal("cannot obtain uid from setuid wrapper:", err) - } else { - us = strconv.Itoa(uid) - } - + us := strconv.Itoa(sys.MustUid(std, flagIdentity)) if u, err := user.LookupId(us); err != nil { hlog.Verbosef("cannot look up uid %s", us) passwd = &user.User{ @@ -104,21 +97,21 @@ func buildCommand(out io.Writer) command.Command { } ) - if homeDir == "os" { + if flagHomeDir == "os" { passwdOnce.Do(passwdFunc) - homeDir = passwd.HomeDir + flagHomeDir = passwd.HomeDir } - if userName == "chronos" { + if flagUserName == "chronos" { passwdOnce.Do(passwdFunc) - userName = passwd.Username + flagUserName = passwd.Username } - config.Identity = aid - config.Groups = groups - config.Username = userName + config.Identity = flagIdentity + config.Groups = flagGroups + config.Username = flagUserName - if a, err := container.NewAbs(homeDir); err != nil { + if a, err := container.NewAbs(flagHomeDir); err != nil { log.Fatal(err.Error()) return err } else { @@ -126,43 +119,43 @@ func buildCommand(out io.Writer) command.Command { } var e system.Enablement - if wayland { + if flagWayland { e |= system.EWayland } - if x11 { + if flagX11 { e |= system.EX11 } - if dBus { + if flagDBus { e |= system.EDBus } - if pulse { + if flagPulse { e |= system.EPulse } config.Enablements = hst.NewEnablements(e) // parse D-Bus config file from flags if applicable - if dBus { - if dbusConfigSession == "builtin" { - config.SessionBus = dbus.NewConfig(fid, true, mpris) + if flagDBus { + if flagDBusConfigSession == "builtin" { + config.SessionBus = dbus.NewConfig(flagID, true, flagDBusMpris) } else { - if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { - log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) + if conf, err := dbus.NewConfigFromFile(flagDBusConfigSession); err != nil { + log.Fatalf("cannot load session bus proxy config from %q: %s", flagDBusConfigSession, 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) + if flagDBusConfigSystem != "nil" { + if conf, err := dbus.NewConfigFromFile(flagDBusConfigSystem); err != nil { + log.Fatalf("cannot load system bus proxy config from %q: %s", flagDBusConfigSystem, err) } else { config.SystemBus = conf } } // override log from configuration - if dbusVerbose { + if flagDBusVerbose { if config.SessionBus != nil { config.SessionBus.Log = true } @@ -176,59 +169,63 @@ func buildCommand(out io.Writer) command.Command { runApp(config) panic("unreachable") }). - Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), + Flag(&flagDBusConfigSession, "dbus-config", command.StringFlag("builtin"), "Path to session bus proxy config file, or \"builtin\" for defaults"). - Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"), + Flag(&flagDBusConfigSystem, "dbus-system", command.StringFlag("nil"), "Path to system bus proxy config file, or \"nil\" to disable"). - Flag(&mpris, "mpris", command.BoolFlag(false), + Flag(&flagDBusMpris, "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), + Flag(&flagDBusVerbose, "dbus-log", command.BoolFlag(false), "Force buffered logging in the D-Bus proxy"). - Flag(&fid, "id", command.StringFlag(""), + Flag(&flagID, "id", command.StringFlag(""), "Reverse-DNS style Application identifier, leave empty to inherit instance identifier"). - Flag(&aid, "a", command.IntFlag(0), + Flag(&flagIdentity, "a", command.IntFlag(0), "Application identity"). - Flag(nil, "g", &groups, + Flag(nil, "g", &flagGroups, "Groups inherited by all container processes"). - Flag(&homeDir, "d", command.StringFlag("os"), + Flag(&flagHomeDir, "d", command.StringFlag("os"), "Container home directory"). - Flag(&userName, "u", command.StringFlag("chronos"), + Flag(&flagUserName, "u", command.StringFlag("chronos"), "Passwd user name within sandbox"). - Flag(&wayland, "wayland", command.BoolFlag(false), + Flag(&flagWayland, "wayland", command.BoolFlag(false), "Enable connection to Wayland via security-context-v1"). - Flag(&x11, "X", command.BoolFlag(false), + Flag(&flagX11, "X", command.BoolFlag(false), "Enable direct connection to X11"). - Flag(&dBus, "dbus", command.BoolFlag(false), + Flag(&flagDBus, "dbus", command.BoolFlag(false), "Enable proxied connection to D-Bus"). - Flag(&pulse, "pulse", command.BoolFlag(false), + Flag(&flagPulse, "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) + { + var flagShort bool + c.NewCommand("show", "Show live or local app configuration", func(args []string) error { + switch len(args) { + case 0: // system + printShowSystem(os.Stdout, flagShort, flagJSON) - case 1: // instance - name := args[0] - config, entry := tryShort(name) - if config == nil { - config = tryPath(name) + case 1: // instance + name := args[0] + config, entry := tryShort(name) + if config == nil { + config = tryPath(name) + } + printShowInstance(os.Stdout, time.Now().UTC(), entry, config, flagShort, flagJSON) + + default: + log.Fatal("show requires 1 argument") } - printShowInstance(os.Stdout, time.Now().UTC(), entry, config, showFlagShort, flagJSON) + return errSuccess + }).Flag(&flagShort, "short", command.BoolFlag(false), "Omit filesystem information") + } - 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.String()), psFlagShort, flagJSON) - return errSuccess - }).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id") + { + var flagShort bool + c.NewCommand("ps", "List active instances", func(args []string) error { + printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath.String()), flagShort, flagJSON) + return errSuccess + }).Flag(&flagShort, "short", command.BoolFlag(false), "Print instance id") + } c.Command("version", "Display version information", func(args []string) error { fmt.Println(internal.Version()) @@ -259,34 +256,15 @@ func runApp(config *hst.Config) { defer stop() // unreachable a := app.MustNew(ctx, std) - rs := new(app.RunState) if sa, err := a.Seal(config); err != nil { hlog.BeforeExit() - fatal("cannot seal app:", err) + if m, ok := container.GetErrorMessage(err); ok { + log.Fatal(m) + } else { + log.Fatalln("cannot seal app:", err) + } } else { - hlog.BeforeExit() - os.Exit(app.PrintRunStateErr(rs, sa.Run(rs))) + sa.Main() + panic("unreachable") } - - *(*int)(nil) = 0 // not reached -} - -// fatal prints the error message according to [container.GetErrorMessage], or fallback -// prepended to err if an error message is not available, followed by a call to [os.Exit](1). -func fatal(fallback string, err error) { - // this indicates the error message has already reached stderr, outside the current process's control; - // this is only reached when hsu fails for any reason, as a second error message following hsu is confusing - if errors.Is(err, sys.ErrHsuAccess) { - hlog.Verbose("*"+fallback, err) - os.Exit(1) - return - } - - m, ok := container.GetErrorMessage(err) - if !ok { - log.Fatalln(fallback, err) - return - } - - log.Fatal(m) } diff --git a/cmd/hakurei/print.go b/cmd/hakurei/print.go index 9e1ede9..387b5c2 100644 --- a/cmd/hakurei/print.go +++ b/cmd/hakurei/print.go @@ -13,6 +13,7 @@ import ( "hakurei.app/hst" "hakurei.app/internal/app/state" + "hakurei.app/internal/sys" "hakurei.app/system/dbus" ) @@ -20,13 +21,9 @@ func printShowSystem(output io.Writer, short, flagJSON bool) { t := newPrinter(output) defer t.MustFlush() - info := &hst.Info{Paths: std.Paths()} - - // get hid by querying uid of identity 0 - if uid, err := std.Uid(0); err != nil { - fatal("cannot obtain uid from setuid wrapper:", err) - } else { - info.User = (uid / 10000) - 100 + info := &hst.Info{ + Paths: std.Paths(), + User: sys.MustGetUserID(std), } if flagJSON { diff --git a/internal/app/errors.go b/internal/app/errors.go deleted file mode 100644 index f7fa2b1..0000000 --- a/internal/app/errors.go +++ /dev/null @@ -1,184 +0,0 @@ -package app - -import ( - "errors" - "log" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal/hlog" - "hakurei.app/internal/sys" -) - -// PrintRunStateErr prints an error message via [log] if runErr is not nil, and returns an appropriate exit code. -// -// TODO(ophestra): remove this function once RunState has been replaced -func PrintRunStateErr(rs *RunState, runErr error) (code int) { - code = rs.ExitStatus() - - if runErr != nil { - if rs.Time == nil { - // no process has been created - printMessageError("cannot start app:", runErr) - } else { - if m, ok := container.GetErrorMessage(runErr); !ok { - // catch-all for unexpected errors - log.Println("run returned error:", runErr) - } else { - var se *StateStoreError - if !errors.As(runErr, &se) { - // this could only be returned from a shim setup failure path - log.Print(m) - } else { - // InnerErr is returned by c.Save(&sd, seal.ct), and are always unwrapped - printMessageError("error returned during revert:", - &hst.AppError{Step: "save process state", Err: se.InnerErr}) - } - } - } - - if code == 0 { - code = 126 - } - } - - if rs.RevertErr != nil { - var stateStoreError *StateStoreError - if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil { - printMessageError("cannot clean up:", rs.RevertErr) - goto out - } - - if stateStoreError.Errs != nil { - if len(stateStoreError.Errs) == 2 { // storeErr.save(revertErr, store.Close()) - if stateStoreError.Errs[0] != nil { // revertErr is MessageError joined by errors.Join - var joinedErrors interface { - Unwrap() []error - error - } - if !errors.As(stateStoreError.Errs[0], &joinedErrors) { - printMessageError("cannot revert:", stateStoreError.Errs[0]) - } else { - for _, err := range joinedErrors.Unwrap() { - if err != nil { - printMessageError("cannot revert:", err) - } - } - } - } - if stateStoreError.Errs[1] != nil { // store.Close() is joined by errors.Join - log.Printf("cannot close store: %v", stateStoreError.Errs[1]) - } - } else { - log.Printf("fault during cleanup: %v", errors.Join(stateStoreError.Errs...)) - } - } - - if stateStoreError.OpErr != nil { - log.Printf("blind revert due to store fault: %v", stateStoreError.OpErr) - } - - if stateStoreError.DoErr != nil { - printMessageError("state store operation unsuccessful:", stateStoreError.DoErr) - } - - if stateStoreError.Inner && stateStoreError.InnerErr != nil { - printMessageError("cannot destroy state entry:", stateStoreError.InnerErr) - } - - out: - if code == 0 { - code = 128 - } - } - if rs.WaitErr != nil { - hlog.Verbosef("wait: %v", rs.WaitErr) - } - return -} - -// TODO(ophestra): this duplicates code in cmd/hakurei/command.go, keep this up to date until removal -func printMessageError(fallback string, err error) { - // this indicates the error message has already reached stderr, outside the current process's control; - // this is only reached when hsu fails for any reason, as a second error message following hsu is confusing - if errors.Is(err, sys.ErrHsuAccess) { - hlog.Verbose("*"+fallback, err) - return - } - - m, ok := container.GetErrorMessage(err) - if !ok { - log.Println(fallback, err) - return - } - - log.Print(m) -} - -// 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 - Errs []error -} - -// save saves arbitrary errors in [StateStoreError.Errs] once. -func (e *StateStoreError) save(errs ...error) { - if len(errs) == 0 || e.Errs != nil { - panic("invalid call to save") - } - e.Errs = errs -} - -// equiv returns an error that [StateStoreError] is equivalent to, including nil. -func (e *StateStoreError) equiv(step string) error { - if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Errs...) == nil { - return nil - } else { - return &hst.AppError{Step: step, Err: e} - } -} - -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.Errs...); 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+len(e.Errs)) - 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) - } - for _, err := range e.Errs { - if err != nil { - errs = append(errs, err) - } - } - return -} diff --git a/internal/app/process.go b/internal/app/process.go index daa7832..0ee123a 100644 --- a/internal/app/process.go +++ b/internal/app/process.go @@ -13,7 +13,6 @@ import ( "time" "hakurei.app/container" - "hakurei.app/hst" "hakurei.app/internal" "hakurei.app/internal/app/state" "hakurei.app/internal/hlog" @@ -23,85 +22,166 @@ import ( // duration to wait for shim to exit, after container WaitDelay has elapsed. const shimWaitTimeout = 5 * time.Second -// ErrShimTimeout is returned when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed. -// This is different from the container failing to terminate within its timeout period, as that is enforced -// by the shim. This error is instead returned when there is a lockup in shim preventing it from completing. -var ErrShimTimeout = errors.New("shim did not exit") +// mainState holds persistent state bound to [Outcome.Main]. +type mainState struct { + // done is whether beforeExit has been called already. + done bool -// RunState stores the outcome of a call to [Outcome.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 + seal *Outcome + store state.Store + cancel context.CancelFunc + cmd *exec.Cmd + cmdWait chan error + + uintptr } -// 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 -} +const ( + // mainNeedsRevert indicates the call to Commit has succeeded. + mainNeedsRevert uintptr = 1 << iota + // mainNeedsDestroy indicates the instance state entry is present in the store. + mainNeedsDestroy +) -// Run commits deferred system setup and starts the container. -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") +// beforeExit must be called immediately before a call to [os.Exit]. +func (ms mainState) beforeExit(isFault bool) { + if ms.done { + panic("attempting to call beforeExit twice") + } + ms.done = true + defer hlog.BeforeExit() + + if isFault && ms.cancel != nil { + ms.cancel() } - if rs == nil { - panic("invalid state") + var hasErr bool + // updates hasErr but does not terminate + perror := func(err error, message string) { + hasErr = true + printMessageError("cannot "+message+":", err) } - - // read comp value early to allow for early failure - hsuPath := internal.MustHsuPath() - - if err := seal.sys.Commit(); err != nil { - return err - } - store := state.NewMulti(seal.runDirPath.String()) - deferredStoreFunc := func(c state.Cursor) error { return nil } // noop until state in store + exitCode := 1 defer func() { - var revertErr error - storeErr := new(StateStoreError) - storeErr.Inner, storeErr.DoErr = store.Do(seal.user.identity.unwrap(), func(c state.Cursor) { - revertErr = func() error { - storeErr.InnerErr = deferredStoreFunc(c) + if hasErr { + os.Exit(exitCode) + } + }() - 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) + // this also handles wait for a non-fault termination + if ms.cmd != nil && ms.cmdWait != nil { + waitDone := make(chan struct{}) + // TODO(ophestra): enforce this limit early so it does not have to be done twice + shimTimeoutCompensated := shimWaitTimeout + if ms.seal.waitDelay > MaxShimWaitDelay { + shimTimeoutCompensated += MaxShimWaitDelay + } else { + shimTimeoutCompensated += ms.seal.waitDelay + } + // this ties waitDone to ctx with the additional compensated timeout duration + go func() { <-ms.seal.ctx.Done(); time.Sleep(shimTimeoutCompensated); close(waitDone) }() + + select { + case err := <-ms.cmdWait: + wstatus, ok := ms.cmd.ProcessState.Sys().(syscall.WaitStatus) + if ok { + if v := wstatus.ExitStatus(); v != 0 { + hasErr = true + exitCode = v + } + } + + if hlog.Load() { + if !ok { + if err != nil { + hlog.Verbosef("wait: %v", err) } + } else { + switch { + case wstatus.Exited(): + hlog.Verbosef("process %d exited with code %d", ms.cmd.Process.Pid, wstatus.ExitStatus()) - // accumulate enablements of remaining launchers - for i, s := range states { - if s.Config != nil { - rt |= s.Config.Enablements.Unwrap() - } else { - log.Printf("state entry %d does not contain config", i) + case wstatus.CoreDump(): + hlog.Verbosef("process %d dumped core", ms.cmd.Process.Pid) + + case wstatus.Signaled(): + hlog.Verbosef("process %d got %s", ms.cmd.Process.Pid, wstatus.Signal()) + + default: + hlog.Verbosef("process %d exited with status %#x", ms.cmd.Process.Pid, wstatus) + } + } + } + + case <-waitDone: + hlog.Resume() + // this is only reachable when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed. + // This is different from the container failing to terminate within its timeout period, as that is enforced + // by the shim. This path is instead reached when there is a lockup in shim preventing it from completing. + log.Printf("process %d did not terminate", ms.cmd.Process.Pid) + } + + hlog.Resume() + if ms.seal.sync != nil { + if err := ms.seal.sync.Close(); err != nil { + perror(err, "close wayland security context") + } + } + if ms.seal.dbusMsg != nil { + ms.seal.dbusMsg() + } + } + + if ms.uintptr&mainNeedsRevert != 0 { + if ok, err := ms.store.Do(ms.seal.user.identity.unwrap(), func(c state.Cursor) { + if ms.uintptr&mainNeedsDestroy != 0 { + if err := c.Destroy(ms.seal.id.unwrap()); err != nil { + perror(err, "destroy state entry") + } + } + + var rt system.Enablement + if states, err := c.Load(); err != nil { + // it is impossible to continue from this point; + // revert per-process state here to limit damage + ec := system.Process + if revertErr := ms.seal.sys.Revert((*system.Criteria)(&ec)); revertErr != nil { + var joinError interface { + Unwrap() []error + error + } + if !errors.As(revertErr, &joinError) || joinError == nil { + perror(revertErr, "revert system setup") + } else { + for _, v := range joinError.Unwrap() { + perror(v, "revert system setup step") } } } + perror(err, "load instance states") + } else { + ec := system.Process + 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.Unwrap() + } 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 { @@ -109,27 +189,70 @@ func (seal *Outcome) Run(rs *RunState) error { } } - return seal.sys.Revert((*system.Criteria)(&ec)) - }() - }) - storeErr.save(revertErr, store.Close()) - rs.RevertErr = storeErr.equiv("clean up") - }() + if err = ms.seal.sys.Revert((*system.Criteria)(&ec)); err != nil { + perror(err, "revert system setup") + } + } + }); err != nil { + if ok { + perror(err, "unlock state store") + } else { + perror(err, "open state store") + } + } + } else if ms.uintptr&mainNeedsDestroy != 0 { + panic("unreachable") + } + + if ms.store != nil { + if err := ms.store.Close(); err != nil { + perror(err, "close state store") + } + } +} + +// fatal calls printMessageError, performs necessary cleanup, followed by a call to [os.Exit](1). +func (ms mainState) fatal(fallback string, ferr error) { + printMessageError(fallback, ferr) + ms.beforeExit(true) + os.Exit(1) +} + +// Main commits deferred system setup, runs the container, reverts changes to the system, and terminates the program. +// Main does not return. +func (seal *Outcome) Main() { + if !seal.f.CompareAndSwap(false, true) { + panic("outcome: attempted to run twice") + } + + // read comp value early for early failure + hsuPath := internal.MustHsuPath() + + // ms.beforeExit required beyond this point + ms := &mainState{seal: seal} + + if err := seal.sys.Commit(); err != nil { + ms.fatal("cannot commit system setup:", err) + } + ms.uintptr |= mainNeedsRevert + ms.store = state.NewMulti(seal.runDirPath.String()) 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.FHSRoot // container init enters final working directory + ms.cancel = cancel + + ms.cmd = exec.CommandContext(ctx, hsuPath) + ms.cmd.Stdin, ms.cmd.Stdout, ms.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + ms.cmd.Dir = container.FHSRoot // 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) } + ms.cmd.Cancel = func() error { return ms.cmd.Process.Signal(syscall.SIGCONT) } var e *gob.Encoder - if fd, encoder, err := container.Setup(&cmd.ExtraFiles); err != nil { - return &hst.AppError{Step: "create shim setup pipe", Err: err} + if fd, encoder, err := container.Setup(&ms.cmd.ExtraFiles); err != nil { + ms.fatal("cannot create shim setup pipe:", err) } else { e = encoder - cmd.Env = []string{ + ms.cmd.Env = []string{ // passed through to shim by hsu shimEnv + "=" + strconv.Itoa(fd), // interpreted by hsu @@ -140,102 +263,78 @@ func (seal *Outcome) Run(rs *RunState) error { 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, " ")) + ms.cmd.Env = append(ms.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 &hst.AppError{Step: "start setuid wrapper", Err: err} + if err := ms.cmd.Start(); err != nil { + ms.fatal("cannot start setuid wrapper:", err) } - 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.waitDelay, - seal.container, - hlog.Load(), - }) - }() + startTime := time.Now().UTC() + ms.cmdWait = make(chan error, 1) + // this ties context back to the life of the process + go func() { ms.cmdWait <- ms.cmd.Wait(); cancel() }() + ms.Time = &startTime + // unfortunately the I/O here cannot be directly canceled; + // the cancellation path leads to fatal in this case so that is fine select { - case err := <-setupErr: + case err := <-func() (setupErr chan error) { + setupErr = make(chan error, 1) + go func() { + setupErr <- e.Encode(&shimParams{ + os.Getpid(), + seal.waitDelay, + seal.container, + hlog.Load(), + }) + }() + return + }(): if err != nil { hlog.Resume() - return &hst.AppError{Step: "transmit shim config", Err: err} + ms.fatal("cannot transmit shim config:", err) } case <-ctx.Done(): hlog.Resume() - return newWithMessageError("shim setup canceled", syscall.ECANCELED) + ms.fatal("shim context canceled:", newWithMessageError("shim setup canceled", ctx.Err())) } - // returned after blocking on waitErr - var earlyStoreErr = new(StateStoreError) - { - // shim accepted setup payload, create process state - sd := state.State{ + // shim accepted setup payload, create process state + if ok, err := ms.store.Do(seal.user.identity.unwrap(), func(c state.Cursor) { + if err := c.Save(&state.State{ ID: seal.id.unwrap(), - PID: cmd.Process.Pid, - Time: *rs.Time, + PID: ms.cmd.Process.Pid, + Time: *ms.Time, + }, seal.ct); err != nil { + ms.fatal("cannot save state entry:", err) } - earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.identity.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{}) - // TODO(ophestra): enforce this limit early so it does not have to be done twice - shimTimeoutCompensated := shimWaitTimeout - if seal.waitDelay > MaxShimWaitDelay { - shimTimeoutCompensated += MaxShimWaitDelay - } else { - shimTimeoutCompensated += seal.waitDelay - } - go func() { <-seal.ctx.Done(); time.Sleep(shimTimeoutCompensated); 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 = ErrShimTimeout - hlog.Resume() - // TODO(ophestra): verify this behaviour in vm tests - 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) + }); err != nil { + if ok { + ms.uintptr |= mainNeedsDestroy + ms.fatal("cannot unlock state store:", err) + } else { + ms.fatal("cannot open state store:", err) } } - if seal.dbusMsg != nil { - seal.dbusMsg() - } + // state in store at this point, destroy defunct state entry on termination + ms.uintptr |= mainNeedsDestroy - return earlyStoreErr.equiv("save process state") + // beforeExit ties shim process to context + ms.beforeExit(false) + os.Exit(0) +} + +// printMessageError prints the error message according to [container.GetErrorMessage], +// or fallback prepended to err if an error message is not available. +func printMessageError(fallback string, err error) { + m, ok := container.GetErrorMessage(err) + if !ok { + log.Println(fallback, err) + return + } + + log.Print(m) } diff --git a/internal/app/seal.go b/internal/app/seal.go index e6cccec..cafd691 100644 --- a/internal/app/seal.go +++ b/internal/app/seal.go @@ -119,7 +119,7 @@ type hsuUser struct { username string } -func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Config) error { +func (seal *Outcome) finalise(ctx context.Context, k sys.State, config *hst.Config) error { const ( home = "HOME" shell = "SHELL" @@ -176,19 +176,17 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co home: config.Home, username: config.Username, } + if seal.user.username == "" { seal.user.username = "chronos" } else if !isValidUsername(seal.user.username) { return newWithMessage(fmt.Sprintf("invalid user name %q", seal.user.username)) } - if u, err := sys.Uid(seal.user.identity.unwrap()); err != nil { - return err - } else { - seal.user.uid = newInt(u) - } + seal.user.uid = newInt(sys.MustUid(k, seal.user.identity.unwrap())) + seal.user.supp = make([]string, len(config.Groups)) for i, name := range config.Groups { - if g, err := sys.LookupGroup(name); err != nil { + if g, err := k.LookupGroup(name); err != nil { return newWithMessageError(fmt.Sprintf("unknown group %q", name), err) } else { seal.user.supp[i] = g.Gid @@ -201,7 +199,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co if config.Shell == nil { config.Shell = container.AbsFHSRoot.Append("bin", "sh") - s, _ := sys.LookupEnv(shell) + s, _ := k.LookupEnv(shell) if a, err := container.NewAbs(s); err == nil { config.Shell = a } @@ -210,7 +208,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co // hsu clears the environment so resolve paths early if config.Path == nil { if len(config.Args) > 0 { - if p, err := sys.LookPath(config.Args[0]); err != nil { + if p, err := k.LookPath(config.Args[0]); err != nil { return &hst.AppError{Step: "look up executable file", Err: err} } else if config.Path, err = container.NewAbs(p); err != nil { return newWithMessageError(err.Error(), err) @@ -246,7 +244,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co // hide nscd from container if present nscd := container.AbsFHSVar.Append("run/nscd") - if _, err := sys.Stat(nscd.String()); !errors.Is(err, fs.ErrNotExist) { + if _, err := k.Stat(nscd.String()); !errors.Is(err, fs.ErrNotExist) { conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSEphemeral{Target: nscd}}) } @@ -274,7 +272,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co { var uid, gid int var err error - seal.container, seal.env, err = newContainer(config.Container, sys, seal.id.String(), &uid, &gid) + seal.container, seal.env, err = newContainer(config.Container, k, seal.id.String(), &uid, &gid) seal.waitDelay = config.Container.WaitDelay if err != nil { return &hst.AppError{Step: "initialise container configuration", Err: err} @@ -298,7 +296,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co seal.env[xdgSessionClass] = "user" seal.env[xdgSessionType] = "tty" - share := &shareHost{seal: seal, sc: sys.Paths()} + share := &shareHost{seal: seal, sc: k.Paths()} seal.runDirPath = share.sc.RunDirPath seal.sys = system.New(seal.ctx, seal.user.uid.unwrap()) seal.sys.Ensure(share.sc.SharePath.String(), 0711) @@ -342,14 +340,14 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co } // pass TERM for proper terminal I/O in initial process - if t, ok := sys.LookupEnv(term); ok { + if t, ok := k.LookupEnv(term); ok { seal.env[term] = t } if config.Enablements.Unwrap()&system.EWayland != 0 { // outer wayland socket (usually `/run/user/%d/wayland-%d`) var socketPath *container.Absolute - if name, ok := sys.LookupEnv(wayland.WaylandDisplay); !ok { + if name, ok := k.LookupEnv(wayland.WaylandDisplay); !ok { hlog.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) socketPath = share.sc.RuntimePath.Append(wayland.FallbackName) } else if a, err := container.NewAbs(name); err != nil { @@ -380,7 +378,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co } if config.Enablements.Unwrap()&system.EX11 != 0 { - if d, ok := sys.LookupEnv(display); !ok { + if d, ok := k.LookupEnv(display); !ok { return newWithMessage("DISPLAY is not set") } else { socketDir := container.AbsFHSTmp.Append(".X11-unix") @@ -398,7 +396,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co } } if socketPath != nil { - if _, err := sys.Stat(socketPath.String()); err != nil { + if _, err := k.Stat(socketPath.String()); err != nil { if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} } @@ -422,14 +420,14 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co // PulseAudio socket (usually `/run/user/%d/pulse/native`) pulseSocket := pulseRuntimeDir.Append("native") - if _, err := sys.Stat(pulseRuntimeDir.String()); err != nil { + if _, err := k.Stat(pulseRuntimeDir.String()); err != nil { if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} } return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) } - if s, err := sys.Stat(pulseSocket.String()); err != nil { + if s, err := k.Stat(pulseSocket.String()); err != nil { if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} } @@ -453,7 +451,7 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co const paLocateStep = "locate PulseAudio cookie" // from environment - if p, ok := sys.LookupEnv(pulseCookie); ok { + if p, ok := k.LookupEnv(pulseCookie); ok { if a, err := container.NewAbs(p); err != nil { return &hst.AppError{Step: paLocateStep, Err: err} } else { @@ -464,14 +462,14 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co } // $HOME/.pulse-cookie - if p, ok := sys.LookupEnv(home); ok { + if p, ok := k.LookupEnv(home); ok { if a, err := container.NewAbs(p); err != nil { return &hst.AppError{Step: paLocateStep, Err: err} } else { paCookiePath = a.Append(".pulse-cookie") } - if s, err := sys.Stat(paCookiePath.String()); err != nil { + if s, err := k.Stat(paCookiePath.String()); err != nil { paCookiePath = nil if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: "access PulseAudio cookie", Err: err} @@ -485,13 +483,13 @@ func (seal *Outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co } // $XDG_CONFIG_HOME/pulse/cookie - if p, ok := sys.LookupEnv(xdgConfigHome); ok { + if p, ok := k.LookupEnv(xdgConfigHome); ok { if a, err := container.NewAbs(p); err != nil { return &hst.AppError{Step: paLocateStep, Err: err} } else { paCookiePath = a.Append("pulse", "cookie") } - if s, err := sys.Stat(paCookiePath.String()); err != nil { + if s, err := k.Stat(paCookiePath.String()); err != nil { paCookiePath = nil if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: "access PulseAudio cookie", Err: err} diff --git a/internal/app/state/multi.go b/internal/app/state/multi.go index 80a3abf..9a400ac 100644 --- a/internal/app/state/multi.go +++ b/internal/app/state/multi.go @@ -27,27 +27,27 @@ type multiStore struct { lock sync.RWMutex } -func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) { +func (s *multiStore) Do(identity 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 { + if v, ok := s.backends.LoadOrStore(identity, b); ok { b = v.(*multiBackend) } else { - b.path = path.Join(s.base, strconv.Itoa(aid)) + b.path = path.Join(s.base, strconv.Itoa(identity)) // ensure directory if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) { - s.backends.CompareAndDelete(aid, b) + s.backends.CompareAndDelete(identity, 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) + s.backends.CompareAndDelete(identity, b) return false, err } else { b.lockfile = l diff --git a/internal/app/state/state.go b/internal/app/state/state.go index 42750ec..6a3a82d 100644 --- a/internal/app/state/state.go +++ b/internal/app/state/state.go @@ -17,7 +17,7 @@ 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) + Do(identity 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. diff --git a/internal/sys/hsu.go b/internal/sys/hsu.go index 84bb073..2be12d1 100644 --- a/internal/sys/hsu.go +++ b/internal/sys/hsu.go @@ -3,6 +3,7 @@ package sys import ( "errors" "fmt" + "log" "os" "os/exec" "strconv" @@ -11,6 +12,7 @@ import ( "hakurei.app/container" "hakurei.app/hst" "hakurei.app/internal" + "hakurei.app/internal/hlog" ) // Hsu caches responses from cmd/hsu. @@ -79,3 +81,24 @@ func (h *Hsu) Uid(identity int) (int, error) { } return u.uid, u.err } + +// MustUid calls [State.Uid] and terminates on error. +func MustUid(s State, identity int) int { + uid, err := s.Uid(identity) + if err == nil { + return uid + } + + const fallback = "cannot obtain uid from setuid wrapper:" + if errors.Is(err, ErrHsuAccess) { + hlog.Verbose("*"+fallback, err) + os.Exit(1) + return -0xdeadbeef + } else if m, ok := container.GetErrorMessage(err); ok { + log.Fatal(m) + return -0xdeadbeef + } else { + log.Fatalln(fallback, err) + return -0xdeadbeef + } +} diff --git a/internal/sys/interface.go b/internal/sys/interface.go index ed765ec..f2465e6 100644 --- a/internal/sys/interface.go +++ b/internal/sys/interface.go @@ -49,14 +49,8 @@ type State interface { Uid(identity int) (int, error) } -// GetUserID obtains user id from hsu by querying uid of identity 0. -func GetUserID(os State) (int, error) { - if uid, err := os.Uid(0); err != nil { - return -1, err - } else { - return (uid / 10000) - 100, nil - } -} +// MustGetUserID obtains user id from hsu by querying uid of identity 0. +func MustGetUserID(os State) int { return (MustUid(os, 0) / 10000) - 100 } // CopyPaths is a generic implementation of [hst.Paths]. func CopyPaths(os State, v *hst.Paths, userid int) { diff --git a/internal/sys/std.go b/internal/sys/std.go index 24b36fa..11334c7 100644 --- a/internal/sys/std.go +++ b/internal/sys/std.go @@ -1,9 +1,7 @@ package sys import ( - "errors" "io/fs" - "log" "os" "os/exec" "os/user" @@ -41,30 +39,6 @@ func (s *Std) Printf(format string, v ...any) { hlog.Verbosef(form const xdgRuntimeDir = "XDG_RUNTIME_DIR" func (s *Std) Paths() hst.Paths { - s.pathsOnce.Do(func() { - if userid, err := GetUserID(s); err != nil { - // TODO(ophestra): this duplicates code in cmd/hakurei/command.go, keep this up to date until removal - hlog.BeforeExit() - const fallback = "cannot obtain user id from hsu:" - - // this indicates the error message has already reached stderr, outside the current process's control; - // this is only reached when hsu fails for any reason, as a second error message following hsu is confusing - if errors.Is(err, ErrHsuAccess) { - hlog.Verbose("*"+fallback, err) - os.Exit(1) - return - } - - m, ok := container.GetErrorMessage(err) - if !ok { - log.Fatalln(fallback, err) - return - } - - log.Fatal(m) - } else { - CopyPaths(s, &s.paths, userid) - } - }) + s.pathsOnce.Do(func() { CopyPaths(s, &s.paths, MustGetUserID(s)) }) return s.paths }