feat(api): контрактный харнесс и in-memory состояние мок-сервера (Refs #19)
contract-openapi / openapi-contract (push) Has been cancelled
contract-openapi / ts-client-is-current (push) Has been cancelled

- contract.py: загрузка docs/openapi.yaml, вырезание writeOnly-секретов из схем
  ответов, валидация тела ответа (jsonschema Draft2020-12 + FormatChecker),
  двусторонняя сверка маршрутов приложения и контракта.
- state.py: мок-состояние туннеля/правил/устройств/журнала; секреты хранятся
  отдельно от словарей-ответов.
This commit is contained in:
2026-10-22 00:21:43 +03:00
parent 83d5a23406
commit 839f773010
2 changed files with 713 additions and 0 deletions
+471
View File
@@ -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,
}