mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 19:12:03 +03:00
render TLS, DNS advanced, path diversity, prefix bench, DPI sections in reports
This commit is contained in:
parent
fc502f1756
commit
342d92e709
3 changed files with 528 additions and 2 deletions
|
|
@ -17,8 +17,13 @@ SECTION_ORDER = (
|
||||||
"vpn_assessment",
|
"vpn_assessment",
|
||||||
"bgp",
|
"bgp",
|
||||||
"reputation",
|
"reputation",
|
||||||
|
"dns_advanced",
|
||||||
"latency",
|
"latency",
|
||||||
|
"tls",
|
||||||
"path",
|
"path",
|
||||||
|
"path_diversity",
|
||||||
|
"prefix_benchmark",
|
||||||
|
"dpi_check",
|
||||||
"speed",
|
"speed",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -238,8 +243,13 @@ _SECTION_TITLES = {
|
||||||
"vpn_assessment": "## VPN / proxy assessment",
|
"vpn_assessment": "## VPN / proxy assessment",
|
||||||
"bgp": "## ASN & BGP intelligence",
|
"bgp": "## ASN & BGP intelligence",
|
||||||
"reputation": "## Reputation",
|
"reputation": "## Reputation",
|
||||||
|
"dns_advanced": "## DNS: system vs DoH",
|
||||||
"latency": "## Latency",
|
"latency": "## Latency",
|
||||||
|
"tls": "## TLS handshake",
|
||||||
"path": "## Path",
|
"path": "## Path",
|
||||||
|
"path_diversity": "## Path diversity (Anycast)",
|
||||||
|
"prefix_benchmark": "## AS prefix benchmark",
|
||||||
|
"dpi_check": "## DPI / port check",
|
||||||
"speed": "## Speed",
|
"speed": "## Speed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,8 +259,13 @@ _SECTION_TITLES_RU = {
|
||||||
"vpn_assessment": "## Оценка VPN/прокси",
|
"vpn_assessment": "## Оценка VPN/прокси",
|
||||||
"bgp": "## ASN и BGP-разведка",
|
"bgp": "## ASN и BGP-разведка",
|
||||||
"reputation": "## Репутация",
|
"reputation": "## Репутация",
|
||||||
|
"dns_advanced": "## DNS: системный резолвер vs DoH",
|
||||||
"latency": "## Задержки",
|
"latency": "## Задержки",
|
||||||
|
"tls": "## TLS-рукопожатие",
|
||||||
"path": "## Маршрут",
|
"path": "## Маршрут",
|
||||||
|
"path_diversity": "## Диверсификация маршрута (Anycast)",
|
||||||
|
"prefix_benchmark": "## Бенчмарк префиксов AS",
|
||||||
|
"dpi_check": "## Проверка DPI / портов",
|
||||||
"speed": "## Скорость",
|
"speed": "## Скорость",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -653,6 +668,188 @@ def _speed(report: dict, lang: str = "en") -> list[str]:
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _tls(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("tls", lang)
|
||||||
|
note = unavailable(report, "tls", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
results = _data(report, "tls") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Target", "Цель"),
|
||||||
|
_t(lang, "Address", "Адрес"),
|
||||||
|
_t(lang, "TCP RTT (ms)", "TCP RTT (мс)"),
|
||||||
|
_t(lang, "TLS handshake (ms)", "TLS-рукопожатие (мс)"),
|
||||||
|
_t(lang, "TTFB (ms)", "TTFB (мс)"),
|
||||||
|
_t(lang, "Version", "Версия"),
|
||||||
|
_t(lang, "Cipher", "Шифр"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
r.get("label", ""),
|
||||||
|
r.get("resolved_ip") or r.get("host", ""),
|
||||||
|
_num(r.get("tcp_rtt_ms")),
|
||||||
|
_num(r.get("tls_handshake_ms")),
|
||||||
|
_num(r.get("ttfb_ms")),
|
||||||
|
r.get("tls_version") or "—",
|
||||||
|
r.get("cipher") or "—",
|
||||||
|
]
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
errored = [r for r in results if r.get("error")]
|
||||||
|
if errored:
|
||||||
|
lines += [""] + [f"- {r.get('label')}: {r.get('error')}" for r in errored]
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"> {_t(lang, 'TLS handshake time is derived by subtracting a separate TCP-only baseline connection from a second, full TLS connection.', 'Время TLS-рукопожатия получено вычитанием отдельного базового TCP-соединения из времени второго, полного TLS-соединения.')}",
|
||||||
|
]
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_advanced(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("dns_advanced", lang)
|
||||||
|
note = unavailable(report, "dns_advanced", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
adv = _data(report, "dns_advanced") or {}
|
||||||
|
probes = adv.get("probes") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Resolver", "Резолвер"),
|
||||||
|
_t(lang, "Kind", "Тип"),
|
||||||
|
_t(lang, "Name", "Имя"),
|
||||||
|
_t(lang, "ms", "мс"),
|
||||||
|
_t(lang, "Answers", "Ответы"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
p.get("name", ""),
|
||||||
|
p.get("kind", ""),
|
||||||
|
p.get("query_name", ""),
|
||||||
|
_num(p.get("elapsed_ms")),
|
||||||
|
", ".join(p.get("answers") or []) or (p.get("error") or "—"),
|
||||||
|
]
|
||||||
|
for p in probes
|
||||||
|
],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
proxy = adv.get("transparent_proxy")
|
||||||
|
proxy_word = {True: _t(lang, "yes", "да"), False: _t(lang, "no", "нет"), None: "—"}[proxy]
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"{_t(lang, 'Transparent DNS proxy', 'Прозрачный DNS-прокси')}: **{proxy_word}** — {adv.get('transparent_proxy_detail') or ''}",
|
||||||
|
]
|
||||||
|
note_text = (adv.get("note_ru") or adv.get("note") or "") if lang == "ru" else (adv.get("note") or "")
|
||||||
|
if note_text:
|
||||||
|
lines += ["", note_text]
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _path_diversity(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("path_diversity", lang)
|
||||||
|
note = unavailable(report, "path_diversity", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
pd = _data(report, "path_diversity") or {}
|
||||||
|
hops = pd.get("hops") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Target", "Цель"),
|
||||||
|
_t(lang, "IP", "IP"),
|
||||||
|
_t(lang, "IP geo", "Гео IP"),
|
||||||
|
_t(lang, "Edge colo", "Точка edge"),
|
||||||
|
_t(lang, "Edge geo", "Гео edge"),
|
||||||
|
_t(lang, "Edge RTT (ms)", "RTT edge (мс)"),
|
||||||
|
_t(lang, "Client RTT (ms)", "RTT клиента (мс)"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
h.get("target", ""),
|
||||||
|
h.get("resolved_ip") or "—",
|
||||||
|
f"{h.get('ip_city') or '?'}, {h.get('ip_country')}" if h.get("ip_country") else "—",
|
||||||
|
h.get("edge_colo") or "—",
|
||||||
|
f"{h.get('edge_city') or '?'}, {h.get('edge_country')}" if h.get("edge_country") else "—",
|
||||||
|
_num(h.get("edge_rtt_ms")),
|
||||||
|
_num(h.get("client_rtt_ms")),
|
||||||
|
]
|
||||||
|
for h in hops
|
||||||
|
],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
loop_word = _t(lang, "yes", "да") if pd.get("international_loop") else _t(lang, "no", "нет")
|
||||||
|
lines += ["", f"{_t(lang, 'International routing loop', 'Международная петля маршрута')}: **{loop_word}**"]
|
||||||
|
note_text = (pd.get("note_ru") or pd.get("note") or "") if lang == "ru" else (pd.get("note") or "")
|
||||||
|
if note_text:
|
||||||
|
lines += ["", note_text]
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_benchmark(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("prefix_benchmark", lang)
|
||||||
|
note = unavailable(report, "prefix_benchmark", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
bench = _data(report, "prefix_benchmark") or {}
|
||||||
|
results = bench.get("results") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Prefix", "Префикс"),
|
||||||
|
_t(lang, "Probe IP", "IP пробы"),
|
||||||
|
_t(lang, "Avg ms", "Сред. мс"),
|
||||||
|
_t(lang, "Loss", "Потери"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[r.get("prefix", ""), r.get("probe_ip") or "—", _num(r.get("avg_ms")), f"{r.get('loss_pct', 0)}%"]
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"{_t(lang, 'Prefixes announced', 'Анонсировано префиксов')}: {bench.get('prefixes_announced', 0)} · "
|
||||||
|
f"{_t(lang, 'probed', 'проверено')}: {bench.get('prefixes_probed', 0)} · "
|
||||||
|
f"{_t(lang, 'best', 'лучший')}: {bench.get('best') or '—'} · "
|
||||||
|
f"{_t(lang, 'worst', 'худший')}: {bench.get('worst') or '—'} · "
|
||||||
|
f"{_t(lang, 'spread', 'разброс')}: {_num(bench.get('spread_ms'))} {_t(lang, 'ms', 'мс')}",
|
||||||
|
]
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
_DPI_VERDICT_RU = {
|
||||||
|
"clean": "чисто",
|
||||||
|
"partial_filtering": "частичная фильтрация",
|
||||||
|
"reset_injection": "инъекция RST",
|
||||||
|
"unreachable": "недоступен",
|
||||||
|
"unknown": "неизвестно",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dpi_check(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("dpi_check", lang)
|
||||||
|
note = unavailable(report, "dpi_check", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
dpi = _data(report, "dpi_check") or {}
|
||||||
|
ports = dpi.get("ports") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Port", "Порт"),
|
||||||
|
_t(lang, "State", "Состояние"),
|
||||||
|
_t(lang, "RTT (ms)", "RTT (мс)"),
|
||||||
|
_t(lang, "Detail", "Детали"),
|
||||||
|
],
|
||||||
|
[[str(p.get("port", "")), p.get("state", ""), _num(p.get("rtt_ms")), p.get("detail") or "—"] for p in ports],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
verdict = dpi.get("verdict", "unknown")
|
||||||
|
verdict_word = verdict if lang == "en" else _DPI_VERDICT_RU.get(verdict, verdict)
|
||||||
|
rationale = (dpi.get("rationale_ru") or dpi.get("rationale") or "") if lang == "ru" else (dpi.get("rationale") or "")
|
||||||
|
lines += ["", f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** — {rationale}"]
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _problems(report: dict, emoji: bool, lang: str = "en") -> list[str]:
|
def _problems(report: dict, emoji: bool, lang: str = "en") -> list[str]:
|
||||||
found = _findings(report)
|
found = _findings(report)
|
||||||
heading = _t(lang, "## Problems & recommendations", "## Проблемы и рекомендации")
|
heading = _t(lang, "## Problems & recommendations", "## Проблемы и рекомендации")
|
||||||
|
|
@ -699,8 +896,13 @@ def render_markdown(
|
||||||
lines += _vpn(report, lang)
|
lines += _vpn(report, lang)
|
||||||
lines += _bgp(report, lang)
|
lines += _bgp(report, lang)
|
||||||
lines += _reputation(report, lang)
|
lines += _reputation(report, lang)
|
||||||
|
lines += _dns_advanced(report, lang)
|
||||||
lines += _latency(report, lang)
|
lines += _latency(report, lang)
|
||||||
|
lines += _tls(report, lang)
|
||||||
lines += _path(report, lang)
|
lines += _path(report, lang)
|
||||||
|
lines += _path_diversity(report, lang)
|
||||||
|
lines += _prefix_benchmark(report, lang)
|
||||||
|
lines += _dpi_check(report, lang)
|
||||||
lines += _speed(report, lang)
|
lines += _speed(report, lang)
|
||||||
lines += _problems(report, emoji, lang)
|
lines += _problems(report, emoji, lang)
|
||||||
lines += _diagnostics(report, lang)
|
lines += _diagnostics(report, lang)
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,13 @@ def test_missing_modules_are_rendered_as_skipped_sections_not_dropped():
|
||||||
"vpn_assessment",
|
"vpn_assessment",
|
||||||
"bgp",
|
"bgp",
|
||||||
"reputation",
|
"reputation",
|
||||||
|
"dns_advanced",
|
||||||
"latency",
|
"latency",
|
||||||
|
"tls",
|
||||||
"path",
|
"path",
|
||||||
|
"path_diversity",
|
||||||
|
"prefix_benchmark",
|
||||||
|
"dpi_check",
|
||||||
"speed",
|
"speed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,15 +271,24 @@ def test_format_extensions_covers_the_three_known_formats():
|
||||||
from netsleuth.exporter import SPARK_CHARS, _forced_interface_rows, badge, first_loss_jump, render_markdown, sparkline
|
from netsleuth.exporter import SPARK_CHARS, _forced_interface_rows, badge, first_loss_jump, render_markdown, sparkline
|
||||||
from netsleuth.models import (
|
from netsleuth.models import (
|
||||||
AdapterLeakResult,
|
AdapterLeakResult,
|
||||||
|
AnycastHop,
|
||||||
BgpIntel,
|
BgpIntel,
|
||||||
CfTrace,
|
CfTrace,
|
||||||
|
DnsAdvanced,
|
||||||
DnsLeak,
|
DnsLeak,
|
||||||
DnsblHit,
|
DnsblHit,
|
||||||
|
DpiCheckResult,
|
||||||
InternetDbResult,
|
InternetDbResult,
|
||||||
IxpPresence,
|
IxpPresence,
|
||||||
|
PathDiversity,
|
||||||
|
PortProbe,
|
||||||
|
PrefixBenchmark,
|
||||||
|
PrefixProbe,
|
||||||
Reputation,
|
Reputation,
|
||||||
|
ResolverProbe,
|
||||||
Signal,
|
Signal,
|
||||||
TierAttempt,
|
TierAttempt,
|
||||||
|
TlsResult,
|
||||||
TraceHop,
|
TraceHop,
|
||||||
TraceResult,
|
TraceResult,
|
||||||
VpnAssessment,
|
VpnAssessment,
|
||||||
|
|
@ -364,6 +378,22 @@ def full_modules() -> dict[str, ModuleResult]:
|
||||||
rationale="listed on bl.spamcop.net",
|
rationale="listed on bl.spamcop.net",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
"dns_advanced": ModuleResult(
|
||||||
|
name="dns_advanced",
|
||||||
|
status="ok",
|
||||||
|
data=DnsAdvanced(
|
||||||
|
probes=[
|
||||||
|
ResolverProbe(name="system", kind="system", query_name="cloudflare.com", answers=["1.1.1.1"], elapsed_ms=18.0),
|
||||||
|
ResolverProbe(name="cloudflare", kind="doh", query_name="cloudflare.com", answers=["104.16.132.229"], elapsed_ms=22.0),
|
||||||
|
],
|
||||||
|
system_avg_ms=18.0,
|
||||||
|
doh_avg_ms=22.0,
|
||||||
|
transparent_proxy=False,
|
||||||
|
transparent_proxy_detail="No response from the bogus resolver IP (timeout): transparent DNS proxy not detected.",
|
||||||
|
note="System resolver and DoH agree within normal CDN variance.",
|
||||||
|
note_ru="Системный резолвер и DoH согласуются в пределах обычной вариации CDN.",
|
||||||
|
),
|
||||||
|
),
|
||||||
"latency": ModuleResult(
|
"latency": ModuleResult(
|
||||||
name="latency",
|
name="latency",
|
||||||
status="ok",
|
status="ok",
|
||||||
|
|
@ -384,6 +414,23 @@ def full_modules() -> dict[str, ModuleResult]:
|
||||||
],
|
],
|
||||||
duration_ms=2400,
|
duration_ms=2400,
|
||||||
),
|
),
|
||||||
|
"tls": ModuleResult(
|
||||||
|
name="tls",
|
||||||
|
status="ok",
|
||||||
|
data=[
|
||||||
|
TlsResult(
|
||||||
|
label="cloudflare",
|
||||||
|
host="cloudflare.com",
|
||||||
|
port=443,
|
||||||
|
resolved_ip="104.16.132.229",
|
||||||
|
tcp_rtt_ms=12.0,
|
||||||
|
tls_handshake_ms=45.0,
|
||||||
|
ttfb_ms=60.0,
|
||||||
|
tls_version="TLSv1.3",
|
||||||
|
cipher="TLS_AES_256_GCM_SHA384",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
"path": ModuleResult(
|
"path": ModuleResult(
|
||||||
name="path",
|
name="path",
|
||||||
status="ok",
|
status="ok",
|
||||||
|
|
@ -400,6 +447,61 @@ def full_modules() -> dict[str, ModuleResult]:
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
"path_diversity": ModuleResult(
|
||||||
|
name="path_diversity",
|
||||||
|
status="ok",
|
||||||
|
data=PathDiversity(
|
||||||
|
client_country="RU",
|
||||||
|
hops=[
|
||||||
|
AnycastHop(
|
||||||
|
target="cloudflare.com",
|
||||||
|
resolved_ip="104.16.132.229",
|
||||||
|
edge_colo="FRA",
|
||||||
|
edge_city="Frankfurt",
|
||||||
|
edge_country="DE",
|
||||||
|
edge_rtt_ms=15.0,
|
||||||
|
client_rtt_ms=45.0,
|
||||||
|
source="cf_ray",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
international_loop=True,
|
||||||
|
detour_countries=["DE"],
|
||||||
|
note="Anycast routed traffic through DE even though the client is in RU.",
|
||||||
|
note_ru="Anycast завернул трафик через DE, хотя клиент находится в RU.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"prefix_benchmark": ModuleResult(
|
||||||
|
name="prefix_benchmark",
|
||||||
|
status="ok",
|
||||||
|
data=PrefixBenchmark(
|
||||||
|
asn="AS64500",
|
||||||
|
prefixes_announced=50,
|
||||||
|
prefixes_probed=2,
|
||||||
|
method="icmp",
|
||||||
|
results=[
|
||||||
|
PrefixProbe(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=12.0, loss_pct=0.0, reachable=True),
|
||||||
|
PrefixProbe(prefix="198.51.100.0/24", probe_ip="198.51.100.1", avg_ms=None, loss_pct=100.0, reachable=False),
|
||||||
|
],
|
||||||
|
best="203.0.113.0/24",
|
||||||
|
worst="203.0.113.0/24",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"dpi_check": ModuleResult(
|
||||||
|
name="dpi_check",
|
||||||
|
status="ok",
|
||||||
|
data=DpiCheckResult(
|
||||||
|
target="203.0.113.9",
|
||||||
|
resolved_ip="203.0.113.9",
|
||||||
|
consented=True,
|
||||||
|
ports=[
|
||||||
|
PortProbe(port=443, state="open", rtt_ms=20.0),
|
||||||
|
PortProbe(port=8443, state="filtered"),
|
||||||
|
],
|
||||||
|
verdict="partial_filtering",
|
||||||
|
rationale="Some ports responded (open) while others were silently dropped (filtered).",
|
||||||
|
rationale_ru="Часть портов ответила (открыты), а часть тихо не ответила (отфильтрованы).",
|
||||||
|
),
|
||||||
|
),
|
||||||
"speed": ModuleResult(
|
"speed": ModuleResult(
|
||||||
name="speed",
|
name="speed",
|
||||||
status="ok",
|
status="ok",
|
||||||
|
|
@ -492,8 +594,13 @@ def test_markdown_has_every_section_from_the_spec_in_order():
|
||||||
"## VPN / proxy assessment",
|
"## VPN / proxy assessment",
|
||||||
"## ASN & BGP intelligence",
|
"## ASN & BGP intelligence",
|
||||||
"## Reputation",
|
"## Reputation",
|
||||||
|
"## DNS: system vs DoH",
|
||||||
"## Latency",
|
"## Latency",
|
||||||
|
"## TLS handshake",
|
||||||
"## Path",
|
"## Path",
|
||||||
|
"## Path diversity (Anycast)",
|
||||||
|
"## AS prefix benchmark",
|
||||||
|
"## DPI / port check",
|
||||||
"## Speed",
|
"## Speed",
|
||||||
"## Problems & recommendations",
|
"## Problems & recommendations",
|
||||||
"## Run diagnostics",
|
"## Run diagnostics",
|
||||||
|
|
@ -693,8 +800,8 @@ def test_diagnostics_translates_module_warnings_into_russian():
|
||||||
def test_an_all_modules_failed_run_still_renders_every_section():
|
def test_an_all_modules_failed_run_still_renders_every_section():
|
||||||
text = render_markdown(dead_report())
|
text = render_markdown(dead_report())
|
||||||
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||||||
assert len(headings) == 10
|
assert len(headings) == 15
|
||||||
assert text.count("Not available") == 8
|
assert text.count("Not available") == len(SECTION_ORDER)
|
||||||
|
|
||||||
|
|
||||||
def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
||||||
|
|
@ -794,8 +901,13 @@ def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_featu
|
||||||
"## VPN / proxy assessment",
|
"## VPN / proxy assessment",
|
||||||
"## ASN & BGP intelligence",
|
"## ASN & BGP intelligence",
|
||||||
"## Reputation",
|
"## Reputation",
|
||||||
|
"## DNS: system vs DoH",
|
||||||
"## Latency",
|
"## Latency",
|
||||||
|
"## TLS handshake",
|
||||||
"## Path",
|
"## Path",
|
||||||
|
"## Path diversity (Anycast)",
|
||||||
|
"## AS prefix benchmark",
|
||||||
|
"## DPI / port check",
|
||||||
"## Speed",
|
"## Speed",
|
||||||
"## Problems & recommendations",
|
"## Problems & recommendations",
|
||||||
"## Run diagnostics",
|
"## Run diagnostics",
|
||||||
|
|
|
||||||
212
tests/test_exporter_l7.py
Normal file
212
tests/test_exporter_l7.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from netsleuth.exporter import (
|
||||||
|
SECTION_ORDER,
|
||||||
|
_dns_advanced,
|
||||||
|
_dpi_check,
|
||||||
|
_path_diversity,
|
||||||
|
_prefix_benchmark,
|
||||||
|
_section_title,
|
||||||
|
_tls,
|
||||||
|
build_report,
|
||||||
|
dump_json,
|
||||||
|
)
|
||||||
|
from netsleuth.models import (
|
||||||
|
AnycastHop,
|
||||||
|
DnsAdvanced,
|
||||||
|
DpiCheckResult,
|
||||||
|
ModuleResult,
|
||||||
|
PathDiversity,
|
||||||
|
PortProbe,
|
||||||
|
PrefixBenchmark,
|
||||||
|
PrefixProbe,
|
||||||
|
ResolverProbe,
|
||||||
|
TlsResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def meta() -> dict:
|
||||||
|
return {
|
||||||
|
"run_id": "b7f1",
|
||||||
|
"started_at": "2026-08-08T19:12:00Z",
|
||||||
|
"finished_at": "2026-08-08T19:13:04Z",
|
||||||
|
"mode": "auto",
|
||||||
|
"target": None,
|
||||||
|
"flags": {},
|
||||||
|
"host_os": "Windows",
|
||||||
|
"capabilities": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_section_in_order_has_a_title_in_both_languages():
|
||||||
|
for section in SECTION_ORDER:
|
||||||
|
assert _section_title(section, "en").startswith("## ")
|
||||||
|
assert _section_title(section, "ru").startswith("## ")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("section", "renderer"),
|
||||||
|
[
|
||||||
|
("tls", _tls),
|
||||||
|
("dns_advanced", _dns_advanced),
|
||||||
|
("path_diversity", _path_diversity),
|
||||||
|
("prefix_benchmark", _prefix_benchmark),
|
||||||
|
("dpi_check", _dpi_check),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_new_l7_section_shows_a_placeholder_when_skipped(section, renderer):
|
||||||
|
report = build_report(meta(), {section: ModuleResult(name=section, status="skipped")}, [], {})
|
||||||
|
lines = renderer(report, "en")
|
||||||
|
assert any("Not available" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_section_renders_measurements_and_the_handshake_footnote():
|
||||||
|
report = build_report(
|
||||||
|
meta(),
|
||||||
|
{
|
||||||
|
"tls": ModuleResult(
|
||||||
|
name="tls",
|
||||||
|
status="ok",
|
||||||
|
data=[
|
||||||
|
TlsResult(
|
||||||
|
label="cloudflare",
|
||||||
|
host="cloudflare.com",
|
||||||
|
resolved_ip="104.16.132.229",
|
||||||
|
tcp_rtt_ms=12.0,
|
||||||
|
tls_handshake_ms=45.0,
|
||||||
|
ttfb_ms=60.0,
|
||||||
|
tls_version="TLSv1.3",
|
||||||
|
cipher="TLS_AES_256_GCM_SHA384",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
text = "\n".join(_tls(report, "en"))
|
||||||
|
assert "104.16.132.229" in text
|
||||||
|
assert "45" in text
|
||||||
|
assert "TLSv1.3" in text
|
||||||
|
assert "subtracting" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_section_lists_errored_targets_separately():
|
||||||
|
report = build_report(
|
||||||
|
meta(),
|
||||||
|
{"tls": ModuleResult(name="tls", status="partial", data=[TlsResult(label="dead", host="dead.test", error="timeout")])},
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
text = "\n".join(_tls(report, "en"))
|
||||||
|
assert "dead" in text
|
||||||
|
assert "timeout" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_advanced_section_renders_probes_and_transparent_proxy_verdict():
|
||||||
|
adv = DnsAdvanced(
|
||||||
|
probes=[ResolverProbe(name="system", kind="system", query_name="example.com", answers=["1.2.3.4"], elapsed_ms=15.0)],
|
||||||
|
transparent_proxy=True,
|
||||||
|
transparent_proxy_detail="A response was received from the bogus resolver IP: transparent DNS proxy detected.",
|
||||||
|
note="proxy present",
|
||||||
|
note_ru="прокси обнаружен",
|
||||||
|
)
|
||||||
|
report = build_report(meta(), {"dns_advanced": ModuleResult(name="dns_advanced", status="ok", data=adv)}, [], {})
|
||||||
|
text = "\n".join(_dns_advanced(report, "en"))
|
||||||
|
assert "example.com" in text
|
||||||
|
assert "1.2.3.4" in text
|
||||||
|
assert "Transparent DNS proxy" in text
|
||||||
|
assert "detected" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_advanced_section_in_russian_translates_the_note():
|
||||||
|
adv = DnsAdvanced(transparent_proxy=False, note="clean", note_ru="чисто")
|
||||||
|
report = build_report(meta(), {"dns_advanced": ModuleResult(name="dns_advanced", status="ok", data=adv)}, [], {})
|
||||||
|
text = "\n".join(_dns_advanced(report, "ru"))
|
||||||
|
assert "чисто" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_diversity_section_renders_hops_and_loop_verdict():
|
||||||
|
pd = PathDiversity(
|
||||||
|
client_country="RU",
|
||||||
|
hops=[
|
||||||
|
AnycastHop(
|
||||||
|
target="cloudflare.com",
|
||||||
|
resolved_ip="104.16.132.229",
|
||||||
|
edge_colo="FRA",
|
||||||
|
edge_city="Frankfurt",
|
||||||
|
edge_country="DE",
|
||||||
|
edge_rtt_ms=10.0,
|
||||||
|
client_rtt_ms=80.0,
|
||||||
|
source="cf_ray",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
international_loop=True,
|
||||||
|
detour_countries=["DE"],
|
||||||
|
note="Anycast routed traffic through DE.",
|
||||||
|
note_ru="Anycast завернул трафик через DE.",
|
||||||
|
)
|
||||||
|
report = build_report(meta(), {"path_diversity": ModuleResult(name="path_diversity", status="ok", data=pd)}, [], {})
|
||||||
|
text = "\n".join(_path_diversity(report, "en"))
|
||||||
|
assert "cloudflare.com" in text
|
||||||
|
assert "FRA" in text
|
||||||
|
assert "Frankfurt" in text
|
||||||
|
assert "International routing loop" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_benchmark_section_renders_results_and_summary():
|
||||||
|
bench = PrefixBenchmark(
|
||||||
|
asn="AS64500",
|
||||||
|
prefixes_announced=50,
|
||||||
|
prefixes_probed=2,
|
||||||
|
method="icmp",
|
||||||
|
results=[
|
||||||
|
PrefixProbe(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=12.0, loss_pct=0.0, reachable=True),
|
||||||
|
PrefixProbe(prefix="198.51.100.0/24", probe_ip="198.51.100.1", avg_ms=None, loss_pct=100.0, reachable=False),
|
||||||
|
],
|
||||||
|
best="203.0.113.0/24",
|
||||||
|
worst="203.0.113.0/24",
|
||||||
|
spread_ms=None,
|
||||||
|
)
|
||||||
|
report = build_report(meta(), {"prefix_benchmark": ModuleResult(name="prefix_benchmark", status="ok", data=bench)}, [], {})
|
||||||
|
text = "\n".join(_prefix_benchmark(report, "en"))
|
||||||
|
assert "203.0.113.0/24" in text
|
||||||
|
assert "198.51.100.0/24" in text
|
||||||
|
assert "50" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpi_check_section_renders_ports_and_verdict():
|
||||||
|
dpi = DpiCheckResult(
|
||||||
|
target="203.0.113.9",
|
||||||
|
resolved_ip="203.0.113.9",
|
||||||
|
consented=True,
|
||||||
|
ports=[PortProbe(port=443, state="open", rtt_ms=20.0), PortProbe(port=8443, state="filtered")],
|
||||||
|
verdict="partial_filtering",
|
||||||
|
rationale="Some ports responded while others were dropped.",
|
||||||
|
rationale_ru="Часть портов ответила, часть тихо не ответила.",
|
||||||
|
)
|
||||||
|
report = build_report(meta(), {"dpi_check": ModuleResult(name="dpi_check", status="ok", data=dpi)}, [], {})
|
||||||
|
text = "\n".join(_dpi_check(report, "en"))
|
||||||
|
assert "443" in text
|
||||||
|
assert "open" in text
|
||||||
|
assert "8443" in text
|
||||||
|
assert "filtered" in text
|
||||||
|
assert "Some ports responded" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_report_with_every_new_section_serializes_with_allow_nan_false():
|
||||||
|
modules = {
|
||||||
|
"tls": ModuleResult(name="tls", status="ok", data=[TlsResult(label="cf", host="cloudflare.com")]),
|
||||||
|
"prefix_benchmark": ModuleResult(name="prefix_benchmark", status="ok", data=PrefixBenchmark()),
|
||||||
|
"dpi_check": ModuleResult(name="dpi_check", status="ok", data=DpiCheckResult()),
|
||||||
|
"dns_advanced": ModuleResult(name="dns_advanced", status="ok", data=DnsAdvanced()),
|
||||||
|
"path_diversity": ModuleResult(name="path_diversity", status="ok", data=PathDiversity()),
|
||||||
|
}
|
||||||
|
report = build_report(meta(), modules, [], {})
|
||||||
|
text = dump_json(report)
|
||||||
|
back = json.loads(text)
|
||||||
|
assert back["tls"]["data"][0]["host"] == "cloudflare.com"
|
||||||
Loading…
Add table
Reference in a new issue