This commit is contained in:
Andrew Pimenov 2026-09-04 10:05:07 +03:00 • committed by GitHub
commit 96094b4479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -81,7 +81,7 @@ if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
except AttributeError, ValueError:
pass
app = typer.Typer(add_completion=False, help="Deep network diagnostics.")
@ -185,7 +185,9 @@ def _dedupe(findings: list[Finding]) -> list[Finding]:
return out
async def _identity(client: httpx.AsyncClient, settings: Settings, options: Options, raw: dict) -> dict:
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")
@ -204,7 +206,7 @@ async def _identity(client: httpx.AsyncClient, settings: Settings, options: Opti
merged, cf, flags, payloads = await gather_identity(
v4_client, settings.providers, ip=lookup, ipinfo_token=token
)
except (httpx.HTTPError, OSError):
except httpx.HTTPError, OSError:
merged, cf, flags, payloads = await gather_identity(
client, settings.providers, ip=lookup, ipinfo_token=token
)
@ -220,13 +222,17 @@ async def _identity(client: httpx.AsyncClient, settings: Settings, options: Opti
)
elif own_network:
try:
transport = httpx.AsyncHTTPTransport(local_address=options.bind.ipv6 if options.bind else "::")
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_merged, _cf6, _flags6, _raw6 = await gather_identity(
v6_client, settings.providers
)
v6 = v6_merged if v6_merged.ip else None
except (httpx.HTTPError, OSError):
except httpx.HTTPError, OSError:
v6 = None
dual_stack = dual_stack_mismatch(merged, v6)
nat64_prefix = await detect_nat64(settings.timeouts.dns_seconds)
@ -244,18 +250,32 @@ async def _identity(client: httpx.AsyncClient, settings: Settings, options: Opti
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"])
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
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
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):
async def _reputation_section(
client, settings: Settings, options: Options, geo: IpGeo, raw: dict
):
from netsleuth.reputation import (
ReputationContext,
build_reputation,
@ -267,18 +287,27 @@ async def _reputation_section(client, settings: Settings, options: Options, geo:
)
if not geo.ip:
return ModuleResult(name="reputation", status="skipped", warnings=["no egress IP to check"])
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))
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)
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()
client,
settings.providers,
geo.ip,
settings.abuseipdb_api_key.get_secret_value(),
)
raw["abuseipdb"] = abuse
data = abuse.get("data") or {}
@ -296,13 +325,18 @@ async def _reputation_section(client, settings: Settings, options: Options, geo:
)
)
return ModuleResult(
name="reputation", status="partial" if warnings else "ok", data=reputation, warnings=warnings
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"])
return ModuleResult(
name="tls", status="skipped", warnings=["tls probe skipped: not requested"]
)
from netsleuth.probes.tls_rtt import tls_fanout
cfg = settings.tls
@ -319,24 +353,37 @@ async def _tls_section(settings: Settings, options: Options) -> ModuleResult:
return ModuleResult(name="tls", status="ok", data=results)
async def _dns_advanced_section(client, settings: Settings, options: Options) -> ModuleResult:
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"]
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
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:
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"]
name="path_diversity",
status="skipped",
warnings=["path diversity probe skipped: not requested"],
)
from netsleuth.probes.bgp_path import collect_path_diversity
@ -353,14 +400,20 @@ async def _path_diversity_section(client, settings: Settings, options: Options,
return ModuleResult(name="path_diversity", status="ok", data=pd)
async def _prefix_bench_section(caps, settings: Settings, options: Options, bgp) -> ModuleResult:
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"]
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"]
name="prefix_benchmark",
status="skipped",
warnings=["prefix benchmark skipped: no announced prefixes"],
)
from netsleuth.probes.prefix_benchmark import benchmark_prefixes
@ -384,7 +437,9 @@ async def _prefix_bench_section(caps, settings: Settings, options: Options, bgp)
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"]
name="dpi_check",
status="skipped",
warnings=["dpi check skipped: no explicit --my-server target"],
)
from netsleuth.probes.dpi_check import check_dpi
@ -397,7 +452,13 @@ async def _dpi_section(settings: Settings, options: Options) -> ModuleResult:
return ModuleResult(
name="dpi_check",
status="failed",
errors=[ProbeError(source="dpi_check", kind="unavailable", message=f"could not resolve {target}: {exc}")],
errors=[
ProbeError(
source="dpi_check",
kind="unavailable",
message=f"could not resolve {target}: {exc}",
)
],
)
cfg = settings.dpi_check
console.print(
@ -418,14 +479,20 @@ async def _dpi_section(settings: Settings, options: Options) -> ModuleResult:
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"])
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)"],
warnings=[
"quic probe skipped: aioquic not installed (uv sync --extra quic)"
],
)
from netsleuth.probes.quic_rtt import quic_fanout
@ -434,16 +501,26 @@ async def _quic_section(settings: Settings, options: Options) -> ModuleResult:
# 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")
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)
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:
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"])
return ModuleResult(
name="pmtud",
status="skipped",
warnings=["pmtud probe skipped: not requested"],
)
from netsleuth.probes.pmtud import probe_pmtu
cfg = settings.pmtud
@ -462,13 +539,21 @@ async def _pmtud_section(settings: Settings, options: Options, iface_mtu: int |
return ModuleResult(name="pmtud", status="ok", data=results)
async def _ecmp_section(caps, settings: Settings, options: Options, cycles: int) -> ModuleResult:
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"])
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]]
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:
@ -512,7 +597,9 @@ async def _traces(hosts, caps, settings: Settings, cycles: int, options: Options
)
async def _speed_section(client, settings: Settings, options: Options, idle_rtt_ms: float | None):
async def _speed_section(
client, settings: Settings, options: Options, idle_rtt_ms: float | None
):
import shutil
from netsleuth.speed import (
@ -537,25 +624,35 @@ async def _speed_section(client, settings: Settings, options: Options, idle_rtt_
),
"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),
"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])
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
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
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)
return await tcp_connect_rtt(
"1.1.1.1", timeout=settings.probing.ping_timeout_seconds
)
result = await measure_with_bufferbloat(
result,
@ -577,7 +674,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
raw: dict[str, Any] = {}
client_transport = (
httpx.AsyncHTTPTransport(local_address=options.bind.ipv4, http2=True) if options.bind else None
httpx.AsyncHTTPTransport(local_address=options.bind.ipv4, http2=True)
if options.bind
else None
)
async with httpx.AsyncClient(
transport=client_transport,
@ -594,26 +693,37 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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
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"]
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
"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
"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 []
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"])
@ -621,17 +731,38 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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),
(
bgp_result,
rep_result,
dns_result,
dns_advanced_result,
path_diversity_result,
) = await gather_modules(
run_module(
"reputation", _reputation_section(client, settings, options, geo, raw), timeout=timeouts.module_seconds
"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),
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("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),
@ -639,7 +770,10 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
),
)
modules["bgp"], modules["reputation"] = bgp_result, rep_result
modules["dns_advanced"], modules["path_diversity"] = dns_advanced_result, path_diversity_result
modules["dns_advanced"], modules["path_diversity"] = (
dns_advanced_result,
path_diversity_result,
)
ctx = VpnContext(
local=local,
geo=geo,
@ -656,7 +790,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
data=assess_vpn(
signals,
settings.thresholds.vpn_confidence,
tunnel_iface=local.iface_name if is_tunnel_iface(local.iface_name or "") else None,
tunnel_iface=local.iface_name
if is_tunnel_iface(local.iface_name or "")
else None,
dns_leak=dns_result.data,
),
errors=dns_result.errors,
@ -672,12 +808,31 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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]
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(
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(
@ -690,8 +845,14 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
),
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(
"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(
@ -707,7 +868,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
)
# 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"].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
@ -719,13 +882,19 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
timeout=timeouts.subprocess_seconds,
)
modules["dpi_check"] = await run_module(
"dpi_check", _dpi_section(settings, options), timeout=timeouts.module_seconds
"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
"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
"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
@ -750,27 +919,41 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
# 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)
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"],
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
"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))
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)
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)
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:
@ -778,7 +961,11 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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_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
@ -786,7 +973,11 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
+ 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)]
+ [
f
for result in (modules["pmtud"].data or [])
for f in pmtud_findings(result)
]
+ dual_family_findings(pings)
)
@ -809,7 +1000,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
"path_diversity": options.path_diversity,
"prefix_bench": options.prefix_bench,
},
"captive_portal": bool((modules["captive_portal"].data or CaptivePortal()).detected),
"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()}",
@ -817,7 +1010,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
}
if options.bind:
_kept, dropped = filter_trace_tiers(
tier_order(caps, options.tcp_trace), caps.os_name, forced_v4=bool(options.bind.ipv4)
tier_order(caps, options.tcp_trace),
caps.os_name,
forced_v4=bool(options.bind.ipv4),
)
meta["interface"] = {
"requested": options.bind.requested,
@ -832,12 +1027,18 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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)
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)
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)
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:
@ -851,12 +1052,25 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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 []
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):
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(
@ -884,21 +1098,101 @@ def _check_ndt7_consent(settings: Settings) -> bool:
return False
def _handle_compare(settings: Settings, compare: tuple[Path, Path]) -> None:
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)
def _handle_trend(settings: Settings, trend: int, target: str | None) -> 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)
def _setup_interface(options: Options, interface: str) -> None:
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()
def _print_verdict_and_paths(report: dict, paths: list[Path]) -> None:
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")
@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: str | None = typer.Option(None, "--target", help="Investigate AS<n>, an IP or a domain."),
target_host: str | None = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
speedtest_server: str | None = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
watch: bool = typer.Option(False, "--watch", help="Continuous monitoring with a live dashboard."),
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: str | None = typer.Option(
None, "--target", help="Investigate AS<n>, an IP or a domain."
),
target_host: str | None = typer.Option(
None, "--target-host", help="Extra host for ping/traceroute."
),
speedtest_server: str | None = typer.Option(
None, "--speedtest-server", help="Pin the speedtest server."
),
watch: bool = typer.Option(
False, "--watch", help="Continuous monitoring with a live dashboard."
),
webhook: str | None = typer.Option(
None,
"--webhook",
help="With --watch, POST a JSON alert to this URL on a verdict transition (overrides watch.webhook_url).",
),
compare: tuple[Path, Path] | None = typer.Option(
None, "--compare", help="Diff two saved JSON reports — reads reports produced with --format json."
None,
"--compare",
help="Diff two saved JSON reports — reads reports produced with --format json.",
),
trend: int | None = typer.Option(
None,
@ -906,17 +1200,33 @@ def run(
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."
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.",
),
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."
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."
False,
"--path-diversity",
help="Compare client geo against the Cloudflare edge PoP actually serving traffic.",
),
prefix_bench: bool = typer.Option(
False,
@ -958,7 +1268,9 @@ def run(
),
),
ru: bool = typer.Option(False, "--ru", help="Shorthand for --format ru-md."),
json_flag: bool = typer.Option(False, "--json", help="Shorthand for --format json."),
json_flag: bool = typer.Option(
False, "--json", help="Shorthand for --format json."
),
interface: str | None = typer.Option(
None,
"--interface",
@ -967,38 +1279,23 @@ def run(
) -> 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)
_handle_compare(settings, compare)
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)
_handle_trend(settings, trend, target)
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"
"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))
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,
@ -1023,55 +1320,28 @@ def run(
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()
_setup_interface(options, interface)
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']}"
f"netsleuth {__version__} · {options.mode} mode · {platform.system()}"
)
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")
report, paths = asyncio.run(diagnose(settings, options))
_print_verdict_and_paths(report, paths)
def main() -> None: