diff --git a/.gitea/workflows/contract-openapi.yml b/.gitea/workflows/contract-openapi.yml new file mode 100644 index 0000000..6745e31 --- /dev/null +++ b/.gitea/workflows/contract-openapi.yml @@ -0,0 +1,55 @@ +name: contract-openapi +# Контракт OpenAPI 3.1 (issue #19): линтеры, контрактный тест «схема ↔ реализация» +# и проверка, что сгенерированный TS-клиент актуален относительно docs/openapi.yaml. +on: + push: + branches: + - "**" + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + contract: + name: openapi-contract + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt -r requirements-contract.txt + + - name: lint (ruff + mypy) + run: | + ruff check backend + ruff format --check backend + mypy backend + + - name: contract and unit tests (coverage >= 70%) + run: pytest + + ts-client: + name: ts-client-is-current + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: regenerate client and compare + run: | + bash ui/scripts/generate-api.sh + git diff --exit-code -- ui/src/api/generated/schema.d.ts diff --git a/backend/awg_api/app.py b/backend/awg_api/app.py new file mode 100644 index 0000000..6d8050e --- /dev/null +++ b/backend/awg_api/app.py @@ -0,0 +1,47 @@ +"""Сборка FastAPI-приложения мок-контрол-плейна (контракт ``docs/openapi.yaml``).""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import FastAPI + +from awg_api import __version__ +from awg_api.contract import load_spec +from awg_api.errors import install_error_handlers +from awg_api.routers import auth, config, devices, ops, routing, status +from awg_api.state import DEV_TOKEN, MockState +from awg_api.ws import metrics + +TOKEN_ENV_VAR = "AWG_API_TOKEN" # noqa: S105 — имя переменной окружения, не секрет + + +class ContractFastAPI(FastAPI): + """FastAPI, отдающий ``/openapi.json`` из контрактной спецификации, не из кода.""" + + def openapi(self) -> dict[str, Any]: + return load_spec() + + +def create_app(state: MockState | None = None, token: str | None = None) -> FastAPI: + """Создаёт приложение мок-сервера; ``state``/``token`` подменяются в тестах.""" + resolved_token = token or os.environ.get(TOKEN_ENV_VAR) or DEV_TOKEN + mock_state = state if state is not None else MockState() + mock_state.token = resolved_token + app = ContractFastAPI( + title="awg-dash API", + version=__version__, + description="Мок-контрол-плейн по контракту docs/openapi.yaml (задача #19)", + ) + app.state.mock_state = mock_state + app.state.api_token = resolved_token + install_error_handlers(app) + app.include_router(ops.router) + app.include_router(auth.router) + app.include_router(status.router) + app.include_router(config.router) + app.include_router(routing.router) + app.include_router(devices.router) + app.include_router(metrics.router) + return app 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/deps.py b/backend/awg_api/deps.py new file mode 100644 index 0000000..9cafeca --- /dev/null +++ b/backend/awg_api/deps.py @@ -0,0 +1,53 @@ +"""FastAPI-зависимости: доступ к состоянию и проверка токена доступа.""" + +from __future__ import annotations + +import hmac +from typing import Annotated + +from fastapi import Depends, Request + +from awg_api.errors import ApiError +from awg_api.state import MockState + +SESSION_COOKIE_NAME = "awg_session" + + +def get_state(request: Request) -> MockState: + """In-memory состояние мок-сервера (до появления SQLite-хранилища, задача #20).""" + state: MockState = request.app.state.mock_state + return state + + +def _bearer_token(request: Request) -> str | None: + header = request.headers.get("authorization", "") + scheme, _, credentials = header.partition(" ") + if scheme.lower() == "bearer" and credentials.strip(): + return credentials.strip() + return None + + +def token_matches(candidate: str | None, expected: str | None) -> bool: + if candidate is None or expected is None: + return False + return hmac.compare_digest(candidate, expected) + + +def authorize(request: Request, token: str | None = None) -> None: + """Проверяет Bearer-токен или активную cookie-сессию; иначе — 401.""" + expected: str = request.app.state.api_token + if token_matches(token, expected) or token_matches(_bearer_token(request), expected): + return + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if session_id and token_matches(session_id, request.app.state.mock_state.session_token): + return + raise ApiError(401, "unauthorized", "Требуется токен доступа") + + +async def require_token(request: Request) -> None: + """Зависимость для защищённых роутеров.""" + authorize(request) + + +StateDep = Annotated[MockState, Depends(get_state)] +TokenDep = Annotated[None, Depends(require_token)] diff --git a/backend/awg_api/errors.py b/backend/awg_api/errors.py new file mode 100644 index 0000000..df2d9f5 --- /dev/null +++ b/backend/awg_api/errors.py @@ -0,0 +1,88 @@ +"""Единый формат ошибок API: ``{code, message, details}`` (схема Error из контракта).""" + +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +STATUS_TO_CODE: dict[int, str] = { + 400: "bad_request", + 401: "unauthorized", + 403: "forbidden", + 404: "not_found", + 409: "conflict", + 422: "validation_failed", + 429: "rate_limited", + 500: "internal_error", + 503: "agent_unavailable", +} + + +class ApiError(Exception): + """Ошибка API, сериализуемая в схему Error.""" + + def __init__( + self, + status_code: int, + code: str, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + self.details = details + + +def error_payload(code: str, message: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + """Тело ошибки в контрактном виде (``details`` всегда присутствует, может быть null).""" + return {"code": code, "message": message, "details": details} + + +def install_error_handlers(app: FastAPI) -> None: + """Приводит все ошибки (включая 422 и 500) к схеме Error.""" + + @app.exception_handler(ApiError) + async def _api_error(_: Request, exc: ApiError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_payload(exc.code, exc.message, exc.details), + ) + + @app.exception_handler(RequestValidationError) + async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse: + issues = [ + { + "path": "/" + "/".join(str(part) for part in error.get("loc", ())), + "code": str(error.get("type", "invalid")), + "message": str(error.get("msg", "invalid value")), + } + for error in exc.errors() + ] + return JSONResponse( + status_code=422, + content=error_payload( + "validation_failed", + "Тело запроса не соответствует схеме", + {"issues": issues}, + ), + ) + + @app.exception_handler(StarletteHTTPException) + async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse: + code = STATUS_TO_CODE.get(exc.status_code, "internal_error") + return JSONResponse( + status_code=exc.status_code, content=error_payload(code, str(exc.detail), None) + ) + + @app.exception_handler(Exception) + async def _unhandled(_: Request, __: Exception) -> JSONResponse: + return JSONResponse( + status_code=500, + content=error_payload("internal_error", "Внутренняя ошибка", None), + ) diff --git a/backend/awg_api/routers/__init__.py b/backend/awg_api/routers/__init__.py new file mode 100644 index 0000000..00e59d1 --- /dev/null +++ b/backend/awg_api/routers/__init__.py @@ -0,0 +1 @@ +"""Роутеры API v1.""" diff --git a/backend/awg_api/routers/auth.py b/backend/awg_api/routers/auth.py new file mode 100644 index 0000000..97b50e4 --- /dev/null +++ b/backend/awg_api/routers/auth.py @@ -0,0 +1,34 @@ +"""Аутентификация UI и API: /api/v1/auth/*.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Response + +from awg_api.deps import SESSION_COOKIE_NAME, StateDep, require_token +from awg_api.schemas import AuthLoginRequest + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +TokenDep = Annotated[None, Depends(require_token)] + + +@router.post("/login") +async def login(payload: AuthLoginRequest, state: StateDep, response: Response) -> dict[str, Any]: + session = state.login(payload.username, payload.password) + csrf_token = session["csrf_token"] + if isinstance(csrf_token, str): + response.set_cookie( + SESSION_COOKIE_NAME, csrf_token, httponly=True, samesite="strict", path="/" + ) + return session + + +@router.post("/logout") +async def logout(state: StateDep, _: TokenDep = None) -> dict[str, Any]: + return state.logout() + + +@router.get("/session") +async def get_session(state: StateDep, _: TokenDep = None) -> dict[str, Any]: + return state.get_session() diff --git a/backend/awg_api/routers/config.py b/backend/awg_api/routers/config.py new file mode 100644 index 0000000..ec418dc --- /dev/null +++ b/backend/awg_api/routers/config.py @@ -0,0 +1,27 @@ +"""Конфигурация AmneziaWG: чтение, атомарное применение, сухая валидация.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from awg_api.deps import StateDep, require_token +from awg_api.schemas import ConfigUpdateRequest + +router = APIRouter(prefix="/api/v1/config", tags=["config"], dependencies=[Depends(require_token)]) + + +@router.get("") +async def get_config(state: StateDep) -> dict[str, Any]: + return state.get_config() + + +@router.put("") +async def put_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]: + return state.apply_config(payload) + + +@router.post("/validate") +async def validate_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]: + return state.validate_config(payload) diff --git a/backend/awg_api/routers/devices.py b/backend/awg_api/routers/devices.py new file mode 100644 index 0000000..945567d --- /dev/null +++ b/backend/awg_api/routers/devices.py @@ -0,0 +1,29 @@ +"""Устройства и журнал: /api/v1/devices, /api/v1/logs.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from awg_api.deps import StateDep, require_token + +router = APIRouter( + prefix="/api/v1", tags=["devices", "logs"], dependencies=[Depends(require_token)] +) + + +@router.get("/devices") +async def list_devices(state: StateDep) -> dict[str, Any]: + return state.list_devices() + + +@router.get("/logs") +async def list_logs( + state: StateDep, + limit: int = Query(default=100, ge=1, le=500), + cursor: str | None = None, + level: str | None = None, + q: str | None = None, +) -> dict[str, Any]: + return state.list_logs(limit=limit, cursor=cursor, level=level, query=q) diff --git a/backend/awg_api/routers/ops.py b/backend/awg_api/routers/ops.py new file mode 100644 index 0000000..51ec542 --- /dev/null +++ b/backend/awg_api/routers/ops.py @@ -0,0 +1,36 @@ +"""Служебные эндпоинты: /api/v1/health, /api/v1/ready, /metrics (без аутентификации).""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse, PlainTextResponse + +from awg_api.deps import StateDep +from awg_api.errors import error_payload + +router = APIRouter(tags=["ops"]) + + +@router.get("/api/v1/health") +async def get_health(state: StateDep) -> dict[str, Any]: + return state.get_health() + + +@router.get("/api/v1/ready") +async def get_ready(state: StateDep) -> JSONResponse: + if state.ready: + return JSONResponse(status_code=200, content=state.get_ready()) + checks = state.get_ready()["checks"] + return JSONResponse( + status_code=503, + content=error_payload( + "agent_unavailable", "Шлюз не готов обслуживать запросы", {"checks": checks} + ), + ) + + +@router.get("/metrics", response_class=PlainTextResponse) +async def get_prometheus_metrics(state: StateDep) -> PlainTextResponse: + return PlainTextResponse(state.prometheus_text()) diff --git a/backend/awg_api/routers/routing.py b/backend/awg_api/routers/routing.py new file mode 100644 index 0000000..b2ae4ce --- /dev/null +++ b/backend/awg_api/routers/routing.py @@ -0,0 +1,67 @@ +"""Маршрутизация: правила, режим, GeoIP, импорт.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from awg_api.deps import StateDep, require_token +from awg_api.schemas import ( + GeoIpUpdateRequest, + ImportRequest, + ModeUpdateRequest, + RoutingRuleCreate, + RoutingRuleUpdate, +) + +router = APIRouter( + prefix="/api/v1/routing", tags=["routing"], dependencies=[Depends(require_token)] +) + + +@router.get("/rules") +async def list_rules( + state: StateDep, + enabled: bool | None = None, + kind: str | None = None, +) -> dict[str, Any]: + return state.list_rules(enabled=enabled, kind=kind) + + +@router.post("/rules", status_code=201) +async def create_rule(payload: RoutingRuleCreate, state: StateDep) -> dict[str, Any]: + return state.create_rule(payload) + + +@router.get("/rules/{rule_id}") +async def get_rule(rule_id: str, state: StateDep) -> dict[str, Any]: + return state.get_rule(rule_id) + + +@router.put("/rules/{rule_id}") +async def update_rule(rule_id: str, payload: RoutingRuleUpdate, state: StateDep) -> dict[str, Any]: + return state.update_rule(rule_id, payload) + + +@router.delete("/rules/{rule_id}", status_code=204) +async def delete_rule(rule_id: str, state: StateDep) -> None: + state.delete_rule(rule_id) + + +@router.put("/mode") +async def put_mode(payload: ModeUpdateRequest, state: StateDep) -> dict[str, Any]: + return state.set_mode(payload.mode) + + +@router.post("/geoip/update") +async def update_geoip( + state: StateDep, payload: GeoIpUpdateRequest | None = None +) -> dict[str, Any]: + force = bool(payload.force) if payload is not None else False + return state.update_geoip(force=force) + + +@router.post("/import") +async def import_config(payload: ImportRequest, state: StateDep) -> dict[str, Any]: + return state.import_config(payload) diff --git a/backend/awg_api/routers/status.py b/backend/awg_api/routers/status.py new file mode 100644 index 0000000..bfe3579 --- /dev/null +++ b/backend/awg_api/routers/status.py @@ -0,0 +1,16 @@ +"""Состояние туннеля: /api/v1/status.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from awg_api.deps import StateDep, require_token + +router = APIRouter(prefix="/api/v1", tags=["status"], dependencies=[Depends(require_token)]) + + +@router.get("/status") +async def get_status(state: StateDep) -> dict[str, Any]: + return state.get_status() diff --git a/backend/awg_api/schemas.py b/backend/awg_api/schemas.py new file mode 100644 index 0000000..13ed6d1 --- /dev/null +++ b/backend/awg_api/schemas.py @@ -0,0 +1,120 @@ +"""Pydantic-модели запросов, зеркалящие схемы ``docs/openapi.yaml``. + +Соответствие проверяется тестом ``test_request_models_match_spec``: наборы полей, +обязательность и базовые типы моделей обязаны соответствовать схемам контракта. +""" + +from __future__ import annotations + +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +RoutingMode = Literal["geoip", "dns", "all-vpn", "direct-only"] +RuleKind = Literal["domain", "cidr", "geoip", "asn"] +RuleAction = Literal["vpn", "direct"] +Ipv6Policy = Literal["direct", "tunnel", "block"] +DnsMode = Literal["internal", "external"] +GeoIpSource = Literal["ripe-stat", "cache", "none"] +ImportFormat = Literal["amneziawg-conf", "wireguard-conf", "json"] + + +class StrictModel(BaseModel): + """Модель запроса: лишние поля запрещены (``additionalProperties: false`` в контракте).""" + + model_config = ConfigDict(extra="forbid") + + +class GeoIpStatusIn(StrictModel): + source: GeoIpSource + prefix_count: int = Field(ge=0) + updated_at: str | None + + +class TunnelConfigIn(StrictModel): + address: str + listen_port: int = Field(ge=1, le=65535) + mtu: int = Field(ge=576, le=1500) + private_key: str = Field(json_schema_extra={"writeOnly": True}) + dns: list[str] | None = None + + +class PeerConfigIn(StrictModel): + public_key: str + preshared_key: str | None = Field(default=None, json_schema_extra={"writeOnly": True}) + endpoint: str + allowed_ips: list[str] + persistent_keepalive: int | None = Field(default=None, ge=0, le=65535) + + +class DnsConfigIn(StrictModel): + mode: DnsMode + servers: list[str] + + +class RoutingConfigIn(StrictModel): + mode: RoutingMode + kill_switch: bool + mtu_clamping: bool + ipv6: Ipv6Policy + geoip: GeoIpStatusIn + + +class ConfigUpdateRequest(StrictModel): + """Частичное обновление конфигурации (merge в текущее состояние).""" + + tunnel: TunnelConfigIn | None = None + peers: list[PeerConfigIn] | None = None + dns: DnsConfigIn | None = None + routing: RoutingConfigIn | None = None + + @model_validator(mode="after") + def _require_any_field(self) -> Self: + if not self.model_fields_set: + raise ValueError("нужно передать хотя бы одно поле конфигурации") + return self + + +class RoutingRuleCreate(StrictModel): + name: str = Field(min_length=1, max_length=128) + kind: RuleKind + value: str = Field(min_length=1) + action: RuleAction + enabled: bool = True + priority: int = Field(default=100, ge=0, le=10000) + comment: str | None = None + + +class RoutingRuleUpdate(StrictModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + kind: RuleKind | None = None + value: str | None = Field(default=None, min_length=1) + action: RuleAction | None = None + enabled: bool | None = None + priority: int | None = Field(default=None, ge=0, le=10000) + comment: str | None = None + + @model_validator(mode="after") + def _require_any_field(self) -> Self: + if not self.model_fields_set: + raise ValueError("нужно передать хотя бы одно поле правила") + return self + + +class ModeUpdateRequest(StrictModel): + mode: RoutingMode + + +class GeoIpUpdateRequest(StrictModel): + force: bool = False + + +class ImportRequest(StrictModel): + format: ImportFormat + content: str = Field(min_length=1) + apply: bool = False + + +class AuthLoginRequest(StrictModel): + username: str = Field(min_length=1) + password: str = Field(min_length=1, json_schema_extra={"writeOnly": True}) 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, + } diff --git a/backend/awg_api/ws/__init__.py b/backend/awg_api/ws/__init__.py new file mode 100644 index 0000000..646c17e --- /dev/null +++ b/backend/awg_api/ws/__init__.py @@ -0,0 +1 @@ +"""WebSocket-поток метрик.""" diff --git a/backend/awg_api/ws/metrics.py b/backend/awg_api/ws/metrics.py new file mode 100644 index 0000000..cc28cf4 --- /dev/null +++ b/backend/awg_api/ws/metrics.py @@ -0,0 +1,47 @@ +"""WebSocket-поток метрик: GET /api/v1/metrics/stream.""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from awg_api.deps import SESSION_COOKIE_NAME, token_matches +from awg_api.state import MockState + +router = APIRouter(tags=["metrics"]) + +WS_ACCESS_DENIED = 4401 +MIN_INTERVAL_MS = 250 + + +def _authorized(websocket: WebSocket, token: str | None) -> bool: + expected: str = websocket.app.state.api_token + header = websocket.headers.get("authorization", "") + scheme, _, credentials = header.partition(" ") + if scheme.lower() == "bearer" and token_matches(credentials.strip(), expected): + return True + if token_matches(token, expected): + return True + session_id = websocket.cookies.get(SESSION_COOKIE_NAME) + state: MockState = websocket.app.state.mock_state + return token_matches(session_id, state.session_token) + + +@router.websocket("/api/v1/metrics/stream") +async def stream_metrics( + websocket: WebSocket, token: str | None = None, interval_ms: int = 1000 +) -> None: + """Отправляет кадры ``WsFrame``: метрики до отключения клиента.""" + if not _authorized(websocket, token): + await websocket.close(code=WS_ACCESS_DENIED) + return + state: MockState = websocket.app.state.mock_state + await websocket.accept() + interval_s = max(MIN_INTERVAL_MS, interval_ms) / 1000 + try: + while True: + await websocket.send_json(state.metrics_frame()) + await asyncio.sleep(interval_s) + except (WebSocketDisconnect, RuntimeError): + return diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..323b9af --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,48 @@ +"""Общие фикстуры контрактных тестов.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from awg_api.app import create_app +from awg_api.contract import load_spec +from awg_api.state import MockState + +TEST_TOKEN = "test-token-contract" + + +@pytest.fixture(scope="session") +def spec() -> dict[str, Any]: + """Спецификация контракта (docs/openapi.yaml).""" + return load_spec() + + +@pytest.fixture +def state() -> MockState: + return MockState() + + +@pytest.fixture +def app(state: MockState) -> FastAPI: + return create_app(state=state, token=TEST_TOKEN) + + +@pytest.fixture +def client(app: FastAPI) -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {TEST_TOKEN}"} + + +@pytest.fixture +def token() -> str: + return TEST_TOKEN diff --git a/backend/tests/test_contract_negative.py b/backend/tests/test_contract_negative.py new file mode 100644 index 0000000..0fc9773 --- /dev/null +++ b/backend/tests/test_contract_negative.py @@ -0,0 +1,110 @@ +"""Негативные проверки контрактного теста: отклонение реализации обязано его ронять. + +Эти тесты — доказательство AC «GIVEN реализованный эндпоинт с отклонением от схемы, +WHEN запущен контрактный тест, THEN CI падает с указанием расхождения»: они подменяют +реализацию на заведомо расходящуюся и проверяют, что тот же самый валидатор её ловит. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from awg_api.app import create_app +from awg_api.contract import ContractViolationError, assert_routes_match, validate_response +from awg_api.state import PRIVATE_KEY_SENTINEL, MockState + + +class DeviatingStatusState(MockState): + """Реализация, «забывшая» контракт: неверный тип и лишнее поле.""" + + def get_status(self) -> dict[str, Any]: + payload = super().get_status() + payload["tunnel"]["rx_bytes"] = "много" # намеренно неверный тип вместо integer + payload["extra_field"] = True # additionalProperties: false + return payload + + +class LeakingConfigState(MockState): + """Реализация, возвращающая приватный ключ в ответе.""" + + def get_config(self) -> dict[str, Any]: + payload = super().get_config() + payload["tunnel"]["private_key"] = PRIVATE_KEY_SENTINEL + return payload + + +class DroppingFieldState(MockState): + """Реализация, потерявшая обязательное поле.""" + + def get_config(self) -> dict[str, Any]: + payload = super().get_config() + del payload["routing"]["kill_switch"] + return payload + + +def _client_for(state: MockState, token: str) -> TestClient: + return TestClient(create_app(state=state, token=token)) + + +def test_valid_implementation_passes(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None: + """Контроль ложных срабатываний: корректная реализация проходит без замечаний.""" + response = client.get("/api/v1/status", headers=auth_headers) + validate_response(spec, "/api/v1/status", "get", 200, response.json()) + + +def test_wrong_type_and_extra_field_are_caught(spec: dict[str, Any], token: str) -> None: + with _client_for(DeviatingStatusState(), token) as client: + response = client.get("/api/v1/status", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + with pytest.raises(ContractViolationError) as excinfo: + validate_response(spec, "/api/v1/status", "get", 200, response.json()) + message = str(excinfo.value) + assert "tunnel/rx_bytes" in message + assert "extra_field" in message + + +def test_private_key_in_response_is_caught(spec: dict[str, Any], token: str) -> None: + with _client_for(LeakingConfigState(), token) as client: + response = client.get("/api/v1/config", headers={"Authorization": f"Bearer {token}"}) + assert PRIVATE_KEY_SENTINEL in response.text, "подготовка теста: ключ действительно утёк" + with pytest.raises(ContractViolationError) as excinfo: + validate_response(spec, "/api/v1/config", "get", 200, response.json()) + assert "private_key" in str(excinfo.value) + + +def test_missing_required_field_is_caught(spec: dict[str, Any], token: str) -> None: + with _client_for(DroppingFieldState(), token) as client: + response = client.get("/api/v1/config", headers={"Authorization": f"Bearer {token}"}) + with pytest.raises(ContractViolationError) as excinfo: + validate_response(spec, "/api/v1/config", "get", 200, response.json()) + assert "kill_switch" in str(excinfo.value) + + +def test_undocumented_route_is_caught(spec: dict[str, Any], token: str) -> None: + app = create_app(token=token) + + @app.get("/api/v1/undocumented") + async def undocumented(request: Request) -> dict[str, bool]: + return {"ok": True} + + with pytest.raises(ContractViolationError) as excinfo: + assert_routes_match(spec, app) + assert "нет в контракте" in str(excinfo.value) + assert "/api/v1/undocumented" in str(excinfo.value) + + +def test_missing_route_is_caught(spec: dict[str, Any]) -> None: + with pytest.raises(ContractViolationError) as excinfo: + assert_routes_match(spec, FastAPI()) + assert "нет в реализации" in str(excinfo.value) + + +def test_undeclared_status_code_is_caught(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None: + response = client.get("/api/v1/status", headers=auth_headers) + with pytest.raises(ContractViolationError) as excinfo: + validate_response(spec, "/api/v1/status", "get", 202, response.json()) + assert "не описан в спецификации" in str(excinfo.value) diff --git a/backend/tests/test_contract_responses.py b/backend/tests/test_contract_responses.py new file mode 100644 index 0000000..4c6ba6e --- /dev/null +++ b/backend/tests/test_contract_responses.py @@ -0,0 +1,321 @@ +"""Контрактный тест «схема ↔ реализация»: каждый эндпоинт сверяется со схемой из docs/openapi.yaml. + +Любое расхождение (лишнее поле, неверный тип, отсутствующая обязательная ветка, +утечка writeOnly-секрета) — падение с указанием пути до поля. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from awg_api.app import create_app +from awg_api.contract import ( + ContractViolationError, + assert_routes_match, + response_schema, + validator_for, + validate_response, +) +from awg_api.state import PRIVATE_KEY_SENTINEL, PRESHARED_KEY_SENTINEL, MockState + +RULE_ID_PATH = "/api/v1/routing/rules/{rule_id}" +LEAKED_KEY = "NEW-PRIVATE-KEY-MUST-NOT-LEAK" +SECRET_SENTINELS = (PRIVATE_KEY_SENTINEL, PRESHARED_KEY_SENTINEL, LEAKED_KEY) +SECRET_FIELD_NAMES = ("private_key", "preshared_key", "password") + +TUNNEL_PAYLOAD: dict[str, Any] = { + "address": "10.8.0.9/32", + "listen_port": 51821, + "mtu": 1420, + "private_key": LEAKED_KEY, + "dns": ["10.8.0.1"], +} +ROUTING_PAYLOAD: dict[str, Any] = { + "mode": "all-vpn", + "kill_switch": True, + "mtu_clamping": True, + "ipv6": "block", + "geoip": {"source": "cache", "prefix_count": 11412, "updated_at": None}, +} +RULE_PAYLOAD: dict[str, Any] = { + "name": "Правило контрактного теста", + "kind": "domain", + "value": "contract.example.net", + "action": "vpn", +} +IMPORT_PAYLOAD: dict[str, Any] = { + "format": "amneziawg-conf", + "content": "[Peer]\nPublicKey = abc\nPresharedKey = def\n", +} + +# (метод, путь, тело) — успешный путь для каждой операции контракта. +HAPPY_PATHS: tuple[tuple[str, str, dict[str, Any] | None], ...] = ( + ("get", "/api/v1/health", None), + ("get", "/api/v1/ready", None), + ("get", "/metrics", None), + ("get", "/api/v1/status", None), + ("get", "/api/v1/config", None), + ("put", "/api/v1/config", {"tunnel": TUNNEL_PAYLOAD}), + ("post", "/api/v1/config/validate", {"tunnel": TUNNEL_PAYLOAD}), + ("get", "/api/v1/routing/rules", None), + ("post", "/api/v1/routing/rules", RULE_PAYLOAD), + ("put", "/api/v1/routing/mode", {"mode": "dns"}), + ("post", "/api/v1/routing/geoip/update", {"force": True}), + ("post", "/api/v1/routing/import", IMPORT_PAYLOAD), + ("get", "/api/v1/devices", None), + ("get", "/api/v1/logs", None), + ("get", "/api/v1/auth/session", None), + ("post", "/api/v1/auth/logout", None), + ("post", "/api/v1/auth/login", {"username": "admin", "password": "awg-dash-dev-password"}), +) + +EXPECTED_STATUS: dict[tuple[str, str], int] = {("post", "/api/v1/routing/rules"): 201} + + +def _check(spec: dict[str, Any], method: str, path: str, response: Any) -> None: + """Валидирует ответ против схемы контракта (JSON — как объект, иначе — как текст).""" + content_type = response.headers.get("content-type", "") + payload = response.json() if content_type.startswith("application/json") else response.text + validate_response(spec, path, method, response.status_code, payload) + + +def test_every_spec_operation_is_implemented(spec: dict[str, Any], app: Any) -> None: + assert_routes_match(spec, app) + + +def test_openapi_json_serves_the_contract(client: TestClient, spec: dict[str, Any]) -> None: + response = client.get("/openapi.json") + assert response.status_code == 200 + assert response.json() == spec + + +@pytest.mark.parametrize(("method", "path", "body"), HAPPY_PATHS) +def test_success_response_matches_spec( + spec: dict[str, Any], + client: TestClient, + auth_headers: dict[str, str], + method: str, + path: str, + body: dict[str, Any] | None, +) -> None: + response = client.request(method.upper(), path, json=body, headers=auth_headers) + expected = EXPECTED_STATUS.get((method, path), 200) + assert response.status_code == expected, f"{method.upper()} {path}: {response.status_code} {response.text}" + _check(spec, method, path, response) + + +def test_rule_crud_cycle_matches_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None: + created = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers) + assert created.status_code == 201, created.text + _check(spec, "post", "/api/v1/routing/rules", created) + rule_id = created.json()["id"] + + fetched = client.get(f"/api/v1/routing/rules/{rule_id}", headers=auth_headers) + assert fetched.status_code == 200 + _check(spec, "get", RULE_ID_PATH, fetched) + + updated = client.put(f"/api/v1/routing/rules/{rule_id}", json={"enabled": False}, headers=auth_headers) + assert updated.status_code == 200 + assert updated.json()["enabled"] is False + _check(spec, "put", RULE_ID_PATH, updated) + + deleted = client.delete(f"/api/v1/routing/rules/{rule_id}", headers=auth_headers) + assert deleted.status_code == 204 + assert deleted.content == b"" + + +def test_rules_list_filters_match_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None: + response = client.get("/api/v1/routing/rules", params={"enabled": "true", "kind": "cidr"}, headers=auth_headers) + assert response.status_code == 200 + payload = response.json() + assert all(rule["kind"] == "cidr" and rule["enabled"] for rule in payload["items"]) + _check(spec, "get", "/api/v1/routing/rules", response) + + +def test_logs_pagination_matches_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None: + first = client.get("/api/v1/logs", params={"limit": 2}, headers=auth_headers) + assert first.status_code == 200 + payload = first.json() + assert len(payload["items"]) == 2 + assert payload["next_cursor"] is not None + _check(spec, "get", "/api/v1/logs", first) + + second = client.get( + "/api/v1/logs", + params={"limit": 2, "cursor": payload["next_cursor"], "level": "info"}, + headers=auth_headers, + ) + assert second.status_code == 200 + assert all(entry["level"] == "info" for entry in second.json()["items"]) + _check(spec, "get", "/api/v1/logs", second) + + +@pytest.mark.parametrize( + ("method", "path", "body", "expected_status", "expected_code"), + ( + ("get", "/api/v1/status", None, 401, "unauthorized"), + ("get", "/api/v1/config", None, 401, "unauthorized"), + ("get", "/api/v1/routing/rules", None, 401, "unauthorized"), + ("get", "/api/v1/devices", None, 401, "unauthorized"), + ("get", "/api/v1/logs", None, 401, "unauthorized"), + ("post", "/api/v1/routing/rules", {"name": "x", "kind": "domain", "value": "x", "action": "vpn"}, 401, "unauthorized"), + ( + "put", + "/api/v1/config", + {"tunnel": {**TUNNEL_PAYLOAD, "unknown_field": 1}}, + 422, + "validation_failed", + ), + ("post", "/api/v1/config/validate", {"tunnel": {"address": "10.8.0.2/32", "listen_port": 99999}}, 422, "validation_failed"), + ("post", "/api/v1/routing/rules", {"name": "", "kind": "nope", "value": "x"}, 422, "validation_failed"), + ("put", "/api/v1/routing/mode", {"mode": "turbo"}, 422, "validation_failed"), + ("post", "/api/v1/auth/login", {"username": "admin", "password": "wrong"}, 401, "unauthorized"), + ("post", "/api/v1/auth/login", {"username": "", "password": ""}, 422, "validation_failed"), + ("get", "/api/v1/logs", None, 422, "validation_failed"), + ), +) +def test_error_response_matches_error_schema( + spec: dict[str, Any], + client: TestClient, + auth_headers: dict[str, str], + method: str, + path: str, + body: dict[str, Any] | None, + expected_status: int, + expected_code: str, +) -> None: + headers = dict(auth_headers) + params: dict[str, Any] = {} + if expected_status == 401 and path != "/api/v1/auth/login": + headers = {} + if path == "/api/v1/logs" and expected_status == 422: + params = {"cursor": "not-a-number"} + response = client.request(method.upper(), path, json=body, headers=headers, params=params) + assert response.status_code == expected_status, response.text + payload = response.json() + assert payload["code"] == expected_code + assert set(payload) == {"code", "message", "details"} + _check(spec, method, path, response) + + +def test_not_found_rule_matches_error_schema( + spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str] +) -> None: + missing = "00000000-0000-4000-8000-000000000000" + url = f"/api/v1/routing/rules/{missing}" + responses = [ + ("get", client.get(url, headers=auth_headers)), + ("put", client.put(url, json={"enabled": True}, headers=auth_headers)), + ("delete", client.delete(url, headers=auth_headers)), + ] + for method, response in responses: + assert response.status_code == 404, response.text + assert response.json()["code"] == "not_found" + _check(spec, method, RULE_ID_PATH, response) + + +def test_conflict_on_duplicate_rule_matches_error_schema( + spec: dict[str, Any], + client: TestClient, + auth_headers: dict[str, str], +) -> None: + first = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers) + assert first.status_code == 201 + duplicate = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers) + assert duplicate.status_code == 409 + assert duplicate.json()["code"] == "conflict" + _check(spec, "post", "/api/v1/routing/rules", duplicate) + + +def test_ready_503_matches_error_schema(spec: dict[str, Any], token: str) -> None: + app = create_app(state=MockState(ready=False), token=token) + with TestClient(app) as client: + response = client.get("/api/v1/ready") + assert response.status_code == 503 + assert response.json()["code"] == "agent_unavailable" + _check(spec, "get", "/api/v1/ready", response) + health = client.get("/api/v1/health") + assert health.status_code == 200 + assert health.json()["status"] == "degraded" + _check(spec, "get", "/api/v1/health", health) + + +def test_validate_endpoint_reports_issues_without_applying( + spec: dict[str, Any], + client: TestClient, + auth_headers: dict[str, str], +) -> None: + response = client.post( + "/api/v1/config/validate", + json={"tunnel": {"address": "10.8.0.2", "listen_port": 51820, "mtu": 1500, "private_key": "k"}}, + headers=auth_headers, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["valid"] is False + assert {issue["code"] for issue in payload["errors"]} == {"invalid_cidr"} + assert {issue["severity"] for issue in payload["warnings"]} == {"warning"} + _check(spec, "post", "/api/v1/config/validate", response) + + +def test_metrics_endpoint_returns_prometheus_text( + spec: dict[str, Any], + client: TestClient, +) -> None: + response = client.get("/metrics") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert "awg_dash_tunnel_up 1" in response.text + _check(spec, "get", "/metrics", response) + + +def test_responses_contain_no_secret_material( + spec: dict[str, Any], + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """AC: в ответах нет приватных ключей — ни значений, ни имён полей.""" + responses = [ + client.get("/api/v1/config", headers=auth_headers), + client.put("/api/v1/config", json={"tunnel": TUNNEL_PAYLOAD}, headers=auth_headers), + client.get("/api/v1/status", headers=auth_headers), + client.get("/api/v1/devices", headers=auth_headers), + client.get("/api/v1/logs", headers=auth_headers), + client.get("/api/v1/auth/session", headers=auth_headers), + client.post("/api/v1/auth/logout", headers=auth_headers), + ] + for response in responses: + body = response.text + for sentinel in SECRET_SENTINELS: + assert sentinel not in body, f"утечка секрета {sentinel} в {response.request.url}" + for field in SECRET_FIELD_NAMES: + assert field not in body, f"поле {field} присутствует в ответе {response.request.url}" + + +def test_websocket_frames_match_spec(spec: dict[str, Any], client: TestClient, token: str) -> None: + frame_schema = spec["components"]["schemas"]["WsFrame"] + metrics_schema = spec["components"]["schemas"]["MetricsSample"] + frame_validator = validator_for(frame_schema, spec) + data_validator = validator_for(metrics_schema, spec) + with client.websocket_connect(f"/api/v1/metrics/stream?token={token}&interval_ms=250") as websocket: + frame = websocket.receive_json() + assert list(frame_validator.iter_errors(frame)) == [] + assert frame["type"] == "metrics" + assert list(data_validator.iter_errors(frame["data"])) == [] + + +def test_websocket_requires_token(client: TestClient) -> None: + with pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/metrics/stream") as websocket: + websocket.receive_json() + + +def test_response_schema_lookup_fails_for_unknown_response(spec: dict[str, Any], client: TestClient) -> None: + """Неописанный в контракте ответ — ошибка контракта, а не молчаливый пропуск.""" + response = client.get("/api/v1/health") + with pytest.raises(ContractViolationError): + validate_response(spec, "/api/v1/health", "get", 418, response.json()) + assert response_schema(spec, "/api/v1/health", "get", 418) is None diff --git a/backend/tests/test_openapi_spec.py b/backend/tests/test_openapi_spec.py new file mode 100644 index 0000000..1555a68 --- /dev/null +++ b/backend/tests/test_openapi_spec.py @@ -0,0 +1,186 @@ +"""Проверки самой спецификации: валидность 3.1, полнота по ТЗ, writeOnly-секреты, единый Error.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +from openapi_spec_validator import validate as validate_openapi +from pydantic import BaseModel + +from awg_api.contract import ( + SECRET_FIELD_NAMES, + error_codes, + iter_operations, + response_schema, + validator_for, + write_only_fields, +) +from awg_api.schemas import ( + AuthLoginRequest, + ConfigUpdateRequest, + DnsConfigIn, + GeoIpUpdateRequest, + ImportRequest, + ModeUpdateRequest, + PeerConfigIn, + RoutingConfigIn, + RoutingRuleCreate, + RoutingRuleUpdate, + TunnelConfigIn, +) + +# Операции, требуемые ТЗ/issue #19. +REQUIRED_OPERATIONS: tuple[tuple[str, str], ...] = ( + ("/api/v1/status", "get"), + ("/api/v1/config", "get"), + ("/api/v1/config", "put"), + ("/api/v1/config/validate", "post"), + ("/api/v1/routing/rules", "get"), + ("/api/v1/routing/rules", "post"), + ("/api/v1/routing/rules/{rule_id}", "get"), + ("/api/v1/routing/rules/{rule_id}", "put"), + ("/api/v1/routing/rules/{rule_id}", "delete"), + ("/api/v1/routing/mode", "put"), + ("/api/v1/routing/geoip/update", "post"), + ("/api/v1/routing/import", "post"), + ("/api/v1/metrics/stream", "get"), + ("/api/v1/health", "get"), + ("/api/v1/ready", "get"), + ("/metrics", "get"), + ("/api/v1/devices", "get"), + ("/api/v1/logs", "get"), + ("/api/v1/auth/login", "post"), + ("/api/v1/auth/logout", "post"), + ("/api/v1/auth/session", "get"), +) + +REQUEST_MODEL_CASES: tuple[tuple[str, type[BaseModel]], ...] = ( + ("TunnelConfig", TunnelConfigIn), + ("PeerConfig", PeerConfigIn), + ("DnsConfig", DnsConfigIn), + ("RoutingConfig", RoutingConfigIn), + ("ConfigUpdateRequest", ConfigUpdateRequest), + ("RoutingRuleCreate", RoutingRuleCreate), + ("RoutingRuleUpdate", RoutingRuleUpdate), + ("ModeUpdateRequest", ModeUpdateRequest), + ("GeoIpUpdateRequest", GeoIpUpdateRequest), + ("ImportRequest", ImportRequest), + ("AuthLoginRequest", AuthLoginRequest), +) + +STATUS_OK_RANGE = range(200, 300) +# 101 Switching Protocols — WebSocket-апгрейд, не ошибка и не JSON-ответ. +STATUS_PROTOCOL_SWITCH = 101 + + +def _spec_schema(spec: dict[str, Any], name: str) -> dict[str, Any]: + return cast(dict[str, Any], spec["components"]["schemas"][name]) + + +def test_spec_is_valid_openapi_31(spec: dict[str, Any]) -> None: + assert spec["openapi"].startswith("3.1") + validate_openapi(spec) + + +@pytest.mark.parametrize(("path", "method"), REQUIRED_OPERATIONS) +def test_required_operation_declared(spec: dict[str, Any], path: str, method: str) -> None: + assert method in spec["paths"].get(path, {}), f"{method.upper()} {path} отсутствует в контракте" + + +def test_every_non_2xx_response_uses_error_schema(spec: dict[str, Any]) -> None: + """Единый формат ошибок: любой не-2xx ответ описан схемой Error.""" + offenders: list[str] = [] + for path, method, operation in iter_operations(spec): + for status in operation.get("responses", {}): + if int(status) in STATUS_OK_RANGE or int(status) == STATUS_PROTOCOL_SWITCH: + continue + schema = response_schema(spec, path, method, status) + if not isinstance(schema, dict) or not str(schema.get("$ref", "")).endswith("/Error"): + offenders.append(f"{method.upper()} {path} -> {status}") + assert offenders == [], f"ответы без схемы Error: {offenders}" + + +def test_error_codes_are_closed_enum(spec: dict[str, Any]) -> None: + codes = error_codes(spec) + assert {"validation_failed", "unauthorized", "not_found", "internal_error"} <= codes + + +def test_secret_fields_are_write_only(spec: dict[str, Any]) -> None: + declared = write_only_fields(spec) + assert SECRET_FIELD_NAMES <= declared, f"секреты без writeOnly: {SECRET_FIELD_NAMES - declared}" + + +def test_write_only_secrets_removed_from_response_schema(spec: dict[str, Any]) -> None: + """Схема ответа (после вырезания writeOnly) не содержит приватных ключей.""" + schema = response_schema(spec, "/api/v1/config", "get", 200) + assert schema is not None + response_document = validator_for(schema, spec).schema + tunnel = response_document["components"]["schemas"]["TunnelConfig"] + peer = response_document["components"]["schemas"]["PeerConfig"] + assert "private_key" not in tunnel["properties"] + assert "private_key" not in tunnel.get("required", []) + assert "preshared_key" not in peer["properties"] + + +def test_private_key_in_response_fails_validation(spec: dict[str, Any]) -> None: + schema = response_schema(spec, "/api/v1/config", "get", 200) + assert schema is not None + validator = validator_for(schema, spec) + payload = { + "tunnel": { + "address": "10.8.0.2/32", + "listen_port": 51820, + "mtu": 1420, + "private_key": "must-not-be-here", + }, + "peers": [], + "dns": {"mode": "internal", "servers": []}, + "routing": { + "mode": "geoip", + "kill_switch": True, + "mtu_clamping": True, + "ipv6": "direct", + "geoip": {"source": "cache", "prefix_count": 1, "updated_at": None}, + }, + "updated_at": "2026-10-22T00:00:00Z", + } + assert list(validator.iter_errors(payload)), "приватный ключ в ответе обязан ломать валидацию" + + +@pytest.mark.parametrize(("schema_name", "model"), REQUEST_MODEL_CASES) +def test_request_models_match_spec(spec: dict[str, Any], schema_name: str, model: type[BaseModel]) -> None: + """Модель запроса и схема контракта совпадают по полям и обязательности.""" + spec_properties = set(_spec_schema(spec, schema_name).get("properties", {})) + spec_required = set(_spec_schema(spec, schema_name).get("required", [])) + model_schema = model.model_json_schema() + model_properties = set(model_schema.get("properties", {})) + model_required = set(model_schema.get("required", [])) + assert model_properties == spec_properties, ( + f"{schema_name}: поля расходятся (только в контракте: " + f"{spec_properties - model_properties}, только в модели: {model_properties - spec_properties})" + ) + assert model_required == spec_required, ( + f"{schema_name}: обязательность расходится (только в контракте: " + f"{spec_required - model_required}, только в модели: {model_required - spec_required})" + ) + + +def test_secret_fields_write_only_in_request_models() -> None: + tunnel_schema = TunnelConfigIn.model_json_schema() + peer_schema = PeerConfigIn.model_json_schema() + auth_schema = AuthLoginRequest.model_json_schema() + assert tunnel_schema["properties"]["private_key"].get("writeOnly") is True + assert peer_schema["properties"]["preshared_key"].get("writeOnly") is True + assert auth_schema["properties"]["password"].get("writeOnly") is True + + +def test_spec_paths_are_versioned(spec: dict[str, Any]) -> None: + """Все прикладные эндпоинты живут под /api/v1 (кроме служебных /metrics и /ready-хелперов).""" + allowed_unversioned = {"/metrics"} + offenders = [ + path + for path, _method, _operation in iter_operations(spec) + if not path.startswith("/api/v1/") and path not in allowed_unversioned + ] + assert offenders == [], f"эндпоинты вне /api/v1: {offenders}" diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..4f46a1e --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1254 @@ +openapi: 3.1.0 +info: + title: awg-dash API + version: 0.1.0 + summary: REST + WebSocket control plane of the AmneziaWG gateway dashboard + description: | + Контракт API v1 (source of truth, ADR-008). Спецификация — источник истины: + реализация и UI-клиент генерируются/проверяются от неё. + + Соглашения: + * Единый формат ошибки — схема `Error` (`{code, message, details}`) для любого + не-2xx ответа, включая 422 и ошибки внутренних исключений. + * Секреты (`private_key`, `preshared_key`, `password`) помечены `writeOnly`: + они допустимы в запросах и ЗАПРЕЩЕНЫ в ответах (`additionalProperties: false` + + удаление `writeOnly`-свойств из ответной валидации). + * Аутентификация: `Authorization: Bearer ` для REST, cookie-сессия + + `X-CSRF-Token` для UI. `/api/v1/health`, `/api/v1/ready`, `/metrics` — открыты. + * Все таймстемпы — RFC 3339 (UTC), целые размеры — байты, длительности — секунды. + + Генерация TS-клиента: `ui/scripts/generate-api.sh` → + `ui/src/api/generated/schema.d.ts`. Контрактный тест: + `pytest backend/tests/test_contract_responses.py`. + license: + name: MIT + identifier: MIT +servers: + - url: / + description: Встроенный uvicorn (ADR-007), тот же origin, что и UI +tags: + - name: ops + description: Служебные эндпоинты (health/ready/metrics), без аутентификации + - name: status + description: Состояние туннеля и сводный статус + - name: config + description: Чтение, запись и валидация конфигурации AmneziaWG + - name: routing + description: Правила маршрутизации, режим split-tunnel, GeoIP, импорт + - name: devices + description: Устройства за шлюзом и их маршрутизация + - name: logs + description: Журнал событий шлюза + - name: auth + description: Аутентификация UI и API + - name: metrics + description: Потоковые метрики (WebSocket) + +security: + - bearerAuth: [] + +paths: + /api/v1/health: + get: + tags: [ops] + operationId: getHealth + summary: Liveness-проба + security: [] + responses: + "200": + description: Процесс жив + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/ready: + get: + tags: [ops] + operationId: getReady + summary: Readiness-проба (готовность агента и зависимостей) + security: [] + responses: + "200": + description: Состояние готовности (в том числе при ready=false) + content: + application/json: + schema: + $ref: "#/components/schemas/ReadyResponse" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /metrics: + get: + tags: [ops] + operationId: getPrometheusMetrics + summary: Метрики в формате Prometheus text exposition + security: [] + responses: + "200": + description: Текстовый экспорт метрик + content: + text/plain: + schema: + type: string + description: Prometheus text exposition format (version 0.0.4) + + /api/v1/status: + get: + tags: [status] + operationId: getStatus + summary: Сводный статус шлюза, туннеля и GeoIP + responses: + "200": + description: Статус + content: + application/json: + schema: + $ref: "#/components/schemas/StatusResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/config: + get: + tags: [config] + operationId: getConfig + summary: Текущая конфигурация (без секретов) + responses: + "200": + description: Конфигурация; секретные поля отсутствуют + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [config] + operationId: putConfig + summary: Атомарное применение конфигурации (validate → staging → apply → rollback) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigUpdateRequest" + responses: + "200": + description: Конфигурация применена + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/config/validate: + post: + tags: [config] + operationId: validateConfig + summary: Сухая валидация конфигурации без применения + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigUpdateRequest" + responses: + "200": + description: Результат валидации (в том числе при valid=false) + content: + application/json: + schema: + $ref: "#/components/schemas/ValidateResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/rules: + get: + tags: [routing] + operationId: listRoutingRules + summary: Список правил маршрутизации + parameters: + - name: enabled + in: query + required: false + description: Фильтр по признаку включённости + schema: + type: boolean + - name: kind + in: query + required: false + schema: + $ref: "#/components/schemas/RuleKind" + responses: + "200": + description: Список правил + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleList" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: [routing] + operationId: createRoutingRule + summary: Создать правило маршрутизации + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleCreate" + responses: + "201": + description: Правило создано + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/rules/{rule_id}: + parameters: + - $ref: "#/components/parameters/RuleId" + get: + tags: [routing] + operationId: getRoutingRule + summary: Правило по идентификатору + responses: + "200": + description: Правило + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [routing] + operationId: updateRoutingRule + summary: Изменить правило + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleUpdate" + responses: + "200": + description: Обновлённое правило + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + delete: + tags: [routing] + operationId: deleteRoutingRule + summary: Удалить правило + responses: + "204": + description: Правило удалено + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/mode: + put: + tags: [routing] + operationId: putRoutingMode + summary: Переключить режим split-tunnel + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModeUpdateRequest" + responses: + "200": + description: Режим применён + content: + application/json: + schema: + $ref: "#/components/schemas/ModeUpdateResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/geoip/update: + post: + tags: [routing] + operationId: updateGeoIp + summary: Обновить снапшот GeoIP (RIPE Stat → локальный кэш) + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/GeoIpUpdateRequest" + responses: + "200": + description: Обновление выполнено (или взято из кэша) + content: + application/json: + schema: + $ref: "#/components/schemas/GeoIpUpdateResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "503": + $ref: "#/components/responses/UpstreamUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/import: + post: + tags: [routing] + operationId: importRoutingConfig + summary: Импорт .conf / JSON (пиры и правила) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ImportRequest" + responses: + "200": + description: Результат импорта + content: + application/json: + schema: + $ref: "#/components/schemas/ImportResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/metrics/stream: + get: + tags: [metrics] + operationId: streamMetrics + summary: WebSocket-поток метрик и статуса + description: | + WebSocket-апгрейд на том же пути. Клиент передаёт токен либо заголовком + `Authorization: Bearer `, либо query-параметром `token`. + Сервер отправляет текстовые JSON-кадры схемы `WsFrame`; при `type=metrics` + поле `data` соответствует `MetricsSample`, при `type=status` — `StatusResponse`, + при `type=error` — `Error`. + parameters: + - name: token + in: query + required: false + description: Токен доступа для сред, где нельзя задать заголовок + schema: + type: string + - name: interval_ms + in: query + required: false + description: Период отправки кадров, мс + schema: + type: integer + minimum: 250 + maximum: 60000 + default: 1000 + responses: + "101": + description: Переключение протокола на WebSocket + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/devices: + get: + tags: [devices] + operationId: listDevices + summary: Устройства за шлюзом + responses: + "200": + description: Список устройств + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceList" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/logs: + get: + tags: [logs] + operationId: listLogs + summary: Журнал событий + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + - name: cursor + in: query + required: false + description: Курсор пагинации из предыдущего ответа (`next_cursor`) + schema: + type: string + - name: level + in: query + required: false + schema: + $ref: "#/components/schemas/LogLevel" + - name: q + in: query + required: false + description: Подстрока поиска по сообщению + schema: + type: string + responses: + "200": + description: Страница журнала + content: + application/json: + schema: + $ref: "#/components/schemas/LogList" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/login: + post: + tags: [auth] + operationId: login + summary: Аутентификация (сессия UI + токен API) + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuthLoginRequest" + responses: + "200": + description: Сессия создана + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/logout: + post: + tags: [auth] + operationId: logout + summary: Завершить сессию + responses: + "200": + description: Сессия завершена + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/session: + get: + tags: [auth] + operationId: getAuthSession + summary: Текущая сессия и CSRF-токен + responses: + "200": + description: Состояние сессии + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: "Токен доступа API (заголовок `Authorization: Bearer `)" + sessionCookie: + type: apiKey + in: cookie + name: awg_session + description: Сессионная cookie UI (требуется вместе с `X-CSRF-Token` для изменяющих запросов) + + parameters: + RuleId: + name: rule_id + in: path + required: true + description: Идентификатор правила + schema: + type: string + format: uuid + + responses: + BadRequest: + description: Некорректный запрос + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Отсутствует или недействителен токен доступа + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Недостаточно прав + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Объект не найден + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Конфликт состояния (например, apply уже выполняется) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + ValidationFailed: + description: Тело запроса не соответствует схеме + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + RateLimited: + description: Превышен лимит запросов + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + UpstreamUnavailable: + description: Внешний источник (RIPE Stat) недоступен + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + AgentUnavailable: + description: Агент шлюза недоступен (unix-socket не отвечает) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalError: + description: Внутренняя ошибка + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + ErrorCode: + type: string + description: Машиночитаемый код ошибки + enum: + - bad_request + - validation_failed + - unauthorized + - forbidden + - not_found + - conflict + - rate_limited + - upstream_unavailable + - agent_unavailable + - internal_error + + Error: + type: object + description: Единый формат ошибки для всех не-2xx ответов + additionalProperties: false + required: [code, message, details] + properties: + code: + $ref: "#/components/schemas/ErrorCode" + message: + type: string + minLength: 1 + description: Человекочитаемое описание + details: + description: Машиночитаемые детали (для validation_failed — список issues) + type: [object, "null"] + additionalProperties: true + default: null + + Check: + type: object + additionalProperties: false + required: [name, ok] + properties: + name: + type: string + ok: + type: boolean + detail: + type: [string, "null"] + + HealthResponse: + type: object + additionalProperties: false + required: [status, version, uptime_s, checks] + properties: + status: + type: string + enum: [ok, degraded, down] + version: + type: string + uptime_s: + type: integer + minimum: 0 + checks: + type: array + items: + $ref: "#/components/schemas/Check" + + ReadyResponse: + type: object + additionalProperties: false + required: [ready, checks] + properties: + ready: + type: boolean + checks: + type: array + items: + $ref: "#/components/schemas/Check" + + RoutingMode: + type: string + description: Режим split-tunnel + enum: [geoip, dns, all-vpn, direct-only] + + RuleKind: + type: string + enum: [domain, cidr, geoip, asn] + + RuleAction: + type: string + enum: [vpn, direct] + + LogLevel: + type: string + enum: [debug, info, warning, error] + + TunnelState: + type: string + enum: [up, down, error] + + TunnelStatus: + type: object + additionalProperties: false + required: [interface, state, handshake_age_s, rx_bytes, tx_bytes, endpoint, last_handshake_at] + properties: + interface: + type: string + examples: [awg0] + state: + $ref: "#/components/schemas/TunnelState" + handshake_age_s: + type: [integer, "null"] + minimum: 0 + rx_bytes: + type: integer + minimum: 0 + tx_bytes: + type: integer + minimum: 0 + endpoint: + type: [string, "null"] + last_handshake_at: + type: [string, "null"] + format: date-time + + GeoIpSource: + type: string + enum: [ripe-stat, cache, none] + + GeoIpStatus: + type: object + additionalProperties: false + required: [source, prefix_count, updated_at] + properties: + source: + $ref: "#/components/schemas/GeoIpSource" + prefix_count: + type: integer + minimum: 0 + updated_at: + type: [string, "null"] + format: date-time + + StatusResponse: + type: object + additionalProperties: false + required: [version, uptime_s, mode, tunnel, geoip, kill_switch, dns_leak_protection] + properties: + version: + type: string + uptime_s: + type: integer + minimum: 0 + mode: + $ref: "#/components/schemas/RoutingMode" + tunnel: + $ref: "#/components/schemas/TunnelStatus" + geoip: + $ref: "#/components/schemas/GeoIpStatus" + kill_switch: + type: boolean + dns_leak_protection: + type: boolean + + TunnelConfig: + type: object + additionalProperties: false + required: [address, listen_port, mtu, private_key] + properties: + address: + type: string + description: Адрес интерфейса в CIDR + examples: ["10.8.0.2/32"] + listen_port: + type: integer + minimum: 1 + maximum: 65535 + mtu: + type: integer + minimum: 576 + maximum: 1500 + private_key: + type: string + writeOnly: true + description: Приватный ключ интерфейса; только запись, в ответах отсутствует + dns: + type: array + items: + type: string + + PeerConfig: + type: object + additionalProperties: false + required: [public_key, endpoint, allowed_ips] + properties: + public_key: + type: string + preshared_key: + type: string + writeOnly: true + description: Preshared key; только запись, в ответах отсутствует + endpoint: + type: string + allowed_ips: + type: array + items: + type: string + persistent_keepalive: + type: [integer, "null"] + minimum: 0 + maximum: 65535 + + DnsConfig: + type: object + additionalProperties: false + required: [mode, servers] + properties: + mode: + type: string + enum: [internal, external] + servers: + type: array + items: + type: string + + Ipv6Policy: + type: string + enum: [direct, tunnel, block] + + RoutingConfig: + type: object + additionalProperties: false + required: [mode, kill_switch, mtu_clamping, ipv6, geoip] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + kill_switch: + type: boolean + mtu_clamping: + type: boolean + ipv6: + $ref: "#/components/schemas/Ipv6Policy" + geoip: + $ref: "#/components/schemas/GeoIpStatus" + + ConfigResponse: + type: object + additionalProperties: false + required: [tunnel, peers, dns, routing, updated_at] + properties: + tunnel: + $ref: "#/components/schemas/TunnelConfig" + peers: + type: array + items: + $ref: "#/components/schemas/PeerConfig" + dns: + $ref: "#/components/schemas/DnsConfig" + routing: + $ref: "#/components/schemas/RoutingConfig" + updated_at: + type: string + format: date-time + + ConfigUpdateRequest: + type: object + description: Частичное обновление конфигурации (merge в текущее состояние) + additionalProperties: false + minProperties: 1 + properties: + tunnel: + $ref: "#/components/schemas/TunnelConfig" + peers: + type: array + items: + $ref: "#/components/schemas/PeerConfig" + dns: + $ref: "#/components/schemas/DnsConfig" + routing: + $ref: "#/components/schemas/RoutingConfig" + + ValidationIssue: + type: object + additionalProperties: false + required: [path, code, message, severity] + properties: + path: + type: string + description: JSON Pointer до проблемного поля + code: + type: string + message: + type: string + severity: + type: string + enum: [error, warning] + + ValidateResponse: + type: object + additionalProperties: false + required: [valid, errors, warnings] + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + warnings: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + + RoutingRule: + type: object + additionalProperties: false + required: [id, name, kind, value, action, enabled, priority, comment, created_at, updated_at] + properties: + id: + type: string + format: uuid + name: + type: string + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + priority: + type: integer + comment: + type: [string, "null"] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + RoutingRuleCreate: + type: object + additionalProperties: false + required: [name, kind, value, action] + properties: + name: + type: string + minLength: 1 + maxLength: 128 + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + minLength: 1 + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + default: true + priority: + type: integer + minimum: 0 + maximum: 10000 + default: 100 + comment: + type: [string, "null"] + + RoutingRuleUpdate: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: + type: string + minLength: 1 + maxLength: 128 + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + minLength: 1 + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + priority: + type: integer + minimum: 0 + maximum: 10000 + comment: + type: [string, "null"] + + RoutingRuleList: + type: object + additionalProperties: false + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/RoutingRule" + total: + type: integer + minimum: 0 + + ModeUpdateRequest: + type: object + additionalProperties: false + required: [mode] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + + ModeUpdateResponse: + type: object + additionalProperties: false + required: [mode, applied_at, reconcile_id] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + applied_at: + type: string + format: date-time + reconcile_id: + type: string + format: uuid + + GeoIpUpdateRequest: + type: object + additionalProperties: false + properties: + force: + type: boolean + default: false + description: Игнорировать локальный кэш и обновить из RIPE Stat + + GeoIpUpdateResponse: + type: object + additionalProperties: false + required: [status, source, prefix_count, updated_at, duration_ms] + properties: + status: + type: string + enum: [updated, cached, failed] + source: + $ref: "#/components/schemas/GeoIpSource" + prefix_count: + type: integer + minimum: 0 + updated_at: + type: string + format: date-time + duration_ms: + type: integer + minimum: 0 + + ImportRequest: + type: object + additionalProperties: false + required: [format, content] + properties: + format: + type: string + enum: [amneziawg-conf, wireguard-conf, json] + content: + type: string + minLength: 1 + apply: + type: boolean + default: false + description: Применить импорт сразу, а не только вернуть разбор + + ImportResponse: + type: object + additionalProperties: false + required: [imported_peers, imported_rules, warnings] + properties: + imported_peers: + type: integer + minimum: 0 + imported_rules: + type: integer + minimum: 0 + warnings: + type: array + items: + type: string + + Device: + type: object + additionalProperties: false + required: [name, ip, mac, vpn, ipsets, last_seen_at] + properties: + name: + type: string + ip: + type: string + mac: + type: [string, "null"] + vpn: + type: boolean + ipsets: + type: array + items: + type: string + last_seen_at: + type: [string, "null"] + format: date-time + + DeviceList: + type: object + additionalProperties: false + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/Device" + total: + type: integer + minimum: 0 + + LogEntry: + type: object + additionalProperties: false + required: [ts, level, logger, message, fields] + properties: + ts: + type: string + format: date-time + level: + $ref: "#/components/schemas/LogLevel" + logger: + type: string + message: + type: string + fields: + type: object + additionalProperties: true + + LogList: + type: object + additionalProperties: false + required: [items, total, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/LogEntry" + total: + type: integer + minimum: 0 + next_cursor: + type: [string, "null"] + + AuthLoginRequest: + type: object + additionalProperties: false + required: [username, password] + properties: + username: + type: string + minLength: 1 + password: + type: string + minLength: 1 + writeOnly: true + + AuthSession: + type: object + additionalProperties: false + required: [authenticated, username, expires_at, csrf_token] + properties: + authenticated: + type: boolean + username: + type: [string, "null"] + expires_at: + type: [string, "null"] + format: date-time + csrf_token: + type: [string, "null"] + + MetricsSample: + type: object + additionalProperties: false + required: [rx_bytes, tx_bytes, rx_rate_bps, tx_rate_bps, handshake_age_s, active_peers] + properties: + rx_bytes: + type: integer + minimum: 0 + tx_bytes: + type: integer + minimum: 0 + rx_rate_bps: + type: integer + minimum: 0 + tx_rate_bps: + type: integer + minimum: 0 + handshake_age_s: + type: [integer, "null"] + minimum: 0 + active_peers: + type: integer + minimum: 0 + + WsFrame: + type: object + additionalProperties: false + required: [type, ts, data] + properties: + type: + type: string + enum: [metrics, status, error] + ts: + type: string + format: date-time + data: + description: | + MetricsSample при type=metrics, StatusResponse при type=status, + Error при type=error. + type: object + additionalProperties: true diff --git a/requirements-contract.txt b/requirements-contract.txt new file mode 100644 index 0000000..d5faa3e --- /dev/null +++ b/requirements-contract.txt @@ -0,0 +1,5 @@ +# Зависимости контрактных тестов API (issue #19). +# После мержа каркаса монорепо (PR #47) строки переезжают в requirements-dev.txt. +jsonschema>=4.23 +openapi-spec-validator>=0.7 +PyYAML>=6 diff --git a/ui/scripts/generate-api.sh b/ui/scripts/generate-api.sh new file mode 100644 index 0000000..7ffd2b6 --- /dev/null +++ b/ui/scripts/generate-api.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Генерация TypeScript-типов клиента из контракта (docs/openapi.yaml, issue #19). +# +# Требуется Node.js >= 20 (генератор запускается через npx, версия запинена). +# Результат коммитится в ui/src/api/generated/schema.d.ts и проверяется в CI. +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +spec="${root_dir}/docs/openapi.yaml" +out_dir="${root_dir}/ui/src/api/generated" +generator="openapi-typescript@7" + +if [[ ! -f "${spec}" ]]; then + echo "contract not found: ${spec}" >&2 + exit 1 +fi + +mkdir -p "${out_dir}" +npx --yes "${generator}" "${spec}" -o "${out_dir}/schema.d.ts" +echo "OK: ${out_dir}/schema.d.ts <- ${spec}" diff --git a/ui/src/api/generated/README.md b/ui/src/api/generated/README.md new file mode 100644 index 0000000..f16f70b --- /dev/null +++ b/ui/src/api/generated/README.md @@ -0,0 +1,27 @@ +# ui/src/api/generated — сгенерированный API-клиент + +**Файлы в этой папке не редактируются руками.** + +| Файл | Источник | +|---|---| +| `schema.d.ts` | `docs/openapi.yaml` (контракт API v1, issue #19) через `openapi-typescript@7` | + +## Перегенерация + +```bash +bash ui/scripts/generate-api.sh +``` + +Требуется Node.js >= 20. CI-джоба `ts-client-is-current` повторяет генерацию и падает, +если закоммиченный файл расходится со спецификацией. + +## Секреты + +Поля `private_key` (TunnelConfig), `preshared_key` (PeerConfig) и `password` +(AuthLoginRequest) помечены в контракте `writeOnly`: + +* их **можно** передавать в запросах (`PUT /api/v1/config`, `POST /api/v1/auth/login`); +* их **нельзя** ожидать в ответах — сервер обязан их вырезать (см. контрактный тест + `backend/tests/test_contract_responses.py::test_responses_contain_no_secret_material`). + +Поэтому UI читает секреты только из полей ввода и никогда не рендерит их из ответов API. diff --git a/ui/src/api/generated/schema.d.ts b/ui/src/api/generated/schema.d.ts new file mode 100644 index 0000000..dec3734 --- /dev/null +++ b/ui/src/api/generated/schema.d.ts @@ -0,0 +1,1229 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Liveness-проба */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Readiness-проба (готовность агента и зависимостей) */ + get: operations["getReady"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Метрики в формате Prometheus text exposition */ + get: operations["getPrometheusMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Сводный статус шлюза, туннеля и GeoIP */ + get: operations["getStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Текущая конфигурация (без секретов) */ + get: operations["getConfig"]; + /** Атомарное применение конфигурации (validate → staging → apply → rollback) */ + put: operations["putConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/config/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Сухая валидация конфигурации без применения */ + post: operations["validateConfig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Список правил маршрутизации */ + get: operations["listRoutingRules"]; + put?: never; + /** Создать правило маршрутизации */ + post: operations["createRoutingRule"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/rules/{rule_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + /** Правило по идентификатору */ + get: operations["getRoutingRule"]; + /** Изменить правило */ + put: operations["updateRoutingRule"]; + post?: never; + /** Удалить правило */ + delete: operations["deleteRoutingRule"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/mode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Переключить режим split-tunnel */ + put: operations["putRoutingMode"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/geoip/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Обновить снапшот GeoIP (RIPE Stat → локальный кэш) */ + post: operations["updateGeoIp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Импорт .conf / JSON (пиры и правила) */ + post: operations["importRoutingConfig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/metrics/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * WebSocket-поток метрик и статуса + * @description WebSocket-апгрейд на том же пути. Клиент передаёт токен либо заголовком + * `Authorization: Bearer `, либо query-параметром `token`. + * Сервер отправляет текстовые JSON-кадры схемы `WsFrame`; при `type=metrics` + * поле `data` соответствует `MetricsSample`, при `type=status` — `StatusResponse`, + * при `type=error` — `Error`. + */ + get: operations["streamMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Устройства за шлюзом */ + get: operations["listDevices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Журнал событий */ + get: operations["listLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Аутентификация (сессия UI + токен API) */ + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Завершить сессию */ + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Текущая сессия и CSRF-токен */ + get: operations["getAuthSession"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description Машиночитаемый код ошибки + * @enum {string} + */ + ErrorCode: "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "upstream_unavailable" | "agent_unavailable" | "internal_error"; + /** @description Единый формат ошибки для всех не-2xx ответов */ + Error: { + code: components["schemas"]["ErrorCode"]; + /** @description Человекочитаемое описание */ + message: string; + /** + * @description Машиночитаемые детали (для validation_failed — список issues) + * @default null + */ + details: { + [key: string]: unknown; + } | null; + }; + Check: { + name: string; + ok: boolean; + detail?: string | null; + }; + HealthResponse: { + /** @enum {string} */ + status: "ok" | "degraded" | "down"; + version: string; + uptime_s: number; + checks: components["schemas"]["Check"][]; + }; + ReadyResponse: { + ready: boolean; + checks: components["schemas"]["Check"][]; + }; + /** + * @description Режим split-tunnel + * @enum {string} + */ + RoutingMode: "geoip" | "dns" | "all-vpn" | "direct-only"; + /** @enum {string} */ + RuleKind: "domain" | "cidr" | "geoip" | "asn"; + /** @enum {string} */ + RuleAction: "vpn" | "direct"; + /** @enum {string} */ + LogLevel: "debug" | "info" | "warning" | "error"; + /** @enum {string} */ + TunnelState: "up" | "down" | "error"; + TunnelStatus: { + /** @example awg0 */ + interface: string; + state: components["schemas"]["TunnelState"]; + handshake_age_s: number | null; + rx_bytes: number; + tx_bytes: number; + endpoint: string | null; + /** Format: date-time */ + last_handshake_at: string | null; + }; + /** @enum {string} */ + GeoIpSource: "ripe-stat" | "cache" | "none"; + GeoIpStatus: { + source: components["schemas"]["GeoIpSource"]; + prefix_count: number; + /** Format: date-time */ + updated_at: string | null; + }; + StatusResponse: { + version: string; + uptime_s: number; + mode: components["schemas"]["RoutingMode"]; + tunnel: components["schemas"]["TunnelStatus"]; + geoip: components["schemas"]["GeoIpStatus"]; + kill_switch: boolean; + dns_leak_protection: boolean; + }; + TunnelConfig: { + /** + * @description Адрес интерфейса в CIDR + * @example 10.8.0.2/32 + */ + address: string; + listen_port: number; + mtu: number; + /** @description Приватный ключ интерфейса; только запись, в ответах отсутствует */ + private_key: string; + dns?: string[]; + }; + PeerConfig: { + public_key: string; + /** @description Preshared key; только запись, в ответах отсутствует */ + preshared_key?: string; + endpoint: string; + allowed_ips: string[]; + persistent_keepalive?: number | null; + }; + DnsConfig: { + /** @enum {string} */ + mode: "internal" | "external"; + servers: string[]; + }; + /** @enum {string} */ + Ipv6Policy: "direct" | "tunnel" | "block"; + RoutingConfig: { + mode: components["schemas"]["RoutingMode"]; + kill_switch: boolean; + mtu_clamping: boolean; + ipv6: components["schemas"]["Ipv6Policy"]; + geoip: components["schemas"]["GeoIpStatus"]; + }; + ConfigResponse: { + tunnel: components["schemas"]["TunnelConfig"]; + peers: components["schemas"]["PeerConfig"][]; + dns: components["schemas"]["DnsConfig"]; + routing: components["schemas"]["RoutingConfig"]; + /** Format: date-time */ + updated_at: string; + }; + /** @description Частичное обновление конфигурации (merge в текущее состояние) */ + ConfigUpdateRequest: { + tunnel?: components["schemas"]["TunnelConfig"]; + peers?: components["schemas"]["PeerConfig"][]; + dns?: components["schemas"]["DnsConfig"]; + routing?: components["schemas"]["RoutingConfig"]; + }; + ValidationIssue: { + /** @description JSON Pointer до проблемного поля */ + path: string; + code: string; + message: string; + /** @enum {string} */ + severity: "error" | "warning"; + }; + ValidateResponse: { + valid: boolean; + errors: components["schemas"]["ValidationIssue"][]; + warnings: components["schemas"]["ValidationIssue"][]; + }; + RoutingRule: { + /** Format: uuid */ + id: string; + name: string; + kind: components["schemas"]["RuleKind"]; + value: string; + action: components["schemas"]["RuleAction"]; + enabled: boolean; + priority: number; + comment: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + RoutingRuleCreate: { + name: string; + kind: components["schemas"]["RuleKind"]; + value: string; + action: components["schemas"]["RuleAction"]; + /** @default true */ + enabled: boolean; + /** @default 100 */ + priority: number; + comment?: string | null; + }; + RoutingRuleUpdate: { + name?: string; + kind?: components["schemas"]["RuleKind"]; + value?: string; + action?: components["schemas"]["RuleAction"]; + enabled?: boolean; + priority?: number; + comment?: string | null; + }; + RoutingRuleList: { + items: components["schemas"]["RoutingRule"][]; + total: number; + }; + ModeUpdateRequest: { + mode: components["schemas"]["RoutingMode"]; + }; + ModeUpdateResponse: { + mode: components["schemas"]["RoutingMode"]; + /** Format: date-time */ + applied_at: string; + /** Format: uuid */ + reconcile_id: string; + }; + GeoIpUpdateRequest: { + /** + * @description Игнорировать локальный кэш и обновить из RIPE Stat + * @default false + */ + force: boolean; + }; + GeoIpUpdateResponse: { + /** @enum {string} */ + status: "updated" | "cached" | "failed"; + source: components["schemas"]["GeoIpSource"]; + prefix_count: number; + /** Format: date-time */ + updated_at: string; + duration_ms: number; + }; + ImportRequest: { + /** @enum {string} */ + format: "amneziawg-conf" | "wireguard-conf" | "json"; + content: string; + /** + * @description Применить импорт сразу, а не только вернуть разбор + * @default false + */ + apply: boolean; + }; + ImportResponse: { + imported_peers: number; + imported_rules: number; + warnings: string[]; + }; + Device: { + name: string; + ip: string; + mac: string | null; + vpn: boolean; + ipsets: string[]; + /** Format: date-time */ + last_seen_at: string | null; + }; + DeviceList: { + items: components["schemas"]["Device"][]; + total: number; + }; + LogEntry: { + /** Format: date-time */ + ts: string; + level: components["schemas"]["LogLevel"]; + logger: string; + message: string; + fields: { + [key: string]: unknown; + }; + }; + LogList: { + items: components["schemas"]["LogEntry"][]; + total: number; + next_cursor: string | null; + }; + AuthLoginRequest: { + username: string; + password: string; + }; + AuthSession: { + authenticated: boolean; + username: string | null; + /** Format: date-time */ + expires_at: string | null; + csrf_token: string | null; + }; + MetricsSample: { + rx_bytes: number; + tx_bytes: number; + rx_rate_bps: number; + tx_rate_bps: number; + handshake_age_s: number | null; + active_peers: number; + }; + WsFrame: { + /** @enum {string} */ + type: "metrics" | "status" | "error"; + /** Format: date-time */ + ts: string; + /** + * @description MetricsSample при type=metrics, StatusResponse при type=status, + * Error при type=error. + */ + data: { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Некорректный запрос */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Отсутствует или недействителен токен доступа */ + Unauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Недостаточно прав */ + Forbidden: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Объект не найден */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Конфликт состояния (например, apply уже выполняется) */ + Conflict: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Тело запроса не соответствует схеме */ + ValidationFailed: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Превышен лимит запросов */ + RateLimited: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Внешний источник (RIPE Stat) недоступен */ + UpstreamUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Агент шлюза недоступен (unix-socket не отвечает) */ + AgentUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Внутренняя ошибка */ + InternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description Идентификатор правила */ + RuleId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Процесс жив */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + getReady: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Состояние готовности (в том числе при ready=false) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadyResponse"]; + }; + }; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + getPrometheusMetrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Текстовый экспорт метрик */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + getStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Статус */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + getConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Конфигурация; секретные поля отсутствуют */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + putConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfigUpdateRequest"]; + }; + }; + responses: { + /** @description Конфигурация применена */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + validateConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfigUpdateRequest"]; + }; + }; + responses: { + /** @description Результат валидации (в том числе при valid=false) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidateResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + listRoutingRules: { + parameters: { + query?: { + /** @description Фильтр по признаку включённости */ + enabled?: boolean; + kind?: components["schemas"]["RuleKind"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Список правил */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRuleList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + createRoutingRule: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoutingRuleCreate"]; + }; + }; + responses: { + /** @description Правило создано */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + getRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Правило */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + updateRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoutingRuleUpdate"]; + }; + }; + responses: { + /** @description Обновлённое правило */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + deleteRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Правило удалено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + putRoutingMode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ModeUpdateRequest"]; + }; + }; + responses: { + /** @description Режим применён */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModeUpdateResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + updateGeoIp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["GeoIpUpdateRequest"]; + }; + }; + responses: { + /** @description Обновление выполнено (или взято из кэша) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeoIpUpdateResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["UpstreamUnavailable"]; + }; + }; + importRoutingConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImportRequest"]; + }; + }; + responses: { + /** @description Результат импорта */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + streamMetrics: { + parameters: { + query?: { + /** @description Токен доступа для сред, где нельзя задать заголовок */ + token?: string; + /** @description Период отправки кадров, мс */ + interval_ms?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Переключение протокола на WebSocket */ + 101: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + listDevices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Список устройств */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeviceList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + listLogs: { + parameters: { + query?: { + limit?: number; + /** @description Курсор пагинации из предыдущего ответа (`next_cursor`) */ + cursor?: string; + level?: components["schemas"]["LogLevel"]; + /** @description Подстрока поиска по сообщению */ + q?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Страница журнала */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthLoginRequest"]; + }; + }; + responses: { + /** @description Сессия создана */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 429: components["responses"]["RateLimited"]; + 500: components["responses"]["InternalError"]; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Сессия завершена */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + getAuthSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Состояние сессии */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; +}