mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 15:42:10 +03:00
`timeframe_days` is configuration logic that was incorrectly placed into `BgpContext`, which is meant for raw data from BGP sources. This refactor removes it from `BgpContext` and instead passes it as a parameter to `build_bgp_intel` where it's actually used by `classify_stability`. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
279 lines
8.8 KiB
Python
279 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
from netsleuth.bgp import (
|
|
BgpContext,
|
|
build_bgp_intel,
|
|
cached_json,
|
|
classify_stability,
|
|
parse_announced_prefixes,
|
|
parse_as_overview,
|
|
parse_asn_neighbours,
|
|
parse_asrank,
|
|
parse_bgp_updates,
|
|
parse_cymru_origin,
|
|
parse_peeringdb_net,
|
|
parse_peeringdb_netixlan,
|
|
ripestat,
|
|
)
|
|
from netsleuth.config import Providers
|
|
from netsleuth.models import BgpEvent
|
|
|
|
|
|
def test_as_overview_extracts_holder_and_block(api_fixture):
|
|
info = parse_as_overview(api_fixture("ripestat_as_overview.json"))
|
|
assert info["holder"] == "EXAMPLE-AS Example Telecom BV"
|
|
assert info["registry"] == "IANA 16-bit Autonomous System Number Block"
|
|
|
|
|
|
def test_as_overview_of_an_error_payload_is_empty():
|
|
assert parse_as_overview({"status": "error", "data": {}}) == {
|
|
"holder": None,
|
|
"registry": None,
|
|
"allocated_at": None,
|
|
}
|
|
|
|
|
|
def test_neighbours_split_into_upstreams_peers_and_downstreams(api_fixture):
|
|
upstreams, peers, downstreams = parse_asn_neighbours(
|
|
api_fixture("ripestat_asn_neighbours.json")
|
|
)
|
|
assert upstreams == ["AS3356", "AS1299"]
|
|
assert downstreams == ["AS6939", "AS64501"]
|
|
assert peers == ["AS8075"]
|
|
|
|
|
|
def test_announced_prefixes_are_counted_by_family(api_fixture):
|
|
prefixes, v4, v6 = parse_announced_prefixes(
|
|
api_fixture("ripestat_announced_prefixes.json")
|
|
)
|
|
assert prefixes == ["203.0.113.0/24", "198.51.100.0/24", "2001:db8::/32"]
|
|
assert v4 == 2
|
|
assert v6 == 1
|
|
|
|
|
|
def test_announced_prefixes_of_an_empty_payload():
|
|
assert parse_announced_prefixes({"data": {}}) == ([], 0, 0)
|
|
|
|
|
|
def test_bgp_updates_become_typed_events(api_fixture):
|
|
events = parse_bgp_updates(api_fixture("ripestat_bgp_updates.json"))
|
|
assert len(events) == 3
|
|
assert events[0].type == "A"
|
|
assert events[0].prefix == "203.0.113.0/24"
|
|
assert events[0].path == [3356, 64500]
|
|
assert events[1].type == "W"
|
|
assert events[1].path == []
|
|
|
|
|
|
def test_stability_of_a_quiet_asn_is_stable():
|
|
assert classify_stability([], days=14) == "stable"
|
|
|
|
|
|
def test_stability_of_a_flapping_asn_is_unstable():
|
|
events = [
|
|
BgpEvent(timestamp="2026-08-01T00:00:00", type="W", prefix="203.0.113.0/24")
|
|
for _ in range(60)
|
|
]
|
|
assert classify_stability(events, days=14) == "unstable"
|
|
|
|
|
|
def test_stability_only_counts_withdrawals_not_announcements():
|
|
events = [
|
|
BgpEvent(timestamp="2026-08-01T00:00:00", type="A", prefix="203.0.113.0/24")
|
|
for _ in range(200)
|
|
]
|
|
assert classify_stability(events, days=14) == "stable"
|
|
|
|
|
|
def test_stability_is_unknown_without_a_timeframe():
|
|
assert classify_stability([BgpEvent(timestamp="x", type="W")], days=0) == "unknown"
|
|
|
|
|
|
async def test_ripestat_bounds_bulk_calls_with_max_rows_and_a_timeframe(httpx_mock):
|
|
httpx_mock.add_response(json={"status": "ok", "data": {}})
|
|
providers = Providers(ripestat_max_rows=25, ripestat_timeframe_days=7)
|
|
async with httpx.AsyncClient() as client:
|
|
await ripestat(client, providers, "bgp-updates", "AS64500")
|
|
request = httpx_mock.get_request()
|
|
assert request.url.params["resource"] == "AS64500"
|
|
assert request.url.params["max_rows"] == "25"
|
|
assert "starttime" in request.url.params
|
|
assert request.url.path.endswith("/bgp-updates/data.json")
|
|
|
|
|
|
async def test_ripestat_does_not_bound_non_bulk_calls(httpx_mock):
|
|
httpx_mock.add_response(json={"status": "ok", "data": {}})
|
|
async with httpx.AsyncClient() as client:
|
|
await ripestat(client, Providers(), "as-overview", "AS64500")
|
|
request = httpx_mock.get_request()
|
|
assert "max_rows" not in request.url.params
|
|
assert "starttime" not in request.url.params
|
|
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
|
|
def test_asrank_gives_rank_and_customer_cone(api_fixture):
|
|
rank, cone_asns, cone_prefixes = parse_asrank(api_fixture("asrank.json"))
|
|
assert rank == 1842
|
|
assert cone_asns == 37
|
|
assert cone_prefixes == 412
|
|
|
|
|
|
def test_asrank_of_an_unknown_asn_is_all_none():
|
|
assert parse_asrank({"data": {"asn": None}}) == (None, None, None)
|
|
|
|
|
|
def test_cymru_origin_txt_is_split_into_fields():
|
|
record = "64500 | 203.0.113.0/24 | NL | ripencc | 2001-05-21"
|
|
assert parse_cymru_origin(record) == {
|
|
"asn": "AS64500",
|
|
"prefix": "203.0.113.0/24",
|
|
"country": "NL",
|
|
"registry": "ripencc",
|
|
"allocated_at": "2001-05-21",
|
|
}
|
|
|
|
|
|
def test_cymru_origin_with_multiple_origin_asns_takes_the_first():
|
|
record = "64500 64501 | 203.0.113.0/24 | NL | ripencc | 2001-05-21"
|
|
assert parse_cymru_origin(record)["asn"] == "AS64500"
|
|
|
|
|
|
def test_cymru_origin_of_garbage_is_empty():
|
|
assert parse_cymru_origin("no such name") == {}
|
|
|
|
|
|
def test_peeringdb_net_gives_info_type_traffic_and_id(api_fixture):
|
|
info_type, traffic, net_id = parse_peeringdb_net(api_fixture("peeringdb_net.json"))
|
|
assert info_type == "Cable/DSL/ISP"
|
|
assert traffic == "100-200Gbps"
|
|
assert net_id == 4242
|
|
|
|
|
|
def test_peeringdb_net_of_an_unlisted_asn_is_all_none():
|
|
assert parse_peeringdb_net({"data": []}) == (None, None, None)
|
|
|
|
|
|
def test_peeringdb_netixlan_lists_only_operational_exchanges(api_fixture):
|
|
ixps = parse_peeringdb_netixlan(api_fixture("peeringdb_netixlan.json"))
|
|
assert [i.name for i in ixps] == ["AMS-IX", "DE-CIX Frankfurt"]
|
|
assert ixps[0].country == "NL"
|
|
assert ixps[1].speed_mbps == 200000
|
|
|
|
|
|
async def test_cached_json_writes_then_reuses_the_cache(tmp_path: Path):
|
|
calls = []
|
|
|
|
async def fetch() -> dict:
|
|
calls.append(1)
|
|
return {"value": len(calls)}
|
|
|
|
first = await cached_json(
|
|
tmp_path, "peeringdb-net-64500", ttl_hours=24, fetch=fetch
|
|
)
|
|
second = await cached_json(
|
|
tmp_path, "peeringdb-net-64500", ttl_hours=24, fetch=fetch
|
|
)
|
|
assert first == {"value": 1}
|
|
assert second == {"value": 1}
|
|
assert len(calls) == 1
|
|
assert (
|
|
json.loads((tmp_path / "peeringdb-net-64500.json").read_text(encoding="utf-8"))[
|
|
"value"
|
|
]
|
|
== 1
|
|
)
|
|
|
|
|
|
async def test_cached_json_refetches_once_the_entry_expires(tmp_path: Path):
|
|
calls = []
|
|
|
|
async def fetch() -> dict:
|
|
calls.append(1)
|
|
return {"value": len(calls)}
|
|
|
|
await cached_json(tmp_path, "k", ttl_hours=24, fetch=fetch)
|
|
result = await cached_json(tmp_path, "k", ttl_hours=0, fetch=fetch)
|
|
assert result == {"value": 2}
|
|
assert len(calls) == 2
|
|
|
|
|
|
async def test_cached_json_refetches_when_mtime_reads_ahead_of_now(tmp_path: Path):
|
|
import os
|
|
|
|
calls = []
|
|
|
|
async def fetch() -> dict:
|
|
calls.append(1)
|
|
return {"value": len(calls)}
|
|
|
|
await cached_json(tmp_path, "k", ttl_hours=24, fetch=fetch)
|
|
future = (tmp_path / "k.json").stat().st_mtime + 5
|
|
os.utime(tmp_path / "k.json", (future, future))
|
|
result = await cached_json(tmp_path, "k", ttl_hours=0, fetch=fetch)
|
|
assert result == {"value": 2}
|
|
assert len(calls) == 2
|
|
|
|
|
|
async def test_cached_json_survives_a_corrupt_cache_file(tmp_path: Path):
|
|
(tmp_path / "k.json").write_text("{not json", encoding="utf-8")
|
|
|
|
async def fetch() -> dict:
|
|
return {"value": "fresh"}
|
|
|
|
assert await cached_json(tmp_path, "k", ttl_hours=24, fetch=fetch) == {
|
|
"value": "fresh"
|
|
}
|
|
|
|
|
|
def test_build_bgp_intel_merges_every_source(api_fixture):
|
|
intel = build_bgp_intel(
|
|
BgpContext(
|
|
asn="AS64500",
|
|
overview=api_fixture("ripestat_as_overview.json"),
|
|
neighbours=api_fixture("ripestat_asn_neighbours.json"),
|
|
prefixes=api_fixture("ripestat_announced_prefixes.json"),
|
|
updates=api_fixture("ripestat_bgp_updates.json"),
|
|
asrank=api_fixture("asrank.json"),
|
|
pdb_net=api_fixture("peeringdb_net.json"),
|
|
pdb_ixlan=api_fixture("peeringdb_netixlan.json"),
|
|
),
|
|
timeframe_days=14,
|
|
)
|
|
assert intel.asn == "AS64500"
|
|
assert intel.holder == "EXAMPLE-AS Example Telecom BV"
|
|
assert intel.upstreams == ["AS3356", "AS1299"]
|
|
assert intel.prefix_count_v4 == 2
|
|
assert intel.prefix_count_v6 == 1
|
|
assert intel.stability == "stable"
|
|
assert intel.asrank == 1842
|
|
assert intel.cone_asns == 37
|
|
assert intel.pdb_info_type == "Cable/DSL/ISP"
|
|
assert [i.name for i in intel.ixps] == ["AMS-IX", "DE-CIX Frankfurt"]
|
|
assert len(intel.flaps) == 3
|
|
|
|
|
|
def test_build_bgp_intel_tolerates_every_optional_source_being_absent(api_fixture):
|
|
intel = build_bgp_intel(
|
|
BgpContext(
|
|
asn="AS64500",
|
|
overview=api_fixture("ripestat_as_overview.json"),
|
|
neighbours={},
|
|
prefixes={},
|
|
updates={},
|
|
asrank=None,
|
|
pdb_net=None,
|
|
pdb_ixlan=None,
|
|
),
|
|
timeframe_days=14,
|
|
)
|
|
assert intel.asn == "AS64500"
|
|
assert intel.upstreams == []
|
|
assert intel.asrank is None
|
|
assert intel.pdb_info_type is None
|
|
assert intel.ixps == []
|
|
assert intel.stability == "stable"
|