From f7bd28118c4ac34a9a444073b31ca8f2579188f3 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 29 Jul 2025 03:06:49 +0900 Subject: hst: configurable wait delay This is useful for programs that take a long time to clean up. Signed-off-by: Ophestra --- cmd/hakurei/print_test.go | 6 +- hst/container.go | 8 +- hst/template.go | 24 +++--- hst/template_test.go | 2 +- internal/app/container_linux.go | 2 +- internal/app/errors.go | 181 ++++++++++++++++++++++++++++++++++++++++ internal/app/errors_linux.go | 181 ---------------------------------------- internal/app/process_linux.go | 10 ++- internal/app/seal_linux.go | 3 + internal/app/shim_linux.go | 18 +++- internal/app/strings.go | 19 +++++ internal/app/strings_linux.go | 19 ----- nixos.nix | 2 +- options.nix | 12 ++- test/configuration.nix | 2 +- 15 files changed, 262 insertions(+), 227 deletions(-) create mode 100644 internal/app/errors.go delete mode 100644 internal/app/errors_linux.go create mode 100644 internal/app/strings.go delete mode 100644 internal/app/strings_linux.go diff --git a/cmd/hakurei/print_test.go b/cmd/hakurei/print_test.go index b9d4bb27..efe6ffb8 100644 --- a/cmd/hakurei/print_test.go +++ b/cmd/hakurei/print_test.go @@ -256,7 +256,7 @@ App ], "container": { "hostname": "localhost", - "immediate_termination": true, + "wait_delay": -1, "seccomp_flags": 1, "seccomp_presets": 1, "seccomp_compat": true, @@ -384,7 +384,7 @@ App ], "container": { "hostname": "localhost", - "immediate_termination": true, + "wait_delay": -1, "seccomp_flags": 1, "seccomp_presets": 1, "seccomp_compat": true, @@ -566,7 +566,7 @@ func Test_printPs(t *testing.T) { ], "container": { "hostname": "localhost", - "immediate_termination": true, + "wait_delay": -1, "seccomp_flags": 1, "seccomp_presets": 1, "seccomp_compat": true, diff --git a/hst/container.go b/hst/container.go index 1ee9e839..c385344c 100644 --- a/hst/container.go +++ b/hst/container.go @@ -1,6 +1,8 @@ package hst import ( + "time" + "hakurei.app/container/seccomp" ) @@ -10,8 +12,10 @@ type ( // container hostname Hostname string `json:"hostname,omitempty"` - // do not interrupt and wait for initial process during termination - ImmediateTermination bool `json:"immediate_termination,omitempty"` + // duration to wait for after interrupting a container's initial process in nanoseconds; + // a negative value causes the container to be terminated immediately on cancellation + WaitDelay time.Duration `json:"wait_delay,omitempty"` + // extra seccomp flags SeccompFlags seccomp.ExportFlag `json:"seccomp_flags"` // extra seccomp presets diff --git a/hst/template.go b/hst/template.go index 60af2ebc..3ab9c277 100644 --- a/hst/template.go +++ b/hst/template.go @@ -57,18 +57,18 @@ func Template() *Config { Groups: []string{"video", "dialout", "plugdev"}, Container: &ContainerConfig{ - Hostname: "localhost", - Devel: true, - Userns: true, - Net: true, - Device: true, - ImmediateTermination: true, - SeccompFlags: seccomp.AllowMultiarch, - SeccompPresets: seccomp.PresetExt, - SeccompCompat: true, - Tty: true, - Multiarch: true, - MapRealUID: true, + Hostname: "localhost", + Devel: true, + Userns: true, + Net: true, + Device: true, + WaitDelay: -1, + SeccompFlags: seccomp.AllowMultiarch, + SeccompPresets: seccomp.PresetExt, + SeccompCompat: true, + Tty: true, + Multiarch: true, + MapRealUID: true, // example API credentials pulled from Google Chrome // DO NOT USE THESE IN A REAL BROWSER Env: map[string]string{ diff --git a/hst/template_test.go b/hst/template_test.go index a9b92262..8761b7d4 100644 --- a/hst/template_test.go +++ b/hst/template_test.go @@ -80,7 +80,7 @@ func TestTemplate(t *testing.T) { ], "container": { "hostname": "localhost", - "immediate_termination": true, + "wait_delay": -1, "seccomp_flags": 1, "seccomp_presets": 1, "seccomp_compat": true, diff --git a/internal/app/container_linux.go b/internal/app/container_linux.go index baea57d9..5efd3de7 100644 --- a/internal/app/container_linux.go +++ b/internal/app/container_linux.go @@ -35,7 +35,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, uid, gid *int) (*contain // the container is canceled when shim is requested to exit or receives an interrupt or termination signal; // this behaviour is implemented in the shim - ForwardCancel: !s.ImmediateTermination, + ForwardCancel: s.WaitDelay >= 0, } { diff --git a/internal/app/errors.go b/internal/app/errors.go new file mode 100644 index 00000000..b9bd6f57 --- /dev/null +++ b/internal/app/errors.go @@ -0,0 +1,181 @@ +package app + +import ( + "errors" + "log" + + "hakurei.app/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/errors_linux.go b/internal/app/errors_linux.go deleted file mode 100644 index b9bd6f57..00000000 --- a/internal/app/errors_linux.go +++ /dev/null @@ -1,181 +0,0 @@ -package app - -import ( - "errors" - "log" - - "hakurei.app/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/process_linux.go b/internal/app/process_linux.go index 818c7d7b..516beb4b 100644 --- a/internal/app/process_linux.go +++ b/internal/app/process_linux.go @@ -123,7 +123,15 @@ func (seal *outcome) Run(rs *RunState) error { // 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()}) }() + go func() { + setupErr <- e.Encode(&shimParams{ + os.Getpid(), + seal.waitDelay, + seal.container, + seal.user.data, + hlog.Load(), + }) + }() select { case err := <-setupErr: diff --git a/internal/app/seal_linux.go b/internal/app/seal_linux.go index 40c68c1b..13257211 100644 --- a/internal/app/seal_linux.go +++ b/internal/app/seal_linux.go @@ -15,6 +15,7 @@ import ( "strings" "sync/atomic" "syscall" + "time" "hakurei.app/container" "hakurei.app/hst" @@ -79,6 +80,7 @@ type outcome struct { sys *system.I ctx context.Context + waitDelay time.Duration container *container.Params env map[string]string sync *os.File @@ -281,6 +283,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, &uid, &gid) + seal.waitDelay = config.Container.WaitDelay if err != nil { return hlog.WrapErrSuffix(err, "cannot initialise container configuration:") diff --git a/internal/app/shim_linux.go b/internal/app/shim_linux.go index c88c8672..e423b321 100644 --- a/internal/app/shim_linux.go +++ b/internal/app/shim_linux.go @@ -28,6 +28,10 @@ type shimParams struct { // monitor pid, checked against ppid in signal handler Monitor int + // duration to wait for after interrupting a container's initial process before the container is killed; + // zero value defaults to [DefaultShimWaitDelay], values exceeding [MaxShimWaitDelay] becomes [MaxShimWaitDelay] + WaitDelay time.Duration + // finalised container params Container *container.Params // path to outer home directory @@ -43,9 +47,8 @@ const ( // ShimExitOrphan is returned when the shim is orphaned before monitor delivers a signal. ShimExitOrphan = 3 - // ShimWaitDelay is the duration to wait after interrupting a container's initial process - // before the container is fully killed off. - ShimWaitDelay = 5 * time.Second + DefaultShimWaitDelay = 5 * time.Second + MaxShimWaitDelay = 30 * time.Second ) // ShimMain is the main function of the shim process and runs as the unconstrained target user. @@ -163,7 +166,14 @@ func ShimMain() { z := container.New(ctx, name) z.Params = *params.Container z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr - z.WaitDelay = ShimWaitDelay + + z.WaitDelay = params.WaitDelay + if z.WaitDelay == 0 { + z.WaitDelay = DefaultShimWaitDelay + } + if z.WaitDelay > MaxShimWaitDelay { + z.WaitDelay = MaxShimWaitDelay + } if err := z.Start(); err != nil { hlog.PrintBaseError(err, "cannot start container:") diff --git a/internal/app/strings.go b/internal/app/strings.go new file mode 100644 index 00000000..e4465cee --- /dev/null +++ b/internal/app/strings.go @@ -0,0 +1,19 @@ +package app + +import ( + "strconv" + + "hakurei.app/internal/app/state" +) + +func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } +func newID(id *state.ID) *stringPair[state.ID] { return &stringPair[state.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/app/strings_linux.go b/internal/app/strings_linux.go deleted file mode 100644 index e4465cee..00000000 --- a/internal/app/strings_linux.go +++ /dev/null @@ -1,19 +0,0 @@ -package app - -import ( - "strconv" - - "hakurei.app/internal/app/state" -) - -func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } -func newID(id *state.ID) *stringPair[state.ID] { return &stringPair[state.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/nixos.nix b/nixos.nix index 919b2c96..bda29884 100644 --- a/nixos.nix +++ b/nixos.nix @@ -128,7 +128,7 @@ in container = { inherit (app) - immediate_termination + wait_delay devel userns net diff --git a/options.nix b/options.nix index 17c55372..90ad3565 100644 --- a/options.nix +++ b/options.nix @@ -76,6 +76,7 @@ in type = let inherit (types) + int ints str bool @@ -195,7 +196,16 @@ in ''; }; - immediate_termination = mkEnableOption "immediate termination of the container on interrupt"; + wait_delay = mkOption { + type = nullOr int; + default = null; + description = '' + Duration to wait for after interrupting a container's initial process in nanoseconds. + A negative value causes the container to be terminated immediately on cancellation. + Setting this to null defaults to five seconds. + ''; + }; + devel = mkEnableOption "debugging-related kernel interfaces"; userns = mkEnableOption "user namespace creation"; tty = mkEnableOption "access to the controlling terminal"; diff --git a/test/configuration.nix b/test/configuration.nix index 58bc19a0..e1fa892d 100644 --- a/test/configuration.nix +++ b/test/configuration.nix @@ -132,7 +132,7 @@ identity = 1; shareUid = true; verbose = true; - immediate_termination = true; + wait_delay = -1; share = pkgs.foot; packages = [ ]; command = "foot"; -- cgit v1.3.1