From f269fb95d80d038f0a8d9af88a5921c7a57a7e1e Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 02:15:07 +0300 Subject: [PATCH] bgp: ripestat parsers and bounded bulk queries --- src/netcheck/bgp.py | 104 ++++++++++++++++++ .../api/ripestat_announced_prefixes.json | 11 ++ tests/fixtures/api/ripestat_as_overview.json | 15 +++ .../fixtures/api/ripestat_asn_neighbours.json | 13 +++ tests/fixtures/api/ripestat_bgp_updates.json | 12 ++ tests/test_bgp.py | 96 ++++++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 src/netcheck/bgp.py create mode 100644 tests/fixtures/api/ripestat_announced_prefixes.json create mode 100644 tests/fixtures/api/ripestat_as_overview.json create mode 100644 tests/fixtures/api/ripestat_asn_neighbours.json create mode 100644 tests/fixtures/api/ripestat_bgp_updates.json create mode 100644 tests/test_bgp.py diff --git a/src/netcheck/bgp.py b/src/netcheck/bgp.py new file mode 100644 index 0000000..48677d7 --- /dev/null +++ b/src/netcheck/bgp.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import ipaddress +from datetime import datetime, timedelta, timezone + +import httpx + +from netcheck.config import Providers +from netcheck.models import BgpEvent + +# 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. +BULK_CALLS = {"bgp-updates", "routing-history", "bgplay", "announced-prefixes"} + +_UNSTABLE_WITHDRAWALS_PER_DAY = 3.0 + + +def _as_label(asn: object) -> str: + text = str(asn).upper() + return text if text.startswith("AS") else f"AS{text}" + + +def parse_as_overview(payload: dict) -> dict[str, str | None]: + data = payload.get("data") or {} + block = data.get("block") or {} + return { + "holder": data.get("holder") or None, + "registry": block.get("name") or None, + "allocated_at": data.get("announced_since") or None, + } + + +def parse_asn_neighbours(payload: dict) -> tuple[list[str], list[str], list[str]]: + upstreams: list[str] = [] + peers: list[str] = [] + downstreams: list[str] = [] + for neighbour in (payload.get("data") or {}).get("neighbours") or []: + label = _as_label(neighbour.get("asn")) + kind = neighbour.get("type") + if kind == "left": + upstreams.append(label) + elif kind == "right": + downstreams.append(label) + else: + peers.append(label) + return upstreams, peers, downstreams + + +def parse_announced_prefixes(payload: dict) -> tuple[list[str], int, int]: + prefixes: list[str] = [] + v4 = v6 = 0 + for entry in (payload.get("data") or {}).get("prefixes") or []: + prefix = entry.get("prefix") + if not prefix: + continue + prefixes.append(prefix) + try: + version = ipaddress.ip_network(prefix, strict=False).version + except ValueError: + continue + if version == 4: + v4 += 1 + else: + v6 += 1 + return prefixes, v4, v6 + + +def parse_bgp_updates(payload: dict) -> list[BgpEvent]: + events: list[BgpEvent] = [] + for update in (payload.get("data") or {}).get("updates") or []: + attrs = update.get("attrs") or {} + events.append( + BgpEvent( + timestamp=update.get("timestamp", ""), + type=update.get("type", ""), + prefix=attrs.get("target_prefix"), + path=[int(hop) for hop in attrs.get("path") or []], + ) + ) + return events + + +def classify_stability(events: list[BgpEvent], days: int) -> str: + if days <= 0: + return "unknown" + withdrawals = sum(1 for e in events if e.type == "W") + return "unstable" if withdrawals / days >= _UNSTABLE_WITHDRAWALS_PER_DAY else "stable" + + +async def ripestat( + client: httpx.AsyncClient, + providers: Providers, + call: str, + resource: str, + **extra: str, +) -> dict: + params: dict[str, str] = {"resource": resource, **extra} + if call in BULK_CALLS: + start = datetime.now(timezone.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) + response.raise_for_status() + return response.json() diff --git a/tests/fixtures/api/ripestat_announced_prefixes.json b/tests/fixtures/api/ripestat_announced_prefixes.json new file mode 100644 index 0000000..1e162e1 --- /dev/null +++ b/tests/fixtures/api/ripestat_announced_prefixes.json @@ -0,0 +1,11 @@ +{ + "status": "ok", + "data_call_name": "announced-prefixes", + "data": { + "prefixes": [ + { "prefix": "203.0.113.0/24", "timelines": [] }, + { "prefix": "198.51.100.0/24", "timelines": [] }, + { "prefix": "2001:db8::/32", "timelines": [] } + ] + } +} diff --git a/tests/fixtures/api/ripestat_as_overview.json b/tests/fixtures/api/ripestat_as_overview.json new file mode 100644 index 0000000..62cb8f2 --- /dev/null +++ b/tests/fixtures/api/ripestat_as_overview.json @@ -0,0 +1,15 @@ +{ + "status": "ok", + "data_call_name": "as-overview", + "data": { + "resource": "64500", + "holder": "EXAMPLE-AS Example Telecom BV", + "announced": true, + "block": { + "resource": "64496-64511", + "desc": "Reserved for use in documentation", + "name": "IANA 16-bit Autonomous System Number Block" + }, + "type": "as" + } +} diff --git a/tests/fixtures/api/ripestat_asn_neighbours.json b/tests/fixtures/api/ripestat_asn_neighbours.json new file mode 100644 index 0000000..7afbf30 --- /dev/null +++ b/tests/fixtures/api/ripestat_asn_neighbours.json @@ -0,0 +1,13 @@ +{ + "status": "ok", + "data_call_name": "asn-neighbours", + "data": { + "neighbours": [ + { "asn": 3356, "type": "left", "power": 3, "v4_peers": 12, "v6_peers": 4 }, + { "asn": 1299, "type": "left", "power": 2, "v4_peers": 8, "v6_peers": 3 }, + { "asn": 6939, "type": "right", "power": 1, "v4_peers": 2, "v6_peers": 1 }, + { "asn": 64501, "type": "right", "power": 1, "v4_peers": 1, "v6_peers": 0 }, + { "asn": 8075, "type": "unknown", "power": 1, "v4_peers": 1, "v6_peers": 1 } + ] + } +} diff --git a/tests/fixtures/api/ripestat_bgp_updates.json b/tests/fixtures/api/ripestat_bgp_updates.json new file mode 100644 index 0000000..e1b4b05 --- /dev/null +++ b/tests/fixtures/api/ripestat_bgp_updates.json @@ -0,0 +1,12 @@ +{ + "status": "ok", + "data_call_name": "bgp-updates", + "data": { + "updates": [ + { "timestamp": "2026-08-01T04:00:00", "type": "A", "attrs": { "target_prefix": "203.0.113.0/24", "path": [3356, 64500] } }, + { "timestamp": "2026-08-01T04:05:00", "type": "W", "attrs": { "target_prefix": "203.0.113.0/24" } }, + { "timestamp": "2026-08-01T04:06:00", "type": "A", "attrs": { "target_prefix": "203.0.113.0/24", "path": [1299, 64500] } } + ], + "nr_updates": 3 + } +} diff --git a/tests/test_bgp.py b/tests/test_bgp.py new file mode 100644 index 0000000..72425a6 --- /dev/null +++ b/tests/test_bgp.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import httpx +import pytest + +from netcheck.bgp import ( + classify_stability, + parse_announced_prefixes, + parse_as_overview, + parse_asn_neighbours, + parse_bgp_updates, + ripestat, +) +from netcheck.config import Providers +from netcheck.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