diff --git a/README.md b/README.md index b140950..d3a2aff 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,37 @@ sbx run my-sbx --mount /home/me/src/tooling:/workspace/tooling # Disable automatic OAuth callback forwarding. sbx run my-sbx --no-auth-port +``` + +Port forwarding lets your host connect to a TCP service running inside the sandbox, such as a dev server or web app. `sbx network forward` runs in the foreground; Ctrl-C stops the forwarding. + +A forward `SPEC` maps a host address/port to a guest port: + +```text +GUEST_PORT host 127.0.0.1:GUEST_PORT -> guest 127.0.0.1:GUEST_PORT +HOST_PORT:GUEST_PORT host 127.0.0.1:HOST_PORT -> guest 127.0.0.1:GUEST_PORT +BIND_HOST:HOST_PORT:GUEST_PORT + host BIND_HOST:HOST_PORT -> guest 127.0.0.1:GUEST_PORT +``` + +Examples: -# Temporarily forward a running guest web server until Ctrl-C. +```text +3000 host 127.0.0.1:3000 -> guest 127.0.0.1:3000 +8080:80 host 127.0.0.1:8080 -> guest 127.0.0.1:80 +0.0.0.0:8080:80 host 0.0.0.0:8080 -> guest 127.0.0.1:80 +``` + +You can pass multiple specs in one command; one Ctrl-C stops all of them: + +```bash sbx network forward my-sbx 3000 sbx network forward 8080:3000 sbx network forward 0.0.0.0:3000:3000 +sbx network forward my-sbx 3000 8080:80 +``` +```bash # Keep the VM running after the agent/shell exits. sbx run my-sbx --keep-running sbx shell my-sbx --keep-running @@ -116,7 +141,7 @@ sbx run my-sbx --agent claude | `shell [NAME]` | Open a shell in a sandbox. | | `ls` | List running sandboxes. Use `ls -a` / `ls --all` to include stopped ones. | | `network status [NAME]` | Expert helper: show sandbox networking and auth callback tunnel status. | -| `network forward [NAME] SPEC` | Temporarily forward a host TCP port to a running sandbox until Ctrl-C. | +| `network forward [NAME] SPEC...` | Temporarily forward host TCP ports to a running sandbox until Ctrl-C. | | `network auth-port [NAME]` | Expert helper: manually expose the OAuth callback port for an already-running sandbox. | | `network close-auth-port [NAME]` | Expert helper: close the tracked OAuth callback tunnel. | | `image build-debian` | Advanced helper: build a local Debian/Pi image, optionally with `--with-docker`. | diff --git a/docs/network-command-roadmap.md b/docs/network-command-roadmap.md index 8ad0d22..17c8d29 100644 --- a/docs/network-command-roadmap.md +++ b/docs/network-command-roadmap.md @@ -10,14 +10,24 @@ sbx network ... ## Current commands -### `sbx network forward [NAME] SPEC` +### `sbx network forward [NAME] SPEC...` -Forwards a host TCP port to a running sandbox in the foreground. Press Ctrl-C to stop. +Forwards host TCP ports to a running sandbox in the foreground. Press Ctrl-C to stop all forwards. ```bash sbx network forward 3000 sbx network forward 8080:3000 sbx network forward 0.0.0.0:3000:3000 +sbx network forward 3000 8080:80 +sbx network forward my-sbx 3000 8080:80 +``` + +`SPEC` is one of: + +```text +3000 host 127.0.0.1:3000 -> guest 127.0.0.1:3000 +8080:80 host 127.0.0.1:8080 -> guest 127.0.0.1:80 +0.0.0.0:8080:80 host 0.0.0.0:8080 -> guest 127.0.0.1:80 ``` Configured forwards live in `.sbx.toml` and are applied when the VM starts: diff --git a/src/sbx/cli.py b/src/sbx/cli.py index d881a17..0857e83 100644 --- a/src/sbx/cli.py +++ b/src/sbx/cli.py @@ -2101,8 +2101,8 @@ def build_parser() -> argparse.ArgumentParser: forward.add_argument( "forward_args", nargs="+", - metavar="[NAME] SPEC", - help="Forward spec: GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.", + metavar="[NAME] SPEC...", + help="Forward specs: GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.", ) forward.set_defaults(func=network.cmd_forward) diff --git a/src/sbx/network.py b/src/sbx/network.py index fd2e37d..11e7851 100644 --- a/src/sbx/network.py +++ b/src/sbx/network.py @@ -216,36 +216,36 @@ def expose_auth_port(vm_id: str, host_port: int, guest_port: int, *, replace: bo return 1 -def _foreground_port_forward(vm_id: str, forward: tuple[str, int, int]) -> int: - host, host_port, guest_port = forward +def _foreground_port_forward(vm_id: str, forwards: Sequence[tuple[str, int, int]]) -> int: cmd = ssh_command(vm_id) - cmd[-1:-1] = [ - "-N", - "-L", - f"{host}:{host_port}:127.0.0.1:{guest_port}", - "-o", - "ExitOnForwardFailure=yes", - ] - print(f"Forwarding {host}:{host_port} -> guest 127.0.0.1:{guest_port}") + forward_args = ["-N"] + for host, host_port, guest_port in forwards: + forward_args.extend(["-L", f"{host}:{host_port}:127.0.0.1:{guest_port}"]) + forward_args.extend(["-o", "ExitOnForwardFailure=yes"]) + cmd[-1:-1] = forward_args + for host, host_port, guest_port in forwards: + print(f"Forwarding {host}:{host_port} -> guest 127.0.0.1:{guest_port}") print("Press Ctrl-C to stop.") return run(cmd) def cmd_forward(args: argparse.Namespace) -> int: - if len(args.forward_args) == 1: - spec = args.forward_args[0] + name = None + specs = args.forward_args + if len(specs) > 1: + try: + parse_port_forward(specs[0]) + except ConfigError: + name = specs[0] + specs = specs[1:] + if name is None: name = vm_name_from_arg_or_config( args, getattr(args, "config_data", None), "network forward" ) - elif len(args.forward_args) == 2: - name, spec = args.forward_args - else: - print("sbx: network forward expects [NAME] SPEC", file=sys.stderr) - return 2 if name is None: return 2 try: - return _foreground_port_forward(str(name), parse_port_forward(spec)) + return _foreground_port_forward(str(name), [parse_port_forward(spec) for spec in specs]) except ConfigError as exc: print(f"sbx: {exc}", file=sys.stderr) return 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 9296588..1b7abd1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1402,9 +1402,9 @@ def test_network_forward_defaults_to_configured_name( config.write_text('[sbx]\nname = "vm1"\n', encoding="utf-8") captured: dict[str, object] = {} - def fake_forward(vm_id: str, forward: tuple[str, int, int]) -> int: + def fake_forward(vm_id: str, forwards: list[tuple[str, int, int]]) -> int: captured["vm_id"] = vm_id - captured["forward"] = forward + captured["forwards"] = forwards return 0 monkeypatch.setattr(cli.network, "_foreground_port_forward", fake_forward) @@ -1412,7 +1412,7 @@ def fake_forward(vm_id: str, forward: tuple[str, int, int]) -> int: assert cli.main(["--config", str(config), "network", "forward", "8080:3000"]) == 0 assert captured == { "vm_id": "vm1", - "forward": ("127.0.0.1", 8080, 3000), + "forwards": [("127.0.0.1", 8080, 3000)], } @@ -1421,18 +1421,55 @@ def test_network_forward_accepts_explicit_name(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr( cli.network, "_foreground_port_forward", - lambda vm_id, forward: captured.update({"vm_id": vm_id, "forward": forward}) or 0, + lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0, ) assert cli.main(["network", "forward", "vm2", "0.0.0.0:3000:3000"]) == 0 assert captured == { "vm_id": "vm2", - "forward": ("0.0.0.0", 3000, 3000), + "forwards": [("0.0.0.0", 3000, 3000)], + } + + +def test_network_forward_accepts_multiple_specs_from_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / "config.toml" + config.write_text('[sbx]\nname = "vm1"\n', encoding="utf-8") + captured: dict[str, object] = {} + monkeypatch.setattr( + cli.network, + "_foreground_port_forward", + lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0, + ) + + assert cli.main(["--config", str(config), "network", "forward", "3000", "8080:80"]) == 0 + assert captured == { + "vm_id": "vm1", + "forwards": [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)], + } + + +def test_network_forward_accepts_explicit_name_with_multiple_specs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + cli.network, + "_foreground_port_forward", + lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0, + ) + + assert cli.main(["network", "forward", "vm2", "3000", "8080:80"]) == 0 + assert captured == { + "vm_id": "vm2", + "forwards": [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)], } def test_network_forward_ctrl_c_returns_130(monkeypatch: pytest.MonkeyPatch) -> None: - def interrupted(vm_id: str, forward: tuple[str, int, int]) -> int: + def interrupted(vm_id: str, forwards: list[tuple[str, int, int]]) -> int: raise KeyboardInterrupt monkeypatch.setattr(cli.network, "_foreground_port_forward", interrupted) diff --git a/tests/test_cli_extra.py b/tests/test_cli_extra.py index 0b9b366..c242e60 100644 --- a/tests/test_cli_extra.py +++ b/tests/test_cli_extra.py @@ -780,6 +780,33 @@ def poll(self) -> int: assert cli.network.expose_auth_port("vm1", 1, 2) == 1 +def test_foreground_port_forward_uses_one_ssh_for_multiple_ports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, list[str]] = {} + def fake_run(argv: list[str]) -> int: + captured["argv"] = list(argv) + return 0 + + monkeypatch.setattr(cli.network, "ssh_command", lambda vm_id: ["ssh", "root@host"]) + monkeypatch.setattr(cli.network, "run", fake_run) + + assert cli.network._foreground_port_forward( + "vm1", [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)] + ) == 0 + assert captured["argv"] == [ + "ssh", + "-N", + "-L", + "127.0.0.1:3000:127.0.0.1:3000", + "-L", + "127.0.0.1:8080:127.0.0.1:80", + "-o", + "ExitOnForwardFailure=yes", + "root@host", + ] + + def test_delete_vm_error_paths( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: