diff --git a/backend/awg_api/__init__.py b/backend/awg_api/__init__.py new file mode 100644 index 0000000..e02b912 --- /dev/null +++ b/backend/awg_api/__init__.py @@ -0,0 +1,5 @@ +"""FastAPI-приложение awg-dash: REST API v1, WebSocket, статика UI.""" + +__version__ = "0.0.1" + +__all__ = ["__version__"] 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/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/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})