netsleuth/src/netsleuth/cli.py
google-labs-jules[bot] a30eeef02d Remove unused import 'primary_interface_ip' from cli.py
This addresses a code health issue where an unused import was left over, improving maintainability without changing functionality.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
2026-08-19 12:39:50 +00:00

1067 lines
44 KiB
Python

from __future__ import annotations
import asyncio
import os
import platform
import socket
import sys
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Tuple
import httpx
import psutil
import typer
from rich.console import Console
from netsleuth import __version__, history
from netsleuth.compare import diff_reports, load_report, render_diff, render_diff_brief
from netsleuth.config import FORMAT_EXTENSIONS, Settings, load_settings
from netsleuth.exporter import build_report, dump_json, egress_asn, render_markdown, report_filename, write_report
from netsleuth.iface import available_interfaces_hint, resolve_bind_target
from netsleuth.metrics import collect_metrics, render_csv, render_prometheus
from netsleuth.interpret import (
assess_vpn,
captive_portal_findings,
cgnat_findings,
dns_advanced_findings,
dpi_findings,
dual_family_findings,
dual_stack_findings,
ecmp_findings,
gather_vpn_signals,
latency_findings,
path_asn_findings,
path_diversity_findings,
path_findings,
pmtud_findings,
prefix_findings,
quic_findings,
speed_findings,
tls_findings,
)
from netsleuth.ip_geo import dual_stack_mismatch, gather_identity
from netsleuth.models import (
BindTarget,
CaptivePortal,
Finding,
IpGeo,
LocalNet,
ModuleResult,
ProbeError,
SpeedResult,
VpnContext,
)
from netsleuth.netinfo import collect_local_net, detect_capabilities, iface_for_ip, is_tunnel_iface
from netsleuth.orchestration import gather_modules, run_module, utc_now_iso
from netsleuth.probes.captive_portal import check_captive_portal
from netsleuth.probes.dns_leak import collect_dns_leak
from netsleuth.probes.hop_asn import enrich_hops
from netsleuth.probes.latency import ping_fanout, tcp_connect_rtt
from netsleuth.probes.nat64 import detect_nat64
from netsleuth.probes.traceroute import filter_trace_tiers, tier_order, traceroute
from netsleuth.speed import NDT7_CONSENT_NOTICE
if sys.platform == "win32":
# A legacy console codepage (e.g. cp1251) cannot encode the emoji badges and
# Unicode punctuation this CLI prints; forcing UTF-8 here avoids a hard crash
# in rich's Windows renderer on every non-ASCII character.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
app = typer.Typer(add_completion=False, help="Deep network diagnostics.")
console = Console()
@dataclass
class Options:
mode: str = "auto"
target_kind: str | None = None
target_value: str | None = None
quick: bool = False
full: bool = False
extra_host: str | None = None
speedtest_server: str | None = None
dnsbl: bool = False
ndt7: bool = False
tcp_trace: bool = False
tls: bool = False
dns_advanced: bool = False
path_diversity: bool = False
prefix_bench: bool = False
ecmp: bool = False
pmtud: bool = False
quic: bool = False
ipv6: Optional[bool] = None
dpi_target: str | None = None
formats: frozenset[str] = frozenset({"md"})
interface: str | None = None
bind: BindTarget | None = None
webhook: str | None = None
def parse_formats(
values: list[str], ru: bool, json_flag: bool, default: frozenset[str]
) -> frozenset[str]:
tokens = [t.strip().lower() for raw in values for t in raw.split(",") if t.strip()]
if ru:
tokens.append("ru-md")
if json_flag:
tokens.append("json")
if not tokens:
return default
if "none" in tokens:
if len(set(tokens)) > 1:
raise typer.BadParameter("'none' cannot be combined with other formats.")
return frozenset()
if "all" in tokens:
return frozenset(FORMAT_EXTENSIONS)
unknown = sorted(set(tokens) - set(FORMAT_EXTENSIONS))
if unknown:
raise typer.BadParameter(
f"unknown format(s) {unknown}; expected one of {sorted(FORMAT_EXTENSIONS)}, 'all' or 'none'"
)
return frozenset(tokens)
def format_siblings(
formats: frozenset[str], md_name: str, ru_md_name: str
) -> tuple[str | None, str | None]:
if "md" in formats and "ru-md" in formats:
return ru_md_name, md_name
return None, None
def parse_target(value: str) -> tuple[str, str]:
text = value.strip()
if text.upper().startswith("AS") and text[2:].isdigit():
return "asn", text.upper()
if text.isdigit():
return "asn", f"AS{text}"
if all(part.isdigit() for part in text.split(".")) and text.count(".") == 3:
return "ip", text
if ":" in text:
return "ip", text
return "domain", text
def _os_timezone() -> str | None:
# The VPN timezone signal needs an IANA name; Windows has none, so the signal
# simply stays unobserved there rather than being guessed at.
if os.environ.get("TZ"):
return os.environ["TZ"]
localtime = Path("/etc/localtime")
if localtime.is_symlink():
return str(localtime.readlink()).split("zoneinfo/")[-1] or None
try:
return Path("/etc/timezone").read_text(encoding="utf-8").strip() or None
except OSError:
return None
def _dedupe(findings: list[Finding]) -> list[Finding]:
seen: set[str] = set()
out: list[Finding] = []
for finding in findings:
if finding.id in seen:
continue
seen.add(finding.id)
out.append(finding)
return out
async def _identity(client: httpx.AsyncClient, settings: Settings, options: Options, raw: dict) -> dict:
token = settings.ipinfo_token.get_secret_value() if settings.ipinfo_token else None
lookup = options.target_value if options.target_kind in ("ip", "domain") else None
own_network = options.target_kind in (None, "asn")
bind_v4 = options.bind.ipv4 if options.bind else "0.0.0.0"
warnings: list[str] = []
if own_network:
# The shared client has no address family pinned, so on a dual-stack host it can
# silently connect over IPv6 (OS/happy-eyeballs preference) and hand back an IPv6
# literal mislabeled as egress_v4. Force IPv4 here the same way egress_v6 is forced
# below, so the two slots can never both resolve to the same protocol.
try:
transport = httpx.AsyncHTTPTransport(local_address=bind_v4)
async with httpx.AsyncClient(
transport=transport, timeout=settings.timeouts.http_seconds
) as v4_client:
merged, cf, flags, payloads = await gather_identity(
v4_client, settings.providers, ip=lookup, ipinfo_token=token
)
except (httpx.HTTPError, OSError):
merged, cf, flags, payloads = await gather_identity(
client, settings.providers, ip=lookup, ipinfo_token=token
)
else:
merged, cf, flags, payloads = await gather_identity(
client, settings.providers, ip=lookup, ipinfo_token=token
)
raw.update(payloads)
v6 = None
if own_network and options.bind and options.bind.ipv6 is None:
warnings.append(
f"IPv6 identity skipped: forced interface {options.bind.iface_name!r} has no global IPv6 address"
)
elif own_network:
try:
transport = httpx.AsyncHTTPTransport(local_address=options.bind.ipv6 if options.bind else "::")
async with httpx.AsyncClient(
transport=transport, timeout=settings.timeouts.http_seconds
) as v6_client:
v6_merged, _cf6, _flags6, _raw6 = await gather_identity(v6_client, settings.providers)
v6 = v6_merged if v6_merged.ip else None
except (httpx.HTTPError, OSError):
v6 = None
dual_stack = dual_stack_mismatch(merged, v6)
nat64_prefix = await detect_nat64(settings.timeouts.dns_seconds)
return {
"egress_v4": merged,
"egress_v6": v6,
"cf_trace": cf,
"dual_stack_note": dual_stack[0] if dual_stack else None,
"dual_stack_note_ru": dual_stack[1] if dual_stack else None,
"nat64_prefix": nat64_prefix,
"_flags": flags,
"_warnings": warnings,
}
async def _bgp_section(client, settings: Settings, asn: str | None, raw: dict):
if not asn:
return ModuleResult(name="bgp", status="skipped", warnings=["no ASN was resolved for this target"])
from netsleuth.bgp import collect_bgp
key = settings.peeringdb_api_key.get_secret_value() if settings.peeringdb_api_key else None
intel, payloads = await collect_bgp(
client, settings.providers, Path(settings.output.cache_dir), asn, peeringdb_key=key
)
raw.update(payloads)
return ModuleResult(name="bgp", status="ok", data=intel)
async def _reputation_section(client, settings: Settings, options: Options, geo: IpGeo, raw: dict):
from netsleuth.reputation import (
build_reputation,
ReputationContext,
fetch_abuseipdb,
fetch_internetdb,
normalize_internetdb,
query_dnsbl,
refresh_netsets,
)
if not geo.ip:
return ModuleResult(name="reputation", status="skipped", warnings=["no egress IP to check"])
warnings: list[str] = []
index = await refresh_netsets(client, settings.providers, Path(settings.output.cache_dir))
payload = await fetch_internetdb(client, settings.providers, geo.ip)
raw["internetdb"] = payload
outcomes = None
if options.dnsbl and geo.ip_version == 4:
outcomes = await query_dnsbl(geo.ip, settings.dnsbl.zones, settings.timeouts.dns_seconds)
score = reports = None
if settings.abuseipdb_api_key:
abuse = await fetch_abuseipdb(
client, settings.providers, geo.ip, settings.abuseipdb_api_key.get_secret_value()
)
raw["abuseipdb"] = abuse
data = abuse.get("data") or {}
score, reports = data.get("abuseConfidenceScore"), data.get("totalReports")
else:
warnings.append("ABUSEIPDB_API_KEY not set — abuse score skipped")
reputation = build_reputation(
ReputationContext(
internetdb=normalize_internetdb(payload),
firehol_hits=index.hits(geo.ip),
dnsbl_outcomes=outcomes,
ip_type=geo.ip_type,
abuseipdb_score=score,
abuseipdb_reports=reports,
)
)
return ModuleResult(
name="reputation", status="partial" if warnings else "ok", data=reputation, warnings=warnings
)
async def _tls_section(settings: Settings, options: Options) -> ModuleResult:
if not options.tls:
return ModuleResult(name="tls", status="skipped", warnings=["tls probe skipped: not requested"])
from netsleuth.probes.tls_rtt import tls_fanout
cfg = settings.tls
targets = [(t.label, t.host) for t in cfg.targets]
results = await tls_fanout(
targets,
port=cfg.port,
timeout=settings.timeouts.http_seconds,
concurrency=cfg.concurrency,
source_ip=options.bind.ipv4 if options.bind else None,
verify=cfg.verify,
pins=cfg.pinned_fingerprints,
)
return ModuleResult(name="tls", status="ok", data=results)
async def _dns_advanced_section(client, settings: Settings, options: Options) -> ModuleResult:
if not options.dns_advanced:
return ModuleResult(
name="dns_advanced", status="skipped", warnings=["dns advanced probe skipped: not requested"]
)
from netsleuth.probes.dns_advanced import collect_dns_advanced
cfg = settings.dns_advanced
adv = await collect_dns_advanced(
client, cfg.control_names, cfg.doh_endpoints, cfg.bogus_resolver_ip, cfg.bogus_probe_name, settings.timeouts.dns_seconds
)
return ModuleResult(name="dns_advanced", status="ok", data=adv)
async def _path_diversity_section(client, settings: Settings, options: Options, client_country: str | None) -> ModuleResult:
if not options.path_diversity:
return ModuleResult(
name="path_diversity", status="skipped", warnings=["path diversity probe skipped: not requested"]
)
from netsleuth.probes.bgp_path import collect_path_diversity
cfg = settings.path_diversity
targets = [(t.label, t.host) for t in cfg.targets]
pd = await collect_path_diversity(
client,
targets,
client_country,
max_targets=cfg.max_targets,
timeout=settings.timeouts.http_seconds,
source_ip=options.bind.ipv4 if options.bind else None,
)
return ModuleResult(name="path_diversity", status="ok", data=pd)
async def _prefix_bench_section(caps, settings: Settings, options: Options, bgp) -> ModuleResult:
if not options.prefix_bench:
return ModuleResult(
name="prefix_benchmark", status="skipped", warnings=["prefix benchmark skipped: not requested"]
)
if not bgp or not bgp.announced_prefixes:
return ModuleResult(
name="prefix_benchmark", status="skipped", warnings=["prefix benchmark skipped: no announced prefixes"]
)
from netsleuth.probes.prefix_benchmark import benchmark_prefixes
cfg = settings.prefix_bench
bench = await benchmark_prefixes(
bgp.announced_prefixes,
caps,
limit=cfg.max_prefixes,
count=cfg.ping_count,
interval=settings.probing.ping_interval_seconds,
timeout=settings.probing.ping_timeout_seconds,
concurrency=cfg.concurrency,
offset=cfg.host_offset,
family=cfg.family,
source_ip=options.bind.ipv4 if options.bind else None,
)
bench.asn = bgp.asn
return ModuleResult(name="prefix_benchmark", status="ok", data=bench)
async def _dpi_section(settings: Settings, options: Options) -> ModuleResult:
if not options.dpi_target:
return ModuleResult(
name="dpi_check", status="skipped", warnings=["dpi check skipped: no explicit --my-server target"]
)
from netsleuth.probes.dpi_check import check_dpi
target = options.dpi_target
try:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(target, None)
resolved = infos[0][4][0]
except (OSError, IndexError) as exc:
return ModuleResult(
name="dpi_check",
status="failed",
errors=[ProbeError(source="dpi_check", kind="unavailable", message=f"could not resolve {target}: {exc}")],
)
cfg = settings.dpi_check
console.print(
f"[yellow]--my-server opens TCP connections to {target} ({resolved}) on {len(cfg.ports)} ports. "
"Use it only on hosts you own or are authorised to test.[/yellow]"
)
result = await check_dpi(
target,
resolved,
cfg.ports,
timeout=cfg.connect_timeout_seconds,
delay=cfg.delay_between_ports_seconds,
concurrency=cfg.concurrency,
source_ip=options.bind.ipv4 if options.bind else None,
)
return ModuleResult(name="dpi_check", status="ok", data=result)
async def _quic_section(settings: Settings, options: Options) -> ModuleResult:
if not options.quic:
return ModuleResult(name="quic", status="skipped", warnings=["quic probe skipped: not requested"])
try:
import aioquic # noqa: F401
except ImportError:
return ModuleResult(
name="quic",
status="skipped",
warnings=["quic probe skipped: aioquic not installed (uv sync --extra quic)"],
)
from netsleuth.probes.quic_rtt import quic_fanout
warnings: list[str] = []
if options.bind:
# aioquic's connect() takes only local_port, never a source address, so a
# forced adapter cannot be honored here -- say so rather than silently
# measuring the OS-default path under a report claiming a forced interface.
warnings.append("quic probe ran over the OS default route: aioquic has no source-address bind option")
cfg = settings.quic
targets = [(t.label, t.host) for t in cfg.targets]
results = await quic_fanout(targets, port=cfg.port, timeout=cfg.timeout_seconds, concurrency=cfg.concurrency)
return ModuleResult(name="quic", status="ok", data=results, warnings=warnings)
async def _pmtud_section(settings: Settings, options: Options, iface_mtu: int | None) -> ModuleResult:
if not options.pmtud:
return ModuleResult(name="pmtud", status="skipped", warnings=["pmtud probe skipped: not requested"])
from netsleuth.probes.pmtud import probe_pmtu
cfg = settings.pmtud
results = []
for target in cfg.targets:
results.append(
await probe_pmtu(
target.host,
low=cfg.min_bytes,
high=cfg.max_bytes,
timeout=cfg.probe_timeout_seconds,
iface_mtu=iface_mtu,
source_ip=options.bind.ipv4 if options.bind else None,
)
)
return ModuleResult(name="pmtud", status="ok", data=results)
async def _ecmp_section(caps, settings: Settings, options: Options, cycles: int) -> ModuleResult:
if not options.ecmp:
return ModuleResult(name="ecmp", status="skipped", warnings=["ecmp probe skipped: not requested"])
from netsleuth.probes.ecmp import detect_ecmp
cfg = settings.ecmp
targets = [(h.label, h.host) for h in settings.probing.reference_hosts[: cfg.max_targets]]
semaphore = asyncio.Semaphore(settings.probing.trace_concurrency)
reports = []
for _label, host in targets:
runs = await asyncio.gather(
*(
traceroute(
host,
caps,
max_hops=settings.probing.max_hops,
cycles=cycles,
timeout=settings.timeouts.subprocess_seconds,
semaphore=semaphore,
tcp_trace=options.tcp_trace,
source_ip=options.bind.ipv4 if options.bind else None,
)
for _ in range(cfg.runs)
)
)
reports.append(detect_ecmp(list(runs)))
return ModuleResult(name="ecmp", status="ok", data=reports)
async def _traces(hosts, caps, settings: Settings, cycles: int, options: Options):
semaphore = asyncio.Semaphore(settings.probing.trace_concurrency)
return list(
await asyncio.gather(
*(
traceroute(
host,
caps,
max_hops=settings.probing.max_hops,
cycles=cycles,
timeout=settings.timeouts.subprocess_seconds,
semaphore=semaphore,
tcp_trace=options.tcp_trace,
source_ip=options.bind.ipv4 if options.bind else None,
)
for _label, host in hosts
)
)
)
async def _speed_section(client, settings: Settings, options: Options, idle_rtt_ms: float | None):
import shutil
from netsleuth.speed import (
measure_with_bufferbloat,
run_speed_cascade,
tier_cloudflare,
tier_fastcom,
tier_ndt7,
tier_ookla,
)
cfg = settings.speedtest
timeout = settings.timeouts.speedtest_seconds
bind = options.bind
builders = {
"ookla_bin": lambda: tier_ookla(
shutil.which("speedtest") or "speedtest",
options.speedtest_server,
timeout,
iface_name=bind.iface_name if bind else None,
ipv4=bind.ipv4 if bind else None,
),
"cloudflare": lambda: tier_cloudflare(client, cfg, timeout),
"fastcom": lambda: tier_fastcom(client, cfg, timeout),
"ndt7": lambda: tier_ndt7(client, cfg, timeout, source_ip=bind.ipv4 if bind else None),
}
enabled = list(cfg.enabled_tiers) + (["ndt7"] if options.ndt7 else [])
result = await run_speed_cascade([(name, builders[name]) for name in enabled if name in builders])
if result.method == "none":
return ModuleResult(name="speed", status="failed", data=result)
async def _saturate_down() -> None:
await client.get(
f"{cfg.cloudflare_base_url}/__down", params={"bytes": cfg.download_sizes_bytes[-1]}, timeout=timeout
)
async def _saturate_up() -> None:
await client.post(
f"{cfg.cloudflare_base_url}/__up", content=b"\x00" * cfg.upload_sizes_bytes[-1], timeout=timeout
)
async def _probe() -> float | None:
return await tcp_connect_rtt("1.1.1.1", timeout=settings.probing.ping_timeout_seconds)
result = await measure_with_bufferbloat(
result,
idle_rtt_ms=idle_rtt_ms if result.idle_rtt_ms is None else result.idle_rtt_ms,
bands=settings.thresholds.bufferbloat_ms,
run_download=_saturate_down,
run_upload=_saturate_up,
probe=_probe,
interval=cfg.bufferbloat_probe_interval_seconds,
)
return ModuleResult(name="speed", status="ok", data=result)
async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Path]]:
started_at = utc_now_iso()
caps = detect_capabilities()
timeouts = settings.timeouts
modules: dict[str, ModuleResult] = {}
raw: dict[str, Any] = {}
client_transport = (
httpx.AsyncHTTPTransport(local_address=options.bind.ipv4, http2=True) if options.bind else None
)
async with httpx.AsyncClient(
transport=client_transport,
timeout=timeouts.http_seconds,
follow_redirects=True,
http2=True,
headers={"User-Agent": f"netsleuth/{__version__}"},
) as client:
# Phase 0 — captive portal gate. Detect-and-tag, never short-circuit: every
# later phase still runs, and the report says plainly that it was measured
# from behind a portal rather than silently looking like a normal run.
cp_cfg = settings.captive_portal
if cp_cfg.enabled:
modules["captive_portal"] = await run_module(
"captive_portal",
check_captive_portal(
client, cp_cfg.check_urls, expected_status=cp_cfg.expected_status, timeout=timeouts.http_seconds
),
timeout=timeouts.module_seconds,
)
else:
modules["captive_portal"] = ModuleResult(
name="captive_portal", status="skipped", warnings=["captive portal check disabled in config"]
)
# Phase 1 — local facts and identity. Blocking: everything below needs the ASN.
modules["connection"] = await run_module(
"connection", asyncio.to_thread(collect_local_net), timeout=timeouts.module_seconds
)
local = modules["connection"].data or LocalNet()
modules["ip_geo"] = await run_module(
"ip_geo", _identity(client, settings, options, raw), timeout=timeouts.module_seconds
)
bundle = modules["ip_geo"].data or {}
flags = bundle.pop("_flags", {}) if isinstance(bundle, dict) else {}
identity_warnings = bundle.pop("_warnings", []) if isinstance(bundle, dict) else []
geo = bundle.get("egress_v4") or IpGeo()
if bundle.get("dual_stack_note"):
modules["ip_geo"].warnings.append(bundle["dual_stack_note"])
modules["ip_geo"].warnings.extend(identity_warnings)
asn = geo.asn if options.target_kind != "asn" else options.target_value
# Phase 2 — bgp || reputation || dns_leak || dns_advanced || path_diversity
bgp_result, rep_result, dns_result, dns_advanced_result, path_diversity_result = await gather_modules(
run_module("bgp", _bgp_section(client, settings, asn, raw), timeout=timeouts.module_seconds),
run_module(
"reputation", _reputation_section(client, settings, options, geo, raw), timeout=timeouts.module_seconds
),
run_module(
"dns_leak",
collect_dns_leak(local, asn, settings.providers.cymru_origin_zone, timeouts.dns_seconds),
timeout=timeouts.module_seconds,
),
run_module("dns_advanced", _dns_advanced_section(client, settings, options), timeout=timeouts.module_seconds),
run_module(
"path_diversity",
_path_diversity_section(client, settings, options, geo.country_code),
timeout=timeouts.module_seconds,
),
)
modules["bgp"], modules["reputation"] = bgp_result, rep_result
modules["dns_advanced"], modules["path_diversity"] = dns_advanced_result, path_diversity_result
ctx = VpnContext(
local=local,
geo=geo,
cf=bundle.get("cf_trace"),
dns_leak=dns_result.data,
pdb_info_type=getattr(bgp_result.data, "pdb_info_type", None),
os_timezone=_os_timezone(),
provider_flags=flags,
)
signals = gather_vpn_signals(ctx)
modules["vpn_assessment"] = ModuleResult(
name="vpn_assessment",
status="ok" if dns_result.status == "ok" else "partial",
data=assess_vpn(
signals,
settings.thresholds.vpn_confidence,
tunnel_iface=local.iface_name if is_tunnel_iface(local.iface_name or "") else None,
dns_leak=dns_result.data,
),
errors=dns_result.errors,
started_at=dns_result.started_at,
duration_ms=dns_result.duration_ms,
)
# Phase 3 — latency || traceroute, both bounded
hosts = [(h.label, h.host) for h in settings.probing.reference_hosts]
if not options.quick:
hosts += [(h.label, h.host) for h in settings.probing.service_hosts]
if options.extra_host:
hosts.append(("target-host", options.extra_host))
if options.target_kind in ("ip", "domain"):
hosts.append(("target", options.target_value))
count = settings.probing.quick_ping_count if options.quick else settings.probing.ping_count
cycles = settings.probing.quick_mtr_cycles if options.quick else settings.probing.mtr_cycles
ipv6_policy = {"auto": local.is_dual_stack, "on": True, "off": False}[settings.probing.ipv6]
ipv6_enabled = ipv6_policy if options.ipv6 is None else options.ipv6
v6_hosts = [(f"{h.label}-v6", h.host) for h in settings.probing.reference_hosts_v6] if ipv6_enabled else []
modules["latency"], modules["path"], modules["tls"], v6_result = await gather_modules(
run_module(
"latency",
ping_fanout(
hosts,
caps,
count,
settings.probing.ping_interval_seconds,
settings.probing.ping_timeout_seconds,
source_ip=options.bind.ipv4 if options.bind else None,
),
timeout=timeouts.subprocess_seconds,
),
run_module("path", _traces(hosts, caps, settings, cycles, options), timeout=timeouts.subprocess_seconds),
run_module("tls", _tls_section(settings, options), timeout=timeouts.module_seconds),
run_module(
"latency_v6",
ping_fanout(
v6_hosts,
caps,
count,
settings.probing.ping_interval_seconds,
settings.probing.ping_timeout_seconds,
source_ip=options.bind.ipv6 if options.bind else None,
),
timeout=timeouts.subprocess_seconds,
),
)
# v6 rows are tagged onto the same Latency section via a "<label>-v6" label
# rather than a parallel section, so exporter/metrics/compare need no changes.
modules["latency"].data = (modules["latency"].data or []) + (v6_result.data or [])
modules["latency"].warnings += v6_result.warnings
# Phase 3b — prefix benchmark and DPI check, opt-in and exclusive: each is a
# single-target/self-scoped probe that should not share the wire with the
# reference-host latency fanout above.
modules["prefix_benchmark"] = await run_module(
"prefix_benchmark",
_prefix_bench_section(caps, settings, options, bgp_result.data),
timeout=timeouts.subprocess_seconds,
)
modules["dpi_check"] = await run_module(
"dpi_check", _dpi_section(settings, options), timeout=timeouts.module_seconds
)
modules["ecmp"] = await run_module(
"ecmp", _ecmp_section(caps, settings, options, cycles), timeout=timeouts.subprocess_seconds
)
modules["pmtud"] = await run_module(
"pmtud", _pmtud_section(settings, options, local.iface_mtu), timeout=timeouts.subprocess_seconds
)
modules["quic"] = await run_module(
"quic", _quic_section(settings, options), timeout=timeouts.module_seconds
)
# Phase 3c — per-hop ASN/AS-name/country enrichment. Mutates the trace hops
# in place; a lookup failure must never downgrade the trace itself, so only
# its warnings (not its status) get folded into modules["path"].
enrich_result = await run_module(
"path_enrich",
enrich_hops(
modules["path"].data or [],
origin_zone=settings.providers.cymru_origin_zone,
origin6_zone=settings.providers.cymru_origin6_zone,
asn_zone=settings.providers.cymru_asn_zone,
timeout=timeouts.dns_seconds,
),
timeout=timeouts.module_seconds,
)
modules["path"].warnings.extend(enrich_result.warnings)
# Phase 4 — speed, exclusive: nothing else is in flight at this point.
pings = modules["latency"].data or []
idle_rtt = min((p.avg_ms for p in pings if p.avg_ms is not None), default=None)
skip_speed = options.quick or (options.mode == "target" and not options.speedtest_server)
if skip_speed:
modules["speed"] = ModuleResult(
name="speed",
status="skipped",
warnings=["speedtest skipped: --quick" if options.quick else "speedtest skipped in target mode"],
)
else:
modules["speed"] = await run_module(
"speed", _speed_section(client, settings, options, idle_rtt), timeout=timeouts.speedtest_seconds
)
# Phase 5 — interpret
speed = modules["speed"].data or SpeedResult()
l7_findings: list[Finding] = list(tls_findings(modules["tls"].data or [], settings.thresholds))
if modules["prefix_benchmark"].data is not None:
l7_findings += prefix_findings(modules["prefix_benchmark"].data, settings.thresholds)
if modules["dpi_check"].data is not None:
l7_findings += dpi_findings(modules["dpi_check"].data)
if modules["dns_advanced"].data is not None:
l7_findings += dns_advanced_findings(modules["dns_advanced"].data, settings.thresholds)
if modules["path_diversity"].data is not None:
l7_findings += path_diversity_findings(modules["path_diversity"].data)
if modules["quic"].data is not None:
l7_findings += quic_findings(modules["quic"].data, settings.thresholds)
traces = modules["path"].data or []
findings = _dedupe(
latency_findings(pings, settings.thresholds)
+ [f for trace in traces for f in path_findings(trace, local, settings.thresholds)]
+ [f for trace in traces for f in path_asn_findings(trace, geo.country_code)]
+ speed_findings(speed, settings.thresholds.bufferbloat_ms, geo.country)
+ l7_findings
+ cgnat_findings(local, traces)
+ dual_stack_findings(bundle.get("nat64_prefix"), local)
+ captive_portal_findings(modules["captive_portal"].data or CaptivePortal())
+ [f for report in (modules["ecmp"].data or []) for f in ecmp_findings(report)]
+ [f for result in (modules["pmtud"].data or []) for f in pmtud_findings(result)]
+ dual_family_findings(pings)
)
# Phase 6 — export
meta = {
"run_id": uuid.uuid4().hex[:12],
"started_at": started_at,
"finished_at": utc_now_iso(),
"mode": options.mode,
"target": options.target_value,
"flags": {
"quick": options.quick,
"full": options.full,
"dnsbl": options.dnsbl,
"ndt7": options.ndt7,
"tcp_trace": options.tcp_trace,
"interface": options.interface,
"tls": options.tls,
"dns_advanced": options.dns_advanced,
"path_diversity": options.path_diversity,
"prefix_bench": options.prefix_bench,
},
"captive_portal": bool((modules["captive_portal"].data or CaptivePortal()).detected),
"dpi_target": options.dpi_target,
"formats": sorted(options.formats),
"host_os": f"{platform.system()} {platform.release()}",
"capabilities": caps,
}
if options.bind:
_kept, dropped = filter_trace_tiers(
tier_order(caps, options.tcp_trace), caps.os_name, forced_v4=bool(options.bind.ipv4)
)
meta["interface"] = {
"requested": options.bind.requested,
"resolved_iface": options.bind.iface_name,
"bind_ipv4": options.bind.ipv4,
"bind_ipv6": options.bind.ipv6,
"os_default_iface": local.iface_name,
"os_default_ipv4": local.local_ipv4,
"dropped_backends": dropped,
}
report = build_report(meta, modules, findings, raw)
asn = meta.get("target") or egress_asn(report)
md_name = report_filename(asn, started_at, "md")
ru_md_name = report_filename(asn, started_at, "ru.md")
sibling_for_md, sibling_for_ru = format_siblings(options.formats, md_name, ru_md_name)
artifacts: dict[str, str] = {}
if "md" in options.formats:
artifacts["md"] = render_markdown(report, emoji=settings.output.emoji, lang="en", sibling=sibling_for_md)
if "ru-md" in options.formats:
artifacts["ru-md"] = render_markdown(report, emoji=settings.output.emoji, lang="ru", sibling=sibling_for_ru)
if "json" in options.formats:
artifacts["json"] = dump_json(report)
if "prom" in options.formats or "csv" in options.formats:
computed_metrics = collect_metrics(report)
if "prom" in options.formats:
artifacts["prom"] = render_prometheus(computed_metrics)
if "csv" in options.formats:
artifacts["csv"] = render_csv(computed_metrics)
if settings.history.auto_diff:
if "json" in options.formats:
try:
key = report.get("meta", {}).get("target") or asn
previous = history.find_previous(settings.output.logs_dir, key or "unknown", limit=1) if key else []
if previous:
previous_report = load_report(previous[0])
console.print(render_diff_brief(diff_reports(previous_report, report), emoji=settings.output.emoji))
report["meta"]["compared_to"] = previous[0].relative_to(settings.output.logs_dir).as_posix()
except (OSError, ValueError, KeyError):
pass
else:
console.print(
"[dim](no JSON written this run — future runs will not be able to diff against it)[/dim]"
)
paths = write_report(report, artifacts, Path(settings.output.logs_dir))
return report, paths
def _check_ndt7_consent(settings: Settings) -> bool:
consent_marker = Path(settings.output.cache_dir) / "ndt7_consent"
if consent_marker.exists():
return True
console.print(f"[yellow]{NDT7_CONSENT_NOTICE}[/yellow]")
if not sys.stdin.isatty():
return True
if typer.confirm("Continue with NDT7?", default=False):
consent_marker.parent.mkdir(parents=True, exist_ok=True)
consent_marker.write_text("consented\n", encoding="utf-8")
return True
return False
@app.command()
def run(
full: bool = typer.Option(False, "--full", help="Full run: speedtest and full MTR cycles."),
quick: bool = typer.Option(False, "--quick", help="Express run: reference hosts only, no speedtest."),
target: Optional[str] = typer.Option(None, "--target", help="Investigate AS<n>, an IP or a domain."),
target_host: Optional[str] = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
speedtest_server: Optional[str] = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
watch: bool = typer.Option(False, "--watch", help="Continuous monitoring with a live dashboard."),
webhook: Optional[str] = typer.Option(
None,
"--webhook",
help="With --watch, POST a JSON alert to this URL on a verdict transition (overrides watch.webhook_url).",
),
compare: Optional[Tuple[Path, Path]] = typer.Option(
None, "--compare", help="Diff two saved JSON reports — reads reports produced with --format json."
),
trend: Optional[int] = typer.Option(
None,
"--trend",
help="Show a sparkline trend across the last N saved JSON reports for this network. Read-only, no probing.",
),
no_auto_diff: bool = typer.Option(
False, "--no-auto-diff", help="Skip the automatic vs-previous-run summary this run would otherwise print."
),
dnsbl: bool = typer.Option(False, "--dnsbl", help="Also query classic DNSBL zones."),
ndt7: bool = typer.Option(False, "--ndt7", help="Add the M-Lab NDT7 speedtest tier (publishes your IP)."),
tcp_trace: bool = typer.Option(False, "--tcp-trace", help="Add a scapy TCP-SYN traceroute tier."),
tls: bool = typer.Option(False, "--tls", help="Measure TCP/TLS handshake RTT and TTFB to reference services."),
dns_advanced: bool = typer.Option(
False, "--dns-advanced", help="Compare system DNS vs DoH and probe for a transparent DNS proxy."
),
path_diversity: bool = typer.Option(
False, "--path-diversity", help="Compare client geo against the Cloudflare edge PoP actually serving traffic."
),
prefix_bench: bool = typer.Option(
False,
"--prefix-bench",
help="Ping the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP.",
),
ecmp: bool = typer.Option(
False,
"--ecmp",
help="Trace a handful of reference hosts multiple times to detect per-hop load balancing (ECMP).",
),
ipv6: Optional[bool] = typer.Option(
None,
"--ipv6/--no-ipv6",
help="Also measure latency to IPv6 reference hosts (default: auto, only on a dual-stack connection).",
),
pmtud: bool = typer.Option(
False,
"--pmtud",
help="Binary-search the largest DF-set packet that reaches each pmtud.target, to catch a PMTUD blackhole.",
),
quic: bool = typer.Option(
False,
"--quic",
help="Measure QUIC/HTTP3 handshake time and check whether UDP/443 is blocked (needs the quic extra).",
),
my_server: Optional[str] = typer.Option(
None,
"--my-server",
help="Check a server YOU OWN for TCP port blocking / RST injection. Single host only — never a CIDR or someone else's server.",
),
format_: List[str] = typer.Option(
[],
"--format",
help=(
"Which report artifacts to write: md, ru-md, json, prom, csv, all, none. Repeatable and "
"comma-separated (--format md,json). Any --format replaces the default instead of "
"adding to it. Ignored with --watch and --compare."
),
),
ru: bool = typer.Option(False, "--ru", help="Shorthand for --format ru-md."),
json_flag: bool = typer.Option(False, "--json", help="Shorthand for --format json."),
interface: Optional[str] = typer.Option(
None,
"--interface",
help="Force outbound probing through this adapter (name or IP), instead of the OS default route.",
),
) -> None:
settings = load_settings()
if compare:
before, after = load_report(compare[0]), load_report(compare[1])
console.print(render_diff(diff_reports(before, after), emoji=settings.output.emoji))
raise typer.Exit(0)
if trend is not None:
from netsleuth.trend import build_trend, render_trend
if trend < 1:
raise typer.BadParameter("--trend must be at least 1", param_hint="--trend")
kind, value = parse_target(target) if target else (None, None)
key = value or history.latest_key(settings.output.logs_dir)
if not key:
console.print("No saved reports found to build a trend from.")
raise typer.Exit(0)
count = trend or settings.history.trend_default_reports
report_paths = history.find_previous(settings.output.logs_dir, key, limit=count)
if not report_paths:
console.print(f"No saved reports found for {key}.")
raise typer.Exit(0)
reports = [load_report(p) for p in reversed(report_paths)]
console.print(render_trend(build_trend(reports), emoji=settings.output.emoji))
raise typer.Exit(0)
if no_auto_diff:
settings.history.auto_diff = False
if my_server and "/" in my_server:
raise typer.BadParameter(
"this is a single-host self-diagnostic, not a network scanner", param_hint="--my-server"
)
kind, value = parse_target(target) if target else (None, None)
formats = parse_formats(format_, ru=ru, json_flag=json_flag, default=frozenset(settings.output.formats))
options = Options(
mode="target" if target else "auto",
target_kind=kind,
target_value=value,
quick=quick and not full,
full=full,
extra_host=target_host,
speedtest_server=speedtest_server,
dnsbl=dnsbl,
ndt7=ndt7,
tcp_trace=tcp_trace,
tls=tls or full,
dns_advanced=dns_advanced or full,
path_diversity=path_diversity or full,
prefix_bench=prefix_bench,
ecmp=ecmp,
pmtud=pmtud,
quic=quic,
ipv6=ipv6,
dpi_target=my_server,
formats=formats,
webhook=webhook,
)
if interface:
addrs_by_iface = {
name: [(a.family, a.address.split("%")[0]) for a in addrs]
for name, addrs in psutil.net_if_addrs().items()
}
up_by_iface = {name: stats.isup for name, stats in psutil.net_if_stats().items()}
bind = resolve_bind_target(interface, addrs_by_iface, up_by_iface)
if bind.error:
raise typer.BadParameter(bind.error, param_hint="--interface")
options.interface = interface
options.bind = bind
if bind.ipv4:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.bind((bind.ipv4, 0))
probe.connect(("1.1.1.1", 53))
except OSError:
console.print(
f"[yellow]--interface {bind.iface_name}: no route to the internet from this "
"address yet — continuing, this run may show every module as unreachable.[/yellow]"
)
finally:
probe.close()
if ndt7:
options.ndt7 = _check_ndt7_consent(settings)
if tcp_trace:
console.print(
"[yellow]--tcp-trace needs Npcap (Windows) or root (Unix) and the `tcptrace` extra; "
"if it cannot run, the cascade falls through to the normal tiers.[/yellow]"
)
if watch:
from netsleuth.watch import run_watch
asyncio.run(run_watch(settings, options))
raise typer.Exit(0)
console.print(f"netsleuth {__version__} · {options.mode} mode · {platform.system()}")
report, paths = asyncio.run(diagnose(settings, options))
interpretation = report["interpretation"]
console.print(
f"Verdict: [bold]{interpretation['overall_status']}[/bold] "
f"({interpretation['overall_score']}/100) — {interpretation['summary_text']}"
)
if not paths:
console.print("No report written (--format none).")
else:
label = "Report written to"
for path in paths:
console.print(f"{label} {path}")
label = " " * len("Report written to")
def main() -> None:
app()