add config sections for TLS, prefix bench, DPI check, DNS advanced, path diversity

This commit is contained in:
Andrew Pimenov 2026-08-09 17:37:48 +03:00
parent 33d7d2d942
commit c63c179b57
3 changed files with 212 additions and 0 deletions

View file

@ -67,6 +67,53 @@ thresholds:
loss_pct: { good: 0.0, warn: 2.0 } loss_pct: { good: 0.0, warn: 2.0 }
bufferbloat_ms: { a: 5.0, b: 30.0, c: 60.0, d: 200.0, e: 400.0 } bufferbloat_ms: { a: 5.0, b: 30.0, c: 60.0, d: 200.0, e: 400.0 }
vpn_confidence: { likely: 0.40, confirmed: 0.75 } vpn_confidence: { likely: 0.40, confirmed: 0.75 }
tls_handshake_ms: { good: 100.0, warn: 300.0 }
ttfb_ms: { good: 200.0, warn: 800.0 }
dns_resolve_ms: { good: 30.0, warn: 120.0 }
prefix_spread_ms: { good: 20.0, warn: 80.0 }
tls_cpu_bound_ratio: 2.0
tls:
targets:
- { label: "cloudflare", host: "cloudflare.com" }
- { label: "google", host: "google.com" }
port: 443
concurrency: 4
ttfb_path: "/"
# Opt-in via --prefix-bench. Pings the first host of a handful of your own AS's
# announced prefixes to find the lowest-latency PoP. max_prefixes is capped at 256
# in code: this benchmarks a sample of your own network, not a mass scan of it.
prefix_bench:
max_prefixes: 32
ping_count: 3
concurrency: 8
host_offset: 1
family: 4
# Opt-in via --my-server <host>. Single-host self-diagnostic for provider port
# blocking/TCP RST injection. ports/concurrency are capped in code so this cannot
# become a range scanner: never point it at infrastructure you don't own.
dpi_check:
ports: [80, 443, 8443, 2083, 2096, 53]
connect_timeout_seconds: 3.0
delay_between_ports_seconds: 0.25
concurrency: 2
dns_advanced:
control_names: ["cloudflare.com", "example.com"]
doh_endpoints:
- { name: "cloudflare", url: "https://cloudflare-dns.com/dns-query" }
- { name: "google", url: "https://dns.google/resolve" }
- { name: "quad9", url: "https://dns.quad9.net:5053/dns-query" }
bogus_resolver_ip: "192.0.2.1"
bogus_probe_name: "cloudflare.com"
path_diversity:
targets:
- { label: "cloudflare", host: "cloudflare.com" }
- { label: "discord", host: "discord.com" }
max_targets: 3
output: output:
logs_dir: "./logs" logs_dir: "./logs"

View file

@ -120,6 +120,93 @@ class Thresholds(BaseModel):
loss_pct: Band = Band(good=0.0, warn=2.0) loss_pct: Band = Band(good=0.0, warn=2.0)
bufferbloat_ms: BufferbloatBands = BufferbloatBands() bufferbloat_ms: BufferbloatBands = BufferbloatBands()
vpn_confidence: VpnBands = VpnBands() vpn_confidence: VpnBands = VpnBands()
tls_handshake_ms: Band = Band(good=100.0, warn=300.0)
ttfb_ms: Band = Band(good=200.0, warn=800.0)
dns_resolve_ms: Band = Band(good=30.0, warn=120.0)
prefix_spread_ms: Band = Band(good=20.0, warn=80.0)
tls_cpu_bound_ratio: float = 2.0
class TlsConfig(BaseModel):
targets: list[HostSpec] = Field(
default_factory=lambda: [
HostSpec(label="cloudflare", host="cloudflare.com"),
HostSpec(label="google", host="google.com"),
]
)
port: int = 443
concurrency: int = 4
ttfb_path: str = "/"
class PrefixBenchConfig(BaseModel):
max_prefixes: int = 32
ping_count: int = 3
concurrency: int = 8
host_offset: int = 1
family: int = 4
@field_validator("max_prefixes")
@classmethod
def _cap_max_prefixes(cls, value: int) -> int:
if value > 256:
raise ValueError(
"prefix_bench.max_prefixes may not exceed 256 "
"(this benchmarks a handful of your own AS's PoPs, not a mass scan of the whole AS)"
)
return value
class DpiCheckConfig(BaseModel):
ports: list[int] = Field(default_factory=lambda: [80, 443, 8443, 2083, 2096, 53])
connect_timeout_seconds: float = 3.0
delay_between_ports_seconds: float = 0.25
concurrency: int = 2
@field_validator("ports")
@classmethod
def _cap_ports(cls, value: list[int]) -> list[int]:
if len(value) > 16:
raise ValueError(
"dpi_check.ports may list at most 16 ports "
"(this is a single-host self-diagnostic, not a port-range scanner)"
)
return value
@field_validator("concurrency")
@classmethod
def _cap_concurrency(cls, value: int) -> int:
if value > 4:
raise ValueError("dpi_check.concurrency may not exceed 4 (keep the self-check rate-limited)")
return value
class DohEndpoint(BaseModel):
name: str
url: str
class DnsAdvancedConfig(BaseModel):
control_names: list[str] = Field(default_factory=lambda: ["cloudflare.com", "example.com"])
doh_endpoints: list[DohEndpoint] = Field(
default_factory=lambda: [
DohEndpoint(name="cloudflare", url="https://cloudflare-dns.com/dns-query"),
DohEndpoint(name="google", url="https://dns.google/resolve"),
DohEndpoint(name="quad9", url="https://dns.quad9.net:5053/dns-query"),
]
)
bogus_resolver_ip: str = "192.0.2.1"
bogus_probe_name: str = "cloudflare.com"
class PathDiversityConfig(BaseModel):
targets: list[HostSpec] = Field(
default_factory=lambda: [
HostSpec(label="cloudflare", host="cloudflare.com"),
HostSpec(label="discord", host="discord.com"),
]
)
max_targets: int = 3
class Output(BaseModel): class Output(BaseModel):
@ -169,6 +256,11 @@ class Settings(BaseSettings):
thresholds: Thresholds = Thresholds() thresholds: Thresholds = Thresholds()
output: Output = Output() output: Output = Output()
watch: Watch = Watch() watch: Watch = Watch()
tls: TlsConfig = TlsConfig()
prefix_bench: PrefixBenchConfig = PrefixBenchConfig()
dpi_check: DpiCheckConfig = DpiCheckConfig()
dns_advanced: DnsAdvancedConfig = DnsAdvancedConfig()
path_diversity: PathDiversityConfig = PathDiversityConfig()
ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN") ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN")
peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY") peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY")

