6 Commits

29 changed files with 596 additions and 226 deletions
+22
View File
@@ -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
+36
View File
@@ -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
+24
View File
@@ -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]
+44
View File
@@ -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
+50 -26
View File
@@ -1,40 +1,64 @@
# awg-dash # awg-dash
# awg-dash 🚀 VPN-шлюз **AmneziaWG** с интеллектуальной маршрутизацией: один Docker-образ = туннель + policy routing + split-tunnel (GeoIP / DNS) + REST API v1 + Web UI (тёмная тема).
**awg-dash** — это легковесная и удобная веб-панель (Dashboard) для визуального управления сервером **Amnezia WireGuard (AWG)**. Она позволяет создавать, удалять и просматривать конфигурации пользователей в один клик через современный веб-интерфейс, защищенный от блокировок. - Эпик и декомпозиция: 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`).
[![License: MIT](https://shields.io)](https://opensource.org) ## Архитектура
[![GitHub Stars](https://shields.io)](https://github.com)
--- Два процесса в одном образе:
## ✨ Особенности / Features | Процесс | Пользователь | Ответственность |
|---|---|---|
| `awg-agent` | root | awg/wg-интерфейс, iptables/ipset, dnsmasq, policy routing, метрики |
| `awg-api` | непривилегированный | FastAPI: REST API v1, WebSocket, статика UI |
- 🛠️ **Полная поддержка Amnezia WireGuard:** Тонкая настройка параметров обфускации (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`, `H2`, `H3`, `H4`) прямо из панели. Обмен — unix-socket `/run/awg/agent.sock`; состояние — SQLite в `/data`, конфиги `.conf` в `/config`.
- 👥 **Управление пользователями:** Создание, удаление, временная заморозка и управление лимитами пиров (клиентов). Принятые решения зафиксированы в `docs/adr/`.
- 📊 **Мониторинг в реальном времени:** Графики потребления трафика, статус подключения клиентов и общая нагрузка на сервер.
- 📱 **QR-коды и конфиги:** Мгновенная генерация QR-кодов и скачивание `.conf` файлов для импорта в официальное приложение Amnezia.
- 🔒 **Безопасность:** Авторизация по паролю/токену, поддержка сессий и возможность работы за обратным прокси (Nginx, Caddy).
- 🐳 **Docker-first:** Быстрый запуск одной командой через Docker Compose.
--- ## Быстрый старт (после релиза 1.0)
## 🛠️ Технологический стек / Tech Stack ```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
```
- **Backend:** Node.js (Express) / Python (FastAPI) / Go *(выберите нужное)* Web UI: `http://<host>:8080`. До релиза образ собирается локально: `make build`.
- **Frontend:** Vue.js / React / Простой HTML+JS *(выберите нужное)*
- **База данных:** SQLite / JSON-file (минималистично, без тяжелых БД)
- **Ядро:** Amnezia WireGuard (модифицированная утилита `wg`)
--- ## Разработка
## 🚀 Быстрый старт / Quick Start ```bash
make venv # локальное окружение (.venv)
make lint # ruff + mypy
make test # pytest + coverage (порог 70%)
make build # docker build образа (задача #18)
make dev-up # dev-стенд (задача #21)
```
### Способ 1: Через Docker Compose (Рекомендуемый) Сетевые тесты выполняются в netns-стенде (`tests/netns/`), без реального VPN-провайдера.
1. Клонируйте репозиторий: ## Структура репозитория
```bash
git clone https://github.com | Путь | Назначение |
cd awg-dash |---|---|
``` | `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/<issue>-slug` = 1 PR.
Гейты мержа: CI green, coverage нового кода ≥ 70 %, security scan low/none, ревью владельца роли.
Merge в `main` и деплой в prod — только TechLead после гейтов. Все операции с репозиторием — через Gitea MCP.
+16
View File
@@ -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).
+5
View File
@@ -0,0 +1,5 @@
"""Привилегированный агент awg-dash: туннель, маршрутизация, GeoIP, DNS, метрики."""
__version__ = "0.0.1"
__all__ = ["__version__"]
+13
View File
@@ -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/`.
+5
View File
@@ -0,0 +1,5 @@
"""FastAPI-приложение awg-dash: REST API v1, WebSocket, статика UI."""
__version__ = "0.0.1"
__all__ = ["__version__"]
+13
View File
@@ -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__
-79
View File
@@ -1,79 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# awg-dash — единый образ: AmneziaWG gateway + policy routing + REST API + Web UI.
# Задача: #18 (эпик #15). ADR: docs/adr/002-single-image-modular-monolith.md
#
# ВАЖНО: AMNEZIAWG_GO_REF / AMNEZIAWG_TOOLS_REF по умолчанию указывают на master
# и должны быть запинены на проверенный тег/коммит перед релизом (см. #45).
ARG GO_IMAGE=golang:1.25-alpine
# --- stage 1: сборка awg-утилит (Go) -----------------------------------------
FROM ${GO_IMAGE} AS go-build
ARG AMNEZIAWG_GO_REPO=https://github.com/amnezia-vpn/amneziawg-go.git
ARG AMNEZIAWG_GO_REF=master
ARG AMNEZIAWG_TOOLS_REPO=https://github.com/amnezia-vpn/amneziawg-tools.git
ARG AMNEZIAWG_TOOLS_REF=master
RUN apk add --no-cache git make gcc musl-dev linux-headers
WORKDIR /src
RUN git clone --depth 1 --branch "${AMNEZIAWG_GO_REF}" "${AMNEZIAWG_GO_REPO}" amneziawg-go \
&& cd amneziawg-go && make && cp amneziawg-go /usr/local/bin/amneziawg-go
RUN git clone --depth 1 --branch "${AMNEZIAWG_TOOLS_REF}" "${AMNEZIAWG_TOOLS_REPO}" amneziawg-tools \
&& cd amneziawg-tools/src && make \
&& cp wg /usr/local/bin/awg \
&& if [ -f wg-quick ]; then cp wg-quick /usr/local/bin/awg-quick; fi
# --- stage 2: runtime -------------------------------------------------------
FROM alpine:3.21 AS runtime
LABEL org.opencontainers.image.title="awg-dash" \
org.opencontainers.image.description="AmneziaWG gateway with intelligent routing: REST API + Web UI + split-tunnel" \
org.opencontainers.image.source="http://192.168.1.111:3000/embeddevio/awg-dash"
# Сетевые утилиты: iptables/ip6tables, ipset, iproute2 (policy routing), dnsmasq (ADR-005),
# curl (HEALTHCHECK), tini (PID 1), su-exec (запуск API под непривилегированным пользователем).
RUN apk add --no-cache \
bash curl iproute2 ipset iptables ip6tables dnsmasq \
python3 py3-pip tini su-exec \
&& addgroup -S awg \
&& adduser -S -G awg -h /app awg
ENV PATH=/opt/venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app/backend \
AWG_CONFIG_DIR=/config \
AWG_DATA_DIR=/data \
AWG_RUN_DIR=/run/awg \
AWG_API_HOST=0.0.0.0 \
AWG_API_PORT=8080
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev libffi-dev \
&& python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/venv/bin/pip install --no-cache-dir -r /app/requirements.txt \
&& apk del .build-deps
COPY backend/ /app/backend/
COPY --from=go-build /usr/local/bin/awg /usr/local/bin/awg
COPY --from=go-build /usr/local/bin/amneziawg-go /usr/local/bin/amneziawg-go
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /config /data /run/awg \
&& chown -R awg:awg /app /config /data /run/awg
VOLUME ["/config", "/data"]
EXPOSE 8080
# healthcheck ходит в /api/v1/health (задача #31). До реализации API контейнер
# будет помечаться как unhealthy — это ожидаемое состояние до #31.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${AWG_API_PORT}/api/v1/health" >/dev/null || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
+12
View File
@@ -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`.
-32
View File
@@ -1,32 +0,0 @@
# Пример клиентского конфига AmneziaWG для awg-dash.
#
# Скопируйте в том /config как awg0.conf (имя файла = имя интерфейса) или
# загрузите через Web UI (задача #38). Значения ниже — заглушки, замените своими.
# Файл в репозитории не является рабочим конфигом и не содержит реальных ключей.
[Interface]
# Адрес внутри туннеля (из панели провайдера AmneziaWG)
Address = 10.8.1.2/32
# DNS: адрес должен указывать на этот шлюз, иначе не сработает DNS-split (ADR-005)
DNS = 10.8.1.1
PrivateKey = <PRIVATE_KEY>
# Обфускация AmneziaWG (Amnezia VPN: экспорт -> AmneziaWG)
Jc = 4
Jmin = 40
Jmax = 70
S1 = 0
S2 = 0
H1 = 1
H2 = 2
H3 = 3
H4 = 4
[Peer]
PublicKey = <SERVER_PUBLIC_KEY>
PresharedKey = <PRESHARED_KEY>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <SERVER_HOST>:<SERVER_PORT>
PersistentKeepalive = 25
-77
View File
@@ -1,77 +0,0 @@
#!/bin/sh
# Супервизор контейнера awg-dash (задача #18, ADR-002).
# Последовательность: preflight -> подготовка -> запуск awg-api под awg.
# Привилегированный агент awg-agent стартует отсюда же после реализации (#22).
#
# Роль процесса: все сетевые операции выполняет агент под root; здесь только
# проверяем, что нужные возможности и устройства проброшены, и падаем явной
# ошибкой, если контейнер запущен без них (а не тихо деградируем).
set -eu
CONFIG_DIR="${AWG_CONFIG_DIR:-/config}"
DATA_DIR="${AWG_DATA_DIR:-/data}"
RUN_DIR="${AWG_RUN_DIR:-/run/awg}"
API_HOST="${AWG_API_HOST:-0.0.0.0}"
API_PORT="${AWG_API_PORT:-8080}"
ROUTING_TABLE_ID=100
ROUTING_TABLE_NAME=awg-vpn
log() { printf '[entrypoint] %s\n' "$*"; }
fail() { printf '[entrypoint] FATAL: %s\n' "$*" >&2; exit 1; }
log 'preflight'
[ -e /dev/net/tun ] || fail '/dev/net/tun не проброшен: добавьте --device=/dev/net/tun:/dev/net/tun'
iptables -t mangle -L -n >/dev/null 2>&1 || fail 'iptables недоступен: нужен --cap-add=NET_ADMIN (и/или --privileged)'
command -v awg >/dev/null 2>&1 || fail 'бинарь awg не найден в образе'
command -v amneziawg-go >/dev/null 2>&1 || log 'WARN: amneziawg-go не найден — остаётся только kernel-модуль'
command -v ipset >/dev/null 2>&1 || fail 'ipset не найден в образе'
log 'подготовка каталогов состояния'
mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$RUN_DIR"
chown -R awg:awg "$CONFIG_DIR" "$DATA_DIR" "$RUN_DIR" 2>/dev/null || true
chmod 700 "$CONFIG_DIR" "$DATA_DIR" 2>/dev/null || true
mkdir -p /etc/iproute2
touch /etc/iproute2/rt_tables 2>/dev/null || true
if ! grep -q "^${ROUTING_TABLE_ID} ${ROUTING_TABLE_NAME}" /etc/iproute2/rt_tables 2>/dev/null; then
printf '%s %s\n' "$ROUTING_TABLE_ID" "$ROUTING_TABLE_NAME" >> /etc/iproute2/rt_tables \
|| log 'WARN: не удалось записать таблицу маршрутизации в /etc/iproute2/rt_tables'
fi
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 \
|| log 'WARN: net.ipv4.ip_forward не включён — задайте --sysctl net.ipv4.ip_forward=1 или --privileged (см. runbook #45)'
log "awg: $(awg --version 2>/dev/null | head -n 1 || echo 'version unknown')"
api_pid=''
if [ -f /app/backend/awg_api/app.py ]; then
log "запуск awg-api на ${API_HOST}:${API_PORT}"
su-exec awg /opt/venv/bin/uvicorn awg_api.app:app --host "$API_HOST" --port "$API_PORT" &
api_pid=$!
else
log 'WARN: awg_api/app.py ещё не реализован (задача #31) — API не запущен, контейнер работает только как агент'
fi
stop() {
if [ -n "$api_pid" ] && kill -0 "$api_pid" 2>/dev/null; then
log 'остановка awg-api'
kill -TERM "$api_pid" 2>/dev/null || true
fi
}
trap stop INT TERM
if [ -n "$api_pid" ]; then
# ждём завершения API и прокидываем его код возврата в Docker
set +e
wait "$api_pid"
rc=$?
set -e
log "awg-api завершился с кодом $rc"
exit "$rc"
fi
log 'активных процессов нет (agent #22 и api #31 ещё не реализованы) — ожидание сигнала'
while :; do
sleep 3600 &
wait $!
done
+21
View File
@@ -0,0 +1,21 @@
# NNN. Название решения
- **Статус:** предложено | принято | устарело
- **Дата:** YYYY-MM-DD
- **Контекст/issue:** #N
## Контекст
Что вынуждает принять решение и какие есть ограничения (хостовое окружение, привилегии, один шлюз, целевой UX).
## Решение
Что именно решили, одним абзацем.
## Альтернативы
Что рассмотрели и почему отклонили.
## Последствия
Положительные и отрицательные следствия; что решение накладывает на задачи эпика.
+27
View File
@@ -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 нет.
@@ -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 не разрывает туннель.
+28
View File
@@ -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.
+28
View File
@@ -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.
+27
View File
@@ -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).
+29
View File
@@ -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).
+26
View File
@@ -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).
+31
View File
@@ -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.
+16
View File
@@ -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, для которого решение принято.
+32
View File
@@ -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"
+8
View File
@@ -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
+12
View File
@@ -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-зависимостей для сети не требуется.
+13
View File
@@ -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`).
+14
View File
@@ -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.