diff --git a/solstone/apps/network/routes.py b/solstone/apps/network/routes.py index 571c4f19b..62ddcd180 100644 --- a/solstone/apps/network/routes.py +++ b/solstone/apps/network/routes.py @@ -111,7 +111,8 @@ from solstone.think.utils import get_journal, now_ms logger = logging.getLogger(__name__) _SENDER_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,256}$") VALID_ROLES = {"", "phone", "observer", "peer"} -# The watcher emits only lan/ula today; vpn stays empty until a scope is wired. +# Overlay (Tailscale/CGNAT) endpoints the watcher scopes `vpn`; surfaced via +# /api/status and ordered after lan/ula in pair-link candidates. VPN_SCOPES = {"vpn"} _HEALTH_FRESHNESS_MS = 90_000 journal_sources = import_module("solstone.apps.import.journal_sources") @@ -188,21 +189,30 @@ def _current_local_endpoints() -> list[LocalEndpoint]: def _list_pair_link_candidates() -> list[str]: - """Return up to 4 watcher IPv4 candidates, detect-ip hinted, deduped then capped.""" - candidates: list[str] = [] + """Return up to 4 watcher IPv4 candidates. + + vpn-scoped candidates always order after lan/ula ones, and the default-route + promotion happens only within the route IP's own scope group — a vpn route IP + never displaces an available lan candidate. Results are deduped and capped. + """ + non_vpn: list[str] = [] + vpn: list[str] = [] for endpoint in _current_local_endpoints(): address = ipaddress.ip_address(endpoint.ip) - if isinstance(address, ipaddress.IPv4Address): - candidates.append(str(address)) + if not isinstance(address, ipaddress.IPv4Address): + continue + (vpn if endpoint.scope in VPN_SCOPES else non_vpn).append(str(address)) route_ip = _detect_lan_ip() - if route_ip in candidates: - candidates.remove(route_ip) - candidates.insert(0, route_ip) + for group in (non_vpn, vpn): + if route_ip in group: + group.remove(route_ip) + group.insert(0, route_ip) + break deduped: list[str] = [] seen: set[str] = set() - for candidate in candidates: + for candidate in (*non_vpn, *vpn): if candidate not in seen: deduped.append(candidate) seen.add(candidate) diff --git a/solstone/apps/network/tests/test_pair_link_v05.py b/solstone/apps/network/tests/test_pair_link_v05.py index c1e1bb71d..c236d6f43 100644 --- a/solstone/apps/network/tests/test_pair_link_v05.py +++ b/solstone/apps/network/tests/test_pair_link_v05.py @@ -38,6 +38,17 @@ def _split_v05(pair_link: str) -> tuple[int, list[str], str, str]: return port, addresses, nonce, ca_pin +def _split_v04(pair_link: str) -> tuple[str, int, str, str]: + blob = _decode_blob(pair_link) + assert blob[0:2] == b"\x04\x01" + host = str(ipaddress.IPv4Address(blob[2:6])) + port = int.from_bytes(blob[6:8], "big") + nonce = blob[8:24].hex() + ca_pin = blob[24:40].hex() + assert len(blob) == 40 + return host, port, nonce, ca_pin + + def _post_pair_start(env) -> dict: response = env.client.post( "/app/network/pair-start", @@ -107,6 +118,20 @@ def test_pair_start_emit_switch_v04_then_v05(link_env) -> None: assert multiple[0] == 0x05 +def test_pair_start_vpn_only_uses_v04_candidate(link_env, monkeypatch) -> None: + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) + env = link_env( + local_endpoints=[LocalEndpoint(ip="100.64.0.5", port=7657, scope="vpn")] + ) + + pair_link = _post_pair_start(env)["pair_link"] + host, port, _, _ = _split_v04(pair_link) + + assert pair_link + assert host == "100.64.0.5" + assert port == 7657 + + def test_pair_start_candidates_sourced_from_snapshot_not_detect( link_env, monkeypatch, @@ -139,6 +164,41 @@ def test_pair_start_default_route_moved_first(link_env, monkeypatch) -> None: assert addresses == ["198.51.100.20", "192.0.2.10"] +def test_pair_start_vpn_route_ip_does_not_displace_lan( + link_env, + monkeypatch, +) -> None: + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "100.64.0.5") + env = link_env( + local_endpoints=[ + LocalEndpoint(ip="192.168.1.10", port=7657, scope="lan"), + LocalEndpoint(ip="100.64.0.5", port=7657, scope="vpn"), + ] + ) + + _, addresses, _, _ = _split_v05(_post_pair_start(env)["pair_link"]) + + assert addresses == ["192.168.1.10", "100.64.0.5"] + + +def test_pair_start_orders_vpn_after_non_vpn_input_order( + link_env, + monkeypatch, +) -> None: + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) + env = link_env( + local_endpoints=[ + LocalEndpoint(ip="100.64.0.5", port=7657, scope="vpn"), + LocalEndpoint(ip="192.0.2.10", port=7657, scope="lan"), + LocalEndpoint(ip="192.0.2.11", port=7657, scope="lan"), + ] + ) + + _, addresses, _, _ = _split_v05(_post_pair_start(env)["pair_link"]) + + assert addresses == ["192.0.2.10", "192.0.2.11", "100.64.0.5"] + + def test_pair_start_v05_uses_secure_listener_port_not_endpoint_ports( link_env, monkeypatch, diff --git a/solstone/think/link/interface_watcher.py b/solstone/think/link/interface_watcher.py index c9a634c60..2f1334440 100644 --- a/solstone/think/link/interface_watcher.py +++ b/solstone/think/link/interface_watcher.py @@ -17,21 +17,20 @@ from .local_endpoints import LocalEndpoint log = logging.getLogger("link.interface_watcher") LINK_DIRECT_PORT = 7657 -_EXCLUDED_INTERFACE_PREFIXES = ( - "lo", - "docker", - "br-", - "vbox", - "vmnet", - "tun", - "tap", - "utun", -) +# Interfaces we never advertise, no matter the address (bridged/virtual/loopback). +# `tap*` is here: bridged Ethernet over VPN carrying RFC1918, not +# peer-reachable overlay. +_HARD_EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "vbox", "vmnet", "tap") +# Overlay tunnels that may carry peer-reachable overlay addresses (macOS utun, +# generic tun, Linux tailscale0 and Tailscale-branded adapters). Admitted only +# when the address itself is CGNAT or ULA - name AND range, never name alone. +_OVERLAY_INTERFACE_PREFIXES = ("utun", "tun", "tailscale") _LAN_V4_NETWORKS = ( ipaddress.IPv4Network("10.0.0.0/8"), ipaddress.IPv4Network("172.16.0.0/12"), ipaddress.IPv4Network("192.168.0.0/16"), ) +_CGNAT_V4_NETWORK = ipaddress.IPv4Network("100.64.0.0/10") # RFC 6598 _ULA_V6_NETWORK = ipaddress.IPv6Network("fc00::/7") @@ -70,10 +69,8 @@ class InterfaceWatcher: def _update_snapshot(self, raw: Iterable[tuple[str, str]]) -> None: endpoints: list[LocalEndpoint] = [] for ifname, ip_str in raw: - if _is_excluded_interface(ifname): - continue normalized = _normalize_ip(ip_str) - scope = _classify(normalized) + scope = _classify(ifname, normalized) if scope is None: continue endpoints.append( @@ -99,7 +96,7 @@ class InterfaceWatcher: try: raw: set[tuple[str, str]] = set() for ifname, addrs in psutil.net_if_addrs().items(): - if _is_excluded_interface(ifname): + if _is_hard_excluded(ifname): continue for addr in addrs: if addr.family not in (socket.AF_INET, socket.AF_INET6): @@ -111,25 +108,39 @@ class InterfaceWatcher: await asyncio.sleep(self._poll_interval) -def _is_excluded_interface(name: str) -> bool: - return name.startswith(_EXCLUDED_INTERFACE_PREFIXES) +def _is_hard_excluded(name: str) -> bool: + return name.startswith(_HARD_EXCLUDED_INTERFACE_PREFIXES) + + +def _is_overlay_interface(name: str) -> bool: + return name.startswith(_OVERLAY_INTERFACE_PREFIXES) def _normalize_ip(ip_str: str) -> str: return ip_str.split("%", 1)[0].split("/", 1)[0] -def _classify(ip_str: str) -> str | None: +def _classify(ifname: str, ip_str: str) -> str | None: + if _is_hard_excluded(ifname): + return None try: ip_addr = ipaddress.ip_address(_normalize_ip(ip_str)) except ValueError: return None if ip_addr.is_loopback or ip_addr.is_link_local or ip_addr.is_multicast: return None + overlay = _is_overlay_interface(ifname) if isinstance(ip_addr, ipaddress.IPv4Address): if any(ip_addr in network for network in _LAN_V4_NETWORKS): - return "lan" + # RFC1918 on an overlay interface is the tunnel's own private space, + # not peer-reachable overlay; drop. On an ordinary NIC it's the LAN. + return None if overlay else "lan" + if overlay and ip_addr in _CGNAT_V4_NETWORK: + return "vpn" + # CGNAT on an ordinary NIC is the host's ISP-CGNAT WAN address, not + # peer-reachable - drop (falls through to None). return None + # IPv6 - ULA is name-agnostic: preserves today's `en0 + fd00::1 -> ula`. if ip_addr in _ULA_V6_NETWORK: return "ula" return None @@ -151,7 +162,7 @@ __all__ = [ "InterfaceWatcher", "LINK_DIRECT_PORT", "_classify", - "_is_excluded_interface", + "_is_hard_excluded", "get_interface_watcher", "set_interface_watcher", ] diff --git a/solstone/think/link/local_endpoints.py b/solstone/think/link/local_endpoints.py index 9bc338ad8..193551b1c 100644 --- a/solstone/think/link/local_endpoints.py +++ b/solstone/think/link/local_endpoints.py @@ -12,7 +12,7 @@ from dataclasses import dataclass class LocalEndpoint: ip: str port: int - scope: str # "lan" or "ula" + scope: str # "lan", "ula", or "vpn" @dataclass(frozen=True) diff --git a/tests/link/test_interface_watcher.py b/tests/link/test_interface_watcher.py index af2a0622c..3439ca218 100644 --- a/tests/link/test_interface_watcher.py +++ b/tests/link/test_interface_watcher.py @@ -6,41 +6,83 @@ from __future__ import annotations from solstone.think.link.interface_watcher import ( InterfaceWatcher, _classify, - _is_excluded_interface, + _is_hard_excluded, ) def test_classify_lan_ipv4() -> None: - assert _classify("10.0.0.1") == "lan" - assert _classify("172.16.5.5") == "lan" - assert _classify("172.31.255.254") == "lan" - assert _classify("192.168.1.1") == "lan" + assert _classify("en0", "10.0.0.1") == "lan" + assert _classify("en0", "172.16.5.5") == "lan" + assert _classify("en0", "172.31.255.254") == "lan" + assert _classify("en0", "192.168.1.1") == "lan" def test_classify_ula_ipv6() -> None: - assert _classify("fc00::1") == "ula" - assert _classify("fd00::1") == "ula" + assert _classify("en0", "fc00::1") == "ula" + assert _classify("en0", "fd00::1") == "ula" def test_classify_excludes_non_lan_addresses() -> None: - assert _classify("8.8.8.8") is None - assert _classify("172.32.0.1") is None - assert _classify("127.0.0.1") is None - assert _classify("169.254.1.1") is None - assert _classify("fe80::1") is None - assert _classify("fe80::1%eth0") is None - assert _classify("::1") is None - assert _classify("ff02::1") is None - assert _classify("2001:db8::1") is None - - -def test_is_excluded_interface() -> None: - for name in ("lo", "lo0", "docker0", "br-abc", "vboxnet0", "vmnet8"): - assert _is_excluded_interface(name) - for name in ("tun0", "tap0", "utun3"): - assert _is_excluded_interface(name) - for name in ("eth0", "en0", "wlan0", "wlp3s0", "enp0s31f6"): - assert not _is_excluded_interface(name) + assert _classify("en0", "8.8.8.8") is None + assert _classify("en0", "172.32.0.1") is None + assert _classify("en0", "127.0.0.1") is None + assert _classify("en0", "169.254.1.1") is None + assert _classify("en0", "fe80::1") is None + assert _classify("en0", "fe80::1%eth0") is None + assert _classify("en0", "::1") is None + assert _classify("en0", "ff02::1") is None + assert _classify("en0", "2001:db8::1") is None + + +def test_is_hard_excluded_interface() -> None: + for name in ( + "lo", + "lo0", + "docker0", + "br-abc", + "vboxnet0", + "vmnet8", + "tap0", + "tap5", + ): + assert _is_hard_excluded(name) + for name in ( + "eth0", + "en0", + "wlan0", + "wlp3s0", + "enp0s31f6", + "tun0", + "utun3", + "tailscale0", + ): + assert not _is_hard_excluded(name) + + +def test_joint_classification_matrix() -> None: + cases = [ + ("en0", "10.0.0.1", "lan"), + ("eth0", "192.168.1.5", "lan"), + ("wlan0", "172.16.5.5", "lan"), + ("en0", "fd00::1", "ula"), + ("utun7", "fd00::1", "ula"), + ("tailscale0", "100.64.0.5", "vpn"), + ("utun7", "100.64.0.5", "vpn"), + ("tun0", "100.100.1.2", "vpn"), + ("utun7", "fd7a:115c:a1e0::1", "ula"), + ("eth0", "100.64.0.5", None), + ("en0", "100.64.0.5", None), + ("wlan0", "100.64.0.5", None), + ("docker0", "100.64.0.5", None), + ("br-abc", "100.64.0.5", None), + ("vboxnet0", "100.64.0.5", None), + ("vmnet1", "100.64.0.5", None), + ("tap0", "100.64.0.5", None), + ("utun7", "10.0.0.1", None), + ("tun0", "192.168.1.5", None), + ] + for ifname, ip, expected in cases: + assert _classify(ifname, ip) == expected def test_update_snapshot_filters_and_sorts() -> None: @@ -65,3 +107,21 @@ def test_update_snapshot_filters_and_sorts() -> None: ("ula", "fd00::1", 7657), ] assert endpoints == sorted(endpoints, key=lambda ep: (ep.scope, ep.ip)) + + +def test_update_snapshot_classifies_overlay_jointly() -> None: + watcher = InterfaceWatcher() + + watcher._update_snapshot( + [ + ("tailscale0", "100.64.0.7"), + ("utun5", "10.0.0.9"), + ("en0", "192.168.1.10"), + ("tap0", "100.64.0.9"), + ] + ) + + assert [(ep.scope, ep.ip, ep.port) for ep in watcher.snapshot()] == [ + ("lan", "192.168.1.10", 7657), + ("vpn", "100.64.0.7", 7657), + ]