Sprint 0 / #19: OpenAPI 3.1 контракт API v1 + TS-клиент и контрактные тесты #57
@@ -0,0 +1,242 @@
|
|||||||
|
"""Загрузка OpenAPI-контракта и валидация ответов реализации против схем.
|
||||||
|
|
||||||
|
Контракт (``docs/openapi.yaml``) — источник истины: тесты
|
||||||
|
``backend/tests/test_contract_responses.py`` прогоняют каждый эндпоинт реализации
|
||||||
|
и сверяют ответ против схемы спецификации. Любое расхождение — ``ContractViolationError``,
|
||||||
|
то есть красный CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
SPEC_RELATIVE_PATH = Path("docs") / "openapi.yaml"
|
||||||
|
SPEC_ENV_VAR = "AWG_OPENAPI_SPEC"
|
||||||
|
JSON_MEDIA_TYPE = "application/json"
|
||||||
|
HTTP_METHODS: tuple[str, ...] = ("get", "put", "post", "delete", "patch", "head", "options")
|
||||||
|
DOC_ROUTE_PATHS: frozenset[str] = frozenset(
|
||||||
|
{"/openapi.json", "/docs", "/redoc", "/docs/oauth2-redirect"}
|
||||||
|
)
|
||||||
|
SECRET_FIELD_NAMES: frozenset[str] = frozenset({"private_key", "preshared_key", "password"})
|
||||||
|
|
||||||
|
|
||||||
|
class ContractViolationError(AssertionError):
|
||||||
|
"""Ответ реализации не соответствует схеме контракта."""
|
||||||
|
|
||||||
|
|
||||||
|
def spec_path() -> Path:
|
||||||
|
"""Путь до спецификации (``AWG_OPENAPI_SPEC`` переопределяет поиск от корня репозитория)."""
|
||||||
|
override = os.environ.get(SPEC_ENV_VAR)
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
for parent in Path(__file__).resolve().parents:
|
||||||
|
candidate = parent / SPEC_RELATIVE_PATH
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
raise FileNotFoundError(f"спецификация {SPEC_RELATIVE_PATH} не найдена")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_spec() -> dict[str, Any]:
|
||||||
|
"""Разобранная спецификация OpenAPI (кэшируется на процесс)."""
|
||||||
|
raw = yaml.safe_load(spec_path().read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ContractViolationError("спецификация должна быть YAML-объектом на верхнем уровне")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def iter_operations(spec: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
|
||||||
|
"""Перечень описанных в контракте операций как (путь, метод, операция)."""
|
||||||
|
operations: list[tuple[str, str, dict[str, Any]]] = []
|
||||||
|
for path, item in spec.get("paths", {}).items():
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
for method in HTTP_METHODS:
|
||||||
|
operation = item.get(method)
|
||||||
|
if isinstance(operation, dict):
|
||||||
|
operations.append((path, method, operation))
|
||||||
|
return operations
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ref(spec: dict[str, Any], ref: str) -> Any:
|
||||||
|
node: Any = spec
|
||||||
|
for part in ref.lstrip("#/").split("/"):
|
||||||
|
node = node[part]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_write_only(node: Any) -> None:
|
||||||
|
"""Удаляет ``writeOnly``-свойства (секреты) из схемы ответа.
|
||||||
|
|
||||||
|
``writeOnly`` в JSON Schema 2020-12 означает «только в запросе», поэтому в ответе
|
||||||
|
такие поля запрещены: они вырезаются, включая упоминание в ``required``; при этом
|
||||||
|
``additionalProperties: false`` делает их присутствие ошибкой валидации.
|
||||||
|
"""
|
||||||
|
if isinstance(node, dict):
|
||||||
|
properties = node.get("properties")
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
dropped = [
|
||||||
|
name
|
||||||
|
for name, sub in properties.items()
|
||||||
|
if isinstance(sub, dict) and sub.get("writeOnly") is True
|
||||||
|
]
|
||||||
|
for name in dropped:
|
||||||
|
del properties[name]
|
||||||
|
required = node.get("required")
|
||||||
|
if dropped and isinstance(required, list):
|
||||||
|
node["required"] = [name for name in required if name not in dropped]
|
||||||
|
for value in node.values():
|
||||||
|
_strip_write_only(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
_strip_write_only(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_for(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
document = copy.deepcopy(schema)
|
||||||
|
document["components"] = copy.deepcopy(spec.get("components", {}))
|
||||||
|
_strip_write_only(document)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def validator_for(schema: dict[str, Any], spec: dict[str, Any]) -> Draft202012Validator:
|
||||||
|
"""Валидатор фрагмента спецификации: внутренние ссылки ``#/components`` разрешаются."""
|
||||||
|
return Draft202012Validator(_document_for(schema, spec), format_checker=FormatChecker())
|
||||||
|
|
||||||
|
|
||||||
|
def response_schema(
|
||||||
|
spec: dict[str, Any], path: str, method: str, status: int | str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Схема тела ответа для (path, method, status) или ``None``, если ответ не описан."""
|
||||||
|
operation = spec.get("paths", {}).get(path, {}).get(method)
|
||||||
|
if not isinstance(operation, dict):
|
||||||
|
return None
|
||||||
|
response = operation.get("responses", {}).get(str(status))
|
||||||
|
if isinstance(response, dict) and "$ref" in response:
|
||||||
|
response = _resolve_ref(spec, str(response["$ref"]))
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
return None
|
||||||
|
media = response.get("content", {}).get(JSON_MEDIA_TYPE)
|
||||||
|
if not isinstance(media, dict):
|
||||||
|
# Non-JSON ответы (например, /metrics → text/plain): берём единственный медиатип.
|
||||||
|
content = response.get("content", {})
|
||||||
|
media = next((item for item in content.values() if isinstance(item, dict)), None)
|
||||||
|
if not isinstance(media, dict):
|
||||||
|
return None
|
||||||
|
schema = media.get("schema")
|
||||||
|
return schema if isinstance(schema, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_response(
|
||||||
|
spec: dict[str, Any],
|
||||||
|
path: str,
|
||||||
|
method: str,
|
||||||
|
status: int,
|
||||||
|
payload: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Проверяет тело ответа против схемы контракта; поднимает ``ContractViolationError``."""
|
||||||
|
schema = response_schema(spec, path, method, status)
|
||||||
|
if schema is None:
|
||||||
|
raise ContractViolationError(
|
||||||
|
f"{method.upper()} {path} -> {status}: ответ не описан в спецификации"
|
||||||
|
)
|
||||||
|
errors = sorted(
|
||||||
|
validator_for(schema, spec).iter_errors(payload), key=lambda err: list(err.absolute_path)
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
lines = [f"{method.upper()} {path} -> {status}: несоответствий схеме: {len(errors)}"]
|
||||||
|
for err in errors[:10]:
|
||||||
|
location = "/".join(str(part) for part in err.absolute_path) or "<root>"
|
||||||
|
lines.append(f" - {location}: {err.message}")
|
||||||
|
raise ContractViolationError("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def error_codes(spec: dict[str, Any]) -> set[str]:
|
||||||
|
"""Множество допустимых кодов ошибок из схемы ``ErrorCode``."""
|
||||||
|
enum = spec.get("components", {}).get("schemas", {}).get("ErrorCode", {}).get("enum", [])
|
||||||
|
return {str(item) for item in enum}
|
||||||
|
|
||||||
|
|
||||||
|
def write_only_fields(node: Any) -> set[str]:
|
||||||
|
"""Имена всех свойств, помеченных ``writeOnly`` (в любом месте документа)."""
|
||||||
|
found: set[str] = set()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for name, sub in node.get("properties", {}).items():
|
||||||
|
if isinstance(sub, dict) and sub.get("writeOnly") is True:
|
||||||
|
found.add(str(name))
|
||||||
|
found |= write_only_fields(sub)
|
||||||
|
for key, value in node.items():
|
||||||
|
if key != "properties":
|
||||||
|
found |= write_only_fields(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
found |= write_only_fields(item)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def iter_route_operations(app: Any) -> set[tuple[str, str]]:
|
||||||
|
"""Множество ``(путь, МЕТОД)`` для всех HTTP/WebSocket-маршрутов приложения.
|
||||||
|
|
||||||
|
Обходит вложенные роутеры: FastAPI 0.159 не разворачивает ``include_router``
|
||||||
|
в ``app.routes``: хранит обёртки, содержащие ``original_router``.
|
||||||
|
"""
|
||||||
|
found: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def walk(router: Any, seen: set[int]) -> None:
|
||||||
|
if id(router) in seen:
|
||||||
|
return
|
||||||
|
seen.add(id(router))
|
||||||
|
for route in getattr(router, "routes", []):
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
if isinstance(path, str):
|
||||||
|
if path in DOC_ROUTE_PATHS:
|
||||||
|
continue
|
||||||
|
methods = getattr(route, "methods", None)
|
||||||
|
if methods is None:
|
||||||
|
found.add((path, "GET")) # WebSocketRoute
|
||||||
|
continue
|
||||||
|
for method in methods:
|
||||||
|
if method.upper() not in {"HEAD", "OPTIONS"}:
|
||||||
|
found.add((path, method.upper()))
|
||||||
|
continue
|
||||||
|
inner = (
|
||||||
|
getattr(route, "original_router", None)
|
||||||
|
or getattr(route, "router", None)
|
||||||
|
or getattr(route, "app", None)
|
||||||
|
)
|
||||||
|
if inner is not None:
|
||||||
|
walk(inner, seen)
|
||||||
|
|
||||||
|
walk(app, set())
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def assert_routes_match(spec: dict[str, Any], app: Any) -> None:
|
||||||
|
"""Двусторонняя сверка маршрутов приложения и контракта.
|
||||||
|
|
||||||
|
* каждая операция контракта обязана существовать в приложении;
|
||||||
|
* каждый HTTP/WebSocket-маршрут приложения обязан быть описан в контракте.
|
||||||
|
"""
|
||||||
|
declared: set[tuple[str, str]] = {
|
||||||
|
(path, method.upper()) for path, method, _ in iter_operations(spec)
|
||||||
|
}
|
||||||
|
implemented = iter_route_operations(app)
|
||||||
|
|
||||||
|
missing = sorted(declared - implemented)
|
||||||
|
undocumented = sorted(implemented - declared)
|
||||||
|
problems: list[str] = []
|
||||||
|
if missing:
|
||||||
|
problems.append("нет в реализации: " + ", ".join(f"{m} {p}" for p, m in missing))
|
||||||
|
if undocumented:
|
||||||
|
problems.append("нет в контракте: " + ", ".join(f"{m} {p}" for p, m in undocumented))
|
||||||
|
if problems:
|
||||||
|
raise ContractViolationError(
|
||||||
|
"маршруты приложения и docs/openapi.yaml расходятся:\n " + "\n ".join(problems)
|
||||||
|
)
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""In-memory состояние мок-сервера (замена агента и SQLite до Sprint 1).
|
||||||
|
|
||||||
|
Секреты (private key, preshared key, пароль UI) хранятся отдельно от словарей-ответов
|
||||||
|
и никогда в них не попадают — это проверяется тестом «в ответах нет приватных ключей».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from awg_api.errors import ApiError
|
||||||
|
from awg_api.schemas import (
|
||||||
|
ConfigUpdateRequest,
|
||||||
|
ImportRequest,
|
||||||
|
RoutingRuleCreate,
|
||||||
|
RoutingRuleUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
VERSION = "0.1.0-mock"
|
||||||
|
GEOIP_PREFIX_COUNT = 11412
|
||||||
|
DEV_TOKEN = "awg-dash-dev-token" # noqa: S105 — документированный dev-дефолт мок-сервера
|
||||||
|
DEV_UI_PASSWORD = "awg-dash-dev-password" # noqa: S105 — документированный dev-дефолт
|
||||||
|
PRIVATE_KEY_SENTINEL = "PRIVATE-KEY-MUST-NOT-LEAK"
|
||||||
|
PRESHARED_KEY_SENTINEL = "PRESHARED-KEY-MUST-NOT-LEAK"
|
||||||
|
LOG_LEVELS = ("debug", "info", "warning", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
"""RFC 3339 (UTC), суффикс Z — формат всех таймстемпов контракта."""
|
||||||
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
class MockState:
|
||||||
|
"""Состояние мок-контрол-плейна: туннель, правила, устройства, журнал, сессия."""
|
||||||
|
|
||||||
|
def __init__(self, *, ready: bool = True) -> None:
|
||||||
|
self._started = time.monotonic()
|
||||||
|
self.ready = ready
|
||||||
|
self.token = DEV_TOKEN
|
||||||
|
self.ui_password = DEV_UI_PASSWORD
|
||||||
|
self.session_token: str | None = None
|
||||||
|
self.username: str | None = None
|
||||||
|
self.expires_at: str | None = None
|
||||||
|
|
||||||
|
self.mode: str = "geoip"
|
||||||
|
self.kill_switch = True
|
||||||
|
self.dns_leak_protection = True
|
||||||
|
self.mtu_clamping = True
|
||||||
|
self.ipv6: str = "direct"
|
||||||
|
self.geoip: dict[str, Any] = {
|
||||||
|
"source": "ripe-stat",
|
||||||
|
"prefix_count": GEOIP_PREFIX_COUNT,
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
self.tunnel_state: str = "up"
|
||||||
|
self.handshake_age_s: int | None = 27
|
||||||
|
self.rx_bytes = 5_812_664
|
||||||
|
self.tx_bytes = 1_204_331
|
||||||
|
self.peer_endpoint = "vpn.example.net:51820"
|
||||||
|
self.secrets: dict[str, str] = {
|
||||||
|
"private_key": PRIVATE_KEY_SENTINEL,
|
||||||
|
"preshared_key": PRESHARED_KEY_SENTINEL,
|
||||||
|
}
|
||||||
|
self.tunnel_config: dict[str, Any] = {
|
||||||
|
"address": "10.8.0.2/32",
|
||||||
|
"listen_port": 51820,
|
||||||
|
"mtu": 1420,
|
||||||
|
"dns": ["10.8.0.1"],
|
||||||
|
}
|
||||||
|
self.peers: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"public_key": "kZ9vT4publicKeyExampleOnly0000000000000000000=",
|
||||||
|
"endpoint": "vpn.example.net:51820",
|
||||||
|
"allowed_ips": ["0.0.0.0/0", "::/0"],
|
||||||
|
"persistent_keepalive": 25,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.dns: dict[str, Any] = {"mode": "internal", "servers": ["10.8.0.1", "1.1.1.1"]}
|
||||||
|
self.rules: dict[str, dict[str, Any]] = {}
|
||||||
|
self.devices: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "laptop-anna",
|
||||||
|
"ip": "10.8.0.11",
|
||||||
|
"mac": "3c:22:fb:11:22:33",
|
||||||
|
"vpn": True,
|
||||||
|
"ipsets": ["awg_geoip", "awg_domains"],
|
||||||
|
"last_seen_at": _now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone-max",
|
||||||
|
"ip": "10.8.0.12",
|
||||||
|
"mac": None,
|
||||||
|
"vpn": False,
|
||||||
|
"ipsets": [],
|
||||||
|
"last_seen_at": _now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self.logs: list[dict[str, Any]] = []
|
||||||
|
self.updated_at = _now()
|
||||||
|
self._seed_rules()
|
||||||
|
self._seed_logs()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ служебное
|
||||||
|
|
||||||
|
def uptime_s(self) -> int:
|
||||||
|
return max(0, int(time.monotonic() - self._started))
|
||||||
|
|
||||||
|
def _seed_rules(self) -> None:
|
||||||
|
self.rules["0192a1b2-0000-7000-8000-000000000001"] = {
|
||||||
|
"id": "0192a1b2-0000-7000-8000-000000000001",
|
||||||
|
"name": "Маршрутизировать домены сервисов",
|
||||||
|
"kind": "domain",
|
||||||
|
"value": "*.example.net",
|
||||||
|
"action": "vpn",
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 100,
|
||||||
|
"comment": None,
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
self.rules["0192a1b2-0000-7000-8000-000000000002"] = {
|
||||||
|
"id": "0192a1b2-0000-7000-8000-000000000002",
|
||||||
|
"name": "Локальная подсеть напрямую",
|
||||||
|
"kind": "cidr",
|
||||||
|
"value": "192.168.1.0/24",
|
||||||
|
"action": "direct",
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 10,
|
||||||
|
"comment": "LAN исключаем из туннеля",
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _seed_logs(self) -> None:
|
||||||
|
seeds = [
|
||||||
|
(
|
||||||
|
"info",
|
||||||
|
"awg_agent.reconcile",
|
||||||
|
"Применён desired state: 2 правила, 1 пир",
|
||||||
|
{"mode": "geoip"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"info",
|
||||||
|
"awg_api.routing",
|
||||||
|
"Режим split-tunnel: geoip",
|
||||||
|
{"prefix_count": GEOIP_PREFIX_COUNT},
|
||||||
|
),
|
||||||
|
("warning", "awg_agent.geoip", "Снапшот GeoIP старше 24 часов", {"age_h": 26}),
|
||||||
|
("error", "awg_agent.tunnel", "Краткий разрыв handshake", {"duration_s": 4}),
|
||||||
|
("debug", "awg_api.auth", "Создана сессия UI", {"username": "admin"}),
|
||||||
|
]
|
||||||
|
for level, logger, message, fields in seeds:
|
||||||
|
self.logs.append(
|
||||||
|
{
|
||||||
|
"ts": _now(),
|
||||||
|
"level": level,
|
||||||
|
"logger": logger,
|
||||||
|
"message": message,
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ ops
|
||||||
|
|
||||||
|
def get_health(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "ok" if self.ready else "degraded",
|
||||||
|
"version": VERSION,
|
||||||
|
"uptime_s": self.uptime_s(),
|
||||||
|
"checks": [
|
||||||
|
{"name": "api", "ok": True, "detail": None},
|
||||||
|
{
|
||||||
|
"name": "agent_socket",
|
||||||
|
"ok": self.ready,
|
||||||
|
"detail": None if self.ready else "unix-socket не отвечает",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_ready(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ready": self.ready,
|
||||||
|
"checks": [
|
||||||
|
{"name": "tunnel", "ok": self.ready, "detail": None},
|
||||||
|
{"name": "geoip_cache", "ok": True, "detail": None},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def prometheus_text(self) -> str:
|
||||||
|
tunnel_up = 1 if self.tunnel_state == "up" else 0
|
||||||
|
lines = [
|
||||||
|
"# HELP awg_dash_tunnel_up Tunnel state (1 = up)",
|
||||||
|
"# TYPE awg_dash_tunnel_up gauge",
|
||||||
|
f"awg_dash_tunnel_up {tunnel_up}",
|
||||||
|
"# HELP awg_dash_rx_bytes_total Received bytes on the tunnel",
|
||||||
|
"# TYPE awg_dash_rx_bytes_total counter",
|
||||||
|
f"awg_dash_rx_bytes_total {self.rx_bytes}",
|
||||||
|
"# HELP awg_dash_tx_bytes_total Transmitted bytes on the tunnel",
|
||||||
|
"# TYPE awg_dash_tx_bytes_total counter",
|
||||||
|
f"awg_dash_tx_bytes_total {self.tx_bytes}",
|
||||||
|
"# HELP awg_dash_geoip_prefixes GeoIP prefix count in the active snapshot",
|
||||||
|
"# TYPE awg_dash_geoip_prefixes gauge",
|
||||||
|
f"awg_dash_geoip_prefixes {self.geoip['prefix_count']}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ status
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"uptime_s": self.uptime_s(),
|
||||||
|
"mode": self.mode,
|
||||||
|
"tunnel": {
|
||||||
|
"interface": "awg0",
|
||||||
|
"state": self.tunnel_state,
|
||||||
|
"handshake_age_s": self.handshake_age_s,
|
||||||
|
"rx_bytes": self.rx_bytes,
|
||||||
|
"tx_bytes": self.tx_bytes,
|
||||||
|
"endpoint": self.peer_endpoint,
|
||||||
|
"last_handshake_at": _now(),
|
||||||
|
},
|
||||||
|
"geoip": dict(self.geoip),
|
||||||
|
"kill_switch": self.kill_switch,
|
||||||
|
"dns_leak_protection": self.dns_leak_protection,
|
||||||
|
}
|
||||||
|
|
||||||
|
def metrics_sample(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rx_bytes": self.rx_bytes,
|
||||||
|
"tx_bytes": self.tx_bytes,
|
||||||
|
"rx_rate_bps": 1_048_576,
|
||||||
|
"tx_rate_bps": 262_144,
|
||||||
|
"handshake_age_s": self.handshake_age_s,
|
||||||
|
"active_peers": len(self.peers),
|
||||||
|
}
|
||||||
|
|
||||||
|
def metrics_frame(self) -> dict[str, Any]:
|
||||||
|
return {"type": "metrics", "ts": _now(), "data": self.metrics_sample()}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
|
||||||
|
def get_config(self) -> dict[str, Any]:
|
||||||
|
"""Конфигурация без секретов: private_key/preshared_key не сериализуются."""
|
||||||
|
return {
|
||||||
|
"tunnel": dict(self.tunnel_config),
|
||||||
|
"peers": [dict(peer) for peer in self.peers],
|
||||||
|
"dns": dict(self.dns),
|
||||||
|
"routing": self._routing_config(),
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _routing_config(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mode": self.mode,
|
||||||
|
"kill_switch": self.kill_switch,
|
||||||
|
"mtu_clamping": self.mtu_clamping,
|
||||||
|
"ipv6": self.ipv6,
|
||||||
|
"geoip": dict(self.geoip),
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_config(self, update: ConfigUpdateRequest) -> dict[str, Any]:
|
||||||
|
if update.tunnel is not None:
|
||||||
|
payload = update.tunnel.model_dump(exclude={"private_key"})
|
||||||
|
self.tunnel_config = {**self.tunnel_config, **payload}
|
||||||
|
self.secrets["private_key"] = update.tunnel.private_key
|
||||||
|
if update.peers is not None:
|
||||||
|
self.peers = [peer.model_dump(exclude={"preshared_key"}) for peer in update.peers]
|
||||||
|
sentinel = next(
|
||||||
|
(peer.preshared_key for peer in update.peers if peer.preshared_key is not None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if sentinel is not None:
|
||||||
|
self.secrets["preshared_key"] = sentinel
|
||||||
|
if update.dns is not None:
|
||||||
|
self.dns = update.dns.model_dump()
|
||||||
|
if update.routing is not None:
|
||||||
|
routing = update.routing.model_dump()
|
||||||
|
self.mode = routing["mode"]
|
||||||
|
self.kill_switch = routing["kill_switch"]
|
||||||
|
self.mtu_clamping = routing["mtu_clamping"]
|
||||||
|
self.ipv6 = routing["ipv6"]
|
||||||
|
self.geoip = routing["geoip"]
|
||||||
|
self.updated_at = _now()
|
||||||
|
return self.get_config()
|
||||||
|
|
||||||
|
def validate_config(self, update: ConfigUpdateRequest) -> dict[str, Any]:
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
warnings: list[dict[str, Any]] = []
|
||||||
|
if update.tunnel is not None:
|
||||||
|
address = update.tunnel.address
|
||||||
|
if "/" not in address:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"path": "/tunnel/address",
|
||||||
|
"code": "invalid_cidr",
|
||||||
|
"message": "Адрес интерфейса должен быть в CIDR-нотации",
|
||||||
|
"severity": "error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if update.tunnel.mtu > 1420:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"path": "/tunnel/mtu",
|
||||||
|
"code": "mtu_above_recommended",
|
||||||
|
"message": "MTU выше 1420 может требовать MSS clamping",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for index, peer in enumerate(update.peers or []):
|
||||||
|
if ":" not in peer.endpoint:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"path": f"/peers/{index}/endpoint",
|
||||||
|
"code": "invalid_endpoint",
|
||||||
|
"message": "Endpoint должен содержать host:port",
|
||||||
|
"severity": "error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"valid": not errors, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ routing
|
||||||
|
|
||||||
|
def list_rules(self, *, enabled: bool | None = None, kind: str | None = None) -> dict[str, Any]:
|
||||||
|
items = [
|
||||||
|
dict(rule)
|
||||||
|
for rule in sorted(
|
||||||
|
self.rules.values(), key=lambda rule: (rule["priority"], rule["created_at"])
|
||||||
|
)
|
||||||
|
if (enabled is None or rule["enabled"] is enabled)
|
||||||
|
and (kind is None or rule["kind"] == kind)
|
||||||
|
]
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
def create_rule(self, payload: RoutingRuleCreate) -> dict[str, Any]:
|
||||||
|
if any(
|
||||||
|
rule["kind"] == payload.kind and rule["value"] == payload.value
|
||||||
|
for rule in self.rules.values()
|
||||||
|
):
|
||||||
|
raise ApiError(409, "conflict", "Правило для такого kind/value уже существует")
|
||||||
|
timestamp = _now()
|
||||||
|
rule_id = str(uuid.uuid4())
|
||||||
|
rule = {
|
||||||
|
"id": rule_id,
|
||||||
|
"name": payload.name,
|
||||||
|
"kind": payload.kind,
|
||||||
|
"value": payload.value,
|
||||||
|
"action": payload.action,
|
||||||
|
"enabled": payload.enabled,
|
||||||
|
"priority": payload.priority,
|
||||||
|
"comment": payload.comment,
|
||||||
|
"created_at": timestamp,
|
||||||
|
"updated_at": timestamp,
|
||||||
|
}
|
||||||
|
self.rules[rule_id] = rule
|
||||||
|
return dict(rule)
|
||||||
|
|
||||||
|
def get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||||
|
rule = self.rules.get(rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise ApiError(404, "not_found", f"Правило {rule_id} не найдено")
|
||||||
|
return dict(rule)
|
||||||
|
|
||||||
|
def update_rule(self, rule_id: str, payload: RoutingRuleUpdate) -> dict[str, Any]:
|
||||||
|
rule = self.get_rule(rule_id)
|
||||||
|
rule.update(payload.model_dump(exclude_unset=True))
|
||||||
|
rule["updated_at"] = _now()
|
||||||
|
self.rules[rule_id] = rule
|
||||||
|
return dict(rule)
|
||||||
|
|
||||||
|
def delete_rule(self, rule_id: str) -> None:
|
||||||
|
if rule_id not in self.rules:
|
||||||
|
raise ApiError(404, "not_found", f"Правило {rule_id} не найдено")
|
||||||
|
del self.rules[rule_id]
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> dict[str, Any]:
|
||||||
|
self.mode = mode
|
||||||
|
self.updated_at = _now()
|
||||||
|
return {"mode": mode, "applied_at": _now(), "reconcile_id": str(uuid.uuid4())}
|
||||||
|
|
||||||
|
def update_geoip(self, *, force: bool = False) -> dict[str, Any]:
|
||||||
|
status = "updated" if force or self.geoip["source"] == "none" else "cached"
|
||||||
|
if status == "updated":
|
||||||
|
self.geoip = {
|
||||||
|
"source": "ripe-stat",
|
||||||
|
"prefix_count": GEOIP_PREFIX_COUNT,
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"source": self.geoip["source"],
|
||||||
|
"prefix_count": self.geoip["prefix_count"],
|
||||||
|
"updated_at": self.geoip["updated_at"] or _now(),
|
||||||
|
"duration_ms": 1870 if status == "updated" else 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
def import_config(self, payload: ImportRequest) -> dict[str, Any]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
peers = 0
|
||||||
|
for line in payload.content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("[Peer]"):
|
||||||
|
peers += 1
|
||||||
|
elif stripped.startswith("PresharedKey") and payload.format != "json":
|
||||||
|
warnings.append("PresharedKey импортирован в секреты и не будет возвращён в API")
|
||||||
|
return {
|
||||||
|
"imported_peers": peers,
|
||||||
|
"imported_rules": 0,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ devices / logs
|
||||||
|
|
||||||
|
def list_devices(self) -> dict[str, Any]:
|
||||||
|
return {"items": [dict(device) for device in self.devices], "total": len(self.devices)}
|
||||||
|
|
||||||
|
def list_logs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
cursor: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
query: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
offset = 0
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
offset = int(cursor)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ApiError(422, "validation_failed", "Некорректный курсор пагинации") from exc
|
||||||
|
entries = [entry for entry in self.logs if level is None or entry["level"] == level]
|
||||||
|
if query:
|
||||||
|
entries = [entry for entry in entries if query.lower() in entry["message"].lower()]
|
||||||
|
page = entries[offset : offset + limit]
|
||||||
|
next_offset = offset + len(page)
|
||||||
|
return {
|
||||||
|
"items": [dict(entry) for entry in page],
|
||||||
|
"total": len(entries),
|
||||||
|
"next_cursor": str(next_offset) if next_offset < len(entries) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ auth
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||||
|
if password != self.ui_password:
|
||||||
|
raise ApiError(401, "unauthorized", "Неверные учётные данные")
|
||||||
|
self.username = username
|
||||||
|
self.session_token = str(uuid.uuid4())
|
||||||
|
self.expires_at = (
|
||||||
|
(datetime.now(UTC) + timedelta(hours=12))
|
||||||
|
.isoformat(timespec="seconds")
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
return self.get_session()
|
||||||
|
|
||||||
|
def logout(self) -> dict[str, Any]:
|
||||||
|
self.session_token = None
|
||||||
|
self.username = None
|
||||||
|
self.expires_at = None
|
||||||
|
return self.get_session()
|
||||||
|
|
||||||
|
def get_session(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"authenticated": self.session_token is not None,
|
||||||
|
"username": self.username,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"csrf_token": self.session_token,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user