"""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, }