From 22138474b889560355932f7abb4c0be185bdcac2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 22:10:07 +0200 Subject: [PATCH] fix(manager): pin local forwards to 127.0.0.1 so port collisions fail loudly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unbound `-L port:host:port` listens on every loopback family. When another process already holds the IPv4 port, the IPv6 bind still succeeds and ExitOnForwardFailure does not fire: the tunnel comes up on [::1] while every client resolving 127.0.0.1 reaches the other process instead. This is how the local State Hub cache impersonated the primary from 2026-07-08 onward — both on port 8000, separated only by IP family. Refs CUST-WP-0067-T02, ADR-010 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- src/bridge/manager.py | 12 +++++++++++- tests/test_manager.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/bridge/manager.py b/src/bridge/manager.py index f6e1df4..f4ee9e4 100644 --- a/src/bridge/manager.py +++ b/src/bridge/manager.py @@ -29,7 +29,17 @@ def build_ssh_command(cfg: TunnelConfig, cert_path: Optional[Path] = None) -> Li """Build the SSH tunnel command (reverse -R or local -L).""" key = os.path.expanduser(cfg.ssh_key) if cfg.direction == "local": - forward_flag = ["-L", f"{cfg.local_port}:{cfg.remote_host}:{cfg.remote_port}"] + # Bind the forward explicitly to 127.0.0.1. Without a bind address ssh + # listens on every loopback family, so if another process already holds + # the IPv4 port the IPv6 bind still succeeds and ExitOnForwardFailure + # never fires — the tunnel comes up on [::1] and every client that + # resolves 127.0.0.1 silently reaches the other process instead. + # That is how a local State Hub cache impersonated the primary for + # seven weeks (CUST-WP-0067-T02). + forward_flag = [ + "-L", + f"127.0.0.1:{cfg.local_port}:{cfg.remote_host}:{cfg.remote_port}", + ] else: forward_flag = ["-R", f"{cfg.remote_port}:{cfg.remote_host}:{cfg.local_port}"] cmd = [ diff --git a/tests/test_manager.py b/tests/test_manager.py index 613617f..67e212c 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -25,6 +25,10 @@ def tunnel_cfg(): ) +def cmd_value(cmd, flag): + """Return the argument following ``flag`` in an argv list.""" + return cmd[cmd.index(flag) + 1] + @pytest.fixture def state_dir(tmp_path): return tmp_path / "bridge" @@ -44,7 +48,19 @@ class TestBuildSshCommand: cfg = replace(tunnel_cfg, direction="local", remote_host="10.43.103.154") cmd = build_ssh_command(cfg) assert "-L" in cmd - assert f"{cfg.local_port}:10.43.103.154:{cfg.remote_port}" in cmd + assert f"127.0.0.1:{cfg.local_port}:10.43.103.154:{cfg.remote_port}" in cmd + + def test_local_forward_pins_bind_address(self, tunnel_cfg): + """An unbound -L also listens on [::1], so a taken IPv4 port does not fail. + + ExitOnForwardFailure only fires when every requested bind fails. Pinning + 127.0.0.1 makes a port collision loud instead of routing clients to + whatever else holds the port (CUST-WP-0067-T02). + """ + cfg = replace(tunnel_cfg, direction="local") + forward = cmd_value(build_ssh_command(cfg), "-L") + assert forward.startswith("127.0.0.1:") + assert forward.count(":") == 3 def test_remote_host_default_loopback(self, tunnel_cfg): cmd = build_ssh_command(tunnel_cfg)