From 6363fc0d0c04566f2dc67f9d2ee097a7ac2ea42c Mon Sep 17 00:00:00 2001 From: Erik Swedberg Date: Thu, 21 May 2026 23:18:08 +0000 Subject: [PATCH 1/4] Add --host-port to expose host loopback services into sandboxes Apple's container CLI puts each sandbox on a vmnet bridge with its own IP. Services bound to 127.0.0.1 on the host (like the Figma desktop app's MCP server at 127.0.0.1:3845) are therefore unreachable from inside a sandbox. This adds a repeatable --host-port flag to 'sand new' (and the oneshot/exec creation paths). For each requested port: * The daemon spawns an in-process TCP forwarder bound to the sandbox's bridge gateway IP, forwarding to 127.0.0.1: on the host. The listener is scoped to the bridge interface (not 0.0.0.0). * An iptables DNAT + MASQUERADE rule is installed inside the sandbox so 127.0.0.1: is rewritten to :, with route_localnet=1 to allow the kernel to route the redirected loopback packet. The agent sees the service at the same loopback address it would use on the host -- no client reconfiguration. Lifecycle: forwarders and rules are set up by a start hook and torn down on StopContainer / SoftDelete / daemon Close. * new package internal/hostport: Forwarder + Manager * new migration 000010_host_ports + sqlc regen * proto: CreateSandboxRequest.host_ports (field 16) * doc/HOST_SERVICES.md Co-authored-by: Shelley --- README.md | 1 + cmd/sand/HELP.md | 3 + doc/HOST_SERVICES.md | 62 ++++++ internal/cli/cli.go | 1 + internal/cli/new_cmd.go | 1 + internal/cli/oneshot_cmd.go | 1 + internal/daemon/daemon_client_test.go | 5 + internal/daemon/daemon_grpc_streams.go | 24 +++ internal/daemon/daemon_server.go | 2 + internal/daemon/daemonpb/daemon.pb.go | 14 +- internal/daemon/daemonpb/daemon.proto | 1 + internal/daemon/internal/boxer/boxer.go | 115 +++++++++++ .../daemon/internal/boxer/hostport_test.go | 40 ++++ .../db/migrations/000010_host_ports.down.sql | 1 + .../db/migrations/000010_host_ports.up.sql | 1 + internal/db/models.go | 1 + internal/db/queries.sql | 5 +- internal/db/queries.sql.go | 19 +- internal/db/schema.sql | 1 + internal/hostport/forwarder.go | 184 ++++++++++++++++++ internal/hostport/forwarder_test.go | 142 ++++++++++++++ internal/sandtypes/box.go | 5 + 22 files changed, 619 insertions(+), 10 deletions(-) create mode 100644 doc/HOST_SERVICES.md create mode 100644 internal/daemon/internal/boxer/hostport_test.go create mode 100644 internal/db/migrations/000010_host_ports.down.sql create mode 100644 internal/db/migrations/000010_host_ports.up.sql create mode 100644 internal/hostport/forwarder.go create mode 100644 internal/hostport/forwarder_test.go 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..f9e13e6 --- /dev/null +++ b/doc/HOST_SERVICES.md @@ -0,0 +1,62 @@ +# Exposing host services to a sandbox + +The `--host-port` flag exposes a TCP service bound to your Mac's loopback +(`127.0.0.1:`) to a sandbox at the *same* address inside the container. +An agent running in the sandbox can talk to the service using exactly the +configuration it would use on the host — no MCP/client reconfiguration +required. + +## Example: Figma MCP + +The Figma desktop app exposes an MCP server at `http://127.0.0.1:3845/mcp` on +your Mac. To make it reachable from inside a sandbox: + +```sh +sand new --host-port 3845 -a claude +``` + +Inside the sandbox, point any MCP client at the usual URL: + +``` +http://127.0.0.1:3845/mcp +``` + +The flag is repeatable: + +```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. +Inside the sandbox, `127.0.0.1` is the sandbox itself, not your Mac. The Mac +is the bridge gateway (typically `192.168.64.1`). + +For each requested port `--host-port` does two things: + +1. The `sand` daemon spawns a TCP forwarder bound to the sandbox's bridge + gateway IP on the host. The listener forwards to `127.0.0.1:` on the + host. The listener is scoped to the bridge interface — it is **not** bound + to `0.0.0.0`, so other machines on your LAN cannot reach it. +2. The daemon installs an `iptables` DNAT rule inside the sandbox that + rewrites `127.0.0.1:` to `:` and a matching + `MASQUERADE` rule for the return path. `net.ipv4.conf.{all,lo}.route_localnet` + is set to `1` so the kernel will route the redirected packet out of the + loopback interface. + +Both the forwarder and the iptables rule are torn down when the sandbox +stops or is removed. + +## Security notes + +`--host-port` is opt-in per port, per sandbox. It does, by design, punch a +hole in the sandbox's network isolation — comparable to `--ssh-agent` +forwarding. Only forward ports you would already trust the sandbox to reach +on your own 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..2d7da54 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,68 @@ func boxerStartHooks(hooks []sandtypes.ContainerHook) []sandtypes.ContainerHook return append(systemHooks, hooks...) } +// hostPortSetupHook returns a hook that, for each host-loopback port requested +// for this sandbox, starts a daemon-side TCP forwarder on the sandbox's bridge +// gateway IP and installs an iptables DNAT rule inside the sandbox redirecting +// 127.0.0.1: there. The net effect: the agent can reach host services on +// 127.0.0.1: as if they were running on the sandbox itself. +func hostPortSetupHook(sb *sandtypes.Box, mgr *hostport.Manager) sandtypes.ContainerHook { + return sandtypes.NewContainerHook("set up host port forwards", func(ctx context.Context, ctr *types.Container, exec sandtypes.HookStreamer) error { + if len(sb.HostPorts) == 0 || mgr == nil { + return nil + } + 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 := mgr.StartForSandbox(sb.ID, gateway, sb.HostPorts); err != nil { + return fmt.Errorf("host port setup: %w", err) + } + + // Install in-sandbox DNAT so 127.0.0.1: -> :. + // We also need route_localnet=1 because the destination of a DNAT'd + // loopback packet would otherwise be considered martian. + script := buildHostPortIptablesScript(gateway, sb.HostPorts) + out, err := exec.Exec(ctx, "sh", "-c", script) + if err != nil { + // If iptables fails, tear down the host-side listeners so we don't + // leave a half-configured forward in place. + mgr.StopForSandbox(sb.ID) + if out != "" { + return fmt.Errorf("host port setup iptables: %w: %s", err, strings.TrimSpace(out)) + } + return fmt.Errorf("host port setup iptables: %w", err) + } + return nil + }) +} + +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 +241,7 @@ func NewBoxerWithDeps(appRoot string, deps BoxerDeps) (*Boxer, error) { FileOps: deps.FileOps, SSHim: deps.SSHim, AgentRegistry: deps.AgentRegistry, + HostPortManager: hostport.NewManager(), }, nil } @@ -210,11 +276,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 +377,7 @@ type NewSandboxOpts struct { Username string Uid string AllowedDomains []string + HostPorts []int Mounts []string CloneMounts []string SharedCaches sandtypes.SharedCacheConfig @@ -412,6 +483,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 +626,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 +747,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 +835,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 { @@ -946,6 +1054,7 @@ func (sber *Boxer) StartNewContainer(ctx context.Context, sb *sandtypes.Box, pro // Get agent config to reconstruct hooks agentConfig := sber.AgentRegistry.Get(sb.AgentType) hooks := boxerStartHooks(agentConfig.Configuration.GetFirstStartHooks(artifacts)) + hooks = append(hooks, hostPortSetupHook(sb, sber.HostPortManager)) slog.InfoContext(ctx, "Boxer.StartNewContainer", "box", *sb, "ContainerHooks", len(hooks)) if err := sber.startContainerProcess(ctx, sb.ID, sb.ContainerID); err != nil { @@ -973,6 +1082,7 @@ func (sber *Boxer) StartExistingContainer(ctx context.Context, sb *sandtypes.Box // Get agent config to reconstruct hooks agentConfig := sber.AgentRegistry.Get(sb.AgentType) hooks := boxerStartHooks(agentConfig.Configuration.GetStartHooks(artifacts)) + hooks = append(hooks, hostPortSetupHook(sb, sber.HostPortManager)) slog.InfoContext(ctx, "Boxer.StartExistingContainer", "box", *sb, "ContainerHooks", len(hooks)) if err := sber.startContainerProcess(ctx, sb.ID, sb.ContainerID); err != nil { @@ -1121,6 +1231,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 +1273,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..dba43d2 --- /dev/null +++ b/internal/hostport/forwarder.go @@ -0,0 +1,184 @@ +// 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 ( + "fmt" + "io" + "log/slog" + "net" + "strconv" + "sync" +) + +// Forwarder is a single TCP listener that accepts connections on ListenAddr +// and proxies each one to TargetAddr. +type Forwarder struct { + ListenAddr string + TargetAddr 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() + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, _ = io.Copy(upstream, client) + 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() +} + +// 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. +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 { + f := &Forwarder{ + ListenAddr: net.JoinHostPort(listenIP, strconv.Itoa(p)), + TargetAddr: net.JoinHostPort(targetIP, strconv.Itoa(p)), + } + 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..d303cd0 --- /dev/null +++ b/internal/hostport/forwarder_test.go @@ -0,0 +1,142 @@ +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) + } +} 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. From 62b2c577b77ed21b4fa5e1dba73c9d6d1a911a22 Mon Sep 17 00:00:00 2001 From: Erik Swedberg Date: Thu, 21 May 2026 23:27:13 +0000 Subject: [PATCH 2/4] Run host-port iptables setup as root via Exec User=0 The container hook abstraction execs as the container's default user, which by the time post-bootstrap hooks run is usually the non-root agent user. iptables (and route_localnet sysctl) need CAP_NET_ADMIN / root, so the hook was failing with: iptables: Could not fetch rule set generation id: Permission denied (you must be root) Switch to calling ContainerService.Exec directly with User="0" so the script always runs as uid 0 inside the sandbox. Co-authored-by: Shelley --- internal/daemon/internal/boxer/boxer.go | 87 ++++++++++++++----------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/internal/daemon/internal/boxer/boxer.go b/internal/daemon/internal/boxer/boxer.go index 2d7da54..9a9cefc 100644 --- a/internal/daemon/internal/boxer/boxer.go +++ b/internal/daemon/internal/boxer/boxer.go @@ -126,43 +126,48 @@ func boxerStartHooks(hooks []sandtypes.ContainerHook) []sandtypes.ContainerHook return append(systemHooks, hooks...) } -// hostPortSetupHook returns a hook that, for each host-loopback port requested -// for this sandbox, starts a daemon-side TCP forwarder on the sandbox's bridge -// gateway IP and installs an iptables DNAT rule inside the sandbox redirecting -// 127.0.0.1: there. The net effect: the agent can reach host services on -// 127.0.0.1: as if they were running on the sandbox itself. -func hostPortSetupHook(sb *sandtypes.Box, mgr *hostport.Manager) sandtypes.ContainerHook { - return sandtypes.NewContainerHook("set up host port forwards", func(ctx context.Context, ctr *types.Container, exec sandtypes.HookStreamer) error { - if len(sb.HostPorts) == 0 || mgr == nil { - return nil - } - 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 := mgr.StartForSandbox(sb.ID, gateway, sb.HostPorts); err != nil { - return fmt.Errorf("host port setup: %w", err) - } +// setupHostPorts, for each host-loopback port requested for this sandbox, +// starts a daemon-side TCP forwarder on the sandbox's bridge gateway IP and +// installs an iptables DNAT rule inside the sandbox redirecting +// 127.0.0.1: there. We use ContainerService.Exec directly rather than +// the container hook abstraction because iptables and sysctl require root, +// and the container's default user has typically been changed away from root +// by agent bootstrap by the time this runs. +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) + } - // Install in-sandbox DNAT so 127.0.0.1: -> :. - // We also need route_localnet=1 because the destination of a DNAT'd - // loopback packet would otherwise be considered martian. - script := buildHostPortIptablesScript(gateway, sb.HostPorts) - out, err := exec.Exec(ctx, "sh", "-c", script) - if err != nil { - // If iptables fails, tear down the host-side listeners so we don't - // leave a half-configured forward in place. - mgr.StopForSandbox(sb.ID) - if out != "" { - return fmt.Errorf("host port setup iptables: %w: %s", err, strings.TrimSpace(out)) - } - return fmt.Errorf("host port setup iptables: %w", err) + script := buildHostPortIptablesScript(gateway, sb.HostPorts) + out, execErr := sber.ContainerService.Exec(ctx, + &options.ExecContainer{ + ProcessOptions: options.ProcessOptions{ + User: "0", + }, + }, sb.ContainerID, "sh", os.Environ(), "-c", script) + if execErr != nil { + sber.HostPortManager.StopForSandbox(sb.ID) + if out != "" { + return fmt.Errorf("host port setup iptables: %w: %s", execErr, strings.TrimSpace(out)) } - return nil - }) + return fmt.Errorf("host port setup iptables: %w", execErr) + } + slog.InfoContext(ctx, "Boxer.setupHostPorts done", "sandbox", sb.ID, "gateway", gateway, "ports", sb.HostPorts) + return nil } func buildHostPortIptablesScript(gatewayIP string, ports []int) string { @@ -1054,14 +1059,16 @@ func (sber *Boxer) StartNewContainer(ctx context.Context, sb *sandtypes.Box, pro // Get agent config to reconstruct hooks agentConfig := sber.AgentRegistry.Get(sb.AgentType) hooks := boxerStartHooks(agentConfig.Configuration.GetFirstStartHooks(artifacts)) - hooks = append(hooks, hostPortSetupHook(sb, sber.HostPortManager)) slog.InfoContext(ctx, "Boxer.StartNewContainer", "box", *sb, "ContainerHooks", len(hooks)) if err := sber.startContainerProcess(ctx, sb.ID, sb.ContainerID); err != nil { 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. @@ -1082,14 +1089,16 @@ func (sber *Boxer) StartExistingContainer(ctx context.Context, sb *sandtypes.Box // Get agent config to reconstruct hooks agentConfig := sber.AgentRegistry.Get(sb.AgentType) hooks := boxerStartHooks(agentConfig.Configuration.GetStartHooks(artifacts)) - hooks = append(hooks, hostPortSetupHook(sb, sber.HostPortManager)) slog.InfoContext(ctx, "Boxer.StartExistingContainer", "box", *sb, "ContainerHooks", len(hooks)) if err := sber.startContainerProcess(ctx, sb.ID, sb.ContainerID); err != nil { 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 { From 4bee0919fe9c6da3a2f9d4dff3afcfa35e4d536f Mon Sep 17 00:00:00 2001 From: Erik Swedberg Date: Thu, 21 May 2026 23:47:04 +0000 Subject: [PATCH 3/4] Run host-port setup via doas; add host.sand fallback Recent sand changes mean the container's default user is no longer root, so ExecContainer.User=0 alone wasn't enough. The base image configures 'permit nopass :wheel' for doas and adds the sandbox user to wheel, so switch to 'doas sh -c ...' for both the iptables setup and the /etc/hosts edit. Also make the iptables step best-effort: Apple's container runtime typically does not grant CAP_NET_ADMIN, so DNAT will fail anyway. When it does, fall back to a 'host.sand' /etc/hosts entry pointing at the bridge gateway IP. Agents can then reach the host service at http://host.sand:/ instead of http://127.0.0.1:/. Co-authored-by: Shelley --- internal/daemon/internal/boxer/boxer.go | 55 ++++++++++++++++++------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/internal/daemon/internal/boxer/boxer.go b/internal/daemon/internal/boxer/boxer.go index 9a9cefc..cb722b7 100644 --- a/internal/daemon/internal/boxer/boxer.go +++ b/internal/daemon/internal/boxer/boxer.go @@ -128,11 +128,15 @@ func boxerStartHooks(hooks []sandtypes.ContainerHook) []sandtypes.ContainerHook // setupHostPorts, for each host-loopback port requested for this sandbox, // starts a daemon-side TCP forwarder on the sandbox's bridge gateway IP and -// installs an iptables DNAT rule inside the sandbox redirecting -// 127.0.0.1: there. We use ContainerService.Exec directly rather than -// the container hook abstraction because iptables and sysctl require root, -// and the container's default user has typically been changed away from root -// by agent bootstrap by the time this runs. +// 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 @@ -152,24 +156,43 @@ func (sber *Boxer) setupHostPorts(ctx context.Context, sb *sandtypes.Box) error 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. + // Many Apple container setups lack CAP_NET_ADMIN; in that case we just + // log and continue. The host.sand hostname still works. script := buildHostPortIptablesScript(gateway, sb.HostPorts) out, execErr := sber.ContainerService.Exec(ctx, - &options.ExecContainer{ - ProcessOptions: options.ProcessOptions{ - User: "0", - }, - }, sb.ContainerID, "sh", os.Environ(), "-c", script) + &options.ExecContainer{}, + sb.ContainerID, "doas", os.Environ(), "sh", "-c", script) if execErr != nil { - sber.HostPortManager.StopForSandbox(sb.ID) - if out != "" { - return fmt.Errorf("host port setup iptables: %w: %s", execErr, strings.TrimSpace(out)) - } - return fmt.Errorf("host port setup iptables: %w", execErr) + slog.WarnContext(ctx, "setupHostPorts: iptables DNAT unavailable; falling back to host.sand", + "sandbox", sb.ID, "error", execErr, "output", strings.TrimSpace(out)) + fmt.Printf("[sand] note: in-sandbox iptables redirect not available; "+ + "reach host services as http://host.sand:/ (e.g. http://host.sand:%d/)\n", + sb.HostPorts[0]) + } else { + slog.InfoContext(ctx, "setupHostPorts: iptables installed", "sandbox", sb.ID, "gateway", gateway, "ports", sb.HostPorts) } - slog.InfoContext(ctx, "Boxer.setupHostPorts done", "sandbox", sb.ID, "gateway", gateway, "ports", 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") From 49a32027b177983d98d3c29ab4e4da342ed77236 Mon Sep 17 00:00:00 2001 From: Erik Swedberg Date: Fri, 22 May 2026 00:22:47 +0000 Subject: [PATCH 4/4] Make host-port forwarder HTTP Host-aware; clean up UX The proxy now sniffs the first bytes of each connection; if they look like an HTTP/1.x request and a rewrite target is configured (always the case when started via the Manager), it parses each request, rewrites the Host header to 127.0.0.1:, and re-serializes it upstream. Non-HTTP traffic falls back to a plain TCP pipe. WebSocket and other Upgrade requests have their initial Host header rewritten and then switch to raw passthrough. This means a sandbox client can use http://host.sand:/ with no custom headers; servers like Figma's MCP that validate Host are happy. Also: * Demote the expected iptables/CAP_NET_ADMIN failure from a warning to an info log; print a single positive '[sand] host services exposed at...' line on start. * Rewrite doc/HOST_SERVICES.md to lead with host.sand: as the user-facing entry point and document the HTTP Host rewrite. Co-authored-by: Shelley --- doc/HOST_SERVICES.md | 73 ++++++++++------- internal/daemon/internal/boxer/boxer.go | 13 +-- internal/hostport/forwarder.go | 103 ++++++++++++++++++++++-- internal/hostport/forwarder_test.go | 83 +++++++++++++++++++ 4 files changed, 230 insertions(+), 42 deletions(-) diff --git a/doc/HOST_SERVICES.md b/doc/HOST_SERVICES.md index f9e13e6..1014721 100644 --- a/doc/HOST_SERVICES.md +++ b/doc/HOST_SERVICES.md @@ -1,27 +1,31 @@ # Exposing host services to a sandbox -The `--host-port` flag exposes a TCP service bound to your Mac's loopback -(`127.0.0.1:`) to a sandbox at the *same* address inside the container. -An agent running in the sandbox can talk to the service using exactly the -configuration it would use on the host — no MCP/client reconfiguration -required. +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. To make it reachable from inside a sandbox: +your Mac. Expose it to a sandbox: ```sh sand new --host-port 3845 -a claude ``` -Inside the sandbox, point any MCP client at the usual URL: +Inside the sandbox: +```sh +curl -v http://host.sand:3845/mcp ``` -http://127.0.0.1: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 ``` -The flag is repeatable: +Multiple ports: ```sh sand new --host-port 3845 --host-port 5173 @@ -30,30 +34,37 @@ 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. -Inside the sandbox, `127.0.0.1` is the sandbox itself, not your Mac. The Mac -is the bridge gateway (typically `192.168.64.1`). - -For each requested port `--host-port` does two things: - -1. The `sand` daemon spawns a TCP forwarder bound to the sandbox's bridge - gateway IP on the host. The listener forwards to `127.0.0.1:` on the - host. The listener is scoped to the bridge interface — it is **not** bound - to `0.0.0.0`, so other machines on your LAN cannot reach it. -2. The daemon installs an `iptables` DNAT rule inside the sandbox that - rewrites `127.0.0.1:` to `:` and a matching - `MASQUERADE` rule for the return path. `net.ipv4.conf.{all,lo}.route_localnet` - is set to `1` so the kernel will route the redirected packet out of the - loopback interface. - -Both the forwarder and the iptables rule are torn down when the sandbox -stops or is removed. - -## Security notes +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 to `--ssh-agent` -forwarding. Only forward ports you would already trust the sandbox to reach -on your own machine. +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` diff --git a/internal/daemon/internal/boxer/boxer.go b/internal/daemon/internal/boxer/boxer.go index cb722b7..85eb9d4 100644 --- a/internal/daemon/internal/boxer/boxer.go +++ b/internal/daemon/internal/boxer/boxer.go @@ -168,21 +168,22 @@ func (sber *Boxer) setupHostPorts(ctx context.Context, sb *sandtypes.Box) error } // Best-effort iptables DNAT so 127.0.0.1: resolves transparently. - // Many Apple container setups lack CAP_NET_ADMIN; in that case we just - // log and continue. The host.sand hostname still works. + // 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.WarnContext(ctx, "setupHostPorts: iptables DNAT unavailable; falling back to host.sand", + slog.InfoContext(ctx, "setupHostPorts: in-sandbox iptables unavailable; using host.sand fallback", "sandbox", sb.ID, "error", execErr, "output", strings.TrimSpace(out)) - fmt.Printf("[sand] note: in-sandbox iptables redirect not available; "+ - "reach host services as http://host.sand:/ (e.g. http://host.sand:%d/)\n", - sb.HostPorts[0]) } 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 } diff --git a/internal/hostport/forwarder.go b/internal/hostport/forwarder.go index dba43d2..09da7dc 100644 --- a/internal/hostport/forwarder.go +++ b/internal/hostport/forwarder.go @@ -13,19 +13,29 @@ 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 + ListenAddr string + TargetAddr string + RewriteHTTPHost string listener net.Listener wg sync.WaitGroup @@ -77,11 +87,27 @@ func (f *Forwarder) handle(client net.Conn) { } 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() - _, _ = io.Copy(upstream, client) + 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() } @@ -96,6 +122,67 @@ func (f *Forwarder) handle(client net.Conn) { 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 { @@ -126,6 +213,10 @@ func NewManager() *Manager { // 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) } @@ -137,9 +228,11 @@ func (m *Manager) startForSandbox(sandboxID, listenIP, targetIP string, ports [] } var started []*Forwarder for _, p := range ports { + targetAddr := net.JoinHostPort(targetIP, strconv.Itoa(p)) f := &Forwarder{ - ListenAddr: net.JoinHostPort(listenIP, strconv.Itoa(p)), - TargetAddr: net.JoinHostPort(targetIP, strconv.Itoa(p)), + ListenAddr: net.JoinHostPort(listenIP, strconv.Itoa(p)), + TargetAddr: targetAddr, + RewriteHTTPHost: targetAddr, } if err := f.Start(); err != nil { for _, x := range started { diff --git a/internal/hostport/forwarder_test.go b/internal/hostport/forwarder_test.go index d303cd0..0244703 100644 --- a/internal/hostport/forwarder_test.go +++ b/internal/hostport/forwarder_test.go @@ -140,3 +140,86 @@ func TestManagerRejectsBadIP(t *testing.T) { 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) + } + } +}