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..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,26 @@ 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" +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. +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 - pass "guest Squid port is not forwarded to the host" + fail "forward makes a guest port reachable on the host" 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 +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 echo "" echo "── Firewall modes ────────────────────────────────────────────────"