diff --git a/backend/awg_api/contract.py b/backend/awg_api/contract.py new file mode 100644 index 0000000..30ebe8f --- /dev/null +++ b/backend/awg_api/contract.py @@ -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 "" + 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) + ) diff --git a/backend/awg_api/state.py b/backend/awg_api/state.py new file mode 100644 index 0000000..518ff36 --- /dev/null +++ b/backend/awg_api/state.py @@ -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, + }