From d559f8dfbde39d35243ff7b21fafed6f57e6b9bc Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 2 Jul 2026 22:36:07 -0600 Subject: [PATCH] feat(link): advertise Tailscale-overlay (CGNAT) interfaces as pairing candidates The LAN-interface watcher dropped every overlay-class interface by name before classification ever ran, so a phone and journal sharing a Tailscale tailnet but no physical LAN couldn't pair despite being directly reachable. Make interface classification a joint name+address decision: overlay-plausible names (utun*/tun*/tailscale*) carrying CGNAT (100.64.0.0/10) scope "vpn", ULA (fc00::/7) still scope "ula"; tap* becomes hard-excluded regardless of address; CGNAT on an ordinary NIC stays excluded (host ISP-CGNAT WAN, not peer-reachable). The already-declared "vpn" scope now flows through to /api/status vpn_candidates and the pair-link candidate list. Fix a candidate-ordering bug so a vpn-scoped IP is never promoted ahead of a lan/ula one: _list_pair_link_candidates() retains scope, promotes the default route only within its own scope group, and orders vpn candidates last. This unblocks pairing when the only reachable IPv4 is the Tailscale overlay address. No wire-format change, no contract change, no iOS change. Co-Authored-By: Codex --- solstone/apps/network/routes.py | 28 +++-- .../apps/network/tests/test_pair_link_v05.py | 60 ++++++++++ solstone/think/link/interface_watcher.py | 49 +++++--- solstone/think/link/local_endpoints.py | 2 +- tests/link/test_interface_watcher.py | 110 ++++++++++++++---- 5 files changed, 195 insertions(+), 54 deletions(-) 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), + ] -- 2.51.2