diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4db9151..a88a2d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,10 @@ src/netsleuth/ speed.py Cascading speedtest, throughput math, cfL4 header parsing, bufferbloat probe. exporter.py JSON and Markdown rendering, atomic writes into ./logs/. Which artifacts get written is decided by the caller (cli.py), not here. - compare.py --compare: diff two saved JSON reports. + compare.py --compare: diff two saved JSON reports; render_diff_brief() feeds the automatic vs-previous-run summary. + history.py Same-ASN report lookup by filename convention: report_key(), find_previous(), latest_key(). + trend.py --trend N: aggregates N saved JSON reports into per-label sparkline series. + metrics.py collect_metrics() extracts a flat Metric list from a report; render_prometheus()/render_csv() serialize it. watch.py --watch: periodic re-run loop with a live Rich dashboard. probes/ latency.py Ping fan-out and jitter/loss/mdev/p95/p99 statistics. @@ -145,11 +148,17 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f **Pinning is configured, not flagged.** `tls.pinned_fingerprints` (host → expected SHA-256, capped at 32 entries, each normalized and hex-validated by a pydantic validator) follows the same posture as `dpi_check.ports`: raising the list requires editing and redeploying the config, not a `--pin` CLI argument on a diagnostic tool that anyone could point at anything. +**Prometheus and CSV are one extraction, two serializations — not two renderers.** `metrics.py::collect_metrics(report) -> list[Metric]` does the per-section special-casing a flat walk of the report dict can't (latency needs a label/host/method triple, TLS a label/host pair, path hops a target/ttl/ip/asn quadruple) as a single pure `dict -> list[Metric]` function; `render_prometheus()` and `render_csv()` are then ~15 lines each, formatting the same list two ways. `FORMAT_EXTENSIONS` (`config.py`) is still the single source of truth both `cli.py::parse_formats()` and `config.py::Output._normalize_formats()` key off of — adding `"prom"`/`"csv"` there was the only wiring point. + +**Same-ASN auto-diff runs before `write_report()`, or the current run would diff against itself.** `history.find_previous()` globs `./logs/` for `report_{key}_*.json` using the exact same `sanitize_name()`/timestamp convention `exporter.report_filename()` already writes, so the lookup key can never drift from what's actually on disk. It only fires when this run is itself writing JSON (there'd be nothing to diff against on a future run otherwise) and only prints a one-line note — never a warning — when JSON isn't in this run's formats, since silence is correct on every other skip path (first run ever, or a prior run that didn't write JSON). `compare.py::render_diff_brief()` exists because the full `render_diff()` table is too long to print unbidden after every run; it surfaces only the ASN/IP change, the single worst latency delta, the download delta, and new/resolved finding counts. + +**`--trend` needs no live network call to find "this network's" history.** Rather than re-running identity detection just to know which ASN's reports to load, `history.latest_key()` reads it back off the newest `report_*.json` filename already on disk — the same file-naming convention already encodes it, and a read-only historical view has no reason to touch the network at all. + **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 -- `./logs/report__.{md,ru.md,json}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. Compact ISO timestamps because Windows forbids `:` in filenames. Falls back to `report_unknown_…` when the ASN lookup fails entirely. Written temp-file-plus-`os.replace()`, always atomic. Gitignored: reports contain the external IP, city, coordinates and ISP name. +- `./logs/report__.{md,ru.md,json,prom,csv}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. Compact ISO timestamps because Windows forbids `:` in filenames. Falls back to `report_unknown_…` when the ASN lookup fails entirely. Written temp-file-plus-`os.replace()`, always atomic. Gitignored: reports contain the external IP, city, coordinates and ISP name. - `./logs/watch__.json` — one time-series artifact per `--watch` session, not one report per tick. - `./.cache/firehol/*.netset` — downloaded blocklists, refreshed per `providers.firehol_refresh_hours`. - `./.cache/pdb-net-.json`, `./.cache/pdb-netixlan-.json` — PeeringDB responses, valid for `providers.peeringdb_cache_hours`. @@ -163,4 +172,4 @@ Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a ## Tests -`uv run pytest -q`. Roughly 777 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 807 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. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 53f6012..6e88352 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -23,7 +23,10 @@ src/netsleuth/ speed.py Каскадный замер скорости, математика пропускной способности, разбор cfL4, bufferbloat. exporter.py Рендер JSON и Markdown, атомарная запись в ./logs/. Какие артефакты писать, решает вызывающий код (cli.py), не этот модуль. - compare.py --compare: сравнение двух сохранённых JSON-отчётов. + compare.py --compare: сравнение двух сохранённых JSON-отчётов; render_diff_brief() питает автоматическую сводку vs-прошлый-прогон. + history.py Поиск отчётов той же ASN по соглашению об именах файлов: report_key(), find_previous(), latest_key(). + trend.py --trend N: агрегирует N сохранённых JSON-отчётов в спарклайн-серии по меткам. + metrics.py collect_metrics() извлекает плоский список Metric из отчёта; render_prometheus()/render_csv() его сериализуют. watch.py --watch: цикл периодических прогонов с живой панелью Rich. probes/ latency.py Веер пингов и статистика джиттера/потерь/mdev/p95/p99. @@ -145,11 +148,17 @@ src/netsleuth/ **Пиннинг конфигурируется, а не флагуется.** `tls.pinned_fingerprints` (хост → ожидаемый SHA-256, capped на 32 записи, каждая нормализована и провалидирована как hex pydantic-валидатором) следует той же позиции, что `dpi_check.ports`: поднять список можно только правкой и передеплоем конфига, а не аргументом `--pin` в диагностическом инструменте, который кто угодно может направить куда угодно. +**Prometheus и CSV — одна экстракция, две сериализации, а не два рендерера.** `metrics.py::collect_metrics(report) -> list[Metric]` делает построчный особый разбор каждой секции, который плоский обход словаря отчёта не осилит (latency нужна тройка label/host/method, TLS — пара label/host, хопы пути — четвёрка target/ttl/ip/asn), как единая чистая функция `dict -> list[Metric]`; `render_prometheus()` и `render_csv()` — тогда ~15 строк каждая, форматирующие один и тот же список двумя способами. `FORMAT_EXTENSIONS` (`config.py`) остаётся единственным источником истины, от которого зависят и `cli.py::parse_formats()`, и `config.py::Output._normalize_formats()` — добавление `"prom"`/`"csv"` туда было единственной точкой подключения. + +**Авто-diff по той же ASN работает до `write_report()`, иначе текущий прогон сравнивался бы сам с собой.** `history.find_previous()` ищет по `./logs/` файлы `report_{key}_*.json`, используя то же соглашение `sanitize_name()`/таймстампа, что уже пишет `exporter.report_filename()` — поэтому ключ поиска не может разойтись с тем, что реально на диске. Срабатывает только когда сам этот прогон пишет JSON (иначе не с чем сравнивать в будущем прогоне) и печатает одну строку-заметку, никогда предупреждение, только когда JSON не входит в форматы этого прогона — молчание корректно на всех остальных путях пропуска (самый первый прогон, либо прошлый прогон не писал JSON). `compare.py::render_diff_brief()` существует потому, что полная таблица `render_diff()` слишком длинная, чтобы печатать её незапрошенной после каждого прогона; она показывает только смену ASN/IP, худшую дельту задержки, дельту скачивания и число новых/устранённых находок. + +**`--trend` не требует живого сетевого запроса, чтобы понять «историю этой сети».** Вместо повторного запуска определения identity только чтобы узнать, чьи отчёты грузить, `history.latest_key()` считывает это обратно из имени самого нового `report_*.json` на диске — то же соглашение об именовании файлов уже это кодирует, а read-only исторический просмотр не имеет причин вообще трогать сеть. + **Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции. ## Хранение -- `./logs/report__.{md,ru.md,json}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Компактный ISO-таймстамп, потому что Windows запрещает `:` в именах файлов. При полном провале определения ASN — `report_unknown_…`. Запись через временный файл и `os.replace()`, всегда атомарно. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера. +- `./logs/report__.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Компактный ISO-таймстамп, потому что Windows запрещает `:` в именах файлов. При полном провале определения ASN — `report_unknown_…`. Запись через временный файл и `os.replace()`, всегда атомарно. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера. - `./logs/watch__.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик. - `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`. - `./.cache/pdb-net-.json`, `./.cache/pdb-netixlan-.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`. @@ -163,4 +172,4 @@ src/netsleuth/ ## Тесты -`uv run pytest -q`. Около 777 тестов: парсеры, нормализаторы, движок вердиктов, декодирование 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`. Около 807 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров. diff --git a/README.md b/README.md index dd11b98..8b3426d 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ None of this is required — netsleuth degrades gracefully without any of it — | `--speedtest-server ` | Pin the speedtest to a specific server | | `--watch` | Continuous monitoring with a live dashboard — for catching intermittent drops. Writes one `logs/watch_*.json` time series per session, not one report per tick | | `--compare a.json b.json` | Diff two earlier reports: before/after a VPN switch, before/after an ISP call. Read-only — no probing, no network calls. Reads reports produced with `--format json` | +| `--trend N` | Sparkline trend across the last N saved JSON reports for this network. Read-only — resolves which network from the newest report already on disk, no probing needed | +| `--no-auto-diff` | Skip the one-line vs-previous-run summary a run otherwise prints automatically when it writes JSON and an earlier JSON report for the same network exists | | `--dnsbl` | Opt in to classic DNSBL reputation zones | | `--tls` | Measure TCP/TLS handshake RTT and TTFB to reference services. Included in `--full` | | `--dns-advanced` | Compare system DNS vs DoH and probe for a transparent DNS proxy. Included in `--full` | @@ -90,12 +92,12 @@ None of this is required — netsleuth degrades gracefully without any of it — | `--my-server ` | Check a server **you own** for TCP port blocking / RST injection. Single host only — rejects CIDR input outright. **Not** included in `--full` — opt in explicitly | | `--ndt7` | Opt in to M-Lab NDT7 (publishes your measurement, including your IP, as CC0 open data). On an interactive terminal it prints the consent notice and asks first; on a non-interactive one it proceeds without asking | | `--tcp-trace` | Opt in to a scapy TCP-SYN traceroute through ICMP-filtering middleboxes (needs the `tcptrace` extra plus Npcap on Windows or root on Unix). Falls through to the normal cascade when it cannot run | -| `--format md,ru-md,json,all,none` | Choose which report artifacts to write (default: `md`). Repeatable and comma-separated (`--format md,json`); any use replaces the default instead of adding to it. `all` writes all three, `none` writes nothing. Ignored with `--watch`/`--compare` | +| `--format md,ru-md,json,prom,csv,all,none` | Choose which report artifacts to write (default: `md`). Repeatable and comma-separated (`--format md,json`); any use replaces the default instead of adding to it. `prom`/`csv` are metrics exports for scripting/monitoring — the same numbers as `json`, flattened. `all` writes every format, `none` writes nothing. Ignored with `--watch`/`--compare` | | `--ru` | Shorthand for `--format ru-md` | | `--json` | Shorthand for `--format json` | | `--interface ` | Force outbound probing (identity, BGP/reputation, latency, traceroute, speedtest) through this adapter instead of the OS default route — e.g. compare a VPN adapter against your raw connection. Accepts an adapter name or one of its IPs. Errors out immediately, listing every adapter, if the name doesn't resolve or the adapter is down; if it resolves but has no route to the internet, the run continues and reports that honestly rather than aborting. Windows' `tracert` has no IPv4 source-address option, so that one traceroute tier is skipped when forced (noted in the report) — every other backend, including the Windows ICMP API, honors it | -Each run writes the selected artifacts into `./logs/`: an English Markdown report, a full Russian translation of the same report (`.ru.md`, same sections, same tables, same numbers — cross-linked with the English one when both are written), and/or a JSON dump containing every raw provider response, untruncated. Default is the English Markdown report only; set `output.formats` in `config.yaml` to change the default permanently, or pass `--format` per run. `./logs/` is gitignored — reports contain your external IP, city, coordinates and ISP name. +Each run writes the selected artifacts into `./logs/`: an English Markdown report, a full Russian translation of the same report (`.ru.md`, same sections, same tables, same numbers — cross-linked with the English one when both are written), a JSON dump containing every raw provider response untruncated, and/or `.prom`/`.csv` metrics exports (the same numbers as the JSON dump, flattened for Prometheus/Grafana or a spreadsheet). Default is the English Markdown report only; set `output.formats` in `config.yaml` to change the default permanently, or pass `--format` per run. `./logs/` is gitignored — reports contain your external IP, city, coordinates and ISP name. netsleuth never requires administrator or root. Where privileges would give better data, it degrades to an unprivileged method and says so in the report. diff --git a/README.ru.md b/README.ru.md index 7426a13..fa87330 100644 --- a/README.ru.md +++ b/README.ru.md @@ -82,6 +82,8 @@ netsleuth | `--speedtest-server ` | Зафиксировать конкретный сервер замера скорости | | `--watch` | Непрерывный мониторинг с живой панелью — чтобы поймать плавающие обрывы. Пишет один тайм-ряд `logs/watch_*.json` за сессию, а не отдельный отчёт на каждый тик | | `--compare a.json b.json` | Сравнить два прошлых отчёта: до/после смены VPN, до/после звонка провайдеру. Только чтение — без проб и сетевых запросов. Читает отчёты, полученные с `--format json` | +| `--trend N` | Спарклайн-тренд по последним N сохранённым JSON-отчётам для этой сети. Только чтение — сеть определяется по самому новому отчёту уже на диске, проб не требуется | +| `--no-auto-diff` | Пропустить однострочную сводку vs-прошлый-прогон, которую прогон иначе печатает автоматически, когда пишет JSON и есть более ранний JSON-отчёт для той же сети | | `--dnsbl` | Включить проверку по классическим зонам DNSBL | | `--tls` | Замерить TCP/TLS RTT рукопожатия и TTFB к эталонным сервисам. Входит в `--full` | | `--dns-advanced` | Сравнить системный DNS с DoH и проверить прозрачный DNS-прокси. Входит в `--full` | @@ -90,12 +92,12 @@ netsleuth | `--my-server ` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно | | `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса | | `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад | -| `--format md,ru-md,json,all,none` | Выбрать, какие артефакты отчёта писать (по умолчанию: `md`). Повторяемый флаг и через запятую (`--format md,json`); любое использование заменяет дефолт, а не дополняет его. `all` пишет все три, `none` — ничего. Игнорируется с `--watch`/`--compare` | +| `--format md,ru-md,json,prom,csv,all,none` | Выбрать, какие артефакты отчёта писать (по умолчанию: `md`). Повторяемый флаг и через запятую (`--format md,json`); любое использование заменяет дефолт, а не дополняет его. `prom`/`csv` — экспорт метрик для скриптинга/мониторинга, те же числа, что в `json`, но плоские. `all` пишет все форматы, `none` — ничего. Игнорируется с `--watch`/`--compare` | | `--ru` | Сокращение для `--format ru-md` | | `--json` | Сокращение для `--format json` | | `--interface <имя\|ip>` | Пустить все исходящие пробы (идентификация, BGP/репутация, задержки, трассировка, спидтест) через конкретный адаптер вместо маршрута ОС по умолчанию — например, сравнить VPN-адаптер с "сырым" подключением. Принимает имя адаптера или один из его IP. Сразу падает с ошибкой и списком всех адаптеров, если имя не резолвится или адаптер выключен; если адаптер есть, но маршрута в интернет нет — прогон продолжается и честно это показывает, а не падает. У Windows `tracert` нет опции адреса-источника для IPv4, поэтому этот уровень трассировки при форсированном интерфейсе пропускается (отмечено в отчёте) — все остальные бэкенды, включая Windows ICMP API, флаг соблюдают | -Каждый прогон пишет в `./logs/` выбранные артефакты: Markdown-отчёт на английском, его полный перевод на русский (`.ru.md` — те же разделы, те же таблицы, те же числа, со взаимными ссылками, если пишутся оба) и/или JSON-дамп со всеми сырыми ответами провайдеров без усечения. По умолчанию — только английский Markdown; чтобы поменять дефолт навсегда, задайте `output.formats` в `config.yaml`, либо используйте `--format` на конкретном прогоне. `./logs/` в gitignore — отчёты содержат ваш внешний IP, город, координаты и название провайдера. +Каждый прогон пишет в `./logs/` выбранные артефакты: Markdown-отчёт на английском, его полный перевод на русский (`.ru.md` — те же разделы, те же таблицы, те же числа, со взаимными ссылками, если пишутся оба), JSON-дамп со всеми сырыми ответами провайдеров без усечения и/или экспорт метрик `.prom`/`.csv` (те же числа, что в JSON-дампе, но плоские — для Prometheus/Grafana или таблицы). По умолчанию — только английский Markdown; чтобы поменять дефолт навсегда, задайте `output.formats` в `config.yaml`, либо используйте `--format` на конкретном прогоне. `./logs/` в gitignore — отчёты содержат ваш внешний IP, город, координаты и название провайдера. netsleuth никогда не требует администратора или root. Там, где привилегии дали бы данные лучше, он деградирует до непривилегированного метода и пишет об этом в отчёте. diff --git a/config.yaml b/config.yaml index 5df6f1a..1a87ed8 100644 --- a/config.yaml +++ b/config.yaml @@ -136,10 +136,18 @@ output: logs_dir: "./logs" cache_dir: "./.cache" emoji: true - # Which report artifacts a run writes by default: md, ru-md, json, or all. + # Which report artifacts a run writes by default: md, ru-md, json, prom, csv, or all. # Overridden per run by --format/--ru/--json. formats: ["md"] +# auto_diff prints a compact vs-previous-run summary after every run that writes JSON +# for the same network (silent if there is no prior JSON report to compare against). +# trend_default_reports is the N used by --trend when no explicit count is given, +# capped at 200 in code (reading 200 multi-MB JSON reports is the real cost). +history: + auto_diff: true + trend_default_reports: 10 + watch: interval_seconds: 60 speedtest_every_n_cycles: 10 diff --git a/src/netsleuth/cli.py b/src/netsleuth/cli.py index ea060d8..145cbfd 100644 --- a/src/netsleuth/cli.py +++ b/src/netsleuth/cli.py @@ -15,11 +15,12 @@ import psutil import typer from rich.console import Console -from netsleuth import __version__ -from netsleuth.compare import diff_reports, load_report, render_diff +from netsleuth import __version__, history +from netsleuth.compare import diff_reports, load_report, render_diff, render_diff_brief from netsleuth.config import FORMAT_EXTENSIONS, Settings, load_settings from netsleuth.exporter import build_report, dump_json, egress_asn, render_markdown, report_filename, write_report from netsleuth.iface import available_interfaces_hint, resolve_bind_target +from netsleuth.metrics import collect_metrics, render_csv, render_prometheus from netsleuth.interpret import ( assess_vpn, captive_portal_findings, @@ -711,6 +712,29 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat 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: + computed_metrics = collect_metrics(report) + if "prom" in options.formats: + artifacts["prom"] = render_prometheus(computed_metrics) + if "csv" in options.formats: + artifacts["csv"] = render_csv(computed_metrics) + + if settings.history.auto_diff: + 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 [] + 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].name + except (OSError, ValueError, KeyError): + pass + else: + console.print( + "[dim](no JSON written this run — future runs will not be able to diff against it)[/dim]" + ) + paths = write_report(report, artifacts, Path(settings.output.logs_dir)) return report, paths @@ -743,6 +767,14 @@ def run( compare: Optional[Tuple[Path, Path]] = typer.Option( None, "--compare", help="Diff two saved JSON reports — reads reports produced with --format json." ), + trend: Optional[int] = typer.Option( + None, + "--trend", + 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." + ), 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."), @@ -767,7 +799,7 @@ def run( [], "--format", help=( - "Which report artifacts to write: md, ru-md, json, all, none. Repeatable and " + "Which report artifacts to write: md, ru-md, json, prom, csv, all, none. Repeatable and " "comma-separated (--format md,json). Any --format replaces the default instead of " "adding to it. Ignored with --watch and --compare." ), @@ -786,6 +818,28 @@ def run( console.print(render_diff(diff_reports(before, after), emoji=settings.output.emoji)) raise typer.Exit(0) + 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) + + 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" diff --git a/src/netsleuth/compare.py b/src/netsleuth/compare.py index 755952e..79cd1fa 100644 --- a/src/netsleuth/compare.py +++ b/src/netsleuth/compare.py @@ -123,6 +123,31 @@ def _block(title: str, changes: list[Change]) -> list[str]: ) +def render_diff_brief(diff: ReportDiff, emoji: bool = True) -> str: + lines: list[str] = [] + for label in ("Egress IP", "ASN"): + change = next((c for c in diff.identity if c.label == label), None) + if change: + lines.append(f"{label}: {_fmt(change.before)} -> {_fmt(change.after)}") + worst_latency = max( + (c for c in diff.latency if c.label.endswith(" avg_ms") and c.delta is not None), + key=lambda c: abs(c.delta), + default=None, + ) + if worst_latency: + sign = "+" if worst_latency.delta >= 0 else "" + lines.append(f"Latency {worst_latency.label}: {sign}{worst_latency.delta} ms") + download = next((c for c in diff.speed if c.label == "Download Mbps" and c.delta is not None), None) + if download: + sign = "+" if download.delta >= 0 else "" + lines.append(f"Download: {sign}{download.delta} Mbps") + if diff.new_findings or diff.resolved_findings: + lines.append(f"Findings: {len(diff.new_findings)} new, {len(diff.resolved_findings)} resolved") + if not lines: + return "vs previous run — no significant change." + return "vs previous run:\n" + "\n".join(f" {line}" for line in lines) + + def render_diff(diff: ReportDiff, emoji: bool = True) -> str: lines = ["# netsleuth compare", ""] lines += _block("Identity", diff.identity) diff --git a/src/netsleuth/config.py b/src/netsleuth/config.py index 84a6370..87ba31b 100644 --- a/src/netsleuth/config.py +++ b/src/netsleuth/config.py @@ -6,7 +6,7 @@ from typing import Any from pydantic import BaseModel, Field, SecretStr, field_validator -FORMAT_EXTENSIONS = {"md": "md", "ru-md": "ru.md", "json": "json"} +FORMAT_EXTENSIONS = {"md": "md", "ru-md": "ru.md", "json": "json", "prom": "prom", "csv": "csv"} from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -275,6 +275,20 @@ class Output(BaseModel): return seen +class HistoryConfig(BaseModel): + auto_diff: bool = True + trend_default_reports: int = 10 + + @field_validator("trend_default_reports") + @classmethod + def _cap_trend_default_reports(cls, value: int) -> int: + if value > 200: + raise ValueError( + "history.trend_default_reports may not exceed 200 (reading 200 multi-MB JSON reports is the cost risk)" + ) + return value + + class Watch(BaseModel): interval_seconds: int = 60 speedtest_every_n_cycles: int = 10 @@ -297,6 +311,7 @@ class Settings(BaseSettings): dnsbl: Dnsbl = Dnsbl() thresholds: Thresholds = Thresholds() output: Output = Output() + history: HistoryConfig = HistoryConfig() watch: Watch = Watch() tls: TlsConfig = TlsConfig() prefix_bench: PrefixBenchConfig = PrefixBenchConfig() diff --git a/src/netsleuth/history.py b/src/netsleuth/history.py new file mode 100644 index 0000000..538f018 --- /dev/null +++ b/src/netsleuth/history.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from netsleuth.exporter import egress_asn, sanitize_name + +_REPORT_NAME_RE = re.compile(r"^report_(?P.+)_(?P\d{8}T\d{6}Z)\.json$") + + +def report_key(meta: dict, report: dict) -> str | None: + return meta.get("target") or egress_asn(report) + + +def matching_reports(names: list[str], key: str, *, exclude: str | None = None) -> list[str]: + prefix = f"report_{sanitize_name(key)}_" + + def _timestamp(name: str) -> str: + match = _REPORT_NAME_RE.match(name) + return match.group("ts") if match else "" + + candidates = [n for n in names if n.startswith(prefix) and n.endswith(".json") and n != exclude] + return sorted(candidates, key=_timestamp, reverse=True) + + +def find_previous( + logs_dir: Path | str, key: str, *, exclude: str | None = None, limit: int | None = None +) -> list[Path]: + logs_dir = Path(logs_dir) + if not logs_dir.is_dir(): + return [] + names = [p.name for p in logs_dir.glob("report_*.json")] + ordered = matching_reports(names, key, exclude=exclude) + paths = [logs_dir / name for name in ordered] + return paths[:limit] if limit else paths + + +def latest_key(logs_dir: Path | str) -> str | None: + logs_dir = Path(logs_dir) + if not logs_dir.is_dir(): + return None + for path in sorted(logs_dir.glob("report_*.json"), key=lambda p: p.name, reverse=True): + match = _REPORT_NAME_RE.match(path.name) + if match: + return match.group("key") + return None diff --git a/src/netsleuth/metrics.py b/src/netsleuth/metrics.py new file mode 100644 index 0000000..9ccea84 --- /dev/null +++ b/src/netsleuth/metrics.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import csv +import io +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class Metric: + name: str + value: float + labels: dict[str, str] = field(default_factory=dict) + + +def _data(report: dict, section: str) -> Any: + return (report.get(section) or {}).get("data") + + +def _add(metrics: list[Metric], name: str, value: Any, labels: dict[str, str] | None = None) -> None: + if value is None: + return + try: + metrics.append(Metric(name, float(value), labels or {})) + except (TypeError, ValueError): + return + + +def collect_metrics(report: dict) -> list[Metric]: + metrics: list[Metric] = [] + meta = report.get("meta") or {} + metrics.append(Metric("netsleuth_up", 1.0)) + + geo = (_data(report, "ip_geo") or {}).get("egress_v4") or {} + metrics.append( + Metric( + "netsleuth_run_info", + 1.0, + { + "asn": geo.get("asn") or "", + "country": geo.get("country_code") or "", + "run_id": meta.get("run_id") or "", + }, + ) + ) + + for p in _data(report, "latency") or []: + labels = {"label": p.get("label") or "", "host": p.get("host") or "", "method": p.get("method") or ""} + for metric_name in ("avg_ms", "min_ms", "max_ms", "jitter_ms", "loss_pct", "p95_ms", "p99_ms"): + _add(metrics, f"netsleuth_latency_{metric_name}", p.get(metric_name), labels) + + speed = _data(report, "speed") or {} + for metric_name in ( + "download_mbps", + "upload_mbps", + "idle_rtt_ms", + "loaded_rtt_down_ms", + "loaded_rtt_up_ms", + "bufferbloat_down_ms", + "bufferbloat_up_ms", + ): + _add(metrics, f"netsleuth_speed_{metric_name}", speed.get(metric_name)) + + for r in _data(report, "tls") or []: + labels = {"label": r.get("label") or "", "host": r.get("host") or ""} + _add(metrics, "netsleuth_tls_tcp_rtt_ms", r.get("tcp_rtt_ms"), labels) + _add(metrics, "netsleuth_tls_handshake_ms", r.get("tls_handshake_ms"), labels) + _add(metrics, "netsleuth_tls_ttfb_ms", r.get("ttfb_ms"), labels) + + for trace in _data(report, "path") or []: + target = trace.get("target") or "" + for hop in trace.get("hops") or []: + labels = { + "target": target, + "ttl": str(hop.get("ttl", "")), + "ip": hop.get("ip") or "", + "asn": hop.get("asn") or "", + } + _add(metrics, "netsleuth_path_hop_avg_ms", hop.get("avg_ms"), labels) + _add(metrics, "netsleuth_path_hop_loss_pct", hop.get("loss_pct"), labels) + + bench = _data(report, "prefix_benchmark") or {} + for p in bench.get("results") or []: + labels = {"prefix": p.get("prefix") or ""} + _add(metrics, "netsleuth_prefix_avg_ms", p.get("avg_ms"), labels) + _add(metrics, "netsleuth_prefix_loss_pct", p.get("loss_pct"), labels) + _add(metrics, "netsleuth_prefix_spread_ms", bench.get("spread_ms")) + + bgp = _data(report, "bgp") or {} + _add(metrics, "netsleuth_bgp_prefix_count", bgp.get("prefix_count_v4"), {"family": "4"}) + _add(metrics, "netsleuth_bgp_prefix_count", bgp.get("prefix_count_v6"), {"family": "6"}) + _add(metrics, "netsleuth_bgp_asrank", bgp.get("asrank")) + _add(metrics, "netsleuth_bgp_cone_asns", bgp.get("cone_asns")) + _add(metrics, "netsleuth_bgp_cone_prefixes", bgp.get("cone_prefixes")) + + vpn = _data(report, "vpn_assessment") or {} + _add(metrics, "netsleuth_vpn_confidence", vpn.get("confidence")) + + reputation = _data(report, "reputation") or {} + _add(metrics, "netsleuth_reputation_abuseipdb_score", reputation.get("abuseipdb_score")) + + interpretation = report.get("interpretation") or {} + _add(metrics, "netsleuth_overall_score", interpretation.get("overall_score")) + + severity_counts: dict[str, int] = {} + for finding in interpretation.get("findings") or []: + severity = finding.get("severity") or "info" + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + for severity, count in severity_counts.items(): + metrics.append(Metric("netsleuth_findings_total", float(count), {"severity": severity})) + + return metrics + + +_ESCAPE_MAP = {"\\": "\\\\", '"': '\\"', "\n": "\\n"} + + +def _escape_label(value: str) -> str: + return "".join(_ESCAPE_MAP.get(ch, ch) for ch in value) + + +def _format_value(value: float) -> str: + return str(int(value)) if value == int(value) else repr(value) + + +def render_prometheus(metrics: list[Metric]) -> str: + order: list[str] = [] + by_name: dict[str, list[Metric]] = {} + for m in metrics: + if m.name not in by_name: + by_name[m.name] = [] + order.append(m.name) + by_name[m.name].append(m) + + lines: list[str] = [] + for name in order: + lines.append(f"# HELP {name} netsleuth metric") + lines.append(f"# TYPE {name} gauge") + for m in by_name[name]: + if m.labels: + label_str = ",".join(f'{k}="{_escape_label(str(v))}"' for k, v in m.labels.items()) + lines.append(f"{name}{{{label_str}}} {_format_value(m.value)}") + else: + lines.append(f"{name} {_format_value(m.value)}") + return "\n".join(lines) + "\n" + + +def render_csv(metrics: list[Metric]) -> str: + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerow(["metric", "labels", "value"]) + for m in metrics: + label_str = ";".join(f"{k}={v}" for k, v in m.labels.items()) + writer.writerow([m.name, label_str, _format_value(m.value)]) + return buf.getvalue() diff --git a/src/netsleuth/trend.py b/src/netsleuth/trend.py new file mode 100644 index 0000000..a3c9a42 --- /dev/null +++ b/src/netsleuth/trend.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from netsleuth.exporter import sparkline +from netsleuth.stats import percentile + + +@dataclass +class TrendSeries: + label: str + values: list[float | None] = field(default_factory=list) + + +@dataclass +class Trend: + timestamps: list[str] = field(default_factory=list) + latency: list[TrendSeries] = field(default_factory=list) + download_mbps: list[float | None] = field(default_factory=list) + upload_mbps: list[float | None] = field(default_factory=list) + overall_score: list[float | None] = field(default_factory=list) + + +def build_trend(reports: list[dict]) -> Trend: + """`reports` must be chronologically ordered, oldest first.""" + timestamps = [(r.get("meta") or {}).get("started_at") or "" for r in reports] + + labels: list[str] = [] + for r in reports: + for p in (r.get("latency") or {}).get("data") or []: + label = p.get("label") or "" + if label not in labels: + labels.append(label) + latency_by_label: dict[str, list[float | None]] = {label: [None] * len(reports) for label in labels} + + download_mbps: list[float | None] = [] + upload_mbps: list[float | None] = [] + overall_score: list[float | None] = [] + for i, r in enumerate(reports): + by_label = {p.get("label"): p for p in (r.get("latency") or {}).get("data") or []} + for label in labels: + p = by_label.get(label) + latency_by_label[label][i] = p.get("avg_ms") if p else None + speed = (r.get("speed") or {}).get("data") or {} + download_mbps.append(speed.get("download_mbps")) + upload_mbps.append(speed.get("upload_mbps")) + overall_score.append((r.get("interpretation") or {}).get("overall_score")) + + return Trend( + timestamps=timestamps, + latency=[TrendSeries(label, latency_by_label[label]) for label in labels], + download_mbps=download_mbps, + upload_mbps=upload_mbps, + overall_score=overall_score, + ) + + +def series_summary(values: list[float | None]) -> tuple[float | None, float | None, float | None, float | None]: + """Returns (min, median, max, delta first->last), ignoring holes.""" + numbers = [v for v in values if v is not None] + if not numbers: + return None, None, None, None + first = next((v for v in values if v is not None), None) + last = next((v for v in reversed(values) if v is not None), None) + delta = None if first is None or last is None else round(last - first, 3) + return min(numbers), round(percentile(numbers, 50.0), 3), max(numbers), delta + + +def _fmt(value: float | None) -> str: + return "—" if value is None else f"{value:g}" + + +def _summary_line(values: list[float | None], unit: str) -> str: + lo, median, hi, delta = series_summary(values) + delta_str = _fmt(delta) + if delta is not None and delta > 0: + delta_str = f"+{delta_str}" + return f"{sparkline(values)} min={_fmt(lo)} median={_fmt(median)} max={_fmt(hi)} first→last={delta_str} {unit}" + + +def render_trend(trend: Trend, emoji: bool = True) -> str: + lines = ["# netsleuth trend", "", f"Runs: {len(trend.timestamps)}"] + if trend.timestamps: + lines.append(f"From {trend.timestamps[0]} to {trend.timestamps[-1]}") + lines.append("") + for series in trend.latency: + lines += [f"### Latency: {series.label}", _summary_line(series.values, "ms"), ""] + lines += ["### Download", _summary_line(trend.download_mbps, "Mbps"), ""] + lines += ["### Upload", _summary_line(trend.upload_mbps, "Mbps"), ""] + lines += ["### Overall score", _summary_line(trend.overall_score, "/100"), ""] + return "\n".join(lines).rstrip() + "\n" diff --git a/tests/test_cli_formats.py b/tests/test_cli_formats.py index 4cbf689..18c1f1f 100644 --- a/tests/test_cli_formats.py +++ b/tests/test_cli_formats.py @@ -38,7 +38,7 @@ def test_comma_separated_single_flag(): def test_all_expands_to_every_known_format(): assert parse_formats(["all"], ru=False, json_flag=False, default=DEFAULT) == frozenset( - {"md", "ru-md", "json"} + {"md", "ru-md", "json", "prom", "csv"} ) diff --git a/tests/test_compare.py b/tests/test_compare.py index 19197c4..ebeecff 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -10,6 +10,7 @@ from netsleuth.compare import ( latency_changes, load_report, render_diff, + render_diff_brief, speed_changes, ) @@ -276,3 +277,14 @@ def test_render_diff_of_two_identical_reports_says_so(before): def test_render_diff_honours_the_emoji_setting(before, after): assert "🔴" in render_diff(diff_reports(before, after), emoji=True) assert "[crit]" in render_diff(diff_reports(before, after), emoji=False) + + +def test_render_diff_brief_is_compact_and_mentions_the_asn_change(before, after): + text = render_diff_brief(diff_reports(before, after)) + assert text.startswith("vs previous run:") + assert "ASN" in text + assert len(text.splitlines()) <= 6 + + +def test_render_diff_brief_of_two_identical_reports_says_no_change(before): + assert render_diff_brief(diff_reports(before, before)) == "vs previous run — no significant change." diff --git a/tests/test_config.py b/tests/test_config.py index 06c7c63..53d2b0f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -126,7 +126,7 @@ def test_output_formats_all_expands_to_every_known_format(tmp_path): path = tmp_path / "config.yaml" path.write_text('output:\n formats: ["all"]\n', encoding="utf-8") s = load_settings(config_path=path, env_file=tmp_path / "nope.env") - assert s.output.formats == ["md", "ru-md", "json"] + assert s.output.formats == ["md", "ru-md", "json", "prom", "csv"] def test_output_formats_dedupes_and_lowercases(tmp_path): @@ -224,6 +224,13 @@ def test_tls_pinned_fingerprints_are_capped_at_32_entries(tmp_path): load_settings(config_path=path, env_file=tmp_path / "nope.env") +def test_history_trend_default_reports_is_capped(tmp_path): + path = tmp_path / "config.yaml" + path.write_text("history:\n trend_default_reports: 500\n", encoding="utf-8") + with pytest.raises(ValidationError, match="200"): + load_settings(config_path=path, env_file=tmp_path / "nope.env") + + def test_captive_portal_check_urls_are_capped(tmp_path): path = tmp_path / "config.yaml" path.write_text( diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 4e94b6b..a784af0 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -265,8 +265,8 @@ def test_write_report_returns_paths_in_input_order(tmp_path: Path): assert paths[1].name.endswith(".md") -def test_format_extensions_covers_the_three_known_formats(): - assert FORMAT_EXTENSIONS == {"md": "md", "ru-md": "ru.md", "json": "json"} +def test_format_extensions_covers_the_known_formats(): + assert FORMAT_EXTENSIONS == {"md": "md", "ru-md": "ru.md", "json": "json", "prom": "prom", "csv": "csv"} from netsleuth.exporter import SPARK_CHARS, _forced_interface_rows, badge, first_loss_jump, render_markdown, sparkline diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..10af192 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from netsleuth.history import find_previous, latest_key, matching_reports, report_key + + +def test_report_key_prefers_the_explicit_target(): + assert report_key({"target": "AS64500"}, {}) == "AS64500" + + +def test_report_key_falls_back_to_egress_asn(): + report = {"ip_geo": {"data": {"egress_v4": {"asn": "AS64500"}}}} + assert report_key({}, report) == "AS64500" + + +def test_matching_reports_orders_newest_first(): + names = [ + "report_AS64500_20260101T000000Z.json", + "report_AS64500_20260103T000000Z.json", + "report_AS64500_20260102T000000Z.json", + ] + assert matching_reports(names, "AS64500") == [ + "report_AS64500_20260103T000000Z.json", + "report_AS64500_20260102T000000Z.json", + "report_AS64500_20260101T000000Z.json", + ] + + +def test_matching_reports_excludes_the_current_file(): + names = ["report_AS64500_20260101T000000Z.json", "report_AS64500_20260102T000000Z.json"] + assert matching_reports(names, "AS64500", exclude="report_AS64500_20260102T000000Z.json") == [ + "report_AS64500_20260101T000000Z.json" + ] + + +def test_matching_reports_does_not_cross_contaminate_different_keys(): + names = ["report_AS1_20260101T000000Z.json", "report_AS13335_20260101T000000Z.json"] + assert matching_reports(names, "AS1") == ["report_AS1_20260101T000000Z.json"] + + +def test_matching_reports_ignores_non_json_files(): + names = ["report_AS64500_20260101T000000Z.md", "report_AS64500_20260101T000000Z.json"] + assert matching_reports(names, "AS64500") == ["report_AS64500_20260101T000000Z.json"] + + +def test_find_previous_on_an_empty_directory(tmp_path): + assert find_previous(tmp_path, "AS64500") == [] + + +def test_find_previous_on_a_nonexistent_directory(tmp_path): + assert find_previous(tmp_path / "nope", "AS64500") == [] + + +def test_find_previous_respects_limit(tmp_path): + for ts in ("20260101T000000Z", "20260102T000000Z", "20260103T000000Z"): + (tmp_path / f"report_AS64500_{ts}.json").write_text("{}", encoding="utf-8") + result = find_previous(tmp_path, "AS64500", limit=2) + assert len(result) == 2 + assert result[0].name.endswith("20260103T000000Z.json") + + +def test_latest_key_picks_the_newest_report_across_any_key(tmp_path): + (tmp_path / "report_AS1_20260101T000000Z.json").write_text("{}", encoding="utf-8") + (tmp_path / "report_AS2_20260102T000000Z.json").write_text("{}", encoding="utf-8") + assert latest_key(tmp_path) == "AS2" + + +def test_latest_key_on_an_empty_directory(tmp_path): + assert latest_key(tmp_path) is None diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..0d42121 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from netsleuth.metrics import Metric, collect_metrics, render_csv, render_prometheus + + +def _report(**overrides) -> dict: + base = { + "meta": {"run_id": "abc123"}, + "ip_geo": {"status": "ok", "data": {"egress_v4": {"asn": "AS64500", "country_code": "RU"}}}, + "latency": { + "status": "ok", + "data": [ + {"label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win", "avg_ms": 10.0, "loss_pct": 0.0} + ], + }, + "speed": {"status": "ok", "data": {"download_mbps": 100.0, "upload_mbps": None}}, + "tls": {"status": "skipped", "data": None}, + "path": {"status": "ok", "data": []}, + "prefix_benchmark": {"status": "skipped", "data": None}, + "bgp": {"status": "skipped", "data": None}, + "vpn_assessment": {"status": "ok", "data": {"confidence": 0.1}}, + "reputation": {"status": "skipped", "data": None}, + "interpretation": {"overall_score": 90, "findings": [{"severity": "warn"}, {"severity": "warn"}]}, + } + base.update(overrides) + return base + + +def test_collect_metrics_extracts_latency_and_skips_none_fields(): + metrics = collect_metrics(_report()) + latency_avg = [m for m in metrics if m.name == "netsleuth_latency_avg_ms"] + assert len(latency_avg) == 1 + assert latency_avg[0].value == 10.0 + assert latency_avg[0].labels == {"label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win"} + # upload_mbps is None -> no metric emitted + assert not [m for m in metrics if m.name == "netsleuth_speed_upload_mbps"] + + +def test_collect_metrics_a_skipped_section_contributes_nothing(): + metrics = collect_metrics(_report()) + assert not [m for m in metrics if m.name.startswith("netsleuth_tls_")] + assert not [m for m in metrics if m.name.startswith("netsleuth_bgp_")] + + +def test_collect_metrics_always_emits_up_and_run_info(): + metrics = collect_metrics(_report()) + up = [m for m in metrics if m.name == "netsleuth_up"] + assert up == [Metric("netsleuth_up", 1.0)] + run_info = [m for m in metrics if m.name == "netsleuth_run_info"] + assert run_info[0].labels == {"asn": "AS64500", "country": "RU", "run_id": "abc123"} + + +def test_collect_metrics_aggregates_findings_by_severity(): + metrics = collect_metrics(_report()) + findings = {m.labels["severity"]: m.value for m in metrics if m.name == "netsleuth_findings_total"} + assert findings == {"warn": 2.0} + + +def test_render_prometheus_has_one_help_and_type_per_family(): + metrics = [ + Metric("netsleuth_latency_avg_ms", 10.0, {"label": "a"}), + Metric("netsleuth_latency_avg_ms", 20.0, {"label": "b"}), + ] + text = render_prometheus(metrics) + assert text.count("# HELP netsleuth_latency_avg_ms") == 1 + assert text.count("# TYPE netsleuth_latency_avg_ms") == 1 + assert 'netsleuth_latency_avg_ms{label="a"} 10' in text + assert 'netsleuth_latency_avg_ms{label="b"} 20' in text + + +def test_render_prometheus_handles_no_labels(): + text = render_prometheus([Metric("netsleuth_up", 1.0)]) + assert "netsleuth_up 1" in text + + +def test_render_prometheus_escapes_special_characters_in_labels(): + text = render_prometheus([Metric("netsleuth_x", 1.0, {"host": 'weird"name\\here'})]) + assert 'host="weird\\"name\\\\here"' in text + + +def test_render_csv_has_a_header_row_and_quotes_a_comma_in_a_label(): + metrics = [Metric("netsleuth_x", 5.0, {"target": "a, b"})] + text = render_csv(metrics) + lines = text.strip().splitlines() + assert lines[0] == "metric,labels,value" + assert '"target=a, b"' in lines[1] diff --git a/tests/test_trend.py b/tests/test_trend.py new file mode 100644 index 0000000..dba836e --- /dev/null +++ b/tests/test_trend.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from netsleuth.trend import build_trend, render_trend, series_summary + + +def _report(started_at: str, avg_ms: float | None, download: float | None, score: int | None) -> dict: + return { + "meta": {"started_at": started_at}, + "latency": {"data": [{"label": "cloudflare-dns", "avg_ms": avg_ms}] if avg_ms is not None else []}, + "speed": {"data": {"download_mbps": download}}, + "interpretation": {"overall_score": score}, + } + + +def test_build_trend_over_a_single_report(): + trend = build_trend([_report("2026-01-01T00:00:00Z", 10.0, 100.0, 90)]) + assert trend.timestamps == ["2026-01-01T00:00:00Z"] + assert trend.latency[0].label == "cloudflare-dns" + assert trend.latency[0].values == [10.0] + assert trend.download_mbps == [100.0] + assert trend.overall_score == [90] + + +def test_build_trend_over_two_reports(): + reports = [ + _report("2026-01-01T00:00:00Z", 10.0, 100.0, 90), + _report("2026-01-02T00:00:00Z", 12.0, 90.0, 85), + ] + trend = build_trend(reports) + assert trend.latency[0].values == [10.0, 12.0] + assert trend.download_mbps == [100.0, 90.0] + + +def test_build_trend_handles_a_hole_when_a_label_is_missing_from_one_report(): + reports = [ + _report("2026-01-01T00:00:00Z", 10.0, 100.0, 90), + {"meta": {"started_at": "2026-01-02T00:00:00Z"}, "latency": {"data": []}, "speed": {"data": {}}, "interpretation": {}}, + ] + trend = build_trend(reports) + assert trend.latency[0].values == [10.0, None] + + +def test_build_trend_a_label_appearing_only_in_a_later_report(): + reports = [ + {"meta": {"started_at": "2026-01-01T00:00:00Z"}, "latency": {"data": []}, "speed": {"data": {}}, "interpretation": {}}, + _report("2026-01-02T00:00:00Z", 8.0, 100.0, 90), + ] + trend = build_trend(reports) + assert trend.latency[0].values == [None, 8.0] + + +def test_series_summary_basic_stats(): + lo, median, hi, delta = series_summary([10.0, 20.0, 30.0]) + assert lo == 10.0 + assert median == 20.0 + assert hi == 30.0 + assert delta == 20.0 + + +def test_series_summary_ignores_holes_for_min_max_median(): + lo, median, hi, delta = series_summary([None, 10.0, None, 30.0, None]) + assert lo == 10.0 + assert hi == 30.0 + assert delta == 20.0 + + +def test_series_summary_all_none(): + assert series_summary([None, None]) == (None, None, None, None) + + +def test_render_trend_produces_readable_output(): + trend = build_trend([_report("2026-01-01T00:00:00Z", 10.0, 100.0, 90)]) + text = render_trend(trend) + assert "Latency: cloudflare-dns" in text + assert "Download" in text + assert "Overall score" in text