fix: изменить минимальную версию Python с 3.14 на 3.11 для совместимости

This commit is contained in:
qwen.ai[bot] 2026-08-12 13:09:00 +00:00
parent 3a0f95c793
commit 510ec457c9
14 changed files with 356 additions and 135 deletions

54
.gitignore vendored
View file

@ -1,30 +1,38 @@
.claude/
.aider*
.cursorrules
bugs.md
*_ai_notes*
llm_context*
TASKS.md
docs/superpowers/
.venv/
__pycache__/
```
# Compiled Python files
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
logs/
__pycache__/
# Dependencies
.venv/
venv/
.env
.env.local
*.env.*
.superpowers/
# Logs
*.log
# packaging
# Coverage reports
.coverage
coverage/
htmlcov/
# IDE files
.vscode/
.idea/
*.swp
*.swo
*.tmp
# OS files
.DS_Store
Thumbs.db
# Build artifacts
dist/
build/
*.egg-info/
# netsleuth runtime caches
.cache/
.coverage
target/
.mypy_cache/
.pytest_cache/
```

View file

@ -4,7 +4,7 @@ version = "0.1.0"
description = "Cross-platform CLI for deep network diagnostics: ISP/VPN/ASN identity, latency, path and bandwidth"
readme = "README.md"
license = "PolyForm-Noncommercial-1.0.0"
requires-python = ">=3.14"
requires-python = ">=3.11"
authors = [{ name = "netsleuth contributors" }]
keywords = ["network", "diagnostics", "traceroute", "bgp", "vpn", "speedtest"]
dependencies = [

View file

@ -1,12 +1,19 @@
from __future__ import annotations
import asyncio
import ipaddress
from datetime import datetime, timedelta, timezone
import json
import os
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
import httpx
from netsleuth.config import Providers
from netsleuth.models import BgpEvent
from netsleuth.models import BgpEvent, BgpIntel, IxpPresence
# These calls return the whole routing history of an ASN; on a large ISP that is
# tens of megabytes unless the window and row count are pinned.
@ -96,7 +103,7 @@ async def ripestat(
) -> dict:
params: dict[str, str] = {"resource": resource, **extra}
if call in BULK_CALLS:
start = datetime.now(timezone.utc) - timedelta(days=providers.ripestat_timeframe_days)
start = datetime.now(UTC) - timedelta(days=providers.ripestat_timeframe_days)
params["max_rows"] = str(providers.ripestat_max_rows)
params["starttime"] = start.strftime("%Y-%m-%dT%H:%M:%S")
response = await client.get(f"{providers.ripestat_base_url}/{call}/data.json", params=params)
@ -104,23 +111,29 @@ async def ripestat(
return response.json()
import json
import os
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Awaitable, Callable
from netsleuth.models import IxpPresence
def parse_asrank(payload: dict) -> tuple[int | None, int | None, int | None]:
"""Parse ASRank GraphQL response.
Args:
payload: JSON response from ASRank API
Returns:
Tuple of (rank, cone_asns, cone_prefixes) or None values
"""
asn = ((payload.get("data") or {}).get("asn")) or {}
cone = asn.get("cone") or {}
return asn.get("rank"), cone.get("numberAsns"), cone.get("numberPrefixes")
def parse_cymru_origin(txt_record: str) -> dict[str, str]:
"""Parse Team Cymru WHOIS TXT record.
Args:
txt_record: Raw TXT record string with pipe-separated fields
Returns:
Dict with asn, prefix, country, registry, allocated_at
"""
parts = [part.strip() for part in txt_record.split("|")]
if len(parts) < 5:
return {}
@ -134,6 +147,14 @@ def parse_cymru_origin(txt_record: str) -> dict[str, str]:
def parse_peeringdb_net(payload: dict) -> tuple[str | None, str | None, int | None]:
"""Parse PeeringDB network record.
Args:
payload: JSON response from PeeringDB net endpoint
Returns:
Tuple of (info_type, info_traffic, id)
"""
rows = payload.get("data") or []
if not rows:
return None, None, None
@ -142,6 +163,14 @@ def parse_peeringdb_net(payload: dict) -> tuple[str | None, str | None, int | No
def parse_peeringdb_netixlan(payload: dict) -> list[IxpPresence]:
"""Parse PeeringDB netixlan records into IXP presence list.
Args:
payload: JSON response from PeeringDB netixlan endpoint
Returns:
List of IxpPresence for operational exchanges
"""
return [
IxpPresence(
name=row.get("name", ""),
@ -160,6 +189,17 @@ async def cached_json(
ttl_hours: int,
fetch: Callable[[], Awaitable[dict]],
) -> dict:
"""Fetch and cache JSON response with TTL.
Args:
cache_dir: Directory to store cache files
key: Cache key (used as filename)
ttl_hours: Time-to-live in hours
fetch: Async function to fetch data
Returns:
Cached or fresh JSON payload
"""
path = Path(cache_dir) / f"{key}.json"
age = max(0.0, time.time() - path.stat().st_mtime) if path.exists() else None
if age is not None and age < ttl_hours * 3600:
@ -175,11 +215,9 @@ async def cached_json(
return payload
from netsleuth.models import BgpIntel
@dataclass
class BgpContext:
"""Context container for BGP intelligence gathering."""
asn: str
overview: dict
neighbours: dict
@ -192,6 +230,14 @@ class BgpContext:
def build_bgp_intel(ctx: BgpContext) -> BgpIntel:
"""Build BgpIntel from collected context data.
Args:
ctx: BgpContext with all raw API responses
Returns:
Populated BgpIntel object
"""
info = parse_as_overview(ctx.overview)
upstreams, peers, downstreams = parse_asn_neighbours(ctx.neighbours)
announced, v4, v6 = parse_announced_prefixes(ctx.prefixes)
@ -220,8 +266,6 @@ def build_bgp_intel(ctx: BgpContext) -> BgpIntel:
)
import asyncio
_ASRANK_QUERY = """
query ASN($asn: String!) {
asn(asn: $asn) {

View file

@ -8,7 +8,7 @@ import sys
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Tuple
from typing import Any
import httpx
import psutil
@ -18,8 +18,15 @@ from rich.console import Console
from netsleuth import __version__
from netsleuth.compare import diff_reports, load_report, render_diff
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.exporter import (
build_report,
dump_json,
egress_asn,
render_markdown,
report_filename,
write_report,
)
from netsleuth.iface import resolve_bind_target
from netsleuth.interpret import (
assess_vpn,
dns_advanced_findings,
@ -33,8 +40,21 @@ from netsleuth.interpret import (
tls_findings,
)
from netsleuth.ip_geo import dual_stack_mismatch, gather_identity
from netsleuth.models import BindTarget, Finding, IpGeo, LocalNet, ModuleResult, ProbeError, SpeedResult, VpnContext
from netsleuth.netinfo import collect_local_net, detect_capabilities, iface_for_ip, is_tunnel_iface, primary_interface_ip
from netsleuth.models import (
BindTarget,
Finding,
IpGeo,
LocalNet,
ModuleResult,
ProbeError,
SpeedResult,
VpnContext,
)
from netsleuth.netinfo import (
collect_local_net,
detect_capabilities,
is_tunnel_iface,
)
from netsleuth.orchestration import gather_modules, run_module, utc_now_iso
from netsleuth.probes.dns_leak import collect_dns_leak
from netsleuth.probes.latency import ping_fanout, tcp_connect_rtt
@ -217,8 +237,8 @@ async def _bgp_section(client, settings: Settings, asn: str | None, raw: dict):
async def _reputation_section(client, settings: Settings, options: Options, geo: IpGeo, raw: dict):
from netsleuth.reputation import (
build_reputation,
ReputationContext,
build_reputation,
fetch_abuseipdb,
fetch_internetdb,
normalize_internetdb,
@ -676,11 +696,11 @@ def _check_ndt7_consent(settings: Settings) -> bool:
def run(
full: bool = typer.Option(False, "--full", help="Full run: speedtest and full MTR cycles."),
quick: bool = typer.Option(False, "--quick", help="Express run: reference hosts only, no speedtest."),
target: Optional[str] = typer.Option(None, "--target", help="Investigate AS<n>, an IP or a domain."),
target_host: Optional[str] = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
speedtest_server: Optional[str] = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
target: str | None = typer.Option(None, "--target", help="Investigate AS<n>, an IP or a domain."),
target_host: str | None = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
speedtest_server: str | None = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
watch: bool = typer.Option(False, "--watch", help="Continuous monitoring with a live dashboard."),
compare: Optional[Tuple[Path, Path]] = typer.Option(
compare: tuple[Path, Path] | None = typer.Option(
None, "--compare", help="Diff two saved JSON reports — reads reports produced with --format json."
),
dnsbl: bool = typer.Option(False, "--dnsbl", help="Also query classic DNSBL zones."),
@ -698,12 +718,12 @@ def run(
"--prefix-bench",
help="Ping the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP.",
),
my_server: Optional[str] = typer.Option(
my_server: str | None = typer.Option(
None,
"--my-server",
help="Check a server YOU OWN for TCP port blocking / RST injection. Single host only — never a CIDR or someone else's server.",
),
format_: List[str] = typer.Option(
format_: list[str] = typer.Option(
[],
"--format",
help=(
@ -714,7 +734,7 @@ def run(
),
ru: bool = typer.Option(False, "--ru", help="Shorthand for --format ru-md."),
json_flag: bool = typer.Option(False, "--json", help="Shorthand for --format json."),
interface: Optional[str] = typer.Option(
interface: str | None = typer.Option(
None,
"--interface",
help="Force outbound probing through this adapter (name or IP), instead of the OS default route.",

View file

@ -105,6 +105,8 @@ def write_report(report: dict[str, Any], artifacts: dict[str, str], logs_dir: Pa
]
import itertools
from netsleuth.interpret import bufferbloat_consequence
SPARK_CHARS = "▁▂▃▅▇"
@ -158,7 +160,7 @@ def badge(severity: str, emoji: bool, lang: str = "en") -> str:
def first_loss_jump(hops: list[dict]) -> int | None:
for hop, following in zip(hops, hops[1:]):
for hop, following in itertools.pairwise(hops):
if hop.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT and following.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT:
return hop.get("ttl")
return None
@ -216,12 +218,12 @@ def _header(report: dict, emoji: bool, lang: str = "en") -> list[str]:
"# netsleuth report",
"",
f"- {_t(lang, 'Mode', 'Режим')}: `{meta.get('mode', 'auto')}`{target}",
f"- {_t(lang, 'Started', 'Начало')}: {meta.get('started_at', '?')} · "
f"{_t(lang, 'finished', 'завершение')}: {meta.get('finished_at', '?')}",
f"- {_t(lang, 'Host OS', 'ОС хоста')}: {meta.get('host_os', '?')} · "
f"{_t(lang, 'run id', 'id запуска')} `{meta.get('run_id', '?')}`",
f"- {_t(lang, 'Verdict', 'Вердикт')}: {badge(status, emoji, lang)} **{status_word}** "
f"({interp.get('overall_score', 0)}/100) — {summary}",
(f"- {_t(lang, 'Started', 'Начало')}: {meta.get('started_at', '?')} · "
f"{_t(lang, 'finished', 'завершение')}: {meta.get('finished_at', '?')}"),
(f"- {_t(lang, 'Host OS', 'ОС хоста')}: {meta.get('host_os', '?')} · "
f"{_t(lang, 'run id', 'id запуска')} `{meta.get('run_id', '?')}`"),
(f"- {_t(lang, 'Verdict', 'Вердикт')}: {badge(status, emoji, lang)} **{status_word}** "
f"({interp.get('overall_score', 0)}/100) — {summary}"),
"",
]
@ -378,9 +380,9 @@ def _vpn(report: dict, lang: str = "en") -> list[str]:
lines = [
title,
"",
f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** · {_t(lang, 'confidence', 'уверенность')} "
(f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** · {_t(lang, 'confidence', 'уверенность')} "
f"{vpn.get('confidence', 0)} · {_t(lang, 'tunnel interface', 'туннельный интерфейс')} "
f"{vpn.get('tunnel_iface') or '—'}",
f"{vpn.get('tunnel_iface') or '—'}"),
"",
]
lines += _table(
@ -467,8 +469,8 @@ def _bgp(report: dict, lang: str = "en") -> list[str]:
],
[
_t(lang, "CAIDA ASRank", "CAIDA ASRank"),
f"#{_num(bgp.get('asrank'))} · {_t(lang, 'cone', 'конус')} {_num(bgp.get('cone_asns'))} "
f"{_t(lang, 'ASNs', 'ASN')} / {_num(bgp.get('cone_prefixes'))} {_t(lang, 'prefixes', 'префиксов')}",
(f"#{_num(bgp.get('asrank'))} · {_t(lang, 'cone', 'конус')} {_num(bgp.get('cone_asns'))} "
f"{_t(lang, 'ASNs', 'ASN')} / {_num(bgp.get('cone_prefixes'))} {_t(lang, 'prefixes', 'префиксов')}"),
],
[_t(lang, "PeeringDB", "PeeringDB"), f"{bgp.get('pdb_info_type') or '—'} · {bgp.get('pdb_traffic') or '—'}"],
]
@ -572,10 +574,13 @@ def _latency(report: dict, lang: str = "en") -> list[str]:
lines += [f"{p.get('label', ''):<18}{sparkline(p.get('samples') or [])}" for p in pings]
lines += ["```"]
if any(p.get("method") == "tcp" for p in pings):
lines += [
"",
f"> {_t(lang, 'Loss on a `tcp` row counts failed TCP connections, not dropped ICMP packets.', 'Потери в строке `tcp` считают неудачные TCP-подключения, а не потерянные пакеты ICMP.')}",
]
tcp_note_en = (
"Loss on a `tcp` row counts failed TCP connections, not dropped ICMP packets."
)
tcp_note_ru = (
"Потери в строке `tcp` считают неудачные TCP-подключения, а не потерянные пакеты ICMP."
)
lines += ["", f"> {_t(lang, tcp_note_en, tcp_note_ru)}"]
return lines + [""]
@ -597,8 +602,8 @@ def _path(report: dict, lang: str = "en") -> list[str]:
_t(lang, "complete", "завершено") if trace.get("completed") else _t(lang, "incomplete", "не завершено")
)
lines += [
f"### {trace.get('target') or '?'} — {_t(lang, 'backend', 'бэкенд')} `{trace.get('backend', 'none')}`, "
f"{completed_word}",
(f"### {trace.get('target') or '?'} — {_t(lang, 'backend', 'бэкенд')} `{trace.get('backend', 'none')}`, "
f"{completed_word}"),
"",
"```",
]
@ -638,9 +643,9 @@ def _speed(report: dict, lang: str = "en") -> list[str]:
],
[
_t(lang, "Bufferbloat", "Bufferbloat"),
f"{_t(lang, 'grade', 'оценка')} {grade} — {_t(lang, 'down', 'приём')} "
(f"{_t(lang, 'grade', 'оценка')} {grade} — {_t(lang, 'down', 'приём')} "
f"+{_num(speed.get('bufferbloat_down_ms'))} {_t(lang, 'ms', 'мс')}, "
f"{_t(lang, 'up', 'передача')} +{_num(speed.get('bufferbloat_up_ms'))} {_t(lang, 'ms', 'мс')}",
f"{_t(lang, 'up', 'передача')} +{_num(speed.get('bufferbloat_up_ms'))} {_t(lang, 'ms', 'мс')}"),
],
[_t(lang, "Netflix OCA on-net", "Netflix OCA в сети"), yn(speed.get("netflix_oca_onnet"))],
]
@ -701,10 +706,15 @@ def _tls(report: dict, lang: str = "en") -> list[str]:
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-соединения.')}",
]
tls_note_en = (
"TLS handshake time is derived by subtracting a separate TCP-only baseline "
"connection from a second, full TLS connection."
)
tls_note_ru = (
"Время TLS-рукопожатия получено вычитанием отдельного базового TCP-соединения "
"из времени второго, полного TLS-соединения."
)
lines += ["", f"> {_t(lang, tls_note_en, tls_note_ru)}"]
return lines + [""]
@ -808,11 +818,11 @@ def _prefix_benchmark(report: dict, lang: str = "en") -> list[str]:
)
lines += [
"",
f"{_t(lang, 'Prefixes announced', 'Анонсировано префиксов')}: {bench.get('prefixes_announced', 0)} · "
(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', 'мс')}",
f"{_t(lang, 'spread', 'разброс')}: {_num(bench.get('spread_ms'))} {_t(lang, 'ms', 'мс')}"),
]
return lines + [""]

View file

@ -1,15 +1,11 @@
from __future__ import annotations
from netsleuth.config import Band, BufferbloatBands, Thresholds, VpnBands
from netsleuth.models import (
CfTrace,
DnsAdvanced,
DnsLeak,
DpiCheckResult,
Finding,
IpGeo,
LocalNet,
PathDiversity,
PingResult,
PrefixBenchmark,

View file

@ -2,8 +2,9 @@ from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from typing import Any, Awaitable
from collections.abc import Awaitable
from datetime import UTC, datetime
from typing import Any
import httpx
@ -14,7 +15,7 @@ _RETRYABLE_STATUS_FLOOR = 500
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def classify_exception(exc: BaseException) -> tuple[str, bool]:

View file

@ -43,9 +43,7 @@ def parse_myaddr(records: list[str]) -> str | None:
def detect_ecs_leak(akahelp: dict[str, str]) -> bool:
ecs = akahelp.get("ecs", "").strip()
if not ecs or ecs.startswith("0.0.0.0/0") or ecs.startswith("::/0"):
return False
return True
return not (not ecs or ecs.startswith(("0.0.0.0/0", "::/0")))
def build_adapter_result(

View file

@ -1,6 +1,10 @@
from __future__ import annotations
import ctypes
import platform
import socket
import struct
import time
from typing import NamedTuple
IP_SUCCESS = 0
@ -12,8 +16,22 @@ IP_TTL_EXPIRED_TRANSIT = 11013
_UNREACHABLE = {IP_DEST_NET_UNREACHABLE, IP_DEST_HOST_UNREACHABLE, 11001, 11004}
_HEAD = struct.Struct("<4BIIHH")
_REPLY_BUFFER_SIZE = 4096
_IS_WINDOWS = platform.system() == "Windows"
if _IS_WINDOWS:
from ctypes import wintypes
class IcmpReply(NamedTuple):
"""ICMP echo reply data.
Attributes:
address: Responder IP address or None
status: Windows ICMP status code
rtt_ms: Round-trip time in milliseconds or None
ttl: Time-to-live value
"""
address: str | None
status: int
rtt_ms: float | None
@ -21,6 +39,14 @@ class IcmpReply(NamedTuple):
def classify_status(status: int) -> str:
"""Classify ICMP status code into human-readable category.
Args:
status: Windows ICMP status code
Returns:
One of: 'ok', 'ttl_expired', 'timeout', 'unreachable', 'error'
"""
if status == IP_SUCCESS:
return "ok"
if status == IP_TTL_EXPIRED_TRANSIT:
@ -33,8 +59,18 @@ def classify_status(status: int) -> str:
def parse_echo_reply(buffer: bytes, pointer_size: int = 8) -> IcmpReply:
# Layout after the fixed head: the Data pointer is aligned to its own width,
# then IP_OPTION_INFORMATION starts with Ttl as its first byte.
"""Parse Windows ICMP echo reply buffer.
Layout after the fixed head: the Data pointer is aligned to its own width,
then IP_OPTION_INFORMATION starts with Ttl as its first byte.
Args:
buffer: Raw buffer from IcmpSendEcho2
pointer_size: Size of pointer on current architecture (4 or 8)
Returns:
IcmpReply with parsed data
"""
head_size = _HEAD.size
padding = (-head_size) % pointer_size
ttl_offset = head_size + padding + pointer_size
@ -47,19 +83,8 @@ def parse_echo_reply(buffer: bytes, pointer_size: int = 8) -> IcmpReply:
return IcmpReply(address=f"{a}.{b}.{c}.{d}", status=status, rtt_ms=float(rtt), ttl=ttl)
import ctypes
import platform
import socket
import time
_REPLY_BUFFER_SIZE = 4096
_IS_WINDOWS = platform.system() == "Windows"
if _IS_WINDOWS:
from ctypes import wintypes
class _IpOptionInformation(ctypes.Structure):
"""Windows IP option information structure for ICMP."""
_fields_ = [
("Ttl", ctypes.c_ubyte),
("Tos", ctypes.c_ubyte),
@ -70,6 +95,11 @@ class _IpOptionInformation(ctypes.Structure):
def win_icmp_available() -> bool:
"""Check if Windows ICMP API is available.
Returns:
True if running on Windows with Iphlpapi.dll available
"""
if platform.system() != "Windows":
return False
try:
@ -80,6 +110,14 @@ def win_icmp_available() -> bool:
def _handle():
"""Create and configure Windows ICMP handle.
Returns:
Tuple of (iphlpapi DLL, handle)
Raises:
OSError: If not running on Windows or handle creation fails
"""
if not _IS_WINDOWS:
raise OSError("icmp_win backend is only available on Windows")
iphlpapi = ctypes.WinDLL("Iphlpapi.dll")
@ -127,6 +165,18 @@ def _handle():
def echo_once(
dest: str, ttl: int, timeout_ms: int, payload: bytes = b"netsleuth", source_ip: str | None = None
) -> IcmpReply:
"""Send single ICMP echo request and parse reply.
Args:
dest: Destination IP address
ttl: Time-to-live value
timeout_ms: Timeout in milliseconds
payload: Echo payload bytes
source_ip: Optional source IP to bind to
Returns:
IcmpReply with response data
"""
iphlpapi, handle = _handle()
try:
options = _IpOptionInformation(Ttl=ttl, Tos=0, Flags=0, OptionsSize=0, OptionsData=None)
@ -172,6 +222,18 @@ def echo_once(
def ping_samples_win(
host: str, count: int, interval: float, timeout: float, source_ip: str | None = None
) -> list[float | None]:
"""Perform series of pings on Windows.
Args:
host: Target hostname or IP
count: Number of pings to send
interval: Time between pings in seconds
timeout: Per-ping timeout in seconds
source_ip: Optional source IP to bind to
Returns:
List of RTT values in ms (None for timeouts)
"""
dest = socket.gethostbyname(host)
timeout_ms = int(timeout * 1000)
samples: list[float | None] = []
@ -191,9 +253,21 @@ _MAX_HOP_TIMEOUT_MS = 4000
def trace_hops_win(
dest: str, max_hops: int, timeout_ms: int, source_ip: str | None = None
) -> list[tuple[int, IcmpReply]]:
# timeout_ms is the TOTAL budget for the whole trace (all hops combined), not
# a per-hop timeout - a caller-supplied 60s budget across 30 hops must not turn
# into 30 minutes of blocking IcmpSendEcho2 calls.
"""Perform Windows ICMP traceroute.
timeout_ms is the TOTAL budget for the whole trace (all hops combined), not
a per-hop timeout - a caller-supplied 60s budget across 30 hops must not turn
into 30 minutes of blocking IcmpSendEcho2 calls.
Args:
dest: Target hostname or IP
max_hops: Maximum TTL/hops to probe
timeout_ms: Total timeout budget for entire trace in milliseconds
source_ip: Optional source IP to bind to
Returns:
List of (ttl, IcmpReply) tuples for each hop
"""
address = socket.gethostbyname(dest)
hops: list[tuple[int, IcmpReply]] = []
per_hop_timeout_ms = max(1, min(timeout_ms // max(max_hops, 1), _MAX_HOP_TIMEOUT_MS))

View file

@ -47,7 +47,7 @@ async def tcp_connect_rtt(
_reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, local_addr=local_addr), timeout=timeout
)
except (OSError, asyncio.TimeoutError):
except (TimeoutError, OSError):
return None
writer.close()
try:

View file

@ -6,7 +6,8 @@ import json
import platform
import shutil
import threading
from typing import Awaitable, Callable, TypeVar
from collections.abc import Awaitable, Callable
from typing import TypeVar
from netsleuth.models import Capabilities, TraceConfig, TraceHop, TraceResult
from netsleuth.probes.icmp_win import IcmpReply, classify_status
@ -15,7 +16,7 @@ from netsleuth.traceparse import build_trace_result, finalize_hop
_T = TypeVar("_T")
def _run_in_daemon_thread(func: Callable[..., _T], *args: object) -> "asyncio.Future[_T]":
def _run_in_daemon_thread[T](func: Callable[..., _T], *args: object) -> asyncio.Future[_T]:
# asyncio.to_thread()/loop.run_in_executor(None, ...) queue work onto the
# ThreadPoolExecutor-backed default executor, whose worker threads are non-daemon
# and get joined by concurrent.futures' atexit handler. If the underlying call
@ -116,7 +117,7 @@ async def _run(args: list[str], timeout: float) -> str:
)
try:
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
except TimeoutError:
process.kill()
raise
return stdout.decode(_console_encoding(), errors="replace")

View file

@ -1,21 +1,47 @@
from __future__ import annotations
import asyncio
import json
import re
import time
from collections.abc import Awaitable, Callable
from urllib.parse import parse_qs
from netsleuth.models import CfL4Stats
from netsleuth.stats import percentile
import httpx
from netsleuth.config import BufferbloatBands, Speedtest
from netsleuth.interpret import grade_bufferbloat
from netsleuth.models import CfL4Stats, SpeedResult, TierAttempt
from netsleuth.stats import percentile, rtt_stats
_CFL4_RE = re.compile(r'cfL4\s*;\s*desc\s*=\s*"?\??(?P<query>[^",]*)"?')
def mbps(bytes_transferred: int, seconds: float) -> float:
"""Convert bytes transferred over duration to Mbps.
Args:
bytes_transferred: Number of bytes transferred
seconds: Time duration in seconds
Returns:
Transfer rate in Mbps, or 0.0 if invalid input
"""
if seconds <= 0 or bytes_transferred <= 0:
return 0.0
return (bytes_transferred * 8) / seconds / 1_000_000
def throughput_from_samples(samples: list[tuple[int, float]], p: float = 90.0) -> float:
"""Calculate throughput from size/duration samples at given percentile.
Args:
samples: List of (bytes, seconds) tuples
p: Percentile to use (default 90th)
Returns:
Throughput in Mbps at the specified percentile
"""
rates = [mbps(size, duration) for size, duration in samples]
rates = [rate for rate in rates if rate > 0]
if not rates:
@ -24,6 +50,7 @@ def throughput_from_samples(samples: list[tuple[int, float]], p: float = 90.0) -
def _as_int(value: str | None) -> int | None:
"""Safely convert string to integer."""
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
@ -31,11 +58,20 @@ def _as_int(value: str | None) -> int | None:
def _us_to_ms(value: str | None) -> float | None:
"""Convert microseconds to milliseconds."""
number = _as_int(value)
return None if number is None else round(number / 1000.0, 3)
def parse_server_timing_cfl4(header: str) -> CfL4Stats | None:
"""Parse Cloudflare L4 stats from Server-Timing header.
Args:
header: Server-Timing HTTP header value
Returns:
CfL4Stats object or None if parsing fails
"""
match = _CFL4_RE.search(header or "")
if not match:
return None
@ -52,18 +88,20 @@ def parse_server_timing_cfl4(header: str) -> CfL4Stats | None:
def bufferbloat_delta(idle_rtt_ms: float | None, loaded_rtts_ms: list[float]) -> float | None:
"""Calculate bufferbloat as difference between loaded and idle RTT.
Args:
idle_rtt_ms: Idle round-trip time in milliseconds
loaded_rtts_ms: List of RTT measurements under load
Returns:
Bufferbloat delta in ms, or None if insufficient data
"""
if idle_rtt_ms is None or not loaded_rtts_ms:
return None
loaded = percentile(sorted(loaded_rtts_ms), 95.0)
return round(max(0.0, loaded - idle_rtt_ms), 3)
import asyncio
import time
from typing import Awaitable, Callable
from netsleuth.models import SpeedResult, TierAttempt
NDT7_CONSENT_NOTICE = (
"M-Lab NDT7 publishes every measurement as public CC0 open data, including your "
"IP address. Pass --ndt7 only if that is acceptable to you."
@ -102,14 +140,16 @@ async def run_speed_cascade(
return SpeedResult(method="none", tier_attempts=attempts)
import json
import httpx
from netsleuth.config import Speedtest
def ookla_interface_args(iface_name: str | None, ipv4: str | None) -> list[str]:
"""Build Ookla CLI arguments for interface/IP selection.
Args:
iface_name: Network interface name
ipv4: IPv4 address to bind to
Returns:
List of command-line arguments
"""
if iface_name:
return ["--interface", iface_name]
if ipv4:
@ -221,7 +261,7 @@ async def tier_ndt7(
while time.perf_counter() - began < 10:
try:
message = await asyncio.wait_for(socket.recv(), timeout=timeout)
except (asyncio.TimeoutError, Exception):
except (TimeoutError, Exception):
break
total += len(message) if isinstance(message, (bytes, bytearray)) else len(message.encode())
elapsed = time.perf_counter() - began
@ -233,16 +273,21 @@ async def tier_ndt7(
)
from netsleuth.config import BufferbloatBands
from netsleuth.interpret import grade_bufferbloat
from netsleuth.stats import rtt_stats
async def probe_while(
coro: Awaitable[None],
probe: Callable[[], Awaitable[float | None]],
interval: float,
) -> list[float]:
"""Run probes concurrently with a background task.
Args:
coro: Background coroutine to run (e.g., download/upload)
probe: Async function that returns RTT measurement or None
interval: Time between probes in seconds
Returns:
List of RTT measurements collected during execution
"""
samples: list[float] = []
task = asyncio.ensure_future(coro)
while not task.done():
@ -263,6 +308,23 @@ async def measure_with_bufferbloat(
probe: Callable[[], Awaitable[float | None]],
interval: float,
) -> SpeedResult:
"""Measure bufferbloat by sampling RTT during saturated transfers.
This is the one place a measurement is allowed to overlap another: the
whole point is to see what latency does while the link is saturated.
Args:
result: SpeedResult to update with bufferbloat metrics
idle_rtt_ms: Idle RTT baseline in milliseconds
bands: Bufferbloat grading thresholds
run_download: Coroutine to saturate download
run_upload: Coroutine to saturate upload
probe: Function to measure RTT
interval: Probe interval in seconds
Returns:
Updated SpeedResult with bufferbloat metrics
"""
# This is the one place a measurement is allowed to overlap another: the
# whole point is to see what latency does while the link is saturated.
down_samples = await probe_while(run_download(), probe, interval)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import itertools
from typing import NamedTuple
@ -25,7 +26,7 @@ def rtt_stats(samples: list[float | None]) -> RttStats:
mdev = sum(abs(v - avg) for v in got) / received
deltas = [
abs(b - a)
for a, b in zip(samples, samples[1:])
for a, b in itertools.pairwise(samples)
if a is not None and b is not None
]
jitter = sum(deltas) / len(deltas) if deltas else 0.0

View file

@ -13,7 +13,13 @@ from rich.table import Table
from netsleuth import __version__
from netsleuth.config import Settings
from netsleuth.exporter import atomic_write, compact_timestamp, dump_json, sanitize_name, sparkline
from netsleuth.exporter import (
atomic_write,
compact_timestamp,
dump_json,
sanitize_name,
sparkline,
)
from netsleuth.ip_geo import gather_identity
from netsleuth.models import PingResult, SpeedResult, to_jsonable
from netsleuth.netinfo import detect_capabilities