mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 02:12:04 +03:00
Merge 801fd4ef34 into 8c99ac78b7
This commit is contained in:
commit
96094b4479
1 changed files with 423 additions and 153 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue