mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 15:42:10 +03:00
detect CGNAT, NAT64, and first-hop-vs-path issues separately
- net.cgnat: three evidence paths (local IP, gateway, traceroute hop) for RFC 6598 100.64.0.0/10, since the local address alone misses the common home-router case - net.nat64: RFC 7050 ipv4only.arpa AAAA probe (new probes/nat64.py) instead of checking the client's own address against 64:ff9b::/96, which never fires since that prefix is a synthesis target, not something the client's egress address falls into - path.first_hop_loss/slow/unexpected: path_findings() takes an optional LocalNet/Thresholds so loss or latency at hop 1 is attributed to the user's own router/Wi-Fi instead of blending into the ISP-facing mid-path logic All four findings are always-on additions to existing report sections, no new CLI flags. Part of the larger extension batch tracked in plans/zesty-zooming-wilkinson.md (gitignored, local planning doc).
This commit is contained in:
parent
3a0f95c793
commit
76cfd4605c
15 changed files with 330 additions and 10 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ bugs.md
|
||||||
llm_context*
|
llm_context*
|
||||||
TASKS.md
|
TASKS.md
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
plans/
|
||||||
|
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ src/netsleuth/
|
||||||
bgp_path.py --path-diversity: CF-RAY edge colo lookup, international-routing-loop detection.
|
bgp_path.py --path-diversity: CF-RAY edge colo lookup, international-routing-loop detection.
|
||||||
prefix_benchmark.py --prefix-bench: ping the first host of a capped sample of the AS's announced prefixes.
|
prefix_benchmark.py --prefix-bench: ping the first host of a capped sample of the AS's announced prefixes.
|
||||||
dpi_check.py --my-server: single-host TCP RST/filtering self-diagnostic. Not a scanner — see Key decisions.
|
dpi_check.py --my-server: single-host TCP RST/filtering self-diagnostic. Not a scanner — see Key decisions.
|
||||||
|
nat64.py One RFC 7050 AAAA query (ipv4only.arpa) to detect NAT64/464XLAT translation.
|
||||||
```
|
```
|
||||||
|
|
||||||
**File-size exceptions.** The ~200-line guideline (Global Constraints) held for most modules, but these grew past it during implementation and were kept as single files rather than split, because each one is a single cohesive responsibility that splitting would only scatter across more files with a thinner reason to draw the line anywhere in particular:
|
**File-size exceptions.** The ~200-line guideline (Global Constraints) held for most modules, but these grew past it during implementation and were kept as single files rather than split, because each one is a single cohesive responsibility that splitting would only scatter across more files with a thinner reason to draw the line anywhere in particular:
|
||||||
|
|
@ -124,6 +125,12 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
|
||||||
|
|
||||||
**The international-routing-loop check compares the client's own geolocated country against the edge PoP's country — nothing else.** An earlier version of `detect_international_loop()` also required the *target's* resolved-IP geolocation to match the client's country before it would fire, on the theory that this ruled out an unrelated confound. In practice nothing ever populated that field, so the check could never fire at all — caught by manually running `--path-diversity` end-to-end and inspecting the JSON output, not by any unit test, since the unit tests exercised the function directly and simply supplied the value the buggy production code path never provided. The lesson generalized: two inputs beat three when the third can't reliably be filled in, and a smoke run of new opt-in flags belongs in the same pass as the unit tests, not after them.
|
**The international-routing-loop check compares the client's own geolocated country against the edge PoP's country — nothing else.** An earlier version of `detect_international_loop()` also required the *target's* resolved-IP geolocation to match the client's country before it would fire, on the theory that this ruled out an unrelated confound. In practice nothing ever populated that field, so the check could never fire at all — caught by manually running `--path-diversity` end-to-end and inspecting the JSON output, not by any unit test, since the unit tests exercised the function directly and simply supplied the value the buggy production code path never provided. The lesson generalized: two inputs beat three when the third can't reliably be filled in, and a smoke run of new opt-in flags belongs in the same pass as the unit tests, not after them.
|
||||||
|
|
||||||
|
**CGNAT detection uses three evidence paths, not one.** A user behind a home router sees a private `192.168.x.x` address locally while carrier-grade NAT (RFC 6598, `100.64.0.0/10`) lives one hop further up — checking only `LocalNet.local_ipv4` would miss the common case entirely. `cgnat_findings()` checks the local address, then the default gateway, then every traceroute hop, in that order, and records which one fired as the finding's evidence string.
|
||||||
|
|
||||||
|
**NAT64/464XLAT is detected the RFC 7050 way, not by checking the client's own address.** The well-known synthesis prefix `64:ff9b::/96` is where a NAT64 resolver *embeds* an IPv4 destination, never something the client's own egress address falls into — an earlier design that checked the egress IPv6 against that prefix would never fire. The correct probe is a single AAAA query for `ipv4only.arpa`: its A records are two fixed addresses (`192.0.0.170`/`.171`), and a DNS64 resolver synthesizes an AAAA answer embedding one of them in the network's actual (often ISP-specific) `/96` prefix; a real dual-stack network returns no AAAA for that name at all.
|
||||||
|
|
||||||
|
**The first traceroute hop is diagnosed separately from the rest of the path.** Loss or latency at hop 1 means the user's own router or Wi-Fi, not the ISP or anything downstream — `path_findings()` takes an optional `LocalNet` and, when supplied, compares the first hop's IP against the OS-observed default gateway before running the existing mid-path loss-jump logic, so the two questions ("is my LAN fine" vs "where does the wider path degrade") stay structurally separate rather than one drowning out the other.
|
||||||
|
|
||||||
**Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it.
|
**Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it.
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
@ -142,4 +149,4 @@ Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`uv run pytest -q`. Roughly 705 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.
|
`uv run pytest -q`. Roughly 723 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ src/netsleuth/
|
||||||
bgp_path.py --path-diversity: поиск colo по CF-RAY, детект международной петли маршрута.
|
bgp_path.py --path-diversity: поиск colo по CF-RAY, детект международной петли маршрута.
|
||||||
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
|
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
|
||||||
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
|
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
|
||||||
|
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Исключения по размеру файла.** Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:
|
**Исключения по размеру файла.** Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:
|
||||||
|
|
@ -124,6 +125,12 @@ src/netsleuth/
|
||||||
|
|
||||||
**Детектор международной петли маршрута сравнивает собственную геолокацию клиента со страной edge PoP — и ничего больше.** Более ранняя версия `detect_international_loop()` также требовала совпадения геолокации по резолвнутому IP *цели* со страной клиента, из соображения, что это отсекает лишний конфаундер. На практике это поле никогда и ничем не заполнялось, поэтому детектор не мог сработать вообще ни разу — это обнаружилось при ручном сквозном запуске `--path-diversity` и разборе JSON, а не юнит-тестом: тесты дёргали функцию напрямую и сами подставляли значение, которое рабочий код так и не научился передавать. Вывод общий: два входных параметра лучше трёх, если третий нечем надёжно заполнить, а смоук-прогон новых опциональных флагов должен идти в том же проходе, что юнит-тесты, а не после них.
|
**Детектор международной петли маршрута сравнивает собственную геолокацию клиента со страной edge PoP — и ничего больше.** Более ранняя версия `detect_international_loop()` также требовала совпадения геолокации по резолвнутому IP *цели* со страной клиента, из соображения, что это отсекает лишний конфаундер. На практике это поле никогда и ничем не заполнялось, поэтому детектор не мог сработать вообще ни разу — это обнаружилось при ручном сквозном запуске `--path-diversity` и разборе JSON, а не юнит-тестом: тесты дёргали функцию напрямую и сами подставляли значение, которое рабочий код так и не научился передавать. Вывод общий: два входных параметра лучше трёх, если третий нечем надёжно заполнить, а смоук-прогон новых опциональных флагов должен идти в том же проходе, что юнит-тесты, а не после них.
|
||||||
|
|
||||||
|
**Детект CGNAT использует три источника доказательств, а не один.** Пользователь за домашним роутером видит локально приватный `192.168.x.x`, пока carrier-grade NAT (RFC 6598, `100.64.0.0/10`) сидит на хоп выше — проверка только `LocalNet.local_ipv4` пропустила бы самый частый случай. `cgnat_findings()` проверяет локальный адрес, затем шлюз по умолчанию, затем каждый хоп трассировки, в этом порядке, и записывает, какой именно сработал, в строку доказательства находки.
|
||||||
|
|
||||||
|
**NAT64/464XLAT определяется по RFC 7050, а не проверкой собственного адреса клиента.** Известный префикс синтеза `64:ff9b::/96` — это куда NAT64-резолвер *встраивает* IPv4-адрес назначения, а не то, во что попадает собственный исходящий адрес клиента — более ранний дизайн, проверявший egress IPv6 на вхождение в этот префикс, никогда бы не сработал. Правильная проба — один AAAA-запрос к `ipv4only.arpa`: его A-записи — два фиксированных адреса (`192.0.0.170`/`.171`), и DNS64-резолвер синтезирует AAAA-ответ, встраивая один из них в реальный (часто специфичный для провайдера) `/96`-префикс сети; настоящая dual-stack сеть на этот запрос вообще не вернёт AAAA.
|
||||||
|
|
||||||
|
**Первый хоп трассировки диагностируется отдельно от остального маршрута.** Потери или задержка на хопе 1 означают проблему в собственном роутере или Wi-Fi пользователя, а не у провайдера или где-то дальше по маршруту — `path_findings()` принимает опциональный `LocalNet` и, если он передан, сравнивает IP первого хопа с шлюзом по умолчанию, который видит ОС, перед тем как запускать существующую логику потерь в середине маршрута — так два вопроса («в порядке ли моя локальная сеть» и «где деградирует остальной путь») остаются структурно разделены, а не тонут один в другом.
|
||||||
|
|
||||||
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
@ -142,4 +149,4 @@ src/netsleuth/
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
`uv run pytest -q`. Около 705 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|
`uv run pytest -q`. Около 723 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,12 @@ None of this is required — netsleuth degrades gracefully without any of it —
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **Identity** — egress IPv4 *and* IPv6, reverse DNS, ASN, organisation, geo, and whether the address looks residential, mobile, hosting or business. Cross-checked across six independent providers so one rate limit does not blank the answer.
|
- **Identity** — egress IPv4 *and* IPv6, reverse DNS, ASN, organisation, geo, and whether the address looks residential, mobile, hosting or business. Cross-checked across six independent providers so one rate limit does not blank the answer. Also flags carrier-grade NAT (RFC 6598) and NAT64/464XLAT translation (RFC 7050) when present.
|
||||||
- **VPN / proxy assessment** — a weighted verdict (`none` / `likely` / `confirmed`) built from tunnel interfaces, MTU anomalies, gateway-vs-egress mismatch, Cloudflare WARP flags, provider proxy/hosting flags, PeeringDB network type, timezone-vs-geo mismatch, and DNS resolver ASN mismatch.
|
- **VPN / proxy assessment** — a weighted verdict (`none` / `likely` / `confirmed`) built from tunnel interfaces, MTU anomalies, gateway-vs-egress mismatch, Cloudflare WARP flags, provider proxy/hosting flags, PeeringDB network type, timezone-vs-geo mismatch, and DNS resolver ASN mismatch.
|
||||||
- **DNS leak test** — enumerates resolvers *per network adapter*, so it catches the classic case where the tunnel adapter is clean but the Wi-Fi adapter still holds the ISP resolver. Also detects EDNS Client Subnet leakage.
|
- **DNS leak test** — enumerates resolvers *per network adapter*, so it catches the classic case where the tunnel adapter is clean but the Wi-Fi adapter still holds the ISP resolver. Also detects EDNS Client Subnet leakage.
|
||||||
- **BGP intelligence** — upstreams, peers, downstreams, announced prefixes, route stability, IXP presence, and CAIDA ASRank customer-cone size.
|
- **BGP intelligence** — upstreams, peers, downstreams, announced prefixes, route stability, IXP presence, and CAIDA ASRank customer-cone size.
|
||||||
- **Reputation** — locally cached FireHOL blocklists (no query leaves your machine), Shodan InternetDB exposure, and optional classic DNSBL zones.
|
- **Reputation** — locally cached FireHOL blocklists (no query leaves your machine), Shodan InternetDB exposure, and optional classic DNSBL zones.
|
||||||
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights.
|
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights. The first hop is diagnosed separately from the rest of the route, so loss or slowness there is correctly attributed to your own router/Wi-Fi rather than the ISP.
|
||||||
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades.
|
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades.
|
||||||
- **TLS handshake RTT** (`--tls`) — TCP RTT, TLS 1.3 handshake time and time-to-first-byte to reference services, flagging servers whose handshake is disproportionately slow relative to the network (CPU-bound TLS termination, not a network problem).
|
- **TLS handshake RTT** (`--tls`) — TCP RTT, TLS 1.3 handshake time and time-to-first-byte to reference services, flagging servers whose handshake is disproportionately slow relative to the network (CPU-bound TLS termination, not a network problem).
|
||||||
- **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53.
|
- **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53.
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,12 @@ netsleuth
|
||||||
|
|
||||||
## Что он делает
|
## Что он делает
|
||||||
|
|
||||||
- **Идентификация** — внешний IPv4 *и* IPv6, обратный DNS, ASN, организация, гео и тип адреса: домашний, мобильный, хостинговый или корпоративный. Проверяется по шести независимым источникам, поэтому один rate-limit не обнуляет результат.
|
- **Идентификация** — внешний IPv4 *и* IPv6, обратный DNS, ASN, организация, гео и тип адреса: домашний, мобильный, хостинговый или корпоративный. Проверяется по шести независимым источникам, поэтому один rate-limit не обнуляет результат. Также определяет carrier-grade NAT (RFC 6598) и трансляцию NAT64/464XLAT (RFC 7050), если они есть.
|
||||||
- **Оценка VPN/прокси** — взвешенный вердикт (`none` / `likely` / `confirmed`) из наличия туннельного интерфейса, аномалии MTU, расхождения шлюза и внешнего IP, флагов Cloudflare WARP, флагов proxy/hosting от провайдеров данных, типа сети в PeeringDB, расхождения таймзоны ОС и гео, а также несовпадения ASN DNS-резолвера.
|
- **Оценка VPN/прокси** — взвешенный вердикт (`none` / `likely` / `confirmed`) из наличия туннельного интерфейса, аномалии MTU, расхождения шлюза и внешнего IP, флагов Cloudflare WARP, флагов proxy/hosting от провайдеров данных, типа сети в PeeringDB, расхождения таймзоны ОС и гео, а также несовпадения ASN DNS-резолвера.
|
||||||
- **Тест утечки DNS** — перечисляет резолверы *по каждому сетевому адаптеру*, поэтому ловит классический случай, когда туннельный адаптер чистый, а на Wi-Fi-адаптере остался резолвер провайдера. Также определяет утечку EDNS Client Subnet.
|
- **Тест утечки DNS** — перечисляет резолверы *по каждому сетевому адаптеру*, поэтому ловит классический случай, когда туннельный адаптер чистый, а на Wi-Fi-адаптере остался резолвер провайдера. Также определяет утечку EDNS Client Subnet.
|
||||||
- **BGP-разведка** — аплинки, пиры, клиенты, анонсируемые префиксы, стабильность маршрутов, присутствие на IX и размер клиентского конуса по CAIDA ASRank.
|
- **BGP-разведка** — аплинки, пиры, клиенты, анонсируемые префиксы, стабильность маршрутов, присутствие на IX и размер клиентского конуса по CAIDA ASRank.
|
||||||
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
|
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
|
||||||
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора.
|
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора. Первый хоп диагностируется отдельно от остального маршрута, поэтому потери или медлительность там правильно относятся к вашему роутеру/Wi-Fi, а не к провайдеру.
|
||||||
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу.
|
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу.
|
||||||
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть).
|
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть).
|
||||||
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ thresholds:
|
||||||
dns_resolve_ms: { good: 30.0, warn: 120.0 }
|
dns_resolve_ms: { good: 30.0, warn: 120.0 }
|
||||||
prefix_spread_ms: { good: 20.0, warn: 80.0 }
|
prefix_spread_ms: { good: 20.0, warn: 80.0 }
|
||||||
tls_cpu_bound_ratio: 2.0
|
tls_cpu_bound_ratio: 2.0
|
||||||
|
first_hop_ms: { good: 5.0, warn: 25.0 }
|
||||||
|
|
||||||
tls:
|
tls:
|
||||||
targets:
|
targets:
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,10 @@ 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,
|
||||||
|
cgnat_findings,
|
||||||
dns_advanced_findings,
|
dns_advanced_findings,
|
||||||
dpi_findings,
|
dpi_findings,
|
||||||
|
dual_stack_findings,
|
||||||
gather_vpn_signals,
|
gather_vpn_signals,
|
||||||
latency_findings,
|
latency_findings,
|
||||||
path_diversity_findings,
|
path_diversity_findings,
|
||||||
|
|
@ -38,6 +40,7 @@ from netsleuth.netinfo import collect_local_net, detect_capabilities, iface_for_
|
||||||
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
|
||||||
from netsleuth.probes.latency import ping_fanout, tcp_connect_rtt
|
from netsleuth.probes.latency import ping_fanout, tcp_connect_rtt
|
||||||
|
from netsleuth.probes.nat64 import detect_nat64
|
||||||
from netsleuth.probes.traceroute import filter_trace_tiers, tier_order, traceroute
|
from netsleuth.probes.traceroute import filter_trace_tiers, tier_order, traceroute
|
||||||
from netsleuth.speed import NDT7_CONSENT_NOTICE
|
from netsleuth.speed import NDT7_CONSENT_NOTICE
|
||||||
|
|
||||||
|
|
@ -191,12 +194,14 @@ async def _identity(client: httpx.AsyncClient, settings: Settings, options: Opti
|
||||||
except (httpx.HTTPError, OSError):
|
except (httpx.HTTPError, OSError):
|
||||||
v6 = None
|
v6 = None
|
||||||
dual_stack = dual_stack_mismatch(merged, v6)
|
dual_stack = dual_stack_mismatch(merged, v6)
|
||||||
|
nat64_prefix = await detect_nat64(settings.timeouts.dns_seconds)
|
||||||
return {
|
return {
|
||||||
"egress_v4": merged,
|
"egress_v4": merged,
|
||||||
"egress_v6": v6,
|
"egress_v6": v6,
|
||||||
"cf_trace": cf,
|
"cf_trace": cf,
|
||||||
"dual_stack_note": dual_stack[0] if dual_stack else None,
|
"dual_stack_note": dual_stack[0] if dual_stack else None,
|
||||||
"dual_stack_note_ru": dual_stack[1] if dual_stack else None,
|
"dual_stack_note_ru": dual_stack[1] if dual_stack else None,
|
||||||
|
"nat64_prefix": nat64_prefix,
|
||||||
"_flags": flags,
|
"_flags": flags,
|
||||||
"_warnings": warnings,
|
"_warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|
@ -595,11 +600,14 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
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:
|
if modules["path_diversity"].data is not None:
|
||||||
l7_findings += path_diversity_findings(modules["path_diversity"].data)
|
l7_findings += path_diversity_findings(modules["path_diversity"].data)
|
||||||
|
traces = modules["path"].data or []
|
||||||
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 traces for f in path_findings(trace, local, settings.thresholds)]
|
||||||
+ speed_findings(speed, settings.thresholds.bufferbloat_ms)
|
+ speed_findings(speed, settings.thresholds.bufferbloat_ms)
|
||||||
+ l7_findings
|
+ l7_findings
|
||||||
|
+ cgnat_findings(local, traces)
|
||||||
|
+ dual_stack_findings(bundle.get("nat64_prefix"), local)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 6 — export
|
# Phase 6 — export
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ class Thresholds(BaseModel):
|
||||||
dns_resolve_ms: Band = Band(good=30.0, warn=120.0)
|
dns_resolve_ms: Band = Band(good=30.0, warn=120.0)
|
||||||
prefix_spread_ms: Band = Band(good=20.0, warn=80.0)
|
prefix_spread_ms: Band = Band(good=20.0, warn=80.0)
|
||||||
tls_cpu_bound_ratio: float = 2.0
|
tls_cpu_bound_ratio: float = 2.0
|
||||||
|
first_hop_ms: Band = Band(good=5.0, warn=25.0)
|
||||||
|
|
||||||
|
|
||||||
class TlsConfig(BaseModel):
|
class TlsConfig(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,11 @@ def _connection(report: dict, lang: str = "en") -> list[str]:
|
||||||
],
|
],
|
||||||
[_t(lang, "Address type", "Тип адреса"), geo.get("ip_type") or _t(lang, "unknown", "неизвестно")],
|
[_t(lang, "Address type", "Тип адреса"), geo.get("ip_type") or _t(lang, "unknown", "неизвестно")],
|
||||||
[_t(lang, "Cloudflare colo", "Дата-центр Cloudflare"), cf.get("colo") or "—"],
|
[_t(lang, "Cloudflare colo", "Дата-центр Cloudflare"), cf.get("colo") or "—"],
|
||||||
|
[
|
||||||
|
_t(lang, "CGNAT", "CGNAT"),
|
||||||
|
_t(lang, "yes", "да") if local.get("cgnat") else _t(lang, "no", "нет"),
|
||||||
|
],
|
||||||
|
[_t(lang, "NAT64 prefix", "Префикс NAT64"), geo_bundle.get("nat64_prefix") or "—"],
|
||||||
]
|
]
|
||||||
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) + [""]
|
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) + [""]
|
||||||
note = geo_bundle.get("dual_stack_note_ru" if lang == "ru" else "dual_stack_note")
|
note = geo_bundle.get("dual_stack_note_ru" if lang == "ru" else "dual_stack_note")
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ from netsleuth.models import (
|
||||||
Signal,
|
Signal,
|
||||||
SpeedResult,
|
SpeedResult,
|
||||||
TlsResult,
|
TlsResult,
|
||||||
|
TraceHop,
|
||||||
TraceResult,
|
TraceResult,
|
||||||
VpnAssessment,
|
VpnAssessment,
|
||||||
VpnContext,
|
VpnContext,
|
||||||
)
|
)
|
||||||
from netsleuth.netinfo import is_tunnel_iface, mtu_anomaly
|
from netsleuth.netinfo import is_cgnat, is_tunnel_iface, mtu_anomaly
|
||||||
|
|
||||||
_SEVERITY_ORDER = {"ok": 0, "info": 1, "warn": 2, "crit": 3}
|
_SEVERITY_ORDER = {"ok": 0, "info": 1, "warn": 2, "crit": 3}
|
||||||
|
|
||||||
|
|
@ -113,7 +114,64 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def path_findings(trace: TraceResult) -> list[Finding]:
|
def _first_hop_findings(hop: TraceHop, local: LocalNet, t: Thresholds) -> list[Finding]:
|
||||||
|
if not hop.ip:
|
||||||
|
return []
|
||||||
|
is_gateway = bool(local.default_gateway_v4) and hop.ip == local.default_gateway_v4
|
||||||
|
findings: list[Finding] = []
|
||||||
|
where = f"your LAN/Wi-Fi link to {hop.ip}" if is_gateway else f"the first hop ({hop.ip})"
|
||||||
|
where_ru = f"вашу локальную сеть/Wi-Fi до {hop.ip}" if is_gateway else f"первый хоп ({hop.ip})"
|
||||||
|
if hop.loss_pct >= 20.0 or hop.loss_pct > t.loss_pct.warn:
|
||||||
|
severity = "crit" if hop.loss_pct >= 20.0 else "warn"
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id="path.first_hop_loss",
|
||||||
|
severity=severity,
|
||||||
|
title="Loss starts at the first hop",
|
||||||
|
detail=f"{hop.loss_pct}% loss to {where}, before the rest of the path is even reached.",
|
||||||
|
metric="loss_pct",
|
||||||
|
value=hop.loss_pct,
|
||||||
|
threshold=20.0,
|
||||||
|
advice="This points at your own router or Wi-Fi link, not your ISP or anything further downstream.",
|
||||||
|
title_ru="Потери начинаются на первом хопе",
|
||||||
|
detail_ru=f"{hop.loss_pct}% потерь до {where_ru}, ещё до остального маршрута.",
|
||||||
|
advice_ru="Это указывает на ваш собственный роутер или Wi-Fi-канал, а не на провайдера или что-то дальше по маршруту.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
severity = severity_for(hop.avg_ms, t.first_hop_ms)
|
||||||
|
if severity not in ("ok", "info"):
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id="path.first_hop_slow",
|
||||||
|
severity=severity,
|
||||||
|
title="First hop is slow",
|
||||||
|
detail=f"{hop.avg_ms} ms to {where}.",
|
||||||
|
metric="avg_ms",
|
||||||
|
value=hop.avg_ms,
|
||||||
|
threshold=t.first_hop_ms.warn,
|
||||||
|
advice="A slow first hop is almost always local: a congested Wi-Fi link or an overloaded router.",
|
||||||
|
title_ru="Первый хоп медленный",
|
||||||
|
detail_ru=f"{hop.avg_ms} мс до {where_ru}.",
|
||||||
|
advice_ru="Медленный первый хоп почти всегда локальная проблема: перегруженный Wi-Fi или роутер.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if local.default_gateway_v4 and not is_gateway:
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id="path.first_hop_unexpected",
|
||||||
|
severity="info",
|
||||||
|
title="First hop is not your default gateway",
|
||||||
|
detail=f"Traceroute's first hop is {hop.ip}, but the OS default gateway is {local.default_gateway_v4}.",
|
||||||
|
advice="A double NAT, a bridged modem, or a VPN intercepting traffic before it reaches the real gateway can cause this.",
|
||||||
|
title_ru="Первый хоп — не ваш шлюз по умолчанию",
|
||||||
|
detail_ru=f"Первый хоп трассировки — {hop.ip}, а шлюз ОС по умолчанию — {local.default_gateway_v4}.",
|
||||||
|
advice_ru="Причиной может быть двойной NAT, модем в режиме моста, либо VPN, перехватывающий трафик до настоящего шлюза.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def path_findings(trace: TraceResult, local: LocalNet | None = None, t: Thresholds | None = None) -> list[Finding]:
|
||||||
if not trace.hops:
|
if not trace.hops:
|
||||||
return [
|
return [
|
||||||
Finding(
|
Finding(
|
||||||
|
|
@ -128,6 +186,8 @@ def path_findings(trace: TraceResult) -> list[Finding]:
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
|
if local is not None:
|
||||||
|
findings += _first_hop_findings(trace.hops[0], local, t or Thresholds())
|
||||||
# A run of lossy hops that clears before the last hop we have data for is ICMP
|
# A run of lossy hops that clears before the last hop we have data for is ICMP
|
||||||
# rate limiting on those routers, not a real problem, no matter how many hops
|
# rate limiting on those routers, not a real problem, no matter how many hops
|
||||||
# in a row stayed silent; only loss that persists all the way to the end is real.
|
# in a row stayed silent; only loss that persists all the way to the end is real.
|
||||||
|
|
@ -166,6 +226,64 @@ def path_findings(trace: TraceResult) -> list[Finding]:
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def cgnat_findings(local: LocalNet, traces: list[TraceResult]) -> list[Finding]:
|
||||||
|
evidence = local.cgnat_evidence
|
||||||
|
detected = local.cgnat
|
||||||
|
if not detected:
|
||||||
|
for trace in traces:
|
||||||
|
for hop in trace.hops:
|
||||||
|
if is_cgnat(hop.ip):
|
||||||
|
detected = True
|
||||||
|
evidence = f"traceroute hop {hop.ttl} ({hop.ip}) is in 100.64.0.0/10"
|
||||||
|
break
|
||||||
|
if detected:
|
||||||
|
break
|
||||||
|
if not detected:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
Finding(
|
||||||
|
id="net.cgnat",
|
||||||
|
severity="warn",
|
||||||
|
title="Behind carrier-grade NAT (CGNAT)",
|
||||||
|
detail=evidence or "An address in 100.64.0.0/10 (RFC 6598) was observed.",
|
||||||
|
metric="cgnat",
|
||||||
|
value=True,
|
||||||
|
advice="No inbound port forwarding is possible; P2P and game/server hosting may not work. "
|
||||||
|
"Ask your ISP for a public IPv4 address, or use IPv6 where the destination supports it.",
|
||||||
|
title_ru="За carrier-grade NAT (CGNAT)",
|
||||||
|
detail_ru=evidence or "Обнаружен адрес из диапазона 100.64.0.0/10 (RFC 6598).",
|
||||||
|
advice_ru="Проброс входящих портов невозможен; P2P и хостинг игр/серверов могут не работать. "
|
||||||
|
"Запросите у провайдера публичный IPv4-адрес либо используйте IPv6, если целевой сервис его поддерживает.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def dual_stack_findings(nat64_prefix: str | None, local: LocalNet) -> list[Finding]:
|
||||||
|
if not nat64_prefix:
|
||||||
|
return []
|
||||||
|
severity = "warn" if not local.is_dual_stack else "info"
|
||||||
|
return [
|
||||||
|
Finding(
|
||||||
|
id="net.nat64",
|
||||||
|
severity=severity,
|
||||||
|
title="IPv6 traffic is translated via NAT64",
|
||||||
|
detail=f"ipv4only.arpa resolved through a NAT64 synthesis prefix ({nat64_prefix}).",
|
||||||
|
metric="nat64_prefix",
|
||||||
|
value=nat64_prefix,
|
||||||
|
advice="This network is IPv6-only with 464XLAT/NAT64 translation to reach IPv4-only destinations; "
|
||||||
|
"this can add latency and occasionally breaks apps that assume native IPv4."
|
||||||
|
if not local.is_dual_stack
|
||||||
|
else "IPv4-only destinations are reached via NAT64 translation even though this host is dual-stack.",
|
||||||
|
title_ru="IPv6-трафик транслируется через NAT64",
|
||||||
|
detail_ru=f"ipv4only.arpa разрешился через префикс синтеза NAT64 ({nat64_prefix}).",
|
||||||
|
advice_ru="Сеть IPv6-only с трансляцией 464XLAT/NAT64 для доступа к IPv4-only ресурсам; "
|
||||||
|
"это может добавлять задержку и иногда ломает приложения, рассчитывающие на нативный IPv4."
|
||||||
|
if not local.is_dual_stack
|
||||||
|
else "IPv4-only ресурсы доступны через трансляцию NAT64, хотя хост dual-stack.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
SIGNAL_WEIGHTS: dict[str, float] = {
|
SIGNAL_WEIGHTS: dict[str, float] = {
|
||||||
"tunnel_iface": 0.35,
|
"tunnel_iface": 0.35,
|
||||||
"cf_warp": 0.50,
|
"cf_warp": 0.50,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,24 @@ def _ip_version(ip: str | None) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# RFC 7050: ipv4only.arpa's A records are these two fixed addresses. A resolver
|
||||||
|
# sitting behind NAT64/DNS64 synthesizes an AAAA answer by embedding one of them
|
||||||
|
# in its local /96 prefix; a real dual-stack network returns no AAAA at all.
|
||||||
|
_IPV4ONLY_ARPA_A = (ipaddress.ip_address("192.0.0.170"), ipaddress.ip_address("192.0.0.171"))
|
||||||
|
|
||||||
|
|
||||||
|
def nat64_prefix_from_aaaa(answers: list[str]) -> str | None:
|
||||||
|
for answer in answers:
|
||||||
|
try:
|
||||||
|
addr = ipaddress.IPv6Address(answer)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
embedded = ipaddress.IPv4Address(addr.packed[12:16])
|
||||||
|
if embedded in _IPV4ONLY_ARPA_A:
|
||||||
|
return str(ipaddress.ip_network(f"{addr}/96", strict=False))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def classify_ip_type(mobile: bool, proxy: bool, hosting: bool, known: bool) -> str:
|
def classify_ip_type(mobile: bool, proxy: bool, hosting: bool, known: bool) -> str:
|
||||||
if not known:
|
if not known:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,8 @@ class LocalNet:
|
||||||
default_gateway_v6: str | None = None
|
default_gateway_v6: str | None = None
|
||||||
dns_servers_per_adapter: dict[str, list[str]] = field(default_factory=dict)
|
dns_servers_per_adapter: dict[str, list[str]] = field(default_factory=dict)
|
||||||
is_dual_stack: bool = False
|
is_dual_stack: bool = False
|
||||||
|
cgnat: bool = False
|
||||||
|
cgnat_evidence: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
|
@ -121,6 +122,15 @@ def iface_for_ip(ip: str, addrs_by_iface: dict[str, list[tuple[int, str]]]) -> s
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_cgnat(ip: str | None) -> bool:
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(ip) in ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def mtu_anomaly(mtu: int | None) -> str | None:
|
def mtu_anomaly(mtu: int | None) -> str | None:
|
||||||
if mtu is None or mtu >= 1500:
|
if mtu is None or mtu >= 1500:
|
||||||
return None
|
return None
|
||||||
|
|
@ -222,15 +232,24 @@ def collect_local_net() -> LocalNet:
|
||||||
}
|
}
|
||||||
iface = iface_for_ip(v4, addrs_by_iface) if v4 else None
|
iface = iface_for_ip(v4, addrs_by_iface) if v4 else None
|
||||||
stats = psutil.net_if_stats()
|
stats = psutil.net_if_stats()
|
||||||
|
gateway_v4 = _default_gateway(socket.AF_INET)
|
||||||
|
cgnat = False
|
||||||
|
cgnat_evidence: str | None = None
|
||||||
|
if is_cgnat(v4):
|
||||||
|
cgnat, cgnat_evidence = True, f"local address {v4} is in 100.64.0.0/10"
|
||||||
|
elif is_cgnat(gateway_v4):
|
||||||
|
cgnat, cgnat_evidence = True, f"default gateway {gateway_v4} is in 100.64.0.0/10"
|
||||||
return LocalNet(
|
return LocalNet(
|
||||||
iface_name=iface,
|
iface_name=iface,
|
||||||
local_ipv4=v4,
|
local_ipv4=v4,
|
||||||
local_ipv6=v6,
|
local_ipv6=v6,
|
||||||
iface_mtu=stats[iface].mtu if iface and iface in stats else None,
|
iface_mtu=stats[iface].mtu if iface and iface in stats else None,
|
||||||
default_gateway_v4=_default_gateway(socket.AF_INET),
|
default_gateway_v4=gateway_v4,
|
||||||
default_gateway_v6=_default_gateway(socket.AF_INET6),
|
default_gateway_v6=_default_gateway(socket.AF_INET6),
|
||||||
dns_servers_per_adapter=_resolvers_per_adapter(),
|
dns_servers_per_adapter=_resolvers_per_adapter(),
|
||||||
is_dual_stack=bool(v4 and v6),
|
is_dual_stack=bool(v4 and v6),
|
||||||
|
cgnat=cgnat,
|
||||||
|
cgnat_evidence=cgnat_evidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
18
src/netsleuth/probes/nat64.py
Normal file
18
src/netsleuth/probes/nat64.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dns.asyncresolver
|
||||||
|
import dns.exception
|
||||||
|
|
||||||
|
from netsleuth.ip_geo import nat64_prefix_from_aaaa
|
||||||
|
|
||||||
|
_IPV4ONLY_ARPA = "ipv4only.arpa"
|
||||||
|
|
||||||
|
|
||||||
|
async def detect_nat64(timeout: float) -> str | None:
|
||||||
|
resolver = dns.asyncresolver.Resolver()
|
||||||
|
resolver.lifetime = timeout
|
||||||
|
try:
|
||||||
|
answer = await resolver.resolve(_IPV4ONLY_ARPA, "AAAA")
|
||||||
|
except dns.exception.DNSException:
|
||||||
|
return None
|
||||||
|
return nat64_prefix_from_aaaa([str(r) for r in answer])
|
||||||
115
tests/test_cgnat_nat64.py
Normal file
115
tests/test_cgnat_nat64.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from netsleuth.config import Thresholds
|
||||||
|
from netsleuth.interpret import cgnat_findings, dual_stack_findings, path_findings
|
||||||
|
from netsleuth.ip_geo import nat64_prefix_from_aaaa
|
||||||
|
from netsleuth.models import LocalNet, TraceHop, TraceResult
|
||||||
|
from netsleuth.netinfo import is_cgnat
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_cgnat_boundaries():
|
||||||
|
assert is_cgnat("100.63.255.255") is False
|
||||||
|
assert is_cgnat("100.64.0.0") is True
|
||||||
|
assert is_cgnat("100.127.255.255") is True
|
||||||
|
assert is_cgnat("100.128.0.0") is False
|
||||||
|
assert is_cgnat(None) is False
|
||||||
|
assert is_cgnat("not-an-ip") is False
|
||||||
|
assert is_cgnat("2001:db8::1") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nat64_prefix_from_aaaa_recognises_the_well_known_prefix():
|
||||||
|
assert nat64_prefix_from_aaaa(["64:ff9b::c000:aa"]) == "64:ff9b::/96"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nat64_prefix_from_aaaa_recognises_an_isp_specific_prefix():
|
||||||
|
assert nat64_prefix_from_aaaa(["2001:db8:64::c000:ab"]) == "2001:db8:64::/96"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nat64_prefix_from_aaaa_ignores_a_non_synthesized_answer():
|
||||||
|
assert nat64_prefix_from_aaaa(["2606:4700:4700::1111"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nat64_prefix_from_aaaa_handles_empty_and_garbage_input():
|
||||||
|
assert nat64_prefix_from_aaaa([]) is None
|
||||||
|
assert nat64_prefix_from_aaaa(["not-an-address"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cgnat_findings_fires_on_local_ip_evidence():
|
||||||
|
local = LocalNet(local_ipv4="100.64.5.5", cgnat=True, cgnat_evidence="local address 100.64.5.5 is in 100.64.0.0/10")
|
||||||
|
findings = cgnat_findings(local, [])
|
||||||
|
assert [f.id for f in findings] == ["net.cgnat"]
|
||||||
|
assert findings[0].severity == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cgnat_findings_fires_on_gateway_evidence():
|
||||||
|
local = LocalNet(default_gateway_v4="100.70.0.1", cgnat=True, cgnat_evidence="default gateway 100.70.0.1 is in 100.64.0.0/10")
|
||||||
|
assert [f.id for f in cgnat_findings(local, [])] == ["net.cgnat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cgnat_findings_fires_on_traceroute_hop_evidence():
|
||||||
|
local = LocalNet(local_ipv4="192.168.1.34", default_gateway_v4="192.168.1.1")
|
||||||
|
trace = TraceResult(
|
||||||
|
target="1.1.1.1",
|
||||||
|
hops=[
|
||||||
|
TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0),
|
||||||
|
TraceHop(ttl=2, ip="100.65.1.1", loss_pct=0.0, avg_ms=8.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
findings = cgnat_findings(local, [trace])
|
||||||
|
assert [f.id for f in findings] == ["net.cgnat"]
|
||||||
|
assert "hop 2" in findings[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_cgnat_findings_silent_on_a_clean_private_network():
|
||||||
|
local = LocalNet(local_ipv4="192.168.1.34", default_gateway_v4="192.168.1.1")
|
||||||
|
trace = TraceResult(target="1.1.1.1", hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)])
|
||||||
|
assert cgnat_findings(local, [trace]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_stack_findings_nat64_warns_on_a_v6_only_host():
|
||||||
|
local = LocalNet(local_ipv6="2001:db8::1", is_dual_stack=False)
|
||||||
|
findings = dual_stack_findings("2001:db8:64::/96", local)
|
||||||
|
assert [f.id for f in findings] == ["net.nat64"]
|
||||||
|
assert findings[0].severity == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_stack_findings_nat64_is_informational_on_a_dual_stack_host():
|
||||||
|
local = LocalNet(local_ipv4="203.0.113.5", local_ipv6="2001:db8::1", is_dual_stack=True)
|
||||||
|
findings = dual_stack_findings("2001:db8:64::/96", local)
|
||||||
|
assert findings[0].severity == "info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_stack_findings_silent_when_no_nat64_prefix():
|
||||||
|
assert dual_stack_findings(None, LocalNet()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def _trace(hop0: TraceHop) -> TraceResult:
|
||||||
|
return TraceResult(target="1.1.1.1", resolved_ip="1.1.1.1", backend="icmplib", hops=[hop0], completed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_findings_flags_loss_at_the_gateway_hop():
|
||||||
|
local = LocalNet(default_gateway_v4="192.168.1.1")
|
||||||
|
trace = _trace(TraceHop(ttl=1, ip="192.168.1.1", loss_pct=25.0, avg_ms=1.0))
|
||||||
|
findings = path_findings(trace, local, Thresholds())
|
||||||
|
ids = [f.id for f in findings]
|
||||||
|
assert "path.first_hop_loss" in ids
|
||||||
|
assert [f for f in findings if f.id == "path.first_hop_loss"][0].severity == "crit"
|
||||||
|
assert "path.first_hop_unexpected" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_findings_flags_an_unexpected_first_hop():
|
||||||
|
local = LocalNet(default_gateway_v4="192.168.1.1")
|
||||||
|
trace = _trace(TraceHop(ttl=1, ip="10.0.0.1", loss_pct=0.0, avg_ms=1.0))
|
||||||
|
findings = path_findings(trace, local, Thresholds())
|
||||||
|
assert [f.id for f in findings] == ["path.first_hop_unexpected"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_findings_without_local_param_is_unchanged():
|
||||||
|
trace = _trace(TraceHop(ttl=1, ip="192.168.1.1", loss_pct=30.0, avg_ms=1.0))
|
||||||
|
findings = path_findings(trace)
|
||||||
|
assert all(not f.id.startswith("path.first_hop") for f in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_findings_first_hop_empty_hops_still_reports_incomplete():
|
||||||
|
trace = TraceResult(target="1.1.1.1", resolved_ip="1.1.1.1", backend="icmplib", hops=[], completed=False)
|
||||||
|
assert [f.id for f in path_findings(trace, LocalNet())] == ["path.incomplete"]
|
||||||
Loading…
Add table
Reference in a new issue