mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 21:32:04 +03:00
wire TLS, prefix bench, DPI check, DNS advanced, path diversity into the CLI pipeline
This commit is contained in:
parent
342d92e709
commit
4ed455700b
1 changed files with 189 additions and 4 deletions
|
|
@ -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.iface import available_interfaces_hint, resolve_bind_target
|
||||||
from netsleuth.interpret import (
|
from netsleuth.interpret import (
|
||||||
assess_vpn,
|
assess_vpn,
|
||||||
|
dns_advanced_findings,
|
||||||
|
dpi_findings,
|
||||||
gather_vpn_signals,
|
gather_vpn_signals,
|
||||||
latency_findings,
|
latency_findings,
|
||||||
|
path_diversity_findings,
|
||||||
path_findings,
|
path_findings,
|
||||||
|
prefix_findings,
|
||||||
speed_findings,
|
speed_findings,
|
||||||
|
tls_findings,
|
||||||
)
|
)
|
||||||
from netsleuth.ip_geo import dual_stack_mismatch, gather_identity
|
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.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.orchestration import gather_modules, run_module, utc_now_iso
|
||||||
from netsleuth.probes.dns_leak import collect_dns_leak
|
from netsleuth.probes.dns_leak import collect_dns_leak
|
||||||
|
|
@ -62,6 +67,11 @@ class Options:
|
||||||
dnsbl: bool = False
|
dnsbl: bool = False
|
||||||
ndt7: bool = False
|
ndt7: bool = False
|
||||||
tcp_trace: 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"})
|
formats: frozenset[str] = frozenset({"md"})
|
||||||
interface: str | None = None
|
interface: str | None = None
|
||||||
bind: BindTarget | 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):
|
async def _traces(hosts, caps, settings: Settings, cycles: int, options: Options):
|
||||||
semaphore = asyncio.Semaphore(settings.probing.trace_concurrency)
|
semaphore = asyncio.Semaphore(settings.probing.trace_concurrency)
|
||||||
return list(
|
return list(
|
||||||
|
|
@ -362,8 +486,8 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
modules["ip_geo"].warnings.extend(identity_warnings)
|
modules["ip_geo"].warnings.extend(identity_warnings)
|
||||||
asn = geo.asn if options.target_kind != "asn" else options.target_value
|
asn = geo.asn if options.target_kind != "asn" else options.target_value
|
||||||
|
|
||||||
# Phase 2 — bgp || reputation || dns_leak
|
# Phase 2 — bgp || reputation || dns_leak || dns_advanced || path_diversity
|
||||||
bgp_result, rep_result, dns_result = await gather_modules(
|
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("bgp", _bgp_section(client, settings, asn, raw), timeout=timeouts.module_seconds),
|
||||||
run_module(
|
run_module(
|
||||||
"reputation", _reputation_section(client, settings, options, geo, raw), timeout=timeouts.module_seconds
|
"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),
|
collect_dns_leak(local, asn, settings.providers.cymru_origin_zone, timeouts.dns_seconds),
|
||||||
timeout=timeouts.module_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["bgp"], modules["reputation"] = bgp_result, rep_result
|
||||||
|
modules["dns_advanced"], modules["path_diversity"] = dns_advanced_result, path_diversity_result
|
||||||
ctx = VpnContext(
|
ctx = VpnContext(
|
||||||
local=local,
|
local=local,
|
||||||
geo=geo,
|
geo=geo,
|
||||||
|
|
@ -409,7 +540,7 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
hosts.append(("target", options.target_value))
|
hosts.append(("target", options.target_value))
|
||||||
count = settings.probing.quick_ping_count if options.quick else settings.probing.ping_count
|
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
|
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(
|
run_module(
|
||||||
"latency",
|
"latency",
|
||||||
ping_fanout(
|
ping_fanout(
|
||||||
|
|
@ -423,6 +554,19 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
timeout=timeouts.subprocess_seconds,
|
timeout=timeouts.subprocess_seconds,
|
||||||
),
|
),
|
||||||
run_module("path", _traces(hosts, caps, settings, cycles, options), 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.
|
# 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
|
# Phase 5 — interpret
|
||||||
speed = modules["speed"].data or SpeedResult()
|
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(
|
findings = _dedupe(
|
||||||
latency_findings(pings, settings.thresholds)
|
latency_findings(pings, settings.thresholds)
|
||||||
+ [f for trace in (modules["path"].data or []) for f in path_findings(trace)]
|
+ [f for trace in (modules["path"].data or []) for f in path_findings(trace)]
|
||||||
+ speed_findings(speed, settings.thresholds.bufferbloat_ms)
|
+ speed_findings(speed, settings.thresholds.bufferbloat_ms)
|
||||||
|
+ l7_findings
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 6 — export
|
# Phase 6 — export
|
||||||
|
|
@ -462,7 +616,12 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
"ndt7": options.ndt7,
|
"ndt7": options.ndt7,
|
||||||
"tcp_trace": options.tcp_trace,
|
"tcp_trace": options.tcp_trace,
|
||||||
"interface": options.interface,
|
"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),
|
"formats": sorted(options.formats),
|
||||||
"host_os": f"{platform.system()} {platform.release()}",
|
"host_os": f"{platform.system()} {platform.release()}",
|
||||||
"capabilities": caps,
|
"capabilities": caps,
|
||||||
|
|
@ -527,6 +686,23 @@ def run(
|
||||||
dnsbl: bool = typer.Option(False, "--dnsbl", help="Also query classic DNSBL zones."),
|
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)."),
|
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."),
|
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_: List[str] = typer.Option(
|
||||||
[],
|
[],
|
||||||
"--format",
|
"--format",
|
||||||
|
|
@ -550,6 +726,10 @@ def run(
|
||||||
console.print(render_diff(diff_reports(before, after), emoji=settings.output.emoji))
|
console.print(render_diff(diff_reports(before, after), emoji=settings.output.emoji))
|
||||||
raise typer.Exit(0)
|
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)
|
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(
|
options = Options(
|
||||||
|
|
@ -563,6 +743,11 @@ def run(
|
||||||
dnsbl=dnsbl,
|
dnsbl=dnsbl,
|
||||||
ndt7=ndt7,
|
ndt7=ndt7,
|
||||||
tcp_trace=tcp_trace,
|
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,
|
formats=formats,
|
||||||
)
|
)
|
||||||
if interface:
|
if interface:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue