mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 01:02:10 +03:00
add L4-L7 diagnostic models (TLS, DPI, DNS, anycast, prefix bench)
This commit is contained in:
parent
51d82d997f
commit
33d7d2d942
2 changed files with 253 additions and 0 deletions
|
|
@ -20,6 +20,10 @@ ERROR_KINDS = (
|
||||||
STATUSES = ("ok", "partial", "failed", "skipped")
|
STATUSES = ("ok", "partial", "failed", "skipped")
|
||||||
SEVERITIES = ("ok", "info", "warn", "crit")
|
SEVERITIES = ("ok", "info", "warn", "crit")
|
||||||
DIRECTIONS = ("vpn", "clean")
|
DIRECTIONS = ("vpn", "clean")
|
||||||
|
PORT_STATES = ("open", "closed", "filtered", "reset", "error")
|
||||||
|
DPI_VERDICTS = ("clean", "partial_filtering", "reset_injection", "unreachable", "unknown")
|
||||||
|
RESOLVER_KINDS = ("system", "doh")
|
||||||
|
EDGE_SOURCES = ("cf_ray", "cf_trace", "server_timing", "none")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -342,6 +346,127 @@ class SpeedResult:
|
||||||
netflix_oca_onnet: bool | None = None
|
netflix_oca_onnet: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TlsResult:
|
||||||
|
label: str = ""
|
||||||
|
host: str = ""
|
||||||
|
port: int = 443
|
||||||
|
resolved_ip: str | None = None
|
||||||
|
tcp_rtt_ms: float | None = None
|
||||||
|
tls_handshake_ms: float | None = None
|
||||||
|
ttfb_ms: float | None = None
|
||||||
|
tls_version: str | None = None
|
||||||
|
cipher: str | None = None
|
||||||
|
alpn: str | None = None
|
||||||
|
cert_verified: bool | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrefixProbe:
|
||||||
|
prefix: str
|
||||||
|
probe_ip: str | None = None
|
||||||
|
avg_ms: float | None = None
|
||||||
|
loss_pct: float = 100.0
|
||||||
|
reachable: bool = False
|
||||||
|
reverse_dns: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrefixBenchmark:
|
||||||
|
asn: str | None = None
|
||||||
|
prefixes_announced: int = 0
|
||||||
|
prefixes_probed: int = 0
|
||||||
|
method: str = "none"
|
||||||
|
results: list[PrefixProbe] = field(default_factory=list)
|
||||||
|
best: str | None = None
|
||||||
|
worst: str | None = None
|
||||||
|
spread_ms: float | None = None
|
||||||
|
skipped_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortProbe:
|
||||||
|
port: int
|
||||||
|
state: str
|
||||||
|
rtt_ms: float | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.state not in PORT_STATES:
|
||||||
|
raise ValueError(f"unknown PortProbe state: {self.state!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DpiCheckResult:
|
||||||
|
target: str | None = None
|
||||||
|
resolved_ip: str | None = None
|
||||||
|
consented: bool = False
|
||||||
|
ports: list[PortProbe] = field(default_factory=list)
|
||||||
|
verdict: str = "unknown"
|
||||||
|
rationale: str = ""
|
||||||
|
rationale_ru: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.verdict not in DPI_VERDICTS:
|
||||||
|
raise ValueError(f"unknown DpiCheckResult verdict: {self.verdict!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolverProbe:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
endpoint: str | None = None
|
||||||
|
query_name: str = ""
|
||||||
|
answers: list[str] = field(default_factory=list)
|
||||||
|
elapsed_ms: float | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in RESOLVER_KINDS:
|
||||||
|
raise ValueError(f"unknown ResolverProbe kind: {self.kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DnsAdvanced:
|
||||||
|
probes: list[ResolverProbe] = field(default_factory=list)
|
||||||
|
system_avg_ms: float | None = None
|
||||||
|
doh_avg_ms: float | None = None
|
||||||
|
divergences: list[str] = field(default_factory=list)
|
||||||
|
transparent_proxy: bool | None = None
|
||||||
|
transparent_proxy_detail: str | None = None
|
||||||
|
note: str = ""
|
||||||
|
note_ru: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnycastHop:
|
||||||
|
target: str
|
||||||
|
resolved_ip: str | None = None
|
||||||
|
ip_country: str | None = None
|
||||||
|
ip_city: str | None = None
|
||||||
|
edge_colo: str | None = None
|
||||||
|
edge_city: str | None = None
|
||||||
|
edge_country: str | None = None
|
||||||
|
edge_rtt_ms: float | None = None
|
||||||
|
client_rtt_ms: float | None = None
|
||||||
|
source: str = "none"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.source not in EDGE_SOURCES:
|
||||||
|
raise ValueError(f"unknown AnycastHop source: {self.source!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PathDiversity:
|
||||||
|
client_country: str | None = None
|
||||||
|
hops: list[AnycastHop] = field(default_factory=list)
|
||||||
|
international_loop: bool = False
|
||||||
|
detour_countries: list[str] = field(default_factory=list)
|
||||||
|
note: str = ""
|
||||||
|
note_ru: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def to_jsonable(obj: Any) -> Any:
|
def to_jsonable(obj: Any) -> Any:
|
||||||
if obj is None or isinstance(obj, (str, bool, int)):
|
if obj is None or isinstance(obj, (str, bool, int)):
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,34 @@ import pytest
|
||||||
|
|
||||||
from netsleuth.models import (
|
from netsleuth.models import (
|
||||||
AdapterLeakResult,
|
AdapterLeakResult,
|
||||||
|
AnycastHop,
|
||||||
BgpEvent,
|
BgpEvent,
|
||||||
BgpIntel,
|
BgpIntel,
|
||||||
Capabilities,
|
Capabilities,
|
||||||
CfL4Stats,
|
CfL4Stats,
|
||||||
CfTrace,
|
CfTrace,
|
||||||
|
DnsAdvanced,
|
||||||
DnsLeak,
|
DnsLeak,
|
||||||
DnsblHit,
|
DnsblHit,
|
||||||
|
DpiCheckResult,
|
||||||
Finding,
|
Finding,
|
||||||
InternetDbResult,
|
InternetDbResult,
|
||||||
IpGeo,
|
IpGeo,
|
||||||
IxpPresence,
|
IxpPresence,
|
||||||
LocalNet,
|
LocalNet,
|
||||||
ModuleResult,
|
ModuleResult,
|
||||||
|
PathDiversity,
|
||||||
PingResult,
|
PingResult,
|
||||||
|
PortProbe,
|
||||||
|
PrefixBenchmark,
|
||||||
|
PrefixProbe,
|
||||||
ProbeError,
|
ProbeError,
|
||||||
Reputation,
|
Reputation,
|
||||||
|
ResolverProbe,
|
||||||
Signal,
|
Signal,
|
||||||
SpeedResult,
|
SpeedResult,
|
||||||
TierAttempt,
|
TierAttempt,
|
||||||
|
TlsResult,
|
||||||
TraceHop,
|
TraceHop,
|
||||||
TraceResult,
|
TraceResult,
|
||||||
VpnAssessment,
|
VpnAssessment,
|
||||||
|
|
@ -245,3 +254,122 @@ def test_ip_geo_and_vpn_assessment_defaults_are_json_safe():
|
||||||
out = to_jsonable({"geo": geo, "vpn": vpn})
|
out = to_jsonable({"geo": geo, "vpn": vpn})
|
||||||
assert out["geo"]["ip_type"] == "unknown"
|
assert out["geo"]["ip_type"] == "unknown"
|
||||||
assert out["vpn"]["signals"][0]["name"] == "warp"
|
assert out["vpn"]["signals"][0]["name"] == "warp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_l7_domain_shape_defaults_to_constructible_with_no_arguments():
|
||||||
|
for cls in (TlsResult, PrefixBenchmark, DpiCheckResult, DnsAdvanced, PathDiversity):
|
||||||
|
instance = cls()
|
||||||
|
json.dumps(to_jsonable(instance), allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_result_round_trips_through_json():
|
||||||
|
tls = 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",
|
||||||
|
alpn="h2",
|
||||||
|
cert_verified=True,
|
||||||
|
)
|
||||||
|
out = json.loads(json.dumps(to_jsonable(tls), allow_nan=False))
|
||||||
|
assert out["host"] == "cloudflare.com"
|
||||||
|
assert out["tls_handshake_ms"] == 45.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_probe_rejects_unknown_state():
|
||||||
|
with pytest.raises(ValueError, match="unknown PortProbe state"):
|
||||||
|
PortProbe(port=443, state="bogus")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["open", "closed", "filtered", "reset", "error"])
|
||||||
|
def test_port_probe_accepts_documented_states(state):
|
||||||
|
assert PortProbe(port=443, state=state).state == state
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpi_check_result_rejects_unknown_verdict():
|
||||||
|
with pytest.raises(ValueError, match="unknown DpiCheckResult verdict"):
|
||||||
|
DpiCheckResult(verdict="bogus")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"verdict", ["clean", "partial_filtering", "reset_injection", "unreachable", "unknown"]
|
||||||
|
)
|
||||||
|
def test_dpi_check_result_accepts_documented_verdicts(verdict):
|
||||||
|
assert DpiCheckResult(verdict=verdict).verdict == verdict
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpi_check_result_nests_port_probes():
|
||||||
|
result = DpiCheckResult(
|
||||||
|
target="203.0.113.9",
|
||||||
|
resolved_ip="203.0.113.9",
|
||||||
|
consented=True,
|
||||||
|
ports=[PortProbe(port=443, state="open", rtt_ms=20.0)],
|
||||||
|
verdict="clean",
|
||||||
|
rationale="all probed ports behave normally",
|
||||||
|
)
|
||||||
|
out = to_jsonable(result)
|
||||||
|
assert out["ports"][0]["state"] == "open"
|
||||||
|
assert out["consented"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_probe_rejects_unknown_kind():
|
||||||
|
with pytest.raises(ValueError, match="unknown ResolverProbe kind"):
|
||||||
|
ResolverProbe(name="system", kind="bogus", query_name="example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["system", "doh"])
|
||||||
|
def test_resolver_probe_accepts_documented_kinds(kind):
|
||||||
|
assert ResolverProbe(name="n", kind=kind, query_name="example.com").kind == kind
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_advanced_nests_resolver_probes():
|
||||||
|
adv = DnsAdvanced(
|
||||||
|
probes=[ResolverProbe(name="system", kind="system", query_name="example.com", answers=["1.2.3.4"])],
|
||||||
|
system_avg_ms=12.0,
|
||||||
|
doh_avg_ms=30.0,
|
||||||
|
transparent_proxy=False,
|
||||||
|
note="system resolver answered directly",
|
||||||
|
)
|
||||||
|
out = to_jsonable(adv)
|
||||||
|
assert out["probes"][0]["answers"] == ["1.2.3.4"]
|
||||||
|
assert out["transparent_proxy"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_anycast_hop_rejects_unknown_source():
|
||||||
|
with pytest.raises(ValueError, match="unknown AnycastHop source"):
|
||||||
|
AnycastHop(target="example.com", source="bogus")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["cf_ray", "cf_trace", "server_timing", "none"])
|
||||||
|
def test_anycast_hop_accepts_documented_sources(source):
|
||||||
|
assert AnycastHop(target="example.com", source=source).source == source
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_diversity_nests_anycast_hops():
|
||||||
|
pd = PathDiversity(
|
||||||
|
client_country="RU",
|
||||||
|
hops=[AnycastHop(target="cloudflare.com", edge_colo="DME", source="cf_ray")],
|
||||||
|
international_loop=False,
|
||||||
|
)
|
||||||
|
out = to_jsonable(pd)
|
||||||
|
assert out["hops"][0]["edge_colo"] == "DME"
|
||||||
|
assert out["international_loop"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_benchmark_nests_prefix_probes_and_ranks():
|
||||||
|
bench = PrefixBenchmark(
|
||||||
|
asn="AS64500",
|
||||||
|
prefixes_announced=120,
|
||||||
|
prefixes_probed=32,
|
||||||
|
method="icmp",
|
||||||
|
results=[PrefixProbe(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=15.0, reachable=True)],
|
||||||
|
best="203.0.113.0/24",
|
||||||
|
)
|
||||||
|
out = to_jsonable(bench)
|
||||||
|
assert out["results"][0]["avg_ms"] == 15.0
|
||||||
|
assert out["best"] == "203.0.113.0/24"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue