diff --git a/.gitignore b/.gitignore index f2bb4cc5..a5827526 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ /.tmp-kickpoll-debug /.tmp-vsh-vim-bench /.tmp-virtiofs-exit-sweep +/internal/guestinit/payloads/guest-init-* +/internal/freebsd/guestinit/payloads/guest-init-* +/internal/netbsd/guestinit/payloads/guest-init-* +/internal/openbsd/guestinit/payloads/guest-init-* /internal/guestinit/guest-init-linux-arm64 /internal/guestinit/guest-init-linux-amd64 /internal/freebsd/guestinit/guest-init-freebsd-arm64 diff --git a/client/protocol.go b/client/protocol.go index 40b98178..0ff19ce9 100644 --- a/client/protocol.go +++ b/client/protocol.go @@ -617,24 +617,36 @@ type VMState = InstanceState type RunVMResponse = ExecResponse type ExecRequest struct { - Kind string `json:"kind,omitempty"` - ID string `json:"id,omitempty"` - Image string `json:"image,omitempty"` - Command []string `json:"command"` - Env []string `json:"env,omitempty"` - RootDir string `json:"root_dir,omitempty"` - Path string `json:"path,omitempty"` - Directory bool `json:"directory,omitempty"` - ReplaceEnv bool `json:"replace_env,omitempty"` - SkipResolve bool `json:"skip_resolve,omitempty"` - WorkDir string `json:"workdir,omitempty"` - User string `json:"user,omitempty"` - Stdin []byte `json:"stdin,omitempty"` - TTY bool `json:"tty,omitempty"` - ControlFD bool `json:"control_fd,omitempty"` - Cols int `json:"cols,omitempty"` - Rows int `json:"rows,omitempty"` - ArchiveLimits *ArchiveLimits `json:"archive_limits,omitempty"` + // ProcessMode is single (signal only the direct PID; descendants may survive), + // group (signal the process group and tracked descendants), or empty (legacy + // family). Linux guestinit also terminates group/family descendants after + // the direct process exits: cgroups track forks and setsid. PID 1 fails + // launch if cgroup preparation fails; outside PID 1, unavailable cgroups + // use best-effort ancestry tracking that can miss rapid reparenting. Final + // stdout/stderr/control/PTY draining is bounded to 50ms after cleanup in + // all modes; remaining output may be discarded. Exit status is the direct + // process's, not a descendant's. This does not bound a blocked transport writer. + ProcessMode string `json:"process_mode,omitempty"` + // SandboxProtocol must be 1 when ProcessMode is nonempty; zero preserves cc requests. + SandboxProtocol int `json:"sandbox_protocol,omitempty"` + Kind string `json:"kind,omitempty"` + ID string `json:"id,omitempty"` + Image string `json:"image,omitempty"` + Command []string `json:"command"` + Env []string `json:"env,omitempty"` + RootDir string `json:"root_dir,omitempty"` + Path string `json:"path,omitempty"` + Directory bool `json:"directory,omitempty"` + ReplaceEnv bool `json:"replace_env,omitempty"` + SkipResolve bool `json:"skip_resolve,omitempty"` + WorkDir string `json:"workdir,omitempty"` + User string `json:"user,omitempty"` + Stdin []byte `json:"stdin,omitempty"` + TTY bool `json:"tty,omitempty"` + ControlFD bool `json:"control_fd,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + ArchiveLimits *ArchiveLimits `json:"archive_limits,omitempty"` } // ArchiveLimits bounds the expanded work performed by an fs_extract request. diff --git a/docs/openapi.json b/docs/openapi.json index a910f93d..e011d792 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1458,6 +1458,16 @@ "type": "object", "required": ["command"], "properties": { + "process_mode": { + "type": "string", + "enum": ["", "single", "group"], + "description": "Empty or omitted retains legacy process-family containment. single signals only the direct PID and preserves descendants on exit; group signals and cleans up the process group. Nonempty modes require sandbox_protocol 1." + }, + "sandbox_protocol": { + "type": "integer", + "enum": [0, 1], + "description": "Zero or omitted preserves legacy cc requests. Version 1 must match the guest init protocol and is required for a nonempty process_mode." + }, "kind": { "type": "string" }, "id": { "type": "string" }, "image": { "type": "string" }, diff --git a/guestinit/doc.go b/guestinit/doc.go new file mode 100644 index 00000000..eaf427f9 --- /dev/null +++ b/guestinit/doc.go @@ -0,0 +1,6 @@ +// Package guestinit implements the Linux cc and SA2 guest init entrypoints. +// Non-Linux entrypoints compile but panic if invoked. +package guestinit + +// SandboxProtocol is the SA2 guest protocol supported by this init. +const SandboxProtocol = 1 diff --git a/internal/cmd/init/fs_tar_test.go b/guestinit/fs_tar_test.go similarity index 99% rename from internal/cmd/init/fs_tar_test.go rename to guestinit/fs_tar_test.go index cbddb83f..023431d1 100644 --- a/internal/cmd/init/fs_tar_test.go +++ b/guestinit/fs_tar_test.go @@ -1,6 +1,6 @@ //go:build linux -package main +package guestinit import ( "archive/tar" diff --git a/guestinit/init_linux.go b/guestinit/init_linux.go new file mode 100644 index 00000000..93065ac9 --- /dev/null +++ b/guestinit/init_linux.go @@ -0,0 +1,3795 @@ +//go:build linux + +package guestinit + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/unix" + "j5.nz/cc/client" + "j5.nz/cc/internal/managed/capturerelay" + "j5.nz/cc/internal/managed/guestagent" + "j5.nz/cc/internal/managed/protocol" +) + +const configPath = "/etc/ccx3-init.json" +const guestQEMUPath = "/run/ccx3/qemu-x86_64" +const guestQEMUBinfmtPath = "/run/ccx3-qemu-x86_64" +const initDurationMarker = "__CCX3_INIT_MS__:" +const defaultControlMarkerPref = "__CCX3_CTL__:" +const fatalBootMarker = "ccx3-init-fatal: " +const execPivotMode = "--ccx3-exec-pivot" +const archiveMode = "--ccx3-fs-archive" +const extractMode = "--ccx3-fs-extract" +const captureRelayMode = "--ccx3-capture-relay" + +var credentialSetupMu sync.Mutex + +const ( + managedExecTimingRecv = "recv" + managedExecTimingGuestReadyBegin = "guest_ready_begin" + managedExecTimingGuestReadyDone = "guest_ready_done" + managedExecTimingStartBegin = "start_begin" + managedExecTimingFirstStdout = "first_stdout" + managedExecTimingFirstStderr = "first_stderr" + managedExecTimingStartCall = "start_call" + managedExecTimingStarted = "started" + managedExecTimingWaitBegin = "wait_begin" + managedExecTimingWaitDone = "wait_done" + managedExecTimingStreamsDone = "streams_done" + managedExecTimingExitSent = "exit_sent" +) +const stage2Mode = "--ccx3-stage2" +const stage2Path = "/etc/ccx3/stage2" +const stage2ConfigPath = "/etc/ccx3/config.json" +const snapshotStage2Path = "/var/tmp/.ccx3-snapshot-stage2" +const snapshotStage2ConfigPath = "/var/tmp/.ccx3-snapshot-config.json" +const snapshotModuleDir = "/var/tmp/.ccx3-snapshot-modules" +const initSystemSystemd = "systemd" +const systemdReadyTimeout = 30 * time.Second + +// Exec output is base64-framed by the managed protocol. Small fragments create +// thousands of sidecar frames for ordinary file transfers and can overflow a +// bounded transport queue even while its consumer is making progress. +const execProtocolChunkSize = 32 << 10 +const defaultVsockConnectTimeout = 5 * time.Second +const stage2VsockConnectAttemptTimeout = 500 * time.Millisecond +const managedExecGroupTerminateGrace = 500 * time.Millisecond +const managedExecGroupKillWait = 2 * time.Second +const managedExecStreamDrainGrace = 50 * time.Millisecond + +var consoleFD = 2 +var kmsgFD = -1 +var protocolMu sync.Mutex +var setTimeOfDay = unix.Settimeofday +var managedChildren = struct { + sync.Mutex + pids map[int]struct{} +}{pids: make(map[int]struct{})} + +type config struct { + Protocol int `json:"protocol,omitempty"` + Command []string `json:"command"` + Env []string `json:"env"` + WorkDir string `json:"workdir"` + User string `json:"user,omitempty"` + InitSystem string `json:"init,omitempty"` + Hostname string `json:"hostname,omitempty"` + Modules []string `json:"modules"` + EmulatorTag string `json:"emulator_tag,omitempty"` + RootFSTag string `json:"rootfs_tag"` + RootFSImagePath string `json:"rootfs_image_path,omitempty"` + RootFSImageType string `json:"rootfs_image_type,omitempty"` + Shares []share `json:"shares,omitempty"` + VsockPort uint32 `json:"vsock_port,omitempty"` + ReadyMarker string `json:"ready_marker"` + BeginMarker string `json:"begin_marker"` + OutputMarkerPref string `json:"output_marker_prefix"` + ErrorMarkerPref string `json:"error_marker_prefix"` + ControlMarkerPref string `json:"control_marker_prefix"` + UsageMarkerPref string `json:"usage_marker_prefix"` + ExitMarkerPrefix string `json:"exit_marker_prefix"` + PrecopyAMD64Root bool `json:"precopy_amd64_root,omitempty"` + DisableCgroupMount bool `json:"disable_cgroup_mount,omitempty"` + Network *network `json:"network,omitempty"` + SnapshotMMIOBase uint64 `json:"snapshot_mmio_base,omitempty"` + SnapshotModules []string `json:"snapshot_modules,omitempty"` + UnixTime int64 `json:"unix_time,omitempty"` + KernelRelease string `json:"kernel_release,omitempty"` + ModuleSymversPath string `json:"module_symvers_path,omitempty"` +} + +type network struct { + Interface string `json:"interface,omitempty"` + Address string `json:"address,omitempty"` + Gateway string `json:"gateway,omitempty"` + DNS string `json:"dns,omitempty"` +} + +func installModuleSymvers(root, release string, data []byte) error { + release = strings.TrimSpace(release) + if release == "" || release == "." || release == ".." || filepath.Base(release) != release { + return fmt.Errorf("invalid kernel release %q", release) + } + if len(data) == 0 { + return fmt.Errorf("Module.symvers is empty") + } + + headersDir := filepath.Join(root, "usr", "src", "linux-headers-"+release) + if err := os.MkdirAll(headersDir, 0o755); err != nil { + return fmt.Errorf("create kernel metadata directory: %w", err) + } + symversPath := filepath.Join(headersDir, "Module.symvers") + tmp, err := os.CreateTemp(headersDir, ".Module.symvers-*") + if err != nil { + return fmt.Errorf("create temporary Module.symvers: %w", err) + } + tmpPath := tmp.Name() + ok := false + defer func() { + if !ok { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o644); err != nil { + return fmt.Errorf("set Module.symvers permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("write Module.symvers: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close Module.symvers: %w", err) + } + if err := os.Rename(tmpPath, symversPath); err != nil { + return fmt.Errorf("install Module.symvers: %w", err) + } + ok = true + + modulesDir := filepath.Join(root, "lib", "modules", release) + if err := os.MkdirAll(modulesDir, 0o755); err != nil { + return fmt.Errorf("create kernel module directory: %w", err) + } + buildPath := filepath.Join(modulesDir, "build") + if _, err := os.Lstat(buildPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect kernel build link: %w", err) + } + linkTarget := filepath.Join("..", "..", "..", "usr", "src", "linux-headers-"+release) + if err := os.Symlink(linkTarget, buildPath); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("install kernel build link: %w", err) + } + return nil +} + +type share struct { + Tag string `json:"tag"` + Mount string `json:"mount"` + Writable bool `json:"writable,omitempty"` +} + +type execRequest = protocol.ManagedExecRequest + +type preparedExecRequest struct { + ProcessMode string + ID string + Command []string + Env []string + RootDir string + WorkDir string + User string + Stdin []byte + TTY bool + ControlFD bool + Cols int + Rows int +} + +type execRequestValidation struct { + Message string + ExitCode int +} + +type systemdCommandGate struct { + enabled bool + ready bool + wait func(time.Duration) error + mu sync.Mutex +} + +type managedExec struct { + mode string + stdinMu sync.Mutex + stdin io.WriteCloser + pty *os.File + process *os.Process + family *guestagent.ProcessFamily + group bool + start time.Time +} + +func (m *managedExec) writeStdin(data []byte) error { + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + if m.stdin == nil { + return fmt.Errorf("stdin is closed") + } + _, err := m.stdin.Write(data) + return err +} + +func (m *managedExec) closeStdin() error { + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + if m.stdin == nil { + return nil + } + err := m.stdin.Close() + m.stdin = nil + return err +} + +func (m *managedExec) setProcess(proc *os.Process, group bool, family *guestagent.ProcessFamily) { + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + m.process = proc + m.family = family + m.group = group +} + +func (m *managedExec) setPTY(pty *os.File) { + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + m.pty = pty +} + +func (m *managedExec) signal(name string) error { + sig, err := guestagent.ParseSignal(name) + if err != nil { + return err + } + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + if m.process == nil { + return fmt.Errorf("process is not started") + } + process := m.process + family := m.family + if family != nil { + _ = family.Signal(sig) + } + if m.group && process.Pid > 0 { + err := syscall.Kill(-process.Pid, sig) + if err == nil { + if family != nil { + return family.Signal(sig) + } + return nil + } + if errors.Is(err, syscall.ESRCH) { + if family != nil { + return family.Signal(sig) + } + return nil + } + } + err = process.Signal(sig) + if family != nil { + if familyErr := family.Signal(sig); err == nil { + err = familyErr + } + } + return err +} + +func (m *managedExec) resize(cols, rows int) error { + m.stdinMu.Lock() + defer m.stdinMu.Unlock() + if m.pty == nil { + return fmt.Errorf("exec has no tty") + } + if cols <= 0 || rows <= 0 { + return fmt.Errorf("invalid tty size %dx%d", cols, rows) + } + // File.Fd switches a pollable descriptor back to blocking mode. Use + // SyscallConn so resize preserves interruptible PTY reads during cleanup. + conn, err := m.pty.SyscallConn() + if err != nil { + return err + } + var ioctlErr error + if err := conn.Control(func(fd uintptr) { + ioctlErr = unix.IoctlSetWinsize(int(fd), unix.TIOCSWINSZ, &unix.Winsize{ + Col: uint16(cols), + Row: uint16(rows), + }) + }); err != nil { + return err + } + return ioctlErr +} + +func (m *managedExec) WriteStdin(data []byte) error { + return m.writeStdin(data) +} + +func (m *managedExec) CloseStdin() error { + return m.closeStdin() +} + +func (m *managedExec) Signal(name string) error { + return m.signal(name) +} + +func (m *managedExec) Resize(cols, rows int) error { + return m.resize(cols, rows) +} + +func validateExecRequest(req execRequest) execRequestValidation { + if err := validateSandboxRequest(req); err != nil { + return execRequestValidation{Message: err.Error(), ExitCode: 125} + } + if len(req.Command) == 0 { + return execRequestValidation{Message: "exec request missing command", ExitCode: 125} + } + if req.ID == "" { + return execRequestValidation{Message: "exec request missing id"} + } + return execRequestValidation{} +} + +func prepareExecRequest(cfg config, req execRequest) preparedExecRequest { + workDir := req.WorkDir + if workDir == "" { + workDir = cfg.WorkDir + } + env := req.Env + if len(env) == 0 { + env = cfg.Env + } + user := req.User + if user == "" { + user = cfg.User + } + return preparedExecRequest{ + ID: req.ID, + ProcessMode: req.ProcessMode, + Command: append([]string(nil), req.Command...), + Env: append([]string(nil), env...), + RootDir: req.RootDir, + WorkDir: workDir, + User: user, + Stdin: append([]byte(nil), req.Stdin...), + TTY: req.TTY, + ControlFD: req.ControlFD, + Cols: req.Cols, + Rows: req.Rows, + } +} + +func startPreparedExec(cfg config, control io.Writer, active *guestagent.ActiveExecSet, req preparedExecRequest, waitReady func() error) { + // Use a kernel pipe for command input. An io.Pipe requires a guest Go + // goroutine to rendezvous with every write; immediately after snapshot + // restore that rendezvous could stall even though the child was already + // running. The kernel pipe also preserves natural backpressure without an + // arbitrary in-memory input limit. + stdinR, stdinW, err := os.Pipe() + if err != nil { + if cfg.Protocol == 1 { + writeSandboxEvent(cfg, control, req.ID, "launch_error", err.Error()) + } + writeExecStderr(cfg, control, req.ID, "ccx3-init: open command stdin: "+err.Error()+"\n") + reporterForConfig(cfg, control, req.ID, time.Now()).Exit(126) + return + } + managed := &managedExec{mode: req.ProcessMode, stdin: stdinW, start: time.Now()} + closeStdin := active.Add(req.ID, managed) + // The input sink is fully usable once it is registered: the kernel pipe can + // queue data before the command worker runs. Advertise that state without a + // goroutine rendezvous, which may not be scheduled promptly after restore. + writeExecTiming(control, req.ID, "input_ready", managed.start) + go func() { + runManagedExec(cfg, control, req.ID, req.Command, req.Env, req.RootDir, req.WorkDir, req.User, stdinR, managed, req.TTY, req.ControlFD, req.Cols, req.Rows, waitReady, func() { + _ = managed.closeStdin() + active.Delete(req.ID) + }) + }() + if closeStdin { + if err := managed.closeStdin(); err != nil { + writeKernel("ccx3-init: close pending stdin: " + err.Error()) + } + } + if len(req.Stdin) == 0 { + return + } + initialStdin := append([]byte(nil), req.Stdin...) + go func(id string, managed *managedExec) { + if err := managed.writeStdin(initialStdin); err != nil { + writeKernel("ccx3-init: write initial stdin: " + err.Error()) + } + if err := managed.closeStdin(); err != nil { + writeKernel("ccx3-init: close initial stdin: " + err.Error()) + } + }(req.ID, managed) +} + +func runPreparedExecInline(cfg config, control io.Writer, req preparedExecRequest, waitReady func() error) { + managed := &managedExec{mode: req.ProcessMode, start: time.Now()} + stdin, cleanup, err := inlineExecStdin(req.Stdin) + if err != nil { + if cfg.Protocol == 1 { + writeSandboxEvent(cfg, control, req.ID, "launch_error", err.Error()) + } + writeKernel("ccx3-init: prepare inline stdin: " + err.Error()) + writeExecStderr(cfg, control, req.ID, "ccx3-init: prepare inline stdin: "+err.Error()+"\n") + reporterForConfig(cfg, control, req.ID, time.Now()).Exit(126) + return + } + runManagedExec(cfg, control, req.ID, req.Command, req.Env, req.RootDir, req.WorkDir, req.User, stdin, managed, req.TTY, req.ControlFD, req.Cols, req.Rows, waitReady, cleanup) +} + +func inlineExecStdin(data []byte) (io.ReadCloser, func(), error) { + if len(data) == 0 { + return nil, func() {}, nil + } + file, err := os.CreateTemp("", "ccx3-stdin-*") + if err != nil { + return nil, nil, err + } + cleanup := func() { + _ = os.Remove(file.Name()) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + cleanup() + return nil, nil, err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + _ = file.Close() + cleanup() + return nil, nil, err + } + return file, cleanup, nil +} + +func newSystemdCommandGate(initSystem string) *systemdCommandGate { + return &systemdCommandGate{ + enabled: strings.TrimSpace(initSystem) == initSystemSystemd, + ready: strings.TrimSpace(initSystem) != initSystemSystemd, + wait: waitForSystemdCommandReady, + } +} + +func (g *systemdCommandGate) WaitForCommand(argv []string) func() error { + if g == nil || !g.enabled || !commandNeedsSystemdReady(argv) { + return nil + } + return func() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.ready { + return nil + } + wait := g.wait + if wait == nil { + wait = waitForSystemdCommandReady + } + if err := wait(systemdReadyTimeout); err != nil { + return err + } + g.ready = true + return nil + } +} + +// Run dispatches init helpers and runs the cc Linux init. +func Run() { + if handled, err := runSandboxHelper(os.Args[1:]); handled { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if len(os.Args) > 1 && os.Args[1] == captureRelayMode { + if err := capturerelay.Run(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "ccx3-init: capture relay:", err) + os.Exit(1) + } + return + } + if len(os.Args) > 1 && os.Args[1] == stage2Mode { + if err := runStage2(); err != nil { + writeKernel("ccx3-init: stage2: " + err.Error()) + fmt.Fprintf(os.Stderr, "ccx3-init: stage2: %v\n", err) + os.Exit(126) + } + return + } + if len(os.Args) > 1 && os.Args[1] == execPivotMode { + if err := runExecPivot(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "ccx3-init: exec pivot: %v\n", err) + os.Exit(126) + } + return + } + if len(os.Args) > 1 && os.Args[1] == archiveMode { + if err := runArchiveHelper(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "ccx3-init: fs archive: %v\n", err) + os.Exit(1) + } + return + } + if len(os.Args) > 1 && os.Args[1] == extractMode { + if err := runExtractHelper(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "ccx3-init: fs extract: %v\n", err) + os.Exit(1) + } + return + } + if err := run(); err != nil { + writeKernel(fatalBootMarker + err.Error()) + writeConsole(fatalBootMarker + err.Error() + "\n") + for { + syscall.Pause() + } + } +} + +func run() error { + fd, err := syscall.Open("/dev/console", syscall.O_RDWR|syscall.O_NONBLOCK, 0) + if err == nil { + _ = syscall.SetNonblock(fd, false) + consoleFD = fd + for _, target := range []int{0, 1, 2} { + _ = syscall.Dup3(fd, target, 0) + } + } + openKernelLog() + + var cfg config + buf, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + if err := json.Unmarshal(buf, &cfg); err != nil { + return fmt.Errorf("decode config: %w", err) + } + if err := validateSandboxConfig(cfg); err != nil { + return err + } + if cfg.WorkDir == "" { + cfg.WorkDir = "/" + } + if cfg.Hostname == "" || cfg.Hostname == "(none)" { + cfg.Hostname = "ccx3" + } + if err := configureClock(cfg.UnixTime); err != nil { + return err + } + bootStart := time.Now() + + _ = syscall.Mount("proc", "/proc", "proc", 0, "") + _ = syscall.Mount("sysfs", "/sys", "sysfs", 0, "") + if !cfg.DisableCgroupMount { + mountCgroupFS("/sys/fs/cgroup") + } + configureMemoryOvercommit("/proc") + + modules := cfg.Modules + var moduleSymvers []byte + if cfg.ModuleSymversPath != "" { + moduleSymvers, err = os.ReadFile(cfg.ModuleSymversPath) + if err != nil { + return fmt.Errorf("read staged Module.symvers: %w", err) + } + if len(moduleSymvers) == 0 { + return fmt.Errorf("staged Module.symvers is empty") + } + } + var deferredModulePaths []string + var deferredModules []modulePayload + if cfg.SnapshotMMIOBase != 0 { + modules, deferredModulePaths = splitSnapshotDeferredModules(cfg.Modules) + deferredModules, err = readModulePayloads(deferredModulePaths) + if err != nil { + return err + } + } + writeStage(bootStart, "loading modules") + if err := loadModules(modules); err != nil { + return err + } + writeStage(bootStart, "modules loaded") + if cfg.RootFSTag != "" || cfg.RootFSImagePath != "" { + writeStage(bootStart, "mounting rootfs") + if err := mountConfiguredRootFS(cfg); err != nil { + return err + } + writeStage(bootStart, "rootfs mounted") + if len(moduleSymvers) > 0 { + writeStage(bootStart, "installing kernel symbol versions") + if err := installModuleSymvers("/", cfg.KernelRelease, moduleSymvers); err != nil { + return fmt.Errorf("install kernel symbol versions: %w", err) + } + writeStage(bootStart, "kernel symbol versions installed") + } + writeStage(bootStart, "configuring hostname") + if err := configureHostname(cfg.Hostname); err != nil { + return fmt.Errorf("configure hostname: %w", err) + } + writeStage(bootStart, "hostname configured") + writeStage(bootStart, "configuring runtime filesystem") + if err := configureRuntimeFilesystem(); err != nil { + return fmt.Errorf("configure runtime filesystem: %w", err) + } + writeStage(bootStart, "runtime filesystem configured") + writeStage(bootStart, "configuring package managers") + if err := configurePackageManagers(""); err != nil { + return fmt.Errorf("configure package managers: %w", err) + } + writeStage(bootStart, "package managers configured") + if cfg.PrecopyAMD64Root { + writeStage(bootStart, "precopying amd64 root") + if err := precopyAMD64Root(); err != nil { + return fmt.Errorf("precopy amd64 root: %w", err) + } + writeStage(bootStart, "amd64 root precopied") + } + writeStage(bootStart, "configuring binfmt") + if configured, err := configureBinfmt(); err != nil { + return fmt.Errorf("configure binfmt: %w", err) + } else if configured { + writeStage(bootStart, "binfmt configured") + } else { + writeStage(bootStart, "binfmt unavailable") + } + } + if cfg.Network != nil { + writeStage(bootStart, "configuring network") + if err := configureNetwork(cfg.Network); err != nil { + return fmt.Errorf("configure network: %w", err) + } + writeStage(bootStart, "network configured") + } + writeStage(bootStart, "changing workdir") + if err := os.Chdir(cfg.WorkDir); err != nil { + return fmt.Errorf("chdir %s: %w", cfg.WorkDir, err) + } + + // Start the SA2 listener only in the final init, not before a stage2 exec. + if cfg.Protocol == 1 && cfg.SnapshotMMIOBase == 0 && cfg.InitSystem == "" { + stop, err := startSandboxProxy() + if err != nil { + return err + } + defer stop() + } + if len(cfg.Command) == 0 { + if cfg.VsockPort != 0 { + if strings.TrimSpace(cfg.InitSystem) != "" { + return bootInitSystem(cfg, bootStart) + } + if len(deferredModules) > 0 { + writeStage(bootStart, "staging deferred modules") + cfg.SnapshotModules, err = stageSnapshotModules(deferredModules) + if err != nil { + return err + } + writeStage(bootStart, "deferred modules staged") + } + if cfg.SnapshotMMIOBase != 0 { + writeStage(bootStart, "preparing snapshot stage2") + if err := prepareSnapshotStage2(cfg); err != nil { + return fmt.Errorf("prepare snapshot stage2: %w", err) + } + writeStage(bootStart, "snapshot stage2 prepared") + } + if err := triggerSnapshotMMIO(cfg.SnapshotMMIOBase); err != nil { + return fmt.Errorf("trigger snapshot: %w", err) + } + if cfg.SnapshotMMIOBase != 0 { + writeStage(bootStart, "seeding entropy") + if err := seedEntropyFromHostRNG(64); err != nil { + return fmt.Errorf("seed entropy after snapshot restore: %w", err) + } + writeStage(bootStart, "entropy seeded") + return execSnapshotStage2() + } + writeStage(bootStart, "connecting vsock control") + control, err := connectVsock(cfg.VsockPort) + if err != nil { + return fmt.Errorf("connect vsock control: %w", err) + } + defer control.Close() + if cfg.ReadyMarker != "" { + writeProtocolLineTo(control, initDurationMarker+itoa(int(time.Since(bootStart).Milliseconds()))) + writeStage(bootStart, "sending ready marker") + writeProtocolLineTo(control, cfg.ReadyMarker) + } + return commandLoop(cfg, control) + } + if cfg.ReadyMarker != "" { + writeKernel(cfg.ReadyMarker) + if kmsgFD >= 0 { + writeConsole(cfg.ReadyMarker + "\n") + } + } + return commandLoop(cfg, os.Stdin) + } + + if cfg.BeginMarker != "" { + writeKernel(cfg.BeginMarker) + } + + writeKernel("ccx3-init: exec " + strings.Join(cfg.Command, " ")) + if err := execCommand(cfg); err != nil { + return err + } + return fmt.Errorf("exec command returned unexpectedly") +} + +func runStage2() error { + openKernelLog() + writeKernel("ccx3-init: stage2 starting") + snapshotRestore := os.Getenv("CCX3_STAGE2_CONFIG") == snapshotStage2ConfigPath + var cfg config + buf, err := readStage2Config() + if err != nil { + return fmt.Errorf("read config: %w", err) + } + if err := json.Unmarshal(buf, &cfg); err != nil { + return fmt.Errorf("decode config: %w", err) + } + if snapshotRestore { + if err := loadModules(cfg.SnapshotModules); err != nil { + return fmt.Errorf("load snapshot modules: %w", err) + } + _ = os.RemoveAll(snapshotModuleDir) + _ = os.Remove(snapshotStage2ConfigPath) + _ = os.Remove(snapshotStage2Path) + } + if err := validateSandboxConfig(cfg); err != nil { + return err + } + if cfg.WorkDir == "" { + cfg.WorkDir = "/" + } + if cfg.VsockPort == 0 { + return fmt.Errorf("vsock control port is not configured") + } + if cfg.Protocol == 1 { + stop, err := startSandboxProxy() + if err != nil { + return err + } + defer stop() + } + writeKernel("ccx3-init: stage2 config loaded") + if err := os.Chdir(cfg.WorkDir); err != nil { + return fmt.Errorf("chdir %s: %w", cfg.WorkDir, err) + } + control, err := connectStage2Control(cfg.VsockPort) + if err != nil { + return fmt.Errorf("connect vsock control: %w", err) + } + defer control.Close() + if cfg.ReadyMarker != "" { + writeProtocolLineTo(control, initDurationMarker+"0") + writeProtocolLineTo(control, cfg.ReadyMarker) + } + return commandLoop(cfg, control) +} + +func prepareSnapshotStage2(cfg config) error { + if err := copyCurrentExecutable(snapshotStage2Path); err != nil { + return err + } + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode snapshot stage2 config: %w", err) + } + if err := os.WriteFile(snapshotStage2ConfigPath, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write snapshot stage2 config: %w", err) + } + if err := os.Chmod(snapshotStage2ConfigPath, 0o600); err != nil { + return fmt.Errorf("chmod snapshot stage2 config: %w", err) + } + return nil +} + +func stageSnapshotModules(modules []modulePayload) ([]string, error) { + if err := os.RemoveAll(snapshotModuleDir); err != nil { + return nil, fmt.Errorf("clear snapshot module directory: %w", err) + } + if err := os.MkdirAll(snapshotModuleDir, 0o700); err != nil { + return nil, fmt.Errorf("create snapshot module directory: %w", err) + } + paths := make([]string, 0, len(modules)) + for i, module := range modules { + path := filepath.Join(snapshotModuleDir, fmt.Sprintf("%03d-%s", i, filepath.Base(module.path))) + if err := os.WriteFile(path, module.data, 0o600); err != nil { + return nil, fmt.Errorf("stage snapshot module %s: %w", module.path, err) + } + paths = append(paths, path) + } + return paths, nil +} + +func execSnapshotStage2() error { + env := append(os.Environ(), "CCX3_STAGE2_CONFIG="+snapshotStage2ConfigPath) + if err := syscall.Exec(snapshotStage2Path, []string{snapshotStage2Path, stage2Mode}, env); err != nil { + return fmt.Errorf("exec snapshot stage2: %w", err) + } + return fmt.Errorf("snapshot stage2 returned unexpectedly") +} + +func waitForSystemdControlSocket(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + ok, err := socketExists("/run/systemd/private") + if err != nil { + return err + } + if ok { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("systemd control socket did not appear within %s", timeout) + } + time.Sleep(50 * time.Millisecond) + } +} + +func socketExists(path string) (bool, error) { + info, err := os.Stat(path) + if err == nil { + if info.Mode()&os.ModeSocket == 0 { + return false, fmt.Errorf("%s exists but is not a socket", path) + } + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("stat %s: %w", path, err) +} + +func systemdSystemBusExpected() bool { + for _, candidate := range []string{ + "/usr/bin/dbus-daemon", + "/bin/dbus-daemon", + "/usr/bin/dbus-broker-launch", + "/lib/systemd/system/dbus.service", + "/usr/lib/systemd/system/dbus.service", + } { + if pathExists(candidate) { + return true + } + } + return false +} + +func readStage2Config() ([]byte, error) { + if path := strings.TrimSpace(os.Getenv("CCX3_STAGE2_CONFIG")); path != "" { + return os.ReadFile(path) + } + buf, err := os.ReadFile(stage2ConfigPath) + if err == nil { + return buf, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + return os.ReadFile(configPath) +} + +func connectStage2Control(port uint32) (*os.File, error) { + start := time.Now() + lastLog := start + for attempt := 0; ; attempt++ { + control, err := connectVsockWithTimeout(port, stage2VsockConnectAttemptTimeout) + if err == nil { + if attempt > 0 || time.Since(start) > stage2VsockConnectAttemptTimeout { + writeKernel(fmt.Sprintf("ccx3-init: stage2 vsock connected after %s (%d retries)", time.Since(start).Round(time.Millisecond), attempt)) + } + return control, nil + } + if attempt == 0 { + writeKernel("ccx3-init: stage2 waiting for vsock control: " + err.Error()) + lastLog = time.Now() + } else if time.Since(lastLog) >= 5*time.Second { + writeKernel(fmt.Sprintf("ccx3-init: stage2 still waiting for vsock control after %s: %v", time.Since(start).Round(time.Millisecond), err)) + lastLog = time.Now() + } + time.Sleep(100 * time.Millisecond) + } +} + +func bootInitSystem(cfg config, bootStart time.Time) error { + initSystem := strings.TrimSpace(cfg.InitSystem) + switch initSystem { + case "", "ccx3": + return fmt.Errorf("init system is not configured") + case initSystemSystemd: + return bootSystemd(cfg, bootStart) + default: + return fmt.Errorf("unsupported init system %q", initSystem) + } +} + +func bootSystemd(cfg config, bootStart time.Time) error { + systemdPath, err := findSystemd() + if err != nil { + return err + } + writeStage(bootStart, "installing ccx3 stage2") + if err := installSystemdStage2(cfg); err != nil { + return err + } + writeStage(bootStart, "exec systemd") + return syscall.Exec(systemdPath, []string{systemdPath}, os.Environ()) +} + +func findSystemd() (string, error) { + for _, candidate := range []string{"/lib/systemd/systemd", "/usr/lib/systemd/systemd"} { + info, err := os.Stat(candidate) + if err != nil { + continue + } + if info.IsDir() { + continue + } + return candidate, nil + } + if target, err := filepath.EvalSymlinks("/sbin/init"); err == nil && strings.Contains(target, "systemd") { + return "/sbin/init", nil + } + return "", fmt.Errorf("systemd init requested but no systemd binary was found") +} + +func installSystemdStage2(cfg config) error { + if err := copyCurrentExecutable(stage2Path); err != nil { + return err + } + configData, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode stage2 config: %w", err) + } + if err := os.WriteFile(stage2ConfigPath, append(configData, '\n'), 0o600); err != nil { + return fmt.Errorf("write %s: %w", stage2ConfigPath, err) + } + if err := os.MkdirAll("/etc/systemd/system/sysinit.target.wants", 0o755); err != nil { + return fmt.Errorf("mkdir systemd runtime units: %w", err) + } + unitPath := "/etc/systemd/system/ccx3-stage2.service" + unit := `[Unit] +Description=ccx3 guest control stage2 +DefaultDependencies=no +Before=sysinit.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Environment=CCX3_STAGE2_CONFIG=/etc/ccx3/config.json +ExecStart=/etc/ccx3/stage2 --ccx3-stage2 +Restart=on-failure +RestartSec=250ms +StandardInput=null +StandardOutput=null +StandardError=null + +[Install] +WantedBy=sysinit.target +` + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + return fmt.Errorf("write %s: %w", unitPath, err) + } + if err := ensureSymlink(unitPath, "/etc/systemd/system/sysinit.target.wants/ccx3-stage2.service"); err != nil { + return fmt.Errorf("enable ccx3 stage2: %w", err) + } + for _, unit := range []string{ + "systemd-networkd-wait-online.service", + "NetworkManager-wait-online.service", + "systemd-remount-fs.service", + } { + if err := ensureSymlink("/dev/null", filepath.Join("/run/systemd/system", unit)); err != nil { + return fmt.Errorf("mask %s: %w", unit, err) + } + } + return nil +} + +func copyCurrentExecutable(dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) + } + src, err := os.Open("/proc/self/exe") + if err != nil { + return fmt.Errorf("open current executable: %w", err) + } + defer src.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + if _, err := io.Copy(out, src); err != nil { + _ = out.Close() + return fmt.Errorf("copy current executable to %s: %w", dst, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close %s: %w", dst, err) + } + if err := os.Chmod(dst, 0o755); err != nil { + return fmt.Errorf("chmod %s: %w", dst, err) + } + return nil +} + +func runExecPivot(args []string) error { + req, err := parseExecPivotArgs(args) + if err != nil { + return err + } + if req.rootDir != "" { + if err := pivotExecRoot(req.rootDir); err != nil { + return err + } + } + if req.workDir != "" { + if err := os.Chdir(req.workDir); err != nil { + return fmt.Errorf("chdir %s: %w", req.workDir, err) + } + } + if req.uid != "" || req.gid != "" || req.groups != "" { + if err := applyExecCredential(req.uid, req.gid, req.groups); err != nil { + return err + } + } + argv0, err := lookPathForExec(req.argv[0]) + if err != nil { + return err + } + return syscall.Exec(argv0, req.argv, os.Environ()) +} + +type execPivotRequest struct { + rootDir string + workDir string + uid string + gid string + groups string + argv []string +} + +func parseExecPivotArgs(args []string) (execPivotRequest, error) { + if len(args) < 7 { + return execPivotRequest{}, fmt.Errorf("invalid exec pivot argument count") + } + if args[5] != "--" { + return execPivotRequest{}, fmt.Errorf("invalid exec pivot separator") + } + argv := args[6:] + if len(argv) == 0 { + return execPivotRequest{}, fmt.Errorf("missing command") + } + return execPivotRequest{ + rootDir: args[0], + workDir: args[1], + uid: args[2], + gid: args[3], + groups: args[4], + argv: append([]string(nil), argv...), + }, nil +} + +func pivotExecRoot(rootDir string) error { + return pivotExecRootWithOps(rootDir, execPivotRootOps{ + mount: syscall.Mount, + mkdirAll: os.MkdirAll, + pivot: syscall.PivotRoot, + chdir: os.Chdir, + unmount: syscall.Unmount, + remove: os.Remove, + }) +} + +type execPivotRootOps struct { + mount func(source, target, fstype string, flags uintptr, data string) error + mkdirAll func(path string, perm os.FileMode) error + pivot func(newroot, putold string) error + chdir func(dir string) error + unmount func(target string, flags int) error + remove func(name string) error +} + +func pivotExecRootWithOps(rootDir string, ops execPivotRootOps) error { + if rootDir == "" { + return fmt.Errorf("missing root_dir") + } + if err := ops.mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""); err != nil { + return fmt.Errorf("make mount namespace private: %w", err) + } + putOld := filepath.Join(rootDir, ".ccx3-old-root") + if err := ops.mkdirAll(putOld, 0o700); err != nil { + return fmt.Errorf("mkdir put_old: %w", err) + } + if err := ops.pivot(rootDir, putOld); err != nil { + return fmt.Errorf("pivot_root %s: %w", rootDir, err) + } + if err := ops.chdir("/"); err != nil { + return fmt.Errorf("chdir / after pivot_root: %w", err) + } + if err := ops.unmount("/.ccx3-old-root", syscall.MNT_DETACH); err != nil { + return fmt.Errorf("unmount old root: %w", err) + } + if err := ops.remove("/.ccx3-old-root"); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove old root: %w", err) + } + return nil +} + +func applyExecCredential(uidText, gidText, groupsText string) error { + return applyExecCredentialWithOps(uidText, gidText, groupsText, execCredentialOps{ + setgroups: syscall.Setgroups, + setgid: syscall.Setgid, + setuid: syscall.Setuid, + }) +} + +type execCredentialOps struct { + setgroups func([]int) error + setgid func(int) error + setuid func(int) error +} + +func applyExecCredentialWithOps(uidText, gidText, groupsText string, ops execCredentialOps) error { + uid, err := parseUint32(uidText) + if err != nil { + return fmt.Errorf("invalid uid %q: %w", uidText, err) + } + gid, err := parseUint32(gidText) + if err != nil { + return fmt.Errorf("invalid gid %q: %w", gidText, err) + } + var groups []int + if groupsText != "" { + for _, part := range strings.Split(groupsText, ",") { + group, err := parseUint32(part) + if err != nil { + return fmt.Errorf("invalid group %q: %w", part, err) + } + groups = append(groups, int(group)) + } + } + if err := ops.setgroups(groups); err != nil { + return fmt.Errorf("setgroups: %w", err) + } + if err := ops.setgid(int(gid)); err != nil { + return fmt.Errorf("setgid: %w", err) + } + if err := ops.setuid(int(uid)); err != nil { + return fmt.Errorf("setuid: %w", err) + } + return nil +} + +func parseUint32(value string) (uint32, error) { + n := uint64(0) + for _, ch := range value { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("not numeric") + } + n = n*10 + uint64(ch-'0') + if n > uint64(^uint32(0)) { + return 0, fmt.Errorf("out of range") + } + } + return uint32(n), nil +} + +func lookPathForExec(file string) (string, error) { + return lookPathForExecEnv(file, os.Environ()) +} + +func lookPathForExecEnv(file string, env []string) (string, error) { + if strings.Contains(file, "/") { + return file, nil + } + pathEnv := "" + for _, entry := range env { + if strings.HasPrefix(entry, "PATH=") { + pathEnv = strings.TrimPrefix(entry, "PATH=") + break + } + } + if pathEnv == "" { + pathEnv = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + for _, dir := range strings.Split(pathEnv, ":") { + if dir == "" { + dir = "." + } + candidate := filepath.Join(dir, file) + info, err := os.Stat(candidate) + if err != nil { + continue + } + if info.Mode().IsRegular() && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + return "", fmt.Errorf("executable file not found in PATH: %s", file) +} + +func writeStage(start time.Time, stage string) { + line := fmt.Sprintf("ccx3-init: +%dms %s", time.Since(start).Milliseconds(), stage) + writeKernel(line) + writeConsole(line + "\n") +} + +func configureClock(unixTime int64) error { + if unixTime <= 0 { + return nil + } + tv := unix.NsecToTimeval(unixTime * int64(time.Second)) + if err := setTimeOfDay(&tv); err != nil { + return fmt.Errorf("set guest clock: %w", err) + } + return nil +} + +func mountConfiguredRootFS(cfg config) error { + if strings.TrimSpace(cfg.RootFSImagePath) != "" { + return mountImageRootFS(cfg.RootFSImagePath, cfg.RootFSImageType, cfg.EmulatorTag, cfg.DisableCgroupMount) + } + return mountVirtioRootFS(cfg.RootFSTag, cfg.EmulatorTag, cfg.DisableCgroupMount) +} + +func mountVirtioRootFS(tag, emulatorTag string, disableCgroup bool) error { + if err := os.MkdirAll("/mnt", 0o755); err != nil { + return fmt.Errorf("mkdir /mnt: %w", err) + } + if err := syscall.Mount(tag, "/mnt", "virtiofs", 0, ""); err != nil { + return fmt.Errorf("mount virtiofs %s: %w", tag, err) + } + return finishRootFS("/mnt", emulatorTag, disableCgroup) +} + +func mountImageRootFS(imagePath, imageType, emulatorTag string, disableCgroup bool) error { + imageType = strings.TrimSpace(imageType) + if imageType == "" { + imageType = "ext4" + } + if err := os.MkdirAll("/mnt", 0o755); err != nil { + return fmt.Errorf("mkdir /mnt: %w", err) + } + if err := attachLoop("/dev/loop0", imagePath); err != nil { + return err + } + if err := syscall.Mount("/dev/loop0", "/mnt", imageType, 0, ""); err != nil { + _ = clearLoop("/dev/loop0") + return fmt.Errorf("mount %s rootfs image %s: %w", imageType, imagePath, err) + } + return finishRootFS("/mnt", emulatorTag, disableCgroup) +} + +func attachLoop(loopPath, imagePath string) error { + image, err := syscall.Open(imagePath, syscall.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open rootfs image %s: %w", imagePath, err) + } + defer syscall.Close(image) + loop, err := syscall.Open(loopPath, syscall.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open loop device %s: %w", loopPath, err) + } + defer syscall.Close(loop) + if err := unix.IoctlSetInt(loop, unix.LOOP_SET_FD, image); err != nil { + return fmt.Errorf("attach %s to %s: %w", imagePath, loopPath, err) + } + return nil +} + +func clearLoop(loopPath string) error { + loop, err := syscall.Open(loopPath, syscall.O_RDWR, 0) + if err != nil { + return err + } + defer syscall.Close(loop) + return unix.IoctlSetInt(loop, unix.LOOP_CLR_FD, 0) +} + +func finishRootFS(newRoot, emulatorTag string, disableCgroup bool) error { + if err := switchRootFS(newRoot); err != nil { + return err + } + + for _, dir := range []string{"/proc", "/sys", "/dev", "/tmp", "/run"} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + _ = syscall.Mount("proc", "/proc", "proc", 0, "") + _ = syscall.Mount("sysfs", "/sys", "sysfs", 0, "") + if !disableCgroup { + mountCgroupFS("/sys/fs/cgroup") + } + configureMemoryOvercommit("/proc") + _ = syscall.Mount("devtmpfs", "/dev", "devtmpfs", 0, "") + _ = syscall.Mount("tmpfs", "/tmp", "tmpfs", 0, "mode=1777") + _ = syscall.Mount("tmpfs", "/run", "tmpfs", 0, "mode=755") + for _, dir := range []string{"/dev/pts", "/dev/shm"} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + _ = syscall.Mount("devpts", "/dev/pts", "devpts", 0, "gid=5,mode=620,ptmxmode=666") + _ = syscall.Mount("tmpfs", "/dev/shm", "tmpfs", 0, "mode=1777") + configureDevicePermissions("/dev") + if emulatorTag != "" { + if err := os.MkdirAll("/run/ccx3", 0o755); err != nil { + return fmt.Errorf("mkdir /run/ccx3: %w", err) + } + if err := syscall.Mount(emulatorTag, "/run/ccx3", "virtiofs", 0, ""); err != nil { + return fmt.Errorf("mount emulator virtiofs %s: %w", emulatorTag, err) + } + } + configureDeviceLinks() + return nil +} + +func switchRootFS(newRoot string) error { + if err := os.Chdir(newRoot); err != nil { + return fmt.Errorf("chdir %s before switch_root: %w", newRoot, err) + } + // pivot_root cannot move away from the initramfs rootfs. Use the + // switch_root pattern so later exec roots are not nested under a chroot, + // which would prevent user-namespace sandboxes such as bubblewrap. + for _, mount := range []string{"/sys/fs/cgroup", "/sys", "/proc"} { + _ = syscall.Unmount(mount, syscall.MNT_DETACH) + } + if err := emptyRootFS(newRoot); err != nil { + return err + } + if err := syscall.Mount(".", "/", "", syscall.MS_MOVE, ""); err != nil { + return fmt.Errorf("move root %s to /: %w", newRoot, err) + } + if err := syscall.Chroot("."); err != nil { + return fmt.Errorf("chroot after switch_root: %w", err) + } + if err := os.Chdir("/"); err != nil { + return fmt.Errorf("chdir / after switch_root: %w", err) + } + return nil +} + +func emptyRootFS(keep string) error { + keep = "/" + strings.Trim(strings.TrimSpace(keep), "/") + entries, err := os.ReadDir("/") + if err != nil { + return fmt.Errorf("read old root: %w", err) + } + for _, entry := range entries { + name := "/" + entry.Name() + if name == keep { + continue + } + if err := removeRootFSEntry(name); err != nil { + return err + } + } + return nil +} + +func triggerSnapshotMMIO(base uint64) error { + if base == 0 { + return nil + } + const snapshotMagic = 0x43535833534e4150 + pageSize := unix.Getpagesize() + pageBase := base & ^uint64(pageSize-1) + pageOff := int(base - pageBase) + if err := ensureDeviceNode("/dev/mem", unix.S_IFCHR|0o600, int(unix.Mkdev(1, 1))); err != nil { + writeConsole("__CCX3_SNAPSHOT__\n") + return fmt.Errorf("create /dev/mem: %w", err) + } + mem, err := os.OpenFile("/dev/mem", os.O_RDWR|unix.O_SYNC, 0) + if err != nil { + writeConsole("__CCX3_SNAPSHOT__\n") + return fmt.Errorf("open /dev/mem: %w", err) + } + defer mem.Close() + mapped, err := unix.Mmap(int(mem.Fd()), int64(pageBase), pageSize, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) + if err != nil { + writeConsole("__CCX3_SNAPSHOT__\n") + return fmt.Errorf("map snapshot MMIO: %w", err) + } + defer unix.Munmap(mapped) + if pageOff+8 > len(mapped) { + return fmt.Errorf("snapshot mmio offset %#x outside mapped page", pageOff) + } + trigger := (*uint64)(unsafe.Pointer(&mapped[pageOff])) + deadline := time.Now().Add(30 * time.Second) + for atomic.LoadUint64(trigger) != snapshotMagic { + if time.Now().After(deadline) { + return fmt.Errorf("wait for snapshot readiness: timed out") + } + time.Sleep(time.Millisecond) + } + atomic.StoreUint64(trigger, snapshotMagic) + writeConsole("__CCX3_SNAPSHOT__\n") + return nil +} + +func ensureDeviceNode(path string, mode uint32, dev int) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return unix.Mknod(path, mode, dev) +} + +func seedEntropyFromHostRNG(size int) error { + if size <= 0 { + return nil + } + seed := make([]byte, size) + rng, err := os.Open("/dev/hwrng") + if err != nil { + return fmt.Errorf("open /dev/hwrng: %w", err) + } + if _, err := io.ReadFull(rng, seed); err != nil { + _ = rng.Close() + return fmt.Errorf("read /dev/hwrng: %w", err) + } + if err := rng.Close(); err != nil { + return fmt.Errorf("close /dev/hwrng: %w", err) + } + urandom, err := os.OpenFile("/dev/urandom", os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open /dev/urandom: %w", err) + } + if _, err := urandom.Write(seed); err != nil { + _ = urandom.Close() + return fmt.Errorf("write /dev/urandom: %w", err) + } + if err := urandom.Close(); err != nil { + return fmt.Errorf("close /dev/urandom: %w", err) + } + return nil +} + +func removeRootFSEntry(name string) error { + info, err := os.Lstat(name) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat old root entry %s: %w", name, err) + } + if info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + entries, err := os.ReadDir(name) + if err != nil { + return fmt.Errorf("read old root dir %s: %w", name, err) + } + for _, entry := range entries { + if err := removeRootFSEntry(filepath.Join(name, entry.Name())); err != nil { + return err + } + } + } + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove old root entry %s: %w", name, err) + } + return nil +} + +func mountCgroupFS(path string) { + if err := os.MkdirAll(path, 0o755); err != nil { + writeKernel("ccx3-init: mkdir cgroup mountpoint: " + err.Error()) + return + } + if err := syscall.Mount("none", path, "cgroup2", 0, ""); err == nil || errors.Is(err, syscall.EBUSY) { + return + } + // Older or custom kernels may only expose cgroup v1. Mounting cgroup2 is + // best-effort so ordinary containers continue to boot on those kernels. + if err := syscall.Mount("cgroup", path, "cgroup", 0, ""); err != nil && !errors.Is(err, syscall.EBUSY) { + writeKernel("ccx3-init: mount cgroup filesystem: " + err.Error()) + } +} + +func configureRuntimeFilesystem() error { + if err := mountRuntimeTmpFS("/var/tmp", "mode=1777"); err != nil { + return err + } + for _, dir := range []struct { + path string + mode os.FileMode + }{ + {"/run/lock", 0o1777}, + {"/run/user", 0o755}, + {"/run/sshd", 0o755}, + {"/var/log", 0o755}, + {"/var/cache", 0o755}, + {"/var/lib/dbus", 0o755}, + } { + if err := os.MkdirAll(dir.path, dir.mode); err != nil { + return fmt.Errorf("mkdir %s: %w", dir.path, err) + } + if err := os.Chmod(dir.path, dir.mode); err != nil { + return fmt.Errorf("chmod %s: %w", dir.path, err) + } + } + if err := ensureMachineID(); err != nil { + return err + } + if err := ensureLocaltime(); err != nil { + return err + } + return nil +} + +func mountRuntimeTmpFS(path, options string) error { + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", path, err) + } + if err := syscall.Mount("tmpfs", path, "tmpfs", 0, options); err != nil && !errors.Is(err, syscall.EBUSY) { + return fmt.Errorf("mount tmpfs on %s: %w", path, err) + } + return nil +} + +func configureDeviceLinks() { + for target, link := range map[string]string{ + "/proc/self/fd": "/dev/fd", + "/proc/self/fd/0": "/dev/stdin", + "/proc/self/fd/1": "/dev/stdout", + "/proc/self/fd/2": "/dev/stderr", + } { + if err := ensureSymlink(target, link); err != nil { + writeKernel("ccx3-init: link " + link + ": " + err.Error()) + } + } +} + +// configureDevicePermissions applies the standard permissions normally set +// by udev. Minimal guest targets may deliberately start without udev, but +// should still expose devices intended for unprivileged use. +func configureDevicePermissions(devRoot string) { + for name, mode := range map[string]os.FileMode{ + "fuse": 0o666, + } { + path := filepath.Join(devRoot, name) + if err := os.Chmod(path, mode); err != nil && !errors.Is(err, os.ErrNotExist) { + writeKernel("ccx3-init: chmod " + path + ": " + err.Error()) + } + } +} + +func ensureLocaltime() error { + if pathExists("/etc/localtime") { + return nil + } + if !pathExists("/usr/share/zoneinfo/Etc/UTC") { + return nil + } + return ensureSymlink("/usr/share/zoneinfo/Etc/UTC", "/etc/localtime") +} + +func ensureSymlink(target, link string) error { + if existing, err := os.Readlink(link); err == nil { + if existing == target { + return nil + } + if err := os.Remove(link); err != nil { + return err + } + } else if os.IsNotExist(err) { + // Create it below. + } else if info, statErr := os.Lstat(link); statErr == nil { + if info.IsDir() { + return fmt.Errorf("%s is a directory", link) + } + if err := os.Remove(link); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + return err + } + return os.Symlink(target, link) +} + +func ensureMachineID() error { + id, err := readMachineID("/etc/machine-id") + if err != nil { + return err + } + if id == "" { + id, err = newMachineID() + if err != nil { + return err + } + if err := os.WriteFile("/etc/machine-id", []byte(id+"\n"), 0o444); err != nil { + return fmt.Errorf("write /etc/machine-id: %w", err) + } + } + dbusPath := "/var/lib/dbus/machine-id" + if existing, err := readMachineID(dbusPath); err != nil { + return err + } else if existing == id { + return nil + } + _ = os.Remove(dbusPath) + if err := os.Symlink("/etc/machine-id", dbusPath); err == nil { + return nil + } + if err := os.WriteFile(dbusPath, []byte(id+"\n"), 0o444); err != nil { + return fmt.Errorf("write %s: %w", dbusPath, err) + } + return nil +} + +func readMachineID(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("read %s: %w", path, err) + } + id := strings.TrimSpace(string(data)) + if id == "" || id == "uninitialized" { + return "", nil + } + return id, nil +} + +func newMachineID() (string, error) { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", fmt.Errorf("generate machine-id: %w", err) + } + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + return hex.EncodeToString(buf[:]), nil +} + +func precopyAMD64Root() error { + for _, path := range []string{ + "/run/ccx3/qemu-x86_64", + "/bin/busybox", + "/lib/ld-musl-x86_64.so.1", + "/lib/libc.musl-x86_64.so.1", + } { + if err := copyPath(path, filepath.Join("/run/ccx3-precopy", path)); err != nil { + return err + } + } + return nil +} + +func configureNetwork(cfg *network) error { + if cfg == nil { + return nil + } + iface := strings.TrimSpace(cfg.Interface) + if iface == "" { + iface = "eth0" + } + address := strings.TrimSpace(cfg.Address) + if address == "" { + address = "10.42.0.2/24" + } + gateway := strings.TrimSpace(cfg.Gateway) + if gateway == "" { + gateway = "10.42.0.1" + } + dns := strings.TrimSpace(cfg.DNS) + if dns == "" { + dns = gateway + } + if err := configureNetworkLink("lo"); err != nil { + writeKernel("ccx3-init: configure lo: " + err.Error()) + } + if err := configureNetworkLink(iface); err != nil { + return err + } + if err := configureNetworkAddress(iface, address); err != nil { + return err + } + if err := configureNetworkDefaultRoute(iface, gateway); err != nil { + return err + } + if err := writeResolverConfig(dns); err != nil { + return err + } + return nil +} + +func writeResolverConfig(dns string) error { + data := []byte("nameserver " + dns + "\n") + if err := writeFileReplacingMissingSymlink("/etc/resolv.conf", data, 0o644); err != nil { + return fmt.Errorf("write /etc/resolv.conf: %w", err) + } + return nil +} + +func writeFileReplacingMissingSymlink(name string, data []byte, perm os.FileMode) error { + if err := os.WriteFile(name, data, perm); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if info, err := os.Lstat(name); err == nil && info.Mode()&os.ModeSymlink != 0 { + _ = os.Remove(name) + return os.WriteFile(name, data, perm) + } + return os.WriteFile(name, data, perm) +} + +func configureNetworkLink(name string) error { + iface, err := net.InterfaceByName(name) + if err != nil { + return fmt.Errorf("find interface %s: %w", name, err) + } + msg := unix.IfInfomsg{ + Family: unix.AF_UNSPEC, + Index: int32(iface.Index), + Flags: unix.IFF_UP, + Change: unix.IFF_UP, + } + if err := netlinkRequest(unix.RTM_NEWLINK, 0, structBytes(&msg), false); err != nil { + return fmt.Errorf("set link %s up: %w", name, err) + } + return nil +} + +func configureNetworkAddress(ifaceName, address string) error { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return fmt.Errorf("find interface %s: %w", ifaceName, err) + } + ip, ipNet, err := net.ParseCIDR(address) + if err != nil { + return fmt.Errorf("parse address %s: %w", address, err) + } + ip4 := ip.To4() + if ip4 == nil { + return fmt.Errorf("address %s is not IPv4", address) + } + ones, _ := ipNet.Mask.Size() + msg := unix.IfAddrmsg{ + Family: unix.AF_INET, + Prefixlen: uint8(ones), + Scope: unix.RT_SCOPE_UNIVERSE, + Index: uint32(iface.Index), + } + payload := structBytes(&msg) + payload = appendRtAttr(payload, unix.IFA_LOCAL, ip4) + payload = appendRtAttr(payload, unix.IFA_ADDRESS, ip4) + if err := netlinkRequest(unix.RTM_NEWADDR, unix.NLM_F_CREATE|unix.NLM_F_REPLACE, payload, true); err != nil { + return fmt.Errorf("add address %s to %s: %w", address, ifaceName, err) + } + return nil +} + +func configureNetworkDefaultRoute(ifaceName, gateway string) error { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return fmt.Errorf("find interface %s: %w", ifaceName, err) + } + gw := net.ParseIP(gateway).To4() + if gw == nil { + return fmt.Errorf("gateway %s is not IPv4", gateway) + } + msg := unix.RtMsg{ + Family: unix.AF_INET, + Table: unix.RT_TABLE_MAIN, + Protocol: unix.RTPROT_STATIC, + Scope: unix.RT_SCOPE_UNIVERSE, + Type: unix.RTN_UNICAST, + } + payload := structBytes(&msg) + payload = appendRtAttr(payload, unix.RTA_GATEWAY, gw) + oif := uint32(iface.Index) + payload = appendRtAttr(payload, unix.RTA_OIF, structBytes(&oif)) + if err := netlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_REPLACE, payload, true); err != nil { + return fmt.Errorf("add default route via %s dev %s: %w", gateway, ifaceName, err) + } + return nil +} + +func netlinkRequest(msgType uint16, flags uint16, payload []byte, ignoreExists bool) error { + fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE) + if err != nil { + return err + } + defer unix.Close(fd) + if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { + return err + } + seq := uint32(time.Now().UnixNano()) + hdr := unix.NlMsghdr{ + Len: uint32(unix.NLMSG_HDRLEN + len(payload)), + Type: msgType, + Flags: unix.NLM_F_REQUEST | unix.NLM_F_ACK | flags, + Seq: seq, + } + msg := append(structBytes(&hdr), payload...) + if err := unix.Sendto(fd, msg, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { + return err + } + return readNetlinkAck(fd, seq, ignoreExists) +} + +func readNetlinkAck(fd int, seq uint32, ignoreExists bool) error { + buf := make([]byte, 8192) + for { + n, _, err := unix.Recvfrom(fd, buf, 0) + if err != nil { + return err + } + for off := 0; off+unix.NLMSG_HDRLEN <= n; { + hdr := (*unix.NlMsghdr)(unsafe.Pointer(&buf[off])) + if hdr.Len < unix.NLMSG_HDRLEN || off+int(hdr.Len) > n { + return fmt.Errorf("short netlink response") + } + next := off + nlmsgAlign(int(hdr.Len)) + if hdr.Seq != seq { + off = next + continue + } + switch hdr.Type { + case unix.NLMSG_ERROR: + if int(hdr.Len) < unix.NLMSG_HDRLEN+int(unsafe.Sizeof(unix.NlMsgerr{})) { + return fmt.Errorf("short netlink error response") + } + msgErr := (*unix.NlMsgerr)(unsafe.Pointer(&buf[off+unix.NLMSG_HDRLEN])) + if msgErr.Error == 0 { + return nil + } + errno := syscall.Errno(-msgErr.Error) + if ignoreExists && errno == syscall.EEXIST { + return nil + } + return errno + case unix.NLMSG_DONE: + return nil + } + off = next + } + } +} + +func appendRtAttr(buf []byte, attrType uint16, data []byte) []byte { + attr := unix.RtAttr{ + Len: uint16(unsafe.Sizeof(unix.RtAttr{}) + uintptr(len(data))), + Type: attrType, + } + buf = append(buf, structBytes(&attr)...) + buf = append(buf, data...) + for len(buf)%unix.NLMSG_ALIGNTO != 0 { + buf = append(buf, 0) + } + return buf +} + +func nlmsgAlign(n int) int { + return (n + unix.NLMSG_ALIGNTO - 1) & ^(unix.NLMSG_ALIGNTO - 1) +} + +func structBytes[T any](value *T) []byte { + size := int(unsafe.Sizeof(*value)) + data := unsafe.Slice((*byte)(unsafe.Pointer(value)), size) + out := make([]byte, size) + copy(out, data) + return out +} + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg) + } + return nil +} + +func copyPath(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) + } + info, err := os.Lstat(src) + if err != nil { + return fmt.Errorf("stat %s: %w", src, err) + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(src) + if err != nil { + return fmt.Errorf("readlink %s: %w", src, err) + } + _ = os.Remove(dst) + if err := os.Symlink(target, dst); err != nil { + return fmt.Errorf("symlink %s: %w", dst, err) + } + return nil + } + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open %s: %w", src, err) + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return fmt.Errorf("copy %s to %s: %w", src, dst, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close %s: %w", dst, err) + } + return nil +} + +func configureMemoryOvercommit(procRoot string) { + path := filepath.Join(procRoot, "sys/vm/overcommit_memory") + _ = os.WriteFile(path, []byte("1\n"), 0o644) +} + +func configureBinfmt() (bool, error) { + if _, err := os.Stat(guestQEMUPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + if err := copyPath(guestQEMUPath, guestQEMUBinfmtPath); err != nil { + return false, fmt.Errorf("copy qemu-x86_64 for binfmt: %w", err) + } + if err := os.Chmod(guestQEMUBinfmtPath, 0o755); err != nil { + return false, fmt.Errorf("chmod %s: %w", guestQEMUBinfmtPath, err) + } + if err := os.MkdirAll("/proc/sys/fs/binfmt_misc", 0o755); err != nil { + return false, fmt.Errorf("mkdir binfmt_misc: %w", err) + } + if err := syscall.Mount("binfmt_misc", "/proc/sys/fs/binfmt_misc", "binfmt_misc", 0, ""); err != nil && !errors.Is(err, syscall.EBUSY) { + if errors.Is(err, syscall.ENODEV) { + writeKernel("ccx3-init: binfmt_misc unavailable: " + err.Error()) + return false, nil + } + return false, fmt.Errorf("mount binfmt_misc: %w", err) + } + if _, err := os.Stat("/proc/sys/fs/binfmt_misc/qemu-x86_64"); err == nil { + return true, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("stat qemu-x86_64 registration: %w", err) + } + const qemuX8664Registration = ":qemu-x86_64:M::\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x3e\\x00:\\xff\\xff\\xff\\xff\\xff\\xfe\\xfe\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:" + guestQEMUBinfmtPath + ":F" + if err := os.WriteFile("/proc/sys/fs/binfmt_misc/register", []byte(qemuX8664Registration), 0o644); err != nil { + return false, fmt.Errorf("register qemu-x86_64: %w", err) + } + if _, err := os.Stat("/proc/sys/fs/binfmt_misc/qemu-x86_64"); err != nil { + return false, fmt.Errorf("verify qemu-x86_64 registration: %w", err) + } + return true, nil +} + +func writeString(fd int, value string) { + for len(value) > 0 { + n, err := syscall.Write(fd, []byte(value)) + if err != nil || n <= 0 { + return + } + value = value[n:] + } +} + +func writeConsole(value string) { + writeString(consoleFD, value) +} + +func writeProtocolLine(value string) { + protocolMu.Lock() + defer protocolMu.Unlock() + writeConsole(strings.TrimRight(value, "\n") + "\n") +} + +func writeProtocolLineTo(w io.Writer, value string) { + guestagent.WriteProtocolLine(w, value) +} + +func writeExecStderr(cfg config, control io.Writer, id, value string) { + reporterForConfig(cfg, control, id, time.Time{}).Stderr([]byte(value)) +} + +func writeExecStdoutBytes(cfg config, control io.Writer, id string, data []byte) { + reporterForConfig(cfg, control, id, time.Time{}).Stdout(data) +} + +func writeExecControlBytes(cfg config, control io.Writer, id string, data []byte) { + reporterForConfig(cfg, control, id, time.Time{}).ControlBytes(data) +} + +func writeExecTiming(control io.Writer, id, phase string, start time.Time) { + guestagent.NewExecReporter(guestagent.Protocol{TimingMarkerPrefix: guestagent.TimingMarkerPrefix}, control, id, start).Timing(phase) +} + +func managedExecGuestReadyTimingPhases(waitReady func() error) (begin string, done string, enabled bool) { + if waitReady == nil { + return "", "", false + } + return managedExecTimingGuestReadyBegin, managedExecTimingGuestReadyDone, true +} + +func managedExecFirstOutputTimingPhase(stderr bool) string { + if stderr { + return managedExecTimingFirstStderr + } + return managedExecTimingFirstStdout +} + +func reporterForConfig(cfg config, control io.Writer, id string, start time.Time) guestagent.ExecReporter { + return guestagent.NewExecReporter(protocolForConfig(cfg), control, id, start) +} + +func protocolForConfig(cfg config) guestagent.Protocol { + return guestagent.Protocol{ + BeginMarkerPrefix: cfg.BeginMarker, + OutputMarkerPrefix: cfg.OutputMarkerPref, + ErrorMarkerPrefix: cfg.ErrorMarkerPref, + ControlMarkerPrefix: cfg.ControlMarkerPref, + UsageMarkerPrefix: cfg.UsageMarkerPref, + ExitMarkerPrefix: cfg.ExitMarkerPrefix, + TimingMarkerPrefix: guestagent.TimingMarkerPrefix, + } +} + +func appendFileContents(buf *strings.Builder, path string) { + data, err := os.ReadFile(path) + if err != nil { + buf.WriteString(path) + buf.WriteString(": ") + buf.WriteString(err.Error()) + buf.WriteString("\n") + return + } + buf.WriteString(path) + buf.WriteString(":\n") + buf.Write(data) + if len(data) == 0 || data[len(data)-1] != '\n' { + buf.WriteString("\n") + } +} + +func appendStat(buf *strings.Builder, label, path string) { + info, err := os.Stat(path) + if err != nil { + buf.WriteString(label) + buf.WriteString(": ") + buf.WriteString(path) + buf.WriteString(": ") + buf.WriteString(err.Error()) + buf.WriteString("\n") + return + } + buf.WriteString(label) + buf.WriteString(": ") + buf.WriteString(path) + buf.WriteString(" mode=") + buf.WriteString(fmt.Sprintf("%#o", info.Mode()&0o777)) + if info.IsDir() { + buf.WriteString(" dir=true") + } + buf.WriteString("\n") +} + +func managedExecStartFailure(err error, argv []string, rootDir, workDir string) (int, string) { + command := "command" + if len(argv) > 0 && strings.TrimSpace(argv[0]) != "" { + command = argv[0] + } + switch { + case errors.Is(err, exec.ErrNotFound), errors.Is(err, os.ErrNotExist): + return 127, command + ": executable not found\n" + case errors.Is(err, os.ErrPermission): + return 126, command + ": permission denied\n" + default: + return 126, "ccx3-init: exec error: " + err.Error() + "\n" + collectExecDiagnostics(rootDir, argv, workDir) + } +} + +func collectExecDiagnostics(rootDir string, argv []string, workDir string) string { + var buf strings.Builder + if rootDir != "" { + appendStat(&buf, "root_dir", rootDir) + } + if len(argv) != 0 { + appendStat(&buf, "argv0", argv[0]) + if rootDir != "" && strings.HasPrefix(argv[0], "/") { + appendStat(&buf, "argv0@root", rootDir+argv[0]) + } + } + if workDir != "" { + appendStat(&buf, "workdir", workDir) + if rootDir != "" && strings.HasPrefix(workDir, "/") { + appendStat(&buf, "workdir@root", rootDir+workDir) + } + } + appendStat(&buf, "/run", "/run") + appendStat(&buf, "/run/ccx3", "/run/ccx3") + appendStat(&buf, guestQEMUBinfmtPath, guestQEMUBinfmtPath) + if info, err := os.Stat(guestQEMUPath); err != nil { + buf.WriteString(guestQEMUPath) + buf.WriteString(": ") + buf.WriteString(err.Error()) + buf.WriteString("\n") + } else { + buf.WriteString(guestQEMUPath) + buf.WriteString(" mode: ") + buf.WriteString(fmt.Sprintf("%#o", info.Mode()&0o777)) + buf.WriteString("\n") + } + appendFileContents(&buf, "/proc/sys/fs/binfmt_misc/status") + appendFileContents(&buf, "/proc/sys/fs/binfmt_misc/qemu-x86_64") + return buf.String() +} + +func writeKernel(value string) { + value = strings.TrimRight(value, "\n") + if value == "" { + return + } + if kmsgFD >= 0 { + writeString(kmsgFD, "<6>"+value+"\n") + return + } + writeConsole(value + "\n") +} + +func openKernelLog() { + if kmsgFD >= 0 { + return + } + if fd, err := syscall.Open("/dev/kmsg", syscall.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { + kmsgFD = fd + } +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [32]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + (v % 10)) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +func openPTY(cols, rows int) (*os.File, *os.File, error) { + // A pollable master lets Close interrupt an inherited-descriptor read. + masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + if err != nil { + return nil, nil, err + } + master := os.NewFile(uintptr(masterFD), "ptmx") + if master == nil { + _ = unix.Close(masterFD) + return nil, nil, fmt.Errorf("open /dev/ptmx: no file handle") + } + + if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { + _ = master.Close() + return nil, nil, fmt.Errorf("unlock ptmx: %w", err) + } + ptn, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) + if err != nil { + _ = master.Close() + return nil, nil, fmt.Errorf("query pty number: %w", err) + } + if cols > 0 && rows > 0 { + if err := unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{ + Col: uint16(cols), + Row: uint16(rows), + }); err != nil { + _ = master.Close() + return nil, nil, fmt.Errorf("set initial winsize: %w", err) + } + } + slave, err := os.OpenFile("/dev/pts/"+itoa(ptn), os.O_RDWR, 0) + if err != nil { + _ = master.Close() + return nil, nil, fmt.Errorf("open slave pty: %w", err) + } + return master, slave, nil +} + +func loadModules(modules []string) error { + for _, path := range modules { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read module %s: %w", path, err) + } + if err := loadModuleData(path, data); err != nil { + return err + } + } + return nil +} + +type modulePayload struct { + path string + data []byte +} + +func readModulePayloads(modules []string) ([]modulePayload, error) { + payloads := make([]modulePayload, 0, len(modules)) + for _, path := range modules { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read module %s: %w", path, err) + } + payloads = append(payloads, modulePayload{path: path, data: data}) + } + return payloads, nil +} + +func loadModuleData(path string, data []byte) error { + if len(data) == 0 { + return fmt.Errorf("module %s is empty", path) + } + params, err := syscall.BytePtrFromString("") + if err != nil { + return fmt.Errorf("init module params: %w", err) + } + _, _, errno := syscall.RawSyscall(syscall.SYS_INIT_MODULE, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), uintptr(unsafe.Pointer(params))) + if errno != 0 { + return fmt.Errorf("load module %s: errno=%d", path, errno) + } + return nil +} + +func splitSnapshotDeferredModules(modules []string) ([]string, []string) { + var early []string + var deferred []string + for _, path := range modules { + if snapshotDeferredModule(path) { + deferred = append(deferred, path) + continue + } + early = append(early, path) + } + return early, deferred +} + +func snapshotDeferredModule(path string) bool { + name := filepath.Base(path) + return name == "vsock.ko" || + strings.HasPrefix(name, "vsock.ko.") || + strings.HasPrefix(name, "vmw_vsock_") || + strings.HasPrefix(name, "virtio_vsock") +} + +func execCommand(cfg config) error { + if info, err := os.Stat(cfg.Command[0]); err != nil { + writeKernel("ccx3-init: stat failed for " + cfg.Command[0] + ": " + err.Error()) + } else { + writeKernel("ccx3-init: stat mode for " + cfg.Command[0] + " is " + fmt.Sprintf("%#o", info.Mode()&0o777)) + } + + exitCode, usage, err := execCommandGo(cfg.Command, cfg.Env, cfg.WorkDir, cfg.User) + if err != nil { + return fmt.Errorf("run %s: %w", cfg.Command[0], err) + } + if cfg.UsageMarkerPref != "" && usage != nil { + usageMarker := cfg.UsageMarkerPref + guestagent.EncodeExecUsage(usage) + writeKernel(usageMarker) + writeProtocolLine(usageMarker) + } + if cfg.ExitMarkerPrefix != "" { + exitMarker := cfg.ExitMarkerPrefix + itoa(exitCode) + writeKernel(exitMarker) + writeProtocolLine(exitMarker) + } + syscall.Sync() + _ = syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF) + for { + syscall.Pause() + } +} + +func execCommandGo(argv []string, env []string, workDir string, user string) (int, *guestagent.ExecUsage, error) { + cred, err := guestagent.CredentialForUser(user) + if err != nil { + return 0, nil, err + } + if cred != nil { + credentialSetupMu.Lock() + err := guestagent.EnsureCredentialUser("", cred) + credentialSetupMu.Unlock() + if err != nil { + return 0, nil, err + } + env = guestagent.EnvironmentForCredential("", env, cred) + } + cmd := exec.Command(argv[0], argv[1:]...) + start := time.Now() + cmd.Env = env + if workDir != "" { + cmd.Dir = workDir + } + console := os.NewFile(uintptr(consoleFD), "/dev/console") + cmd.Stdin = console + cmd.Stdout = console + cmd.Stderr = console + if cred != nil { + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred} + } + + err = cmd.Run() + usage := guestagent.UsageFromProcessState(cmd.ProcessState, time.Since(start)) + if err == nil { + return 0, usage, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return guestagent.ProcessExitCode(cmd.ProcessState, exitErr.ExitCode()), usage, nil + } + return 0, usage, err +} + +func handleInitControlRequest(cfg config, control io.Writer, active *guestagent.ActiveExecSet, req execRequest) bool { + switch req.Kind { + case "exec": + return false + case "sync": + if req.ID == "" { + writeKernel("ccx3-init: sync request missing id") + return true + } + go runSyncRequest(cfg, control, req.ID) + case "fs_archive": + if req.ID == "" { + writeKernel("ccx3-init: fs_archive request missing id") + return true + } + go runFSArchiveRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User)) + case "fs_mkdir": + if req.ID == "" { + writeKernel("ccx3-init: fs_mkdir request missing id") + return true + } + go runFSMkdirRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User)) + case "fs_write": + if req.ID == "" { + writeKernel("ccx3-init: fs_write request missing id") + return true + } + go runFSWriteRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User), io.NopCloser(bytes.NewReader(req.Stdin))) + case "fs_extract": + if req.ID == "" { + writeKernel("ccx3-init: fs_extract request missing id") + return true + } + if len(req.Stdin) > 0 { + go runFSExtractRequest(cfg, control, req.ID, req.RootDir, req.Path, req.Directory, req.User, req.ArchiveLimits, io.NopCloser(bytes.NewReader(req.Stdin)), func() {}) + return true + } + stdinR, stdinW := io.Pipe() + managed := &managedExec{mode: req.ProcessMode, stdin: stdinW, start: time.Now()} + active.Add(req.ID, managed) + go runFSExtractRequest(cfg, control, req.ID, req.RootDir, req.Path, req.Directory, req.User, req.ArchiveLimits, stdinR, func() { + _ = managed.closeStdin() + active.Delete(req.ID) + }) + case "stdin": + result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ + Kind: req.Kind, + ID: req.ID, + Stdin: req.Stdin, + }) + if !result.Found { + writeKernel("ccx3-init: stdin for unknown exec id " + req.ID) + return true + } + if result.Err != nil { + writeKernel("ccx3-init: write stdin: " + result.Err.Error()) + } + case "stdin_close": + result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ + Kind: req.Kind, + ID: req.ID, + RememberPendingStdinClose: true, + }) + if !result.Found { + return true + } + if result.Err != nil { + writeKernel("ccx3-init: close stdin: " + result.Err.Error()) + } + case "signal": + result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ + Kind: req.Kind, + ID: req.ID, + Signal: req.Signal, + }) + if !result.Found { + writeKernel("ccx3-init: signal for unknown exec id " + req.ID) + return true + } + if result.Err != nil { + writeKernel("ccx3-init: signal " + req.Signal + ": " + result.Err.Error()) + } + case "resize": + result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ + Kind: req.Kind, + ID: req.ID, + Cols: req.Cols, + Rows: req.Rows, + }) + if !result.Found { + return true + } + if result.Err != nil { + writeKernel("ccx3-init: resize " + itoa(req.Cols) + "x" + itoa(req.Rows) + ": " + result.Err.Error()) + } + default: + writeKernel("ccx3-init: unsupported control kind " + req.Kind) + } + return true +} + +func commandLoop(cfg config, control io.ReadWriter) error { + if cfg.ControlMarkerPref == "" { + cfg.ControlMarkerPref = defaultControlMarkerPref + } + reader := bufio.NewReader(control) + active := guestagent.NewActiveExecSet() + systemdGate := newSystemdCommandGate(cfg.InitSystem) + startOrphanReaper() + for { + line, err := reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("read exec request: %w", err) + } + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + + var req execRequest + if err := json.Unmarshal(line, &req); err != nil { + writeKernel("ccx3-init: decode exec request: " + err.Error()) + if cfg.ExitMarkerPrefix != "" { + writeKernel(cfg.ExitMarkerPrefix + "125") + } + continue + } + if req.Kind == "" { + req.Kind = "exec" + } + inlineExec := req.Kind == "exec_inline" + if inlineExec { + req.Kind = "exec" + } + if err := validateSandboxRequest(req); err != nil || (req.SandboxProtocol != 0 && req.SandboxProtocol != cfg.Protocol) { + message := "sandbox protocol does not match init" + if err != nil { + message = err.Error() + } + writeSandboxEvent(cfg, control, req.ID, "launch_error", message) + reporterForConfig(cfg, control, req.ID, time.Now()).Exit(125) + continue + } + if handleInitControlRequest(cfg, control, active, req) { + continue + } + if validation := validateExecRequest(req); validation.Message != "" { + writeSandboxEvent(cfg, control, req.ID, "launch_error", validation.Message) + reporterForConfig(cfg, control, req.ID, time.Now()).Exit(125) + writeKernel("ccx3-init: " + validation.Message) + if validation.ExitCode != 0 && cfg.ExitMarkerPrefix != "" { + writeKernel(cfg.ExitMarkerPrefix + itoa(validation.ExitCode)) + } + continue + } + + execReq := prepareExecRequest(cfg, req) + if inlineExec { + runPreparedExecInline(cfg, control, execReq, systemdGate.WaitForCommand(execReq.Command)) + continue + } + startPreparedExec(cfg, control, active, execReq, systemdGate.WaitForCommand(execReq.Command)) + } +} + +func commandNeedsSystemdReady(argv []string) bool { + if len(argv) == 0 { + return false + } + return commandTextNeedsSystemdReady(argv[0], argv[1:]) +} + +func commandTextNeedsSystemdReady(cmd string, args []string) bool { + switch filepath.Base(cmd) { + case "systemctl", "journalctl", "loginctl", "busctl", "hostnamectl", "timedatectl", "localectl", "networkctl", "resolvectl": + return true + case "service": + return len(args) == 0 || args[0] != "--help" + case "sudo", "doas", "env", "command": + next, rest, ok := unwrapCommandPrefix(args) + if !ok { + return false + } + return commandTextNeedsSystemdReady(next, rest) + case "sh", "bash", "dash", "zsh": + for i, arg := range args { + if shellArgRunsCommand(arg) && i+1 < len(args) { + return shellScriptNeedsSystemdReady(args[i+1]) + } + } + } + return false +} + +func shellArgRunsCommand(arg string) bool { + if arg == "-c" { + return true + } + if !strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") { + return false + } + return strings.Contains(arg[1:], "c") +} + +func unwrapCommandPrefix(args []string) (string, []string, bool) { + for len(args) > 0 { + arg := args[0] + if arg == "--" { + args = args[1:] + continue + } + if strings.Contains(arg, "=") && !strings.HasPrefix(arg, "-") { + args = args[1:] + continue + } + if strings.HasPrefix(arg, "-") { + args = args[1:] + if arg == "-u" || arg == "-g" || arg == "-C" || arg == "-S" || arg == "--user" || arg == "--group" || arg == "--chdir" || arg == "--shell" { + if len(args) > 0 { + args = args[1:] + } + } + continue + } + return arg, args[1:], true + } + return "", nil, false +} + +func shellScriptNeedsSystemdReady(script string) bool { + fields := strings.FieldsFunc(script, func(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', ';', '&', '|', '(', ')', '<', '>', '`', '$', '"', '\'', '\\': + return true + default: + return false + } + }) + for _, field := range fields { + if commandTextNeedsSystemdReady(field, nil) { + return true + } + } + return false +} + +func waitForSystemdCommandReady(timeout time.Duration) error { + if ok, err := socketExists("/run/systemd/private"); err != nil { + return err + } else if !ok { + if err := waitForSystemdControlSocket(timeout); err != nil { + return err + } + } + if !systemdSystemBusExpected() { + return nil + } + deadline := time.Now().Add(timeout) + for { + ok, err := socketExists("/run/dbus/system_bus_socket") + if err != nil { + return err + } + if ok { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("systemd system bus socket did not appear within %s", timeout) + } + time.Sleep(50 * time.Millisecond) + } +} + +func runSyncRequest(cfg config, control io.Writer, id string) { + proto := protocolForConfig(cfg) + proto.WriteBegin(control, id) + syscall.Sync() + proto.WriteExit(control, id, 0) +} + +func runFSArchiveRequest(cfg config, control io.Writer, id, rootDir, src, user string) { + proto := protocolForConfig(cfg) + proto.WriteBegin(control, id) + exitCode := 0 + if err := archivePathToControl(cfg, control, id, rootDir, src, user); err != nil { + exitCode = 1 + writeExecStderr(cfg, control, id, "ccx3-init: fs archive: "+err.Error()+"\n") + } + proto.WriteExit(control, id, exitCode) +} + +func archivePathToControl(cfg config, control io.Writer, id, rootDir, src, user string) error { + if strings.TrimSpace(src) == "" { + return fmt.Errorf("source path is required") + } + credential, err := guestagent.CredentialForUserInRoot(rootDir, user) + if err != nil { + return err + } + if credential != nil { + cmd := exec.Command("/proc/self/exe", archiveMode, rootDir, src) + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential} + cmd.Stdout = execOutputWriter{write: func(data []byte) { writeExecStdoutBytes(cfg, control, id, data) }} + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := runArchiveHelperCommand(cmd); err != nil { + return archiveHelperError(err, stderr.String()) + } + return nil + } + src = rootPath(rootDir, filepath.Clean(src)) + info, err := os.Lstat(src) + if err != nil { + return err + } + pr, pw := io.Pipe() + go func() { + err := guestagent.WritePathTar(pw, src, filepath.Base(src), info) + _ = pw.CloseWithError(err) + }() + var buf [execProtocolChunkSize]byte + for { + n, err := pr.Read(buf[:]) + if n > 0 { + writeExecStdoutBytes(cfg, control, id, buf[:n]) + } + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + } +} + +type execOutputWriter struct { + write func([]byte) +} + +func (w execOutputWriter) Write(data []byte) (int, error) { + if len(data) > 0 && w.write != nil { + w.write(data) + } + return len(data), nil +} + +func runArchiveHelper(args []string) error { + if len(args) != 2 { + return fmt.Errorf("archive helper requires root and source paths") + } + src := rootPath(args[0], filepath.Clean(args[1])) + info, err := os.Lstat(src) + if err != nil { + return err + } + return guestagent.WritePathTar(os.Stdout, src, filepath.Base(src), info) +} + +type extractHelperRequest struct { + RootDir string `json:"root_dir,omitempty"` + Path string `json:"path"` + IsDir bool `json:"is_dir,omitempty"` + Limits *client.ArchiveLimits `json:"limits,omitempty"` +} + +func runExtractHelper(args []string) error { + if len(args) != 1 { + return fmt.Errorf("extract helper requires one request") + } + var request extractHelperRequest + if err := json.Unmarshal([]byte(args[0]), &request); err != nil { + return fmt.Errorf("decode request: %w", err) + } + ctx, cancel, err := guestagent.ArchiveContext(context.Background(), request.Limits) + if err != nil { + return err + } + defer cancel() + return guestagent.ExtractTarToPathContext(ctx, os.Stdin, request.RootDir, request.Path, request.IsDir, request.Limits) +} + +func archiveHelperError(runErr error, stderr string) error { + stderr = strings.TrimSpace(stderr) + if stderr != "" { + return errors.New(stderr) + } + return runErr +} + +func runArchiveHelperCommand(cmd *exec.Cmd) error { + managedChildren.Lock() + err := cmd.Start() + if err == nil { + managedChildren.pids[cmd.Process.Pid] = struct{}{} + } + managedChildren.Unlock() + if err != nil { + return err + } + err = cmd.Wait() + managedChildren.Lock() + delete(managedChildren.pids, cmd.Process.Pid) + managedChildren.Unlock() + return err +} + +func runFSExtractRequest(cfg config, control io.Writer, id, rootDir, dst string, dstDir bool, user string, limits *client.ArchiveLimits, stdin io.ReadCloser, cleanup func()) { + defer cleanup() + defer stdin.Close() + proto := protocolForConfig(cfg) + proto.WriteBegin(control, id) + exitCode := 0 + ctx, cancel, err := guestagent.ArchiveContext(context.Background(), limits) + if err == nil { + defer cancel() + credential, credentialErr := guestagent.CredentialForUserInRoot(rootDir, user) + if credentialErr != nil { + err = credentialErr + } else if credential != nil { + credentialSetupMu.Lock() + userErr := guestagent.EnsureCredentialUser(rootDir, credential) + if userErr == nil { + userErr = guestagent.EnsureCredentialArchiveHome(rootDir, dst, credential) + } + credentialSetupMu.Unlock() + if userErr != nil { + err = userErr + } + } + if err == nil && credential != nil { + request, marshalErr := json.Marshal(extractHelperRequest{RootDir: rootDir, Path: dst, IsDir: dstDir, Limits: limits}) + if marshalErr != nil { + err = marshalErr + } else { + cmd := exec.Command("/proc/self/exe", extractMode, string(request)) + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential} + cmd.Stdin = stdin + var stderr bytes.Buffer + cmd.Stderr = &stderr + if runErr := runArchiveHelperCommand(cmd); runErr != nil { + err = archiveHelperError(runErr, stderr.String()) + } + } + } + if err == nil && credential == nil { + err = guestagent.ExtractTarToPathContext(ctx, stdin, rootDir, dst, dstDir, limits) + } + } + if err != nil { + exitCode = 1 + writeExecStderr(cfg, control, id, "ccx3-init: fs extract: "+err.Error()+"\n") + } + proto.WriteExit(control, id, exitCode) +} + +func runFSMkdirRequest(cfg config, control io.Writer, id, rootDir, dir, user string) { + if strings.TrimSpace(dir) == "" { + dir = "." + } + proto := protocolForConfig(cfg) + exitCode := 0 + proto.WriteBegin(control, id) + target := rootPath(rootDir, filepath.Clean(dir)) + if err := os.MkdirAll(target, 0o755); err != nil { + exitCode = 1 + writeExecStderr(cfg, control, id, "ccx3-init: fs mkdir: "+err.Error()+"\n") + } else if err := guestagent.ChownPathForUser(rootDir, target, user); err != nil { + exitCode = 1 + writeExecStderr(cfg, control, id, "ccx3-init: fs mkdir: "+err.Error()+"\n") + } + proto.WriteExit(control, id, exitCode) +} + +func runFSWriteRequest(cfg config, control io.Writer, id, rootDir, dst, user string, stdin io.ReadCloser) { + defer stdin.Close() + proto := protocolForConfig(cfg) + exitCode := 0 + proto.WriteBegin(control, id) + if strings.TrimSpace(dst) == "" { + writeExecStderr(cfg, control, id, "ccx3-init: fs write: destination path is required\n") + exitCode = 1 + proto.WriteExit(control, id, exitCode) + return + } + target := rootPath(rootDir, filepath.Clean(dst)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") + exitCode = 1 + } else if err := writeStreamToPath(stdin, target, 0o644); err != nil { + writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") + exitCode = 1 + } else if err := guestagent.ChownPathForUser(rootDir, target, user); err != nil { + writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") + exitCode = 1 + } + proto.WriteExit(control, id, exitCode) +} + +func writeStreamToPath(r io.Reader, target string, mode os.FileMode) error { + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(file, r) + closeErr := file.Close() + return errors.Join(copyErr, closeErr) +} + +func execPivotArgs(rootDir, workDir string, cred *syscall.Credential, argv []string) []string { + uid, gid, groups := execPivotCredentialArgs(cred) + args := []string{rootDir, workDir, uid, gid, groups, "--"} + return append(args, argv...) +} + +func execPivotCredentialArgs(cred *syscall.Credential) (uid, gid, groups string) { + if cred == nil { + return "", "", "" + } + uid = fmt.Sprint(cred.Uid) + gid = fmt.Sprint(cred.Gid) + if len(cred.Groups) == 0 { + return uid, gid, "" + } + parts := make([]string, 0, len(cred.Groups)) + for _, group := range cred.Groups { + parts = append(parts, fmt.Sprint(group)) + } + return uid, gid, strings.Join(parts, ",") +} + +func managedExecCommand(argv []string, env []string, rootDir string, workDir string, cred *syscall.Credential, tty bool) (*exec.Cmd, bool) { + useExecPivot := rootDir != "" || (tty && cred != nil) + var cmd *exec.Cmd + if useExecPivot { + cmd = exec.Command("/proc/self/exe", append([]string{execPivotMode}, execPivotArgs(rootDir, workDir, cred, argv)...)...) + } else { + executable, err := lookPathForExecEnv(argv[0], env) + if err != nil { + executable = argv[0] + } + cmd = exec.Command(executable, argv[1:]...) + cmd.Args[0] = argv[0] + if workDir != "" { + cmd.Dir = workDir + } + } + cmd.Env = env + cmd.WaitDelay = 2 * time.Second + return cmd, useExecPivot +} + +func configureManagedExecProcessAttrs(cmd *exec.Cmd, rootDir string, cred *syscall.Credential, useExecPivot bool) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } + if rootDir != "" { + cmd.SysProcAttr.Cloneflags |= syscall.CLONE_NEWNS + return + } + if cred != nil && !useExecPivot { + cmd.SysProcAttr.Credential = cred + } +} + +func managedExecExitCode(waitErr error, state *os.ProcessState) (int, error) { + if waitErr != nil && errors.Is(waitErr, exec.ErrWaitDelay) { + waitErr = nil + } + if state != nil { + status, ok := state.Sys().(syscall.WaitStatus) + if !ok { + return 126, fmt.Errorf("process state did not include wait status") + } + if status.Signaled() { + return 128 + int(status.Signal()), nil + } + if status.Exited() { + return status.ExitStatus(), nil + } + return 126, fmt.Errorf("process did not exit normally") + } + if waitErr == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + return exitErr.ExitCode(), nil + } + return 126, waitErr +} + +func startManagedExecProcess(cmd *exec.Cmd, controlW **os.File) error { + // Keep registration atomic with Start so the PID 1 orphan reaper cannot + // consume a short-lived managed command before os.Process.Wait observes it. + managedChildren.Lock() + err := cmd.Start() + if err == nil { + managedChildren.pids[cmd.Process.Pid] = struct{}{} + } + managedChildren.Unlock() + if controlW != nil && *controlW != nil { + _ = (*controlW).Close() + *controlW = nil + } + return err +} + +type managedExecWaitResult struct { + WaitErr error + ProcessState *os.ProcessState + Usage string + ExitCode int + ExitErr error +} + +func waitManagedExecProcess(cmd *exec.Cmd, execStart time.Time) managedExecWaitResult { + state, waitErr := cmd.Process.Wait() + managedChildren.Lock() + delete(managedChildren.pids, cmd.Process.Pid) + managedChildren.Unlock() + cmd.ProcessState = state + usage := guestagent.UsageFromProcessState(state, time.Since(execStart)) + exitCode, exitErr := managedExecExitCode(waitErr, state) + var usagePayload string + if usage != nil { + usagePayload = guestagent.EncodeExecUsage(usage) + } + return managedExecWaitResult{ + WaitErr: waitErr, + ProcessState: state, + Usage: usagePayload, + ExitCode: exitCode, + ExitErr: exitErr, + } +} + +func startOrphanReaper() { + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + reapOrphanedChildren(os.Getpid()) + } + }() +} + +// reapOrphanedChildren prevents descendants left behind by canceled command +// trees from accumulating as zombies under ccx3-init. Managed direct children +// remain owned by os.Process.Wait so their structured exit status is preserved. +func reapOrphanedChildren(parentPID int) { + managedChildren.Lock() + defer managedChildren.Unlock() + + entries, err := os.ReadDir("/proc") + if err != nil { + return + } + for _, entry := range entries { + pid64, err := strconv.ParseInt(entry.Name(), 10, 32) + if err != nil { + continue + } + pid := int(pid64) + if _, tracked := managedChildren.pids[pid]; tracked { + continue + } + state, ppid, ok := procProcessState(filepath.Join("/proc", entry.Name(), "stat")) + if !ok || state != 'Z' || ppid != parentPID { + continue + } + var status syscall.WaitStatus + _, _ = syscall.Wait4(pid, &status, syscall.WNOHANG, nil) + } +} + +func procProcessState(path string) (byte, int, bool) { + data, err := os.ReadFile(path) + if err != nil { + return 0, 0, false + } + // The comm field is parenthesized and may itself contain spaces. State and + // parent PID are the first two fields after its final closing parenthesis. + end := bytes.LastIndexByte(data, ')') + if end < 0 { + return 0, 0, false + } + fields := bytes.Fields(data[end+1:]) + if len(fields) < 2 || len(fields[0]) != 1 { + return 0, 0, false + } + ppid, err := strconv.Atoi(string(fields[1])) + if err != nil { + return 0, 0, false + } + return fields[0][0], ppid, true +} + +func managedExecStreamCount(tty bool, hasStdin bool, hasControl bool) int { + streams := 2 + if tty { + streams = 1 + if hasStdin { + streams++ + } + } + if hasControl { + streams++ + } + return streams +} + +func managedExecDoneStreamCount(tty bool, hasStdin bool, hasControl bool) int { + if !tty { + hasStdin = false + } + return managedExecStreamCount(tty, hasStdin, hasControl) +} + +func waitManagedExecStreams(done <-chan struct{}, count int) { + for i := 0; i < count; i++ { + <-done + } +} + +func attachManagedExecControlPipe(cmd *exec.Cmd, enabled bool) (*os.File, *os.File, error) { + if !enabled { + return nil, nil, nil + } + controlR, controlW, err := os.Pipe() + if err != nil { + return nil, nil, err + } + cmd.ExtraFiles = append(cmd.ExtraFiles, controlW) + return controlR, controlW, nil +} + +func copyManagedExecReader(done chan<- struct{}, r io.Reader, closeReader func() error, onFirst func(), emit func([]byte)) { + defer func() { done <- struct{}{} }() + if closeReader != nil { + defer closeReader() + } + var buf [execProtocolChunkSize]byte + first := true + for { + n, err := r.Read(buf[:]) + if n > 0 && emit != nil { + if first { + if onFirst != nil { + onFirst() + } + first = false + } + emit(buf[:n]) + } + if err != nil { + return + } + } +} + +func copyManagedExecStdinToPTY(done chan<- struct{}, stdin io.ReadCloser, pty io.Writer) { + defer func() { done <- struct{}{} }() + defer stdin.Close() + var buf [execProtocolChunkSize]byte + for { + n, err := stdin.Read(buf[:]) + if n > 0 { + if _, writeErr := pty.Write(buf[:n]); writeErr != nil { + return + } + } + if err != nil { + if err == io.EOF { + _, _ = pty.Write([]byte{4}) + } + return + } + } +} + +func copyManagedExecStdinToPipe(done chan<- struct{}, stdin io.ReadCloser, childStdin io.WriteCloser) { + defer func() { done <- struct{}{} }() + defer stdin.Close() + defer childStdin.Close() + _, _ = io.Copy(childStdin, stdin) +} + +func closeManagedExecStdinPipe(stdin io.ReadCloser, childStdin io.WriteCloser, done <-chan struct{}) { + if childStdin != nil { + _ = childStdin.Close() + } + if stdin != nil { + _ = stdin.Close() + } + if done != nil { + <-done + } +} + +func runManagedExec(cfg config, control io.Writer, id string, argv []string, env []string, rootDir string, workDir string, user string, stdin io.ReadCloser, managed *managedExec, tty bool, controlFD bool, cols int, rows int, waitReady func() error, cleanup func()) { + defer cleanup() + execStart := time.Now() + reporter := reporterForConfig(cfg, control, id, execStart) + reporter.Timing(managedExecTimingRecv) + reporter.Begin() + started := false + + writeKernel("ccx3-init: exec " + strings.Join(argv, " ")) + if readyBegin, readyDone, ok := managedExecGuestReadyTimingPhases(waitReady); ok { + reporter.Timing(readyBegin) + if err := waitReady(); err != nil { + writeKernel("ccx3-init: guest not ready for exec: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: guest not ready for exec: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + reporter.Timing(readyDone) + } + reporter.Timing(managedExecTimingStartBegin) + + signalGroup := managed.mode != "single" + var rootMounts []string + if rootDir != "" { + preparedRoot, mounts, err := prepareExecRoot(rootDir) + if err != nil { + writeKernel("ccx3-init: prepare exec root: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: prepare exec root: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + rootDir = preparedRoot + rootMounts = mounts + defer teardownExecRoot(rootMounts) + } + execCred, err := guestagent.CredentialForUserInRoot(rootDir, user) + if err != nil { + writeKernel("ccx3-init: resolve user: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: resolve user: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + if execCred != nil { + credentialSetupMu.Lock() + err := guestagent.EnsureCredentialUser(rootDir, execCred) + credentialSetupMu.Unlock() + if err != nil { + writeKernel("ccx3-init: ensure user: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: ensure user: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + env = guestagent.EnvironmentForCredential(rootDir, env, execCred) + } + if err := guestagent.EnsureCredentialWorkDir(rootDir, workDir, execCred); err != nil { + writeKernel("ccx3-init: ensure workdir: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: ensure workdir: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + if err := validateManagedExecWorkDir(rootDir, workDir); err != nil { + writeKernel("ccx3-init: validate workdir: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: invalid workdir: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + cmd, useExecPivot := managedExecCommand(argv, env, rootDir, workDir, execCred, tty) + var ( + done chan struct{} + stdoutR io.ReadCloser + stderrR io.ReadCloser + stdinW io.WriteCloser + stdinDone chan struct{} + controlR *os.File + controlW *os.File + ptyMaster *os.File + ptySlave *os.File + startError error + ) + if controlFD { + controlR, controlW, startError = attachManagedExecControlPipe(cmd, true) + if startError != nil { + writeKernel("ccx3-init: open control fd: " + startError.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: open control fd: "+startError.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + defer func() { + if controlR != nil { + _ = controlR.Close() + } + }() + defer func() { + if controlW != nil { + _ = controlW.Close() + } + }() + } + + if tty { + ptyMaster, ptySlave, startError = openPTY(cols, rows) + if startError != nil { + writeKernel("ccx3-init: open pty: " + startError.Error()) + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + defer func() { + if ptyMaster != nil { + _ = ptyMaster.Close() + } + }() + defer func() { + if ptySlave != nil { + _ = ptySlave.Close() + } + }() + managed.setPTY(ptyMaster) + cmd.Stdin = ptySlave + cmd.Stdout = ptySlave + cmd.Stderr = ptySlave + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + Setctty: true, + Ctty: 0, + } + var ptyEmit func([]byte) + if cfg.OutputMarkerPref != "" { + ptyEmit = reporter.Stdout + } + done = make(chan struct{}, managedExecDoneStreamCount(true, stdin != nil, controlR != nil)) + go copyManagedExecReader(done, ptyMaster, nil, func() { reporter.Timing(managedExecFirstOutputTimingPhase(false)) }, ptyEmit) + if stdin != nil { + go copyManagedExecStdinToPTY(done, stdin, ptyMaster) + } + } else { + if stdin != nil { + if file, ok := stdin.(*os.File); ok { + cmd.Stdin = file + } else { + var err error + stdinW, err = cmd.StdinPipe() + if err != nil { + closeManagedExecStdinPipe(stdin, nil, nil) + writeKernel("ccx3-init: open stdin pipe: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: open stdin pipe: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + } + } else { + devNull, err := os.Open("/dev/null") + if err == nil { + defer devNull.Close() + cmd.Stdin = devNull + } + } + + var err error + stdoutR, err = cmd.StdoutPipe() + if err != nil { + closeManagedExecStdinPipe(stdin, stdinW, nil) + writeKernel("ccx3-init: open stdout pipe: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: open stdout pipe: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + stderrR, err = cmd.StderrPipe() + if err != nil { + _ = stdoutR.Close() + closeManagedExecStdinPipe(stdin, stdinW, nil) + writeKernel("ccx3-init: open stderr pipe: " + err.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: open stderr pipe: "+err.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + + var stdoutEmit func([]byte) + if cfg.OutputMarkerPref != "" { + stdoutEmit = reporter.Stdout + } + done = make(chan struct{}, managedExecDoneStreamCount(false, stdin != nil, controlR != nil)) + go copyManagedExecReader(done, stdoutR, stdoutR.Close, func() { reporter.Timing(managedExecFirstOutputTimingPhase(false)) }, stdoutEmit) + var stderrEmit func([]byte) + if cfg.ErrorMarkerPref != "" { + stderrEmit = func(data []byte) { writeExecStderr(cfg, control, id, string(data)) } + } + go copyManagedExecReader(done, stderrR, stderrR.Close, func() { reporter.Timing(managedExecFirstOutputTimingPhase(true)) }, stderrEmit) + } + if controlR != nil { + go copyManagedExecReader(done, controlR, nil, nil, reporter.ControlBytes) + } + configureManagedExecProcessAttrs(cmd, rootDir, execCred, useExecPivot) + var preparedFamily *guestagent.PreparedProcessFamily + var prepareFamilyErr error + if signalGroup { + preparedFamily, prepareFamilyErr = guestagent.PrepareProcessFamily(cmd, id) + } + if prepareFamilyErr != nil { + closeManagedExecStdinPipe(stdin, stdinW, stdinDone) + writeKernel("ccx3-init: prepare command containment: " + prepareFamilyErr.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: prepare command containment: "+prepareFamilyErr.Error()+"\n") + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(126) + return + } + + reporter.Timing(managedExecTimingStartCall) + startErr := startManagedExecProcess(cmd, &controlW) + if startErr != nil { + preparedFamily.Abort() + _ = managed.closeStdin() + closeManagedExecStdinPipe(stdin, stdinW, stdinDone) + if ptySlave != nil { + _ = ptySlave.Close() + ptySlave = nil + } + if ptyMaster != nil { + _ = ptyMaster.Close() + ptyMaster = nil + } + if stdoutR != nil { + _ = stdoutR.Close() + } + if stderrR != nil { + _ = stderrR.Close() + } + waitManagedExecStreams(done, cap(done)) + exitCode, message := managedExecStartFailure(startErr, argv, rootDir, workDir) + writeKernel("ccx3-init: exec error: " + startErr.Error()) + writeExecStderr(cfg, control, id, message) + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", strings.TrimSpace(message)) + } + reporter.Exit(exitCode) + return + } + started = true + reporter.Timing(managedExecTimingStarted) + if stdinW != nil { + stdinDone = make(chan struct{}, 1) + go copyManagedExecStdinToPipe(stdinDone, stdin, stdinW) + } + var startedFamily *guestagent.ProcessFamily + if preparedFamily != nil { + if cmd.SysProcAttr.UseCgroupFD { + startedFamily = preparedFamily.Start(cmd.Process.Pid) + } else { + // Linux preparation can succeed without a cgroup outside PID 1. + // Its empty kernel tracker does not poll ancestry; explicitly use + // the best-effort /proc fallback in that case. + preparedFamily.Abort() + startedFamily = guestagent.NewProcessFamily(cmd.Process.Pid) + } + } + managed.setProcess(cmd.Process, signalGroup, startedFamily) + if cfg.Protocol == 1 { + writeSandboxEvent(cfg, control, id, "started", "") + } + if ptySlave != nil { + _ = ptySlave.Close() + ptySlave = nil + } + + reporter.Timing(managedExecTimingWaitBegin) + waitResult := waitManagedExecProcess(cmd, execStart) + if managed.mode != "single" { + terminateManagedExecProcessGroup(cmd.Process.Pid) + } + managed.stdinMu.Lock() + family := managed.family + managed.stdinMu.Unlock() + if family != nil { + family.Terminate() + } + reporter.Timing(managedExecTimingWaitDone) + if tty { + _ = managed.closeStdin() + } + if !tty { + closeManagedExecStdinPipe(stdin, stdinW, stdinDone) + } + { + // Even group/family cleanup can miss reparented descendants without + // cgroups, or fail to kill a descriptor holder. Allow buffered output + // to drain, then close all readers so inherited FDs cannot delay exit. + closeTimer := time.AfterFunc(managedExecStreamDrainGrace, func() { + if stdoutR != nil { + _ = stdoutR.Close() + } + if stderrR != nil { + _ = stderrR.Close() + } + if controlR != nil { + _ = controlR.Close() + } + if ptyMaster != nil { + _ = ptyMaster.Close() + } + }) + waitManagedExecStreams(done, cap(done)) + closeTimer.Stop() + } + reporter.Timing(managedExecTimingStreamsDone) + + if waitResult.ExitErr != nil { + writeKernel("ccx3-init: exec error: " + waitResult.ExitErr.Error()) + writeExecStderr(cfg, control, id, "ccx3-init: exec error: "+waitResult.ExitErr.Error()+"\n") + } + if waitResult.Usage != "" { + reporter.Usage(waitResult.Usage) + } + if reporter.HasExitMarker() { + reporter.Timing(managedExecTimingExitSent) + if cfg.Protocol == 1 && !started { + writeSandboxEvent(cfg, control, id, "launch_error", "process launch failed") + } + reporter.Exit(waitResult.ExitCode) + } +} + +func terminateManagedExecProcessGroup(pgid int) { + if pgid <= 0 || !managedExecProcessGroupExists(pgid) { + return + } + _ = syscall.Kill(-pgid, syscall.SIGTERM) + if waitManagedExecProcessGroup(pgid, managedExecGroupTerminateGrace) { + return + } + _ = syscall.Kill(-pgid, syscall.SIGKILL) + _ = waitManagedExecProcessGroup(pgid, managedExecGroupKillWait) +} + +func waitManagedExecProcessGroup(pgid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + reapOrphanedChildren(os.Getpid()) + if !managedExecProcessGroupExists(pgid) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(10 * time.Millisecond) + } +} + +func managedExecProcessGroupExists(pgid int) bool { + err := syscall.Kill(-pgid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func validateManagedExecWorkDir(rootDir, workDir string) error { + if strings.TrimSpace(workDir) == "" { + return nil + } + target := rootPath(rootDir, filepath.Clean(workDir)) + info, err := os.Stat(target) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", workDir) + } + return nil +} + +func configureHostname(hostname string) error { + hostname = strings.TrimSpace(hostname) + if hostname == "" || hostname == "(none)" { + hostname = "ccx3" + } + if err := syscall.Sethostname([]byte(hostname)); err != nil { + return fmt.Errorf("set hostname %q: %w", hostname, err) + } + if err := os.MkdirAll("/etc", 0o755); err != nil { + return fmt.Errorf("mkdir /etc: %w", err) + } + _ = os.WriteFile("/etc/hostname", []byte(hostname+"\n"), 0o644) + hosts := "127.0.0.1\tlocalhost " + hostname + "\n::1\tlocalhost ip6-localhost ip6-loopback " + hostname + "\n" + _ = os.WriteFile("/etc/hosts", []byte(hosts), 0o644) + return nil +} + +func configurePackageManagers(rootDir string) error { + aptDir := rootPath(rootDir, "/etc/apt/apt.conf.d") + if !pathExists(rootPath(rootDir, "/usr/bin/apt")) && !pathExists(rootPath(rootDir, "/bin/apt")) { + return nil + } + if err := os.MkdirAll(aptDir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", aptDir, err) + } + // Queue all HTTP work through one access-method worker so archive and + // security downloads cannot leave each other's connections idle. Do not + // disable apt's default HTTP pipelining: the old Pipeline-Depth=0 override + // made full Ubuntu updates slow enough to hit the archive's five-second + // keep-alive timeout, after which Ubuntu 24.04's apt worker crashed. + conf := strings.Join([]string{ + `Acquire::Queue-Mode "access";`, + `Acquire::Languages "none";`, + `Acquire::IndexTargets::deb::DEP-11::DefaultEnabled "false";`, + `Acquire::IndexTargets::deb::CNF::DefaultEnabled "false";`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(aptDir, "99ccvm-netstack"), []byte(conf), 0o644); err != nil { + return fmt.Errorf("write apt netstack config: %w", err) + } + return nil +} + +func pathExists(name string) bool { + _, err := os.Stat(name) + return err == nil +} + +func rootPath(rootDir, name string) string { + if rootDir == "" { + return name + } + return filepath.Join(rootDir, strings.TrimPrefix(name, "/")) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func prepareExecRoot(rootDir string) (string, []string, error) { + cleaned := strings.TrimSpace(rootDir) + if cleaned == "" { + return "", nil, nil + } + if !strings.HasPrefix(cleaned, "/") { + return "", nil, fmt.Errorf("root_dir must be absolute") + } + mounts := make([]string, 0, 6) + if err := syscall.Mount(cleaned, cleaned, "", syscall.MS_BIND, ""); err != nil { + return "", nil, fmt.Errorf("bind mount exec root %s: %w", cleaned, err) + } + mounts = append(mounts, cleaned) + for _, dir := range []string{"/proc", "/sys", "/dev", "/run", "/tmp"} { + target := cleaned + dir + if err := os.MkdirAll(target, 0o755); err != nil { + teardownExecRoot(mounts) + return "", nil, fmt.Errorf("mkdir %s: %w", target, err) + } + flags := uintptr(syscall.MS_BIND) + if dir == "/dev" { + flags |= syscall.MS_REC + } + if err := syscall.Mount(dir, target, "", flags, ""); err != nil { + teardownExecRoot(mounts) + return "", nil, fmt.Errorf("bind mount %s -> %s: %w", dir, target, err) + } + mounts = append(mounts, target) + } + if err := copyExecRootNetworkFiles(cleaned); err != nil { + teardownExecRoot(mounts) + return "", nil, err + } + return cleaned, mounts, nil +} + +func copyExecRootNetworkFiles(rootDir string) error { + for _, name := range []string{"resolv.conf", "hosts", "hostname"} { + src := filepath.Join("/etc", name) + data, err := os.ReadFile(src) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return fmt.Errorf("read %s: %w", src, err) + } + dst := filepath.Join(rootDir, "etc", name) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) + } + if err := writeFileReplacingMissingSymlink(dst, data, 0o644); err != nil { + return fmt.Errorf("write %s: %w", dst, err) + } + } + return nil +} + +func teardownExecRoot(mounts []string) { + for i := len(mounts) - 1; i >= 0; i-- { + _ = syscall.Unmount(mounts[i], 0) + } +} + +type sockaddrVM struct { + Family uint16 + Reserved uint16 + Port uint32 + CID uint32 + Zero [4]byte +} + +func connectVsock(port uint32) (*os.File, error) { + return connectVsockWithTimeout(port, defaultVsockConnectTimeout) +} + +func connectVsockWithTimeout(port uint32, timeout time.Duration) (*os.File, error) { + fd, err := syscall.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0) + if err != nil { + return nil, err + } + closeFD := true + defer func() { + if closeFD { + _ = syscall.Close(fd) + } + }() + if timeout > 0 { + if err := syscall.SetNonblock(fd, true); err != nil { + return nil, err + } + } + addr := sockaddrVM{ + Family: unix.AF_VSOCK, + Port: port, + CID: 2, + } + _, _, errno := syscall.Syscall(syscall.SYS_CONNECT, uintptr(fd), uintptr(unsafe.Pointer(&addr)), unsafe.Sizeof(addr)) + if errno == 0 { + if timeout > 0 { + if err := syscall.SetNonblock(fd, false); err != nil { + return nil, err + } + } + closeFD = false + return os.NewFile(uintptr(fd), fmt.Sprintf("vsock:%d", port)), nil + } + if timeout <= 0 || (errno != unix.EINPROGRESS && errno != unix.EALREADY && errno != unix.EWOULDBLOCK) { + return nil, errno + } + deadline := time.Now().Add(timeout) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, fmt.Errorf("vsock:%d connect timed out after %s", port, timeout) + } + timeoutMS := int(remaining / time.Millisecond) + if timeoutMS < 1 { + timeoutMS = 1 + } + pollFD := []unix.PollFd{{ + Fd: int32(fd), + Events: unix.POLLOUT, + }} + n, err := unix.Poll(pollFD, timeoutMS) + if err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + return nil, err + } + if n == 0 { + continue + } + socketErr, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR) + if err != nil { + return nil, err + } + if socketErr != 0 { + return nil, syscall.Errno(socketErr) + } + if err := syscall.SetNonblock(fd, false); err != nil { + return nil, err + } + closeFD = false + return os.NewFile(uintptr(fd), fmt.Sprintf("vsock:%d", port)), nil + } +} diff --git a/guestinit/init_other.go b/guestinit/init_other.go new file mode 100644 index 00000000..555b0eb0 --- /dev/null +++ b/guestinit/init_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +// Package guestinit implements the Linux guest init and its helper protocol. +package guestinit + +func Run() { panic("guestinit requires Linux") } +func RunSandbox() { panic("guestinit requires Linux") } diff --git a/internal/cmd/init/main_test.go b/guestinit/main_test.go similarity index 99% rename from internal/cmd/init/main_test.go rename to guestinit/main_test.go index 3dc76ed3..6a812740 100644 --- a/internal/cmd/init/main_test.go +++ b/guestinit/main_test.go @@ -1,6 +1,6 @@ //go:build linux -package main +package guestinit import ( "bufio" diff --git a/guestinit/process_completion_linux_test.go b/guestinit/process_completion_linux_test.go new file mode 100644 index 00000000..e6a40406 --- /dev/null +++ b/guestinit/process_completion_linux_test.go @@ -0,0 +1,170 @@ +//go:build linux + +package guestinit + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "j5.nz/cc/internal/vmruntime" +) + +// Use the test executable so setsid and inherited control FD behavior do not +// depend on shell utilities. The escaped child retains stdout, stderr and FD 3. +func TestManagedExecEscapedHelper(t *testing.T) { + role := os.Getenv("CC_TEST_ESCAPED_ROLE") + if role == "" { + return + } + path := os.Getenv("CC_TEST_ESCAPED_PATH") + if role == "child" { + fmt.Fprintln(os.Stdout, "escaped stdout") + fmt.Fprintln(os.Stderr, "escaped stderr") + ctl := os.NewFile(3, "control") + fmt.Fprintln(ctl, `{"escaped":true}`) + if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0600); err != nil { + os.Exit(91) + } + time.Sleep(30 * time.Second) + os.Exit(0) + } + exe, err := os.Executable() + if err != nil { + os.Exit(92) + } + cmd := exec.Command(exe, "-test.run=^TestManagedExecEscapedHelper$") + cmd.Env = append(os.Environ(), "CC_TEST_ESCAPED_ROLE=child") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.ExtraFiles = []*os.File{os.NewFile(3, "control")} + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + os.Exit(93) + } + if role != "fast" { + for { + if _, err := os.Stat(path + ".release"); err == nil { + break + } + time.Sleep(time.Millisecond) + } + } + os.Exit(0) +} + +func TestManagedExecEscapedCompletion(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"single", "group", ""} { + for _, action := range []string{"exit", "cancel", "fast", "tty"} { + t.Run(mode+"/"+action, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "escaped.pid") + role := "parent" + if action == "fast" { + role = "fast" + } + env := append(os.Environ(), "CC_TEST_ESCAPED_ROLE="+role, "CC_TEST_ESCAPED_PATH="+path, "GORACE=atexit_sleep_ms=0") + cfg := config{Protocol: 1, OutputMarkerPref: vmruntime.CommandOutputMarker, ErrorMarkerPref: vmruntime.CommandErrorMarker, ControlMarkerPref: vmruntime.CommandControlMarker, ExitMarkerPrefix: vmruntime.CommandExitMarkerPref} + managed := &managedExec{mode: mode} + var output bytes.Buffer + done := make(chan struct{}) + go func() { + runManagedExec(cfg, &output, "escaped", []string{exe, "-test.run=^TestManagedExecEscapedHelper$"}, env, "", "/", "", nil, managed, action == "tty", true, 80, 24, nil, func() {}) + close(done) + }() + // Always clean both processes, including on a regression timeout; do not + // inspect the output buffer until the worker has stopped writing it. + child := 0 + defer func() { + if child > 0 { + _ = syscall.Kill(child, syscall.SIGKILL) + } + managed.stdinMu.Lock() + parent := managed.process + managed.stdinMu.Unlock() + if parent != nil { + _ = parent.Kill() + } + select { + case <-done: + case <-time.After(4 * time.Second): + t.Error("worker did not stop after test cleanup") + } + }() + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + data, _ := os.ReadFile(path) + child, _ = strconv.Atoi(strings.TrimSpace(string(data))) + managed.stdinMu.Lock() + started := managed.process != nil + managed.stdinMu.Unlock() + if child > 0 && started { + break + } + time.Sleep(5 * time.Millisecond) + } + if child <= 0 { + t.Fatal("escaped descendant did not start") + } + pgid, err := syscall.Getpgid(child) + if err != nil || pgid != child { + t.Fatalf("descendant did not setsid: pgid=%d err=%v", pgid, err) + } + if action == "tty" { + if err := managed.resize(100, 30); err != nil { + t.Fatal(err) + } + } + if action != "fast" { + // Give the /proc fallback an observable parent/child interval. Fast exit + // separately exercises the case where that best-effort tracking may miss. + time.Sleep(100 * time.Millisecond) + if action == "cancel" { + if err := managed.signal("KILL"); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(path+".release", nil, 0600); err != nil { + t.Fatal(err) + } + } + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("completion hung on setsid descendant descriptors") + } + state, _, exists := procProcessState("/proc/" + strconv.Itoa(child) + "/stat") + alive := exists && state != 'Z' + if mode == "single" && !alive { + t.Fatal("single killed escaped descendant") + } + if mode != "single" && action != "fast" && alive { + t.Fatal("group/family failed to kill observed escaped descendant") + } + // A fast-reparented child can survive without cgroups, but its inherited + // descriptors must not prevent the direct process's exit event. + foundExit := false + for _, line := range strings.Split(output.String(), "\n") { + event, _, ok, err := vmruntime.ParseManagedExecEventLine(line, "escaped") + if err != nil { + t.Fatal(err) + } + if ok && event.Kind == "exit" { + foundExit = true + } + } + if !foundExit { + t.Fatalf("missing exit event: %s", output.String()) + } + }) + } + } +} diff --git a/guestinit/sandbox_linux.go b/guestinit/sandbox_linux.go new file mode 100644 index 00000000..a59f0250 --- /dev/null +++ b/guestinit/sandbox_linux.go @@ -0,0 +1,314 @@ +//go:build linux + +package guestinit + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "strconv" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const sandboxProxyAddress = ":10780" +const sandboxProxyHeaderLimit = 4096 +const sandboxProxyConnectionLimit = 128 +const sandboxFileLimit int64 = 1 << 30 + +var sandboxInit bool + +// RunSandbox is the SA2 init entrypoint. Its config must specify protocol:1. +// Helper modes are dispatched before config loading, just as in Run. +func RunSandbox() { sandboxInit = true; Run() } + +func validateSandboxConfig(cfg config) error { + if cfg.Protocol != 0 && cfg.Protocol != SandboxProtocol { + return fmt.Errorf("unsupported guest init protocol %d", cfg.Protocol) + } + if sandboxInit && cfg.Protocol != SandboxProtocol { + return errors.New("SA2 init requires protocol 1") + } + return nil +} + +func validateSandboxRequest(req execRequest) error { + if req.SandboxProtocol != 0 && req.SandboxProtocol != SandboxProtocol { + return fmt.Errorf("unsupported sandbox protocol %d", req.SandboxProtocol) + } + switch req.ProcessMode { + case "": + case "single", "group": + if req.SandboxProtocol != SandboxProtocol { + return errors.New("process_mode requires sandbox_protocol 1") + } + default: + return fmt.Errorf("unsupported process mode %q", req.ProcessMode) + } + return nil +} + +func writeSandboxEvent(cfg config, control io.Writer, id, kind, message string) { + data, _ := json.Marshal(struct { + Version int `json:"version"` + Kind string `json:"kind"` + Error string `json:"error,omitempty"` + }{SandboxProtocol, kind, message}) + writeExecControlBytes(cfg, control, id, data) +} + +func runSandboxHelper(args []string) (bool, error) { + if len(args) == 0 { + return false, nil + } + switch args[0] { + case "--sa2-protocol": + if len(args) != 1 { + return true, errors.New("usage: --sa2-protocol") + } + _, err := fmt.Fprintln(os.Stdout, SandboxProtocol) + return true, err + case "--sa2-file-get": + if len(args) != 2 { + return true, errors.New("usage: --sa2-file-get PATH") + } + if !filepath.IsAbs(args[1]) { + return true, errors.New("file-get requires an absolute path") + } + return true, sandboxFileGet(args[1], os.Stdout) + case "--sa2-file-put": + if len(args) != 3 || (args[2] != "true" && args[2] != "false") { + return true, errors.New("usage: --sa2-file-put PATH true|false") + } + if !filepath.IsAbs(args[1]) { + return true, errors.New("file-put requires an absolute path") + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer stop() + // Inherited os.Stdin may use a blocking syscall that Close cannot wake. + // Register a nonblocking duplicate with Go's poller so signal cancellation + // reliably interrupts a pending read without waiting for host EOF. + fd, err := syscall.Dup(int(os.Stdin.Fd())) + if err != nil { + return true, err + } + if err := syscall.SetNonblock(fd, true); err != nil { + _ = syscall.Close(fd) + return true, err + } + input := os.NewFile(uintptr(fd), "sa2-file-put-stdin") + defer input.Close() + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = input.Close() + case <-done: + } + }() + err = sandboxFilePut(ctx, args[1], args[2] == "true", input) + close(done) + return true, err + } + return false, nil +} + +func sandboxFileGet(path string, output io.Writer) error { + // O_NONBLOCK prevents opening a FIFO from hanging before the regular-file check. + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("file-get requires a regular file") + } + _, err = io.Copy(output, file) + return err +} + +func sandboxFilePut(ctx context.Context, path string, overwrite bool, input io.Reader) error { + path = filepath.Clean(path) + if info, err := os.Lstat(path); err == nil { + if !overwrite { + return os.ErrExist + } + if !info.Mode().IsRegular() { + return errors.New("file-put destination is not a regular file") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + stage, err := os.CreateTemp(filepath.Dir(path), ".sa2-file-put-*") + if err != nil { + return err + } + defer os.Remove(stage.Name()) + defer stage.Close() + n, err := io.Copy(stage, io.LimitReader(input, sandboxFileLimit+1)) + if err != nil { + return err + } + if n > sandboxFileLimit { + return errors.New("file-put exceeds 1 GiB") + } + if err := ctx.Err(); err != nil { + return err + } + if err := stage.Sync(); err != nil { + return err + } + if err := stage.Close(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if overwrite { + return os.Rename(stage.Name(), path) + } + // Atomic no-clobber installation also works on guest filesystems without + // hardlinks (such as SCS mounts). Do not fall back to a check-then-rename or + // link: unsupported RENAME_NOREPLACE must fail closed and remove the stage. + return unix.Renameat2(unix.AT_FDCWD, stage.Name(), unix.AT_FDCWD, path, unix.RENAME_NOREPLACE) +} + +type sandboxProxyRequest struct { + Version int `json:"version"` + Host string `json:"host"` + Port string `json:"port"` +} + +func startSandboxProxy() (func(), error) { + listener, err := net.Listen("tcp", sandboxProxyAddress) + if err != nil { + return nil, fmt.Errorf("listen SA2 proxy: %w", err) + } + ctx, cancel := context.WithCancel(context.Background()) + stop := serveSandboxProxy(ctx, listener) + return func() { cancel(); stop() }, nil +} + +// serveSandboxProxy limits concurrent handlers, including slow header senders. +// Closing either endpoint on cancellation unblocks both relay goroutines. +func serveSandboxProxy(ctx context.Context, listener net.Listener) func() { + ctx, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup + slots := make(chan struct{}, sandboxProxyConnectionLimit) + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := listener.Accept() + if err != nil { + return + } + select { + case slots <- struct{}{}: + wg.Add(1) + go func() { defer wg.Done(); defer func() { <-slots }(); handleSandboxProxy(ctx, conn) }() + default: + _ = conn.Close() + } + } + }() + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = listener.Close() + case <-done: + } + }() + var once sync.Once + return func() { once.Do(func() { cancel(); _ = listener.Close(); wg.Wait(); close(done) }) } +} + +func handleSandboxProxy(ctx context.Context, conn net.Conn) { + defer conn.Close() + finished := make(chan struct{}) + defer close(finished) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-finished: + } + }() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + reader := bufio.NewReaderSize(conn, sandboxProxyHeaderLimit) + line, err := reader.ReadSlice('\n') + reply := func(err error) bool { + response := struct { + Version int `json:"version"` + Error string `json:"error,omitempty"` + }{Version: SandboxProtocol} + if err != nil { + response.Error = err.Error() + } + return json.NewEncoder(conn).Encode(response) == nil + } + if err != nil { + reply(errors.New("invalid or oversized proxy header")) + return + } + var request sandboxProxyRequest + if err := json.Unmarshal(line, &request); err != nil { + reply(err) + return + } + port, err := strconv.Atoi(request.Port) + if request.Version != SandboxProtocol || request.Host == "" || err != nil || port < 1 || port > 65535 { + reply(errors.New("proxy requires version 1, nonempty host and port 1..65535")) + return + } + dialer := net.Dialer{Timeout: 10 * time.Second} + remote, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(request.Host, request.Port)) + if err != nil { + reply(err) + return + } + defer remote.Close() + if !reply(nil) { + return + } + _ = conn.SetDeadline(time.Time{}) + remoteDone := make(chan struct{}) + defer close(remoteDone) + go func() { + select { + case <-ctx.Done(): + _ = remote.Close() + case <-remoteDone: + } + }() + copied := make(chan struct{}, 1) + go func() { + _, _ = io.Copy(remote, reader) + if tcp, ok := remote.(*net.TCPConn); ok { + _ = tcp.CloseWrite() + } + copied <- struct{}{} + }() + _, _ = io.Copy(conn, remote) + if tcp, ok := conn.(*net.TCPConn); ok { + _ = tcp.CloseWrite() + } + // Preserve TCP half-closes: the peer may still be sending after EOF. + <-copied +} diff --git a/guestinit/sandbox_linux_test.go b/guestinit/sandbox_linux_test.go new file mode 100644 index 00000000..ff6e430a --- /dev/null +++ b/guestinit/sandbox_linux_test.go @@ -0,0 +1,411 @@ +//go:build linux + +package guestinit + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "j5.nz/cc/internal/managed/guestagent" + "j5.nz/cc/internal/vmruntime" +) + +func TestSandboxProcessModes(t *testing.T) { + for _, mode := range []string{"single", "group", ""} { + for _, cancel := range []bool{false, true} { + t.Run(mode+"/cancel="+strconv.FormatBool(cancel), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "child") + script := "sleep 30 & echo $! > " + path + if cancel { + script += "; wait" + } + cfg := config{Protocol: 1, WorkDir: "/", Env: []string{"PATH=/usr/bin:/bin"}, BeginMarker: vmruntime.CommandBeginMarker, OutputMarkerPref: vmruntime.CommandOutputMarker, ErrorMarkerPref: vmruntime.CommandErrorMarker, ControlMarkerPref: vmruntime.CommandControlMarker, ExitMarkerPrefix: vmruntime.CommandExitMarkerPref} + managed := &managedExec{mode: mode} + var output bytes.Buffer + done := make(chan struct{}) + go func() { + runManagedExec(cfg, &output, "mode", []string{"/bin/sh", "-c", script}, cfg.Env, "", "/", "", nil, managed, false, false, 0, 0, nil, func() {}) + close(done) + }() + var child int + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, _ := os.ReadFile(path) + child, _ = strconv.Atoi(strings.TrimSpace(string(data))) + managed.stdinMu.Lock() + started := managed.process != nil + managed.stdinMu.Unlock() + if child > 0 && started { + break + } + time.Sleep(5 * time.Millisecond) + } + if child <= 0 { + t.Fatal("child did not start") + } + defer syscall.Kill(child, syscall.SIGKILL) + if cancel { + if err := managed.signal("KILL"); err != nil { + t.Fatal(err) + } + } + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("exec hung on descendant descriptors") + } + state, _, ok := procProcessState("/proc/" + strconv.Itoa(child) + "/stat") + alive := ok && state != 'Z' + if alive != (mode == "single") { + t.Fatalf("child alive=%v mode=%q cancel=%v", alive, mode, cancel) + } + found := false + for _, line := range strings.Split(output.String(), "\n") { + event, _, ok, err := vmruntime.ParseManagedExecEventLine(line, "mode") + if err != nil { + t.Fatal(err) + } + if ok && event.Kind == "started" { + found = true + } + } + if !found { + t.Fatal("missing started event") + } + }) + } + } +} + +func TestSandboxLaunchErrorBeforeExit(t *testing.T) { + var output bytes.Buffer + cfg := config{Protocol: 1, ControlMarkerPref: vmruntime.CommandControlMarker, ExitMarkerPrefix: vmruntime.CommandExitMarkerPref} + runManagedExec(cfg, &output, "bad", []string{"/no/such/executable"}, nil, "", "/", "", nil, &managedExec{mode: "single"}, false, false, 0, 0, nil, func() {}) + found := false + for _, line := range strings.Split(output.String(), "\n") { + event, _, ok, err := vmruntime.ParseManagedExecEventLine(line, "bad") + if err != nil { + t.Fatal(err) + } + if !ok { + continue + } + if event.Kind == "started" { + t.Fatal("failed launch emitted started") + } + if event.Kind == "launch_error" { + found = true + if event.Error == "" { + t.Fatal("missing error") + } + } + if event.Kind == "exit" && !found { + t.Fatal("exit preceded launch_error") + } + } + if !found { + t.Fatal("missing launch_error") + } +} + +func TestSandboxProtocolValidation(t *testing.T) { + for _, request := range []execRequest{ + {SandboxProtocol: 2}, {ProcessMode: "family", SandboxProtocol: 1}, {ProcessMode: "single"}, + } { + if validateSandboxRequest(request) == nil { + t.Fatalf("accepted %+v", request) + } + } + for _, request := range []execRequest{{}, {SandboxProtocol: 1, ProcessMode: "single"}, {SandboxProtocol: 1, ProcessMode: "group"}} { + if err := validateSandboxRequest(request); err != nil { + t.Fatal(err) + } + } + if validateSandboxConfig(config{Protocol: 2}) == nil { + t.Fatal("accepted unsupported config") + } +} + +type failingSandboxInput struct{} + +func (failingSandboxInput) Read(p []byte) (int, error) { + copy(p, "partial") + return 7, errors.New("transfer interrupted") +} + +func TestSandboxFileTransferAtomicAndBinary(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file") + data := []byte{0, 255, 1, 10, 0} + if err := sandboxFilePut(context.Background(), path, false, bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := sandboxFileGet(path, &output); err != nil { + t.Fatal(err) + } + if !bytes.Equal(output.Bytes(), data) { + t.Fatal("binary data changed") + } + if err := sandboxFilePut(context.Background(), path, false, strings.NewReader("overwrite")); !errors.Is(err, os.ErrExist) { + t.Fatalf("no-clobber: %v", err) + } + if err := sandboxFilePut(context.Background(), path, true, failingSandboxInput{}); err == nil { + t.Fatal("accepted failed transfer") + } + got, _ := os.ReadFile(path) + if !bytes.Equal(got, data) { + t.Fatal("failed transfer replaced destination") + } + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) != 0 { + t.Fatal("leaked staging file") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := sandboxFilePut(ctx, path, true, strings.NewReader("cancelled")); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel: %v", err) + } + if err := sandboxFilePut(context.Background(), path, true, strings.NewReader("new")); err != nil { + t.Fatal(err) + } + if err := sandboxFileGet(dir, io.Discard); err == nil { + t.Fatal("read directory") + } + fifo := filepath.Join(dir, "fifo") + if err := syscall.Mkfifo(fifo, 0600); err != nil { + t.Fatal(err) + } + if err := sandboxFileGet(fifo, io.Discard); err == nil { + t.Fatal("read fifo") + } +} + +func TestSandboxProxyRelayAndCancellation(t *testing.T) { + target, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer target.Close() + go func() { + conn, err := target.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = io.Copy(conn, conn) + }() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stop := serveSandboxProxy(ctx, listener) + defer stop() + conn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _, port, _ := net.SplitHostPort(target.Addr().String()) + // Pipeline binary payload with the header to verify buffered bytes are relayed. + _, err = conn.Write([]byte(`{"version":1,"host":"localhost","port":"` + port + `"}` + "\n" + "hello\x00")) + if err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(conn) + line, err := reader.ReadBytes('\n') + if err != nil { + t.Fatal(err) + } + var response struct { + Version int + Error string + } + if err := json.Unmarshal(line, &response); err != nil { + t.Fatal(err) + } + if response.Version != 1 || response.Error != "" { + t.Fatalf("response: %s", line) + } + got := make([]byte, 6) + if _, err := io.ReadFull(reader, got); err != nil { + t.Fatal(err) + } + if string(got) != "hello\x00" { + t.Fatalf("relay %q", got) + } + cancel() + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := reader.ReadByte(); err == nil { + t.Fatal("cancel did not close connection") + } +} + +func TestSandboxProxyRejectsBadHeaders(t *testing.T) { + for _, header := range []string{`{"version":2,"host":"localhost","port":"80"}` + "\n", strings.Repeat("x", sandboxProxyHeaderLimit+1) + "\n"} { + server, conn := net.Pipe() + done := make(chan struct{}) + go func() { handleSandboxProxy(context.Background(), server); close(done) }() + go func() { _, _ = io.WriteString(conn, header) }() + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + line, err := bufio.NewReader(conn).ReadBytes('\n') + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(line, []byte(`"error"`)) { + t.Fatalf("response %s", line) + } + _ = conn.Close() + <-done + } +} + +func TestSandboxPutHelperSignalChild(t *testing.T) { + path := os.Getenv("CC_TEST_SA2_PUT_PATH") + if path == "" { + return + } + _, err := runSandboxHelper([]string{"--sa2-file-put", path, "false"}) + if err == nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestSandboxPutSignalRemovesStage(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "destination") + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestSandboxPutHelperSignalChild$") + cmd.Env = append(os.Environ(), "CC_TEST_SA2_PUT_PATH="+path) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + defer stdin.Close() + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + _, _ = stdin.Write([]byte("partial")) + deadline := time.Now().Add(3 * time.Second) + ready := false + for time.Now().Before(deadline) { + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) > 0 { + ready = true + break + } + time.Sleep(5 * time.Millisecond) + } + if !ready { + t.Fatal("helper did not create stage") + } + // Exercise the same signal dispatcher used by host guest-exec cancellation. + managed := &managedExec{mode: "single"} + managed.setProcess(cmd.Process, false, nil) + active := guestagent.NewActiveExecSet() + active.Add("put", managed) + var control bytes.Buffer + if !handleInitControlRequest(config{}, &control, active, execRequest{Kind: "signal", ID: "put", Signal: "TERM"}) { + t.Fatal("signal request not handled") + } + // KVM escalates TERM to KILL after 500 ms. Verify cleanup before escalation, + // independently of the race runtime's additional process-exit delay. + deadline = time.Now().Add(500 * time.Millisecond) + cleaned := false + for time.Now().Before(deadline) { + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) == 0 { + cleaned = true + break + } + time.Sleep(5 * time.Millisecond) + } + if !cleaned { + t.Fatal("TERM failed to remove staging within host kill grace") + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("signal did not unblock helper") + } + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) != 0 { + t.Fatal("signal leaked staging file") + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("signal installed incomplete file: %v", err) + } +} + +func TestSandboxFileHelpersRequireAbsolutePath(t *testing.T) { + for _, path := range []string{"", ".", "relative", "../relative"} { + for _, args := range [][]string{{"--sa2-file-get", path}, {"--sa2-file-put", path, "false"}, {"--sa2-file-put", path, "true"}} { + handled, err := runSandboxHelper(args) + if !handled || err == nil || !strings.Contains(err.Error(), "absolute path") { + t.Fatalf("dispatch %q: handled=%v err=%v", args, handled, err) + } + } + } +} + +type sandboxConcurrentCreateReader struct { + path string + reader io.Reader + created bool +} + +func (r *sandboxConcurrentCreateReader) Read(p []byte) (int, error) { + if !r.created { + r.created = true + if err := os.WriteFile(r.path, []byte("competing writer"), 0600); err != nil { + return 0, err + } + } + return r.reader.Read(p) +} + +func TestSandboxPutNoClobberAtAtomicInstall(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "raced-destination") + // Create the destination after file-put's preflight check, while staging input. + input := &sandboxConcurrentCreateReader{path: path, reader: strings.NewReader("upload")} + if err := sandboxFilePut(context.Background(), path, false, input); !errors.Is(err, os.ErrExist) { + t.Fatalf("atomic no-clobber: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "competing writer" { + t.Fatalf("concurrent destination overwritten: %q", got) + } + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) != 0 { + t.Fatal("failed install leaked staging file") + } +} diff --git a/guestinit/sandbox_rename_linux_amd64_test.go b/guestinit/sandbox_rename_linux_amd64_test.go new file mode 100644 index 00000000..7afdd4d4 --- /dev/null +++ b/guestinit/sandbox_rename_linux_amd64_test.go @@ -0,0 +1,98 @@ +//go:build linux && amd64 + +package guestinit + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "unsafe" + + "golang.org/x/sys/unix" +) + +// Run the upload with real syscalls, denying hardlink creation in a subprocess. +// This models a filesystem that supports RENAME_NOREPLACE but not hardlinks; +// it catches an os.Link regression without depending on a mounted test VM. +func TestSandboxRenameWithoutHardlinks(t *testing.T) { + for _, mode := range []string{"deny-hardlinks", "unsupported-rename"} { + t.Run(mode, func(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestSandboxRenameSyscallChild$") + cmd.Env = append(os.Environ(), "CC_TEST_SA2_RENAME_MODE="+mode, "CC_TEST_SA2_RENAME_DIR="+t.TempDir()) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("syscall regression %s: %v\n%s", mode, err, output) + } + }) + } +} + +func TestSandboxRenameSyscallChild(t *testing.T) { + mode := os.Getenv("CC_TEST_SA2_RENAME_MODE") + if mode == "" { + return + } + dir := os.Getenv("CC_TEST_SA2_RENAME_DIR") + runtime.LockOSThread() + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + t.Fatal(err) + } + filter := []unix.SockFilter{{Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: 0}} + deny := func(number uint32, errno uint32) { + filter = append(filter, unix.SockFilter{Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, K: number, Jf: 1}, unix.SockFilter{Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ERRNO | errno}) + } + deny(unix.SYS_LINK, uint32(unix.EPERM)) + deny(unix.SYS_LINKAT, uint32(unix.EPERM)) + if mode == "unsupported-rename" { + deny(unix.SYS_RENAMEAT2, uint32(unix.ENOSYS)) + } + filter = append(filter, unix.SockFilter{Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ALLOW}) + program := unix.SockFprog{Len: uint16(len(filter)), Filter: &filter[0]} + // TSYNC applies the filter to all Go threads; a goroutine migration cannot + // accidentally bypass the denied syscall and make this regression pass. + _, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, unix.SECCOMP_FILTER_FLAG_TSYNC, uintptr(unsafe.Pointer(&program))) + runtime.KeepAlive(filter) + if errno != 0 { + t.Fatalf("install syscall filter: %v", errno) + } + probe := filepath.Join(dir, "hardlink-probe") + if err := os.WriteFile(probe, []byte("probe"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Link(probe, probe+"-link"); !errors.Is(err, unix.EPERM) { + t.Fatalf("hardlink denial not active: %v", err) + } + destination := filepath.Join(dir, "uploaded") + data := []byte{0, 255, 10, 3, 0} + err := sandboxFilePut(context.Background(), destination, false, bytes.NewReader(data)) + if mode == "unsupported-rename" { + if !errors.Is(err, unix.ENOSYS) { + t.Fatalf("unsupported no-replace must fail closed: %v", err) + } + if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsupported rename installed destination: %v", err) + } + } else { + if err != nil { + t.Fatalf("upload depended on hardlinks: %v", err) + } + got, err := os.ReadFile(destination) + if err != nil || !bytes.Equal(got, data) { + t.Fatalf("binary upload: %x %v", got, err) + } + } + stages, _ := filepath.Glob(filepath.Join(dir, ".sa2-file-put-*")) + if len(stages) != 0 { + t.Fatal(fmt.Sprintf("install leaked staging files: %v", stages)) + } +} diff --git a/internal/cmd/build-guestinit/main.go b/internal/cmd/build-guestinit/main.go index a4838cde..32baa41a 100644 --- a/internal/cmd/build-guestinit/main.go +++ b/internal/cmd/build-guestinit/main.go @@ -21,13 +21,13 @@ func main() { builds := []struct { goos, goarch, pkg, output string }{ - {"linux", "arm64", "./internal/cmd/init", "internal/guestinit/guest-init-linux-arm64"}, - {"linux", "amd64", "./internal/cmd/init", "internal/guestinit/guest-init-linux-amd64"}, + {"linux", "arm64", "./internal/cmd/init", "internal/guestinit/payloads/guest-init-linux-arm64"}, + {"linux", "amd64", "./internal/cmd/init", "internal/guestinit/payloads/guest-init-linux-amd64"}, } for _, goos := range []string{"freebsd", "netbsd", "openbsd"} { builds = append(builds, struct { goos, goarch, pkg, output string - }{goos, *arch, "./internal/cmd/" + goos + "-init", "internal/" + goos + "/guestinit/guest-init-" + goos + "-" + *arch}) + }{goos, *arch, "./internal/cmd/" + goos + "-init", "internal/" + goos + "/guestinit/payloads/guest-init-" + goos + "-" + *arch}) } for _, build := range builds { @@ -44,7 +44,7 @@ func buildPayload(goos, goarch, pkg, output string) error { } tmp := output + ".tmp" _ = os.Remove(tmp) - cmd := exec.Command("go", "build", "-trimpath", "-o", tmp, pkg) + cmd := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-ldflags=-buildid=", "-o", tmp, pkg) cmd.Env = append(withoutGoTarget(os.Environ()), "CGO_ENABLED=0", "GOOS="+goos, "GOARCH="+goarch) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/cmd/init/doc_other.go b/internal/cmd/init/doc_other.go index a53f4de0..45d483ac 100644 --- a/internal/cmd/init/doc_other.go +++ b/internal/cmd/init/doc_other.go @@ -1,3 +1,7 @@ //go:build !linux package main + +import "j5.nz/cc/guestinit" + +func main() { guestinit.Run() } diff --git a/internal/cmd/init/main.go b/internal/cmd/init/main.go index b7abfdad..de6f4633 100644 --- a/internal/cmd/init/main.go +++ b/internal/cmd/init/main.go @@ -2,3642 +2,6 @@ package main -import ( - "bufio" - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - "unsafe" +import "j5.nz/cc/guestinit" - "golang.org/x/sys/unix" - "j5.nz/cc/client" - "j5.nz/cc/internal/managed/capturerelay" - "j5.nz/cc/internal/managed/guestagent" - "j5.nz/cc/internal/managed/protocol" -) - -const configPath = "/etc/ccx3-init.json" -const guestQEMUPath = "/run/ccx3/qemu-x86_64" -const guestQEMUBinfmtPath = "/run/ccx3-qemu-x86_64" -const initDurationMarker = "__CCX3_INIT_MS__:" -const defaultControlMarkerPref = "__CCX3_CTL__:" -const fatalBootMarker = "ccx3-init-fatal: " -const execPivotMode = "--ccx3-exec-pivot" -const archiveMode = "--ccx3-fs-archive" -const extractMode = "--ccx3-fs-extract" -const captureRelayMode = "--ccx3-capture-relay" - -var credentialSetupMu sync.Mutex - -const ( - managedExecTimingRecv = "recv" - managedExecTimingGuestReadyBegin = "guest_ready_begin" - managedExecTimingGuestReadyDone = "guest_ready_done" - managedExecTimingStartBegin = "start_begin" - managedExecTimingFirstStdout = "first_stdout" - managedExecTimingFirstStderr = "first_stderr" - managedExecTimingStartCall = "start_call" - managedExecTimingStarted = "started" - managedExecTimingWaitBegin = "wait_begin" - managedExecTimingWaitDone = "wait_done" - managedExecTimingStreamsDone = "streams_done" - managedExecTimingExitSent = "exit_sent" -) -const stage2Mode = "--ccx3-stage2" -const stage2Path = "/etc/ccx3/stage2" -const stage2ConfigPath = "/etc/ccx3/config.json" -const snapshotStage2Path = "/var/tmp/.ccx3-snapshot-stage2" -const snapshotStage2ConfigPath = "/var/tmp/.ccx3-snapshot-config.json" -const snapshotModuleDir = "/var/tmp/.ccx3-snapshot-modules" -const initSystemSystemd = "systemd" -const systemdReadyTimeout = 30 * time.Second - -// Exec output is base64-framed by the managed protocol. Small fragments create -// thousands of sidecar frames for ordinary file transfers and can overflow a -// bounded transport queue even while its consumer is making progress. -const execProtocolChunkSize = 32 << 10 -const defaultVsockConnectTimeout = 5 * time.Second -const stage2VsockConnectAttemptTimeout = 500 * time.Millisecond -const managedExecGroupTerminateGrace = 500 * time.Millisecond -const managedExecGroupKillWait = 2 * time.Second - -var consoleFD = 2 -var kmsgFD = -1 -var protocolMu sync.Mutex -var setTimeOfDay = unix.Settimeofday -var managedChildren = struct { - sync.Mutex - pids map[int]struct{} -}{pids: make(map[int]struct{})} - -type config struct { - Command []string `json:"command"` - Env []string `json:"env"` - WorkDir string `json:"workdir"` - User string `json:"user,omitempty"` - InitSystem string `json:"init,omitempty"` - Hostname string `json:"hostname,omitempty"` - Modules []string `json:"modules"` - EmulatorTag string `json:"emulator_tag,omitempty"` - RootFSTag string `json:"rootfs_tag"` - RootFSImagePath string `json:"rootfs_image_path,omitempty"` - RootFSImageType string `json:"rootfs_image_type,omitempty"` - Shares []share `json:"shares,omitempty"` - VsockPort uint32 `json:"vsock_port,omitempty"` - ReadyMarker string `json:"ready_marker"` - BeginMarker string `json:"begin_marker"` - OutputMarkerPref string `json:"output_marker_prefix"` - ErrorMarkerPref string `json:"error_marker_prefix"` - ControlMarkerPref string `json:"control_marker_prefix"` - UsageMarkerPref string `json:"usage_marker_prefix"` - ExitMarkerPrefix string `json:"exit_marker_prefix"` - PrecopyAMD64Root bool `json:"precopy_amd64_root,omitempty"` - DisableCgroupMount bool `json:"disable_cgroup_mount,omitempty"` - Network *network `json:"network,omitempty"` - SnapshotMMIOBase uint64 `json:"snapshot_mmio_base,omitempty"` - SnapshotModules []string `json:"snapshot_modules,omitempty"` - UnixTime int64 `json:"unix_time,omitempty"` - KernelRelease string `json:"kernel_release,omitempty"` - ModuleSymversPath string `json:"module_symvers_path,omitempty"` -} - -type network struct { - Interface string `json:"interface,omitempty"` - Address string `json:"address,omitempty"` - Gateway string `json:"gateway,omitempty"` - DNS string `json:"dns,omitempty"` -} - -func installModuleSymvers(root, release string, data []byte) error { - release = strings.TrimSpace(release) - if release == "" || release == "." || release == ".." || filepath.Base(release) != release { - return fmt.Errorf("invalid kernel release %q", release) - } - if len(data) == 0 { - return fmt.Errorf("Module.symvers is empty") - } - - headersDir := filepath.Join(root, "usr", "src", "linux-headers-"+release) - if err := os.MkdirAll(headersDir, 0o755); err != nil { - return fmt.Errorf("create kernel metadata directory: %w", err) - } - symversPath := filepath.Join(headersDir, "Module.symvers") - tmp, err := os.CreateTemp(headersDir, ".Module.symvers-*") - if err != nil { - return fmt.Errorf("create temporary Module.symvers: %w", err) - } - tmpPath := tmp.Name() - ok := false - defer func() { - if !ok { - _ = tmp.Close() - _ = os.Remove(tmpPath) - } - }() - if err := tmp.Chmod(0o644); err != nil { - return fmt.Errorf("set Module.symvers permissions: %w", err) - } - if _, err := tmp.Write(data); err != nil { - return fmt.Errorf("write Module.symvers: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close Module.symvers: %w", err) - } - if err := os.Rename(tmpPath, symversPath); err != nil { - return fmt.Errorf("install Module.symvers: %w", err) - } - ok = true - - modulesDir := filepath.Join(root, "lib", "modules", release) - if err := os.MkdirAll(modulesDir, 0o755); err != nil { - return fmt.Errorf("create kernel module directory: %w", err) - } - buildPath := filepath.Join(modulesDir, "build") - if _, err := os.Lstat(buildPath); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect kernel build link: %w", err) - } - linkTarget := filepath.Join("..", "..", "..", "usr", "src", "linux-headers-"+release) - if err := os.Symlink(linkTarget, buildPath); err != nil && !errors.Is(err, os.ErrExist) { - return fmt.Errorf("install kernel build link: %w", err) - } - return nil -} - -type share struct { - Tag string `json:"tag"` - Mount string `json:"mount"` - Writable bool `json:"writable,omitempty"` -} - -type execRequest = protocol.ManagedExecRequest - -type preparedExecRequest struct { - ID string - Command []string - Env []string - RootDir string - WorkDir string - User string - Stdin []byte - TTY bool - ControlFD bool - Cols int - Rows int -} - -type execRequestValidation struct { - Message string - ExitCode int -} - -type systemdCommandGate struct { - enabled bool - ready bool - wait func(time.Duration) error - mu sync.Mutex -} - -type managedExec struct { - stdinMu sync.Mutex - stdin io.WriteCloser - pty *os.File - process *os.Process - family *guestagent.ProcessFamily - group bool - start time.Time -} - -func (m *managedExec) writeStdin(data []byte) error { - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - if m.stdin == nil { - return fmt.Errorf("stdin is closed") - } - _, err := m.stdin.Write(data) - return err -} - -func (m *managedExec) closeStdin() error { - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - if m.stdin == nil { - return nil - } - err := m.stdin.Close() - m.stdin = nil - return err -} - -func (m *managedExec) setProcess(proc *os.Process, group bool, family *guestagent.ProcessFamily) { - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - m.process = proc - m.family = family - m.group = group -} - -func (m *managedExec) setPTY(pty *os.File) { - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - m.pty = pty -} - -func (m *managedExec) signal(name string) error { - sig, err := guestagent.ParseSignal(name) - if err != nil { - return err - } - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - if m.process == nil { - return fmt.Errorf("process is not started") - } - process := m.process - family := m.family - if family != nil { - _ = family.Signal(sig) - } - if m.group && process.Pid > 0 { - err := syscall.Kill(-process.Pid, sig) - if err == nil { - if family != nil { - return family.Signal(sig) - } - return nil - } - if errors.Is(err, syscall.ESRCH) { - if family != nil { - return family.Signal(sig) - } - return nil - } - } - err = process.Signal(sig) - if family != nil { - if familyErr := family.Signal(sig); err == nil { - err = familyErr - } - } - return err -} - -func (m *managedExec) resize(cols, rows int) error { - m.stdinMu.Lock() - defer m.stdinMu.Unlock() - if m.pty == nil { - return fmt.Errorf("exec has no tty") - } - if cols <= 0 || rows <= 0 { - return fmt.Errorf("invalid tty size %dx%d", cols, rows) - } - return unix.IoctlSetWinsize(int(m.pty.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Col: uint16(cols), - Row: uint16(rows), - }) -} - -func (m *managedExec) WriteStdin(data []byte) error { - return m.writeStdin(data) -} - -func (m *managedExec) CloseStdin() error { - return m.closeStdin() -} - -func (m *managedExec) Signal(name string) error { - return m.signal(name) -} - -func (m *managedExec) Resize(cols, rows int) error { - return m.resize(cols, rows) -} - -func validateExecRequest(req execRequest) execRequestValidation { - if len(req.Command) == 0 { - return execRequestValidation{Message: "exec request missing command", ExitCode: 125} - } - if req.ID == "" { - return execRequestValidation{Message: "exec request missing id"} - } - return execRequestValidation{} -} - -func prepareExecRequest(cfg config, req execRequest) preparedExecRequest { - workDir := req.WorkDir - if workDir == "" { - workDir = cfg.WorkDir - } - env := req.Env - if len(env) == 0 { - env = cfg.Env - } - user := req.User - if user == "" { - user = cfg.User - } - return preparedExecRequest{ - ID: req.ID, - Command: append([]string(nil), req.Command...), - Env: append([]string(nil), env...), - RootDir: req.RootDir, - WorkDir: workDir, - User: user, - Stdin: append([]byte(nil), req.Stdin...), - TTY: req.TTY, - ControlFD: req.ControlFD, - Cols: req.Cols, - Rows: req.Rows, - } -} - -func startPreparedExec(cfg config, control io.Writer, active *guestagent.ActiveExecSet, req preparedExecRequest, waitReady func() error) { - // Use a kernel pipe for command input. An io.Pipe requires a guest Go - // goroutine to rendezvous with every write; immediately after snapshot - // restore that rendezvous could stall even though the child was already - // running. The kernel pipe also preserves natural backpressure without an - // arbitrary in-memory input limit. - stdinR, stdinW, err := os.Pipe() - if err != nil { - writeExecStderr(cfg, control, req.ID, "ccx3-init: open command stdin: "+err.Error()+"\n") - reporterForConfig(cfg, control, req.ID, time.Now()).Exit(126) - return - } - managed := &managedExec{stdin: stdinW, start: time.Now()} - closeStdin := active.Add(req.ID, managed) - // The input sink is fully usable once it is registered: the kernel pipe can - // queue data before the command worker runs. Advertise that state without a - // goroutine rendezvous, which may not be scheduled promptly after restore. - writeExecTiming(control, req.ID, "input_ready", managed.start) - go func() { - runManagedExec(cfg, control, req.ID, req.Command, req.Env, req.RootDir, req.WorkDir, req.User, stdinR, managed, req.TTY, req.ControlFD, req.Cols, req.Rows, waitReady, func() { - _ = managed.closeStdin() - active.Delete(req.ID) - }) - }() - if closeStdin { - if err := managed.closeStdin(); err != nil { - writeKernel("ccx3-init: close pending stdin: " + err.Error()) - } - } - if len(req.Stdin) == 0 { - return - } - initialStdin := append([]byte(nil), req.Stdin...) - go func(id string, managed *managedExec) { - if err := managed.writeStdin(initialStdin); err != nil { - writeKernel("ccx3-init: write initial stdin: " + err.Error()) - } - if err := managed.closeStdin(); err != nil { - writeKernel("ccx3-init: close initial stdin: " + err.Error()) - } - }(req.ID, managed) -} - -func runPreparedExecInline(cfg config, control io.Writer, req preparedExecRequest, waitReady func() error) { - managed := &managedExec{start: time.Now()} - stdin, cleanup, err := inlineExecStdin(req.Stdin) - if err != nil { - writeKernel("ccx3-init: prepare inline stdin: " + err.Error()) - writeExecStderr(cfg, control, req.ID, "ccx3-init: prepare inline stdin: "+err.Error()+"\n") - reporterForConfig(cfg, control, req.ID, time.Now()).Exit(126) - return - } - runManagedExec(cfg, control, req.ID, req.Command, req.Env, req.RootDir, req.WorkDir, req.User, stdin, managed, req.TTY, req.ControlFD, req.Cols, req.Rows, waitReady, cleanup) -} - -func inlineExecStdin(data []byte) (io.ReadCloser, func(), error) { - if len(data) == 0 { - return nil, func() {}, nil - } - file, err := os.CreateTemp("", "ccx3-stdin-*") - if err != nil { - return nil, nil, err - } - cleanup := func() { - _ = os.Remove(file.Name()) - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - cleanup() - return nil, nil, err - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - _ = file.Close() - cleanup() - return nil, nil, err - } - return file, cleanup, nil -} - -func newSystemdCommandGate(initSystem string) *systemdCommandGate { - return &systemdCommandGate{ - enabled: strings.TrimSpace(initSystem) == initSystemSystemd, - ready: strings.TrimSpace(initSystem) != initSystemSystemd, - wait: waitForSystemdCommandReady, - } -} - -func (g *systemdCommandGate) WaitForCommand(argv []string) func() error { - if g == nil || !g.enabled || !commandNeedsSystemdReady(argv) { - return nil - } - return func() error { - g.mu.Lock() - defer g.mu.Unlock() - if g.ready { - return nil - } - wait := g.wait - if wait == nil { - wait = waitForSystemdCommandReady - } - if err := wait(systemdReadyTimeout); err != nil { - return err - } - g.ready = true - return nil - } -} - -func main() { - if len(os.Args) > 1 && os.Args[1] == captureRelayMode { - if err := capturerelay.Run(os.Args[2:]); err != nil { - fmt.Fprintln(os.Stderr, "ccx3-init: capture relay:", err) - os.Exit(1) - } - return - } - if len(os.Args) > 1 && os.Args[1] == stage2Mode { - if err := runStage2(); err != nil { - writeKernel("ccx3-init: stage2: " + err.Error()) - fmt.Fprintf(os.Stderr, "ccx3-init: stage2: %v\n", err) - os.Exit(126) - } - return - } - if len(os.Args) > 1 && os.Args[1] == execPivotMode { - if err := runExecPivot(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "ccx3-init: exec pivot: %v\n", err) - os.Exit(126) - } - return - } - if len(os.Args) > 1 && os.Args[1] == archiveMode { - if err := runArchiveHelper(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "ccx3-init: fs archive: %v\n", err) - os.Exit(1) - } - return - } - if len(os.Args) > 1 && os.Args[1] == extractMode { - if err := runExtractHelper(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "ccx3-init: fs extract: %v\n", err) - os.Exit(1) - } - return - } - if err := run(); err != nil { - writeKernel(fatalBootMarker + err.Error()) - writeConsole(fatalBootMarker + err.Error() + "\n") - for { - syscall.Pause() - } - } -} - -func run() error { - fd, err := syscall.Open("/dev/console", syscall.O_RDWR|syscall.O_NONBLOCK, 0) - if err == nil { - _ = syscall.SetNonblock(fd, false) - consoleFD = fd - for _, target := range []int{0, 1, 2} { - _ = syscall.Dup3(fd, target, 0) - } - } - openKernelLog() - - var cfg config - buf, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("read config: %w", err) - } - if err := json.Unmarshal(buf, &cfg); err != nil { - return fmt.Errorf("decode config: %w", err) - } - if cfg.WorkDir == "" { - cfg.WorkDir = "/" - } - if cfg.Hostname == "" || cfg.Hostname == "(none)" { - cfg.Hostname = "ccx3" - } - if err := configureClock(cfg.UnixTime); err != nil { - return err - } - bootStart := time.Now() - - _ = syscall.Mount("proc", "/proc", "proc", 0, "") - _ = syscall.Mount("sysfs", "/sys", "sysfs", 0, "") - if !cfg.DisableCgroupMount { - mountCgroupFS("/sys/fs/cgroup") - } - configureMemoryOvercommit("/proc") - - modules := cfg.Modules - var moduleSymvers []byte - if cfg.ModuleSymversPath != "" { - moduleSymvers, err = os.ReadFile(cfg.ModuleSymversPath) - if err != nil { - return fmt.Errorf("read staged Module.symvers: %w", err) - } - if len(moduleSymvers) == 0 { - return fmt.Errorf("staged Module.symvers is empty") - } - } - var deferredModulePaths []string - var deferredModules []modulePayload - if cfg.SnapshotMMIOBase != 0 { - modules, deferredModulePaths = splitSnapshotDeferredModules(cfg.Modules) - deferredModules, err = readModulePayloads(deferredModulePaths) - if err != nil { - return err - } - } - writeStage(bootStart, "loading modules") - if err := loadModules(modules); err != nil { - return err - } - writeStage(bootStart, "modules loaded") - if cfg.RootFSTag != "" || cfg.RootFSImagePath != "" { - writeStage(bootStart, "mounting rootfs") - if err := mountConfiguredRootFS(cfg); err != nil { - return err - } - writeStage(bootStart, "rootfs mounted") - if len(moduleSymvers) > 0 { - writeStage(bootStart, "installing kernel symbol versions") - if err := installModuleSymvers("/", cfg.KernelRelease, moduleSymvers); err != nil { - return fmt.Errorf("install kernel symbol versions: %w", err) - } - writeStage(bootStart, "kernel symbol versions installed") - } - writeStage(bootStart, "configuring hostname") - if err := configureHostname(cfg.Hostname); err != nil { - return fmt.Errorf("configure hostname: %w", err) - } - writeStage(bootStart, "hostname configured") - writeStage(bootStart, "configuring runtime filesystem") - if err := configureRuntimeFilesystem(); err != nil { - return fmt.Errorf("configure runtime filesystem: %w", err) - } - writeStage(bootStart, "runtime filesystem configured") - writeStage(bootStart, "configuring package managers") - if err := configurePackageManagers(""); err != nil { - return fmt.Errorf("configure package managers: %w", err) - } - writeStage(bootStart, "package managers configured") - if cfg.PrecopyAMD64Root { - writeStage(bootStart, "precopying amd64 root") - if err := precopyAMD64Root(); err != nil { - return fmt.Errorf("precopy amd64 root: %w", err) - } - writeStage(bootStart, "amd64 root precopied") - } - writeStage(bootStart, "configuring binfmt") - if configured, err := configureBinfmt(); err != nil { - return fmt.Errorf("configure binfmt: %w", err) - } else if configured { - writeStage(bootStart, "binfmt configured") - } else { - writeStage(bootStart, "binfmt unavailable") - } - } - if cfg.Network != nil { - writeStage(bootStart, "configuring network") - if err := configureNetwork(cfg.Network); err != nil { - return fmt.Errorf("configure network: %w", err) - } - writeStage(bootStart, "network configured") - } - writeStage(bootStart, "changing workdir") - if err := os.Chdir(cfg.WorkDir); err != nil { - return fmt.Errorf("chdir %s: %w", cfg.WorkDir, err) - } - - if len(cfg.Command) == 0 { - if cfg.VsockPort != 0 { - if strings.TrimSpace(cfg.InitSystem) != "" { - return bootInitSystem(cfg, bootStart) - } - if len(deferredModules) > 0 { - writeStage(bootStart, "staging deferred modules") - cfg.SnapshotModules, err = stageSnapshotModules(deferredModules) - if err != nil { - return err - } - writeStage(bootStart, "deferred modules staged") - } - if cfg.SnapshotMMIOBase != 0 { - writeStage(bootStart, "preparing snapshot stage2") - if err := prepareSnapshotStage2(cfg); err != nil { - return fmt.Errorf("prepare snapshot stage2: %w", err) - } - writeStage(bootStart, "snapshot stage2 prepared") - } - if err := triggerSnapshotMMIO(cfg.SnapshotMMIOBase); err != nil { - return fmt.Errorf("trigger snapshot: %w", err) - } - if cfg.SnapshotMMIOBase != 0 { - writeStage(bootStart, "seeding entropy") - if err := seedEntropyFromHostRNG(64); err != nil { - return fmt.Errorf("seed entropy after snapshot restore: %w", err) - } - writeStage(bootStart, "entropy seeded") - return execSnapshotStage2() - } - writeStage(bootStart, "connecting vsock control") - control, err := connectVsock(cfg.VsockPort) - if err != nil { - return fmt.Errorf("connect vsock control: %w", err) - } - defer control.Close() - if cfg.ReadyMarker != "" { - writeProtocolLineTo(control, initDurationMarker+itoa(int(time.Since(bootStart).Milliseconds()))) - writeStage(bootStart, "sending ready marker") - writeProtocolLineTo(control, cfg.ReadyMarker) - } - return commandLoop(cfg, control) - } - if cfg.ReadyMarker != "" { - writeKernel(cfg.ReadyMarker) - if kmsgFD >= 0 { - writeConsole(cfg.ReadyMarker + "\n") - } - } - return commandLoop(cfg, os.Stdin) - } - - if cfg.BeginMarker != "" { - writeKernel(cfg.BeginMarker) - } - - writeKernel("ccx3-init: exec " + strings.Join(cfg.Command, " ")) - if err := execCommand(cfg); err != nil { - return err - } - return fmt.Errorf("exec command returned unexpectedly") -} - -func runStage2() error { - openKernelLog() - writeKernel("ccx3-init: stage2 starting") - snapshotRestore := os.Getenv("CCX3_STAGE2_CONFIG") == snapshotStage2ConfigPath - var cfg config - buf, err := readStage2Config() - if err != nil { - return fmt.Errorf("read config: %w", err) - } - if err := json.Unmarshal(buf, &cfg); err != nil { - return fmt.Errorf("decode config: %w", err) - } - if snapshotRestore { - if err := loadModules(cfg.SnapshotModules); err != nil { - return fmt.Errorf("load snapshot modules: %w", err) - } - _ = os.RemoveAll(snapshotModuleDir) - _ = os.Remove(snapshotStage2ConfigPath) - _ = os.Remove(snapshotStage2Path) - } - if cfg.WorkDir == "" { - cfg.WorkDir = "/" - } - if cfg.VsockPort == 0 { - return fmt.Errorf("vsock control port is not configured") - } - writeKernel("ccx3-init: stage2 config loaded") - if err := os.Chdir(cfg.WorkDir); err != nil { - return fmt.Errorf("chdir %s: %w", cfg.WorkDir, err) - } - control, err := connectStage2Control(cfg.VsockPort) - if err != nil { - return fmt.Errorf("connect vsock control: %w", err) - } - defer control.Close() - if cfg.ReadyMarker != "" { - writeProtocolLineTo(control, initDurationMarker+"0") - writeProtocolLineTo(control, cfg.ReadyMarker) - } - return commandLoop(cfg, control) -} - -func prepareSnapshotStage2(cfg config) error { - if err := copyCurrentExecutable(snapshotStage2Path); err != nil { - return err - } - data, err := json.Marshal(cfg) - if err != nil { - return fmt.Errorf("encode snapshot stage2 config: %w", err) - } - if err := os.WriteFile(snapshotStage2ConfigPath, append(data, '\n'), 0o600); err != nil { - return fmt.Errorf("write snapshot stage2 config: %w", err) - } - if err := os.Chmod(snapshotStage2ConfigPath, 0o600); err != nil { - return fmt.Errorf("chmod snapshot stage2 config: %w", err) - } - return nil -} - -func stageSnapshotModules(modules []modulePayload) ([]string, error) { - if err := os.RemoveAll(snapshotModuleDir); err != nil { - return nil, fmt.Errorf("clear snapshot module directory: %w", err) - } - if err := os.MkdirAll(snapshotModuleDir, 0o700); err != nil { - return nil, fmt.Errorf("create snapshot module directory: %w", err) - } - paths := make([]string, 0, len(modules)) - for i, module := range modules { - path := filepath.Join(snapshotModuleDir, fmt.Sprintf("%03d-%s", i, filepath.Base(module.path))) - if err := os.WriteFile(path, module.data, 0o600); err != nil { - return nil, fmt.Errorf("stage snapshot module %s: %w", module.path, err) - } - paths = append(paths, path) - } - return paths, nil -} - -func execSnapshotStage2() error { - env := append(os.Environ(), "CCX3_STAGE2_CONFIG="+snapshotStage2ConfigPath) - if err := syscall.Exec(snapshotStage2Path, []string{snapshotStage2Path, stage2Mode}, env); err != nil { - return fmt.Errorf("exec snapshot stage2: %w", err) - } - return fmt.Errorf("snapshot stage2 returned unexpectedly") -} - -func waitForSystemdControlSocket(timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for { - ok, err := socketExists("/run/systemd/private") - if err != nil { - return err - } - if ok { - return nil - } - if time.Now().After(deadline) { - return fmt.Errorf("systemd control socket did not appear within %s", timeout) - } - time.Sleep(50 * time.Millisecond) - } -} - -func socketExists(path string) (bool, error) { - info, err := os.Stat(path) - if err == nil { - if info.Mode()&os.ModeSocket == 0 { - return false, fmt.Errorf("%s exists but is not a socket", path) - } - return true, nil - } - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, fmt.Errorf("stat %s: %w", path, err) -} - -func systemdSystemBusExpected() bool { - for _, candidate := range []string{ - "/usr/bin/dbus-daemon", - "/bin/dbus-daemon", - "/usr/bin/dbus-broker-launch", - "/lib/systemd/system/dbus.service", - "/usr/lib/systemd/system/dbus.service", - } { - if pathExists(candidate) { - return true - } - } - return false -} - -func readStage2Config() ([]byte, error) { - if path := strings.TrimSpace(os.Getenv("CCX3_STAGE2_CONFIG")); path != "" { - return os.ReadFile(path) - } - buf, err := os.ReadFile(stage2ConfigPath) - if err == nil { - return buf, nil - } - if !errors.Is(err, os.ErrNotExist) { - return nil, err - } - return os.ReadFile(configPath) -} - -func connectStage2Control(port uint32) (*os.File, error) { - start := time.Now() - lastLog := start - for attempt := 0; ; attempt++ { - control, err := connectVsockWithTimeout(port, stage2VsockConnectAttemptTimeout) - if err == nil { - if attempt > 0 || time.Since(start) > stage2VsockConnectAttemptTimeout { - writeKernel(fmt.Sprintf("ccx3-init: stage2 vsock connected after %s (%d retries)", time.Since(start).Round(time.Millisecond), attempt)) - } - return control, nil - } - if attempt == 0 { - writeKernel("ccx3-init: stage2 waiting for vsock control: " + err.Error()) - lastLog = time.Now() - } else if time.Since(lastLog) >= 5*time.Second { - writeKernel(fmt.Sprintf("ccx3-init: stage2 still waiting for vsock control after %s: %v", time.Since(start).Round(time.Millisecond), err)) - lastLog = time.Now() - } - time.Sleep(100 * time.Millisecond) - } -} - -func bootInitSystem(cfg config, bootStart time.Time) error { - initSystem := strings.TrimSpace(cfg.InitSystem) - switch initSystem { - case "", "ccx3": - return fmt.Errorf("init system is not configured") - case initSystemSystemd: - return bootSystemd(cfg, bootStart) - default: - return fmt.Errorf("unsupported init system %q", initSystem) - } -} - -func bootSystemd(cfg config, bootStart time.Time) error { - systemdPath, err := findSystemd() - if err != nil { - return err - } - writeStage(bootStart, "installing ccx3 stage2") - if err := installSystemdStage2(cfg); err != nil { - return err - } - writeStage(bootStart, "exec systemd") - return syscall.Exec(systemdPath, []string{systemdPath}, os.Environ()) -} - -func findSystemd() (string, error) { - for _, candidate := range []string{"/lib/systemd/systemd", "/usr/lib/systemd/systemd"} { - info, err := os.Stat(candidate) - if err != nil { - continue - } - if info.IsDir() { - continue - } - return candidate, nil - } - if target, err := filepath.EvalSymlinks("/sbin/init"); err == nil && strings.Contains(target, "systemd") { - return "/sbin/init", nil - } - return "", fmt.Errorf("systemd init requested but no systemd binary was found") -} - -func installSystemdStage2(cfg config) error { - if err := copyCurrentExecutable(stage2Path); err != nil { - return err - } - configData, err := json.Marshal(cfg) - if err != nil { - return fmt.Errorf("encode stage2 config: %w", err) - } - if err := os.WriteFile(stage2ConfigPath, append(configData, '\n'), 0o600); err != nil { - return fmt.Errorf("write %s: %w", stage2ConfigPath, err) - } - if err := os.MkdirAll("/etc/systemd/system/sysinit.target.wants", 0o755); err != nil { - return fmt.Errorf("mkdir systemd runtime units: %w", err) - } - unitPath := "/etc/systemd/system/ccx3-stage2.service" - unit := `[Unit] -Description=ccx3 guest control stage2 -DefaultDependencies=no -Before=sysinit.target -StartLimitIntervalSec=0 - -[Service] -Type=simple -Environment=CCX3_STAGE2_CONFIG=/etc/ccx3/config.json -ExecStart=/etc/ccx3/stage2 --ccx3-stage2 -Restart=on-failure -RestartSec=250ms -StandardInput=null -StandardOutput=null -StandardError=null - -[Install] -WantedBy=sysinit.target -` - if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { - return fmt.Errorf("write %s: %w", unitPath, err) - } - if err := ensureSymlink(unitPath, "/etc/systemd/system/sysinit.target.wants/ccx3-stage2.service"); err != nil { - return fmt.Errorf("enable ccx3 stage2: %w", err) - } - for _, unit := range []string{ - "systemd-networkd-wait-online.service", - "NetworkManager-wait-online.service", - "systemd-remount-fs.service", - } { - if err := ensureSymlink("/dev/null", filepath.Join("/run/systemd/system", unit)); err != nil { - return fmt.Errorf("mask %s: %w", unit, err) - } - } - return nil -} - -func copyCurrentExecutable(dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) - } - src, err := os.Open("/proc/self/exe") - if err != nil { - return fmt.Errorf("open current executable: %w", err) - } - defer src.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) - if err != nil { - return fmt.Errorf("create %s: %w", dst, err) - } - if _, err := io.Copy(out, src); err != nil { - _ = out.Close() - return fmt.Errorf("copy current executable to %s: %w", dst, err) - } - if err := out.Close(); err != nil { - return fmt.Errorf("close %s: %w", dst, err) - } - if err := os.Chmod(dst, 0o755); err != nil { - return fmt.Errorf("chmod %s: %w", dst, err) - } - return nil -} - -func runExecPivot(args []string) error { - req, err := parseExecPivotArgs(args) - if err != nil { - return err - } - if req.rootDir != "" { - if err := pivotExecRoot(req.rootDir); err != nil { - return err - } - } - if req.workDir != "" { - if err := os.Chdir(req.workDir); err != nil { - return fmt.Errorf("chdir %s: %w", req.workDir, err) - } - } - if req.uid != "" || req.gid != "" || req.groups != "" { - if err := applyExecCredential(req.uid, req.gid, req.groups); err != nil { - return err - } - } - argv0, err := lookPathForExec(req.argv[0]) - if err != nil { - return err - } - return syscall.Exec(argv0, req.argv, os.Environ()) -} - -type execPivotRequest struct { - rootDir string - workDir string - uid string - gid string - groups string - argv []string -} - -func parseExecPivotArgs(args []string) (execPivotRequest, error) { - if len(args) < 7 { - return execPivotRequest{}, fmt.Errorf("invalid exec pivot argument count") - } - if args[5] != "--" { - return execPivotRequest{}, fmt.Errorf("invalid exec pivot separator") - } - argv := args[6:] - if len(argv) == 0 { - return execPivotRequest{}, fmt.Errorf("missing command") - } - return execPivotRequest{ - rootDir: args[0], - workDir: args[1], - uid: args[2], - gid: args[3], - groups: args[4], - argv: append([]string(nil), argv...), - }, nil -} - -func pivotExecRoot(rootDir string) error { - return pivotExecRootWithOps(rootDir, execPivotRootOps{ - mount: syscall.Mount, - mkdirAll: os.MkdirAll, - pivot: syscall.PivotRoot, - chdir: os.Chdir, - unmount: syscall.Unmount, - remove: os.Remove, - }) -} - -type execPivotRootOps struct { - mount func(source, target, fstype string, flags uintptr, data string) error - mkdirAll func(path string, perm os.FileMode) error - pivot func(newroot, putold string) error - chdir func(dir string) error - unmount func(target string, flags int) error - remove func(name string) error -} - -func pivotExecRootWithOps(rootDir string, ops execPivotRootOps) error { - if rootDir == "" { - return fmt.Errorf("missing root_dir") - } - if err := ops.mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""); err != nil { - return fmt.Errorf("make mount namespace private: %w", err) - } - putOld := filepath.Join(rootDir, ".ccx3-old-root") - if err := ops.mkdirAll(putOld, 0o700); err != nil { - return fmt.Errorf("mkdir put_old: %w", err) - } - if err := ops.pivot(rootDir, putOld); err != nil { - return fmt.Errorf("pivot_root %s: %w", rootDir, err) - } - if err := ops.chdir("/"); err != nil { - return fmt.Errorf("chdir / after pivot_root: %w", err) - } - if err := ops.unmount("/.ccx3-old-root", syscall.MNT_DETACH); err != nil { - return fmt.Errorf("unmount old root: %w", err) - } - if err := ops.remove("/.ccx3-old-root"); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove old root: %w", err) - } - return nil -} - -func applyExecCredential(uidText, gidText, groupsText string) error { - return applyExecCredentialWithOps(uidText, gidText, groupsText, execCredentialOps{ - setgroups: syscall.Setgroups, - setgid: syscall.Setgid, - setuid: syscall.Setuid, - }) -} - -type execCredentialOps struct { - setgroups func([]int) error - setgid func(int) error - setuid func(int) error -} - -func applyExecCredentialWithOps(uidText, gidText, groupsText string, ops execCredentialOps) error { - uid, err := parseUint32(uidText) - if err != nil { - return fmt.Errorf("invalid uid %q: %w", uidText, err) - } - gid, err := parseUint32(gidText) - if err != nil { - return fmt.Errorf("invalid gid %q: %w", gidText, err) - } - var groups []int - if groupsText != "" { - for _, part := range strings.Split(groupsText, ",") { - group, err := parseUint32(part) - if err != nil { - return fmt.Errorf("invalid group %q: %w", part, err) - } - groups = append(groups, int(group)) - } - } - if err := ops.setgroups(groups); err != nil { - return fmt.Errorf("setgroups: %w", err) - } - if err := ops.setgid(int(gid)); err != nil { - return fmt.Errorf("setgid: %w", err) - } - if err := ops.setuid(int(uid)); err != nil { - return fmt.Errorf("setuid: %w", err) - } - return nil -} - -func parseUint32(value string) (uint32, error) { - n := uint64(0) - for _, ch := range value { - if ch < '0' || ch > '9' { - return 0, fmt.Errorf("not numeric") - } - n = n*10 + uint64(ch-'0') - if n > uint64(^uint32(0)) { - return 0, fmt.Errorf("out of range") - } - } - return uint32(n), nil -} - -func lookPathForExec(file string) (string, error) { - return lookPathForExecEnv(file, os.Environ()) -} - -func lookPathForExecEnv(file string, env []string) (string, error) { - if strings.Contains(file, "/") { - return file, nil - } - pathEnv := "" - for _, entry := range env { - if strings.HasPrefix(entry, "PATH=") { - pathEnv = strings.TrimPrefix(entry, "PATH=") - break - } - } - if pathEnv == "" { - pathEnv = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - } - for _, dir := range strings.Split(pathEnv, ":") { - if dir == "" { - dir = "." - } - candidate := filepath.Join(dir, file) - info, err := os.Stat(candidate) - if err != nil { - continue - } - if info.Mode().IsRegular() && info.Mode()&0o111 != 0 { - return candidate, nil - } - } - return "", fmt.Errorf("executable file not found in PATH: %s", file) -} - -func writeStage(start time.Time, stage string) { - line := fmt.Sprintf("ccx3-init: +%dms %s", time.Since(start).Milliseconds(), stage) - writeKernel(line) - writeConsole(line + "\n") -} - -func configureClock(unixTime int64) error { - if unixTime <= 0 { - return nil - } - tv := unix.NsecToTimeval(unixTime * int64(time.Second)) - if err := setTimeOfDay(&tv); err != nil { - return fmt.Errorf("set guest clock: %w", err) - } - return nil -} - -func mountConfiguredRootFS(cfg config) error { - if strings.TrimSpace(cfg.RootFSImagePath) != "" { - return mountImageRootFS(cfg.RootFSImagePath, cfg.RootFSImageType, cfg.EmulatorTag, cfg.DisableCgroupMount) - } - return mountVirtioRootFS(cfg.RootFSTag, cfg.EmulatorTag, cfg.DisableCgroupMount) -} - -func mountVirtioRootFS(tag, emulatorTag string, disableCgroup bool) error { - if err := os.MkdirAll("/mnt", 0o755); err != nil { - return fmt.Errorf("mkdir /mnt: %w", err) - } - if err := syscall.Mount(tag, "/mnt", "virtiofs", 0, ""); err != nil { - return fmt.Errorf("mount virtiofs %s: %w", tag, err) - } - return finishRootFS("/mnt", emulatorTag, disableCgroup) -} - -func mountImageRootFS(imagePath, imageType, emulatorTag string, disableCgroup bool) error { - imageType = strings.TrimSpace(imageType) - if imageType == "" { - imageType = "ext4" - } - if err := os.MkdirAll("/mnt", 0o755); err != nil { - return fmt.Errorf("mkdir /mnt: %w", err) - } - if err := attachLoop("/dev/loop0", imagePath); err != nil { - return err - } - if err := syscall.Mount("/dev/loop0", "/mnt", imageType, 0, ""); err != nil { - _ = clearLoop("/dev/loop0") - return fmt.Errorf("mount %s rootfs image %s: %w", imageType, imagePath, err) - } - return finishRootFS("/mnt", emulatorTag, disableCgroup) -} - -func attachLoop(loopPath, imagePath string) error { - image, err := syscall.Open(imagePath, syscall.O_RDWR, 0) - if err != nil { - return fmt.Errorf("open rootfs image %s: %w", imagePath, err) - } - defer syscall.Close(image) - loop, err := syscall.Open(loopPath, syscall.O_RDWR, 0) - if err != nil { - return fmt.Errorf("open loop device %s: %w", loopPath, err) - } - defer syscall.Close(loop) - if err := unix.IoctlSetInt(loop, unix.LOOP_SET_FD, image); err != nil { - return fmt.Errorf("attach %s to %s: %w", imagePath, loopPath, err) - } - return nil -} - -func clearLoop(loopPath string) error { - loop, err := syscall.Open(loopPath, syscall.O_RDWR, 0) - if err != nil { - return err - } - defer syscall.Close(loop) - return unix.IoctlSetInt(loop, unix.LOOP_CLR_FD, 0) -} - -func finishRootFS(newRoot, emulatorTag string, disableCgroup bool) error { - if err := switchRootFS(newRoot); err != nil { - return err - } - - for _, dir := range []string{"/proc", "/sys", "/dev", "/tmp", "/run"} { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - } - _ = syscall.Mount("proc", "/proc", "proc", 0, "") - _ = syscall.Mount("sysfs", "/sys", "sysfs", 0, "") - if !disableCgroup { - mountCgroupFS("/sys/fs/cgroup") - } - configureMemoryOvercommit("/proc") - _ = syscall.Mount("devtmpfs", "/dev", "devtmpfs", 0, "") - _ = syscall.Mount("tmpfs", "/tmp", "tmpfs", 0, "mode=1777") - _ = syscall.Mount("tmpfs", "/run", "tmpfs", 0, "mode=755") - for _, dir := range []string{"/dev/pts", "/dev/shm"} { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - } - _ = syscall.Mount("devpts", "/dev/pts", "devpts", 0, "gid=5,mode=620,ptmxmode=666") - _ = syscall.Mount("tmpfs", "/dev/shm", "tmpfs", 0, "mode=1777") - configureDevicePermissions("/dev") - if emulatorTag != "" { - if err := os.MkdirAll("/run/ccx3", 0o755); err != nil { - return fmt.Errorf("mkdir /run/ccx3: %w", err) - } - if err := syscall.Mount(emulatorTag, "/run/ccx3", "virtiofs", 0, ""); err != nil { - return fmt.Errorf("mount emulator virtiofs %s: %w", emulatorTag, err) - } - } - configureDeviceLinks() - return nil -} - -func switchRootFS(newRoot string) error { - if err := os.Chdir(newRoot); err != nil { - return fmt.Errorf("chdir %s before switch_root: %w", newRoot, err) - } - // pivot_root cannot move away from the initramfs rootfs. Use the - // switch_root pattern so later exec roots are not nested under a chroot, - // which would prevent user-namespace sandboxes such as bubblewrap. - for _, mount := range []string{"/sys/fs/cgroup", "/sys", "/proc"} { - _ = syscall.Unmount(mount, syscall.MNT_DETACH) - } - if err := emptyRootFS(newRoot); err != nil { - return err - } - if err := syscall.Mount(".", "/", "", syscall.MS_MOVE, ""); err != nil { - return fmt.Errorf("move root %s to /: %w", newRoot, err) - } - if err := syscall.Chroot("."); err != nil { - return fmt.Errorf("chroot after switch_root: %w", err) - } - if err := os.Chdir("/"); err != nil { - return fmt.Errorf("chdir / after switch_root: %w", err) - } - return nil -} - -func emptyRootFS(keep string) error { - keep = "/" + strings.Trim(strings.TrimSpace(keep), "/") - entries, err := os.ReadDir("/") - if err != nil { - return fmt.Errorf("read old root: %w", err) - } - for _, entry := range entries { - name := "/" + entry.Name() - if name == keep { - continue - } - if err := removeRootFSEntry(name); err != nil { - return err - } - } - return nil -} - -func triggerSnapshotMMIO(base uint64) error { - if base == 0 { - return nil - } - const snapshotMagic = 0x43535833534e4150 - pageSize := unix.Getpagesize() - pageBase := base & ^uint64(pageSize-1) - pageOff := int(base - pageBase) - if err := ensureDeviceNode("/dev/mem", unix.S_IFCHR|0o600, int(unix.Mkdev(1, 1))); err != nil { - writeConsole("__CCX3_SNAPSHOT__\n") - return fmt.Errorf("create /dev/mem: %w", err) - } - mem, err := os.OpenFile("/dev/mem", os.O_RDWR|unix.O_SYNC, 0) - if err != nil { - writeConsole("__CCX3_SNAPSHOT__\n") - return fmt.Errorf("open /dev/mem: %w", err) - } - defer mem.Close() - mapped, err := unix.Mmap(int(mem.Fd()), int64(pageBase), pageSize, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - if err != nil { - writeConsole("__CCX3_SNAPSHOT__\n") - return fmt.Errorf("map snapshot MMIO: %w", err) - } - defer unix.Munmap(mapped) - if pageOff+8 > len(mapped) { - return fmt.Errorf("snapshot mmio offset %#x outside mapped page", pageOff) - } - trigger := (*uint64)(unsafe.Pointer(&mapped[pageOff])) - deadline := time.Now().Add(30 * time.Second) - for atomic.LoadUint64(trigger) != snapshotMagic { - if time.Now().After(deadline) { - return fmt.Errorf("wait for snapshot readiness: timed out") - } - time.Sleep(time.Millisecond) - } - atomic.StoreUint64(trigger, snapshotMagic) - writeConsole("__CCX3_SNAPSHOT__\n") - return nil -} - -func ensureDeviceNode(path string, mode uint32, dev int) error { - if _, err := os.Stat(path); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - return unix.Mknod(path, mode, dev) -} - -func seedEntropyFromHostRNG(size int) error { - if size <= 0 { - return nil - } - seed := make([]byte, size) - rng, err := os.Open("/dev/hwrng") - if err != nil { - return fmt.Errorf("open /dev/hwrng: %w", err) - } - if _, err := io.ReadFull(rng, seed); err != nil { - _ = rng.Close() - return fmt.Errorf("read /dev/hwrng: %w", err) - } - if err := rng.Close(); err != nil { - return fmt.Errorf("close /dev/hwrng: %w", err) - } - urandom, err := os.OpenFile("/dev/urandom", os.O_WRONLY, 0) - if err != nil { - return fmt.Errorf("open /dev/urandom: %w", err) - } - if _, err := urandom.Write(seed); err != nil { - _ = urandom.Close() - return fmt.Errorf("write /dev/urandom: %w", err) - } - if err := urandom.Close(); err != nil { - return fmt.Errorf("close /dev/urandom: %w", err) - } - return nil -} - -func removeRootFSEntry(name string) error { - info, err := os.Lstat(name) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("stat old root entry %s: %w", name, err) - } - if info.IsDir() && info.Mode()&os.ModeSymlink == 0 { - entries, err := os.ReadDir(name) - if err != nil { - return fmt.Errorf("read old root dir %s: %w", name, err) - } - for _, entry := range entries { - if err := removeRootFSEntry(filepath.Join(name, entry.Name())); err != nil { - return err - } - } - } - if err := os.Remove(name); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove old root entry %s: %w", name, err) - } - return nil -} - -func mountCgroupFS(path string) { - if err := os.MkdirAll(path, 0o755); err != nil { - writeKernel("ccx3-init: mkdir cgroup mountpoint: " + err.Error()) - return - } - if err := syscall.Mount("none", path, "cgroup2", 0, ""); err == nil || errors.Is(err, syscall.EBUSY) { - return - } - // Older or custom kernels may only expose cgroup v1. Mounting cgroup2 is - // best-effort so ordinary containers continue to boot on those kernels. - if err := syscall.Mount("cgroup", path, "cgroup", 0, ""); err != nil && !errors.Is(err, syscall.EBUSY) { - writeKernel("ccx3-init: mount cgroup filesystem: " + err.Error()) - } -} - -func configureRuntimeFilesystem() error { - if err := mountRuntimeTmpFS("/var/tmp", "mode=1777"); err != nil { - return err - } - for _, dir := range []struct { - path string - mode os.FileMode - }{ - {"/run/lock", 0o1777}, - {"/run/user", 0o755}, - {"/run/sshd", 0o755}, - {"/var/log", 0o755}, - {"/var/cache", 0o755}, - {"/var/lib/dbus", 0o755}, - } { - if err := os.MkdirAll(dir.path, dir.mode); err != nil { - return fmt.Errorf("mkdir %s: %w", dir.path, err) - } - if err := os.Chmod(dir.path, dir.mode); err != nil { - return fmt.Errorf("chmod %s: %w", dir.path, err) - } - } - if err := ensureMachineID(); err != nil { - return err - } - if err := ensureLocaltime(); err != nil { - return err - } - return nil -} - -func mountRuntimeTmpFS(path, options string) error { - if err := os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", path, err) - } - if err := syscall.Mount("tmpfs", path, "tmpfs", 0, options); err != nil && !errors.Is(err, syscall.EBUSY) { - return fmt.Errorf("mount tmpfs on %s: %w", path, err) - } - return nil -} - -func configureDeviceLinks() { - for target, link := range map[string]string{ - "/proc/self/fd": "/dev/fd", - "/proc/self/fd/0": "/dev/stdin", - "/proc/self/fd/1": "/dev/stdout", - "/proc/self/fd/2": "/dev/stderr", - } { - if err := ensureSymlink(target, link); err != nil { - writeKernel("ccx3-init: link " + link + ": " + err.Error()) - } - } -} - -// configureDevicePermissions applies the standard permissions normally set -// by udev. Minimal guest targets may deliberately start without udev, but -// should still expose devices intended for unprivileged use. -func configureDevicePermissions(devRoot string) { - for name, mode := range map[string]os.FileMode{ - "fuse": 0o666, - } { - path := filepath.Join(devRoot, name) - if err := os.Chmod(path, mode); err != nil && !errors.Is(err, os.ErrNotExist) { - writeKernel("ccx3-init: chmod " + path + ": " + err.Error()) - } - } -} - -func ensureLocaltime() error { - if pathExists("/etc/localtime") { - return nil - } - if !pathExists("/usr/share/zoneinfo/Etc/UTC") { - return nil - } - return ensureSymlink("/usr/share/zoneinfo/Etc/UTC", "/etc/localtime") -} - -func ensureSymlink(target, link string) error { - if existing, err := os.Readlink(link); err == nil { - if existing == target { - return nil - } - if err := os.Remove(link); err != nil { - return err - } - } else if os.IsNotExist(err) { - // Create it below. - } else if info, statErr := os.Lstat(link); statErr == nil { - if info.IsDir() { - return fmt.Errorf("%s is a directory", link) - } - if err := os.Remove(link); err != nil { - return err - } - } else if !os.IsNotExist(err) { - return err - } - if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { - return err - } - return os.Symlink(target, link) -} - -func ensureMachineID() error { - id, err := readMachineID("/etc/machine-id") - if err != nil { - return err - } - if id == "" { - id, err = newMachineID() - if err != nil { - return err - } - if err := os.WriteFile("/etc/machine-id", []byte(id+"\n"), 0o444); err != nil { - return fmt.Errorf("write /etc/machine-id: %w", err) - } - } - dbusPath := "/var/lib/dbus/machine-id" - if existing, err := readMachineID(dbusPath); err != nil { - return err - } else if existing == id { - return nil - } - _ = os.Remove(dbusPath) - if err := os.Symlink("/etc/machine-id", dbusPath); err == nil { - return nil - } - if err := os.WriteFile(dbusPath, []byte(id+"\n"), 0o444); err != nil { - return fmt.Errorf("write %s: %w", dbusPath, err) - } - return nil -} - -func readMachineID(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", fmt.Errorf("read %s: %w", path, err) - } - id := strings.TrimSpace(string(data)) - if id == "" || id == "uninitialized" { - return "", nil - } - return id, nil -} - -func newMachineID() (string, error) { - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", fmt.Errorf("generate machine-id: %w", err) - } - buf[6] = (buf[6] & 0x0f) | 0x40 - buf[8] = (buf[8] & 0x3f) | 0x80 - return hex.EncodeToString(buf[:]), nil -} - -func precopyAMD64Root() error { - for _, path := range []string{ - "/run/ccx3/qemu-x86_64", - "/bin/busybox", - "/lib/ld-musl-x86_64.so.1", - "/lib/libc.musl-x86_64.so.1", - } { - if err := copyPath(path, filepath.Join("/run/ccx3-precopy", path)); err != nil { - return err - } - } - return nil -} - -func configureNetwork(cfg *network) error { - if cfg == nil { - return nil - } - iface := strings.TrimSpace(cfg.Interface) - if iface == "" { - iface = "eth0" - } - address := strings.TrimSpace(cfg.Address) - if address == "" { - address = "10.42.0.2/24" - } - gateway := strings.TrimSpace(cfg.Gateway) - if gateway == "" { - gateway = "10.42.0.1" - } - dns := strings.TrimSpace(cfg.DNS) - if dns == "" { - dns = gateway - } - if err := configureNetworkLink("lo"); err != nil { - writeKernel("ccx3-init: configure lo: " + err.Error()) - } - if err := configureNetworkLink(iface); err != nil { - return err - } - if err := configureNetworkAddress(iface, address); err != nil { - return err - } - if err := configureNetworkDefaultRoute(iface, gateway); err != nil { - return err - } - if err := writeResolverConfig(dns); err != nil { - return err - } - return nil -} - -func writeResolverConfig(dns string) error { - data := []byte("nameserver " + dns + "\n") - if err := writeFileReplacingMissingSymlink("/etc/resolv.conf", data, 0o644); err != nil { - return fmt.Errorf("write /etc/resolv.conf: %w", err) - } - return nil -} - -func writeFileReplacingMissingSymlink(name string, data []byte, perm os.FileMode) error { - if err := os.WriteFile(name, data, perm); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - if info, err := os.Lstat(name); err == nil && info.Mode()&os.ModeSymlink != 0 { - _ = os.Remove(name) - return os.WriteFile(name, data, perm) - } - return os.WriteFile(name, data, perm) -} - -func configureNetworkLink(name string) error { - iface, err := net.InterfaceByName(name) - if err != nil { - return fmt.Errorf("find interface %s: %w", name, err) - } - msg := unix.IfInfomsg{ - Family: unix.AF_UNSPEC, - Index: int32(iface.Index), - Flags: unix.IFF_UP, - Change: unix.IFF_UP, - } - if err := netlinkRequest(unix.RTM_NEWLINK, 0, structBytes(&msg), false); err != nil { - return fmt.Errorf("set link %s up: %w", name, err) - } - return nil -} - -func configureNetworkAddress(ifaceName, address string) error { - iface, err := net.InterfaceByName(ifaceName) - if err != nil { - return fmt.Errorf("find interface %s: %w", ifaceName, err) - } - ip, ipNet, err := net.ParseCIDR(address) - if err != nil { - return fmt.Errorf("parse address %s: %w", address, err) - } - ip4 := ip.To4() - if ip4 == nil { - return fmt.Errorf("address %s is not IPv4", address) - } - ones, _ := ipNet.Mask.Size() - msg := unix.IfAddrmsg{ - Family: unix.AF_INET, - Prefixlen: uint8(ones), - Scope: unix.RT_SCOPE_UNIVERSE, - Index: uint32(iface.Index), - } - payload := structBytes(&msg) - payload = appendRtAttr(payload, unix.IFA_LOCAL, ip4) - payload = appendRtAttr(payload, unix.IFA_ADDRESS, ip4) - if err := netlinkRequest(unix.RTM_NEWADDR, unix.NLM_F_CREATE|unix.NLM_F_REPLACE, payload, true); err != nil { - return fmt.Errorf("add address %s to %s: %w", address, ifaceName, err) - } - return nil -} - -func configureNetworkDefaultRoute(ifaceName, gateway string) error { - iface, err := net.InterfaceByName(ifaceName) - if err != nil { - return fmt.Errorf("find interface %s: %w", ifaceName, err) - } - gw := net.ParseIP(gateway).To4() - if gw == nil { - return fmt.Errorf("gateway %s is not IPv4", gateway) - } - msg := unix.RtMsg{ - Family: unix.AF_INET, - Table: unix.RT_TABLE_MAIN, - Protocol: unix.RTPROT_STATIC, - Scope: unix.RT_SCOPE_UNIVERSE, - Type: unix.RTN_UNICAST, - } - payload := structBytes(&msg) - payload = appendRtAttr(payload, unix.RTA_GATEWAY, gw) - oif := uint32(iface.Index) - payload = appendRtAttr(payload, unix.RTA_OIF, structBytes(&oif)) - if err := netlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_REPLACE, payload, true); err != nil { - return fmt.Errorf("add default route via %s dev %s: %w", gateway, ifaceName, err) - } - return nil -} - -func netlinkRequest(msgType uint16, flags uint16, payload []byte, ignoreExists bool) error { - fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE) - if err != nil { - return err - } - defer unix.Close(fd) - if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { - return err - } - seq := uint32(time.Now().UnixNano()) - hdr := unix.NlMsghdr{ - Len: uint32(unix.NLMSG_HDRLEN + len(payload)), - Type: msgType, - Flags: unix.NLM_F_REQUEST | unix.NLM_F_ACK | flags, - Seq: seq, - } - msg := append(structBytes(&hdr), payload...) - if err := unix.Sendto(fd, msg, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { - return err - } - return readNetlinkAck(fd, seq, ignoreExists) -} - -func readNetlinkAck(fd int, seq uint32, ignoreExists bool) error { - buf := make([]byte, 8192) - for { - n, _, err := unix.Recvfrom(fd, buf, 0) - if err != nil { - return err - } - for off := 0; off+unix.NLMSG_HDRLEN <= n; { - hdr := (*unix.NlMsghdr)(unsafe.Pointer(&buf[off])) - if hdr.Len < unix.NLMSG_HDRLEN || off+int(hdr.Len) > n { - return fmt.Errorf("short netlink response") - } - next := off + nlmsgAlign(int(hdr.Len)) - if hdr.Seq != seq { - off = next - continue - } - switch hdr.Type { - case unix.NLMSG_ERROR: - if int(hdr.Len) < unix.NLMSG_HDRLEN+int(unsafe.Sizeof(unix.NlMsgerr{})) { - return fmt.Errorf("short netlink error response") - } - msgErr := (*unix.NlMsgerr)(unsafe.Pointer(&buf[off+unix.NLMSG_HDRLEN])) - if msgErr.Error == 0 { - return nil - } - errno := syscall.Errno(-msgErr.Error) - if ignoreExists && errno == syscall.EEXIST { - return nil - } - return errno - case unix.NLMSG_DONE: - return nil - } - off = next - } - } -} - -func appendRtAttr(buf []byte, attrType uint16, data []byte) []byte { - attr := unix.RtAttr{ - Len: uint16(unsafe.Sizeof(unix.RtAttr{}) + uintptr(len(data))), - Type: attrType, - } - buf = append(buf, structBytes(&attr)...) - buf = append(buf, data...) - for len(buf)%unix.NLMSG_ALIGNTO != 0 { - buf = append(buf, 0) - } - return buf -} - -func nlmsgAlign(n int) int { - return (n + unix.NLMSG_ALIGNTO - 1) & ^(unix.NLMSG_ALIGNTO - 1) -} - -func structBytes[T any](value *T) []byte { - size := int(unsafe.Sizeof(*value)) - data := unsafe.Slice((*byte)(unsafe.Pointer(value)), size) - out := make([]byte, size) - copy(out, data) - return out -} - -func runCommand(name string, args ...string) error { - cmd := exec.Command(name, args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() - } - return fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg) - } - return nil -} - -func copyPath(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) - } - info, err := os.Lstat(src) - if err != nil { - return fmt.Errorf("stat %s: %w", src, err) - } - if info.Mode()&os.ModeSymlink != 0 { - target, err := os.Readlink(src) - if err != nil { - return fmt.Errorf("readlink %s: %w", src, err) - } - _ = os.Remove(dst) - if err := os.Symlink(target, dst); err != nil { - return fmt.Errorf("symlink %s: %w", dst, err) - } - return nil - } - in, err := os.Open(src) - if err != nil { - return fmt.Errorf("open %s: %w", src, err) - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) - if err != nil { - return fmt.Errorf("create %s: %w", dst, err) - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return fmt.Errorf("copy %s to %s: %w", src, dst, err) - } - if err := out.Close(); err != nil { - return fmt.Errorf("close %s: %w", dst, err) - } - return nil -} - -func configureMemoryOvercommit(procRoot string) { - path := filepath.Join(procRoot, "sys/vm/overcommit_memory") - _ = os.WriteFile(path, []byte("1\n"), 0o644) -} - -func configureBinfmt() (bool, error) { - if _, err := os.Stat(guestQEMUPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, err - } - if err := copyPath(guestQEMUPath, guestQEMUBinfmtPath); err != nil { - return false, fmt.Errorf("copy qemu-x86_64 for binfmt: %w", err) - } - if err := os.Chmod(guestQEMUBinfmtPath, 0o755); err != nil { - return false, fmt.Errorf("chmod %s: %w", guestQEMUBinfmtPath, err) - } - if err := os.MkdirAll("/proc/sys/fs/binfmt_misc", 0o755); err != nil { - return false, fmt.Errorf("mkdir binfmt_misc: %w", err) - } - if err := syscall.Mount("binfmt_misc", "/proc/sys/fs/binfmt_misc", "binfmt_misc", 0, ""); err != nil && !errors.Is(err, syscall.EBUSY) { - if errors.Is(err, syscall.ENODEV) { - writeKernel("ccx3-init: binfmt_misc unavailable: " + err.Error()) - return false, nil - } - return false, fmt.Errorf("mount binfmt_misc: %w", err) - } - if _, err := os.Stat("/proc/sys/fs/binfmt_misc/qemu-x86_64"); err == nil { - return true, nil - } else if !errors.Is(err, os.ErrNotExist) { - return false, fmt.Errorf("stat qemu-x86_64 registration: %w", err) - } - const qemuX8664Registration = ":qemu-x86_64:M::\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x3e\\x00:\\xff\\xff\\xff\\xff\\xff\\xfe\\xfe\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:" + guestQEMUBinfmtPath + ":F" - if err := os.WriteFile("/proc/sys/fs/binfmt_misc/register", []byte(qemuX8664Registration), 0o644); err != nil { - return false, fmt.Errorf("register qemu-x86_64: %w", err) - } - if _, err := os.Stat("/proc/sys/fs/binfmt_misc/qemu-x86_64"); err != nil { - return false, fmt.Errorf("verify qemu-x86_64 registration: %w", err) - } - return true, nil -} - -func writeString(fd int, value string) { - for len(value) > 0 { - n, err := syscall.Write(fd, []byte(value)) - if err != nil || n <= 0 { - return - } - value = value[n:] - } -} - -func writeConsole(value string) { - writeString(consoleFD, value) -} - -func writeProtocolLine(value string) { - protocolMu.Lock() - defer protocolMu.Unlock() - writeConsole(strings.TrimRight(value, "\n") + "\n") -} - -func writeProtocolLineTo(w io.Writer, value string) { - guestagent.WriteProtocolLine(w, value) -} - -func writeExecStderr(cfg config, control io.Writer, id, value string) { - reporterForConfig(cfg, control, id, time.Time{}).Stderr([]byte(value)) -} - -func writeExecStdoutBytes(cfg config, control io.Writer, id string, data []byte) { - reporterForConfig(cfg, control, id, time.Time{}).Stdout(data) -} - -func writeExecControlBytes(cfg config, control io.Writer, id string, data []byte) { - reporterForConfig(cfg, control, id, time.Time{}).ControlBytes(data) -} - -func writeExecTiming(control io.Writer, id, phase string, start time.Time) { - guestagent.NewExecReporter(guestagent.Protocol{TimingMarkerPrefix: guestagent.TimingMarkerPrefix}, control, id, start).Timing(phase) -} - -func managedExecGuestReadyTimingPhases(waitReady func() error) (begin string, done string, enabled bool) { - if waitReady == nil { - return "", "", false - } - return managedExecTimingGuestReadyBegin, managedExecTimingGuestReadyDone, true -} - -func managedExecFirstOutputTimingPhase(stderr bool) string { - if stderr { - return managedExecTimingFirstStderr - } - return managedExecTimingFirstStdout -} - -func reporterForConfig(cfg config, control io.Writer, id string, start time.Time) guestagent.ExecReporter { - return guestagent.NewExecReporter(protocolForConfig(cfg), control, id, start) -} - -func protocolForConfig(cfg config) guestagent.Protocol { - return guestagent.Protocol{ - BeginMarkerPrefix: cfg.BeginMarker, - OutputMarkerPrefix: cfg.OutputMarkerPref, - ErrorMarkerPrefix: cfg.ErrorMarkerPref, - ControlMarkerPrefix: cfg.ControlMarkerPref, - UsageMarkerPrefix: cfg.UsageMarkerPref, - ExitMarkerPrefix: cfg.ExitMarkerPrefix, - TimingMarkerPrefix: guestagent.TimingMarkerPrefix, - } -} - -func appendFileContents(buf *strings.Builder, path string) { - data, err := os.ReadFile(path) - if err != nil { - buf.WriteString(path) - buf.WriteString(": ") - buf.WriteString(err.Error()) - buf.WriteString("\n") - return - } - buf.WriteString(path) - buf.WriteString(":\n") - buf.Write(data) - if len(data) == 0 || data[len(data)-1] != '\n' { - buf.WriteString("\n") - } -} - -func appendStat(buf *strings.Builder, label, path string) { - info, err := os.Stat(path) - if err != nil { - buf.WriteString(label) - buf.WriteString(": ") - buf.WriteString(path) - buf.WriteString(": ") - buf.WriteString(err.Error()) - buf.WriteString("\n") - return - } - buf.WriteString(label) - buf.WriteString(": ") - buf.WriteString(path) - buf.WriteString(" mode=") - buf.WriteString(fmt.Sprintf("%#o", info.Mode()&0o777)) - if info.IsDir() { - buf.WriteString(" dir=true") - } - buf.WriteString("\n") -} - -func managedExecStartFailure(err error, argv []string, rootDir, workDir string) (int, string) { - command := "command" - if len(argv) > 0 && strings.TrimSpace(argv[0]) != "" { - command = argv[0] - } - switch { - case errors.Is(err, exec.ErrNotFound), errors.Is(err, os.ErrNotExist): - return 127, command + ": executable not found\n" - case errors.Is(err, os.ErrPermission): - return 126, command + ": permission denied\n" - default: - return 126, "ccx3-init: exec error: " + err.Error() + "\n" + collectExecDiagnostics(rootDir, argv, workDir) - } -} - -func collectExecDiagnostics(rootDir string, argv []string, workDir string) string { - var buf strings.Builder - if rootDir != "" { - appendStat(&buf, "root_dir", rootDir) - } - if len(argv) != 0 { - appendStat(&buf, "argv0", argv[0]) - if rootDir != "" && strings.HasPrefix(argv[0], "/") { - appendStat(&buf, "argv0@root", rootDir+argv[0]) - } - } - if workDir != "" { - appendStat(&buf, "workdir", workDir) - if rootDir != "" && strings.HasPrefix(workDir, "/") { - appendStat(&buf, "workdir@root", rootDir+workDir) - } - } - appendStat(&buf, "/run", "/run") - appendStat(&buf, "/run/ccx3", "/run/ccx3") - appendStat(&buf, guestQEMUBinfmtPath, guestQEMUBinfmtPath) - if info, err := os.Stat(guestQEMUPath); err != nil { - buf.WriteString(guestQEMUPath) - buf.WriteString(": ") - buf.WriteString(err.Error()) - buf.WriteString("\n") - } else { - buf.WriteString(guestQEMUPath) - buf.WriteString(" mode: ") - buf.WriteString(fmt.Sprintf("%#o", info.Mode()&0o777)) - buf.WriteString("\n") - } - appendFileContents(&buf, "/proc/sys/fs/binfmt_misc/status") - appendFileContents(&buf, "/proc/sys/fs/binfmt_misc/qemu-x86_64") - return buf.String() -} - -func writeKernel(value string) { - value = strings.TrimRight(value, "\n") - if value == "" { - return - } - if kmsgFD >= 0 { - writeString(kmsgFD, "<6>"+value+"\n") - return - } - writeConsole(value + "\n") -} - -func openKernelLog() { - if kmsgFD >= 0 { - return - } - if fd, err := syscall.Open("/dev/kmsg", syscall.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { - kmsgFD = fd - } -} - -func itoa(v int) string { - if v == 0 { - return "0" - } - neg := v < 0 - if neg { - v = -v - } - var buf [32]byte - i := len(buf) - for v > 0 { - i-- - buf[i] = byte('0' + (v % 10)) - v /= 10 - } - if neg { - i-- - buf[i] = '-' - } - return string(buf[i:]) -} - -func openPTY(cols, rows int) (*os.File, *os.File, error) { - masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) - if err != nil { - return nil, nil, err - } - master := os.NewFile(uintptr(masterFD), "ptmx") - if master == nil { - _ = unix.Close(masterFD) - return nil, nil, fmt.Errorf("open /dev/ptmx: no file handle") - } - - if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { - _ = master.Close() - return nil, nil, fmt.Errorf("unlock ptmx: %w", err) - } - ptn, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) - if err != nil { - _ = master.Close() - return nil, nil, fmt.Errorf("query pty number: %w", err) - } - if cols > 0 && rows > 0 { - if err := unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{ - Col: uint16(cols), - Row: uint16(rows), - }); err != nil { - _ = master.Close() - return nil, nil, fmt.Errorf("set initial winsize: %w", err) - } - } - slave, err := os.OpenFile("/dev/pts/"+itoa(ptn), os.O_RDWR, 0) - if err != nil { - _ = master.Close() - return nil, nil, fmt.Errorf("open slave pty: %w", err) - } - return master, slave, nil -} - -func loadModules(modules []string) error { - for _, path := range modules { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read module %s: %w", path, err) - } - if err := loadModuleData(path, data); err != nil { - return err - } - } - return nil -} - -type modulePayload struct { - path string - data []byte -} - -func readModulePayloads(modules []string) ([]modulePayload, error) { - payloads := make([]modulePayload, 0, len(modules)) - for _, path := range modules { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read module %s: %w", path, err) - } - payloads = append(payloads, modulePayload{path: path, data: data}) - } - return payloads, nil -} - -func loadModuleData(path string, data []byte) error { - if len(data) == 0 { - return fmt.Errorf("module %s is empty", path) - } - params, err := syscall.BytePtrFromString("") - if err != nil { - return fmt.Errorf("init module params: %w", err) - } - _, _, errno := syscall.RawSyscall(syscall.SYS_INIT_MODULE, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), uintptr(unsafe.Pointer(params))) - if errno != 0 { - return fmt.Errorf("load module %s: errno=%d", path, errno) - } - return nil -} - -func splitSnapshotDeferredModules(modules []string) ([]string, []string) { - var early []string - var deferred []string - for _, path := range modules { - if snapshotDeferredModule(path) { - deferred = append(deferred, path) - continue - } - early = append(early, path) - } - return early, deferred -} - -func snapshotDeferredModule(path string) bool { - name := filepath.Base(path) - return name == "vsock.ko" || - strings.HasPrefix(name, "vsock.ko.") || - strings.HasPrefix(name, "vmw_vsock_") || - strings.HasPrefix(name, "virtio_vsock") -} - -func execCommand(cfg config) error { - if info, err := os.Stat(cfg.Command[0]); err != nil { - writeKernel("ccx3-init: stat failed for " + cfg.Command[0] + ": " + err.Error()) - } else { - writeKernel("ccx3-init: stat mode for " + cfg.Command[0] + " is " + fmt.Sprintf("%#o", info.Mode()&0o777)) - } - - exitCode, usage, err := execCommandGo(cfg.Command, cfg.Env, cfg.WorkDir, cfg.User) - if err != nil { - return fmt.Errorf("run %s: %w", cfg.Command[0], err) - } - if cfg.UsageMarkerPref != "" && usage != nil { - usageMarker := cfg.UsageMarkerPref + guestagent.EncodeExecUsage(usage) - writeKernel(usageMarker) - writeProtocolLine(usageMarker) - } - if cfg.ExitMarkerPrefix != "" { - exitMarker := cfg.ExitMarkerPrefix + itoa(exitCode) - writeKernel(exitMarker) - writeProtocolLine(exitMarker) - } - syscall.Sync() - _ = syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF) - for { - syscall.Pause() - } -} - -func execCommandGo(argv []string, env []string, workDir string, user string) (int, *guestagent.ExecUsage, error) { - cred, err := guestagent.CredentialForUser(user) - if err != nil { - return 0, nil, err - } - if cred != nil { - credentialSetupMu.Lock() - err := guestagent.EnsureCredentialUser("", cred) - credentialSetupMu.Unlock() - if err != nil { - return 0, nil, err - } - env = guestagent.EnvironmentForCredential("", env, cred) - } - cmd := exec.Command(argv[0], argv[1:]...) - start := time.Now() - cmd.Env = env - if workDir != "" { - cmd.Dir = workDir - } - console := os.NewFile(uintptr(consoleFD), "/dev/console") - cmd.Stdin = console - cmd.Stdout = console - cmd.Stderr = console - if cred != nil { - cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred} - } - - err = cmd.Run() - usage := guestagent.UsageFromProcessState(cmd.ProcessState, time.Since(start)) - if err == nil { - return 0, usage, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return guestagent.ProcessExitCode(cmd.ProcessState, exitErr.ExitCode()), usage, nil - } - return 0, usage, err -} - -func handleInitControlRequest(cfg config, control io.Writer, active *guestagent.ActiveExecSet, req execRequest) bool { - switch req.Kind { - case "exec": - return false - case "sync": - if req.ID == "" { - writeKernel("ccx3-init: sync request missing id") - return true - } - go runSyncRequest(cfg, control, req.ID) - case "fs_archive": - if req.ID == "" { - writeKernel("ccx3-init: fs_archive request missing id") - return true - } - go runFSArchiveRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User)) - case "fs_mkdir": - if req.ID == "" { - writeKernel("ccx3-init: fs_mkdir request missing id") - return true - } - go runFSMkdirRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User)) - case "fs_write": - if req.ID == "" { - writeKernel("ccx3-init: fs_write request missing id") - return true - } - go runFSWriteRequest(cfg, control, req.ID, req.RootDir, req.Path, firstNonEmpty(req.User, cfg.User), io.NopCloser(bytes.NewReader(req.Stdin))) - case "fs_extract": - if req.ID == "" { - writeKernel("ccx3-init: fs_extract request missing id") - return true - } - if len(req.Stdin) > 0 { - go runFSExtractRequest(cfg, control, req.ID, req.RootDir, req.Path, req.Directory, req.User, req.ArchiveLimits, io.NopCloser(bytes.NewReader(req.Stdin)), func() {}) - return true - } - stdinR, stdinW := io.Pipe() - managed := &managedExec{stdin: stdinW, start: time.Now()} - active.Add(req.ID, managed) - go runFSExtractRequest(cfg, control, req.ID, req.RootDir, req.Path, req.Directory, req.User, req.ArchiveLimits, stdinR, func() { - _ = managed.closeStdin() - active.Delete(req.ID) - }) - case "stdin": - result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ - Kind: req.Kind, - ID: req.ID, - Stdin: req.Stdin, - }) - if !result.Found { - writeKernel("ccx3-init: stdin for unknown exec id " + req.ID) - return true - } - if result.Err != nil { - writeKernel("ccx3-init: write stdin: " + result.Err.Error()) - } - case "stdin_close": - result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ - Kind: req.Kind, - ID: req.ID, - RememberPendingStdinClose: true, - }) - if !result.Found { - return true - } - if result.Err != nil { - writeKernel("ccx3-init: close stdin: " + result.Err.Error()) - } - case "signal": - result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ - Kind: req.Kind, - ID: req.ID, - Signal: req.Signal, - }) - if !result.Found { - writeKernel("ccx3-init: signal for unknown exec id " + req.ID) - return true - } - if result.Err != nil { - writeKernel("ccx3-init: signal " + req.Signal + ": " + result.Err.Error()) - } - case "resize": - result := guestagent.HandleActiveControl(active, guestagent.ActiveControlRequest{ - Kind: req.Kind, - ID: req.ID, - Cols: req.Cols, - Rows: req.Rows, - }) - if !result.Found { - return true - } - if result.Err != nil { - writeKernel("ccx3-init: resize " + itoa(req.Cols) + "x" + itoa(req.Rows) + ": " + result.Err.Error()) - } - default: - writeKernel("ccx3-init: unsupported control kind " + req.Kind) - } - return true -} - -func commandLoop(cfg config, control io.ReadWriter) error { - if cfg.ControlMarkerPref == "" { - cfg.ControlMarkerPref = defaultControlMarkerPref - } - reader := bufio.NewReader(control) - active := guestagent.NewActiveExecSet() - systemdGate := newSystemdCommandGate(cfg.InitSystem) - startOrphanReaper() - for { - line, err := reader.ReadBytes('\n') - if err != nil { - return fmt.Errorf("read exec request: %w", err) - } - line = bytes.TrimSpace(line) - if len(line) == 0 { - continue - } - - var req execRequest - if err := json.Unmarshal(line, &req); err != nil { - writeKernel("ccx3-init: decode exec request: " + err.Error()) - if cfg.ExitMarkerPrefix != "" { - writeKernel(cfg.ExitMarkerPrefix + "125") - } - continue - } - if req.Kind == "" { - req.Kind = "exec" - } - inlineExec := req.Kind == "exec_inline" - if inlineExec { - req.Kind = "exec" - } - if handleInitControlRequest(cfg, control, active, req) { - continue - } - if validation := validateExecRequest(req); validation.Message != "" { - writeKernel("ccx3-init: " + validation.Message) - if validation.ExitCode != 0 && cfg.ExitMarkerPrefix != "" { - writeKernel(cfg.ExitMarkerPrefix + itoa(validation.ExitCode)) - } - continue - } - - execReq := prepareExecRequest(cfg, req) - if inlineExec { - runPreparedExecInline(cfg, control, execReq, systemdGate.WaitForCommand(execReq.Command)) - continue - } - startPreparedExec(cfg, control, active, execReq, systemdGate.WaitForCommand(execReq.Command)) - } -} - -func commandNeedsSystemdReady(argv []string) bool { - if len(argv) == 0 { - return false - } - return commandTextNeedsSystemdReady(argv[0], argv[1:]) -} - -func commandTextNeedsSystemdReady(cmd string, args []string) bool { - switch filepath.Base(cmd) { - case "systemctl", "journalctl", "loginctl", "busctl", "hostnamectl", "timedatectl", "localectl", "networkctl", "resolvectl": - return true - case "service": - return len(args) == 0 || args[0] != "--help" - case "sudo", "doas", "env", "command": - next, rest, ok := unwrapCommandPrefix(args) - if !ok { - return false - } - return commandTextNeedsSystemdReady(next, rest) - case "sh", "bash", "dash", "zsh": - for i, arg := range args { - if shellArgRunsCommand(arg) && i+1 < len(args) { - return shellScriptNeedsSystemdReady(args[i+1]) - } - } - } - return false -} - -func shellArgRunsCommand(arg string) bool { - if arg == "-c" { - return true - } - if !strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") { - return false - } - return strings.Contains(arg[1:], "c") -} - -func unwrapCommandPrefix(args []string) (string, []string, bool) { - for len(args) > 0 { - arg := args[0] - if arg == "--" { - args = args[1:] - continue - } - if strings.Contains(arg, "=") && !strings.HasPrefix(arg, "-") { - args = args[1:] - continue - } - if strings.HasPrefix(arg, "-") { - args = args[1:] - if arg == "-u" || arg == "-g" || arg == "-C" || arg == "-S" || arg == "--user" || arg == "--group" || arg == "--chdir" || arg == "--shell" { - if len(args) > 0 { - args = args[1:] - } - } - continue - } - return arg, args[1:], true - } - return "", nil, false -} - -func shellScriptNeedsSystemdReady(script string) bool { - fields := strings.FieldsFunc(script, func(r rune) bool { - switch r { - case ' ', '\t', '\n', '\r', ';', '&', '|', '(', ')', '<', '>', '`', '$', '"', '\'', '\\': - return true - default: - return false - } - }) - for _, field := range fields { - if commandTextNeedsSystemdReady(field, nil) { - return true - } - } - return false -} - -func waitForSystemdCommandReady(timeout time.Duration) error { - if ok, err := socketExists("/run/systemd/private"); err != nil { - return err - } else if !ok { - if err := waitForSystemdControlSocket(timeout); err != nil { - return err - } - } - if !systemdSystemBusExpected() { - return nil - } - deadline := time.Now().Add(timeout) - for { - ok, err := socketExists("/run/dbus/system_bus_socket") - if err != nil { - return err - } - if ok { - return nil - } - if time.Now().After(deadline) { - return fmt.Errorf("systemd system bus socket did not appear within %s", timeout) - } - time.Sleep(50 * time.Millisecond) - } -} - -func runSyncRequest(cfg config, control io.Writer, id string) { - proto := protocolForConfig(cfg) - proto.WriteBegin(control, id) - syscall.Sync() - proto.WriteExit(control, id, 0) -} - -func runFSArchiveRequest(cfg config, control io.Writer, id, rootDir, src, user string) { - proto := protocolForConfig(cfg) - proto.WriteBegin(control, id) - exitCode := 0 - if err := archivePathToControl(cfg, control, id, rootDir, src, user); err != nil { - exitCode = 1 - writeExecStderr(cfg, control, id, "ccx3-init: fs archive: "+err.Error()+"\n") - } - proto.WriteExit(control, id, exitCode) -} - -func archivePathToControl(cfg config, control io.Writer, id, rootDir, src, user string) error { - if strings.TrimSpace(src) == "" { - return fmt.Errorf("source path is required") - } - credential, err := guestagent.CredentialForUserInRoot(rootDir, user) - if err != nil { - return err - } - if credential != nil { - cmd := exec.Command("/proc/self/exe", archiveMode, rootDir, src) - cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential} - cmd.Stdout = execOutputWriter{write: func(data []byte) { writeExecStdoutBytes(cfg, control, id, data) }} - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := runArchiveHelperCommand(cmd); err != nil { - return archiveHelperError(err, stderr.String()) - } - return nil - } - src = rootPath(rootDir, filepath.Clean(src)) - info, err := os.Lstat(src) - if err != nil { - return err - } - pr, pw := io.Pipe() - go func() { - err := guestagent.WritePathTar(pw, src, filepath.Base(src), info) - _ = pw.CloseWithError(err) - }() - var buf [execProtocolChunkSize]byte - for { - n, err := pr.Read(buf[:]) - if n > 0 { - writeExecStdoutBytes(cfg, control, id, buf[:n]) - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -type execOutputWriter struct { - write func([]byte) -} - -func (w execOutputWriter) Write(data []byte) (int, error) { - if len(data) > 0 && w.write != nil { - w.write(data) - } - return len(data), nil -} - -func runArchiveHelper(args []string) error { - if len(args) != 2 { - return fmt.Errorf("archive helper requires root and source paths") - } - src := rootPath(args[0], filepath.Clean(args[1])) - info, err := os.Lstat(src) - if err != nil { - return err - } - return guestagent.WritePathTar(os.Stdout, src, filepath.Base(src), info) -} - -type extractHelperRequest struct { - RootDir string `json:"root_dir,omitempty"` - Path string `json:"path"` - IsDir bool `json:"is_dir,omitempty"` - Limits *client.ArchiveLimits `json:"limits,omitempty"` -} - -func runExtractHelper(args []string) error { - if len(args) != 1 { - return fmt.Errorf("extract helper requires one request") - } - var request extractHelperRequest - if err := json.Unmarshal([]byte(args[0]), &request); err != nil { - return fmt.Errorf("decode request: %w", err) - } - ctx, cancel, err := guestagent.ArchiveContext(context.Background(), request.Limits) - if err != nil { - return err - } - defer cancel() - return guestagent.ExtractTarToPathContext(ctx, os.Stdin, request.RootDir, request.Path, request.IsDir, request.Limits) -} - -func archiveHelperError(runErr error, stderr string) error { - stderr = strings.TrimSpace(stderr) - if stderr != "" { - return errors.New(stderr) - } - return runErr -} - -func runArchiveHelperCommand(cmd *exec.Cmd) error { - managedChildren.Lock() - err := cmd.Start() - if err == nil { - managedChildren.pids[cmd.Process.Pid] = struct{}{} - } - managedChildren.Unlock() - if err != nil { - return err - } - err = cmd.Wait() - managedChildren.Lock() - delete(managedChildren.pids, cmd.Process.Pid) - managedChildren.Unlock() - return err -} - -func runFSExtractRequest(cfg config, control io.Writer, id, rootDir, dst string, dstDir bool, user string, limits *client.ArchiveLimits, stdin io.ReadCloser, cleanup func()) { - defer cleanup() - defer stdin.Close() - proto := protocolForConfig(cfg) - proto.WriteBegin(control, id) - exitCode := 0 - ctx, cancel, err := guestagent.ArchiveContext(context.Background(), limits) - if err == nil { - defer cancel() - credential, credentialErr := guestagent.CredentialForUserInRoot(rootDir, user) - if credentialErr != nil { - err = credentialErr - } else if credential != nil { - credentialSetupMu.Lock() - userErr := guestagent.EnsureCredentialUser(rootDir, credential) - if userErr == nil { - userErr = guestagent.EnsureCredentialArchiveHome(rootDir, dst, credential) - } - credentialSetupMu.Unlock() - if userErr != nil { - err = userErr - } - } - if err == nil && credential != nil { - request, marshalErr := json.Marshal(extractHelperRequest{RootDir: rootDir, Path: dst, IsDir: dstDir, Limits: limits}) - if marshalErr != nil { - err = marshalErr - } else { - cmd := exec.Command("/proc/self/exe", extractMode, string(request)) - cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential} - cmd.Stdin = stdin - var stderr bytes.Buffer - cmd.Stderr = &stderr - if runErr := runArchiveHelperCommand(cmd); runErr != nil { - err = archiveHelperError(runErr, stderr.String()) - } - } - } - if err == nil && credential == nil { - err = guestagent.ExtractTarToPathContext(ctx, stdin, rootDir, dst, dstDir, limits) - } - } - if err != nil { - exitCode = 1 - writeExecStderr(cfg, control, id, "ccx3-init: fs extract: "+err.Error()+"\n") - } - proto.WriteExit(control, id, exitCode) -} - -func runFSMkdirRequest(cfg config, control io.Writer, id, rootDir, dir, user string) { - if strings.TrimSpace(dir) == "" { - dir = "." - } - proto := protocolForConfig(cfg) - exitCode := 0 - proto.WriteBegin(control, id) - target := rootPath(rootDir, filepath.Clean(dir)) - if err := os.MkdirAll(target, 0o755); err != nil { - exitCode = 1 - writeExecStderr(cfg, control, id, "ccx3-init: fs mkdir: "+err.Error()+"\n") - } else if err := guestagent.ChownPathForUser(rootDir, target, user); err != nil { - exitCode = 1 - writeExecStderr(cfg, control, id, "ccx3-init: fs mkdir: "+err.Error()+"\n") - } - proto.WriteExit(control, id, exitCode) -} - -func runFSWriteRequest(cfg config, control io.Writer, id, rootDir, dst, user string, stdin io.ReadCloser) { - defer stdin.Close() - proto := protocolForConfig(cfg) - exitCode := 0 - proto.WriteBegin(control, id) - if strings.TrimSpace(dst) == "" { - writeExecStderr(cfg, control, id, "ccx3-init: fs write: destination path is required\n") - exitCode = 1 - proto.WriteExit(control, id, exitCode) - return - } - target := rootPath(rootDir, filepath.Clean(dst)) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") - exitCode = 1 - } else if err := writeStreamToPath(stdin, target, 0o644); err != nil { - writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") - exitCode = 1 - } else if err := guestagent.ChownPathForUser(rootDir, target, user); err != nil { - writeExecStderr(cfg, control, id, "ccx3-init: fs write: "+err.Error()+"\n") - exitCode = 1 - } - proto.WriteExit(control, id, exitCode) -} - -func writeStreamToPath(r io.Reader, target string, mode os.FileMode) error { - file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) - if err != nil { - return err - } - _, copyErr := io.Copy(file, r) - closeErr := file.Close() - return errors.Join(copyErr, closeErr) -} - -func execPivotArgs(rootDir, workDir string, cred *syscall.Credential, argv []string) []string { - uid, gid, groups := execPivotCredentialArgs(cred) - args := []string{rootDir, workDir, uid, gid, groups, "--"} - return append(args, argv...) -} - -func execPivotCredentialArgs(cred *syscall.Credential) (uid, gid, groups string) { - if cred == nil { - return "", "", "" - } - uid = fmt.Sprint(cred.Uid) - gid = fmt.Sprint(cred.Gid) - if len(cred.Groups) == 0 { - return uid, gid, "" - } - parts := make([]string, 0, len(cred.Groups)) - for _, group := range cred.Groups { - parts = append(parts, fmt.Sprint(group)) - } - return uid, gid, strings.Join(parts, ",") -} - -func managedExecCommand(argv []string, env []string, rootDir string, workDir string, cred *syscall.Credential, tty bool) (*exec.Cmd, bool) { - useExecPivot := rootDir != "" || (tty && cred != nil) - var cmd *exec.Cmd - if useExecPivot { - cmd = exec.Command("/proc/self/exe", append([]string{execPivotMode}, execPivotArgs(rootDir, workDir, cred, argv)...)...) - } else { - executable, err := lookPathForExecEnv(argv[0], env) - if err != nil { - executable = argv[0] - } - cmd = exec.Command(executable, argv[1:]...) - cmd.Args[0] = argv[0] - if workDir != "" { - cmd.Dir = workDir - } - } - cmd.Env = env - cmd.WaitDelay = 2 * time.Second - return cmd, useExecPivot -} - -func configureManagedExecProcessAttrs(cmd *exec.Cmd, rootDir string, cred *syscall.Credential, useExecPivot bool) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - } - if rootDir != "" { - cmd.SysProcAttr.Cloneflags |= syscall.CLONE_NEWNS - return - } - if cred != nil && !useExecPivot { - cmd.SysProcAttr.Credential = cred - } -} - -func managedExecExitCode(waitErr error, state *os.ProcessState) (int, error) { - if waitErr != nil && errors.Is(waitErr, exec.ErrWaitDelay) { - waitErr = nil - } - if state != nil { - status, ok := state.Sys().(syscall.WaitStatus) - if !ok { - return 126, fmt.Errorf("process state did not include wait status") - } - if status.Signaled() { - return 128 + int(status.Signal()), nil - } - if status.Exited() { - return status.ExitStatus(), nil - } - return 126, fmt.Errorf("process did not exit normally") - } - if waitErr == nil { - return 0, nil - } - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - return exitErr.ExitCode(), nil - } - return 126, waitErr -} - -func startManagedExecProcess(cmd *exec.Cmd, controlW **os.File) error { - // Keep registration atomic with Start so the PID 1 orphan reaper cannot - // consume a short-lived managed command before os.Process.Wait observes it. - managedChildren.Lock() - err := cmd.Start() - if err == nil { - managedChildren.pids[cmd.Process.Pid] = struct{}{} - } - managedChildren.Unlock() - if controlW != nil && *controlW != nil { - _ = (*controlW).Close() - *controlW = nil - } - return err -} - -type managedExecWaitResult struct { - WaitErr error - ProcessState *os.ProcessState - Usage string - ExitCode int - ExitErr error -} - -func waitManagedExecProcess(cmd *exec.Cmd, execStart time.Time) managedExecWaitResult { - state, waitErr := cmd.Process.Wait() - managedChildren.Lock() - delete(managedChildren.pids, cmd.Process.Pid) - managedChildren.Unlock() - cmd.ProcessState = state - usage := guestagent.UsageFromProcessState(state, time.Since(execStart)) - exitCode, exitErr := managedExecExitCode(waitErr, state) - var usagePayload string - if usage != nil { - usagePayload = guestagent.EncodeExecUsage(usage) - } - return managedExecWaitResult{ - WaitErr: waitErr, - ProcessState: state, - Usage: usagePayload, - ExitCode: exitCode, - ExitErr: exitErr, - } -} - -func startOrphanReaper() { - go func() { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for range ticker.C { - reapOrphanedChildren(os.Getpid()) - } - }() -} - -// reapOrphanedChildren prevents descendants left behind by canceled command -// trees from accumulating as zombies under ccx3-init. Managed direct children -// remain owned by os.Process.Wait so their structured exit status is preserved. -func reapOrphanedChildren(parentPID int) { - managedChildren.Lock() - defer managedChildren.Unlock() - - entries, err := os.ReadDir("/proc") - if err != nil { - return - } - for _, entry := range entries { - pid64, err := strconv.ParseInt(entry.Name(), 10, 32) - if err != nil { - continue - } - pid := int(pid64) - if _, tracked := managedChildren.pids[pid]; tracked { - continue - } - state, ppid, ok := procProcessState(filepath.Join("/proc", entry.Name(), "stat")) - if !ok || state != 'Z' || ppid != parentPID { - continue - } - var status syscall.WaitStatus - _, _ = syscall.Wait4(pid, &status, syscall.WNOHANG, nil) - } -} - -func procProcessState(path string) (byte, int, bool) { - data, err := os.ReadFile(path) - if err != nil { - return 0, 0, false - } - // The comm field is parenthesized and may itself contain spaces. State and - // parent PID are the first two fields after its final closing parenthesis. - end := bytes.LastIndexByte(data, ')') - if end < 0 { - return 0, 0, false - } - fields := bytes.Fields(data[end+1:]) - if len(fields) < 2 || len(fields[0]) != 1 { - return 0, 0, false - } - ppid, err := strconv.Atoi(string(fields[1])) - if err != nil { - return 0, 0, false - } - return fields[0][0], ppid, true -} - -func managedExecStreamCount(tty bool, hasStdin bool, hasControl bool) int { - streams := 2 - if tty { - streams = 1 - if hasStdin { - streams++ - } - } - if hasControl { - streams++ - } - return streams -} - -func managedExecDoneStreamCount(tty bool, hasStdin bool, hasControl bool) int { - if !tty { - hasStdin = false - } - return managedExecStreamCount(tty, hasStdin, hasControl) -} - -func waitManagedExecStreams(done <-chan struct{}, count int) { - for i := 0; i < count; i++ { - <-done - } -} - -func attachManagedExecControlPipe(cmd *exec.Cmd, enabled bool) (*os.File, *os.File, error) { - if !enabled { - return nil, nil, nil - } - controlR, controlW, err := os.Pipe() - if err != nil { - return nil, nil, err - } - cmd.ExtraFiles = append(cmd.ExtraFiles, controlW) - return controlR, controlW, nil -} - -func copyManagedExecReader(done chan<- struct{}, r io.Reader, closeReader func() error, onFirst func(), emit func([]byte)) { - defer func() { done <- struct{}{} }() - if closeReader != nil { - defer closeReader() - } - var buf [execProtocolChunkSize]byte - first := true - for { - n, err := r.Read(buf[:]) - if n > 0 && emit != nil { - if first { - if onFirst != nil { - onFirst() - } - first = false - } - emit(buf[:n]) - } - if err != nil { - return - } - } -} - -func copyManagedExecStdinToPTY(done chan<- struct{}, stdin io.ReadCloser, pty io.Writer) { - defer func() { done <- struct{}{} }() - defer stdin.Close() - var buf [execProtocolChunkSize]byte - for { - n, err := stdin.Read(buf[:]) - if n > 0 { - if _, writeErr := pty.Write(buf[:n]); writeErr != nil { - return - } - } - if err != nil { - if err == io.EOF { - _, _ = pty.Write([]byte{4}) - } - return - } - } -} - -func copyManagedExecStdinToPipe(done chan<- struct{}, stdin io.ReadCloser, childStdin io.WriteCloser) { - defer func() { done <- struct{}{} }() - defer stdin.Close() - defer childStdin.Close() - _, _ = io.Copy(childStdin, stdin) -} - -func closeManagedExecStdinPipe(stdin io.ReadCloser, childStdin io.WriteCloser, done <-chan struct{}) { - if childStdin != nil { - _ = childStdin.Close() - } - if stdin != nil { - _ = stdin.Close() - } - if done != nil { - <-done - } -} - -func runManagedExec(cfg config, control io.Writer, id string, argv []string, env []string, rootDir string, workDir string, user string, stdin io.ReadCloser, managed *managedExec, tty bool, controlFD bool, cols int, rows int, waitReady func() error, cleanup func()) { - defer cleanup() - execStart := time.Now() - reporter := reporterForConfig(cfg, control, id, execStart) - reporter.Timing(managedExecTimingRecv) - reporter.Begin() - writeKernel("ccx3-init: exec " + strings.Join(argv, " ")) - if readyBegin, readyDone, ok := managedExecGuestReadyTimingPhases(waitReady); ok { - reporter.Timing(readyBegin) - if err := waitReady(); err != nil { - writeKernel("ccx3-init: guest not ready for exec: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: guest not ready for exec: "+err.Error()+"\n") - reporter.Exit(126) - return - } - reporter.Timing(readyDone) - } - reporter.Timing(managedExecTimingStartBegin) - - signalGroup := true - var rootMounts []string - if rootDir != "" { - preparedRoot, mounts, err := prepareExecRoot(rootDir) - if err != nil { - writeKernel("ccx3-init: prepare exec root: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: prepare exec root: "+err.Error()+"\n") - reporter.Exit(126) - return - } - rootDir = preparedRoot - rootMounts = mounts - defer teardownExecRoot(rootMounts) - } - execCred, err := guestagent.CredentialForUserInRoot(rootDir, user) - if err != nil { - writeKernel("ccx3-init: resolve user: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: resolve user: "+err.Error()+"\n") - reporter.Exit(126) - return - } - if execCred != nil { - credentialSetupMu.Lock() - err := guestagent.EnsureCredentialUser(rootDir, execCred) - credentialSetupMu.Unlock() - if err != nil { - writeKernel("ccx3-init: ensure user: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: ensure user: "+err.Error()+"\n") - reporter.Exit(126) - return - } - env = guestagent.EnvironmentForCredential(rootDir, env, execCred) - } - if err := guestagent.EnsureCredentialWorkDir(rootDir, workDir, execCred); err != nil { - writeKernel("ccx3-init: ensure workdir: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: ensure workdir: "+err.Error()+"\n") - reporter.Exit(126) - return - } - if err := validateManagedExecWorkDir(rootDir, workDir); err != nil { - writeKernel("ccx3-init: validate workdir: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: invalid workdir: "+err.Error()+"\n") - reporter.Exit(126) - return - } - cmd, useExecPivot := managedExecCommand(argv, env, rootDir, workDir, execCred, tty) - var ( - done chan struct{} - stdoutR io.ReadCloser - stderrR io.ReadCloser - stdinW io.WriteCloser - stdinDone chan struct{} - controlR *os.File - controlW *os.File - ptyMaster *os.File - ptySlave *os.File - startError error - ) - if controlFD { - controlR, controlW, startError = attachManagedExecControlPipe(cmd, true) - if startError != nil { - writeKernel("ccx3-init: open control fd: " + startError.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: open control fd: "+startError.Error()+"\n") - reporter.Exit(126) - return - } - defer func() { - if controlR != nil { - _ = controlR.Close() - } - }() - defer func() { - if controlW != nil { - _ = controlW.Close() - } - }() - } - - if tty { - ptyMaster, ptySlave, startError = openPTY(cols, rows) - if startError != nil { - writeKernel("ccx3-init: open pty: " + startError.Error()) - reporter.Exit(126) - return - } - defer func() { - if ptyMaster != nil { - _ = ptyMaster.Close() - } - }() - defer func() { - if ptySlave != nil { - _ = ptySlave.Close() - } - }() - managed.setPTY(ptyMaster) - cmd.Stdin = ptySlave - cmd.Stdout = ptySlave - cmd.Stderr = ptySlave - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, - Setctty: true, - Ctty: 0, - } - var ptyEmit func([]byte) - if cfg.OutputMarkerPref != "" { - ptyEmit = reporter.Stdout - } - done = make(chan struct{}, managedExecDoneStreamCount(true, stdin != nil, controlR != nil)) - go copyManagedExecReader(done, ptyMaster, nil, func() { reporter.Timing(managedExecFirstOutputTimingPhase(false)) }, ptyEmit) - if stdin != nil { - go copyManagedExecStdinToPTY(done, stdin, ptyMaster) - } - } else { - if stdin != nil { - if file, ok := stdin.(*os.File); ok { - cmd.Stdin = file - } else { - var err error - stdinW, err = cmd.StdinPipe() - if err != nil { - closeManagedExecStdinPipe(stdin, nil, nil) - writeKernel("ccx3-init: open stdin pipe: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: open stdin pipe: "+err.Error()+"\n") - reporter.Exit(126) - return - } - } - } else { - devNull, err := os.Open("/dev/null") - if err == nil { - defer devNull.Close() - cmd.Stdin = devNull - } - } - - var err error - stdoutR, err = cmd.StdoutPipe() - if err != nil { - closeManagedExecStdinPipe(stdin, stdinW, nil) - writeKernel("ccx3-init: open stdout pipe: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: open stdout pipe: "+err.Error()+"\n") - reporter.Exit(126) - return - } - stderrR, err = cmd.StderrPipe() - if err != nil { - _ = stdoutR.Close() - closeManagedExecStdinPipe(stdin, stdinW, nil) - writeKernel("ccx3-init: open stderr pipe: " + err.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: open stderr pipe: "+err.Error()+"\n") - reporter.Exit(126) - return - } - - var stdoutEmit func([]byte) - if cfg.OutputMarkerPref != "" { - stdoutEmit = reporter.Stdout - } - done = make(chan struct{}, managedExecDoneStreamCount(false, stdin != nil, controlR != nil)) - go copyManagedExecReader(done, stdoutR, stdoutR.Close, func() { reporter.Timing(managedExecFirstOutputTimingPhase(false)) }, stdoutEmit) - var stderrEmit func([]byte) - if cfg.ErrorMarkerPref != "" { - stderrEmit = func(data []byte) { writeExecStderr(cfg, control, id, string(data)) } - } - go copyManagedExecReader(done, stderrR, stderrR.Close, func() { reporter.Timing(managedExecFirstOutputTimingPhase(true)) }, stderrEmit) - } - if controlR != nil { - go copyManagedExecReader(done, controlR, nil, nil, reporter.ControlBytes) - } - configureManagedExecProcessAttrs(cmd, rootDir, execCred, useExecPivot) - preparedFamily, prepareFamilyErr := guestagent.PrepareProcessFamily(cmd, id) - if prepareFamilyErr != nil { - closeManagedExecStdinPipe(stdin, stdinW, stdinDone) - writeKernel("ccx3-init: prepare command containment: " + prepareFamilyErr.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: prepare command containment: "+prepareFamilyErr.Error()+"\n") - reporter.Exit(126) - return - } - - reporter.Timing(managedExecTimingStartCall) - startErr := startManagedExecProcess(cmd, &controlW) - if startErr != nil { - preparedFamily.Abort() - _ = managed.closeStdin() - closeManagedExecStdinPipe(stdin, stdinW, stdinDone) - if ptySlave != nil { - _ = ptySlave.Close() - ptySlave = nil - } - if ptyMaster != nil { - _ = ptyMaster.Close() - ptyMaster = nil - } - if stdoutR != nil { - _ = stdoutR.Close() - } - if stderrR != nil { - _ = stderrR.Close() - } - waitManagedExecStreams(done, cap(done)) - exitCode, message := managedExecStartFailure(startErr, argv, rootDir, workDir) - writeKernel("ccx3-init: exec error: " + startErr.Error()) - writeExecStderr(cfg, control, id, message) - reporter.Exit(exitCode) - return - } - reporter.Timing(managedExecTimingStarted) - if stdinW != nil { - stdinDone = make(chan struct{}, 1) - go copyManagedExecStdinToPipe(stdinDone, stdin, stdinW) - } - startedFamily := preparedFamily.Start(cmd.Process.Pid) - managed.setProcess(cmd.Process, signalGroup, startedFamily) - if ptySlave != nil { - _ = ptySlave.Close() - ptySlave = nil - } - - reporter.Timing(managedExecTimingWaitBegin) - waitResult := waitManagedExecProcess(cmd, execStart) - terminateManagedExecProcessGroup(cmd.Process.Pid) - managed.stdinMu.Lock() - family := managed.family - managed.stdinMu.Unlock() - if family != nil { - family.Terminate() - } - reporter.Timing(managedExecTimingWaitDone) - if tty { - _ = managed.closeStdin() - } - if !tty { - closeManagedExecStdinPipe(stdin, stdinW, stdinDone) - } - waitManagedExecStreams(done, cap(done)) - reporter.Timing(managedExecTimingStreamsDone) - - if waitResult.ExitErr != nil { - writeKernel("ccx3-init: exec error: " + waitResult.ExitErr.Error()) - writeExecStderr(cfg, control, id, "ccx3-init: exec error: "+waitResult.ExitErr.Error()+"\n") - } - if waitResult.Usage != "" { - reporter.Usage(waitResult.Usage) - } - if reporter.HasExitMarker() { - reporter.Timing(managedExecTimingExitSent) - reporter.Exit(waitResult.ExitCode) - } -} - -func terminateManagedExecProcessGroup(pgid int) { - if pgid <= 0 || !managedExecProcessGroupExists(pgid) { - return - } - _ = syscall.Kill(-pgid, syscall.SIGTERM) - if waitManagedExecProcessGroup(pgid, managedExecGroupTerminateGrace) { - return - } - _ = syscall.Kill(-pgid, syscall.SIGKILL) - _ = waitManagedExecProcessGroup(pgid, managedExecGroupKillWait) -} - -func waitManagedExecProcessGroup(pgid int, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for { - reapOrphanedChildren(os.Getpid()) - if !managedExecProcessGroupExists(pgid) { - return true - } - if time.Now().After(deadline) { - return false - } - time.Sleep(10 * time.Millisecond) - } -} - -func managedExecProcessGroupExists(pgid int) bool { - err := syscall.Kill(-pgid, 0) - return err == nil || errors.Is(err, syscall.EPERM) -} - -func validateManagedExecWorkDir(rootDir, workDir string) error { - if strings.TrimSpace(workDir) == "" { - return nil - } - target := rootPath(rootDir, filepath.Clean(workDir)) - info, err := os.Stat(target) - if err != nil { - return err - } - if !info.IsDir() { - return fmt.Errorf("%s is not a directory", workDir) - } - return nil -} - -func configureHostname(hostname string) error { - hostname = strings.TrimSpace(hostname) - if hostname == "" || hostname == "(none)" { - hostname = "ccx3" - } - if err := syscall.Sethostname([]byte(hostname)); err != nil { - return fmt.Errorf("set hostname %q: %w", hostname, err) - } - if err := os.MkdirAll("/etc", 0o755); err != nil { - return fmt.Errorf("mkdir /etc: %w", err) - } - _ = os.WriteFile("/etc/hostname", []byte(hostname+"\n"), 0o644) - hosts := "127.0.0.1\tlocalhost " + hostname + "\n::1\tlocalhost ip6-localhost ip6-loopback " + hostname + "\n" - _ = os.WriteFile("/etc/hosts", []byte(hosts), 0o644) - return nil -} - -func configurePackageManagers(rootDir string) error { - aptDir := rootPath(rootDir, "/etc/apt/apt.conf.d") - if !pathExists(rootPath(rootDir, "/usr/bin/apt")) && !pathExists(rootPath(rootDir, "/bin/apt")) { - return nil - } - if err := os.MkdirAll(aptDir, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", aptDir, err) - } - // Queue all HTTP work through one access-method worker so archive and - // security downloads cannot leave each other's connections idle. Do not - // disable apt's default HTTP pipelining: the old Pipeline-Depth=0 override - // made full Ubuntu updates slow enough to hit the archive's five-second - // keep-alive timeout, after which Ubuntu 24.04's apt worker crashed. - conf := strings.Join([]string{ - `Acquire::Queue-Mode "access";`, - `Acquire::Languages "none";`, - `Acquire::IndexTargets::deb::DEP-11::DefaultEnabled "false";`, - `Acquire::IndexTargets::deb::CNF::DefaultEnabled "false";`, - "", - }, "\n") - if err := os.WriteFile(filepath.Join(aptDir, "99ccvm-netstack"), []byte(conf), 0o644); err != nil { - return fmt.Errorf("write apt netstack config: %w", err) - } - return nil -} - -func pathExists(name string) bool { - _, err := os.Stat(name) - return err == nil -} - -func rootPath(rootDir, name string) string { - if rootDir == "" { - return name - } - return filepath.Join(rootDir, strings.TrimPrefix(name, "/")) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func prepareExecRoot(rootDir string) (string, []string, error) { - cleaned := strings.TrimSpace(rootDir) - if cleaned == "" { - return "", nil, nil - } - if !strings.HasPrefix(cleaned, "/") { - return "", nil, fmt.Errorf("root_dir must be absolute") - } - mounts := make([]string, 0, 6) - if err := syscall.Mount(cleaned, cleaned, "", syscall.MS_BIND, ""); err != nil { - return "", nil, fmt.Errorf("bind mount exec root %s: %w", cleaned, err) - } - mounts = append(mounts, cleaned) - for _, dir := range []string{"/proc", "/sys", "/dev", "/run", "/tmp"} { - target := cleaned + dir - if err := os.MkdirAll(target, 0o755); err != nil { - teardownExecRoot(mounts) - return "", nil, fmt.Errorf("mkdir %s: %w", target, err) - } - flags := uintptr(syscall.MS_BIND) - if dir == "/dev" { - flags |= syscall.MS_REC - } - if err := syscall.Mount(dir, target, "", flags, ""); err != nil { - teardownExecRoot(mounts) - return "", nil, fmt.Errorf("bind mount %s -> %s: %w", dir, target, err) - } - mounts = append(mounts, target) - } - if err := copyExecRootNetworkFiles(cleaned); err != nil { - teardownExecRoot(mounts) - return "", nil, err - } - return cleaned, mounts, nil -} - -func copyExecRootNetworkFiles(rootDir string) error { - for _, name := range []string{"resolv.conf", "hosts", "hostname"} { - src := filepath.Join("/etc", name) - data, err := os.ReadFile(src) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - return fmt.Errorf("read %s: %w", src, err) - } - dst := filepath.Join(rootDir, "etc", name) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", filepath.Dir(dst), err) - } - if err := writeFileReplacingMissingSymlink(dst, data, 0o644); err != nil { - return fmt.Errorf("write %s: %w", dst, err) - } - } - return nil -} - -func teardownExecRoot(mounts []string) { - for i := len(mounts) - 1; i >= 0; i-- { - _ = syscall.Unmount(mounts[i], 0) - } -} - -type sockaddrVM struct { - Family uint16 - Reserved uint16 - Port uint32 - CID uint32 - Zero [4]byte -} - -func connectVsock(port uint32) (*os.File, error) { - return connectVsockWithTimeout(port, defaultVsockConnectTimeout) -} - -func connectVsockWithTimeout(port uint32, timeout time.Duration) (*os.File, error) { - fd, err := syscall.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0) - if err != nil { - return nil, err - } - closeFD := true - defer func() { - if closeFD { - _ = syscall.Close(fd) - } - }() - if timeout > 0 { - if err := syscall.SetNonblock(fd, true); err != nil { - return nil, err - } - } - addr := sockaddrVM{ - Family: unix.AF_VSOCK, - Port: port, - CID: 2, - } - _, _, errno := syscall.Syscall(syscall.SYS_CONNECT, uintptr(fd), uintptr(unsafe.Pointer(&addr)), unsafe.Sizeof(addr)) - if errno == 0 { - if timeout > 0 { - if err := syscall.SetNonblock(fd, false); err != nil { - return nil, err - } - } - closeFD = false - return os.NewFile(uintptr(fd), fmt.Sprintf("vsock:%d", port)), nil - } - if timeout <= 0 || (errno != unix.EINPROGRESS && errno != unix.EALREADY && errno != unix.EWOULDBLOCK) { - return nil, errno - } - deadline := time.Now().Add(timeout) - for { - remaining := time.Until(deadline) - if remaining <= 0 { - return nil, fmt.Errorf("vsock:%d connect timed out after %s", port, timeout) - } - timeoutMS := int(remaining / time.Millisecond) - if timeoutMS < 1 { - timeoutMS = 1 - } - pollFD := []unix.PollFd{{ - Fd: int32(fd), - Events: unix.POLLOUT, - }} - n, err := unix.Poll(pollFD, timeoutMS) - if err != nil { - if errors.Is(err, unix.EINTR) { - continue - } - return nil, err - } - if n == 0 { - continue - } - socketErr, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR) - if err != nil { - return nil, err - } - if socketErr != 0 { - return nil, syscall.Errno(socketErr) - } - if err := syscall.SetNonblock(fd, false); err != nil { - return nil, err - } - closeFD = false - return os.NewFile(uintptr(fd), fmt.Sprintf("vsock:%d", port)), nil - } -} +func main() { guestinit.Run() } diff --git a/internal/freebsd/guestinit/payload_embed_amd64.go b/internal/freebsd/guestinit/payload_embed_amd64.go index a416cd10..ad8fbd65 100644 --- a/internal/freebsd/guestinit/payload_embed_amd64.go +++ b/internal/freebsd/guestinit/payload_embed_amd64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-freebsd-amd64 -var guestInitFreeBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["amd64"] = guestInitFreeBSD + embeddedPayloads["amd64"], _ = payloadFiles.ReadFile("payloads/guest-init-freebsd-amd64") } diff --git a/internal/freebsd/guestinit/payload_embed_arm64.go b/internal/freebsd/guestinit/payload_embed_arm64.go index 9394b1f3..1c7bc3a3 100644 --- a/internal/freebsd/guestinit/payload_embed_arm64.go +++ b/internal/freebsd/guestinit/payload_embed_arm64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-freebsd-arm64 -var guestInitFreeBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["arm64"] = guestInitFreeBSD + embeddedPayloads["arm64"], _ = payloadFiles.ReadFile("payloads/guest-init-freebsd-arm64") } diff --git a/internal/freebsd/guestinit/payload_embed_test.go b/internal/freebsd/guestinit/payload_embed_test.go index 49478815..cf7b745f 100644 --- a/internal/freebsd/guestinit/payload_embed_test.go +++ b/internal/freebsd/guestinit/payload_embed_test.go @@ -1,12 +1,19 @@ package guestinit import ( + "context" "runtime" "testing" ) func TestReleasePayloadIsEmbedded(t *testing.T) { payload := embeddedPayload(runtime.GOARCH) + if len(payload) == 0 { + if _, err := BuildForArch(context.Background(), "", runtime.GOARCH); err == nil { + t.Fatal("missing payload accepted") + } + return + } if len(payload) < 4 || string(payload[:4]) != "\x7fELF" { t.Fatalf("embedded FreeBSD/%s guest init is not an ELF payload", runtime.GOARCH) } diff --git a/internal/freebsd/guestinit/payloads/README.txt b/internal/freebsd/guestinit/payloads/README.txt new file mode 100644 index 00000000..227c4d35 --- /dev/null +++ b/internal/freebsd/guestinit/payloads/README.txt @@ -0,0 +1 @@ +Generated cc guest init payloads live here. Run cc's tools/build.sh or go run ./internal/cmd/build-guestinit before producing a cc runtime that uses default init payloads. SA2's tools/build-peer.sh generates only its custom Linux-amd64 override, not these files. Missing binaries fail closed; this placeholder permits source-only compilation. diff --git a/internal/guestinit/guestinit_test.go b/internal/guestinit/guestinit_test.go index d64b0079..7c27c9fa 100644 --- a/internal/guestinit/guestinit_test.go +++ b/internal/guestinit/guestinit_test.go @@ -8,6 +8,9 @@ import ( func TestBuildForArchReturnsEmbeddedPayloadCopy(t *testing.T) { payload := embeddedPayload("arm64") + if len(payload) == 0 { + t.Skip("source-only build: payload absent") + } got, err := BuildForArch(context.Background(), t.TempDir(), "arm64") if err != nil { t.Fatalf("load embedded guest init: %v", err) diff --git a/internal/guestinit/payload_embed.go b/internal/guestinit/payload_embed.go index 8738a9f1..beaf9493 100644 --- a/internal/guestinit/payload_embed.go +++ b/internal/guestinit/payload_embed.go @@ -1,14 +1,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-linux-arm64 -var guestInitLinuxARM64 []byte - -//go:embed guest-init-linux-amd64 -var guestInitLinuxAMD64 []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["arm64"] = guestInitLinuxARM64 - embeddedPayloads["amd64"] = guestInitLinuxAMD64 + embeddedPayloads["amd64"], _ = payloadFiles.ReadFile("payloads/guest-init-linux-amd64") + embeddedPayloads["arm64"], _ = payloadFiles.ReadFile("payloads/guest-init-linux-arm64") } diff --git a/internal/guestinit/payload_embed_test.go b/internal/guestinit/payload_embed_test.go index 917bcac9..e1a4b8df 100644 --- a/internal/guestinit/payload_embed_test.go +++ b/internal/guestinit/payload_embed_test.go @@ -1,9 +1,18 @@ package guestinit -import "testing" +import ( + "context" + "testing" +) func TestReleasePayloadsAreEmbedded(t *testing.T) { for _, arch := range []string{"arm64", "amd64"} { + if len(embeddedPayload(arch)) == 0 { + if _, err := BuildForArch(context.Background(), "", arch); err == nil { + t.Fatal("missing payload accepted") + } + continue + } if err := validateGuestInitPayload(arch, embeddedPayload(arch)); err != nil { t.Errorf("%s: %v", arch, err) } diff --git a/internal/guestinit/payloads/README.txt b/internal/guestinit/payloads/README.txt new file mode 100644 index 00000000..227c4d35 --- /dev/null +++ b/internal/guestinit/payloads/README.txt @@ -0,0 +1 @@ +Generated cc guest init payloads live here. Run cc's tools/build.sh or go run ./internal/cmd/build-guestinit before producing a cc runtime that uses default init payloads. SA2's tools/build-peer.sh generates only its custom Linux-amd64 override, not these files. Missing binaries fail closed; this placeholder permits source-only compilation. diff --git a/internal/managed/agent/client.go b/internal/managed/agent/client.go index ec3c25e2..1c922de2 100644 --- a/internal/managed/agent/client.go +++ b/internal/managed/agent/client.go @@ -14,23 +14,25 @@ type SendFunc func(protocol.ManagedExecRequest) error func ExecRequest(id string, req client.ExecRequest) protocol.ManagedExecRequest { return protocol.ManagedExecRequest{ - Kind: RequestKind(req.Kind), - ID: id, - Command: append([]string(nil), req.Command...), - Env: append([]string(nil), req.Env...), - RootDir: req.RootDir, - Path: req.Path, - Directory: req.Directory, - ReplaceEnv: req.ReplaceEnv, - SkipResolve: req.SkipResolve, - WorkDir: req.WorkDir, - User: req.User, - Stdin: append([]byte(nil), req.Stdin...), - TTY: req.TTY, - ControlFD: req.ControlFD, - Cols: req.Cols, - Rows: req.Rows, - ArchiveLimits: req.ArchiveLimits, + Kind: RequestKind(req.Kind), + ProcessMode: req.ProcessMode, + SandboxProtocol: req.SandboxProtocol, + ID: id, + Command: append([]string(nil), req.Command...), + Env: append([]string(nil), req.Env...), + RootDir: req.RootDir, + Path: req.Path, + Directory: req.Directory, + ReplaceEnv: req.ReplaceEnv, + SkipResolve: req.SkipResolve, + WorkDir: req.WorkDir, + User: req.User, + Stdin: append([]byte(nil), req.Stdin...), + TTY: req.TTY, + ControlFD: req.ControlFD, + Cols: req.Cols, + Rows: req.Rows, + ArchiveLimits: req.ArchiveLimits, } } diff --git a/internal/managed/agent/client_test.go b/internal/managed/agent/client_test.go index d9e7d080..05f984ef 100644 --- a/internal/managed/agent/client_test.go +++ b/internal/managed/agent/client_test.go @@ -15,20 +15,22 @@ import ( func TestExecRequestCopiesFields(t *testing.T) { req := ExecRequest("7", client.ExecRequest{ - Command: []string{"/bin/sh", "-c", "echo hi"}, - Env: []string{"A=B"}, - RootDir: "/mnt", - Path: "/tmp/file", - Directory: true, - ReplaceEnv: true, - SkipResolve: true, - WorkDir: "/work", - User: "1000:1000", - Stdin: []byte("input"), - TTY: true, - ControlFD: true, - Cols: 80, - Rows: 24, + ProcessMode: "single", + SandboxProtocol: 1, + Command: []string{"/bin/sh", "-c", "echo hi"}, + Env: []string{"A=B"}, + RootDir: "/mnt", + Path: "/tmp/file", + Directory: true, + ReplaceEnv: true, + SkipResolve: true, + WorkDir: "/work", + User: "1000:1000", + Stdin: []byte("input"), + TTY: true, + ControlFD: true, + Cols: 80, + Rows: 24, }) if req.Kind != "exec" || req.ID != "7" || req.Command[0] != "/bin/sh" || req.Env[0] != "A=B" { t.Fatalf("unexpected request: %+v", req) @@ -36,6 +38,9 @@ func TestExecRequestCopiesFields(t *testing.T) { if !req.Directory || !req.TTY || !req.ControlFD || req.Cols != 80 || req.Rows != 24 { t.Fatalf("missing flags: %+v", req) } + if req.ProcessMode != "single" || req.SandboxProtocol != 1 { + t.Fatalf("missing sandbox protocol fields: %+v", req) + } if !req.ReplaceEnv || !req.SkipResolve { t.Fatalf("missing resolver flags: %+v", req) } diff --git a/internal/managed/protocol/request.go b/internal/managed/protocol/request.go index 90c55197..8994ba03 100644 --- a/internal/managed/protocol/request.go +++ b/internal/managed/protocol/request.go @@ -15,23 +15,25 @@ const ( ) type ManagedExecRequest struct { - ID string `json:"id"` - Command []string `json:"command"` - Env []string `json:"env,omitempty"` - RootDir string `json:"root_dir,omitempty"` - Path string `json:"path,omitempty"` - Directory bool `json:"directory,omitempty"` - ReplaceEnv bool `json:"replace_env,omitempty"` - SkipResolve bool `json:"skip_resolve,omitempty"` - WorkDir string `json:"workdir,omitempty"` - User string `json:"user,omitempty"` - Stdin []byte `json:"stdin,omitempty"` - TTY bool `json:"tty,omitempty"` - ControlFD bool `json:"control_fd,omitempty"` - Kind string `json:"kind,omitempty"` - Signal string `json:"signal,omitempty"` - ControlID string `json:"control_id,omitempty"` - Cols int `json:"cols,omitempty"` - Rows int `json:"rows,omitempty"` - ArchiveLimits *client.ArchiveLimits `json:"archive_limits,omitempty"` + ProcessMode string `json:"process_mode,omitempty"` + SandboxProtocol int `json:"sandbox_protocol,omitempty"` + ID string `json:"id"` + Command []string `json:"command"` + Env []string `json:"env,omitempty"` + RootDir string `json:"root_dir,omitempty"` + Path string `json:"path,omitempty"` + Directory bool `json:"directory,omitempty"` + ReplaceEnv bool `json:"replace_env,omitempty"` + SkipResolve bool `json:"skip_resolve,omitempty"` + WorkDir string `json:"workdir,omitempty"` + User string `json:"user,omitempty"` + Stdin []byte `json:"stdin,omitempty"` + TTY bool `json:"tty,omitempty"` + ControlFD bool `json:"control_fd,omitempty"` + Kind string `json:"kind,omitempty"` + Signal string `json:"signal,omitempty"` + ControlID string `json:"control_id,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + ArchiveLimits *client.ArchiveLimits `json:"archive_limits,omitempty"` } diff --git a/internal/netbsd/guestinit/payload_embed_amd64.go b/internal/netbsd/guestinit/payload_embed_amd64.go index 053ea34b..aa5ec9ae 100644 --- a/internal/netbsd/guestinit/payload_embed_amd64.go +++ b/internal/netbsd/guestinit/payload_embed_amd64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-netbsd-amd64 -var guestInitNetBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["amd64"] = guestInitNetBSD + embeddedPayloads["amd64"], _ = payloadFiles.ReadFile("payloads/guest-init-netbsd-amd64") } diff --git a/internal/netbsd/guestinit/payload_embed_arm64.go b/internal/netbsd/guestinit/payload_embed_arm64.go index e8d0e03a..210472b7 100644 --- a/internal/netbsd/guestinit/payload_embed_arm64.go +++ b/internal/netbsd/guestinit/payload_embed_arm64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-netbsd-arm64 -var guestInitNetBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["arm64"] = guestInitNetBSD + embeddedPayloads["arm64"], _ = payloadFiles.ReadFile("payloads/guest-init-netbsd-arm64") } diff --git a/internal/netbsd/guestinit/payload_embed_test.go b/internal/netbsd/guestinit/payload_embed_test.go index 670e102c..a078475d 100644 --- a/internal/netbsd/guestinit/payload_embed_test.go +++ b/internal/netbsd/guestinit/payload_embed_test.go @@ -1,12 +1,19 @@ package guestinit import ( + "context" "runtime" "testing" ) func TestReleasePayloadIsEmbedded(t *testing.T) { payload := embeddedPayload(runtime.GOARCH) + if len(payload) == 0 { + if _, err := BuildForArch(context.Background(), "", runtime.GOARCH); err == nil { + t.Fatal("missing payload accepted") + } + return + } if len(payload) < 4 || string(payload[:4]) != "\x7fELF" { t.Fatalf("embedded NetBSD/%s guest init is not an ELF payload", runtime.GOARCH) } diff --git a/internal/netbsd/guestinit/payloads/README.txt b/internal/netbsd/guestinit/payloads/README.txt new file mode 100644 index 00000000..227c4d35 --- /dev/null +++ b/internal/netbsd/guestinit/payloads/README.txt @@ -0,0 +1 @@ +Generated cc guest init payloads live here. Run cc's tools/build.sh or go run ./internal/cmd/build-guestinit before producing a cc runtime that uses default init payloads. SA2's tools/build-peer.sh generates only its custom Linux-amd64 override, not these files. Missing binaries fail closed; this placeholder permits source-only compilation. diff --git a/internal/oci/image_ref_test.go b/internal/oci/image_ref_test.go new file mode 100644 index 00000000..6113f13b --- /dev/null +++ b/internal/oci/image_ref_test.go @@ -0,0 +1,95 @@ +package oci + +import ( + "crypto/sha256" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "j5.nz/cc/internal/download" +) + +const acceptanceAlpineDigest = "sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8" + +func TestParseImageRefDigestPrecedesTag(t *testing.T) { + for _, tc := range []struct{ reference, registry, image, selector string }{ + {"docker.io/library/alpine@" + acceptanceAlpineDigest, defaultRegistry, "library/alpine", acceptanceAlpineDigest}, + {"docker.io/library/alpine:3.22@" + acceptanceAlpineDigest, defaultRegistry, "library/alpine", acceptanceAlpineDigest}, + {"alpine@" + acceptanceAlpineDigest, defaultRegistry, "library/alpine", acceptanceAlpineDigest}, + {"alpine:3.22@" + acceptanceAlpineDigest, defaultRegistry, "library/alpine", acceptanceAlpineDigest}, + {"localhost:5000/team/alpine:3.22@" + acceptanceAlpineDigest, "https://localhost:5000/v2", "team/alpine", acceptanceAlpineDigest}, + {"localhost:5000/team/alpine@" + acceptanceAlpineDigest, "https://localhost:5000/v2", "team/alpine", acceptanceAlpineDigest}, + {"localhost:5000/team/alpine:3.22", "https://localhost:5000/v2", "team/alpine", "3.22"}, + {"alpine:3.22", defaultRegistry, "library/alpine", "3.22"}, + {"alpine", defaultRegistry, "library/alpine", "latest"}, + } { + t.Run(tc.reference, func(t *testing.T) { + registry, image, selector, err := ParseImageRef(tc.reference) + if err != nil { + t.Fatal(err) + } + if registry != tc.registry || image != tc.image || selector != tc.selector { + t.Fatalf("parsed (%q,%q,%q), want (%q,%q,%q)", registry, image, selector, tc.registry, tc.image, tc.selector) + } + }) + } + for _, reference := range []string{"alpine@", "@" + acceptanceAlpineDigest, "alpine@" + acceptanceAlpineDigest + "@other"} { + if _, _, _, err := ParseImageRef(reference); err == nil { + t.Fatalf("accepted malformed digest reference %q", reference) + } + } +} + +func TestParsedImageRefReachesManifestDigestVerification(t *testing.T) { + body := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`) + validDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(body)) + for _, tc := range []struct { + reference, pin string + substituted bool + }{ + {"docker.io/library/alpine@" + acceptanceAlpineDigest, acceptanceAlpineDigest, true}, + {"docker.io/library/alpine:3.22@" + acceptanceAlpineDigest, acceptanceAlpineDigest, true}, + {"docker.io/library/alpine@" + validDigest, validDigest, false}, + {"docker.io/library/alpine:3.22@" + validDigest, validDigest, false}, + } { + t.Run(tc.reference, func(t *testing.T) { + _, image, selector, err := ParseImageRef(tc.reference) + if err != nil { + t.Fatal(err) + } + requested := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested <- r.URL.Path + // No Docker-Content-Digest header: verification must use the parsed pin. + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + _, _ = w.Write(body) + })) + defer server.Close() + store := NewStore(t.TempDir()) + reg := ®istryContext{registry: server.URL + "/v2", client: server.Client()} + _, digest, err := store.fetchManifest(t.Context(), reg, image, selector, "amd64") + gotPath := <-requested + wantPath := "/v2/library/alpine/manifests/" + tc.pin + if gotPath != wantPath { + t.Fatalf("requested %q, want %q", gotPath, wantPath) + } + if strings.Contains(image, "@") || strings.Contains(image, ":") { + t.Fatalf("selector leaked into repository: %q", image) + } + if tc.substituted { + var mismatch *download.DigestError + if !errors.As(err, &mismatch) { + t.Fatalf("substitution error = %v, want digest mismatch", err) + } + if mismatch.Expected != tc.pin || mismatch.Actual != validDigest { + t.Fatalf("wrong digest verification: %+v", mismatch) + } + } else if err != nil || digest != validDigest { + t.Fatalf("valid pinned manifest: digest=%q err=%v", digest, err) + } + }) + } +} diff --git a/internal/oci/manifest_identity.go b/internal/oci/manifest_identity.go new file mode 100644 index 00000000..56721ead --- /dev/null +++ b/internal/oci/manifest_identity.go @@ -0,0 +1,21 @@ +package oci + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "j5.nz/cc/internal/download" + "strings" +) + +func verifyManifestSHA256(body []byte, expected string) error { + if !strings.HasPrefix(expected, "sha256:") || len(expected) != 71 { + return fmt.Errorf("unsupported manifest digest %q", expected) + } + sum := sha256.Sum256(body) + actual := "sha256:" + hex.EncodeToString(sum[:]) + if !strings.EqualFold(expected, actual) { + return &download.DigestError{Expected: expected, Actual: actual} + } + return nil +} diff --git a/internal/oci/manifest_identity_test.go b/internal/oci/manifest_identity_test.go new file mode 100644 index 00000000..63739e15 --- /dev/null +++ b/internal/oci/manifest_identity_test.go @@ -0,0 +1,46 @@ +package oci + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPinnedManifestRejectsSubstitutionWithoutDigestHeader(t *testing.T) { + body := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Write(body) + })) + defer server.Close() + store := NewStore(t.TempDir()) + reg := ®istryContext{registry: server.URL, client: server.Client()} + if _, _, err := store.fetchManifest(t.Context(), reg, "image", "sha256:"+strings.Repeat("a", 64), "amd64"); err == nil { + t.Fatal("accepted substituted manifest") + } + pin := fmt.Sprintf("sha256:%x", sha256.Sum256(body)) + if _, digest, err := store.fetchManifest(t.Context(), reg, "image", pin, "amd64"); err != nil || digest != pin { + t.Fatalf("valid pin: %s %v", digest, err) + } +} +func TestManifestIndexRejectsSubstitutedPlatformManifest(t *testing.T) { + index := fmt.Sprintf(`{"schemaVersion":2,"manifests":[{"digest":"sha256:%s","platform":{"os":"linux","architecture":"amd64"}}]}`, strings.Repeat("a", 64)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/tag") { + w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json") + fmt.Fprint(w, index) + return + } + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + fmt.Fprint(w, `{"schemaVersion":2,"layers":[]}`) + })) + defer server.Close() + store := NewStore(t.TempDir()) + reg := ®istryContext{registry: server.URL, client: server.Client()} + if _, _, err := store.fetchManifest(t.Context(), reg, "image", "tag", "amd64"); err == nil { + t.Fatal("accepted substituted platform manifest") + } +} diff --git a/internal/oci/oci.go b/internal/oci/oci.go index 09ccbd16..b53a2789 100644 --- a/internal/oci/oci.go +++ b/internal/oci/oci.go @@ -1602,6 +1602,13 @@ func (s *Store) fetchManifest(ctx context.Context, reg *registryContext, imageNa return manifest{}, "", err } + // A registry response header is not proof that the body matches a pinned + // request. Verify the requested digest even when that header is absent. + if strings.HasPrefix(tag, "sha256:") { + if err := verifyManifestSHA256(body, tag); err != nil { + return manifest{}, "", err + } + } if isManifestMediaType(mediaType) { var mani manifest if err := json.Unmarshal(body, &mani); err != nil { @@ -1625,6 +1632,9 @@ func (s *Store) fetchManifest(ctx context.Context, reg *registryContext, imageNa if err != nil { return manifest{}, "", err } + if err := verifyManifestSHA256(body, entry.Digest); err != nil { + return manifest{}, "", err + } var mani manifest if err := json.Unmarshal(body, &mani); err != nil { return manifest{}, "", fmt.Errorf("decode manifest: %w", err) @@ -2761,14 +2771,25 @@ func ParseImageRef(imageRef string) (registry string, image string, tag string, if strings.TrimSpace(imageRef) == "" { return "", "", "", fmt.Errorf("image source is required") } - image = imageRef + // Split the digest before looking for a tag: the digest's algorithm colon + // is not a tag separator. With name:tag@digest, the digest is authoritative + // and the optional tag must not remain part of the repository name. + image, digest, pinned := strings.Cut(imageRef, "@") tag = "latest" + if pinned { + if image == "" || digest == "" || strings.Contains(digest, "@") { + return "", "", "", fmt.Errorf("invalid image digest reference %q", imageRef) + } + tag = digest + } - lastSlash := strings.LastIndex(imageRef, "/") - lastColon := strings.LastIndex(imageRef, ":") + lastSlash := strings.LastIndex(image, "/") + lastColon := strings.LastIndex(image, ":") if lastColon > lastSlash { - image = imageRef[:lastColon] - tag = imageRef[lastColon+1:] + if !pinned { + tag = image[lastColon+1:] + } + image = image[:lastColon] } firstSlash := strings.Index(image, "/") diff --git a/internal/openbsd/guestinit/payload_embed_amd64.go b/internal/openbsd/guestinit/payload_embed_amd64.go index e9ba37e2..e48b5b12 100644 --- a/internal/openbsd/guestinit/payload_embed_amd64.go +++ b/internal/openbsd/guestinit/payload_embed_amd64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-openbsd-amd64 -var guestInitOpenBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["amd64"] = guestInitOpenBSD + embeddedPayloads["amd64"], _ = payloadFiles.ReadFile("payloads/guest-init-openbsd-amd64") } diff --git a/internal/openbsd/guestinit/payload_embed_arm64.go b/internal/openbsd/guestinit/payload_embed_arm64.go index 988c2008..bab0b04b 100644 --- a/internal/openbsd/guestinit/payload_embed_arm64.go +++ b/internal/openbsd/guestinit/payload_embed_arm64.go @@ -2,11 +2,11 @@ package guestinit -import _ "embed" +import "embed" -//go:embed guest-init-openbsd-arm64 -var guestInitOpenBSD []byte +//go:embed payloads +var payloadFiles embed.FS func init() { - embeddedPayloads["arm64"] = guestInitOpenBSD + embeddedPayloads["arm64"], _ = payloadFiles.ReadFile("payloads/guest-init-openbsd-arm64") } diff --git a/internal/openbsd/guestinit/payload_embed_test.go b/internal/openbsd/guestinit/payload_embed_test.go index ad7420ce..ab45d4e2 100644 --- a/internal/openbsd/guestinit/payload_embed_test.go +++ b/internal/openbsd/guestinit/payload_embed_test.go @@ -1,12 +1,19 @@ package guestinit import ( + "context" "runtime" "testing" ) func TestReleasePayloadIsEmbedded(t *testing.T) { payload := embeddedPayload(runtime.GOARCH) + if len(payload) == 0 { + if _, err := BuildForArch(context.Background(), "", runtime.GOARCH); err == nil { + t.Fatal("missing payload accepted") + } + return + } if len(payload) < 4 || string(payload[:4]) != "\x7fELF" { t.Fatalf("embedded OpenBSD/%s guest init is not an ELF payload", runtime.GOARCH) } diff --git a/internal/openbsd/guestinit/payloads/README.txt b/internal/openbsd/guestinit/payloads/README.txt new file mode 100644 index 00000000..227c4d35 --- /dev/null +++ b/internal/openbsd/guestinit/payloads/README.txt @@ -0,0 +1 @@ +Generated cc guest init payloads live here. Run cc's tools/build.sh or go run ./internal/cmd/build-guestinit before producing a cc runtime that uses default init payloads. SA2's tools/build-peer.sh generates only its custom Linux-amd64 override, not these files. Missing binaries fail closed; this placeholder permits source-only compilation. diff --git a/internal/virtio/fs.go b/internal/virtio/fs.go index 395e1fae..d979ca54 100644 --- a/internal/virtio/fs.go +++ b/internal/virtio/fs.go @@ -1551,7 +1551,20 @@ type fsReply struct { ok bool } +// fsErrnoIs accepts either backend convention before FUSE wire normalization. +// code is a positive Linux errno. +func fsErrnoIs(errno, code int32) bool { return errno == code || errno == -code } + func fuseReply(unique uint64, errno int32, extra []byte) fsReply { + // FUSE's out_header.error is zero or a negative Linux errno. Direct/RPC + // backends may report positive errno values; never let those become positive + // VFS return values (e.g. an apparently successful atomic_open with no inode). + if errno > 0 { + errno = -errno + } + if errno != 0 { + extra = nil + } return fsReply{unique: unique, errno: errno, extra: extra, ok: true} } @@ -1613,7 +1626,7 @@ func (s *fuseServer) readDirPlus(nodeID uint64, fh uint64, off uint64, maxBytes childID := binary.LittleEndian.Uint64(entries[cursor : cursor+8]) attr, attrErrno := s.backend.GetAttr(childID) cursor += direntBytes - if attrErrno == -linuxENOENT { + if fsErrnoIs(attrErrno, linuxENOENT) { continue } if attrErrno != 0 { diff --git a/internal/virtio/fs_protocol_test.go b/internal/virtio/fs_protocol_test.go index b72dda23..10a80585 100644 --- a/internal/virtio/fs_protocol_test.go +++ b/internal/virtio/fs_protocol_test.go @@ -2,6 +2,7 @@ package virtio import ( "encoding/binary" + "reflect" "testing" ) @@ -205,3 +206,151 @@ func BenchmarkDecodeFUSERequest(b *testing.B) { } } } + +// RPC backends conventionally return positive errno. The wire must never expose +// a positive out_header.error: VFS can interpret it as success without an inode. +type positiveErrnoFSBackend struct{ inertFSBackend } + +func (positiveErrnoFSBackend) Lookup(uint64, string) (uint64, FuseAttr, int32) { + return 0, FuseAttr{}, linuxENOENT +} +func (positiveErrnoFSBackend) Create(uint64, string, uint32, uint32, uint32, uint32) (uint64, uint64, FuseAttr, int32) { + return 0, 0, FuseAttr{}, linuxEACCES +} + +func TestFUSEDispatcherNormalizesPositiveBackendErrno(t *testing.T) { + device := NewFS(0, 0, 0, "errno", positiveErrnoFSBackend{}) + for _, tc := range []struct { + opcode uint32 + size int + want int32 + }{ + {fuseLookup, 0, -linuxENOENT}, {fuseCreate, 16, -linuxEACCES}, + } { + raw := make([]byte, fuseInHeaderSize+tc.size+5) + binary.LittleEndian.PutUint32(raw[0:4], uint32(len(raw))) + binary.LittleEndian.PutUint32(raw[4:8], tc.opcode) + binary.LittleEndian.PutUint64(raw[8:16], 77) + binary.LittleEndian.PutUint64(raw[16:24], 1) + copy(raw[fuseInHeaderSize+tc.size:], "live") + result, err := device.dispatcher.Dispatch(raw) + if err != nil { + t.Fatal(err) + } + wire := result.reply.Bytes() + if got := int32(binary.LittleEndian.Uint32(wire[4:8])); got != tc.want { + t.Fatalf("opcode %d error=%d, want %d", tc.opcode, got, tc.want) + } + if len(wire) != fuseOutHeaderSize { + t.Fatalf("error reply included success payload: %x", wire) + } + } + for _, errno := range []int32{linuxEACCES, -linuxEACCES} { + wire := fuseReply(78, errno, []byte("must not appear in error reply")).Bytes() + if len(wire) != fuseOutHeaderSize || int32(binary.LittleEndian.Uint32(wire[4:8])) != -linuxEACCES { + t.Fatalf("malformed error reply: %x", wire) + } + } +} + +type disappearingReadDirFSBackend struct { + inertFSBackend + missingErrno int32 + calls []uint64 +} + +func disappearingReadDirEntry(node, cookie uint64, name string) []byte { + entry := make([]byte, align8(fuseDirentBaseSize+len(name))) + binary.LittleEndian.PutUint64(entry[0:8], node) + binary.LittleEndian.PutUint64(entry[8:16], cookie) + binary.LittleEndian.PutUint32(entry[16:20], uint32(len(name))) + binary.LittleEndian.PutUint32(entry[20:24], 8) + copy(entry[24:], name) + return entry +} + +func (b *disappearingReadDirFSBackend) ReadDir(_, _ uint64, off uint64, _ uint32) ([]byte, int32) { + b.calls = append(b.calls, off) + switch off { + case 0: + return disappearingReadDirEntry(2, 7, "gone-page"), 0 + case 7: + page := disappearingReadDirEntry(3, 19, "gone-entry") + return append(page, disappearingReadDirEntry(4, 42, "alive-a")...), 0 + case 42: + return disappearingReadDirEntry(5, 61, "alive-b"), 0 + case 61: + return nil, 0 + default: + return nil, -linuxEINVAL + } +} +func (b *disappearingReadDirFSBackend) GetAttr(node uint64) (FuseAttr, int32) { + if node == 2 || node == 3 { + return FuseAttr{}, b.missingErrno + } + return FuseAttr{Ino: node, Mode: 0100644, NLink: 1}, 0 +} + +func TestReadDirPlusSkipsVanishedEntriesWithEitherErrnoSign(t *testing.T) { + for _, tc := range []struct { + name string + errno int32 + }{{"positive", linuxENOENT}, {"negative", -linuxENOENT}} { + t.Run(tc.name, func(t *testing.T) { + backend := &disappearingReadDirFSBackend{missingErrno: tc.errno} + device := NewFS(0, 0, 0, "vanishing", backend) + off := uint64(0) + for i, name := range []string{"alive-a", "alive-b", ""} { + req := make([]byte, fuseInHeaderSize+24) + binary.LittleEndian.PutUint32(req[0:4], uint32(len(req))) + binary.LittleEndian.PutUint32(req[4:8], fuseReadDirPlus) + binary.LittleEndian.PutUint64(req[8:16], uint64(i+1)) + binary.LittleEndian.PutUint64(req[16:24], 1) + binary.LittleEndian.PutUint64(req[40:48], 9) + binary.LittleEndian.PutUint64(req[48:56], off) + binary.LittleEndian.PutUint32(req[56:60], 4096) + reply, err := dispatchFUSEForTest(device, req) + if err != nil || reply.errno != 0 { + t.Fatalf("page %d: error=%v errno=%d", i, err, reply.errno) + } + if name == "" { + if len(reply.extra) != 0 { + t.Fatal("expected real EOF") + } + continue + } + if len(reply.extra) < fuseDirentPlusBaseSize { + t.Fatalf("false EOF after vanished entry at cookie %d", off) + } + data := reply.extra[fuseEntryOutSize:] + n := int(binary.LittleEndian.Uint32(data[16:20])) + if len(data) < 24+n || string(data[24:24+n]) != name { + t.Fatalf("wrong surviving entry: %x", data) + } + if len(reply.extra) != align8(fuseDirentPlusBaseSize+n) { + t.Fatal("vanished entry leaked into result") + } + node := uint64(i + 4) + if binary.LittleEndian.Uint64(reply.extra[0:8]) != node || binary.LittleEndian.Uint64(reply.extra[40:48]) != node || binary.LittleEndian.Uint64(data[0:8]) != node { + t.Fatal("surviving node identity changed") + } + off = binary.LittleEndian.Uint64(data[8:16]) + } + if !reflect.DeepEqual(backend.calls, []uint64{0, 7, 42, 61}) { + t.Fatalf("directory cookies skipped or reset: %v", backend.calls) + } + }) + } +} + +func TestReadDirPlusDoesNotHideOtherBackendErrors(t *testing.T) { + for _, errno := range []int32{linuxEIO, -linuxEIO} { + backend := &disappearingReadDirFSBackend{missingErrno: errno} + device := NewFS(0, 0, 0, "io-error", backend) + data, got := device.dispatcher.(*fuseServer).readDirPlus(1, 9, 0, 4096) + if got != errno || len(data) != 0 { + t.Fatalf("non-ENOENT error became EOF/success: errno=%d data=%x", got, data) + } + } +} diff --git a/internal/virtio/mountfs.go b/internal/virtio/mountfs.go index d280ceb1..d8bee85b 100644 --- a/internal/virtio/mountfs.go +++ b/internal/virtio/mountfs.go @@ -678,10 +678,10 @@ func (m *mountedFS) restoreNodePath(nodePath string) error { childID, _, errno := m.Lookup(parentID, name) if errno != 0 { childID, _, errno = m.Mkdir(parentID, name, 0o755, 0, 0) - if errno != 0 && errno != -linuxabi.EEXIST { + if errno != 0 && !fsErrnoIs(errno, linuxabi.EEXIST) { return fmt.Errorf("restore mountedfs node %q: lookup errno %d", nodePath, errno) } - if errno == -linuxabi.EEXIST { + if fsErrnoIs(errno, linuxabi.EEXIST) { childID, _, errno = m.Lookup(parentID, name) if errno != 0 { return fmt.Errorf("restore mountedfs node %q: lookup after mkdir errno %d", nodePath, errno) @@ -877,7 +877,7 @@ func (m *mountedFS) OpenDir(nodeID uint64, flags uint32) (uint64, int32) { } return m.storeHandle(node.path, backend, backendNodeID, fh, true, entries), 0 } - if errno != -linuxENOENT || !m.isSyntheticPath(node.path) { + if !fsErrnoIs(errno, linuxENOENT) || !m.isSyntheticPath(node.path) { return 0, errno } entries, errno := m.snapshotDirEntries(node, nil, 0, 0) diff --git a/internal/virtio/mountfs_test.go b/internal/virtio/mountfs_test.go index 8246ed44..fa52e964 100644 --- a/internal/virtio/mountfs_test.go +++ b/internal/virtio/mountfs_test.go @@ -734,3 +734,56 @@ func readSnapshotFile(t *testing.T, root imagefs.Directory, guestPath string) st } return string(data) } + +type signedErrnoMountBackend struct { + inertFSBackend + sign int32 + appeared bool +} + +func (b *signedErrnoMountBackend) GetAttr(node uint64) (FuseAttr, int32) { + return FuseAttr{Ino: node, Mode: 0040755, NLink: 2}, 0 +} +func (b *signedErrnoMountBackend) Lookup(_ uint64, name string) (uint64, FuseAttr, int32) { + if name == "restored" && b.appeared { + attr, _ := b.GetAttr(2) + return 2, attr, 0 + } + return 0, FuseAttr{}, b.sign * linuxENOENT +} +func (b *signedErrnoMountBackend) Mkdir(uint64, string, uint32, uint32, uint32) (uint64, FuseAttr, int32) { + // A competing creator made this directory after the restore's initial Lookup. + b.appeared = true + return 0, FuseAttr{}, b.sign * linuxEEXIST +} + +func TestMountedFSInternalErrnoDecisionsAcceptEitherSign(t *testing.T) { + for _, tc := range []struct { + name string + sign int32 + }{{"positive", 1}, {"negative", -1}} { + t.Run(tc.name, func(t *testing.T) { + root := &signedErrnoMountBackend{sign: tc.sign} + mounted := NewMountedFS(root, []ShareMount{{GuestPath: "/synthetic/share", Backend: inertFSBackend{}}}).(*mountedFS) + node, _, errno := mounted.Lookup(1, "synthetic") + if errno != 0 { + t.Fatalf("synthetic lookup: %d", errno) + } + fh, errno := mounted.OpenDir(node, 0) + if errno != 0 { + t.Fatalf("synthetic directory fallback rejected signed ENOENT: %d", errno) + } + data, errno := mounted.ReadDir(node, fh, 0, 4096) + if errno != 0 || len(data) == 0 { + t.Fatalf("synthetic directory listing: %x %d", data, errno) + } + mounted.ReleaseDir(node, fh) + if err := mounted.RestoreNodePaths([]string{"/restored"}); err != nil { + t.Fatalf("restore rejected concurrent signed EEXIST: %v", err) + } + if mounted.nodeIDForPath("/restored") == 0 { + t.Fatal("restore did not bind raced directory") + } + }) + } +} diff --git a/internal/vm/backend_linux_amd64.go b/internal/vm/backend_linux_amd64.go index 1d279c24..460b3692 100644 --- a/internal/vm/backend_linux_amd64.go +++ b/internal/vm/backend_linux_amd64.go @@ -111,7 +111,11 @@ func (b *runtimeBackend) StartStream(ctx context.Context, req client.CreateInsta closeVirtioFSDevices(fsdevs) } }() - initBin, err := guestinit.BuildForArch(ctx, b.guestInitCache, "amd64") + initOverride := sandboxInitFromContext(ctx) + initBin := initOverride.payload + if len(initBin) == 0 { + initBin, err = guestinit.BuildForArch(ctx, b.guestInitCache, "amd64") + } if err != nil { return nil, fmt.Errorf("build guest init: %w", err) } @@ -120,6 +124,7 @@ func (b *runtimeBackend) StartStream(ctx context.Context, req client.CreateInsta workDir = "/" } initCfg := linuxGuestInitConfig(modules, true, req.Network, network) + initCfg.Protocol = initOverride.protocol initCfg.InitSystem = req.InitSystem initCfg.RootFSTag = vmruntime.RootFSTag initCfg.Env = vmruntime.WithDefaultEnv(vmruntime.MergeEnv(image.Config.Env, req.Env)) diff --git a/internal/vm/execplan/execplan.go b/internal/vm/execplan/execplan.go index 82b9596d..be937215 100644 --- a/internal/vm/execplan/execplan.go +++ b/internal/vm/execplan/execplan.go @@ -66,23 +66,25 @@ func ResolveExecRequest(req client.ExecRequest, resolver Resolver) (client.ExecR skipResolve = true } return client.ExecRequest{ - ID: req.ID, - Kind: req.Kind, - Command: command, - Env: env, - RootDir: rootDir, - Path: req.Path, - Directory: req.Directory, - ReplaceEnv: req.ReplaceEnv, - SkipResolve: skipResolve, - WorkDir: workDir, - User: user, - Stdin: append([]byte(nil), req.Stdin...), - TTY: req.TTY, - ControlFD: req.ControlFD, - Cols: req.Cols, - Rows: req.Rows, - ArchiveLimits: req.ArchiveLimits, + ProcessMode: req.ProcessMode, + SandboxProtocol: req.SandboxProtocol, + ID: req.ID, + Kind: req.Kind, + Command: command, + Env: env, + RootDir: rootDir, + Path: req.Path, + Directory: req.Directory, + ReplaceEnv: req.ReplaceEnv, + SkipResolve: skipResolve, + WorkDir: workDir, + User: user, + Stdin: append([]byte(nil), req.Stdin...), + TTY: req.TTY, + ControlFD: req.ControlFD, + Cols: req.Cols, + Rows: req.Rows, + ArchiveLimits: req.ArchiveLimits, }, nil } @@ -116,15 +118,17 @@ func ControlRequest(req client.ExecRequest, defaultWorkDir string) client.ExecRe workDir = defaultWorkDir } return client.ExecRequest{ - ID: req.ID, - Kind: req.Kind, - RootDir: req.RootDir, - Path: req.Path, - Directory: req.Directory, - WorkDir: workDir, - User: req.User, - Stdin: append([]byte(nil), req.Stdin...), - ArchiveLimits: req.ArchiveLimits, + ProcessMode: req.ProcessMode, + SandboxProtocol: req.SandboxProtocol, + ID: req.ID, + Kind: req.Kind, + RootDir: req.RootDir, + Path: req.Path, + Directory: req.Directory, + WorkDir: workDir, + User: req.User, + Stdin: append([]byte(nil), req.Stdin...), + ArchiveLimits: req.ArchiveLimits, } } diff --git a/internal/vm/execplan/execplan_test.go b/internal/vm/execplan/execplan_test.go index 7cbc4df1..a4438094 100644 --- a/internal/vm/execplan/execplan_test.go +++ b/internal/vm/execplan/execplan_test.go @@ -1,13 +1,17 @@ package execplan import ( + "bytes" + "encoding/json" "io/fs" "strings" "testing" "j5.nz/cc/client" "j5.nz/cc/internal/imagefs" + managedagent "j5.nz/cc/internal/managed/agent" managedguest "j5.nz/cc/internal/managed/guest" + "j5.nz/cc/internal/managed/protocol" "j5.nz/cc/internal/vmruntime" ) @@ -233,3 +237,30 @@ type staticCapabilityProvider struct { func (p staticCapabilityProvider) ManagedCapabilities() managedguest.Capabilities { return p.caps } + +func TestSandboxModeSurvivesResolutionAndManagedWire(t *testing.T) { + for _, mode := range []string{"single", "group", ""} { + for _, skip := range []bool{false, true} { + req := client.ExecRequest{Command: []string{"tool"}, ProcessMode: mode, SandboxProtocol: 1, SkipResolve: skip} + got, err := ResolveExecRequest(req, Resolver{Root: testResolverRoot(t), BaseEnv: []string{"PATH=/bin"}}) + if err != nil { + t.Fatal(err) + } + var wire bytes.Buffer + if err := managedagent.Send(&wire, managedagent.ExecRequest("sandbox", got)); err != nil { + t.Fatal(err) + } + var decoded protocol.ManagedExecRequest + if err := json.Unmarshal(bytes.TrimSpace(wire.Bytes()), &decoded); err != nil { + t.Fatal(err) + } + if decoded.ProcessMode != mode || decoded.SandboxProtocol != 1 { + t.Fatalf("normalization lost cancellation semantics: skip=%v mode=%q wire=%s", skip, mode, wire.String()) + } + } + control := ControlRequest(client.ExecRequest{Kind: "fs_write", ProcessMode: mode, SandboxProtocol: 1}, "/") + if control.ProcessMode != mode || control.SandboxProtocol != 1 { + t.Fatalf("control normalization lost protocol: %+v", control) + } + } +} diff --git a/internal/vm/sandbox_linux_amd64.go b/internal/vm/sandbox_linux_amd64.go new file mode 100644 index 00000000..b65473f8 --- /dev/null +++ b/internal/vm/sandbox_linux_amd64.go @@ -0,0 +1,56 @@ +//go:build linux && amd64 + +package vm + +import ( + "context" + "errors" + "j5.nz/cc/client" + "j5.nz/cc/internal/virtio" + "net" +) + +type sandboxInitKey struct{} +type sandboxInitOverride struct { + payload []byte + protocol int +} + +// WithSandboxInit supplies an immutable init payload for this boot only. +func WithSandboxInit(ctx context.Context, payload []byte, protocol int) context.Context { + return context.WithValue(ctx, sandboxInitKey{}, sandboxInitOverride{append([]byte(nil), payload...), protocol}) +} + +func sandboxInitFromContext(ctx context.Context) sandboxInitOverride { + v, _ := ctx.Value(sandboxInitKey{}).(sandboxInitOverride) + return v +} + +// StartSandbox attaches caller-owned filesystem capabilities directly to the +// in-process Linux backend. It does not expose directory shares or a daemon. +func StartSandbox(ctx context.Context, backend Backend, req client.CreateInstanceRequest, shares []virtio.ShareMount, progress func(client.BootEvent) error) (Instance, error) { + return backend.StartStream(withHostMounts(ctx, shares), req, progress) +} + +// DialGuestContext can only send TCP into this VM's independent network stack. +// It cannot dial a host socket, resolve host DNS or select another guest. +func (i *linuxInstance) DialGuestContext(ctx context.Context, network, address string) (net.Conn, error) { + if i == nil || i.managedInstance == nil { + return nil, errors.New("guest unavailable") + } + i.netMu.RLock() + n := i.managedInstance.network + i.netMu.RUnlock() + if n == nil || n.networkRuntime == nil { + return nil, errors.New("guest network unavailable") + } + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ip := net.ParseIP(host) + if ip == nil || !ip.Equal(n.ip) { + return nil, errors.New("destination is not this guest") + } + return n.stack.DialInternalContext(ctx, network, net.JoinHostPort(n.ip.String(), port)) +} diff --git a/internal/vm/sandbox_linux_amd64_test.go b/internal/vm/sandbox_linux_amd64_test.go new file mode 100644 index 00000000..77efb4f6 --- /dev/null +++ b/internal/vm/sandbox_linux_amd64_test.go @@ -0,0 +1,139 @@ +//go:build linux && amd64 + +package vm + +import ( + "context" + "io" + "j5.nz/cc/client" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSandboxGuestHTTPWithoutInternet(t *testing.T) { + env := newRuntimeBootEnv(t) + helperDir := t.TempDir() + source := `package main +import("net";"net/http";"os";"time") +func main(){ + if os.Args[1]=="probe" {c,e:=net.DialTimeout("tcp","1.1.1.1:80",300*time.Millisecond);if e==nil{c.Close();os.Exit(1)};return} + if e:=http.ListenAndServe(os.Args[1],http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){w.Write([]byte("guest-netstack"))}));e!=nil{panic(e)} +}` + sourcePath := filepath.Join(helperDir, "server.go") + if err := os.WriteFile(sourcePath, []byte(source), 0600); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-trimpath", "-o", filepath.Join(helperDir, "server"), sourcePath) + build.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build guest HTTP helper: %v %s", err, out) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + inst, err := StartSandbox(ctx, env.backend, client.CreateInstanceRequest{ID: "sandbox-http", Image: env.imageName, Shares: []client.ShareMount{{Source: helperDir, Mount: "/test-helper"}}, DefaultUser: "root", MemoryMB: 768, CPUs: 1, Network: &client.NetworkConfig{Enabled: true, AllowInternet: false, BlockHostAccess: true}}, nil, nil) + if err != nil { + t.Fatal(err) + } + defer inst.Close() + dial := inst.(interface { + DialGuestContext(context.Context, string, string) (net.Conn, error) + }) + guestIP := inst.(interface{ NetworkIPv4() string }).NetworkIPv4() + for _, addr := range []string{"127.0.0.1:80", "localhost:80", "169.254.169.254:80", "1.1.1.1:80"} { + if c, err := dial.DialGuestContext(ctx, "tcp", addr); err == nil { + c.Close() + t.Fatalf("accepted non-guest target %s", addr) + } + } + root := execInRuntimeRequest(t, inst, client.ExecRequest{Command: []string{"id", "-u"}, User: "root"}) + user := execInRuntimeRequest(t, inst, client.ExecRequest{Command: []string{"id", "-u"}, User: "nobody"}) + if strings.TrimSpace(root.Output) != "0" || strings.TrimSpace(user.Output) == "0" { + t.Fatalf("guest user selection root=%q nobody=%q", root.Output, user.Output) + } + execInRuntimeRequest(t, inst, client.ExecRequest{SkipResolve: true, Command: []string{"/test-helper/server", "probe"}}) + serveCtx, stop := context.WithCancel(ctx) + defer stop() + done := make(chan error, 1) + go func() { + done <- inst.ExecStream(serveCtx, client.ExecRequest{SkipResolve: true, Command: []string{"/test-helper/server", "0.0.0.0:8000"}}, nil, func(event client.ExecEvent) error { t.Logf("guest service event: %+v", event); return nil }) + }() + transport := &http.Transport{Proxy: nil, DisableKeepAlives: true, DialContext: dial.DialGuestContext} + defer transport.CloseIdleConnections() + httpClient := &http.Client{Transport: transport, Timeout: 2 * time.Second} + deadline := time.Now().Add(10 * time.Second) + var body []byte + for time.Now().Before(deadline) { + response, e := httpClient.Get("http://" + net.JoinHostPort(guestIP, "8000") + "/") + if e == nil { + body, e = io.ReadAll(response.Body) + response.Body.Close() + if e == nil { + break + } + } + select { + case err := <-done: + t.Fatalf("guest service exited: %v", err) + case <-time.After(50 * time.Millisecond): + } + } + if string(body) != "guest-netstack" { + t.Fatalf("guest HTTP body %q", body) + } + stop() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("guest service did not cancel") + } + // A Linux listener bound only to guest loopback is NOT assumed reachable + // through the guest NIC. Verify that limitation rather than host-fallback. + loopCtx, loopStop := context.WithCancel(ctx) + defer loopStop() + loopDone := make(chan error, 1) + go func() { + loopDone <- inst.ExecStream(loopCtx, clientpkgExecLoopback(), nil, func(event client.ExecEvent) error { t.Logf("guest service event: %+v", event); return nil }) + }() + ready := false + for attempt := 0; attempt < 50; attempt++ { + response, err := inst.Exec(ctx, client.ExecRequest{Command: []string{"cat", "/proc/net/tcp"}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(response.Output, "0100007F:1F41") { + ready = true + break + } + select { + case err := <-loopDone: + t.Fatalf("loopback service exited: %v", err) + case <-time.After(20 * time.Millisecond): + } + } + if !ready { + t.Fatal("loopback listener was not established") + } + + probeCtx, probeCancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer probeCancel() + if c, err := dial.DialGuestContext(probeCtx, "tcp", net.JoinHostPort(guestIP, "8001")); err == nil { + c.Close() + t.Fatal("unexpected loopback-only service reachability; inspect routing contract") + } + loopStop() + select { + case <-loopDone: + case <-time.After(5 * time.Second): + t.Fatal("loopback service did not cancel") + } +} +func clientpkgExecLoopback() client.ExecRequest { + return client.ExecRequest{SkipResolve: true, Command: []string{"/test-helper/server", "127.0.0.1:8001"}} +} diff --git a/internal/vmruntime/exec.go b/internal/vmruntime/exec.go index 4ade62eb..3a6a7ca6 100644 --- a/internal/vmruntime/exec.go +++ b/internal/vmruntime/exec.go @@ -123,6 +123,14 @@ func ParseManagedExecEventLine(line, id string) (client.ExecEvent, bool, bool, e if err != nil { return client.ExecEvent{}, false, false, nil } + var lifecycle struct { + Version int `json:"version"` + Kind string `json:"kind"` + Error string `json:"error,omitempty"` + } + if json.Unmarshal(data, &lifecycle) == nil && lifecycle.Version == 1 && (lifecycle.Kind == "started" || lifecycle.Kind == "launch_error") { + return client.ExecEvent{Kind: lifecycle.Kind, Error: lifecycle.Error}, false, true, nil + } return client.ExecEvent{Kind: "control", Output: string(data), Data: data}, false, true, nil case strings.HasPrefix(line, exitPrefix): code, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, exitPrefix))) diff --git a/internal/vmruntime/guestinit.go b/internal/vmruntime/guestinit.go index 35f689c9..647a7fb7 100644 --- a/internal/vmruntime/guestinit.go +++ b/internal/vmruntime/guestinit.go @@ -10,6 +10,7 @@ import ( ) type GuestInitConfig struct { + Protocol int `json:"protocol,omitempty"` Command []string `json:"command"` Env []string `json:"env"` WorkDir string `json:"workdir"` @@ -143,6 +144,9 @@ func GuestShareConfigs(shares []DirectoryShare) []GuestInitShare { } func BuildInitramfs(initPayload []byte, modules []alpine.Module, config GuestInitConfig) ([]byte, error) { + if config.Protocol != 0 && config.Protocol != 1 { + return nil, fmt.Errorf("unsupported guest init protocol %d", config.Protocol) + } if len(config.RootFSImage) > 0 { if strings.TrimSpace(config.RootFSImageType) == "" { config.RootFSImageType = "ext4" diff --git a/sandbox/create_linux_amd64.go b/sandbox/create_linux_amd64.go new file mode 100644 index 00000000..8598de9f --- /dev/null +++ b/sandbox/create_linux_amd64.go @@ -0,0 +1,137 @@ +//go:build linux && amd64 + +package sandbox + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "j5.nz/cc/client" + "j5.nz/cc/internal/kernel/alpine" + "j5.nz/cc/internal/oci" + "j5.nz/cc/internal/vm" + "path" + "path/filepath" + "strings" +) + +// Runtime shares only immutable image/kernel preparation caches across VMs. +// Each VM gets a separate backend and network switch; guest root writes use +// cc's per-instance copy-on-write storage, not the image cache or live shares. +type Runtime struct { + root string + prepare chan struct{} + images *oci.Store + kernel *alpine.Manager +} + +func NewRuntime(cacheDirectory string) (*Runtime, error) { + if !filepath.IsAbs(cacheDirectory) { + return nil, errors.New("sandbox cache directory must be absolute") + } + if err := vm.Supports(); err != nil { + return nil, err + } + return &Runtime{root: cacheDirectory, prepare: make(chan struct{}, 1), images: oci.NewStore(filepath.Join(cacheDirectory, "images")), kernel: alpine.NewManager(filepath.Join(cacheDirectory, "kernel"))}, nil +} +func validateConfig(cfg Config) error { + if err := validateInit(cfg.Init, cfg.SandboxProtocol); err != nil { + return err + } + parts := strings.Split(cfg.Image, "@sha256:") + if len(parts) != 2 || parts[0] == "" || len(parts[1]) != 64 { + return errors.New("sandbox requires an immutable OCI image reference") + } + if _, err := hex.DecodeString(parts[1]); err != nil { + return errors.New("invalid OCI digest") + } + source, err := oci.ParseSource(cfg.Image) + if err != nil || source.Kind != oci.SourceKindOCI { + return errors.New("sandbox accepts only OCI registry references") + } + if cfg.Platform != "linux/amd64" { + return errors.New("embedded sandbox currently requires linux/amd64") + } + if cfg.ID == "" || len(cfg.ID) > 128 || cfg.MemoryMB < 128 || cfg.MemoryMB > 1048576 || cfg.CPUs < 1 || cfg.CPUs > 256 { + return errors.New("invalid sandbox identity or resources") + } + for n, m := range cfg.Mounts { + if m.Backend == nil || !path.IsAbs(m.GuestPath) || path.Clean(m.GuestPath) != m.GuestPath || m.GuestPath == "/" || strings.ContainsRune(m.GuestPath, 0) { + return errors.New("invalid sandbox filesystem mount") + } + for _, other := range cfg.Mounts[:n] { + if m.GuestPath == other.GuestPath || strings.HasPrefix(m.GuestPath, other.GuestPath+"/") || strings.HasPrefix(other.GuestPath, m.GuestPath+"/") { + return errors.New("overlapping sandbox mounts") + } + } + } + return nil +} +func (r *Runtime) Create(ctx context.Context, cfg Config, progress func(Progress)) (*Sandbox, error) { + if err := validateConfig(cfg); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + report := func(p Progress) { + if progress != nil { + progress(p) + } + } + name := fmt.Sprintf("sandbox-%x", sha256.Sum256([]byte(cfg.Platform+"\n"+cfg.Image))) + // cc image activation is serialized, so simultaneous boots of one image do + // not get a transient "download already in progress" error. + select { + case r.prepare <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + state, err := r.images.Pull(ctx, name, cfg.Image, oci.PullOptions{Architecture: "amd64", Report: func(event client.ProgressEvent) { report(Progress{Stage: "image", Image: &event}) }}) + if err == nil { + report(Progress{Stage: "kernel", Message: "Preparing Linux kernel"}) + err = r.kernel.Ensure(ctx) + } + <-r.prepare + if err != nil { + return nil, err + } + image, err := r.images.Open(name) + if err != nil { + return nil, err + } + if image.Architecture != "amd64" { + return nil, errors.New("resolved image architecture mismatch") + } + parts := strings.Split(state.ResolvedSource, "@sha256:") + if len(parts) != 2 || len(parts[1]) != 64 { + return nil, errors.New("OCI store did not record a resolved manifest digest") + } + if _, err := hex.DecodeString(parts[1]); err != nil { + return nil, errors.New("OCI store returned invalid manifest digest") + } + identity := Identity{Reference: state.ResolvedSource, Digest: "sha256:" + parts[1], Platform: "linux/amd64"} + report(Progress{Stage: "boot", Message: "Starting Linux VM"}) + backend := vm.NewRuntimeBackend(r.kernel, r.images, filepath.Join(r.root, "guestinit")) + ctx = vm.WithSandboxInit(ctx, cfg.Init, cfg.SandboxProtocol) + inst, err := vm.StartSandbox(ctx, backend, client.CreateInstanceRequest{ID: cfg.ID, Image: name, DefaultUser: "root", MemoryMB: cfg.MemoryMB, CPUs: cfg.CPUs, Network: &client.NetworkConfig{Enabled: true, AllowInternet: cfg.Internet, BlockHostAccess: true}}, cfg.Mounts, func(event client.BootEvent) error { + report(Progress{Stage: "boot", Message: event.Message}) + return nil + }) + if err != nil { + return nil, err + } + s := &Sandbox{instance: inst, identity: identity} + if cfg.SandboxProtocol != 0 { + if err := verifyProtocol(ctx, s, cfg.SandboxProtocol); err != nil { + closeErr := s.Close() + if closeErr != nil { + return s, errors.Join(err, closeErr) + } + return nil, err + } + } + return s, nil +} diff --git a/sandbox/create_other.go b/sandbox/create_other.go new file mode 100644 index 00000000..abf6aeda --- /dev/null +++ b/sandbox/create_other.go @@ -0,0 +1,17 @@ +//go:build !linux || !amd64 + +package sandbox + +import ( + "context" + "errors" +) + +type Runtime struct{} + +func NewRuntime(string) (*Runtime, error) { + return nil, errors.New("embedded sandbox requires Linux amd64 with KVM") +} +func (*Runtime) Create(context.Context, Config, func(Progress)) (*Sandbox, error) { + return nil, errors.New("embedded sandbox requires Linux amd64 with KVM") +} diff --git a/sandbox/fs/fs.go b/sandbox/fs/fs.go new file mode 100644 index 00000000..ac5aba50 --- /dev/null +++ b/sandbox/fs/fs.go @@ -0,0 +1,10 @@ +// Package fs exposes cc's virtio-fs backend contract without linking a VM +// runtime or requiring embedded guest-init payloads. +package fs + +import "j5.nz/cc/internal/virtio" + +type Backend = virtio.FSBackend +type Attr = virtio.FuseAttr +type CachePolicy = virtio.FSCachePolicy +type Mount = virtio.ShareMount diff --git a/sandbox/init.go b/sandbox/init.go new file mode 100644 index 00000000..27cef4f7 --- /dev/null +++ b/sandbox/init.go @@ -0,0 +1,82 @@ +package sandbox + +import ( + "bytes" + "context" + "debug/elf" + "errors" + "fmt" + "strconv" + "time" + + "j5.nz/cc/client" +) + +func validateInit(payload []byte, protocol int) error { + if protocol != 0 && protocol != 1 { + return fmt.Errorf("unsupported sandbox protocol %d", protocol) + } + if len(payload) == 0 { + if protocol != 0 { + return errors.New("sandbox protocol requires a custom init payload") + } + return nil + } + f, err := elf.NewFile(bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("custom init must be static Linux amd64 ELF: %w", err) + } + defer f.Close() + if f.Class != elf.ELFCLASS64 || f.Machine != elf.EM_X86_64 || (f.Type != elf.ET_EXEC && f.Type != elf.ET_DYN) { + return errors.New("custom init must be static Linux amd64 ELF") + } + for _, p := range f.Progs { + if p.Type == elf.PT_INTERP { + return errors.New("custom init must not require a dynamic linker") + } + } + return nil +} + +type protocolExecutor interface { + ExecStream(context.Context, client.ExecRequest, <-chan client.ExecInput, func(client.ExecEvent) error) error +} + +func verifyProtocol(ctx context.Context, s protocolExecutor, version int) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + exited := false + err := s.ExecStream(ctx, client.ExecRequest{Command: []string{"/proc/1/exe", "--sa2-protocol"}, User: "root", SkipResolve: true}, nil, func(event client.ExecEvent) error { + switch event.Kind { + case "output", "stdout", "stderr": + data := event.Data + if data == nil { + data = []byte(event.Output) + } + if stdout.Len()+stderr.Len()+len(data) > 4096 { + return errors.New("sandbox protocol response exceeds limit") + } + if event.Stream == "stderr" || event.Kind == "stderr" { + stderr.Write(data) + } else { + stdout.Write(data) + } + case "launch_error": + return fmt.Errorf("sandbox protocol helper: %s", event.Error) + case "exit": + exited = true + if event.ExitCode != 0 { + return fmt.Errorf("sandbox protocol helper exited %d", event.ExitCode) + } + } + return nil + }) + if err != nil { + return fmt.Errorf("verify sandbox protocol: %w", err) + } + if !exited || stdout.String() != strconv.Itoa(version)+"\n" || stderr.Len() != 0 { + return errors.New("sandbox init protocol mismatch") + } + return nil +} diff --git a/sandbox/init_test.go b/sandbox/init_test.go new file mode 100644 index 00000000..65ff1e37 --- /dev/null +++ b/sandbox/init_test.go @@ -0,0 +1,61 @@ +package sandbox + +import ( + "context" + "strings" + "testing" + + "j5.nz/cc/client" +) + +type fakeProtocolExec struct{ events []client.ExecEvent } + +func (f fakeProtocolExec) ExecStream(ctx context.Context, req client.ExecRequest, _ <-chan client.ExecInput, emit func(client.ExecEvent) error) error { + if len(req.Command) != 2 || req.Command[0] != "/proc/1/exe" || req.Command[1] != "--sa2-protocol" || !req.SkipResolve { + panic("unsafe protocol probe") + } + for _, e := range f.events { + if err := emit(e); err != nil { + return err + } + } + return nil +} + +func TestVerifyProtocol(t *testing.T) { + tests := []struct { + name string + events []client.ExecEvent + ok bool + }{ + {"valid", []client.ExecEvent{{Kind: "stdout", Data: []byte("1\n")}, {Kind: "exit"}}, true}, + {"wrong version", []client.ExecEvent{{Kind: "stdout", Output: "2\n"}, {Kind: "exit"}}, false}, + {"failed exit", []client.ExecEvent{{Kind: "stdout", Output: "1\n"}, {Kind: "exit", ExitCode: 1}}, false}, + {"missing exit", []client.ExecEvent{{Kind: "stdout", Output: "1\n"}}, false}, + {"stderr", []client.ExecEvent{{Kind: "stderr", Output: "1\n"}, {Kind: "exit"}}, false}, + {"oversized", []client.ExecEvent{{Kind: "stdout", Output: strings.Repeat("1", 4097)}, {Kind: "exit"}}, false}, + {"launch error", []client.ExecEvent{{Kind: "launch_error", Error: "not found"}}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := verifyProtocol(context.Background(), fakeProtocolExec{tc.events}, 1) + if (err == nil) != tc.ok { + t.Fatalf("result %v", err) + } + }) + } +} + +func TestValidateCustomInitFailClosed(t *testing.T) { + if err := validateInit(nil, 0); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + payload []byte + protocol int + }{{nil, 1}, {nil, 2}, {[]byte("not ELF"), 1}} { + if err := validateInit(tc.payload, tc.protocol); err == nil { + t.Fatal("invalid init accepted") + } + } +} diff --git a/sandbox/proxy.go b/sandbox/proxy.go new file mode 100644 index 00000000..517b41f0 --- /dev/null +++ b/sandbox/proxy.go @@ -0,0 +1,138 @@ +package sandbox + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "time" +) + +const proxyHeaderLimit = 4096 + +type proxyRequest struct { + Version int `json:"version"` + Host string `json:"host"` + Port string `json:"port"` +} +type proxyResponse struct { + Version int `json:"version"` + Error string `json:"error,omitempty"` +} + +// ProxyDialContext resolves the destination only inside the guest. The host +// independently dials this VM's proxy, never the requested host or host DNS. +func (s *Sandbox) ProxyDialContext(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, errors.New("sandbox proxy supports only TCP") + } + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 || host == "" { + return nil, errors.New("invalid sandbox proxy destination") + } + req := proxyRequest{Version: 1, Host: host, Port: port} + wire, err := json.Marshal(req) + if err != nil || len(wire)+1 > proxyHeaderLimit { + return nil, errors.New("sandbox proxy request exceeds limit") + } + ip := s.GuestIPv4() + if ip == "" { + return nil, errors.New("guest network unavailable") + } + conn, err := s.DialContext(ctx, "tcp", net.JoinHostPort(ip, "10780")) + if err != nil { + return nil, err + } + return proxyHandshake(ctx, conn, append(wire, '\n')) +} + +func proxyHandshake(ctx context.Context, conn net.Conn, wire []byte) (net.Conn, error) { + ok := false + defer func() { + if !ok { + conn.Close() + } + }() + deadline := time.Now().Add(10 * time.Second) + if d, exists := ctx.Deadline(); exists && d.Before(deadline) { + deadline = d + } + if err := conn.SetDeadline(deadline); err != nil { + return nil, err + } + stopped := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { conn.Close(); close(stopped) }) + watchFinished := false + defer func() { + if !watchFinished && !stop() { + <-stopped + } + }() + for len(wire) > 0 { + n, err := conn.Write(wire) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, err + } + if n == 0 { + return nil, errors.New("short proxy write") + } + wire = wire[n:] + } + reader := bufio.NewReaderSize(conn, proxyHeaderLimit) + line, err := reader.ReadSlice('\n') + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("sandbox proxy response: %w", err) + } + if len(line) > proxyHeaderLimit { + return nil, errors.New("sandbox proxy response exceeds limit") + } + var response proxyResponse + if err := json.Unmarshal(line, &response); err != nil { + return nil, fmt.Errorf("sandbox proxy response: %w", err) + } + if response.Version != 1 { + return nil, errors.New("sandbox proxy protocol mismatch") + } + if response.Error != "" { + return nil, fmt.Errorf("guest proxy: %s", response.Error) + } + watchFinished = true + if !stop() { + <-stopped + return nil, ctx.Err() + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := conn.SetDeadline(time.Time{}); err != nil { + return nil, err + } + ok = true + return &proxyConn{Conn: conn, reader: reader}, nil +} + +type proxyConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *proxyConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *proxyConn) CloseWrite() error { + if c, ok := c.Conn.(interface{ CloseWrite() error }); ok { + return c.CloseWrite() + } + return errors.New("proxy connection does not support half-close") +} diff --git a/sandbox/proxy_test.go b/sandbox/proxy_test.go new file mode 100644 index 00000000..5a0944b6 --- /dev/null +++ b/sandbox/proxy_test.go @@ -0,0 +1,90 @@ +package sandbox + +import ( + "bufio" + "context" + "errors" + "io" + "net" + "strings" + "testing" + "time" +) + +func TestProxyHandshakePreservesBufferedRelay(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + done := make(chan error, 1) + go func() { + line, err := bufio.NewReader(server).ReadString('\n') + if err == nil && !strings.Contains(line, `"host":"localhost"`) { + err = errors.New("destination lost") + } + if err == nil { + _, err = io.WriteString(server, "{\"version\":1}\nhello") + } + done <- err + }() + conn, err := proxyHandshake(context.Background(), client, []byte("{\"version\":1,\"host\":\"localhost\",\"port\":\"8080\"}\n")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + data := make([]byte, 5) + if _, err := io.ReadFull(conn, data); err != nil || string(data) != "hello" { + t.Fatalf("relay %q: %v", data, err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestProxyHandshakeRejectsResponses(t *testing.T) { + for _, response := range []string{"{\"version\":2}\n", "{\"version\":1,\"error\":\"refused\"}\n", "garbage\n", strings.Repeat("x", proxyHeaderLimit+1) + "\n"} { + t.Run(response[:min(len(response), 30)], func(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + done := make(chan struct{}) + go func() { defer close(done); bufio.NewReader(server).ReadString('\n'); io.WriteString(server, response) }() + if c, err := proxyHandshake(context.Background(), client, []byte("{}\n")); err == nil { + c.Close() + t.Fatal("accepted invalid response") + } + <-done + }) + } +} + +func TestProxyHandshakeCancellation(t *testing.T) { + for _, blockWrite := range []bool{true, false} { + client, server := net.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := proxyHandshake(ctx, client, []byte("{}\n")); done <- err }() + if !blockWrite { + if _, err := bufio.NewReader(server).ReadString('\n'); err != nil { + t.Fatal(err) + } + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("cancellation did not interrupt handshake") + } + server.Close() + } +} + +func TestProxyHandshakeDeadline(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := proxyHandshake(ctx, client, []byte("{}\n")); err == nil { + t.Fatal("deadline not enforced") + } +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go new file mode 100644 index 00000000..d7af146b --- /dev/null +++ b/sandbox/sandbox.go @@ -0,0 +1,107 @@ +// Package sandbox exposes narrow in-process Linux VM integration. It is a +// trusted host library, not an agent-facing image catalog or policy engine. +// Callers must enforce admission, ownership, leases and host resource limits. +package sandbox + +import ( + "context" + "errors" + "j5.nz/cc/client" + "j5.nz/cc/internal/virtio" + vmhost "j5.nz/cc/internal/vm/host" + "net" + "sync" +) + +type FSBackend = virtio.FSBackend +type FuseAttr = virtio.FuseAttr +type FSCachePolicy = virtio.FSCachePolicy +type ShareMount = virtio.ShareMount + +type Config struct { + ID string + // Image is a trusted administrator's OCI reference pinned by digest. + Image string + Platform string + MemoryMB uint64 + CPUs int + Internet bool + Mounts []ShareMount + // Init overrides cc's init with a caller-supplied static Linux amd64 ELF. + Init []byte + // SandboxProtocol requires Init and is verified in the booted guest. + SandboxProtocol int +} +type Identity struct{ Reference, Digest, Platform string } +type Progress struct { + Stage string + Message string + Image *client.ProgressEvent +} +type Sandbox struct { + mu sync.Mutex + closing bool + instance vmhost.Instance + identity Identity +} + +func (s *Sandbox) Identity() Identity { return s.identity } +func (s *Sandbox) current() (vmhost.Instance, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closing || s.instance == nil { + return nil, errors.New("sandbox closed") + } + return s.instance, nil +} + +// ExecStream forwards cc's guest-agent requests. Explicit process modes require +// the matching sandbox protocol; empty mode retains cc's legacy semantics. +func (s *Sandbox) ExecStream(ctx context.Context, req client.ExecRequest, inputs <-chan client.ExecInput, onEvent func(client.ExecEvent) error) error { + i, err := s.current() + if err != nil { + return err + } + if req.User == "" { + req.User = "root" + } + // Commands can live in the writable root or live draft, not just the image. + req.SkipResolve = true + return i.ExecStream(ctx, req, inputs, onEvent) +} +func (s *Sandbox) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closing = true + if s.instance == nil { + return nil + } + if err := s.instance.Close(); err != nil { + return err + } + s.instance = nil + return nil +} +func (s *Sandbox) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + i, err := s.current() + if err != nil { + return nil, err + } + dial, ok := i.(interface { + DialGuestContext(context.Context, string, string) (net.Conn, error) + }) + if !ok { + return nil, errors.New("independent guest dialer unavailable") + } + return dial.DialGuestContext(ctx, network, address) +} +func (s *Sandbox) GuestIPv4() string { + i, err := s.current() + if err != nil { + return "" + } + if n, ok := i.(interface{ NetworkIPv4() string }); ok { + return n.NetworkIPv4() + } + return "" +} diff --git a/tools/build.sh b/tools/build.sh index 56dbe358..9ba2e0c4 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -5,8 +5,8 @@ set -x ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="${ROOT_DIR}/build" -GUESTINIT_ARM64_EMBED_PATH="${ROOT_DIR}/internal/guestinit/guest-init-linux-arm64" -GUESTINIT_AMD64_EMBED_PATH="${ROOT_DIR}/internal/guestinit/guest-init-linux-amd64" +GUESTINIT_ARM64_EMBED_PATH="${ROOT_DIR}/internal/guestinit/payloads/guest-init-linux-arm64" +GUESTINIT_AMD64_EMBED_PATH="${ROOT_DIR}/internal/guestinit/payloads/guest-init-linux-amd64" TARGET_GOOS="${CCX3_TARGET_GOOS:-$(go env GOOS)}" TARGET_GOARCH="${CCX3_TARGET_GOARCH:-$(go env GOARCH)}" TARGET_SUFFIX="" @@ -15,26 +15,28 @@ if [[ "${TARGET_GOOS}" == "windows" ]]; then fi CCVM_OUTPUT="${BUILD_DIR}/ccvm-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" +cd "${ROOT_DIR}" export CGO_ENABLED=0 +export GOFLAGS="${GOFLAGS:-} -trimpath -buildvcs=false" mkdir -p "${BUILD_DIR}" -GOOS=linux GOARCH=arm64 go build -o "${BUILD_DIR}/init-linux-arm64" ./internal/cmd/init +GOOS=linux GOARCH=arm64 go build -ldflags='-buildid=' -o "${BUILD_DIR}/init-linux-arm64" ./internal/cmd/init install -m 644 "${BUILD_DIR}/init-linux-arm64" "${GUESTINIT_ARM64_EMBED_PATH}" -GOOS=linux GOARCH=amd64 go build -o "${BUILD_DIR}/init-linux-amd64" ./internal/cmd/init +GOOS=linux GOARCH=amd64 go build -ldflags='-buildid=' -o "${BUILD_DIR}/init-linux-amd64" ./internal/cmd/init install -m 644 "${BUILD_DIR}/init-linux-amd64" "${GUESTINIT_AMD64_EMBED_PATH}" for bsd in openbsd freebsd netbsd; do - GOOS="${bsd}" GOARCH="${TARGET_GOARCH}" go build -o "${BUILD_DIR}/guest-init-${bsd}-${TARGET_GOARCH}" "./internal/cmd/${bsd}-init" - install -m 644 "${BUILD_DIR}/guest-init-${bsd}-${TARGET_GOARCH}" "${ROOT_DIR}/internal/${bsd}/guestinit/guest-init-${bsd}-${TARGET_GOARCH}" + GOOS="${bsd}" GOARCH="${TARGET_GOARCH}" go build -ldflags='-buildid=' -o "${BUILD_DIR}/guest-init-${bsd}-${TARGET_GOARCH}" "./internal/cmd/${bsd}-init" + install -m 644 "${BUILD_DIR}/guest-init-${bsd}-${TARGET_GOARCH}" "${ROOT_DIR}/internal/${bsd}/guestinit/payloads/guest-init-${bsd}-${TARGET_GOARCH}" done -GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -o "${CCVM_OUTPUT}" ./cmd/ccvm -GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -o "${BUILD_DIR}/cc-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/cc -GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -o "${BUILD_DIR}/glass-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/glass +GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -ldflags='-buildid=' -o "${CCVM_OUTPUT}" ./cmd/ccvm +GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -ldflags='-buildid=' -o "${BUILD_DIR}/cc-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/cc +GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -ldflags='-buildid=' -o "${BUILD_DIR}/glass-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/glass -(cd "${ROOT_DIR}/frontends/vmsh" && GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -o "${BUILD_DIR}/vmsh-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/vmsh) +(cd "${ROOT_DIR}/frontends/vmsh" && GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build -ldflags='-buildid=' -o "${BUILD_DIR}/vmsh-${TARGET_GOOS}-${TARGET_GOARCH}${TARGET_SUFFIX}" ./cmd/vmsh) if [[ "${TARGET_GOOS}" == "darwin" && "$(uname -s)" == "Darwin" ]]; then codesign -f -s - --entitlements "${ROOT_DIR}/tools/entitlements.xml" "${CCVM_OUTPUT}"