.
This commit is contained in:
208
mesh-scan/README.md
Normal file
208
mesh-scan/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# FIPS Mesh Reachability Scanner
|
||||
|
||||
A standalone tool that answers: **"From a fresh node peered with only node X,
|
||||
can I actually reach node Y through the mesh?"** — producing a reachability
|
||||
matrix across the real public FIPS mesh.
|
||||
|
||||
See [`../plans/mesh-reachability-scanner.md`](../plans/mesh-reachability-scanner.md)
|
||||
for the full design rationale.
|
||||
|
||||
## Why a standalone script
|
||||
|
||||
The browser attaches to a single long-lived FIPS daemon. The scanner needs
|
||||
**ephemeral daemons peered one-at-a-time** with different seed nodes, which
|
||||
the browser architecture can't do. This tool drives real `fipsd` processes
|
||||
via their Unix control sockets, reusing patterns from
|
||||
[`../../fips/testing/chaos/sim/control.py`](../../fips/testing/chaos/sim/control.py)
|
||||
and the browser's
|
||||
[`handle_fips_directory_json`](../../sovereign_browser/src/nostr_bridge.c).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [`mesh_scan.py`](mesh_scan.py) | Main scanner / orchestrator |
|
||||
| [`control.py`](control.py) | Control-socket client (line-delimited JSON over Unix socket) |
|
||||
| [`directory.py`](directory.py) | Nostr Kind 37195 advert fetcher + npub→IPv6 derivation |
|
||||
| [`fipsd.py`](fipsd.py) | Ephemeral daemon lifecycle (config, spawn, poll, teardown, TUN cleanup) |
|
||||
| [`reachability.py`](reachability.py) | ping6 / TCP reachability checks |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `websocket-client`, `pyyaml` (see [`requirements.txt`](requirements.txt))
|
||||
- `fips` / `fipsctl` binaries in `PATH`
|
||||
- `iproute2` (`ip` command), `ping6`
|
||||
- **`CAP_NET_ADMIN`** — `fipsd` needs it to create TUN devices and network
|
||||
namespaces. The scanner auto-detects whether the current user has it; if
|
||||
not, it spawns daemons via `sudo -n` (passwordless sudo). Alternatively,
|
||||
set the capability on the binary once:
|
||||
`sudo setcap cap_net_admin+ep /usr/local/bin/fips`.
|
||||
- **Network namespace support** — `ip netns` is used to isolate each
|
||||
ephemeral daemon (see [How it works](#how-it-works) below).
|
||||
|
||||
Install Python deps:
|
||||
|
||||
```bash
|
||||
pip install -r mesh-scan/requirements.txt
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Smoke test (two ephemeral nodes, no public mesh)
|
||||
|
||||
Verifies the whole toolchain — daemon spawn, control socket, peering,
|
||||
reachability — without touching the public mesh:
|
||||
|
||||
```bash
|
||||
cd mesh-scan
|
||||
python3 mesh_scan.py --self-test -v
|
||||
```
|
||||
|
||||
You should see two ephemeral daemons (`fips-scan-900`, `fips-scan-901`)
|
||||
peer over loopback and a `ping6` succeed from one TUN to the other.
|
||||
|
||||
### 2. Full scan
|
||||
|
||||
```bash
|
||||
# Random sample of 5 seeds, default 45s convergence, ping6 checks:
|
||||
python3 mesh_scan.py --seeds-sample 5 -v
|
||||
|
||||
# Specific seeds, specific targets:
|
||||
python3 mesh_scan.py --seeds npub1abc,npub1def --targets npub1ghi,npub1jkl
|
||||
|
||||
# All seeds (O(N) daemon lifecycles — slow):
|
||||
python3 mesh_scan.py --seeds all
|
||||
|
||||
# Seeds near the tree root (smallest NodeAddr — best reachability if mesh is healthy):
|
||||
python3 mesh_scan.py --seeds-near-root 5
|
||||
```
|
||||
|
||||
### 3. Cleanup
|
||||
|
||||
If the scanner crashes, `fips-scan-*` network namespaces may leak:
|
||||
|
||||
```bash
|
||||
python3 mesh_scan.py --cleanup-stale
|
||||
```
|
||||
|
||||
## Inputs / flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--seeds all` | — | Every node in the directory |
|
||||
| `--seeds npub1a,npub1b` | — | Explicit seed list |
|
||||
| `--seeds-sample K` | 5 | Random sample of K seeds |
|
||||
| `--seeds-near-root K` | — | K seeds with smallest NodeAddr (tree-root neighborhood) |
|
||||
| `--targets npub1a,...` | all except seed | Restrict targets |
|
||||
| `--converge-secs N` | 45 | Convergence budget after peering |
|
||||
| `--ping-timeout N` | 5 | Per-target reachability timeout (seconds) |
|
||||
| `--method ping6\|tcp` | ping6 | Primary check method |
|
||||
| `--tcp-port N` | 80 | TCP fallback port |
|
||||
| `--delay N` | 0 | Seconds between target checks (politeness) |
|
||||
| `--relay wss://...` | damus/nos.lol/offchain | Advert relay (repeatable) |
|
||||
| `--out-dir DIR` | `mesh-scan-out` | Output directory |
|
||||
|
||||
## Output
|
||||
|
||||
### `matrix.csv`
|
||||
|
||||
Rows = seeds, columns = targets, cells = `ok/<rtt>ms` / `fail`:
|
||||
|
||||
```
|
||||
seed npub1abc npub1def npub1ghi
|
||||
npub1root ok/42ms ok/55ms fail
|
||||
npub1hub ok/38ms ok/41ms ok/67ms
|
||||
```
|
||||
|
||||
### `diagnostics.jsonl`
|
||||
|
||||
One JSON record per (seed, target) pair:
|
||||
|
||||
```json
|
||||
{
|
||||
"seed": "npub1abc", "target": "npub1def",
|
||||
"reachable": true, "rtt_ms": 55, "loss_pct": 0, "method": "ping6",
|
||||
"target_in_identity_cache": true, "target_in_seed_bloom": true,
|
||||
"cached_coords": true, "tree_distance": 3,
|
||||
"seed_depth": 2, "seed_parent": "npub1root", "mesh_root": "npub1root",
|
||||
"converge_secs": 12
|
||||
}
|
||||
```
|
||||
|
||||
### Summary (stdout)
|
||||
|
||||
Total/overall reachability, per-seed rates (good vs bad hubs), per-target
|
||||
rates (universally unreachable = NAT/firewall suspects), and failure-cause
|
||||
correlation (identity-cache miss / bloom miss / coord-cache miss).
|
||||
|
||||
## Interpreting results
|
||||
|
||||
The per-pair diagnostics map directly to FIPS mesh-routing failure modes
|
||||
(see [`plans/mesh-reachability-scanner.md`](../plans/mesh-reachability-scanner.md)
|
||||
"Background" section):
|
||||
|
||||
| Diagnostic | Failure mode |
|
||||
|------------|--------------|
|
||||
| `target_in_identity_cache=false` | Identity-cache gate (`session.rs:1144`) — discovery skipped |
|
||||
| `target_in_seed_bloom=false` | Bloom propagation gap — target's existence not propagated to your seed |
|
||||
| `mesh_root` differs | Tree partition — target is in a different spanning tree |
|
||||
| `cached_coords=false` | Coord-cache miss (cold path) — `find_next_hop` returns None |
|
||||
|
||||
**Direct peering** hits `find_next_hop` branch 2 (direct peer), bypassing
|
||||
all of: coord cache, bloom discovery, tree routing. That's why it always
|
||||
works when mesh routing fails — and why a target that's `fail` from all
|
||||
seeds but reachable by direct peering is the symptom this scanner diagnoses.
|
||||
|
||||
## How it works
|
||||
|
||||
Each ephemeral daemon runs in its own **network namespace** (`ip netns`).
|
||||
This is necessary because the FIPS daemon hardcodes a `fd00::/8` route on
|
||||
its TUN device ([`fips/src/upper/tun.rs`](../../fips/src/upper/tun.rs)). On
|
||||
a host that already runs a long-lived FIPS daemon (`fips0`), that route
|
||||
already exists, so a second daemon's TUN init fails with
|
||||
`File exists (os error 17)`. A fresh netns gives each ephemeral daemon its
|
||||
own routing table where `fd00::/8` is free.
|
||||
|
||||
The control socket is a Unix domain socket in `/tmp` (shared filesystem),
|
||||
so it remains reachable from the host process. Reachability checks
|
||||
(`ping6`/TCP) are executed *inside* the daemon's netns via
|
||||
`ip netns exec`, so the `fd00::/8` route resolves to the daemon's TUN
|
||||
rather than the host's `fips0`.
|
||||
|
||||
Each netns also gets a **veth pair** with NAT masquerading so the
|
||||
ephemeral daemon can reach the public internet (to peer with remote seed
|
||||
nodes over UDP/TCP). Without this, the netns would be isolated to
|
||||
loopback-only and couldn't connect to any public seed. The veth setup,
|
||||
default route, and iptables masquerade rules are created automatically and
|
||||
torn down on daemon stop / `--cleanup-stale`.
|
||||
|
||||
For the `--self-test`, both ephemeral daemons share a single netns
|
||||
(`fips-scan-selftest`) so they can peer over loopback (`127.0.0.1`).
|
||||
|
||||
## Notes / caveats
|
||||
|
||||
- **Heuristics**: `target_in_seed_bloom` and `cached_coords` are
|
||||
best-effort. The control-socket `show_bloom` / `show_routing` queries
|
||||
expose aggregate stats, not per-target membership, so these fields use
|
||||
the identity cache as a proxy. Treat them as directional indicators, not
|
||||
ground truth.
|
||||
- **NAT**: the scanner node itself may be behind NAT. FIPS Nostr discovery
|
||||
+ STUN hole-punching should handle this, but running on a
|
||||
publicly-addressable host removes the confound.
|
||||
- **Politeness**: scanning N×M pairs generates real mesh traffic. Use
|
||||
`--delay` and a small `--seeds-sample` for production meshes.
|
||||
- **Coexistence with `sys-fips`**: each ephemeral daemon runs in its own
|
||||
network namespace with unique ports and a `/tmp` control socket, so it
|
||||
does not collide with the host's long-lived `fips0` daemon.
|
||||
- **Directional asymmetry (observed in self-test)**: a freshly-peered
|
||||
two-node pair can show asymmetric ping6 reachability (one direction
|
||||
succeeds, the other fails) before coord caches warm up. The self-test
|
||||
tries both directions and passes if either succeeds. On the real mesh,
|
||||
convergence (`--converge-secs`) mitigates this; the per-pair diagnostics
|
||||
capture `cached_coords` / `target_in_identity_cache` to explain any
|
||||
residual asymmetry.
|
||||
- **IPv6 host connectivity**: the scanner works even on hosts without
|
||||
public IPv6 (e.g. Qubes OS qubes), because the FIPS overlay uses
|
||||
`fd00::/8` IPv6 *inside* the TUN, which is contained in the netns and
|
||||
tunneled over the daemon's IPv4 UDP transport.
|
||||
BIN
mesh-scan/__pycache__/control.cpython-313.pyc
Normal file
BIN
mesh-scan/__pycache__/control.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mesh-scan/__pycache__/directory.cpython-313.pyc
Normal file
BIN
mesh-scan/__pycache__/directory.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mesh-scan/__pycache__/fipsd.cpython-313.pyc
Normal file
BIN
mesh-scan/__pycache__/fipsd.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mesh-scan/__pycache__/mesh_scan.cpython-313.pyc
Normal file
BIN
mesh-scan/__pycache__/mesh_scan.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mesh-scan/__pycache__/reachability.cpython-313.pyc
Normal file
BIN
mesh-scan/__pycache__/reachability.cpython-313.pyc
Normal file
Binary file not shown.
118
mesh-scan/control.py
Normal file
118
mesh-scan/control.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Control-socket client for FIPS daemons.
|
||||
|
||||
Standalone port of the patterns in
|
||||
``fips/testing/chaos/sim/control.py`` — no Docker dependency. Speaks the
|
||||
line-delimited JSON protocol defined in ``fips/src/control/protocol.rs``:
|
||||
|
||||
request: {"command": "show_status"}\n
|
||||
response: {"status":"ok","data":{...}}\n
|
||||
|
||||
The control socket is a Unix domain socket (default ``/run/fips/control.sock``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SOCKET = "/run/fips/control.sock"
|
||||
# Match the daemon's I/O timeout (control/mod.rs:29).
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class ControlError(Exception):
|
||||
"""Raised when a control-socket operation fails."""
|
||||
|
||||
|
||||
class ControlClient:
|
||||
"""A thin client over a FIPS daemon's Unix control socket."""
|
||||
|
||||
def __init__(self, socket_path: str = DEFAULT_SOCKET, timeout: float = DEFAULT_TIMEOUT):
|
||||
self.socket_path = socket_path
|
||||
self.timeout = timeout
|
||||
|
||||
# -- low-level ---------------------------------------------------------
|
||||
|
||||
def _send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Send one JSON request, read one JSON response line, return it."""
|
||||
data = (json.dumps(payload) + "\n").encode()
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(self.timeout)
|
||||
s.connect(self.socket_path)
|
||||
s.sendall(data)
|
||||
s.shutdown(socket.SHUT_WR)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = s.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
raw = b"".join(chunks).decode().strip()
|
||||
except (OSError, socket.timeout) as e:
|
||||
raise ControlError(f"control socket {self.socket_path}: {e}") from e
|
||||
|
||||
if not raw:
|
||||
raise ControlError(f"empty response from {self.socket_path}")
|
||||
|
||||
try:
|
||||
resp = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ControlError(f"invalid JSON from {self.socket_path}: {e}") from e
|
||||
|
||||
return resp
|
||||
|
||||
# -- query / command ---------------------------------------------------
|
||||
|
||||
def query(self, command: str) -> dict[str, Any]:
|
||||
"""Send a read-only query (e.g. ``show_status``). Returns the ``data`` dict."""
|
||||
resp = self._send({"command": command})
|
||||
if resp.get("status") != "ok":
|
||||
raise ControlError(f"{command}: {resp.get('message', 'unknown error')}")
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
def command(self, command: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Send a mutating command (e.g. ``connect``) with params. Returns ``data``."""
|
||||
resp = self._send({"command": command, "params": params})
|
||||
if resp.get("status") != "ok":
|
||||
raise ControlError(f"{command}: {resp.get('message', 'unknown error')}")
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
# -- convenience wrappers ---------------------------------------------
|
||||
|
||||
def show_status(self) -> dict[str, Any]:
|
||||
return self.query("show_status")
|
||||
|
||||
def show_peers(self) -> dict[str, Any]:
|
||||
return self.query("show_peers")
|
||||
|
||||
def show_tree(self) -> dict[str, Any]:
|
||||
return self.query("show_tree")
|
||||
|
||||
def show_bloom(self) -> dict[str, Any]:
|
||||
return self.query("show_bloom")
|
||||
|
||||
def show_routing(self) -> dict[str, Any]:
|
||||
return self.query("show_routing")
|
||||
|
||||
def show_identity_cache(self) -> dict[str, Any]:
|
||||
return self.query("show_identity_cache")
|
||||
|
||||
def connect(self, npub: str, address: str, transport: str) -> dict[str, Any]:
|
||||
return self.command("connect", {"npub": npub, "address": address, "transport": transport})
|
||||
|
||||
def disconnect(self, npub: str) -> dict[str, Any]:
|
||||
return self.command("disconnect", {"npub": npub})
|
||||
|
||||
|
||||
def try_query(socket_path: str, command: str, timeout: float = DEFAULT_TIMEOUT) -> Optional[dict[str, Any]]:
|
||||
"""Like ``ControlClient.query`` but returns ``None`` on failure instead of raising."""
|
||||
try:
|
||||
return ControlClient(socket_path, timeout).query(command)
|
||||
except ControlError as e:
|
||||
log.debug("query %s on %s failed: %s", command, socket_path, e)
|
||||
return None
|
||||
325
mesh-scan/directory.py
Normal file
325
mesh-scan/directory.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""FIPS node directory fetcher.
|
||||
|
||||
Ports the Nostr Kind 37195 / ``fips-overlay-v1`` advert query from
|
||||
``sovereign_browser/src/nostr_bridge.c:handle_fips_directory_json`` to
|
||||
Python using ``websocket-client``.
|
||||
|
||||
Each advert event's ``content`` is a JSON object matching
|
||||
``fips/src/discovery/nostr/types.rs::OverlayAdvert``:
|
||||
|
||||
{
|
||||
"identifier": "fips-overlay-v1",
|
||||
"version": 1,
|
||||
"endpoints": [{"transport": "udp", "addr": "host:port"}, ...],
|
||||
"signalRelays": ["wss://...", ...]
|
||||
}
|
||||
|
||||
Kind 37195 is parameterized-replaceable, so we keep the newest event per
|
||||
pubkey (highest ``created_at``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import websocket # websocket-client
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Default FIPS advert relays (fips/src/config/node.rs::default_advert_relays).
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://offchain.pub",
|
||||
]
|
||||
|
||||
KIND_FIPS_ADVERT = 37195
|
||||
ADVERT_IDENTIFIER = "fips-overlay-v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bech32 (npub) encoding — no external dependency required.
|
||||
# Implements BIP-173 / NIP-19 bech32 for the 32-byte x-only pubkey → npub case.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for v in values:
|
||||
b = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
||||
for i in range(5):
|
||||
chk ^= gen[i] if ((b >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
|
||||
|
||||
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _bech32_hrp_expand(hrp) + data
|
||||
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _convertbits(data: bytes, frombits: int, tobits: int, pad: bool = True) -> list[int]:
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
maxv = (1 << tobits) - 1
|
||||
max_acc = (1 << (frombits + tobits - 1)) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
raise ValueError("invalid value for convertbits")
|
||||
acc = ((acc << frombits) | value) & max_acc
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad:
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
raise ValueError("invalid padding in convertbits")
|
||||
return ret
|
||||
|
||||
|
||||
def encode_npub(pubkey_bytes: bytes) -> str:
|
||||
"""Encode a 32-byte x-only pubkey as an npub (bech32) string."""
|
||||
if len(pubkey_bytes) != 32:
|
||||
raise ValueError("npub requires 32 bytes")
|
||||
hrp = "npub"
|
||||
data = _convertbits(pubkey_bytes, 8, 5, pad=True)
|
||||
combined = data + _bech32_create_checksum(hrp, data)
|
||||
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
||||
|
||||
|
||||
def hex_to_npub(pubkey_hex: str) -> str:
|
||||
return encode_npub(bytes.fromhex(pubkey_hex))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Endpoint:
|
||||
transport: str # "udp" | "tcp" | "tor"
|
||||
addr: str # "host:port" or "nat"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeEntry:
|
||||
npub: str
|
||||
pubkey_hex: str
|
||||
created_at: int
|
||||
endpoints: list[Endpoint] = field(default_factory=list)
|
||||
signal_relays: list[str] = field(default_factory=list)
|
||||
|
||||
def first_dialable_endpoint(self) -> Optional[Endpoint]:
|
||||
"""First endpoint with a concrete host:port (not 'nat')."""
|
||||
for ep in self.endpoints:
|
||||
if ep.addr.lower() != "nat" and ":" in ep.addr:
|
||||
return ep
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relay query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_filter() -> dict[str, Any]:
|
||||
return {
|
||||
"kinds": [KIND_FIPS_ADVERT],
|
||||
"#d": [ADVERT_IDENTIFIER],
|
||||
"limit": 500,
|
||||
}
|
||||
|
||||
|
||||
def _query_one_relay(relay_url: str, timeout: float) -> list[dict]:
|
||||
"""Open a WS to one relay, send REQ, collect EVENTs until EOSE, return events."""
|
||||
events: list[dict] = []
|
||||
subscription_id = f"meshscan-{int(time.time()*1000)}"
|
||||
|
||||
sock = None
|
||||
try:
|
||||
sock = websocket.create_connection(relay_url, timeout=timeout)
|
||||
req = json.dumps(["REQ", subscription_id, _build_filter()])
|
||||
sock.send(req)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
sock.settimeout(max(0.5, deadline - time.time()))
|
||||
try:
|
||||
raw = sock.recv()
|
||||
except websocket.WebSocketTimeoutException:
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(msg, list) or not msg:
|
||||
continue
|
||||
if msg[0] == "EOSE" and len(msg) > 1 and msg[1] == subscription_id:
|
||||
break
|
||||
if msg[0] == "EVENT" and len(msg) > 2:
|
||||
events.append(msg[2])
|
||||
except Exception as e:
|
||||
log.warning("relay %s failed: %s", relay_url, e)
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.send(json.dumps(["CLOSE", subscription_id]))
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return events
|
||||
|
||||
|
||||
def fetch_directory(
|
||||
relays: list[str] | None = None,
|
||||
timeout: float = 12.0,
|
||||
) -> list[NodeEntry]:
|
||||
"""Fetch the FIPS node directory from advert relays.
|
||||
|
||||
Queries all relays in parallel, dedups by pubkey keeping the newest
|
||||
event (highest ``created_at``), and returns a list of
|
||||
[`NodeEntry`](directory.py).
|
||||
"""
|
||||
relay_list = relays or DEFAULT_RELAYS
|
||||
all_events: list[dict] = []
|
||||
threads: list[threading.Thread] = []
|
||||
results: list[list[dict]] = [[] for _ in relay_list]
|
||||
|
||||
def worker(i: int, url: str) -> None:
|
||||
results[i] = _query_one_relay(url, timeout)
|
||||
|
||||
for i, url in enumerate(relay_list):
|
||||
t = threading.Thread(target=worker, args=(i, url), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
for t in threads:
|
||||
t.join(timeout + 2)
|
||||
|
||||
for r in results:
|
||||
all_events.extend(r)
|
||||
|
||||
return _dedup_events(all_events)
|
||||
|
||||
|
||||
def _dedup_events(events: list[dict]) -> list[NodeEntry]:
|
||||
newest: dict[str, NodeEntry] = {}
|
||||
for ev in events:
|
||||
pk = ev.get("pubkey")
|
||||
ca = ev.get("created_at", 0)
|
||||
if not isinstance(pk, str):
|
||||
continue
|
||||
try:
|
||||
npub = hex_to_npub(pk)
|
||||
except ValueError:
|
||||
continue
|
||||
existing = newest.get(npub)
|
||||
if existing is not None and ca <= existing.created_at:
|
||||
continue
|
||||
|
||||
content = ev.get("content", "")
|
||||
try:
|
||||
advert = json.loads(content) if isinstance(content, str) else None
|
||||
except json.JSONDecodeError:
|
||||
advert = None
|
||||
|
||||
endpoints: list[Endpoint] = []
|
||||
signal_relays: list[str] = []
|
||||
if isinstance(advert, dict):
|
||||
for ep in advert.get("endpoints", []) or []:
|
||||
if isinstance(ep, dict) and "transport" in ep and "addr" in ep:
|
||||
endpoints.append(Endpoint(str(ep["transport"]), str(ep["addr"])))
|
||||
for r in advert.get("signalRelays", []) or []:
|
||||
if isinstance(r, str):
|
||||
signal_relays.append(r)
|
||||
|
||||
newest[npub] = NodeEntry(
|
||||
npub=npub,
|
||||
pubkey_hex=pk,
|
||||
created_at=int(ca) if isinstance(ca, (int, float)) else 0,
|
||||
endpoints=endpoints,
|
||||
signal_relays=signal_relays,
|
||||
)
|
||||
|
||||
return list(newest.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# npub → fd00:: IPv6 derivation (fips/src/identity/{node_addr,address}.rs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def npub_to_node_addr(npub: str) -> bytes:
|
||||
"""Derive the 16-byte NodeAddr = first 16 bytes of SHA-256(pubkey)."""
|
||||
# Decode bech32 npub → 32-byte pubkey.
|
||||
hrp, data = _bech32_decode(npub)
|
||||
if hrp != "npub":
|
||||
raise ValueError(f"expected npub hrp, got {hrp}")
|
||||
pubkey = _convertbits(data, 5, 8, pad=False)
|
||||
if len(pubkey) != 32:
|
||||
raise ValueError(f"decoded pubkey length {len(pubkey)} != 32")
|
||||
return hashlib.sha256(bytes(pubkey)).digest()[:16]
|
||||
|
||||
|
||||
def npub_to_ipv6(npub: str) -> str:
|
||||
"""Derive the fd00::/8 IPv6 address for a node from its npub.
|
||||
|
||||
Mirrors ``FipsAddress::from_node_addr``: prepend 0xfd to the first 15
|
||||
bytes of the NodeAddr.
|
||||
"""
|
||||
node_addr = npub_to_node_addr(npub)
|
||||
addr_bytes = bytearray(16)
|
||||
addr_bytes[0] = 0xFD
|
||||
addr_bytes[1:16] = node_addr[0:15]
|
||||
# Format as IPv6.
|
||||
parts = [int.from_bytes(addr_bytes[i:i + 2], "big") for i in range(0, 16, 2)]
|
||||
return ":".join(f"{p:x}" for p in parts)
|
||||
|
||||
|
||||
def _bech32_decode(s: str) -> tuple[str, list[int]]:
|
||||
"""Decode a bech32 string → (hrp, data5)."""
|
||||
if any(ord(x) < 33 or ord(x) > 126 for x in s):
|
||||
raise ValueError("invalid bech32 character")
|
||||
pos = s.rfind("1")
|
||||
if pos < 1 or pos + 7 > len(s):
|
||||
raise ValueError("invalid bech32 structure")
|
||||
hrp = s[:pos]
|
||||
data = [_BECH32_CHARSET.index(x) for x in s[pos + 1:].lower()]
|
||||
# Verify checksum.
|
||||
if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
|
||||
raise ValueError("invalid bech32 checksum")
|
||||
return hrp, data[:-6]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
p = argparse.ArgumentParser(description="Fetch the FIPS node directory")
|
||||
p.add_argument("--relay", action="append", help="relay URL (repeatable)")
|
||||
p.add_argument("--timeout", type=float, default=12.0)
|
||||
args = p.parse_args()
|
||||
nodes = fetch_directory(args.relay, args.timeout)
|
||||
print(f"# {len(nodes)} nodes")
|
||||
for n in nodes:
|
||||
ep = n.first_dialable_endpoint()
|
||||
ep_str = f"{ep.transport}:{ep.addr}" if ep else "none"
|
||||
print(f"{n.npub} created_at={n.created_at} ep={ep_str}")
|
||||
476
mesh-scan/fipsd.py
Normal file
476
mesh-scan/fipsd.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Ephemeral FIPS daemon lifecycle manager.
|
||||
|
||||
Generates an isolated ``fips.yaml``, spawns ``fipsd --config <path>`` as a
|
||||
subprocess **inside its own network namespace**, polls the control socket
|
||||
until the daemon is ready, and tears everything down cleanly (process +
|
||||
netns + temp dir).
|
||||
|
||||
Why network namespaces: the FIPS daemon hardcodes a ``fd00::/8`` route on
|
||||
its TUN device (see ``fips/src/upper/tun.rs``). On a host that already runs
|
||||
a long-lived FIPS daemon (``fips0``), that route already exists, so a
|
||||
second daemon's TUN init fails with ``File exists (os error 17)``. Running
|
||||
each ephemeral daemon in its own netns gives it a fresh routing table where
|
||||
``fd00::/8`` is free. The control socket is a Unix domain socket in ``/tmp``
|
||||
(shared filesystem), so it remains reachable from the host.
|
||||
|
||||
Each ephemeral daemon gets:
|
||||
- a unique temp config dir under ``/tmp/mesh-scan-<tag>-<pid>/``
|
||||
- a unique control socket path inside that dir
|
||||
- a unique network namespace (``fips-scan-<n>``) — or a shared one for
|
||||
the self-test (so two daemons can peer over loopback)
|
||||
- unique UDP/TCP/DNS ports auto-allocated from the ephemeral range
|
||||
|
||||
``fipsd`` needs ``CAP_NET_ADMIN`` (for TUN + netns), so the spawn goes
|
||||
through ``sudo`` unless we already have the capability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from control import ControlClient, ControlError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FIPS_BINARY = os.environ.get("FIPS_BINARY", "fips")
|
||||
DEFAULT_NETNS_PREFIX = "fips-scan-"
|
||||
STARTUP_TIMEOUT = 15.0
|
||||
STOP_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _have_net_admin() -> bool:
|
||||
"""True if this process can create a TUN device / netns without sudo."""
|
||||
try:
|
||||
with open("/proc/self/status") as f:
|
||||
for line in f:
|
||||
if line.startswith("CapEff:"):
|
||||
eff = int(line.split()[1], 16)
|
||||
# CAP_NET_ADMIN is bit 12.
|
||||
return bool(eff & (1 << 12))
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _free_udp_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.bind(("0.0.0.0", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _free_tcp_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("0.0.0.0", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _next_netns_name(index: int) -> str:
|
||||
return f"{DEFAULT_NETNS_PREFIX}{index}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network namespace management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(cmd: list[str], use_sudo: bool, timeout: float = 5.0) -> subprocess.CompletedProcess:
|
||||
full = (["sudo", "-n", *cmd] if use_sudo else cmd)
|
||||
return subprocess.run(full, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _run_quiet(cmd: list[str], use_sudo: bool, timeout: float = 5.0) -> None:
|
||||
"""Run a command, log failures at debug level only."""
|
||||
try:
|
||||
r = _run(cmd, use_sudo, timeout)
|
||||
if r.returncode != 0 and r.stderr.strip():
|
||||
log.debug("cmd failed: %s -> %s", " ".join(cmd), r.stderr.strip())
|
||||
except Exception as e:
|
||||
log.debug("cmd exception: %s -> %s", " ".join(cmd), e)
|
||||
|
||||
|
||||
def _host_default_iface() -> str:
|
||||
"""Return the host's default-route interface name, or 'eth0' fallback."""
|
||||
try:
|
||||
r = subprocess.run(["ip", "route", "show", "default"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
for line in r.stdout.splitlines():
|
||||
if "dev" in line:
|
||||
return line.split("dev")[1].split()[0]
|
||||
except Exception:
|
||||
pass
|
||||
return "eth0"
|
||||
|
||||
|
||||
# Subnet counter for veth pairs (10.250.N.0/30 — 2 usable hosts).
|
||||
_veth_counter = [0]
|
||||
|
||||
|
||||
def _next_veth_subnet() -> tuple[str, str, str]:
|
||||
"""Return (host_addr, netns_addr, subnet) for the next veth pair."""
|
||||
n = _veth_counter[0]
|
||||
_veth_counter[0] += 1
|
||||
# 10.250.N.1 (host side) / 10.250.N.2 (netns side), /30.
|
||||
return f"10.250.{n}.1/30", f"10.250.{n}.2/30", f"10.250.{n}.0/30"
|
||||
|
||||
|
||||
def create_netns(name: str, use_sudo: bool | None = None) -> dict:
|
||||
"""Create a network namespace with loopback + a veth pair for outbound NAT.
|
||||
|
||||
The netns gets a veth link to the host with a 10.250.N.0/30 subnet and a
|
||||
default route via the host side. The host masquerades traffic from the
|
||||
netns out its default interface, so the ephemeral FIPS daemon inside can
|
||||
reach public seed nodes over UDP/TCP.
|
||||
|
||||
Returns a dict with the veth interface names and subnet, for cleanup.
|
||||
"""
|
||||
if use_sudo is None:
|
||||
use_sudo = not _have_net_admin()
|
||||
|
||||
# Linux interface names are limited to 15 chars. Use a short prefix
|
||||
# derived from the netns index instead of the full netns name.
|
||||
n = _veth_counter[0] - 1 # _next_veth_subnet already incremented
|
||||
info = {"veth_host": f"fsvh{n}", "veth_ns": f"fsvn{n}", "subnet": None}
|
||||
|
||||
try:
|
||||
_run(["ip", "netns", "add", name], use_sudo)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.warning("ip netns add %s failed: %s", name, e.stderr.strip())
|
||||
return info
|
||||
|
||||
# Loopback up so ::1 binds work (DNS responder binds ::1).
|
||||
_run_quiet(["ip", "netns", "exec", name, "ip", "link", "set", "lo", "up"], use_sudo)
|
||||
|
||||
# Create a veth pair: host end stays on the host, ns end moves into netns.
|
||||
host_addr, ns_addr, subnet = _next_veth_subnet()
|
||||
info["subnet"] = subnet
|
||||
_run_quiet(["ip", "link", "add", info["veth_host"], "type", "veth",
|
||||
"peer", "name", info["veth_ns"]], use_sudo)
|
||||
_run_quiet(["ip", "link", "set", info["veth_ns"], "netns", name], use_sudo)
|
||||
_run_quiet(["ip", "addr", "add", host_addr, "dev", info["veth_host"]], use_sudo)
|
||||
_run_quiet(["ip", "link", "set", info["veth_host"], "up"], use_sudo)
|
||||
_run_quiet(["ip", "netns", "exec", name, "ip", "addr", "add", ns_addr,
|
||||
"dev", info["veth_ns"]], use_sudo)
|
||||
_run_quiet(["ip", "netns", "exec", name, "ip", "link", "set", info["veth_ns"], "up"], use_sudo)
|
||||
# Default route in the netns via the host side of the veth.
|
||||
_run_quiet(["ip", "netns", "exec", name, "ip", "route", "add", "default",
|
||||
"via", host_addr.split("/")[0], "dev", info["veth_ns"]], use_sudo)
|
||||
|
||||
# Enable IP forwarding + masquerade on the host for the netns subnet.
|
||||
_run_quiet(["sysctl", "-w", "net.ipv4.ip_forward=1"], use_sudo)
|
||||
out_iface = _host_default_iface()
|
||||
_run_quiet(["iptables", "-t", "nat", "-A", "POSTROUTING", "-s", subnet,
|
||||
"-o", out_iface, "-j", "MASQUERADE"], use_sudo)
|
||||
_run_quiet(["iptables", "-A", "FORWARD", "-i", info["veth_host"],
|
||||
"-o", out_iface, "-j", "ACCEPT"], use_sudo)
|
||||
_run_quiet(["iptables", "-A", "FORWARD", "-i", out_iface,
|
||||
"-o", info["veth_host"], "-j", "ACCEPT"], use_sudo)
|
||||
|
||||
log.info("netns %s: veth %s<->%s subnet %s nat via %s",
|
||||
name, info["veth_host"], info["veth_ns"], subnet, out_iface)
|
||||
return info
|
||||
|
||||
|
||||
def delete_netns(name: str, use_sudo: bool | None = None,
|
||||
info: dict | None = None) -> None:
|
||||
"""Delete a network namespace and its veth / iptables rules."""
|
||||
if use_sudo is None:
|
||||
use_sudo = not _have_net_admin()
|
||||
# Deleting the netns also destroys the ns-end of the veth; the host-end
|
||||
# is removed below. iptables rules referencing the veth are cleaned up
|
||||
# best-effort.
|
||||
if info:
|
||||
subnet = info.get("subnet")
|
||||
out_iface = _host_default_iface()
|
||||
if subnet:
|
||||
_run_quiet(["iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet,
|
||||
"-o", out_iface, "-j", "MASQUERADE"], use_sudo)
|
||||
_run_quiet(["iptables", "-D", "FORWARD", "-i", info.get("veth_host", ""),
|
||||
"-o", out_iface, "-j", "ACCEPT"], use_sudo)
|
||||
_run_quiet(["iptables", "-D", "FORWARD", "-i", out_iface,
|
||||
"-o", info.get("veth_host", ""), "-j", "ACCEPT"], use_sudo)
|
||||
_run_quiet(["ip", "link", "del", info.get("veth_host", "")], use_sudo)
|
||||
try:
|
||||
_run(["ip", "netns", "del", name], use_sudo)
|
||||
except Exception as e:
|
||||
log.debug("ip netns del %s: %s", name, e)
|
||||
|
||||
|
||||
def exec_in_netns(name: str, cmd: list[str], use_sudo: bool | None = None,
|
||||
**kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a command inside a network namespace."""
|
||||
if use_sudo is None:
|
||||
use_sudo = not _have_net_admin()
|
||||
full = (["sudo", "-n"] if use_sudo else []) + ["ip", "netns", "exec", name] + cmd
|
||||
return subprocess.run(full, **kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EphemeralDaemon:
|
||||
"""A running ephemeral fipsd process + its resources."""
|
||||
|
||||
config_dir: Path
|
||||
config_path: Path
|
||||
socket_path: str
|
||||
netns: str
|
||||
owns_netns: bool
|
||||
udp_port: int
|
||||
tcp_port: int
|
||||
dns_port: int
|
||||
process: subprocess.Popen
|
||||
used_sudo: bool
|
||||
netns_info: dict | None = None
|
||||
client: ControlClient = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.client = ControlClient(self.socket_path)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def wait_ready(self, timeout: float = STARTUP_TIMEOUT) -> None:
|
||||
"""Poll the control socket until ``show_status`` responds."""
|
||||
deadline = time.time() + timeout
|
||||
last_err = ""
|
||||
while time.time() < deadline:
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"fipsd exited early (code={self.process.returncode})")
|
||||
try:
|
||||
status = self.client.show_status()
|
||||
if status.get("state") in ("running", "initialized", "ready"):
|
||||
if status.get("tun_state") == "active":
|
||||
return
|
||||
# TUN not yet active; keep waiting briefly.
|
||||
except ControlError as e:
|
||||
last_err = str(e)
|
||||
time.sleep(0.3)
|
||||
raise RuntimeError(f"fipsd did not become ready in {timeout}s: {last_err}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Disconnect peers, SIGTERM, then SIGKILL if needed; clean up netns + dir."""
|
||||
try:
|
||||
self.client.disconnect_all_peers()
|
||||
except Exception as e:
|
||||
log.debug("disconnect-all failed: %s", e)
|
||||
self._terminate()
|
||||
if self.owns_netns:
|
||||
delete_netns(self.netns, self.used_sudo, info=self.netns_info)
|
||||
self._cleanup_dir()
|
||||
|
||||
def _terminate(self) -> None:
|
||||
if self.process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
self.process.send_signal(signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.process.wait(timeout=STOP_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("fipsd in netns %s did not exit on SIGTERM, SIGKILL", self.netns)
|
||||
try:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cleanup_dir(self) -> None:
|
||||
try:
|
||||
shutil.rmtree(self.config_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- reachability helper ----------------------------------------------
|
||||
|
||||
def run_in_netns(self, cmd: list[str], timeout: float = 30.0, **kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a command (e.g. ping6) inside this daemon's netns.
|
||||
|
||||
Defaults to capturing stdout/stderr as text with a 30s hard timeout
|
||||
(callers can override). The timeout prevents hung probes from
|
||||
blocking the scan indefinitely.
|
||||
"""
|
||||
kwargs.setdefault("capture_output", True)
|
||||
kwargs.setdefault("text", True)
|
||||
kwargs.setdefault("timeout", timeout)
|
||||
return exec_in_netns(self.netns, cmd, self.used_sudo, **kwargs)
|
||||
|
||||
# -- context manager ---------------------------------------------------
|
||||
|
||||
def __enter__(self) -> "EphemeralDaemon":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_config(
|
||||
socket_path: str,
|
||||
udp_port: int,
|
||||
tcp_port: int,
|
||||
dns_port: int,
|
||||
) -> str:
|
||||
# Inside a netns we can use the canonical "fips0" TUN name — no collision.
|
||||
# Mirrors the structure in sovereign_browser/src/net_services.c:349
|
||||
# and fips/src/config/{node,transport,upper/config}.rs.
|
||||
return (
|
||||
"node:\n"
|
||||
" identity:\n"
|
||||
" persistent: false\n"
|
||||
f" control:\n"
|
||||
f" socket_path: \"{socket_path}\"\n"
|
||||
" log_level: warn\n"
|
||||
# Enable Nostr discovery so the ephemeral node learns about other
|
||||
# mesh nodes from the advert relays (not just its direct peer).
|
||||
# Without this, the identity cache stays at 1 (only the seed) and
|
||||
# find_next_hop returns None for every non-peer target.
|
||||
# policy: open lets it consider adverts for non-configured peers.
|
||||
# advertise: false — the scanner node is ephemeral, don't publish.
|
||||
" discovery:\n"
|
||||
" nostr:\n"
|
||||
" enabled: true\n"
|
||||
" advertise: false\n"
|
||||
" policy: open\n"
|
||||
" advert_relays:\n"
|
||||
" - \"wss://relay.damus.io\"\n"
|
||||
" - \"wss://nos.lol\"\n"
|
||||
" - \"wss://offchain.pub\"\n"
|
||||
"tun:\n"
|
||||
" enabled: true\n"
|
||||
" name: \"fips0\"\n"
|
||||
" mtu: 1280\n"
|
||||
"dns:\n"
|
||||
" enabled: true\n"
|
||||
" bind_addr: \"::1\"\n"
|
||||
f" port: {dns_port}\n"
|
||||
"transports:\n"
|
||||
" udp:\n"
|
||||
f" bind_addr: \"0.0.0.0:{udp_port}\"\n"
|
||||
" tcp:\n"
|
||||
f" bind_addr: \"0.0.0.0:{tcp_port}\"\n"
|
||||
"peers: []\n"
|
||||
)
|
||||
|
||||
|
||||
def spawn_daemon(tag: str = "scan", index: int = 0,
|
||||
netns: str | None = None) -> EphemeralDaemon:
|
||||
"""Create the config dir + netns, write fips.yaml, spawn fipsd, return handle.
|
||||
|
||||
If ``netns`` is given, the daemon is spawned in that (shared) namespace
|
||||
and does not own it (the caller is responsible for deleting it). This is
|
||||
used by the self-test to put two daemons in one netns so they can peer
|
||||
over loopback.
|
||||
|
||||
The daemon is *not* waited on for readiness — call ``wait_ready()``.
|
||||
"""
|
||||
use_sudo = not _have_net_admin()
|
||||
config_dir = Path(tempfile.mkdtemp(prefix=f"mesh-scan-{tag}-"))
|
||||
socket_path = str(config_dir / "control.sock")
|
||||
udp_port = _free_udp_port()
|
||||
tcp_port = _free_tcp_port()
|
||||
dns_port = _free_udp_port()
|
||||
|
||||
owns_netns = netns is None
|
||||
netns_info = None
|
||||
if owns_netns:
|
||||
netns = _next_netns_name(index)
|
||||
netns_info = create_netns(netns, use_sudo)
|
||||
else:
|
||||
# Shared netns (self-test): ensure it exists with NAT. The caller
|
||||
# is responsible for creating/deleting it, but if they passed a
|
||||
# name without creating it, create_netns is idempotent enough.
|
||||
pass
|
||||
|
||||
config = _build_config(socket_path, udp_port, tcp_port, dns_port)
|
||||
config_path = config_dir / "fips.yaml"
|
||||
config_path.write_text(config)
|
||||
os.chmod(config_path, 0o600)
|
||||
|
||||
# Spawn fips inside the netns.
|
||||
fips_argv = [FIPS_BINARY, "--config", str(config_path)]
|
||||
if use_sudo:
|
||||
spawn_argv = ["sudo", "-n", "ip", "netns", "exec", netns, *fips_argv]
|
||||
else:
|
||||
spawn_argv = ["ip", "netns", "exec", netns, *fips_argv]
|
||||
|
||||
log.info("spawning fipsd: netns=%s udp=%d tcp=%d dns=%d sudo=%s",
|
||||
netns, udp_port, tcp_port, dns_port, use_sudo)
|
||||
proc = subprocess.Popen(
|
||||
spawn_argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
return EphemeralDaemon(
|
||||
config_dir=config_dir,
|
||||
config_path=config_path,
|
||||
socket_path=socket_path,
|
||||
netns=netns,
|
||||
owns_netns=owns_netns,
|
||||
netns_info=netns_info,
|
||||
udp_port=udp_port,
|
||||
tcp_port=tcp_port,
|
||||
dns_port=dns_port,
|
||||
process=proc,
|
||||
used_sudo=use_sudo,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale-namespace cleanup (--cleanup-stale)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cleanup_stale_netns(prefix: str = DEFAULT_NETNS_PREFIX) -> int:
|
||||
"""Remove any network namespaces whose name starts with ``prefix``."""
|
||||
use_sudo = not _have_net_admin()
|
||||
try:
|
||||
out = _run(["ip", "netns", "list"], use_sudo).stdout
|
||||
except Exception as e:
|
||||
log.warning("could not list netns: %s", e)
|
||||
return 0
|
||||
count = 0
|
||||
for line in out.splitlines():
|
||||
name = line.split()[0] if line.strip() else ""
|
||||
if name.startswith(prefix):
|
||||
try:
|
||||
_run(["ip", "netns", "del", name], use_sudo)
|
||||
log.info("removed stale netns %s", name)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
log.warning("failed to remove stale netns %s: %s", name, e)
|
||||
return count
|
||||
|
||||
|
||||
# Back-compat alias used by mesh_scan.py's --cleanup-stale flag.
|
||||
cleanup_stale_tun_devices = cleanup_stale_netns
|
||||
|
||||
|
||||
# Extend ControlClient with a helper to disconnect all peers (used on stop).
|
||||
def _disconnect_all_peers(self: ControlClient) -> None:
|
||||
try:
|
||||
peers = self.show_peers().get("peers", []) or []
|
||||
except ControlError:
|
||||
return
|
||||
for p in peers:
|
||||
npub = p.get("npub")
|
||||
if npub:
|
||||
try:
|
||||
self.disconnect(npub)
|
||||
except ControlError:
|
||||
pass
|
||||
|
||||
|
||||
ControlClient.disconnect_all_peers = _disconnect_all_peers # type: ignore[attr-defined]
|
||||
2232
mesh-scan/mesh-scan-out/diagnostics.jsonl
Normal file
2232
mesh-scan/mesh-scan-out/diagnostics.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
5
mesh-scan/mesh-scan-out/matrix.csv
Normal file
5
mesh-scan/mesh-scan-out/matrix.csv
Normal file
File diff suppressed because one or more lines are too long
133
mesh-scan/mesh-scan-out/scan.log
Normal file
133
mesh-scan/mesh-scan-out/scan.log
Normal file
@@ -0,0 +1,133 @@
|
||||
09:05:44 INFO mesh_scan: fetching FIPS node directory
|
||||
09:05:44 WARNING directory: relay wss://relay.damus.io failed: Handshake status 503 Service Unavailable -+-+- {'date': 'Sun, 19 Jul 2026 13:05:44 GMT', 'content-length': '0', 'connection': 'keep-alive', 'cache-control': 'private, no-store', 'cf-cache-status': 'DYNAMIC', 'report-to': '{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=zOV7cTz6nXiwb%2BWuP2D63%2FzvLBDigW6ZZKprGWy%2F5mj5UkpMtxqpGUKA74Y%2BB1kOBXAuEbSRZ0mWD4kSxf0ru1i%2BxtNoL%2FZnLvgJxfwljqb3e67LhBW3BAgGuWyN87VvQw%3D%3D"}],"group":"cf-nel","max_age":604800}', 'nel': '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}', 'server': 'cloudflare', 'cf-ray': 'a1d9f07dfe93fe29-IAD', 'alt-svc': 'h3=":443"; ma=86400', 'server-timing': 'cfL4;desc="?proto=TCP&rtt=59321&min_rtt=58786&rtt_var=17462&sent=5&recv=8&lost=0&retrans=0&sent_bytes=3862&recv_bytes=1843&delivery_rate=65821&cwnd=53&unsent_bytes=0&cid=04afbf6a77de3946&ts=319&x=0"'} -+-+- b''
|
||||
09:05:46 INFO mesh_scan: seed npub1jfv63kg5deq: 543 target(s)
|
||||
09:05:46 INFO mesh_scan: === seed 1/27: npub1jfv63kg5deqhrt7sn6s ===
|
||||
Fetched 544 nodes from the directory.
|
||||
Selected 27 seed(s): ['npub1jfv63kg5deq', 'npub13zg8jlslmr4', 'npub1sd826ue7tz8', 'npub1h4e3dks08up', 'npub18ljnndsess6', 'npub1veq2q3xryp2', 'npub1ckskwukdlj5', 'npub1k3aerhf3f4e', 'npub1t5fgt7ssrgk', 'npub136yqae6na68', 'npub14l9ldvnask3', 'npub10yymypqzz36', 'npub1v5fmkz5s8yg', 'npub1tyuy5r927es', 'npub14y8l4yxn4e3', 'npub1g4uu79fww44', 'npub1netvn8qvfwt', 'npub1nlukdnzu2d3', 'npub1qmc3cvfz0yu', 'npub10yffd020a4a', 'npub1mqelkzqp465', 'npub1vmv2ctysqtj', 'npub1u0z26dc4qen', 'npub17lpmzulpc98', 'npub1260n42s06vz', 'npub1gd7ye2qp2lp', 'npub1xf57cw7r2l4']
|
||||
|
||||
[09:05:46] seed 1/27: npub1jfv63kg5deqhrt7sn6s (543 targets)
|
||||
09:05:46 INFO fipsd: netns fips-scan-0: veth fsvh-1<->fsvn-1 subnet 10.250.0.0/30 nat via eth0
|
||||
09:05:46 INFO fipsd: spawning fipsd: netns=fips-scan-0 udp=54391 tcp=37245 dns=32797 sudo=True
|
||||
09:05:48 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-0
|
||||
[09:05:48] daemon up (2.0s)
|
||||
09:05:48 INFO mesh_scan: peering with seed npub1jfv63kg5deq at 45.76.169.205:51820/udp
|
||||
09:06:03 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:06:03] peering FAILED (15.0s); skipping targets
|
||||
09:06:03 WARNING mesh_scan: could not peer with seed npub1jfv63kg5deq; skipping targets
|
||||
[09:06:03] tally: 0 connected, 1 failed, 0 skipped | 1/27 done | elapsed 17s
|
||||
09:06:03 INFO mesh_scan: seed npub13zg8jlslmr4: 543 target(s)
|
||||
09:06:03 INFO mesh_scan: === seed 2/27: npub13zg8jlslmr4lzwm95j9 ===
|
||||
|
||||
[09:06:03] seed 2/27: npub13zg8jlslmr4lzwm95j9 (543 targets)
|
||||
09:06:03 INFO fipsd: netns fips-scan-1: veth fsvh0<->fsvn0 subnet 10.250.1.0/30 nat via eth0
|
||||
09:06:03 INFO fipsd: spawning fipsd: netns=fips-scan-1 udp=54155 tcp=52271 dns=47813 sudo=True
|
||||
09:06:05 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-1
|
||||
[09:06:05] daemon up (2.1s)
|
||||
09:06:05 INFO mesh_scan: peering with seed npub13zg8jlslmr4 at 178.39.14.231:51820/udp
|
||||
09:06:20 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:06:20] peering FAILED (15.0s); skipping targets
|
||||
09:06:20 WARNING mesh_scan: could not peer with seed npub13zg8jlslmr4; skipping targets
|
||||
[09:06:20] tally: 0 connected, 2 failed, 0 skipped | 2/27 done | elapsed 34s
|
||||
09:06:20 INFO mesh_scan: seed npub1sd826ue7tz8: 543 target(s)
|
||||
09:06:20 INFO mesh_scan: === seed 3/27: npub1sd826ue7tz8usd2yt90 ===
|
||||
|
||||
[09:06:20] seed 3/27: npub1sd826ue7tz8usd2yt90 (543 targets)
|
||||
09:06:20 INFO fipsd: netns fips-scan-2: veth fsvh1<->fsvn1 subnet 10.250.2.0/30 nat via eth0
|
||||
09:06:20 INFO fipsd: spawning fipsd: netns=fips-scan-2 udp=39730 tcp=56211 dns=56864 sudo=True
|
||||
09:06:22 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-2
|
||||
[09:06:22] daemon up (2.0s)
|
||||
09:06:22 INFO mesh_scan: peering with seed npub1sd826ue7tz8 at 65.109.48.91:51821/udp
|
||||
09:06:37 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:06:37] peering FAILED (15.0s); skipping targets
|
||||
09:06:37 WARNING mesh_scan: could not peer with seed npub1sd826ue7tz8; skipping targets
|
||||
[09:06:37] tally: 0 connected, 3 failed, 0 skipped | 3/27 done | elapsed 52s
|
||||
09:06:37 INFO mesh_scan: seed npub1h4e3dks08up: 543 target(s)
|
||||
09:06:37 INFO mesh_scan: === seed 4/27: npub1h4e3dks08up67cdtuhl ===
|
||||
|
||||
[09:06:37] seed 4/27: npub1h4e3dks08up67cdtuhl (543 targets)
|
||||
09:06:38 INFO fipsd: netns fips-scan-3: veth fsvh2<->fsvn2 subnet 10.250.3.0/30 nat via eth0
|
||||
09:06:38 INFO fipsd: spawning fipsd: netns=fips-scan-3 udp=39046 tcp=49811 dns=33881 sudo=True
|
||||
09:06:39 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-3
|
||||
[09:06:39] daemon up (2.0s)
|
||||
09:06:39 INFO mesh_scan: peering with seed npub1h4e3dks08up at 167.17.180.42:51820/udp
|
||||
09:06:54 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:06:54] peering FAILED (15.0s); skipping targets
|
||||
09:06:54 WARNING mesh_scan: could not peer with seed npub1h4e3dks08up; skipping targets
|
||||
[09:06:55] tally: 0 connected, 4 failed, 0 skipped | 4/27 done | elapsed 69s
|
||||
09:06:55 INFO mesh_scan: seed npub18ljnndsess6: 543 target(s)
|
||||
09:06:55 INFO mesh_scan: === seed 5/27: npub18ljnndsess6kvkpzapq ===
|
||||
|
||||
[09:06:55] seed 5/27: npub18ljnndsess6kvkpzapq (543 targets)
|
||||
09:06:55 INFO fipsd: netns fips-scan-4: veth fsvh3<->fsvn3 subnet 10.250.4.0/30 nat via eth0
|
||||
09:06:55 INFO fipsd: spawning fipsd: netns=fips-scan-4 udp=47487 tcp=44251 dns=34104 sudo=True
|
||||
09:06:57 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-4
|
||||
[09:06:57] daemon up (2.0s)
|
||||
09:06:57 INFO mesh_scan: peering with seed npub18ljnndsess6 at 195.86.38.35:51820/udp
|
||||
09:07:12 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:07:12] peering FAILED (15.0s); skipping targets
|
||||
09:07:12 WARNING mesh_scan: could not peer with seed npub18ljnndsess6; skipping targets
|
||||
[09:07:12] tally: 0 connected, 5 failed, 0 skipped | 5/27 done | elapsed 86s
|
||||
09:07:12 INFO mesh_scan: seed npub1veq2q3xryp2: 543 target(s)
|
||||
09:07:12 INFO mesh_scan: === seed 6/27: npub1veq2q3xryp2em5angt5 ===
|
||||
|
||||
[09:07:12] seed 6/27: npub1veq2q3xryp2em5angt5 (543 targets)
|
||||
09:07:12 INFO fipsd: netns fips-scan-5: veth fsvh4<->fsvn4 subnet 10.250.5.0/30 nat via eth0
|
||||
09:07:12 INFO fipsd: spawning fipsd: netns=fips-scan-5 udp=36420 tcp=56923 dns=38906 sudo=True
|
||||
09:07:14 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-5
|
||||
[09:07:14] daemon up (2.0s)
|
||||
09:07:14 INFO mesh_scan: peering with seed npub1veq2q3xryp2 at 8.222.128.129:51820/udp
|
||||
09:07:29 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:07:29] peering FAILED (15.0s); skipping targets
|
||||
09:07:29 WARNING mesh_scan: could not peer with seed npub1veq2q3xryp2; skipping targets
|
||||
[09:07:29] tally: 0 connected, 6 failed, 0 skipped | 6/27 done | elapsed 103s
|
||||
09:07:29 INFO mesh_scan: seed npub1ckskwukdlj5: 543 target(s)
|
||||
09:07:29 INFO mesh_scan: === seed 7/27: npub1ckskwukdlj5y9klezc3 ===
|
||||
|
||||
[09:07:29] seed 7/27: npub1ckskwukdlj5y9klezc3 (543 targets)
|
||||
09:07:29 INFO fipsd: netns fips-scan-6: veth fsvh5<->fsvn5 subnet 10.250.6.0/30 nat via eth0
|
||||
09:07:29 INFO fipsd: spawning fipsd: netns=fips-scan-6 udp=36740 tcp=45813 dns=37400 sudo=True
|
||||
09:07:31 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-6
|
||||
[09:07:31] daemon up (2.0s)
|
||||
09:07:31 INFO mesh_scan: peering with seed npub1ckskwukdlj5 at 107.10.122.197:48298/udp
|
||||
09:07:46 WARNING mesh_scan: seed did not connect within 15s
|
||||
[09:07:46] peering FAILED (15.0s); skipping targets
|
||||
09:07:46 WARNING mesh_scan: could not peer with seed npub1ckskwukdlj5; skipping targets
|
||||
[09:07:46] tally: 0 connected, 7 failed, 0 skipped | 7/27 done | elapsed 121s
|
||||
09:07:46 INFO mesh_scan: seed npub1k3aerhf3f4e: 543 target(s)
|
||||
09:07:46 INFO mesh_scan: === seed 8/27: npub1k3aerhf3f4ed9mrlu2z ===
|
||||
|
||||
[09:07:46] seed 8/27: npub1k3aerhf3f4ed9mrlu2z (543 targets)
|
||||
09:07:46 INFO fipsd: netns fips-scan-7: veth fsvh6<->fsvn6 subnet 10.250.7.0/30 nat via eth0
|
||||
09:07:46 INFO fipsd: spawning fipsd: netns=fips-scan-7 udp=49272 tcp=51565 dns=48209 sudo=True
|
||||
09:07:48 INFO mesh_scan: ephemeral daemon ready: netns=fips-scan-7
|
||||
[09:07:48] daemon up (2.1s)
|
||||
09:07:48 INFO mesh_scan: peering with seed npub1k3aerhf3f4e at 194.191.252.108:2121/udp
|
||||
09:07:49 INFO mesh_scan: seed peer connected
|
||||
09:08:49 INFO mesh_scan: converged in 60.0s
|
||||
09:08:49 INFO mesh_scan: seed state: peers=5 depth=2 root=(none) known=26
|
||||
[09:08:49] peered ok | peers=5 depth=2 known=26 | peer 60.5s + converge 60.0s
|
||||
09:08:51 INFO mesh_scan: target npub18t37rm05657: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:08:53 INFO mesh_scan: target npub1ke6ah04f3hl: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:08:55 INFO mesh_scan: target npub1gzzmfvhrls4: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:09:33 INFO mesh_scan: target npub1ggr79fd0q8l: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:10:11 INFO mesh_scan: target npub1422d47s46fy: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:10:50 INFO mesh_scan: target npub160flhw62unc: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:11:28 INFO mesh_scan: target npub1xk6cyq0fwjd: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:12:06 INFO mesh_scan: target npub17kta33jk7yd: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:12:44 INFO mesh_scan: target npub1swqqxn5zukw: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:13:22 INFO mesh_scan: target npub1e5upq7qkpxs: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:14:00 INFO mesh_scan: target npub1prrax6290k5: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:14:38 INFO mesh_scan: target npub1x6pjnrkt6s8: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:15:17 INFO mesh_scan: target npub1zt6qrdxad07: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:15:55 INFO mesh_scan: target npub14l59jjzm9ql: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:16:33 INFO mesh_scan: target npub1cgrzfakmccs: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:17:11 INFO mesh_scan: target npub13jjytpje4r5: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:17:49 INFO mesh_scan: target npub1pykqq7kujz4: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:18:27 INFO mesh_scan: target npub1p99dld9xgpg: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:19:05 INFO mesh_scan: target npub1fnlavfkk5sa: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:19:44 INFO mesh_scan: target npub1jfv63kg5deq: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:20:22 INFO mesh_scan: target npub1tctqu58prak: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:21:00 INFO mesh_scan: target npub1utkzpaf8lp7: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:21:38 INFO mesh_scan: target npub1mv72d55k8cq: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:22:16 INFO mesh_scan: target npub1996gsgmv2gj: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
09:22:54 INFO mesh_scan: target npub1tnm6dw2rthv: FAIL rtt=- method=ping6+tcp cache=False bloom=False
|
||||
726
mesh-scan/mesh_scan.py
Normal file
726
mesh-scan/mesh_scan.py
Normal file
@@ -0,0 +1,726 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FIPS Mesh Reachability Scanner.
|
||||
|
||||
Orchestrates the per-seed scan lifecycle described in
|
||||
[`plans/mesh-reachability-scanner.md`](../plans/mesh-reachability-scanner.md):
|
||||
|
||||
directory → seed selection → per-seed ephemeral daemon → peer with seed →
|
||||
converge → record diagnostics → per-target reachability check → output
|
||||
matrix + per-pair diagnostics JSON + summary.
|
||||
|
||||
Usage:
|
||||
python3 mesh_scan.py --seeds-sample 3 --converge-secs 30
|
||||
python3 mesh_scan.py --seeds npub1abc,npub1def --targets npub1ghi
|
||||
python3 mesh_scan.py --self-test # quick 2-node smoke test
|
||||
python3 mesh_scan.py --cleanup-stale
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Allow running both as a module and as a script.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import control # noqa: E402
|
||||
from control import ControlClient, ControlError # noqa: E402
|
||||
import directory # noqa: E402
|
||||
from directory import NodeEntry, fetch_directory, npub_to_ipv6 # noqa: E402
|
||||
import fipsd # noqa: E402
|
||||
from fipsd import EphemeralDaemon, spawn_daemon, cleanup_stale_tun_devices # noqa: E402
|
||||
import reachability # noqa: E402
|
||||
from reachability import check as reach_check # noqa: E402
|
||||
|
||||
log = logging.getLogger("mesh_scan")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedState:
|
||||
"""Diagnostics recorded about the ephemeral node after convergence."""
|
||||
npub: str
|
||||
peer_count: int = 0
|
||||
tree_state: str = ""
|
||||
estimated_mesh_size: int = 0
|
||||
seed_depth: int | None = None
|
||||
seed_parent: str = ""
|
||||
mesh_root: str = ""
|
||||
known_npubs: list[str] = field(default_factory=list)
|
||||
bloom_peer_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairResult:
|
||||
seed: str
|
||||
target: str
|
||||
reachable: bool
|
||||
rtt_ms: float | None
|
||||
loss_pct: int | None
|
||||
method: str
|
||||
target_in_identity_cache: bool
|
||||
target_in_seed_bloom: bool
|
||||
cached_coords: bool
|
||||
tree_distance: int | None
|
||||
seed_depth: int | None
|
||||
seed_parent: str
|
||||
mesh_root: str
|
||||
converge_secs: float
|
||||
detail: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed / target selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
"""Current wall-clock time as HH:MM:SS for progress prints."""
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
|
||||
def select_seeds(nodes: list[NodeEntry], args: argparse.Namespace) -> list[NodeEntry]:
|
||||
by_npub = {n.npub: n for n in nodes}
|
||||
# For automatic selection (all/sample/near-root), only consider seeds
|
||||
# with a dialable endpoint — nat-only nodes can't be peered with
|
||||
# directly, so scanning them just wastes time.
|
||||
dialable = [n for n in nodes if n.first_dialable_endpoint() is not None]
|
||||
|
||||
if args.seeds == "all":
|
||||
return dialable
|
||||
|
||||
if args.seeds and args.seeds != "all":
|
||||
chosen: list[NodeEntry] = []
|
||||
for npub in args.seeds.split(","):
|
||||
npub = npub.strip()
|
||||
if npub in by_npub:
|
||||
chosen.append(by_npub[npub])
|
||||
else:
|
||||
log.warning("seed %s not in directory, skipping", npub)
|
||||
return chosen
|
||||
|
||||
if args.seeds_near_root:
|
||||
return sorted(dialable, key=lambda n: npub_to_ipv6(n.npub))[:args.seeds_near_root]
|
||||
|
||||
if args.seeds_sample:
|
||||
k = min(args.seeds_sample, len(dialable))
|
||||
return random.sample(dialable, k) if k > 0 else []
|
||||
|
||||
# Default: a small sample of 5 from dialable nodes.
|
||||
k = min(5, len(dialable))
|
||||
return random.sample(dialable, k) if k > 0 else []
|
||||
|
||||
|
||||
def select_targets(nodes: list[NodeEntry], seed: NodeEntry, args: argparse.Namespace) -> list[NodeEntry]:
|
||||
if args.targets:
|
||||
wanted = {t.strip() for t in args.targets.split(",")}
|
||||
return [n for n in nodes if n.npub in wanted and n.npub != seed.npub]
|
||||
candidates = [n for n in nodes if n.npub != seed.npub]
|
||||
if args.targets_sample:
|
||||
k = min(args.targets_sample, len(candidates))
|
||||
return random.sample(candidates, k) if k > 0 else []
|
||||
return candidates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-seed lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _peer_connected(client: ControlClient, seed_npub: str) -> bool:
|
||||
try:
|
||||
peers = client.show_peers().get("peers", []) or []
|
||||
except ControlError:
|
||||
return False
|
||||
for p in peers:
|
||||
if p.get("npub") == seed_npub and p.get("connectivity") == "connected":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _peer_with_seed(daemon: EphemeralDaemon, seed: NodeEntry, timeout: float = 15.0) -> bool:
|
||||
ep = seed.first_dialable_endpoint()
|
||||
if ep is None:
|
||||
log.warning("seed %s has no dialable endpoint (only nat?)", seed.npub[:16])
|
||||
return False
|
||||
log.info("peering with seed %s at %s/%s", seed.npub[:16], ep.addr, ep.transport)
|
||||
try:
|
||||
daemon.client.connect(seed.npub, ep.addr, ep.transport)
|
||||
except ControlError as e:
|
||||
log.warning("connect command failed: %s", e)
|
||||
return False
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if _peer_connected(daemon.client, seed.npub):
|
||||
log.info("seed peer connected")
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
log.warning("seed did not connect within %.0fs", timeout)
|
||||
return False
|
||||
|
||||
|
||||
def _converged(client: ControlClient) -> tuple[bool, dict[str, Any]]:
|
||||
"""Check convergence criteria. Returns (converged, status_dict)."""
|
||||
try:
|
||||
status = client.show_status()
|
||||
except ControlError:
|
||||
return False, {}
|
||||
peer_count = status.get("peer_count", 0)
|
||||
if peer_count < 1:
|
||||
return False, status
|
||||
# tree_state isn't always present; treat non-empty tree with a root as stable.
|
||||
try:
|
||||
tree = client.show_tree()
|
||||
except ControlError:
|
||||
tree = {}
|
||||
root = tree.get("root")
|
||||
peer_tree_count = tree.get("peer_tree_count", 0)
|
||||
if not root or peer_tree_count < 1:
|
||||
return False, status
|
||||
return True, {"status": status, "tree": tree}
|
||||
|
||||
|
||||
def _wait_convergence(daemon: EphemeralDaemon, budget: float) -> tuple[float, dict[str, Any]]:
|
||||
"""Poll until converged or budget exhausted. Returns (elapsed_secs, snapshot).
|
||||
|
||||
Convergence requires:
|
||||
- tree is stable (has a root + peer tree entries)
|
||||
- identity cache has stopped growing for 3 consecutive 5s polls
|
||||
- identity cache has at least 2 entries (not just the direct peer)
|
||||
The minimum-count gate prevents premature exit before Nostr discovery
|
||||
has had time to populate the cache from the advert relays.
|
||||
"""
|
||||
start = time.time()
|
||||
deadline = start + budget
|
||||
last_snapshot: dict[str, Any] = {}
|
||||
stable_count = 0
|
||||
last_known_count = -1
|
||||
poll_interval = 5.0
|
||||
min_cache = 2
|
||||
while time.time() < deadline:
|
||||
converged, snap = _converged(daemon.client)
|
||||
if converged:
|
||||
try:
|
||||
ic = daemon.client.show_identity_cache().get("count", 0)
|
||||
except ControlError:
|
||||
ic = 0
|
||||
if ic == last_known_count and ic >= min_cache:
|
||||
stable_count += 1
|
||||
else:
|
||||
stable_count = 0
|
||||
last_known_count = ic
|
||||
if stable_count >= 3:
|
||||
last_snapshot = snap
|
||||
return time.time() - start, last_snapshot
|
||||
last_snapshot = snap
|
||||
time.sleep(poll_interval)
|
||||
return time.time() - start, last_snapshot
|
||||
|
||||
|
||||
def _record_seed_state(daemon: EphemeralDaemon, seed_npub: str) -> SeedState:
|
||||
try:
|
||||
status = daemon.client.show_status()
|
||||
except ControlError:
|
||||
status = {}
|
||||
try:
|
||||
tree = daemon.client.show_tree()
|
||||
except ControlError:
|
||||
tree = {}
|
||||
try:
|
||||
ic = daemon.client.show_identity_cache()
|
||||
except ControlError:
|
||||
ic = {}
|
||||
try:
|
||||
bloom = daemon.client.show_bloom()
|
||||
except ControlError:
|
||||
bloom = {}
|
||||
|
||||
known = [e.get("npub") for e in (ic.get("entries", []) or []) if e.get("npub")]
|
||||
return SeedState(
|
||||
npub=seed_npub,
|
||||
peer_count=status.get("peer_count", 0),
|
||||
tree_state=str(status.get("state", "")),
|
||||
estimated_mesh_size=status.get("estimated_mesh_size", 0),
|
||||
seed_depth=tree.get("depth"),
|
||||
seed_parent=tree.get("parent_display_name", "") or "",
|
||||
mesh_root=tree.get("root_npub", "") or "",
|
||||
known_npubs=known,
|
||||
bloom_peer_count=len(bloom.get("peer_filters", []) or []),
|
||||
)
|
||||
|
||||
|
||||
def _target_in_bloom(daemon: EphemeralDaemon, target_npub: str) -> bool:
|
||||
"""Best-effort: is the target's node_addr in any peer's bloom filter?
|
||||
|
||||
The bloom query doesn't expose per-element membership directly, so this
|
||||
is a heuristic: we treat 'target known to at least one peer filter' as
|
||||
a proxy. Falls back to False if unavailable.
|
||||
"""
|
||||
try:
|
||||
bloom = daemon.client.show_bloom()
|
||||
except ControlError:
|
||||
return False
|
||||
# show_bloom gives per-peer filter stats, not membership. We approximate
|
||||
# by checking the identity cache (a node we know about is more likely to
|
||||
# be in propagated filters). This is documented as a heuristic.
|
||||
try:
|
||||
ic = daemon.client.show_identity_cache()
|
||||
except ControlError:
|
||||
return False
|
||||
for e in ic.get("entries", []) or []:
|
||||
if e.get("npub") == target_npub:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _target_has_cached_coords(daemon: EphemeralDaemon, target_npub: str) -> bool:
|
||||
"""Inferred from show_routing: is the target in the coord cache?
|
||||
|
||||
show_routing gives a count, not per-target entries, so we approximate:
|
||||
if the target is in the identity cache AND there are coord_cache_entries,
|
||||
we assume coords may be cached. (Documented heuristic.)
|
||||
"""
|
||||
try:
|
||||
routing = daemon.client.show_routing()
|
||||
except ControlError:
|
||||
return False
|
||||
return routing.get("coord_cache_entries", 0) > 0 and _target_in_bloom(daemon, target_npub)
|
||||
|
||||
|
||||
def _tree_distance_to_target(daemon: EphemeralDaemon, target_npub: str) -> int | None:
|
||||
"""Tree distance to a target, if it appears in show_tree peers."""
|
||||
try:
|
||||
tree = daemon.client.show_tree()
|
||||
except ControlError:
|
||||
return None
|
||||
for p in tree.get("peers", []) or []:
|
||||
if p.get("display_name") == target_npub or p.get("npub") == target_npub:
|
||||
d = p.get("distance_to_us")
|
||||
if isinstance(d, (int, float)):
|
||||
return int(d)
|
||||
return None
|
||||
|
||||
|
||||
def scan_seed(
|
||||
seed: NodeEntry,
|
||||
targets: list[NodeEntry],
|
||||
index: int,
|
||||
args: argparse.Namespace,
|
||||
total_seeds: int = 0,
|
||||
) -> tuple[list[PairResult], SeedState | None]:
|
||||
"""Run the full per-seed lifecycle. Returns (pair_results, seed_state)."""
|
||||
log.info("=== seed %d/%d: %s ===", index + 1, total_seeds, seed.npub[:24])
|
||||
t0 = time.time()
|
||||
print(f"\n[{_ts()}] seed {index + 1}/{total_seeds}: {seed.npub[:24]} "
|
||||
f"({len(targets)} targets)", flush=True)
|
||||
pair_results: list[PairResult] = []
|
||||
# Skip seeds with no dialable endpoint (only nat) — we can't peer with
|
||||
# them directly, so there's no point spawning a daemon.
|
||||
if seed.first_dialable_endpoint() is None:
|
||||
print(f" [{_ts()}] skipped: no dialable endpoint (nat only) "
|
||||
f"({time.time()-t0:.1f}s)", flush=True)
|
||||
log.warning("seed %s has no dialable endpoint (only nat?); skipping", seed.npub[:16])
|
||||
return pair_results, None
|
||||
daemon: EphemeralDaemon | None = None
|
||||
try:
|
||||
daemon = spawn_daemon(tag=seed.npub[:12], index=index)
|
||||
daemon.wait_ready(args.startup_timeout)
|
||||
log.info("ephemeral daemon ready: netns=%s", daemon.netns)
|
||||
print(f" [{_ts()}] daemon up ({time.time()-t0:.1f}s)", flush=True)
|
||||
|
||||
t_peer = time.time()
|
||||
if not _peer_with_seed(daemon, seed, timeout=args.peer_timeout):
|
||||
print(f" [{_ts()}] peering FAILED ({time.time()-t_peer:.1f}s); "
|
||||
f"skipping targets", flush=True)
|
||||
log.warning("could not peer with seed %s; skipping targets", seed.npub[:16])
|
||||
return pair_results, None
|
||||
|
||||
t_conv = time.time()
|
||||
converge_secs, snap = _wait_convergence(daemon, args.converge_secs)
|
||||
log.info("converged in %.1fs", converge_secs)
|
||||
|
||||
seed_state = _record_seed_state(daemon, seed.npub)
|
||||
log.info("seed state: peers=%d depth=%s root=%s known=%d",
|
||||
seed_state.peer_count, seed_state.seed_depth,
|
||||
seed_state.mesh_root[:16] or "(none)", len(seed_state.known_npubs))
|
||||
print(f" [{_ts()}] peered ok | peers={seed_state.peer_count} "
|
||||
f"depth={seed_state.seed_depth} known={len(seed_state.known_npubs)} | "
|
||||
f"peer {time.time()-t_peer:.1f}s + converge {time.time()-t_conv:.1f}s",
|
||||
flush=True)
|
||||
|
||||
# Reachability checks run inside the daemon's netns so the fd00::/8
|
||||
# route resolves to the daemon's TUN, not the host's fips0.
|
||||
runner = daemon.run_in_netns
|
||||
for target in targets:
|
||||
target_ipv6 = npub_to_ipv6(target.npub)
|
||||
res = reach_check(target_ipv6, timeout=args.ping_timeout,
|
||||
tcp_port=args.tcp_port, prefer=args.method,
|
||||
runner=runner)
|
||||
in_cache = target.npub in seed_state.known_npubs
|
||||
in_bloom = _target_in_bloom(daemon, target.npub)
|
||||
cached_coords = _target_has_cached_coords(daemon, target.npub)
|
||||
tree_dist = _tree_distance_to_target(daemon, target.npub)
|
||||
|
||||
pr = PairResult(
|
||||
seed=seed.npub,
|
||||
target=target.npub,
|
||||
reachable=res.reachable,
|
||||
rtt_ms=res.rtt_ms,
|
||||
loss_pct=res.loss_pct,
|
||||
method=res.method,
|
||||
target_in_identity_cache=in_cache,
|
||||
target_in_seed_bloom=in_bloom,
|
||||
cached_coords=cached_coords,
|
||||
tree_distance=tree_dist,
|
||||
seed_depth=seed_state.seed_depth,
|
||||
seed_parent=seed_state.seed_parent,
|
||||
mesh_root=seed_state.mesh_root,
|
||||
converge_secs=converge_secs,
|
||||
detail=res.detail,
|
||||
)
|
||||
pair_results.append(pr)
|
||||
status = "ok" if pr.reachable else "FAIL"
|
||||
log.info(" target %s: %s rtt=%s method=%s cache=%s bloom=%s",
|
||||
target.npub[:16], status,
|
||||
f"{res.rtt_ms:.0f}ms" if res.rtt_ms else "-",
|
||||
res.method, in_cache, in_bloom)
|
||||
if args.delay > 0:
|
||||
time.sleep(args.delay)
|
||||
|
||||
# Per-seed summary line (always visible on stdout).
|
||||
ok_count = sum(1 for r in pair_results if r.reachable)
|
||||
total = len(pair_results)
|
||||
rate = 100 * ok_count / total if total else 0
|
||||
print(f" [{_ts()}] result: {ok_count}/{total} reachable ({rate:.0f}%) "
|
||||
f"| total {time.time()-t0:.1f}s", flush=True)
|
||||
return pair_results, seed_state
|
||||
|
||||
except Exception as e:
|
||||
log.exception("seed scan failed: %s", e)
|
||||
return pair_results, None
|
||||
finally:
|
||||
if daemon is not None:
|
||||
try:
|
||||
daemon.stop()
|
||||
except Exception as e:
|
||||
log.warning("daemon cleanup failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_matrix_csv(results: list[PairResult], path: Path) -> None:
|
||||
seeds = list(dict.fromkeys(r.seed for r in results))
|
||||
targets = list(dict.fromkeys(r.target for r in results))
|
||||
with path.open("w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["seed"] + [t[:16] for t in targets])
|
||||
for s in seeds:
|
||||
row = [s[:16]]
|
||||
for t in targets:
|
||||
pr = next((r for r in results if r.seed == s and r.target == t), None)
|
||||
if pr is None:
|
||||
row.append("")
|
||||
elif pr.reachable:
|
||||
row.append(f"ok/{pr.rtt_ms:.0f}ms" if pr.rtt_ms else "ok")
|
||||
else:
|
||||
row.append("fail")
|
||||
w.writerow(row)
|
||||
|
||||
|
||||
def write_diagnostics_jsonl(results: list[PairResult], path: Path) -> None:
|
||||
with path.open("w") as f:
|
||||
for pr in results:
|
||||
f.write(json.dumps(asdict(pr)) + "\n")
|
||||
|
||||
|
||||
def print_summary(results: list[PairResult], seed_states: dict[str, SeedState]) -> None:
|
||||
total = len(results)
|
||||
if total == 0:
|
||||
print("\nNo pairs scanned.")
|
||||
return
|
||||
ok = sum(1 for r in results if r.reachable)
|
||||
print("\n" + "=" * 60)
|
||||
print("MESH REACHABILITY SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"Seeds scanned: {len(seed_states)}")
|
||||
print(f"Total pairs: {total}")
|
||||
print(f"Reachable: {ok}/{total} ({100*ok/total:.1f}%)")
|
||||
|
||||
# Per-seed rates.
|
||||
print("\nPer-seed reachability:")
|
||||
for s, state in seed_states.items():
|
||||
seed_results = [r for r in results if r.seed == s]
|
||||
s_ok = sum(1 for r in seed_results if r.reachable)
|
||||
n = len(seed_results)
|
||||
rate = 100 * s_ok / n if n else 0
|
||||
print(f" {s[:24]} {s_ok}/{n} ({rate:.0f}%) peers={state.peer_count} depth={state.seed_depth}")
|
||||
|
||||
# Per-target rates.
|
||||
targets = list(dict.fromkeys(r.target for r in results))
|
||||
print("\nPer-target reachability (universally unreachable = NAT/firewall suspect):")
|
||||
for t in targets:
|
||||
t_results = [r for r in results if r.target == t]
|
||||
t_ok = sum(1 for r in t_results if r.reachable)
|
||||
n = len(t_results)
|
||||
rate = 100 * t_ok / n if n else 0
|
||||
flag = " <-- unreachable from all seeds" if t_ok == 0 and n > 0 else ""
|
||||
print(f" {t[:24]} {t_ok}/{n} ({rate:.0f}%){flag}")
|
||||
|
||||
# Failure-cause correlation.
|
||||
fails = [r for r in results if not r.reachable]
|
||||
if fails:
|
||||
cache_miss = sum(1 for r in fails if not r.target_in_identity_cache)
|
||||
bloom_miss = sum(1 for r in fails if not r.target_in_seed_bloom)
|
||||
coord_miss = sum(1 for r in fails if not r.cached_coords)
|
||||
print(f"\nFailure correlation ({len(fails)} failures):")
|
||||
print(f" identity-cache miss: {cache_miss}")
|
||||
print(f" bloom miss: {bloom_miss}")
|
||||
print(f" coord-cache miss: {coord_miss}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-test: two ephemeral nodes peered directly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def self_test(args: argparse.Namespace) -> int:
|
||||
"""Smoke test: spawn two ephemeral daemons, peer them, verify they communicate.
|
||||
|
||||
This validates the whole toolchain (daemon spawn, control socket, peering,
|
||||
reachability) without touching the public mesh. Both daemons share a
|
||||
single network namespace so they can peer over loopback (127.0.0.1).
|
||||
"""
|
||||
log.info("SELF-TEST: spawning two ephemeral daemons (shared netns) and peering them")
|
||||
shared_netns = "fips-scan-selftest"
|
||||
a = b = None
|
||||
try:
|
||||
# Create the shared namespace up front; both daemons join it.
|
||||
shared_info = fipsd.create_netns(shared_netns)
|
||||
a = spawn_daemon(tag="selftest-a", index=900, netns=shared_netns)
|
||||
a.wait_ready(args.startup_timeout)
|
||||
b = spawn_daemon(tag="selftest-b", index=901, netns=shared_netns)
|
||||
b.wait_ready(args.startup_timeout)
|
||||
|
||||
sa = a.client.show_status()
|
||||
sb = b.client.show_status()
|
||||
npub_a = sa.get("npub")
|
||||
npub_b = sb.get("npub")
|
||||
ipv6_a = sa.get("ipv6_addr")
|
||||
ipv6_b = sb.get("ipv6_addr")
|
||||
log.info("node A: npub=%s ipv6=%s netns=%s", npub_a, ipv6_a, a.netns)
|
||||
log.info("node B: npub=%s ipv6=%s netns=%s", npub_b, ipv6_b, b.netns)
|
||||
|
||||
if not (npub_a and npub_b and ipv6_a and ipv6_b):
|
||||
log.error("could not read both nodes' status")
|
||||
return 1
|
||||
|
||||
# Peer B → A using A's UDP port (loopback inside the shared netns).
|
||||
a_ep = f"127.0.0.1:{a.udp_port}"
|
||||
log.info("connecting B -> A at %s/udp", a_ep)
|
||||
try:
|
||||
b.client.connect(npub_a, a_ep, "udp")
|
||||
except ControlError as e:
|
||||
log.error("connect command failed: %s", e)
|
||||
return 1
|
||||
|
||||
# Wait for the peer to come up on both sides.
|
||||
deadline = time.time() + 20
|
||||
connected = False
|
||||
while time.time() < deadline:
|
||||
if _peer_connected(b.client, npub_a) and _peer_connected(a.client, npub_b):
|
||||
connected = True
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if not connected:
|
||||
log.error("nodes did not peer within 20s")
|
||||
return 1
|
||||
log.info("nodes peered (connected on both sides)")
|
||||
|
||||
# Give the tree a moment to settle.
|
||||
time.sleep(5)
|
||||
|
||||
# Reachability: try both directions. The ephemeral nodes are direct
|
||||
# peers, so find_next_hop branch 2 (direct peer) should deliver. We
|
||||
# try B->A and A->B because freshly-peered two-node trees can show
|
||||
# directional asymmetry before coord caches warm up; either
|
||||
# direction succeeding proves the overlay carries traffic.
|
||||
results = []
|
||||
for label, src, dst_ipv6 in [("B->A", b, ipv6_a), ("A->B", a, ipv6_b)]:
|
||||
log.info("ping6 %s (%s) from %s netns", label, dst_ipv6, src.netns)
|
||||
res = reachability.ping6(dst_ipv6, timeout=5, count=3, runner=src.run_in_netns)
|
||||
log.info("%s ping6: reachable=%s rtt=%s loss=%s",
|
||||
label, res.reachable, res.rtt_ms, res.loss_pct)
|
||||
results.append((label, res))
|
||||
if res.reachable:
|
||||
rtt_str = f"{res.rtt_ms:.3f}ms" if res.rtt_ms is not None else "n/a"
|
||||
print(f"\nSELF-TEST PASS: {label} ping6 succeeds "
|
||||
f"({dst_ipv6}) rtt={rtt_str}")
|
||||
return 0
|
||||
|
||||
# Fallback: TCP probe in each direction (ephemeral nodes don't serve
|
||||
# HTTP by default, so this is unlikely to succeed — included for
|
||||
# completeness).
|
||||
for label, src, dst_ipv6 in [("B->A", b, ipv6_a), ("A->B", a, ipv6_b)]:
|
||||
tcp_res = reachability.tcp_probe(dst_ipv6, port=80, timeout=5,
|
||||
runner=src.run_in_netns)
|
||||
if tcp_res.reachable:
|
||||
print(f"\nSELF-TEST PASS (tcp): {label} reaches {dst_ipv6} "
|
||||
f"rtt={tcp_res.rtt_ms:.1f}ms")
|
||||
return 0
|
||||
|
||||
print("\nSELF-TEST FAIL: neither direction reached the other via ping6.")
|
||||
for label, res in results:
|
||||
print(f" {label}: {res.detail[:160]}")
|
||||
print("Nodes peered successfully but ICMPv6 echo did not complete in either")
|
||||
print("direction. This can happen when coord caches are cold; the peering")
|
||||
print("itself succeeded, which validates spawn/control/peer toolchain.")
|
||||
return 2
|
||||
finally:
|
||||
for d in (b, a):
|
||||
if d is not None:
|
||||
try:
|
||||
d.stop()
|
||||
except Exception:
|
||||
pass
|
||||
# Delete the shared netns (daemons didn't own it).
|
||||
fipsd.delete_netns(shared_netns, info=shared_info)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="FIPS Mesh Reachability Scanner")
|
||||
p.add_argument("--seeds", help="'all' or comma-separated npubs")
|
||||
p.add_argument("--seeds-sample", type=int, help="random sample of K seeds")
|
||||
p.add_argument("--seeds-near-root", type=int, help="K seeds with smallest NodeAddr")
|
||||
p.add_argument("--targets", help="comma-separated target npubs (default: all except seed)")
|
||||
p.add_argument("--targets-sample", type=int, help="random sample of K targets per seed")
|
||||
p.add_argument("--converge-secs", type=float, default=45.0)
|
||||
p.add_argument("--ping-timeout", type=int, default=5)
|
||||
p.add_argument("--tcp-port", type=int, default=80, help="TCP fallback port")
|
||||
p.add_argument("--method", choices=["ping6", "tcp"], default="ping6")
|
||||
p.add_argument("--delay", type=float, default=0.0, help="seconds between target checks")
|
||||
p.add_argument("--startup-timeout", type=float, default=15.0)
|
||||
p.add_argument("--peer-timeout", type=float, default=15.0)
|
||||
p.add_argument("--relay", action="append", help="advert relay URL (repeatable)")
|
||||
p.add_argument("--relay-timeout", type=float, default=12.0)
|
||||
p.add_argument("--out-dir", default="mesh-scan-out", help="output directory")
|
||||
p.add_argument("--seed", type=int, default=None, help="RNG seed for --seeds-sample")
|
||||
p.add_argument("--self-test", action="store_true", help="run a 2-node smoke test and exit")
|
||||
p.add_argument("--cleanup-stale", action="store_true", help="remove fips-scan-* TUN devices and exit")
|
||||
p.add_argument("-v", "--verbose", action="count", default=0)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
level = logging.DEBUG if args.verbose >= 2 else (logging.INFO if args.verbose >= 1 else logging.WARNING)
|
||||
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S")
|
||||
|
||||
if args.cleanup_stale:
|
||||
n = fipsd.cleanup_stale_netns()
|
||||
print(f"removed {n} stale namespace(s)")
|
||||
return 0
|
||||
|
||||
# Auto-cleanup any stale namespaces from previous runs before starting.
|
||||
n = fipsd.cleanup_stale_netns()
|
||||
if n:
|
||||
print(f"Auto-cleanup: removed {n} stale namespace(s) from previous run(s).")
|
||||
|
||||
if args.self_test:
|
||||
try:
|
||||
return self_test(args)
|
||||
finally:
|
||||
fipsd.cleanup_stale_netns()
|
||||
|
||||
if args.seed is not None:
|
||||
random.seed(args.seed)
|
||||
|
||||
log.info("fetching FIPS node directory")
|
||||
nodes = fetch_directory(args.relay, args.relay_timeout)
|
||||
print(f"Fetched {len(nodes)} nodes from the directory.")
|
||||
if not nodes:
|
||||
print("No nodes found. Exiting.")
|
||||
return 1
|
||||
|
||||
seeds = select_seeds(nodes, args)
|
||||
if not seeds:
|
||||
print("No seeds selected. Exiting.")
|
||||
return 1
|
||||
print(f"Selected {len(seeds)} seed(s): {[s.npub[:16] for s in seeds]}")
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
all_results: list[PairResult] = []
|
||||
seed_states: dict[str, SeedState] = {}
|
||||
tally = {"connected": 0, "failed": 0, "skipped": 0}
|
||||
scan_start = time.time()
|
||||
matrix_path = out_dir / "matrix.csv"
|
||||
diag_path = out_dir / "diagnostics.jsonl"
|
||||
|
||||
# Write diagnostics incrementally (append after each seed) so a Ctrl-C
|
||||
# doesn't lose all data. The matrix is rewritten (overwrite) each time
|
||||
# since it's a full table.
|
||||
diag_path.unlink(missing_ok=True)
|
||||
|
||||
def write_incremental() -> None:
|
||||
write_matrix_csv(all_results, matrix_path)
|
||||
write_diagnostics_jsonl(all_results, diag_path)
|
||||
|
||||
interrupted = False
|
||||
try:
|
||||
for i, seed in enumerate(seeds):
|
||||
targets = select_targets(nodes, seed, args)
|
||||
log.info("seed %s: %d target(s)", seed.npub[:16], len(targets))
|
||||
results, state = scan_seed(seed, targets, i, args, total_seeds=len(seeds))
|
||||
all_results.extend(results)
|
||||
if state is not None:
|
||||
seed_states[seed.npub] = state
|
||||
tally["connected"] += 1
|
||||
elif not results:
|
||||
tally["failed" if seed.first_dialable_endpoint() else "skipped"] += 1
|
||||
else:
|
||||
tally["connected"] += 1
|
||||
# Write incremental output after each seed.
|
||||
write_incremental()
|
||||
elapsed = time.time() - scan_start
|
||||
print(f"[{_ts()}] tally: {tally['connected']} connected, "
|
||||
f"{tally['failed']} failed, {tally['skipped']} skipped | "
|
||||
f"{i+1}/{len(seeds)} done | elapsed {elapsed:.0f}s", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
print("\n[INTERRUPTED] Writing collected data and exiting...", flush=True)
|
||||
|
||||
write_incremental()
|
||||
print_summary(all_results, seed_states)
|
||||
if interrupted:
|
||||
print(f"\n[INTERRUPTED] Partial results: {len(all_results)} pairs from "
|
||||
f"{len(seed_states)} seeds.")
|
||||
print(f"\nOutputs written to {out_dir}/ (matrix.csv, diagnostics.jsonl)")
|
||||
# Auto-cleanup any leaked namespaces on exit.
|
||||
fipsd.cleanup_stale_netns()
|
||||
return 0 if not interrupted else 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
188
mesh-scan/reachability.py
Normal file
188
mesh-scan/reachability.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Reachability checks for FIPS mesh targets.
|
||||
|
||||
Builds on the pattern in
|
||||
[`test_fips.sh`](../fips_playground/test_fips.sh) (curl over ``.fips``) and
|
||||
[`test_qube_pair.sh`](../fips_playground/test_qube_pair.sh) (ping6 / TCP
|
||||
probe). Two methods:
|
||||
|
||||
- **Primary**: ``ping6 -c 3 -W <timeout> <target.ipv6>`` through the
|
||||
ephemeral daemon's TUN. Records success/fail/RTT/loss.
|
||||
- **Fallback**: open a TCP connection to ``[<target.ipv6>]:<port>`` with a
|
||||
short timeout (used when ICMPv6 is unavailable or filtered).
|
||||
|
||||
The target IPv6 is computed directly from the npub via
|
||||
[`directory.npub_to_ipv6`](directory.py) so we don't depend on the daemon's
|
||||
DNS resolver being wired into the host's ``/etc/resolv.conf``.
|
||||
|
||||
Because each ephemeral daemon runs in its own network namespace (see
|
||||
[`fipsd.py`](fipsd.py)), the checks are executed *inside* that namespace via
|
||||
``ip netns exec`` so the ``fd00::/8`` route resolves to the daemon's TUN
|
||||
rather than the host's ``fips0``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A runner executes a command list and returns a CompletedProcess.
|
||||
# Defaults to a plain subprocess.run; fipsd.EphemeralDaemon.run_in_netns
|
||||
# wraps it in `ip netns exec <netns>`.
|
||||
Runner = Callable[[list[str]], subprocess.CompletedProcess]
|
||||
|
||||
|
||||
def _default_runner(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=30)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReachabilityResult:
|
||||
reachable: bool
|
||||
method: str # "ping6" | "tcp" | "none"
|
||||
rtt_ms: float | None = None
|
||||
loss_pct: int | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def ping6(target_ipv6: str, timeout: int = 5, count: int = 3,
|
||||
runner: Optional[Runner] = None) -> ReachabilityResult:
|
||||
"""Ping the target's fd00:: IPv6 through the daemon's TUN.
|
||||
|
||||
``runner`` executes the command inside the daemon's network namespace
|
||||
(so the ``fd00::/8`` route hits the right TUN). If omitted, runs on the
|
||||
host (only correct when the host has no competing ``fd00::/8`` route).
|
||||
"""
|
||||
cmd = ["ping6", "-c", str(count), "-W", str(timeout), target_ipv6]
|
||||
run = runner or _default_runner
|
||||
log.debug("ping6: %s", " ".join(shlex.quote(c) for c in cmd))
|
||||
try:
|
||||
proc = run(cmd)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ReachabilityResult(False, "ping6", detail="ping6 command timed out")
|
||||
except FileNotFoundError:
|
||||
return ReachabilityResult(False, "ping6", detail="ping6 not installed")
|
||||
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
if proc.returncode == 0:
|
||||
rtt = _parse_rtt(out)
|
||||
loss = _parse_loss(out)
|
||||
return ReachabilityResult(True, "ping6", rtt_ms=rtt, loss_pct=loss, detail=out.strip())
|
||||
|
||||
loss = _parse_loss(out)
|
||||
return ReachabilityResult(False, "ping6", rtt_ms=None, loss_pct=loss, detail=out.strip())
|
||||
|
||||
|
||||
def tcp_probe(target_ipv6: str, port: int = 80, timeout: float = 5.0,
|
||||
runner: Optional[Runner] = None) -> ReachabilityResult:
|
||||
"""Open a TCP connection to ``[target_ipv6]:port``.
|
||||
|
||||
When ``runner`` is given (netns), the probe is done with a small Python
|
||||
one-liner executed inside the namespace so the source address and
|
||||
routing come from the daemon's TUN. When omitted, a direct socket
|
||||
connect is attempted on the host.
|
||||
"""
|
||||
if runner is not None:
|
||||
script = (
|
||||
"import socket,sys,time;"
|
||||
"s=socket.socket(socket.AF_INET6,socket.SOCK_STREAM);"
|
||||
f"s.settimeout({timeout});"
|
||||
"t=time.time();"
|
||||
f"rc=s.connect_ex(('{target_ipv6}',{port},0,0));"
|
||||
"dt=(time.time()-t)*1000.0;"
|
||||
"print(rc,dt);s.close()"
|
||||
)
|
||||
cmd = ["python3", "-c", script]
|
||||
log.debug("tcp(netns): [%s]:%d", target_ipv6, port)
|
||||
try:
|
||||
proc = runner(cmd)
|
||||
except Exception as e:
|
||||
return ReachabilityResult(False, "tcp", detail=f"netns tcp runner failed: {e}")
|
||||
out = (proc.stdout or "").strip()
|
||||
try:
|
||||
rc_str, dt_str = out.split()
|
||||
rc = int(rc_str)
|
||||
rtt = float(dt_str)
|
||||
except Exception:
|
||||
return ReachabilityResult(False, "tcp", detail=f"tcp parse failed: {out!r}")
|
||||
if rc == 0:
|
||||
return ReachabilityResult(True, "tcp", rtt_ms=rtt, loss_pct=0,
|
||||
detail=f"connected to [{target_ipv6}]:{port}")
|
||||
return ReachabilityResult(False, "tcp", detail=f"tcp connect_ex rc={rc}")
|
||||
|
||||
# Host-side direct connect.
|
||||
log.debug("tcp: [%s]:%d", target_ipv6, port)
|
||||
try:
|
||||
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
start = time.time()
|
||||
s.connect((target_ipv6, port, 0, 0))
|
||||
rtt = (time.time() - start) * 1000.0
|
||||
return ReachabilityResult(True, "tcp", rtt_ms=rtt, loss_pct=0,
|
||||
detail=f"connected to [{target_ipv6}]:{port}")
|
||||
except socket.timeout:
|
||||
return ReachabilityResult(False, "tcp", detail="tcp connect timed out")
|
||||
except OSError as e:
|
||||
return ReachabilityResult(False, "tcp", detail=f"tcp connect failed: {e}")
|
||||
|
||||
|
||||
def check(target_ipv6: str, timeout: int = 5, tcp_port: int = 80,
|
||||
prefer: str = "ping6", runner: Optional[Runner] = None) -> ReachabilityResult:
|
||||
"""Run the primary check, fall back to TCP on failure.
|
||||
|
||||
``prefer`` may be ``"ping6"`` (default) or ``"tcp"``. ``runner``
|
||||
executes commands inside the ephemeral daemon's network namespace.
|
||||
"""
|
||||
if prefer == "tcp":
|
||||
return tcp_probe(target_ipv6, tcp_port, timeout, runner=runner)
|
||||
|
||||
res = ping6(target_ipv6, timeout=timeout, runner=runner)
|
||||
if res.reachable:
|
||||
return res
|
||||
# Fall back to TCP.
|
||||
tcp_res = tcp_probe(target_ipv6, tcp_port, timeout, runner=runner)
|
||||
if tcp_res.reachable:
|
||||
return tcp_res
|
||||
# Return the ping result as the canonical failure (it's the more
|
||||
# informative one for mesh diagnostics), but note the TCP fallback.
|
||||
res.detail = (res.detail + " | tcp_fallback: " + tcp_res.detail).strip()
|
||||
res.method = "ping6+tcp"
|
||||
return res
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ping6 output parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RTT_RE = re.compile(r"rtt min/avg/max/mdev\s*=\s*[\d.]+/([\d.]+)/[\d.]+/[\d.]+\s*ms")
|
||||
_RTT_ROUNDTRIP_RE = re.compile(r"round-trip min/avg/max\s*=\s*[\d.]+/([\d.]+)/[\d.]+\s*ms")
|
||||
_LOSS_RE = re.compile(r"(\d+)% packet loss")
|
||||
|
||||
|
||||
def _parse_rtt(out: str) -> float | None:
|
||||
for rx in (_RTT_RE, _RTT_ROUNDTRIP_RE):
|
||||
m = rx.search(out)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_loss(out: str) -> int | None:
|
||||
m = _LOSS_RE.search(out)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
4
mesh-scan/requirements.txt
Normal file
4
mesh-scan/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
websocket-client>=1.6.0
|
||||
pyyaml>=6.0
|
||||
# coincurve is optional — directory.py implements bech32 (npub) encoding
|
||||
# internally so no secp256k1 dependency is required.
|
||||
529
plans/fips-explained.md
Normal file
529
plans/fips-explained.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# How FIPS Works — A Freshman-Level Explanation
|
||||
|
||||
FIPS (Freedom Internet Protocol Stack) is a **mesh network** — a way for
|
||||
computers to talk to each other directly, without going through a central
|
||||
server like Google or your ISP. Instead of connecting to a website through
|
||||
a data center, your computer connects to a few nearby computers, which
|
||||
connect to other computers, which connect to other computers, and so on.
|
||||
Messages hop from computer to computer until they reach their destination.
|
||||
|
||||
Think of it like passing a note in class: you hand it to the person next
|
||||
to you, they hand it to the next person, and eventually it reaches the
|
||||
person it's addressed to. Except in FIPS, the "note" is encrypted, the
|
||||
"people" are computers running the FIPS software, and the "classroom" is
|
||||
the entire internet.
|
||||
|
||||
This document explains how FIPS does four things:
|
||||
|
||||
1. **Discovery** — how nodes find each other
|
||||
2. **Connection** — how nodes establish secure links
|
||||
3. **Topology** — how nodes organize themselves into a network
|
||||
4. **Routing** — how messages get from A to B through the mesh
|
||||
|
||||
---
|
||||
|
||||
## The Big Picture
|
||||
|
||||
```
|
||||
THE INTERNET
|
||||
(the underlay)
|
||||
|
|
||||
+--------------+--------------+
|
||||
| | |
|
||||
+--------+ +--------+ +--------+
|
||||
| Node A |-----| Node B |-----| Node C |
|
||||
+--------+ +--------+ +--------+
|
||||
| | |
|
||||
| | |
|
||||
+--------+ +--------+ +--------+
|
||||
| Node D |-----| Node E | | Node F |
|
||||
+--------+ +--------+ +--------+
|
||||
|
|
||||
+--------+
|
||||
| Node G |
|
||||
+--------+
|
||||
|
||||
Each line = a direct encrypted connection (a "link")
|
||||
Each box = a computer running fipsd (the FIPS daemon)
|
||||
```
|
||||
|
||||
Every node in FIPS runs a program called **`fipsd`** (the FIPS daemon).
|
||||
It runs in the background, maintains connections to a few other nodes,
|
||||
and routes messages through the mesh. Each node has a unique identity
|
||||
(a cryptographic keypair) and a unique address on the mesh.
|
||||
|
||||
---
|
||||
|
||||
## 1. Discovery: How Nodes Find Each Other
|
||||
|
||||
The first problem: how does a new node, starting from nothing, find
|
||||
other FIPS nodes to connect to? You can't route through the mesh yet
|
||||
because you're not in it.
|
||||
|
||||
### The Nostr Rendezvous
|
||||
|
||||
FIPS uses **Nostr** (a decentralized social protocol) as a bulletin
|
||||
board where nodes post "hello, I'm here" messages. Nostr itself is just
|
||||
a network of relay servers that store and forward events — think of them
|
||||
as public message boards.
|
||||
|
||||
```
|
||||
+----------+ "I'm Node A, +----------+
|
||||
| Node A |-----> reach me at | Nostr |
|
||||
| (new) | 1.2.3.4:2121" ----->| Relays |
|
||||
+----------+ (Kind 37195 event) +----------+
|
||||
|
|
||||
+---------------+
|
||||
| (stores the
|
||||
| advert for
|
||||
| ~1 hour)
|
||||
|
|
||||
+---------------+----------+
|
||||
| |
|
||||
v v
|
||||
+----------+ +----------+
|
||||
| Node B | | Node C |
|
||||
| (reads | | (reads |
|
||||
| adverts)| | adverts)|
|
||||
+----------+ +----------+
|
||||
```
|
||||
|
||||
Here's what happens:
|
||||
|
||||
1. **Node A publishes an advert.** Its `fipsd` creates a Nostr event
|
||||
(kind 37195) containing:
|
||||
- Its identity (public key / npub)
|
||||
- Its endpoint address (e.g., `1.2.3.4:2121` for a public IP, or
|
||||
`nat` if behind a firewall)
|
||||
- Its signal relays (which Nostr relays to use for hole-punching)
|
||||
- Its STUN servers (for NAT traversal)
|
||||
- An expiration time (~1 hour)
|
||||
|
||||
2. **The Nostr relays store it.** Three relays by default:
|
||||
`wss://relay.damus.io`, `wss://nos.lol`, `wss://offchain.pub`.
|
||||
They keep the advert until it expires (~1 hour), then delete it.
|
||||
|
||||
3. **Node A republishes every ~30 minutes.** This keeps the advert
|
||||
fresh. If Node A goes offline, its advert expires within an hour
|
||||
and disappears from the relays.
|
||||
|
||||
4. **Other nodes read the adverts.** Their `fipsd` subscribes to the
|
||||
same relays and receives every advert in real time. They cache them
|
||||
in memory and learn "Node A exists, and here's how to reach it."
|
||||
|
||||
### The Two Cases: Public IP vs. NAT
|
||||
|
||||
**If Node A has a public IP** (e.g., a VPS in a data center):
|
||||
- Its advert says `endpoints: [{transport: "udp", addr: "1.2.3.4:2121"}]`
|
||||
- Other nodes can just send UDP packets directly to that address
|
||||
- Simple — no extra steps needed
|
||||
|
||||
**If Node A is behind NAT** (e.g., a home computer behind a router):
|
||||
- Its advert says `endpoints: [{transport: "udp", addr: "nat"}]`
|
||||
- "nat" means "I can't be dialed directly — you need to help me punch
|
||||
a hole through my firewall"
|
||||
- This requires a **NAT traversal** dance (explained below)
|
||||
|
||||
### NAT Traversal (Hole-Punching)
|
||||
|
||||
When Node B wants to connect to Node A, but Node A is behind NAT:
|
||||
|
||||
```
|
||||
Node B Nostr Relays Node A
|
||||
(behind NAT too, (the matchmaker) (behind NAT)
|
||||
or maybe not)
|
||||
| | |
|
||||
| 1. "Hey Node A, | |
|
||||
| I want to connect" | |
|
||||
|----- (encrypted offer -->| |
|
||||
| via Nostr DM) |-------> offer to A ----->|
|
||||
| | |
|
||||
| |<----- answer from A -----|
|
||||
|<-- answer (encrypted) <--| 2. "OK, let's punch" |
|
||||
| | |
|
||||
| 3. Both ask STUN: | 3. Both ask STUN:
|
||||
| "What's my public IP?" | "What's my public IP?"
|
||||
|---------> STUN server | -------> STUN server
|
||||
|<--------- "You are | <------- "You are
|
||||
| 5.6.7.8:9999" | 9.10.11.12:8888"
|
||||
| | |
|
||||
| 4. Node B sends UDP | 4. Node A sends UDP |
|
||||
| to 9.10.11.12:8888 | to 5.6.7.8:9999 |
|
||||
|-----> (hole punch!) ---->|-----> (hole punch!) ---->|
|
||||
| | |
|
||||
| 5. The NAT firewalls | 5. The NAT firewalls |
|
||||
| see outbound traffic | see outbound traffic|
|
||||
| and allow replies | and allow replies |
|
||||
| | |
|
||||
| <======== UDP tunnel now open =======> |
|
||||
| (encrypted with Noise IK) |
|
||||
```
|
||||
|
||||
The key insight: both nodes send UDP packets to each other's public
|
||||
addresses (discovered via STUN). When Node B sends a packet to Node A's
|
||||
NAT, the NAT sees it as a reply to Node A's outbound packet and lets it
|
||||
through. The Nostr relays act as the "matchmaker" that lets them
|
||||
exchange public addresses before the hole-punch.
|
||||
|
||||
Once the UDP tunnel is open, the **Noise IK handshake** runs over it to
|
||||
authenticate both sides and establish encryption keys. After that, the
|
||||
link is a normal encrypted peer connection — Nostr is no longer needed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Connection: How Nodes Establish Secure Links
|
||||
|
||||
Every direct connection between two FIPS nodes is encrypted using the
|
||||
**Noise Protocol Framework** (specifically the Noise IK pattern, the
|
||||
same crypto family used by WireGuard and Signal).
|
||||
|
||||
```
|
||||
Node A Node B
|
||||
(initiator) (responder)
|
||||
| |
|
||||
| msg1: A's ephemeral public key |
|
||||
| (encrypted with B's static public key) |
|
||||
|--------------------------------------------->|
|
||||
| |
|
||||
| msg2: B's ephemeral public key
|
||||
| + B's static public key (encrypted)
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| Both sides now have: |
|
||||
| - Shared secret (via Diffie-Hellman) |
|
||||
| - Each other's identity (public key) |
|
||||
| - Symmetric encryption keys |
|
||||
| |
|
||||
| <=== Encrypted link established ===> |
|
||||
| All future traffic is encrypted and |
|
||||
| authenticated |
|
||||
```
|
||||
|
||||
The important properties:
|
||||
- **Authentication**: both sides prove they own their private keys
|
||||
(which match their npub / Nostr identity)
|
||||
- **Encryption**: all traffic is encrypted with modern symmetric ciphers
|
||||
- **Forward secrecy**: even if a key is compromised later, past traffic
|
||||
stays secure
|
||||
- **Identity binding**: you know exactly who you're talking to — no
|
||||
man-in-the-middle possible if you trust the Nostr advert's pubkey
|
||||
|
||||
Once this handshake completes, the two nodes are **peers** — they have
|
||||
an authenticated, encrypted link they can send mesh traffic over.
|
||||
|
||||
---
|
||||
|
||||
## 3. Topology: How Nodes Organize Into a Network
|
||||
|
||||
Once a node has a few peer connections, it needs to know the shape of
|
||||
the entire mesh. Not just "who am I connected to" but "who is everyone
|
||||
else, and how do I reach them?"
|
||||
|
||||
FIPS builds a **spanning tree** — a logical structure that connects all
|
||||
nodes without loops, like a family tree or an org chart.
|
||||
|
||||
### The Spanning Tree
|
||||
|
||||
```
|
||||
[Root]
|
||||
Node 00001a...
|
||||
(smallest NodeAddr
|
||||
in the network)
|
||||
/ \
|
||||
/ \
|
||||
[depth 1] [depth 1]
|
||||
Node B Node C
|
||||
/ \ |
|
||||
/ \ |
|
||||
[depth 2] [depth 2] [depth 2]
|
||||
Node D Node E Node F
|
||||
|
|
||||
[depth 3]
|
||||
Node G (us)
|
||||
Node H
|
||||
|
||||
Each node has exactly one PARENT (closer to root)
|
||||
A node may have multiple CHILDREN (farther from root)
|
||||
The root is the node with the smallest NodeAddr
|
||||
(a 16-byte hash derived from its public key)
|
||||
```
|
||||
|
||||
**How the tree is built:**
|
||||
|
||||
1. Every node computes its **NodeAddr** = first 16 bytes of
|
||||
SHA-256(public key). This is a random-looking number that's unique
|
||||
to each node.
|
||||
|
||||
2. The node with the **smallest NodeAddr** is the **root**. This is
|
||||
deterministic — everyone agrees on who the root is without any
|
||||
election or voting.
|
||||
|
||||
3. Each node picks its **parent** = the peer that's closest to the root
|
||||
(shallowest depth). If you're not the root, your parent is someone
|
||||
who's one step closer to the root than you.
|
||||
|
||||
4. Nodes announce their tree position to their peers using
|
||||
**TreeAnnounce** messages: "I am Node G, my parent is Node D, my
|
||||
path to root is [G → D → B → Root]."
|
||||
|
||||
5. When a node receives a TreeAnnounce, it may discover a better parent
|
||||
(one with shallower depth) and switch. This is how the tree
|
||||
self-organizes and heals when nodes join/leave.
|
||||
|
||||
```
|
||||
TreeAnnounce flow (direct peer-to-peer, NOT via Nostr):
|
||||
|
||||
Node G --"my parent is D, depth 3"--> Node D (its parent)
|
||||
Node D --"my parent is B, depth 2"--> Node B (its parent)
|
||||
Node B --"my parent is Root, depth 1"--> Root
|
||||
|
||||
And in the other direction (to children):
|
||||
Node D --"I'm at depth 2, parent B"--> Node G (its child)
|
||||
Node B --"I'm at depth 1, parent Root"--> Node D (its child)
|
||||
```
|
||||
|
||||
TreeAnnounce messages are sent:
|
||||
- On initial connection (after handshake)
|
||||
- When a node's parent changes
|
||||
- Periodically (every ~60 seconds) as a refresh
|
||||
- Rate-limited to 1 per 500ms per peer to prevent storms
|
||||
|
||||
### Why a Spanning Tree?
|
||||
|
||||
The tree gives every node a **path to every other node**: go up to your
|
||||
common ancestor, then down to the destination. It also prevents routing
|
||||
loops (there's exactly one path between any two nodes in a tree).
|
||||
|
||||
But the tree alone isn't enough for efficient routing — going all the
|
||||
way up to the root and back down is slow for distant nodes. That's where
|
||||
bloom filters come in (next section).
|
||||
|
||||
---
|
||||
|
||||
## 4. Routing: How Messages Get From A to B
|
||||
|
||||
### The Problem
|
||||
|
||||
Node G wants to send a message to Node F. G is only connected to Node D.
|
||||
How does the message get to F?
|
||||
|
||||
```
|
||||
G wants to send to F:
|
||||
|
||||
Root
|
||||
/ \
|
||||
B C
|
||||
/ \ |
|
||||
D E F <-- destination
|
||||
|
|
||||
G <-- source
|
||||
H
|
||||
```
|
||||
|
||||
The naive approach: send the message to G's parent (D), D sends to its
|
||||
parent (B), B sends to Root, Root sends to C, C sends to F. That's 5
|
||||
hops — and it forces all traffic through the root, which doesn't scale.
|
||||
|
||||
FIPS does better using **bloom filters**.
|
||||
|
||||
### Bloom Filters: Who Can Reach Whom?
|
||||
|
||||
A **bloom filter** is a compact data structure that can answer "is X in
|
||||
this set?" without storing the whole set. It's probabilistic — it can
|
||||
say "definitely not" or "maybe yes" (with a small false-positive rate).
|
||||
A bloom filter for 500 nodes is about 1 KB.
|
||||
|
||||
Every FIPS node maintains a bloom filter of **which nodes are reachable
|
||||
through it** (itself + everything in its subtree). It sends this filter
|
||||
to its direct peers via **FilterAnnounce** messages.
|
||||
|
||||
```
|
||||
Each node tells its peers: "here's who I can reach"
|
||||
|
||||
Node G's filter: {G, H} (just itself + children)
|
||||
Node D's filter: {D, G, H} (itself + its subtree)
|
||||
Node B's filter: {B, D, E, G, H} (itself + its subtree)
|
||||
Node C's filter: {C, F} (itself + its subtree)
|
||||
Root's filter: {everyone} (it's the root, sees all)
|
||||
|
||||
Filters propagate UP the tree and are shared with all peers:
|
||||
Node D tells B: "I can reach {D, G, H}"
|
||||
Node B tells Root: "I can reach {B, D, E, G, H}"
|
||||
Node C tells Root: "I can reach {C, F}"
|
||||
```
|
||||
|
||||
Now every node knows, for each of its direct peers, a compact summary of
|
||||
who's reachable through that peer. When Node G needs to send to Node F:
|
||||
|
||||
```
|
||||
G wants to send to F:
|
||||
|
||||
G checks its peers' bloom filters:
|
||||
- Peer D's filter contains F? No (D can reach {D,G,H})
|
||||
- (G only has one peer, so it sends to D anyway)
|
||||
|
||||
G --> D: "forward this to F"
|
||||
|
||||
D checks its peers' bloom filters:
|
||||
- Peer B's filter contains F? No (B can reach {B,D,E,G,H})
|
||||
- (D only has one peer besides G, so it sends to B)
|
||||
|
||||
D --> B: "forward this to F"
|
||||
|
||||
B checks its peers' bloom filters:
|
||||
- Peer Root's filter contains F? Yes! (Root can reach everyone)
|
||||
- Peer E's filter contains F? No
|
||||
|
||||
B --> Root: "forward this to F"
|
||||
|
||||
Root checks its peers' bloom filters:
|
||||
- Peer B's filter contains F? No
|
||||
- Peer C's filter contains F? Yes!
|
||||
|
||||
Root --> C: "forward this to F"
|
||||
|
||||
C --> F: "here's your message"
|
||||
```
|
||||
|
||||
The bloom filters let each node make an **informed decision** about
|
||||
which peer to forward to, instead of blindly sending everything to its
|
||||
parent. This keeps traffic off the root when possible and makes routing
|
||||
efficient.
|
||||
|
||||
### Coordinate Discovery: Finding the Exact Path
|
||||
|
||||
Bloom filters tell you *which peer* to forward to, but not the exact
|
||||
path. For that, FIPS uses **coordinate discovery**:
|
||||
|
||||
```
|
||||
G wants to send to F (first time — no cached coordinates):
|
||||
|
||||
1. G sends a LookupRequest: "Where is F?"
|
||||
(flooded through the tree, guided by bloom filters —
|
||||
only forwarded to peers whose filter contains F)
|
||||
|
||||
G --> D --> B --> Root --> C --> F
|
||||
|
||||
2. F responds with a LookupResponse: "I'm here, my coordinates are
|
||||
[F, C, Root, B, D, G]"
|
||||
(sent back along the reverse path)
|
||||
|
||||
F --> C --> Root --> B --> D --> G
|
||||
|
||||
3. G caches F's coordinates in its coord cache.
|
||||
Next time G sends to F, it knows the path and can route directly
|
||||
without another lookup.
|
||||
```
|
||||
|
||||
The coordinate cache is like a phone book — once you've looked up
|
||||
someone's number, you don't need to look it up again. Entries expire
|
||||
after 5 minutes (default), so if the network topology changes, stale
|
||||
routes get refreshed.
|
||||
|
||||
### Putting It All Together: A Message's Journey
|
||||
|
||||
```
|
||||
Node G sends a web request to Node F's .fips address:
|
||||
|
||||
1. G's application: "GET http://npub1...fxf.fips/index.html"
|
||||
|
||||
2. G's fipsd:
|
||||
- Derives F's mesh IPv6 address from its npub (fd00::...)
|
||||
- Checks coord cache: do I have F's coordinates?
|
||||
- Yes: route directly using cached path
|
||||
- No: send LookupRequest, wait for response, cache it
|
||||
|
||||
3. G's fipsd wraps the HTTP request in an encrypted session
|
||||
datagram and sends it hop-by-hop:
|
||||
|
||||
G --[encrypted link]--> D --[encrypted link]--> B
|
||||
B --[encrypted link]--> Root --[encrypted link]--> C
|
||||
C --[encrypted link]--> F
|
||||
|
||||
Each hop:
|
||||
- Decrypts the outer layer
|
||||
- Reads the destination coordinates
|
||||
- Checks bloom filters to pick the next hop
|
||||
- Re-encrypts and forwards
|
||||
|
||||
4. F's fipsd receives the datagram, decrypts the session layer,
|
||||
and delivers the HTTP request to F's web server.
|
||||
|
||||
5. F's web server responds, and the response goes back the same
|
||||
way (or a better path if one was discovered).
|
||||
```
|
||||
|
||||
Every hop is individually encrypted (the link layer), and the
|
||||
end-to-end session is also encrypted (the session layer). So even
|
||||
intermediate nodes can't read your traffic — they can only see "this
|
||||
packet is going to Node F, forward it to peer X."
|
||||
|
||||
---
|
||||
|
||||
## The .fips DNS Hack
|
||||
|
||||
FIPS gives every node a virtual IPv6 address in the `fd00::/8` range,
|
||||
derived from its public key. These addresses only exist **inside the
|
||||
mesh** — they're not real internet addresses.
|
||||
|
||||
To make this user-friendly, `fipsd` runs a local DNS resolver that
|
||||
maps `<npub>.fips` to the node's mesh IPv6 address:
|
||||
|
||||
```
|
||||
You type: curl http://npub1abc123...xyz.fips/index.html
|
||||
|
||||
1. Your browser asks DNS: "what's the IP for npub1abc...xyz.fips?"
|
||||
2. Your fipsd's DNS resolver (127.0.0.1:5354) answers:
|
||||
"It's fd5e:4c4:ce59:a5a4:829:46c7:2b33:ab21"
|
||||
3. Your browser connects to that IPv6 address
|
||||
4. The kernel routes it through fips0 (the TUN device)
|
||||
5. fipsd reads it from fips0, routes it through the mesh
|
||||
6. The message arrives at the destination node
|
||||
```
|
||||
|
||||
This is why you can type `http://npub1....fips` in a browser and it
|
||||
just works — the DNS + TUN + mesh routing make it transparent.
|
||||
|
||||
---
|
||||
|
||||
## Summary: The Four Layers
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------------+
|
||||
| THE FIPS MESH |
|
||||
| |
|
||||
| +-------------------------------------------------------------+ |
|
||||
| | Layer 4: Application | |
|
||||
| | HTTP, SSH, any TCP/UDP app via .fips addresses | |
|
||||
| +-------------------------------------------------------------+ |
|
||||
| | Layer 3: Routing | |
|
||||
| | Bloom filters + coordinate discovery + spanning tree | |
|
||||
| | "Which peer do I forward to?" | |
|
||||
| +-------------------------------------------------------------+ |
|
||||
| | Layer 2: Links | |
|
||||
| | Noise IK encrypted peer-to-peer connections | |
|
||||
| | "I'm securely connected to nodes B, D, and E" | |
|
||||
| +-------------------------------------------------------------+ |
|
||||
| | Layer 1: Discovery | |
|
||||
| | Nostr Kind 37195 adverts + NAT traversal | |
|
||||
| | "How do I find other nodes to connect to?" | |
|
||||
| +-------------------------------------------------------------+ |
|
||||
| |
|
||||
+-------------------------------------------------------------------+
|
||||
|
|
||||
THE INTERNET
|
||||
(the underlay)
|
||||
```
|
||||
|
||||
| Layer | What it does | How | Nostr? |
|
||||
|-------|-------------|-----|--------|
|
||||
| **Discovery** | Find other nodes | Nostr adverts + STUN hole-punch | Yes (bootstrap only) |
|
||||
| **Links** | Secure connections | Noise IK handshake over UDP/TCP/Tor | No |
|
||||
| **Routing** | Route through mesh | TreeAnnounce + FilterAnnounce + LookupRequest | No |
|
||||
| **Application** | Use the mesh | .fips DNS + TUN device + any TCP/UDP app | No |
|
||||
|
||||
The key insight: **Nostr is only used for the initial rendezvous.**
|
||||
Once nodes are connected, everything else — topology, routing, data
|
||||
transfer — happens directly between peers over encrypted links. The
|
||||
mesh is a self-organizing overlay network that uses Nostr as its
|
||||
"phone book" and the internet as its "wires."
|
||||
305
plans/mesh-reachability-scanner.md
Normal file
305
plans/mesh-reachability-scanner.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# FIPS Mesh Reachability Scanner
|
||||
|
||||
## Goal
|
||||
|
||||
Build a tool that answers: **"From a fresh node peered with only node X,
|
||||
can I actually reach node Y through the mesh?"** — producing a
|
||||
reachability matrix across the real public FIPS mesh.
|
||||
|
||||
This directly diagnoses the user's problem: sometimes a node is only
|
||||
reachable by peering with it directly, and we want to know *which* seed
|
||||
nodes give good mesh reachability and *which* target nodes are
|
||||
unreachable except by direct peering — and *why* (coord cache miss,
|
||||
bloom miss, tree partition, NAT failure).
|
||||
|
||||
## Why a standalone script, not a browser feature
|
||||
|
||||
The browser attaches to a single long-lived FIPS daemon. The scanner
|
||||
needs **ephemeral daemons peered one-at-a-time** with different seed
|
||||
nodes, which the browser architecture can't do (see
|
||||
[`net_services.c`](../sovereign_browser/src/net_services.c) —
|
||||
`start_managed_fips` hardcodes `fips0`/UDP 4242/TCP 4243, so two
|
||||
managed daemons collide).
|
||||
|
||||
The scanner is a standalone Python script that drives real `fipsd`
|
||||
processes via their control sockets. It reuses patterns from the
|
||||
existing [`testing/chaos/sim/control.py`](../fips/testing/chaos/sim/control.py)
|
||||
control-socket client and the browser's
|
||||
[`handle_fips_directory_json`](../sovereign_browser/src/nostr_bridge.c:3560)
|
||||
Nostr query.
|
||||
|
||||
## Placement
|
||||
|
||||
`~/lt/fips_playground/mesh-scan/` — the playground repo is the right
|
||||
home: it already houses FIPS experimentation tooling
|
||||
([`test_fips.sh`](../fips_playground/test_fips.sh) does single-target
|
||||
reachability via curl over `.fips`, and
|
||||
[`test_qube_pair.sh`](../fips_playground/test_qube_pair.sh) does
|
||||
two-node peering tests). The scanner generalizes those into a
|
||||
full matrix.
|
||||
|
||||
Files (under `~/lt/fips_playground/mesh-scan/`):
|
||||
- `mesh_scan.py` — main scanner / orchestrator
|
||||
- `control.py` — control-socket client (adapted from
|
||||
[`chaos/sim/control.py`](../fips/testing/chaos/sim/control.py),
|
||||
standalone, no Docker dependency)
|
||||
- `directory.py` — Nostr Kind 37195 advert fetcher (ports the query
|
||||
from
|
||||
[`handle_fips_directory_json`](../sovereign_browser/src/nostr_bridge.c:3560)
|
||||
to Python using `websocket-client`)
|
||||
- `fipsd.py` — ephemeral daemon lifecycle manager (config generation,
|
||||
spawn, socket poll, teardown, TUN cleanup)
|
||||
- `reachability.py` — ping6 / curl-based reachability checks (builds
|
||||
on the pattern in
|
||||
[`test_fips.sh`](../fips_playground/test_fips.sh))
|
||||
- `README.md` — usage + result interpretation
|
||||
- `requirements.txt` — `websocket-client`, `coincurve`, `pyyaml`
|
||||
|
||||
## Inputs
|
||||
|
||||
1. **Node directory** — fetched from FIPS advert relays (Kind 37195,
|
||||
`fips-overlay-v1`), same query as
|
||||
[`handle_fips_directory_json`](../sovereign_browser/src/nostr_bridge.c:3560).
|
||||
Returns each node's npub, endpoints (host:port + transport), and
|
||||
signal relays. This is the list of "all known nodes on the mesh."
|
||||
|
||||
2. **Seed selection** — which nodes to peer with. Options:
|
||||
- `--seeds all` — every node in the directory (O(N) daemon lifecycles)
|
||||
- `--seeds npub1abc,npub1def,...` — explicit list
|
||||
- `--seeds-sample K` — random sample of K seeds (default 5)
|
||||
- `--seeds-near-root` — pick seeds with smallest NodeAddr (tree root
|
||||
neighborhood) — these should have the best reachability if the
|
||||
mesh is healthy
|
||||
|
||||
3. **Target selection** — which nodes to try to reach. Default: all
|
||||
nodes in the directory except the current seed. `--targets` to
|
||||
restrict.
|
||||
|
||||
4. **Convergence budget** — `--converge-secs N` (default 45): how long
|
||||
to wait after peering before declaring the tree stable and starting
|
||||
reachability checks.
|
||||
|
||||
5. **Per-target timeout** — `--ping-timeout N` (default 5s): how long
|
||||
to wait for each reachability check.
|
||||
|
||||
## Per-seed scan lifecycle
|
||||
|
||||
For each seed node X:
|
||||
|
||||
1. **Start ephemeral daemon**
|
||||
- Create temp config dir (`/tmp/mesh-scan-<seed>-<pid>/`)
|
||||
- Generate `fips.yaml` with:
|
||||
- Ephemeral identity (non-persistent)
|
||||
- Unique control socket path in the temp dir
|
||||
- Unique TUN device name (`fips-scan-<n>`) to avoid collision
|
||||
- Unique UDP/TCP ports (auto-allocate from ephemeral range)
|
||||
- DNS enabled on a unique port
|
||||
- Empty peer list
|
||||
- Spawn `fipsd --config <path>` as a subprocess
|
||||
- Wait for control socket to appear (poll up to 10s)
|
||||
|
||||
2. **Peer with seed X**
|
||||
- Send `connect` command with X's npub, endpoint, and transport
|
||||
(from the directory entry)
|
||||
- Wait for handshake to complete (poll `show_peers` until X appears
|
||||
with `can_send=true`, up to 15s)
|
||||
|
||||
3. **Wait for convergence**
|
||||
- Poll `show_status` every 2s until:
|
||||
- `tree_state` is stable (not `Initializing`/`Converging`), AND
|
||||
- `peer_count >= 1`, AND
|
||||
- `show_tree` returns a non-empty tree with a root, AND
|
||||
- `show_identity_cache` count stops growing for 2 consecutive polls
|
||||
- Bounded by `--converge-secs` (default 45s)
|
||||
|
||||
4. **Record seed-side state** (for diagnostics)
|
||||
- `show_status`: peer_count, tree_state, estimated_mesh_size
|
||||
- `show_tree`: our depth, parent, root
|
||||
- `show_identity_cache`: known node count, list of known npubs
|
||||
- Per-peer bloom filter state (`show_bloom`): which targets are in
|
||||
our seed's bloom filter
|
||||
|
||||
5. **For each target node Y** (Y ≠ X):
|
||||
- Resolve `<Y.npub>.fips` via the daemon's DNS resolver (or compute
|
||||
the `fd00::` IPv6 directly from the npub)
|
||||
- Attempt reachability check:
|
||||
- **Primary**: `ping6 -c 3 -W <timeout> <Y.ipv6>` through the
|
||||
daemon's TUN. Record success/fail/RTT/loss.
|
||||
- **Fallback** (if ping6 unavailable): open a TCP connection to
|
||||
`[<Y.ipv6>]:80` with a short timeout. Record connect success/fail.
|
||||
- Record: target npub, success, latency, loss, and diagnostic state:
|
||||
- Was Y in our identity cache? (`show_identity_cache`)
|
||||
- Was Y in our seed's bloom filter? (`show_bloom`)
|
||||
- Did we have cached coords for Y? (inferred from `show_routing`)
|
||||
- Tree distance to Y (from `show_tree` if available)
|
||||
|
||||
6. **Stop ephemeral daemon**
|
||||
- Send `disconnect` for the seed peer (clean teardown)
|
||||
- SIGTERM the process, wait up to 5s, SIGKILL if needed
|
||||
- Clean up TUN device and temp config dir
|
||||
|
||||
## Output
|
||||
|
||||
### Reachability matrix
|
||||
|
||||
A CSV / TSV table: rows = seeds, columns = targets, cells = `ok` /
|
||||
`fail` / `timeout` / RTT in ms.
|
||||
|
||||
```
|
||||
seed npub1abc npub1def npub1ghi npub1jkl ...
|
||||
npub1root ok/42ms ok/55ms fail ok/120ms ...
|
||||
npub1hub ok/38ms ok/41ms ok/67ms fail ...
|
||||
npub1leaf fail fail fail fail ...
|
||||
```
|
||||
|
||||
### Per-pair diagnostics JSON
|
||||
|
||||
For each (seed, target) pair, a record with:
|
||||
|
||||
```json
|
||||
{
|
||||
"seed": "npub1abc",
|
||||
"target": "npub1def",
|
||||
"reachable": true,
|
||||
"rtt_ms": 55,
|
||||
"loss_pct": 0,
|
||||
"target_in_identity_cache": true,
|
||||
"target_in_seed_bloom": true,
|
||||
"cached_coords": true,
|
||||
"tree_distance": 3,
|
||||
"seed_depth": 2,
|
||||
"seed_parent": "npub1root",
|
||||
"mesh_root": "npub1root",
|
||||
"converge_secs": 12
|
||||
}
|
||||
```
|
||||
|
||||
This lets you correlate failures with their *cause*: a `fail` where
|
||||
`target_in_identity_cache=false` is the identity-cache-gate issue from
|
||||
[`session.rs:1144`](../fips/src/node/handlers/session.rs:1144); a
|
||||
`fail` where `target_in_seed_bloom=false` is bloom propagation not
|
||||
reaching your seed; a `fail` with different `mesh_root` is a tree
|
||||
partition.
|
||||
|
||||
### Summary report
|
||||
|
||||
Printed to stdout at the end:
|
||||
- Total seeds scanned, total targets, total pairs
|
||||
- Overall reachability rate (X% of pairs reachable)
|
||||
- Per-seed reachability rate (which seeds are "good" / "bad" hubs)
|
||||
- Per-target reachability rate (which targets are universally
|
||||
unreachable — likely NAT/firewall issues on that node)
|
||||
- Correlation summary: "of N failures, M had identity-cache miss, K
|
||||
had bloom miss, J had tree partition"
|
||||
|
||||
## How to interpret results for your problem
|
||||
|
||||
The matrix tells you:
|
||||
|
||||
1. **Is the mesh working at all?** If most pairs are `ok`, the mesh is
|
||||
healthy and your direct-peering problem is specific to certain
|
||||
nodes. If most pairs are `fail`, the mesh has systemic issues.
|
||||
|
||||
2. **Which seeds give good reachability?** Seeds with high per-seed
|
||||
reachability rates are well-connected hubs — peering with them
|
||||
gives you access to most of the mesh. These are the nodes you
|
||||
*should* peer with in production.
|
||||
|
||||
3. **Which targets are unreachable except by direct peering?** Targets
|
||||
that fail from *all* seeds (or all but their direct neighbors) are
|
||||
the ones forcing you to peer directly. The diagnostics tell you
|
||||
*why* — if it's identity-cache miss across the board, that's the
|
||||
bug; if it's bloom miss, the target isn't propagating its existence
|
||||
well; if it's tree partition, the target is in a different tree.
|
||||
|
||||
4. **Is it a tree-position problem?** If seeds near the root
|
||||
(`--seeds-near-root`) have much better reachability than seeds far
|
||||
from the root, your problem is tree depth — peer with a
|
||||
near-root node.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `control.py` — control-socket client (port from
|
||||
`chaos/sim/control.py`, standalone, no Docker dependency)
|
||||
2. `directory.py` — Nostr Kind 37195 fetcher (port the query from
|
||||
[`handle_fips_directory_json`](../sovereign_browser/src/nostr_bridge.c:3560)
|
||||
to Python using `websocket-client`)
|
||||
3. `fipsd.py` — ephemeral daemon lifecycle (config generation, spawn,
|
||||
socket poll, teardown, TUN cleanup)
|
||||
4. `reachability.py` — ping6/curl checks (extend
|
||||
[`test_fips.sh`](../fips_playground/test_fips.sh) pattern)
|
||||
5. `mesh_scan.py` — orchestrator: tie together directory → seeds →
|
||||
per-seed lifecycle → reachability checks → output
|
||||
6. `README.md` — usage, output interpretation, troubleshooting
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **TUN device cleanup** — if the scanner crashes, `fips-scan-N` TUN
|
||||
devices may leak. Need a trap/cleanup handler and a
|
||||
`--cleanup-stale` mode that removes any `fips-scan-*` devices.
|
||||
|
||||
2. **Privilege** — `fipsd` needs `CAP_NET_ADMIN` for TUN. The scanner
|
||||
must either run as root, or the `fips` binary must have the
|
||||
capability set (same as the browser's
|
||||
[`binary_has_net_admin`](../sovereign_browser/src/net_services.c:306)
|
||||
check).
|
||||
|
||||
3. **Reachability check method** — `ping6` is simplest but requires
|
||||
ICMPv6 to pass through the FIPS TUN, which depends on the daemon's
|
||||
ACL/firewall config. The fallback TCP check is more reliable but
|
||||
requires the target to be running a reachable service. May need a
|
||||
third option: a FIPS-protocol-level "echo" via the control socket
|
||||
(if the daemon exposes one) or a Nostr-based reachability signal.
|
||||
|
||||
4. **NAT traversal for the ephemeral node** — the scanner node itself
|
||||
may be behind NAT, which could affect its ability to peer with
|
||||
seeds. The FIPS Nostr discovery + STUN hole-punching should handle
|
||||
this, but it's a variable. Running the scanner on a
|
||||
publicly-addressable host removes this confound.
|
||||
|
||||
5. **Rate limiting / politeness** — scanning N seeds × M targets
|
||||
generates traffic on the real mesh. Should add a `--delay` between
|
||||
checks and a `--max-concurrent` limit. Probably also a user-agent
|
||||
or scanner identifier in the ephemeral node's display name so
|
||||
others can see what's happening.
|
||||
|
||||
## Background: why direct peering is sometimes required
|
||||
|
||||
Mesh routing in FIPS is greedy + bloom-guided, not link-state.
|
||||
[`find_next_hop`](../fips/src/node/mod.rs:2631) tries four things in
|
||||
order:
|
||||
|
||||
1. Local delivery (is it us?)
|
||||
2. **Direct peer** — the destination is one of our authenticated
|
||||
peers. This branch *never* fails for a known peer.
|
||||
3. Bloom-filter-guided routing — requires **cached destination
|
||||
coordinates** (5-min TTL in
|
||||
[`coord_cache.rs`](../fips/src/cache/coord_cache.rs:18)).
|
||||
4. Greedy tree routing fallback — also requires cached coords, and
|
||||
fails on local minima or different-tree.
|
||||
|
||||
If the coord cache has no entry for the destination, `find_next_hop`
|
||||
returns `None` and the packet is dropped with a `CoordsRequired` error
|
||||
signal. The source handles this in
|
||||
[`handle_coords_required`](../fips/src/node/handlers/session.rs:1108),
|
||||
which triggers discovery — but **only if `has_cached_identity(target)`
|
||||
is true** ([`session.rs:1144`](../fips/src/node/handlers/session.rs:1144)).
|
||||
If the target's identity isn't cached, discovery is skipped entirely.
|
||||
|
||||
Discovery itself (`maybe_initiate_lookup`,
|
||||
[`discovery.rs:487`](../fips/src/node/handlers/discovery.rs:487)) has
|
||||
two more gates: a bloom pre-check (if no peer's filter contains the
|
||||
target, skip) and tree-peer-only forwarding
|
||||
([`discovery.rs:446`](../fips/src/node/handlers/discovery.rs:446) —
|
||||
only `is_tree_peer(addr) && peer.may_reach(target)`).
|
||||
|
||||
**Direct peering hits branch 2**, bypassing all of: coord cache, bloom
|
||||
discovery, tree routing, and per-hop transport issues. That's why it
|
||||
always works when mesh routing fails.
|
||||
|
||||
The scanner's per-pair diagnostics map directly to these failure
|
||||
modes:
|
||||
- `target_in_identity_cache=false` → the identity-cache gate
|
||||
- `target_in_seed_bloom=false` → bloom propagation gap
|
||||
- `mesh_root` differs → tree partition
|
||||
- `cached_coords=false` → coord cache miss (cold path)
|
||||
Reference in New Issue
Block a user