personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

Split pairing home address from dashboard URL

Persist pairing.home_address as a bare IPv4 listener address, keep Convey client resolution loopback-only, and converge network status/pair-start LAN evidence through a shared pure resolver. Remove the settings host-url mutation surfaces, expose dashboard_url for Convey status, and add the one-shot migration from legacy pairing.host_url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+709 -398
+48
solstone/apps/network/home_candidates.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pure home-address candidate resolution for network pairing.""" 5 + 6 + from __future__ import annotations 7 + 8 + from solstone.think.link.local_endpoints import LocalEndpoint 9 + from solstone.think.pairing.config import is_usable_ipv4 10 + 11 + VPN_SCOPES = {"vpn"} 12 + 13 + 14 + def resolve_pair_link_candidates( 15 + endpoints: list[LocalEndpoint], 16 + route_ipv4: str | None, 17 + ) -> list[str]: 18 + """Return up to four ordered usable IPv4 candidates for direct pairing.""" 19 + 20 + usable_route = ( 21 + route_ipv4 if route_ipv4 is not None and is_usable_ipv4(route_ipv4) else None 22 + ) 23 + filtered = [endpoint for endpoint in endpoints if is_usable_ipv4(endpoint.ip)] 24 + if not filtered: 25 + return [usable_route] if usable_route is not None else [] 26 + 27 + non_vpn: list[str] = [] 28 + vpn: list[str] = [] 29 + for endpoint in filtered: 30 + (vpn if endpoint.scope in VPN_SCOPES else non_vpn).append(endpoint.ip) 31 + 32 + if usable_route is not None: 33 + for group in (non_vpn, vpn): 34 + if usable_route in group: 35 + group.remove(usable_route) 36 + group.insert(0, usable_route) 37 + break 38 + 39 + deduped: list[str] = [] 40 + seen: set[str] = set() 41 + for candidate in (*non_vpn, *vpn): 42 + if candidate not in seen: 43 + deduped.append(candidate) 44 + seen.add(candidate) 45 + return deduped[:4] 46 + 47 + 48 + __all__ = ["VPN_SCOPES", "resolve_pair_link_candidates"]
+104 -87
solstone/apps/network/routes.py
··· 47 47 link_copy_payload, 48 48 ) 49 49 from solstone.apps.network.crockford32 import encode as crockford_encode 50 + from solstone.apps.network.home_candidates import ( 51 + VPN_SCOPES, 52 + resolve_pair_link_candidates, 53 + ) 50 54 from solstone.apps.network.relay_link import ( 51 55 derive_rk, 52 56 encode_pair_window_link, ··· 98 102 ) 99 103 from solstone.think.link.window import read_posture 100 104 from solstone.think.pairing.config import ( 101 - InvalidHostUrl, 102 - clear_host_url, 103 - override_host_port, 104 - set_host_url, 105 - validate_host_url, 105 + InvalidHomeAddress, 106 + clear_home_address, 107 + get_home_address, 108 + set_home_address, 109 + validate_home_address, 106 110 ) 107 111 from solstone.think.services import operations, spl, spl_handoff 108 112 from solstone.think.services import status as service_status ··· 113 117 logger = logging.getLogger(__name__) 114 118 _SENDER_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,256}$") 115 119 VALID_ROLES = {"", "phone", "observer", "peer"} 116 - # Overlay (Tailscale/CGNAT) endpoints the watcher scopes `vpn`; surfaced via 117 - # /api/status and ordered after lan/ula in pair-link candidates. 118 - VPN_SCOPES = {"vpn"} 119 120 _HEALTH_FRESHNESS_MS = 90_000 120 121 journal_sources = import_module("solstone.apps.import.journal_sources") 121 122 create_state_directory = journal_sources.create_state_directory ··· 190 191 return watcher.snapshot() if watcher else [] 191 192 192 193 193 - def _list_pair_link_candidates() -> list[str]: 194 - """Return up to 4 watcher IPv4 candidates. 194 + @dataclass(frozen=True) 195 + class HomeAddressStatus: 196 + home_address: str | None 197 + lan_accessible: bool 198 + candidates: list[str] 199 + home_candidates: list[dict[str, Any]] 200 + home_candidates_state: str 201 + home_candidates_error: str | None 195 202 196 - vpn-scoped candidates always order after lan/ula ones, and the default-route 197 - promotion happens only within the route IP's own scope group — a vpn route IP 198 - never displaces an available lan candidate. Results are deduped and capped. 199 - """ 200 - non_vpn: list[str] = [] 201 - vpn: list[str] = [] 202 - for endpoint in _current_local_endpoints(): 203 - address = ipaddress.ip_address(endpoint.ip) 204 - if not isinstance(address, ipaddress.IPv4Address): 205 - continue 206 - (vpn if endpoint.scope in VPN_SCOPES else non_vpn).append(str(address)) 207 203 208 - route_ip = _detect_lan_ip() 209 - for group in (non_vpn, vpn): 210 - if route_ip in group: 211 - group.remove(route_ip) 212 - group.insert(0, route_ip) 213 - break 204 + def _resolve_pair_link_candidates(endpoints: list[LocalEndpoint]) -> list[str]: 205 + """Return resolved direct-pairing IPv4 candidates from local evidence.""" 214 206 215 - deduped: list[str] = [] 216 - seen: set[str] = set() 217 - for candidate in (*non_vpn, *vpn): 218 - if candidate not in seen: 219 - deduped.append(candidate) 220 - seen.add(candidate) 221 - return deduped[:4] 207 + return resolve_pair_link_candidates(endpoints, _detect_lan_ip()) 222 208 223 209 224 210 def _secure_listener_port() -> int: ··· 231 217 return interface_watcher.LINK_DIRECT_PORT 232 218 233 219 234 - def _home_candidate_entries() -> list[dict[str, Any]]: 220 + def _home_candidate_entries( 221 + home_address: str | None, 222 + candidates: list[str], 223 + ) -> list[dict[str, Any]]: 235 224 port = _secure_listener_port() 236 - detected = [f"{ip}:{port}" for ip in _list_pair_link_candidates()] 237 - override = override_host_port() 238 - selected = override or (detected[0] if detected else None) 225 + detected = [f"{ip}:{port}" for ip in candidates] 226 + selected = home_address or (detected[0] if detected else None) 239 227 240 228 entries: list[dict[str, Any]] = [ 241 229 { ··· 245 233 } 246 234 for address in detected 247 235 ] 248 - if override is not None and override not in detected: 236 + if home_address is not None and home_address not in detected: 249 237 entries.append( 250 238 { 251 - "address": override, 239 + "address": home_address, 252 240 "selected": True, 253 241 "source": "override", 254 242 } ··· 256 244 return entries 257 245 258 246 259 - def _effective_home_address() -> tuple[bool, str | None]: 260 - override_addr = override_host_port() 261 - if override_addr is not None: 262 - return True, override_addr 263 - return _is_lan_accessible(), None 247 + def _home_address_host(home_address: str) -> str: 248 + return home_address.partition(":")[0] 249 + 250 + 251 + def _home_address_status() -> tuple[HomeAddressStatus, list[LocalEndpoint]]: 252 + home_address = get_home_address() 253 + try: 254 + endpoints = _current_local_endpoints() 255 + candidates = _resolve_pair_link_candidates(endpoints) 256 + except Exception: 257 + logger.exception("link home candidate collection failed") 258 + if home_address is not None: 259 + candidates = [_home_address_host(home_address)] 260 + return ( 261 + HomeAddressStatus( 262 + home_address=home_address, 263 + lan_accessible=True, 264 + candidates=candidates, 265 + home_candidates=_home_candidate_entries(home_address, []), 266 + home_candidates_state="ready", 267 + home_candidates_error=None, 268 + ), 269 + [], 270 + ) 271 + return ( 272 + HomeAddressStatus( 273 + home_address=None, 274 + lan_accessible=False, 275 + candidates=[], 276 + home_candidates=[], 277 + home_candidates_state="unavailable", 278 + home_candidates_error=link_copy.HOME_CANDIDATES_ERROR, 279 + ), 280 + [], 281 + ) 282 + 283 + return ( 284 + HomeAddressStatus( 285 + home_address=home_address, 286 + lan_accessible=home_address is not None or bool(candidates), 287 + candidates=candidates, 288 + home_candidates=_home_candidate_entries(home_address, candidates), 289 + home_candidates_state="ready", 290 + home_candidates_error=None, 291 + ), 292 + endpoints, 293 + ) 264 294 265 295 266 296 def _detect_lan_ip() -> str | None: ··· 345 375 346 376 def _jsonify_preserving_order(payload: dict[str, Any]) -> Response: 347 377 return Response(_json.dumps(payload), mimetype="application/json") 348 - 349 - 350 - def _is_lan_accessible() -> bool: 351 - """Check whether the journal's home address is reachable on the LAN. 352 - 353 - Feeds the home-address reachability status on /link. Best-effort: the 354 - signal is the Host header the dashboard loaded under. 355 - """ 356 - hostname, _, _ = request.host.partition(":") 357 - if hostname in ("localhost", "127.0.0.1", "::1"): 358 - return bool(_detect_lan_ip()) 359 - return True 360 378 361 379 362 380 def _derive_relay_state(token_present: bool) -> str: ··· 487 505 token = load_service_token() 488 506 token_present = token is not None 489 507 ca_fp = _ca_fingerprint() if ca_dir().exists() else None 490 - lan_accessible, home_address = _effective_home_address() 508 + home_status, local_endpoints = _home_address_status() 491 509 posture = read_posture() 492 510 relay_state = ( 493 511 _derive_spl_relay_state(token_present, health, now_ms_val) 494 512 if posture == "spl" 495 513 else _derive_relay_state(token_present) 496 514 ) 497 - reachability = _derive_reachability(lan_accessible, posture, relay_state) 515 + reachability = _derive_reachability( 516 + home_status.lan_accessible, 517 + posture, 518 + relay_state, 519 + ) 498 520 vpn_candidates = [ 499 521 {"label": ep.scope, "address": f"{ep.ip}:{ep.port}"} 500 - for ep in _current_local_endpoints() 522 + for ep in local_endpoints 501 523 if ep.scope in VPN_SCOPES 502 524 ] 503 - try: 504 - home_candidates = _home_candidate_entries() 505 - home_candidates_state = "ready" 506 - home_candidates_error = None 507 - except Exception: 508 - logger.exception("link home candidate collection failed") 509 - home_candidates = [] 510 - home_candidates_state = "unavailable" 511 - home_candidates_error = link_copy.HOME_CANDIDATES_ERROR 512 525 return jsonify( 513 526 { 514 527 "instance_id": state.instance_id if state else None, ··· 516 529 "enrolled": token_present, 517 530 "relay_url": relay_url(), 518 531 "ca_fingerprint": ca_fp, 519 - "lan_accessible": lan_accessible, 532 + "lan_accessible": home_status.lan_accessible, 520 533 "posture": posture, 521 534 "reachability": reachability, 522 535 "relay_state": relay_state, ··· 531 544 "last_relay_tunnel_error_at": ( 532 545 health["last_relay_tunnel_error_at"] if health else None 533 546 ), 534 - "home_address": home_address, 547 + "home_address": home_status.home_address, 535 548 "vpn": {"active": None, "candidates": vpn_candidates}, 536 - "home_candidates": home_candidates, 537 - "home_candidates_state": home_candidates_state, 538 - "home_candidates_error": home_candidates_error, 549 + "home_candidates": home_status.home_candidates, 550 + "home_candidates_state": home_status.home_candidates_state, 551 + "home_candidates_error": home_status.home_candidates_error, 539 552 } 540 553 ) 541 554 ··· 617 630 618 631 619 632 @network_bp.route("/host-address", methods=["POST"]) 620 - def set_host_address() -> Any: 633 + def set_home_address_route() -> Any: 621 634 payload = request.get_json(silent=True) or {} 622 635 if not isinstance(payload, dict): 623 636 payload = {} 624 - raw_address = payload.get("address") 625 - address = raw_address if isinstance(raw_address, str) else None 637 + raw_address = payload.get("home_address") 638 + home_address = raw_address if isinstance(raw_address, str) else None 626 639 try: 627 - if address is not None and address.strip(): 628 - set_host_url(validate_host_url(address)) 640 + if home_address is not None and home_address.strip(): 641 + set_home_address(validate_home_address(home_address)) 629 642 else: 630 - clear_host_url() 631 - except InvalidHostUrl as exc: 643 + clear_home_address() 644 + except InvalidHomeAddress as exc: 632 645 return error_response(INVALID_CONFIG_VALUE, detail=str(exc)) 633 - _, home_address = _effective_home_address() 634 - return jsonify({"ok": True, "home_address": home_address}) 646 + home_status, _ = _home_address_status() 647 + return jsonify({"ok": True, "home_address": home_status.home_address}) 635 648 636 649 637 650 @network_bp.get("/local-endpoints") ··· 701 714 else: 702 715 ca_fp = _ca_fingerprint() 703 716 port = _secure_listener_port() 704 - override = override_host_port() 705 - if override is not None: 706 - candidates = [override.partition(":")[0]] 717 + home_address = get_home_address() 718 + if home_address is not None: 719 + candidates = [_home_address_host(home_address)] 707 720 else: 708 - candidates = _list_pair_link_candidates() 721 + try: 722 + candidates = _resolve_pair_link_candidates(_current_local_endpoints()) 723 + except Exception: 724 + logger.exception("link pair-start candidate collection failed") 725 + candidates = [] 709 726 if not candidates: 710 727 return error_response( 711 728 PAIRING_REQUEST_INVALID,
+51 -17
solstone/apps/network/tests/test_api_status.py
··· 99 99 ) 100 100 101 101 102 - def _write_host_override(env: Any, address: str) -> None: 102 + def _write_home_address(env: Any, address: str) -> None: 103 103 config_path = env.journal / "config" / "journal.json" 104 104 config = json.loads(config_path.read_text("utf-8")) 105 - config["pairing"] = {"host_url": f"http://{address}"} 105 + config["pairing"] = {"home_address": address} 106 106 config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 107 107 108 108 ··· 148 148 assert data["relay_state"] == "not-enrolled" 149 149 150 150 151 - def test_direct_reports_host_address_override(link_env, monkeypatch) -> None: 151 + def test_direct_reports_manual_home_address(link_env, monkeypatch) -> None: 152 152 env = link_env() 153 - config_path = env.journal / "config" / "journal.json" 154 - config = json.loads(config_path.read_text("utf-8")) 155 - config["pairing"] = {"host_url": "http://192.168.1.44:7657"} 156 - config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 153 + _write_home_address(env, "192.168.1.44:7657") 157 154 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 158 155 159 156 data = _get_status(env) ··· 165 162 166 163 def test_host_address_override_unblocks_lan_unreachable(link_env, monkeypatch) -> None: 167 164 env = link_env() 168 - config_path = env.journal / "config" / "journal.json" 169 - config = json.loads(config_path.read_text("utf-8")) 170 - config["pairing"] = {"host_url": "http://192.168.1.44:7657"} 171 - config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 165 + _write_home_address(env, "192.168.1.44:7657") 172 166 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) 173 167 174 168 data = _get_status(env) ··· 179 173 180 174 181 175 def test_loopback_only_is_lan_unreachable(link_env, monkeypatch) -> None: 182 - env = link_env() 176 + env = link_env(local_endpoints=[]) 183 177 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) 184 178 185 179 data = _get_status(env) ··· 189 183 assert data["reachability"] == "lan-unreachable" 190 184 191 185 186 + def test_empty_snapshot_route_fallback_reports_online(link_env, monkeypatch) -> None: 187 + env = link_env(local_endpoints=[]) 188 + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 189 + 190 + data = _get_status(env) 191 + 192 + assert data["lan_accessible"] is True 193 + assert data["home_address"] is None 194 + assert data["reachability"] == "online" 195 + assert data["home_candidates"] == [ 196 + {"address": "192.168.1.50:7657", "selected": True, "source": "detected"} 197 + ] 198 + assert data["home_candidates_state"] == "ready" 199 + assert data["home_candidates_error"] is None 200 + 201 + 192 202 def test_lan_unreachable_precedence_over_spl(link_env, monkeypatch) -> None: 193 - env = link_env() 203 + env = link_env(local_endpoints=[]) 194 204 _write_config(env, link={"posture": "spl"}) 195 205 _write_service_token(env) 196 206 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) ··· 401 411 LocalEndpoint(ip="192.168.1.51", port=7657, scope="lan"), 402 412 ] 403 413 ) 404 - _write_host_override(env, "192.168.1.51:7657") 414 + _write_home_address(env, "192.168.1.51:7657") 405 415 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 406 416 407 417 data = _get_status(env) ··· 419 429 env = link_env( 420 430 local_endpoints=[LocalEndpoint(ip="192.168.1.50", port=7657, scope="lan")] 421 431 ) 422 - _write_host_override(env, "192.168.1.44:7657") 432 + _write_home_address(env, "192.168.1.44:7657") 423 433 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 424 434 425 435 data = _get_status(env) ··· 437 447 env = link_env() 438 448 monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 439 449 440 - def fail_candidates() -> list[str]: 450 + def fail_candidates(endpoints: list[LocalEndpoint]) -> list[str]: 441 451 raise RuntimeError("watcher exploded") 442 452 443 - monkeypatch.setattr(link_routes, "_list_pair_link_candidates", fail_candidates) 453 + monkeypatch.setattr(link_routes, "_resolve_pair_link_candidates", fail_candidates) 444 454 445 455 data = _get_status(env) 446 456 447 457 assert data["home_candidates"] == [] 448 458 assert data["home_candidates_state"] == "unavailable" 449 459 assert data["home_candidates_error"] == link_copy.HOME_CANDIDATES_ERROR 460 + assert data["reachability"] == "lan-unreachable" 461 + 462 + 463 + def test_home_candidates_exception_with_override_stays_usable( 464 + link_env, 465 + monkeypatch, 466 + ) -> None: 467 + env = link_env() 468 + _write_home_address(env, "192.168.1.44:7657") 469 + 470 + def fail_candidates(endpoints: list[LocalEndpoint]) -> list[str]: 471 + raise RuntimeError("watcher exploded") 472 + 473 + monkeypatch.setattr(link_routes, "_resolve_pair_link_candidates", fail_candidates) 474 + 475 + data = _get_status(env) 476 + 477 + assert data["lan_accessible"] is True 478 + assert data["home_address"] == "192.168.1.44:7657" 450 479 assert data["reachability"] == "online" 480 + assert data["home_candidates"] == [ 481 + {"address": "192.168.1.44:7657", "selected": True, "source": "override"} 482 + ] 483 + assert data["home_candidates_state"] == "ready" 484 + assert data["home_candidates_error"] is None 451 485 452 486 453 487 def test_api_status_does_not_mint_pairing_nonces(link_env, monkeypatch) -> None:
+65
solstone/apps/network/tests/test_home_candidates.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from solstone.apps.network.home_candidates import resolve_pair_link_candidates 7 + from solstone.think.link.local_endpoints import LocalEndpoint 8 + 9 + 10 + def _endpoint(ip: str, scope: str = "lan") -> LocalEndpoint: 11 + return LocalEndpoint(ip=ip, port=7657, scope=scope) 12 + 13 + 14 + def test_resolver_orders_non_vpn_before_vpn() -> None: 15 + assert resolve_pair_link_candidates( 16 + [ 17 + _endpoint("100.64.0.5", "vpn"), 18 + _endpoint("192.168.1.10"), 19 + _endpoint("192.168.1.11"), 20 + ], 21 + None, 22 + ) == ["192.168.1.10", "192.168.1.11", "100.64.0.5"] 23 + 24 + 25 + def test_resolver_promotes_route_only_within_existing_bucket() -> None: 26 + assert resolve_pair_link_candidates( 27 + [ 28 + _endpoint("192.168.1.10"), 29 + _endpoint("100.64.0.5", "vpn"), 30 + ], 31 + "100.64.0.5", 32 + ) == ["192.168.1.10", "100.64.0.5"] 33 + 34 + 35 + def test_resolver_does_not_inject_distinct_route_into_non_empty_watcher() -> None: 36 + assert resolve_pair_link_candidates( 37 + [ 38 + _endpoint("192.168.1.10"), 39 + _endpoint("192.168.1.11"), 40 + ], 41 + "192.168.1.99", 42 + ) == ["192.168.1.10", "192.168.1.11"] 43 + 44 + 45 + def test_resolver_uses_route_fallback_when_watcher_empty() -> None: 46 + assert resolve_pair_link_candidates([], "192.168.1.50") == ["192.168.1.50"] 47 + 48 + 49 + def test_resolver_ignores_unusable_route() -> None: 50 + assert resolve_pair_link_candidates([], "127.0.0.1") == [] 51 + 52 + 53 + def test_resolver_dedupes_excludes_ipv6_and_caps() -> None: 54 + assert resolve_pair_link_candidates( 55 + [ 56 + _endpoint("192.168.1.10"), 57 + _endpoint("fd00::1", "ula"), 58 + _endpoint("192.168.1.11"), 59 + _endpoint("192.168.1.10"), 60 + _endpoint("192.168.1.12"), 61 + _endpoint("192.168.1.13"), 62 + _endpoint("192.168.1.14"), 63 + ], 64 + "192.168.1.14", 65 + ) == ["192.168.1.14", "192.168.1.10", "192.168.1.11", "192.168.1.12"]
+12 -15
solstone/apps/network/tests/test_host_address_route.py
··· 19 19 20 20 response = env.client.post( 21 21 "/app/network/host-address", 22 - json={"address": "http://192.168.1.44:7657"}, 22 + json={"home_address": "192.168.1.44:7657"}, 23 23 ) 24 24 25 25 assert response.status_code == 200 ··· 27 27 "ok": True, 28 28 "home_address": "192.168.1.44:7657", 29 29 } 30 - assert ( 31 - _read_config(env.journal)["pairing"]["host_url"] == "http://192.168.1.44:7657" 32 - ) 30 + assert _read_config(env.journal)["pairing"]["home_address"] == "192.168.1.44:7657" 33 31 34 32 35 33 def test_host_address_normalizes_bare_ipv4_port(link_env) -> None: ··· 37 35 38 36 response = env.client.post( 39 37 "/app/network/host-address", 40 - json={"address": "192.168.1.44:7657"}, 38 + json={"home_address": " 192.168.1.44:7657 "}, 41 39 ) 42 40 43 41 assert response.status_code == 200 44 42 assert response.get_json()["home_address"] == "192.168.1.44:7657" 45 - assert ( 46 - _read_config(env.journal)["pairing"]["host_url"] == "http://192.168.1.44:7657" 47 - ) 43 + assert _read_config(env.journal)["pairing"]["home_address"] == "192.168.1.44:7657" 48 44 49 45 50 46 def test_host_address_clears_on_empty_null_or_missing(link_env) -> None: 51 47 env = link_env() 52 48 53 - for body in ({"address": ""}, {"address": None}, {}): 49 + for body in ({"home_address": ""}, {"home_address": None}, {}): 54 50 env.client.post( 55 - "/app/network/host-address", json={"address": "192.168.1.44:7657"} 51 + "/app/network/host-address", 52 + json={"home_address": "192.168.1.44:7657"}, 56 53 ) 57 54 response = env.client.post("/app/network/host-address", json=body) 58 55 59 56 assert response.status_code == 200 60 - assert _read_config(env.journal)["pairing"]["host_url"] is None 57 + assert _read_config(env.journal)["pairing"]["home_address"] is None 61 58 62 59 63 60 def test_host_address_rejects_hostname_without_writing(link_env) -> None: ··· 66 63 67 64 response = env.client.post( 68 65 "/app/network/host-address", 69 - json={"address": "mylab.local:7657"}, 66 + json={"home_address": "mylab.local:7657"}, 70 67 ) 71 68 72 69 assert response.status_code == 400 73 70 payload = response.get_json() 74 71 assert payload["reason_code"] == "invalid_config_value" 75 - assert payload["detail"] == pairing_config.HOST_URL_HOSTNAME_UNSUPPORTED 72 + assert payload["detail"] == pairing_config.HOME_ADDRESS_HOSTNAME_UNSUPPORTED 76 73 assert _read_config(env.journal) == before 77 74 78 75 ··· 82 79 83 80 response = env.client.post( 84 81 "/app/network/host-address", 85 - json={"address": "http://192.168.1.44"}, 82 + json={"home_address": "http://192.168.1.44"}, 86 83 ) 87 84 88 85 assert response.status_code == 400 89 86 payload = response.get_json() 90 87 assert payload["reason_code"] == "invalid_config_value" 91 - assert payload["detail"] == pairing_config.HOST_URL_INVALID 88 + assert payload["detail"] == pairing_config.HOME_ADDRESS_INVALID 92 89 assert _read_config(env.journal) == before
+126 -4
solstone/apps/network/tests/test_pair_start.py
··· 13 13 from solstone.apps.network.crockford32 import decode as crockford_decode 14 14 from solstone.apps.network.relay_link import decode_pair_window_link, derive_rk 15 15 from solstone.think.link.ca import load_or_generate_ca 16 + from solstone.think.link.local_endpoints import LocalEndpoint 16 17 from solstone.think.link.nonces import NONCE_TTL_SECONDS 17 18 from solstone.think.link.paths import ca_dir 18 19 ··· 114 115 env = link_env() 115 116 config_path = env.journal / "config" / "journal.json" 116 117 config = json.loads(config_path.read_text("utf-8")) 117 - config["pairing"] = {"host_url": "http://192.0.2.44:7070"} 118 + config["pairing"] = {"home_address": "192.0.2.44:7657"} 118 119 config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 119 120 120 121 response = env.client.post( ··· 128 129 assert decoded[0:2] == b"\x04\x01" 129 130 assert decoded[2:6] == ipaddress.IPv4Address("192.0.2.44").packed 130 131 assert int.from_bytes(decoded[6:8], "big") == link_routes._secure_listener_port() 131 - assert int.from_bytes(decoded[6:8], "big") != 7070 132 132 133 133 134 134 def test_pair_start_direct_pair_link_port_uses_secure_listener_source( ··· 138 138 env = link_env() 139 139 config_path = env.journal / "config" / "journal.json" 140 140 config = json.loads(config_path.read_text("utf-8")) 141 - config["pairing"] = {"host_url": "http://192.0.2.44:7070"} 141 + config["pairing"] = {"home_address": "192.0.2.44:7657"} 142 142 config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 143 143 monkeypatch.setattr(link_routes.interface_watcher, "LINK_DIRECT_PORT", 8765) 144 144 ··· 154 154 assert int.from_bytes(decoded[6:8], "big") == 8765 155 155 156 156 157 - def test_pair_start_no_candidates_rejected_without_nonce(link_env) -> None: 157 + def test_pair_start_no_candidates_rejected_without_nonce( 158 + link_env, 159 + monkeypatch, 160 + ) -> None: 158 161 env = link_env(local_endpoints=[]) 162 + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) 159 163 160 164 response = env.client.post( 161 165 "/app/network/pair-start", ··· 169 173 assert link_routes._nonces().snapshot() == [] 170 174 171 175 176 + def test_pair_start_uses_route_fallback_when_snapshot_empty( 177 + link_env, 178 + monkeypatch, 179 + ) -> None: 180 + env = link_env(local_endpoints=[]) 181 + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 182 + 183 + response = env.client.post( 184 + "/app/network/pair-start", 185 + json={"device_label": "Test Phone"}, 186 + ) 187 + 188 + assert response.status_code == 200 189 + payload = response.get_json() 190 + decoded = _decode_pair_link(payload["pair_link"]) 191 + assert decoded[0:2] == b"\x04\x01" 192 + assert decoded[2:6] == ipaddress.IPv4Address("192.168.1.50").packed 193 + assert int.from_bytes(decoded[6:8], "big") == link_routes._secure_listener_port() 194 + 195 + 196 + def test_pair_start_resolver_exception_rejected_without_nonce( 197 + link_env, 198 + monkeypatch, 199 + ) -> None: 200 + env = link_env() 201 + 202 + def fail_candidates(endpoints): 203 + raise RuntimeError("watcher exploded") 204 + 205 + monkeypatch.setattr(link_routes, "_resolve_pair_link_candidates", fail_candidates) 206 + 207 + response = env.client.post( 208 + "/app/network/pair-start", 209 + json={"device_label": "Test Phone"}, 210 + ) 211 + 212 + assert response.status_code == 400 213 + payload = response.get_json() 214 + assert payload["reason_code"] == "pairing_request_invalid" 215 + assert payload["detail"] == "pair-link requires an IPv4 LAN address; none found" 216 + assert link_routes._nonces().snapshot() == [] 217 + 218 + 219 + def test_pair_start_override_survives_resolver_exception( 220 + link_env, 221 + monkeypatch, 222 + ) -> None: 223 + env = link_env() 224 + config_path = env.journal / "config" / "journal.json" 225 + config = json.loads(config_path.read_text("utf-8")) 226 + config["pairing"] = {"home_address": "192.168.1.44:7657"} 227 + config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") 228 + 229 + def fail_candidates(endpoints): 230 + raise RuntimeError("watcher exploded") 231 + 232 + monkeypatch.setattr(link_routes, "_resolve_pair_link_candidates", fail_candidates) 233 + 234 + response = env.client.post( 235 + "/app/network/pair-start", 236 + json={"device_label": "Test Phone"}, 237 + ) 238 + 239 + assert response.status_code == 200 240 + payload = response.get_json() 241 + decoded = _decode_pair_link(payload["pair_link"]) 242 + assert decoded[0:2] == b"\x04\x01" 243 + assert decoded[2:6] == ipaddress.IPv4Address("192.168.1.44").packed 244 + assert int.from_bytes(decoded[6:8], "big") == link_routes._secure_listener_port() 245 + 246 + 247 + def test_pair_start_detected_order_matches_api_status( 248 + link_env, 249 + monkeypatch, 250 + ) -> None: 251 + env = link_env( 252 + local_endpoints=[ 253 + LocalEndpoint(ip="192.168.1.51", port=1111, scope="lan"), 254 + LocalEndpoint(ip="192.168.1.50", port=2222, scope="lan"), 255 + LocalEndpoint(ip="192.168.1.52", port=3333, scope="lan"), 256 + ] 257 + ) 258 + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") 259 + 260 + status_response = env.client.get( 261 + "/app/network/api/status", 262 + base_url="http://localhost:7657", 263 + ) 264 + assert status_response.status_code == 200 265 + status_payload = status_response.get_json() 266 + status_addresses = [ 267 + entry["address"].partition(":")[0] 268 + for entry in status_payload["home_candidates"] 269 + ] 270 + 271 + response = env.client.post( 272 + "/app/network/pair-start", 273 + json={"device_label": "Test Phone"}, 274 + ) 275 + assert response.status_code == 200 276 + pair_addresses = _decode_pair_link_addresses(response.get_json()["pair_link"]) 277 + 278 + assert status_addresses == ["192.168.1.50", "192.168.1.51", "192.168.1.52"] 279 + assert pair_addresses == status_addresses 280 + 281 + 172 282 def _fragment(pair_link: str) -> str: 173 283 return pair_link.rsplit("#", 1)[1] 174 284 175 285 176 286 def _decode_pair_link(pair_link: str) -> bytes: 177 287 return crockford_decode(_fragment(pair_link)) 288 + 289 + 290 + def _decode_pair_link_addresses(pair_link: str) -> list[str]: 291 + decoded = _decode_pair_link(pair_link) 292 + if decoded[0:2] == b"\x04\x01": 293 + return [str(ipaddress.IPv4Address(decoded[2:6]))] 294 + assert decoded[0:2] == b"\x05\x01" 295 + count = decoded[2] 296 + return [ 297 + str(ipaddress.IPv4Address(decoded[offset : offset + 4])) 298 + for offset in range(5, 5 + count * 4, 4) 299 + ] 178 300 179 301 180 302 def test_pair_start_spl_mints_relay_form_pair_link(link_env) -> None:
+1
solstone/apps/network/tests/test_workspace_reach.py
··· 267 267 submit_end = body.index("async function applyHostAddressOverride", submit_start) 268 268 submit_body = body[submit_start:submit_end] 269 269 assert "'/app/network/host-address'" in submit_body 270 + assert "JSON.stringify({ home_address: address })" in submit_body 270 271 assert "setHostAddressError('');" in submit_body 271 272 assert "return await refreshStatus();" in submit_body 272 273
+1 -1
solstone/apps/network/workspace.html
··· 956 956 await window.apiJson('/app/network/host-address', { 957 957 method: 'POST', 958 958 headers: { 'Content-Type': 'application/json' }, 959 - body: JSON.stringify({ address }), 959 + body: JSON.stringify({ home_address: address }), 960 960 }); 961 961 setHostAddressError(''); 962 962 return await refreshStatus();
+2 -41
solstone/apps/settings/call.py
··· 11 11 12 12 import typer 13 13 14 - from solstone.convey.reasons import ( 15 - INVALID_CONFIG_VALUE, 16 - ) 14 + from solstone.convey.reasons import INVALID_CONFIG_VALUE 17 15 from solstone.think.convey_client import ConveyClientError, convey_cli, get_client 18 16 19 17 # Mirrors solstone.apps.settings.routes.API_KEY_ENV_VARS (the canonical order ··· 155 153 _echo_json(response.get("config", {}).get("processing", {})) 156 154 157 155 158 - @convey_app.command("host-url") 159 - @convey_cli 160 - def convey_host_url( 161 - url: str | None = typer.Argument( 162 - None, help="Absolute URL to advertise to devices." 163 - ), 164 - auto: bool = typer.Option( 165 - False, "--auto", help="Clear the manual host URL override." 166 - ), 167 - show: bool = typer.Option(False, "--show", help="Show the effective host URL."), 168 - ) -> None: 169 - """Manage the host URL advertised to remote devices.""" 170 - 171 - if sum(bool(flag) for flag in (url is not None, auto, show)) != 1: 172 - _exit_with("error: choose exactly one of <url>, --auto, or --show") 173 - if show: 174 - result = _request("GET", "/app/settings/api/convey/host-url") 175 - typer.echo(result["host_url"]) 176 - return 177 - if auto: 178 - _request("POST", "/app/settings/api/convey/host-url", json_body={"auto": True}) 179 - typer.echo("host url cleared. auto-detect is active.") 180 - return 181 - assert url is not None 182 - try: 183 - result = _request( 184 - "POST", 185 - "/app/settings/api/convey/host-url", 186 - json_body={"url": url}, 187 - ) 188 - except ConveyClientError as err: 189 - if err.reason_code == INVALID_CONFIG_VALUE.code and err.detail: 190 - _exit_with(err.detail) 191 - raise 192 - typer.echo(f"host url set: {result['host_url']}") 193 - 194 - 195 156 @convey_app.command("status") 196 157 @convey_cli 197 158 def convey_status() -> None: 198 - """Show Convey network and host-URL status.""" 159 + """Show Convey network and dashboard URL status.""" 199 160 200 161 result = _request("GET", "/app/settings/api/convey/status") 201 162 typer.echo(result["status_text"])
+74
solstone/apps/settings/maint/008_migrate_pairing_home_address.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate legacy pairing host URLs to bare home addresses.""" 5 + 6 + from __future__ import annotations 7 + 8 + from typing import Any 9 + from urllib.parse import urlsplit 10 + 11 + from solstone.think.journal_config import ( 12 + hold_config_lock, 13 + read_journal_config, 14 + write_journal_config, 15 + ) 16 + from solstone.think.pairing.config import InvalidHomeAddress, validate_home_address 17 + from solstone.think.utils import get_journal 18 + 19 + 20 + def _legacy_host_url_to_home_address(value: Any) -> str | None: 21 + if not isinstance(value, str): 22 + return None 23 + cleaned = value.strip() 24 + if not cleaned: 25 + return None 26 + parsed = urlsplit(cleaned) 27 + if parsed.scheme != "http" or not parsed.netloc: 28 + return None 29 + if parsed.username is not None or parsed.password is not None: 30 + return None 31 + if parsed.query or parsed.fragment or parsed.path not in ("", "/"): 32 + return None 33 + host = parsed.hostname 34 + try: 35 + port = parsed.port 36 + except ValueError: 37 + return None 38 + if host is None or port is None: 39 + return None 40 + try: 41 + return validate_home_address(f"{host}:{port}") 42 + except InvalidHomeAddress: 43 + return None 44 + 45 + 46 + def migrate(config: dict[str, Any]) -> bool: 47 + pairing = config.get("pairing") 48 + if not isinstance(pairing, dict) or "host_url" not in pairing: 49 + return False 50 + 51 + changed = False 52 + home_address = _legacy_host_url_to_home_address(pairing.get("host_url")) 53 + if home_address is not None and pairing.get("home_address") != home_address: 54 + pairing["home_address"] = home_address 55 + changed = True 56 + 57 + pairing.pop("host_url") 58 + changed = True 59 + return changed 60 + 61 + 62 + def main() -> None: 63 + journal = get_journal() 64 + with hold_config_lock(journal): 65 + config = read_journal_config(journal) 66 + if not migrate(config): 67 + print("Pairing home address already migrated.") 68 + return 69 + write_journal_config(config, journal) 70 + print("Migrated pairing home address config.") 71 + 72 + 73 + if __name__ == "__main__": 74 + main()
+4 -58
solstone/apps/settings/routes.py
··· 496 496 return _settings_operation_failed() 497 497 498 498 499 - def _host_url_status_value() -> str: 500 - from solstone.think.pairing.config import get_host_url 501 - 502 - return get_host_url() 503 - 504 - 505 - @settings_bp.route("/api/convey/host-url", methods=["GET", "POST"]) 506 - def convey_host_url() -> Any: 507 - """Read or update the host URL advertised to remote devices.""" 508 - 509 - from solstone.think.pairing.config import ( 510 - InvalidHostUrl, 511 - clear_host_url, 512 - get_host_url, 513 - set_host_url, 514 - validate_host_url, 515 - ) 516 - 517 - try: 518 - if request.method == "GET": 519 - return jsonify({"host_url": get_host_url()}) 520 - 521 - request_data = request.get_json() 522 - if not isinstance(request_data, dict): 523 - return error_response( 524 - INVALID_REQUEST_VALUE, 525 - detail="Expected JSON object with url or auto", 526 - ) 527 - 528 - has_url = "url" in request_data and request_data.get("url") is not None 529 - auto = bool(request_data.get("auto", False)) 530 - if sum((has_url, auto)) != 1: 531 - return error_response( 532 - INVALID_REQUEST_VALUE, 533 - detail="Provide exactly one of url or auto", 534 - ) 535 - 536 - if auto: 537 - clear_host_url() 538 - return jsonify({"host_url": get_host_url(), "cleared": True}) 539 - 540 - raw_url = request_data.get("url") 541 - if not isinstance(raw_url, str): 542 - return error_response(INVALID_REQUEST_VALUE, detail="url must be a string") 543 - try: 544 - canonical = validate_host_url(raw_url) 545 - except InvalidHostUrl as exc: 546 - return error_response(INVALID_CONFIG_VALUE, detail=str(exc)) 547 - set_host_url(canonical) 548 - return jsonify({"host_url": canonical}) 549 - except Exception: 550 - logger.exception("error updating convey host url") 551 - return _settings_operation_failed() 552 - 553 - 554 499 @settings_bp.route("/api/convey/status") 555 500 def convey_status() -> Any: 556 - """Return formatted Convey bind and host URL status.""" 501 + """Return formatted Convey bind and dashboard URL status.""" 557 502 558 503 try: 559 504 from solstone.convey.cli import _resolve_bind_host ··· 562 507 563 508 bind_host = _resolve_bind_host() 564 509 port = read_service_port("convey") or DEFAULT_SERVICE_PORT 510 + dashboard_url = f"http://localhost:{port}" 565 511 status_text = convey_copy.format_convey_status( 566 512 bind=f"{bind_host}:{port}", 567 - host_url=_host_url_status_value(), 513 + dashboard_url=dashboard_url, 568 514 ) 569 - return jsonify({"status_text": status_text}) 515 + return jsonify({"dashboard_url": dashboard_url, "status_text": status_text}) 570 516 except Exception: 571 517 logger.exception("error loading convey status") 572 518 return _settings_operation_failed()
+67
solstone/apps/settings/tests/test_maint_008_migrate_pairing_home_address.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import importlib 7 + 8 + mod = importlib.import_module( 9 + "solstone.apps.settings.maint.008_migrate_pairing_home_address" 10 + ) 11 + 12 + 13 + def test_migration_moves_valid_legacy_host_url_to_home_address() -> None: 14 + config = { 15 + "pairing": { 16 + "host_url": "http://192.168.1.44:7657", 17 + "note": "preserve me", 18 + }, 19 + "unrelated": {"value": True}, 20 + } 21 + 22 + assert mod.migrate(config) is True 23 + 24 + assert config == { 25 + "pairing": { 26 + "home_address": "192.168.1.44:7657", 27 + "note": "preserve me", 28 + }, 29 + "unrelated": {"value": True}, 30 + } 31 + assert mod.migrate(config) is False 32 + 33 + 34 + def test_migration_removes_invalid_legacy_values_without_home_address() -> None: 35 + for legacy in ( 36 + "http://localhost:7657", 37 + "http://127.0.0.1:7657", 38 + "http://192.168.1.44:5015", 39 + "not a url", 40 + None, 41 + ): 42 + config = {"pairing": {"host_url": legacy, "note": "preserve me"}} 43 + 44 + assert mod.migrate(config) is True 45 + 46 + assert config == {"pairing": {"note": "preserve me"}} 47 + 48 + 49 + def test_migration_preserves_existing_new_key_when_legacy_invalid() -> None: 50 + config = { 51 + "pairing": { 52 + "host_url": "http://127.0.0.1:7657", 53 + "home_address": "192.168.1.44:7657", 54 + } 55 + } 56 + 57 + assert mod.migrate(config) is True 58 + 59 + assert config == {"pairing": {"home_address": "192.168.1.44:7657"}} 60 + assert mod.migrate(config) is False 61 + 62 + 63 + def test_migration_noops_without_legacy_key() -> None: 64 + config = {"pairing": {"home_address": "192.168.1.44:7657"}} 65 + 66 + assert mod.migrate(config) is False 67 + assert config == {"pairing": {"home_address": "192.168.1.44:7657"}}
+2 -2
solstone/convey/copy.py
··· 76 76 def format_convey_status( 77 77 *, 78 78 bind: str, 79 - host_url: str, 79 + dashboard_url: str, 80 80 ) -> str: 81 81 """Return the locked convey status block.""" 82 82 83 - return f"convey\n bind: {bind}\n host url: {host_url}" 83 + return f"convey\n bind: {bind}\n dashboard url: {dashboard_url}" 84 84 85 85 86 86 __all__ = [
-4
solstone/think/convey_client.py
··· 23 23 import typer 24 24 from requests.adapters import TimeoutSauce 25 25 26 - from solstone.think.pairing.config import get_host_url_override 27 26 from solstone.think.service import DEFAULT_SERVICE_PORT 28 27 from solstone.think.utils import read_service_port, require_solstone 29 28 ··· 81 80 82 81 83 82 def resolve_base_url() -> str: 84 - override = get_host_url_override() 85 - if override is not None: 86 - return override 87 83 port = read_service_port("convey") or DEFAULT_SERVICE_PORT 88 84 return f"http://localhost:{port}" 89 85
+1 -1
solstone/think/journal_default.json
··· 73 73 } 74 74 }, 75 75 "pairing": { 76 - "host_url": null 76 + "home_address": null 77 77 }, 78 78 "backup": { 79 79 "enabled": false,
+55 -62
solstone/think/pairing/config.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Shared pairing host-address configuration.""" 4 + """Shared pairing home-address configuration.""" 5 5 6 6 from __future__ import annotations 7 7 8 8 import ipaddress 9 9 from typing import Any 10 - from urllib.parse import urlsplit 11 10 12 11 from solstone.think.journal_config import write_journal_config 13 - from solstone.think.service import DEFAULT_SERVICE_PORT 14 - from solstone.think.utils import get_config, read_service_port 12 + from solstone.think.link import interface_watcher 13 + from solstone.think.utils import get_config 15 14 16 - HOST_URL_INVALID = "enter an ipv4 address and port, like 192.168.1.44:7657" 17 - HOST_URL_HOSTNAME_UNSUPPORTED = ( 15 + HOME_ADDRESS_INVALID = "enter an ipv4 address and port, like 192.168.1.44:7657" 16 + HOME_ADDRESS_HOSTNAME_UNSUPPORTED = ( 18 17 "this needs an ip address — to reach home by name from anywhere, " 19 18 "turn on your private network" 20 19 ) 21 20 22 21 23 - class InvalidHostUrl(Exception): 24 - """Raised when a manual host URL cannot be normalized.""" 22 + class InvalidHomeAddress(Exception): 23 + """Raised when a manual home address cannot be normalized.""" 25 24 26 25 27 26 def _pairing_config() -> dict[str, Any]: ··· 38 37 39 38 40 39 def _input_looks_like_hostname(host: str) -> bool: 41 - return ":" not in host and any(char.isalpha() for char in host) 40 + return any(char.isalpha() for char in host) and all( 41 + char.isalnum() or char in ".-" for char in host 42 + ) 43 + 44 + 45 + def is_usable_ipv4(value: Any) -> bool: 46 + """Return whether value is a non-special IPv4 address usable for pairing.""" 47 + 48 + try: 49 + ipv4 = ipaddress.IPv4Address(value) 50 + except (TypeError, ValueError): 51 + return False 52 + return not ( 53 + ipv4.is_loopback 54 + or ipv4.is_unspecified 55 + or ipv4.is_link_local 56 + or ipv4.is_multicast 57 + ) 42 58 43 59 44 - def validate_host_url(value: str) -> str: 45 - """Normalize a manual host URL to ``http://<IPv4>:<port>``.""" 60 + def validate_home_address(value: str) -> str: 61 + """Normalize a manual home address to ``<IPv4>:<secure-listener-port>``.""" 46 62 47 63 cleaned = value.strip() 48 - if not cleaned: 49 - raise InvalidHostUrl(HOST_URL_INVALID) 64 + if not cleaned or "://" in cleaned or "/" in cleaned: 65 + raise InvalidHomeAddress(HOME_ADDRESS_INVALID) 50 66 51 - candidate = cleaned if "://" in cleaned else f"http://{cleaned}" 52 - parsed = urlsplit(candidate) 53 - if parsed.scheme != "http" or not parsed.netloc: 54 - raise InvalidHostUrl(HOST_URL_INVALID) 55 - if parsed.username is not None or parsed.password is not None: 56 - raise InvalidHostUrl(HOST_URL_INVALID) 57 - if parsed.query or parsed.fragment: 58 - raise InvalidHostUrl(HOST_URL_INVALID) 59 - if parsed.path not in ("", "/"): 60 - raise InvalidHostUrl(HOST_URL_INVALID) 67 + host, sep, port_text = cleaned.rpartition(":") 68 + if sep != ":" or not host or not port_text: 69 + if _input_looks_like_hostname(cleaned): 70 + raise InvalidHomeAddress(HOME_ADDRESS_HOSTNAME_UNSUPPORTED) 71 + raise InvalidHomeAddress(HOME_ADDRESS_INVALID) 61 72 62 - host = parsed.hostname or "" 63 73 try: 64 74 ipv4 = ipaddress.IPv4Address(host) 65 75 except ValueError as exc: 66 76 if _input_looks_like_hostname(host): 67 - raise InvalidHostUrl(HOST_URL_HOSTNAME_UNSUPPORTED) from exc 68 - raise InvalidHostUrl(HOST_URL_INVALID) from exc 77 + raise InvalidHomeAddress(HOME_ADDRESS_HOSTNAME_UNSUPPORTED) from exc 78 + raise InvalidHomeAddress(HOME_ADDRESS_INVALID) from exc 69 79 70 80 try: 71 - port = parsed.port 81 + port = int(port_text) 72 82 except ValueError as exc: 73 - raise InvalidHostUrl(HOST_URL_INVALID) from exc 74 - if port is None or port < 1 or port > 65535: 75 - raise InvalidHostUrl(HOST_URL_INVALID) 83 + raise InvalidHomeAddress(HOME_ADDRESS_INVALID) from exc 84 + if port != interface_watcher.LINK_DIRECT_PORT or not is_usable_ipv4(str(ipv4)): 85 + raise InvalidHomeAddress(HOME_ADDRESS_INVALID) 76 86 77 - return f"http://{ipv4}:{port}" 87 + return f"{ipv4}:{port}" 78 88 79 89 80 - def get_host_url_override() -> str | None: 81 - return _clean_str(_pairing_config().get("host_url")) 82 - 83 - 84 - def override_host_port() -> str | None: 85 - override = get_host_url_override() 86 - if override is None: 87 - return None 88 - parsed = urlsplit(override) 89 - return f"{parsed.hostname}:{parsed.port}" 90 - 91 - 92 - def get_host_url() -> str: 93 - configured = get_host_url_override() 94 - if configured is not None: 95 - return configured 96 - convey_port = read_service_port("convey") or DEFAULT_SERVICE_PORT 97 - return f"http://localhost:{convey_port}" 90 + def get_home_address() -> str | None: 91 + return _clean_str(_pairing_config().get("home_address")) 98 92 99 93 100 - def set_host_url(canonical: str) -> None: 94 + def set_home_address(canonical: str) -> None: 101 95 config = get_config() 102 - config.setdefault("pairing", {})["host_url"] = canonical 96 + config.setdefault("pairing", {})["home_address"] = canonical 103 97 write_journal_config(config) 104 98 105 99 106 - def clear_host_url() -> None: 100 + def clear_home_address() -> None: 107 101 config = get_config() 108 - config.setdefault("pairing", {})["host_url"] = None 102 + config.setdefault("pairing", {})["home_address"] = None 109 103 write_journal_config(config) 110 104 111 105 112 106 __all__ = [ 113 - "HOST_URL_HOSTNAME_UNSUPPORTED", 114 - "HOST_URL_INVALID", 115 - "InvalidHostUrl", 116 - "clear_host_url", 117 - "get_host_url", 118 - "get_host_url_override", 119 - "override_host_port", 120 - "set_host_url", 121 - "validate_host_url", 107 + "HOME_ADDRESS_HOSTNAME_UNSUPPORTED", 108 + "HOME_ADDRESS_INVALID", 109 + "InvalidHomeAddress", 110 + "clear_home_address", 111 + "get_home_address", 112 + "is_usable_ipv4", 113 + "set_home_address", 114 + "validate_home_address", 122 115 ]
+7 -8
solstone/think/settings_cli.py
··· 11 11 12 12 from solstone.convey.cli import _resolve_bind_host 13 13 from solstone.convey.copy import format_convey_status 14 - from solstone.think.pairing.config import get_host_url 15 14 from solstone.think.service import DEFAULT_SERVICE_PORT 16 15 from solstone.think.utils import ( 17 16 read_service_port, ··· 19 18 ) 20 19 21 20 22 - def _host_url_status_value() -> str: 23 - return get_host_url() 21 + def _convey_port() -> int: 22 + return read_service_port("convey") or DEFAULT_SERVICE_PORT 24 23 25 24 26 - def _convey_port() -> int: 27 - return read_service_port("convey") or DEFAULT_SERVICE_PORT 25 + def _dashboard_url() -> str: 26 + return f"http://localhost:{_convey_port()}" 28 27 29 28 30 29 def _status_payload() -> dict[str, str]: 31 30 return { 32 - "effective_host_url": get_host_url(), 31 + "dashboard_url": _dashboard_url(), 33 32 } 34 33 35 34 ··· 43 42 print( 44 43 format_convey_status( 45 44 bind=f"{bind_host}:{port}", 46 - host_url=_host_url_status_value(), 45 + dashboard_url=f"http://localhost:{port}", 47 46 ) 48 47 ) 49 48 ··· 57 56 58 57 status_parser = convey_subparsers.add_parser( 59 58 "status", 60 - help="Show convey bind and host-URL status", 59 + help="Show convey bind and dashboard URL status", 61 60 ) 62 61 status_parser.add_argument( 63 62 "--json",
+4 -9
tests/test_convey_client.py
··· 233 233 assert excinfo.value.error == SERVER_ERROR_MESSAGE 234 234 235 235 236 - def test_resolve_base_url_uses_override(monkeypatch: pytest.MonkeyPatch) -> None: 237 - monkeypatch.setattr( 238 - convey_client, 239 - "get_host_url_override", 240 - lambda: "http://192.168.1.44:5015", 241 - ) 236 + def test_resolve_base_url_ignores_pairing_home_address( 237 + monkeypatch: pytest.MonkeyPatch, 238 + ) -> None: 242 239 monkeypatch.setattr(convey_client, "read_service_port", lambda service: 5099) 243 240 244 - assert resolve_base_url() == "http://192.168.1.44:5015" 241 + assert resolve_base_url() == "http://localhost:5099" 245 242 246 243 247 244 def test_resolve_base_url_uses_recorded_port(monkeypatch: pytest.MonkeyPatch) -> None: 248 - monkeypatch.setattr(convey_client, "get_host_url_override", lambda: None) 249 245 monkeypatch.setattr(convey_client, "read_service_port", lambda service: 5099) 250 246 251 247 assert resolve_base_url() == "http://localhost:5099" 252 248 253 249 254 250 def test_resolve_base_url_uses_default_port(monkeypatch: pytest.MonkeyPatch) -> None: 255 - monkeypatch.setattr(convey_client, "get_host_url_override", lambda: None) 256 251 monkeypatch.setattr(convey_client, "read_service_port", lambda service: None) 257 252 258 253 assert resolve_base_url() == "http://localhost:5015"
+65 -46
tests/test_pairing_config.py
··· 26 26 payload["identity"] = {"name": "", "preferred": ""} 27 27 _write_config(journal_copy, payload) 28 28 29 - assert config.get_host_url() == "http://localhost:5015" 29 + assert config.get_home_address() is None 30 30 31 31 32 - def test_pairing_host_url_reads_trimmed_value(journal_copy): 32 + def test_pairing_home_address_reads_trimmed_value(journal_copy): 33 33 payload = _read_config(journal_copy) 34 34 payload["pairing"] = { 35 - "host_url": " http://192.168.1.44:6123 ", 35 + "home_address": " 192.168.1.44:7657 ", 36 36 } 37 37 _write_config(journal_copy, payload) 38 38 39 - assert config.get_host_url() == "http://192.168.1.44:6123" 39 + assert config.get_home_address() == "192.168.1.44:7657" 40 40 41 41 42 42 @pytest.mark.parametrize( 43 43 ("raw", "expected"), 44 44 [ 45 - ("192.168.1.44:5015", "http://192.168.1.44:5015"), 46 - (" http://192.168.1.44:5015 ", "http://192.168.1.44:5015"), 47 - ("http://192.168.1.44:5015/", "http://192.168.1.44:5015"), 45 + ("192.168.1.44:7657", "192.168.1.44:7657"), 46 + (" 192.168.1.44:7657 ", "192.168.1.44:7657"), 48 47 ], 49 48 ) 50 - def test_validate_host_url_accepts_ipv4_port(raw: str, expected: str) -> None: 51 - assert config.validate_host_url(raw) == expected 49 + def test_validate_home_address_accepts_ipv4_secure_port( 50 + raw: str, 51 + expected: str, 52 + ) -> None: 53 + assert config.validate_home_address(raw) == expected 52 54 53 55 54 56 @pytest.mark.parametrize( ··· 56 58 [ 57 59 "", 58 60 " ", 59 - "http://", 61 + "http://192.168.1.44:7657", 60 62 "192.168.1.44", 61 63 "http://192.168.1.44", 62 64 "192.168.1.44:0", 65 + "192.168.1.44:5015", 63 66 "192.168.1.44:65536", 64 67 "192.168.1.44:notaport", 65 - "https://192.168.1.44:5015", 66 - "http://user@192.168.1.44:5015", 67 - "http://192.168.1.44:5015/path", 68 - "http://192.168.1.44:5015?x=1", 69 - "http://192.168.1.44:5015#frag", 70 - "http://[::1]:5015", 71 - "http://[fe80::1]:5015", 68 + "https://192.168.1.44:7657", 69 + "user@192.168.1.44:7657", 70 + "192.168.1.44:7657/path", 71 + "192.168.1.44:7657?x=1", 72 + "192.168.1.44:7657#frag", 73 + "127.0.0.1:7657", 74 + "0.0.0.0:7657", 75 + "169.254.1.1:7657", 76 + "224.0.0.1:7657", 77 + "[::1]:7657", 78 + "[fe80::1]:7657", 72 79 ], 73 80 ) 74 - def test_validate_host_url_rejects_invalid_values(raw: str) -> None: 75 - with pytest.raises(config.InvalidHostUrl) as excinfo: 76 - config.validate_host_url(raw) 81 + def test_validate_home_address_rejects_invalid_values(raw: str) -> None: 82 + with pytest.raises(config.InvalidHomeAddress) as excinfo: 83 + config.validate_home_address(raw) 77 84 78 - assert str(excinfo.value) == config.HOST_URL_INVALID 85 + assert str(excinfo.value) == config.HOME_ADDRESS_INVALID 79 86 80 87 81 - @pytest.mark.parametrize("raw", ["mylab.local:5015", "http://home.local:5015"]) 82 - def test_validate_host_url_rejects_hostname_with_sol_private_link_message( 88 + @pytest.mark.parametrize("raw", ["mylab.local:7657", "home.local"]) 89 + def test_validate_home_address_rejects_hostname_with_private_link_message( 83 90 raw: str, 84 91 ) -> None: 85 - with pytest.raises(config.InvalidHostUrl) as excinfo: 86 - config.validate_host_url(raw) 92 + with pytest.raises(config.InvalidHomeAddress) as excinfo: 93 + config.validate_home_address(raw) 94 + 95 + assert str(excinfo.value) == config.HOME_ADDRESS_HOSTNAME_UNSUPPORTED 87 96 88 - assert str(excinfo.value) == config.HOST_URL_HOSTNAME_UNSUPPORTED 89 97 98 + @pytest.mark.parametrize( 99 + ("value", "expected"), 100 + [ 101 + ("192.168.1.44", True), 102 + ("10.0.0.5", True), 103 + ("127.0.0.1", False), 104 + ("0.0.0.0", False), 105 + ("169.254.1.1", False), 106 + ("224.0.0.1", False), 107 + ("::1", False), 108 + ("not-an-ip", False), 109 + (None, False), 110 + ], 111 + ) 112 + def test_is_usable_ipv4(value: object, expected: bool) -> None: 113 + assert config.is_usable_ipv4(value) is expected 90 114 91 - def test_host_url_override_round_trip(journal_copy) -> None: 92 - canonical = config.validate_host_url("192.168.1.44:5015") 115 + 116 + def test_home_address_round_trip(journal_copy) -> None: 117 + canonical = config.validate_home_address("192.168.1.44:7657") 118 + 119 + config.set_home_address(canonical) 93 120 94 - config.set_host_url(canonical) 121 + assert _read_config(journal_copy)["pairing"]["home_address"] == canonical 122 + assert config.get_home_address() == canonical 95 123 96 - assert _read_config(journal_copy)["pairing"]["host_url"] == canonical 97 - assert config.get_host_url_override() == canonical 98 - assert config.get_host_url() == canonical 99 - assert config.override_host_port() == "192.168.1.44:5015" 124 + config.clear_home_address() 100 125 101 - config.clear_host_url() 126 + assert _read_config(journal_copy)["pairing"]["home_address"] is None 127 + assert config.get_home_address() is None 102 128 103 - assert _read_config(journal_copy)["pairing"]["host_url"] is None 104 - assert config.get_host_url_override() is None 105 - assert config.override_host_port() is None 106 129 130 + def test_validate_home_address_rejects_without_writing(journal_copy) -> None: 131 + before = _read_config(journal_copy) 107 132 108 - def test_pairing_host_url_uses_localhost_without_manual_override(journal_copy): 109 - payload = _read_config(journal_copy) 110 - payload["pairing"] = {"host_url": None} 111 - payload["convey"]["allow_network_access"] = True 112 - _write_config(journal_copy, payload) 113 - health_dir = journal_copy / "health" 114 - health_dir.mkdir(parents=True, exist_ok=True) 115 - (health_dir / "convey.port").write_text("6123", encoding="utf-8") 133 + with pytest.raises(config.InvalidHomeAddress): 134 + config.validate_home_address("192.168.1.44:5015") 116 135 117 - assert config.get_host_url() == "http://localhost:6123" 136 + assert _read_config(journal_copy) == before
+14 -40
tests/test_settings_call_parity.py
··· 325 325 } 326 326 327 327 328 - def test_convey_status_host_url( 328 + def test_convey_status_dashboard_url( 329 329 journal_copy: Path, 330 330 monkeypatch: pytest.MonkeyPatch, 331 331 ) -> None: ··· 334 334 assert status.stdout == ( 335 335 "convey\n" 336 336 " bind: 127.0.0.1:5015\n" 337 - " host url: http://localhost:5015\n" 337 + " dashboard url: http://localhost:5015\n" 338 338 ) 339 339 340 + config_before = _read_config(journal_copy) 341 + manual_status = runner.invoke(settings_call.app, ["convey", "status"]) 342 + assert manual_status.exit_code == 0 343 + assert "dashboard url: http://localhost:5015" in manual_status.stdout 344 + assert _read_config(journal_copy) == config_before 345 + 340 346 calls = [] 341 347 with monkeypatch.context() as m: 342 348 m.setattr(settings_call, "get_client", lambda: calls.append("called")) 343 - conflict = runner.invoke( 344 - settings_call.app, 345 - ["convey", "host-url", "--auto", "--show"], 346 - ) 347 - assert conflict.exit_code == 1 348 - assert conflict.stderr == "error: choose exactly one of <url>, --auto, or --show\n" 349 + missing = runner.invoke(settings_call.app, ["convey", "host-url"]) 350 + assert missing.exit_code != 0 351 + assert "No such command" in missing.stderr 349 352 assert calls == [] 350 353 351 - set_url = runner.invoke( 352 - settings_call.app, 353 - ["convey", "host-url", "192.168.1.44:5015"], 354 - ) 355 - assert set_url.exit_code == 0 356 - assert set_url.stdout == "host url set: http://192.168.1.44:5015\n" 357 - 358 - show_url = runner.invoke(settings_call.app, ["convey", "host-url", "--show"]) 359 - assert show_url.exit_code == 0 360 - assert show_url.stdout == "http://192.168.1.44:5015\n" 361 - 362 - manual_status = runner.invoke(settings_call.app, ["convey", "status"]) 363 - assert manual_status.exit_code == 0 364 - assert "host url: http://192.168.1.44:5015" in manual_status.stdout 365 - 366 - auto = runner.invoke(settings_call.app, ["convey", "host-url", "--auto"]) 367 - assert auto.exit_code == 0 368 - assert auto.stdout == "host url cleared. auto-detect is active.\n" 369 - 370 - bad_url = runner.invoke(settings_call.app, ["convey", "host-url", "/bad"]) 371 - assert bad_url.exit_code == 1 372 - assert bad_url.stderr == "enter an ipv4 address and port, like 192.168.1.44:7657\n" 373 - 374 - bad_host = runner.invoke( 375 - settings_call.app, 376 - ["convey", "host-url", "mylab.local:5015"], 377 - ) 378 - assert bad_host.exit_code == 1 379 - assert bad_host.stderr == ( 380 - "this needs an ip address — to reach home by name from anywhere, " 381 - "turn on your private network\n" 382 - ) 354 + client = make_test_client(journal_copy) 355 + response = client.get("/app/settings/api/convey/host-url") 356 + assert response.status_code == 404
+2 -2
tests/test_settings_cli.py
··· 21 21 monkeypatch, 22 22 capsys, 23 23 ): 24 - monkeypatch.setattr(settings_cli, "get_host_url", lambda: "http://localhost:5015") 24 + monkeypatch.setattr(settings_cli, "read_service_port", lambda service: 5015) 25 25 26 26 _run(monkeypatch, ["convey", "status", "--json"]) 27 27 28 28 captured = capsys.readouterr() 29 29 payload = json.loads(captured.out) 30 30 assert payload == { 31 - "effective_host_url": "http://localhost:5015", 31 + "dashboard_url": "http://localhost:5015", 32 32 } 33 33 assert captured.err == "" 34 34