diff --git a/src/bridge/config.py b/src/bridge/config.py index 35cf60e..a7fac7b 100644 --- a/src/bridge/config.py +++ b/src/bridge/config.py @@ -5,7 +5,7 @@ import os import warnings from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, Optional import yaml @@ -59,45 +59,9 @@ def _parse_tunnels(raw: dict) -> Dict[str, TunnelConfig]: if not isinstance(data, dict): raise ConfigError(f"Tunnel '{name}' must be a mapping") tunnels[name] = _parse_tunnel(name, data) - _reject_duplicate_local_ports(tunnels) return tunnels -def _reject_duplicate_local_ports(tunnels: Dict[str, TunnelConfig]) -> None: - """Two `direction: local` tunnels must never share a local_port. - - Only local forwards bind a port on this machine - (``-L local_port:remote_host:remote_port``). If two claim the same one, - whichever binds first wins and the losers sit in an endless reconnect loop — - the map reads healthy while the port answers from a different machine than - the one you asked for. Silent, and worse than an outage. Fail at load - instead (BRIDGE-WP-0007). - - Reverse tunnels are deliberately exempt. There, local_port is the - *destination* on this workstation - (``-R remote_port:remote_host:local_port``) and the listener is on the - remote, so sharing is the intended design: every ``state-hub-*`` reverse - tunnel targets 8000 so each remote box reaches this hub at its own 18000. - Rejecting that would be rejecting the point of the tool. - """ - by_port: Dict[int, List[str]] = {} - for name, tunnel in tunnels.items(): - if tunnel.direction != "local": - continue - by_port.setdefault(tunnel.local_port, []).append(name) - collisions = {port: names for port, names in by_port.items() if len(names) > 1} - if not collisions: - return - detail = "; ".join( - f"port {port}: {', '.join(sorted(names))}" for port, names in sorted(collisions.items()) - ) - raise ConfigError( - f"Duplicate local_port among 'direction: local' tunnels ({detail}). " - f"Each local forward binds that port here, so they would race for it — " - f"give the non-canonical one a different port." - ) - - def _parse_tunnel(name: str, data: dict) -> TunnelConfig: required = ["host", "remote_port", "local_port", "ssh_user", "ssh_key", "actor"] for field in required: diff --git a/tests/test_config.py b/tests/test_config.py index b6ed135..1d453f7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -297,48 +297,3 @@ class TestCertCommandConfig: monkeypatch.setenv("BRIDGE_CONFIG", str(config_file)) cfg = load_config() assert cfg.tunnels["state-hub-coulombcore"].cert_command is None - - -def test_duplicate_local_ports_are_rejected(): - """A local-forward port collision is silent at runtime; fail at load.""" - from bridge.config import ConfigError, _parse_tunnels - - base = { - "host": "192.0.2.1", - "remote_port": 18000, - "ssh_user": "tegwick", - "ssh_key": "~/.ssh/id_ops", - "actor": "agt-claude-railiance01", - "direction": "local", - } - with pytest.raises(ConfigError, match="Duplicate local_port"): - _parse_tunnels( - { - "k3s-a": {**base, "local_port": 16443}, - "k3s-b": {**base, "local_port": 16443}, - } - ) - - -def test_reverse_tunnels_may_share_a_local_port(): - """local_port is the destination on this workstation for a reverse tunnel. - - Every state-hub-* reverse tunnel targets 8000 on purpose, so each remote box - reaches this hub at its own 18000. Rejecting that would break the tool. - """ - from bridge.config import _parse_tunnels - - base = { - "host": "192.0.2.1", - "remote_port": 18000, - "ssh_user": "tegwick", - "ssh_key": "~/.ssh/id_ops", - "actor": "agt-claude-railiance01", - } - tunnels = _parse_tunnels( - { - "state-hub-a": {**base, "local_port": 8000}, - "state-hub-b": {**base, "local_port": 8000}, - } - ) - assert {t.local_port for t in tunnels.values()} == {8000} diff --git a/wiki/OpsBridge.md b/wiki/OpsBridge.md index 8130ae6..fd07095 100644 --- a/wiki/OpsBridge.md +++ b/wiki/OpsBridge.md @@ -224,8 +224,8 @@ Tunnels in `~/.config/bridge/tunnels.yaml` serve three host roles: | Role | Hosts | Behaviour | |------|-------|-----------| | **Workstation origin** | WSL laptop | Shutdown, sleep, and network changes kill local bridge processes without graceful remote SSH teardown. Orphan forwards on all remotes are common after wake. | -| **VPS remotes** | railiance01, coulombcore (being retired) | Normally always-on. Maintenance reboots clear kernel state, but laptop return can leave orphan forwards from the previous session if the VPS did not reboot. | -| **LAN builder** | *(none)* | haskelseed carried this role until 2026-08-19. Its Haskell/IHP build lane had been parked since 2026-07-08 (`CORE-WP-0007`) and the host was retired; its four tunnels and the `agt-claude-haskelseed` actor were removed. | +| **VPS remotes** | coulombcore, railiance01 | Normally always-on. Maintenance reboots clear kernel state, but laptop return can leave orphan forwards from the previous session if the VPS did not reboot. | +| **LAN builder** | haskelseed | Haskell/IHP build lane parked (`CORE-WP-0007`, 2026-07-08). Intermittently offline; same orphan-forward pattern when the workstation-side tunnel dies uncleanly. | Conditional remote cleanup before restart benefits all reverse tunnels. `should_cleanup_tunnel` skips healthy forwards — VPS tunnels with live working