189 lines
7.0 KiB
Python
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
|