Compare commits

...

2 commits

Author SHA1 Message Date
68bccdd1ec docs(agents): repoint remote State Hub URL to the in-cluster address
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
The remote row pointed at 127.0.0.1:18000, a reverse tunnel back to the
workstation. On railiance01 the State Hub runs in the cluster on that same
machine, so the request left the box and came back to reach a local service.

Refs CUST-WP-0067-T07

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 00:21:28 +02:00
22138474b8 fix(manager): pin local forwards to 127.0.0.1 so port collisions fail loudly
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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 22:10:07 +02:00
3 changed files with 29 additions and 3 deletions

View file

@ -19,7 +19,7 @@ there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
| Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` |
| Optional local edge relay | http://127.0.0.1:18080 |
When an operator has enabled the edge relay, set API_BASE to the relay URL.

View file

@ -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 = [

View file

@ -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)