Files
ripe-cidr-collector/api_server.py
T
ayurishchevandClaude Sonnet 5 cb935fef0d Add per-source status tracking, /health sources block and /sources
Collectors record the result of every source (schema v3, table
source_status); /health reports failing sources (failures in a row >=
source_failure_threshold) and turns degraded; GET /sources shows the full
state; runs end with a summary log line instead of "data saved".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:46:38 +03:00

382 lines
16 KiB
Python

from datetime import datetime, timezone
from enum import Enum
import ipaddress
import logging
import os
import re
import secrets
import sqlite3
from typing import Literal, Optional
from apscheduler.triggers.cron import CronTrigger
from fastapi import Depends, FastAPI, Header, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from pydantic import BaseModel, Field, field_validator
import cidr_collector as cc
import db
import formatters
from cidr_collector import load_full_config
from storage import StorageError, load_json
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
TOKEN_ENV = "RIPE_API_TOKEN"
MAX_CURSOR_DIGITS = 30 # курсор длиннее не бывает: такое значение - ошибка запроса
app = FastAPI(title="RIPE CIDR/FQDN API")
class AddressType(str, Enum):
cidr = "cidr"
fqdn = "fqdn"
all_types = "all"
class OutputFormat(str, Enum):
json = "json"
nftables = "nftables"
mikrotik = "mikrotik"
bird = "bird"
frr = "frr"
class IPVersion(str, Enum):
all_versions = "all"
v4 = "4"
v6 = "6"
class CollectRequest(BaseModel):
type: Literal["asn", "fqdn", "all"] = "all"
class ScheduleUpdate(BaseModel):
type: Literal["asn", "fqdn", "backup"]
cron: str
model_config = {"json_schema_extra": {"example": {"type": "asn", "cron": "*/10 * * * *"}}}
ASN_MIN, ASN_MAX = 1, 4294967295
_FQDN_LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
def normalize_fqdn(value: str) -> str:
"""Нижний регистр, без завершающей точки; проверка длины и меток, IP-литералы отклоняются."""
fqdn = value.strip().lower().rstrip(".")
if not fqdn or len(fqdn) > 253:
raise ValueError("FQDN must be 1-253 characters")
try:
ipaddress.ip_address(fqdn)
except ValueError:
pass
else:
raise ValueError("IP addresses are not allowed, use a domain name")
if not all(_FQDN_LABEL.match(label) for label in fqdn.split(".")):
raise ValueError("Invalid FQDN: labels must be 1-63 chars of [a-z0-9-], no leading/trailing hyphen")
return fqdn
class ASNBody(BaseModel):
asn: int = Field(ge=ASN_MIN, le=ASN_MAX)
class FQDNBody(BaseModel):
fqdn: str
@field_validator("fqdn")
@classmethod
def _normalize(cls, value):
return normalize_fqdn(value)
@app.exception_handler(StorageError)
@app.exception_handler(sqlite3.Error)
def storage_error_handler(request, exc):
return JSONResponse(status_code=503, content={"detail": "Data storage unavailable"})
def verify_token(x_api_key: Optional[str] = Header(None)):
expected = os.environ.get(TOKEN_ENV)
if not expected:
# Fail closed: без заданного токена управление отключено
raise HTTPException(status_code=503, detail=f"{TOKEN_ENV} is not configured; write access disabled.")
# Сравнение по байтам: compare_digest для str принимает только ASCII и падает на других символах
if not x_api_key or not secrets.compare_digest(x_api_key.encode(), expected.encode()):
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.")
def kinds_of(address_type):
"""Типы данных в базе для значения параметра type."""
return {"asn" if t == AddressType.cidr else "fqdn"
for t in (AddressType.cidr, AddressType.fqdn) if address_type in (t, AddressType.all_types)}
def ensure_data_ready(conn, kinds):
"""503, пока база пересоздана после порчи и данные не собраны заново: пустой список ввёл бы потребителя в заблуждение."""
if db.recreated_pending(conn, kinds):
raise HTTPException(status_code=503, headers={"Retry-After": "300"},
detail="The database was recreated after corruption; data is being collected again.")
@app.get("/addresses", response_model=None, responses={
200: {"description": "JSON list for format=json, text/plain configuration script for other formats",
"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}},
"text/plain": {"schema": {"type": "string"}}}}})
def get_addresses(
type: AddressType = Query(AddressType.all_types, description="Filter by address type"),
format: OutputFormat = Query(OutputFormat.json, description="Output format"),
ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"),
aggregate: bool = Query(False, description="Collapse overlapping/adjacent prefixes"),
name: str = Query("ripe", pattern=r"^[A-Za-z][A-Za-z0-9_]{0,31}$",
description="List/set name used in generated configuration"),
response: Response = None,
):
# Одна сессия и один снимок: курсор в заголовке точно соответствует выданным данным
kinds = kinds_of(type)
results = set()
with db.session() as conn, db.read_transaction(conn):
ensure_data_ready(conn, kinds)
headers = {"X-Changes-Cursor": str(db.journal_head(conn))}
for kind in kinds:
results.update(db.get_values(conn, kind))
output = formatters.build_output(results, format.value, ip_version.value, aggregate, name)
if format == OutputFormat.json:
response.headers.update(headers)
return output
return PlainTextResponse(output, headers=headers)
def parse_since(raw: str):
"""since: целый курсор или время ISO 8601 (без пояса - UTC). Возвращает (курсор, время UTC в формате журнала)."""
invalid = HTTPException(status_code=400, detail="since must be a cursor (integer) or an ISO 8601 time")
# Только ASCII-цифры и ограниченная длина: str.isdigit() принимает юникод-цифры, а int() ограничен по длине
if raw.isascii() and raw.isdigit():
if len(raw) > MAX_CURSOR_DIGITS:
raise invalid
return int(raw), None
try:
# «+» в адресной строке приходит пробелом; «Z» понимаем явно
moment = datetime.fromisoformat(raw.strip().replace(" ", "+").replace("Z", "+00:00"))
moment = moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc)
except (ValueError, OverflowError): # OverflowError: крайние даты с часовым поясом
raise invalid
return None, moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
@app.get("/addresses/diff", responses={
400: {"description": "Invalid since"},
410: {"description": "since is older than the change journal: fetch the full /addresses list"}})
def get_addresses_diff(
since: str = Query(..., description="Cursor from the previous response (recommended) or ISO 8601 time (UTC)"),
type: AddressType = Query(AddressType.all_types, description="Filter by address type"),
ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"),
):
cursor, since_ts = parse_since(since)
kinds = kinds_of(type)
with db.session() as conn:
ensure_data_ready(conn, kinds)
changes = db.get_changes(conn, kinds, cursor, since_ts)
if changes is None:
raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list")
added, removed, head = changes
return {
"since": since,
"now": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"cursor": head,
"added": formatters.select_strings(added, ip_version.value),
"removed": formatters.select_strings(removed, ip_version.value),
}
def collector_state():
"""Состояние демона по status.json (задания + heartbeat): (жив ли, время heartbeat, задания)."""
try:
status = load_json(cc.STATUS_FILE, None)
except StorageError:
status = None
alive, updated_at = False, None
if status:
updated_at = status.get("updated_at")
try:
age = (datetime.now() - datetime.fromisoformat(updated_at)).total_seconds()
alive = age < cc.STATUS_STALE_AFTER
except (TypeError, ValueError):
pass
return alive, updated_at, (status.get("jobs", {}) if status else {})
def source_health():
"""Сводка по источникам для /health: всего и только неисправные (сбоев подряд не меньше порога)."""
try:
config = load_full_config()
except StorageError:
return None, False
threshold = cc.failure_threshold(config)
with db.session() as conn:
statuses = db.source_statuses(conn)
summary, any_failing = {}, False
for kind, key in (("asn", "asns"), ("fqdn", "fqdns")):
configured = [str(source) for source in config.get(key, [])]
failing = [{"source": source, "failures": statuses[kind, source]["failures"],
"last_success": statuses[kind, source]["last_success"],
"error_kind": statuses[kind, source]["error_kind"]}
for source in configured
if (kind, source) in statuses and statuses[kind, source]["failures"] >= threshold]
summary[kind] = {"total": len(configured), "failing": failing}
any_failing = any_failing or bool(failing)
return summary, any_failing
@app.get("/health")
def health():
"""Состояние сборщика: демон пишет status.json (задания + heartbeat), API только читает."""
alive, updated_at, jobs = collector_state()
with db.session() as conn:
counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")}
pending = db.recreated_pending(conn, ("asn", "fqdn"))
try:
restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии
except StorageError:
restore = None
# Наружу - только время и имя файла копии; каталоги и текст ошибки остаются в last_restore.json
last_restore = ({"at": restore.get("at"), "backup": os.path.basename(str(restore.get("backup") or ""))}
if isinstance(restore, dict) else None)
try:
recreated = load_json(cc.RECREATED_FILE, None) # база пересоздана после порчи без копий
except StorageError:
recreated = None
sources, any_failing = source_health()
healthy = alive and not pending and not any_failing and not any(j.get("last_error") for j in jobs.values())
return {
"status": "ok" if healthy else "degraded",
"collector_alive": alive,
"collector_updated_at": updated_at,
"jobs": jobs,
"counts": counts,
"sources": sources,
"last_restore": last_restore,
# Только время и признак ожидания (пути карантина наружу не отдаём)
"db_recreated": {"at": recreated.get("at"), "pending": pending} if recreated else None,
}
@app.get("/schedule")
def get_schedule():
return load_full_config().get("schedule", {})
@app.post("/schedule", dependencies=[Depends(verify_token)])
def update_schedule(schedule_update: ScheduleUpdate):
# Validate cron string by attempting to create trigger
try:
CronTrigger.from_crontab(schedule_update.cron)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid cron string: {e}")
# Update config file (read-modify-write под блокировкой, запись атомарная)
def mutate(config):
config.setdefault("schedule", {})[schedule_update.type] = schedule_update.cron
cc.update_config(mutate)
# Демон подхватит новое расписание из config.json при ближайшей сверке
logger.info("Schedule updated: %s -> %s", schedule_update.type, schedule_update.cron)
return {"message": "Schedule updated", "type": schedule_update.type, "cron": schedule_update.cron,
"applied_within_seconds": cc.SYNC_INTERVAL}
def _purge(kind, source):
with db.session() as conn:
return db.purge_source(conn, kind, source)
@app.get("/sources")
def list_sources():
"""Состояние всех настроенных источников: сколько адресов дал, когда опрашивался, сколько сбоев подряд."""
config = load_full_config()
with db.session() as conn:
statuses, counts = db.source_statuses(conn), db.address_counts(conn)
empty = {"last_attempt": None, "last_success": None, "failures": 0, "error_kind": None}
return {"failure_threshold": cc.failure_threshold(config), "sources": [
{"kind": kind, "source": str(source), "addresses": counts.get((kind, str(source)), 0),
**statuses.get((kind, str(source)), empty)}
for kind, key in (("asn", "asns"), ("fqdn", "fqdns")) for source in config.get(key, [])]}
@app.get("/asns")
def list_asns():
return {"asns": load_full_config().get("asns", [])}
@app.post("/asns", dependencies=[Depends(verify_token)])
def add_asn(body: ASNBody, response: Response):
added, _ = cc.add_to_config_list("asns", body.asn)
response.status_code = 201 if added else 200
logger.info("ASN %s %s", body.asn, "added" if added else "already present")
return {"asn": body.asn, "added": added}
@app.delete("/asns/{asn}", dependencies=[Depends(verify_token)])
def remove_asn(asn: int = Path(ge=ASN_MIN, le=ASN_MAX),
purge: bool = Query(False, description="Also delete collected prefixes immediately")):
removed, _ = cc.remove_from_config_list("asns", asn)
purged = _purge("asn", str(asn)) if purge else False
if not removed and not purged:
raise HTTPException(status_code=404, detail=f"AS{asn} not found")
logger.info("ASN %s removed (purged=%s)", asn, purged)
return {"asn": asn, "removed": removed, "purged": purged}
@app.get("/fqdns")
def list_fqdns():
return {"fqdns": load_full_config().get("fqdns", [])}
@app.post("/fqdns", dependencies=[Depends(verify_token)])
def add_fqdn(body: FQDNBody, response: Response):
added, _ = cc.add_to_config_list("fqdns", body.fqdn)
response.status_code = 201 if added else 200
logger.info("FQDN %s %s", body.fqdn, "added" if added else "already present")
return {"fqdn": body.fqdn, "added": added}
@app.delete("/fqdns/{fqdn}", dependencies=[Depends(verify_token)])
def remove_fqdn(fqdn: str, purge: bool = Query(False, description="Also delete collected IPs immediately")):
try:
fqdn = normalize_fqdn(fqdn)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
removed, _ = cc.remove_from_config_list("fqdns", fqdn)
purged = _purge("fqdn", fqdn) if purge else False
if not removed and not purged:
raise HTTPException(status_code=404, detail=f"{fqdn} not found")
logger.info("FQDN %s removed (purged=%s)", fqdn, purged)
return {"fqdn": fqdn, "removed": removed, "purged": purged}
@app.post("/collect", status_code=202, dependencies=[Depends(verify_token)])
def request_collect(body: Optional[CollectRequest] = None):
"""Немедленный сбор: запрос передаётся демону (он проверяет его каждые несколько секунд)."""
alive, _, _ = collector_state()
if not alive:
raise HTTPException(status_code=503, detail="Collector daemon is not running; start it first.")
kind = body.type if body else "all"
requested = cc.request_collection(cc.COLLECT_TYPES if kind == "all" else [kind])
logger.info("Manual collection requested: %s", requested)
return {"requested": requested, "message": "Collection requested; follow progress in GET /health",
"picked_up_within_seconds": cc.TRIGGER_POLL_INTERVAL}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)