diff --git a/src/netsleuth/interpret.py b/src/netsleuth/interpret.py index c9b12f8..f2bc040 100644 --- a/src/netsleuth/interpret.py +++ b/src/netsleuth/interpret.py @@ -49,13 +49,13 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: id=f"latency.unreachable.{p.label}", severity="crit", title=f"{p.host} did not answer", - detail=f"{p.sent} probes sent via {p.method}, none returned.", + detail=f"{len(p.samples)} probes sent via {p.method}, none returned.", metric="received", value=0, threshold=1, advice="If every reference host is unreachable the link is down or filtering the probe method.", title_ru=f"{p.host} не отвечает", - detail_ru=f"{p.sent} проб отправлено через {p.method}, ни одна не вернулась.", + detail_ru=f"{len(p.samples)} проб отправлено через {p.method}, ни одна не вернулась.", advice_ru="Если все эталонные хосты недоступны, значит канал не работает или фильтрует метод пробы.", ) ) @@ -103,13 +103,13 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: id=f"latency.loss.{p.label}", severity=severity, title=f"Loss to {p.host}", - detail=f"{p.loss_pct}% {kind} over {p.sent} probes via {p.method}.", + detail=f"{p.loss_pct}% {kind} over {len(p.samples)} probes via {p.method}.", metric="loss_pct", value=p.loss_pct, threshold=t.loss_pct.warn, advice="Sustained loss on every host points at the local link or the first upstream hop.", title_ru=f"Потери до {p.host}", - detail_ru=f"{p.loss_pct}% {kind_ru} за {p.sent} проб через {p.method}.", + detail_ru=f"{p.loss_pct}% {kind_ru} за {len(p.samples)} проб через {p.method}.", advice_ru="Устойчивые потери на всех хостах указывают на локальный канал или первый аплинк.", ) ) @@ -121,8 +121,16 @@ def _first_hop_findings(hop: TraceHop, local: LocalNet, t: Thresholds) -> list[F 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})" + 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( @@ -228,7 +236,9 @@ def dual_family_findings(pings: list[PingResult]) -> list[Finding]: return findings -def path_findings(trace: TraceResult, local: LocalNet | None = None, t: Thresholds | None = None) -> list[Finding]: +def path_findings( + trace: TraceResult, local: LocalNet | None = None, t: Thresholds | None = None +) -> list[Finding]: if not trace.hops: return [ Finding( @@ -250,7 +260,11 @@ def path_findings(trace: TraceResult, local: LocalNet | None = None, t: Threshol # in a row stayed silent; only loss that persists all the way to the end is real. loss_persists_to_the_end = trace.hops[-1].loss_pct >= 20.0 for hop, following in zip(trace.hops, trace.hops[1:]): - if loss_persists_to_the_end and hop.loss_pct >= 20.0 and following.loss_pct >= 20.0: + if ( + loss_persists_to_the_end + and hop.loss_pct >= 20.0 + and following.loss_pct >= 20.0 + ): findings.append( Finding( id="path.loss_jump", @@ -323,7 +337,9 @@ def cgnat_findings(local: LocalNet, traces: list[TraceResult]) -> list[Finding]: 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" + evidence = ( + f"traceroute hop {hop.ttl} ({hop.ip}) is in 100.64.0.0/10" + ) break if detected: break @@ -340,7 +356,8 @@ def cgnat_findings(local: LocalNet, traces: list[TraceResult]) -> list[Finding]: 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).", + detail_ru=evidence + or "Обнаружен адрес из диапазона 100.64.0.0/10 (RFC 6598).", advice_ru="Проброс входящих портов невозможен; P2P и хостинг игр/серверов могут не работать. " "Запросите у провайдера публичный IPv4-адрес либо используйте IPv6, если целевой сервис его поддерживает.", ) @@ -533,7 +550,11 @@ def path_asn_findings(trace: TraceResult, client_country: str | None) -> list[Fi final_country = trace.hops[-1].country run: list[TraceHop] = [] for hop in trace.hops: - detour = bool(hop.country) and hop.country != client_country and hop.country != final_country + detour = ( + bool(hop.country) + and hop.country != client_country + and hop.country != final_country + ) if not detour: run = [] continue @@ -584,7 +605,13 @@ _TZ_COUNTRY_PREFIX = { def _signal(name: str, observed: bool, note: str = "") -> Signal: - return Signal(name=name, observed=observed, weight=SIGNAL_WEIGHTS[name], direction="vpn", note=note) + return Signal( + name=name, + observed=observed, + weight=SIGNAL_WEIGHTS[name], + direction="vpn", + note=note, + ) def gather_vpn_signals(ctx: VpnContext) -> list[Signal]: @@ -598,7 +625,11 @@ def gather_vpn_signals(ctx: VpnContext) -> list[Signal]: tz_country = _TZ_COUNTRY_PREFIX.get(ctx.os_timezone or "") return [ _signal("tunnel_iface", is_tunnel_iface(iface), iface), - _signal("cf_warp", bool(ctx.cf and (ctx.cf.warp or "").lower() == "on"), (ctx.cf.warp if ctx.cf else "") or ""), + _signal( + "cf_warp", + bool(ctx.cf and (ctx.cf.warp or "").lower() == "on"), + (ctx.cf.warp if ctx.cf else "") or "", + ), _signal("provider_proxy", bool(ctx.provider_flags.get("proxy"))), _signal("provider_hosting", bool(ctx.provider_flags.get("hosting"))), _signal("provider_mobile", bool(ctx.provider_flags.get("mobile"))), @@ -613,10 +644,18 @@ def gather_vpn_signals(ctx: VpnContext) -> list[Signal]: bool(ctx.local.default_gateway_v4 and is_tunnel_iface(iface)), ctx.local.default_gateway_v4 or "", ), - _signal("pdb_info_type_nsp", (ctx.pdb_info_type or "").upper() in ("NSP", "CONTENT", "ENTERPRISE"), ctx.pdb_info_type or ""), + _signal( + "pdb_info_type_nsp", + (ctx.pdb_info_type or "").upper() in ("NSP", "CONTENT", "ENTERPRISE"), + ctx.pdb_info_type or "", + ), _signal( "timezone_mismatch", - bool(tz_country and ctx.geo.country_code and tz_country != ctx.geo.country_code), + bool( + tz_country + and ctx.geo.country_code + and tz_country != ctx.geo.country_code + ), f"{ctx.os_timezone} vs {ctx.geo.country_code}" if tz_country else "", ), ] @@ -670,14 +709,28 @@ _CONSEQUENCES_RU = { "F": "Одна закачка делает звонок непригодным; это раздутие очереди, а не медленный канал.", "?": "Не измерено — уровень speedtest-каскада, который это измеряет, не запускался.", } -_GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"} +_GRADE_SEVERITY = { + "A": "ok", + "B": "ok", + "C": "warn", + "D": "crit", + "E": "crit", + "F": "crit", + "?": "info", +} def grade_bufferbloat(delta_ms: float | None, bands: BufferbloatBands) -> str: if delta_ms is None: return "?" delta = max(0.0, delta_ms) - for grade, ceiling in (("A", bands.a), ("B", bands.b), ("C", bands.c), ("D", bands.d), ("E", bands.e)): + for grade, ceiling in ( + ("A", bands.a), + ("B", bands.b), + ("C", bands.c), + ("D", bands.d), + ("E", bands.e), + ): if delta <= ceiling: return grade return "F" @@ -688,7 +741,9 @@ def bufferbloat_consequence(grade: str, lang: str = "en") -> str: return table.get(grade, table["?"]) -def speed_findings(speed: SpeedResult, bands: BufferbloatBands, client_country: str | None = None) -> list[Finding]: +def speed_findings( + speed: SpeedResult, bands: BufferbloatBands, client_country: str | None = None +) -> list[Finding]: if speed.method == "none": tried = ", ".join(a.tier for a in speed.tier_attempts) or "none" return [ @@ -729,7 +784,11 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands, client_country: + " Включение SQM/fq_codel на роутере — стандартное решение.", ) ) - if client_country and speed.server_country and client_country.casefold() != speed.server_country.casefold(): + if ( + client_country + and speed.server_country + and client_country.casefold() != speed.server_country.casefold() + ): findings.append( Finding( id="speed.server_other_country", @@ -749,7 +808,9 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands, client_country: return findings -def _cpu_bound_ratio(tls_handshake_ms: float | None, tcp_rtt_ms: float | None) -> float | None: +def _cpu_bound_ratio( + tls_handshake_ms: float | None, tcp_rtt_ms: float | None +) -> float | None: if tls_handshake_ms is None or not tcp_rtt_ms: return None return tls_handshake_ms / tcp_rtt_ms @@ -775,7 +836,11 @@ def _check_tls_error(r: TlsResult) -> Finding | None: def _check_tls_performance(r: TlsResult, t: Thresholds) -> list[Finding]: findings: list[Finding] = [] ratio = _cpu_bound_ratio(r.tls_handshake_ms, r.tcp_rtt_ms) - if ratio is not None and ratio > t.tls_cpu_bound_ratio and (r.tls_handshake_ms or 0.0) >= 20.0: + if ( + ratio is not None + and ratio > t.tls_cpu_bound_ratio + and (r.tls_handshake_ms or 0.0) >= 20.0 + ): findings.append( Finding( id=f"tls.server_cpu_bound.{r.label}", @@ -937,9 +1002,21 @@ def prefix_findings(bench: PrefixBenchmark, t: Thresholds) -> list[Finding]: _DPI_TEXT = { - "reset_injection": ("crit", "Possible active DPI interference on {t}", "Возможное активное вмешательство DPI на {t}"), - "partial_filtering": ("warn", "Selective port filtering detected on {t}", "Обнаружена выборочная фильтрация портов на {t}"), - "unreachable": ("info", "{t} did not respond on any probed port", "{t} не ответил ни на одном проверенном порту"), + "reset_injection": ( + "crit", + "Possible active DPI interference on {t}", + "Возможное активное вмешательство DPI на {t}", + ), + "partial_filtering": ( + "warn", + "Selective port filtering detected on {t}", + "Обнаружена выборочная фильтрация портов на {t}", + ), + "unreachable": ( + "info", + "{t} did not respond on any probed port", + "{t} не ответил ни на одном проверенном порту", + ), } diff --git a/src/netsleuth/models.py b/src/netsleuth/models.py index 0e502ae..44940b0 100644 --- a/src/netsleuth/models.py +++ b/src/netsleuth/models.py @@ -265,7 +265,6 @@ class PingResult: host: str resolved_ip: str | None = None method: str = "none" - sent: int = 0 received: int = 0 loss_pct: float = 0.0 min_ms: float | None = None diff --git a/src/netsleuth/probes/latency.py b/src/netsleuth/probes/latency.py index 1927d24..47f8c73 100644 --- a/src/netsleuth/probes/latency.py +++ b/src/netsleuth/probes/latency.py @@ -23,7 +23,6 @@ def summarize_ping( host=host, resolved_ip=resolved_ip, method=method, - sent=s.sent, received=s.received, loss_pct=s.loss_pct, min_ms=s.min_ms, diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 649fd58..618820b 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -61,7 +61,7 @@ def modules() -> dict[str, ModuleResult]: "latency": ModuleResult( name="latency", status="partial", - data=[PingResult(label="cloudflare-dns", host="1.1.1.1", method="icmp_win", sent=5, received=5)], + data=[PingResult(label="cloudflare-dns", host="1.1.1.1", method="icmp_win", received=5, samples=[12.0]*5)], errors=[ProbeError(source="quad9-dns", kind="timeout", message="2s", retryable=True)], warnings=["quad9 did not answer"], duration_ms=1500, @@ -420,7 +420,6 @@ def full_modules() -> dict[str, ModuleResult]: label="cloudflare-dns", host="1.1.1.1", method="icmp_win", - sent=5, received=5, avg_ms=12.4, min_ms=11.0, @@ -428,7 +427,7 @@ def full_modules() -> dict[str, ModuleResult]: jitter_ms=1.9, samples=[11.0, 12.0, None, 15.1, 12.4], ), - PingResult(label="github", host="github.com", method="tcp", sent=5, received=4, loss_pct=20.0, avg_ms=42.0), + PingResult(label="github", host="github.com", method="tcp", received=4, loss_pct=20.0, avg_ms=42.0, samples=[42.0, 42.0, 42.0, 42.0, None]), ], duration_ms=2400, ), diff --git a/tests/test_interpret.py b/tests/test_interpret.py index d49520a..b70e7f3 100644 --- a/tests/test_interpret.py +++ b/tests/test_interpret.py @@ -44,7 +44,6 @@ def ping(**kw) -> PingResult: host="1.1.1.1", resolved_ip="1.1.1.1", method="icmp_dgram", - sent=20, received=20, loss_pct=0.0, min_ms=10.0, diff --git a/tests/test_latency.py b/tests/test_latency.py index d51dcfa..d5e6427 100644 --- a/tests/test_latency.py +++ b/tests/test_latency.py @@ -19,7 +19,7 @@ def test_summarize_ping_calculates_correct_stats(): assert result.host == "host.com" assert result.resolved_ip == "1.1.1.1" assert result.method == "tcp" - assert result.sent == 4 + assert len(result.samples) == 4 assert result.received == 4 assert result.loss_pct == 0.0 assert result.min_ms == 10.0 diff --git a/tests/test_models.py b/tests/test_models.py index e04b2cb..11425d9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -156,7 +156,6 @@ def test_ping_result_round_trips_through_json(): host="1.1.1.1", resolved_ip="1.1.1.1", method="icmp_win", - sent=20, received=20, loss_pct=0.0, min_ms=11.0, diff --git a/tests/test_prefix_benchmark.py b/tests/test_prefix_benchmark.py index e3d560c..23f1123 100644 --- a/tests/test_prefix_benchmark.py +++ b/tests/test_prefix_benchmark.py @@ -129,10 +129,10 @@ async def test_benchmark_prefixes_probes_selected_hosts_with_mocked_ping_host(mo host=host, resolved_ip=host, method="tcp", - sent=count, received=count, loss_pct=0.0, avg_ms=10.0, + samples=[10.0] * count, ) monkeypatch.setattr("netsleuth.probes.prefix_benchmark.ping_host", fake_ping_host) diff --git a/tests/test_prober_stats.py b/tests/test_prober_stats.py index 6849419..306deee 100644 --- a/tests/test_prober_stats.py +++ b/tests/test_prober_stats.py @@ -17,7 +17,7 @@ def test_summarize_builds_a_complete_ping_result(): assert result.host == "1.1.1.1" assert result.resolved_ip == "1.1.1.1" assert result.method == "icmp_dgram" - assert result.sent == 4 + assert len(result.samples) == 4 assert result.received == 4 assert result.loss_pct == 0.0 assert result.min_ms == 10.0 @@ -31,7 +31,7 @@ def test_summarize_builds_a_complete_ping_result(): def test_summarize_keeps_timeout_positions_in_the_sample_list(): result = summarize_ping("h", "example.test", None, "tcp", [None, 20.0, None, 24.0]) assert result.samples == [None, 20.0, None, 24.0] - assert result.sent == 4 + assert len(result.samples) == 4 assert result.received == 2 assert result.loss_pct == 50.0 @@ -53,7 +53,7 @@ def test_summarize_of_a_single_sample_reports_zero_jitter(): def test_summarize_of_zero_probes_is_a_well_formed_empty_result(): result = summarize_ping("h", "1.1.1.1", None, "none", []) - assert result.sent == 0 + assert len(result.samples) == 0 assert result.received == 0 assert result.loss_pct == 0.0 assert result.samples == [] diff --git a/tests/test_watch.py b/tests/test_watch.py index 16744a7..c063681 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -23,11 +23,11 @@ def ping(label: str, avg: float | None, loss: float = 0.0) -> PingResult: host="1.1.1.1", resolved_ip="1.1.1.1", method="icmp_win", - sent=5, received=5 if avg is not None else 0, loss_pct=loss, avg_ms=avg, jitter_ms=1.5 if avg is not None else None, + samples=[avg] * 5 if avg is not None else [None] * 5, )