View file

@ -7,6 +7,24 @@ from pydantic import ValidationError
from netsleuth.config import load_settings from netsleuth.config import load_settings
L7_YAML = textwrap.dedent(
"""
tls:
port: 8443
concurrency: 2
prefix_bench:
max_prefixes: 8
dpi_check:
delay_between_ports_seconds: 0.5
dns_advanced:
bogus_resolver_ip: "198.51.100.1"
path_diversity:
max_targets: 5
thresholds:
tls_handshake_ms: { good: 50.0, warn: 150.0 }
"""
).strip()
@pytest.fixture() @pytest.fixture()
def yaml_file(tmp_path): def yaml_file(tmp_path):
@ -123,3 +141,58 @@ def test_output_formats_rejects_unknown_value(tmp_path):
path.write_text('output:\n formats: ["pdf"]\n', encoding="utf-8") path.write_text('output:\n formats: ["pdf"]\n', encoding="utf-8")
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
load_settings(config_path=path, env_file=tmp_path / "nope.env") load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_l7_sections_default_without_any_yaml(tmp_path):
s = load_settings(config_path=tmp_path / "nope.yaml", env_file=tmp_path / "nope.env")
assert s.tls.port == 443
assert s.tls.targets
assert s.prefix_bench.max_prefixes == 32
assert s.dpi_check.ports == [80, 443, 8443, 2083, 2096, 53]
assert s.dns_advanced.doh_endpoints
assert s.path_diversity.targets
assert s.thresholds.tls_handshake_ms.warn == 300.0
assert s.thresholds.tls_cpu_bound_ratio == 2.0
def test_l7_sections_yaml_overrides(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(L7_YAML, encoding="utf-8")
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
assert s.tls.port == 8443
assert s.tls.concurrency == 2
assert s.prefix_bench.max_prefixes == 8
assert s.dpi_check.delay_between_ports_seconds == 0.5
assert s.dns_advanced.bogus_resolver_ip == "198.51.100.1"
assert s.path_diversity.max_targets == 5
assert s.thresholds.tls_handshake_ms.good == 50.0
def test_env_overrides_an_l7_section_field(tmp_path, monkeypatch):
monkeypatch.setenv("NETSLEUTH_PREFIX_BENCH__MAX_PREFIXES", "16")
s = load_settings(config_path=tmp_path / "nope.yaml", env_file=tmp_path / "nope.env")
assert s.prefix_bench.max_prefixes == 16
def test_dpi_check_ports_are_capped_so_it_cannot_become_a_range_scanner(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(
"dpi_check:\n ports: [" + ", ".join(str(80 + i) for i in range(17)) + "]\n",
encoding="utf-8",
)
with pytest.raises(ValidationError, match="at most 16 ports"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_dpi_check_concurrency_is_capped_as_a_rate_limit(tmp_path):
path = tmp_path / "config.yaml"
path.write_text("dpi_check:\n concurrency: 10\n", encoding="utf-8")
with pytest.raises(ValidationError, match="concurrency"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_prefix_bench_max_prefixes_is_capped_so_it_cannot_scan_a_whole_as(tmp_path):
path = tmp_path / "config.yaml"
path.write_text("prefix_bench:\n max_prefixes: 1000\n", encoding="utf-8")
with pytest.raises(ValidationError, match="256"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")