netsleuth/docs/superpowers/specs/2026-08-08-netcheck-design.md
2026-08-08 19:13:20 +03:00

256 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# netcheck — design spec
Date: 2026-08-08
Status: approved for planning
## 1. Purpose
A cross-platform (Windows / Linux / macOS) Python 3.10+ CLI for deep network diagnostics: current-connection analysis (ISP/VPN/ASN identity, latency, path, bandwidth) and target-mode lookups (a given ASN/IP/domain). Produces a human-readable Markdown report and a complete machine-readable JSON dump per run. Personal/open-source tool, not commercial — this gates which free-tier APIs are usable (see §7).
## 2. Modes and flags
- **Auto mode (default):** diagnoses the device's active connection.
- **Target mode:** `--target AS<n>` or `--target <ip|domain>` — investigates a specific ASN/host instead of the local connection. Speedtest is skipped in target mode by default (measures the local line, not the target) unless explicitly requested.
- `--full` / `--quick` — full run (speedtest + MTR, ~60–120s) vs express (~10s: reference-host pings only, `mtr_cycles=1`, no speedtest).
- `--target-host <ip|domain>` — extra host added to ping/traceroute fan-out.
- `--speedtest-server <id|url>` — explicit speedtest server override.
- `--watch` — continuous monitoring: periodic re-run (lightweight probes; full speedtest only at a configurable interval), live Rich dashboard in-terminal. For catching intermittent issues (VPN drops in the evening, etc).
- `--compare <report_a.json> <report_b.json>` — diffs two prior JSON reports from `./logs/`, prints what changed (before/after a VPN switch, before/after an ISP call).
- `--dnsbl` — opt-in classic DNSBL reputation check (off by default, see §6).
- `--ndt7` — opt-in M-Lab NDT7 bandwidth tier in the speedtest cascade, with an explicit one-time consent notice (every NDT7 measurement is published as open CC0 data including the client IP — see §5).
- `--tcp-trace` — opt-in scapy-based TCP-SYN traceroute for probing through ICMP-filtering middleboxes (needs Npcap on Windows / root elsewhere). Not part of the default path.
## 3. Module layout
Public surface named in the brief (`cli.py`, `ip_geo.py`, `bgp.py`, `prober.py`, `speed.py`, `exporter.py`) plus supporting modules, split to respect the project convention of ~200 lines/file and one responsibility per module:
```
netcheck/
pyproject.toml # uv-based, source of truth
uv.lock
requirements.txt # generated, for non-uv users
config.yaml
.env.example
.gitignore # must include logs/, .claude/, .venv/
README.md
ARCHITECTURE.md
src/netcheck/
__init__.py __main__.py
cli.py # Typer app, flag parsing, phase orchestration, Rich progress — no business logic
config.py # pydantic-settings, YamlConfigSettingsSource, env > .env > yaml > default
models.py # ModuleResult[T], ProbeError, Finding, Signal — shared shapes
netinfo.py # local iface/gateway/MTU/DNS servers, 3-way OS branch
ip_geo.py # multi-provider identity/geo/IP-type + VPN signal gathering
bgp.py # RIPEstat + CAIDA ASRank + Team Cymru + PeeringDB
reputation.py # FireHOL cached netsets + Shodan InternetDB + optional DNSBL/AbuseIPDB
probes/
__init__.py
latency.py # ping/jitter fan-out (icmplib / IcmpSendEcho2 / TCP-timing / cfL4)
traceroute.py # mtr → per-OS unprivileged ICMP → system-binary cascade
icmp_win.py # ctypes IcmpSendEcho2/Icmp6SendEcho2 engine (Windows only)
dns_leak.py # per-adapter resolver enumeration + echo probes + ECS detection
traceparse.py # pure text→TraceHop parsers: Windows/Linux/macOS
speed.py # cascading speedtest: Ookla bin → Cloudflare → fast.com → [--ndt7]
interpret.py # pure verdict engine: thresholds → Finding[], VPN confidence scoring
exporter.py # JSON + Markdown rendering, atomic writes to ./logs/
compare.py # --compare: diff two JSON reports
watch.py # --watch: periodic re-run loop + live Rich dashboard
tests/
conftest.py
test_traceparse.py test_interpret.py test_exporter.py
test_ip_geo_normalize.py test_speed_math.py test_prober_stats.py
test_dnsbl_decode.py test_compare.py
fixtures/traceroute/{windows,linux,darwin}/*.txt
fixtures/api/*.json
logs/ # gitignored — reports contain external IP, city, coords
```
## 4. Core data shapes
```
ModuleResult[T]: name, status(ok|partial|failed|skipped), data: T|None,
errors: list[ProbeError], warnings: list[str],
started_at, duration_ms
ProbeError: source, kind(timeout|http_error|rate_limited|blocked|
parse_error|unavailable|no_privilege|not_applicable), message, retryable
Finding: id, severity(ok|info|warn|crit), title, detail, metric, value, threshold, advice
Signal: name, observed, weight: float, direction(vpn|clean), note
Capabilities: os_name('Windows'|'Linux'|'Darwin'), is_elevated,
icmp_dgram, icmp_raw, icmp_win_api,
mtr_binary, traceroute_binary,
chosen_latency_backend, chosen_trace_backend, notes
LocalNet: iface_name, local_ipv4, local_ipv6, iface_mtu,
default_gateway_v4/v6, dns_servers_per_adapter: dict[str, list[str]],
is_dual_stack
IpGeo: ip, ip_version, reverse_dns, asn, as_name, org,
country, country_code, city, lat, lon, timezone,
ip_type(residential|mobile|hosting|business|unknown), sources: dict[str,str]
VpnAssessment: verdict(none|likely|confirmed), confidence, signals: list[Signal],
tunnel_iface, dns_leak: DnsLeak|None
DnsLeak: per_adapter: list[AdapterLeakResult], ecs_leaked: bool, note
AdapterLeakResult: adapter, configured_resolvers: list[str],
echoed_ip, echoed_asn, matches_egress_asn
BgpIntel: asn, holder, registry, allocated_at,
upstreams, peers, downstreams,
announced_prefixes, prefix_count_v4/v6,
flaps: list[BgpEvent], stability(stable|unstable|unknown),
ixps: list[IxpPresence], pdb_info_type, pdb_traffic,
asrank, cone_asns, cone_prefixes # from CAIDA ASRank
Reputation: internetdb: InternetDbResult|None,
firehol_hits: list[str], # matched blocklist names
dnsbl_hits: list[DnsblHit] | None, # only if --dnsbl
dnsbl_query_blocked: bool, # true if resolver returned 127.255.255.254
abuseipdb_score, abuseipdb_reports, # only if keyed
captcha_risk(low|medium|high), rationale
PingResult: label, host, resolved_ip, method(icmp_win|icmp_dgram|icmp_raw|tcp|cfL4),
sent, received, loss_pct, min_ms, avg_ms, max_ms, mdev_ms, jitter_ms, samples
TraceHop: ttl, ip, reverse_dns, asn, as_name, probes: list[float|None],
loss_pct, min_ms, avg_ms, max_ms, jitter_ms, annotations
TraceResult: target, resolved_ip, backend(mtr_json|icmp_win|icmplib|system_traceroute|tcp_trace|none),
hops: list[TraceHop], cycles, completed, max_hops_reached
SpeedResult: method(ookla_bin|cloudflare|fastcom|ndt7|none), tier_attempts: list[TierAttempt],
download_mbps, upload_mbps|None, server,
idle_rtt_ms, loaded_rtt_down_ms, loaded_rtt_up_ms,
bufferbloat_down_ms, bufferbloat_up_ms, bufferbloat_grade,
cfL4_stats: CfL4Stats|None, netflix_oca_onnet: bool|None
```
## 5. Speedtest cascade
1. **Ookla native binary** if on PATH (`speedtest --format=json --accept-license --accept-gdpr`). `speedtest-cli` (pip) is archived/unmaintained since 2021 — not used.
2. **Cloudflare** — raw httpx against `speed.cloudflare.com/__down`/`__up`, ramping transfer sizes, 90th-percentile throughput; harvest `cdn-cgi/trace` (`warp=`/`gateway=`/`rbi=` — also feeds VPN signals, §6) and the `Server-Timing: cfL4` header (kernel TCP RTT/loss/retransmit — used as an ICMP-free latency fallback everywhere, not just here).
3. **fast.com (Netflix)** — `api.fast.com/netflix/speedtest/v2` public token, download-only, download from returned OCA target URLs; the OCA hostname reveals on-net-vs-off-net Netflix appliance placement, a real ISP-peering-quality signal RIPEstat/PeeringDB can't provide directly.
4. **`--ndt7` (opt-in)** — M-Lab NDT7 over WebSocket via `locate.measurementlab.net`. Best TCP-level fidelity (BBR/retransmit detail). Requires one-time consent notice: results are published publicly as CC0 open data including the client IP. Not in the default cascade.
Bufferbloat measured inside `speed.py`: a concurrent latency probe loop during saturation, compared to the idle RTT baseline from `probes/latency.py`, graded A–F, download and upload reported separately.
Fails gracefully — cascade exhaustion yields `status=failed` for `speed`, never an exception; rest of the report unaffected.
## 6. VPN / IP-type detection
**Provider chain (parallel, merged by field):**
- `cdn-cgi/trace` (Cloudflare) — egress IP, colo, country, `warp`/`gateway`/`rbi` flags
- `ip-api.com` — geo/ASN + `mobile`/`proxy`/`hosting` booleans (HTTP-only free tier)
- `freeipapi.com` — HTTPS keyless equivalent, used in parallel so an ip-api block/rate-limit doesn't blank the classification
- `ipinfo.io` — opportunistic enrichment if a token is configured (keyless free tier 429s in practice; `privacy` field is paid-only)
- `ipwho.is` — geo/ASN backup
- RIPEstat `network-info` — authoritative prefix/ASN
Both IPv4 and IPv6 egress resolved and compared — providers have been observed to disagree (dual-stack egress can differ per path); mismatch is reported, not silently resolved.
**VPN confidence signals** (scored in `interpret.py`, gathered elsewhere):
private-gateway-vs-egress mismatch; tunnel interface present (`tun*`/`tap*`/`wg*`/`utun*`/`ppp*`, Windows `WireGuard Tunnel`/`TAP-Windows`); MTU anomaly (WireGuard ~1420, IPsec ~1400); `hosting`/`proxy`/`mobile` flags from the provider chain; Cloudflare `warp=on`; PeeringDB `info_type` indicating NSP/hosting rather than eyeball ISP; DNS-resolver-ASN ≠ egress-ASN (§ DNS leak below); OS timezone vs geo country mismatch.
**DNS leak test:** enumerate DNS servers per network adapter (not just "the system resolver" — catches the case where the VPN's tunnel adapter is clean but a Wi-Fi adapter still holds the ISP resolver). For each configured resolver, send echo probes to `o-o.myaddr.l.google.com` (TXT) and `whoami.ds.akahelp.net` (TXT — also surfaces EDNS Client Subnet leakage, a separate finding from ASN mismatch), map the echoed IP to an ASN via Team Cymru DNS, compare against egress ASN. Reported per-adapter. Report language is explicit that this detects OS-level resolver leaks only — a browser using DoH bypasses it.
## 7. BGP / ASN intelligence
- **RIPEstat** (`stat.ripe.net`, keyless) — `as-overview`, `asn-neighbours` (upstreams/peers/downstreams), `announced-prefixes`, `routing-status`, `bgp-updates` (flap history), `whois`. Verified global (no ARIN/APNIC-region gap). `routing-history`/similar bulk endpoints **must** be called with bounded `max_rows`/timeframe — unbounded calls on large ISPs return 10MB+ payloads.
- **CAIDA ASRank** (`api.asrank.caida.org`, keyless) — rank, customer-cone size (ASNs/prefixes/addresses), degree (customer/peer/provider) — the "how big is this network" metric RIPEstat doesn't have.
- **Team Cymru** (`<ip>.origin.asn.cymru.com` TXT, keyless DNS) — IP→ASN/prefix/RIR/alloc-date fallback for when HTTPS is unavailable; also used to resolve DNS-leak echo results (§6).
- **PeeringDB** (`www.peeringdb.com/api`, keyless REST) — IXP presence, `info_type`/`info_traffic`/`policy_general`. Anonymous limits are tight (20 req/min, large responses throttled to 1/hour) — results cached to disk, never re-fetched within a run.
- BGPView.io is defunct (shut down 2025-11-26) — not used. bgp.he.net is HTML-only — not scraped. bgp.tools has no REST API (whois:43 + static table dumps only, mandatory identifying User-Agent, HTML scraping bans) — not used in v1, candidate for a later optional enrichment.
## 8. Reputation
**Default (keyless, no per-query disclosure to a third party of "which IP am I checking"):**
- **FireHOL / community IP blocklists** — cached netset download (~2–5MB, refreshed periodically per config), local prefix-trie lookup. No runtime query, no rate limit, no privacy exposure.
- **Shodan InternetDB** (`internetdb.shodan.io`, keyless) — open ports/hostnames/tags on the egress IP. More actionable for a home user than a mail-blocklist hit (exposed router, forgotten port-forward). Free for non-commercial use, matching netcheck's own status (§ project status).
**Opt-in:**
- `--dnsbl` — classic Spamhaus ZEN / SpamCop / Barracuda / DroneBL zone queries. **Must** decode Spamhaus's `127.255.255.x` error range correctly: `.254` means "you queried via a public resolver" (hits a large share of real users on 1.1.1.1/8.8.8.8) and `.255` means rate-limited — neither is a listing, and a naive "any 127.x.x.x = listed" implementation produces false-positive red flags for most users. Both are surfaced as "DNSBL result unavailable" rather than a finding.
- AbuseIPDB / IPQualityScore — only when a key is configured in `.env`.
## 9. Traceroute / MTR
Cascade, no privilege ever required for the default path:
1. `mtr --json`/`mtr --report --json` binary if present (best data: per-hop loss% and stddev for free).
2. **Windows:** ctypes engine (`icmp_win.py`) around `IcmpSendEcho2`/`Icmp6SendEcho2` (`Iphlpapi.dll`) — the same unprivileged API `tracert.exe` itself uses internally. No Administrator, no Npcap, stdlib-only (`ctypes`). Per-hop RTT, TTL-expired detection, proper timeout handling — strictly better data than parsing `tracert.exe`'s text output (3 fixed probes, localized headers, no loss%/jitter).
3. **Linux/macOS:** `icmplib` unprivileged mode (`SOCK_DGRAM` ICMP) — works out of the box on macOS, on Linux subject to `net.ipv4.ping_group_range` (true by default on most desktop distros, may need adjustment in containers); falls back to `icmplib` privileged (raw socket, needs root) when available.
4. Text-parsing the system binary (`tracert.exe` / GNU `traceroute` / BSD `traceroute`) as the last resort, via `traceparse.py` — three separate parsers, since Windows/Linux/macOS output formats genuinely differ (BSD emits multi-IP-per-hop lines and `!H`/`!N`/`!X` annotations Linux doesn't; Windows output is locale- and code-page-dependent — cp866 on Russian Windows, not UTF-8 — so parsing is by line *shape* (leading hop integer, RTT tokens, IP regex), never by matching header/footer text).
5. `--tcp-trace` (opt-in, not default): scapy TCP-SYN traceroute for getting past ICMP-filtering middleboxes. Needs Npcap (Windows) / root (Unix) — kept out of the default dependency set and default path entirely.
Latency probing (`probes/latency.py`) never parses `ping`'s own text output (would mean three more locale-dependent parsers for no benefit): uses `icmp_win`/`icmplib` when available, else TCP-connect timing to port 443, else the Cloudflare `cfL4` header. Every result is tagged with the method used — TCP "loss" (connection-failure rate) and ICMP loss are different metrics and must never be silently conflated in the report.
## 10. Capability detection
Attempt-based, never OS-assumption-based; cached once per run into `Capabilities`, included in JSON `meta`.
- Elevation: `ctypes.windll.shell32.IsUserAnAdmin()` (Windows) / `os.geteuid() == 0` (Linux, macOS).
- ICMP: try unprivileged `SOCK_DGRAM` ICMP → raw `SOCK_RAW` ICMP → (Windows) `IcmpSendEcho2` availability (always available, no privilege check needed) → none.
- Never require elevation for the default path — worst case degrades to TCP-connect timing + system-binary traceroute + `cfL4` header, and the report states the degradation once, plainly, with the one-line remedy.
## 11. Error handling
Single wrapper `async def run_module(name, coro, *, timeout) -> ModuleResult` — times the call, enforces a per-module timeout, catches `BaseException` except `CancelledError`, classifies into `ProbeError.kind`, always returns a well-formed envelope. `asyncio.gather(..., return_exceptions=True)` as a second net at the orchestration layer. Failure is a value, not control flow — no single API/probe failure aborts the run.
Four status levels: `ok` → `partial` (some sub-sources failed) → `failed` (nothing usable) → `skipped` (not applicable / missing optional key).
Both outputs surface every failure: JSON carries per-module `errors[]` plus a flattened top-level `errors[]` union; Markdown has a mandatory "Run diagnostics" section (module/status/duration/error table) plus inline placeholders in the body instead of silently omitting sections (a report missing its speed section must not be indistinguishable from a report where speed was fine).
`exporter.py` uses `json.dumps(..., allow_nan=False)`, coercing `inf`/`NaN` (possible from zero-duration timing math) to `null` before serialization — otherwise "strictly valid JSON" breaks on exactly the failure cases that matter most.
## 12. Concurrency model
Single shared `httpx.AsyncClient`; per-host semaphores for rate-limited APIs (ip-api 45/min, PeeringDB 20/min). Blocking calls (`speedtest` binary via subprocess is fine natively; `icmplib`/ctypes send/receive; `psutil` enumeration) via `asyncio.to_thread` where genuinely blocking. Subprocess traceroute via `asyncio.create_subprocess_exec` (native on all three OSes, no thread wrapper needed).
Two hard constraints:
1. **Measurement isolation** — speedtest never overlaps traceroute/ping/API calls (an exclusive phase lock); the one intentional exception is the bufferbloat probe, which overlaps saturation *by design* inside `speed.py`.
2. **Bounded traceroute fan-out** — parallel traceroutes to different targets share early hops and inflate each other's latency; bound concurrent traceroutes with a semaphore (2–3), never unbounded `gather`.
Phase order: `netinfo` + `ip_geo` provider chain (blocking — everything downstream needs the ASN) → `bgp` ∥ `reputation` ∥ `dns_leak` (parallel) → `latency` ∥ `traceroute` fan-out (parallel, bounded) → `speed` (exclusive) → `interpret` → `exporter`. `--quick` skips speed and reduces the probe fan-out to reference hosts with `mtr_cycles=1`.
## 13. Config and secrets
`config.py` via `pydantic-settings` + `YamlConfigSettingsSource`, precedence env > `.env` > `config.yaml` > default. All three optional API keys (`IPINFO_TOKEN`, `PEERINGDB_API_KEY`, `ABUSEIPDB_API_KEY`) are `SecretStr | None = None` — a diagnostics tool must run with zero configuration; a missing key downgrades the dependent enrichment to `skipped` with a warning, never fails the run.
`.env` — secrets only (the three keys above). `config.yaml` — timeouts, `probing` (reference/service hosts, ping count, mtr cycles, max hops), `speedtest` (enabled tiers, transfer sizes, CDN fallback URLs), `providers` (base URLs, FireHOL refresh interval), `thresholds` (latency/jitter/loss/bufferbloat good/warn bands), `output` (logs dir, emoji on/off), `watch` (interval, dashboard refresh rate).
## 14. Output
**Filenames:** `report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,json}` (compact ISO — Windows forbids `:` in filenames), falling back to `report_unknown_…` when ASN lookup fails entirely. Written via temp-file + atomic rename into `./logs/` (gitignored — reports contain external IP, city, coordinates, ISP name).
**JSON top-level keys:** `schema_version, meta{run_id, started_at, finished_at, mode, target, flags, host_os, capabilities}, connection, ip_geo, vpn_assessment, bgp, reputation, latency, path, speed, interpretation{overall_status, overall_score, summary_text, findings}, errors, raw{<source>: <verbatim payload>, ...}`. `raw` stores every provider response verbatim under its source key — this is what actually satisfies "100% of collected data, no truncation," since the typed layer above it can drop fields the raw payload still has.
**Markdown sections:** Header (mode/target/timestamp/overall verdict) → TL;DR (top findings) → Connection & identity → VPN/proxy assessment (verdict + signals table) → ASN & BGP intelligence (upstreams, prefixes, stability, IXPs, ASRank) → Reputation (InternetDB, FireHOL hits, DNSBL if run) → Latency (per-host table + sparkline) → Path/MTR (hop table + ASCII bar, first-loss-jump highlighted) → Speed (down/up, cascade tier used, bufferbloat with plain-language consequence) → Problems & recommendations → Run diagnostics (module status table). ASCII graphs inside fenced code blocks so `rich.markdown.Markdown` doesn't reflow them; emoji (🟢/🟡/🔴) gated behind `output.emoji` config.
## 15. `--compare` and `--watch`
- **`--compare a.json b.json`** (`compare.py`): loads two prior JSON reports, diffs the typed fields (egress IP/ASN, VPN verdict, latency deltas per host, speed deltas, new/resolved Findings), prints a compact before/after table. Read-only, no new probing.
- **`--watch`** (`watch.py`): loop of lightweight runs (reference-host ping/jitter + a reduced traceroute) at a configurable interval, full speedtest only every N cycles (configurable), live-updating Rich table/dashboard in the terminal. Each cycle's summary is appended to the run's JSON log (not a full separate report file per tick) so a `--watch` session produces one time-series artifact, not hundreds of report files.
## 16. Testing strategy
Per project convention — meaningful behavior only, glue untested.
**Test:** `traceparse.py` (all three OS formats incl. BSD multi-IP hops, `!H`/`!N` annotations, cp866-localized Windows output, timeouts, truncated/max-hops-without-arrival); `interpret.py` (threshold→severity mapping, VPN confidence across signal combinations including combos that should *not* fire, bufferbloat grading); `ip_geo.py` normalizers (each provider's shape → common `IpGeo`, from captured fixtures); reputation `127.255.255.x` decoding (regression-critical — this is the false-positive bug found during design, §8); `speed.py` math (bytes+interval→Mbps, `cfL4` header parsing); `probes/latency.py` statistics (jitter/loss/mdev, all-timeout and single-sample edge cases); `exporter.py` (JSON validity with `allow_nan=False`/inf-NaN regression, all-modules-failed run still produces a complete report, filename sanitization); `compare.py` diff logic; cascade ordering (tier1 fails → tier2 used → ... → all-fail yields `status=failed` not an exception, via `pytest-httpx`).
**Skip:** live HTTP, real ICMP, Typer wiring, config loading, `psutil`/`ctypes` calls, `--watch` timing loop.
## 17. Dependencies
```
typer>=0.12 rich>=13.7 httpx[http2]>=0.28
pydantic>=2.7 pydantic-settings>=2.3
pyyaml>=6.0 dnspython>=2.6 psutil>=5.9
[optional] tcptrace = scapy>=2.5 # --tcp-trace only
[optional] ndt7 = websockets>=12 # --ndt7 only
[dev] pytest>=8 pytest-asyncio>=0.23 pytest-httpx>=0.30
```
`icmplib` is Unix-only per its own docs (ignored on Windows) — included as a base dependency, used conditionally by OS. No Ookla pip package dependency (archived); the native binary is an optional runtime dependency the tool detects, never installs.
## 18. Project status
Personal/open-source tool, not commercial, not sold or bundled into a paid product. This is a hard input to §6/§8's provider choice — Shodan InternetDB and Spamhaus's free mirrors are non-commercial-use-only, and this status is what makes them usable by default. If that status ever changes, §8's default reputation sources need re-review before shipping.