diff --git a/cmd/bbox/main.go b/cmd/bbox/main.go index 8249dc6..22662b2 100644 --- a/cmd/bbox/main.go +++ b/cmd/bbox/main.go @@ -17,6 +17,7 @@ import ( "os/signal" "path/filepath" "slices" + "strconv" "strings" "syscall" "time" @@ -54,6 +55,7 @@ import ( "github.com/stacklok/brood-box/pkg/domain/egress" "github.com/stacklok/brood-box/pkg/domain/progress" "github.com/stacklok/brood-box/pkg/domain/snapshot" + domvm "github.com/stacklok/brood-box/pkg/domain/vm" "github.com/stacklok/brood-box/pkg/domain/workspace" "github.com/stacklok/brood-box/pkg/sandbox" ) @@ -103,6 +105,7 @@ func rootCmd() *cobra.Command { timings bool exec string envForward []string + ports []string ) cmd := &cobra.Command{ @@ -178,6 +181,7 @@ Example: exec: exec, commandArgs: commandArgs, envForward: envForward, + ports: ports, }) }, SilenceUsage: true, @@ -189,6 +193,7 @@ Example: cmd.Flags().StringVar(&tmpSize, "tmp-size", "", "Size of /tmp tmpfs inside the VM, e.g. 512m or 2g (empty = agent default)") cmd.Flags().StringVar(&wsPath, "workspace", "", "Workspace directory to mount (default: current directory)") cmd.Flags().Uint16Var(&sshPort, "ssh-port", 0, "Host SSH port (0 = auto-pick)") + cmd.Flags().StringSliceVar(&ports, "port", nil, "Forward an additional TCP port from guest to host as HOST:GUEST, bound to 127.0.0.1 (repeatable)") cmd.Flags().StringVar(&cfgPath, "config", "", "Config file path (default: ~/.config/broodbox/config.yaml)") cmd.Flags().StringVar(&image, "image", "", "Override OCI image reference") cmd.Flags().BoolVar(&debug, "debug", false, "Enable debug-level logging to file (default: info level)") @@ -376,6 +381,7 @@ type runFlags struct { exec string commandArgs []string envForward []string + ports []string } func run(parentCtx context.Context, agentName string, flags runFlags) error { @@ -401,6 +407,11 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { return fmt.Errorf("invalid --env-forward: %w", err) } + parsedPorts, portsErr := parsePortForwards(flags.ports) + if portsErr != nil { + return portsErr + } + // --no-image-cache + --pull=never is a contradiction: "never" requires the // cache to serve hits, but "no-image-cache" disables it entirely. if flags.noImageCache && flags.pull == domainconfig.PullNever { @@ -938,6 +949,7 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { TmpSizeMiB: tmpSizeMiB, Workspace: ws, SSHPort: flags.sshPort, + ExtraPorts: parsedPorts, ImageOverride: flags.image, EgressProfile: flags.egressProfile, AllowHosts: parsedAllowHosts, @@ -1428,6 +1440,36 @@ func imageCacheDir() (string, error) { // this notice, `git submodule add` would silently half-land: the entry // appears in `.gitmodules` on the host, but there is no populated // `.git/modules//` to back it. +// parsePortForwards parses repeated --port HOST:GUEST values into PortForward +// entries. Rejects malformed input, port 0, and duplicate host ports. +func parsePortForwards(specs []string) ([]domvm.PortForward, error) { + if len(specs) == 0 { + return nil, nil + } + out := make([]domvm.PortForward, 0, len(specs)) + seenHost := make(map[uint16]struct{}, len(specs)) + for _, spec := range specs { + host, guest, ok := strings.Cut(spec, ":") + if !ok { + return nil, fmt.Errorf("--port %q: expected HOST:GUEST", spec) + } + hostPort, err := strconv.ParseUint(strings.TrimSpace(host), 10, 16) + if err != nil || hostPort == 0 { + return nil, fmt.Errorf("--port %q: host port must be 1-65535", spec) + } + guestPort, err := strconv.ParseUint(strings.TrimSpace(guest), 10, 16) + if err != nil || guestPort == 0 { + return nil, fmt.Errorf("--port %q: guest port must be 1-65535", spec) + } + if _, dup := seenHost[uint16(hostPort)]; dup { + return nil, fmt.Errorf("--port: host port %d used more than once", hostPort) + } + seenHost[uint16(hostPort)] = struct{}{} + out = append(out, domvm.PortForward{Host: uint16(hostPort), Guest: uint16(guestPort)}) + } + return out, nil +} + func maybePrintSubmoduleHint(w io.Writer, accepted []snapshot.FileChange) { for _, ch := range accepted { if filepath.Base(ch.RelPath) == ".gitmodules" { diff --git a/internal/infra/vm/runner.go b/internal/infra/vm/runner.go index 57f5956..e8d7fa4 100644 --- a/internal/infra/vm/runner.go +++ b/internal/infra/vm/runner.go @@ -184,7 +184,7 @@ func (r *MicroVMRunner) Start(ctx context.Context, cfg domvm.VMConfig) (domvm.VM microvm.WithMemory(cfg.Memory.MiB()), microvm.WithLogLevel(cfg.LogLevel), microvm.WithTmpSize(cfg.TmpSize.MiB()), - microvm.WithPorts(microvm.PortForward{Host: sshPort, Guest: 22}), + microvm.WithPorts(buildPortForwards(sshPort, cfg.ExtraPorts)...), microvm.WithRootFSHook( hooks.InjectAuthorizedKeys(pubKey), hooks.InjectFile("/etc/ssh/ssh_host_ecdsa_key", hostKeyPEM, 0o600), @@ -463,3 +463,14 @@ func pickFreePort() (uint16, error) { } return port, nil } + +// buildPortForwards returns the SSH forward followed by any extra forwards. +// The SSH forward (host -> guest:22) is always first. +func buildPortForwards(sshPort uint16, extra []domvm.PortForward) []microvm.PortForward { + out := make([]microvm.PortForward, 0, 1+len(extra)) + out = append(out, microvm.PortForward{Host: sshPort, Guest: 22}) + for _, pf := range extra { + out = append(out, microvm.PortForward{Host: pf.Host, Guest: pf.Guest}) + } + return out +} diff --git a/pkg/domain/vm/vm.go b/pkg/domain/vm/vm.go index 0614dbd..68fa39f 100644 --- a/pkg/domain/vm/vm.go +++ b/pkg/domain/vm/vm.go @@ -88,6 +88,17 @@ type VMConfig struct { // Valid values: "always", "if-not-present", "never". // Empty defaults to "if-not-present". PullPolicy string + + // ExtraPorts are additional host->guest TCP forwards in addition to SSH. + // Host side always binds 127.0.0.1 (enforced by the runtime). + ExtraPorts []PortForward +} + +// PortForward maps a host TCP port to a guest TCP port. +// Host side always binds 127.0.0.1. +type PortForward struct { + Host uint16 + Guest uint16 } // HostService describes an HTTP service exposed from host to guest. diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index a910736..7949736 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -60,6 +60,9 @@ type RunOpts struct { // SSHPort is the host port for SSH (0 = auto-pick). SSHPort uint16 + // ExtraPorts are additional host->guest TCP forwards beyond SSH. + ExtraPorts []domvm.PortForward + // ImageOverride overrides the agent's OCI image reference. ImageOverride string @@ -530,6 +533,7 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO CPUs: ag.DefaultCPUs, Memory: ag.DefaultMemory, SSHPort: opts.SSHPort, + ExtraPorts: opts.ExtraPorts, WorkspacePath: workspacePath, EnvVars: envVars, EgressPolicy: egressPolicy,