diff --git a/README.md b/README.md index ff65ad8..6054bac 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ See [Git remotes between host and sandbox](doc/GIT_REMOTES.md) for the full work - [Sandbox profiles](doc/CONFIGURATION.md#profiles) - [Non-interactive oneshot runs](doc/ONESHOT.md) - [Network filtering](doc/NETWORK_FILTERING.md) +- [Exposing host services to a sandbox](doc/HOST_SERVICES.md) - [Architecture and trade-offs](doc/ARCHITECTURE.md) - [Claude Code setup](doc/CLAUDE_CODE_HOWTO.md) - [Troubleshooting](doc/TROUBLESHOOTING.md) diff --git a/cmd/sand/HELP.md b/cmd/sand/HELP.md index 8f5b873..b2b7048 100644 --- a/cmd/sand/HELP.md +++ b/cmd/sand/HELP.md @@ -51,6 +51,7 @@ sand new [flags] [SANDBOX-NAME] - `-e, --env-file` _``_ - legacy env file path used when no default profile is configured (default: `.env`) - `--rm` - remove the sandbox after the command terminates - `--allowed-domains-file` _``_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default) +- `--host-port` _``_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1: (repeatable) - `--mount` _``_ - bind mount a host directory (can be specified multiple times) - `--clone-mount` _``_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times) - `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`) @@ -83,6 +84,7 @@ sand oneshot [flags] - `-e, --env-file` _``_ - legacy env file path used when no default profile is configured (default: `.env`) - `--rm` - remove the sandbox after the command terminates - `--allowed-domains-file` _``_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default) +- `--host-port` _``_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1: (repeatable) - `--mount` _``_ - bind mount a host directory (can be specified multiple times) - `--clone-mount` _``_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times) - `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`) @@ -130,6 +132,7 @@ sand exec [flags] ... - `-e, --env-file` _``_ - legacy env file path used when no default profile is configured (default: `.env`) - `--rm` - remove the sandbox after the command terminates - `--allowed-domains-file` _``_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default) +- `--host-port` _``_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1: (repeatable) - `--mount` _``_ - bind mount a host directory (can be specified multiple times) - `--clone-mount` _``_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times) - `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`) diff --git a/doc/HOST_SERVICES.md b/doc/HOST_SERVICES.md new file mode 100644 index 0000000..1014721 --- /dev/null +++ b/doc/HOST_SERVICES.md @@ -0,0 +1,73 @@ +# Exposing host services to a sandbox + +The `--host-port` flag on `sand new` (repeatable) makes a TCP service bound to +your Mac's loopback (`127.0.0.1:`) reachable from inside a sandbox at +`http://host.sand:/`. + +## Example: Figma MCP + +The Figma desktop app exposes an MCP server at `http://127.0.0.1:3845/mcp` on +your Mac. Expose it to a sandbox: + +```sh +sand new --host-port 3845 -a claude +``` + +Inside the sandbox: + +```sh +curl -v http://host.sand:3845/mcp +``` + +Configure the agent's MCP client to use the same URL. For Claude Code: + +```sh +claude mcp add --transport http figma http://host.sand:3845/mcp +``` + +Multiple ports: + +```sh +sand new --host-port 3845 --host-port 5173 +``` + +## How it works + +Apple's `container` CLI puts each sandbox on a vmnet bridge with its own IP. +From inside the sandbox, `127.0.0.1` is the sandbox itself, not your Mac; +your Mac is the bridge gateway (typically `192.168.64.1`). + +For each requested port `--host-port` does the following: + +1. The `sand` daemon starts a TCP forwarder listening on the sandbox's + bridge gateway IP. The listener is scoped to the bridge interface only + (not `0.0.0.0`), so other machines on your LAN cannot reach it. The + forwarder targets `127.0.0.1:` on the Mac. +2. The forwarder is HTTP-aware: when a client speaks HTTP/1.x it rewrites + the `Host:` header to `127.0.0.1:` on the way to the upstream. + That keeps servers that validate `Host` (Figma's MCP among them) happy + without any client-side workarounds. Non-HTTP traffic is forwarded as + plain TCP. +3. An `/etc/hosts` entry is added inside the sandbox mapping `host.sand` + to the gateway IP, so `http://host.sand:/` Just Works. +4. Optionally, a best-effort `iptables` DNAT is installed inside the + sandbox so `127.0.0.1:` is transparently redirected to + `host.sand:`. Apple's container runtime currently does not grant + `CAP_NET_ADMIN`, so this step usually fails silently; the host.sand + path remains the supported entry point. + +Forwarders, the `/etc/hosts` entry, and any iptables rules are torn down +when the sandbox stops or is removed. + +## Security + +`--host-port` is opt-in per port, per sandbox. It does, by design, punch a +hole in the sandbox's network isolation — comparable in trust to +`--ssh-agent` forwarding. Only forward ports you would already trust the +sandbox to reach on your machine. + +## Interaction with `--allowed-domains-file` + +The eBPF egress filter installed by `--allowed-domains-file` already permits +RFC1918 destinations (including the bridge gateway IP), so no additional +allowlist entries are required. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1f37df0..8644056 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -54,6 +54,7 @@ type SandboxCreationFlags struct { EnvFile string `short:"e" default:".env" placeholder:"" help:"legacy env file path used when no default profile is configured"` Rm bool `help:"remove the sandbox after the command terminates"` AllowedDomainsFile string `placeholder:"" help:"path to allowed-domains.txt file for DNS egress filtering (overrides the init image default)"` + HostPort []int `name:"host-port" placeholder:"" help:"expose a host-loopback TCP port to the sandbox at 127.0.0.1: (repeatable)"` Mount []string `sep:"none" placeholder:"" help:"bind mount a host directory (can be specified multiple times)"` CloneMount []string `sep:"none" placeholder:"" help:"copy-on-write clone a host directory and bind mount the clone (can be specified multiple times)"` CPU int `help:"number of CPUs to allocate to the container" default:"2"` diff --git a/internal/cli/new_cmd.go b/internal/cli/new_cmd.go index c55e05d..aa5813e 100644 --- a/internal/cli/new_cmd.go +++ b/internal/cli/new_cmd.go @@ -141,6 +141,7 @@ func (c *NewCmd) Run(k *kong.Kong, cctx *CLIContext) error { Agent: c.Agent, SSHAgent: c.SSHAgent, AllowedDomains: allowedDomains, + HostPorts: append([]int(nil), c.HostPort...), Mounts: c.Mount, CloneMounts: c.CloneMount, SharedCaches: cctx.SharedCaches, diff --git a/internal/cli/oneshot_cmd.go b/internal/cli/oneshot_cmd.go index 80fdec2..1b7a7e1 100644 --- a/internal/cli/oneshot_cmd.go +++ b/internal/cli/oneshot_cmd.go @@ -103,6 +103,7 @@ func (c *OneshotCmd) Run(cctx *CLIContext) error { Agent: c.Agent, SSHAgent: c.SSHAgent, AllowedDomains: allowedDomains, + HostPorts: append([]int(nil), c.HostPort...), Mounts: c.Mount, CloneMounts: c.CloneMount, SharedCaches: cctx.SharedCaches, diff --git a/internal/daemon/daemon_client_test.go b/internal/daemon/daemon_client_test.go index 876b75f..357e2b4 100644 --- a/internal/daemon/daemon_client_test.go +++ b/internal/daemon/daemon_client_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net" "os" "path/filepath" @@ -248,6 +249,7 @@ func TestCreateSandboxOptsProtoRoundTrip(t *testing.T) { Username: "dev", Uid: "501", AllowedDomains: []string{"example.com", "api.example.com"}, + HostPorts: []int{3845, 5173}, Mounts: []string{"source=/host,target=/container,readonly"}, CloneMounts: []string{"source=/src/data,target=/data,readonly"}, SharedCaches: sandtypes.SharedCacheConfig{Mise: true, APK: true}, @@ -273,6 +275,9 @@ func TestCreateSandboxOptsProtoRoundTrip(t *testing.T) { if strings.Join(got.AllowedDomains, ",") != strings.Join(opts.AllowedDomains, ",") { t.Fatalf("round trip allowed domains = %+v, want %+v", got.AllowedDomains, opts.AllowedDomains) } + if fmt.Sprintf("%v", got.HostPorts) != fmt.Sprintf("%v", opts.HostPorts) { + t.Fatalf("round trip host ports = %+v, want %+v", got.HostPorts, opts.HostPorts) + } if strings.Join(got.Mounts, ",") != strings.Join(opts.Mounts, ",") { t.Fatalf("round trip mounts = %+v, want %+v", got.Mounts, opts.Mounts) } diff --git a/internal/daemon/daemon_grpc_streams.go b/internal/daemon/daemon_grpc_streams.go index 67f626f..8aa1e6e 100644 --- a/internal/daemon/daemon_grpc_streams.go +++ b/internal/daemon/daemon_grpc_streams.go @@ -96,6 +96,7 @@ func createSandboxOptsToProto(opts CreateSandboxOpts) *daemonpb.CreateSandboxReq Username: opts.Username, Uid: opts.Uid, AllowedDomains: append([]string(nil), opts.AllowedDomains...), + HostPorts: intsToInt32s(opts.HostPorts), Mounts: append([]string(nil), opts.Mounts...), CloneMounts: append([]string(nil), opts.CloneMounts...), SharedCaches: &daemonpb.SharedCacheConfig{ @@ -119,6 +120,7 @@ func createSandboxOptsFromProto(req *daemonpb.CreateSandboxRequest) CreateSandbo Username: req.GetUsername(), Uid: req.GetUid(), AllowedDomains: append([]string(nil), req.GetAllowedDomains()...), + HostPorts: int32sToInts(req.GetHostPorts()), Mounts: append([]string(nil), req.GetMounts()...), CloneMounts: append([]string(nil), req.GetCloneMounts()...), CPUs: int(req.GetCpus()), @@ -132,3 +134,25 @@ func createSandboxOptsFromProto(req *daemonpb.CreateSandboxRequest) CreateSandbo } return opts } + +func intsToInt32s(in []int) []int32 { + if len(in) == 0 { + return nil + } + out := make([]int32, len(in)) + for i, v := range in { + out[i] = int32(v) + } + return out +} + +func int32sToInts(in []int32) []int { + if len(in) == 0 { + return nil + } + out := make([]int, len(in)) + for i, v := range in { + out[i] = int(v) + } + return out +} diff --git a/internal/daemon/daemon_server.go b/internal/daemon/daemon_server.go index 6eb1aab..1c2bd49 100644 --- a/internal/daemon/daemon_server.go +++ b/internal/daemon/daemon_server.go @@ -596,6 +596,7 @@ type CreateSandboxOpts struct { Uid string `json:"uid,omitempty"` AllowedDomains []string `json:"allowedDomains,omitempty"` + HostPorts []int `json:"hostPorts,omitempty"` Mounts []string `json:"mounts,omitempty"` CloneMounts []string `json:"cloneMounts,omitempty"` SharedCaches sandtypes.SharedCacheConfig `json:"sharedCaches,omitempty"` @@ -707,6 +708,7 @@ func (d *Daemon) createSandbox(ctx context.Context, opts CreateSandboxOpts, prog Username: opts.Username, Uid: opts.Uid, AllowedDomains: opts.AllowedDomains, + HostPorts: opts.HostPorts, Mounts: opts.Mounts, CloneMounts: opts.CloneMounts, SharedCaches: opts.SharedCaches, diff --git a/internal/daemon/daemonpb/daemon.pb.go b/internal/daemon/daemonpb/daemon.pb.go index 0a97fc0..dd80146 100644 --- a/internal/daemon/daemonpb/daemon.pb.go +++ b/internal/daemon/daemonpb/daemon.pb.go @@ -1094,6 +1094,7 @@ type CreateSandboxRequest struct { Memory int32 `protobuf:"varint,13,opt,name=memory,proto3" json:"memory,omitempty"` ProfileName string `protobuf:"bytes,14,opt,name=profile_name,json=profileName,proto3" json:"profile_name,omitempty"` CloneMounts []string `protobuf:"bytes,15,rep,name=clone_mounts,json=cloneMounts,proto3" json:"clone_mounts,omitempty"` + HostPorts []int32 `protobuf:"varint,16,rep,packed,name=host_ports,json=hostPorts,proto3" json:"host_ports,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1233,6 +1234,13 @@ func (x *CreateSandboxRequest) GetCloneMounts() []string { return nil } +func (x *CreateSandboxRequest) GetHostPorts() []int32 { + if x != nil { + return x.HostPorts + } + return nil +} + type CreateSandboxResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Event: @@ -1544,7 +1552,7 @@ const file_internal_daemon_daemonpb_daemon_proto_rawDesc = "" + "stats_json\x18\x01 \x01(\fR\tstatsJson\"9\n" + "\x11SharedCacheConfig\x12\x12\n" + "\x04mise\x18\x01 \x01(\bR\x04mise\x12\x10\n" + - "\x03apk\x18\x02 \x01(\bR\x03apk\"\xe2\x03\n" + + "\x03apk\x18\x02 \x01(\bR\x03apk\"\x81\x04\n" + "\x14CreateSandboxRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12$\n" + "\x0eclone_from_dir\x18\x02 \x01(\tR\fcloneFromDir\x12\x1d\n" + @@ -1562,7 +1570,9 @@ const file_internal_daemon_daemonpb_daemon_proto_rawDesc = "" + "\x04cpus\x18\f \x01(\x05R\x04cpus\x12\x16\n" + "\x06memory\x18\r \x01(\x05R\x06memory\x12!\n" + "\fprofile_name\x18\x0e \x01(\tR\vprofileName\x12!\n" + - "\fclone_mounts\x18\x0f \x03(\tR\vcloneMounts\"s\n" + + "\fclone_mounts\x18\x0f \x03(\tR\vcloneMounts\x12\x1d\n" + + "\n" + + "host_ports\x18\x10 \x03(\x05R\thostPorts\"s\n" + "\x15CreateSandboxResponse\x12\x1c\n" + "\bprogress\x18\x01 \x01(\tH\x00R\bprogress\x12\x1b\n" + "\bbox_json\x18\x02 \x01(\fH\x00R\aboxJson\x12\x16\n" + diff --git a/internal/daemon/daemonpb/daemon.proto b/internal/daemon/daemonpb/daemon.proto index a62d962..b34fae0 100644 --- a/internal/daemon/daemonpb/daemon.proto +++ b/internal/daemon/daemonpb/daemon.proto @@ -134,6 +134,7 @@ message CreateSandboxRequest { int32 memory = 13; string profile_name = 14; repeated string clone_mounts = 15; + repeated int32 host_ports = 16; } message CreateSandboxResponse { diff --git a/internal/daemon/internal/boxer/boxer.go b/internal/daemon/internal/boxer/boxer.go index d6287ec..85eb9d4 100644 --- a/internal/daemon/internal/boxer/boxer.go +++ b/internal/daemon/internal/boxer/boxer.go @@ -11,6 +11,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/banksean/sand/internal/cloning" "github.com/banksean/sand/internal/db" "github.com/banksean/sand/internal/hostops" + "github.com/banksean/sand/internal/hostport" "github.com/banksean/sand/internal/runtimedeps" "github.com/banksean/sand/internal/runtimepaths" "github.com/banksean/sand/internal/sandboxlog" @@ -57,6 +59,7 @@ type Boxer struct { FileOps hostops.FileOps SSHim SSHimmer AgentRegistry *cloning.AgentRegistry + HostPortManager *hostport.Manager } type hookExecutor struct { @@ -123,6 +126,97 @@ func boxerStartHooks(hooks []sandtypes.ContainerHook) []sandtypes.ContainerHook return append(systemHooks, hooks...) } +// setupHostPorts, for each host-loopback port requested for this sandbox, +// starts a daemon-side TCP forwarder on the sandbox's bridge gateway IP and +// then attempts (best-effort) to install an iptables DNAT rule inside the +// sandbox redirecting 127.0.0.1: to :. A `host.sand` +// /etc/hosts entry is always added so reaching the service works regardless +// of iptables availability. +// +// We use ContainerService.Exec directly rather than the container hook +// abstraction so we can request uid 0. Apple's container runtime typically +// does not grant CAP_NET_ADMIN, so the iptables step will often fail even +// as root; that is logged but not fatal. +func (sber *Boxer) setupHostPorts(ctx context.Context, sb *sandtypes.Box) error { + if len(sb.HostPorts) == 0 || sber.HostPortManager == nil { + return nil + } + ctr, err := sber.GetContainer(ctx, sb.ContainerID) + if err != nil { + return fmt.Errorf("host port setup: %w", err) + } + if ctr == nil || len(ctr.Networks) == 0 { + return fmt.Errorf("host port setup: container has no network info") + } + gateway := ctr.Networks[0].IPv4Gateway + if gateway == "" { + return fmt.Errorf("host port setup: container has no IPv4 gateway") + } + if err := sber.HostPortManager.StartForSandbox(sb.ID, gateway, sb.HostPorts); err != nil { + return fmt.Errorf("host port setup: %w", err) + } + + // Always add /etc/hosts entry — this is the reliable fallback path. + // We invoke via `doas` because sand no longer execs as root by default; + // the sandbox user is in the wheel group with passwordless doas. + if _, err := sber.ContainerService.Exec(ctx, + &options.ExecContainer{}, + sb.ContainerID, "doas", os.Environ(), "sh", "-c", + buildHostSandEtcHostsScript(gateway), + ); err != nil { + slog.WarnContext(ctx, "setupHostPorts: failed to update /etc/hosts", "sandbox", sb.ID, "error", err) + } + + // Best-effort iptables DNAT so 127.0.0.1: resolves transparently. + // Apple's container runtime typically does not grant CAP_NET_ADMIN to + // containers, in which case this fails and we silently use the + // host.sand hostname path instead. The forwarder also HTTP-rewrites the + // Host header, so most clients work via host.sand: without any + // further configuration. + script := buildHostPortIptablesScript(gateway, sb.HostPorts) + out, execErr := sber.ContainerService.Exec(ctx, + &options.ExecContainer{}, + sb.ContainerID, "doas", os.Environ(), "sh", "-c", script) + if execErr != nil { + slog.InfoContext(ctx, "setupHostPorts: in-sandbox iptables unavailable; using host.sand fallback", + "sandbox", sb.ID, "error", execErr, "output", strings.TrimSpace(out)) + } else { + slog.InfoContext(ctx, "setupHostPorts: iptables installed", "sandbox", sb.ID, "gateway", gateway, "ports", sb.HostPorts) + } + fmt.Printf("[sand] host services exposed at http://host.sand:/ (ports: %v)\n", sb.HostPorts) + return nil +} + +// buildHostSandEtcHostsScript returns a shell snippet that, idempotently, +// inserts/refreshes a `\thost.sand` line in /etc/hosts. +func buildHostSandEtcHostsScript(gatewayIP string) string { + return "sed -i.bak '/[[:space:]]host\\.sand$/d' /etc/hosts 2>/dev/null || true; " + + "printf '%s\\thost.sand\\n' " + gatewayIP + " >> /etc/hosts" +} + +func buildHostPortIptablesScript(gatewayIP string, ports []int) string { + var b strings.Builder + b.WriteString("set -e\n") + // Enable redirecting loopback-destined packets via DNAT. + b.WriteString("sysctl -w net.ipv4.conf.all.route_localnet=1 >/dev/null\n") + b.WriteString("sysctl -w net.ipv4.conf.lo.route_localnet=1 >/dev/null\n") + for _, p := range ports { + ps := strconv.Itoa(p) + // Output chain handles locally-generated traffic to 127.0.0.1:. + b.WriteString("iptables -t nat -C OUTPUT -p tcp -d 127.0.0.1 --dport " + ps + + " -j DNAT --to-destination " + gatewayIP + ":" + ps + + " 2>/dev/null || iptables -t nat -A OUTPUT -p tcp -d 127.0.0.1 --dport " + ps + + " -j DNAT --to-destination " + gatewayIP + ":" + ps + "\n") + // SNAT the return path so connections sourced from loopback get the + // correct source IP when reaching the host. + b.WriteString("iptables -t nat -C POSTROUTING -p tcp -d " + gatewayIP + + " --dport " + ps + " -j MASQUERADE" + + " 2>/dev/null || iptables -t nat -A POSTROUTING -p tcp -d " + gatewayIP + + " --dport " + ps + " -j MASQUERADE\n") + } + return b.String() +} + func innieSocketPermissionHook() sandtypes.ContainerHook { return sandtypes.NewContainerHook("repair host service socket permissions", func(ctx context.Context, ctr *types.Container, exec sandtypes.HookStreamer) error { out, err := exec.Exec(ctx, "sh", "-c", innieSocketPermissionScript) @@ -176,6 +270,7 @@ func NewBoxerWithDeps(appRoot string, deps BoxerDeps) (*Boxer, error) { FileOps: deps.FileOps, SSHim: deps.SSHim, AgentRegistry: deps.AgentRegistry, + HostPortManager: hostport.NewManager(), }, nil } @@ -210,11 +305,15 @@ func NewBoxer(appRoot, localDomain string, terminalWriter io.Writer) (*Boxer, er FileOps: fileOps, SSHim: sshim, AgentRegistry: agentRegistry, + HostPortManager: hostport.NewManager(), } return sb, nil } func (sb *Boxer) Close() error { + if sb.HostPortManager != nil { + sb.HostPortManager.StopAll() + } if sb.sqlDB != nil { return sb.sqlDB.Close() } @@ -307,6 +406,7 @@ type NewSandboxOpts struct { Username string Uid string AllowedDomains []string + HostPorts []int Mounts []string CloneMounts []string SharedCaches sandtypes.SharedCacheConfig @@ -412,6 +512,7 @@ func (sb *Boxer) NewSandbox(ctx context.Context, opts NewSandboxOpts) (*sandtype DNSDomain: opts.LocalDomain, EnvFile: envFile, AllowedDomains: opts.AllowedDomains, + HostPorts: append([]int(nil), opts.HostPorts...), MountRequests: mountRequests, SharedCacheMounts: sharedCacheMounts, Mounts: append(mounts, sshKeysMountSpec), @@ -554,6 +655,10 @@ func (sb *Boxer) SoftDelete(ctx context.Context, sbox *sandtypes.Box) error { ctx = sandboxlog.WithSandboxID(ctx, sbox.ID) slog.InfoContext(ctx, "Boxer.SoftDelete", "id", sbox.ID, "name", sbox.Name) + if sb.HostPortManager != nil { + sb.HostPortManager.StopForSandbox(sbox.ID) + } + out, err := sb.ContainerService.Stop(ctx, nil, sbox.ContainerID) if err != nil { slog.ErrorContext(ctx, "Boxer Containers.Stop", "error", err, "out", out) @@ -671,6 +776,7 @@ func (sb *Boxer) sandboxFromDB(s *db.Sandbox) *sandtypes.Box { DNSDomain: fromNullString(s.DnsDomain), EnvFile: fromNullString(s.EnvFile), AllowedDomains: domainsFromNullString(s.AllowedDomains), + HostPorts: hostPortsFromNullString(s.HostPorts), MountRequests: mountRequests, OriginalGitDetails: &sandtypes.GitDetails{ RemoteOrigin: fromNullString(s.OriginalGitOrigin), @@ -758,6 +864,37 @@ func domainsFromNullString(ns sql.NullString) []string { return domains } +func hostPortsToNullString(ports []int) sql.NullString { + if len(ports) == 0 { + return sql.NullString{} + } + parts := make([]string, 0, len(ports)) + for _, p := range ports { + parts = append(parts, strconv.Itoa(p)) + } + return sql.NullString{String: strings.Join(parts, ","), Valid: true} +} + +func hostPortsFromNullString(ns sql.NullString) []int { + if !ns.Valid || ns.String == "" { + return nil + } + var out []int + for _, s := range strings.Split(ns.String, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + n, err := strconv.Atoi(s) + if err != nil { + slog.Warn("failed to parse host port", "value", s, "error", err) + continue + } + out = append(out, n) + } + return out +} + func (sb *Boxer) getContainer(ctx context.Context, containerID string) (interface{}, error) { ctrs, err := sb.ContainerService.Inspect(ctx, containerID) if err != nil { @@ -952,7 +1089,10 @@ func (sber *Boxer) StartNewContainer(ctx context.Context, sb *sandtypes.Box, pro return err } - return sber.executeHooks(ctx, sb, hooks, progress) + if err := sber.executeHooks(ctx, sb, hooks, progress); err != nil { + return err + } + return sber.setupHostPorts(ctx, sb) } // StartExistingContainer starts an existing (previously-started) container instance. @@ -979,7 +1119,10 @@ func (sber *Boxer) StartExistingContainer(ctx context.Context, sb *sandtypes.Box return err } - return sber.executeHooks(ctx, sb, hooks, nil) + if err := sber.executeHooks(ctx, sb, hooks, nil); err != nil { + return err + } + return sber.setupHostPorts(ctx, sb) } func (sb *Boxer) startContainerProcess(ctx context.Context, sandboxID, containerID string) error { @@ -1121,6 +1264,7 @@ func (sb *Boxer) SaveSandbox(ctx context.Context, sbox *sandtypes.Box) error { AgentType: toNullString(sbox.AgentType), ProfileName: toNullString(sbox.ProfileName), AllowedDomains: domainsToNullString(sbox.AllowedDomains), + HostPorts: hostPortsToNullString(sbox.HostPorts), MountSpecs: mountRequestsToNullString(sbox.MountRequests), Cpu: toNullInt(sbox.CPUs), MemoryMb: toNullInt(sbox.MemoryMB), @@ -1162,6 +1306,10 @@ func (sb *Boxer) StopContainer(ctx context.Context, sbox *sandtypes.Box) error { return fmt.Errorf("sandbox %s has no container ID", sbox.ID) } + if sb.HostPortManager != nil { + sb.HostPortManager.StopForSandbox(sbox.ID) + } + out, err := sb.ContainerService.Stop(ctx, nil, sbox.ContainerID) if err != nil { slog.ErrorContext(ctx, "Boxer.StopContainer", "containerID", sbox.ContainerID, "error", err, "out", out) diff --git a/internal/daemon/internal/boxer/hostport_test.go b/internal/daemon/internal/boxer/hostport_test.go new file mode 100644 index 0000000..48fc0b6 --- /dev/null +++ b/internal/daemon/internal/boxer/hostport_test.go @@ -0,0 +1,40 @@ +package boxer + +import ( + "strings" + "testing" +) + +func TestBuildHostPortIptablesScript(t *testing.T) { + script := buildHostPortIptablesScript("192.168.64.1", []int{3845, 5173}) + wantSubstrings := []string{ + "route_localnet=1", + "iptables -t nat -A OUTPUT -p tcp -d 127.0.0.1 --dport 3845 -j DNAT --to-destination 192.168.64.1:3845", + "iptables -t nat -A OUTPUT -p tcp -d 127.0.0.1 --dport 5173 -j DNAT --to-destination 192.168.64.1:5173", + "iptables -t nat -A POSTROUTING -p tcp -d 192.168.64.1 --dport 3845 -j MASQUERADE", + "iptables -t nat -A POSTROUTING -p tcp -d 192.168.64.1 --dport 5173 -j MASQUERADE", + "iptables -t nat -C OUTPUT", // idempotency check + } + for _, s := range wantSubstrings { + if !strings.Contains(script, s) { + t.Errorf("script missing %q\nfull:\n%s", s, script) + } + } +} + +func TestHostPortsRoundTrip(t *testing.T) { + ns := hostPortsToNullString([]int{1, 22, 3845}) + if !ns.Valid || ns.String != "1,22,3845" { + t.Fatalf("hostPortsToNullString = %+v", ns) + } + got := hostPortsFromNullString(ns) + if len(got) != 3 || got[0] != 1 || got[1] != 22 || got[2] != 3845 { + t.Fatalf("round trip = %v", got) + } + if hostPortsToNullString(nil).Valid { + t.Fatalf("empty should be NULL") + } + if hostPortsFromNullString(hostPortsToNullString(nil)) != nil { + t.Fatalf("empty round trip should be nil") + } +} diff --git a/internal/db/migrations/000010_host_ports.down.sql b/internal/db/migrations/000010_host_ports.down.sql new file mode 100644 index 0000000..1218536 --- /dev/null +++ b/internal/db/migrations/000010_host_ports.down.sql @@ -0,0 +1 @@ +ALTER TABLE sandboxes DROP COLUMN host_ports; diff --git a/internal/db/migrations/000010_host_ports.up.sql b/internal/db/migrations/000010_host_ports.up.sql new file mode 100644 index 0000000..0db829d --- /dev/null +++ b/internal/db/migrations/000010_host_ports.up.sql @@ -0,0 +1 @@ +ALTER TABLE sandboxes ADD COLUMN host_ports TEXT; diff --git a/internal/db/models.go b/internal/db/models.go index 6223355..8c061d2 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -34,4 +34,5 @@ type Sandbox struct { TrashWorkDir sql.NullString `json:"trash_work_dir"` ProfileName sql.NullString `json:"profile_name"` MountSpecs sql.NullString `json:"mount_specs"` + HostPorts sql.NullString `json:"host_ports"` } diff --git a/internal/db/queries.sql b/internal/db/queries.sql index a977388..455dd6b 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -18,10 +18,10 @@ INSERT INTO sandboxes ( id, name, state, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, agent_type, profile_name, original_git_origin, original_git_branch, original_git_commit, - original_git_is_dirty, allowed_domains, mount_specs, + original_git_is_dirty, allowed_domains, host_ports, mount_specs, cpu, memory_mb, default_username, default_uid, deleted_at, trash_work_dir -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, state = excluded.state, @@ -39,6 +39,7 @@ ON CONFLICT(id) DO UPDATE SET original_git_commit = excluded.original_git_commit, original_git_is_dirty = excluded.original_git_is_dirty, allowed_domains = excluded.allowed_domains, + host_ports = excluded.host_ports, mount_specs = excluded.mount_specs, cpu = excluded.cpu, memory_mb = excluded.memory_mb, diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index b6f5007..4c2a0a8 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeleteSandbox(ctx context.Context, id string) error { } const getActiveSandboxByName = `-- name: GetActiveSandboxByName :one -SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs FROM sandboxes +SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs, host_ports FROM sandboxes WHERE name = ? AND state = 'active' LIMIT 1 ` @@ -55,12 +55,13 @@ func (q *Queries) GetActiveSandboxByName(ctx context.Context, name string) (Sand &i.TrashWorkDir, &i.ProfileName, &i.MountSpecs, + &i.HostPorts, ) return i, err } const getSandboxByID = `-- name: GetSandboxByID :one -SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs FROM sandboxes +SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs, host_ports FROM sandboxes WHERE id = ? LIMIT 1 ` @@ -94,12 +95,13 @@ func (q *Queries) GetSandboxByID(ctx context.Context, id string) (Sandbox, error &i.TrashWorkDir, &i.ProfileName, &i.MountSpecs, + &i.HostPorts, ) return i, err } const getSandboxesByImage = `-- name: GetSandboxesByImage :many -SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs FROM sandboxes +SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs, host_ports FROM sandboxes WHERE image_name = ? AND state = 'active' ORDER BY created_at DESC ` @@ -139,6 +141,7 @@ func (q *Queries) GetSandboxesByImage(ctx context.Context, imageName string) ([] &i.TrashWorkDir, &i.ProfileName, &i.MountSpecs, + &i.HostPorts, ); err != nil { return nil, err } @@ -154,7 +157,7 @@ func (q *Queries) GetSandboxesByImage(ctx context.Context, imageName string) ([] } const listSandboxes = `-- name: ListSandboxes :many -SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs FROM sandboxes +SELECT id, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, created_at, updated_at, agent_type, original_git_origin, original_git_branch, original_git_commit, original_git_is_dirty, allowed_domains, cpu, memory_mb, default_username, default_uid, name, state, deleted_at, trash_work_dir, profile_name, mount_specs, host_ports FROM sandboxes WHERE state = 'active' ORDER BY created_at DESC ` @@ -194,6 +197,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { &i.TrashWorkDir, &i.ProfileName, &i.MountSpecs, + &i.HostPorts, ); err != nil { return nil, err } @@ -250,10 +254,10 @@ INSERT INTO sandboxes ( id, name, state, container_id, host_origin_dir, sandbox_work_dir, image_name, dns_domain, env_file, agent_type, profile_name, original_git_origin, original_git_branch, original_git_commit, - original_git_is_dirty, allowed_domains, mount_specs, + original_git_is_dirty, allowed_domains, host_ports, mount_specs, cpu, memory_mb, default_username, default_uid, deleted_at, trash_work_dir -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, state = excluded.state, @@ -271,6 +275,7 @@ ON CONFLICT(id) DO UPDATE SET original_git_commit = excluded.original_git_commit, original_git_is_dirty = excluded.original_git_is_dirty, allowed_domains = excluded.allowed_domains, + host_ports = excluded.host_ports, mount_specs = excluded.mount_specs, cpu = excluded.cpu, memory_mb = excluded.memory_mb, @@ -297,6 +302,7 @@ type UpsertSandboxParams struct { OriginalGitCommit sql.NullString `json:"original_git_commit"` OriginalGitIsDirty bool `json:"original_git_is_dirty"` AllowedDomains sql.NullString `json:"allowed_domains"` + HostPorts sql.NullString `json:"host_ports"` MountSpecs sql.NullString `json:"mount_specs"` Cpu sql.NullInt64 `json:"cpu"` MemoryMb sql.NullInt64 `json:"memory_mb"` @@ -324,6 +330,7 @@ func (q *Queries) UpsertSandbox(ctx context.Context, arg UpsertSandboxParams) er arg.OriginalGitCommit, arg.OriginalGitIsDirty, arg.AllowedDomains, + arg.HostPorts, arg.MountSpecs, arg.Cpu, arg.MemoryMb, diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 036a122..4daaa69 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS sandboxes ( original_git_commit TEXT, original_git_is_dirty BOOLEAN NOT NULL DEFAULT 0, allowed_domains TEXT, + host_ports TEXT, mount_specs TEXT, default_username TEXT, default_uid TEXT, diff --git a/internal/hostport/forwarder.go b/internal/hostport/forwarder.go new file mode 100644 index 0000000..09da7dc --- /dev/null +++ b/internal/hostport/forwarder.go @@ -0,0 +1,277 @@ +// Package hostport provides a small TCP forwarder used to expose host-loopback +// services into sand sandboxes. +// +// Apple's container CLI puts each sandbox on a vmnet bridge with its own IP. +// Inside the sandbox, 127.0.0.1 is the sandbox itself; the Mac is the gateway +// IP on that bridge. Services bound to 127.0.0.1 on the Mac (e.g. Figma's MCP +// at 127.0.0.1:3845) are unreachable from the sandbox. +// +// A Forwarder listens on a bridge-facing host IP (the gateway IP) and forwards +// connections to a target on host loopback. Combined with an in-sandbox +// iptables DNAT rule that rewrites 127.0.0.1: to :, this +// gives the agent the illusion of reaching the service on its own loopback. +package hostport + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strconv" + "sync" +) + +// Forwarder is a single TCP listener that accepts connections on ListenAddr +// and proxies each one to TargetAddr. +// +// If RewriteHTTPHost is non-empty and the client speaks HTTP/1.x, the Host +// header on each request is rewritten to RewriteHTTPHost before being +// forwarded upstream. This lets a sandbox client say it's talking to +// host.sand:3845 while the upstream service (which only accepts +// 127.0.0.1:3845) sees the Host header it expects. +type Forwarder struct { + ListenAddr string + TargetAddr string + RewriteHTTPHost string + + listener net.Listener + wg sync.WaitGroup + closed chan struct{} + once sync.Once +} + +// Start binds the listener and begins accepting in a background goroutine. +// It returns an error if the bind fails. +func (f *Forwarder) Start() error { + ln, err := net.Listen("tcp", f.ListenAddr) + if err != nil { + return fmt.Errorf("hostport: listen %s: %w", f.ListenAddr, err) + } + f.listener = ln + f.closed = make(chan struct{}) + f.wg.Add(1) + go f.acceptLoop() + return nil +} + +func (f *Forwarder) acceptLoop() { + defer f.wg.Done() + for { + conn, err := f.listener.Accept() + if err != nil { + select { + case <-f.closed: + return + default: + } + slog.Warn("hostport: accept error", "listen", f.ListenAddr, "error", err) + return + } + f.wg.Add(1) + go func() { + defer f.wg.Done() + f.handle(conn) + }() + } +} + +func (f *Forwarder) handle(client net.Conn) { + defer client.Close() + upstream, err := net.Dial("tcp", f.TargetAddr) + if err != nil { + slog.Warn("hostport: dial upstream failed", "target", f.TargetAddr, "error", err) + return + } + defer upstream.Close() + + // Peek the first few bytes from the client. If they look like an HTTP + // request line and Host rewriting is enabled, run an HTTP-aware loop. + // Otherwise fall through to a plain bidirectional pipe. + br := bufio.NewReader(client) + httpMode := false + if f.RewriteHTTPHost != "" { + peek, _ := br.Peek(8) + httpMode = looksLikeHTTPRequest(peek) + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if httpMode { + if err := proxyHTTPRequests(br, upstream, f.RewriteHTTPHost); err != nil && err != io.EOF { + slog.Debug("hostport: http proxy ended", "target", f.TargetAddr, "error", err) + } + } else { + _, _ = io.Copy(upstream, br) + } + if tc, ok := upstream.(*net.TCPConn); ok { + _ = tc.CloseWrite() + } + }() + go func() { + defer wg.Done() + _, _ = io.Copy(client, upstream) + if tc, ok := client.(*net.TCPConn); ok { + _ = tc.CloseWrite() + } + }() + wg.Wait() +} + +var httpMethods = [][]byte{ + []byte("GET "), []byte("HEAD "), []byte("POST "), []byte("PUT "), + []byte("DELETE "), []byte("OPTIONS "), []byte("PATCH "), []byte("CONNECT "), + []byte("TRACE "), +} + +func looksLikeHTTPRequest(b []byte) bool { + for _, m := range httpMethods { + if bytes.HasPrefix(b, m) { + return true + } + } + return false +} + +// proxyHTTPRequests reads HTTP/1.x requests from br, rewrites the Host header +// to host, and writes them to w. It runs until the connection closes or an +// unrecoverable error occurs. WebSocket and other Upgrade requests are +// forwarded with Host rewritten and the remaining stream is copied raw. +func proxyHTTPRequests(br *bufio.Reader, w io.Writer, host string) error { + for { + req, err := http.ReadRequest(br) + if err != nil { + return err + } + req.Host = host + // http.Request.Write uses req.Host for the Host header and + // req.URL.RequestURI() for the request line. RequestURI is + // preserved unchanged. + if err := req.Write(w); err != nil { + return err + } + if isUpgrade(req) { + // Drain remaining client bytes raw — the protocol has switched. + _, err := io.Copy(w, br) + return err + } + } +} + +func isUpgrade(r *http.Request) bool { + if r == nil { + return false + } + for _, v := range r.Header.Values("Connection") { + if containsToken(v, "upgrade") { + return true + } + } + return false +} + +func containsToken(headerValue, token string) bool { + for _, part := range bytes.Split([]byte(headerValue), []byte{','}) { + if string(bytes.ToLower(bytes.TrimSpace(part))) == token { + return true + } + } + return false +} + +// Close stops accepting new connections and waits for in-flight connections +// to finish. Safe to call multiple times. +func (f *Forwarder) Close() error { + var closeErr error + f.once.Do(func() { + if f.closed != nil { + close(f.closed) + } + if f.listener != nil { + closeErr = f.listener.Close() + } + f.wg.Wait() + }) + return closeErr +} + +// Manager tracks active Forwarders by sandbox ID so they can be torn down +// when a sandbox stops or is removed. +type Manager struct { + mu sync.Mutex + fwd map[string][]*Forwarder +} + +func NewManager() *Manager { + return &Manager{fwd: map[string][]*Forwarder{}} +} + +// StartForSandbox starts one forwarder per port. ListenIP is the bridge-facing +// host IP (gateway IP of the sandbox's network); ports are the host-loopback +// ports to expose. Already-running forwarders for sandboxID are stopped first. +// +// Each forwarder rewrites the HTTP Host header on incoming requests to +// 127.0.0.1:, so clients pointed at host.sand: (or another +// gateway-resolved name) reach upstreams that expect a loopback Host header. +func (m *Manager) StartForSandbox(sandboxID, listenIP string, ports []int) error { + return m.startForSandbox(sandboxID, listenIP, "127.0.0.1", ports) +} + +func (m *Manager) startForSandbox(sandboxID, listenIP, targetIP string, ports []int) error { + m.StopForSandbox(sandboxID) + if listenIP == "" || len(ports) == 0 { + return nil + } + var started []*Forwarder + for _, p := range ports { + targetAddr := net.JoinHostPort(targetIP, strconv.Itoa(p)) + f := &Forwarder{ + ListenAddr: net.JoinHostPort(listenIP, strconv.Itoa(p)), + TargetAddr: targetAddr, + RewriteHTTPHost: targetAddr, + } + if err := f.Start(); err != nil { + for _, x := range started { + _ = x.Close() + } + return fmt.Errorf("hostport: start forwarder for port %d: %w", p, err) + } + slog.Info("hostport: forwarder started", "sandbox", sandboxID, "listen", f.ListenAddr, "target", f.TargetAddr) + started = append(started, f) + } + m.mu.Lock() + m.fwd[sandboxID] = started + m.mu.Unlock() + return nil +} + +// StopForSandbox closes any forwarders for sandboxID. Safe if none exist. +func (m *Manager) StopForSandbox(sandboxID string) { + m.mu.Lock() + fwds := m.fwd[sandboxID] + delete(m.fwd, sandboxID) + m.mu.Unlock() + for _, f := range fwds { + if err := f.Close(); err != nil { + slog.Warn("hostport: close forwarder", "sandbox", sandboxID, "listen", f.ListenAddr, "error", err) + } + } +} + +// StopAll closes every forwarder the manager knows about. Used at daemon +// shutdown. +func (m *Manager) StopAll() { + m.mu.Lock() + ids := make([]string, 0, len(m.fwd)) + for id := range m.fwd { + ids = append(ids, id) + } + m.mu.Unlock() + for _, id := range ids { + m.StopForSandbox(id) + } +} diff --git a/internal/hostport/forwarder_test.go b/internal/hostport/forwarder_test.go new file mode 100644 index 0000000..0244703 --- /dev/null +++ b/internal/hostport/forwarder_test.go @@ -0,0 +1,225 @@ +package hostport + +import ( + "io" + "net" + "strconv" + "strings" + "testing" + "time" +) + +// startEcho starts a localhost TCP server on a free port that echoes everything +// it reads back to the client. Returns the chosen port and a cleanup func. +func startEcho(t *testing.T) (int, func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo: %v", err) + } + done := make(chan struct{}) + go func() { + for { + c, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + go func() { + defer c.Close() + _, _ = io.Copy(c, c) + }() + } + }() + port := ln.Addr().(*net.TCPAddr).Port + return port, func() { + close(done) + _ = ln.Close() + } +} + +func TestForwarderProxiesTraffic(t *testing.T) { + port, stop := startEcho(t) + defer stop() + + f := &Forwarder{ + ListenAddr: "127.0.0.1:0", + TargetAddr: "127.0.0.1:" + strconv.Itoa(port), + } + // Start with a real bound address. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen test: %v", err) + } + listenAddr := ln.Addr().String() + _ = ln.Close() + f.ListenAddr = listenAddr + if err := f.Start(); err != nil { + t.Fatalf("start forwarder: %v", err) + } + defer f.Close() + + c, err := net.DialTimeout("tcp", listenAddr, 2*time.Second) + if err != nil { + t.Fatalf("dial forwarder: %v", err) + } + defer c.Close() + + msg := "hello sandbox\n" + if _, err := c.Write([]byte(msg)); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, len(msg)) + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadFull(c, buf); err != nil { + t.Fatalf("read echo: %v", err) + } + if got := string(buf); got != msg { + t.Fatalf("echo mismatch: got %q want %q", got, msg) + } +} + +func TestManagerLifecycle(t *testing.T) { + port, stop := startEcho(t) + defer stop() + + // Echo binds to 127.0.0.1; have the manager listen on a different loopback + // alias (127.0.0.2) so it doesn't collide on the same port. + listenIP := "127.0.0.2" + if probe, err := net.Listen("tcp", listenIP+":0"); err != nil { + t.Skipf("%s not available as loopback alias: %v", listenIP, err) + } else { + _ = probe.Close() + } + + m := NewManager() + if err := m.startForSandbox("sb1", listenIP, "127.0.0.1", []int{port}); err != nil { + t.Fatalf("start: %v", err) + } + + // Look up the listener we created. + m.mu.Lock() + fwds := m.fwd["sb1"] + m.mu.Unlock() + if len(fwds) != 1 { + t.Fatalf("want 1 forwarder, got %d", len(fwds)) + } + addr := fwds[0].listener.Addr().String() + + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial via manager: %v", err) + } + c.Close() + + m.StopForSandbox("sb1") + m.mu.Lock() + _, exists := m.fwd["sb1"] + m.mu.Unlock() + if exists { + t.Fatalf("sb1 should be gone from manager") + } + + // Dialing should now fail. + if _, err := net.DialTimeout("tcp", addr, 200*time.Millisecond); err == nil { + t.Fatalf("expected dial to fail after stop") + } +} + +func TestManagerRejectsBadIP(t *testing.T) { + m := NewManager() + err := m.StartForSandbox("sb", "203.0.113.255", []int{1}) // unbindable + if err == nil { + t.Fatalf("expected error binding to unreachable IP") + } + if !strings.Contains(err.Error(), "start forwarder") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestForwarderRewritesHTTPHost(t *testing.T) { + // Upstream HTTP server that echoes back the Host header it saw. + up, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen upstream: %v", err) + } + defer up.Close() + go func() { + for { + c, err := up.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 4096) + n, _ := c.Read(buf) + got := string(buf[:n]) + host := "" + for _, line := range strings.Split(got, "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "host:") { + host = strings.TrimSpace(line[5:]) + } + } + body := "host=" + host + resp := "HTTP/1.1 200 OK\r\nContent-Length: " + strconv.Itoa(len(body)) + + "\r\nConnection: close\r\n\r\n" + body + _, _ = c.Write([]byte(resp)) + }(c) + } + }() + + upAddr := up.Addr().String() + f := &Forwarder{ + ListenAddr: "127.0.0.1:0", + TargetAddr: upAddr, + RewriteHTTPHost: upAddr, + } + // Bind a real port. + probe, _ := net.Listen("tcp", "127.0.0.1:0") + fwdAddr := probe.Addr().String() + _ = probe.Close() + f.ListenAddr = fwdAddr + if err := f.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer f.Close() + + c, err := net.DialTimeout("tcp", fwdAddr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + // Send a request with Host: host.sand. + req := "GET /mcp HTTP/1.1\r\nHost: host.sand:3845\r\nUser-Agent: t\r\n\r\n" + if _, err := c.Write([]byte(req)); err != nil { + t.Fatalf("write: %v", err) + } + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + resp, err := io.ReadAll(c) + if err != nil { + t.Fatalf("read: %v", err) + } + want := "host=" + upAddr + if !strings.Contains(string(resp), want) { + t.Fatalf("upstream saw wrong host. response:\n%s\nwant body containing %q", resp, want) + } +} + +func TestLooksLikeHTTPRequest(t *testing.T) { + cases := map[string]bool{ + "GET / HTTP": true, + "POST /x": true, + "HELLO": false, + "\x00\x01\x02": false, + } + for in, want := range cases { + if got := looksLikeHTTPRequest([]byte(in)); got != want { + t.Errorf("looksLikeHTTPRequest(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/sandtypes/box.go b/internal/sandtypes/box.go index 9afb4ab..d6a02cc 100644 --- a/internal/sandtypes/box.go +++ b/internal/sandtypes/box.go @@ -46,6 +46,11 @@ type Box struct { // AllowedDomains is the list of domains the sandbox container is permitted to contact. // When non-empty, this overrides the default allowlist baked into the init image. AllowedDomains []string + // HostPorts is the list of host-loopback TCP ports to expose to the sandbox + // as if they were running on the sandbox's own loopback. Each port spawns a + // daemon-side forwarder bound to the sandbox's bridge gateway IP, and an + // iptables DNAT rule inside the sandbox redirecting 127.0.0.1: there. + HostPorts []int // Mounts defines bind mounts that should be attached when creating the container. Mounts []MountSpec // MountRequests records user-requested direct and cloned bind mount metadata.