diff --git a/config.yaml b/config.yaml index ee510ea..b803813 100644 --- a/config.yaml +++ b/config.yaml @@ -67,6 +67,53 @@ thresholds: 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 } 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 . 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: logs_dir: "./logs" diff --git a/src/netsleuth/config.py b/src/netsleuth/config.py index fe430cf..392c8c8 100644 --- a/src/netsleuth/config.py +++ b/src/netsleuth/config.py @@ -120,6 +120,93 @@ class Thresholds(BaseModel): loss_pct: Band = Band(good=0.0, warn=2.0) bufferbloat_ms: BufferbloatBands = BufferbloatBands() 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): @@ -169,6 +256,11 @@ class Settings(BaseSettings): thresholds: Thresholds = Thresholds() output: Output = Output() 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") peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY") diff --git a/tests/test_config.py b/tests/test_config.py index c522636..a45df90 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,24 @@ from pydantic import ValidationError 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() 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") with pytest.raises(ValidationError): 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")