From 7d9cf8ca5e449df1f686e500caa2811d248c9ba4 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Thu, 22 Oct 2026 00:21:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=80=D0=BE=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20API=20v1=20=D0=B8=20WebSocket-=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20(Refs=20#?= =?UTF-8?q?19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops: health/ready/metrics без аутентификации (503 = схема Error). - auth: login/logout/session, cookie-сессия + CSRF-токен. - status/config/routing/devices+logs: все эндпоинты контракта. - ws/metrics: /api/v1/metrics/stream, токен в заголовке или query. --- backend/awg_api/routers/__init__.py | 1 + backend/awg_api/routers/auth.py | 34 +++++++++++++++ backend/awg_api/routers/config.py | 27 ++++++++++++ backend/awg_api/routers/devices.py | 29 +++++++++++++ backend/awg_api/routers/ops.py | 36 ++++++++++++++++ backend/awg_api/routers/routing.py | 67 +++++++++++++++++++++++++++++ backend/awg_api/routers/status.py | 16 +++++++ backend/awg_api/ws/__init__.py | 1 + backend/awg_api/ws/metrics.py | 47 ++++++++++++++++++++ 9 files changed, 258 insertions(+) create mode 100644 backend/awg_api/routers/__init__.py create mode 100644 backend/awg_api/routers/auth.py create mode 100644 backend/awg_api/routers/config.py create mode 100644 backend/awg_api/routers/devices.py create mode 100644 backend/awg_api/routers/ops.py create mode 100644 backend/awg_api/routers/routing.py create mode 100644 backend/awg_api/routers/status.py create mode 100644 backend/awg_api/ws/__init__.py create mode 100644 backend/awg_api/ws/metrics.py 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/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