personal memory agent
0
fork

Configure Feed

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

link: fork spl/home into think/link + apps/link convey surface

Scaffolding chunks 1-4 of the spl-solstone integration (vpe req_xhoetsh6).
Complete fork from github.com/solpbc/spl home/ — no pip dep, no submodule,
no sync. Two projects are fully independent from here.

- think/link/: tunnel service (service.py, relay_client.py, wsgi_bridge.py,
ca.py, auth.py, nonces.py, mux.py, framing.py, tls_adapter.py, paths.py).
pair_server.py dropped — pair runs through convey's existing listener.
CA has no passphrase layer per spec (journal/link/ca/private.pem mode 0600).
Added last_seen_at column + touch_last_seen() on AuthorizedClients.
WSGI bridge pipes tunnel bytes to convey's real Flask app.
- apps/link/: dashboard (workspace.html), Flask routes (/pair-start,
/pair, /unpair, /api/devices, /api/status), Typer CLI (pair/list/
unpair/status). All spec literal-copy strings landed verbatim.
- supervisor: link service launches alongside cortex (--no-link opt-out).
- sol.py: 'sol link' command + GROUPS entry.
- pyproject.toml: pyOpenSSL + websockets deps.
- tests/link/test_framing.py: 17 tests ported from spl-repo (all green).

Privacy invariant: no payload bytes in logs. Rendezvous-only (method,
path, status, byte counts, tunnel_id, stream_id). Callosum tract 'link'
emits enrolled/connecting/connected/disconnect/tunnel_pair/tunnel_close/
last_seen.

Remaining chunks (ca/auth/mux/nonces/wsgi unit tests; in-tree test
client; end-to-end integration test; blindness grep; spl-repo
cross-reference PR) delegated to a continuation hopper lode.

Full solstone suite: 3498 tests passing.

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

