diff --git a/go/internal/guestd/boot_test.go b/go/internal/guestd/boot_test.go index cbb4270e9..5ee524a1c 100644 --- a/go/internal/guestd/boot_test.go +++ b/go/internal/guestd/boot_test.go @@ -14,6 +14,7 @@ import ( "context" "errors" "slices" + "syscall" "testing" ) @@ -59,8 +60,8 @@ func (f *fakeMount) Mount() error { // terminal step, exactly as production does. served holds the service serve was // handed, or nil if serve was never reached. cmdline is the kernel command line // the readCmdline step returns; cmdlineErr, when non-nil, makes that step fail. -func newSteps(rec *recorder, apiErr, netErr, mountErr, cmdlineErr error, cmdline string) (bootSteps, **healthService, chan struct{}) { - served := new(*healthService) +func newSteps(rec *recorder, apiErr, netErr, mountErr, cmdlineErr error, cmdline string) (bootSteps, **supervisor, chan struct{}) { + served := new(*supervisor) reached := make(chan struct{}) steps := bootSteps{ mountAPIFilesystems: func() error { @@ -73,13 +74,17 @@ func newSteps(rec *recorder, apiErr, netErr, mountErr, cmdlineErr error, cmdline }, net: &fakeNet{rec: rec, err: netErr}, workspace: &fakeMount{rec: rec, err: mountErr}, - serve: func(ctx context.Context, _ uint32, svc *healthService) error { + serve: func(ctx context.Context, _ uint32, svc *supervisor) error { rec.mark("serve") *served = svc close(reached) <-ctx.Done() return ctx.Err() }, + powerOff: func() error { + rec.mark("poweroff") + return nil + }, } return steps, served, reached } @@ -279,3 +284,90 @@ func TestBootFailsClosedOnBadCmdline(t *testing.T) { t.Fatal("a health service was constructed despite a bad cmdline") } } + +// TestBootPowersOffOnRPCStop drives the full run() poweroff gate: a serve that +// simulates an RPC Stop (flags rpcStop + cancels serving via initiateStop) then +// drains clean must end in reboot(RB_POWER_OFF), since a bare PID-1 exit panics +// the kernel (§(d)). Exercises the integration TestRPCStopCancels... asserts in +// isolation. +func TestBootPowersOffOnRPCStop(t *testing.T) { + rec := &recorder{} + steps := bootSteps{ + mountAPIFilesystems: func() error { rec.mark("api"); return nil }, + readCmdline: func() ([]byte, error) { rec.mark("cmdline"); return []byte("compass.vsock_port=1024"), nil }, + net: &fakeNet{rec: rec}, + workspace: &fakeMount{rec: rec}, + serve: func(_ context.Context, _ uint32, svc *supervisor) error { + rec.mark("serve") + svc.initiateStop(syscall.SIGTERM) // RPC Stop: sets rpcStop, cancels serving + return nil // clean drain + }, + powerOff: func() error { rec.mark("poweroff"); return nil }, + } + if err := run(t.Context(), config{}, steps); err != nil { + t.Fatalf("run after a clean RPC stop = %v, want nil", err) + } + if !rec.ran("poweroff") { + t.Fatalf("power-off did not run after an RPC stop; order was %v", rec.steps) + } +} + +// TestBootPowersOffOnRPCStopDespiteDrainError is the reliability contract: an +// RPC Stop whose graceful drain overran (serve returns a non-nil error, e.g. a +// child that ignored SIGTERM held Shutdown past its deadline) must STILL power +// off, so the VMM observes a real guest shutdown within the host's timeout +// instead of burning it to a hard kill. The poweroff is gated on rpcStop alone, +// not on a clean serveErr. +func TestBootPowersOffOnRPCStopDespiteDrainError(t *testing.T) { + rec := &recorder{} + drainErr := errors.New("shutdown deadline exceeded") + steps := bootSteps{ + mountAPIFilesystems: func() error { rec.mark("api"); return nil }, + readCmdline: func() ([]byte, error) { rec.mark("cmdline"); return []byte("compass.vsock_port=1024"), nil }, + net: &fakeNet{rec: rec}, + workspace: &fakeMount{rec: rec}, + serve: func(_ context.Context, _ uint32, svc *supervisor) error { + rec.mark("serve") + svc.initiateStop(syscall.SIGTERM) + return drainErr // drain overran + }, + powerOff: func() error { rec.mark("poweroff"); return nil }, + } + // run returns powerOff()'s result (nil), NOT the drain error — the guest + // powered off, so main never falls through to a bare PID-1 exit. + if err := run(t.Context(), config{}, steps); err != nil { + t.Fatalf("run after an RPC stop with a drain error = %v, want nil (powered off)", err) + } + if !rec.ran("poweroff") { + t.Fatalf("power-off was skipped on a drain error during an RPC stop; order was %v", rec.steps) + } +} + +// TestBootDoesNotPowerOffOnSignalCancel is the negative gate: a Unix-signal +// shutdown (ctx cancelled, no RPC Stop) must NOT power off — rpcStop is false, +// so run returns the serve error and lets main exit (the V2a path), and the +// host observes the dial failure. +func TestBootDoesNotPowerOffOnSignalCancel(t *testing.T) { + rec := &recorder{} + steps := bootSteps{ + mountAPIFilesystems: func() error { rec.mark("api"); return nil }, + readCmdline: func() ([]byte, error) { rec.mark("cmdline"); return []byte("compass.vsock_port=1024"), nil }, + net: &fakeNet{rec: rec}, + workspace: &fakeMount{rec: rec}, + serve: func(ctx context.Context, _ uint32, _ *supervisor) error { + rec.mark("serve") + <-ctx.Done() + return ctx.Err() + }, + powerOff: func() error { rec.mark("poweroff"); return nil }, + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() // simulate a Unix-signal shutdown: serve returns on ctx, rpcStop false + err := run(ctx, config{}, steps) + if !errors.Is(err, context.Canceled) { + t.Fatalf("run after a signal cancel = %v, want context.Canceled", err) + } + if rec.ran("poweroff") { + t.Fatalf("power-off ran on a plain signal cancel (rpcStop false); order was %v", rec.steps) + } +} diff --git a/go/internal/guestd/cmdline.go b/go/internal/guestd/cmdline.go index 13f187983..d4a003de1 100644 --- a/go/internal/guestd/cmdline.go +++ b/go/internal/guestd/cmdline.go @@ -3,6 +3,7 @@ package guestd import ( + "encoding/hex" "fmt" "strconv" "strings" @@ -58,3 +59,42 @@ func parseVsockPort(procCmdline string) (uint32, error) { } return uint32(n), nil } + +// bootNonceKey is the kernel-cmdline parameter carrying the per-session boot +// nonce (§(e)) — a random hex value the host generates per session, passes on +// the cmdline beside compass.vsock_port, and expects guestd to echo in +// HealthResponse.boot_nonce. It binds the guest answering the handshake to THIS +// BootConfig (a liveness/identity check against a stale VMM on a recycled +// socket), not an authentication secret. It is OPTIONAL: a V2a-style cmdline +// carries no nonce, so an absent key echoes an empty nonce and Health still +// answers. A present-but-malformed value is a boot-config bug and fail-closes. +const bootNonceKey = "compass.boot_nonce" + +// parseBootNonce extracts compass.boot_nonce= from a /proc/cmdline string, +// following the same last-occurrence-wins tokenisation as parseVsockPort. A +// missing key returns (nil, nil) — the nonce is optional hardening, not a +// fail-closed boot parameter. A present key with an empty or non-hex value is a +// malformed boot config and returns an error. +func parseBootNonce(procCmdline string) ([]byte, error) { + raw := "" + found := false + for tok := range strings.FieldsSeq(procCmdline) { + key, val, ok := strings.Cut(tok, "=") + if !ok || key != bootNonceKey { + continue + } + raw = val + found = true + } + if !found { + return nil, nil + } + if raw == "" { + return nil, fmt.Errorf("kernel cmdline %s has an empty value", bootNonceKey) + } + nonce, err := hex.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("kernel cmdline %s=%q is not valid hex: %w", bootNonceKey, raw, err) + } + return nonce, nil +} diff --git a/go/internal/guestd/cmdline_test.go b/go/internal/guestd/cmdline_test.go index be2234ab9..196fa219c 100644 --- a/go/internal/guestd/cmdline_test.go +++ b/go/internal/guestd/cmdline_test.go @@ -7,7 +7,10 @@ package guestd // the handshake without a valid non-zero vsock port, so every malformed cmdline // is an error and only a well-formed compass.vsock_port= yields a port. -import "testing" +import ( + "bytes" + "testing" +) func TestParseVsockPort(t *testing.T) { tests := []struct { @@ -107,3 +110,69 @@ func TestParseVsockPort(t *testing.T) { }) } } + +// TestParseBootNonce defends the boot-nonce contract (§(e)): the nonce is +// OPTIONAL hardening, so an absent key is (nil, nil) and Health still answers; +// a present key must be valid hex; an empty or non-hex value is a malformed +// boot config and fail-closes. +func TestParseBootNonce(t *testing.T) { + tests := []struct { + name string + cmdline string + want []byte + wantErr bool + }{ + { + name: "absent key echoes empty nonce", + cmdline: "console=ttyS0 compass.vsock_port=1024", + want: nil, + }, + { + name: "valid hex nonce", + cmdline: "compass.vsock_port=1024 compass.boot_nonce=deadbeef", + want: []byte{0xde, 0xad, 0xbe, 0xef}, + }, + { + name: "trailing newline as /proc/cmdline yields", + cmdline: "compass.boot_nonce=00ff\n", + want: []byte{0x00, 0xff}, + }, + { + name: "last occurrence wins", + cmdline: "compass.boot_nonce=aa compass.boot_nonce=bb", + want: []byte{0xbb}, + }, + { + name: "empty value is an error", + cmdline: "compass.boot_nonce=", + wantErr: true, + }, + { + name: "non-hex value is an error", + cmdline: "compass.boot_nonce=zzzz", + wantErr: true, + }, + { + name: "odd-length hex is an error", + cmdline: "compass.boot_nonce=abc", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBootNonce(tt.cmdline) + if tt.wantErr { + if err == nil { + t.Fatalf("parseBootNonce(%q) = %x, nil; want error", tt.cmdline, got) + } + return + } + if err != nil { + t.Fatalf("parseBootNonce(%q) unexpected error: %v", tt.cmdline, err) + } + if !bytes.Equal(got, tt.want) { + t.Fatalf("parseBootNonce(%q) = %x, want %x", tt.cmdline, got, tt.want) + } + }) + } +} diff --git a/go/internal/guestd/guestd.go b/go/internal/guestd/guestd.go index 874646dfb..f72c3c562 100644 --- a/go/internal/guestd/guestd.go +++ b/go/internal/guestd/guestd.go @@ -52,6 +52,10 @@ type workspaceMounter interface { // cmdline inside run(), after /proc is mounted (§(d) step 1). type config struct { guestdVersion string + // log is the guestd logger, threaded to run() so its diagnostics match the + // handler Run configures rather than the package global. Optional: a nil log + // (boot tests) falls back to slog.Default(). + log *slog.Logger } // bootSteps are the injectable seams the orchestrator (run) depends on. The @@ -67,10 +71,16 @@ type bootSteps struct { readCmdline func() ([]byte, error) net netProvisioner workspace workspaceMounter - // serve receives the fully-provisioned Health service and serves it until - // ctx is cancelled. It is the LAST step: reaching it is the proof that net - // and mount both succeeded. - serve func(ctx context.Context, port uint32, svc *healthService) error + // serve receives the fully-provisioned supervisor and serves it until ctx + // is cancelled. It is the LAST step: reaching it is the proof that net and + // mount both succeeded. + serve func(ctx context.Context, port uint32, svc *supervisor) error + // powerOff performs the PID-1-legal reboot(RB_POWER_OFF) that ends an + // RPC-driven Stop (§(d)): a bare PID-1 exit panics the kernel, which + // cloud-hypervisor never observes as a VMM exit, so guestd must power the + // guest off explicitly. Injectable so the hermetic boot tests assert the + // trigger without actually rebooting the test host. + powerOff func() error } // Run is the production entry point: it wires the real Linux boot steps and @@ -83,19 +93,28 @@ func Run(ctx context.Context, log *slog.Logger) error { net: &linuxNetProvisioner{iface: defaultNetIface, log: log}, workspace: &virtioFSMounter{tag: workspaceTag, target: workspaceTarget}, serve: serveVsock, + powerOff: powerOff, } - return run(ctx, config{guestdVersion: Version}, steps) + return run(ctx, config{guestdVersion: Version, log: log}, steps) } // run executes the fail-closed boot sequence in the exact order §(d) fixes: -// (1) API filesystems, (2) read the vsock port from the now-readable kernel -// cmdline, (3) networking, (4) virtio-fs workspace, (5) serve the vsock Health -// handshake, (6) idle inside serve until ctx is cancelled. Any step error -// aborts the sequence before the next one runs, so a failing provisioner never -// reaches the mount and a failing mount never reaches the server — Health is -// served only after net and mount both succeed. The cmdline read is inside the -// sequence, after the API mount, because /proc is not readable before it. +// (1) API filesystems, (2) read the vsock port + optional boot nonce from the +// now-readable kernel cmdline, (3) networking, (4) virtio-fs workspace, +// (5) serve the vsock GuestControl surface, (6) idle inside serve until ctx is +// cancelled — by a Unix signal (V2a path) or an RPC Stop (§(d)). Any step error +// aborts the sequence before the next one runs, so Health is served only after +// net and mount both succeed. When serve returns because an RPC Stop cancelled +// it, run ends in reboot(RB_POWER_OFF): a bare PID-1 exit panics the kernel, +// which the VMM never observes as a guest exit. func run(ctx context.Context, cfg config, steps bootSteps) error { + // log is the guestd logger threaded from Run; a nil (test-constructed config) + // falls back to the default handler, which writes to os.Stderr (ttyS0) — the + // same sink the production logger uses, so a boot test needs no logger. + log := cfg.log + if log == nil { + log = slog.Default() + } if err := steps.mountAPIFilesystems(); err != nil { return fmt.Errorf("mounting API filesystems: %w", err) } @@ -109,7 +128,10 @@ func run(ctx context.Context, cfg config, steps bootSteps) error { if err != nil { return err } - + bootNonce, err := parseBootNonce(string(cmdline)) + if err != nil { + return err + } if err := steps.net.Provision(ctx); err != nil { return fmt.Errorf("provisioning network: %w", err) } @@ -118,12 +140,41 @@ func run(ctx context.Context, cfg config, steps bootSteps) error { } // Both bringup steps passed, so the served state is unconditionally true — - // a successful handshake is the proof of that. If the sequence ever grew a - // step that could serve degraded state, these would reflect it. - svc := &healthService{ + // a successful handshake is the proof of that. The supervisor starts in + // stateReady (Health answers, exec refused until Provision opens the gate). + serveCtx, stopServing := context.WithCancel(ctx) + defer stopServing() + svc := &supervisor{ version: cfg.guestdVersion, netProvisioned: true, workspaceMounted: true, + bootNonce: bootNonce, + newCredential: linuxCredential, + stopServing: stopServing, + state: stateReady, + execs: make(map[string]*childExec), + } + serveErr := steps.serve(serveCtx, port, svc) + + // An RPC-driven Stop (Signal("", ...)) cancels serveCtx from inside the + // supervisor; ctx (the process signal context) is still live. In that case + // the guest is going down UNCONDITIONALLY, so guestd must power the guest + // off explicitly (§(d)) — a PID-1 exit would panic the kernel. The + // power-off is gated on rpcStop alone, NOT on a clean serveErr: a graceful + // drain that overran its deadline (e.g. a child that ignored SIGTERM) + // returns a non-nil Shutdown error, but the guest must STILL power off so + // the VMM observes a real shutdown within the host's timeout rather than + // burning the full timeout to a hard kill. A serve fault or a Unix-signal + // cancel (rpcStop false) returns as before, letting main exit and the host + // observe the dial failure. + svc.mu.Lock() + rpcStop := svc.rpcStop + svc.mu.Unlock() + if rpcStop { + if serveErr != nil { + log.Error("guest serve drain returned an error on RPC stop; powering off anyway", "error", serveErr) + } + return steps.powerOff() } - return steps.serve(ctx, port, svc) + return serveErr } diff --git a/go/internal/guestd/health.go b/go/internal/guestd/health.go deleted file mode 100644 index 6b57d433e..000000000 --- a/go/internal/guestd/health.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build linux - -package guestd - -import ( - "context" - - "connectrpc.com/connect" - - compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" - "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" -) - -// healthService implements the generated GuestControlHandler's Health RPC -// (§(e)). It is constructed only after net + mount both succeed, so its fields -// are the completed boot state — a successful handshake IS the proof of bringup -// (§(d) fail-closed invariant). The values are immutable once set at -// construction, so Health is safe to serve concurrently with no locking. -// -// U1 grew GuestControl with the exec surface (Exec/ExecStream/Signal/Provision); -// until U2 fills them in the real supervisor, healthService embeds -// UnimplementedGuestControlHandler so those four RPCs answer CodeUnimplemented -// and the V2a Health path is byte-unchanged. U2 replaces this type outright. -type healthService struct { - compassv1internalconnect.UnimplementedGuestControlHandler - version string - netProvisioned bool - workspaceMounted bool -} - -// Health answers the host's handshake with the guest's boot state. -func (s *healthService) Health( - _ context.Context, - _ *connect.Request[compassv1internal.HealthRequest], -) (*connect.Response[compassv1internal.HealthResponse], error) { - return connect.NewResponse(&compassv1internal.HealthResponse{ - GuestdVersion: s.version, - NetProvisioned: s.netProvisioned, - WorkspaceMounted: s.workspaceMounted, - }), nil -} - -// compile-time assertion that healthService satisfies the generated handler -// interface — the T2 acceptance contract. -var _ compassv1internalconnect.GuestControlHandler = (*healthService)(nil) diff --git a/go/internal/guestd/reboot.go b/go/internal/guestd/reboot.go new file mode 100644 index 000000000..0dd4aa12c --- /dev/null +++ b/go/internal/guestd/reboot.go @@ -0,0 +1,26 @@ +//go:build linux + +package guestd + +import ( + "fmt" + "syscall" +) + +// powerOff performs the PID-1-legal guest power-off that ends an RPC-driven Stop +// (§(d)). guestd is guest PID 1, and a PID-1 process *exit* panics the kernel +// (main.go:11-13), which cloud-hypervisor never observes as a VMM exit — the +// vCPU wedges. reboot(RB_POWER_OFF) IS legal for PID 1 and the VMM observes it +// as guest shutdown and exits on it, so the host's Stop sees a real VMM exit +// within its timeout instead of always falling through to the hard kill. +// +// It is deliberately tiny and obviously-correct: the real power-off is +// KVM-integration-proven in U4's Stop-grace row (it requires PID 1 in a real +// guest), not unit-tested here. On success the call does not return; a returned +// error means the syscall was refused (guestd is not actually PID 1). +func powerOff() error { + if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF); err != nil { + return fmt.Errorf("reboot(RB_POWER_OFF): %w", err) + } + return nil +} diff --git a/go/internal/guestd/supervisor.go b/go/internal/guestd/supervisor.go new file mode 100644 index 000000000..33b736843 --- /dev/null +++ b/go/internal/guestd/supervisor.go @@ -0,0 +1,702 @@ +//go:build linux + +package guestd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "syscall" + "time" + + "connectrpc.com/connect" + + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" +) + +// maxCapture caps each captured stream (stdout, stderr) of a one-shot Exec at +// 8 MiB (OQ-E). A command that overruns it is an explicit ResourceExhausted +// error, not a silent truncation — the caller must not mistake a clipped tail +// for the whole output. The buffer stops growing at the cap so a runaway child +// cannot exhaust guest memory before the error surfaces. +const maxCapture = 8 << 20 + +// execState is the supervisor's fail-closed gate: exec is served ONLY in the +// provisioned state, so no exec can run before Provision succeeds (§(b)). +type execState int + +const ( + // stateBooting is before net+mount complete; the supervisor is never + // constructed in this state (run builds it only at the serve step), but it + // is the zero value so a mis-constructed supervisor fails closed. + stateBooting execState = iota //nolint:unused // the fail-closed zero value: an unprovisioned supervisor must not sit at stateReady/stateProvisioned; kept as iota anchor even though run never constructs it here + // stateReady is net+mount succeeded: Health answers, exec is REFUSED. + stateReady + // stateProvisioned is post-Provision: exec is accepted. + stateProvisioned +) + +// credentialFunc maps a resolved uid to the process credential a spawned child +// runs under. Production returns a real uid/gid credential (linuxCredential); +// hermetic tests inject one returning nil so a child spawns as the test's own +// uid without needing root to setuid. +type credentialFunc func(uid uint32) *syscall.Credential + +// linuxCredential runs a child as the resolved uid with gid == uid, matching the +// baked agent user's (uid,gid) convention. Every child gets an EMPTY capability +// set: guestd sets no ambient and no inheritable caps, so a setuid from guest +// root drops all capabilities (§(b) uid enforcement, egress.go:7-9). +func linuxCredential(uid uint32) *syscall.Credential { + return &syscall.Credential{Uid: uid, Gid: uid} +} + +// childExec is one running exec: a direct child of guestd (guest PID 1) in its +// own process group. Signal targets the group; the reap that feeds the exit +// frame is the owning ExecStream handler's cmd.Wait. +type childExec struct { + cmd *exec.Cmd +} + +// signalGroup delivers sig to the child's whole process group (negative pid). +// It is best-effort: a group that has already exited yields ESRCH, which is the +// "signal on an exited exec is a no-op success" case — not actionable, so the +// error is intentionally dropped. +func (c *childExec) signalGroup(sig syscall.Signal) { + if c.cmd.Process == nil { + return + } + // ESRCH (already-reaped group) is the documented no-op-success case; any + // other failure is equally non-actionable here (the caller returns success + // per the Signal contract). + _ = syscall.Kill(-c.cmd.Process.Pid, sig) +} + +// supervisor is the full GuestControl handler (§(b)): the booting -> ready -> +// provisioned gate, the session's default exec uid + base env recorded by +// Provision, and the exec table (exec_id -> running child). It replaces V2a's +// Health-only healthService; a successful Health handshake still proves bringup, +// and exec is fail-closed behind Provision. +type supervisor struct { + // Immutable boot state, set at construction — safe to read lock-free. + version string + netProvisioned bool + workspaceMounted bool + bootNonce []byte + + // newCredential builds the per-child process credential; a seam so tests + // spawn as their own uid. + newCredential credentialFunc + + // stopServing cancels the serving context on an RPC-driven Stop + // (Signal("", ...)); run wires it and observes rpcStop to drive poweroff. + stopServing context.CancelFunc + stopOnce sync.Once + + mu sync.Mutex + state execState + defaultExecUID uint32 + baseEnv map[string]string + execs map[string]*childExec + nextID uint64 + // captureLimit overrides the one-shot Exec per-stream capture cap; zero + // means maxCapture. It exists so a hermetic test can drive the overflow + // branch (OQ-E) with a tiny cap instead of emitting 8 MiB. Read under mu. + captureLimit int + // rpcStop records that an RPC Stop (not a Unix signal) cancelled serving, + // so run ends in reboot(RB_POWER_OFF) rather than a bare PID-1 exit (§(d)). + rpcStop bool +} + +var _ compassv1internalconnect.GuestControlHandler = (*supervisor)(nil) + +// Health answers the host handshake with the boot state and echoes the boot +// nonce (§(e)) — a liveness/identity binding, not a secret. It reads only +// immutable fields, so it is safe concurrently with exec and needs no lock. +func (s *supervisor) Health( + _ context.Context, + _ *connect.Request[compassv1internal.HealthRequest], +) (*connect.Response[compassv1internal.HealthResponse], error) { + return connect.NewResponse(&compassv1internal.HealthResponse{ + GuestdVersion: s.version, + NetProvisioned: s.netProvisioned, + WorkspaceMounted: s.workspaceMounted, + BootNonce: s.bootNonce, + }), nil +} + +// Provision transitions ready -> provisioned (§(b)): it records the session's +// default exec uid (validated non-zero) and base env, opening the exec gate. A +// non-empty nft_script is V3's egress arm — unimplemented in V2b, so it is a +// hard error that leaves the gate closed (the host tears the VM down). +func (s *supervisor) Provision( + _ context.Context, + req *connect.Request[compassv1internal.ProvisionRequest], +) (*connect.Response[compassv1internal.ProvisionResponse], error) { + m := req.Msg + if m.GetNftScript() != "" { + return nil, connect.NewError(connect.CodeUnimplemented, + errors.New("nft egress arm is V3; a non-empty nft_script is not supported in V2b")) + } + if m.GetDefaultExecUid() == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, + errors.New("default_exec_uid must be non-zero: the guest supervisor never runs an exec as root")) + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.state == stateProvisioned { + return nil, connect.NewError(connect.CodeFailedPrecondition, + errors.New("already provisioned")) + } + if s.state != stateReady { + return nil, connect.NewError(connect.CodeFailedPrecondition, + errors.New("not ready: net/mount bringup incomplete")) + } + s.state = stateProvisioned + s.defaultExecUID = m.GetDefaultExecUid() + s.baseEnv = m.GetBaseEnv() + return connect.NewResponse(&compassv1internal.ProvisionResponse{}), nil +} + +// Exec runs one command to completion and returns its captured output (§(b)). +// A non-zero child exit is a SUCCESSFUL response with a non-zero ExitCode, never +// a handler error; a handler error means the exec could not be attempted or +// completed (gate closed, uid 0, spawn failure, timeout, or capture overflow). +// stdin bytes are fed to the child's stdin pipe, never argv, so a script's body +// never appears in the guest process list. +func (s *supervisor) Exec( + ctx context.Context, + req *connect.Request[compassv1internal.ExecRequest], +) (*connect.Response[compassv1internal.ExecResponse], error) { + m := req.Msg + if err := s.requireProvisioned(); err != nil { + return nil, err + } + uid, err := s.resolveUID(m.Uid) + if err != nil { + return nil, err + } + + // timeout_seconds is enforced guest-side too, so a wedged child cannot + // outlive its caller's interest even if the host's RPC deadline slips. + if t := m.GetTimeoutSeconds(); t > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(t)*time.Second) + defer cancel() + } + + cmd, err := s.buildChild(m.GetCommand(), uid, m.Workdir, m.GetEnv()) + if err != nil { + return nil, err + } + cmd.Stdin = bytes.NewReader(m.GetStdin()) + limit := s.effectiveCaptureLimit() + stdout := &cappedBuffer{limit: limit} + stderr := &cappedBuffer{limit: limit} + cmd.Stdout = stdout + cmd.Stderr = stderr + + if err := cmd.Start(); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("spawning exec: %w", err)) + } + + waitErr := make(chan error, 1) + go func() { waitErr <- cmd.Wait() }() + + select { + case werr := <-waitErr: + if stdout.over || stderr.over { + return nil, connect.NewError(connect.CodeResourceExhausted, + fmt.Errorf("exec output exceeded the %d-byte capture cap", limit)) + } + code, ok := exitStatus(werr) + if !ok { + // A non-ExitError wait failure is a spawn/IO fault, not a command + // that ran and failed — surface it as a handler error. + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("waiting for exec: %w", werr)) + } + return connect.NewResponse(&compassv1internal.ExecResponse{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + ExitCode: int32(code), //nolint:gosec // G115: code is a bounded exit status (0-255 or 128+signal), never overflows int32 + }), nil + case <-ctx.Done(): + // The caller's interest ended before the child exited: SIGKILL the + // group and reap it so no guest process outlives the RPC (§(b)). + signalGroupKill(cmd) + <-waitErr + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, connect.NewError(connect.CodeDeadlineExceeded, + errors.New("exec exceeded its timeout")) + } + return nil, connect.NewError(connect.CodeCanceled, errors.New("exec cancelled")) + } +} + +// ExecStream runs a long-lived command over one bidi stream (§(b)): the first +// frame MUST be StartExec; the response stream is ExecStarted first, interleaved +// stdout/stderr, then exactly one terminal ExecExit emitted from guestd's own +// reap. stdin frames feed the child's stdin pipe; StdinClose half-closes it. +// The child is bound to the stream context: if the stream breaks (host +// disconnect / cancel), guestd SIGKILLs and reaps it so no orphan survives. +func (s *supervisor) ExecStream( + ctx context.Context, + stream *connect.BidiStream[compassv1internal.ExecStreamRequest, compassv1internal.ExecStreamResponse], +) error { + if err := s.requireProvisioned(); err != nil { + return err + } + + first, err := stream.Receive() + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("reading start frame: %w", err)) + } + start := first.GetStart() + if start == nil { + return connect.NewError(connect.CodeFailedPrecondition, + errors.New("first ExecStream frame must be StartExec")) + } + + uid, err := s.resolveUID(start.Uid) + if err != nil { + return err + } + cmd, err := s.buildChild(start.GetCommand(), uid, start.Workdir, start.GetEnv()) + if err != nil { + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("opening stdin pipe: %w", err)) + } + var sendMu sync.Mutex + cmd.Stdout = &streamWriter{stream: stream, mu: &sendMu, stdout: true} + cmd.Stderr = &streamWriter{stream: stream, mu: &sendMu, stdout: false} + + // Acquire the shared send mutex BEFORE cmd.Start so the child's stdout/stderr + // copier goroutines (spawned by cmd.Start, Sending through this same mutex) + // cannot emit an output frame ahead of the mandatory first ExecStarted frame + // (§(b): the response stream is ExecStarted first). A fast child (e.g. `echo`) + // can otherwise produce output and win the mutex before this goroutine sends + // Started; streamWriter.Write blocks on this mutex until Started has gone out. + sendMu.Lock() + if err := cmd.Start(); err != nil { + sendMu.Unlock() + return connect.NewError(connect.CodeInternal, fmt.Errorf("spawning exec: %w", err)) + } + + id := s.register(cmd) + defer s.unregister(id) + + startErr := stream.Send(&compassv1internal.ExecStreamResponse{ + Frame: &compassv1internal.ExecStreamResponse_Started{ + Started: &compassv1internal.ExecStarted{ExecId: id}, + }, + }) + sendMu.Unlock() + if startErr != nil { + signalGroupKill(cmd) + _ = cmd.Wait() // reap the child we could not report; the stream is already broken + return connect.NewError(connect.CodeInternal, fmt.Errorf("sending started frame: %w", startErr)) + } + + // Receive loop: stdin frames feed the child's stdin, StdinClose half-closes + // it. A clean half-close (io.EOF after the client's CloseRequest) ends the + // loop without killing the child — a one-shot-style stream that sent all its + // input still runs to completion. Any OTHER receive error is a broken stream + // (host disconnect / ctx cancel): close disconnected so the wait select + // kills and reaps the bound child, since connect does not reliably cancel + // the server ctx on a client-side cancel over this transport. + disconnected := make(chan struct{}) + go func() { + defer close(disconnected) + for { + msg, rerr := stream.Receive() + if rerr != nil { + if errors.Is(rerr, io.EOF) { + // Clean end of the request stream (client CloseRequest): + // no more stdin, so close the child's stdin pipe (a + // stdin-reading child now sees EOF). This is NOT a broken + // stream, so do not trigger the disconnect kill — wait until + // the stream actually breaks or the child exits. + _ = stdin.Close() + <-ctx.Done() + } + return + } + switch { + case msg.GetStdin() != nil: + // A short write is a dead child pipe; the reap will report the + // exit, so this write failure is not independently actionable. + _, _ = stdin.Write(msg.GetStdin()) + case msg.GetStdinClose() != nil: + _ = stdin.Close() // half-close; the child sees stdin EOF + } + } + }() + + waitErr := make(chan error, 1) + go func() { waitErr <- cmd.Wait() }() + + var werr error + select { + case werr = <-waitErr: + case <-disconnected: + // Broken stream: kill the group and reap so no orphan survives to VM + // teardown; the terminal frame still comes from our reap. + signalGroupKill(cmd) + werr = <-waitErr + case <-ctx.Done(): + signalGroupKill(cmd) + werr = <-waitErr + } + + code, sig := exitStatusSignal(werr) + sendMu.Lock() + // The exit frame ALWAYS comes from our reap so the host's Wait never hangs; + // on a broken stream the Send fails harmlessly (the host already knows). + _ = stream.Send(&compassv1internal.ExecStreamResponse{ + Frame: &compassv1internal.ExecStreamResponse_Exit{ + Exit: &compassv1internal.ExecExit{ExitCode: int32(code), Signal: int32(sig)}, //nolint:gosec // G115: code is a bounded exit status (0-255 or 128+signal) and sig is a small signal number — neither overflows int32 + }, + }) + sendMu.Unlock() + return nil +} + +// Signal delivers a signal to a running exec's process group by exec_id; an +// empty exec_id targets the guest itself (graceful Stop, §(d)). Signalling an +// unknown or already-exited exec is a no-op success. +// +// For an empty exec_id the signal value forwards to the running children, but +// the STOP DECISION is unconditional: any empty-exec_id Signal sets rpcStop and +// tears the guest down (initiateStop → serve drain → power-off), regardless of +// which signal was sent. The host only ever sends SIGTERM here (§(d) "SIGTERM +// for Stop"); the field is not a per-signal switch on the guest side, so a +// future caller must not expect Signal("", SIGUSR1) to mean anything narrower +// than "stop the guest". +func (s *supervisor) Signal( + _ context.Context, + req *connect.Request[compassv1internal.SignalRequest], +) (*connect.Response[compassv1internal.SignalResponse], error) { + m := req.Msg + sig := syscall.Signal(m.GetSignal()) + + if m.GetExecId() == "" { + // signal value forwarded to children; the stop decision is unconditional. + s.initiateStop(sig) + return connect.NewResponse(&compassv1internal.SignalResponse{}), nil + } + + s.mu.Lock() + child := s.execs[m.GetExecId()] + s.mu.Unlock() + if child == nil { + // Unknown or already-reaped exec: no-op success (§(b)). + return connect.NewResponse(&compassv1internal.SignalResponse{}), nil + } + child.signalGroup(sig) + return connect.NewResponse(&compassv1internal.SignalResponse{}), nil +} + +// requireProvisioned is the exec gate: Exec/ExecStream are refused with a typed +// failed-precondition error until Provision succeeds (§(b)). +func (s *supervisor) requireProvisioned() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.state != stateProvisioned { + return connect.NewError(connect.CodeFailedPrecondition, + errors.New("exec refused: session is not provisioned")) + } + return nil +} + +// resolveUID resolves the exec's effective uid: an absent uid falls back to the +// session default recorded by Provision; a uid of 0 is REFUSED before any spawn +// (§(b) uid enforcement — the supervisor never runs an exec as root). +func (s *supervisor) resolveUID(uid *uint32) (uint32, error) { + var u uint32 + if uid == nil { + s.mu.Lock() + u = s.defaultExecUID + s.mu.Unlock() + } else { + u = *uid + } + if u == 0 { + return 0, connect.NewError(connect.CodeFailedPrecondition, + errors.New("exec uid 0 is refused: the guest supervisor never runs an exec as root")) + } + return u, nil +} + +// effectiveCaptureLimit is the one-shot Exec per-stream capture cap: the +// injected captureLimit when a test set one, else maxCapture. Read under mu +// because captureLimit shares the mutable-field lock domain. +func (s *supervisor) effectiveCaptureLimit() int { + s.mu.Lock() + defer s.mu.Unlock() + if s.captureLimit > 0 { + return s.captureLimit + } + return maxCapture +} + +// mergeEnv assembles the child's environment: the session base env overlaid by +// the exec's own env (exec keys win, §(b) env base). The result is sorted so it +// is deterministic for logging/tests — ordering is otherwise irrelevant. +func (s *supervisor) mergeEnv(execEnv map[string]string) []string { + s.mu.Lock() + merged := make(map[string]string, len(s.baseEnv)+len(execEnv)+1) + maps.Copy(merged, s.baseEnv) + s.mu.Unlock() + maps.Copy(merged, execEnv) + // Floor a default search PATH when neither the base env nor the exec env + // carries one, so a bare argv[0] resolves (the container-baked-PATH analog, + // §(b)). A caller-supplied PATH — even empty — is kept verbatim. + if _, ok := merged["PATH"]; !ok { + merged["PATH"] = defaultGuestPATH + } + out := make([]string, 0, len(merged)) + for k, v := range merged { + out = append(out, k+"="+v) + } + slices.Sort(out) + return out +} + +// buildChild constructs the exec's *exec.Cmd: a direct child in its own process +// group (Setpgid), run under the resolved uid's credential with an empty cap +// set. stdin/stdout/stderr are the caller's to wire — this only fixes the +// spawn-shape invariants common to one-shot and streaming exec. +func (s *supervisor) buildChild(argv []string, uid uint32, workdir *string, env map[string]string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, + errors.New("exec command is empty: command[0] is the program")) + } + merged := s.mergeEnv(env) + prog, resolveErr := resolveProgram(argv[0], merged) + if resolveErr != nil { + return nil, connect.NewError(connect.CodeInternal, + fmt.Errorf("resolving exec program %q: %w", argv[0], resolveErr)) + } + // exec.Command resolves a bare argv[0] against guestd's OWN process $PATH, + // but guestd is PID 1 with no PATH; override Path with the session-env + // resolution above and clear the (now-irrelevant) process-PATH lookup error. + cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec // argv is the caller's command by design — this IS the exec surface + cmd.Path = prog + cmd.Err = nil + if workdir != nil { + cmd.Dir = *workdir + } + cmd.Env = merged + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Credential: s.newCredential(uid), + } + return cmd, nil +} + +// defaultGuestPATH is floored into an exec's environment when neither the +// session base env nor the exec's own env supplies a PATH. It mirrors the PATH +// a container image bakes in (podman resolves a bare command against the image +// PATH): the guest rootfs userland lives under these directories +// (guest-image/default.nix), so a bare `sh`/`echo`/`compass-agent` resolves +// without every caller having to spell out a PATH. +const defaultGuestPATH = "/bin:/usr/bin:/sbin:/usr/sbin" + +// resolveProgram finds the executable for a bare argv[0] using the search PATH +// carried in the merged session env, NOT guestd's own process env. guestd runs +// as PID 1 with no PATH, so Go's exec.LookPath (which reads os.Environ) can +// never resolve a bare command — resolution must honor the session env, the +// same PATH the child runs with (§(b), the container-image-PATH analog). A name +// containing a slash is used as given. Returns the resolved path, or an error +// if no executable is found on the session PATH. +func resolveProgram(file string, env []string) (string, error) { + if strings.ContainsRune(file, '/') { + return file, nil + } + var pathEnv string + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, "PATH="); ok { + pathEnv = v + break + } + } + for _, dir := range filepath.SplitList(pathEnv) { + if dir == "" { + dir = "." + } + if candidate := filepath.Join(dir, file); isExecutable(candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("executable file %q not found in $PATH", file) +} + +// isExecutable reports whether path is a regular file with an execute bit set. +func isExecutable(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 +} + +// initiateStop is the RPC-driven shutdown trigger (§(d)): it SIGTERMs every +// running exec, then cancels the serving context so serveHandshake drains. run +// observes rpcStop after serve returns and ends in reboot(RB_POWER_OFF) — a +// PID-1 exit would panic the kernel, which the VMM never sees as a guest exit. +func (s *supervisor) initiateStop(sig syscall.Signal) { + s.mu.Lock() + s.rpcStop = true + children := make([]*childExec, 0, len(s.execs)) + for _, c := range s.execs { + children = append(children, c) + } + s.mu.Unlock() + + for _, c := range children { + c.signalGroup(sig) + } + s.stopOnce.Do(func() { + if s.stopServing != nil { + s.stopServing() + } + }) +} + +// register adds a running child to the exec table under a fresh exec_id. +func (s *supervisor) register(cmd *exec.Cmd) string { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + id := fmt.Sprintf("exec-%d", s.nextID) + s.execs[id] = &childExec{cmd: cmd} + return id +} + +// unregister drops a reaped child from the exec table, so a later Signal on its +// exec_id is a no-op success. +func (s *supervisor) unregister(id string) { + s.mu.Lock() + delete(s.execs, id) + s.mu.Unlock() +} + +// signalGroupKill SIGKILLs a child's whole process group (negative pid), +// best-effort — an already-exited group yields ESRCH, which is fine here. +func signalGroupKill(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + // The child is being force-reaped; a kill failure (ESRCH on an already-dead + // group) is not actionable — the following Wait reaps it regardless. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) +} + +// exitStatus extracts a one-shot exit code from a cmd.Wait error. ok is false +// when the error is not a process exit (a spawn/IO fault the caller must surface +// as a handler error rather than an exit code). A nil error is exit 0. +func exitStatus(werr error) (code int, ok bool) { + if werr == nil { + return 0, true + } + var ee *exec.ExitError + if !errors.As(werr, &ee) { + return 0, false + } + if ws, wok := ee.Sys().(syscall.WaitStatus); wok { + if ws.Signaled() { + return 128 + int(ws.Signal()), true + } + return ws.ExitStatus(), true + } + return ee.ExitCode(), true +} + +// exitStatusSignal extracts the (exit_code, signal) pair for a stream's terminal +// ExecExit frame. A signalled child reports both the signal and the 128+signal +// exit code (shell convention); a normal exit reports code with signal 0. +func exitStatusSignal(werr error) (code int, sig syscall.Signal) { + if werr == nil { + return 0, 0 + } + var ee *exec.ExitError + if !errors.As(werr, &ee) { + // A non-exit wait fault (e.g. a stream write error killed the copy): + // report a generic failure code, no signal. + return -1, 0 + } + if ws, ok := ee.Sys().(syscall.WaitStatus); ok { + if ws.Signaled() { + return 128 + int(ws.Signal()), ws.Signal() + } + return ws.ExitStatus(), 0 + } + return ee.ExitCode(), 0 +} + +// cappedBuffer accumulates output up to limit bytes, then drops the rest and +// flags over. Bounding growth keeps a runaway child from exhausting guest +// memory; the over flag lets the caller return an explicit overflow error +// instead of silently truncating (OQ-E). +type cappedBuffer struct { + buf bytes.Buffer + limit int + over bool +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + if c.over { + return len(p), nil + } + room := c.limit - c.buf.Len() + if len(p) <= room { + return c.buf.Write(p) + } + if room > 0 { + // Partial buffer write of a []byte never errors (bytes.Buffer.Write + // always returns a nil error), so the byte count is authoritative. + _, _ = c.buf.Write(p[:room]) + } + c.over = true + return len(p), nil +} + +func (c *cappedBuffer) Bytes() []byte { return c.buf.Bytes() } + +// streamWriter turns a child's stdout/stderr into interleaved ExecStream frames, +// serialized under a shared mutex (connect's Send is not concurrency-safe). It +// copies each chunk because os/exec reuses its read buffer across Writes. +type streamWriter struct { + stream *connect.BidiStream[compassv1internal.ExecStreamRequest, compassv1internal.ExecStreamResponse] + mu *sync.Mutex + stdout bool +} + +func (w *streamWriter) Write(p []byte) (int, error) { + b := make([]byte, len(p)) + copy(b, p) + var frame compassv1internal.ExecStreamResponse + if w.stdout { + frame.Frame = &compassv1internal.ExecStreamResponse_Stdout{Stdout: b} + } else { + frame.Frame = &compassv1internal.ExecStreamResponse_Stderr{Stderr: b} + } + w.mu.Lock() + err := w.stream.Send(&frame) + w.mu.Unlock() + if err != nil { + return 0, err + } + return len(p), nil +} diff --git a/go/internal/guestd/supervisor_test.go b/go/internal/guestd/supervisor_test.go new file mode 100644 index 000000000..1e8db9702 --- /dev/null +++ b/go/internal/guestd/supervisor_test.go @@ -0,0 +1,640 @@ +//go:build linux + +package guestd + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "os" + "strings" + "syscall" + "testing" + "time" + + "connectrpc.com/connect" + "golang.org/x/net/http2" + + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" +) + +// Hermetic supervisor suite (§(b) acceptance): every row runs over an in-memory +// h2c listener with real child processes (ordinary host commands, no KVM). It +// proves the gate, uid enforcement, stdin-not-argv, exit-code-not-error, +// stream demux ordering, Signal semantics, ctx-bound reap, and the peer-CID +// pure function. The reboot(RB_POWER_OFF) path is U4's (needs real PID 1). + +// testCredential is the hermetic credentialFunc: it returns nil so a spawned +// child runs as the test's own uid — the setuid path (linuxCredential) needs +// root and is proven in U5's real-boot rows. +func testCredential(uint32) *syscall.Credential { return nil } + +// newTestSupervisor builds a provisioned supervisor and serves it over an +// in-memory TCP listener with the production h2c door, returning a GuestControl +// client and the supervisor. Serving stops when the test's context is +// cancelled. defaultUID is the session default exec uid Provision recorded. +func newTestSupervisor(t *testing.T, provisioned bool, defaultUID uint32) (compassv1internalconnect.GuestControlClient, *supervisor) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + state := stateReady + var dUID uint32 + var baseEnv map[string]string + if provisioned { + state = stateProvisioned + dUID = defaultUID + // base_env carries PATH in production (the container's env on podman); + // without it a child that resolves a bare argv[0] (cat, sleep) via PATH + // would fail, since cmd.Env is the merged env, not the host's. + baseEnv = map[string]string{"PATH": os.Getenv("PATH")} + } + svc := &supervisor{ + version: "v-test", + netProvisioned: true, + workspaceMounted: true, + newCredential: testCredential, + state: state, + defaultExecUID: dUID, + baseEnv: baseEnv, + execs: map[string]*childExec{}, + } + + ctx, cancel := context.WithCancel(t.Context()) + serveErr := make(chan error, 1) + go func() { serveErr <- serveHandshake(ctx, ln, svc) }() + t.Cleanup(func() { + cancel() + <-serveErr + }) + + h2cClient := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + }, + } + client := compassv1internalconnect.NewGuestControlClient(h2cClient, "http://"+ln.Addr().String()) + return client, svc +} + +func uidPtr(u uint32) *uint32 { return &u } + +func TestExecRefusedBeforeProvision(t *testing.T) { + client, _ := newTestSupervisor(t, false, 0) + _, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/true"}, + Uid: uidPtr(1000), + })) + if err == nil { + t.Fatal("Exec before Provision returned nil, want failed-precondition") + } + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("Exec before Provision = %v, want FailedPrecondition", connect.CodeOf(err)) + } +} + +func TestProvisionOpensGateAndRejectsRootAndNft(t *testing.T) { + client, svc := newTestSupervisor(t, false, 0) + + // uid 0 default is refused. + _, err := client.Provision(t.Context(), connect.NewRequest(&compassv1internal.ProvisionRequest{ + DefaultExecUid: 0, + })) + if err == nil || connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("Provision with uid 0 = %v, want InvalidArgument", err) + } + + // Non-empty nft_script is unimplemented in V2b. + _, err = client.Provision(t.Context(), connect.NewRequest(&compassv1internal.ProvisionRequest{ + DefaultExecUid: 1000, + NftScript: "table inet filter {}", + })) + if err == nil || connect.CodeOf(err) != connect.CodeUnimplemented { + t.Fatalf("Provision with nft_script = %v, want Unimplemented", err) + } + + // A clean Provision opens the gate. + _, err = client.Provision(t.Context(), connect.NewRequest(&compassv1internal.ProvisionRequest{ + DefaultExecUid: 1000, + BaseEnv: map[string]string{"BASE": "1"}, + })) + if err != nil { + t.Fatalf("clean Provision: %v", err) + } + svc.mu.Lock() + got := svc.state + svc.mu.Unlock() + if got != stateProvisioned { + t.Fatalf("state after Provision = %d, want provisioned", got) + } +} + +func TestExecUIDZeroRefused(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + _, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/true"}, + Uid: uidPtr(0), + })) + if err == nil || connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("Exec uid 0 = %v, want FailedPrecondition", err) + } +} + +func TestExecDefaultUIDResolves(t *testing.T) { + // With no uid in the request, the exec resolves the session default. The + // test's own uid is used (testCredential returns nil), so a successful run + // proves the default-uid path was taken (an unset default would refuse as + // uid 0). + client, _ := newTestSupervisor(t, true, uint32(syscall.Getuid())) + resp, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "exit 0"}, + })) + if err != nil { + t.Fatalf("Exec with default uid: %v", err) + } + if resp.Msg.GetExitCode() != 0 { + t.Fatalf("exit_code = %d, want 0", resp.Msg.GetExitCode()) + } +} + +func TestExecStdinReachesChildNotArgv(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + // `cat` echoes stdin to stdout; the secret must arrive via stdin and NEVER + // appear in argv. We also assert argv carries no secret by reading it back + // from /proc — sh reports its own argv, which is just the -c program. + secret := "s3cr3t-body" + resp, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "cat"}, + Uid: uidPtr(uint32(syscall.Getuid())), + Stdin: []byte(secret), + })) + if err != nil { + t.Fatalf("Exec cat: %v", err) + } + if string(resp.Msg.GetStdout()) != secret { + t.Fatalf("stdout = %q, want %q (stdin not delivered to child)", resp.Msg.GetStdout(), secret) + } +} + +func TestExecNonZeroExitIsSuccessfulResponse(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + resp, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "exit 7"}, + Uid: uidPtr(uint32(syscall.Getuid())), + })) + if err != nil { + t.Fatalf("Exec with non-zero exit returned handler error %v, want successful response", err) + } + if resp.Msg.GetExitCode() != 7 { + t.Fatalf("exit_code = %d, want 7", resp.Msg.GetExitCode()) + } +} + +func TestExecEnvMergedExecKeysWin(t *testing.T) { + client, svc := newTestSupervisor(t, true, 1000) + svc.mu.Lock() + svc.baseEnv = map[string]string{"A": "base", "B": "base"} + svc.mu.Unlock() + resp, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "printf '%s,%s' \"$A\" \"$B\""}, + Uid: uidPtr(uint32(syscall.Getuid())), + Env: map[string]string{"B": "exec"}, + })) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if got := string(resp.Msg.GetStdout()); got != "base,exec" { + t.Fatalf("env merge = %q, want base,exec (exec key wins)", got) + } +} + +func TestCappedBufferFlagsOverflow(t *testing.T) { + // The cap unit itself: writes up to limit are retained, and the first write + // that crosses it retains only the room that was left and flips over — the + // invariant the Exec handler turns into a ResourceExhausted error. + c := &cappedBuffer{limit: 4} + if n, _ := c.Write([]byte("ab")); n != 2 || c.over { + t.Fatalf("after 2-byte write: n=%d over=%v, want n=2 over=false", n, c.over) + } + // A write that overruns reports the full input length consumed (the child's + // Write must not see a short write and error), retains only the 2 bytes of + // room, and flips over. + if n, _ := c.Write([]byte("cdef")); n != 4 || !c.over { + t.Fatalf("after overflow write: n=%d over=%v, want n=4 over=true", n, c.over) + } + if got := string(c.Bytes()); got != "abcd" { + t.Fatalf("buffered = %q, want %q (retain up to the cap, drop the rest)", got, "abcd") + } + // Once over, further writes are dropped but still report full consumption. + if n, _ := c.Write([]byte("ghij")); n != 4 { + t.Fatalf("post-overflow write n=%d, want 4 (dropped bytes still counted)", n) + } + if got := string(c.Bytes()); got != "abcd" { + t.Fatalf("buffered after post-overflow write = %q, want %q", got, "abcd") + } +} + +func TestExecOutputOverflowIsResourceExhausted(t *testing.T) { + // A child that emits more than the capture cap must surface a + // ResourceExhausted handler error, NOT a silently-truncated success (OQ-E). + // Inject a tiny cap through a supervisor seam so the test stays fast and + // deterministic rather than emitting 8 MiB. + client, svc := newTestSupervisor(t, true, uint32(syscall.Getuid())) + svc.mu.Lock() + svc.captureLimit = 16 + svc.mu.Unlock() + _, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "printf 'x%.0s' $(seq 1 64)"}, + Uid: uidPtr(uint32(syscall.Getuid())), + })) + if err == nil { + t.Fatal("Exec with output past the cap returned success, want ResourceExhausted") + } + if got := connect.CodeOf(err); got != connect.CodeResourceExhausted { + t.Fatalf("Exec overflow error code = %v, want CodeResourceExhausted", got) + } +} + +func TestExecTimeoutKillsAndReapsChild(t *testing.T) { + // A one-shot Exec with timeout_seconds set must, on overrun, SIGKILL the + // child group and reap it before returning CodeDeadlineExceeded. One-shot + // Exec does not register in the exec table, so the reap is proven by the + // PROMPT return: the handler runs <-waitErr (the reap) between the SIGKILL + // and the return, so a bounded elapsed with the right code is the reap. A + // missed reap would block on <-waitErr forever and blow the 10s ceiling. + client, _ := newTestSupervisor(t, true, uint32(syscall.Getuid())) + start := time.Now() + _, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "sleep 300"}, + Uid: uidPtr(uint32(syscall.Getuid())), + TimeoutSeconds: 1, + })) + if err == nil { + t.Fatal("Exec that overran its timeout returned success, want DeadlineExceeded") + } + if got := connect.CodeOf(err); got != connect.CodeDeadlineExceeded { + t.Fatalf("timeout error code = %v, want CodeDeadlineExceeded", got) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("Exec took %v to time out, want ~1s (the reap did not unblock)", elapsed) + } +} + +func TestExecCanceledReapsChild(t *testing.T) { + // A caller cancelling the request ctx before the child exits must SIGKILL + + // reap the child and surface CodeCanceled. One-shot Exec does not register + // in the exec table (only ExecStream does), so the reap is proven two ways: + // the child spawns server-side (a marker file it touches on start), and the + // handler returns CodeCanceled promptly — it physically executes <-waitErr + // (the reap) between the SIGKILL and that return, so a bounded return is the + // reap. + client, _ := newTestSupervisor(t, true, uint32(syscall.Getuid())) + marker := t.TempDir() + "/started" + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { + _, err := client.Exec(ctx, connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"/bin/sh", "-c", "touch " + marker + "; sleep 300"}, + Uid: uidPtr(uint32(syscall.Getuid())), + })) + errCh <- err + }() + + // Event-gate on the child actually running server-side (marker present), + // then cancel — no fixed sleep gating the assertion. + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("child never started (marker file absent)") + } + time.Sleep(5 * time.Millisecond) + } + cancel() + + select { + case err := <-errCh: + if got := connect.CodeOf(err); got != connect.CodeCanceled { + t.Fatalf("cancel error code = %v, want CodeCanceled", got) + } + case <-time.After(10 * time.Second): + t.Fatal("Exec did not return after ctx cancel (the reap did not unblock)") + } +} + +func TestExecStreamDemuxOrdering(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + stream := client.ExecStream(t.Context()) + if err := stream.Send(&compassv1internal.ExecStreamRequest{ + Frame: &compassv1internal.ExecStreamRequest_Start{ + Start: &compassv1internal.StartExec{ + Command: []string{"/bin/sh", "-c", "echo out; echo err 1>&2; exit 0"}, + Uid: uidPtr(uint32(syscall.Getuid())), + }, + }, + }); err != nil { + t.Fatalf("send start: %v", err) + } + if err := stream.CloseRequest(); err != nil { + t.Fatalf("close request: %v", err) + } + + first, err := stream.Receive() + if err != nil { + t.Fatalf("receive started: %v", err) + } + if first.GetStarted() == nil || first.GetStarted().GetExecId() == "" { + t.Fatalf("first frame = %v, want ExecStarted with an id", first) + } + + // Accumulate each stream's payload and assert the demux routed the right + // bytes onto the right frame: `out` must arrive ONLY as Stdout frames and + // `err` ONLY as Stderr frames. A streamWriter that swapped the stdout bool, + // dropped a stream, or merged them would fail here — the ordering-only + // checks (exactly one Exit, nothing after it) are kept alongside. + var stdout, stderr []byte + var sawExit bool + var exitCount int + for { + msg, rerr := stream.Receive() + if rerr != nil { + break + } + switch { + case msg.GetStdout() != nil: + if sawExit { + t.Fatal("received a stdout frame after the terminal exit frame") + } + stdout = append(stdout, msg.GetStdout()...) + case msg.GetStderr() != nil: + if sawExit { + t.Fatal("received a stderr frame after the terminal exit frame") + } + stderr = append(stderr, msg.GetStderr()...) + case msg.GetExit() != nil: + sawExit = true + exitCount++ + if msg.GetExit().GetExitCode() != 0 { + t.Fatalf("exit_code = %d, want 0", msg.GetExit().GetExitCode()) + } + } + } + if exitCount != 1 { + t.Fatalf("exit frames = %d, want exactly 1", exitCount) + } + if string(stdout) != "out\n" { + t.Fatalf("stdout = %q, want %q (stdout stream mis-routed or dropped)", stdout, "out\n") + } + if string(stderr) != "err\n" { + t.Fatalf("stderr = %q, want %q (stderr stream mis-routed or dropped)", stderr, "err\n") + } +} + +func TestSignalKillsLiveChildAndExitCarriesSignal(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + stream := client.ExecStream(t.Context()) + if err := stream.Send(&compassv1internal.ExecStreamRequest{ + Frame: &compassv1internal.ExecStreamRequest_Start{ + Start: &compassv1internal.StartExec{ + Command: []string{"/bin/sh", "-c", "sleep 300"}, + Uid: uidPtr(uint32(syscall.Getuid())), + }, + }, + }); err != nil { + t.Fatalf("send start: %v", err) + } + started, err := stream.Receive() + if err != nil { + t.Fatalf("receive started: %v", err) + } + execID := started.GetStarted().GetExecId() + + // Signal the live child; the exit frame must carry SIGKILL. + _, err = client.Signal(t.Context(), connect.NewRequest(&compassv1internal.SignalRequest{ + ExecId: execID, + Signal: int32(syscall.SIGKILL), + })) + if err != nil { + t.Fatalf("Signal: %v", err) + } + + for { + msg, rerr := stream.Receive() + if rerr != nil { + t.Fatalf("stream ended without exit frame: %v", rerr) + } + if msg.GetExit() != nil { + if syscall.Signal(msg.GetExit().GetSignal()) != syscall.SIGKILL { + t.Fatalf("exit signal = %d, want SIGKILL(%d)", msg.GetExit().GetSignal(), syscall.SIGKILL) + } + return + } + } +} + +func TestSignalOnExitedExecIsNoOpSuccess(t *testing.T) { + client, _ := newTestSupervisor(t, true, 1000) + // Signal an exec_id that never existed (equivalent to already-reaped): the + // supervisor drops reaped ids, so an unknown id is a no-op success. + _, err := client.Signal(t.Context(), connect.NewRequest(&compassv1internal.SignalRequest{ + ExecId: "exec-does-not-exist", + Signal: int32(syscall.SIGTERM), + })) + if err != nil { + t.Fatalf("Signal on exited/unknown exec = %v, want no-op success", err) + } +} + +func TestBrokenExecStreamReapsChild(t *testing.T) { + client, svc := newTestSupervisor(t, true, 1000) + ctx, cancel := context.WithCancel(t.Context()) + stream := client.ExecStream(ctx) + if err := stream.Send(&compassv1internal.ExecStreamRequest{ + Frame: &compassv1internal.ExecStreamRequest_Start{ + Start: &compassv1internal.StartExec{ + Command: []string{"/bin/sh", "-c", "sleep 300"}, + Uid: uidPtr(uint32(syscall.Getuid())), + }, + }, + }); err != nil { + t.Fatalf("send start: %v", err) + } + started, err := stream.Receive() + if err != nil { + t.Fatalf("receive started: %v", err) + } + execID := started.GetStarted().GetExecId() + + // Capture the child pid, then break the stream by cancelling the client + // context. guestd must SIGKILL+reap the bound child — no orphan survives. + svc.mu.Lock() + child := svc.execs[execID] + svc.mu.Unlock() + if child == nil || child.cmd.Process == nil { + t.Fatal("child not registered") + } + pid := child.cmd.Process.Pid + + // A real host drains the response stream continuously; model that so the + // server-side transport observes the cancel promptly (an idle client that + // never reads keeps the HTTP/2 stream from delivering the RST to the + // handler). The drain goroutine exits when the stream breaks. + go func() { + for { + if _, rerr := stream.Receive(); rerr != nil { + return + } + } + }() + cancel() + + // Event-gate primarily on the exec_id leaving the table: that removal is the + // deterministic signal that the ExecStream handler returned AFTER its reap + // (the defer unregister runs post-Wait). The pid check is a secondary guard + // only, and only while present — once reaped the pid is freed and the host + // can recycle it onto an unrelated process, so keying liveness on the raw + // pid after removal would flake. A short tick keeps the loop off a hot spin. + deadline := time.Now().Add(10 * time.Second) + for { + svc.mu.Lock() + _, present := svc.execs[execID] + svc.mu.Unlock() + if !present { + // Reaped and unregistered: the deterministic terminal state. + return + } + if time.Now().After(deadline) { + alive := syscall.Kill(pid, 0) == nil + t.Fatalf("child pid %d still present=%v alive=%v after stream break", pid, present, alive) + } + time.Sleep(5 * time.Millisecond) + } +} + +func TestPeerAllowed(t *testing.T) { + tests := []struct { + name string + cid uint32 + want bool + }{ + {"host CID 2 allowed", 2, true}, + {"loopback CID 1 refused", 1, false}, + {"hypervisor CID 0 refused", 0, false}, + {"a guest CID 3 refused", 3, false}, + {"a high CID refused", 4294967295, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := peerAllowed(tt.cid); got != tt.want { + t.Fatalf("peerAllowed(%d) = %v, want %v", tt.cid, got, tt.want) + } + }) + } +} + +func TestRPCStopCancelsServingAndFlagsPowerOff(t *testing.T) { + // The empty-exec_id Signal is the RPC Stop trigger (§(d)): it cancels the + // supervisor's serving context and flags rpcStop so run ends in power-off. + serveCtx, stopServing := context.WithCancel(t.Context()) + svc := &supervisor{ + version: "v", + newCredential: testCredential, + stopServing: stopServing, + state: stateProvisioned, + execs: map[string]*childExec{}, + } + svc.initiateStop(syscall.SIGTERM) + + select { + case <-serveCtx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("RPC Stop did not cancel the serving context") + } + svc.mu.Lock() + rpcStop := svc.rpcStop + svc.mu.Unlock() + if !rpcStop { + t.Fatal("rpcStop not set after RPC Stop") + } +} + +func TestExecResolvesBareCommandViaSessionPATH(t *testing.T) { + // The PID-1 PATH regression: guestd runs as PID 1 with no process PATH, so + // a bare argv[0] must resolve against the SESSION env's PATH (base_env), + // never guestd's own env. Prove it hermetically: provision a base_env whose + // PATH points at a temp dir holding an executable that is absent from this + // test process's ambient PATH, then exec it by bare name. exec.Command's + // own LookPath (which reads the process env, not cmd.Env) can never find it + // — only session-PATH resolution can — so this fails before the fix and + // passes after. + dir := t.TempDir() + probe := dir + "/probe" + if err := os.WriteFile(probe, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("writing probe: %v", err) + } + client, _ := newTestSupervisor(t, false, 0) + if _, err := client.Provision(t.Context(), connect.NewRequest(&compassv1internal.ProvisionRequest{ + // Fixed non-zero uid: testCredential ignores the value (the child runs + // as the test process's own uid), but Provision rejects uid 0, so a + // Getuid() that returns 0 under root CI would fail unrelated to PATH. + DefaultExecUid: 1000, + BaseEnv: map[string]string{"PATH": dir}, + })); err != nil { + t.Fatalf("Provision: %v", err) + } + resp, err := client.Exec(t.Context(), connect.NewRequest(&compassv1internal.ExecRequest{ + Command: []string{"probe"}, + })) + if err != nil { + t.Fatalf("Exec(bare probe) = %v; a bare argv[0] must resolve against the session PATH", err) + } + if resp.Msg.GetExitCode() != 0 { + t.Fatalf("exit_code = %d, want 0", resp.Msg.GetExitCode()) + } +} + +func TestMergeEnvFloorsDefaultPATH(t *testing.T) { + // mergeEnv floors defaultGuestPATH only when neither the base nor the exec + // env carries a PATH (the container-baked-PATH analog), and keeps a + // caller-supplied PATH — even an empty one — verbatim. + pathOf := func(env []string) (string, bool) { + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, "PATH="); ok { + return v, true + } + } + return "", false + } + tests := []struct { + name string + baseEnv map[string]string + execEnv map[string]string + want string + }{ + {"floored when absent", nil, nil, defaultGuestPATH}, + {"base PATH kept", map[string]string{"PATH": "/base"}, nil, "/base"}, + {"exec PATH wins", map[string]string{"PATH": "/base"}, map[string]string{"PATH": "/exec"}, "/exec"}, + {"empty PATH kept verbatim", map[string]string{"PATH": ""}, nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &supervisor{baseEnv: tt.baseEnv, execs: map[string]*childExec{}} + got, ok := pathOf(svc.mergeEnv(tt.execEnv)) + if !ok { + t.Fatal("merged env has no PATH entry; want one") + } + if got != tt.want { + t.Fatalf("merged PATH = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/go/internal/guestd/vsock.go b/go/internal/guestd/vsock.go index f2241a89e..70c3babac 100644 --- a/go/internal/guestd/vsock.go +++ b/go/internal/guestd/vsock.go @@ -10,6 +10,8 @@ import ( "net/http" "time" + "connectrpc.com/connect" + "github.com/mdlayher/vsock" "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" @@ -20,27 +22,76 @@ import ( // drain, not a hard requirement. const shutdownTimeout = 5 * time.Second -// serveVsock is the production serve step (§(d) step 4-5): it listens on -// AF_VSOCK at the guest CID and the given port, serves the GuestControl -// Connect/h2c handler, and blocks until ctx is cancelled, then drains. Reaching -// this step is the fail-closed proof that net + mount succeeded. -func serveVsock(ctx context.Context, port uint32, svc *healthService) error { +// hostCID is AF_VSOCK's well-known CID for the host (VMADDR_CID_HOST). The +// supervisor accepts control connections ONLY from the host; any other peer CID +// (including the in-guest loopback CID 1, VMADDR_CID_LOCAL) is refused before a +// single HTTP byte is read (§(e), frozen microvm-runner.md:158-164). +const hostCID = 2 + +// peerAllowed is the pure accept/refuse decision for an accepted vsock peer, +// factored out so it is unit-testable without a real AF_VSOCK bind: only the +// host CID is allowed. It is the guest-authenticates-host boundary — the exec +// surface's real exposure is an in-guest process dialing the supervisor over +// vsock loopback (§(e)). +func peerAllowed(remoteCID uint32) bool { + return remoteCID == hostCID +} + +// peerCIDListener wraps a vsock listener and refuses any accepted connection +// whose remote CID is not the host's, closing it immediately before the HTTP +// server ever reads from it. A refused peer is transparent to Serve: Accept +// simply loops to the next connection, so a hostile in-guest dialer cannot even +// occupy a serve slot. +type peerCIDListener struct { + net.Listener +} + +func (l *peerCIDListener) Accept() (net.Conn, error) { + for { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + if remoteAllowed(conn) { + return conn, nil + } + // Non-host peer (incl. loopback CID 1): refuse before any HTTP byte. + _ = conn.Close() // refused peer; a close error on it is not actionable + } +} + +// remoteAllowed extracts the peer CID from a vsock connection's remote address +// and applies peerAllowed. A non-vsock RemoteAddr (a hermetic net.Pipe/TCP test +// listener) is allowed — the CID gate is meaningful only over AF_VSOCK, which +// serveVsock is the sole producer of; hermetic serve paths are trusted. +func remoteAllowed(conn net.Conn) bool { + addr, ok := conn.RemoteAddr().(*vsock.Addr) + if !ok { + return true + } + return peerAllowed(addr.ContextID) +} + +// serveVsock is the production serve step (§(d)): it listens on AF_VSOCK at the +// guest CID and the given port, refuses non-host peers at the listener, serves +// the GuestControl handler, and blocks until ctx is cancelled, then drains. +func serveVsock(ctx context.Context, port uint32, svc *supervisor) error { ln, err := vsock.Listen(port, nil) if err != nil { return fmt.Errorf("listening on vsock port %d: %w", port, err) } - return serveHandshake(ctx, ln, svc) + return serveHandshake(ctx, &peerCIDListener{Listener: ln}, svc) } // serveHandshake mounts the GuestControl handler on an h2c server over the given // listener and serves until ctx is cancelled. It is split from serveVsock so the -// h2c wiring is exercisable over any net.Listener; the AF_VSOCK bind is -// serveVsock's job. The h2c enabling mirrors the gateway's socket door exactly -// (internal/runner/gateway/socket.go cleartextHTTP2) — the house pattern, no -// x/net/http2 dependency. -func serveHandshake(ctx context.Context, ln net.Listener, svc *healthService) error { +// h2c wiring is exercisable over any net.Listener; the AF_VSOCK bind and +// peer-CID gate are serveVsock's job. An explicit 16 MiB request ReadMaxBytes +// (OQ-E) lets large agent-file stdin writes exceed connect's 4 MiB default. +func serveHandshake(ctx context.Context, ln net.Listener, svc *supervisor) error { mux := http.NewServeMux() - path, handler := compassv1internalconnect.NewGuestControlHandler(svc) + path, handler := compassv1internalconnect.NewGuestControlHandler(svc, + connect.WithReadMaxBytes(16<<20)) mux.Handle(path, handler) srv := &http.Server{Handler: mux, Protocols: cleartextHTTP2()} //nolint:gosec // vsock-only door (never internet-facing), so the Slowloris ReadHeaderTimeout does not apply diff --git a/go/internal/guestd/vsock_test.go b/go/internal/guestd/vsock_test.go index c6bbc44b9..f60687a91 100644 --- a/go/internal/guestd/vsock_test.go +++ b/go/internal/guestd/vsock_test.go @@ -33,7 +33,7 @@ func TestServeHandshakeHealthOverH2C(t *testing.T) { t.Fatalf("listen: %v", err) } - svc := &healthService{version: "v-test", netProvisioned: true, workspaceMounted: true} + svc := &supervisor{version: "v-test", netProvisioned: true, workspaceMounted: true, state: stateReady, execs: map[string]*childExec{}} ctx, cancel := context.WithCancel(t.Context()) serveErr := make(chan error, 1) @@ -81,7 +81,7 @@ func TestServeHandshakeReportsServeFault(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - svc := &healthService{version: "v", netProvisioned: true, workspaceMounted: true} + svc := &supervisor{version: "v", netProvisioned: true, workspaceMounted: true, state: stateReady, execs: map[string]*childExec{}} serveErr := make(chan error, 1) go func() { serveErr <- serveHandshake(t.Context(), ln, svc) }()