fix: stabilize earth bgp geography and rendering
This commit is contained in:
@@ -5,6 +5,7 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
@@ -18,7 +19,8 @@ from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph
|
||||
from app.services.bgp_enrichment import _lookup_prefix_geography
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
router = APIRouter()
|
||||
@@ -316,11 +318,16 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any]:
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
features = []
|
||||
geography_hints = geography_hints or {}
|
||||
|
||||
for record in records:
|
||||
evidence = record.evidence or {}
|
||||
hint = geography_hints.get(str(record.entity_key or record.id), {})
|
||||
collectors = evidence.get("collectors") or record.peer_scope or []
|
||||
if not collectors:
|
||||
nested = evidence.get("events") or []
|
||||
@@ -334,23 +341,6 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
||||
if not collectors:
|
||||
collectors = []
|
||||
|
||||
collector = collectors[0] if collectors else None
|
||||
location = None
|
||||
if collector:
|
||||
location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector))
|
||||
|
||||
if location is None:
|
||||
nested = evidence.get("events") or []
|
||||
for item in nested:
|
||||
collector_name = (item or {}).get("collector")
|
||||
if collector_name and collector_name in RIPE_RIS_COLLECTOR_COORDS:
|
||||
location = RIPE_RIS_COLLECTOR_COORDS[collector_name]
|
||||
collector = collector_name
|
||||
break
|
||||
|
||||
if location is None:
|
||||
continue
|
||||
|
||||
as_path = []
|
||||
if isinstance(evidence.get("as_path"), list):
|
||||
as_path = evidence.get("as_path") or []
|
||||
@@ -385,6 +375,28 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
||||
}
|
||||
)
|
||||
|
||||
geography_regions = _normalize_geo_regions(hint.get("regions") or [])
|
||||
geography_mode = hint.get("geography_mode") or "collector_centroid"
|
||||
|
||||
collector = collectors[0] if collectors else None
|
||||
location = geography_regions[0] if geography_regions else None
|
||||
|
||||
if location is None and collector:
|
||||
location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector))
|
||||
|
||||
if location is None:
|
||||
nested = evidence.get("events") or []
|
||||
for item in nested:
|
||||
collector_name = (item or {}).get("collector")
|
||||
if collector_name and collector_name in RIPE_RIS_COLLECTOR_COORDS:
|
||||
location = RIPE_RIS_COLLECTOR_COORDS[collector_name]
|
||||
collector = collector_name
|
||||
geography_mode = "collector_centroid"
|
||||
break
|
||||
|
||||
if location is None:
|
||||
continue
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
@@ -408,6 +420,7 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
||||
"collector_count": len(collectors) or 1,
|
||||
"as_path": as_path,
|
||||
"impacted_regions": impacted_regions,
|
||||
"geography_mode": geography_mode,
|
||||
"confidence": record.confidence,
|
||||
"summary": record.summary,
|
||||
"created_at": to_iso8601_utc(record.created_at),
|
||||
@@ -418,6 +431,54 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
async def build_anomaly_geography_hints(
|
||||
db: AsyncSession,
|
||||
records: List[BGPAnomaly],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
hints: Dict[str, Dict[str, Any]] = {}
|
||||
for record in records:
|
||||
evidence = record.evidence or {}
|
||||
key = str(record.entity_key or record.id)
|
||||
|
||||
prefix_geo_regions = []
|
||||
prefix_regions = []
|
||||
asn_regions = []
|
||||
evidence_prefix_geography = evidence.get("prefix_geography") or {}
|
||||
prefix_geo_regions.extend(
|
||||
_normalize_geo_regions(evidence_prefix_geography.get("regions") or [])
|
||||
)
|
||||
|
||||
prefix_scope = evidence.get("prefix_scope") or {}
|
||||
prefix_regions.extend(_normalize_geo_regions(prefix_scope.get("regions") or []))
|
||||
|
||||
for profile_key in ("origin_asn_profile", "new_origin_asn_profile"):
|
||||
profile = evidence.get(profile_key) or {}
|
||||
latitude = profile.get("latitude")
|
||||
longitude = profile.get("longitude")
|
||||
if isinstance(latitude, (int, float)) and isinstance(longitude, (int, float)):
|
||||
asn_regions.append(
|
||||
{
|
||||
"country": profile.get("country"),
|
||||
"city": profile.get("city"),
|
||||
"latitude": float(latitude),
|
||||
"longitude": float(longitude),
|
||||
}
|
||||
)
|
||||
|
||||
prefix_geo_regions = _normalize_geo_regions(prefix_geo_regions)
|
||||
prefix_regions = _normalize_geo_regions(prefix_regions)
|
||||
asn_regions = _normalize_geo_regions(asn_regions)
|
||||
|
||||
if prefix_geo_regions:
|
||||
hints[key] = {"regions": prefix_geo_regions, "geography_mode": "prefix_geography"}
|
||||
elif prefix_regions:
|
||||
hints[key] = {"regions": prefix_regions, "geography_mode": "prefix_scope"}
|
||||
elif asn_regions:
|
||||
hints[key] = {"regions": asn_regions, "geography_mode": "asn_region"}
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def convert_bgp_collectors_to_geojson(
|
||||
coverage_by_collector: Dict[str, Dict[str, Any]] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -442,8 +503,10 @@ def convert_bgp_collectors_to_geojson(
|
||||
"prefix_count": coverage.get("prefix_count", 0),
|
||||
"origin_asn_count": coverage.get("origin_asn_count", 0),
|
||||
"peer_asn_count": coverage.get("peer_asn_count", 0),
|
||||
"recent_15m_observation_count": coverage.get("recent_15m_observation_count", 0),
|
||||
"recent_24h_observation_count": coverage.get("recent_24h_observation_count", 0),
|
||||
"recent_7d_observation_count": coverage.get("recent_7d_observation_count", 0),
|
||||
"recent_15m_prefix_count": coverage.get("recent_15m_prefix_count", 0),
|
||||
"recent_24h_prefix_count": coverage.get("recent_24h_prefix_count", 0),
|
||||
"recent_7d_prefix_count": coverage.get("recent_7d_prefix_count", 0),
|
||||
"top_event_types": coverage.get("top_event_types", []),
|
||||
@@ -463,33 +526,183 @@ def convert_bgp_collectors_to_geojson(
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_bgp_incidents_to_geojson(records: List[BGPIncident]) -> Dict[str, Any]:
|
||||
def _incident_estimated_center(valid_regions: List[Dict[str, Any]]) -> Dict[str, float]:
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
z = 0.0
|
||||
for region in valid_regions:
|
||||
lat_rad = math.radians(float(region["latitude"]))
|
||||
lon_rad = math.radians(float(region["longitude"]))
|
||||
x += math.cos(lat_rad) * math.cos(lon_rad)
|
||||
y += math.cos(lat_rad) * math.sin(lon_rad)
|
||||
z += math.sin(lat_rad)
|
||||
|
||||
total = float(len(valid_regions))
|
||||
if total <= 0:
|
||||
return {"latitude": 0.0, "longitude": 0.0}
|
||||
|
||||
x /= total
|
||||
y /= total
|
||||
z /= total
|
||||
hyp = math.sqrt((x * x) + (y * y))
|
||||
if hyp == 0:
|
||||
return {"latitude": 0.0, "longitude": 0.0}
|
||||
|
||||
return {
|
||||
"latitude": math.degrees(math.atan2(z, hyp)),
|
||||
"longitude": math.degrees(math.atan2(y, x)),
|
||||
}
|
||||
|
||||
|
||||
def _incident_estimated_radius_km(center: Dict[str, float], valid_regions: List[Dict[str, Any]]) -> float:
|
||||
center_coords = (float(center["longitude"]), float(center["latitude"]))
|
||||
distances = [
|
||||
haversine_distance(
|
||||
center_coords,
|
||||
(float(region["longitude"]), float(region["latitude"])),
|
||||
)
|
||||
for region in valid_regions
|
||||
]
|
||||
return round(max(distances) if distances else 0.0, 1)
|
||||
|
||||
|
||||
def _normalize_geo_regions(regions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[Any, ...]] = set()
|
||||
for region in regions:
|
||||
if not isinstance(region, dict):
|
||||
continue
|
||||
latitude = region.get("latitude")
|
||||
longitude = region.get("longitude")
|
||||
if not isinstance(latitude, (int, float)) or not isinstance(longitude, (int, float)):
|
||||
continue
|
||||
item = {
|
||||
"collector": region.get("collector"),
|
||||
"country": region.get("country"),
|
||||
"city": region.get("city"),
|
||||
"latitude": float(latitude),
|
||||
"longitude": float(longitude),
|
||||
}
|
||||
key = (
|
||||
item["collector"],
|
||||
item["country"],
|
||||
item["city"],
|
||||
item["latitude"],
|
||||
item["longitude"],
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
async def build_incident_geography_hints(
|
||||
db: AsyncSession,
|
||||
records: List[BGPIncident],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
evidence_refs = sorted(
|
||||
{
|
||||
str(ref)
|
||||
for record in records
|
||||
for ref in (record.evidence_refs or [])
|
||||
if ref
|
||||
}
|
||||
)
|
||||
|
||||
anomalies = []
|
||||
if evidence_refs:
|
||||
result = await db.execute(
|
||||
select(BGPAnomaly).where(BGPAnomaly.entity_key.in_(evidence_refs))
|
||||
)
|
||||
anomalies = result.scalars().all()
|
||||
anomaly_by_key = {
|
||||
str(anomaly.entity_key): anomaly
|
||||
for anomaly in anomalies
|
||||
if anomaly.entity_key
|
||||
}
|
||||
|
||||
hints: Dict[str, Dict[str, Any]] = {}
|
||||
for record in records:
|
||||
prefix_geo_regions: list[dict[str, Any]] = []
|
||||
prefix_regions: list[dict[str, Any]] = []
|
||||
asn_regions: list[dict[str, Any]] = []
|
||||
|
||||
for ref in record.evidence_refs or []:
|
||||
anomaly = anomaly_by_key.get(str(ref))
|
||||
if anomaly is None:
|
||||
continue
|
||||
evidence = anomaly.evidence or {}
|
||||
if not prefix_geo_regions:
|
||||
prefix_geography = evidence.get("prefix_geography") or {}
|
||||
prefix_geo_regions.extend(_normalize_geo_regions(prefix_geography.get("regions") or []))
|
||||
prefix_scope = evidence.get("prefix_scope") or {}
|
||||
prefix_regions.extend(_normalize_geo_regions(prefix_scope.get("regions") or []))
|
||||
|
||||
for key in ("origin_asn_profile", "new_origin_asn_profile"):
|
||||
profile = evidence.get(key) or {}
|
||||
latitude = profile.get("latitude")
|
||||
longitude = profile.get("longitude")
|
||||
if isinstance(latitude, (int, float)) and isinstance(longitude, (int, float)):
|
||||
asn_regions.append(
|
||||
{
|
||||
"country": profile.get("country"),
|
||||
"city": profile.get("city"),
|
||||
"latitude": float(latitude),
|
||||
"longitude": float(longitude),
|
||||
}
|
||||
)
|
||||
|
||||
prefix_geo_regions = _normalize_geo_regions(prefix_geo_regions)
|
||||
prefix_regions = _normalize_geo_regions(prefix_regions)
|
||||
asn_regions = _normalize_geo_regions(asn_regions)
|
||||
if prefix_geo_regions:
|
||||
hints[record.incident_key] = {
|
||||
"regions": prefix_geo_regions,
|
||||
"geography_mode": "prefix_geography",
|
||||
}
|
||||
elif prefix_regions:
|
||||
hints[record.incident_key] = {
|
||||
"regions": prefix_regions,
|
||||
"geography_mode": "prefix_scope",
|
||||
}
|
||||
elif asn_regions:
|
||||
hints[record.incident_key] = {
|
||||
"regions": asn_regions,
|
||||
"geography_mode": "asn_region",
|
||||
}
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def convert_bgp_incidents_to_geojson(
|
||||
records: List[BGPIncident],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
regions = record.affected_regions or []
|
||||
hint = (geography_hints or {}).get(record.incident_key, {})
|
||||
regions = hint.get("regions") or (record.affected_regions or [])
|
||||
if not regions:
|
||||
continue
|
||||
|
||||
valid_regions = [
|
||||
region
|
||||
for region in regions
|
||||
if isinstance(region, dict)
|
||||
and isinstance(region.get("latitude"), (int, float))
|
||||
and isinstance(region.get("longitude"), (int, float))
|
||||
]
|
||||
valid_regions = _normalize_geo_regions(regions)
|
||||
if not valid_regions:
|
||||
continue
|
||||
|
||||
avg_lat = sum(float(region["latitude"]) for region in valid_regions) / len(valid_regions)
|
||||
avg_lon = sum(float(region["longitude"]) for region in valid_regions) / len(valid_regions)
|
||||
estimated_center = _incident_estimated_center(valid_regions)
|
||||
estimated_radius_km = _incident_estimated_radius_km(estimated_center, valid_regions)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [avg_lon, avg_lat],
|
||||
"coordinates": [
|
||||
estimated_center["longitude"],
|
||||
estimated_center["latitude"],
|
||||
],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
@@ -504,6 +717,9 @@ def convert_bgp_incidents_to_geojson(records: List[BGPIncident]) -> Dict[str, An
|
||||
"affected_asns": record.affected_asns or [],
|
||||
"affected_collectors": record.affected_collectors or [],
|
||||
"affected_regions": valid_regions,
|
||||
"estimated_center": estimated_center,
|
||||
"estimated_radius_km": estimated_radius_km,
|
||||
"geography_mode": hint.get("geography_mode") or "collector_centroid",
|
||||
"related_cables": record.related_cables or [],
|
||||
"related_ixps": record.related_ixps or [],
|
||||
"created_at": to_iso8601_utc(record.created_at),
|
||||
@@ -739,7 +955,8 @@ async def get_bgp_anomalies_geojson(
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = list(result.scalars().all())
|
||||
geojson = convert_bgp_anomalies_to_geojson(records)
|
||||
geography_hints = await build_anomaly_geography_hints(db, records)
|
||||
geojson = convert_bgp_anomalies_to_geojson(records, geography_hints)
|
||||
return {**geojson, "count": len(geojson.get("features", []))}
|
||||
|
||||
|
||||
@@ -758,7 +975,8 @@ async def get_bgp_incidents_geojson(
|
||||
|
||||
result = await db.execute(stmt)
|
||||
records = list(result.scalars().all())
|
||||
geojson = convert_bgp_incidents_to_geojson(records)
|
||||
geography_hints = await build_incident_geography_hints(db, records)
|
||||
geojson = convert_bgp_incidents_to_geojson(records, geography_hints)
|
||||
return {**geojson, "count": len(geojson.get("features", []))}
|
||||
|
||||
|
||||
|
||||
@@ -232,6 +232,30 @@ for canonical, aliases in COUNTRY_ENTRIES:
|
||||
COUNTRY_ALIAS_MAP[alias.casefold()] = canonical
|
||||
|
||||
|
||||
COUNTRY_CENTROIDS = {
|
||||
"美国": {"latitude": 39.8283, "longitude": -98.5795},
|
||||
"英国": {"latitude": 55.3781, "longitude": -3.4360},
|
||||
"荷兰": {"latitude": 52.1326, "longitude": 5.2913},
|
||||
"日本": {"latitude": 36.2048, "longitude": 138.2529},
|
||||
"德国": {"latitude": 51.1657, "longitude": 10.4515},
|
||||
"法国": {"latitude": 46.2276, "longitude": 2.2137},
|
||||
"新加坡": {"latitude": 1.3521, "longitude": 103.8198},
|
||||
"中国": {"latitude": 35.8617, "longitude": 104.1954},
|
||||
"中国(香港)": {"latitude": 22.3193, "longitude": 114.1694},
|
||||
"中国(台湾)": {"latitude": 23.6978, "longitude": 120.9605},
|
||||
"韩国": {"latitude": 35.9078, "longitude": 127.7669},
|
||||
"俄罗斯": {"latitude": 61.5240, "longitude": 105.3188},
|
||||
"加拿大": {"latitude": 56.1304, "longitude": -106.3468},
|
||||
"澳大利亚": {"latitude": -25.2744, "longitude": 133.7751},
|
||||
"巴西": {"latitude": -14.2350, "longitude": -51.9253},
|
||||
"南非": {"latitude": -30.5595, "longitude": 22.9375},
|
||||
"西班牙": {"latitude": 40.4637, "longitude": -3.7492},
|
||||
"意大利": {"latitude": 41.8719, "longitude": 12.5674},
|
||||
"瑞士": {"latitude": 46.8182, "longitude": 8.2275},
|
||||
"阿联酋": {"latitude": 23.4241, "longitude": 53.8478},
|
||||
}
|
||||
|
||||
|
||||
def normalize_country(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -258,6 +282,13 @@ def normalize_country(value: Any) -> Optional[str]:
|
||||
return COUNTRY_ALIAS_MAP.get(lowered)
|
||||
|
||||
|
||||
def get_country_centroid(value: Any) -> Optional[dict[str, float]]:
|
||||
canonical = normalize_country(value)
|
||||
if not canonical:
|
||||
return None
|
||||
return COUNTRY_CENTROIDS.get(canonical)
|
||||
|
||||
|
||||
def get_country_search_variants(value: Any) -> list[str]:
|
||||
canonical = normalize_country(value)
|
||||
if canonical is None:
|
||||
|
||||
@@ -25,6 +25,7 @@ COLLECTOR_URL_KEYS = {
|
||||
"spacetrack_tle": "spacetrack.tle_query_url",
|
||||
"ris_live_bgp": "ris_live.url",
|
||||
"bgpstream_bgp": "bgpstream.url",
|
||||
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,3 +43,6 @@ ris_live:
|
||||
|
||||
bgpstream:
|
||||
url: "https://broker.bgpstream.caida.org/v2"
|
||||
|
||||
iptoasn:
|
||||
combined_url: "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
|
||||
|
||||
@@ -134,6 +134,13 @@ DEFAULT_DATASOURCES = {
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 360,
|
||||
},
|
||||
"iptoasn_prefix_geo": {
|
||||
"id": 23,
|
||||
"name": "IPtoASN Prefix Geography",
|
||||
"module": "L3",
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 1440,
|
||||
},
|
||||
}
|
||||
|
||||
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||
|
||||
@@ -20,6 +20,7 @@ async def build_bgp_collector_coverage(
|
||||
source_filter: tuple[str, ...] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
now = datetime.now(UTC)
|
||||
recent_15m_threshold = now - timedelta(minutes=15)
|
||||
recent_24h_threshold = now - timedelta(hours=24)
|
||||
recent_7d_threshold = now - timedelta(days=7)
|
||||
|
||||
@@ -52,8 +53,10 @@ async def build_bgp_collector_coverage(
|
||||
"event_types": defaultdict(int),
|
||||
"countries": set(),
|
||||
"cities": set(),
|
||||
"recent_15m_observation_count": 0,
|
||||
"recent_24h_observation_count": 0,
|
||||
"recent_7d_observation_count": 0,
|
||||
"recent_15m_prefixes": set(),
|
||||
"recent_24h_prefixes": set(),
|
||||
"recent_7d_prefixes": set(),
|
||||
"latest_observed_at": None,
|
||||
@@ -78,6 +81,10 @@ async def build_bgp_collector_coverage(
|
||||
if observed_at.tzinfo
|
||||
else observed_at.replace(tzinfo=UTC)
|
||||
)
|
||||
if aware_observed_at >= recent_15m_threshold:
|
||||
coverage["recent_15m_observation_count"] += 1
|
||||
if record.prefix:
|
||||
coverage["recent_15m_prefixes"].add(record.prefix)
|
||||
if aware_observed_at >= recent_24h_threshold:
|
||||
coverage["recent_24h_observation_count"] += 1
|
||||
if record.prefix:
|
||||
@@ -116,8 +123,10 @@ async def build_bgp_collector_coverage(
|
||||
"event_types": defaultdict(int),
|
||||
"countries": {location.get("country")} if location.get("country") else set(),
|
||||
"cities": {location.get("city")} if location.get("city") else set(),
|
||||
"recent_15m_observation_count": 0,
|
||||
"recent_24h_observation_count": 0,
|
||||
"recent_7d_observation_count": 0,
|
||||
"recent_15m_prefixes": set(),
|
||||
"recent_24h_prefixes": set(),
|
||||
"recent_7d_prefixes": set(),
|
||||
"latest_observed_at": None,
|
||||
@@ -142,8 +151,10 @@ async def build_bgp_collector_coverage(
|
||||
"prefix_count": len(item["prefixes"]),
|
||||
"origin_asn_count": len(item["origin_asns"]),
|
||||
"peer_asn_count": len(item["peer_asns"]),
|
||||
"recent_15m_observation_count": item["recent_15m_observation_count"],
|
||||
"recent_24h_observation_count": item["recent_24h_observation_count"],
|
||||
"recent_7d_observation_count": item["recent_7d_observation_count"],
|
||||
"recent_15m_prefix_count": len(item["recent_15m_prefixes"]),
|
||||
"recent_24h_prefix_count": len(item["recent_24h_prefixes"]),
|
||||
"recent_7d_prefix_count": len(item["recent_7d_prefixes"]),
|
||||
"top_event_types": [
|
||||
|
||||
@@ -55,6 +55,11 @@ def _unique_peers(events: list[dict[str, Any]]) -> list[int]:
|
||||
return sorted(peers)
|
||||
|
||||
|
||||
def _path_signature(metadata: dict[str, Any]) -> tuple[int, ...]:
|
||||
path = metadata.get("as_path") or []
|
||||
return tuple(int(asn) for asn in path if asn is not None)
|
||||
|
||||
|
||||
def detect_origin_change_anomalies(
|
||||
*,
|
||||
source: str,
|
||||
@@ -100,6 +105,7 @@ def detect_origin_change_anomalies(
|
||||
)
|
||||
sample_metadata = sample_event.get("metadata") or {}
|
||||
sample_enrichment = sample_metadata.get("enrichment") or {}
|
||||
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
|
||||
anomaly_type = "origin_change"
|
||||
severity = "critical"
|
||||
confidence = 0.86
|
||||
@@ -140,8 +146,10 @@ def detect_origin_change_anomalies(
|
||||
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
|
||||
"new_origin_asn_profile": sample_enrichment.get("new_origin_asn_profile"),
|
||||
"rpki_validation": sample_enrichment.get("rpki_validation"),
|
||||
"prefix_geography": sample_prefix_geography,
|
||||
"prefix_scope": sample_enrichment.get("prefix_scope"),
|
||||
"impacted_regions": related_regions
|
||||
"impacted_regions": sample_prefix_geography.get("regions")
|
||||
or related_regions
|
||||
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
|
||||
},
|
||||
)
|
||||
@@ -180,6 +188,7 @@ def detect_more_specific_burst_anomalies(
|
||||
|
||||
sample = more_specifics[0].get("metadata") or {}
|
||||
sample_enrichment = sample.get("enrichment") or {}
|
||||
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
|
||||
event_count = len(more_specifics)
|
||||
anomalies.append(
|
||||
BGPAnomaly(
|
||||
@@ -205,8 +214,10 @@ def detect_more_specific_burst_anomalies(
|
||||
"unique_prefixes": unique_prefixes,
|
||||
"rpki_validation": sample_enrichment.get("rpki_validation"),
|
||||
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
|
||||
"prefix_geography": sample_prefix_geography,
|
||||
"prefix_scope": sample_enrichment.get("prefix_scope"),
|
||||
"impacted_regions": _iter_event_regions(more_specifics)
|
||||
"impacted_regions": sample_prefix_geography.get("regions")
|
||||
or _iter_event_regions(more_specifics)
|
||||
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
|
||||
},
|
||||
)
|
||||
@@ -242,6 +253,7 @@ def detect_mass_withdrawal_anomalies(
|
||||
sample_event = related_events[0] if related_events else {}
|
||||
sample_metadata = sample_event.get("metadata") or {}
|
||||
sample_enrichment = sample_metadata.get("enrichment") or {}
|
||||
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
|
||||
severity = "medium"
|
||||
if count >= 4 or len(related_collectors) >= 3:
|
||||
severity = "high"
|
||||
@@ -277,8 +289,175 @@ def detect_mass_withdrawal_anomalies(
|
||||
],
|
||||
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
|
||||
"rpki_validation": sample_enrichment.get("rpki_validation"),
|
||||
"prefix_geography": sample_prefix_geography,
|
||||
"prefix_scope": sample_enrichment.get("prefix_scope"),
|
||||
"impacted_regions": _iter_event_regions(related_events)
|
||||
"impacted_regions": sample_prefix_geography.get("regions")
|
||||
or _iter_event_regions(related_events)
|
||||
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def detect_route_leak_anomalies(
|
||||
*,
|
||||
source: str,
|
||||
snapshot_id: int | None,
|
||||
task_id: int | None,
|
||||
events: list[dict[str, Any]],
|
||||
) -> list[BGPAnomaly]:
|
||||
events_by_prefix: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for event in events:
|
||||
metadata = event.get("metadata") or {}
|
||||
prefix = metadata.get("prefix")
|
||||
if prefix and metadata.get("event_type") == "announcement":
|
||||
events_by_prefix[str(prefix)].append(event)
|
||||
|
||||
anomalies: list[BGPAnomaly] = []
|
||||
for prefix, related_events in events_by_prefix.items():
|
||||
related_collectors = _unique_collectors(related_events)
|
||||
if len(related_collectors) < 2:
|
||||
continue
|
||||
|
||||
path_signatures = Counter()
|
||||
max_path_length = 0
|
||||
for event in related_events:
|
||||
metadata = event.get("metadata") or {}
|
||||
signature = _path_signature(metadata)
|
||||
if signature:
|
||||
path_signatures[signature] += 1
|
||||
max_path_length = max(max_path_length, len(signature))
|
||||
|
||||
if len(path_signatures) < 2:
|
||||
continue
|
||||
|
||||
dominant_length = len(path_signatures.most_common(1)[0][0])
|
||||
if max_path_length < max(dominant_length + 2, 5):
|
||||
continue
|
||||
|
||||
sample_event = max(
|
||||
related_events,
|
||||
key=lambda event: len(_path_signature((event.get("metadata") or {}))),
|
||||
)
|
||||
sample_metadata = sample_event.get("metadata") or {}
|
||||
sample_enrichment = sample_metadata.get("enrichment") or {}
|
||||
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
|
||||
peer_scope = related_collectors
|
||||
path_lengths = sorted({len(signature) for signature in path_signatures if signature})
|
||||
|
||||
anomalies.append(
|
||||
BGPAnomaly(
|
||||
snapshot_id=snapshot_id,
|
||||
task_id=task_id,
|
||||
source=source,
|
||||
anomaly_type="route_leak_candidate",
|
||||
severity="high" if max_path_length >= dominant_length + 3 else "medium",
|
||||
status="active",
|
||||
entity_key=f"route_leak_candidate:{prefix}:{max_path_length}:{len(related_collectors)}",
|
||||
prefix=prefix,
|
||||
origin_asn=sample_metadata.get("origin_asn"),
|
||||
new_origin_asn=None,
|
||||
peer_scope=peer_scope,
|
||||
started_at=datetime.now(UTC),
|
||||
confidence=min(0.58 + (0.05 * min(len(related_collectors), 4)) + (0.03 * min(max_path_length - dominant_length, 4)), 0.88),
|
||||
summary=(
|
||||
f"Prefix {prefix} shows divergent long AS paths across "
|
||||
f"{len(related_collectors)} collectors, suggesting a possible route leak."
|
||||
),
|
||||
evidence={
|
||||
"path_lengths": path_lengths,
|
||||
"dominant_path_length": dominant_length,
|
||||
"max_path_length": max_path_length,
|
||||
"path_signatures": [
|
||||
{"path": list(signature), "count": count}
|
||||
for signature, count in path_signatures.most_common(5)
|
||||
],
|
||||
"events": [(item.get("metadata") or {}) for item in related_events[:10]],
|
||||
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
|
||||
"rpki_validation": sample_enrichment.get("rpki_validation"),
|
||||
"prefix_geography": sample_prefix_geography,
|
||||
"prefix_scope": sample_enrichment.get("prefix_scope"),
|
||||
"impacted_regions": sample_prefix_geography.get("regions")
|
||||
or _iter_event_regions(related_events)
|
||||
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def detect_path_flap_anomalies(
|
||||
*,
|
||||
source: str,
|
||||
snapshot_id: int | None,
|
||||
task_id: int | None,
|
||||
events: list[dict[str, Any]],
|
||||
) -> list[BGPAnomaly]:
|
||||
events_by_prefix: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for event in events:
|
||||
metadata = event.get("metadata") or {}
|
||||
prefix = metadata.get("prefix")
|
||||
if prefix:
|
||||
events_by_prefix[str(prefix)].append(event)
|
||||
|
||||
anomalies: list[BGPAnomaly] = []
|
||||
for prefix, related_events in events_by_prefix.items():
|
||||
ordered = sorted(
|
||||
related_events,
|
||||
key=lambda event: str((event.get("metadata") or {}).get("timestamp") or ""),
|
||||
)
|
||||
event_types = [str((item.get("metadata") or {}).get("event_type") or "") for item in ordered]
|
||||
transitions = sum(1 for index in range(1, len(event_types)) if event_types[index] != event_types[index - 1])
|
||||
distinct_paths = {
|
||||
_path_signature(item.get("metadata") or {})
|
||||
for item in ordered
|
||||
if _path_signature(item.get("metadata") or {})
|
||||
}
|
||||
related_collectors = _unique_collectors(ordered)
|
||||
|
||||
if transitions < 3 and len(distinct_paths) < 3:
|
||||
continue
|
||||
|
||||
sample_metadata = (ordered[0].get("metadata") or {}) if ordered else {}
|
||||
sample_enrichment = sample_metadata.get("enrichment") or {}
|
||||
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
|
||||
severity = "medium"
|
||||
if transitions >= 5 or len(distinct_paths) >= 4:
|
||||
severity = "high"
|
||||
|
||||
anomalies.append(
|
||||
BGPAnomaly(
|
||||
snapshot_id=snapshot_id,
|
||||
task_id=task_id,
|
||||
source=source,
|
||||
anomaly_type="path_flap",
|
||||
severity=severity,
|
||||
status="active",
|
||||
entity_key=f"path_flap:{prefix}:{transitions}:{len(distinct_paths)}",
|
||||
prefix=prefix,
|
||||
origin_asn=sample_metadata.get("origin_asn"),
|
||||
new_origin_asn=None,
|
||||
peer_scope=related_collectors,
|
||||
started_at=datetime.now(UTC),
|
||||
confidence=min(0.54 + (0.05 * min(transitions, 5)) + (0.03 * min(len(distinct_paths), 4)), 0.9),
|
||||
summary=(
|
||||
f"Prefix {prefix} shows repeated state/path changes "
|
||||
f"({transitions} transitions, {len(distinct_paths)} distinct paths) in the current window."
|
||||
),
|
||||
evidence={
|
||||
"transitions": transitions,
|
||||
"event_types": event_types[:12],
|
||||
"distinct_paths": [list(path) for path in list(distinct_paths)[:6]],
|
||||
"events": [(item.get("metadata") or {}) for item in ordered[:10]],
|
||||
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
|
||||
"rpki_validation": sample_enrichment.get("rpki_validation"),
|
||||
"prefix_geography": sample_prefix_geography,
|
||||
"prefix_scope": sample_enrichment.get("prefix_scope"),
|
||||
"impacted_regions": sample_prefix_geography.get("regions")
|
||||
or _iter_event_regions(ordered)
|
||||
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import get_country_centroid, normalize_country
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
@@ -96,6 +97,83 @@ def extract_bgp_network_fields(prefix: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def _lookup_prefix_geography(
|
||||
db: AsyncSession,
|
||||
prefix_values: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for prefix in prefix_values:
|
||||
try:
|
||||
network = ipaddress.ip_network(prefix, strict=False)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
family = f"ipv{network.version}"
|
||||
range_start = str(network.network_address)
|
||||
range_end = str(network.broadcast_address)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT metadata
|
||||
FROM collected_data
|
||||
WHERE source = 'iptoasn_prefix_geo'
|
||||
AND COALESCE(is_current, TRUE) = TRUE
|
||||
AND metadata->>'family' = :family
|
||||
AND CAST(metadata->>'range_start' AS inet) <= CAST(:range_start AS inet)
|
||||
AND CAST(metadata->>'range_end' AS inet) >= CAST(:range_end AS inet)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"family": family,
|
||||
"range_start": range_start,
|
||||
"range_end": range_end,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
|
||||
if isinstance(row, dict):
|
||||
payload = row.get("metadata") or row.get("extra_data")
|
||||
elif hasattr(row, "_mapping"):
|
||||
payload = row._mapping.get("metadata") or row._mapping.get("extra_data")
|
||||
else:
|
||||
payload = row[0]
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
country = normalize_country(payload.get("country") or payload.get("country_code"))
|
||||
prefix_hint = payload.get("prefix") or prefix
|
||||
asn = _safe_int(payload.get("asn"))
|
||||
as_name = payload.get("as_name")
|
||||
centroid = get_country_centroid(country)
|
||||
regions = []
|
||||
if country:
|
||||
regions.append(
|
||||
{
|
||||
"country": country,
|
||||
"city": None,
|
||||
"latitude": centroid.get("latitude") if centroid else None,
|
||||
"longitude": centroid.get("longitude") if centroid else None,
|
||||
}
|
||||
)
|
||||
|
||||
results[prefix] = {
|
||||
"prefix": prefix_hint,
|
||||
"country": country,
|
||||
"asn": asn,
|
||||
"as_name": as_name,
|
||||
"source": payload.get("source_dataset") or "iptoasn_combined",
|
||||
"confidence": "country_range",
|
||||
"regions": regions,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def enrich_bgp_events_for_batch(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -165,6 +243,7 @@ async def enrich_bgp_events_for_batch(
|
||||
}
|
||||
|
||||
asn_profiles: dict[int, dict[str, Any]] = {}
|
||||
prefix_geographies = await _lookup_prefix_geography(db, prefix_values) if prefix_values else {}
|
||||
if origin_asns:
|
||||
peeringdb_result = await db.execute(
|
||||
select(CollectedData).where(CollectedData.source == "peeringdb_network")
|
||||
@@ -207,21 +286,12 @@ async def enrich_bgp_events_for_batch(
|
||||
collector = str(metadata.get("collector") or "").strip()
|
||||
collector_location = metadata.get("collector_location") or {}
|
||||
baseline = historical_prefix_baseline.get(prefix, {})
|
||||
prefix_geography = prefix_geographies.get(prefix)
|
||||
observed_at = _parse_timestamp(metadata.get("timestamp") or event.get("reference_date"))
|
||||
origin_asn = _safe_int(metadata.get("origin_asn"))
|
||||
new_origin_asn = _safe_int(metadata.get("new_origin_asn"))
|
||||
observed_regions = _compact_locations(
|
||||
[
|
||||
{
|
||||
"country": collector_location.get("country"),
|
||||
"city": collector_location.get("city"),
|
||||
"latitude": collector_location.get("latitude"),
|
||||
"longitude": collector_location.get("longitude"),
|
||||
}
|
||||
]
|
||||
)
|
||||
baseline_regions = baseline.get("historical_regions", [])
|
||||
prefix_scope_regions = _compact_locations([*observed_regions, *baseline_regions])
|
||||
prefix_scope_regions = _compact_locations([*baseline_regions])
|
||||
|
||||
enrichment = {
|
||||
**extract_bgp_network_fields(prefix),
|
||||
@@ -248,6 +318,7 @@ async def enrich_bgp_events_for_batch(
|
||||
},
|
||||
"origin_asn_profile": asn_profiles.get(origin_asn),
|
||||
"new_origin_asn_profile": asn_profiles.get(new_origin_asn),
|
||||
"prefix_geography": prefix_geography,
|
||||
"prefix_scope": {
|
||||
"countries": sorted(
|
||||
{
|
||||
|
||||
@@ -209,15 +209,14 @@ async def create_bgp_incidents_for_anomalies(
|
||||
grouped.setdefault(incident_key, []).append(anomaly)
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(BGPIncident.incident_key).where(BGPIncident.incident_key.in_(sorted(grouped.keys())))
|
||||
select(BGPIncident).where(BGPIncident.incident_key.in_(sorted(grouped.keys())))
|
||||
)
|
||||
existing_keys = {row[0] for row in existing_result.fetchall()}
|
||||
existing_incidents = {
|
||||
incident.incident_key: incident for incident in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
created = 0
|
||||
for incident_key, items in grouped.items():
|
||||
if incident_key in existing_keys:
|
||||
continue
|
||||
|
||||
items = sorted(items, key=lambda item: item.created_at or item.started_at or datetime.now(UTC))
|
||||
primary = items[0]
|
||||
prefixes = sorted({item.prefix for item in items if item.prefix})
|
||||
@@ -270,6 +269,28 @@ async def create_bgp_incidents_for_anomalies(
|
||||
)
|
||||
related_infrastructure = await infer_related_infrastructure(db, regions)
|
||||
|
||||
existing = existing_incidents.get(incident_key)
|
||||
if existing is not None:
|
||||
existing.snapshot_id = snapshot_id
|
||||
existing.task_id = task_id
|
||||
existing.source = source
|
||||
existing.incident_type = primary.anomaly_type
|
||||
existing.title = title
|
||||
existing.summary = summary
|
||||
existing.severity = severity
|
||||
existing.status = "active"
|
||||
existing.confidence = confidence
|
||||
existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC)
|
||||
existing.ended_at = None
|
||||
existing.affected_prefixes = prefixes
|
||||
existing.affected_asns = asns
|
||||
existing.affected_collectors = collectors
|
||||
existing.affected_regions = regions
|
||||
existing.related_cables = related_infrastructure["related_cables"]
|
||||
existing.related_ixps = related_infrastructure["related_ixps"]
|
||||
existing.evidence_refs = evidence_refs
|
||||
continue
|
||||
|
||||
db.add(
|
||||
BGPIncident(
|
||||
snapshot_id=snapshot_id,
|
||||
@@ -294,7 +315,7 @@ async def create_bgp_incidents_for_anomalies(
|
||||
)
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
if created or existing_incidents:
|
||||
await db.commit()
|
||||
|
||||
return created
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.services.collectors.spacetrack import SpaceTrackTLECollector
|
||||
from app.services.collectors.celestrak import CelesTrakTLECollector
|
||||
from app.services.collectors.ris_live import RISLiveCollector
|
||||
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
|
||||
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
||||
|
||||
collector_registry.register(TOP500Collector())
|
||||
collector_registry.register(EpochAIGPUCollector())
|
||||
@@ -55,3 +56,4 @@ collector_registry.register(SpaceTrackTLECollector())
|
||||
collector_registry.register(CelesTrakTLECollector())
|
||||
collector_registry.register(RISLiveCollector())
|
||||
collector_registry.register(BGPStreamBackfillCollector())
|
||||
collector_registry.register(IPtoASNPrefixGeoCollector())
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.services.bgp_detectors import (
|
||||
detect_mass_withdrawal_anomalies,
|
||||
detect_more_specific_burst_anomalies,
|
||||
detect_origin_change_anomalies,
|
||||
detect_path_flap_anomalies,
|
||||
detect_route_leak_anomalies,
|
||||
)
|
||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||
|
||||
@@ -282,6 +284,18 @@ async def create_bgp_anomalies_for_batch(
|
||||
task_id=task_id,
|
||||
events=enriched_events,
|
||||
),
|
||||
*detect_route_leak_anomalies(
|
||||
source=source,
|
||||
snapshot_id=snapshot_id,
|
||||
task_id=task_id,
|
||||
events=enriched_events,
|
||||
),
|
||||
*detect_path_flap_anomalies(
|
||||
source=source,
|
||||
snapshot_id=snapshot_id,
|
||||
task_id=task_id,
|
||||
events=enriched_events,
|
||||
),
|
||||
]
|
||||
|
||||
if not pending_anomalies:
|
||||
@@ -302,16 +316,29 @@ async def create_bgp_anomalies_for_batch(
|
||||
|
||||
created = 0
|
||||
created_anomalies: list[BGPAnomaly] = []
|
||||
refreshed_anomalies: list[BGPAnomaly] = []
|
||||
existing_map = {item.entity_key: item for item in existing_anomalies if item.entity_key}
|
||||
for anomaly in pending_anomalies:
|
||||
if anomaly.entity_key in existing_keys:
|
||||
existing = existing_map.get(anomaly.entity_key)
|
||||
if existing is not None:
|
||||
existing.severity = anomaly.severity
|
||||
existing.status = anomaly.status
|
||||
existing.summary = anomaly.summary
|
||||
existing.confidence = anomaly.confidence
|
||||
existing.peer_scope = anomaly.peer_scope
|
||||
existing.evidence = anomaly.evidence
|
||||
existing.new_origin_asn = anomaly.new_origin_asn
|
||||
existing.origin_asn = anomaly.origin_asn
|
||||
refreshed_anomalies.append(existing)
|
||||
continue
|
||||
db.add(anomaly)
|
||||
created_anomalies.append(anomaly)
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
if created or refreshed_anomalies:
|
||||
await db.commit()
|
||||
incident_seed_anomalies = [*created_anomalies, *existing_anomalies]
|
||||
incident_seed_anomalies = [*created_anomalies, *refreshed_anomalies]
|
||||
if incident_seed_anomalies:
|
||||
await create_bgp_incidents_for_anomalies(
|
||||
db,
|
||||
|
||||
111
backend/app/services/collectors/iptoasn.py
Normal file
111
backend/app/services/collectors/iptoasn.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""IPtoASN prefix geography collector.
|
||||
|
||||
Downloads the public combined IPv4+IPv6 TSV database and stores coarse
|
||||
prefix-to-country/ASN geography hints for BGP enrichment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from datetime import UTC, datetime
|
||||
from ipaddress import summarize_address_range, ip_address
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
class IPtoASNPrefixGeoCollector(BaseCollector):
|
||||
name = "iptoasn_prefix_geo"
|
||||
priority = "P1"
|
||||
module = "L3"
|
||||
frequency_hours = 24
|
||||
data_type = "prefix_geography"
|
||||
fail_on_empty = True
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
if not self._resolved_url:
|
||||
raise RuntimeError("IPtoASN combined URL is not configured")
|
||||
|
||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
self._resolved_url,
|
||||
headers={
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/gzip,application/octet-stream,*/*",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = gzip.decompress(response.content).decode("utf-8", errors="replace")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for raw_line in body.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
range_start, range_end, asn, country_code, as_name = parts[:5]
|
||||
rows.append(
|
||||
{
|
||||
"range_start": range_start,
|
||||
"range_end": range_end,
|
||||
"asn": asn,
|
||||
"country_code": country_code,
|
||||
"as_name": as_name,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
reference_date = datetime.now(UTC).isoformat()
|
||||
transformed: list[dict[str, Any]] = []
|
||||
|
||||
for item in raw_data:
|
||||
try:
|
||||
start_ip = ip_address(str(item["range_start"]))
|
||||
end_ip = ip_address(str(item["range_end"]))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if start_ip.version != end_ip.version:
|
||||
continue
|
||||
|
||||
summarized = list(summarize_address_range(start_ip, end_ip))
|
||||
primary_prefix = str(summarized[0]) if summarized else f"{start_ip}/{32 if start_ip.version == 4 else 128}"
|
||||
family = f"ipv{start_ip.version}"
|
||||
|
||||
asn_value = item.get("asn")
|
||||
try:
|
||||
normalized_asn = int(str(asn_value))
|
||||
except (TypeError, ValueError):
|
||||
normalized_asn = None
|
||||
|
||||
transformed.append(
|
||||
{
|
||||
"source_id": f"{family}:{item['range_start']}-{item['range_end']}",
|
||||
"name": primary_prefix,
|
||||
"title": f"{primary_prefix} {item.get('country_code', '').strip()}".strip(),
|
||||
"country": item.get("country_code"),
|
||||
"city": "",
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"metadata": {
|
||||
"family": family,
|
||||
"range_start": item["range_start"],
|
||||
"range_end": item["range_end"],
|
||||
"prefix": primary_prefix,
|
||||
"prefixes": [str(prefix) for prefix in summarized[:8]],
|
||||
"range_prefix_count": len(summarized),
|
||||
"country_code": item.get("country_code"),
|
||||
"asn": normalized_asn,
|
||||
"as_name": item.get("as_name"),
|
||||
"source_dataset": "iptoasn_combined",
|
||||
},
|
||||
"reference_date": reference_date,
|
||||
}
|
||||
)
|
||||
|
||||
return transformed
|
||||
@@ -13,6 +13,8 @@ from app.main import app
|
||||
from app.services.bgp_detectors import (
|
||||
detect_mass_withdrawal_anomalies,
|
||||
detect_origin_change_anomalies,
|
||||
detect_path_flap_anomalies,
|
||||
detect_route_leak_anomalies,
|
||||
)
|
||||
from app.services.collectors.bgp_common import (
|
||||
create_bgp_anomalies_for_batch,
|
||||
@@ -24,6 +26,7 @@ from app.services.bgp_incidents import (
|
||||
infer_related_infrastructure,
|
||||
)
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.api.v1.visualization import convert_bgp_incidents_to_geojson, build_incident_geography_hints
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
@@ -31,6 +34,7 @@ from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.collectors.bgp_common import normalize_bgp_event
|
||||
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
|
||||
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
@@ -51,6 +55,9 @@ class _FakeResult:
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
def __init__(self, results, gets=None):
|
||||
@@ -59,7 +66,7 @@ class _FakeAsyncSession:
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
async def execute(self, _stmt):
|
||||
async def execute(self, _stmt, _params=None):
|
||||
if not self._results:
|
||||
return _FakeResult([])
|
||||
return _FakeResult(self._results.pop(0))
|
||||
@@ -140,6 +147,29 @@ def test_bgpstream_transform_preserves_broker_record():
|
||||
assert record["metadata"]["broker_record"]["filename"] == "rib.20260326.0800.gz"
|
||||
|
||||
|
||||
def test_iptoasn_transform_creates_prefix_geography_records():
|
||||
collector = IPtoASNPrefixGeoCollector()
|
||||
transformed = collector.transform(
|
||||
[
|
||||
{
|
||||
"range_start": "1.0.0.0",
|
||||
"range_end": "1.0.0.255",
|
||||
"asn": "13335",
|
||||
"country_code": "AU",
|
||||
"as_name": "CLOUDFLARENET",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(transformed) == 1
|
||||
record = transformed[0]
|
||||
assert record["name"] == "1.0.0.0/24"
|
||||
assert record["metadata"]["family"] == "ipv4"
|
||||
assert record["metadata"]["country_code"] == "AU"
|
||||
assert record["metadata"]["asn"] == 13335
|
||||
assert record["metadata"]["source_dataset"] == "iptoasn_combined"
|
||||
|
||||
|
||||
def test_bgp_anomaly_to_dict():
|
||||
anomaly = BGPAnomaly(
|
||||
source="ris_live_bgp",
|
||||
@@ -315,6 +345,75 @@ def test_detect_mass_withdrawal_anomalies_accepts_cross_collector_pair():
|
||||
assert anomalies[0].evidence["collector_count"] == 2
|
||||
|
||||
|
||||
def test_detect_route_leak_anomalies_creates_candidate_for_divergent_long_paths():
|
||||
events = [
|
||||
{
|
||||
"metadata": {
|
||||
"collector": "rrc00",
|
||||
"event_type": "announcement",
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64496,
|
||||
"as_path": [64500, 64496],
|
||||
"collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9},
|
||||
"enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}},
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"collector": "rrc01",
|
||||
"event_type": "announcement",
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64496,
|
||||
"as_path": [64510, 64520, 64530, 64540, 64496],
|
||||
"collector_location": {"country": "GB", "city": "London", "latitude": 51.5, "longitude": -0.1},
|
||||
"enrichment": {"prefix_scope": {"regions": [{"country": "GB"}]}},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
anomalies = detect_route_leak_anomalies(
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert len(anomalies) == 1
|
||||
assert anomalies[0].anomaly_type == "route_leak_candidate"
|
||||
assert anomalies[0].evidence["max_path_length"] == 5
|
||||
|
||||
|
||||
def test_detect_path_flap_anomalies_creates_signal_for_repeated_state_changes():
|
||||
base_timestamp = datetime(2026, 3, 27, 0, 0, tzinfo=UTC)
|
||||
events = []
|
||||
for index, event_type in enumerate(["announcement", "withdrawal", "announcement", "withdrawal"]):
|
||||
events.append(
|
||||
{
|
||||
"metadata": {
|
||||
"collector": "rrc00",
|
||||
"event_type": event_type,
|
||||
"timestamp": (base_timestamp + timedelta(minutes=index)).isoformat(),
|
||||
"prefix": "198.51.100.0/24",
|
||||
"origin_asn": 64512,
|
||||
"as_path": [64500 + index, 64512] if event_type == "announcement" else [],
|
||||
"collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9},
|
||||
"enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
anomalies = detect_path_flap_anomalies(
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert len(anomalies) == 1
|
||||
assert anomalies[0].anomaly_type == "path_flap"
|
||||
assert anomalies[0].evidence["transitions"] == 3
|
||||
|
||||
|
||||
def test_bgp_incident_to_dict():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
@@ -338,6 +437,131 @@ def test_bgp_incident_to_dict():
|
||||
assert data["affected_collectors"] == ["rrc00", "rrc01"]
|
||||
|
||||
|
||||
def test_convert_bgp_incidents_to_geojson_adds_estimated_geography():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:203.0.113.0/24:64497",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 203.0.113.0/24",
|
||||
summary="Grouped incident summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
confidence=0.91,
|
||||
affected_prefixes=["203.0.113.0/24"],
|
||||
affected_collectors=["rrc00", "rrc01"],
|
||||
affected_regions=[
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
},
|
||||
{
|
||||
"collector": "rrc01",
|
||||
"country": "United Kingdom",
|
||||
"city": "London",
|
||||
"latitude": 51.5072,
|
||||
"longitude": -0.1276,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
payload = convert_bgp_incidents_to_geojson([incident])
|
||||
feature = payload["features"][0]
|
||||
assert feature["properties"]["geography_mode"] == "collector_centroid"
|
||||
assert feature["properties"]["estimated_radius_km"] > 0
|
||||
assert feature["properties"]["estimated_center"]["latitude"] != 0
|
||||
|
||||
|
||||
def test_convert_bgp_incidents_to_geojson_prefers_prefix_scope_hint():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:203.0.113.0/24:64497",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 203.0.113.0/24",
|
||||
summary="Grouped incident summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
confidence=0.91,
|
||||
affected_prefixes=["203.0.113.0/24"],
|
||||
affected_collectors=["rrc00"],
|
||||
affected_regions=[
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
payload = convert_bgp_incidents_to_geojson(
|
||||
[incident],
|
||||
{
|
||||
incident.incident_key: {
|
||||
"geography_mode": "prefix_scope",
|
||||
"regions": [
|
||||
{
|
||||
"country": "Japan",
|
||||
"city": "Tokyo",
|
||||
"latitude": 35.6764,
|
||||
"longitude": 139.65,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
feature = payload["features"][0]
|
||||
assert feature["properties"]["geography_mode"] == "prefix_scope"
|
||||
assert feature["geometry"]["coordinates"] == [139.65, 35.6764]
|
||||
|
||||
|
||||
def test_convert_bgp_incidents_to_geojson_prefers_prefix_geography_hint():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:198.51.100.0/24:64512",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 198.51.100.0/24",
|
||||
summary="Grouped incident summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
confidence=0.91,
|
||||
affected_prefixes=["198.51.100.0/24"],
|
||||
affected_collectors=["rrc00"],
|
||||
affected_regions=[
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
payload = convert_bgp_incidents_to_geojson(
|
||||
[incident],
|
||||
{
|
||||
incident.incident_key: {
|
||||
"geography_mode": "prefix_geography",
|
||||
"regions": [
|
||||
{
|
||||
"country": "日本",
|
||||
"city": None,
|
||||
"latitude": 35.6764,
|
||||
"longitude": 139.65,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
feature = payload["features"][0]
|
||||
assert feature["properties"]["geography_mode"] == "prefix_geography"
|
||||
assert feature["geometry"]["coordinates"] == [139.65, 35.6764]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_bgp_events_for_batch_adds_profiles_and_prefix_scope():
|
||||
historical_observation = BGPObservation(
|
||||
@@ -367,8 +591,21 @@ async def test_enrich_bgp_events_for_batch_adds_profiles_and_prefix_scope():
|
||||
},
|
||||
)
|
||||
peeringdb_record.id = 99
|
||||
iptoasn_row = {
|
||||
"extra_data": {
|
||||
"family": "ipv4",
|
||||
"range_start": "203.0.113.0",
|
||||
"range_end": "203.0.113.255",
|
||||
"prefix": "203.0.113.0/24",
|
||||
"country": "英国",
|
||||
"country_code": "GB",
|
||||
"asn": 64497,
|
||||
"as_name": "Example ASN",
|
||||
"source_dataset": "iptoasn_combined",
|
||||
}
|
||||
}
|
||||
|
||||
db = _FakeAsyncSession([[historical_observation], [peeringdb_record]])
|
||||
db = _FakeAsyncSession([[historical_observation], [iptoasn_row], [peeringdb_record]])
|
||||
events = [
|
||||
{
|
||||
"metadata": {
|
||||
@@ -396,8 +633,10 @@ async def test_enrich_bgp_events_for_batch_adds_profiles_and_prefix_scope():
|
||||
assert enrichment["is_new_origin_for_prefix"] is True
|
||||
assert enrichment["rpki_validation"]["status"] == "unknown"
|
||||
assert enrichment["origin_asn_profile"]["name"] == "ExampleNet"
|
||||
assert enrichment["prefix_scope"]["countries"] == ["Netherlands", "United Kingdom"]
|
||||
assert enrichment["prefix_scope"]["cities"] == ["Amsterdam", "London"]
|
||||
assert enrichment["prefix_geography"]["country"] == "英国"
|
||||
assert enrichment["prefix_geography"]["source"] == "iptoasn_combined"
|
||||
assert enrichment["prefix_scope"]["countries"] == ["United Kingdom"]
|
||||
assert enrichment["prefix_scope"]["cities"] == ["London"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -448,6 +687,126 @@ async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collect
|
||||
assert incident.affected_regions[0]["city"] == "Amsterdam"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_bgp_incidents_for_anomalies_refreshes_existing_incident():
|
||||
existing = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:203.0.113.0/24:64497",
|
||||
incident_type="origin_change",
|
||||
title="Old title",
|
||||
summary="Old summary",
|
||||
severity="medium",
|
||||
status="active",
|
||||
confidence=0.4,
|
||||
affected_prefixes=["203.0.113.0/24"],
|
||||
affected_asns=[64496, 64497],
|
||||
affected_collectors=["rrc00"],
|
||||
affected_regions=[
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
}
|
||||
],
|
||||
related_cables=[],
|
||||
related_ixps=[],
|
||||
evidence_refs=["old-key"],
|
||||
)
|
||||
db = _FakeAsyncSession([[existing]])
|
||||
anomaly = BGPAnomaly(
|
||||
source="ris_live_bgp",
|
||||
anomaly_type="origin_change",
|
||||
severity="critical",
|
||||
status="active",
|
||||
entity_key="origin_change:203.0.113.0/24:64497",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
new_origin_asn=64497,
|
||||
summary="Origin ASN changed",
|
||||
confidence=0.9,
|
||||
evidence={
|
||||
"impacted_regions": [
|
||||
{
|
||||
"collector": None,
|
||||
"country": "United States",
|
||||
"city": None,
|
||||
"latitude": 39.8283,
|
||||
"longitude": -98.5795,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.bgp_incidents.infer_related_infrastructure",
|
||||
new=AsyncMock(return_value={"related_cables": [{"landing_point": "NYC"}], "related_ixps": []}),
|
||||
):
|
||||
created = await create_bgp_incidents_for_anomalies(
|
||||
db,
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
anomalies=[anomaly],
|
||||
)
|
||||
|
||||
assert created == 0
|
||||
assert db.commits == 1
|
||||
assert len(db.added) == 0
|
||||
assert existing.summary != "Old summary"
|
||||
assert existing.severity == "critical"
|
||||
assert existing.confidence == 0.9
|
||||
assert existing.affected_regions[0]["country"] == "United States"
|
||||
assert existing.related_cables == [{"landing_point": "NYC"}]
|
||||
assert existing.evidence_refs == ["origin_change:203.0.113.0/24:64497"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_incident_geography_hints_prefers_evidence_prefix_scope_when_no_cached_prefix_geo():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:93.175.153.0/24:16509",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 93.175.153.0/24",
|
||||
summary="summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
affected_prefixes=["93.175.153.0/24"],
|
||||
evidence_refs=["origin_change:93.175.153.0/24:16509"],
|
||||
)
|
||||
anomaly = BGPAnomaly(
|
||||
source="ris_live_bgp",
|
||||
anomaly_type="origin_change",
|
||||
severity="critical",
|
||||
status="active",
|
||||
entity_key="origin_change:93.175.153.0/24:16509",
|
||||
prefix="93.175.153.0/24",
|
||||
origin_asn=12654,
|
||||
new_origin_asn=16509,
|
||||
summary="summary",
|
||||
confidence=0.8,
|
||||
evidence={
|
||||
"prefix_scope": {
|
||||
"regions": [
|
||||
{
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
db = _FakeAsyncSession([[anomaly]])
|
||||
|
||||
hints = await build_incident_geography_hints(db, [incident])
|
||||
|
||||
assert hints["origin_change:93.175.153.0/24:16509"]["geography_mode"] == "prefix_scope"
|
||||
assert hints["origin_change:93.175.153.0/24:16509"]["regions"][0]["country"] == "Netherlands"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
landing = CollectedData(
|
||||
@@ -524,9 +883,11 @@ async def test_build_bgp_collector_coverage_summarizes_observations():
|
||||
|
||||
first = next(item for item in coverage if item["collector"] == "rrc00")
|
||||
assert first["observation_count"] == 2
|
||||
assert first["recent_15m_observation_count"] == 2
|
||||
assert first["recent_24h_observation_count"] == 2
|
||||
assert first["recent_7d_observation_count"] == 2
|
||||
assert first["prefix_count"] == 2
|
||||
assert first["recent_15m_prefix_count"] == 2
|
||||
assert first["recent_24h_prefix_count"] == 2
|
||||
assert first["origin_asn_count"] == 2
|
||||
assert first["latest_event_type"] == "withdrawal"
|
||||
@@ -583,6 +944,7 @@ async def test_create_bgp_anomalies_for_batch_calls_incident_aggregation():
|
||||
extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496},
|
||||
)
|
||||
db = _FakeAsyncSession([
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[previous_record],
|
||||
@@ -647,6 +1009,7 @@ async def test_create_bgp_anomalies_for_batch_skips_existing_entity_keys():
|
||||
new_origin_asn=64497,
|
||||
)
|
||||
db = _FakeAsyncSession([
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[previous_record],
|
||||
|
||||
Reference in New Issue
Block a user