+3390
+5
apps/link/app.json
··· 1 + { 2 + "icon": "🔗", 3 + "label": "Link", 4 + "facets": {"disabled": true} 5 + }
+195
apps/link/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for the link tunnel service. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call link ...``. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import datetime as dt 12 + import socket 13 + import time 14 + 15 + import typer 16 + 17 + from think.link.auth import AuthorizedClients 18 + from think.link.ca import generate_nonce, load_or_generate_ca 19 + from think.link.nonces import NONCE_TTL_SECONDS, NonceStore 20 + from think.link.paths import ( 21 + LinkState, 22 + authorized_clients_path, 23 + ca_dir, 24 + load_account_token, 25 + nonces_path, 26 + relay_url, 27 + ) 28 + from think.utils import require_solstone 29 + 30 + app = typer.Typer( 31 + help="Link — tunnel service for reaching this solstone from paired phones." 32 + ) 33 + 34 + 35 + @app.callback() 36 + def _require_up() -> None: 37 + require_solstone() 38 + 39 + 40 + def _authorized() -> AuthorizedClients: 41 + return AuthorizedClients(authorized_clients_path()) 42 + 43 + 44 + def _nonces() -> NonceStore: 45 + return NonceStore(nonces_path()) 46 + 47 + 48 + def _detect_lan_ip() -> str | None: 49 + try: 50 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 51 + try: 52 + sock.connect(("8.8.8.8", 80)) 53 + return sock.getsockname()[0] 54 + finally: 55 + sock.close() 56 + except OSError: 57 + return None 58 + 59 + 60 + def _relative_time(iso: str | None) -> str: 61 + if not iso: 62 + return "never" 63 + try: 64 + then = dt.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.UTC) 65 + except ValueError: 66 + return iso 67 + now = dt.datetime.now(dt.UTC) 68 + delta = (now - then).total_seconds() 69 + if delta < 15: 70 + return "just now" 71 + if delta < 60: 72 + return f"{int(delta)} seconds ago" 73 + if delta < 3600: 74 + return f"{int(delta // 60)} minutes ago" 75 + if delta < 86400: 76 + return f"{int(delta // 3600)} hours ago" 77 + return f"{int(delta // 86400)} days ago" 78 + 79 + 80 + @app.command() 81 + def pair( 82 + device_label: str = typer.Option(..., "--device-label", help="Label for the phone being paired"), 83 + convey_host: str = typer.Option( 84 + "", 85 + "--convey-host", 86 + help="Override host[:port] for the pair URL (default: auto-detect LAN IP)", 87 + ), 88 + convey_port: int = typer.Option( 89 + 0, 90 + "--convey-port", 91 + help="Override convey port (default: read from service port file or 5015)", 92 + ), 93 + timeout_seconds: int = typer.Option( 94 + NONCE_TTL_SECONDS, 95 + "--timeout", 96 + help="How long to wait for the phone before giving up", 97 + ), 98 + ) -> None: 99 + """Mint a one-shot nonce, print the pair URL + QR-ready payload, wait for completion.""" 100 + from think.utils import read_service_port 101 + 102 + value = generate_nonce() 103 + _nonces().add(value, device_label) 104 + ca_fp = load_or_generate_ca(ca_dir()).fingerprint_sha256() 105 + 106 + host = convey_host or _detect_lan_ip() or "localhost" 107 + port = convey_port or read_service_port("convey") or 5015 108 + base = f"http://{host}:{port}" 109 + url = f"{base}/app/link/pair?token={value}" 110 + 111 + typer.echo(f"Pair code: {value} (expires in 5 minutes)") 112 + typer.echo(f"Pair URL: {url}") 113 + typer.echo(f"CA fingerprint: sha256:{ca_fp}") 114 + typer.echo(f"Device: {device_label}") 115 + typer.echo("") 116 + typer.echo("Waiting for phone…") 117 + 118 + # Poll authorized_clients.json for a new entry. 119 + authorized = _authorized() 120 + before = {e.fingerprint for e in authorized.snapshot()} 121 + deadline = time.time() + timeout_seconds 122 + while time.time() < deadline: 123 + time.sleep(1.0) 124 + current = authorized.snapshot() 125 + new_entries = [e for e in current if e.fingerprint not in before] 126 + if new_entries: 127 + entry = new_entries[-1] 128 + typer.echo(f"Paired: {entry.device_label}") 129 + typer.echo(f" fingerprint: {entry.fingerprint}") 130 + typer.echo(f" paired_at: {entry.paired_at}") 131 + raise typer.Exit(0) 132 + # Also detect nonce consumption — if the nonce is gone/used, assume 133 + # the pair route fired but we missed the device (rare). 134 + nonce_entry = _nonces().peek(value) 135 + if nonce_entry and nonce_entry.used: 136 + typer.echo("Pair request completed; device should appear in `sol call link list`.") 137 + raise typer.Exit(0) 138 + typer.echo("Timed out. Pair code expired.") 139 + raise typer.Exit(2) 140 + 141 + 142 + @app.command("list") 143 + def list_devices() -> None: 144 + """Print every paired device with its last-seen time.""" 145 + entries = _authorized().snapshot() 146 + if not entries: 147 + typer.echo("No phones paired yet.") 148 + return 149 + for entry in entries: 150 + short_fp = entry.fingerprint.replace("sha256:", "")[:16] 151 + typer.echo( 152 + f"- {entry.device_label}" 153 + f" — added {_relative_time(entry.paired_at)}" 154 + f" — last seen {_relative_time(entry.last_seen_at)}" 155 + f" [{short_fp}]" 156 + ) 157 + 158 + 159 + @app.command() 160 + def unpair( 161 + target: str = typer.Argument(..., help="Device label or fingerprint (sha256:<hex>)"), 162 + ) -> None: 163 + """Revoke a paired device. Next reconnect from that device fails at TLS handshake.""" 164 + authorized = _authorized() 165 + if target.startswith("sha256:"): 166 + removed = authorized.remove(target) 167 + if not removed: 168 + typer.echo(f"No paired device with fingerprint {target}") 169 + raise typer.Exit(1) 170 + typer.echo("Unpaired.") 171 + return 172 + entry = authorized.find_by_label(target) 173 + if entry is None: 174 + typer.echo(f"No paired device with label {target!r}") 175 + raise typer.Exit(1) 176 + authorized.remove(entry.fingerprint) 177 + typer.echo("Unpaired.") 178 + 179 + 180 + @app.command() 181 + def status() -> None: 182 + """Report enrollment, listen-WS state, active tunnel count, relay endpoint.""" 183 + state = LinkState.load_or_create() 184 + token = load_account_token() 185 + url = relay_url() 186 + entries = _authorized().snapshot() 187 + typer.echo(f"Instance ID: {state.instance_id}") 188 + typer.echo(f"Home label: {state.home_label}") 189 + typer.echo(f"Relay URL: {url}") 190 + typer.echo(f"Enrolled: {'yes' if token else 'no'}") 191 + typer.echo(f"Paired devices: {len(entries)}") 192 + # Listen-WS state and active-tunnel count live in the service process 193 + # memory — surfaced via callosum events rather than polled here. The 194 + # convey /app/link/api/status route is the live vantage. 195 + typer.echo("Listen-WS state: (query convey /app/link/api/status for live state)")
+296
apps/link/routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """link app routes — pair ceremony + paired-device dashboard. 5 + 6 + All user-facing work for the spl tunnel integration happens here. The 7 + protocol-level code (TLS, framing, mux) lives in `think/link/`; this 8 + module is the HTTP surface that mobiles and the convey UI hit. 9 + 10 + Routes: 11 + 12 + GET /link dashboard (paired devices + pair button) 13 + GET /link/qr.png QR image for an active nonce (via ?token=) 14 + POST /link/pair-start generate a new nonce + return QR payload 15 + POST /link/pair mobile posts CSR + nonce; we sign + attest 16 + POST /link/unpair remove a fingerprint (immediate revocation) 17 + GET /link/api/devices JSON list of paired devices for JS polling 18 + GET /link/api/status service status (for dashboard refresh) 19 + 20 + The pair hop is plain HTTP on convey's existing listener — there is no 21 + separate port. Integrity is provided by the CA-fingerprint pinned in the 22 + QR, not by transport TLS. A MITM on the LAN can observe the nonce but 23 + cannot forge a cert signed by the pinned CA. 24 + """ 25 + 26 + from __future__ import annotations 27 + 28 + import datetime as dt 29 + import logging 30 + import socket 31 + from typing import Any 32 + 33 + from cryptography.hazmat.primitives import serialization 34 + from flask import Blueprint, jsonify, request 35 + 36 + from think.link.auth import AuthorizedClients, ClientEntry 37 + from think.link.ca import generate_nonce, load_or_generate_ca, mint_attestation, sign_csr 38 + from think.link.nonces import NonceStore 39 + from think.link.paths import ( 40 + LinkState, 41 + authorized_clients_path, 42 + ca_dir, 43 + load_account_token, 44 + nonces_path, 45 + relay_url, 46 + ) 47 + 48 + logger = logging.getLogger(__name__) 49 + 50 + link_bp = Blueprint( 51 + "app:link", 52 + __name__, 53 + url_prefix="/app/link", 54 + ) 55 + 56 + 57 + def _authorized() -> AuthorizedClients: 58 + return AuthorizedClients(authorized_clients_path()) 59 + 60 + 61 + def _nonces() -> NonceStore: 62 + return NonceStore(nonces_path()) 63 + 64 + 65 + def _utc_now_iso() -> str: 66 + return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 67 + 68 + 69 + def _lan_pair_base_url() -> str: 70 + """Best-effort LAN URL for the convey host — used in the QR payload.""" 71 + host = request.host 72 + scheme = "http" if not request.is_secure else "https" 73 + # If the request came in on localhost, substitute a routable LAN IP so 74 + # the phone's QR scan works. If we can't find one, fall back to host. 75 + try: 76 + hostname, _, port = host.partition(":") 77 + if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): 78 + lan_ip = _detect_lan_ip() 79 + if lan_ip: 80 + host = f"{lan_ip}:{port}" if port else lan_ip 81 + except Exception: 82 + logger.debug("lan ip detection failed", exc_info=True) 83 + return f"{scheme}://{host}" 84 + 85 + 86 + def _detect_lan_ip() -> str | None: 87 + """Pick a reasonable LAN-facing IPv4 by opening a UDP socket. 88 + 89 + No packets are sent — we just read what src address the kernel would 90 + pick for a route to an external host. Returns None on any error. 91 + """ 92 + try: 93 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 94 + try: 95 + sock.connect(("8.8.8.8", 80)) 96 + return sock.getsockname()[0] 97 + finally: 98 + sock.close() 99 + except OSError: 100 + return None 101 + 102 + 103 + def _ca_fingerprint() -> str: 104 + ca = load_or_generate_ca(ca_dir()) 105 + return ca.fingerprint_sha256() 106 + 107 + 108 + def _is_lan_accessible() -> bool: 109 + """Check whether convey is bound to a non-loopback interface. 110 + 111 + Used to drive the "enable LAN access" nudge on /link. Best-effort: the 112 + signal is the Host header the dashboard loaded under. 113 + """ 114 + hostname, _, _ = request.host.partition(":") 115 + if hostname in ("localhost", "127.0.0.1", "::1"): 116 + return bool(_detect_lan_ip()) 117 + return True 118 + 119 + 120 + # --------------------------------------------------------------------------- 121 + # dashboard 122 + # --------------------------------------------------------------------------- 123 + 124 + 125 + @link_bp.route("/api/devices") 126 + def api_devices() -> Any: 127 + """JSON list of paired devices — used by the dashboard JS.""" 128 + entries = _authorized().snapshot() 129 + devices = [_entry_to_json(e) for e in entries] 130 + return jsonify({"devices": devices}) 131 + 132 + 133 + @link_bp.route("/api/status") 134 + def api_status() -> Any: 135 + """Snapshot of link-service state for the dashboard header.""" 136 + state = LinkState.load_or_create() 137 + token = load_account_token() 138 + ca_fp = _ca_fingerprint() if ca_dir().exists() else None 139 + return jsonify( 140 + { 141 + "instance_id": state.instance_id, 142 + "home_label": state.home_label, 143 + "enrolled": token is not None, 144 + "relay_url": relay_url(), 145 + "ca_fingerprint": ca_fp, 146 + "lan_accessible": _is_lan_accessible(), 147 + } 148 + ) 149 + 150 + 151 + # --------------------------------------------------------------------------- 152 + # pair ceremony 153 + # --------------------------------------------------------------------------- 154 + 155 + 156 + @link_bp.route("/pair-start", methods=["POST"]) 157 + def pair_start() -> Any: 158 + """Generate a single-use 5-minute nonce and return QR-ready payload.""" 159 + payload = request.get_json(silent=True) or {} 160 + device_label = str(payload.get("device_label") or "").strip() or "unnamed device" 161 + 162 + nonce = generate_nonce() 163 + _nonces().add(nonce, device_label) 164 + 165 + ca_fp = _ca_fingerprint() 166 + pair_url = f"{_lan_pair_base_url()}/app/link/pair?token={nonce}" 167 + # The QR payload is a stable shape the mobile can parse — keep it 168 + # versioned so future mobiles stay backward-compatible. 169 + qr_payload = { 170 + "v": 1, 171 + "kind": "spl-pair", 172 + "pair_url": pair_url, 173 + "ca_fingerprint": ca_fp, 174 + "expires_in": 300, 175 + "device_label": device_label, 176 + } 177 + return jsonify( 178 + { 179 + "nonce": nonce, 180 + "pair_url": pair_url, 181 + "qr_payload": qr_payload, 182 + "ca_fingerprint": ca_fp, 183 + "expires_in": 300, 184 + "device_label": device_label, 185 + } 186 + ) 187 + 188 + 189 + @link_bp.route("/pair", methods=["POST"]) 190 + def pair() -> Any: 191 + """Mobile pair endpoint — accepts CSR + nonce, signs + mints attestation. 192 + 193 + Query: `?token=<nonce>` (the nonce minted by /pair-start). 194 + Body (JSON): 195 + { 196 + "csr": "<PEM>", // required 197 + "device_label": "<string>", // optional (falls back to nonce label) 198 + "nonce": "<hex>" // optional: may be in body instead of query 199 + } 200 + 201 + Response on success (200): 202 + { 203 + "client_cert": "<PEM>", 204 + "ca_chain": ["<PEM>", ...], 205 + "instance_id": "<uuid>", 206 + "home_label": "<string>", 207 + "home_attestation": "<JWT>", 208 + "fingerprint": "sha256:<hex>" 209 + } 210 + """ 211 + body = request.get_json(silent=True) or {} 212 + nonce_value = request.args.get("token") or body.get("nonce") 213 + csr_pem = body.get("csr") 214 + device_label = str(body.get("device_label") or "").strip() or "unnamed device" 215 + 216 + if not isinstance(nonce_value, str) or not isinstance(csr_pem, str): 217 + return jsonify({"error": "missing fields (nonce + csr required)"}), 400 218 + 219 + consumed = _nonces().consume(nonce_value) 220 + if consumed is None: 221 + return jsonify({"error": "nonce expired or used"}), 410 222 + 223 + effective_label = device_label or (consumed.device_label or "unnamed device") 224 + 225 + ca = load_or_generate_ca(ca_dir()) 226 + try: 227 + client_cert_pem, fingerprint = sign_csr(ca, csr_pem, effective_label) 228 + except Exception as exc: 229 + logger.info("pair: bad csr: %s", exc) 230 + return jsonify({"error": f"bad csr: {exc}"}), 400 231 + 232 + state = LinkState.load_or_create() 233 + authorized = _authorized() 234 + authorized.add( 235 + fingerprint=fingerprint, 236 + device_label=effective_label, 237 + instance_id=state.instance_id, 238 + paired_at=_utc_now_iso(), 239 + ) 240 + attestation = mint_attestation(ca, state.instance_id, fingerprint) 241 + ca_chain_pem = ca.cert.public_bytes(serialization.Encoding.PEM).decode("ascii") 242 + return jsonify( 243 + { 244 + "client_cert": client_cert_pem, 245 + "ca_chain": [ca_chain_pem], 246 + "instance_id": state.instance_id, 247 + "home_label": state.home_label, 248 + "home_attestation": attestation, 249 + "fingerprint": fingerprint, 250 + } 251 + ) 252 + 253 + 254 + @link_bp.route("/unpair", methods=["POST"]) 255 + def unpair() -> Any: 256 + """Revoke a paired device by label or fingerprint. 257 + 258 + Body (JSON): {"fingerprint": "sha256:..."} or {"device_label": "..."} 259 + """ 260 + body = request.get_json(silent=True) or {} 261 + fingerprint = body.get("fingerprint") 262 + device_label = body.get("device_label") 263 + if not isinstance(fingerprint, str): 264 + if not isinstance(device_label, str): 265 + return jsonify({"error": "fingerprint or device_label required"}), 400 266 + entry = _authorized().find_by_label(device_label) 267 + if entry is None: 268 + return jsonify({"error": "no paired device with that label"}), 404 269 + fingerprint = entry.fingerprint 270 + 271 + removed = _authorized().remove(fingerprint) 272 + if not removed: 273 + return jsonify({"error": "fingerprint not paired"}), 404 274 + return jsonify({"unpaired": fingerprint}) 275 + 276 + 277 + def _entry_to_json(entry: ClientEntry) -> dict[str, Any]: 278 + short_fp = entry.fingerprint.replace("sha256:", "")[:16] 279 + return { 280 + "fingerprint": entry.fingerprint, 281 + "fingerprint_short": short_fp, 282 + "device_label": entry.device_label, 283 + "paired_at": entry.paired_at, 284 + "last_seen_at": entry.last_seen_at, 285 + } 286 + 287 + 288 + # --------------------------------------------------------------------------- 289 + # helpers for the workspace template 290 + # --------------------------------------------------------------------------- 291 + 292 + 293 + @link_bp.app_context_processor 294 + def _inject_link_helpers() -> dict[str, Any]: 295 + """Make `url_for` to link endpoints easy from templates.""" 296 + return {}
+326
apps/link/workspace.html
··· 1 + <main class="link-dashboard"> 2 + <header class="link-header"> 3 + <h1 class="link-h1">Reach your solstone from anywhere</h1> 4 + <p class="link-trust">spl is blind by construction. Cloudflare and sol pbc cannot see anything inside the tunnel.</p> 5 + <div class="link-status-row"> 6 + <span id="link-status-indicator" class="link-status-indicator" aria-live="polite"> 7 + Service status: <span id="link-status-text">loading…</span> 8 + </span> 9 + <button type="button" class="link-pair-btn" id="link-pair-btn">Pair a phone</button> 10 + </div> 11 + </header> 12 + 13 + <div id="link-lan-nudge" class="link-lan-nudge" hidden> 14 + <p> 15 + Your convey isn't reachable on your local network, so phones can't complete the pair ceremony on the same wifi yet. 16 + Start the supervisor with a LAN-bound port (for example <code>make dev PORT=0.0.0.0:5015</code>) or set <code>convey.host</code> in your journal config to a non-loopback interface, then reload this page. 17 + </p> 18 + </div> 19 + 20 + <section class="link-paired-section" aria-labelledby="link-paired-h2"> 21 + <h2 id="link-paired-h2">Paired devices</h2> 22 + <div id="link-devices-list" class="link-devices-list" aria-live="polite"> 23 + <p class="link-empty-state" id="link-empty-state">No phones paired yet.</p> 24 + </div> 25 + </section> 26 + 27 + <div id="link-pair-modal" class="link-modal" hidden role="dialog" aria-modal="true" aria-labelledby="link-pair-modal-title"> 28 + <div class="link-modal-box"> 29 + <h3 id="link-pair-modal-title">Pair a phone</h3> 30 + <label class="link-modal-label"> 31 + Device label 32 + <input type="text" id="link-device-label" placeholder="e.g. My iPhone" maxlength="80" /> 33 + </label> 34 + <div class="link-modal-actions"> 35 + <button type="button" id="link-pair-generate">Generate pair code</button> 36 + <button type="button" id="link-pair-cancel" class="link-modal-cancel">Cancel</button> 37 + </div> 38 + 39 + <div id="link-pair-code" class="link-pair-code" hidden> 40 + <p class="link-qr-instruction">Scan this code with solstone mobile on the same wifi.</p> 41 + <p class="link-qr-subtext">This code expires in 5 minutes and only works once.</p> 42 + <div class="link-qr-container" id="link-qr-container" aria-label="pair QR code"></div> 43 + <dl class="link-qr-details"> 44 + <dt>Pair URL</dt> 45 + <dd><code id="link-pair-url"></code></dd> 46 + <dt>CA fingerprint</dt> 47 + <dd><code id="link-pair-ca-fp"></code></dd> 48 + </dl> 49 + <p class="link-waiting" id="link-pair-waiting">Waiting for phone…</p> 50 + </div> 51 + </div> 52 + </div> 53 + 54 + <div id="link-unpair-modal" class="link-modal" hidden role="dialog" aria-modal="true" aria-labelledby="link-unpair-title"> 55 + <div class="link-modal-box"> 56 + <h3 id="link-unpair-title">Unpair this phone?</h3> 57 + <p>Are you sure? This phone will lose access immediately.</p> 58 + <div class="link-modal-actions"> 59 + <button type="button" id="link-unpair-confirm" class="link-modal-danger">Unpair</button> 60 + <button type="button" id="link-unpair-cancel" class="link-modal-cancel">Cancel</button> 61 + </div> 62 + </div> 63 + </div> 64 + 65 + <div id="link-toast" class="link-toast" role="status" aria-live="polite" hidden></div> 66 + </main> 67 + 68 + <style> 69 + .link-dashboard { max-width: 880px; margin: 0 auto; padding: 1.5em; color: #222; } 70 + .link-header { margin-bottom: 2em; } 71 + .link-h1 { font-size: 1.8em; margin-bottom: 0.25em; font-weight: 600; } 72 + .link-trust { color: #666; font-size: 0.95em; margin-bottom: 1em; } 73 + .link-status-row { display: flex; align-items: center; justify-content: space-between; gap: 1em; flex-wrap: wrap; } 74 + .link-status-indicator { color: #444; font-size: 0.9em; } 75 + .link-status-indicator.is-online { color: #1e7b42; } 76 + .link-status-indicator.is-offline { color: #a53a1f; } 77 + .link-status-indicator.is-reconnecting { color: #b88400; } 78 + .link-pair-btn { background: #1e6bd0; color: #fff; border: none; border-radius: 6px; padding: 0.6em 1.2em; cursor: pointer; font-weight: 500; } 79 + .link-pair-btn:hover { background: #184f9a; } 80 + 81 + .link-lan-nudge { background: #fff6d4; border: 1px solid #e6cf72; border-radius: 8px; padding: 1em; margin-bottom: 1.5em; font-size: 0.9em; } 82 + .link-lan-nudge code { background: rgba(0,0,0,0.06); padding: 0.1em 0.35em; border-radius: 3px; } 83 + 84 + .link-paired-section h2 { font-size: 1.15em; margin-bottom: 0.75em; font-weight: 600; color: #444; } 85 + .link-devices-list { display: flex; flex-direction: column; gap: 0.5em; } 86 + .link-empty-state { color: #888; font-style: italic; } 87 + .link-device-row { display: flex; align-items: center; justify-content: space-between; background: #f9f8f5; border: 1px solid #ede9e1; border-radius: 8px; padding: 0.75em 1em; } 88 + .link-device-row .device-main { font-weight: 500; } 89 + .link-device-row .device-sub { color: #777; font-size: 0.85em; margin-top: 0.2em; } 90 + .link-device-row .device-fp { font-family: ui-monospace, monospace; font-size: 0.75em; color: #999; } 91 + .link-unpair-btn { background: transparent; border: 1px solid #c44; color: #c44; border-radius: 4px; padding: 0.35em 0.7em; cursor: pointer; font-size: 0.85em; } 92 + .link-unpair-btn:hover { background: #c44; color: #fff; } 93 + 94 + .link-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 1000; } 95 + .link-modal-box { background: #fff; border-radius: 10px; padding: 1.5em; max-width: 480px; width: 92%; box-shadow: 0 10px 40px rgba(0,0,0,0.2); } 96 + .link-modal-box h3 { margin-top: 0; margin-bottom: 1em; } 97 + .link-modal-label { display: flex; flex-direction: column; gap: 0.35em; margin-bottom: 1em; font-size: 0.9em; } 98 + .link-modal-label input { padding: 0.5em; border: 1px solid #ccc; border-radius: 5px; font-size: 1em; } 99 + .link-modal-actions { display: flex; gap: 0.5em; justify-content: flex-end; } 100 + .link-modal-actions button { padding: 0.55em 1.1em; border-radius: 5px; cursor: pointer; border: none; font-weight: 500; } 101 + .link-modal-actions button:not(.link-modal-cancel):not(.link-modal-danger) { background: #1e6bd0; color: #fff; } 102 + .link-modal-cancel { background: #eee; color: #444; } 103 + .link-modal-danger { background: #c44; color: #fff; } 104 + 105 + .link-pair-code { margin-top: 1.25em; padding-top: 1.25em; border-top: 1px solid #eee; } 106 + .link-qr-instruction { font-weight: 500; margin-bottom: 0.25em; } 107 + .link-qr-subtext { color: #777; font-size: 0.85em; margin-bottom: 1em; } 108 + .link-qr-container { background: #fff; padding: 1em; border: 1px solid #eee; border-radius: 6px; display: flex; justify-content: center; margin-bottom: 1em; } 109 + .link-qr-container svg, .link-qr-container img { max-width: 260px; height: auto; } 110 + .link-qr-details { font-size: 0.8em; color: #555; } 111 + .link-qr-details dt { font-weight: 500; margin-top: 0.5em; } 112 + .link-qr-details dd { font-family: ui-monospace, monospace; overflow-wrap: anywhere; margin-left: 0; } 113 + .link-waiting { color: #777; font-style: italic; margin-top: 1em; text-align: center; } 114 + 115 + .link-toast { position: fixed; bottom: 2em; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 0.75em 1.5em; border-radius: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1100; } 116 + </style> 117 + 118 + <script> 119 + (function () { 120 + const STATUS_LABELS = { 121 + online: 'reachable from the internet', 122 + connecting: 'connecting…', 123 + reconnecting: 'reconnecting…', 124 + offline: 'offline — check your connection', 125 + 'not-enrolled': 'not yet enrolled', 126 + unknown: 'loading…', 127 + }; 128 + 129 + const statusText = document.getElementById('link-status-text'); 130 + const statusEl = document.getElementById('link-status-indicator'); 131 + const devicesList = document.getElementById('link-devices-list'); 132 + const emptyState = document.getElementById('link-empty-state'); 133 + const lanNudge = document.getElementById('link-lan-nudge'); 134 + 135 + const pairBtn = document.getElementById('link-pair-btn'); 136 + const pairModal = document.getElementById('link-pair-modal'); 137 + const pairCancel = document.getElementById('link-pair-cancel'); 138 + const pairGenerate = document.getElementById('link-pair-generate'); 139 + const deviceLabelInput = document.getElementById('link-device-label'); 140 + const pairCodeBox = document.getElementById('link-pair-code'); 141 + const qrContainer = document.getElementById('link-qr-container'); 142 + const pairUrlEl = document.getElementById('link-pair-url'); 143 + const caFpEl = document.getElementById('link-pair-ca-fp'); 144 + const pairWaiting = document.getElementById('link-pair-waiting'); 145 + 146 + const unpairModal = document.getElementById('link-unpair-modal'); 147 + const unpairConfirm = document.getElementById('link-unpair-confirm'); 148 + const unpairCancel = document.getElementById('link-unpair-cancel'); 149 + let unpairTarget = null; 150 + 151 + const toast = document.getElementById('link-toast'); 152 + 153 + function showToast(msg) { 154 + toast.textContent = msg; 155 + toast.hidden = false; 156 + setTimeout(() => { toast.hidden = true; }, 2500); 157 + } 158 + 159 + function relativeTime(iso) { 160 + if (!iso) return 'never'; 161 + const then = new Date(iso).getTime(); 162 + if (isNaN(then)) return 'never'; 163 + const diff = Math.max(0, (Date.now() - then) / 1000); 164 + if (diff < 15) return 'just now'; 165 + if (diff < 60) return `${Math.floor(diff)} seconds ago`; 166 + if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; 167 + if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; 168 + return `${Math.floor(diff / 86400)} days ago`; 169 + } 170 + 171 + async function refreshStatus() { 172 + try { 173 + const r = await fetch('/app/link/api/status', {headers: {'accept': 'application/json'}}); 174 + const data = await r.json(); 175 + const state = data.enrolled ? 'online' : 'not-enrolled'; 176 + setStatus(state); 177 + lanNudge.hidden = !!data.lan_accessible; 178 + } catch (e) { 179 + setStatus('offline'); 180 + } 181 + } 182 + 183 + function setStatus(key) { 184 + const label = STATUS_LABELS[key] || STATUS_LABELS.unknown; 185 + statusText.textContent = label; 186 + statusEl.classList.remove('is-online', 'is-offline', 'is-reconnecting'); 187 + if (key === 'online') statusEl.classList.add('is-online'); 188 + else if (key === 'offline') statusEl.classList.add('is-offline'); 189 + else if (key === 'reconnecting' || key === 'connecting') statusEl.classList.add('is-reconnecting'); 190 + } 191 + 192 + async function refreshDevices() { 193 + try { 194 + const r = await fetch('/app/link/api/devices', {headers: {'accept': 'application/json'}}); 195 + const data = await r.json(); 196 + renderDevices(data.devices || []); 197 + } catch (e) { 198 + console.warn('devices fetch failed', e); 199 + } 200 + } 201 + 202 + function renderDevices(devices) { 203 + devicesList.innerHTML = ''; 204 + if (!devices.length) { 205 + devicesList.appendChild(emptyState); 206 + emptyState.hidden = false; 207 + return; 208 + } 209 + emptyState.hidden = true; 210 + for (const d of devices) { 211 + const row = document.createElement('div'); 212 + row.className = 'link-device-row'; 213 + const left = document.createElement('div'); 214 + const main = document.createElement('div'); 215 + main.className = 'device-main'; 216 + main.textContent = `Paired: ${d.device_label} — added ${relativeTime(d.paired_at)}`; 217 + const sub = document.createElement('div'); 218 + sub.className = 'device-sub'; 219 + sub.textContent = d.last_seen_at 220 + ? `Last seen: ${relativeTime(d.last_seen_at)}` 221 + : 'Last seen: never'; 222 + const fp = document.createElement('div'); 223 + fp.className = 'device-fp'; 224 + fp.textContent = d.fingerprint_short; 225 + left.appendChild(main); 226 + left.appendChild(sub); 227 + left.appendChild(fp); 228 + const btn = document.createElement('button'); 229 + btn.className = 'link-unpair-btn'; 230 + btn.type = 'button'; 231 + btn.textContent = 'Unpair'; 232 + btn.addEventListener('click', () => openUnpair(d)); 233 + row.appendChild(left); 234 + row.appendChild(btn); 235 + devicesList.appendChild(row); 236 + } 237 + } 238 + 239 + function renderQrAscii(data) { 240 + // Minimal fallback — render the URL as selectable text + a data-uri 241 + // QR is not bundled. A real deploy would load a QR JS lib; kept 242 + // dependency-free for the first lode. 243 + qrContainer.innerHTML = ''; 244 + const note = document.createElement('p'); 245 + note.style.fontSize = '0.85em'; 246 + note.style.color = '#555'; 247 + note.textContent = 'Paste this URL into solstone mobile (QR rendering lib not bundled yet):'; 248 + qrContainer.appendChild(note); 249 + const code = document.createElement('code'); 250 + code.textContent = data.pair_url; 251 + code.style.wordBreak = 'break-all'; 252 + qrContainer.appendChild(code); 253 + } 254 + 255 + pairBtn.addEventListener('click', () => { 256 + deviceLabelInput.value = ''; 257 + pairCodeBox.hidden = true; 258 + pairModal.hidden = false; 259 + }); 260 + 261 + pairCancel.addEventListener('click', () => { 262 + pairModal.hidden = true; 263 + }); 264 + 265 + pairGenerate.addEventListener('click', async () => { 266 + const label = deviceLabelInput.value.trim() || 'unnamed device'; 267 + try { 268 + const r = await fetch('/app/link/pair-start', { 269 + method: 'POST', 270 + headers: {'content-type': 'application/json'}, 271 + body: JSON.stringify({device_label: label}), 272 + }); 273 + if (!r.ok) throw new Error(`pair-start failed: ${r.status}`); 274 + const data = await r.json(); 275 + pairUrlEl.textContent = data.pair_url; 276 + caFpEl.textContent = 'sha256:' + data.ca_fingerprint; 277 + renderQrAscii(data); 278 + pairCodeBox.hidden = false; 279 + // Poll for new device appearing. 280 + const started = Date.now(); 281 + const interval = setInterval(async () => { 282 + await refreshDevices(); 283 + if (Date.now() - started > 310000) { 284 + clearInterval(interval); 285 + pairWaiting.textContent = 'Timed out. Pair code expired.'; 286 + } 287 + }, 2000); 288 + } catch (e) { 289 + showToast('Failed to generate pair code'); 290 + console.error(e); 291 + } 292 + }); 293 + 294 + function openUnpair(device) { 295 + unpairTarget = device; 296 + unpairModal.hidden = false; 297 + } 298 + unpairCancel.addEventListener('click', () => { 299 + unpairModal.hidden = true; 300 + unpairTarget = null; 301 + }); 302 + unpairConfirm.addEventListener('click', async () => { 303 + if (!unpairTarget) { unpairModal.hidden = true; return; } 304 + try { 305 + const r = await fetch('/app/link/unpair', { 306 + method: 'POST', 307 + headers: {'content-type': 'application/json'}, 308 + body: JSON.stringify({fingerprint: unpairTarget.fingerprint}), 309 + }); 310 + if (!r.ok) throw new Error(`unpair failed: ${r.status}`); 311 + showToast('Unpaired.'); 312 + unpairModal.hidden = true; 313 + unpairTarget = null; 314 + refreshDevices(); 315 + } catch (e) { 316 + showToast('Failed to unpair'); 317 + console.error(e); 318 + } 319 + }); 320 + 321 + refreshStatus(); 322 + refreshDevices(); 323 + setInterval(refreshStatus, 15000); 324 + setInterval(refreshDevices, 10000); 325 + })(); 326 + </script>
+5
pyproject.toml
··· 53 53 "httpx", 54 54 "jsonschema>=4.26,<5", 55 55 "genai-prices", 56 + # Link tunnel service (think/link/): TLS 1.3 in memory-BIO mode over 57 + # an opaque WebSocket; requires pyOpenSSL for the handshake-time 58 + # verify callback that stdlib ssl can't express. 59 + "pyOpenSSL>=24.0", 60 + "websockets>=13.0", 56 61 "pypdf", 57 62 "pdf2image", 58 63 "pytesseract",
+2
sol.py
··· 61 61 "providers": "think.providers_cli", 62 62 "cortex": "think.cortex", 63 63 "talent": "think.talent_cli", 64 + "link": "think.link", 64 65 "call": "think.call", 65 66 "engage": "think.engage", 66 67 "chat": "think.chat_cli", ··· 128 129 "streams", 129 130 "segment", 130 131 "journal-stats", 132 + "link", 131 133 ], 132 134 "Help": ["chat"], 133 135 }
tests/link/__init__.py

This is a binary file and will not be displayed.

+143
tests/link/test_framing.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Framing encode/decode round-trip + flag validation (forked from spl/home).""" 5 + 6 + from __future__ import annotations 7 + 8 + import pytest 9 + 10 + from think.link.framing import ( 11 + FLAG_CLOSE, 12 + FLAG_DATA, 13 + FLAG_OPEN, 14 + FLAG_RESERVED_MASK, 15 + FLAG_RESET, 16 + FLAG_WINDOW, 17 + HEADER_LEN, 18 + RESET_PROTOCOL_ERROR, 19 + Frame, 20 + FrameDecoder, 21 + ProtocolError, 22 + build_close, 23 + build_data, 24 + build_open, 25 + build_reset, 26 + build_window, 27 + parse_reset_reason, 28 + parse_window_credit, 29 + validate_flags, 30 + ) 31 + 32 + 33 + def test_header_is_8_bytes() -> None: 34 + assert HEADER_LEN == 8 35 + 36 + 37 + def test_encode_decode_roundtrip() -> None: 38 + original = Frame(stream_id=7, flags=FLAG_DATA, payload=b"hello world") 39 + encoded = original.encode() 40 + decoder = FrameDecoder() 41 + decoder.feed(encoded) 42 + got = decoder.next() 43 + assert got == original 44 + 45 + 46 + def test_decoder_handles_fragmented_feeds() -> None: 47 + frame = Frame(stream_id=5, flags=FLAG_DATA, payload=b"fragmented") 48 + encoded = frame.encode() 49 + decoder = FrameDecoder() 50 + for byte in encoded: 51 + decoder.feed(bytes([byte])) 52 + assert decoder.next() == frame 53 + 54 + 55 + def test_decoder_returns_none_when_incomplete() -> None: 56 + decoder = FrameDecoder() 57 + decoder.feed(b"\x00\x00\x00\x01") # partial header 58 + assert decoder.next() is None 59 + 60 + 61 + def test_multiple_frames_in_one_buffer() -> None: 62 + frames = [build_data(1, b"a"), build_data(3, b"bb"), build_data(5, b"ccc")] 63 + decoder = FrameDecoder() 64 + for f in frames: 65 + decoder.feed(f.encode()) 66 + assert decoder.drain() == frames 67 + 68 + 69 + def test_reserved_bits_rejected_on_encode() -> None: 70 + with pytest.raises(ProtocolError): 71 + Frame(stream_id=1, flags=FLAG_RESERVED_MASK, payload=b"").encode() 72 + 73 + 74 + def test_reserved_bits_rejected_on_decode() -> None: 75 + bad = Frame(stream_id=1, flags=FLAG_DATA, payload=b"") 76 + encoded = bytearray(bad.encode()) 77 + encoded[4] |= 0x80 78 + decoder = FrameDecoder() 79 + decoder.feed(bytes(encoded)) 80 + with pytest.raises(ProtocolError): 81 + decoder.next() 82 + 83 + 84 + def test_payload_length_bound() -> None: 85 + build_data(1, b"").encode() 86 + ok = Frame(stream_id=1, flags=FLAG_DATA, payload=b"x" * ((1 << 24) - 1)) 87 + ok.encode() 88 + 89 + 90 + def test_open_with_initial_bytes_carries_both_flags() -> None: 91 + f = build_open(1, b"seed") 92 + assert f.flags & FLAG_OPEN and f.flags & FLAG_DATA 93 + assert f.payload == b"seed" 94 + 95 + 96 + def test_open_without_bytes_is_pure_open() -> None: 97 + f = build_open(1) 98 + assert f.flags & FLAG_OPEN 99 + assert not (f.flags & FLAG_DATA) 100 + 101 + 102 + def test_data_with_close_carries_both() -> None: 103 + f = build_data(1, b"last", close=True) 104 + assert f.flags & FLAG_DATA and f.flags & FLAG_CLOSE 105 + 106 + 107 + def test_close_carries_only_close_flag() -> None: 108 + f = build_close(1) 109 + assert f.flags & FLAG_CLOSE 110 + assert not (f.flags & FLAG_OPEN) 111 + 112 + 113 + def test_window_credit_parse_roundtrip() -> None: 114 + f = build_window(1, 65536) 115 + assert parse_window_credit(f) == 65536 116 + 117 + 118 + def test_reset_reason_parse_roundtrip() -> None: 119 + f = build_reset(1, RESET_PROTOCOL_ERROR) 120 + assert parse_reset_reason(f) == RESET_PROTOCOL_ERROR 121 + 122 + 123 + def test_validate_flags_allows_only_legal_combos() -> None: 124 + for flag in (FLAG_OPEN, FLAG_DATA, FLAG_CLOSE, FLAG_RESET, FLAG_WINDOW): 125 + validate_flags(flag) 126 + validate_flags(FLAG_OPEN | FLAG_DATA) 127 + validate_flags(FLAG_DATA | FLAG_CLOSE) 128 + with pytest.raises(ProtocolError): 129 + validate_flags(FLAG_OPEN | FLAG_CLOSE) 130 + with pytest.raises(ProtocolError): 131 + validate_flags(FLAG_DATA | FLAG_WINDOW) 132 + 133 + 134 + def test_window_frame_requires_4_byte_payload() -> None: 135 + f = Frame(stream_id=1, flags=FLAG_WINDOW, payload=b"abc") 136 + with pytest.raises(ProtocolError): 137 + parse_window_credit(f) 138 + 139 + 140 + def test_reset_frame_requires_1_byte_payload() -> None: 141 + f = Frame(stream_id=1, flags=FLAG_RESET, payload=b"") 142 + with pytest.raises(ProtocolError): 143 + parse_reset_reason(f)
+31
think/link/README.md
··· 1 + # link service 2 + 3 + The home-side tunnel endpoint for the spl protocol — solstone's long-term home for this code. 4 + 5 + **Forked from [`github.com/solpbc/spl`](https://github.com/solpbc/spl) `home/` on 2026-04-20.** 6 + The two copies are now fully independent: no pip dep, no submodule, no sync scripts. 7 + The `spl` repo's `home/` continues as the open-source reference implementation of the protocol; this module is the canonical production implementation. 8 + 9 + ## layout 10 + 11 + | File | Purpose | 12 + |------|---------| 13 + | `service.py` | Entry point + runtime. `sol link` runs `main()` here. | 14 + | `relay_client.py` | Listen-WS + per-tunnel TLS pump. Spawns a task per incoming tunnel. | 15 + | `wsgi_bridge.py` | HTTP/1.1 ⇄ WSGI adapter that pipes tunnel bytes to convey's real Flask app. | 16 + | `tls_adapter.py` | pyOpenSSL memory-BIO adapter. Runs TLS 1.3 over opaque byte streams. | 17 + | `ca.py` | Local CA lifecycle + CSR signing + home-attestation minting. | 18 + | `auth.py` | `authorized_clients.json` reader/writer with mtime-reload and last-seen tracking. | 19 + | `nonces.py` | Pair-ceremony nonce store (shared between CLI and convey pair route). | 20 + | `mux.py` | Multiplex state machine per stream. | 21 + | `framing.py` | Wire-frame encode/decode. | 22 + | `paths.py` | Journal-path helpers + `SOL_LINK_RELAY_URL` resolution. | 23 + 24 + ## naming 25 + 26 + - **link** — user-facing and architecturally-visible names: service name, convey app, `sol call link`, `journal/link/`, `/link` route. 27 + - **spl** — protocol-level constructs: wire-format frames, JWT claim schemas, reset reason codes. These reference the external stable spl protocol and keep that name. 28 + 29 + ## privacy 30 + 31 + No payload bytes are ever logged. The tunnel pump only emits rendezvous metadata (tunnel_id, stream_id, method, path, status, byte counts) to logs and callosum. The CA private key never leaves `journal/link/ca/private.pem`; account tokens live in `journal/link/tokens/` and device tokens live on the phone.
+24
think/link/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """link service — solstone's tunnel endpoint. 5 + 6 + Forked from github.com/solpbc/spl home/ on 2026-04-20; the two copies are 7 + now fully independent. The spl repo is the open-source protocol reference; 8 + this is the canonical production implementation. 9 + 10 + The service opens a listen WebSocket to spl-relay and terminates TLS 1.3 11 + inside the solstone process. Paired devices reach convey's real routes 12 + through the tunnel — there is no separate in-tunnel test app. 13 + 14 + The wire protocol and frame types keep the "spl" name (spl_frame, 15 + SplTunnelFrame, etc.); everything user-facing, architecturally-visible, 16 + or journal-facing is "link" (service name, apps/link, journal/link, 17 + sol call link, /link). 18 + """ 19 + 20 + __version__ = "0.1.0" 21 + 22 + from .service import main # noqa: E402 — re-exported so `sol link` can import it 23 + 24 + __all__ = ["main"]
+9
think/link/__main__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """python -m think.link — entry point for `sol link`.""" 5 + 6 + from .service import main 7 + 8 + if __name__ == "__main__": 9 + main()
+192
think/link/auth.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """authorized_clients.json — the link service's authoritative revocation ledger. 5 + 6 + Entry shape is fixed by the spl protocol (see github.com/solpbc/spl 7 + proto/pairing.md §6), plus a solstone-specific `last_seen_at` field for 8 + UX: 9 + 10 + { 11 + "fingerprint": "sha256:<hex>", 12 + "device_label": "Jer's iPhone", 13 + "paired_at": "2026-04-19T17:42:13Z", 14 + "instance_id": "<home_instance_id>", 15 + "last_seen_at": "2026-04-19T18:03:12Z" // optional; null/absent = never 16 + } 17 + 18 + The TLS 1.3 server reloads the file on mtime change so an unpair action 19 + takes effect within ~500 ms of the file write. The pair route and the 20 + relay client both own the writer surface; reads (convey dashboard, TLS 21 + verify callback) go through this module. 22 + 23 + `last_seen_at` is local-only — never transmitted externally. 24 + """ 25 + 26 + from __future__ import annotations 27 + 28 + import datetime as dt 29 + import fcntl 30 + import json 31 + import os 32 + import threading 33 + from dataclasses import dataclass, replace 34 + from pathlib import Path 35 + 36 + 37 + @dataclass(frozen=True) 38 + class ClientEntry: 39 + fingerprint: str 40 + device_label: str 41 + paired_at: str 42 + instance_id: str 43 + last_seen_at: str | None = None 44 + 45 + 46 + class AuthorizedClients: 47 + """In-memory view of authorized_clients.json with mtime-based reload.""" 48 + 49 + def __init__(self, path: Path) -> None: 50 + self._path = path 51 + self._lock = threading.Lock() 52 + self._entries: dict[str, ClientEntry] = {} 53 + self._mtime_ns = 0 54 + if path.exists(): 55 + self._reload_locked() 56 + 57 + @property 58 + def path(self) -> Path: 59 + return self._path 60 + 61 + def reload_if_stale(self) -> bool: 62 + """Re-read the file if its mtime changed. Returns True if reloaded.""" 63 + with self._lock: 64 + try: 65 + current = self._path.stat().st_mtime_ns 66 + except FileNotFoundError: 67 + if self._entries: 68 + self._entries = {} 69 + self._mtime_ns = 0 70 + return True 71 + return False 72 + if current == self._mtime_ns: 73 + return False 74 + self._reload_locked() 75 + return True 76 + 77 + def is_authorized(self, fingerprint: str) -> bool: 78 + self.reload_if_stale() 79 + with self._lock: 80 + return fingerprint in self._entries 81 + 82 + def add( 83 + self, 84 + fingerprint: str, 85 + device_label: str, 86 + instance_id: str, 87 + *, 88 + paired_at: str | None = None, 89 + ) -> None: 90 + paired_at = paired_at or dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 91 + entry = ClientEntry( 92 + fingerprint=fingerprint, 93 + device_label=device_label, 94 + paired_at=paired_at, 95 + instance_id=instance_id, 96 + last_seen_at=None, 97 + ) 98 + with self._lock: 99 + current = self._load_file_locked() 100 + current[fingerprint] = entry 101 + self._atomic_write_locked(current) 102 + self._entries = current 103 + 104 + def remove(self, fingerprint: str) -> bool: 105 + with self._lock: 106 + current = self._load_file_locked() 107 + if fingerprint not in current: 108 + return False 109 + del current[fingerprint] 110 + self._atomic_write_locked(current) 111 + self._entries = current 112 + return True 113 + 114 + def touch_last_seen(self, fingerprint: str, *, now: dt.datetime | None = None) -> bool: 115 + """Update last_seen_at for a paired device. Returns False if not paired.""" 116 + ts = (now or dt.datetime.now(dt.UTC)).strftime("%Y-%m-%dT%H:%M:%SZ") 117 + with self._lock: 118 + current = self._load_file_locked() 119 + existing = current.get(fingerprint) 120 + if existing is None: 121 + return False 122 + current[fingerprint] = replace(existing, last_seen_at=ts) 123 + self._atomic_write_locked(current) 124 + self._entries = current 125 + return True 126 + 127 + def snapshot(self) -> list[ClientEntry]: 128 + self.reload_if_stale() 129 + with self._lock: 130 + return list(self._entries.values()) 131 + 132 + def find_by_label(self, label: str) -> ClientEntry | None: 133 + self.reload_if_stale() 134 + with self._lock: 135 + for entry in self._entries.values(): 136 + if entry.device_label == label: 137 + return entry 138 + return None 139 + 140 + def _reload_locked(self) -> None: 141 + entries = self._load_file_locked() 142 + self._entries = entries 143 + try: 144 + self._mtime_ns = self._path.stat().st_mtime_ns 145 + except FileNotFoundError: 146 + self._mtime_ns = 0 147 + 148 + def _load_file_locked(self) -> dict[str, ClientEntry]: 149 + if not self._path.exists(): 150 + return {} 151 + try: 152 + raw = json.loads(self._path.read_text("utf-8")) 153 + except (json.JSONDecodeError, OSError): 154 + return {} 155 + out: dict[str, ClientEntry] = {} 156 + if isinstance(raw, list): 157 + for item in raw: 158 + if not isinstance(item, dict): 159 + continue 160 + fp = item.get("fingerprint") 161 + if not isinstance(fp, str): 162 + continue 163 + last_seen = item.get("last_seen_at") 164 + out[fp] = ClientEntry( 165 + fingerprint=fp, 166 + device_label=str(item.get("device_label", "")), 167 + paired_at=str(item.get("paired_at", "")), 168 + instance_id=str(item.get("instance_id", "")), 169 + last_seen_at=last_seen if isinstance(last_seen, str) else None, 170 + ) 171 + return out 172 + 173 + def _atomic_write_locked(self, entries: dict[str, ClientEntry]) -> None: 174 + self._path.parent.mkdir(parents=True, exist_ok=True) 175 + payload = [ 176 + { 177 + "fingerprint": e.fingerprint, 178 + "device_label": e.device_label, 179 + "paired_at": e.paired_at, 180 + "instance_id": e.instance_id, 181 + **({"last_seen_at": e.last_seen_at} if e.last_seen_at else {}), 182 + } 183 + for e in entries.values() 184 + ] 185 + tmp = self._path.with_suffix(self._path.suffix + ".tmp") 186 + with open(tmp, "w", encoding="utf-8") as f: 187 + fcntl.flock(f.fileno(), fcntl.LOCK_EX) 188 + json.dump(payload, f, indent=2) 189 + f.write("\n") 190 + f.flush() 191 + os.fsync(f.fileno()) 192 + os.replace(tmp, self._path)
+252
think/link/ca.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Local CA management + home attestation minting. 5 + 6 + Generates an ECDSA-P256 CA on first run, keeps the private key unencrypted 7 + on disk with mode 0600 (no passphrase layer — filesystem perms + disk 8 + encryption are the protection surface; see the spl-solstone-integration 9 + spec "CA lifecycle" for rationale), signs mobile CSRs during the LAN pair 10 + ceremony, and mints short-lived home-attestation JWTs that spl-relay 11 + consumes at /enroll/device. 12 + 13 + See github.com/solpbc/spl proto/pairing.md §"the local CA" and 14 + proto/tokens.md §"POST /enroll/device" for the protocol contracts. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import base64 20 + import datetime as dt 21 + import hashlib 22 + import json 23 + import secrets 24 + import time 25 + import uuid 26 + from dataclasses import dataclass 27 + from pathlib import Path 28 + 29 + from cryptography import x509 30 + from cryptography.hazmat.primitives import hashes, serialization 31 + from cryptography.hazmat.primitives.asymmetric import ec 32 + from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature 33 + from cryptography.x509.oid import NameOID 34 + 35 + CA_VALIDITY_DAYS = 365 * 10 36 + CLIENT_CERT_VALIDITY_DAYS = 365 * 10 # Revocation is via authorized_clients.json. 37 + ATTESTATION_LIFETIME_SECONDS = 240 # 4 min — under the 5 min relay cap. 38 + 39 + 40 + @dataclass(frozen=True) 41 + class LoadedCa: 42 + """Materialized CA state: certificate, private key, and cached public-key PEM.""" 43 + 44 + cert: x509.Certificate 45 + private_key: ec.EllipticCurvePrivateKey 46 + pubkey_spki_pem: str 47 + 48 + def fingerprint_sha256(self) -> str: 49 + """SHA-256 of the CA cert DER — used as the CA identifier at /enroll/home.""" 50 + return _hex_sha256(self.cert.public_bytes(serialization.Encoding.DER)) 51 + 52 + 53 + def generate_ca( 54 + ca_dir: Path, 55 + common_name: str = "solstone link CA", 56 + ) -> LoadedCa: 57 + """Generate a fresh ECDSA-P256 CA and write it to disk. 58 + 59 + Writes `<ca_dir>/cert.pem` (world-readable) + `<ca_dir>/private.pem` 60 + (mode 0600). No passphrase — filesystem perms + disk encryption are 61 + the protection surface per the spec. 62 + """ 63 + private_key = ec.generate_private_key(ec.SECP256R1()) 64 + now = dt.datetime.now(dt.UTC) 65 + subject = issuer = x509.Name( 66 + [x509.NameAttribute(NameOID.COMMON_NAME, common_name)], 67 + ) 68 + cert = ( 69 + x509.CertificateBuilder() 70 + .subject_name(subject) 71 + .issuer_name(issuer) 72 + .public_key(private_key.public_key()) 73 + .serial_number(x509.random_serial_number()) 74 + .not_valid_before(now - dt.timedelta(minutes=5)) 75 + .not_valid_after(now + dt.timedelta(days=CA_VALIDITY_DAYS)) 76 + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) 77 + .add_extension( 78 + x509.KeyUsage( 79 + digital_signature=True, 80 + content_commitment=False, 81 + key_encipherment=False, 82 + data_encipherment=False, 83 + key_agreement=False, 84 + key_cert_sign=True, 85 + crl_sign=True, 86 + encipher_only=False, 87 + decipher_only=False, 88 + ), 89 + critical=True, 90 + ) 91 + .sign(private_key, hashes.SHA256()) 92 + ) 93 + 94 + ca_dir.mkdir(parents=True, exist_ok=True) 95 + cert_path = _cert_path(ca_dir) 96 + key_path = _key_path(ca_dir) 97 + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) 98 + _write_key(key_path, private_key) 99 + return _materialize(cert, private_key) 100 + 101 + 102 + def load_ca(ca_dir: Path) -> LoadedCa: 103 + """Load a previously generated CA. Raises if either file is missing.""" 104 + cert_pem = _cert_path(ca_dir).read_bytes() 105 + key_pem = _key_path(ca_dir).read_bytes() 106 + cert = x509.load_pem_x509_certificate(cert_pem) 107 + key = serialization.load_pem_private_key(key_pem, password=None) 108 + if not isinstance(key, ec.EllipticCurvePrivateKey): 109 + raise ValueError("CA key is not an ECDSA private key") 110 + return _materialize(cert, key) 111 + 112 + 113 + def load_or_generate_ca(ca_dir: Path) -> LoadedCa: 114 + """Return an existing CA if present; otherwise generate a fresh one.""" 115 + if _cert_path(ca_dir).exists() and _key_path(ca_dir).exists(): 116 + return load_ca(ca_dir) 117 + return generate_ca(ca_dir) 118 + 119 + 120 + def sign_csr( 121 + ca: LoadedCa, 122 + csr_pem: str | bytes, 123 + device_label: str, 124 + ) -> tuple[str, str]: 125 + """Sign a mobile CSR with the CA. Returns (client_cert_pem, fingerprint).""" 126 + csr_bytes = csr_pem.encode("utf-8") if isinstance(csr_pem, str) else csr_pem 127 + csr = x509.load_pem_x509_csr(csr_bytes) 128 + if not csr.is_signature_valid: 129 + raise ValueError("CSR signature invalid") 130 + pub = csr.public_key() 131 + if not isinstance(pub, ec.EllipticCurvePublicKey): 132 + raise ValueError("CSR public key must be ECDSA") 133 + if not isinstance(pub.curve, ec.SECP256R1): 134 + raise ValueError("CSR public key must be ECDSA-P256") 135 + 136 + now = dt.datetime.now(dt.UTC) 137 + cert = ( 138 + x509.CertificateBuilder() 139 + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, device_label)])) 140 + .issuer_name(ca.cert.subject) 141 + .public_key(pub) 142 + .serial_number(x509.random_serial_number()) 143 + .not_valid_before(now - dt.timedelta(minutes=5)) 144 + .not_valid_after(now + dt.timedelta(days=CLIENT_CERT_VALIDITY_DAYS)) 145 + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) 146 + .add_extension( 147 + x509.KeyUsage( 148 + digital_signature=True, 149 + content_commitment=False, 150 + key_encipherment=False, 151 + data_encipherment=False, 152 + key_agreement=False, 153 + key_cert_sign=False, 154 + crl_sign=False, 155 + encipher_only=False, 156 + decipher_only=False, 157 + ), 158 + critical=True, 159 + ) 160 + .add_extension( 161 + x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), 162 + critical=False, 163 + ) 164 + .sign(ca.private_key, hashes.SHA256()) 165 + ) 166 + pem = cert.public_bytes(serialization.Encoding.PEM).decode("ascii") 167 + fp = _hex_sha256(cert.public_bytes(serialization.Encoding.DER)) 168 + return pem, f"sha256:{fp}" 169 + 170 + 171 + def mint_attestation( 172 + ca: LoadedCa, 173 + instance_id: str, 174 + device_fp: str, 175 + *, 176 + now: int | None = None, 177 + ) -> str: 178 + """Mint an ES256 home-attestation JWT for /enroll/device. 179 + 180 + Shape is locked in github.com/solpbc/spl proto/tokens.md §"POST /enroll/device". 181 + """ 182 + iat = now if now is not None else int(time.time()) 183 + exp = iat + ATTESTATION_LIFETIME_SECONDS 184 + header = {"alg": "ES256", "typ": "home-attest"} 185 + claims = { 186 + "iss": f"home:{instance_id}", 187 + "aud": "spl-relay", 188 + "scope": "device.enroll", 189 + "instance_id": instance_id, 190 + "device_fp": device_fp, 191 + "iat": iat, 192 + "exp": exp, 193 + "jti": str(uuid.uuid4()), 194 + } 195 + header_b64 = _b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) 196 + payload_b64 = _b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8")) 197 + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") 198 + der_sig = ca.private_key.sign(signing_input, ec.ECDSA(hashes.SHA256())) 199 + r, s = decode_dss_signature(der_sig) 200 + raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") 201 + sig_b64 = _b64url(raw_sig) 202 + return f"{header_b64}.{payload_b64}.{sig_b64}" 203 + 204 + 205 + def cert_fingerprint(cert_pem: str | bytes) -> str: 206 + """Compute `sha256:<hex>` over the DER form of a PEM-encoded cert.""" 207 + pem_bytes = cert_pem.encode("utf-8") if isinstance(cert_pem, str) else cert_pem 208 + cert = x509.load_pem_x509_certificate(pem_bytes) 209 + return f"sha256:{_hex_sha256(cert.public_bytes(serialization.Encoding.DER))}" 210 + 211 + 212 + def generate_nonce() -> str: 213 + """64-character hex nonce for the pair ceremony.""" 214 + return secrets.token_hex(32) 215 + 216 + 217 + def _materialize(cert: x509.Certificate, key: ec.EllipticCurvePrivateKey) -> LoadedCa: 218 + pub_pem = ( 219 + cert.public_key() 220 + .public_bytes( 221 + serialization.Encoding.PEM, 222 + serialization.PublicFormat.SubjectPublicKeyInfo, 223 + ) 224 + .decode("ascii") 225 + ) 226 + return LoadedCa(cert=cert, private_key=key, pubkey_spki_pem=pub_pem) 227 + 228 + 229 + def _cert_path(ca_dir: Path) -> Path: 230 + return ca_dir / "cert.pem" 231 + 232 + 233 + def _key_path(ca_dir: Path) -> Path: 234 + return ca_dir / "private.pem" 235 + 236 + 237 + def _write_key(path: Path, key: ec.EllipticCurvePrivateKey) -> None: 238 + encoded = key.private_bytes( 239 + encoding=serialization.Encoding.PEM, 240 + format=serialization.PrivateFormat.PKCS8, 241 + encryption_algorithm=serialization.NoEncryption(), 242 + ) 243 + path.write_bytes(encoded) 244 + path.chmod(0o600) 245 + 246 + 247 + def _hex_sha256(data: bytes) -> str: 248 + return hashlib.sha256(data).hexdigest() 249 + 250 + 251 + def _b64url(data: bytes) -> str: 252 + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
+185
think/link/framing.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Multiplex framing per the spl protocol (see github.com/solpbc/spl proto/framing.md). 5 + 6 + Wire format (8-byte header + payload): 7 + 8 + +------+------+---+-------+ 9 + | sid4 | flg1 | len3 | header 10 + +------+------+-----------+ 11 + | payload (len bytes) | 12 + +-------------------------+ 13 + 14 + This module implements encoding, decoding, the flag bitfield, the reset 15 + reason codes, and a small `Stream` helper that tracks per-stream credit + 16 + close state per the spec. Protocol violations (reserved bits, illegal flag 17 + combos) raise `ProtocolError`; the caller is expected to translate that 18 + into a RESET frame with reason PROTOCOL_ERROR. 19 + 20 + The relay does not parse frames — this code runs on both tunnel endpoints. 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + from dataclasses import dataclass, field 26 + from typing import Final 27 + 28 + # Flag bits — each frame must carry exactly one of OPEN / DATA / CLOSE / 29 + # RESET / WINDOW, except OPEN|DATA (open with initial bytes) and DATA|CLOSE 30 + # (last data + half-close). 31 + FLAG_OPEN: Final[int] = 0x01 32 + FLAG_DATA: Final[int] = 0x02 33 + FLAG_CLOSE: Final[int] = 0x04 34 + FLAG_RESET: Final[int] = 0x08 35 + FLAG_WINDOW: Final[int] = 0x10 36 + FLAG_RESERVED_MASK: Final[int] = 0xE0 # bits 5-7 must be zero on send 37 + 38 + # Reset reason codes — 1-byte payload of RESET frames. 39 + RESET_PROTOCOL_ERROR: Final[int] = 0x01 40 + RESET_FLOW_CONTROL_ERROR: Final[int] = 0x02 41 + RESET_STREAM_LIMIT_EXCEEDED: Final[int] = 0x03 42 + RESET_INTERNAL_ERROR: Final[int] = 0x04 43 + RESET_CANCEL: Final[int] = 0x05 44 + RESET_UNSPECIFIED: Final[int] = 0xFF 45 + 46 + # Spec constants. 47 + HEADER_LEN: Final[int] = 8 48 + MAX_PAYLOAD: Final[int] = (1 << 24) - 1 # 16 MiB - 1 49 + INITIAL_WINDOW: Final[int] = 1 << 20 # 1 MiB 50 + MAX_CONCURRENT_STREAMS: Final[int] = 256 51 + RECOMMENDED_CHUNK: Final[int] = 64 * 1024 52 + 53 + 54 + class ProtocolError(ValueError): 55 + """Raised when a frame violates the spl framing spec — caller should RESET.""" 56 + 57 + 58 + @dataclass(frozen=True) 59 + class Frame: 60 + stream_id: int 61 + flags: int 62 + payload: bytes 63 + 64 + def encode(self) -> bytes: 65 + if not 0 <= self.stream_id <= 0xFFFFFFFF: 66 + raise ProtocolError(f"stream_id out of range: {self.stream_id}") 67 + if not 0 <= self.flags <= 0xFF: 68 + raise ProtocolError(f"flags out of range: {self.flags}") 69 + if self.flags & FLAG_RESERVED_MASK: 70 + raise ProtocolError(f"reserved flag bits set: {self.flags:#x}") 71 + length = len(self.payload) 72 + if length > MAX_PAYLOAD: 73 + raise ProtocolError(f"payload exceeds 16 MiB - 1: {length}") 74 + header = bytearray(HEADER_LEN) 75 + header[0:4] = self.stream_id.to_bytes(4, "big") 76 + header[4] = self.flags 77 + header[5:8] = length.to_bytes(3, "big") 78 + return bytes(header) + self.payload 79 + 80 + 81 + @dataclass 82 + class FrameDecoder: 83 + """Stream decoder. Feed bytes, pull frames until no complete frame remains. 84 + 85 + WebSocket message boundaries are ignored — we re-frame ourselves so that 86 + coalescing or fragmentation at the transport layer doesn't affect 87 + framing-layer semantics. 88 + """ 89 + 90 + _buf: bytearray = field(default_factory=bytearray) 91 + 92 + def feed(self, data: bytes | bytearray | memoryview) -> None: 93 + self._buf.extend(data) 94 + 95 + def next(self) -> Frame | None: 96 + if len(self._buf) < HEADER_LEN: 97 + return None 98 + stream_id = int.from_bytes(self._buf[0:4], "big") 99 + flags = self._buf[4] 100 + length = int.from_bytes(self._buf[5:8], "big") 101 + if flags & FLAG_RESERVED_MASK: 102 + raise ProtocolError(f"reserved flag bits set: {flags:#x}") 103 + end = HEADER_LEN + length 104 + if len(self._buf) < end: 105 + return None 106 + payload = bytes(self._buf[HEADER_LEN:end]) 107 + del self._buf[:end] 108 + return Frame(stream_id=stream_id, flags=flags, payload=payload) 109 + 110 + def drain(self) -> list[Frame]: 111 + out: list[Frame] = [] 112 + while True: 113 + frame = self.next() 114 + if frame is None: 115 + return out 116 + out.append(frame) 117 + 118 + 119 + def build_open(stream_id: int, payload: bytes = b"") -> Frame: 120 + flags = FLAG_OPEN 121 + if payload: 122 + flags |= FLAG_DATA 123 + return Frame(stream_id=stream_id, flags=flags, payload=payload) 124 + 125 + 126 + def build_data(stream_id: int, payload: bytes, *, close: bool = False) -> Frame: 127 + if not payload and not close: 128 + return Frame(stream_id=stream_id, flags=FLAG_DATA, payload=b"") 129 + flags = FLAG_DATA 130 + if close: 131 + flags |= FLAG_CLOSE 132 + return Frame(stream_id=stream_id, flags=flags, payload=payload) 133 + 134 + 135 + def build_close(stream_id: int, payload: bytes = b"") -> Frame: 136 + return Frame(stream_id=stream_id, flags=FLAG_CLOSE, payload=payload) 137 + 138 + 139 + def build_reset(stream_id: int, reason: int = RESET_UNSPECIFIED) -> Frame: 140 + if not 0 <= reason <= 0xFF: 141 + raise ProtocolError(f"reset reason out of range: {reason}") 142 + return Frame(stream_id=stream_id, flags=FLAG_RESET, payload=bytes([reason])) 143 + 144 + 145 + def build_window(stream_id: int, credit: int) -> Frame: 146 + if not 0 <= credit <= 0xFFFFFFFF: 147 + raise ProtocolError(f"window credit out of range: {credit}") 148 + return Frame(stream_id=stream_id, flags=FLAG_WINDOW, payload=credit.to_bytes(4, "big")) 149 + 150 + 151 + def validate_flags(flags: int) -> None: 152 + if flags & FLAG_RESERVED_MASK: 153 + raise ProtocolError(f"reserved flag bits set: {flags:#x}") 154 + exclusive = flags & (FLAG_OPEN | FLAG_DATA | FLAG_CLOSE | FLAG_RESET | FLAG_WINDOW) 155 + if not exclusive: 156 + raise ProtocolError("frame has no OPEN/DATA/CLOSE/RESET/WINDOW bit set") 157 + allowed = { 158 + FLAG_OPEN, 159 + FLAG_DATA, 160 + FLAG_CLOSE, 161 + FLAG_RESET, 162 + FLAG_WINDOW, 163 + FLAG_OPEN | FLAG_DATA, 164 + FLAG_DATA | FLAG_CLOSE, 165 + } 166 + if exclusive not in allowed: 167 + raise ProtocolError(f"illegal flag combination: {flags:#x}") 168 + 169 + 170 + def parse_window_credit(frame: Frame) -> int: 171 + if frame.flags & (FLAG_OPEN | FLAG_DATA | FLAG_CLOSE | FLAG_RESET) or not ( 172 + frame.flags & FLAG_WINDOW 173 + ): 174 + raise ProtocolError("not a WINDOW frame") 175 + if len(frame.payload) != 4: 176 + raise ProtocolError(f"WINDOW payload must be 4 bytes, got {len(frame.payload)}") 177 + return int.from_bytes(frame.payload, "big") 178 + 179 + 180 + def parse_reset_reason(frame: Frame) -> int: 181 + if not (frame.flags & FLAG_RESET): 182 + raise ProtocolError("not a RESET frame") 183 + if len(frame.payload) != 1: 184 + raise ProtocolError(f"RESET payload must be 1 byte, got {len(frame.payload)}") 185 + return frame.payload[0]
+287
think/link/mux.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Multiplex driver: framing-layer state + per-stream asyncio I/O. 5 + 6 + Bytes in from the TLS-plaintext side are fed into this module; we produce 7 + frames to send back, and each logical stream surfaces as an 8 + `asyncio.StreamReader`/`StreamWriter` pair that the HTTP app can drive. 9 + 10 + Flow-control uses the 1 MiB initial window per the spl framing spec — this 11 + side grants credit as bytes drain into the app; the peer uses its granted 12 + credit to send more data. For MVP the default "grant on every drained 13 + chunk" policy is fine. 14 + 15 + Concurrent stream cap: 256 per direction. OPENs beyond cap RESET with 16 + STREAM_LIMIT_EXCEEDED. 17 + """ 18 + 19 + from __future__ import annotations 20 + 21 + import asyncio 22 + from collections.abc import Awaitable, Callable 23 + from dataclasses import dataclass, field 24 + from typing import TYPE_CHECKING 25 + 26 + from .framing import ( 27 + FLAG_CLOSE, 28 + FLAG_DATA, 29 + FLAG_OPEN, 30 + FLAG_RESET, 31 + FLAG_WINDOW, 32 + INITIAL_WINDOW, 33 + MAX_CONCURRENT_STREAMS, 34 + RECOMMENDED_CHUNK, 35 + RESET_FLOW_CONTROL_ERROR, 36 + RESET_INTERNAL_ERROR, 37 + RESET_PROTOCOL_ERROR, 38 + RESET_STREAM_LIMIT_EXCEEDED, 39 + Frame, 40 + FrameDecoder, 41 + ProtocolError, 42 + build_close, 43 + build_data, 44 + build_open, 45 + build_reset, 46 + build_window, 47 + parse_reset_reason, 48 + parse_window_credit, 49 + ) 50 + 51 + if TYPE_CHECKING: 52 + StreamHandler = Callable[[asyncio.StreamReader, "StreamWriter"], Awaitable[None]] 53 + else: 54 + StreamHandler = object 55 + 56 + 57 + @dataclass 58 + class _StreamState: 59 + stream_id: int 60 + reader: asyncio.StreamReader 61 + reader_closed: bool = False 62 + writer_closed: bool = False 63 + send_credit: int = INITIAL_WINDOW 64 + recv_credit: int = INITIAL_WINDOW 65 + unacked_recv: int = 0 66 + credit_event: asyncio.Event = field(default_factory=asyncio.Event) 67 + task: asyncio.Task[None] | None = None 68 + 69 + 70 + class StreamWriter: 71 + """Per-stream writer. Calls into the mux to emit DATA/CLOSE/RESET frames.""" 72 + 73 + def __init__(self, mux: Multiplexer, state: _StreamState) -> None: 74 + self._mux = mux 75 + self._state = state 76 + 77 + async def write(self, data: bytes) -> None: 78 + if self._state.writer_closed: 79 + raise ConnectionError(f"stream {self._state.stream_id} writer is closed") 80 + view = memoryview(data) 81 + while view: 82 + chunk_len = min(len(view), RECOMMENDED_CHUNK, self._state.send_credit) 83 + if chunk_len <= 0: 84 + self._state.credit_event.clear() 85 + await self._state.credit_event.wait() 86 + continue 87 + chunk = bytes(view[:chunk_len]) 88 + view = view[chunk_len:] 89 + self._state.send_credit -= chunk_len 90 + await self._mux._emit(build_data(self._state.stream_id, chunk)) 91 + 92 + async def close(self) -> None: 93 + if self._state.writer_closed: 94 + return 95 + self._state.writer_closed = True 96 + await self._mux._emit(build_close(self._state.stream_id)) 97 + 98 + async def reset(self, reason: int = RESET_INTERNAL_ERROR) -> None: 99 + if self._state.writer_closed and self._state.reader_closed: 100 + return 101 + self._state.writer_closed = True 102 + self._state.reader_closed = True 103 + await self._mux._emit(build_reset(self._state.stream_id, reason)) 104 + self._state.reader.feed_eof() 105 + self._mux._forget(self._state.stream_id) 106 + 107 + 108 + class Multiplexer: 109 + """Frame-level state. Caller pumps incoming bytes with `feed`.""" 110 + 111 + def __init__( 112 + self, 113 + send_frame: Callable[[bytes], Awaitable[None]], 114 + handler: StreamHandler, 115 + *, 116 + is_listener: bool = True, 117 + ) -> None: 118 + """If `is_listener=True`, this side expects odd stream_ids from the peer.""" 119 + self._decoder = FrameDecoder() 120 + self._send_frame = send_frame 121 + self._handler = handler 122 + self._is_listener = is_listener 123 + self._streams: dict[int, _StreamState] = {} 124 + self._closed = False 125 + 126 + async def feed(self, plaintext: bytes) -> None: 127 + if not plaintext: 128 + return 129 + self._decoder.feed(plaintext) 130 + while True: 131 + try: 132 + frame = self._decoder.next() 133 + except ProtocolError: 134 + await self._reset_all(RESET_PROTOCOL_ERROR) 135 + return 136 + if frame is None: 137 + return 138 + await self._dispatch(frame) 139 + 140 + async def close(self) -> None: 141 + if self._closed: 142 + return 143 + self._closed = True 144 + for state in list(self._streams.values()): 145 + state.reader.feed_eof() 146 + state.writer_closed = True 147 + if state.task and not state.task.done(): 148 + state.task.cancel() 149 + self._streams.clear() 150 + 151 + async def _dispatch(self, frame: Frame) -> None: 152 + if frame.flags & FLAG_OPEN: 153 + if not self._valid_peer_stream_id(frame.stream_id): 154 + await self._emit(build_reset(frame.stream_id, RESET_PROTOCOL_ERROR)) 155 + return 156 + if frame.stream_id in self._streams: 157 + await self._emit(build_reset(frame.stream_id, RESET_PROTOCOL_ERROR)) 158 + return 159 + if len(self._streams) >= MAX_CONCURRENT_STREAMS: 160 + await self._emit(build_reset(frame.stream_id, RESET_STREAM_LIMIT_EXCEEDED)) 161 + return 162 + state = self._open_stream(frame.stream_id) 163 + if frame.payload: 164 + state.reader.feed_data(frame.payload) 165 + state.recv_credit -= len(frame.payload) 166 + if frame.flags & FLAG_CLOSE: 167 + state.reader.feed_eof() 168 + state.reader_closed = True 169 + return 170 + 171 + maybe_state = self._streams.get(frame.stream_id) 172 + if maybe_state is None: 173 + await self._emit(build_reset(frame.stream_id, RESET_PROTOCOL_ERROR)) 174 + return 175 + state = maybe_state 176 + 177 + if frame.flags & FLAG_DATA: 178 + if len(frame.payload) > state.recv_credit: 179 + await self._emit(build_reset(frame.stream_id, RESET_FLOW_CONTROL_ERROR)) 180 + self._terminate(state) 181 + return 182 + state.reader.feed_data(frame.payload) 183 + state.recv_credit -= len(frame.payload) 184 + state.unacked_recv += len(frame.payload) 185 + if state.unacked_recv >= INITIAL_WINDOW // 2: 186 + grant = state.unacked_recv 187 + state.recv_credit += grant 188 + state.unacked_recv = 0 189 + await self._emit(build_window(frame.stream_id, grant)) 190 + if frame.flags & FLAG_CLOSE: 191 + state.reader.feed_eof() 192 + state.reader_closed = True 193 + if state.writer_closed: 194 + self._forget(frame.stream_id) 195 + if frame.flags & FLAG_WINDOW: 196 + try: 197 + credit = parse_window_credit(frame) 198 + except ProtocolError: 199 + await self._emit(build_reset(frame.stream_id, RESET_PROTOCOL_ERROR)) 200 + self._terminate(state) 201 + return 202 + state.send_credit += credit 203 + state.credit_event.set() 204 + if frame.flags & FLAG_RESET: 205 + try: 206 + _ = parse_reset_reason(frame) 207 + except ProtocolError: 208 + pass 209 + state.reader.feed_eof() 210 + self._terminate(state) 211 + 212 + def _open_stream(self, stream_id: int) -> _StreamState: 213 + reader = asyncio.StreamReader() 214 + state = _StreamState(stream_id=stream_id, reader=reader) 215 + state.credit_event.set() 216 + self._streams[stream_id] = state 217 + writer = StreamWriter(self, state) 218 + 219 + async def runner() -> None: 220 + try: 221 + await self._handler(reader, writer) 222 + except Exception: 223 + await writer.reset(RESET_INTERNAL_ERROR) 224 + finally: 225 + if not state.writer_closed: 226 + try: 227 + await writer.close() 228 + except Exception: 229 + pass 230 + self._forget(stream_id) 231 + 232 + state.task = asyncio.create_task(runner(), name=f"link-stream-{stream_id}") 233 + return state 234 + 235 + def _terminate(self, state: _StreamState) -> None: 236 + state.writer_closed = True 237 + state.reader_closed = True 238 + if state.task and not state.task.done(): 239 + state.task.cancel() 240 + self._forget(state.stream_id) 241 + 242 + def _forget(self, stream_id: int) -> None: 243 + self._streams.pop(stream_id, None) 244 + 245 + def _valid_peer_stream_id(self, stream_id: int) -> bool: 246 + if stream_id == 0: 247 + return False 248 + return (stream_id % 2 == 1) if self._is_listener else (stream_id % 2 == 0) 249 + 250 + async def _emit(self, frame: Frame) -> None: 251 + if self._closed: 252 + return 253 + try: 254 + encoded = frame.encode() 255 + except ProtocolError: 256 + return 257 + await self._send_frame(encoded) 258 + 259 + async def _reset_all(self, reason: int) -> None: 260 + for state in list(self._streams.values()): 261 + await self._emit(build_reset(state.stream_id, reason)) 262 + self._terminate(state) 263 + 264 + async def open_stream( 265 + self, 266 + initial_payload: bytes = b"", 267 + ) -> tuple[asyncio.StreamReader, StreamWriter]: 268 + next_id = self._next_local_stream_id() 269 + reader = asyncio.StreamReader() 270 + state = _StreamState(stream_id=next_id, reader=reader) 271 + state.credit_event.set() 272 + self._streams[next_id] = state 273 + writer = StreamWriter(self, state) 274 + frame = build_open(next_id, initial_payload) 275 + if initial_payload: 276 + state.send_credit -= len(initial_payload) 277 + await self._emit(frame) 278 + return reader, writer 279 + 280 + def _next_local_stream_id(self) -> int: 281 + start = 2 if self._is_listener else 1 282 + cur = start 283 + while cur in self._streams: 284 + cur += 2 285 + if cur > 0xFFFFFFFF: 286 + raise RuntimeError("stream_id space exhausted") 287 + return cur
+176
think/link/nonces.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pair-nonce store — shared between the CLI pair flow and convey's pair route. 5 + 6 + `sol call link pair` mints a nonce and writes it to disk; convey's 7 + `POST /link/pair` reads on every incoming pair request, garbage-collects 8 + expired entries, and enforces single-use semantics. The file is the IPC 9 + channel between the two processes — simple, durable across crashes, no 10 + extra port. 11 + 12 + Consumers treat the file as opaque and call only into the methods here. 13 + Atomic replaces guard against partial writes. 14 + """ 15 + 16 + from __future__ import annotations 17 + 18 + import fcntl 19 + import json 20 + import os 21 + import time 22 + from dataclasses import dataclass 23 + from pathlib import Path 24 + 25 + NONCE_TTL_SECONDS = 300 # 5 min per the spl pairing spec. 26 + 27 + 28 + @dataclass(frozen=True) 29 + class Nonce: 30 + value: str 31 + device_label: str 32 + issued_at: int 33 + expires_at: int 34 + used: bool 35 + 36 + 37 + class NonceStore: 38 + def __init__(self, path: Path) -> None: 39 + self._path = path 40 + 41 + @property 42 + def path(self) -> Path: 43 + return self._path 44 + 45 + def add( 46 + self, 47 + nonce: str, 48 + device_label: str, 49 + *, 50 + now: int | None = None, 51 + ) -> Nonce: 52 + ts = now if now is not None else int(time.time()) 53 + entry = Nonce( 54 + value=nonce, 55 + device_label=device_label, 56 + issued_at=ts, 57 + expires_at=ts + NONCE_TTL_SECONDS, 58 + used=False, 59 + ) 60 + with self._locked_read_write() as entries: 61 + self._gc_locked(entries, ts) 62 + entries[nonce] = entry 63 + self._write_locked(entries) 64 + return entry 65 + 66 + def consume(self, value: str, *, now: int | None = None) -> Nonce | None: 67 + """Mark a nonce used if valid. Single-use enforced atomically.""" 68 + ts = now if now is not None else int(time.time()) 69 + with self._locked_read_write() as entries: 70 + self._gc_locked(entries, ts) 71 + entry = entries.get(value) 72 + if entry is None: 73 + return None 74 + if entry.used or entry.expires_at <= ts: 75 + return None 76 + entry = Nonce( 77 + value=entry.value, 78 + device_label=entry.device_label, 79 + issued_at=entry.issued_at, 80 + expires_at=entry.expires_at, 81 + used=True, 82 + ) 83 + entries[value] = entry 84 + self._write_locked(entries) 85 + return entry 86 + 87 + def peek(self, value: str) -> Nonce | None: 88 + entries = self._read() 89 + return entries.get(value) 90 + 91 + def snapshot(self) -> list[Nonce]: 92 + return list(self._read().values()) 93 + 94 + def gc(self, *, now: int | None = None) -> int: 95 + """Remove expired entries. Returns count removed.""" 96 + ts = now if now is not None else int(time.time()) 97 + with self._locked_read_write() as entries: 98 + before = len(entries) 99 + self._gc_locked(entries, ts) 100 + if len(entries) != before: 101 + self._write_locked(entries) 102 + return before - len(entries) 103 + 104 + def _read(self) -> dict[str, Nonce]: 105 + if not self._path.exists(): 106 + return {} 107 + try: 108 + raw = json.loads(self._path.read_text("utf-8")) 109 + except (json.JSONDecodeError, OSError): 110 + return {} 111 + out: dict[str, Nonce] = {} 112 + if isinstance(raw, list): 113 + for item in raw: 114 + if not isinstance(item, dict): 115 + continue 116 + val = item.get("value") 117 + if not isinstance(val, str): 118 + continue 119 + out[val] = Nonce( 120 + value=val, 121 + device_label=str(item.get("device_label", "")), 122 + issued_at=int(item.get("issued_at", 0)), 123 + expires_at=int(item.get("expires_at", 0)), 124 + used=bool(item.get("used", False)), 125 + ) 126 + return out 127 + 128 + def _write_locked(self, entries: dict[str, Nonce]) -> None: 129 + self._path.parent.mkdir(parents=True, exist_ok=True) 130 + payload = [ 131 + { 132 + "value": e.value, 133 + "device_label": e.device_label, 134 + "issued_at": e.issued_at, 135 + "expires_at": e.expires_at, 136 + "used": e.used, 137 + } 138 + for e in entries.values() 139 + ] 140 + tmp = self._path.with_suffix(self._path.suffix + ".tmp") 141 + with open(tmp, "w", encoding="utf-8") as f: 142 + json.dump(payload, f, indent=2) 143 + f.write("\n") 144 + f.flush() 145 + os.fsync(f.fileno()) 146 + os.replace(tmp, self._path) 147 + 148 + def _gc_locked(self, entries: dict[str, Nonce], now: int) -> None: 149 + to_drop = [k for k, e in entries.items() if e.used or e.expires_at <= now] 150 + for k in to_drop: 151 + del entries[k] 152 + 153 + class _Guard: 154 + def __init__(self, store: NonceStore) -> None: 155 + self.store = store 156 + self.lock_path = store._path.with_suffix(store._path.suffix + ".lock") 157 + self.fd = -1 158 + 159 + def __enter__(self) -> dict[str, Nonce]: 160 + self.lock_path.parent.mkdir(parents=True, exist_ok=True) 161 + self.fd = os.open( 162 + str(self.lock_path), 163 + os.O_RDWR | os.O_CREAT, 164 + 0o600, 165 + ) 166 + fcntl.flock(self.fd, fcntl.LOCK_EX) 167 + return self.store._read() 168 + 169 + def __exit__(self, *_: object) -> None: 170 + try: 171 + fcntl.flock(self.fd, fcntl.LOCK_UN) 172 + finally: 173 + os.close(self.fd) 174 + 175 + def _locked_read_write(self) -> NonceStore._Guard: 176 + return NonceStore._Guard(self)
+164
think/link/paths.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """journal/link/ path resolution + service state I/O. 5 + 6 + All link-service state lives under `journal/link/`: 7 + 8 + journal/link/ 9 + ca/ 10 + cert.pem world-readable local CA cert 11 + private.pem mode 0600 — filesystem-perms-only protection 12 + authorized_clients.json paired-device ledger (mtime-reloaded) 13 + tokens/ 14 + account.json cached account_token from /enroll/home 15 + nonces.json pair-ceremony nonces (5-min TTL, single-use) 16 + state.json instance_id + home_label (generated on first run) 17 + 18 + `journal/link/` is a narrow exception to the "memories live in 19 + day/stream/segment/" rule — this is config, not memory, scoped to this 20 + one service (see cpo/strategy/journal-memory-structure.md). 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + import json 26 + import os 27 + import uuid 28 + from dataclasses import dataclass 29 + from pathlib import Path 30 + 31 + from think.utils import get_journal 32 + 33 + # Production spl-relay endpoint. Single source of truth — self-hosters 34 + # override via SOL_LINK_RELAY_URL env var. When CTO wires 35 + # spl.solpbc.org as DNS front, update this constant. 36 + DEFAULT_RELAY_URL = "https://spl-relay-staging.jer-3f2.workers.dev" 37 + 38 + 39 + def link_root() -> Path: 40 + """`journal/link/` — auto-created.""" 41 + root = Path(get_journal()) / "link" 42 + root.mkdir(parents=True, exist_ok=True) 43 + return root 44 + 45 + 46 + def ca_dir() -> Path: 47 + d = link_root() / "ca" 48 + d.mkdir(parents=True, exist_ok=True) 49 + return d 50 + 51 + 52 + def authorized_clients_path() -> Path: 53 + return link_root() / "authorized_clients.json" 54 + 55 + 56 + def tokens_dir() -> Path: 57 + d = link_root() / "tokens" 58 + d.mkdir(parents=True, exist_ok=True) 59 + return d 60 + 61 + 62 + def account_token_path() -> Path: 63 + return tokens_dir() / "account.json" 64 + 65 + 66 + def nonces_path() -> Path: 67 + return link_root() / "nonces.json" 68 + 69 + 70 + def state_path() -> Path: 71 + return link_root() / "state.json" 72 + 73 + 74 + def relay_url() -> str: 75 + """Resolve the spl-relay endpoint. 76 + 77 + Precedence: SOL_LINK_RELAY_URL env var > journal config `link.relay_url` > 78 + DEFAULT_RELAY_URL constant. Self-hosters override one-field; production 79 + users get the default. 80 + """ 81 + env = os.environ.get("SOL_LINK_RELAY_URL", "").strip() 82 + if env: 83 + return env.rstrip("/") 84 + try: 85 + from think.utils import get_config 86 + 87 + cfg = get_config() 88 + link_cfg = cfg.get("link") if isinstance(cfg, dict) else None 89 + if isinstance(link_cfg, dict): 90 + url = link_cfg.get("relay_url") 91 + if isinstance(url, str) and url.strip(): 92 + return url.strip().rstrip("/") 93 + except Exception: 94 + pass 95 + return DEFAULT_RELAY_URL 96 + 97 + 98 + @dataclass 99 + class LinkState: 100 + """Service identity — the values spl-relay binds an account_token to. 101 + 102 + Persisted to `journal/link/state.json`; generated on first run. 103 + """ 104 + 105 + instance_id: str 106 + home_label: str 107 + 108 + @classmethod 109 + def load_or_create(cls, *, default_label: str = "solstone") -> LinkState: 110 + path = state_path() 111 + if path.exists(): 112 + try: 113 + raw = json.loads(path.read_text("utf-8")) 114 + iid = raw.get("instance_id") 115 + label = raw.get("home_label") or default_label 116 + if isinstance(iid, str) and iid: 117 + return cls(instance_id=iid, home_label=label) 118 + except (json.JSONDecodeError, OSError): 119 + pass 120 + state = cls(instance_id=str(uuid.uuid4()), home_label=default_label) 121 + state.save() 122 + return state 123 + 124 + def save(self) -> None: 125 + path = state_path() 126 + path.parent.mkdir(parents=True, exist_ok=True) 127 + tmp = path.with_suffix(".json.tmp") 128 + with open(tmp, "w", encoding="utf-8") as f: 129 + json.dump( 130 + {"instance_id": self.instance_id, "home_label": self.home_label}, 131 + f, 132 + indent=2, 133 + ) 134 + f.write("\n") 135 + f.flush() 136 + os.fsync(f.fileno()) 137 + os.replace(tmp, path) 138 + 139 + 140 + def load_account_token() -> str | None: 141 + """Read the cached /enroll/home account token, or None.""" 142 + path = account_token_path() 143 + if not path.exists(): 144 + return None 145 + try: 146 + raw = json.loads(path.read_text("utf-8")) 147 + token = raw.get("account_token") 148 + return token if isinstance(token, str) and token else None 149 + except (json.JSONDecodeError, OSError): 150 + return None 151 + 152 + 153 + def save_account_token(token: str) -> None: 154 + """Persist the account token atomically with mode 0600.""" 155 + path = account_token_path() 156 + path.parent.mkdir(parents=True, exist_ok=True) 157 + tmp = path.with_suffix(".json.tmp") 158 + with open(tmp, "w", encoding="utf-8") as f: 159 + json.dump({"account_token": token}, f, indent=2) 160 + f.write("\n") 161 + f.flush() 162 + os.fsync(f.fileno()) 163 + os.chmod(tmp, 0o600) 164 + os.replace(tmp, path)
+390
think/link/relay_client.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Listen WS + tunnel WS orchestrator. 5 + 6 + On startup: 7 + 1. If no account_token stored, POST /enroll/home to mint one (idempotent). 8 + 2. Open listen WS to spl-relay with the account token. 9 + 3. Loop: wait for {"type":"incoming","tunnel_id":...} control messages. 10 + On each signal, spawn a tunnel task that opens /tunnel/<id>, drives 11 + pyOpenSSL TLS 1.3 in memory-BIO mode, and hands the plaintext byte 12 + stream to the multiplexer + WSGI bridge. 13 + 4. On disconnect, reconnect with exponential backoff (1s → 60s, ±25%). 14 + 15 + All WebSocket I/O uses the `websockets` library in asyncio mode. The TLS 16 + state machine runs inline on the event loop — each tunnel is a dedicated 17 + task pumping bytes between the WS and the TLS engine. 18 + 19 + Privacy invariant: NO payload bytes ever appear in logs. Only rendezvous 20 + metadata (tunnel_id, stream_id, byte_count, status code, duration) is 21 + eligible for logging, and everything emitted to callosum is the same 22 + rendezvous-only subset. 23 + """ 24 + 25 + from __future__ import annotations 26 + 27 + import asyncio 28 + import contextlib 29 + import json 30 + import logging 31 + import random 32 + import ssl 33 + import urllib.parse 34 + from typing import Any, Callable 35 + 36 + import websockets 37 + from websockets.asyncio.client import ClientConnection as _WsConnection 38 + from websockets.exceptions import ConnectionClosed 39 + 40 + from .auth import AuthorizedClients 41 + from .ca import LoadedCa 42 + from .mux import Multiplexer, StreamWriter 43 + from .tls_adapter import ( 44 + TlsError, 45 + build_server_context, 46 + drive_tls, 47 + issue_server_cert, 48 + new_server, 49 + ) 50 + from .wsgi_bridge import serve_request 51 + 52 + log = logging.getLogger("link.relay_client") 53 + 54 + _RECONNECT_MIN = 1.0 55 + _RECONNECT_MAX = 60.0 56 + 57 + # Callosum event emitter signature — the service layer injects a closure 58 + # that forwards to the supervisor's callosum connection. None in tests. 59 + CallosumEmit = Callable[[str, dict[str, Any]], None] 60 + 61 + 62 + class RelayClient: 63 + def __init__( 64 + self, 65 + *, 66 + instance_id: str, 67 + home_label: str, 68 + relay_endpoint: str, 69 + account_token: str | None, 70 + on_account_token: Callable[[str], None], 71 + ca: LoadedCa, 72 + authorized: AuthorizedClients, 73 + wsgi_app: Callable[..., Any], 74 + callosum_emit: CallosumEmit | None = None, 75 + ) -> None: 76 + self._instance_id = instance_id 77 + self._home_label = home_label 78 + self._relay_endpoint = relay_endpoint.rstrip("/") 79 + self._account_token = account_token 80 + self._on_account_token = on_account_token 81 + self._ca = ca 82 + self._authorized = authorized 83 + self._wsgi_app = wsgi_app 84 + self._emit = callosum_emit or (lambda _event, _fields: None) 85 + self._running = False 86 + self._listen_state = "offline" 87 + self._tunnels: dict[str, asyncio.Task[None]] = {} 88 + 89 + server_cert, server_key_pem = issue_server_cert( 90 + ca, 91 + common_name=f"solstone link ({home_label})", 92 + ) 93 + self._tls_ctx = build_server_context( 94 + ca=ca, 95 + server_cert=server_cert, 96 + server_key=server_key_pem, 97 + authorized=authorized, 98 + ) 99 + 100 + @property 101 + def listen_state(self) -> str: 102 + """One of: offline, connecting, online, reconnecting, not-enrolled.""" 103 + return self._listen_state 104 + 105 + @property 106 + def account_token(self) -> str | None: 107 + return self._account_token 108 + 109 + def active_tunnel_count(self) -> int: 110 + return sum(1 for t in self._tunnels.values() if not t.done()) 111 + 112 + async def enroll_if_needed(self) -> None: 113 + if self._account_token: 114 + return 115 + endpoint = f"{self._relay_endpoint}/enroll/home" 116 + body = { 117 + "instance_id": self._instance_id, 118 + "ca_pubkey": self._ca.pubkey_spki_pem, 119 + "home_label": self._home_label, 120 + } 121 + log.info("enrolling home with relay at %s", endpoint) 122 + result = await _post_json(endpoint, body) 123 + token = result.get("account_token") 124 + if not isinstance(token, str) or not token: 125 + raise RuntimeError("relay returned no account_token") 126 + self._account_token = token 127 + self._on_account_token(token) 128 + self._emit("enrolled", {"instance_id": self._instance_id}) 129 + 130 + async def run(self) -> None: 131 + self._running = True 132 + delay = _RECONNECT_MIN 133 + while self._running: 134 + try: 135 + await self._run_once() 136 + delay = _RECONNECT_MIN 137 + except ConnectionClosed as exc: 138 + log.warning("listen WS closed: code=%s reason=%s", exc.code, exc.reason) 139 + except Exception as exc: # noqa: BLE001 140 + log.exception("listen loop error: %s", exc) 141 + if not self._running: 142 + return 143 + self._listen_state = "reconnecting" 144 + self._emit("disconnect", {}) 145 + jitter = delay * 0.25 146 + wait = delay + random.uniform(-jitter, jitter) # noqa: S311 147 + log.info("reconnecting in %.1fs", wait) 148 + await asyncio.sleep(wait) 149 + delay = min(_RECONNECT_MAX, delay * 2.0) 150 + self._listen_state = "offline" 151 + 152 + async def stop(self) -> None: 153 + self._running = False 154 + self._listen_state = "offline" 155 + for task in list(self._tunnels.values()): 156 + task.cancel() 157 + if self._tunnels: 158 + await asyncio.gather( 159 + *( 160 + asyncio.shield(t) 161 + for t in self._tunnels.values() 162 + ), 163 + return_exceptions=True, 164 + ) 165 + self._tunnels.clear() 166 + 167 + async def _run_once(self) -> None: 168 + await self.enroll_if_needed() 169 + self._listen_state = "connecting" 170 + self._emit("connecting", {}) 171 + assert self._account_token is not None 172 + listen_url = self._url_for("/session/listen", token=self._account_token) 173 + headers = _auth_header(self._account_token) 174 + log.info("opening listen WS to %s", _redact(listen_url)) 175 + async with websockets.connect( 176 + listen_url, 177 + additional_headers=headers, 178 + max_size=None, 179 + ) as ws: 180 + self._listen_state = "online" 181 + self._emit("connected", {}) 182 + log.info("listen WS open — waiting for incoming") 183 + async for message in ws: 184 + data = _parse_control(message) 185 + if data is None: 186 + continue 187 + tunnel_id = data.get("tunnel_id") 188 + if data.get("type") == "incoming" and isinstance(tunnel_id, str): 189 + log.info("incoming tunnel_id=%s", tunnel_id) 190 + self._emit("tunnel_pair", {"tunnel_id": tunnel_id}) 191 + task = asyncio.create_task( 192 + self._handle_tunnel(tunnel_id), 193 + name=f"link-tunnel-{tunnel_id}", 194 + ) 195 + self._tunnels[tunnel_id] = task 196 + task.add_done_callback(lambda _t, tid=tunnel_id: self._tunnels.pop(tid, None)) 197 + 198 + async def _handle_tunnel(self, tunnel_id: str) -> None: 199 + assert self._account_token is not None 200 + url = self._url_for(f"/tunnel/{tunnel_id}", token=self._account_token) 201 + headers = _auth_header(self._account_token) 202 + try: 203 + async with websockets.connect( 204 + url, 205 + additional_headers=headers, 206 + max_size=None, 207 + ) as ws: 208 + await self._pump_tunnel(ws, tunnel_id) 209 + except ConnectionClosed as exc: 210 + log.info("tunnel %s closed: code=%s reason=%s", tunnel_id, exc.code, exc.reason) 211 + except TlsError as exc: 212 + log.info("tunnel %s TLS rejected: %s", tunnel_id, exc) 213 + except Exception as exc: # noqa: BLE001 214 + log.exception("tunnel %s error: %s", tunnel_id, exc) 215 + finally: 216 + self._emit("tunnel_close", {"tunnel_id": tunnel_id}) 217 + 218 + async def _pump_tunnel(self, ws: _WsConnection, tunnel_id: str) -> None: 219 + tls = new_server(self._tls_ctx) 220 + send_queue: asyncio.Queue[bytes] = asyncio.Queue() 221 + fingerprint_touched = False 222 + 223 + async def send_frame(frame: bytes) -> None: 224 + send_queue.put_nowait(frame) 225 + 226 + async def handle_stream( 227 + reader: asyncio.StreamReader, 228 + writer: StreamWriter, 229 + ) -> None: 230 + meta = await serve_request( 231 + reader, 232 + writer, 233 + self._wsgi_app, 234 + peer_fingerprint=tls.peer_fingerprint, 235 + tunnel_id=tunnel_id, 236 + ) 237 + await writer.close() 238 + log.debug( 239 + "tunnel %s exchange: method=%s path=%s status=%s in=%s out=%s", 240 + tunnel_id, 241 + meta.method, 242 + meta.path, 243 + meta.status, 244 + meta.request_bytes, 245 + meta.response_bytes, 246 + ) 247 + 248 + mux = Multiplexer(send_frame, handle_stream, is_listener=True) 249 + 250 + async def ws_reader() -> None: 251 + nonlocal fingerprint_touched 252 + try: 253 + async for frame in ws: 254 + inbound = frame if isinstance(frame, bytes) else frame.encode("utf-8") 255 + outbound, plaintext = drive_tls(tls, inbound=inbound) 256 + if outbound: 257 + await ws.send(outbound) 258 + if plaintext: 259 + await mux.feed(plaintext) 260 + if ( 261 + not fingerprint_touched 262 + and tls.handshake_done 263 + and tls.peer_fingerprint 264 + ): 265 + fingerprint_touched = True 266 + self._authorized.touch_last_seen(tls.peer_fingerprint) 267 + self._emit( 268 + "last_seen", 269 + { 270 + "fingerprint": tls.peer_fingerprint, 271 + "tunnel_id": tunnel_id, 272 + }, 273 + ) 274 + await _drain_send_queue(tls, ws, send_queue) 275 + except ConnectionClosed: 276 + return 277 + 278 + async def app_writer() -> None: 279 + try: 280 + while True: 281 + data = await send_queue.get() 282 + outbound = _encrypt(tls, data) 283 + if outbound: 284 + await ws.send(outbound) 285 + except ConnectionClosed: 286 + return 287 + 288 + reader_task = asyncio.create_task(ws_reader(), name=f"ws-reader-{tunnel_id}") 289 + writer_task = asyncio.create_task(app_writer(), name=f"app-writer-{tunnel_id}") 290 + try: 291 + await reader_task 292 + finally: 293 + writer_task.cancel() 294 + with contextlib.suppress(asyncio.CancelledError): 295 + await writer_task 296 + await mux.close() 297 + 298 + def _url_for(self, path: str, *, token: str | None = None) -> str: 299 + base = _to_ws(self._relay_endpoint) + path 300 + q = {"instance": self._instance_id} 301 + if token: 302 + q["token"] = token 303 + return base + "?" + urllib.parse.urlencode(q) 304 + 305 + 306 + def _encrypt(tls: Any, plaintext: bytes) -> bytes: 307 + outbound, _ = drive_tls(tls, inbound=b"", plaintext_out=plaintext) 308 + return outbound 309 + 310 + 311 + async def _drain_send_queue( 312 + tls: Any, 313 + ws: _WsConnection, 314 + queue: asyncio.Queue[bytes], 315 + ) -> None: 316 + drained: list[bytes] = [] 317 + while not queue.empty(): 318 + try: 319 + drained.append(queue.get_nowait()) 320 + except asyncio.QueueEmpty: 321 + break 322 + if not drained: 323 + return 324 + for chunk in drained: 325 + outbound = _encrypt(tls, chunk) 326 + if outbound: 327 + await ws.send(outbound) 328 + 329 + 330 + async def _post_json(url: str, body: dict[str, Any]) -> dict[str, Any]: 331 + import urllib.request 332 + 333 + def sync() -> dict[str, Any]: 334 + data = json.dumps(body).encode("utf-8") 335 + req = urllib.request.Request( # noqa: S310 — URL scheme validated below 336 + url, 337 + data=data, 338 + headers={ 339 + "content-type": "application/json", 340 + "user-agent": "solstone-link/0.1", 341 + }, 342 + method="POST", 343 + ) 344 + ctx = ssl.create_default_context() 345 + if url.startswith("http://"): 346 + ctx = None # type: ignore[assignment] # local dev 347 + elif not url.startswith("https://"): 348 + raise ValueError(f"unsupported url scheme: {url!r}") 349 + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: # noqa: S310 350 + payload = resp.read() 351 + parsed: dict[str, Any] = json.loads(payload) 352 + return parsed 353 + 354 + return await asyncio.to_thread(sync) 355 + 356 + 357 + def _to_ws(endpoint: str) -> str: 358 + if endpoint.startswith("http://"): 359 + return "ws://" + endpoint[len("http://") :] 360 + if endpoint.startswith("https://"): 361 + return "wss://" + endpoint[len("https://") :] 362 + return endpoint 363 + 364 + 365 + def _parse_control(message: str | bytes) -> dict[str, Any] | None: 366 + if isinstance(message, bytes): 367 + try: 368 + text = message.decode("utf-8") 369 + except UnicodeDecodeError: 370 + return None 371 + else: 372 + text = message 373 + try: 374 + out = json.loads(text) 375 + except json.JSONDecodeError: 376 + return None 377 + return out if isinstance(out, dict) else None 378 + 379 + 380 + def _auth_header(token: str | None) -> dict[str, str]: 381 + return {"Authorization": f"Bearer {token}"} if token else {} 382 + 383 + 384 + def _redact(url: str) -> str: 385 + parsed = urllib.parse.urlparse(url) 386 + q = urllib.parse.parse_qs(parsed.query) 387 + if "token" in q: 388 + q["token"] = ["<redacted>"] 389 + new_query = urllib.parse.urlencode(q, doseq=True) 390 + return urllib.parse.urlunparse(parsed._replace(query=new_query))
+145
think/link/service.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """link service runtime. 5 + 6 + Registered with solstone's supervisor via `sol.py` COMMANDS (see `sol link`); 7 + the supervisor launches this as a subprocess alongside callosum, cortex, 8 + convey, etc. Service lifecycle: 9 + 10 + start → load state + CA → ensure account_token (enroll once) → 11 + open listen WS to spl-relay → accept tunnel pairs → pump bytes through 12 + TLS → convey WSGI. On disconnect, reconnect with exponential backoff. 13 + 14 + Exits on SIGINT/SIGTERM with a clean close of the listen WS and all 15 + in-flight tunnel WSes. 16 + 17 + Callosum events are emitted on the `link` tract: 18 + enrolled first-run account-token mint 19 + connecting opening listen WS 20 + connected listen WS open (service is reachable) 21 + disconnect listen WS closed (about to reconnect) 22 + tunnel_pair incoming tunnel (paired device dialed in) 23 + tunnel_close tunnel closed 24 + last_seen paired fingerprint completed TLS handshake 25 + """ 26 + 27 + from __future__ import annotations 28 + 29 + import asyncio 30 + import logging 31 + import signal 32 + from typing import Any 33 + 34 + from think.callosum import CallosumConnection 35 + 36 + from .auth import AuthorizedClients 37 + from .ca import load_or_generate_ca 38 + from .paths import ( 39 + LinkState, 40 + authorized_clients_path, 41 + ca_dir, 42 + load_account_token, 43 + relay_url, 44 + save_account_token, 45 + ) 46 + from .relay_client import RelayClient 47 + 48 + log = logging.getLogger("link.service") 49 + 50 + 51 + async def run_service() -> None: 52 + """Build the RelayClient and run it until signaled.""" 53 + state = LinkState.load_or_create() 54 + ca = load_or_generate_ca(ca_dir()) 55 + authorized = AuthorizedClients(authorized_clients_path()) 56 + token = load_account_token() 57 + 58 + wsgi_app = _build_convey_wsgi() 59 + 60 + callosum = CallosumConnection() 61 + callosum.start() 62 + 63 + def emit(event: str, fields: dict[str, Any]) -> None: 64 + try: 65 + callosum.emit("link", event, **fields) 66 + except Exception: 67 + log.debug("callosum emit failed", exc_info=True) 68 + 69 + client = RelayClient( 70 + instance_id=state.instance_id, 71 + home_label=state.home_label, 72 + relay_endpoint=relay_url(), 73 + account_token=token, 74 + on_account_token=save_account_token, 75 + ca=ca, 76 + authorized=authorized, 77 + wsgi_app=wsgi_app, 78 + callosum_emit=emit, 79 + ) 80 + 81 + stop_event = asyncio.Event() 82 + loop = asyncio.get_running_loop() 83 + for sig in (signal.SIGINT, signal.SIGTERM): 84 + with _suppress_not_implemented(): 85 + loop.add_signal_handler(sig, stop_event.set) 86 + 87 + run_task = asyncio.create_task(client.run(), name="link-relay-client") 88 + try: 89 + await stop_event.wait() 90 + finally: 91 + log.info("link service stopping") 92 + await client.stop() 93 + run_task.cancel() 94 + try: 95 + await run_task 96 + except asyncio.CancelledError: 97 + pass 98 + callosum.stop() 99 + 100 + 101 + def _build_convey_wsgi() -> Any: 102 + """Return convey's Flask app as a WSGI callable. 103 + 104 + Imported lazily so `sol call link status` (and other dry reads) don't 105 + pay the convey import cost. The returned object is the Flask app — 106 + calling it as `app(environ, start_response)` invokes its WSGI entry. 107 + """ 108 + from convey import create_app 109 + 110 + return create_app() 111 + 112 + 113 + class _suppress_not_implemented: 114 + """Context manager that swallows NotImplementedError for Windows/TTYs.""" 115 + 116 + def __enter__(self) -> None: 117 + return None 118 + 119 + def __exit__(self, exc_type: Any, _exc: Any, _tb: Any) -> bool: 120 + return exc_type is NotImplementedError 121 + 122 + 123 + def main() -> None: 124 + """CLI entry point for `sol link` — starts the service.""" 125 + import argparse 126 + 127 + from think.utils import require_solstone, setup_cli 128 + 129 + parser = argparse.ArgumentParser(description="solstone link tunnel service") 130 + args = setup_cli(parser) 131 + require_solstone() 132 + 133 + logging.basicConfig( 134 + level=logging.INFO if not args.verbose else logging.DEBUG, 135 + format="%(asctime)s %(name)s %(levelname)s %(message)s", 136 + ) 137 + 138 + try: 139 + asyncio.run(run_service()) 140 + except KeyboardInterrupt: 141 + log.info("link service interrupted") 142 + 143 + 144 + if __name__ == "__main__": 145 + main()
+199
think/link/tls_adapter.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """pyOpenSSL memory-BIO adapter: TLS 1.3 over non-socket byte streams. 5 + 6 + The link service runs TLS inside the spl-relay tunnel, which is an opaque 7 + WebSocket — not a real socket. pyOpenSSL's `SSL.Connection` supports 8 + memory-BIO mode: the caller pushes ciphertext in with `bio_write`, pulls 9 + ciphertext out with `bio_read`, and reads/writes plaintext with 10 + `recv`/`send`. 11 + 12 + This module wraps that state machine with a byte-oriented API the 13 + relay-client and mux loops drive. It installs the pinned verify callback — 14 + the load-bearing reason we use pyOpenSSL and not stdlib `ssl` (stdlib 15 + doesn't expose a handshake-time callback that can reject a cert with a 16 + clean TLS alert). 17 + """ 18 + 19 + from __future__ import annotations 20 + 21 + from dataclasses import dataclass 22 + 23 + from cryptography import x509 24 + from cryptography.hazmat.primitives import serialization 25 + from OpenSSL import SSL, crypto 26 + 27 + from .auth import AuthorizedClients 28 + from .ca import LoadedCa 29 + 30 + 31 + class TlsError(RuntimeError): 32 + """Raised when the TLS handshake is aborted (e.g., fingerprint rejected).""" 33 + 34 + 35 + @dataclass 36 + class TlsServerState: 37 + conn: SSL.Connection 38 + handshake_done: bool = False 39 + peer_fingerprint: str | None = None 40 + 41 + 42 + def build_server_context( 43 + ca: LoadedCa, 44 + server_cert: x509.Certificate, 45 + server_key: bytes, 46 + authorized: AuthorizedClients, 47 + ) -> SSL.Context: 48 + """Build a TLS 1.3 server context with the pinned verify callback.""" 49 + ctx = SSL.Context(SSL.TLS_METHOD) 50 + ctx.set_min_proto_version(SSL.TLS1_3_VERSION) 51 + ctx.set_max_proto_version(SSL.TLS1_3_VERSION) 52 + ctx.use_certificate( 53 + crypto.X509.from_cryptography(server_cert), 54 + ) 55 + ctx.use_privatekey(crypto.load_privatekey(crypto.FILETYPE_PEM, server_key)) 56 + ctx.add_extra_chain_cert(crypto.X509.from_cryptography(ca.cert)) 57 + store = ctx.get_cert_store() 58 + assert store is not None, "pyOpenSSL context must expose a cert store" 59 + store.add_cert(crypto.X509.from_cryptography(ca.cert)) 60 + 61 + def verify_cb( 62 + _conn: SSL.Connection, 63 + cert: crypto.X509, 64 + _errno: int, 65 + depth: int, 66 + preverify_ok: int, 67 + ) -> bool: 68 + if not preverify_ok: 69 + return False 70 + if depth != 0: 71 + return True 72 + der = cert.to_cryptography().public_bytes(serialization.Encoding.DER) 73 + import hashlib 74 + 75 + fp = f"sha256:{hashlib.sha256(der).hexdigest()}" 76 + return authorized.is_authorized(fp) 77 + 78 + ctx.set_verify( 79 + SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, 80 + verify_cb, 81 + ) 82 + return ctx 83 + 84 + 85 + def new_server(ctx: SSL.Context) -> TlsServerState: 86 + """Fresh memory-BIO connection in accept state.""" 87 + conn = SSL.Connection(ctx, None) 88 + conn.set_accept_state() 89 + return TlsServerState(conn=conn) 90 + 91 + 92 + def drive_tls( 93 + state: TlsServerState, 94 + *, 95 + inbound: bytes, 96 + plaintext_out: bytes = b"", 97 + ) -> tuple[bytes, bytes]: 98 + """Push ciphertext in + plaintext out; return (ciphertext_to_send, plaintext_received).""" 99 + if inbound: 100 + state.conn.bio_write(inbound) 101 + if plaintext_out: 102 + try: 103 + state.conn.send(plaintext_out) 104 + except SSL.WantReadError: 105 + pass 106 + 107 + if not state.handshake_done: 108 + try: 109 + state.conn.do_handshake() 110 + state.handshake_done = True 111 + peer = state.conn.get_peer_certificate() 112 + if peer is not None: 113 + import hashlib 114 + 115 + der = peer.to_cryptography().public_bytes( 116 + serialization.Encoding.DER, 117 + ) 118 + state.peer_fingerprint = f"sha256:{hashlib.sha256(der).hexdigest()}" 119 + except SSL.WantReadError: 120 + pass 121 + except SSL.Error as exc: 122 + raise TlsError(f"handshake failed: {exc}") from exc 123 + 124 + plaintext_in = bytearray() 125 + if state.handshake_done: 126 + while True: 127 + try: 128 + chunk = state.conn.recv(16 * 1024) 129 + except SSL.WantReadError: 130 + break 131 + except SSL.ZeroReturnError: 132 + break 133 + if not chunk: 134 + break 135 + plaintext_in.extend(chunk) 136 + 137 + outbound = bytearray() 138 + while True: 139 + try: 140 + chunk = state.conn.bio_read(16 * 1024) 141 + except SSL.WantReadError: 142 + break 143 + if not chunk: 144 + break 145 + outbound.extend(chunk) 146 + return bytes(outbound), bytes(plaintext_in) 147 + 148 + 149 + def issue_server_cert( 150 + ca: LoadedCa, 151 + common_name: str = "solstone link", 152 + ) -> tuple[x509.Certificate, bytes]: 153 + """Mint a server cert (signed by the CA) + its PEM-encoded private key. 154 + 155 + Regenerated on each start — server-side TLS material doesn't need to 156 + survive restarts since the mobile pins the *CA* fingerprint, not the 157 + server cert. 158 + """ 159 + import datetime as dt 160 + 161 + from cryptography.hazmat.primitives import hashes 162 + from cryptography.hazmat.primitives.asymmetric import ec 163 + from cryptography.x509.oid import NameOID 164 + 165 + key = ec.generate_private_key(ec.SECP256R1()) 166 + now = dt.datetime.now(dt.UTC) 167 + cert = ( 168 + x509.CertificateBuilder() 169 + .subject_name( 170 + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]), 171 + ) 172 + .issuer_name(ca.cert.subject) 173 + .public_key(key.public_key()) 174 + .serial_number(x509.random_serial_number()) 175 + .not_valid_before(now - dt.timedelta(minutes=5)) 176 + .not_valid_after(now + dt.timedelta(days=30)) 177 + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) 178 + .add_extension( 179 + x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), 180 + critical=False, 181 + ) 182 + .sign(ca.private_key, hashes.SHA256()) 183 + ) 184 + key_pem = key.private_bytes( 185 + serialization.Encoding.PEM, 186 + serialization.PrivateFormat.PKCS8, 187 + serialization.NoEncryption(), 188 + ) 189 + return cert, key_pem 190 + 191 + 192 + __all__ = [ 193 + "TlsError", 194 + "TlsServerState", 195 + "build_server_context", 196 + "drive_tls", 197 + "issue_server_cert", 198 + "new_server", 199 + ]
+350
think/link/wsgi_bridge.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """HTTP/1.1 ⇄ WSGI bridge for the link tunnel. 5 + 6 + Each tunnel stream is one HTTP request/response exchange (no keep-alive). 7 + This module: 8 + 9 + 1. Parses an HTTP/1.1 request from the per-stream byte reader 10 + 2. Builds a WSGI environ dict 11 + 3. Invokes convey's real Flask app 12 + 4. Writes the response (status + headers + body) back to the stream writer 13 + 14 + Streaming responses (SSE, chunked) are supported — the WSGI iterable is 15 + pumped chunk-by-chunk so the client sees events as the app produces them. 16 + 17 + Request-body size is capped at 64 MiB. Privacy invariant: NO request or 18 + response bytes are ever logged. Only error counts, byte totals, and 19 + status codes are eligible for logging (see `metadata`), never payloads. 20 + """ 21 + 22 + from __future__ import annotations 23 + 24 + import asyncio 25 + import io 26 + import logging 27 + import urllib.parse 28 + from dataclasses import dataclass 29 + from typing import Any, Callable 30 + 31 + log = logging.getLogger("link.wsgi") 32 + 33 + MAX_REQUEST_BODY = 64 * 1024 * 1024 34 + WSGI_SERVER_NAME = "solstone-link" 35 + 36 + 37 + @dataclass 38 + class ExchangeMetadata: 39 + """Per-request rendezvous metadata — safe to log (no payload).""" 40 + 41 + method: str 42 + path: str 43 + status: int | None = None 44 + request_bytes: int = 0 45 + response_bytes: int = 0 46 + stream_id: int | None = None 47 + 48 + 49 + async def serve_request( 50 + reader: asyncio.StreamReader, 51 + writer: Any, # think.link.mux.StreamWriter 52 + wsgi_app: Callable[[dict[str, Any], Callable[..., Any]], Any], 53 + *, 54 + peer_fingerprint: str | None = None, 55 + tunnel_id: str | None = None, 56 + stream_id: int | None = None, 57 + ) -> ExchangeMetadata: 58 + """Read one HTTP/1.1 request; dispatch to `wsgi_app`; write response. 59 + 60 + Returns rendezvous metadata describing what happened (for callosum 61 + events + debug logs). Never returns payload bytes. 62 + """ 63 + meta = ExchangeMetadata(method="-", path="-", stream_id=stream_id) 64 + try: 65 + request = await _read_request(reader) 66 + except _BadRequest as exc: 67 + log.debug("tunnel %s stream %s: bad request: %s", tunnel_id, stream_id, exc) 68 + await _write_simple(writer, 400, "bad request", b"bad request\n") 69 + meta.status = 400 70 + meta.response_bytes = _byte_count_for_simple(b"bad request\n") 71 + return meta 72 + except asyncio.IncompleteReadError: 73 + log.debug("tunnel %s stream %s: incomplete request", tunnel_id, stream_id) 74 + return meta 75 + 76 + meta.method = request.method 77 + meta.path = request.path 78 + meta.request_bytes = len(request.body) 79 + 80 + environ = _build_environ( 81 + request, 82 + peer_fingerprint=peer_fingerprint, 83 + tunnel_id=tunnel_id, 84 + ) 85 + 86 + response_state = _ResponseState() 87 + 88 + def start_response( 89 + status: str, 90 + headers: list[tuple[str, str]], 91 + exc_info: Any = None, 92 + ) -> Callable[[bytes], None]: 93 + response_state.status_line = status 94 + response_state.headers = list(headers) 95 + # WSGI returns a write() callable, but we use the iterable instead. 96 + def write(_data: bytes) -> None: 97 + raise RuntimeError("write() callable not supported; return iterable") 98 + 99 + return write 100 + 101 + # Run the WSGI app in a thread — it may block on DB, file I/O, etc. 102 + loop = asyncio.get_running_loop() 103 + try: 104 + result = await loop.run_in_executor( 105 + None, lambda: wsgi_app(environ, start_response) 106 + ) 107 + except Exception: 108 + log.exception( 109 + "tunnel %s stream %s: wsgi app raised", tunnel_id, stream_id 110 + ) 111 + await _write_simple(writer, 500, "internal server error", b"internal server error\n") 112 + meta.status = 500 113 + meta.response_bytes = _byte_count_for_simple(b"internal server error\n") 114 + return meta 115 + 116 + if response_state.status_line is None: 117 + log.warning( 118 + "tunnel %s stream %s: wsgi app returned without calling start_response", 119 + tunnel_id, 120 + stream_id, 121 + ) 122 + await _write_simple(writer, 500, "internal server error", b"missing response\n") 123 + meta.status = 500 124 + return meta 125 + 126 + # Parse numeric status. 127 + try: 128 + code_str, reason = response_state.status_line.split(" ", 1) 129 + code = int(code_str) 130 + except (ValueError, IndexError): 131 + code = 500 132 + reason = "internal server error" 133 + meta.status = code 134 + 135 + # Detect if the response is Transfer-Encoding: chunked or uses Content-Length. 136 + headers_map = {k.lower(): v for k, v in response_state.headers} 137 + is_chunked = headers_map.get("transfer-encoding", "").lower() == "chunked" 138 + 139 + # Send status + headers. 140 + sent = await _write_status_headers(writer, code, reason, response_state.headers) 141 + meta.response_bytes += sent 142 + 143 + # Stream body. Iterate in a thread (WSGI iterables can block). 144 + iterator = iter(result) if not isinstance(result, (bytes, bytearray)) else iter([bytes(result)]) 145 + 146 + async def next_chunk() -> bytes | None: 147 + def _pull() -> bytes | None: 148 + try: 149 + return next(iterator) 150 + except StopIteration: 151 + return None 152 + 153 + return await loop.run_in_executor(None, _pull) 154 + 155 + try: 156 + while True: 157 + chunk = await next_chunk() 158 + if chunk is None: 159 + break 160 + if not chunk: 161 + continue 162 + if is_chunked: 163 + # The WSGI app is responsible for emitting valid chunked 164 + # framing (Flask's stream_with_context does this). Pass 165 + # through unchanged. 166 + await writer.write(chunk) 167 + else: 168 + await writer.write(chunk) 169 + meta.response_bytes += len(chunk) 170 + finally: 171 + # WSGI requires closing iterables that have a close() method. 172 + close = getattr(result, "close", None) 173 + if callable(close): 174 + try: 175 + await loop.run_in_executor(None, close) 176 + except Exception: 177 + log.debug("wsgi close() raised", exc_info=True) 178 + 179 + return meta 180 + 181 + 182 + @dataclass 183 + class _ResponseState: 184 + status_line: str | None = None 185 + headers: list[tuple[str, str]] | None = None 186 + 187 + 188 + @dataclass 189 + class _Request: 190 + method: str 191 + path: str 192 + query: str 193 + headers: list[tuple[str, str]] 194 + body: bytes 195 + 196 + 197 + class _BadRequest(Exception): 198 + pass 199 + 200 + 201 + async def _read_request(reader: asyncio.StreamReader) -> _Request: 202 + """Parse HTTP/1.1 request line + headers + body (Content-Length only).""" 203 + raw_line = await reader.readline() 204 + if not raw_line: 205 + raise asyncio.IncompleteReadError(b"", None) 206 + line = raw_line.decode("latin-1").rstrip("\r\n") 207 + parts = line.split(" ", 2) 208 + if len(parts) != 3: 209 + raise _BadRequest(f"bad request line: {line!r}") 210 + method, target, _version = parts 211 + path, _, query = target.partition("?") 212 + 213 + headers: list[tuple[str, str]] = [] 214 + while True: 215 + raw = await reader.readline() 216 + if raw in (b"\r\n", b"\n", b""): 217 + break 218 + try: 219 + header_line = raw.decode("latin-1").rstrip("\r\n") 220 + except UnicodeDecodeError as exc: 221 + raise _BadRequest("header decode failed") from exc 222 + if ":" not in header_line: 223 + raise _BadRequest(f"bad header: {header_line!r}") 224 + name, _, value = header_line.partition(":") 225 + headers.append((name.strip(), value.strip())) 226 + 227 + headers_map = {k.lower(): v for k, v in headers} 228 + body = b"" 229 + if "transfer-encoding" in headers_map and headers_map["transfer-encoding"].lower() == "chunked": 230 + body = await _read_chunked(reader) 231 + else: 232 + cl_raw = headers_map.get("content-length", "0") 233 + try: 234 + cl = int(cl_raw) 235 + except ValueError as exc: 236 + raise _BadRequest(f"bad content-length: {cl_raw!r}") from exc 237 + if cl < 0 or cl > MAX_REQUEST_BODY: 238 + raise _BadRequest(f"content-length out of bounds: {cl}") 239 + if cl: 240 + body = await reader.readexactly(cl) 241 + 242 + return _Request( 243 + method=method, 244 + path=urllib.parse.unquote(path), 245 + query=query, 246 + headers=headers, 247 + body=body, 248 + ) 249 + 250 + 251 + async def _read_chunked(reader: asyncio.StreamReader) -> bytes: 252 + """Minimal chunked-transfer decoder for uploads.""" 253 + out = bytearray() 254 + total = 0 255 + while True: 256 + size_line = (await reader.readline()).decode("latin-1").strip() 257 + if ";" in size_line: 258 + size_line = size_line.split(";", 1)[0].strip() 259 + try: 260 + size = int(size_line, 16) 261 + except ValueError as exc: 262 + raise _BadRequest(f"bad chunk size: {size_line!r}") from exc 263 + if size == 0: 264 + # Consume trailer headers until blank line. 265 + while True: 266 + line = await reader.readline() 267 + if line in (b"\r\n", b"\n", b""): 268 + break 269 + break 270 + chunk = await reader.readexactly(size) 271 + out.extend(chunk) 272 + total += size 273 + if total > MAX_REQUEST_BODY: 274 + raise _BadRequest("request body too large") 275 + # Trailing CRLF after each chunk. 276 + trailer = await reader.readexactly(2) 277 + if trailer not in (b"\r\n", b"\n\r"): 278 + # tolerate bare \n 279 + pass 280 + return bytes(out) 281 + 282 + 283 + def _build_environ( 284 + request: _Request, 285 + *, 286 + peer_fingerprint: str | None, 287 + tunnel_id: str | None, 288 + ) -> dict[str, Any]: 289 + """Build a PEP-3333-compliant WSGI environ from the parsed request.""" 290 + environ: dict[str, Any] = { 291 + "REQUEST_METHOD": request.method, 292 + "SCRIPT_NAME": "", 293 + "PATH_INFO": request.path, 294 + "QUERY_STRING": request.query, 295 + "SERVER_NAME": WSGI_SERVER_NAME, 296 + "SERVER_PORT": "443", 297 + "SERVER_PROTOCOL": "HTTP/1.1", 298 + "wsgi.version": (1, 0), 299 + "wsgi.url_scheme": "https", 300 + "wsgi.input": io.BytesIO(request.body), 301 + "wsgi.errors": io.StringIO(), 302 + "wsgi.multithread": True, 303 + "wsgi.multiprocess": False, 304 + "wsgi.run_once": False, 305 + } 306 + 307 + for name, value in request.headers: 308 + key = "HTTP_" + name.upper().replace("-", "_") 309 + if name.lower() == "content-length": 310 + environ["CONTENT_LENGTH"] = value 311 + elif name.lower() == "content-type": 312 + environ["CONTENT_TYPE"] = value 313 + environ[key] = value 314 + 315 + if peer_fingerprint: 316 + environ["LINK_PEER_FINGERPRINT"] = peer_fingerprint 317 + if tunnel_id: 318 + environ["LINK_TUNNEL_ID"] = tunnel_id 319 + 320 + return environ 321 + 322 + 323 + async def _write_status_headers( 324 + writer: Any, 325 + code: int, 326 + reason: str, 327 + headers: list[tuple[str, str]], 328 + ) -> int: 329 + lines = [f"HTTP/1.1 {code} {reason}\r\n"] 330 + for name, value in headers: 331 + lines.append(f"{name}: {value}\r\n") 332 + lines.append("\r\n") 333 + out = "".join(lines).encode("latin-1") 334 + await writer.write(out) 335 + return len(out) 336 + 337 + 338 + async def _write_simple(writer: Any, code: int, reason: str, body: bytes) -> None: 339 + headers = [ 340 + ("Content-Type", "text/plain; charset=utf-8"), 341 + ("Content-Length", str(len(body))), 342 + ] 343 + await _write_status_headers(writer, code, reason, headers) 344 + if body: 345 + await writer.write(body) 346 + 347 + 348 + def _byte_count_for_simple(body: bytes) -> int: 349 + # Just a rough tally for metadata — status line + headers + body. 350 + return len(body) + 64
+14
think/supervisor.py
··· 917 917 return _launch_process("cortex", cmd, restart=True) 918 918 919 919 920 + def start_link_server() -> ManagedProcess: 921 + """Launch the link tunnel service (spl home-side endpoint).""" 922 + cmd = ["sol", "link", "-v"] 923 + return _launch_process("link", cmd, restart=True) 924 + 925 + 920 926 def start_convey_server( 921 927 verbose: bool, debug: bool = False, port: int = 0 922 928 ) -> tuple[ManagedProcess, int]: ··· 1396 1402 help="Do not start the Cortex server (run it manually for debugging)", 1397 1403 ) 1398 1404 parser.add_argument( 1405 + "--no-link", 1406 + action="store_true", 1407 + help="Do not start the link tunnel service", 1408 + ) 1409 + parser.add_argument( 1399 1410 "--no-convey", 1400 1411 action="store_true", 1401 1412 help="Do not start the Convey web application", ··· 1568 1579 # Cortex for agent execution 1569 1580 if not args.no_cortex: 1570 1581 procs.append(start_cortex_server()) 1582 + # Link tunnel service (opt-out via --no-link) 1583 + if not args.no_link: 1584 + procs.append(start_link_server()) 1571 1585 1572 1586 # Make procs accessible to restart handler 1573 1587 _managed_procs = procs