From 082efab2731f39b924c19d4927d020a1aba2abb2 Mon Sep 17 00:00:00 2001 From: Simon Templer Date: Mon, 19 Oct 2026 15:07:25 +0200 Subject: [PATCH 1/2] feat: add forward subcommand to reach guest ports from the host Lima's automatic port forwarding is switched off so nothing in the guest reaches the host on its own, which also left no way to open a dev server or mockup running in the VM in a host browser. `code-vm forward PORT|HOST_PORT:GUEST_PORT...` opens an SSH tunnel over the instance's Lima ssh config for as long as it runs. The host side always binds 127.0.0.1, so the guest is never exposed to the host's network, and nothing is persisted: the default stays closed. The VM must already be running; forward does not boot it. ExitOnForwardFailure turns a busy host port into an error. Interrupt and terminate signals are relayed to ssh, since a signal sent to code-vm alone (a script stopping a background forward) would otherwise leave the tunnel open. --- README.md | 23 ++++ internal/cli/forward.go | 162 ++++++++++++++++++++++++++ internal/cli/forward_test.go | 213 +++++++++++++++++++++++++++++++++++ internal/cli/root.go | 1 + internal/lima/limactl.go | 15 +++ test-vm-sandbox.sh | 27 +++++ 6 files changed, 441 insertions(+) create mode 100644 internal/cli/forward.go create mode 100644 internal/cli/forward_test.go diff --git a/README.md b/README.md index 76fbe43..b5f43f3 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ code-vm # interactive shell | `code-vm recreate` | Delete and rebuild the guest from scratch | | `code-vm proxy-log [all\|denied\|allowed\|follow]` | Read the Squid access log | | `code-vm allow [domain...]` | Add domains to the allowlist and apply them live | +| `code-vm forward ...` | Reach guest ports on the host's localhost until Ctrl-C | | `code-vm secrets` | List secrets/vars the active profiles declare, mapped or not | | `code-vm doctor` | Check host prerequisites | @@ -183,6 +184,28 @@ Removing a domain from the config revokes it on the next invocation; the guest fragment is rewritten to match, rather than keeping stale entries alive for the VM's lifetime. +### Reaching guest servers from the host + +Nothing in the guest is reachable from the host by default: Lima's automatic +port forwarding is switched off, so a dev server, or the Squid and Docker +ports, never appear on the host on their own. To open a server in your own +browser, forward its port explicitly: + +```bash +code-vm forward 8080 # guest :8080 -> http://localhost:8080 +code-vm forward 3000:5173 8080 # host :3000 -> guest :5173, plus :8080 +``` + +The forward runs in the foreground over its own SSH connection to the VM and +closes on Ctrl-C; nothing is persisted. The host side always binds `127.0.0.1`, so the +port is never exposed to the network the host is on. The guest side connects +to the guest's loopback, which reaches servers bound to `127.0.0.1` or +`0.0.0.0` as well as Docker-published ports (`docker run -p 8080:80`). The VM +must already be running; `forward` does not boot it. + +What you open is served by the agent: treat a forwarded page like any other +agent-authored content, and stop the forward when you are done. + ### `containerProxy` Off by default. When on, `docker run` and `docker build` containers get diff --git a/internal/cli/forward.go b/internal/cli/forward.go new file mode 100644 index 0000000..e352226 --- /dev/null +++ b/internal/cli/forward.go @@ -0,0 +1,162 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/spf13/cobra" +) + +// portForward maps a host loopback port to a guest loopback port. +type portForward struct { + Host, Guest int +} + +// parseForward accepts PORT or HOST_PORT:GUEST_PORT. A bind address is not +// part of the syntax: the host side is always 127.0.0.1, so a forward never +// exposes the guest to the network the host sits on. +func parseForward(spec string) (portForward, error) { + parts := strings.Split(spec, ":") + if len(parts) > 2 { + return portForward{}, fmt.Errorf("invalid forward %q; want PORT or HOST_PORT:GUEST_PORT", spec) + } + ports := make([]int, len(parts)) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > 65535 || strconv.Itoa(n) != p { + return portForward{}, fmt.Errorf("invalid forward %q; ports must be numbers from 1 to 65535", spec) + } + ports[i] = n + } + if len(ports) == 1 { + return portForward{Host: ports[0], Guest: ports[0]}, nil + } + return portForward{Host: ports[0], Guest: ports[1]}, nil +} + +// forwardSSHArgs builds the ssh argv for the tunnel. The guest end is +// 127.0.0.1 too: sshd connects from inside the guest, which reaches servers +// bound to loopback as well as wildcard binds and Docker-published ports. +// Lima's ssh config multiplexes connections over a persistent master process +// (ControlMaster auto, ControlPersist yes). A forward requested through that +// master is owned by the master and stays open after the client exits, so +// the tunnel opens its own connection; then the listener lives in this ssh +// process and closes with it. ExitOnForwardFailure turns a host port already +// in use into an error instead of a tunnel that silently forwards nothing. +func forwardSSHArgs(sshConfig, host string, fwds []portForward) []string { + args := []string{ + "-F", sshConfig, "-N", + "-o", "ControlMaster=no", + "-o", "ControlPath=none", + "-o", "ExitOnForwardFailure=yes", + } + for _, f := range fwds { + args = append(args, "-L", fmt.Sprintf("127.0.0.1:%d:127.0.0.1:%d", f.Host, f.Guest)) + } + return append(args, host) +} + +// runSSH is a package variable so tests can substitute a fake. It runs ssh in +// the foreground until it exits. An interrupt or terminate signal ends the +// tunnel and counts as a normal stop. Ctrl-C reaches ssh through the +// terminal's process group, but a signal sent to code-vm alone (a script +// stopping a background forward) does not, so it is relayed explicitly. +var runSSH = func(ctx context.Context, args []string) error { + // Notify rather than ignore: an ignored signal is inherited across exec, + // which would leave ssh deaf to Ctrl-C. + sigs := make(chan os.Signal, 2) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigs) + + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start ssh: %w", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + var err error + select { + case sig := <-sigs: + _ = cmd.Process.Signal(sig) + <-done + return nil + case err = <-done: + } + // A Ctrl-C that ssh exited on can land just after Wait returns. + select { + case <-sigs: + return nil + default: + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return fmt.Errorf("ssh tunnel exited with status %d", ee.ExitCode()) + } + return err +} + +func parseForwards(specs []string) ([]portForward, error) { + fwds := make([]portForward, 0, len(specs)) + seen := map[int]bool{} + for _, spec := range specs { + f, err := parseForward(spec) + if err != nil { + return nil, err + } + if seen[f.Host] { + return nil, fmt.Errorf("host port %d is forwarded more than once", f.Host) + } + seen[f.Host] = true + fwds = append(fwds, f) + } + return fwds, nil +} + +func newForwardCmd() *cobra.Command { + return &cobra.Command{ + Use: "forward PORT|HOST_PORT:GUEST_PORT...", + Short: "Forward guest ports to localhost on the host until Ctrl-C", + Long: "Forward guest ports to the host's loopback interface, so a server\n" + + "running in the VM opens in a host browser. PORT forwards the same\n" + + "port number; HOST_PORT:GUEST_PORT maps a different one. The forward\n" + + "lasts until Ctrl-C and is never reachable from other machines.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fwds, err := parseForwards(args) + if err != nil { + return err + } + c, _, err := loadConfig() + if err != nil { + return err + } + cl := clientFor(c) + status, err := cl.Status(cmd.Context()) + if err != nil { + return err + } + if status != "Running" { + return fmt.Errorf("instance %s is not running; start it with `code-vm start` and the server inside it first", c.Instance) + } + sshConfig, err := cl.SSHConfigFile(cmd.Context()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + for _, f := range fwds { + fmt.Fprintf(out, "http://localhost:%d -> guest port %d\n", f.Host, f.Guest) + } + fmt.Fprintln(out, "Press Ctrl-C to stop forwarding.") + return runSSH(cmd.Context(), forwardSSHArgs(sshConfig, cl.SSHHost(), fwds)) + }, + } +} diff --git a/internal/cli/forward_test.go b/internal/cli/forward_test.go new file mode 100644 index 0000000..bdf385d --- /dev/null +++ b/internal/cli/forward_test.go @@ -0,0 +1,213 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" +) + +func TestParseForward(t *testing.T) { + for _, tc := range []struct { + spec string + want portForward + }{ + {"8080", portForward{Host: 8080, Guest: 8080}}, + {"3000:8080", portForward{Host: 3000, Guest: 8080}}, + {"1:65535", portForward{Host: 1, Guest: 65535}}, + } { + got, err := parseForward(tc.spec) + if err != nil { + t.Errorf("parseForward(%q): %v", tc.spec, err) + continue + } + if got != tc.want { + t.Errorf("parseForward(%q) = %+v, want %+v", tc.spec, got, tc.want) + } + } +} + +// A bind address in the spec is refused rather than passed through: the host +// side is always loopback, so the guest server is never reachable from the +// network the host sits on. +func TestParseForwardRejectsInvalidSpecs(t *testing.T) { + for _, spec := range []string{ + "", "0", "65536", "-1", "http", "80:", ":80", "1:2:3", + "0.0.0.0:8080:8080", "localhost:8080", " 8080", + } { + if got, err := parseForward(spec); err == nil { + t.Errorf("parseForward(%q) = %+v, want an error", spec, got) + } + } +} + +// Lima's ssh config multiplexes over a persistent master; a forward opened +// through it would outlive code-vm, so the tunnel must not use it. +func TestForwardSSHArgsBindHostLoopbackOnly(t *testing.T) { + got := forwardSSHArgs("/lima/code-sandbox/ssh.config", "lima-code-sandbox", []portForward{ + {Host: 8080, Guest: 8080}, + {Host: 3000, Guest: 5173}, + }) + want := []string{ + "-F", "/lima/code-sandbox/ssh.config", + "-N", + "-o", "ControlMaster=no", + "-o", "ControlPath=none", + "-o", "ExitOnForwardFailure=yes", + "-L", "127.0.0.1:8080:127.0.0.1:8080", + "-L", "127.0.0.1:3000:127.0.0.1:5173", + "lima-code-sandbox", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("forwardSSHArgs =\n %q\nwant\n %q", got, want) + } +} + +// installFakeSSH replaces the ssh launcher and records its argv. +func installFakeSSH(t *testing.T) *[][]string { + t.Helper() + var calls [][]string + old := runSSH + runSSH = func(_ context.Context, args []string) error { + calls = append(calls, args) + return nil + } + t.Cleanup(func() { runSSH = old }) + return &calls +} + +func TestForwardTunnelsThroughTheConfiguredInstance(t *testing.T) { + root := NewRootCmd() + withScratchConfig(t) + r := installFakeClient(t, "Running") + ssh := installFakeSSH(t) + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"forward", "8080", "3000:5173"}) + if err := root.Execute(); err != nil { + t.Fatalf("forward: %v\n%s", err, out.String()) + } + + if !ranAny(r.calls, "list code-sandbox-clitest --format {{.SSHConfigFile}}") { + t.Errorf("expected the ssh config to be looked up for the configured instance, calls=%v", r.calls) + } + if len(*ssh) != 1 { + t.Fatalf("ssh ran %d times, want 1", len(*ssh)) + } + args := strings.Join((*ssh)[0], " ") + for _, want := range []string{ + "-L 127.0.0.1:8080:127.0.0.1:8080", + "-L 127.0.0.1:3000:127.0.0.1:5173", + "lima-code-sandbox-clitest", + } { + if !strings.Contains(args, want) { + t.Errorf("ssh args %q lack %q", args, want) + } + } + for _, want := range []string{"http://localhost:8080", "http://localhost:3000"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output lacks %q:\n%s", want, out.String()) + } + } +} + +// A forward is for reaching a server that is already running in the guest, so +// a stopped VM is an error rather than a reason to boot it. +func TestForwardRefusesWhenNotRunning(t *testing.T) { + for _, status := range []string{"", "Stopped"} { + t.Run("status="+status, func(t *testing.T) { + root := NewRootCmd() + withScratchConfig(t) + r := installFakeClient(t, status) + ssh := installFakeSSH(t) + + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"forward", "8080"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "not running") { + t.Errorf("err = %v, want a not-running refusal", err) + } + if r.started() { + t.Error("forward must not boot the VM") + } + if len(*ssh) != 0 { + t.Errorf("ssh ran despite the VM not running: %v", *ssh) + } + }) + } +} + +func TestForwardRejectsBadArgsBeforeTouchingTheVM(t *testing.T) { + for _, args := range [][]string{ + {"forward"}, + {"forward", "web"}, + {"forward", "8080", "8080"}, + {"forward", "8080", "8080:9090"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + root := NewRootCmd() + withScratchConfig(t) + r := installFakeClient(t, "Running") + ssh := installFakeSSH(t) + + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Error("expected an error") + } + if len(r.calls) != 0 || len(*ssh) != 0 { + t.Errorf("expected no VM or ssh interaction, limactl=%v ssh=%v", r.calls, *ssh) + } + }) + } +} + +// A signal sent to code-vm alone, as a script stopping a background forward +// does, never reaches ssh through a terminal process group. It must be relayed, +// or code-vm waits forever on a tunnel that keeps the host port open. +func TestRunSSHRelaysTerminationAndStopsCleanly(t *testing.T) { + dir := t.TempDir() + mark := filepath.Join(dir, "started") + script := "#!/bin/sh\ntouch \"$CODE_VM_TEST_MARK\"\nexec sleep 30\n" + if err := os.WriteFile(filepath.Join(dir, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CODE_VM_TEST_MARK", mark) + + done := make(chan error, 1) + go func() { done <- runSSH(context.Background(), nil) }() + + // The mark exists only once ssh started, which is after runSSH subscribed + // to the signal — so the signal below cannot kill the test binary. + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(mark); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("fake ssh never started") + } + time.Sleep(10 * time.Millisecond) + } + if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Errorf("runSSH = %v, want nil for a signalled stop", err) + } + case <-time.After(5 * time.Second): + t.Fatal("runSSH did not return: the signal was not relayed to ssh") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2878884..2663c03 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,6 +40,7 @@ func NewRootCmd() *cobra.Command { root.AddCommand(newAllowCmd()) root.AddCommand(newProfileCmd()) root.AddCommand(newSecretsCmd()) + root.AddCommand(newForwardCmd()) return root } diff --git a/internal/lima/limactl.go b/internal/lima/limactl.go index 4054a3a..93e7475 100644 --- a/internal/lima/limactl.go +++ b/internal/lima/limactl.go @@ -158,6 +158,21 @@ func (c Client) InstanceDir(ctx context.Context) (string, error) { return strings.TrimSpace(string(out)), nil } +// SSHConfigFile returns the path of the ssh config Lima writes for the +// instance, which authenticates as limaadmin to the instance's forwarded sshd. +func (c Client) SSHConfigFile(ctx context.Context) (string, error) { + out, err := c.R.Output(ctx, "list", c.instance(), "--format", "{{.SSHConfigFile}}") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// SSHHost is the host alias the instance's ssh config defines. +func (c Client) SSHHost() string { + return "lima-" + c.instance() +} + // ResolveConfigInto resolves a rendered template into a self-contained // instance config at dst. Instance configs must have `base` resolved into // concrete fields (observed: a stored config with `base:` fails validation diff --git a/test-vm-sandbox.sh b/test-vm-sandbox.sh index 7876d27..8f80850 100644 --- a/test-vm-sandbox.sh +++ b/test-vm-sandbox.sh @@ -847,6 +847,33 @@ else pass "guest Squid port is not forwarded to the host" fi +# `forward` is the explicit, temporary opening: a Docker-published guest port +# must reach the host's loopback, and only its loopback, until it is stopped. +agent docker run -d --rm --name code-vm-forward -p 18080:80 nginx:alpine > /dev/null 2>&1 +sleep 3 +"${CODE_VM_ARGS[@]}" forward 18081:18080 > /dev/null 2>&1 & +FORWARD_PID=$! +sleep 3 +if curl -fsS -o /dev/null --max-time 10 --noproxy '*' http://127.0.0.1:18081; then + pass "forward makes a guest port reachable on the host" +else + fail "forward makes a guest port reachable on the host" +fi +if ss -tln 2> /dev/null | grep ':18081 ' | grep -qv '127.0.0.1:18081 '; then + fail "forward binds only the host loopback" +else + pass "forward binds only the host loopback" +fi +kill -INT "$FORWARD_PID" 2> /dev/null +wait "$FORWARD_PID" 2> /dev/null +if ss -tln 2> /dev/null | grep -q ':18081 '; then + fail "stopping forward closes the host port" +else + pass "stopping forward closes the host port" +fi +agent docker rm -f code-vm-forward > /dev/null 2>&1 +assert_fails "forward rejects a bind address" "${CODE_VM_ARGS[@]}" forward 0.0.0.0:18081:18080 + echo "" echo "── Firewall modes ────────────────────────────────────────────────" From 00c26a0c7bb7b91c0ae054b189477e7e66b19859 Mon Sep 17 00:00:00 2001 From: Simon Templer Date: Mon, 19 Oct 2026 16:02:49 +0200 Subject: [PATCH 2/2] test: fail host listener checks when the host cannot be inspected The listener checks piped `ss` straight into grep, so a missing `ss` (macOS has no iproute2) read as "nothing is listening" and the loopback and cleanup guarantees passed without being verified. A helper now lists listeners with `ss` on Linux or `lsof` on macOS and fails the check when neither runs. --- test-vm-sandbox.sh | 47 +++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/test-vm-sandbox.sh b/test-vm-sandbox.sh index 8f80850..af1d527 100644 --- a/test-vm-sandbox.sh +++ b/test-vm-sandbox.sh @@ -117,6 +117,35 @@ host_uid_of() { stat -c '%u' "$1" 2> /dev/null || stat -f '%u' "$1"; } # Run a command as the agent user through the real CLI. agent() { "${CODE_VM_ARGS[@]}" -- "$@"; } +# Print the host's listening TCP sockets as ADDR:PORT, one per line. `ss` is +# Linux-only and lsof is what macOS ships; a probe that cannot run returns +# non-zero so that a listener check never passes on an empty answer. +host_listeners() { + if command -v ss > /dev/null 2>&1; then + ss -tln | awk 'NR > 1 { print $4 }' + elif command -v lsof > /dev/null 2>&1; then + # lsof exits 1 when nothing matches, which is a valid empty answer. + { lsof -nP -iTCP -sTCP:LISTEN -F n || [ $? -eq 1 ]; } | sed -n 's/^n//p' + else + echo "[test] neither ss nor lsof is available to list host listeners" >&2 + return 1 + fi +} + +# assert_listeners DESC PORT PATTERN: every host listener on PORT must match +# the extended regex PATTERN; "^$" demands that nothing listens on PORT. A +# host that cannot be inspected fails the check instead of passing it. +assert_listeners() { + local desc="$1" port="$2" pattern="$3" all + if ! all=$(host_listeners); then + fail "$desc (cannot list host listeners)" + elif grep -E ":$port\$" <<< "$all" | grep -Eqv "$pattern"; then + fail "$desc" + else + pass "$desc" + fi +} + assert_ok() { local desc="$1" shift @@ -841,11 +870,7 @@ echo "── Host exposure ───────────────── # The portForwards ignore rule must cover wildcard binds: without guestIP # 0.0.0.0 + proto any, Lima forwarded the guest's Squid port to the host. -if ss -tln 2> /dev/null | grep -q ':3128 '; then - fail "guest Squid port is not forwarded to the host" -else - pass "guest Squid port is not forwarded to the host" -fi +assert_listeners "guest Squid port is not forwarded to the host" 3128 '^$' # `forward` is the explicit, temporary opening: a Docker-published guest port # must reach the host's loopback, and only its loopback, until it is stopped. @@ -859,18 +884,10 @@ if curl -fsS -o /dev/null --max-time 10 --noproxy '*' http://127.0.0.1:18081; th else fail "forward makes a guest port reachable on the host" fi -if ss -tln 2> /dev/null | grep ':18081 ' | grep -qv '127.0.0.1:18081 '; then - fail "forward binds only the host loopback" -else - pass "forward binds only the host loopback" -fi +assert_listeners "forward binds only the host loopback" 18081 '^127\.0\.0\.1:18081$' kill -INT "$FORWARD_PID" 2> /dev/null wait "$FORWARD_PID" 2> /dev/null -if ss -tln 2> /dev/null | grep -q ':18081 '; then - fail "stopping forward closes the host port" -else - pass "stopping forward closes the host port" -fi +assert_listeners "stopping forward closes the host port" 18081 '^$' agent docker rm -f code-vm-forward > /dev/null 2>&1 assert_fails "forward rejects a bind address" "${CODE_VM_ARGS[@]}" forward 0.0.0.0:18081:18080