wire TLS, prefix bench, DPI check, DNS advanced, path diversity into the CLI pipeline

This commit is contained in:
Andrew Pimenov 2026-08-10 21:18:19 +03:00
parent 342d92e709
commit 4ed455700b

View file

@ -22,13 +22,18 @@ from netsleuth.exporter import build_report, dump_json, egress_asn, render_markd
from netsleuth.iface import available_interfaces_hint, resolve_bind_target
from netsleuth.interpret import (
assess_vpn,
dns_advanced_findings,
dpi_findings,
gather_vpn_signals,
latency_findings,
path_diversity_findings,
path_findings,
prefix_findings,
speed_findings,
tls_findings,
)
from netsleuth.ip_geo import dual_stack_mismatch, gather_identity
from netsleuth.models import BindTarget, Finding, IpGeo, LocalNet, ModuleResult, SpeedResult, VpnContext
from netsleuth.models import BindTarget, Finding, IpGeo, LocalNet, ModuleResult, ProbeError, SpeedResult, VpnContext
from netsleuth.netinfo import collect_local_net, detect_capabilities, iface_for_ip, is_tunnel_iface, primary_interface_ip
from netsleuth.orchestration import gather_modules, run_module, utc_now_iso
from netsleuth.probes.dns_leak import collect_dns_leak
@ -62,6 +67,11 @@ class Options:
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
dpi_target: str | None = None
formats: frozenset[str] = frozenset({"md"})
interface: str | None = None
bind: BindTarget | None = None
@ -250,6 +260,120 @@ async def _reputation_section(client, settings: Settings, options: Options, geo:
)
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,
)
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 _traces(hosts, caps, settings: Settings, cycles: int, options: Options):
semaphore = asyncio.Semaphore(settings.probing.trace_concurrency)
return list(
@ -362,8 +486,8 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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
bgp_result, rep_result, dns_result = await gather_modules(
# 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
@ -373,8 +497,15 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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,
@ -409,7 +540,7 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
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
modules["latency"], modules["path"] = await gather_modules(
modules["latency"], modules["path"], modules["tls"] = await gather_modules(
run_module(
"latency",
ping_fanout(
@ -423,6 +554,19 @@ 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),
)
# 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
)
# Phase 4 — speed, exclusive: nothing else is in flight at this point.
@ -442,10 +586,20 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
# 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)
findings = _dedupe(
latency_findings(pings, settings.thresholds)
+ [f for trace in (modules["path"].data or []) for f in path_findings(trace)]
+ speed_findings(speed, settings.thresholds.bufferbloat_ms)
+ l7_findings
)
# Phase 6 — export
@ -462,7 +616,12 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
"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,
},
"dpi_target": options.dpi_target,
"formats": sorted(options.formats),
"host_os": f"{platform.system()} {platform.release()}",
"capabilities": caps,
@ -527,6 +686,23 @@ def run(
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.",
),
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",
@ -550,6 +726,10 @@ def run(
console.print(render_diff(diff_reports(before, after), emoji=settings.output.emoji))
raise typer.Exit(0)
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(
@ -563,6 +743,11 @@ def run(
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,
dpi_target=my_server,
formats=formats,
)
if interface: