From 63c4fa930d520a23d96f4bdf44fe3dc6d9198899 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:28:50 +0300 Subject: [PATCH 1/6] =?UTF-8?q?chore(#16):=20skeleton=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=E2=80=94=20?= =?UTF-8?q?README,=20Makefile,=20=D0=BB=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=8B,=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 22 +++++++++ .gitignore | 36 +++++++++++++++ .pre-commit-config.yaml | 24 ++++++++++ Makefile | 44 ++++++++++++++++++ README.md | 100 +++++++++++++++++++++++++--------------- pyproject.toml | 32 +++++++++++++ requirements-dev.txt | 8 ++++ requirements.txt | 12 +++++ 8 files changed, 240 insertions(+), 38 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2044482 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.{yml,yaml,json,ts,tsx,js,css,html}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc74b5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.venv*/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ + +# UI +node_modules/ +ui/dist/ +ui/.vite/ + +# Runtime / состояние шлюза (живёт в томах, не в репозитории) +data/ +config/ +*.db +*.db-shm +*.db-wal + +# Секреты и конфиги клиентов +.env +.env.* +!.env.example +*.conf +!deploy/*.conf.example + +# IDE / OS +.idea/ +.vscode/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ca1bcb1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + files: ^backend/ + additional_dependencies: [pydantic>=2.9] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=512] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae4f41a --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +PY ?= python3 +VENV ?= .venv +BIN := $(VENV)/bin +IMAGE ?= awg-dash:dev +COMPOSE_DEV := deploy/docker-compose.dev.yml + +.PHONY: help venv lint fmt test build dev-up dev-down dev-logs test-netns + +help: + @grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | sed 's/:.*//' | sort -u + +venv: + $(PY) -m venv $(VENV) + $(BIN)/pip install -U pip + $(BIN)/pip install -r requirements-dev.txt + +lint: + $(BIN)/ruff check backend + $(BIN)/mypy backend + +fmt: + $(BIN)/ruff format backend + $(BIN)/ruff check --fix backend + +test: + $(BIN)/pytest + +build: + @test -f deploy/Dockerfile || { echo 'deploy/Dockerfile ещё нет — задача #18'; exit 1; } + docker build -f deploy/Dockerfile -t $(IMAGE) . + +dev-up: + @test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; } + docker compose -f $(COMPOSE_DEV) up -d --build + +dev-down: + docker compose -f $(COMPOSE_DEV) down + +dev-logs: + docker compose -f $(COMPOSE_DEV) logs -f --tail=100 + +test-netns: + @test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; } + bash tests/netns/run.sh diff --git a/README.md b/README.md index 0b0ddfb..c7709a7 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,64 @@ # awg-dash -# awg-dash 🚀 - -**awg-dash** — это легковесная и удобная веб-панель (Dashboard) для визуального управления сервером **Amnezia WireGuard (AWG)**. Она позволяет создавать, удалять и просматривать конфигурации пользователей в один клик через современный веб-интерфейс, защищенный от блокировок. - -[![License: MIT](https://shields.io)](https://opensource.org) -[![GitHub Stars](https://shields.io)](https://github.com) - ---- - -## ✨ Особенности / Features - -- 🛠️ **Полная поддержка Amnezia WireGuard:** Тонкая настройка параметров обфускации (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`, `H2`, `H3`, `H4`) прямо из панели. -- 👥 **Управление пользователями:** Создание, удаление, временная заморозка и управление лимитами пиров (клиентов). -- 📊 **Мониторинг в реальном времени:** Графики потребления трафика, статус подключения клиентов и общая нагрузка на сервер. -- 📱 **QR-коды и конфиги:** Мгновенная генерация QR-кодов и скачивание `.conf` файлов для импорта в официальное приложение Amnezia. -- 🔒 **Безопасность:** Авторизация по паролю/токену, поддержка сессий и возможность работы за обратным прокси (Nginx, Caddy). -- 🐳 **Docker-first:** Быстрый запуск одной командой через Docker Compose. - ---- - -## 🛠️ Технологический стек / Tech Stack - -- **Backend:** Node.js (Express) / Python (FastAPI) / Go *(выберите нужное)* -- **Frontend:** Vue.js / React / Простой HTML+JS *(выберите нужное)* -- **База данных:** SQLite / JSON-file (минималистично, без тяжелых БД) -- **Ядро:** Amnezia WireGuard (модифицированная утилита `wg`) - ---- - -## 🚀 Быстрый старт / Quick Start - -### Способ 1: Через Docker Compose (Рекомендуемый) - -1. Клонируйте репозиторий: - ```bash - git clone https://github.com - cd awg-dash - ``` \ No newline at end of file +VPN-шлюз **AmneziaWG** с интеллектуальной маршрутизацией: один Docker-образ = туннель + policy routing + split-tunnel (GeoIP / DNS) + REST API v1 + Web UI (тёмная тема). + +- Эпик и декомпозиция: Gitea issue **#15** (`embeddevio/awg-dash`). +- Исполнение: Hermes Kanban, борда **`awg-dash`** (`hermes kanban --board awg-dash list`). +- PoC-референс: `embeddevio/amneziawg-docker-gateway` (bash + iptables, `docs/technical-specification.md`). + +## Архитектура + +Два процесса в одном образе: + +| Процесс | Пользователь | Ответственность | +|---|---|---| +| `awg-agent` | root | awg/wg-интерфейс, iptables/ipset, dnsmasq, policy routing, метрики | +| `awg-api` | непривилегированный | FastAPI: REST API v1, WebSocket, статика UI | + +Обмен — unix-socket `/run/awg/agent.sock`; состояние — SQLite в `/data`, конфиги `.conf` в `/config`. +Принятые решения зафиксированы в `docs/adr/`. + +## Быстрый старт (после релиза 1.0) + +```bash +docker run -dit --name=vpn-gateway \ + --network=host \ + --device=/dev/net/tun:/dev/net/tun \ + --cap-add=NET_ADMIN --cap-add=SYS_MODULE \ + --restart always \ + -v /srv/awg/config:/config -v /srv/awg/data:/data \ + registry.example.com/vpn-gateway:latest +``` + +Web UI: `http://:8080`. До релиза образ собирается локально: `make build`. + +## Разработка + +```bash +make venv # локальное окружение (.venv) +make lint # ruff + mypy +make test # pytest + coverage (порог 70%) +make build # docker build образа (задача #18) +make dev-up # dev-стенд (задача #21) +``` + +Сетевые тесты выполняются в netns-стенде (`tests/netns/`), без реального VPN-провайдера. + +## Структура репозитория + +| Путь | Назначение | +|---|---| +| `backend/awg_agent/` | привилегированный агент: туннель, маршрутизация, GeoIP, DNS, метрики | +| `backend/awg_api/` | FastAPI-приложение (REST + WS + статика UI) | +| `backend/tests/` | unit/integration-тесты (pytest) | +| `ui/` | React + TypeScript + Tailwind + shadcn-ui | +| `deploy/` | Dockerfile, compose-профили, entrypoint, Grafana-дашборд | +| `docs/adr/` | архитектурные решения | +| `tests/netns/` | сетевой тестовый стенд | + +## Процесс разработки + +1 задача = 1 issue = 1 ветка `feat/-slug` = 1 PR. + +Гейты мержа: CI green, coverage нового кода ≥ 70 %, security scan low/none, ревью владельца роли. +Merge в `main` и деплой в prod — только TechLead после гейтов. Все операции с репозиторием — через Gitea MCP. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4fb2f12 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "awg-dash" +version = "0.0.1" +description = "AmneziaWG gateway with intelligent routing: REST API + Web UI + split-tunnel" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["ui", ".venv", "backend/tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "ASYNC", "S", "RUF"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"backend/tests/*" = ["S", "D"] + +[tool.mypy] +python_version = "3.12" +mypy_path = "backend" +files = ["backend"] +strict = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +pythonpath = ["backend"] +addopts = "-q --cov=awg_agent --cov=awg_api --cov-report=term-missing --cov-fail-under=70" +asyncio_mode = "auto" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9b729f2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +ruff>=0.6.9 +mypy>=1.11 +pytest>=8.3 +pytest-cov>=5.0 +pytest-asyncio>=0.24 +pre-commit>=3.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd3b550 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# Runtime (контроль-плоскость) +fastapi>=0.115 +uvicorn[standard]>=0.32 +pydantic>=2.9 +pydantic-settings>=2.5 +aiosqlite>=0.20 +httpx>=0.27 +structlog>=24.4 +prometheus-client>=0.21 + +# Сетевой слой вызывается через бинари (awg, iptables, ipset, dnsmasq), +# поэтому python-зависимостей для сети не требуется. From 5647d339b14efa5ff0cff2f9180cf33565481f70 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:28:59 +0300 Subject: [PATCH 2/6] =?UTF-8?q?chore(#16):=20=D0=BA=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20bac?= =?UTF-8?q?kend,=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B8=20ui/deploy/tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/awg_agent/README.md | 16 ++++++++++++++++ backend/awg_agent/__init__.py | 5 +++++ backend/awg_api/README.md | 13 +++++++++++++ backend/awg_api/__init__.py | 5 +++++ backend/tests/test_skeleton.py | 13 +++++++++++++ deploy/README.md | 12 ++++++++++++ tests/netns/README.md | 13 +++++++++++++ ui/README.md | 14 ++++++++++++++ 8 files changed, 91 insertions(+) create mode 100644 backend/awg_agent/README.md create mode 100644 backend/awg_agent/__init__.py create mode 100644 backend/awg_api/README.md create mode 100644 backend/awg_api/__init__.py create mode 100644 backend/tests/test_skeleton.py create mode 100644 deploy/README.md create mode 100644 tests/netns/README.md create mode 100644 ui/README.md diff --git a/backend/awg_agent/README.md b/backend/awg_agent/README.md new file mode 100644 index 0000000..6c9b353 --- /dev/null +++ b/backend/awg_agent/README.md @@ -0,0 +1,16 @@ +# awg_agent — привилегированный агент + +Запускается как root в контейнере: всё, что требует NET_ADMIN и доступа к ядру. + +| Модуль | Ответственность | Задача | +|---|---|---| +| `vpn/` | парсинг/валидация `.conf`, up/down/restart туннеля, `awg show` → статус | #22 | +| `watchdog/` | контроль handshake, авто-восстановление, fail-closed | #23 | +| `netfilter/` | iptables/ipset, policy routing, atomic swap, MSS clamping, IPv6 | #24 | +| `geoip/` | RIPE Stat, кэш-снапшот, расписание, fallback | #25 | +| `dns/` | dnsmasq, домены → ipset, защита от DNS-утечки | #26 | +| `routing/` | режимы (GeoIP / DNS / All-VPN / Direct-only), reconcile, исключения | #27 | +| `metrics/` | RX/TX, direct vs VPN, история, агрегаты | #28 | +| `devices/` | активные устройства LAN и их политики | #29 | + +Интерфейс для `awg_api` — unix-socket `/run/awg/agent.sock` (см. ADR-002). diff --git a/backend/awg_agent/__init__.py b/backend/awg_agent/__init__.py new file mode 100644 index 0000000..ec9c356 --- /dev/null +++ b/backend/awg_agent/__init__.py @@ -0,0 +1,5 @@ +"""Привилегированный агент awg-dash: туннель, маршрутизация, GeoIP, DNS, метрики.""" + +__version__ = "0.0.1" + +__all__ = ["__version__"] diff --git a/backend/awg_api/README.md b/backend/awg_api/README.md new file mode 100644 index 0000000..288c805 --- /dev/null +++ b/backend/awg_api/README.md @@ -0,0 +1,13 @@ +# awg_api — control plane + +Непривилегированный процесс: FastAPI + REST API v1 + WebSocket + отдача статики UI. + +| Модуль | Ответственность | Задача | +|---|---|---| +| `app.py` | lifespan, конфиг, логирование, error model, статика | #31 | +| `routers/` | эндпоинты по OpenAPI (status, config, routing, devices, logs, auth) | #32 | +| `ws/` | `/api/v1/metrics/stream` | #33 | +| `security/` | токен, CSRF, rate limit, маскировка секретов | #34 | +| `store/` | SQLite (WAL), миграции, audit log, apply/rollback | #20 | + +Контракт — `docs/openapi.yaml` (задача #19), TS-клиент генерируется в `ui/src/api/generated/`. 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/tests/test_skeleton.py b/backend/tests/test_skeleton.py new file mode 100644 index 0000000..3e572ae --- /dev/null +++ b/backend/tests/test_skeleton.py @@ -0,0 +1,13 @@ +"""Скелетные тесты: пакеты импортируются, версия объявлена.""" + +import awg_agent +import awg_api + + +def test_versions_match() -> None: + assert awg_agent.__version__ == awg_api.__version__ + + +def test_public_api_declared() -> None: + assert "__version__" in awg_agent.__all__ + assert "__version__" in awg_api.__all__ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..77c784a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,12 @@ +# deploy — сборка и развёртывание + +| Артефакт | Назначение | Задача | +|---|---|---| +| `Dockerfile` | multi-stage: amneziawg-go + awg-tools → runtime alpine | #18 | +| `entrypoint.sh` | супервизор: `awg-agent` (root) + `awg-api` | #18 | +| `docker-compose.dev.yml` | dev-стенд: порт 8080, тома `/config` `/data`, healthcheck | #21 | +| `docker-compose.test.yml` | стенд для тестов | #21 | +| `grafana/` | дашборд JSON (опциональный профиль monitoring) | #46 | +| `awg0.conf.example` | пример клиентского конфига | #22 | + +Запуск по ТЗ (после релиза): `--network=host`, `--device=/dev/net/tun`, `--cap-add=NET_ADMIN --cap-add=SYS_MODULE`, `--restart always`. diff --git a/tests/netns/README.md b/tests/netns/README.md new file mode 100644 index 0000000..e6139e1 --- /dev/null +++ b/tests/netns/README.md @@ -0,0 +1,13 @@ +# tests/netns — сетевой тестовый стенд + +Задача **#21**, используется тестами #41 и #43. + +Цель: проверять split-tunnel без реального VPN-провайдера и без доступа к продакшн-сети. + +План харнесса: +1. Создать netns: `client`, `gw` (шлюз с awg-интерфейсом), `uplink` (имитация ISP). +2. Поднять мок WireGuard-endpoint (userspace) вместо реального сервера. +3. Запустить шлюз в netns с теми же правилами, что в контейнере. +4. Проверить, куда ушёл пакет: интерфейс/метка/счётчики ipset и iptables. + +Точки входа: `tests/netns/run.sh` (вызывается через `make test-netns`). diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..5a4d4bd --- /dev/null +++ b/ui/README.md @@ -0,0 +1,14 @@ +# ui — Web UI (React + TypeScript + Tailwind + shadcn-ui) + +Тёмная тема, 6 страниц из ТЗ. Каркас — задача **#35**, страницы — #36–#39, E2E — #42. + +| Маршрут | Страница | Задача | +|---|---|---| +| `/` | Dashboard: статус, трафик, handshake, аптайм | #36 | +| `/routing` | редактор правил, ipset, домены, режимы, DnD+diff | #37 | +| `/config` | загрузка `.conf`, валидация, маскировка ключей | #38 | +| `/devices` | устройства и политики | #39 | +| `/logs` | журнал, счётчики RETURN vs MARK | #39 | +| `/settings` | GeoIP-расписание, обновления, интеграции | #39 | + +API-клиент генерируется из OpenAPI (#19) в `src/api/generated/`. Взаимодействие: REST + WebSocket. From 63e653b710e2bddd1f20ca59fdac17a17669dd4c Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:29:18 +0300 Subject: [PATCH 3/6] =?UTF-8?q?docs(#16):=20ADR-001,=20ADR-002,=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=D0=B8=20=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/000-template.md | 21 ++++++++++++ docs/adr/001-backend-python-fastapi.md | 27 ++++++++++++++++ docs/adr/002-single-image-modular-monolith.md | 32 +++++++++++++++++++ docs/adr/README.md | 16 ++++++++++ 4 files changed, 96 insertions(+) create mode 100644 docs/adr/000-template.md create mode 100644 docs/adr/001-backend-python-fastapi.md create mode 100644 docs/adr/002-single-image-modular-monolith.md create mode 100644 docs/adr/README.md diff --git a/docs/adr/000-template.md b/docs/adr/000-template.md new file mode 100644 index 0000000..328cca6 --- /dev/null +++ b/docs/adr/000-template.md @@ -0,0 +1,21 @@ +# NNN. Название решения + +- **Статус:** предложено | принято | устарело +- **Дата:** YYYY-MM-DD +- **Контекст/issue:** #N + +## Контекст + +Что вынуждает принять решение и какие есть ограничения (хостовое окружение, привилегии, один шлюз, целевой UX). + +## Решение + +Что именно решили, одним абзацем. + +## Альтернативы + +Что рассмотрели и почему отклонили. + +## Последствия + +Положительные и отрицательные следствия; что решение накладывает на задачи эпика. diff --git a/docs/adr/001-backend-python-fastapi.md b/docs/adr/001-backend-python-fastapi.md new file mode 100644 index 0000000..9a96158 --- /dev/null +++ b/docs/adr/001-backend-python-fastapi.md @@ -0,0 +1,27 @@ +# 001. Backend — Python 3.12 + FastAPI + +- **Статус:** принято +- **Контекст/issue:** #15, #31 + +## Контекст + +Нужен control plane: REST API, WebSocket, планировщик, парсинг конфигов, работа с SQLite. +Низкоуровневая работа сети и шифрования выполняется не нашим кодом, а бинарями `awg` и `amneziawg-go`, +трафик маршрутизирует ядро. + +## Решение + +Control plane — **Python 3.12 + FastAPI (async)**. Go остаётся только там, где он уже есть: +`amneziawg-go` и `amneziawg-tools` собираются в multi-stage Dockerfile (ADR-002). + +## Альтернативы + +- **Go целиком** — быстрее на сетевом I/O, но маршрутизацией занимается ядро, а не процесс; + выигрыш не оправдывает рост стоимости контракта с UI (схемы, генерация клиента, миграции, веб-фреймворк). +- **Node.js** — преимуществ перед Python на нагрузке в единичного пользователя нет. + +## Последствия + +- OpenAPI-first: FastAPI отдаёт схему, что прямо соответствует задаче #19. +- В образе появляется Python runtime (плюс 40–60 MB к размеру). +- Дисциплина async: внешние команды — только `asyncio.create_subprocess_exec`, блокирующего кода в event loop нет. diff --git a/docs/adr/002-single-image-modular-monolith.md b/docs/adr/002-single-image-modular-monolith.md new file mode 100644 index 0000000..765dc1a --- /dev/null +++ b/docs/adr/002-single-image-modular-monolith.md @@ -0,0 +1,32 @@ +# 002. Модульный монолит в одном образе, два процесса + +- **Статус:** принято +- **Контекст/issue:** #15, #18 + +## Контекст + +ТЗ требует единый Docker-образ с минимальными параметрами запуска. Часть операций требует root +(интерфейс awg, iptables/ipset, dnsmasq на порту 53), а веб-часть должна быть скомпрометирована безопасно — без root. + +## Решение + +Один образ, два процесса под супервизором `deploy/entrypoint.sh`: + +| Процесс | Права | Ответственность | +|---|---|---| +| `awg-agent` | root, NET_ADMIN | туннель, netfilter, routing, dnsmasq, метрики | +| `awg-api` | непривилегированный | FastAPI, REST/WS, статика UI | + +Обмен — unix-socket `/run/awg/agent.sock`, общее состояние — SQLite в `/data` и конфиги в `/config`. + +## Альтернативы + +- **Микросервисы** (отдельные контейнеры agent/api/dns) — общая сеть, координация и дублирование + привилегий; выгода масштабирования на одном шлюзе отсутствует. +- **Один процесс под root** — проще, но даёт веб-слою полные права; неприемлемо. + +## Последствия + +- Супервизор обязан пробрасывать сигналы и коды выхода, иначе Docker не заметит падения процесса. +- Права на unix-socket входят в security-проверки (#44). +- Перезапуск API не разрывает туннель. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..9cf9ed5 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,16 @@ +# ADR — архитектурные решения awg-dash + +Один файл = одно решение. Формат: контекст -> решение -> последствия. + +| ADR | Решение | +|---|---| +| [001](001-backend-python-fastapi.md) | Backend — Python 3.12 + FastAPI | +| [002](002-single-image-modular-monolith.md) | Модульный монолит в одном образе, 2 процесса | +| [003](003-reconcile-desired-state.md) | Декларативный reconcile вместо императивных скриптов | +| [004](004-geoip-ripe-provider.md) | GeoIP: RIPE Stat + локальный кэш + абстракция провайдера | +| [005](005-dns-dnsmasq.md) | DNS: встроенный dnsmasq + перехват порта 53 | +| [006](006-state-sqlite.md) | Состояние: SQLite (WAL) + /config + /data | +| [007](007-embedded-web-server.md) | Web-сервер: встроенный uvicorn, без nginx | +| [008](008-api-ui-security.md) | Безопасность API/UI: токен, CSRF, rate limit, маскировка секретов | + +Новые решения добавляются со следующим номером и ссылкой на issue, для которого решение принято. From 63a2df893d614d2e63842a1791b789fe3be48869 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:29:24 +0300 Subject: [PATCH 4/6] =?UTF-8?q?docs(#16):=20ADR-003=20(reconcile)=20=D0=B8?= =?UTF-8?q?=20ADR-004=20(GeoIP=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9?= =?UTF-8?q?=D0=B4=D0=B5=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/003-reconcile-desired-state.md | 28 +++++++++++++++++++++++++ docs/adr/004-geoip-ripe-provider.md | 28 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 docs/adr/003-reconcile-desired-state.md create mode 100644 docs/adr/004-geoip-ripe-provider.md diff --git a/docs/adr/003-reconcile-desired-state.md b/docs/adr/003-reconcile-desired-state.md new file mode 100644 index 0000000..82f1e47 --- /dev/null +++ b/docs/adr/003-reconcile-desired-state.md @@ -0,0 +1,28 @@ +# 003. Декларативный reconcile вместо императивных скриптов + +- **Статус:** принято +- **Контекст/issue:** #15, #24, #27 + +## Контекст + +PoC (`entrypoint.sh`) применяет правила императивно и только при старте: изменения не откатываются, +GeoIP не обновляется без рестарта, частичная ошибка оставляет систему в неопределённом состоянии. + +## Решение + +Единый reconcile-цикл: desired state (SQLite) -> diff -> идемпотентное применение -> проверка -> +rollback при ошибке. Операции netfilter идемпотентны (проверка `-C` перед `-A`), замена больших +наборов атомарна (`ipset create tmp`, `swap`, `destroy`), применение конфига транзакционно +(validate -> stage -> apply -> rollback). + +## Альтернативы + +- **Скрипты по событию** (как в PoC) — быстро написать, но невозможно гарантировать сходимость после сбоя. +- **Готовый IaC-инструмент** — избыточен, требует внешнего рантайма в образе и не покрывает ipset/dnsmasq. + +## Последствия + +- Старт контейнера = reconcile с нуля: состояние всегда восстановимо. +- Нужны тесты на идемпотентность (повторный apply не создаёт дублей) — #41. +- Сложнее скрипта: оправдано требованиями FR-2 (смена режимов и правил без рестарта). +- Diff reconcile логируется и отдаётся в UI (`/logs`) и audit log. diff --git a/docs/adr/004-geoip-ripe-provider.md b/docs/adr/004-geoip-ripe-provider.md new file mode 100644 index 0000000..e656736 --- /dev/null +++ b/docs/adr/004-geoip-ripe-provider.md @@ -0,0 +1,28 @@ +# 004. GeoIP: RIPE Stat + локальный кэш + абстракция провайдера + +- **Статус:** принято +- **Контекст/issue:** #15, #25 + +## Контекст + +Для split-tunnel нужны списки префиксов страны (в PoC — около 11 400 записей RIPE). +Варианты: RIPE Stat (бесплатно), ip2location и MaxMind (точнее, но лицензия и платный доступ). + +## Решение + +Основной источник — **RIPE Stat** (`country-resource-list`, IPv4 и IPv6): бесплатно и уже проверено в PoC. +Обязательные элементы: локальный кэш-снапшот в `/data/geoip/`, расписание обновления, ручное +обновление, явный статус источника (API или кэш с датой). Внутри — интерфейс `GeoProvider`, чтобы +добавить ip2location/MaxMind без переделки маршрутизации. + +## Альтернативы + +- **MaxMind GeoLite2** — точнее по ASN, но требует регистрации и обновления лицензии; оставляем как опцию. +- **Собственный список в репозитории** — устаревает и не покрывает смену маршрутной политики. + +## Последствия + +- Старт не зависит от внешней сети: при недоступности API работаем на кэше (#25, тест #41). +- Fallback не должен молча менять политику split-tunnel: неявные переходы в режим «всё в VPN» или + «всё напрямую» запрещены, поведение задаётся явно (#23). +- Загрузка 11k+ префиксов — измеримая операция; время старта и swap — часть AC #24 и #25. From d9cad87fc8390528b9b638729cbe0fe4ad7fbba2 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:29:29 +0300 Subject: [PATCH 5/6] =?UTF-8?q?docs(#16):=20ADR-005=20(DNS)=20=D0=B8=20ADR?= =?UTF-8?q?-006=20(=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/005-dns-dnsmasq.md | 27 +++++++++++++++++++++++++++ docs/adr/006-state-sqlite.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 docs/adr/005-dns-dnsmasq.md create mode 100644 docs/adr/006-state-sqlite.md diff --git a/docs/adr/005-dns-dnsmasq.md b/docs/adr/005-dns-dnsmasq.md new file mode 100644 index 0000000..7677486 --- /dev/null +++ b/docs/adr/005-dns-dnsmasq.md @@ -0,0 +1,27 @@ +# 005. DNS: встроенный dnsmasq и перехват порта 53 + +- **Статус:** принято +- **Контекст/issue:** #15, #26 + +## Контекст + +DNS-based split-tunnel требует знать, какие IP принадлежат доменам из списка. Если клиенты сами +резолвят домены (свой DNS, DoH, другой upstream), шлюз не узнаёт об адресах и правило не срабатывает. + +## Решение + +В образе поднимается **встроенный dnsmasq** (процесс агента): +- домены из списка → `ipset` (через `--ipset`), трафик на эти адреса идёт в туннель; +- перехват порта 53 от LAN-клиентов шлюзом — устраняет DNS-утечку и лечит клиентов с ручным DNS; +- upstream-форвардинг и кэш по TTL; опция «внешний DNS» остаётся для нестандартных схем. + +## Альтернативы + +- **Отдельный DNS-контейнер** — лишняя зависимость и точка отказа, ломает требование единого образа. +- **Только DNS клиентов** — не работает без ручной настройки каждого устройства (противоречит FR-2). + +## Последствия + +- Порт 53 занят контейнером: в README/runbook нужно предупреждение (конфликт с локальным резольвером хоста). +- Обязательно тестировать домены с CNAME и динамическими IP (#26, #41). +- Список доменов и его размеры ограничиваются явно (валидация в API, #30). diff --git a/docs/adr/006-state-sqlite.md b/docs/adr/006-state-sqlite.md new file mode 100644 index 0000000..a450b9d --- /dev/null +++ b/docs/adr/006-state-sqlite.md @@ -0,0 +1,29 @@ +# 006. Состояние: SQLite (WAL) + /config + /data + +- **Статус:** принято +- **Контекст/issue:** #15, #20 + +## Контекст + +Нужно хранить правила маршрутизации, исключения, режим, расписание GeoIP, метаданные конфигов, +устройства, audit log и историю метрик. Профиль нагрузки: единичный пользователь, десятки записей в секунду, +важна надёжность и отсутствие внешних зависимостей. + +## Решение + +- **SQLite в режиме WAL** в `/data/awg-dash.db` — всё структурированное состояние и audit log; +- `/config` — файлы `.conf`, права 0600, секреты в подкаталоге с ограниченным доступом; +- `/data` — кэш GeoIP и история метрик с retention. + +Оба каталога объявляются томами образа: без них конфигурация не переживёт пересоздание контейнера. + +## Альтернативы + +- **Postgres/Redis** — лишний сервис в образе для единичного пользователя. +- **Только файлы JSON** — нет транзакций, аудита и атомарного apply/rollback. + +## Последствия + +- Миграции схемы обязательны с первого дня (#20) и проверяются при старте. +- Backup/restore = копирование `/data` и `/config` — отражается в runbook (#45). +- История метрик растёт: нужен retention и периодическая очистка (#28). From 1db3824cb44be986f6ccd2280b341706ed890c72 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:29:36 +0300 Subject: [PATCH 6/6] =?UTF-8?q?docs(#16):=20ADR-007=20(web-=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80)=20=D0=B8=20ADR-008=20(=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/007-embedded-web-server.md | 26 ++++++++++++++++++++++++ docs/adr/008-api-ui-security.md | 31 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 docs/adr/007-embedded-web-server.md create mode 100644 docs/adr/008-api-ui-security.md diff --git a/docs/adr/007-embedded-web-server.md b/docs/adr/007-embedded-web-server.md new file mode 100644 index 0000000..84be090 --- /dev/null +++ b/docs/adr/007-embedded-web-server.md @@ -0,0 +1,26 @@ +# 007. Web-сервер: встроенный uvicorn, без nginx + +- **Статус:** принято +- **Контекст/issue:** #15, #31 + +## Контекст + +UI — SPA, API — REST и WebSocket, всё в одном контейнере. Нагрузка: LAN, единичный пользователь. +Установка должна быть в одну команду `docker run` без дополнительных контейнеров. + +## Решение + +Один uvicorn-процесс `awg-api` отдаёт и API, и собранную статику UI, и WebSocket. Сборка UI выполняется +на этапе билда образа и попадает в `ui/dist`. Закладывается совместимость с внешним reverse-proxy: +поддержка base-path и заголовков `X-Forwarded-*`, без обязательной настройки хоста. + +## Альтернативы + +- **nginx в образе** — плюс к размеру и к конфигу, преимуществ при такой нагрузке нет. +- **Отдельный контейнер для UI** — нарушает требование единого образа. + +## Последствия + +- Заголовки кэширования статики и `index.html` настраиваются в FastAPI (иначе UI кэшируется после обновления). +- WebSocket идёт через тот же порт: учитывается при настройке reverse-proxy в runbook (#45). +- Docker HEALTHCHECK опирается на `/api/v1/health` того же процесса (#31). diff --git a/docs/adr/008-api-ui-security.md b/docs/adr/008-api-ui-security.md new file mode 100644 index 0000000..098fc8d --- /dev/null +++ b/docs/adr/008-api-ui-security.md @@ -0,0 +1,31 @@ +# 008. Безопасность API/UI: токен, CSRF, rate limit, маскировка секретов + +- **Статус:** принято +- **Контекст/issue:** #15, #34 + +## Контекст + +Приложение управляет VPN-туннелем и принимает приватные ключи AmneziaWG. Оно доступно из LAN, +а в перспективе может быть выставлено через reverse-proxy. В ТЗ этот слой не описан, но без него +загрузка `.conf` через веб-интерфейс превращается в дыру. + +## Решение + +- API — токен доступа в заголовке (Bearer), токен только в виде хеша в БД; +- UI — сессия + CSRF-токен на мутирующие запросы; +- приватные ключи (`PrivateKey`, `PresharedKey`) — `writeOnly` в OpenAPI, маска в UI и логах, + файлы 0600 в отдельном каталоге; +- rate limit и lockout на `/auth`, ограничение допустимых источников, bind по умолчанию на LAN; +- audit log всех изменений конфигурации. + +## Альтернативы + +- **Без аутентификации, доступ только по LAN** — не защищает от заражённого устройства в сети и от + ошибочной публикации порта. +- **OIDC-провайдер** — не для локального однопользовательского приложения. + +## Последствия + +- Начальный токен генерируется при первом старте и выводится в лог один раз — это шаг в runbook (#45). +- Маскировка секретов проверяется автотестами (#41) и security-прогоном (#44). +- UI обязан корректно вести себя при 401 (переход на форму входа) — часть AC #35.