2241 lines
83 KiB
Python
2241 lines
83 KiB
Python
"""Hermes Android pairing — generate QR codes and connection info.
|
|
|
|
Replaces the standalone bash script `skills/hermes-pairing-qr/hermes-pair`.
|
|
Exposed as the `hermes pair` CLI sub-command via plugin/cli.py.
|
|
|
|
The payload format matches what QrPairingScanner.kt in the Android app expects.
|
|
v2 added optional TTL / per-channel grants / transport hint / HMAC signature.
|
|
v3 (ADR 24) adds an optional ordered ``endpoints`` array for multi-network
|
|
operators (LAN + Tailscale + public reverse-proxy in a single QR). v1/v2
|
|
remain valid — phones synthesize a priority-0 candidate from the top-level
|
|
fields when ``endpoints`` is absent, and the pair CLI only bumps the
|
|
version bit when it's actually emitting endpoints::
|
|
|
|
v3 (multi-endpoint — ADR 24):
|
|
{
|
|
"hermes": 3,
|
|
"host": "<ip>", "port": <port>, "key": "<token>", "tls": <bool>,
|
|
"dashboard_url": "https://dashboard.example.com",
|
|
"relay": { ... same as v2 ... },
|
|
"endpoints": [
|
|
{ "role": "lan", "priority": 0,
|
|
"api": {"host": "<ip>", "port": <port>, "tls": <bool>},
|
|
"relay": {"url": "ws://<ip>:<port>", "transport_hint": "ws"} },
|
|
{ "role": "tailscale", "priority": 1, "api": {...}, "relay": {...} },
|
|
{ "role": "public", "priority": 2, "api": {...}, "relay": {...} }
|
|
],
|
|
"sig": "<base64-hmac-sha256>"
|
|
}
|
|
|
|
v2 (extended):
|
|
{
|
|
"hermes": 2,
|
|
"host": "<ip>", "port": <port>, "key": "<token>", "tls": <bool>,
|
|
"relay": {
|
|
"url": "ws://<ip>:<port>",
|
|
"code": "<6-char>",
|
|
"ttl_seconds": 2592000, // 0 = never expire
|
|
"grants": {"terminal": ..., "bridge": ...},
|
|
"transport_hint": "ws" // "wss" or "ws"
|
|
},
|
|
"sig": "<base64-hmac-sha256>"
|
|
}
|
|
|
|
v1 (legacy fallback):
|
|
{
|
|
"hermes": 1,
|
|
"host": "<ip>", "port": <port>, "key": "<token>", "tls": <bool>,
|
|
"relay": { "url": "ws://<ip>:<port>", "code": "<6-char>" }
|
|
}
|
|
|
|
Top-level fields configure the optional direct-chat Hermes API server (port
|
|
8642 by default) when it is enabled. New Dashboard-first payloads may omit
|
|
them and advertise independently optional Dashboard, Relay, and API surfaces
|
|
inside ``endpoints``.
|
|
The optional ``relay`` block configures the Hermes-Relay WSS connection
|
|
used by the terminal and bridge channels. The normal CLI flow obtains the
|
|
fully signed invite from the local Relay via ``POST /pairing/mint`` so
|
|
server-owned Secure Link and Reach candidates are included. Older Relays may
|
|
use ``POST /pairing/register`` only when Reach is not configured.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import socket
|
|
import string
|
|
import sys
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
import base64
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
from .relay.qr_sign import load_or_create_secret, sign_payload
|
|
|
|
|
|
def _get_hermes_home() -> Path:
|
|
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
|
|
|
|
|
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
"""Parse a simple KEY=value env file. Ignores comments and blank lines."""
|
|
result: dict[str, str] = {}
|
|
if not path.exists():
|
|
return result
|
|
try:
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
# Strip matching surrounding quotes
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
value = value[1:-1]
|
|
result[key.strip()] = value
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def read_server_config() -> dict:
|
|
"""Read API server config with fallback chain.
|
|
|
|
Priority: hermes config.yaml → ~/.hermes/.env → environment vars → defaults.
|
|
Returns dict with keys: host, port, key, tls, enabled. ``enabled`` follows
|
|
Hermes' API-server rule: an explicit boolean wins; otherwise a configured
|
|
API key enables the compatibility surface.
|
|
"""
|
|
host: Optional[str] = None
|
|
port: Optional[int] = None
|
|
key: Optional[str] = None
|
|
enabled: Optional[bool] = None
|
|
tls = False
|
|
|
|
# 1. Try hermes_cli.config.load_config()
|
|
try:
|
|
from hermes_cli.config import load_config # type: ignore
|
|
|
|
config = load_config()
|
|
api = config.get("platforms", {}).get("api_server", {}) or {}
|
|
extra = api.get("extra", {}) or {}
|
|
enabled = _parse_optional_bool(api.get("enabled"))
|
|
key = extra.get("key") or api.get("api_key")
|
|
port_val = extra.get("port") or api.get("port")
|
|
if port_val is not None:
|
|
port = int(port_val)
|
|
host = extra.get("host") or api.get("host")
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Fall back to ~/.hermes/.env
|
|
if key is None or host is None or port is None:
|
|
env_vals = _parse_env_file(_get_hermes_home() / ".env")
|
|
if key is None:
|
|
key = env_vals.get("API_SERVER_KEY")
|
|
if host is None:
|
|
host = env_vals.get("API_SERVER_HOST")
|
|
if port is None and env_vals.get("API_SERVER_PORT"):
|
|
try:
|
|
port = int(env_vals["API_SERVER_PORT"])
|
|
except ValueError:
|
|
pass
|
|
if enabled is None:
|
|
enabled = _parse_optional_bool(env_vals.get("API_SERVER_ENABLED"))
|
|
|
|
# 3. Fall back to process environment
|
|
if key is None:
|
|
key = os.getenv("API_SERVER_KEY", "")
|
|
if enabled is None:
|
|
enabled = _parse_optional_bool(os.getenv("API_SERVER_ENABLED"))
|
|
if host is None:
|
|
host = os.getenv("API_SERVER_HOST", "127.0.0.1")
|
|
if port is None:
|
|
try:
|
|
port = int(os.getenv("API_SERVER_PORT", "8642"))
|
|
except ValueError:
|
|
port = 8642
|
|
|
|
if enabled is None:
|
|
enabled = bool(key)
|
|
|
|
return {
|
|
"host": host,
|
|
"port": port,
|
|
"key": key or "",
|
|
"tls": tls,
|
|
"enabled": enabled,
|
|
}
|
|
|
|
|
|
def _parse_optional_bool(value: Any) -> Optional[bool]:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value is None:
|
|
return None
|
|
normalized = str(value).strip().lower()
|
|
if normalized in ("1", "true", "yes", "on"):
|
|
return True
|
|
if normalized in ("0", "false", "no", "off"):
|
|
return False
|
|
return None
|
|
|
|
|
|
def _resolve_lan_ip(host: str) -> str:
|
|
"""Auto-detect LAN IP when host is loopback or bind-all.
|
|
|
|
Cross-platform: uses a UDP socket connect() trick to find the outbound
|
|
interface IP without sending any packets.
|
|
"""
|
|
if host not in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
|
|
return host
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.settimeout(2)
|
|
# Doesn't actually send; just picks the outbound interface
|
|
s.connect(("1.1.1.1", 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
return ip
|
|
except OSError:
|
|
return host
|
|
|
|
|
|
def _relay_has_v2_fields(relay: Optional[dict]) -> bool:
|
|
"""True if the relay block uses any v2-only field."""
|
|
if relay is None:
|
|
return False
|
|
return any(
|
|
k in relay for k in ("ttl_seconds", "grants", "transport_hint")
|
|
)
|
|
|
|
|
|
def build_payload(
|
|
host: Optional[str],
|
|
port: Optional[int],
|
|
key: Optional[str],
|
|
tls: Optional[bool],
|
|
relay: Optional[dict] = None,
|
|
sign: bool = True,
|
|
endpoints: Optional[list[dict]] = None,
|
|
dashboard_url: Optional[str] = None,
|
|
legacy_direct_relay: bool = False,
|
|
) -> str:
|
|
"""Build compact JSON payload matching HermesPairingPayload.kt format.
|
|
|
|
If ``relay`` is provided it's embedded as a nested ``"relay"`` object.
|
|
When any v2-only field (``ttl_seconds``, ``grants``, ``transport_hint``)
|
|
is present the top-level ``hermes`` field is bumped to ``2``; otherwise
|
|
it stays at ``1`` so old phones can still parse.
|
|
|
|
If ``endpoints`` is provided and non-empty it's embedded as a top-level
|
|
``"endpoints"`` array and the version bumps to ``3`` (ADR 24 —
|
|
multi-endpoint pairing payload). List order is preserved verbatim
|
|
through canonicalization + signing so strict priority semantics
|
|
survive the HMAC. Callers are responsible for sorting by
|
|
``priority`` before passing the list in; ``build_endpoint_candidates``
|
|
does this for the CLI path.
|
|
|
|
Configured top-level ``host`` / ``port`` / ``key`` / ``tls`` fields are
|
|
retained for API compatibility. API-less Dashboard-first payloads omit
|
|
them; direct Relay remains top-level for v1/v2 clients.
|
|
|
|
``dashboard_url`` is optional. When present, Android stores it as the
|
|
Manage/dashboard URL instead of deriving the conventional same-host
|
|
``:9119`` URL from the API server.
|
|
|
|
If ``sign`` is true the payload is signed with the host-local QR
|
|
secret and the base64 HMAC is added as a top-level ``sig`` field.
|
|
"""
|
|
for candidate in endpoints or []:
|
|
if isinstance(candidate, dict):
|
|
validate_endpoint_candidate_security(candidate)
|
|
normalized_dashboard_url = (
|
|
normalize_dashboard_url(dashboard_url) if dashboard_url else None
|
|
)
|
|
endpoints = add_dashboard_ingress_candidate(
|
|
endpoints=endpoints,
|
|
dashboard_url=normalized_dashboard_url,
|
|
relay=relay,
|
|
api_host=host,
|
|
api_port=port,
|
|
api_tls=tls,
|
|
legacy_direct_relay=legacy_direct_relay,
|
|
)
|
|
if endpoints:
|
|
version = 3
|
|
elif _relay_has_v2_fields(relay):
|
|
version = 2
|
|
else:
|
|
version = 1
|
|
payload: dict = {"hermes": version}
|
|
if host is not None and str(host).strip():
|
|
payload.update(
|
|
{
|
|
"host": str(host).strip(),
|
|
"port": int(port) if port is not None else 8642,
|
|
"key": key or "",
|
|
"tls": bool(tls),
|
|
}
|
|
)
|
|
if relay is not None:
|
|
advertised_relay = dict(relay)
|
|
if not legacy_direct_relay:
|
|
ingress_relay: Optional[dict] = None
|
|
if normalized_dashboard_url:
|
|
expected_dashboard_key = _dashboard_origin_key(normalized_dashboard_url)
|
|
expected_relay_key = _relay_ingress_key(
|
|
dashboard_relay_ingress_url(normalized_dashboard_url)
|
|
)
|
|
matching_ingresses: list[dict] = []
|
|
for candidate in endpoints or []:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
candidate_dashboard = candidate.get("dashboard")
|
|
candidate_relay = candidate.get("relay")
|
|
if not isinstance(candidate_dashboard, dict) or not isinstance(candidate_relay, dict):
|
|
continue
|
|
try:
|
|
dashboard_matches = _dashboard_origin_key(
|
|
str(candidate_dashboard.get("url") or "")
|
|
) == expected_dashboard_key
|
|
relay_matches = _relay_ingress_key(
|
|
str(candidate_relay.get("url") or "")
|
|
) == expected_relay_key
|
|
except ValueError:
|
|
continue
|
|
if dashboard_matches and relay_matches:
|
|
matching_ingresses.append(candidate_relay)
|
|
if len(matching_ingresses) != 1:
|
|
raise ValueError(
|
|
"dashboard_url must have exactly one exact same-origin "
|
|
"Relay ingress candidate"
|
|
)
|
|
ingress_relay = matching_ingresses[0]
|
|
else:
|
|
ingress_relay = next(
|
|
(
|
|
candidate.get("relay")
|
|
for candidate in (endpoints or [])
|
|
if isinstance(candidate, dict)
|
|
and isinstance(candidate.get("dashboard"), dict)
|
|
and isinstance(candidate.get("relay"), dict)
|
|
),
|
|
None,
|
|
)
|
|
if isinstance(ingress_relay, dict) and ingress_relay.get("url"):
|
|
advertised_relay["url"] = ingress_relay["url"]
|
|
if ingress_relay.get("transport_hint"):
|
|
advertised_relay["transport_hint"] = ingress_relay["transport_hint"]
|
|
payload["relay"] = advertised_relay
|
|
if endpoints:
|
|
payload["endpoints"] = endpoints
|
|
if normalized_dashboard_url:
|
|
payload["dashboard_url"] = normalized_dashboard_url
|
|
|
|
if sign:
|
|
try:
|
|
secret = load_or_create_secret()
|
|
payload["sig"] = sign_payload(payload, secret)
|
|
except Exception as exc:
|
|
# Signing is best-effort: if we can't read/write the secret
|
|
# file we still want to emit a usable QR rather than crashing
|
|
# the `hermes pair` flow. Log to stderr so the operator sees
|
|
# it but doesn't lose the QR.
|
|
print(
|
|
f" [warn] QR signing failed ({exc}) — payload will be unsigned.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
return json.dumps(payload, separators=(",", ":"))
|
|
|
|
|
|
def endpoint_role_for_url(url: str) -> str:
|
|
"""Return the existing route role that best describes an origin."""
|
|
parsed = urlparse(url)
|
|
host = (parsed.hostname or "").lower()
|
|
if host.endswith(".ts.net"):
|
|
return "tailscale"
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
address = None
|
|
if address is not None:
|
|
tailnet_v4 = ipaddress.ip_network("100.64.0.0/10")
|
|
tailnet_v6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
|
|
if address in tailnet_v4 or address in tailnet_v6:
|
|
return "tailscale"
|
|
if address.is_private or address.is_loopback or address.is_link_local:
|
|
return "lan"
|
|
if (
|
|
host in {"localhost", "localhost.localdomain"}
|
|
or host.endswith(".local")
|
|
or (host and "." not in host)
|
|
):
|
|
return "lan"
|
|
return "public"
|
|
|
|
|
|
def normalize_dashboard_url(dashboard_url: str) -> str:
|
|
"""Return a credential-free canonical Dashboard HTTP(S) base URL."""
|
|
trimmed = dashboard_url.strip().rstrip("/")
|
|
if any(character.isspace() for character in trimmed):
|
|
raise ValueError("dashboard_url must not include whitespace")
|
|
parsed = urlparse(trimmed)
|
|
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
|
|
raise ValueError("dashboard_url must be an absolute http(s) URL")
|
|
if parsed.username is not None or parsed.password is not None:
|
|
raise ValueError("dashboard_url must not include credentials")
|
|
if parsed.query or parsed.fragment:
|
|
raise ValueError("dashboard_url must not include a query or fragment")
|
|
try:
|
|
parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError("dashboard_url contains an invalid port") from exc
|
|
normalized = parsed._replace(scheme=parsed.scheme.lower()).geturl().rstrip("/")
|
|
if endpoint_role_for_url(normalized) == "public" and parsed.scheme.lower() != "https":
|
|
raise ValueError("public Dashboard URLs must use https://")
|
|
return normalized
|
|
|
|
|
|
def configured_dashboard_url(explicit: Optional[str] = None) -> Optional[str]:
|
|
"""Resolve Dashboard identity from explicit input or trusted URL envs."""
|
|
if explicit and explicit.strip():
|
|
return normalize_dashboard_url(explicit)
|
|
for key in (
|
|
"HERMES_DASHBOARD_PUBLIC_URL",
|
|
"HERMES_DASHBOARD_URL",
|
|
"HERMES_WEB_URL",
|
|
"DASHBOARD_URL",
|
|
):
|
|
value = os.environ.get(key, "").strip()
|
|
if value:
|
|
return normalize_dashboard_url(value)
|
|
return None
|
|
|
|
|
|
def normalize_public_url(public_url: str) -> str:
|
|
"""Return a canonical HTTPS public Dashboard or explicit Relay URL."""
|
|
normalized = normalize_dashboard_url(public_url)
|
|
if urlparse(normalized).scheme.lower() != "https":
|
|
raise ValueError("public_url must use https://")
|
|
return normalized
|
|
|
|
|
|
def dashboard_relay_ingress_url(dashboard_url: str) -> str:
|
|
"""Build the same-origin Relay transport base mounted by Hermes Dashboard."""
|
|
parsed = urlparse(normalize_dashboard_url(dashboard_url))
|
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
prefix = (parsed.path or "").rstrip("/")
|
|
return (
|
|
f"{scheme}://{parsed.netloc}{prefix}"
|
|
"/api/plugins/hermes-relay/transport"
|
|
)
|
|
|
|
|
|
def _route_url_key(url: str, *, expected_schemes: set[str]) -> tuple[str, str, int, str]:
|
|
"""Canonical scheme/host/effective-port/path identity for route matching."""
|
|
parsed = urlparse(url.strip().rstrip("/"))
|
|
scheme = parsed.scheme.lower()
|
|
if scheme not in expected_schemes or not parsed.hostname:
|
|
raise ValueError(f"route URL must use {sorted(expected_schemes)}")
|
|
try:
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError("route URL contains an invalid port") from exc
|
|
effective_port = port or (443 if scheme in {"https", "wss"} else 80)
|
|
return scheme, parsed.hostname.lower().rstrip("."), effective_port, parsed.path.rstrip("/")
|
|
|
|
|
|
def _dashboard_origin_key(dashboard_url: str) -> tuple[str, str, int, str]:
|
|
return _route_url_key(
|
|
normalize_dashboard_url(dashboard_url), expected_schemes={"http", "https"}
|
|
)
|
|
|
|
|
|
def _relay_ingress_key(relay_url: str) -> tuple[str, str, int, str]:
|
|
return _route_url_key(relay_url, expected_schemes={"ws", "wss"})
|
|
|
|
|
|
def add_dashboard_ingress_candidate(
|
|
*,
|
|
endpoints: Optional[list[dict]],
|
|
dashboard_url: Optional[str],
|
|
relay: Optional[dict],
|
|
api_host: Optional[str] = None,
|
|
api_port: Optional[int] = None,
|
|
api_tls: Optional[bool] = None,
|
|
legacy_direct_relay: bool = False,
|
|
) -> Optional[list[dict]]:
|
|
"""Advertise Dashboard's same-origin Relay ingress with direct fallbacks.
|
|
|
|
The candidate uses the normal network role (``https``, ``tailscale``, or
|
|
``lan``), not a service-specific role. API is intentionally optional.
|
|
Direct Relay is included only after an explicit legacy opt-in. New
|
|
Dashboard-origin pairing must not leak the loopback Relay port into a
|
|
public or tailnet route.
|
|
"""
|
|
dashboard = normalize_dashboard_url(dashboard_url) if dashboard_url else ""
|
|
if not dashboard:
|
|
return endpoints
|
|
|
|
relay_code = str((relay or {}).get("code") or "").strip()
|
|
if relay is None or not relay_code:
|
|
return endpoints
|
|
|
|
ingress_url = dashboard_relay_ingress_url(dashboard)
|
|
transport_hint = "wss" if ingress_url.startswith("wss://") else "ws"
|
|
ingress_role = endpoint_role_for_url(dashboard)
|
|
ingress: dict[str, Any] = {
|
|
"role": ingress_role,
|
|
"priority": 0,
|
|
"recommended": ingress_role == "tailscale",
|
|
"dashboard": {"url": dashboard},
|
|
}
|
|
ingress["relay"] = {
|
|
"url": ingress_url,
|
|
"transport_hint": transport_hint,
|
|
}
|
|
|
|
existing = [dict(candidate) for candidate in (endpoints or []) if isinstance(candidate, dict)]
|
|
relay_urls = {
|
|
str(candidate.get("relay", {}).get("url") or "").rstrip("/")
|
|
for candidate in existing
|
|
if isinstance(candidate.get("relay"), dict)
|
|
}
|
|
dashboard_origins: set[tuple[str, str, int, str]] = set()
|
|
for candidate in existing:
|
|
candidate_dashboard = candidate.get("dashboard")
|
|
if not isinstance(candidate_dashboard, dict) or not candidate_dashboard.get("url"):
|
|
continue
|
|
try:
|
|
dashboard_origins.add(_dashboard_origin_key(str(candidate_dashboard["url"])))
|
|
except ValueError:
|
|
continue
|
|
combined: list[dict] = []
|
|
if (
|
|
ingress_url.rstrip("/") not in relay_urls
|
|
and _dashboard_origin_key(dashboard) not in dashboard_origins
|
|
):
|
|
rank = {"tailscale": 0, "public": 1, "lan": 2}.get(ingress_role, 1)
|
|
inserted = False
|
|
for candidate in existing:
|
|
candidate_rank = {"tailscale": 0, "public": 1, "lan": 2}.get(
|
|
str(candidate.get("role") or "").lower(), 1
|
|
)
|
|
if not inserted and candidate_rank > rank:
|
|
combined.append(ingress)
|
|
inserted = True
|
|
combined.append(candidate)
|
|
if not inserted:
|
|
combined.append(ingress)
|
|
else:
|
|
combined.extend(existing)
|
|
|
|
direct_url = str((relay or {}).get("url") or "").strip()
|
|
if (
|
|
legacy_direct_relay
|
|
and direct_url
|
|
and direct_url.rstrip("/") not in relay_urls
|
|
and direct_url.rstrip("/") != ingress_url.rstrip("/")
|
|
):
|
|
direct: dict[str, Any] = {
|
|
"role": "legacy_direct",
|
|
"priority": len(combined),
|
|
"recommended": False,
|
|
"legacy": True,
|
|
"relay": {
|
|
"url": direct_url,
|
|
**(
|
|
{"transport_hint": relay["transport_hint"]}
|
|
if isinstance(relay, dict) and relay.get("transport_hint")
|
|
else {}
|
|
),
|
|
},
|
|
}
|
|
if api_host is not None and str(api_host).strip():
|
|
direct["api"] = {
|
|
"host": str(api_host).strip(),
|
|
"port": int(api_port) if api_port is not None else 8642,
|
|
"tls": bool(api_tls),
|
|
}
|
|
combined.append(direct)
|
|
|
|
for priority, candidate in enumerate(combined):
|
|
candidate["priority"] = priority
|
|
return combined or None
|
|
|
|
|
|
def build_relay_pairing_block(
|
|
*,
|
|
relay_url: str,
|
|
code: str,
|
|
ttl_seconds: Any = None,
|
|
grants: Optional[dict] = None,
|
|
transport_hint: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the nested ``relay`` block used by all QR emitters."""
|
|
def wire_seconds(value: Any) -> Any:
|
|
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
return int(value)
|
|
return value
|
|
|
|
relay_block: dict[str, Any] = {
|
|
"url": relay_url,
|
|
"code": code,
|
|
}
|
|
if ttl_seconds is not None:
|
|
relay_block["ttl_seconds"] = wire_seconds(ttl_seconds)
|
|
if grants is not None:
|
|
relay_block["grants"] = {
|
|
channel: wire_seconds(duration)
|
|
for channel, duration in grants.items()
|
|
}
|
|
if transport_hint is not None:
|
|
relay_block["transport_hint"] = transport_hint
|
|
return relay_block
|
|
|
|
|
|
def build_pairing_qr_payload(
|
|
*,
|
|
host: Optional[str],
|
|
port: Optional[int],
|
|
key: Optional[str],
|
|
tls: Optional[bool],
|
|
relay: Optional[dict] = None,
|
|
endpoints: Optional[list[dict]] = None,
|
|
dashboard_url: Optional[str] = None,
|
|
legacy_direct_relay: bool = False,
|
|
sign: bool = True,
|
|
) -> str:
|
|
"""Build the Android pairing QR payload shared by CLI and dashboard mint."""
|
|
return build_payload(
|
|
host=host,
|
|
port=port,
|
|
key=key,
|
|
tls=tls,
|
|
relay=relay,
|
|
endpoints=endpoints,
|
|
dashboard_url=dashboard_url,
|
|
legacy_direct_relay=legacy_direct_relay,
|
|
sign=sign,
|
|
)
|
|
|
|
|
|
def build_pairing_invite_url(payload: str) -> str:
|
|
"""Wrap a QR payload in a paste-friendly Hermes Relay invite URL.
|
|
|
|
The QR remains compact JSON for Android scanner compatibility. The URL is
|
|
for terminals, dashboard copy buttons, and desktop paste flows where a
|
|
one-line string is easier than raw JSON.
|
|
"""
|
|
encoded = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
|
|
return f"hermes-relay://pair?payload={encoded.rstrip('=')}"
|
|
|
|
|
|
# ── Endpoint candidate discovery (ADR 24) ────────────────────────────────────
|
|
|
|
|
|
_VALID_MODES = ("auto", "lan", "tailscale", "public")
|
|
_DEFAULT_DASHBOARD_PORT = 9119
|
|
_RECOMMENDED_DASHBOARD_LISTENER_PORT = 10443
|
|
_LEGACY_DASHBOARD_LISTENER_PORT = 443
|
|
|
|
|
|
def _lan_endpoint(
|
|
api_host: str,
|
|
api_port: int,
|
|
api_tls: bool,
|
|
relay_host: str,
|
|
relay_port: int,
|
|
relay_tls: bool,
|
|
priority: int = 0,
|
|
dashboard_port: int = _DEFAULT_DASHBOARD_PORT,
|
|
) -> dict[str, Any]:
|
|
"""Build a ``role: lan`` endpoint candidate using the LAN-resolved host.
|
|
|
|
Reuses the same :func:`_resolve_lan_ip` helper the single-endpoint
|
|
flow uses, so a bind-all / loopback host surfaces as a routable LAN
|
|
IP in the candidate.
|
|
"""
|
|
lan_host = _resolve_lan_ip(api_host)
|
|
dashboard_host = _resolve_lan_ip(relay_host)
|
|
url_host = (
|
|
f"[{dashboard_host}]"
|
|
if ":" in dashboard_host and not dashboard_host.startswith("[")
|
|
else dashboard_host
|
|
)
|
|
dashboard_url = f"http://{url_host}:{dashboard_port}"
|
|
return {
|
|
"role": "lan",
|
|
"priority": priority,
|
|
"api": {"host": lan_host, "port": api_port, "tls": api_tls},
|
|
"dashboard": {"url": dashboard_url},
|
|
"relay": {
|
|
"url": dashboard_relay_ingress_url(dashboard_url),
|
|
"transport_hint": "ws",
|
|
},
|
|
}
|
|
|
|
|
|
def is_explicit_relay_url(url: str) -> bool:
|
|
"""Whether an HTTP(S) URL names a Relay path rather than Dashboard base."""
|
|
path = urlparse(url).path.rstrip("/").lower()
|
|
return path.endswith(("/relay", "/ws", "/transport", "/transport/ws"))
|
|
|
|
|
|
def _tailscale_status() -> Optional[dict[str, Any]]:
|
|
"""Probe the optional Tailscale helper. Returns None on any failure.
|
|
|
|
The helper at ``plugin.relay.tailscale`` is owned by a sibling track
|
|
(ADR 25). We import it lazily and treat both ``ImportError`` and any
|
|
runtime exception as "no Tailscale endpoint available" so this code
|
|
works today on a vanilla install where the helper hasn't landed
|
|
yet. When the helper is present and returns a usable ``.ts.net``
|
|
hostname we surface it as a ``role: tailscale`` candidate.
|
|
"""
|
|
try:
|
|
from .relay import tailscale # type: ignore
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
status = tailscale.status()
|
|
except Exception:
|
|
return None
|
|
if not isinstance(status, dict):
|
|
return None
|
|
return status
|
|
|
|
|
|
def _recommended_tailscale_listener(status: Optional[dict[str, Any]]) -> int:
|
|
"""Return the helper-owned recommended listener, with a safe new default."""
|
|
value = status.get("recommended_listener_port") if isinstance(status, dict) else None
|
|
if isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535:
|
|
return value
|
|
return _RECOMMENDED_DASHBOARD_LISTENER_PORT
|
|
|
|
|
|
def _tailscale_endpoint(
|
|
status: dict[str, Any],
|
|
api_port: int,
|
|
relay_port: int,
|
|
api_tls: bool,
|
|
relay_tls: bool,
|
|
priority: int,
|
|
dashboard_port: int = _DEFAULT_DASHBOARD_PORT,
|
|
) -> Optional[dict[str, Any]]:
|
|
"""Materialize a ``role: tailscale`` candidate from a helper status dict.
|
|
|
|
The helper contract (ADR 25) hands back something shaped roughly
|
|
like ``{"hostname": "hermes.tail-scale.ts.net", "tailscale_ip":
|
|
"100.64.0.1", "serve_ports": [...]}``. When Tailscale Serve is active
|
|
for Dashboard, emit the MagicDNS hostname with TLS. Otherwise use the
|
|
raw tailnet IP and Dashboard's normal HTTP port. Relay always rides the
|
|
Dashboard same-origin ingress; direct 8767 is legacy-only.
|
|
"""
|
|
hostname = status.get("hostname") or status.get("dns_name") or status.get("host")
|
|
if isinstance(hostname, str):
|
|
hostname = hostname.strip().rstrip(".")
|
|
else:
|
|
hostname = None
|
|
|
|
tailscale_ip = status.get("tailscale_ip") or status.get("ip") or status.get("address")
|
|
if isinstance(tailscale_ip, str):
|
|
tailscale_ip = tailscale_ip.strip()
|
|
else:
|
|
tailscale_ip = None
|
|
|
|
serve_ports_raw = status.get("serve_ports")
|
|
serve_ports: set[int] = set()
|
|
if isinstance(serve_ports_raw, list):
|
|
for port in serve_ports_raw:
|
|
try:
|
|
serve_ports.add(int(port))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
serve_services = status.get("serve_services")
|
|
serve_services_dict = serve_services if isinstance(serve_services, dict) else {}
|
|
recommended_listener = _recommended_tailscale_listener(status)
|
|
|
|
def _service_listener(name: str, default_port: int) -> Optional[int]:
|
|
service = serve_services_dict.get(name)
|
|
if not isinstance(service, dict) or service.get("active") is not True:
|
|
return None
|
|
ports = service.get("listen_ports")
|
|
valid_ports = sorted({
|
|
int(port)
|
|
for port in (ports if isinstance(ports, list) else [])
|
|
if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535
|
|
})
|
|
if name == "dashboard" and recommended_listener in valid_ports:
|
|
return recommended_listener
|
|
if name == "dashboard" and _LEGACY_DASHBOARD_LISTENER_PORT in valid_ports:
|
|
return _LEGACY_DASHBOARD_LISTENER_PORT
|
|
if default_port in valid_ports:
|
|
return default_port
|
|
return valid_ports[0] if valid_ports else None
|
|
|
|
dashboard_listener = _service_listener("dashboard", dashboard_port)
|
|
api_listener = _service_listener("api", api_port)
|
|
if dashboard_listener is None and not serve_services_dict:
|
|
dashboard_listener = next(
|
|
(
|
|
port
|
|
for port in (
|
|
recommended_listener,
|
|
_LEGACY_DASHBOARD_LISTENER_PORT,
|
|
dashboard_port,
|
|
)
|
|
if port in serve_ports
|
|
),
|
|
None,
|
|
)
|
|
# Compatibility with pre-classification helpers: only the well-known
|
|
# Dashboard listener proves Dashboard Serve. Legacy Relay 8767 must not.
|
|
dashboard_serve_tls = (
|
|
dashboard_listener is not None
|
|
)
|
|
if dashboard_serve_tls:
|
|
if not hostname or not hostname.endswith(".ts.net"):
|
|
return None
|
|
dashboard_host = hostname
|
|
dashboard_scheme = "https"
|
|
else:
|
|
dashboard_host = tailscale_ip or hostname
|
|
if not isinstance(dashboard_host, str) or not dashboard_host.strip():
|
|
return None
|
|
dashboard_scheme = "http"
|
|
|
|
dashboard_host = dashboard_host.strip().rstrip(".")
|
|
if not dashboard_host:
|
|
return None
|
|
dashboard_url_host = (
|
|
f"[{dashboard_host}]"
|
|
if ":" in dashboard_host and not dashboard_host.startswith("[")
|
|
else dashboard_host
|
|
)
|
|
advertised_dashboard_port = dashboard_listener or dashboard_port
|
|
dashboard_port_suffix = (
|
|
"" if dashboard_scheme == "https" and advertised_dashboard_port == 443
|
|
else f":{advertised_dashboard_port}"
|
|
)
|
|
dashboard_url = f"{dashboard_scheme}://{dashboard_url_host}{dashboard_port_suffix}"
|
|
|
|
# A classified helper status is authoritative about which optional
|
|
# services are actually published. Do not advertise a raw 100.x API
|
|
# fallback when the helper explicitly reports API Serve inactive: the
|
|
# API process may be bound only to LAN, making that tailnet address both
|
|
# misleading and unreachable. Pre-classification helpers keep the old
|
|
# direct-tailnet fallback for compatibility because they cannot express
|
|
# per-service activity.
|
|
include_api = api_listener is not None or not serve_services_dict
|
|
api_endpoint: Optional[dict[str, Any]] = None
|
|
if include_api:
|
|
api_serve_tls = bool(
|
|
(api_listener is not None or api_port in serve_ports)
|
|
and hostname
|
|
and hostname.endswith(".ts.net")
|
|
)
|
|
api_host = hostname if api_serve_tls else (tailscale_ip or hostname)
|
|
if not isinstance(api_host, str) or not api_host.strip():
|
|
return None
|
|
api_endpoint = {
|
|
"host": api_host.strip().rstrip("."),
|
|
"port": api_listener or api_port,
|
|
"tls": True if api_serve_tls else api_tls,
|
|
}
|
|
|
|
candidate: dict[str, Any] = {
|
|
"role": "tailscale",
|
|
"priority": priority,
|
|
"recommended": True,
|
|
"dashboard": {"url": dashboard_url},
|
|
"relay": {
|
|
"url": dashboard_relay_ingress_url(dashboard_url),
|
|
"transport_hint": "wss" if dashboard_serve_tls else "ws",
|
|
},
|
|
}
|
|
if api_endpoint is not None:
|
|
candidate["api"] = api_endpoint
|
|
return candidate
|
|
|
|
|
|
def validate_endpoint_candidate_security(candidate: dict[str, Any]) -> None:
|
|
"""Reject plaintext on candidates explicitly labeled as public."""
|
|
role = str(candidate.get("role") or "").strip().lower()
|
|
if role not in {"public", "public_legacy"}:
|
|
return
|
|
candidate_dashboard = candidate.get("dashboard")
|
|
if isinstance(candidate_dashboard, dict) and candidate_dashboard.get("url"):
|
|
public_dashboard = normalize_public_url(str(candidate_dashboard["url"]))
|
|
if urlparse(public_dashboard).scheme.lower() != "https": # pragma: no cover
|
|
raise ValueError("role=public Dashboard routes must use https://")
|
|
candidate_relay = candidate.get("relay")
|
|
if isinstance(candidate_relay, dict) and candidate_relay.get("url"):
|
|
relay_scheme = urlparse(str(candidate_relay["url"])).scheme.lower()
|
|
if relay_scheme != "wss":
|
|
raise ValueError("role=public Relay routes must use wss://")
|
|
candidate_api = candidate.get("api")
|
|
if isinstance(candidate_api, dict) and candidate_api.get("tls") is not True:
|
|
raise ValueError("role=public API routes must enable TLS")
|
|
|
|
|
|
def normalize_endpoint_candidates(
|
|
endpoints: Optional[list[Any]],
|
|
*,
|
|
api_port: Optional[int] = None,
|
|
relay_port: Optional[int] = None,
|
|
api_tls: Optional[bool] = None,
|
|
relay_tls: Optional[bool] = None,
|
|
) -> Optional[list[Any]]:
|
|
"""Normalize server-owned endpoint candidates before QR signing.
|
|
|
|
Dashboard/plugin callers may be long-running processes with an older
|
|
``plugin.pair`` module cached. If they hand the relay a stale Tailscale
|
|
MagicDNS+TLS candidate while Tailscale Serve is not active, rewrite that
|
|
candidate to the direct 100.x tailnet route before the relay signs the QR.
|
|
|
|
Non-Tailscale roles and malformed records pass through unchanged.
|
|
"""
|
|
if endpoints is None:
|
|
return None
|
|
|
|
normalized: list[Any] = []
|
|
status_loaded = False
|
|
status: Optional[dict[str, Any]] = None
|
|
|
|
def _int_or(value: Any, fallback: int) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
for index, candidate in enumerate(endpoints):
|
|
if not isinstance(candidate, dict):
|
|
normalized.append(candidate)
|
|
continue
|
|
candidate = dict(candidate)
|
|
role = str(candidate.get("role") or "").strip().lower()
|
|
validate_endpoint_candidate_security(candidate)
|
|
if candidate.get("legacy") is True:
|
|
normalized.append(candidate)
|
|
continue
|
|
candidate_dashboard = candidate.get("dashboard")
|
|
if isinstance(candidate_dashboard, dict) and candidate_dashboard.get("url"):
|
|
dashboard_dict = dict(candidate_dashboard)
|
|
dashboard_url = normalize_dashboard_url(str(dashboard_dict["url"]))
|
|
dashboard_dict["url"] = dashboard_url
|
|
candidate["dashboard"] = dashboard_dict
|
|
if candidate.get("legacy") is not True:
|
|
existing_relay = candidate.get("relay")
|
|
existing_relay_url = (
|
|
str(existing_relay.get("url") or "")
|
|
if isinstance(existing_relay, dict)
|
|
else ""
|
|
)
|
|
if existing_relay_url:
|
|
dashboard_host = (urlparse(dashboard_url).hostname or "").lower().rstrip(".")
|
|
relay_host = (urlparse(existing_relay_url).hostname or "").lower().rstrip(".")
|
|
if not relay_host or relay_host != dashboard_host:
|
|
raise ValueError(
|
|
"dashboard_url has no exact same-origin Relay ingress candidate"
|
|
)
|
|
ingress_url = dashboard_relay_ingress_url(dashboard_url)
|
|
relay_dict = (
|
|
dict(candidate["relay"])
|
|
if isinstance(candidate.get("relay"), dict)
|
|
else {}
|
|
)
|
|
relay_dict.update({
|
|
"url": ingress_url,
|
|
"transport_hint": "wss" if ingress_url.startswith("wss://") else "ws",
|
|
})
|
|
candidate["relay"] = relay_dict
|
|
|
|
if not isinstance(candidate.get("dashboard"), dict) and role in {"lan", "public", "tailscale"}:
|
|
relay_dict = candidate.get("relay")
|
|
api_dict = candidate.get("api")
|
|
relay_url = (
|
|
str(relay_dict.get("url") or "")
|
|
if isinstance(relay_dict, dict)
|
|
else ""
|
|
)
|
|
parsed_relay = urlparse(relay_url) if relay_url else None
|
|
host = parsed_relay.hostname if parsed_relay is not None else None
|
|
if not host and isinstance(api_dict, dict):
|
|
host = str(api_dict.get("host") or "").strip() or None
|
|
if host:
|
|
url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
|
if role == "public":
|
|
dashboard_url = f"https://{url_host}"
|
|
elif role == "tailscale":
|
|
dashboard_scheme = (
|
|
"https"
|
|
if relay_url.startswith("wss://")
|
|
or (isinstance(api_dict, dict) and api_dict.get("tls") is True)
|
|
else "http"
|
|
)
|
|
dashboard_url = f"{dashboard_scheme}://{url_host}:{_DEFAULT_DASHBOARD_PORT}"
|
|
else:
|
|
dashboard_url = f"http://{url_host}:{_DEFAULT_DASHBOARD_PORT}"
|
|
ingress_url = dashboard_relay_ingress_url(dashboard_url)
|
|
candidate["dashboard"] = {"url": dashboard_url}
|
|
candidate["relay"] = {
|
|
**(dict(relay_dict) if isinstance(relay_dict, dict) else {}),
|
|
"url": ingress_url,
|
|
"transport_hint": "wss" if ingress_url.startswith("wss://") else "ws",
|
|
}
|
|
|
|
if role != "tailscale":
|
|
normalized.append(candidate)
|
|
continue
|
|
|
|
if not status_loaded:
|
|
status = _tailscale_status()
|
|
status_loaded = True
|
|
if status is None:
|
|
normalized.append(candidate)
|
|
continue
|
|
|
|
api = candidate.get("api")
|
|
api_dict = api if isinstance(api, dict) else {}
|
|
relay = candidate.get("relay")
|
|
relay_dict = relay if isinstance(relay, dict) else {}
|
|
relay_url = str(relay_dict.get("url") or "")
|
|
parsed_relay = urlparse(relay_url) if relay_url else None
|
|
|
|
effective_api_port = _int_or(
|
|
api_dict.get("port"),
|
|
api_port if api_port is not None else 8642,
|
|
)
|
|
try:
|
|
parsed_relay_port = parsed_relay.port if parsed_relay is not None else None
|
|
except ValueError:
|
|
parsed_relay_port = None
|
|
effective_relay_port = _int_or(
|
|
parsed_relay_port, relay_port if relay_port is not None else 8767
|
|
)
|
|
effective_api_tls = (
|
|
bool(api_tls) if api_tls is not None else bool(api_dict.get("tls"))
|
|
)
|
|
effective_relay_tls = (
|
|
bool(relay_tls)
|
|
if relay_tls is not None
|
|
else relay_url.startswith("wss://")
|
|
or str(relay_dict.get("transport_hint") or "").lower() == "wss"
|
|
)
|
|
priority = _int_or(candidate.get("priority"), index)
|
|
replacement = _tailscale_endpoint(
|
|
status,
|
|
api_port=effective_api_port,
|
|
relay_port=effective_relay_port,
|
|
api_tls=effective_api_tls,
|
|
relay_tls=effective_relay_tls,
|
|
priority=priority,
|
|
dashboard_port=_DEFAULT_DASHBOARD_PORT,
|
|
)
|
|
if replacement is None:
|
|
normalized.append(candidate)
|
|
continue
|
|
|
|
merged = dict(candidate)
|
|
merged_relay = dict(relay_dict)
|
|
merged_relay.update(replacement["relay"])
|
|
merged["priority"] = replacement["priority"]
|
|
merged["recommended"] = replacement["recommended"]
|
|
if "api" in replacement:
|
|
merged_api = dict(api_dict)
|
|
merged_api.update(replacement["api"])
|
|
merged["api"] = merged_api
|
|
else:
|
|
merged.pop("api", None)
|
|
merged["dashboard"] = replacement["dashboard"]
|
|
merged["relay"] = merged_relay
|
|
normalized.append(merged)
|
|
|
|
return normalized
|
|
|
|
|
|
def _public_endpoint(
|
|
public_url: str,
|
|
relay_port: int,
|
|
priority: int,
|
|
) -> dict[str, Any]:
|
|
"""Parse ``--public-url`` as a public Dashboard-origin candidate.
|
|
|
|
Relay uses Dashboard's same-origin plugin transport. The Relay process's
|
|
private 8767 listener is never inferred from a public origin.
|
|
|
|
Raises :class:`ValueError` when ``public_url`` is empty or its
|
|
scheme isn't ``http`` / ``https``.
|
|
"""
|
|
if not public_url:
|
|
raise ValueError("public_url is required for role=public")
|
|
dashboard_url = normalize_public_url(public_url)
|
|
relay_url = dashboard_relay_ingress_url(dashboard_url)
|
|
relay_scheme = "wss" if relay_url.startswith("wss://") else "ws"
|
|
return {
|
|
"role": "public",
|
|
"priority": priority,
|
|
"recommended": False,
|
|
"dashboard": {"url": dashboard_url},
|
|
"relay": {
|
|
"url": relay_url,
|
|
"transport_hint": relay_scheme,
|
|
},
|
|
}
|
|
|
|
|
|
def _legacy_public_relay_endpoint(
|
|
public_url: str,
|
|
relay_port: int,
|
|
priority: int,
|
|
) -> dict[str, Any]:
|
|
"""Build the old public direct-Relay route after explicit opt-in."""
|
|
dashboard_url = normalize_public_url(public_url)
|
|
parsed = urlparse(dashboard_url)
|
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
host = parsed.hostname or ""
|
|
url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
|
relay_url = (
|
|
f"{scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
|
|
if is_explicit_relay_url(dashboard_url)
|
|
else f"{scheme}://{url_host}:{relay_port}"
|
|
)
|
|
return {
|
|
"role": "public_legacy",
|
|
"priority": priority,
|
|
"recommended": False,
|
|
"legacy": True,
|
|
"relay": {
|
|
"url": relay_url,
|
|
"transport_hint": scheme,
|
|
},
|
|
}
|
|
|
|
|
|
def build_endpoint_candidates(
|
|
mode: str,
|
|
api_host: str,
|
|
api_port: int,
|
|
api_tls: bool,
|
|
relay_host: str,
|
|
relay_port: int,
|
|
relay_tls: bool,
|
|
public_url: Optional[str] = None,
|
|
prefer: Optional[str] = None,
|
|
legacy_direct_relay: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Build the ordered ``endpoints`` array for a v3 QR payload.
|
|
|
|
``mode`` is one of ``auto`` / ``lan`` / ``tailscale`` / ``public``
|
|
(see ADR 24). Priority is strictly increasing by role, starting at
|
|
0 for secure routes and going up — matching DNS SRV semantics (lower
|
|
number = higher priority). Tailscale and public TLS precede plain LAN,
|
|
which remains the final fallback. An empty list is returned when no candidates
|
|
could be detected (e.g. ``--mode tailscale`` on a host without
|
|
Tailscale installed); callers should treat that as "stay on the
|
|
single-endpoint v2 payload".
|
|
|
|
``prefer`` (optional) names a role that should be promoted to
|
|
priority 0, with all other roles shifted down by one. Useful for
|
|
testing a specific path end-to-end ("force Tailscale even though
|
|
I'm on LAN") without re-ordering defaults globally. When the named
|
|
role is not present in the detected candidates a warning is printed
|
|
to stderr and the list is emitted in its natural order — callers
|
|
asking for something that isn't there should see it, not silently
|
|
get the default.
|
|
|
|
Raises :class:`ValueError` if ``mode`` is unknown or ``mode=public``
|
|
is requested without a ``public_url``.
|
|
"""
|
|
if mode not in _VALID_MODES:
|
|
raise ValueError(
|
|
f"invalid --mode {mode!r}; expected one of {_VALID_MODES}"
|
|
)
|
|
candidates: list[dict[str, Any]] = []
|
|
next_priority = 0
|
|
|
|
def _emit(candidate: Optional[dict[str, Any]]) -> None:
|
|
nonlocal next_priority
|
|
if candidate is None:
|
|
return
|
|
# Respect the priority we assigned above; _lan_endpoint /
|
|
# _tailscale_endpoint / _public_endpoint honor it.
|
|
candidates.append(candidate)
|
|
next_priority += 1
|
|
|
|
want_lan = mode in ("auto", "lan")
|
|
want_tailscale = mode in ("auto", "tailscale")
|
|
want_public = mode in ("auto", "public")
|
|
effective_public_url = public_url
|
|
tailscale_status: Optional[dict[str, Any]] = None
|
|
|
|
if want_tailscale:
|
|
tailscale_status = _tailscale_status()
|
|
if tailscale_status is not None:
|
|
_emit(
|
|
_tailscale_endpoint(
|
|
tailscale_status,
|
|
api_port=api_port,
|
|
relay_port=relay_port,
|
|
api_tls=api_tls,
|
|
relay_tls=relay_tls,
|
|
priority=next_priority,
|
|
)
|
|
)
|
|
elif mode == "tailscale":
|
|
# Explicit mode but no helper / no hostname — fail soft
|
|
# rather than emit a half-formed QR. Caller falls back
|
|
# to the single-endpoint path and logs.
|
|
pass
|
|
|
|
if want_public:
|
|
# Auto-detect Tailscale Funnel URL when mode=auto and caller
|
|
# didn't pin one explicitly. Saves operators the "set public
|
|
# URL" step on the Remote Access tab when Funnel is already
|
|
# publishing the relay port. Fails soft — funnel_url returns
|
|
# None when the CLI is absent, nothing is funneled on this
|
|
# port, or the JSON parse hits an unexpected shape.
|
|
if not effective_public_url:
|
|
detected = None
|
|
_ts_helper = None
|
|
try:
|
|
from .relay import tailscale as _ts_helper # type: ignore
|
|
|
|
except Exception: # noqa: BLE001 — any failure = no helper
|
|
_ts_helper = None
|
|
if _ts_helper is not None:
|
|
probe_ports = dict.fromkeys((
|
|
_recommended_tailscale_listener(tailscale_status),
|
|
_LEGACY_DASHBOARD_LISTENER_PORT,
|
|
_DEFAULT_DASHBOARD_PORT,
|
|
))
|
|
for funnel_port in probe_ports:
|
|
try:
|
|
detected = _ts_helper.funnel_url(port=funnel_port)
|
|
except Exception: # noqa: BLE001 — each probe fails soft
|
|
detected = None
|
|
if detected:
|
|
break
|
|
if detected:
|
|
effective_public_url = detected
|
|
|
|
if effective_public_url:
|
|
explicit_relay_url = is_explicit_relay_url(
|
|
normalize_public_url(effective_public_url)
|
|
)
|
|
if explicit_relay_url and not legacy_direct_relay:
|
|
raise ValueError(
|
|
"an explicit Relay public path requires "
|
|
"--legacy-direct-relay"
|
|
)
|
|
if not explicit_relay_url:
|
|
_emit(
|
|
_public_endpoint(
|
|
effective_public_url,
|
|
relay_port=relay_port,
|
|
priority=next_priority,
|
|
)
|
|
)
|
|
elif mode == "public":
|
|
raise ValueError(
|
|
"--mode public requires --public-url <url> "
|
|
"(no Tailscale Funnel detected for this port)"
|
|
)
|
|
|
|
# Plain LAN is deliberately last in auto mode. It remains available as
|
|
# an explicit fallback, while Android and desktop receive the same signed
|
|
# secure-first ordering instead of applying divergent client heuristics.
|
|
if want_lan:
|
|
_emit(
|
|
_lan_endpoint(
|
|
api_host,
|
|
api_port,
|
|
api_tls,
|
|
relay_host,
|
|
relay_port,
|
|
relay_tls,
|
|
priority=next_priority,
|
|
)
|
|
)
|
|
|
|
if legacy_direct_relay:
|
|
if effective_public_url:
|
|
_emit(
|
|
_legacy_public_relay_endpoint(
|
|
effective_public_url,
|
|
relay_port=relay_port,
|
|
priority=next_priority,
|
|
)
|
|
)
|
|
relay_host_resolved = _resolve_lan_ip(relay_host)
|
|
url_host = (
|
|
f"[{relay_host_resolved}]"
|
|
if ":" in relay_host_resolved and not relay_host_resolved.startswith("[")
|
|
else relay_host_resolved
|
|
)
|
|
relay_scheme = "wss" if relay_tls else "ws"
|
|
_emit({
|
|
"role": "legacy_direct",
|
|
"priority": next_priority,
|
|
"recommended": False,
|
|
"legacy": True,
|
|
"relay": {
|
|
"url": f"{relay_scheme}://{url_host}:{relay_port}",
|
|
"transport_hint": relay_scheme,
|
|
},
|
|
})
|
|
|
|
# Priority override — promote the named role to priority 0 and
|
|
# renumber the rest in their existing relative order. Role string
|
|
# matches are case-insensitive + whitespace-trimmed for
|
|
# operator-ergonomics, but the candidate's original ``role`` value
|
|
# is preserved verbatim because the HMAC canonical form requires it.
|
|
if prefer:
|
|
wanted = prefer.strip().lower()
|
|
idx = next(
|
|
(i for i, c in enumerate(candidates) if str(c.get("role", "")).lower() == wanted),
|
|
-1,
|
|
)
|
|
if idx < 0:
|
|
print(
|
|
f" [warn] --prefer {prefer!r}: role not in candidates "
|
|
f"{[c.get('role') for c in candidates]}; emitting natural order",
|
|
file=sys.stderr,
|
|
)
|
|
elif idx > 0:
|
|
promoted = candidates.pop(idx)
|
|
candidates.insert(0, promoted)
|
|
for new_priority, c in enumerate(candidates):
|
|
c["priority"] = new_priority
|
|
|
|
return candidates
|
|
|
|
|
|
# ── TTL / grants parsing ─────────────────────────────────────────────────────
|
|
|
|
|
|
_TTL_PRESETS: dict[str, int] = {
|
|
# 0 => never expire
|
|
"never": 0,
|
|
"1d": 1 * 24 * 3600,
|
|
"7d": 7 * 24 * 3600,
|
|
"30d": 30 * 24 * 3600,
|
|
"90d": 90 * 24 * 3600,
|
|
# 1y ≈ 365 days. Not a leap-year-aware calendar year, just a round
|
|
# duration; "never" covers the "really long" case.
|
|
"1y": 365 * 24 * 3600,
|
|
}
|
|
|
|
|
|
def parse_duration(spec: str) -> int:
|
|
"""Parse a duration spec like ``"30d"`` / ``"1y"`` / ``"never"``.
|
|
|
|
Returns the duration in seconds. ``"never"`` returns 0, which the
|
|
relay interprets as ``math.inf`` (session never expires).
|
|
|
|
Also accepts an explicit number of seconds (e.g. ``"3600"``) for
|
|
power users.
|
|
"""
|
|
normalized = spec.strip().lower()
|
|
if not normalized:
|
|
raise ValueError("empty duration")
|
|
if normalized in _TTL_PRESETS:
|
|
return _TTL_PRESETS[normalized]
|
|
# Explicit numeric seconds.
|
|
if normalized.isdigit():
|
|
return int(normalized)
|
|
# Loose suffix form: <int>[smhdwy]
|
|
unit = normalized[-1]
|
|
head = normalized[:-1]
|
|
if not head.isdigit():
|
|
raise ValueError(f"cannot parse duration {spec!r}")
|
|
n = int(head)
|
|
if unit == "s":
|
|
return n
|
|
if unit == "m":
|
|
return n * 60
|
|
if unit == "h":
|
|
return n * 3600
|
|
if unit == "d":
|
|
return n * 24 * 3600
|
|
if unit == "w":
|
|
return n * 7 * 24 * 3600
|
|
if unit == "y":
|
|
return n * 365 * 24 * 3600
|
|
raise ValueError(f"unknown duration unit {unit!r} in {spec!r}")
|
|
|
|
|
|
def parse_grants(spec: str) -> dict[str, int]:
|
|
"""Parse a ``--grants`` spec like ``"terminal=7d,bridge=1d"``.
|
|
|
|
Returns a dict ``{channel: duration_seconds}``. Unknown channels are
|
|
accepted — the server applies its own whitelist.
|
|
"""
|
|
out: dict[str, int] = {}
|
|
if not spec.strip():
|
|
return out
|
|
for pair_str in spec.split(","):
|
|
pair_str = pair_str.strip()
|
|
if not pair_str:
|
|
continue
|
|
if "=" not in pair_str:
|
|
raise ValueError(
|
|
f"invalid grant {pair_str!r} — expected channel=duration"
|
|
)
|
|
channel, _, duration = pair_str.partition("=")
|
|
channel = channel.strip()
|
|
if not channel:
|
|
raise ValueError(f"empty channel in grant {pair_str!r}")
|
|
out[channel] = parse_duration(duration)
|
|
return out
|
|
|
|
|
|
def format_duration_label(ttl_seconds: int) -> str:
|
|
"""Return a human-readable label for a TTL like ``'30 days'`` or ``'indefinitely'``."""
|
|
if ttl_seconds == 0:
|
|
return "indefinitely"
|
|
day = 24 * 3600
|
|
if ttl_seconds % (365 * day) == 0:
|
|
n = ttl_seconds // (365 * day)
|
|
return f"{n} year{'s' if n != 1 else ''}"
|
|
if ttl_seconds % day == 0:
|
|
n = ttl_seconds // day
|
|
return f"{n} day{'s' if n != 1 else ''}"
|
|
if ttl_seconds % 3600 == 0:
|
|
n = ttl_seconds // 3600
|
|
return f"{n} hour{'s' if n != 1 else ''}"
|
|
return f"{ttl_seconds} seconds"
|
|
|
|
|
|
# ── Relay pre-pairing ────────────────────────────────────────────────────────
|
|
|
|
|
|
# Mirrors the relay's PAIRING_ALPHABET and the app's AuthManager generator.
|
|
_RELAY_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
|
_RELAY_CODE_LENGTH = 6
|
|
|
|
|
|
class InvalidPairingCodeError(ValueError):
|
|
"""Raised when a user-supplied pairing code fails format validation."""
|
|
|
|
|
|
def _generate_relay_code() -> str:
|
|
"""Generate a fresh 6-char pairing code (A-Z / 0-9)."""
|
|
rng = random.SystemRandom()
|
|
return "".join(rng.choice(_RELAY_CODE_ALPHABET) for _ in range(_RELAY_CODE_LENGTH))
|
|
|
|
|
|
def normalize_pairing_code(code: str) -> str:
|
|
"""Validate a user-supplied pairing code and return its canonical form.
|
|
|
|
The relay's :class:`PairingManager` upper-cases codes internally and
|
|
accepts only characters from ``PAIRING_ALPHABET`` at exactly
|
|
``PAIRING_CODE_LENGTH`` chars (6). We mirror that here so the CLI can
|
|
fail fast with a clear message instead of letting the operator find
|
|
out via an HTTP 400.
|
|
|
|
Raises :class:`InvalidPairingCodeError` on length or alphabet
|
|
mismatches. Returns the upper-cased code on success.
|
|
"""
|
|
if code is None:
|
|
raise InvalidPairingCodeError("pairing code is required")
|
|
normalized = code.strip().upper()
|
|
if not normalized:
|
|
raise InvalidPairingCodeError("pairing code is empty")
|
|
if len(normalized) != _RELAY_CODE_LENGTH:
|
|
raise InvalidPairingCodeError(
|
|
f"pairing code must be exactly {_RELAY_CODE_LENGTH} characters "
|
|
f"(got {len(normalized)}: {code!r})"
|
|
)
|
|
bad = [c for c in normalized if c not in _RELAY_CODE_ALPHABET]
|
|
if bad:
|
|
raise InvalidPairingCodeError(
|
|
f"pairing code contains invalid characters {bad!r} — "
|
|
f"only A-Z and 0-9 are allowed"
|
|
)
|
|
return normalized
|
|
|
|
|
|
def _relay_lan_base_url(relay_host: str, relay_port: int, tls: bool = False) -> str:
|
|
"""Build the ws[s]://host:port URL the phone should connect to.
|
|
|
|
Always resolves loopback/bind-all to a routable LAN IP so the QR payload
|
|
contains a URL the phone can actually reach across the network.
|
|
"""
|
|
lan_host = _resolve_lan_ip(relay_host)
|
|
scheme = "wss" if tls else "ws"
|
|
return f"{scheme}://{lan_host}:{relay_port}"
|
|
|
|
|
|
def register_relay_code(
|
|
localhost_port: int,
|
|
code: str,
|
|
timeout_s: float = 2.0,
|
|
ttl_seconds: int | None = None,
|
|
grants: dict[str, int] | None = None,
|
|
transport_hint: str | None = None,
|
|
) -> bool:
|
|
"""Pre-register ``code`` with the running relay via loopback HTTP.
|
|
|
|
The relay's ``/pairing/register`` endpoint is gated to loopback callers,
|
|
which matches the trust model: only a process running on the same host
|
|
as the relay (operator shell) can inject pairing codes. A phone on the
|
|
LAN cannot register codes.
|
|
|
|
Optional ``ttl_seconds`` / ``grants`` / ``transport_hint`` are passed
|
|
through verbatim so the operator's choices at QR generation time are
|
|
applied to the freshly-minted session when the phone claims the code.
|
|
|
|
Returns ``True`` on success, ``False`` on any failure (relay not running,
|
|
timeout, HTTP error). Callers should treat failure as "relay pairing
|
|
unavailable" and render an API-only QR.
|
|
"""
|
|
url = f"http://127.0.0.1:{localhost_port}/pairing/register"
|
|
body_dict: dict = {"code": code}
|
|
if ttl_seconds is not None:
|
|
body_dict["ttl_seconds"] = ttl_seconds
|
|
if grants:
|
|
body_dict["grants"] = grants
|
|
if transport_hint:
|
|
body_dict["transport_hint"] = transport_hint
|
|
body = json.dumps(body_dict).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
if resp.status != 200:
|
|
return False
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
return bool(data.get("ok"))
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def mint_relay_pairing(
|
|
localhost_port: int,
|
|
*,
|
|
host: str,
|
|
port: int,
|
|
api_key: str,
|
|
tls: bool,
|
|
ttl_seconds: int,
|
|
grants: dict[str, int] | None,
|
|
transport_hint: str,
|
|
endpoints: list[dict] | None,
|
|
dashboard_url: str | None,
|
|
api_enabled: bool = True,
|
|
legacy_direct_relay: bool = False,
|
|
timeout_s: float = 5.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Ask the running Relay to mint the authoritative signed invite.
|
|
|
|
Reach bootstrap credentials are created server-side and exist only in the
|
|
returned payload. This function deliberately never logs the request or
|
|
response because both may contain pairing and route credentials.
|
|
"""
|
|
body: dict[str, Any] = {
|
|
"api_enabled": api_enabled,
|
|
"ttl_seconds": ttl_seconds,
|
|
"transport_hint": transport_hint,
|
|
}
|
|
if api_enabled:
|
|
body.update({
|
|
"host": host,
|
|
"port": port,
|
|
"api_key": api_key,
|
|
"tls": tls,
|
|
})
|
|
if grants:
|
|
body["grants"] = grants
|
|
if endpoints:
|
|
body["endpoints"] = endpoints
|
|
if dashboard_url:
|
|
body["dashboard_url"] = dashboard_url
|
|
if legacy_direct_relay:
|
|
body["legacy_direct_relay"] = True
|
|
request = urllib.request.Request(
|
|
f"http://127.0.0.1:{localhost_port}/pairing/mint",
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout_s) as response:
|
|
if response.status != 200:
|
|
return None
|
|
result = json.loads(response.read().decode("utf-8"))
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
|
|
return None
|
|
if not isinstance(result, dict) or result.get("ok") is not True:
|
|
return None
|
|
qr_payload = result.get("qr_payload")
|
|
pairing_url = result.get("pairing_url")
|
|
if not isinstance(qr_payload, str) or not isinstance(pairing_url, str):
|
|
return None
|
|
try:
|
|
parsed = json.loads(qr_payload)
|
|
except (TypeError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(parsed, dict) or not isinstance(parsed.get("relay"), dict):
|
|
return None
|
|
return result
|
|
|
|
|
|
def probe_relay(localhost_port: int, timeout_s: float = 1.0) -> Optional[dict]:
|
|
"""Check if a relay is listening on ``localhost:<port>``.
|
|
|
|
Returns the parsed /health JSON on success, or None if the relay isn't
|
|
reachable. Used to decide whether to embed a relay block in the QR.
|
|
"""
|
|
url = f"http://127.0.0.1:{localhost_port}/health"
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout_s) as resp:
|
|
if resp.status != 200:
|
|
return None
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def read_relay_config() -> dict:
|
|
"""Resolve relay host/port from env vars + defaults.
|
|
|
|
Mirrors ``plugin/relay/config.py`` — uses ``RELAY_HOST`` / ``RELAY_PORT``
|
|
if set, otherwise falls back to ``0.0.0.0:8767`` which the LAN resolver
|
|
will turn into a routable address. The ``tls`` flag is True whenever
|
|
``RELAY_SSL_CERT`` is set in the environment; ``transport_hint`` gets
|
|
set accordingly for the QR payload.
|
|
"""
|
|
host = os.getenv("RELAY_HOST") or "0.0.0.0"
|
|
try:
|
|
port = int(os.getenv("RELAY_PORT") or "8767")
|
|
except ValueError:
|
|
port = 8767
|
|
tls = bool(os.getenv("RELAY_SSL_CERT"))
|
|
return {"host": host, "port": port, "tls": tls}
|
|
|
|
|
|
def _mask_key(key: str) -> str:
|
|
"""Return a redacted preview of the API key."""
|
|
if not key:
|
|
return "(none - open access)"
|
|
if len(key) <= 8:
|
|
return "*" * len(key) + f" ({len(key)} chars)"
|
|
return f"{key[:4]}...{key[-3:]} ({len(key)} chars)"
|
|
|
|
|
|
def render_text_block(
|
|
host: str,
|
|
port: int,
|
|
key: str,
|
|
tls: bool,
|
|
relay: Optional[dict] = None,
|
|
invite_url: Optional[str] = None,
|
|
dashboard_url: Optional[str] = None,
|
|
api_enabled: bool = True,
|
|
) -> str:
|
|
"""Return formatted connection details — always shown (works in any terminal).
|
|
|
|
When a ``relay`` block is provided, adds a second section showing the
|
|
WebSocket URL and the pre-registered pairing code so the operator can
|
|
enter them manually if QR scanning fails.
|
|
"""
|
|
scheme = "https" if tls else "http"
|
|
url = f"{scheme}://{host}:{port}"
|
|
auth_status = "Bearer token configured" if key else "NO AUTH (open access)"
|
|
|
|
lines: list[Optional[str]] = [
|
|
"",
|
|
" Hermes Android Pairing",
|
|
" " + "-" * 40,
|
|
"",
|
|
f" Dashboard: {dashboard_url}" if dashboard_url else None,
|
|
f" API Server: {url}" if api_enabled else None,
|
|
f" API Key: {_mask_key(key)}" if api_enabled else None,
|
|
f" API Auth: {auth_status}" if api_enabled else None,
|
|
"",
|
|
" Enter manually in the app if QR won't scan:",
|
|
f" Dashboard: {dashboard_url}" if dashboard_url else None,
|
|
f" API: {url}" if api_enabled else None,
|
|
]
|
|
lines = [line for line in lines if line is not None]
|
|
if api_enabled and key:
|
|
lines.append(f" Key: {key}")
|
|
|
|
if relay is not None:
|
|
lines.extend([
|
|
"",
|
|
" Relay (terminal + bridge)",
|
|
" " + "-" * 40,
|
|
f" URL : {relay['url']}",
|
|
f" Code : {relay['code']} (expires in 10 min, one-shot)",
|
|
])
|
|
ttl = relay.get("ttl_seconds")
|
|
if ttl is not None:
|
|
label = format_duration_label(int(ttl))
|
|
if ttl == 0:
|
|
lines.append(f" Pair : {label} (never expires)")
|
|
else:
|
|
lines.append(f" Pair : for {label}")
|
|
grants = relay.get("grants")
|
|
if grants:
|
|
grant_parts = []
|
|
for channel, duration in grants.items():
|
|
grant_parts.append(
|
|
f"{channel}={format_duration_label(int(duration))}"
|
|
)
|
|
lines.append(f" Grants: {', '.join(grant_parts)}")
|
|
|
|
if invite_url:
|
|
lines.extend([
|
|
"",
|
|
" Copy/paste pairing invite",
|
|
" " + "-" * 40,
|
|
f" URL : {invite_url}",
|
|
" Use : Hermes Relay Desktop -> Pair -> Paste invite",
|
|
" or: hermes-relay pair --pair-qr '<URL>'",
|
|
])
|
|
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_qr_terminal(payload: str) -> str:
|
|
"""Render QR as Unicode half-block string for terminal output.
|
|
|
|
Returns a helpful message if segno is not installed (graceful fallback).
|
|
"""
|
|
try:
|
|
import segno # type: ignore
|
|
except ImportError:
|
|
return (
|
|
" (QR rendering unavailable — install segno: pip install segno)\n"
|
|
" Use the text details above to enter connection info manually.\n"
|
|
)
|
|
|
|
try:
|
|
# error="l" (low ~7% redundancy) keeps the QR version as small as
|
|
# possible given the signed payload length. Keep the standard
|
|
# four-module quiet zone; dense multi-route invites become unreliable
|
|
# when terminal chrome or adjacent text touches the symbol. compact=True
|
|
# packs two modules per character
|
|
# vertically via ▀ / ▄ half-blocks, halving the visual height vs the
|
|
# full-block renderer. Together these produce the smallest terminal
|
|
# QR segno can emit without dropping features.
|
|
qr = segno.make(payload, error="l")
|
|
buf = io.StringIO()
|
|
qr.terminal(out=buf, compact=True, border=4)
|
|
return buf.getvalue()
|
|
except Exception as e:
|
|
return f" (QR render failed: {e})\n"
|
|
|
|
|
|
def render_qr_png(payload: str, path: Optional[str] = None) -> Optional[str]:
|
|
"""Save QR as PNG file. Returns the file path, or None on failure."""
|
|
try:
|
|
import segno # type: ignore
|
|
except ImportError:
|
|
return None
|
|
|
|
if path is None:
|
|
path = str(Path(tempfile.gettempdir()) / "hermes-pairing-qr.png")
|
|
|
|
try:
|
|
qr = segno.make(payload, error="l")
|
|
qr.save(path, scale=8, border=4)
|
|
return path
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def register_code_command(args) -> int:
|
|
"""CLI entry point for ``hermes-pair --register-code <code>``.
|
|
|
|
Skips QR rendering entirely. Validates the user-supplied code against
|
|
``PAIRING_ALPHABET`` + length, probes the local relay, then calls the
|
|
same loopback ``/pairing/register`` HTTP endpoint that the QR flow
|
|
uses — including any ``--ttl`` / ``--grants`` / ``--transport-hint``
|
|
options so a manual-paired session has the same gating as a QR-paired
|
|
one.
|
|
|
|
Use case: the operator can't render a QR or there's no second device
|
|
to scan with (SSH-only / camera unavailable / phone displaying a
|
|
locally-generated code in Settings → Connection → Manual pairing
|
|
code). The phone displays a code, the operator types it into a host
|
|
shell, this command pre-registers it with the relay, the phone taps
|
|
Connect.
|
|
|
|
Returns a process exit code (0 on success, non-zero on failure).
|
|
"""
|
|
raw_code = getattr(args, "register_code", None)
|
|
try:
|
|
code = normalize_pairing_code(raw_code or "")
|
|
except InvalidPairingCodeError as exc:
|
|
print(f" [error] --register-code: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
# ── TTL + grants from CLI flags (same shape as the QR flow) ───────────
|
|
ttl_spec = getattr(args, "ttl", None) or "30d"
|
|
try:
|
|
ttl_seconds = parse_duration(ttl_spec)
|
|
except ValueError as exc:
|
|
print(f" [error] --ttl: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
grants_spec = getattr(args, "grants", None)
|
|
grants_dict: Optional[dict[str, int]] = None
|
|
if grants_spec:
|
|
try:
|
|
grants_dict = parse_grants(grants_spec)
|
|
except ValueError as exc:
|
|
print(f" [error] --grants: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
# Resolve relay host/port + transport hint. The CLI flag overrides
|
|
# the env-derived default so the operator can register against a
|
|
# non-default relay or pretend the transport is wss (e.g. when
|
|
# running behind an external reverse proxy that terminates TLS).
|
|
relay_cfg = read_relay_config()
|
|
relay_port = relay_cfg["port"]
|
|
relay_tls = bool(relay_cfg.get("tls"))
|
|
transport_hint = getattr(args, "transport_hint", None) or (
|
|
"wss" if relay_tls else "ws"
|
|
)
|
|
|
|
# Probe the relay first so we can give a precise error message
|
|
# instead of a generic "post failed".
|
|
health = probe_relay(relay_port)
|
|
if health is None:
|
|
print(
|
|
f" [error] No relay reachable at http://127.0.0.1:{relay_port}.\n"
|
|
f" Start the relay first: hermes relay start "
|
|
f"(or python -m plugin.relay --no-ssl)",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
ok = register_relay_code(
|
|
relay_port,
|
|
code,
|
|
ttl_seconds=ttl_seconds,
|
|
grants=grants_dict,
|
|
transport_hint=transport_hint,
|
|
)
|
|
if not ok:
|
|
print(
|
|
" [error] Relay rejected the pairing code. The relay's "
|
|
"/pairing/register endpoint is loopback-only — make sure "
|
|
"you're running this command on the same host as the relay.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# ── Success — tell the operator exactly what to do next ──────────────
|
|
ttl_label = format_duration_label(ttl_seconds)
|
|
print()
|
|
print(" Hermes-Relay manual pairing")
|
|
print(" " + "-" * 40)
|
|
print(f" Code : {code}")
|
|
print(f" Relay : http://127.0.0.1:{relay_port}")
|
|
print(f" Transport : {transport_hint}")
|
|
if ttl_seconds == 0:
|
|
print(f" Session TTL : {ttl_label} (never expires)")
|
|
else:
|
|
print(f" Session TTL : {ttl_label}")
|
|
if grants_dict:
|
|
grant_parts = [
|
|
f"{ch}={format_duration_label(int(d))}" for ch, d in grants_dict.items()
|
|
]
|
|
print(f" Grants : {', '.join(grant_parts)}")
|
|
print()
|
|
print(" Code registered. The pairing code is single-use and expires")
|
|
print(" in 10 minutes.")
|
|
print()
|
|
print(" In the Hermes-Relay app:")
|
|
print(" 1. Open Settings -> Connection -> Manual pairing code (fallback).")
|
|
print(f" 2. Confirm the displayed code matches: {code}")
|
|
print(" 3. Tap Connect.")
|
|
print()
|
|
return 0
|
|
|
|
|
|
def pair_command(args) -> None:
|
|
"""CLI entry point for `hermes pair`. Called by argparse dispatch."""
|
|
# Manual-fallback flow: skip QR entirely and just pre-register the code.
|
|
if getattr(args, "register_code", None):
|
|
sys.exit(register_code_command(args))
|
|
|
|
config = read_server_config()
|
|
|
|
# Apply CLI overrides
|
|
if getattr(args, "host", None):
|
|
config["host"] = args.host
|
|
if getattr(args, "port", None):
|
|
config["port"] = int(args.port)
|
|
|
|
host = _resolve_lan_ip(config["host"])
|
|
port = config["port"]
|
|
key = config["key"]
|
|
tls = config["tls"]
|
|
api_enabled = bool(config.get("enabled", True))
|
|
try:
|
|
dashboard_url = configured_dashboard_url(
|
|
str(getattr(args, "dashboard_url", "") or "") or None
|
|
)
|
|
except ValueError as exc:
|
|
print(f" [error] --dashboard-url: {exc}", file=sys.stderr)
|
|
sys.exit(2)
|
|
legacy_direct_relay = bool(getattr(args, "legacy_direct_relay", False))
|
|
|
|
# ── Relay pre-pairing ────────────────────────────────────────────────
|
|
#
|
|
# If a relay is running locally, ask its loopback-only /pairing/mint
|
|
# endpoint for the authoritative signed payload. This is essential for
|
|
# server-owned one-use Reach credentials. A legacy /pairing/register
|
|
# fallback is allowed only when Reach is not configured.
|
|
|
|
# ── TTL + grants from CLI flags ──────────────────────────────────────
|
|
ttl_spec = getattr(args, "ttl", None) or "30d"
|
|
try:
|
|
ttl_seconds = parse_duration(ttl_spec)
|
|
except ValueError as exc:
|
|
print(f" [error] --ttl: {exc}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
grants_spec = getattr(args, "grants", None)
|
|
grants_dict: Optional[dict[str, int]] = None
|
|
if grants_spec:
|
|
try:
|
|
grants_dict = parse_grants(grants_spec)
|
|
except ValueError as exc:
|
|
print(f" [error] --grants: {exc}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
relay_block: Optional[dict] = None
|
|
relay_health: Optional[dict] = None
|
|
skip_relay = getattr(args, "no_relay", False)
|
|
if not skip_relay:
|
|
relay_cfg = read_relay_config()
|
|
relay_port = relay_cfg["port"]
|
|
relay_tls = bool(relay_cfg.get("tls"))
|
|
transport_hint = "wss" if relay_tls else "ws"
|
|
|
|
relay_health = probe_relay(relay_port)
|
|
if relay_health is None:
|
|
print(
|
|
" [info] Relay not running at localhost:"
|
|
f"{relay_port} — QR will configure chat only."
|
|
)
|
|
print(
|
|
" Start the relay with: hermes relay start "
|
|
"(or: python -m plugin.relay --no-ssl)\n"
|
|
)
|
|
else:
|
|
# A provisional block gates endpoint discovery. The authoritative
|
|
# code and signed payload come from /pairing/mint below.
|
|
relay_block = build_relay_pairing_block(
|
|
relay_url=_relay_lan_base_url(
|
|
relay_cfg["host"], relay_port, tls=relay_tls
|
|
),
|
|
code="PENDING",
|
|
ttl_seconds=ttl_seconds,
|
|
grants=grants_dict,
|
|
transport_hint=transport_hint,
|
|
)
|
|
|
|
# ── Multi-endpoint detection (ADR 24) ────────────────────────────────
|
|
# Build an ``endpoints`` array whenever the operator asked for multi-
|
|
# network support (non-default mode OR --public-url). ``mode=auto``
|
|
# silently probes LAN + Tailscale + (if passed) --public-url; explicit
|
|
# modes emit just that role (or empty list on detection failure —
|
|
# caller falls back to the single-endpoint payload).
|
|
mode = (getattr(args, "mode", None) or "auto").strip().lower()
|
|
public_url = getattr(args, "public_url", None)
|
|
prefer = getattr(args, "prefer", None)
|
|
endpoints: list[dict] = []
|
|
# Only emit endpoints when the relay is present — the phone needs a
|
|
# relay URL per candidate to drive reconnect on network switch. If
|
|
# the operator ran with --no-relay we stay on single-endpoint.
|
|
if relay_block is not None:
|
|
_relay_cfg = read_relay_config()
|
|
try:
|
|
endpoints = build_endpoint_candidates(
|
|
mode=mode,
|
|
api_host=host,
|
|
api_port=port,
|
|
api_tls=tls,
|
|
relay_host=_relay_cfg["host"],
|
|
relay_port=_relay_cfg["port"],
|
|
relay_tls=bool(_relay_cfg.get("tls")),
|
|
public_url=public_url,
|
|
prefer=prefer,
|
|
legacy_direct_relay=legacy_direct_relay,
|
|
)
|
|
if not api_enabled:
|
|
endpoints = [
|
|
{k: v for k, v in candidate.items() if k != "api"}
|
|
for candidate in endpoints
|
|
]
|
|
except ValueError as exc:
|
|
print(f" [error] --mode/--public-url: {exc}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
payload: str
|
|
invite_url: str
|
|
if relay_block is not None:
|
|
broker_status = (
|
|
relay_health.get("secure_link", {}).get("broker", {})
|
|
if isinstance(relay_health, dict)
|
|
and isinstance(relay_health.get("secure_link"), dict)
|
|
else {}
|
|
)
|
|
reach_requested = bool(
|
|
isinstance(broker_status, dict) and broker_status.get("enabled")
|
|
)
|
|
minted = mint_relay_pairing(
|
|
relay_port,
|
|
host=host,
|
|
port=port,
|
|
api_key=key,
|
|
tls=tls,
|
|
ttl_seconds=ttl_seconds,
|
|
grants=grants_dict,
|
|
transport_hint=transport_hint,
|
|
endpoints=endpoints or None,
|
|
dashboard_url=dashboard_url,
|
|
api_enabled=api_enabled,
|
|
legacy_direct_relay=legacy_direct_relay,
|
|
)
|
|
if minted is not None:
|
|
payload = str(minted["qr_payload"])
|
|
invite_url = str(minted["pairing_url"])
|
|
parsed_payload = json.loads(payload)
|
|
relay_block = parsed_payload["relay"]
|
|
elif not reach_requested:
|
|
# Compatibility with pre-/pairing/mint Relay versions only. Never
|
|
# downgrade a configured Reach host to a locally constructed QR,
|
|
# because that would silently omit its one-use route credential.
|
|
relay_code = _generate_relay_code()
|
|
if register_relay_code(
|
|
relay_port,
|
|
relay_code,
|
|
ttl_seconds=ttl_seconds,
|
|
grants=grants_dict,
|
|
transport_hint=transport_hint,
|
|
):
|
|
relay_block = build_relay_pairing_block(
|
|
relay_url=_relay_lan_base_url(
|
|
relay_cfg["host"], relay_port, tls=relay_tls
|
|
),
|
|
code=relay_code,
|
|
ttl_seconds=ttl_seconds,
|
|
grants=grants_dict,
|
|
transport_hint=transport_hint,
|
|
)
|
|
payload = build_pairing_qr_payload(
|
|
host=host if api_enabled else None,
|
|
port=port if api_enabled else None,
|
|
key=key if api_enabled else None,
|
|
tls=tls if api_enabled else None,
|
|
relay=relay_block, endpoints=endpoints or None,
|
|
dashboard_url=dashboard_url,
|
|
legacy_direct_relay=legacy_direct_relay,
|
|
)
|
|
invite_url = build_pairing_invite_url(payload)
|
|
else:
|
|
relay_block = None
|
|
print(" [warn] Relay pairing mint was rejected — QR will configure chat only.\n")
|
|
payload = build_pairing_qr_payload(
|
|
host=host if api_enabled else None,
|
|
port=port if api_enabled else None,
|
|
key=key if api_enabled else None,
|
|
tls=tls if api_enabled else None,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
invite_url = build_pairing_invite_url(payload)
|
|
else:
|
|
relay_block = None
|
|
print(
|
|
" [error] Hermes Reach is configured, but the Relay could not "
|
|
"mint its secure pairing invite. No downgraded relay QR was created.\n",
|
|
file=sys.stderr,
|
|
)
|
|
payload = build_pairing_qr_payload(
|
|
host=host if api_enabled else None,
|
|
port=port if api_enabled else None,
|
|
key=key if api_enabled else None,
|
|
tls=tls if api_enabled else None,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
invite_url = build_pairing_invite_url(payload)
|
|
else:
|
|
payload = build_pairing_qr_payload(
|
|
host=host if api_enabled else None,
|
|
port=port if api_enabled else None,
|
|
key=key if api_enabled else None,
|
|
tls=tls if api_enabled else None,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
invite_url = build_pairing_invite_url(payload)
|
|
|
|
# Always show text block — works in any terminal including Hermes TUI
|
|
print(
|
|
render_text_block(
|
|
host,
|
|
port,
|
|
key,
|
|
tls,
|
|
relay=relay_block,
|
|
invite_url=invite_url,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
)
|
|
|
|
png_only = getattr(args, "png", False)
|
|
no_qr = getattr(args, "no_qr", False)
|
|
|
|
if png_only:
|
|
path = render_qr_png(payload)
|
|
if path:
|
|
print(f" PNG saved: {path}")
|
|
else:
|
|
print(" PNG render failed — install segno: pip install segno")
|
|
elif not no_qr and sys.stdout.isatty():
|
|
print(render_qr_terminal(payload))
|
|
png_path = render_qr_png(payload)
|
|
if png_path:
|
|
print(f" PNG: {png_path}")
|
|
print(" Scan with the Hermes-Relay Android app.")
|
|
|
|
if (api_enabled and key) or relay_block is not None:
|
|
print(
|
|
" WARNING: This QR contains credentials "
|
|
"(API key and/or relay pairing code). Do not share screenshots.\n"
|
|
)
|
|
else:
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Allow running as `python -m plugin.pair` for manual testing
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Hermes Android pairing")
|
|
parser.add_argument("--png", action="store_true", help="Save PNG only")
|
|
parser.add_argument("--no-qr", action="store_true", dest="no_qr", help="Text only")
|
|
parser.add_argument(
|
|
"--no-relay",
|
|
action="store_true",
|
|
dest="no_relay",
|
|
help="Skip relay pre-pairing (render API-only QR)",
|
|
)
|
|
parser.add_argument("--host", help="Override API server host")
|
|
parser.add_argument("--port", type=int, help="Override API server port")
|
|
parser.add_argument(
|
|
"--dashboard-url",
|
|
help="Embed an explicit Hermes dashboard URL for Manage/standard voice",
|
|
)
|
|
parser.add_argument(
|
|
"--legacy-direct-relay",
|
|
action="store_true",
|
|
help=(
|
|
"Also advertise the direct Relay listener (normally port 8767). "
|
|
"Use only for older Desktop clients without Dashboard ticket auth."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--ttl",
|
|
default="30d",
|
|
help=(
|
|
"Session TTL — one of 1d/7d/30d/90d/1y/never, or an explicit "
|
|
"<N><unit> like 12h/4w. Default: 30d."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--grants",
|
|
default=None,
|
|
help=(
|
|
"Per-channel grants, comma-separated channel=duration pairs, "
|
|
"e.g. 'terminal=7d,bridge=1d'. Unspecified channels get server "
|
|
"defaults."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--register-code",
|
|
dest="register_code",
|
|
default=None,
|
|
help=(
|
|
"Manual-fallback flow: pre-register a 6-char pairing code "
|
|
"(A-Z / 0-9) supplied by the phone and exit. Skips QR "
|
|
"rendering entirely. Composes with --ttl / --grants / "
|
|
"--transport-hint. Use this when you can't scan a QR "
|
|
"(camera unavailable, SSH-only access, second-device pair "
|
|
"impossible) — the phone displays a code in Settings -> "
|
|
"Connection -> Manual pairing code (fallback), you type it "
|
|
"into this command, and then tap Connect in the app."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--transport-hint",
|
|
dest="transport_hint",
|
|
default=None,
|
|
choices=["ws", "wss"],
|
|
help=(
|
|
"Override the transport hint stored alongside the session "
|
|
"(only meaningful with --register-code). Defaults to 'wss' "
|
|
"when RELAY_SSL_CERT is set, otherwise 'ws'."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--mode",
|
|
dest="mode",
|
|
default="auto",
|
|
choices=list(_VALID_MODES),
|
|
help=(
|
|
"Endpoint discovery mode (ADR 24). 'auto' (default) probes "
|
|
"LAN + Tailscale + --public-url (when passed) and emits an "
|
|
"ordered ``endpoints`` array in the QR. 'lan' / 'tailscale' "
|
|
"/ 'public' emit just that role. 'public' requires "
|
|
"--public-url."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--public-url",
|
|
dest="public_url",
|
|
default=None,
|
|
help=(
|
|
"Public hostname for a reverse proxy / Cloudflare Tunnel "
|
|
"(e.g. https://hermes.example.com). Added as a role=public "
|
|
"endpoint candidate in the QR. Must be http:// or https://."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--prefer",
|
|
dest="prefer",
|
|
default=None,
|
|
help=(
|
|
"Promote a named role to priority 0 (highest). Any open-vocab "
|
|
"role string accepted — commonly 'lan' / 'tailscale' / 'public'. "
|
|
"Role matching is case-insensitive. Example: "
|
|
"'--mode auto --prefer tailscale' emits all detected modes but "
|
|
"with Tailscale as the first-probed endpoint. Warns to stderr "
|
|
"if the named role isn't in the detected candidates."
|
|
),
|
|
)
|
|
pair_command(parser.parse_args())
|