feat(api): роутеры API v1 и WebSocket-поток метрик (Refs #19)
contract-openapi / openapi-contract (push) Has been cancelled
contract-openapi / ts-client-is-current (push) Has been cancelled

- ops: health/ready/metrics без аутентификации (503 = схема Error).
- auth: login/logout/session, cookie-сессия + CSRF-токен.
- status/config/routing/devices+logs: все эндпоинты контракта.
- ws/metrics: /api/v1/metrics/stream, токен в заголовке или query.
This commit is contained in:
2026-10-22 00:21:56 +03:00
parent 839f773010
commit 7d9cf8ca5e
9 changed files with 258 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Роутеры API v1."""
+34
View File
@@ -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()
+27
View File
@@ -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)
+29
View File
@@ -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)
+36
View File
@@ -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())
+67
View File
@@ -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)
+16
View File
@@ -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()