mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 23:52:04 +03:00
bgp: ripestat parsers and bounded bulk queries
This commit is contained in:
parent
db055d8a85
commit
f269fb95d8
6 changed files with 251 additions and 0 deletions
104
src/netcheck/bgp.py
Normal file
104
src/netcheck/bgp.py
Normal file
|
|
@ -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()
|
||||||
11
tests/fixtures/api/ripestat_announced_prefixes.json
vendored
Normal file
11
tests/fixtures/api/ripestat_announced_prefixes.json
vendored
Normal file
|
|
@ -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": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tests/fixtures/api/ripestat_as_overview.json
vendored
Normal file
15
tests/fixtures/api/ripestat_as_overview.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/fixtures/api/ripestat_asn_neighbours.json
vendored
Normal file
13
tests/fixtures/api/ripestat_asn_neighbours.json
vendored
Normal file
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
tests/fixtures/api/ripestat_bgp_updates.json
vendored
Normal file
12
tests/fixtures/api/ripestat_bgp_updates.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
96
tests/test_bgp.py
Normal file
96
tests/test_bgp.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue