Files
2026-07-20 12:10:52 -04:00

189 lines
7.0 KiB
Python

"""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