Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cfed02683 |
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
name: contract-openapi
|
|
||||||
# Контракт OpenAPI 3.1 (issue #19): линтеры, контрактный тест «схема ↔ реализация»
|
|
||||||
# и проверка, что сгенерированный TS-клиент актуален относительно docs/openapi.yaml.
|
|
||||||
#
|
|
||||||
# Обход дефекта Gitea git-http (issue #59): `git-upload-pack` отдаёт пустой pack
|
|
||||||
# (`bad pack header`), из-за чего actions/checkout падает и оба job'а красные на любом
|
|
||||||
# коммите. Основной путь оставлен, но при падении checkout источники забираются
|
|
||||||
# архивом `/archive/<sha>.tar.gz` — тот же приём, что в .gitea/workflows/ci.yml.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
contract:
|
|
||||||
name: openapi-contract
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
id: checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: "checkout fallback (archive tarball)"
|
|
||||||
if: steps.checkout.outcome == 'failure'
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -fsSL -o /tmp/src.tar.gz "http://192.168.1.111:3000/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
|
||||||
tar -xzf /tmp/src.tar.gz --strip-components=1 -C .
|
|
||||||
echo "sources: archive fallback, $(ls -1 | wc -l) entries"
|
|
||||||
|
|
||||||
- name: python 3.12
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt -r requirements-contract.txt
|
|
||||||
|
|
||||||
- name: lint (ruff + mypy)
|
|
||||||
run: |
|
|
||||||
ruff check backend
|
|
||||||
ruff format --check backend
|
|
||||||
mypy backend
|
|
||||||
|
|
||||||
- name: contract and unit tests (coverage >= 70%)
|
|
||||||
run: pytest
|
|
||||||
|
|
||||||
ts-client:
|
|
||||||
name: ts-client-is-current
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
id: checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: "checkout fallback (archive tarball)"
|
|
||||||
if: steps.checkout.outcome == 'failure'
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -fsSL -o /tmp/src.tar.gz "http://192.168.1.111:3000/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
|
||||||
tar -xzf /tmp/src.tar.gz --strip-components=1 -C .
|
|
||||||
echo "sources: archive fallback, $(ls -1 | wc -l) entries"
|
|
||||||
|
|
||||||
- name: node 20
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
|
|
||||||
- name: regenerate client and compare
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
target="ui/src/api/generated/schema.d.ts"
|
|
||||||
if [ -d .git ]; then
|
|
||||||
bash ui/scripts/generate-api.sh
|
|
||||||
git diff --exit-code -- "$target"
|
|
||||||
else
|
|
||||||
sha256sum "$target" > /tmp/before.sha
|
|
||||||
bash ui/scripts/generate-api.sh
|
|
||||||
sha256sum -c /tmp/before.sha
|
|
||||||
fi
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
PY ?= python3
|
|
||||||
VENV ?= .venv
|
|
||||||
BIN := $(VENV)/bin
|
|
||||||
IMAGE ?= awg-dash:dev
|
|
||||||
COMPOSE_DEV := deploy/docker-compose.dev.yml
|
|
||||||
COMPOSE_TEST := deploy/docker-compose.test.yml
|
|
||||||
COMPOSE_STUB := deploy/docker-compose.stub.yml
|
|
||||||
VERIFY := deploy/verify-dev-stand.sh
|
|
||||||
COMPOSE ?= docker compose
|
|
||||||
# Состояние dev-стенда: bind mount для /config и /data (AC-2 задачи #21).
|
|
||||||
HOST_DIR ?= /opt/awg-dash-dev
|
|
||||||
# Хост-порт стенда. На dev-хосте 8080 занят другим сервисом — по умолчанию 8180;
|
|
||||||
# внутри контейнера приложение всегда на 8080.
|
|
||||||
DASH_PORT ?= 8180
|
|
||||||
NETNS_ARGS ?=
|
|
||||||
VERIFY_ARGS ?=
|
|
||||||
MANIFEST_ARGS ?=
|
|
||||||
# Режим входа в netns: auto|ip|nsenter (issue #56). Документированное имя —
|
|
||||||
# AWG_NS_EXEC_MODE (его читают lib.sh/run.sh и tests/netns/ci.sh); NETNS_EXEC_MODE —
|
|
||||||
# make-алиас. Раньше здесь стояло `?= auto`, из-за чего AWG_NS_EXEC_MODE=ip на
|
|
||||||
# `make test-netns-ci` молча игнорировался (замечание ревью #56).
|
|
||||||
NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto)
|
|
||||||
|
|
||||||
.PHONY: help venv lint fmt test build dev-up dev-up-app dev-up-stub dev-down dev-logs dev-verify \
|
|
||||||
dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell \
|
|
||||||
test-deploy
|
|
||||||
|
|
||||||
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/staging стенд (задача #21) ------------------------------------------
|
|
||||||
# Пока FastAPI-каркас (#31) не реализован, API в образе не поднимается и AC-1
|
|
||||||
# (`curl :8080/api/v1/health` -> 200) недостижим. Чтобы гейт #21 проверялся на
|
|
||||||
# живом стенде, `dev-up` автоматически добавляет оверрайд с health-заглушкой
|
|
||||||
# (deploy/docker-compose.stub.yml), ПОКА файла приложения нет. После мержа #31
|
|
||||||
# оверрайд отключается сам. Явные режимы: dev-up-app / dev-up-stub.
|
|
||||||
API_APP ?= backend/awg_api/app.py
|
|
||||||
ifneq ($(wildcard $(API_APP)),)
|
|
||||||
COMPOSE_DEV_EFFECTIVE := -f $(COMPOSE_DEV)
|
|
||||||
STUB_MODE := 0
|
|
||||||
else
|
|
||||||
COMPOSE_DEV_EFFECTIVE := -f $(COMPOSE_DEV) -f $(COMPOSE_STUB)
|
|
||||||
STUB_MODE := 1
|
|
||||||
endif
|
|
||||||
# deploy/Dockerfile приходит с задачей #18; до этого образ собирается отдельно,
|
|
||||||
# поэтому --build не форсируем, если Dockerfile отсутствует в дереве.
|
|
||||||
# После мержа #18 файл есть → --build обязателен, но не всегда возможен: на
|
|
||||||
# стенд-хосте нет DNS/сети для pull базового образа. Явные кнобы:
|
|
||||||
# make dev-up DEV_SKIP_BUILD=1 → up -d --no-build (использовать готовый образ)
|
|
||||||
# make dev-up DEV_UP_BUILD= → вообще не передавать флаги сборки
|
|
||||||
DEV_UP_BUILD ?= $(if $(wildcard deploy/Dockerfile),--build,)
|
|
||||||
ifeq ($(DEV_SKIP_BUILD),1)
|
|
||||||
DEV_UP_BUILD := --no-build
|
|
||||||
endif
|
|
||||||
DEV_ENV := AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR)
|
|
||||||
DEV_VERIFY_ARGS := $(if $(filter 1,$(STUB_MODE)),--stub,)
|
|
||||||
|
|
||||||
dev-up:
|
|
||||||
@test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; }
|
|
||||||
@command -v docker >/dev/null 2>&1 || { echo 'нет docker: dev-стенд поднимается на Docker-хосте (см. deploy/dev-stand.md)'; exit 1; }
|
|
||||||
@[ "$(STUB_MODE)" = "0" ] || echo 'dev-up: API (#31) ещё не в дереве — поднимаю стенд с health-заглушкой (deploy/docker-compose.stub.yml)'
|
|
||||||
$(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) up -d $(DEV_UP_BUILD)
|
|
||||||
|
|
||||||
# Только реальный образ: проверка отказа API без заглушки / режим после #31.
|
|
||||||
dev-up-app:
|
|
||||||
@test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; }
|
|
||||||
$(DEV_ENV) $(COMPOSE) -f $(COMPOSE_DEV) up -d $(DEV_UP_BUILD)
|
|
||||||
|
|
||||||
# Только health-заглушка (проверка плумбинга стенда до #31).
|
|
||||||
dev-up-stub:
|
|
||||||
@test -f $(COMPOSE_STUB) || { echo '$(COMPOSE_STUB) ещё нет — задача #21'; exit 1; }
|
|
||||||
$(DEV_ENV) $(COMPOSE) -f $(COMPOSE_DEV) -f $(COMPOSE_STUB) up -d $(DEV_UP_BUILD)
|
|
||||||
|
|
||||||
dev-down:
|
|
||||||
$(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down
|
|
||||||
|
|
||||||
dev-logs:
|
|
||||||
$(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) logs -f --tail=100
|
|
||||||
|
|
||||||
# Проверка AC #21 на Docker-хосте: health 200 + состояние переживает restart.
|
|
||||||
dev-verify:
|
|
||||||
$(DEV_ENV) bash $(VERIFY) $(DEV_VERIFY_ARGS) $(VERIFY_ARGS)
|
|
||||||
|
|
||||||
dev-verify-stub:
|
|
||||||
$(DEV_ENV) bash $(VERIFY) --stub $(VERIFY_ARGS)
|
|
||||||
|
|
||||||
# Останавливает стенд; каталоги состояния сохраняются (удалять вручную).
|
|
||||||
dev-reset:
|
|
||||||
$(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down --remove-orphans
|
|
||||||
@echo "каталоги состояния сохранены: $(HOST_DIR)/config, $(HOST_DIR)/data (удалять вручную)"
|
|
||||||
|
|
||||||
# --- netns-стенд (задача #21) ------------------------------------------------
|
|
||||||
test-netns:
|
|
||||||
@test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; }
|
|
||||||
bash tests/netns/run.sh $(NETNS_ARGS)
|
|
||||||
|
|
||||||
# То же, но топология и логи остаются для отладки (снести: tests/netns/topology.sh down).
|
|
||||||
test-netns-keep:
|
|
||||||
bash tests/netns/run.sh --keep-topology --keep-logs $(NETNS_ARGS)
|
|
||||||
|
|
||||||
# Тот же стенд в привилегированном контейнере — путь для CI (#17).
|
|
||||||
# Внутри контейнера `ip netns exec` падает с "mount of /sys failed: Operation
|
|
||||||
# not permitted" (iproute2 6.x), поэтому харнесс сам переключается на
|
|
||||||
# `nsenter --net=/var/run/netns/<ns>` (issue #56). Режим задаётся
|
|
||||||
# AWG_NS_EXEC_MODE=ip|nsenter|auto (make-алиас — NETNS_EXEC_MODE).
|
|
||||||
#
|
|
||||||
# ВАЖНО: make возвращает 2 на любой упавший рецепт, поэтому контракт кодов
|
|
||||||
# 0/1/3/4 через этот таргет НЕ виден (см. deploy/VERIFICATION-21.md §6.1).
|
|
||||||
# В пайплайне #17 используйте точку входа без make:
|
|
||||||
# bash tests/netns/ci.sh # те же 0/1/3/4, что отдаёт контейнер
|
|
||||||
test-netns-ci:
|
|
||||||
@test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; }
|
|
||||||
AWG_DASH_IMAGE=$(IMAGE) AWG_NS_EXEC_MODE=$(NETNS_EXEC_MODE) bash tests/netns/ci.sh $(NETNS_ARGS)
|
|
||||||
|
|
||||||
# --- статические проверки манифестов и скриптов ------------------------------
|
|
||||||
test-manifests:
|
|
||||||
$(PY) tests/deploy/validate_compose.py $(MANIFEST_ARGS)
|
|
||||||
|
|
||||||
# Тесты стенда с покрытием: гейт DoD «тесты ≥70 %» для #21/#56.
|
|
||||||
# Отдельный tests/deploy/pytest.ini обязателен: без него `pytest tests/deploy`
|
|
||||||
# наследует addopts корневого pyproject.toml (--cov=awg_agent --cov=awg_api),
|
|
||||||
# покрытие выходит 0.00 % и RC=1 — гейт приходилось гонять с --no-cov.
|
|
||||||
test-deploy:
|
|
||||||
@test -d tests/deploy || { echo 'tests/deploy ещё нет — задача #21'; exit 1; }
|
|
||||||
$(PY) -m pytest tests/deploy -c tests/deploy/pytest.ini
|
|
||||||
|
|
||||||
lint-shell:
|
|
||||||
@command -v shellcheck >/dev/null 2>&1 || { echo 'shellcheck не установлен (apt-get install shellcheck)'; exit 1; }
|
|
||||||
shellcheck -x -S style tests/netns/*.sh deploy/*.sh
|
|
||||||
@@ -1,64 +1,40 @@
|
|||||||
# awg-dash
|
# awg-dash
|
||||||
|
|
||||||
VPN-шлюз **AmneziaWG** с интеллектуальной маршрутизацией: один Docker-образ = туннель + policy routing + split-tunnel (GeoIP / DNS) + REST API v1 + Web UI (тёмная тема).
|
# awg-dash 🚀
|
||||||
|
|
||||||
- Эпик и декомпозиция: Gitea issue **#15** (`embeddevio/awg-dash`).
|
**awg-dash** — это легковесная и удобная веб-панель (Dashboard) для визуального управления сервером **Amnezia WireGuard (AWG)**. Она позволяет создавать, удалять и просматривать конфигурации пользователей в один клик через современный веб-интерфейс, защищенный от блокировок.
|
||||||
- Исполнение: Hermes Kanban, борда **`awg-dash`** (`hermes kanban --board awg-dash list`).
|
|
||||||
- PoC-референс: `embeddevio/amneziawg-docker-gateway` (bash + iptables, `docs/technical-specification.md`).
|
|
||||||
|
|
||||||
## Архитектура
|
[](https://opensource.org)
|
||||||
|
[](https://github.com)
|
||||||
|
|
||||||
Два процесса в одном образе:
|
---
|
||||||
|
|
||||||
| Процесс | Пользователь | Ответственность |
|
## ✨ Особенности / Features
|
||||||
|---|---|---|
|
|
||||||
| `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`.
|
- 🛠️ **Полная поддержка Amnezia WireGuard:** Тонкая настройка параметров обфускации (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`, `H2`, `H3`, `H4`) прямо из панели.
|
||||||
Принятые решения зафиксированы в `docs/adr/`.
|
- 👥 **Управление пользователями:** Создание, удаление, временная заморозка и управление лимитами пиров (клиентов).
|
||||||
|
- 📊 **Мониторинг в реальном времени:** Графики потребления трафика, статус подключения клиентов и общая нагрузка на сервер.
|
||||||
|
- 📱 **QR-коды и конфиги:** Мгновенная генерация QR-кодов и скачивание `.conf` файлов для импорта в официальное приложение Amnezia.
|
||||||
|
- 🔒 **Безопасность:** Авторизация по паролю/токену, поддержка сессий и возможность работы за обратным прокси (Nginx, Caddy).
|
||||||
|
- 🐳 **Docker-first:** Быстрый запуск одной командой через Docker Compose.
|
||||||
|
|
||||||
## Быстрый старт (после релиза 1.0)
|
---
|
||||||
|
|
||||||
```bash
|
## 🛠️ Технологический стек / Tech Stack
|
||||||
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://<host>:8080`. До релиза образ собирается локально: `make build`.
|
- **Backend:** Node.js (Express) / Python (FastAPI) / Go *(выберите нужное)*
|
||||||
|
- **Frontend:** Vue.js / React / Простой HTML+JS *(выберите нужное)*
|
||||||
|
- **База данных:** SQLite / JSON-file (минималистично, без тяжелых БД)
|
||||||
|
- **Ядро:** Amnezia WireGuard (модифицированная утилита `wg`)
|
||||||
|
|
||||||
## Разработка
|
---
|
||||||
|
|
||||||
```bash
|
## 🚀 Быстрый старт / Quick Start
|
||||||
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-провайдера.
|
### Способ 1: Через Docker Compose (Рекомендуемый)
|
||||||
|
|
||||||
## Структура репозитория
|
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.
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SMOKE AUTO 1792234679
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Привилегированный агент awg-dash: туннель, маршрутизация, GeoIP, DNS, метрики."""
|
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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/`.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""FastAPI-приложение awg-dash: REST API v1, WebSocket, статика UI."""
|
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Сборка 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
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""Загрузка OpenAPI-контракта и валидация ответов реализации против схем.
|
|
||||||
|
|
||||||
Контракт (``docs/openapi.yaml``) — источник истины: тесты
|
|
||||||
``backend/tests/test_contract_responses.py`` прогоняют каждый эндпоинт реализации
|
|
||||||
и сверяют ответ против схемы спецификации. Любое расхождение — ``ContractViolationError``,
|
|
||||||
то есть красный CI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from jsonschema import Draft202012Validator, FormatChecker
|
|
||||||
|
|
||||||
SPEC_RELATIVE_PATH = Path("docs") / "openapi.yaml"
|
|
||||||
SPEC_ENV_VAR = "AWG_OPENAPI_SPEC"
|
|
||||||
JSON_MEDIA_TYPE = "application/json"
|
|
||||||
HTTP_METHODS: tuple[str, ...] = ("get", "put", "post", "delete", "patch", "head", "options")
|
|
||||||
DOC_ROUTE_PATHS: frozenset[str] = frozenset(
|
|
||||||
{"/openapi.json", "/docs", "/redoc", "/docs/oauth2-redirect"}
|
|
||||||
)
|
|
||||||
SECRET_FIELD_NAMES: frozenset[str] = frozenset({"private_key", "preshared_key", "password"})
|
|
||||||
|
|
||||||
|
|
||||||
class ContractViolationError(AssertionError):
|
|
||||||
"""Ответ реализации не соответствует схеме контракта."""
|
|
||||||
|
|
||||||
|
|
||||||
def spec_path() -> Path:
|
|
||||||
"""Путь до спецификации (``AWG_OPENAPI_SPEC`` переопределяет поиск от корня репозитория)."""
|
|
||||||
override = os.environ.get(SPEC_ENV_VAR)
|
|
||||||
if override:
|
|
||||||
return Path(override)
|
|
||||||
for parent in Path(__file__).resolve().parents:
|
|
||||||
candidate = parent / SPEC_RELATIVE_PATH
|
|
||||||
if candidate.is_file():
|
|
||||||
return candidate
|
|
||||||
raise FileNotFoundError(f"спецификация {SPEC_RELATIVE_PATH} не найдена")
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def load_spec() -> dict[str, Any]:
|
|
||||||
"""Разобранная спецификация OpenAPI (кэшируется на процесс)."""
|
|
||||||
raw = yaml.safe_load(spec_path().read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ContractViolationError("спецификация должна быть YAML-объектом на верхнем уровне")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def iter_operations(spec: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
|
|
||||||
"""Перечень описанных в контракте операций как (путь, метод, операция)."""
|
|
||||||
operations: list[tuple[str, str, dict[str, Any]]] = []
|
|
||||||
for path, item in spec.get("paths", {}).items():
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
for method in HTTP_METHODS:
|
|
||||||
operation = item.get(method)
|
|
||||||
if isinstance(operation, dict):
|
|
||||||
operations.append((path, method, operation))
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ref(spec: dict[str, Any], ref: str) -> Any:
|
|
||||||
node: Any = spec
|
|
||||||
for part in ref.lstrip("#/").split("/"):
|
|
||||||
node = node[part]
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_write_only(node: Any) -> None:
|
|
||||||
"""Удаляет ``writeOnly``-свойства (секреты) из схемы ответа.
|
|
||||||
|
|
||||||
``writeOnly`` в JSON Schema 2020-12 означает «только в запросе», поэтому в ответе
|
|
||||||
такие поля запрещены: они вырезаются, включая упоминание в ``required``; при этом
|
|
||||||
``additionalProperties: false`` делает их присутствие ошибкой валидации.
|
|
||||||
"""
|
|
||||||
if isinstance(node, dict):
|
|
||||||
properties = node.get("properties")
|
|
||||||
if isinstance(properties, dict):
|
|
||||||
dropped = [
|
|
||||||
name
|
|
||||||
for name, sub in properties.items()
|
|
||||||
if isinstance(sub, dict) and sub.get("writeOnly") is True
|
|
||||||
]
|
|
||||||
for name in dropped:
|
|
||||||
del properties[name]
|
|
||||||
required = node.get("required")
|
|
||||||
if dropped and isinstance(required, list):
|
|
||||||
node["required"] = [name for name in required if name not in dropped]
|
|
||||||
for value in node.values():
|
|
||||||
_strip_write_only(value)
|
|
||||||
elif isinstance(node, list):
|
|
||||||
for item in node:
|
|
||||||
_strip_write_only(item)
|
|
||||||
|
|
||||||
|
|
||||||
def _document_for(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
document = copy.deepcopy(schema)
|
|
||||||
document["components"] = copy.deepcopy(spec.get("components", {}))
|
|
||||||
_strip_write_only(document)
|
|
||||||
return document
|
|
||||||
|
|
||||||
|
|
||||||
def validator_for(schema: dict[str, Any], spec: dict[str, Any]) -> Draft202012Validator:
|
|
||||||
"""Валидатор фрагмента спецификации: внутренние ссылки ``#/components`` разрешаются."""
|
|
||||||
return Draft202012Validator(_document_for(schema, spec), format_checker=FormatChecker())
|
|
||||||
|
|
||||||
|
|
||||||
def response_schema(
|
|
||||||
spec: dict[str, Any], path: str, method: str, status: int | str
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Схема тела ответа для (path, method, status) или ``None``, если ответ не описан."""
|
|
||||||
operation = spec.get("paths", {}).get(path, {}).get(method)
|
|
||||||
if not isinstance(operation, dict):
|
|
||||||
return None
|
|
||||||
response = operation.get("responses", {}).get(str(status))
|
|
||||||
if isinstance(response, dict) and "$ref" in response:
|
|
||||||
response = _resolve_ref(spec, str(response["$ref"]))
|
|
||||||
if not isinstance(response, dict):
|
|
||||||
return None
|
|
||||||
media = response.get("content", {}).get(JSON_MEDIA_TYPE)
|
|
||||||
if not isinstance(media, dict):
|
|
||||||
# Non-JSON ответы (например, /metrics → text/plain): берём единственный медиатип.
|
|
||||||
content = response.get("content", {})
|
|
||||||
media = next((item for item in content.values() if isinstance(item, dict)), None)
|
|
||||||
if not isinstance(media, dict):
|
|
||||||
return None
|
|
||||||
schema = media.get("schema")
|
|
||||||
return schema if isinstance(schema, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_response(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
path: str,
|
|
||||||
method: str,
|
|
||||||
status: int,
|
|
||||||
payload: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Проверяет тело ответа против схемы контракта; поднимает ``ContractViolationError``."""
|
|
||||||
schema = response_schema(spec, path, method, status)
|
|
||||||
if schema is None:
|
|
||||||
raise ContractViolationError(
|
|
||||||
f"{method.upper()} {path} -> {status}: ответ не описан в спецификации"
|
|
||||||
)
|
|
||||||
errors = sorted(
|
|
||||||
validator_for(schema, spec).iter_errors(payload), key=lambda err: list(err.absolute_path)
|
|
||||||
)
|
|
||||||
if errors:
|
|
||||||
lines = [f"{method.upper()} {path} -> {status}: несоответствий схеме: {len(errors)}"]
|
|
||||||
for err in errors[:10]:
|
|
||||||
location = "/".join(str(part) for part in err.absolute_path) or "<root>"
|
|
||||||
lines.append(f" - {location}: {err.message}")
|
|
||||||
raise ContractViolationError("\n".join(lines))
|
|
||||||
|
|
||||||
|
|
||||||
def error_codes(spec: dict[str, Any]) -> set[str]:
|
|
||||||
"""Множество допустимых кодов ошибок из схемы ``ErrorCode``."""
|
|
||||||
enum = spec.get("components", {}).get("schemas", {}).get("ErrorCode", {}).get("enum", [])
|
|
||||||
return {str(item) for item in enum}
|
|
||||||
|
|
||||||
|
|
||||||
def write_only_fields(node: Any) -> set[str]:
|
|
||||||
"""Имена всех свойств, помеченных ``writeOnly`` (в любом месте документа)."""
|
|
||||||
found: set[str] = set()
|
|
||||||
if isinstance(node, dict):
|
|
||||||
for name, sub in node.get("properties", {}).items():
|
|
||||||
if isinstance(sub, dict) and sub.get("writeOnly") is True:
|
|
||||||
found.add(str(name))
|
|
||||||
found |= write_only_fields(sub)
|
|
||||||
for key, value in node.items():
|
|
||||||
if key != "properties":
|
|
||||||
found |= write_only_fields(value)
|
|
||||||
elif isinstance(node, list):
|
|
||||||
for item in node:
|
|
||||||
found |= write_only_fields(item)
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def iter_route_operations(app: Any) -> set[tuple[str, str]]:
|
|
||||||
"""Множество ``(путь, МЕТОД)`` для всех HTTP/WebSocket-маршрутов приложения.
|
|
||||||
|
|
||||||
Обходит вложенные роутеры: FastAPI 0.159 не разворачивает ``include_router``
|
|
||||||
в ``app.routes``: хранит обёртки, содержащие ``original_router``.
|
|
||||||
"""
|
|
||||||
found: set[tuple[str, str]] = set()
|
|
||||||
|
|
||||||
def walk(router: Any, seen: set[int]) -> None:
|
|
||||||
if id(router) in seen:
|
|
||||||
return
|
|
||||||
seen.add(id(router))
|
|
||||||
for route in getattr(router, "routes", []):
|
|
||||||
path = getattr(route, "path", None)
|
|
||||||
if isinstance(path, str):
|
|
||||||
if path in DOC_ROUTE_PATHS:
|
|
||||||
continue
|
|
||||||
methods = getattr(route, "methods", None)
|
|
||||||
if methods is None:
|
|
||||||
found.add((path, "GET")) # WebSocketRoute
|
|
||||||
continue
|
|
||||||
for method in methods:
|
|
||||||
if method.upper() not in {"HEAD", "OPTIONS"}:
|
|
||||||
found.add((path, method.upper()))
|
|
||||||
continue
|
|
||||||
inner = (
|
|
||||||
getattr(route, "original_router", None)
|
|
||||||
or getattr(route, "router", None)
|
|
||||||
or getattr(route, "app", None)
|
|
||||||
)
|
|
||||||
if inner is not None:
|
|
||||||
walk(inner, seen)
|
|
||||||
|
|
||||||
walk(app, set())
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def assert_routes_match(spec: dict[str, Any], app: Any) -> None:
|
|
||||||
"""Двусторонняя сверка маршрутов приложения и контракта.
|
|
||||||
|
|
||||||
* каждая операция контракта обязана существовать в приложении;
|
|
||||||
* каждый HTTP/WebSocket-маршрут приложения обязан быть описан в контракте.
|
|
||||||
"""
|
|
||||||
declared: set[tuple[str, str]] = {
|
|
||||||
(path, method.upper()) for path, method, _ in iter_operations(spec)
|
|
||||||
}
|
|
||||||
implemented = iter_route_operations(app)
|
|
||||||
|
|
||||||
missing = sorted(declared - implemented)
|
|
||||||
undocumented = sorted(implemented - declared)
|
|
||||||
problems: list[str] = []
|
|
||||||
if missing:
|
|
||||||
problems.append("нет в реализации: " + ", ".join(f"{m} {p}" for p, m in missing))
|
|
||||||
if undocumented:
|
|
||||||
problems.append("нет в контракте: " + ", ".join(f"{m} {p}" for p, m in undocumented))
|
|
||||||
if problems:
|
|
||||||
raise ContractViolationError(
|
|
||||||
"маршруты приложения и docs/openapi.yaml расходятся:\n " + "\n ".join(problems)
|
|
||||||
)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""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)]
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Единый формат ошибок 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),
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Роутеры API v1."""
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Аутентификация UI и API: /api/v1/auth/*."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Annotated, Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
|
||||||
|
|
||||||
from awg_api.deps import SESSION_COOKIE_NAME, StateDep, require_token
|
|
||||||
from awg_api.schemas import AuthLoginRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
||||||
TokenDep = Annotated[None, Depends(require_token)]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login(payload: AuthLoginRequest, state: StateDep, response: Response) -> dict[str, Any]:
|
|
||||||
session = state.login(payload.username, payload.password)
|
|
||||||
csrf_token = session["csrf_token"]
|
|
||||||
if isinstance(csrf_token, str):
|
|
||||||
response.set_cookie(
|
|
||||||
SESSION_COOKIE_NAME, csrf_token, httponly=True, samesite="strict", path="/"
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(state: StateDep, _: TokenDep = None) -> dict[str, Any]:
|
|
||||||
return state.logout()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session")
|
|
||||||
async def get_session(state: StateDep, _: TokenDep = None) -> dict[str, Any]:
|
|
||||||
return state.get_session()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Конфигурация AmneziaWG: чтение, атомарное применение, сухая валидация."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from awg_api.deps import StateDep, require_token
|
|
||||||
from awg_api.schemas import ConfigUpdateRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/config", tags=["config"], dependencies=[Depends(require_token)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def get_config(state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.get_config()
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
|
||||||
async def put_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.apply_config(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate")
|
|
||||||
async def validate_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.validate_config(payload)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Устройства и журнал: /api/v1/devices, /api/v1/logs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
|
|
||||||
from awg_api.deps import StateDep, require_token
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/api/v1", tags=["devices", "logs"], dependencies=[Depends(require_token)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices")
|
|
||||||
async def list_devices(state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.list_devices()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs")
|
|
||||||
async def list_logs(
|
|
||||||
state: StateDep,
|
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
|
||||||
cursor: str | None = None,
|
|
||||||
level: str | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return state.list_logs(limit=limit, cursor=cursor, level=level, query=q)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Служебные эндпоинты: /api/v1/health, /api/v1/ready, /metrics (без аутентификации)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
||||||
|
|
||||||
from awg_api.deps import StateDep
|
|
||||||
from awg_api.errors import error_payload
|
|
||||||
|
|
||||||
router = APIRouter(tags=["ops"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/health")
|
|
||||||
async def get_health(state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.get_health()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/ready")
|
|
||||||
async def get_ready(state: StateDep) -> JSONResponse:
|
|
||||||
if state.ready:
|
|
||||||
return JSONResponse(status_code=200, content=state.get_ready())
|
|
||||||
checks = state.get_ready()["checks"]
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=503,
|
|
||||||
content=error_payload(
|
|
||||||
"agent_unavailable", "Шлюз не готов обслуживать запросы", {"checks": checks}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/metrics", response_class=PlainTextResponse)
|
|
||||||
async def get_prometheus_metrics(state: StateDep) -> PlainTextResponse:
|
|
||||||
return PlainTextResponse(state.prometheus_text())
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Маршрутизация: правила, режим, GeoIP, импорт."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from awg_api.deps import StateDep, require_token
|
|
||||||
from awg_api.schemas import (
|
|
||||||
GeoIpUpdateRequest,
|
|
||||||
ImportRequest,
|
|
||||||
ModeUpdateRequest,
|
|
||||||
RoutingRuleCreate,
|
|
||||||
RoutingRuleUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/api/v1/routing", tags=["routing"], dependencies=[Depends(require_token)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules")
|
|
||||||
async def list_rules(
|
|
||||||
state: StateDep,
|
|
||||||
enabled: bool | None = None,
|
|
||||||
kind: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return state.list_rules(enabled=enabled, kind=kind)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules", status_code=201)
|
|
||||||
async def create_rule(payload: RoutingRuleCreate, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.create_rule(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules/{rule_id}")
|
|
||||||
async def get_rule(rule_id: str, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.get_rule(rule_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rules/{rule_id}")
|
|
||||||
async def update_rule(rule_id: str, payload: RoutingRuleUpdate, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.update_rule(rule_id, payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/rules/{rule_id}", status_code=204)
|
|
||||||
async def delete_rule(rule_id: str, state: StateDep) -> None:
|
|
||||||
state.delete_rule(rule_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/mode")
|
|
||||||
async def put_mode(payload: ModeUpdateRequest, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.set_mode(payload.mode)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/geoip/update")
|
|
||||||
async def update_geoip(
|
|
||||||
state: StateDep, payload: GeoIpUpdateRequest | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
force = bool(payload.force) if payload is not None else False
|
|
||||||
return state.update_geoip(force=force)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import")
|
|
||||||
async def import_config(payload: ImportRequest, state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.import_config(payload)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Состояние туннеля: /api/v1/status."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from awg_api.deps import StateDep, require_token
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["status"], dependencies=[Depends(require_token)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def get_status(state: StateDep) -> dict[str, Any]:
|
|
||||||
return state.get_status()
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""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})
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
"""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,
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# store — хранилище состояния (issue #20)
|
|
||||||
|
|
||||||
SQLite (WAL) в `/data/awg-dash.db`: правила маршрутизации, исключения, режим,
|
|
||||||
расписание GeoIP, метаданные конфигов, устройства, журнал аудита и снимки
|
|
||||||
состояния. Путь переопределяется аргументом или переменной `AWG_DASH_DB`.
|
|
||||||
|
|
||||||
| Модуль | Назначение |
|
|
||||||
|---|---|
|
|
||||||
| `db.py` | открытие соединения, PRAGMA (WAL, `synchronous=NORMAL`, `foreign_keys`, `busy_timeout`), транзакции `BEGIN IMMEDIATE` |
|
|
||||||
| `migrations.py` | версионированные миграции, `schema_migrations`, проверка схемы при старте (`verify_schema`) |
|
|
||||||
| `models.py` | dataclass-модели сущностей и перечисления (`Mode`, `Entity`, `Rule`, …) |
|
|
||||||
| `validation.py` | чистые функции валидации (domain/CIDR/ipset, MAC, cron, режим, политика) |
|
|
||||||
| `repository.py` | `Store`: чтения + аудируемые мутации «кто/когда/что/old→new» |
|
|
||||||
| `audit.py` | запись и чтение `audit_log` (мутация и её аудит — одна транзакция) |
|
|
||||||
| `state.py` | снимки состояния (`state_snapshots`), `capture_state`, `restore_state`, `rollback_to_snapshot` |
|
|
||||||
| `apply.py` | `ApplyEngine`: `validate → stage → apply → rollback` и протокол `Applier` (awg/iptables) |
|
|
||||||
| `service.py` | `open_storage()` — сборка `Database + Store + ApplyEngine` для старта приложения |
|
|
||||||
| `errors.py` | `StorageError`, `SchemaError`, `ValidationError`, `ApplyError(restored=…)` |
|
|
||||||
|
|
||||||
## Гарантии
|
|
||||||
|
|
||||||
- **Пустой `/data`** — каталог создаётся, миграции применяются, схема v1 появляется без ручных действий.
|
|
||||||
- **Версия схемы** — при старте схема проверяется: новее ожидаемой или не мигрированная → `SchemaError` (без «автоотката»).
|
|
||||||
- **Аудит** — каждая мутация пишет `actor/ts/action/entity/old_value/new_value` в `audit_log` в той же транзакции; `old_value` читается внутри той же транзакции, поэтому конкурентные обновления не рвут цепочку `old→new`.
|
|
||||||
- **Атомарность** — применяемое состояние проходит `validate → stage → apply`; падение внешнего применителя откатывает транзакцию БД и вызывает `Applier.revert(previous)`, `ApplyError.restored=True`. Ловится `BaseException`, поэтому отмена (`asyncio.CancelledError`), `KeyboardInterrupt` и `SystemExit` тоже дают `revert` и запись `failed` в журнале.
|
|
||||||
- **Откат** — `ApplyEngine.rollback(plan_id)` возвращает состояние из снимка.
|
|
||||||
|
|
||||||
## Потокобезопасность (#54)
|
|
||||||
|
|
||||||
Контракт: **одно соединение на процесс, все операции под одним `RLock`**.
|
|
||||||
|
|
||||||
| Операция | API | Поведение |
|
|
||||||
|---|---|---|
|
|
||||||
| Мутация | `Database.transaction()` | `BEGIN IMMEDIATE` … `COMMIT`/`ROLLBACK` под локом |
|
|
||||||
| Чтение | `Database.read()` | берёт тот же лок; ждёт завершения чужой транзакции |
|
|
||||||
| Прямой доступ | `Database.connection` / `Store.raw()` | лок **не** берётся — только однопоточный код (миграции, тесты, отладка) |
|
|
||||||
|
|
||||||
Следствия, на которые можно опираться в API-слое (#19/#21):
|
|
||||||
|
|
||||||
- обычные чтения (`GET /rules`, `GET /mode`, …) не падают от конкурентных мутаций и не отдают перемешанный результат операторов;
|
|
||||||
- читатель **не видит** незафиксированных строк другой транзакции;
|
|
||||||
- во время `apply` наружу не видно состояние, которое затем будет откачено (AC «не частично применённая конфигурация»);
|
|
||||||
- конкурентные запросы ждут, а не получают ошибку; потокобезопасность подтверждена `backend/tests/test_store_concurrency.py`.
|
|
||||||
|
|
||||||
WAL остаётся включённым: он даёт устойчивость к падению процесса и позволяет читать БД снаружи (`sqlite3` CLI) во время работы шлюза. Внутрипроцессного параллелизма чтений контракт не обещает: если позже понадобится параллельная пропускная способность, меняем контракт на «соединение на поток» (`threading.local`), а не полагаемся на неявное поведение.
|
|
||||||
|
|
||||||
## Пример
|
|
||||||
|
|
||||||
```python
|
|
||||||
from awg_api.store import DesiredState, Rule, open_storage
|
|
||||||
|
|
||||||
with open_storage() as storage: # /data/awg-dash.db
|
|
||||||
storage.store.set_geoip_schedule(cron="0 3 * * *", actor="ui:admin")
|
|
||||||
plan = storage.engine.stage(
|
|
||||||
DesiredState(
|
|
||||||
mode=storage.store.get_mode(), rules=(Rule("yt", "domain", "youtube.com", "tun0"),)
|
|
||||||
),
|
|
||||||
reason="ui: split-tunnel для YouTube",
|
|
||||||
)
|
|
||||||
result = storage.engine.apply(plan)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
`backend/tests/test_store_*.py` — миграции, аудит, репозиторий, apply/rollback,
|
|
||||||
персистентность после рестарта. Порог покрытия — 70 % (`pyproject.toml`).
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Слой хранения состояния awg-dash (issue #20).
|
|
||||||
|
|
||||||
Состав: SQLite (WAL) в ``/data/awg-dash.db``, версионированные миграции,
|
|
||||||
проверка схемы при старте, журнал аудита «кто/когда/что/old→new» и движок
|
|
||||||
атомарного применения ``validate -> stage -> apply -> rollback``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .apply import (
|
|
||||||
Applier,
|
|
||||||
ApplyEngine,
|
|
||||||
ApplyResult,
|
|
||||||
DesiredState,
|
|
||||||
NullApplier,
|
|
||||||
StagedPlan,
|
|
||||||
)
|
|
||||||
from .audit import count as audit_count
|
|
||||||
from .audit import last_entry as audit_last_entry
|
|
||||||
from .audit import list_entries as list_audit_entries
|
|
||||||
from .audit import record as record_audit
|
|
||||||
from .db import DB_PATH_ENV, DEFAULT_DB_PATH, Database, resolve_db_path
|
|
||||||
from .errors import ApplyError, SchemaError, StorageError, ValidationError
|
|
||||||
from .migrations import (
|
|
||||||
EXPECTED_TABLES,
|
|
||||||
LATEST_VERSION,
|
|
||||||
MIGRATIONS,
|
|
||||||
SCHEMA_MIGRATIONS,
|
|
||||||
Migration,
|
|
||||||
MigrationReport,
|
|
||||||
current_version,
|
|
||||||
migrate,
|
|
||||||
verify_schema,
|
|
||||||
)
|
|
||||||
from .models import (
|
|
||||||
AUDIT_STATUS_FAILED,
|
|
||||||
AUDIT_STATUS_OK,
|
|
||||||
AUDIT_STATUS_STAGED,
|
|
||||||
DEVICE_POLICIES,
|
|
||||||
RULE_KINDS,
|
|
||||||
AuditEntry,
|
|
||||||
ConfigMeta,
|
|
||||||
Device,
|
|
||||||
Entity,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
RuleException,
|
|
||||||
)
|
|
||||||
from .repository import Store
|
|
||||||
from .service import Storage, open_storage, rollback_to
|
|
||||||
from .state import StateSnapshot, capture_state, restore_state, rollback_to_snapshot
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AUDIT_STATUS_FAILED",
|
|
||||||
"AUDIT_STATUS_OK",
|
|
||||||
"AUDIT_STATUS_STAGED",
|
|
||||||
"DB_PATH_ENV",
|
|
||||||
"DEFAULT_DB_PATH",
|
|
||||||
"DEVICE_POLICIES",
|
|
||||||
"EXPECTED_TABLES",
|
|
||||||
"LATEST_VERSION",
|
|
||||||
"MIGRATIONS",
|
|
||||||
"RULE_KINDS",
|
|
||||||
"SCHEMA_MIGRATIONS",
|
|
||||||
"Applier",
|
|
||||||
"ApplyEngine",
|
|
||||||
"ApplyError",
|
|
||||||
"ApplyResult",
|
|
||||||
"AuditEntry",
|
|
||||||
"ConfigMeta",
|
|
||||||
"Database",
|
|
||||||
"DesiredState",
|
|
||||||
"Device",
|
|
||||||
"Entity",
|
|
||||||
"GeoIpSchedule",
|
|
||||||
"Migration",
|
|
||||||
"MigrationReport",
|
|
||||||
"Mode",
|
|
||||||
"NullApplier",
|
|
||||||
"Rule",
|
|
||||||
"RuleException",
|
|
||||||
"SchemaError",
|
|
||||||
"StagedPlan",
|
|
||||||
"StateSnapshot",
|
|
||||||
"Storage",
|
|
||||||
"StorageError",
|
|
||||||
"Store",
|
|
||||||
"ValidationError",
|
|
||||||
"audit_count",
|
|
||||||
"audit_last_entry",
|
|
||||||
"capture_state",
|
|
||||||
"current_version",
|
|
||||||
"list_audit_entries",
|
|
||||||
"migrate",
|
|
||||||
"open_storage",
|
|
||||||
"record_audit",
|
|
||||||
"resolve_db_path",
|
|
||||||
"restore_state",
|
|
||||||
"rollback_to",
|
|
||||||
"rollback_to_snapshot",
|
|
||||||
"verify_schema",
|
|
||||||
]
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"""Атомарное применение состояния: ``validate -> stage -> apply -> rollback`` (issue #20).
|
|
||||||
|
|
||||||
Гарантии:
|
|
||||||
|
|
||||||
* **validate** — несостоятельное желаемое состояние не доходит до БД;
|
|
||||||
* **stage** — перед мутацией сохраняется снимок текущего состояния (durable,
|
|
||||||
в отдельной таблице ``state_snapshots``), поэтому откат возможен в любой момент;
|
|
||||||
* **apply** — мутация БД и запись в ``audit_log`` выполняются в одной транзакции,
|
|
||||||
внешнее применение (iptables/awg) вызывается внутри неё: падение внешнего
|
|
||||||
применителя откатывает транзакцию, то есть БД возвращается к предыдущему
|
|
||||||
состоянию целиком, без частичных изменений;
|
|
||||||
* **rollback** — явный возврат к снимку по id.
|
|
||||||
|
|
||||||
Ошибка применения поднимает :class:`ApplyError` (поле ``restored=True``): API
|
|
||||||
должен ответить ошибкой, не «полуприменённой» конфигурацией. Ловится
|
|
||||||
``BaseException`` (#55 D-1), поэтому отмена запроса тоже вызывает
|
|
||||||
``Applier.revert(previous)`` и закрывает запись аудита исходом ``failed`` —
|
|
||||||
«висящих» записей ``staged`` без исхода не остаётся.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from . import audit, state, validation
|
|
||||||
from .errors import ApplyError, ValidationError
|
|
||||||
from .models import (
|
|
||||||
AUDIT_STATUS_FAILED,
|
|
||||||
AUDIT_STATUS_OK,
|
|
||||||
AUDIT_STATUS_STAGED,
|
|
||||||
Entity,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
RuleException,
|
|
||||||
)
|
|
||||||
from .repository import Store
|
|
||||||
from .state import StateSnapshot
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DesiredState:
|
|
||||||
"""Полное желаемое состояние шлюза (то, что применяется как единое целое)."""
|
|
||||||
|
|
||||||
mode: Mode
|
|
||||||
rules: tuple[Rule, ...] = ()
|
|
||||||
geoip: GeoIpSchedule = field(default_factory=GeoIpSchedule)
|
|
||||||
exceptions: tuple[RuleException, ...] = ()
|
|
||||||
|
|
||||||
def to_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"mode": self.mode.value,
|
|
||||||
"geoip": self.geoip.as_dict(),
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"name": rule.name,
|
|
||||||
"kind": rule.kind,
|
|
||||||
"match": rule.match,
|
|
||||||
"target": rule.target,
|
|
||||||
"priority": rule.priority,
|
|
||||||
"enabled": rule.enabled,
|
|
||||||
}
|
|
||||||
for rule in self.rules
|
|
||||||
],
|
|
||||||
"exceptions": [
|
|
||||||
{
|
|
||||||
"match": exception.match,
|
|
||||||
"reason": exception.reason,
|
|
||||||
"enabled": exception.enabled,
|
|
||||||
}
|
|
||||||
for exception in self.exceptions
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_payload(cls, payload: Mapping[str, Any]) -> DesiredState:
|
|
||||||
"""Разобрать желаемое состояние из JSON-совместимого словаря."""
|
|
||||||
raw_rules = payload.get("rules", [])
|
|
||||||
if not isinstance(raw_rules, (list, tuple)):
|
|
||||||
raise ValidationError("rules", "ожидается список правил")
|
|
||||||
rules = tuple(_rule_from_mapping(item) for item in raw_rules)
|
|
||||||
raw_exceptions = payload.get("exceptions", [])
|
|
||||||
if not isinstance(raw_exceptions, (list, tuple)):
|
|
||||||
raise ValidationError("exceptions", "ожидается список исключений")
|
|
||||||
exceptions = tuple(_exception_from_mapping(item) for item in raw_exceptions)
|
|
||||||
geoip_raw = payload.get("geoip") or {}
|
|
||||||
if not isinstance(geoip_raw, Mapping):
|
|
||||||
raise ValidationError("geoip", "ожидается объект расписания")
|
|
||||||
geoip = GeoIpSchedule(
|
|
||||||
enabled=bool(geoip_raw.get("enabled", True)),
|
|
||||||
cron=str(geoip_raw.get("cron", "0 4 * * *")),
|
|
||||||
provider=str(geoip_raw.get("provider", "ripe")),
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
mode=validation.validate_mode(str(payload.get("mode", Mode.OFF.value))),
|
|
||||||
rules=rules,
|
|
||||||
geoip=geoip,
|
|
||||||
exceptions=exceptions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_from_mapping(item: Any) -> Rule:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
raise ValidationError("rules", f"ожидается объект правила, получено {item!r}")
|
|
||||||
try:
|
|
||||||
return Rule(
|
|
||||||
name=str(item["name"]),
|
|
||||||
kind=str(item["kind"]),
|
|
||||||
match=str(item["match"]),
|
|
||||||
target=str(item["target"]),
|
|
||||||
priority=int(item.get("priority", 100)),
|
|
||||||
enabled=bool(item.get("enabled", True)),
|
|
||||||
)
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValidationError("rules", f"в правиле нет обязательного поля {exc.args[0]!r}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _exception_from_mapping(item: Any) -> RuleException:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
raise ValidationError("exceptions", f"ожидается объект исключения, получено {item!r}")
|
|
||||||
try:
|
|
||||||
return RuleException(
|
|
||||||
match=str(item["match"]),
|
|
||||||
reason=str(item.get("reason", "")),
|
|
||||||
enabled=bool(item.get("enabled", True)),
|
|
||||||
)
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValidationError(
|
|
||||||
"exceptions", f"в исключении нет обязательного поля {exc.args[0]!r}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class Applier(Protocol):
|
|
||||||
"""Внешний применитель состояния (awg/iptables/ipset)."""
|
|
||||||
|
|
||||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
"""Применить состояние во внешней подсистеме. Падение -> ApplyError."""
|
|
||||||
|
|
||||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
"""Вернуть внешнюю подсистему к состоянию ``payload`` при откате."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StagedPlan:
|
|
||||||
"""Промежуточный план: снимок предыдущего состояния + желаемое состояние."""
|
|
||||||
|
|
||||||
plan_id: int
|
|
||||||
desired: DesiredState
|
|
||||||
previous: dict[str, Any]
|
|
||||||
actor: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ApplyResult:
|
|
||||||
"""Успешный результат применения."""
|
|
||||||
|
|
||||||
ok: bool
|
|
||||||
plan_id: int
|
|
||||||
audit_id: int
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class NullApplier:
|
|
||||||
"""Заглушка внешнего применителя (для тестов и dry-run режима)."""
|
|
||||||
|
|
||||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ApplyEngine:
|
|
||||||
"""Оркестратор ``validate -> stage -> apply -> rollback``."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, store: Store, applier: Applier | None = None, *, actor: str = "system"
|
|
||||||
) -> None:
|
|
||||||
self._store = store
|
|
||||||
self._applier: Applier = applier if applier is not None else NullApplier()
|
|
||||||
self._actor = actor
|
|
||||||
|
|
||||||
# -- стадии ---------------------------------------------------------
|
|
||||||
def validate(self, desired: DesiredState) -> DesiredState:
|
|
||||||
"""Проверить желаемое состояние, не касаясь БД."""
|
|
||||||
validation.validate_mode(desired.mode)
|
|
||||||
validation.validate_cron(desired.geoip.cron)
|
|
||||||
validation.validate_provider(desired.geoip.provider)
|
|
||||||
seen: set[str] = set()
|
|
||||||
for rule in desired.rules:
|
|
||||||
validation.validate_name("name", rule.name)
|
|
||||||
kind = validation.validate_rule_kind(rule.kind)
|
|
||||||
validation.validate_match(kind, rule.match)
|
|
||||||
validation.validate_target(rule.target)
|
|
||||||
validation.validate_priority(rule.priority)
|
|
||||||
if rule.name in seen:
|
|
||||||
raise ValidationError("rules", f"дублирующееся имя правила: {rule.name!r}")
|
|
||||||
seen.add(rule.name)
|
|
||||||
for exception in desired.exceptions:
|
|
||||||
validation.validate_exception_match(exception.match)
|
|
||||||
return desired
|
|
||||||
|
|
||||||
def stage(self, desired: DesiredState, *, reason: str = "") -> StagedPlan:
|
|
||||||
"""Проверить состояние и сохранить снимок предыдущего состояния."""
|
|
||||||
self.validate(desired)
|
|
||||||
payload = desired.to_payload()
|
|
||||||
with self._store.database.transaction() as conn:
|
|
||||||
previous = state.capture_state(conn)
|
|
||||||
plan_id = state.save_snapshot(conn, previous, reason=reason or "pre-apply")
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=self._actor,
|
|
||||||
action="stage",
|
|
||||||
entity=Entity.APPLY.value,
|
|
||||||
entity_id=plan_id,
|
|
||||||
old_value=previous,
|
|
||||||
new_value=payload,
|
|
||||||
status=AUDIT_STATUS_STAGED,
|
|
||||||
detail=reason,
|
|
||||||
)
|
|
||||||
return StagedPlan(
|
|
||||||
plan_id=plan_id, desired=desired, previous=previous, actor=self._actor, reason=reason
|
|
||||||
)
|
|
||||||
|
|
||||||
def apply(self, plan: StagedPlan) -> ApplyResult:
|
|
||||||
"""Применить план. При падении — откатить БД и внешнюю подсистему.
|
|
||||||
|
|
||||||
:raises ApplyError: если применитель упал; ``restored=True`` означает,
|
|
||||||
что предыдущее состояние подтверждённо восстановлено.
|
|
||||||
|
|
||||||
Ловится ``BaseException``, не только ``Exception`` (#55 D-1): отмена
|
|
||||||
запроса (``asyncio.CancelledError``), ``KeyboardInterrupt`` и
|
|
||||||
``SystemExit`` тоже обязаны вернуть внешнюю подсистему в предыдущее
|
|
||||||
состояние и закрыть запись аудита исходом ``failed``. Иначе iptables
|
|
||||||
останется в новом состоянии, тогда как ``audit_log`` сохранит
|
|
||||||
«висящую» запись ``staged`` без исхода. Исключения из ``Exception``
|
|
||||||
преобразуются в :class:`ApplyError`, остальные пробрасываются как есть
|
|
||||||
(семантика отмены сохраняется).
|
|
||||||
"""
|
|
||||||
payload = plan.desired.to_payload()
|
|
||||||
audit_id: int
|
|
||||||
try:
|
|
||||||
with self._store.database.transaction() as conn:
|
|
||||||
state.restore_state(conn, payload)
|
|
||||||
self._applier.apply(payload)
|
|
||||||
audit_id = audit.record(
|
|
||||||
conn,
|
|
||||||
actor=plan.actor,
|
|
||||||
action="apply",
|
|
||||||
entity=Entity.APPLY.value,
|
|
||||||
entity_id=plan.plan_id,
|
|
||||||
old_value=plan.previous,
|
|
||||||
new_value=payload,
|
|
||||||
status=AUDIT_STATUS_OK,
|
|
||||||
detail=plan.reason,
|
|
||||||
)
|
|
||||||
except BaseException as exc:
|
|
||||||
restored_ok = self._rollback_failed_apply(plan, exc)
|
|
||||||
if not isinstance(exc, Exception):
|
|
||||||
raise
|
|
||||||
raise ApplyError(
|
|
||||||
f"применение состояния не удалось: {exc}",
|
|
||||||
restored=restored_ok,
|
|
||||||
audit_id=self._last_failed_audit_id(plan),
|
|
||||||
) from exc
|
|
||||||
return ApplyResult(
|
|
||||||
ok=True, plan_id=plan.plan_id, audit_id=audit_id, message="состояние применено"
|
|
||||||
)
|
|
||||||
|
|
||||||
def apply_payload(self, payload: Mapping[str, Any], *, reason: str = "") -> ApplyResult:
|
|
||||||
"""validate + stage + apply одной командой (для сервисного слоя)."""
|
|
||||||
desired = self.validate(DesiredState.from_payload(payload))
|
|
||||||
return self.apply(self.stage(desired, reason=reason))
|
|
||||||
|
|
||||||
def rollback(self, plan_id: int, *, reason: str = "") -> StateSnapshot:
|
|
||||||
"""Явно вернуть состояние к снимку плана."""
|
|
||||||
with self._store.database.transaction() as conn:
|
|
||||||
return state.rollback_to_snapshot(conn, plan_id, actor=self._actor, reason=reason)
|
|
||||||
|
|
||||||
# -- внутреннее -----------------------------------------------------
|
|
||||||
def _rollback_failed_apply(self, plan: StagedPlan, exc: BaseException) -> bool:
|
|
||||||
"""Вернуть внешнюю подсистему и убедиться, что БД = предыдущее состояние.
|
|
||||||
|
|
||||||
Откат внешней подсистемы — best-effort: падение ``revert`` не мешает
|
|
||||||
восстановить БД и записать исход в журнал (причина падения revert
|
|
||||||
попадает в ``detail``), и наоборот — падение revert не должно скрывать
|
|
||||||
исходное исключение.
|
|
||||||
"""
|
|
||||||
revert_error: BaseException | None = None
|
|
||||||
try:
|
|
||||||
self._applier.revert(plan.previous)
|
|
||||||
except BaseException as revert_exc: # откат best-effort: исход важнее падения revert
|
|
||||||
revert_error = revert_exc
|
|
||||||
detail = f"{type(exc).__name__}: {exc}"
|
|
||||||
if revert_error is not None:
|
|
||||||
detail += f"; revert: {type(revert_error).__name__}: {revert_error}"
|
|
||||||
with self._store.database.transaction() as conn:
|
|
||||||
state.restore_state(conn, plan.previous)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=plan.actor,
|
|
||||||
action="apply",
|
|
||||||
entity=Entity.APPLY.value,
|
|
||||||
entity_id=plan.plan_id,
|
|
||||||
old_value=plan.desired.to_payload(),
|
|
||||||
new_value=plan.previous,
|
|
||||||
status=AUDIT_STATUS_FAILED,
|
|
||||||
detail=detail,
|
|
||||||
)
|
|
||||||
with self._store.database.read() as conn:
|
|
||||||
return state.capture_state(conn) == plan.previous
|
|
||||||
|
|
||||||
def _last_failed_audit_id(self, plan: StagedPlan) -> int | None:
|
|
||||||
with self._store.database.read() as conn:
|
|
||||||
entries = audit.list_entries(
|
|
||||||
conn, entity=Entity.APPLY.value, entity_id=plan.plan_id, limit=1
|
|
||||||
)
|
|
||||||
if not entries:
|
|
||||||
return None
|
|
||||||
last = entries[0]
|
|
||||||
return last.id if last.status == AUDIT_STATUS_FAILED else None
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Журнал аудита «кто/когда/что/old->new» (issue #20).
|
|
||||||
|
|
||||||
Запись всегда выполняется на переданном соединении, то есть внутри уже
|
|
||||||
открытой транзакции вызывающего кода: мутация и её аудит коммитятся вместе и
|
|
||||||
не могут разойтись.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .clock import utcnow
|
|
||||||
from .models import AUDIT_STATUS_OK, AuditEntry
|
|
||||||
|
|
||||||
__all__ = ["list_entries", "record"]
|
|
||||||
|
|
||||||
|
|
||||||
def _dumps(value: Any) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return json.dumps(dict(value), sort_keys=True, ensure_ascii=False, default=str)
|
|
||||||
return json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _loads(value: Any) -> Any:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, (bytes, bytearray)):
|
|
||||||
value = value.decode("utf-8")
|
|
||||||
return json.loads(str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def record(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
*,
|
|
||||||
actor: str,
|
|
||||||
action: str,
|
|
||||||
entity: str,
|
|
||||||
entity_id: str | int | None = None,
|
|
||||||
old_value: Any = None,
|
|
||||||
new_value: Any = None,
|
|
||||||
status: str = AUDIT_STATUS_OK,
|
|
||||||
detail: str | None = None,
|
|
||||||
ts: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Записать событие в ``audit_log`` и вернуть id записи."""
|
|
||||||
if not actor:
|
|
||||||
raise ValueError("actor обязателен: аудит должен знать, кто изменил состояние")
|
|
||||||
cursor = conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audit_log (ts, actor, action, entity, entity_id,
|
|
||||||
old_value, new_value, status, detail)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
ts or utcnow(),
|
|
||||||
actor,
|
|
||||||
action,
|
|
||||||
entity,
|
|
||||||
None if entity_id is None else str(entity_id),
|
|
||||||
_dumps(old_value),
|
|
||||||
_dumps(new_value),
|
|
||||||
status,
|
|
||||||
detail,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return int(cursor.lastrowid or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def list_entries(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
*,
|
|
||||||
entity: str | None = None,
|
|
||||||
entity_id: str | int | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[AuditEntry]:
|
|
||||||
"""Прочитать журнал аудита (свежие первыми)."""
|
|
||||||
sql = "SELECT * FROM audit_log"
|
|
||||||
clauses: list[str] = []
|
|
||||||
params: list[Any] = []
|
|
||||||
if entity is not None:
|
|
||||||
clauses.append("entity = ?")
|
|
||||||
params.append(entity)
|
|
||||||
if entity_id is not None:
|
|
||||||
clauses.append("entity_id = ?")
|
|
||||||
params.append(str(entity_id))
|
|
||||||
if clauses:
|
|
||||||
sql += " WHERE " + " AND ".join(clauses)
|
|
||||||
sql += " ORDER BY id DESC LIMIT ?"
|
|
||||||
params.append(int(limit))
|
|
||||||
rows = conn.execute(sql, params).fetchall()
|
|
||||||
return [
|
|
||||||
AuditEntry(
|
|
||||||
id=int(row["id"]),
|
|
||||||
ts=str(row["ts"]),
|
|
||||||
actor=str(row["actor"]),
|
|
||||||
action=str(row["action"]),
|
|
||||||
entity=str(row["entity"]),
|
|
||||||
entity_id=None if row["entity_id"] is None else str(row["entity_id"]),
|
|
||||||
old_value=_loads(row["old_value"]),
|
|
||||||
new_value=_loads(row["new_value"]),
|
|
||||||
status=str(row["status"]),
|
|
||||||
detail=None if row["detail"] is None else str(row["detail"]),
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def count(conn: sqlite3.Connection, *, entity: str | None = None) -> int:
|
|
||||||
"""Сколько записей в журнале (опционально — по сущности)."""
|
|
||||||
if entity is None:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM audit_log").fetchone()
|
|
||||||
else:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM audit_log WHERE entity = ?", (entity,)).fetchone()
|
|
||||||
return int(row[0]) if row is not None else 0
|
|
||||||
|
|
||||||
|
|
||||||
def last_entry(conn: sqlite3.Connection, *, entity: str | None = None) -> AuditEntry | None:
|
|
||||||
"""Последняя запись журнала (опционально — по сущности)."""
|
|
||||||
entries = list_entries(conn, entity=entity, limit=1)
|
|
||||||
return entries[0] if entries else None
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Источник времени для audit_log и снимков состояния.
|
|
||||||
|
|
||||||
Отдельный модуль — чтобы тесты могли подменять время детерминированно
|
|
||||||
(``monkeypatch.setattr(clock, "utcnow", ...)``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
__all__ = ["utcnow"]
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> str:
|
|
||||||
"""Текущее время в UTC: ISO-8601, миллисекунды, суффикс ``Z``."""
|
|
||||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"""Открытие и жизненный цикл SQLite-хранилища состояния (issue #20, ADR-006).
|
|
||||||
|
|
||||||
Путь по умолчанию — ``/data/awg-dash.db`` (персистентный volume контейнера),
|
|
||||||
переопределяется аргументом или переменной окружения ``AWG_DASH_DB``.
|
|
||||||
Каталог создаётся автоматически, поэтому на пустом ``/data`` схема v1
|
|
||||||
поднимается без ручных действий.
|
|
||||||
|
|
||||||
Соединение открывается в режиме WAL: ``synchronous=NORMAL`` достаточен для WAL
|
|
||||||
(риск потери только последних транзакций при падении операционной системы, БД
|
|
||||||
при этом не разрушается).
|
|
||||||
|
|
||||||
Контракт потокобезопасности (#54): соединение одно на процесс, все операции —
|
|
||||||
и записи (:meth:`Database.transaction`), и чтения (:meth:`Database.read`) —
|
|
||||||
выполняются под одним ``RLock``. Поэтому:
|
|
||||||
|
|
||||||
* результаты операторов из разных потоков не перемешиваются (нет
|
|
||||||
``TypeError``/``IndexError``);
|
|
||||||
* читатель не видит незафиксированных строк чужой транзакции и состояния,
|
|
||||||
которое затем будет откачено (падение внешнего применителя);
|
|
||||||
* конкурентные запросы ждут, не падают.
|
|
||||||
|
|
||||||
WAL при этом остаётся: он даёт устойчивость к падению процесса и позволяет
|
|
||||||
читать БД снаружи (``sqlite3`` CLI) во время работы шлюза. Если позже
|
|
||||||
понадобится параллельная пропускная способность чтений, контракт меняется на
|
|
||||||
«соединение на поток» (``threading.local``) — это отдельное решение, не
|
|
||||||
молчаливое поведение.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .errors import StorageError
|
|
||||||
from .migrations import MigrationReport, current_version, migrate, verify_schema
|
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/awg-dash.db"
|
|
||||||
DB_PATH_ENV = "AWG_DASH_DB"
|
|
||||||
BUSY_TIMEOUT_MS = 5000
|
|
||||||
|
|
||||||
_WAL_PRAGMAS: tuple[str, ...] = (
|
|
||||||
"PRAGMA journal_mode = WAL",
|
|
||||||
"PRAGMA synchronous = NORMAL",
|
|
||||||
"PRAGMA foreign_keys = ON",
|
|
||||||
"PRAGMA busy_timeout = 5000",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_db_path(path: str | os.PathLike[str] | None = None) -> Path:
|
|
||||||
"""Определить путь к БД: аргумент -> ``AWG_DASH_DB`` -> ``/data/awg-dash.db``."""
|
|
||||||
if path is None:
|
|
||||||
path = os.environ.get(DB_PATH_ENV) or DEFAULT_DB_PATH
|
|
||||||
return Path(path)
|
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
|
||||||
"""Соединение SQLite: миграции, проверка схемы, транзакции."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
path: str | os.PathLike[str] | None = None,
|
|
||||||
*,
|
|
||||||
auto_migrate: bool = True,
|
|
||||||
) -> None:
|
|
||||||
self.path = resolve_db_path(path)
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._conn = self._open(self.path)
|
|
||||||
self.migration_report: MigrationReport | None = None
|
|
||||||
if auto_migrate:
|
|
||||||
self.migrate()
|
|
||||||
else:
|
|
||||||
verify_schema(self._conn)
|
|
||||||
|
|
||||||
# -- соединение -----------------------------------------------------
|
|
||||||
@staticmethod
|
|
||||||
def _open(path: Path) -> sqlite3.Connection:
|
|
||||||
if str(path) != ":memory:":
|
|
||||||
try:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
except OSError as exc:
|
|
||||||
raise StorageError(f"не удалось создать каталог {path.parent}: {exc}") from exc
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(
|
|
||||||
str(path),
|
|
||||||
isolation_level=None,
|
|
||||||
check_same_thread=False,
|
|
||||||
timeout=BUSY_TIMEOUT_MS / 1000,
|
|
||||||
)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
for pragma in _WAL_PRAGMAS:
|
|
||||||
conn.execute(pragma)
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise StorageError(f"не удалось открыть БД {path}: {exc}") from exc
|
|
||||||
return conn
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connection(self) -> sqlite3.Connection:
|
|
||||||
"""Прямой доступ к соединению — только для однопоточного кода.
|
|
||||||
|
|
||||||
Рантайм API/сервисного слоя использует :meth:`read` (чтения) и
|
|
||||||
:meth:`transaction` (мутации): они берут общий лок и гарантируют
|
|
||||||
согласованность результата (#54).
|
|
||||||
"""
|
|
||||||
return self._conn
|
|
||||||
|
|
||||||
@property
|
|
||||||
def journal_mode(self) -> str:
|
|
||||||
with self._lock:
|
|
||||||
row = self._conn.execute("PRAGMA journal_mode").fetchone()
|
|
||||||
return str(row[0]) if row is not None else ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_wal(self) -> bool:
|
|
||||||
return self.journal_mode.lower() == "wal"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def schema_version(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return current_version(self._conn)
|
|
||||||
|
|
||||||
# -- схема ----------------------------------------------------------
|
|
||||||
def migrate(self) -> MigrationReport:
|
|
||||||
"""Применить неприменённые миграции и проверить схему."""
|
|
||||||
with self._lock:
|
|
||||||
report = migrate(self._conn)
|
|
||||||
self.migration_report = report
|
|
||||||
return report
|
|
||||||
|
|
||||||
def verify_schema(self) -> None:
|
|
||||||
"""Проверить соответствие фактической схемы ожидаемой версии."""
|
|
||||||
with self._lock:
|
|
||||||
verify_schema(self._conn)
|
|
||||||
|
|
||||||
# -- транзакции и чтения --------------------------------------------
|
|
||||||
@contextmanager
|
|
||||||
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
||||||
"""Транзакция ALL-OR-NOTHING (``BEGIN IMMEDIATE`` + rollback при ошибке)."""
|
|
||||||
with self._lock:
|
|
||||||
self._conn.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
yield self._conn
|
|
||||||
except BaseException:
|
|
||||||
self._conn.rollback()
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def read(self) -> Iterator[sqlite3.Connection]:
|
|
||||||
"""Согласованное чтение под общим локом (#54).
|
|
||||||
|
|
||||||
Ждёт завершения чужой транзакции и потому не видит незафиксированных
|
|
||||||
строк и состояния, которое затем будет откачено. Внутри уже открытой
|
|
||||||
транзакции того же потока лок реентерабелен, поэтому читать можно и из
|
|
||||||
неё.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
yield self._conn
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
try:
|
|
||||||
self._conn.close()
|
|
||||||
except sqlite3.Error as exc: # pragma: no cover - защита от гонок при shutdown
|
|
||||||
raise StorageError(f"ошибка закрытия БД: {exc}") from exc
|
|
||||||
|
|
||||||
def __enter__(self) -> Database:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc_info: object) -> None:
|
|
||||||
self.close()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Иерархия ошибок хранилища awg-dash."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class StorageError(RuntimeError):
|
|
||||||
"""Базовая ошибка слоя хранения состояния."""
|
|
||||||
|
|
||||||
|
|
||||||
class SchemaError(StorageError):
|
|
||||||
"""Схема БД не соответствует ожидаемой версии (или требует отката)."""
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(StorageError):
|
|
||||||
"""Данные не прошли валидацию до применения (стадия validate)."""
|
|
||||||
|
|
||||||
def __init__(self, field: str, message: str) -> None:
|
|
||||||
super().__init__(f"{field}: {message}")
|
|
||||||
self.field = field
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
|
|
||||||
class ApplyError(StorageError):
|
|
||||||
"""Ошибка применения конфигурации.
|
|
||||||
|
|
||||||
``restored`` — предыдущее состояние подтверждённо восстановлено,
|
|
||||||
``audit_id`` — id записи в audit_log, статус ``failed``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, restored: bool, audit_id: int | None) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.restored = restored
|
|
||||||
self.audit_id = audit_id
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Версионированные миграции схемы состояния (issue #20).
|
|
||||||
|
|
||||||
Схема БД управляется списком :class:`Migration`. Каждая миграция выполняется
|
|
||||||
в своей транзакции, запись версии в ``schema_migrations`` — там же: падение
|
|
||||||
миграции не оставляет «полуприменённой» схемы.
|
|
||||||
|
|
||||||
При старте приложения вызывается :func:`migrate` (создание/обновление схемы)
|
|
||||||
и :func:`verify_schema` (проверка, что фактическая схема соответствует
|
|
||||||
ожидаемой версии).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from .clock import utcnow
|
|
||||||
from .errors import SchemaError, StorageError
|
|
||||||
|
|
||||||
SCHEMA_MIGRATIONS = "schema_migrations"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Migration:
|
|
||||||
"""Одна версия схемы: имя и список DDL-выражений."""
|
|
||||||
|
|
||||||
version: int
|
|
||||||
name: str
|
|
||||||
statements: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MigrationReport:
|
|
||||||
"""Результат :func:`migrate`."""
|
|
||||||
|
|
||||||
from_version: int
|
|
||||||
to_version: int
|
|
||||||
applied: tuple[int, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def changed(self) -> bool:
|
|
||||||
return bool(self.applied)
|
|
||||||
|
|
||||||
|
|
||||||
_V1_STATEMENTS: tuple[str, ...] = (
|
|
||||||
f"""
|
|
||||||
CREATE TABLE IF NOT EXISTS {SCHEMA_MIGRATIONS} (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
applied_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS routing_rules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('domain', 'cidr', 'ipset')),
|
|
||||||
match TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
priority INTEGER NOT NULL DEFAULT 100,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rule_exceptions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
match TEXT NOT NULL UNIQUE,
|
|
||||||
reason TEXT NOT NULL DEFAULT '',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS routing_mode (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
mode TEXT NOT NULL CHECK (mode IN ('off', 'split', 'full')),
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS geoip_schedule (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
||||||
cron TEXT NOT NULL,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS configs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
checksum TEXT NOT NULL DEFAULT '',
|
|
||||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
applied INTEGER NOT NULL DEFAULT 0 CHECK (applied IN (0, 1)),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS devices (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mac TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
address TEXT NOT NULL DEFAULT '',
|
|
||||||
policy TEXT NOT NULL DEFAULT 'default',
|
|
||||||
last_seen TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ts TEXT NOT NULL,
|
|
||||||
actor TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
entity TEXT NOT NULL,
|
|
||||||
entity_id TEXT,
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'ok',
|
|
||||||
detail TEXT
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log (entity, entity_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log (ts DESC)",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS state_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
entity TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
reason TEXT NOT NULL DEFAULT ''
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_state_snapshots_key ON state_snapshots (entity, entity_id, id)",
|
|
||||||
)
|
|
||||||
|
|
||||||
V1_SEED_STATEMENTS: tuple[str, ...] = (
|
|
||||||
"INSERT OR IGNORE INTO routing_mode (id, mode, updated_at) VALUES (1, 'off',"
|
|
||||||
" strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
|
||||||
"INSERT OR IGNORE INTO geoip_schedule (id, enabled, cron, provider, updated_at) VALUES"
|
|
||||||
" (1, 1, '0 4 * * *', 'ripe', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
|
||||||
)
|
|
||||||
|
|
||||||
MIGRATIONS: tuple[Migration, ...] = (
|
|
||||||
Migration(
|
|
||||||
version=1,
|
|
||||||
name="initial-state-schema",
|
|
||||||
statements=(*_V1_STATEMENTS, *V1_SEED_STATEMENTS),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
LATEST_VERSION: int = max(migration.version for migration in MIGRATIONS)
|
|
||||||
|
|
||||||
#: Ожидаемые таблицы схемы v1 и обязательные колонки каждой из них.
|
|
||||||
EXPECTED_TABLES: dict[str, frozenset[str]] = {
|
|
||||||
SCHEMA_MIGRATIONS: frozenset({"version", "name", "applied_at"}),
|
|
||||||
"routing_rules": frozenset(
|
|
||||||
{"id", "name", "kind", "match", "target", "priority", "enabled", "created_at", "updated_at"}
|
|
||||||
),
|
|
||||||
"rule_exceptions": frozenset({"id", "match", "reason", "enabled", "created_at", "updated_at"}),
|
|
||||||
"routing_mode": frozenset({"id", "mode", "updated_at"}),
|
|
||||||
"geoip_schedule": frozenset({"id", "enabled", "cron", "provider", "updated_at"}),
|
|
||||||
"configs": frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"version",
|
|
||||||
"path",
|
|
||||||
"checksum",
|
|
||||||
"size_bytes",
|
|
||||||
"applied",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"devices": frozenset(
|
|
||||||
{"id", "mac", "name", "address", "policy", "last_seen", "created_at", "updated_at"}
|
|
||||||
),
|
|
||||||
"audit_log": frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"ts",
|
|
||||||
"actor",
|
|
||||||
"action",
|
|
||||||
"entity",
|
|
||||||
"entity_id",
|
|
||||||
"old_value",
|
|
||||||
"new_value",
|
|
||||||
"status",
|
|
||||||
"detail",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"state_snapshots": frozenset({"id", "entity", "entity_id", "created_at", "payload", "reason"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def table_names(conn: sqlite3.Connection) -> set[str]:
|
|
||||||
"""Имена таблиц, фактически присутствующих в БД."""
|
|
||||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
|
|
||||||
return {str(row[0]) for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
||||||
"""Колонки таблицы (через параметризованное table-valued PRAGMA)."""
|
|
||||||
rows = conn.execute("SELECT name FROM pragma_table_info(?)", (table,)).fetchall()
|
|
||||||
return {str(row[0]) for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def current_version(conn: sqlite3.Connection) -> int:
|
|
||||||
"""Текущая версия схемы; ``0`` — если таблицы версий ещё нет."""
|
|
||||||
if SCHEMA_MIGRATIONS not in table_names(conn):
|
|
||||||
return 0
|
|
||||||
row = conn.execute(f"SELECT MAX(version) FROM {SCHEMA_MIGRATIONS}").fetchone() # noqa: S608
|
|
||||||
if row is None or row[0] is None:
|
|
||||||
return 0
|
|
||||||
return int(row[0])
|
|
||||||
|
|
||||||
|
|
||||||
def applied_migrations(conn: sqlite3.Connection) -> dict[int, str]:
|
|
||||||
"""Карта «версия -> имя» для применённых миграций."""
|
|
||||||
if SCHEMA_MIGRATIONS not in table_names(conn):
|
|
||||||
return {}
|
|
||||||
rows = conn.execute(f"SELECT version, name FROM {SCHEMA_MIGRATIONS}").fetchall() # noqa: S608
|
|
||||||
return {int(row[0]): str(row[1]) for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_migration(conn: sqlite3.Connection, migration: Migration) -> None:
|
|
||||||
"""Применить одну миграцию в отдельной транзакции (all-or-nothing)."""
|
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
for statement in migration.statements:
|
|
||||||
conn.execute(statement)
|
|
||||||
conn.execute(
|
|
||||||
f"INSERT INTO {SCHEMA_MIGRATIONS} (version, name, applied_at) VALUES (?, ?, ?)", # noqa: S608
|
|
||||||
(migration.version, migration.name, utcnow()),
|
|
||||||
)
|
|
||||||
except sqlite3.Error as exc: # pragma: no cover - защита от порчи БД
|
|
||||||
conn.rollback()
|
|
||||||
raise StorageError(
|
|
||||||
f"миграция v{migration.version} ({migration.name}) не применена: {exc}"
|
|
||||||
) from exc
|
|
||||||
except Exception:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(
|
|
||||||
conn: sqlite3.Connection, *, migrations: tuple[Migration, ...] = MIGRATIONS
|
|
||||||
) -> MigrationReport:
|
|
||||||
"""Применить все неприменённые миграции по возрастанию версии."""
|
|
||||||
start = current_version(conn)
|
|
||||||
known = sorted(migrations, key=lambda migration: migration.version)
|
|
||||||
latest = max((migration.version for migration in known), default=0)
|
|
||||||
if start > latest:
|
|
||||||
raise SchemaError(
|
|
||||||
f"схема БД новее, чем известно приложению: v{start} > v{latest};"
|
|
||||||
" требуется обновление образа, не автооткат"
|
|
||||||
)
|
|
||||||
applied: list[int] = []
|
|
||||||
for migration in known:
|
|
||||||
if migration.version <= start:
|
|
||||||
continue
|
|
||||||
apply_migration(conn, migration)
|
|
||||||
applied.append(migration.version)
|
|
||||||
verify_schema(conn)
|
|
||||||
return MigrationReport(
|
|
||||||
from_version=start, to_version=current_version(conn), applied=tuple(applied)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_schema(conn: sqlite3.Connection) -> None:
|
|
||||||
"""Проверить, что фактическая схема соответствует ожидаемой версии.
|
|
||||||
|
|
||||||
Вызывает :class:`SchemaError`, если версия схемы новее ожидаемой или если
|
|
||||||
отсутствует таблица/колонка, на которую рассчитывает приложение.
|
|
||||||
"""
|
|
||||||
version = current_version(conn)
|
|
||||||
if version > LATEST_VERSION:
|
|
||||||
raise SchemaError(f"схема БД v{version} новее ожидаемой v{LATEST_VERSION}")
|
|
||||||
if version < LATEST_VERSION:
|
|
||||||
raise SchemaError(f"схема БД v{version} не мигрирована до v{LATEST_VERSION}")
|
|
||||||
existing = table_names(conn)
|
|
||||||
missing_tables = sorted(set(EXPECTED_TABLES) - existing)
|
|
||||||
if missing_tables:
|
|
||||||
raise SchemaError(f"в схеме отсутствуют таблицы: {', '.join(missing_tables)}")
|
|
||||||
for table, required in EXPECTED_TABLES.items():
|
|
||||||
missing_columns = sorted(required - table_columns(conn, table))
|
|
||||||
if missing_columns:
|
|
||||||
raise SchemaError(f"таблица {table}: отсутствуют колонки {', '.join(missing_columns)}")
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
"""Модель данных хранилища awg-dash (issue #20).
|
|
||||||
|
|
||||||
Каждая сущность описана неизменяемым dataclass-ом; идентификатор ``None``
|
|
||||||
означает «ещё не сохранено». Строки времени — UTC ISO-8601, суффикс ``Z``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
RULE_KINDS: tuple[str, ...] = ("domain", "cidr", "ipset")
|
|
||||||
|
|
||||||
DEVICE_POLICIES: tuple[str, ...] = ("default", "tunnel", "direct", "block")
|
|
||||||
|
|
||||||
AUDIT_STATUS_OK = "ok"
|
|
||||||
AUDIT_STATUS_FAILED = "failed"
|
|
||||||
AUDIT_STATUS_STAGED = "staged"
|
|
||||||
|
|
||||||
|
|
||||||
class Mode(StrEnum):
|
|
||||||
"""Режим маршрутизации шлюза."""
|
|
||||||
|
|
||||||
OFF = "off"
|
|
||||||
SPLIT = "split"
|
|
||||||
FULL = "full"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def values(cls) -> tuple[str, ...]:
|
|
||||||
return tuple(member.value for member in cls)
|
|
||||||
|
|
||||||
|
|
||||||
class Entity(StrEnum):
|
|
||||||
"""Имена сущностей для audit_log и снимков состояния."""
|
|
||||||
|
|
||||||
RULE = "rule"
|
|
||||||
EXCEPTION = "exception"
|
|
||||||
MODE = "mode"
|
|
||||||
GEOIP_SCHEDULE = "geoip_schedule"
|
|
||||||
CONFIG = "config"
|
|
||||||
DEVICE = "device"
|
|
||||||
APPLY = "apply"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Rule:
|
|
||||||
"""Правило маршрутизации (domain / cidr / ipset -> target)."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
kind: str
|
|
||||||
match: str
|
|
||||||
target: str
|
|
||||||
priority: int = 100
|
|
||||||
enabled: bool = True
|
|
||||||
id: int | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"kind": self.kind,
|
|
||||||
"match": self.match,
|
|
||||||
"target": self.target,
|
|
||||||
"priority": self.priority,
|
|
||||||
"enabled": self.enabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RuleException:
|
|
||||||
"""Исключение из правил (host/domain, который никогда не маршрутизируется в туннель)."""
|
|
||||||
|
|
||||||
match: str
|
|
||||||
reason: str = ""
|
|
||||||
enabled: bool = True
|
|
||||||
id: int | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"match": self.match,
|
|
||||||
"reason": self.reason,
|
|
||||||
"enabled": self.enabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class GeoIpSchedule:
|
|
||||||
"""Расписание обновления GeoIP-наборов."""
|
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
cron: str = "0 4 * * *"
|
|
||||||
provider: str = "ripe"
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"enabled": self.enabled,
|
|
||||||
"cron": self.cron,
|
|
||||||
"provider": self.provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ConfigMeta:
|
|
||||||
"""Метаданные сгенерированного конфига (сам файл живёт в /config)."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
version: int = 1
|
|
||||||
checksum: str = ""
|
|
||||||
size_bytes: int = 0
|
|
||||||
applied: bool = False
|
|
||||||
id: int | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"path": self.path,
|
|
||||||
"version": self.version,
|
|
||||||
"checksum": self.checksum,
|
|
||||||
"size_bytes": self.size_bytes,
|
|
||||||
"applied": self.applied,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Device:
|
|
||||||
"""Известный клиент шлюза."""
|
|
||||||
|
|
||||||
mac: str
|
|
||||||
name: str
|
|
||||||
address: str = ""
|
|
||||||
policy: str = "default"
|
|
||||||
last_seen: str | None = None
|
|
||||||
id: int | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"mac": self.mac,
|
|
||||||
"name": self.name,
|
|
||||||
"address": self.address,
|
|
||||||
"policy": self.policy,
|
|
||||||
"last_seen": self.last_seen,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AuditEntry:
|
|
||||||
"""Запись журнала «кто/когда/что/old->new»."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
ts: str
|
|
||||||
actor: str
|
|
||||||
action: str
|
|
||||||
entity: str
|
|
||||||
entity_id: str | None
|
|
||||||
old_value: Any
|
|
||||||
new_value: Any
|
|
||||||
status: str
|
|
||||||
detail: str | None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"ts": self.ts,
|
|
||||||
"actor": self.actor,
|
|
||||||
"action": self.action,
|
|
||||||
"entity": self.entity,
|
|
||||||
"entity_id": self.entity_id,
|
|
||||||
"old_value": self.old_value,
|
|
||||||
"new_value": self.new_value,
|
|
||||||
"status": self.status,
|
|
||||||
"detail": self.detail,
|
|
||||||
}
|
|
||||||
@@ -1,599 +0,0 @@
|
|||||||
"""Репозиторий сущностей состояния (issue #20).
|
|
||||||
|
|
||||||
Каждая мутация выполняется в одной транзакции, запись в ``audit_log``
|
|
||||||
(«кто/когда/что/old→new») коммитится там же. Чтения идут через
|
|
||||||
:meth:`Database.read` — под тем же ``RLock``, что и транзакции (#54), поэтому
|
|
||||||
читатель не видит незафиксированных строк и состояния, которое затем будет
|
|
||||||
откачено, и не может получить перемешанный результат операторов другого потока.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from dataclasses import replace
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from . import audit, validation
|
|
||||||
from .clock import utcnow
|
|
||||||
from .db import Database
|
|
||||||
from .errors import StorageError
|
|
||||||
from .models import (
|
|
||||||
AuditEntry,
|
|
||||||
ConfigMeta,
|
|
||||||
Device,
|
|
||||||
Entity,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
RuleException,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_from_row(row: sqlite3.Row) -> Rule:
|
|
||||||
return Rule(
|
|
||||||
id=int(row["id"]),
|
|
||||||
name=str(row["name"]),
|
|
||||||
kind=str(row["kind"]),
|
|
||||||
match=str(row["match"]),
|
|
||||||
target=str(row["target"]),
|
|
||||||
priority=int(row["priority"]),
|
|
||||||
enabled=bool(row["enabled"]),
|
|
||||||
created_at=str(row["created_at"]),
|
|
||||||
updated_at=str(row["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _exception_from_row(row: sqlite3.Row) -> RuleException:
|
|
||||||
return RuleException(
|
|
||||||
id=int(row["id"]),
|
|
||||||
match=str(row["match"]),
|
|
||||||
reason=str(row["reason"]),
|
|
||||||
enabled=bool(row["enabled"]),
|
|
||||||
created_at=str(row["created_at"]),
|
|
||||||
updated_at=str(row["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _config_from_row(row: sqlite3.Row) -> ConfigMeta:
|
|
||||||
return ConfigMeta(
|
|
||||||
id=int(row["id"]),
|
|
||||||
name=str(row["name"]),
|
|
||||||
path=str(row["path"]),
|
|
||||||
version=int(row["version"]),
|
|
||||||
checksum=str(row["checksum"]),
|
|
||||||
size_bytes=int(row["size_bytes"]),
|
|
||||||
applied=bool(row["applied"]),
|
|
||||||
created_at=str(row["created_at"]),
|
|
||||||
updated_at=str(row["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _device_from_row(row: sqlite3.Row) -> Device:
|
|
||||||
return Device(
|
|
||||||
id=int(row["id"]),
|
|
||||||
mac=str(row["mac"]),
|
|
||||||
name=str(row["name"]),
|
|
||||||
address=str(row["address"]),
|
|
||||||
policy=str(row["policy"]),
|
|
||||||
last_seen=None if row["last_seen"] is None else str(row["last_seen"]),
|
|
||||||
created_at=str(row["created_at"]),
|
|
||||||
updated_at=str(row["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _geoip_from_row(conn: sqlite3.Connection) -> GeoIpSchedule:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT enabled, cron, provider, updated_at FROM geoip_schedule WHERE id = 1"
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return GeoIpSchedule()
|
|
||||||
return GeoIpSchedule(
|
|
||||||
enabled=bool(row["enabled"]),
|
|
||||||
cron=str(row["cron"]),
|
|
||||||
provider=str(row["provider"]),
|
|
||||||
updated_at=str(row["updated_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
|
||||||
"""Доступ к состоянию: чтение + аудируемые мутации."""
|
|
||||||
|
|
||||||
def __init__(self, db: Database) -> None:
|
|
||||||
self._db = db
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database(self) -> Database:
|
|
||||||
return self._db
|
|
||||||
|
|
||||||
# -- чтение ---------------------------------------------------------
|
|
||||||
# Чтения идут через Database.read() под тем же RLock, что транзакции
|
|
||||||
# (#54): это исключает перемешивание операторов разных потоков и грязные
|
|
||||||
# чтения незафиксированных строк.
|
|
||||||
def get_mode(self) -> Mode:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
|
||||||
if row is None:
|
|
||||||
return Mode.OFF
|
|
||||||
return Mode(str(row["mode"]))
|
|
||||||
|
|
||||||
def get_geoip_schedule(self) -> GeoIpSchedule:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
return _geoip_from_row(conn)
|
|
||||||
|
|
||||||
def list_rules(self, *, enabled_only: bool = False) -> list[Rule]:
|
|
||||||
sql = "SELECT * FROM routing_rules"
|
|
||||||
if enabled_only:
|
|
||||||
sql += " WHERE enabled = 1"
|
|
||||||
sql += " ORDER BY priority, id"
|
|
||||||
with self._db.read() as conn:
|
|
||||||
rows = conn.execute(sql).fetchall()
|
|
||||||
return [_rule_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
def get_rule(self, rule_id: int) -> Rule | None:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
|
|
||||||
return None if row is None else _rule_from_row(row)
|
|
||||||
|
|
||||||
def get_rule_by_name(self, name: str) -> Rule | None:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM routing_rules WHERE name = ?", (name,)).fetchone()
|
|
||||||
return None if row is None else _rule_from_row(row)
|
|
||||||
|
|
||||||
def list_exceptions(self) -> list[RuleException]:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
rows = conn.execute("SELECT * FROM rule_exceptions ORDER BY id").fetchall()
|
|
||||||
return [_exception_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
def get_exception(self, match: str) -> RuleException | None:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM rule_exceptions WHERE match = ?", (match,)).fetchone()
|
|
||||||
return None if row is None else _exception_from_row(row)
|
|
||||||
|
|
||||||
def list_configs(self) -> list[ConfigMeta]:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
rows = conn.execute("SELECT * FROM configs ORDER BY name").fetchall()
|
|
||||||
return [_config_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
def get_config(self, name: str) -> ConfigMeta | None:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM configs WHERE name = ?", (name,)).fetchone()
|
|
||||||
return None if row is None else _config_from_row(row)
|
|
||||||
|
|
||||||
def list_devices(self) -> list[Device]:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
rows = conn.execute("SELECT * FROM devices ORDER BY name").fetchall()
|
|
||||||
return [_device_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
def get_device(self, mac: str) -> Device | None:
|
|
||||||
normalized = validation.normalize_mac(mac)
|
|
||||||
with self._db.read() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM devices WHERE mac = ?", (normalized,)).fetchone()
|
|
||||||
return None if row is None else _device_from_row(row)
|
|
||||||
|
|
||||||
def list_audit(
|
|
||||||
self, *, entity: str | None = None, entity_id: str | int | None = None, limit: int = 100
|
|
||||||
) -> list[AuditEntry]:
|
|
||||||
with self._db.read() as conn:
|
|
||||||
return audit.list_entries(conn, entity=entity, entity_id=entity_id, limit=limit)
|
|
||||||
|
|
||||||
# -- мутации: правила ------------------------------------------------
|
|
||||||
def upsert_rule(self, rule: Rule, *, actor: str) -> Rule:
|
|
||||||
"""Создать или обновить правило (по ``id`` либо по уникальному ``name``)."""
|
|
||||||
validation.validate_name("name", rule.name)
|
|
||||||
kind = validation.validate_rule_kind(rule.kind)
|
|
||||||
match = validation.validate_match(kind, rule.match)
|
|
||||||
target = validation.validate_target(rule.target)
|
|
||||||
priority = validation.validate_priority(rule.priority)
|
|
||||||
now = utcnow()
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
existing = self._find_rule_row(conn, rule.id, rule.name)
|
|
||||||
core: tuple[str, str, str, str, int, int] = (
|
|
||||||
rule.name,
|
|
||||||
kind,
|
|
||||||
match,
|
|
||||||
target,
|
|
||||||
priority,
|
|
||||||
1 if rule.enabled else 0,
|
|
||||||
)
|
|
||||||
if existing is None:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
|
|
||||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(*core, now, now),
|
|
||||||
)
|
|
||||||
new_id = int(cursor.lastrowid or 0)
|
|
||||||
saved = replace(
|
|
||||||
rule,
|
|
||||||
id=new_id,
|
|
||||||
kind=kind,
|
|
||||||
match=match,
|
|
||||||
target=target,
|
|
||||||
priority=priority,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="create",
|
|
||||||
entity=Entity.RULE.value,
|
|
||||||
entity_id=new_id,
|
|
||||||
old_value=None,
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rule_id = int(existing["id"])
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE routing_rules SET name = ?, kind = ?, match = ?, target = ?,"
|
|
||||||
" priority = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
|
||||||
(*core, now, rule_id),
|
|
||||||
)
|
|
||||||
saved = replace(
|
|
||||||
rule,
|
|
||||||
id=rule_id,
|
|
||||||
kind=kind,
|
|
||||||
match=match,
|
|
||||||
target=target,
|
|
||||||
priority=priority,
|
|
||||||
created_at=str(existing["created_at"]),
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="update",
|
|
||||||
entity=Entity.RULE.value,
|
|
||||||
entity_id=rule_id,
|
|
||||||
old_value=_rule_from_row(existing).as_dict(),
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
def delete_rule(self, rule_id: int, *, actor: str) -> bool:
|
|
||||||
"""Удалить правило; ``False`` — если правила не было."""
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
row = self._find_rule_row(conn, rule_id, None)
|
|
||||||
if row is None:
|
|
||||||
return False
|
|
||||||
removed = _rule_from_row(row)
|
|
||||||
conn.execute("DELETE FROM routing_rules WHERE id = ?", (int(row["id"]),))
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="delete",
|
|
||||||
entity=Entity.RULE.value,
|
|
||||||
entity_id=int(row["id"]),
|
|
||||||
old_value=removed.as_dict(),
|
|
||||||
new_value=None,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_rule_row(
|
|
||||||
conn: sqlite3.Connection, rule_id: int | None, name: str | None
|
|
||||||
) -> sqlite3.Row | None:
|
|
||||||
if rule_id is not None:
|
|
||||||
row = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
|
|
||||||
return cast("sqlite3.Row | None", row)
|
|
||||||
if name is not None:
|
|
||||||
row = conn.execute("SELECT * FROM routing_rules WHERE name = ?", (name,)).fetchone()
|
|
||||||
return cast("sqlite3.Row | None", row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# -- мутации: исключения ---------------------------------------------
|
|
||||||
def upsert_exception(self, exception: RuleException, *, actor: str) -> RuleException:
|
|
||||||
match = validation.validate_exception_match(exception.match)
|
|
||||||
now = utcnow()
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT * FROM rule_exceptions WHERE match = ?", (match,)
|
|
||||||
).fetchone()
|
|
||||||
if existing is None:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO rule_exceptions (match, reason, enabled, created_at, updated_at)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(match, exception.reason, 1 if exception.enabled else 0, now, now),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE rule_exceptions SET reason = ?, enabled = ?, updated_at = ?"
|
|
||||||
" WHERE match = ?",
|
|
||||||
(exception.reason, 1 if exception.enabled else 0, now, match),
|
|
||||||
)
|
|
||||||
row = conn.execute("SELECT * FROM rule_exceptions WHERE match = ?", (match,)).fetchone()
|
|
||||||
if row is None: # pragma: no cover - невозможен после успешной записи
|
|
||||||
raise StorageError("правило-исключение исчезло сразу после записи")
|
|
||||||
saved = _exception_from_row(row)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="create" if existing is None else "update",
|
|
||||||
entity=Entity.EXCEPTION.value,
|
|
||||||
entity_id=saved.id,
|
|
||||||
old_value=None if existing is None else _exception_from_row(existing).as_dict(),
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
def delete_exception(self, exception_id: int, *, actor: str) -> bool:
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM rule_exceptions WHERE id = ?", (exception_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return False
|
|
||||||
removed = _exception_from_row(row)
|
|
||||||
conn.execute("DELETE FROM rule_exceptions WHERE id = ?", (exception_id,))
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="delete",
|
|
||||||
entity=Entity.EXCEPTION.value,
|
|
||||||
entity_id=exception_id,
|
|
||||||
old_value=removed.as_dict(),
|
|
||||||
new_value=None,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# -- мутации: режим и GeoIP ------------------------------------------
|
|
||||||
def set_mode(self, mode: str | Mode, *, actor: str) -> Mode:
|
|
||||||
"""Установить режим маршрутизации; в журнал пишется old→new."""
|
|
||||||
validated = validation.validate_mode(mode)
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
previous = conn.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
|
||||||
old = "off" if previous is None else str(previous["mode"])
|
|
||||||
now = utcnow()
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO routing_mode (id, mode, updated_at) VALUES (1, ?, ?)"
|
|
||||||
" ON CONFLICT(id) DO UPDATE SET mode = excluded.mode,"
|
|
||||||
" updated_at = excluded.updated_at",
|
|
||||||
(validated.value, now),
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="set_mode",
|
|
||||||
entity=Entity.MODE.value,
|
|
||||||
entity_id=1,
|
|
||||||
old_value={"mode": old},
|
|
||||||
new_value={"mode": validated.value},
|
|
||||||
)
|
|
||||||
return validated
|
|
||||||
|
|
||||||
def set_geoip_schedule(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
enabled: bool | None = None,
|
|
||||||
cron: str | None = None,
|
|
||||||
provider: str | None = None,
|
|
||||||
actor: str,
|
|
||||||
) -> GeoIpSchedule:
|
|
||||||
"""Обновить расписание GeoIP (частичное обновление) и записать аудит."""
|
|
||||||
now = utcnow()
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
# чтение old_value — внутри той же транзакции, что и запись (#55 D-2):
|
|
||||||
# иначе при конкурентном обновлении журнал заявит устаревшее «старое».
|
|
||||||
current = _geoip_from_row(conn)
|
|
||||||
stored = GeoIpSchedule(
|
|
||||||
enabled=current.enabled if enabled is None else enabled,
|
|
||||||
cron=validation.validate_cron(current.cron if cron is None else cron),
|
|
||||||
provider=validation.validate_provider(
|
|
||||||
current.provider if provider is None else provider
|
|
||||||
),
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO geoip_schedule (id, enabled, cron, provider, updated_at)"
|
|
||||||
" VALUES (1, ?, ?, ?, ?)"
|
|
||||||
" ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, cron = excluded.cron,"
|
|
||||||
" provider = excluded.provider, updated_at = excluded.updated_at",
|
|
||||||
(1 if stored.enabled else 0, stored.cron, stored.provider, now),
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="set_geoip_schedule",
|
|
||||||
entity=Entity.GEOIP_SCHEDULE.value,
|
|
||||||
entity_id=1,
|
|
||||||
old_value=current.as_dict(),
|
|
||||||
new_value=stored.as_dict(),
|
|
||||||
)
|
|
||||||
return stored
|
|
||||||
|
|
||||||
# -- мутации: конфиги -------------------------------------------------
|
|
||||||
def upsert_config(self, config: ConfigMeta, *, actor: str) -> ConfigMeta:
|
|
||||||
name = validation.validate_name("name", config.name)
|
|
||||||
path = validation.validate_config_path(config.path)
|
|
||||||
now = utcnow()
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
existing = conn.execute("SELECT * FROM configs WHERE name = ?", (name,)).fetchone()
|
|
||||||
if existing is None:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO configs (name, version, path, checksum, size_bytes, applied,"
|
|
||||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
name,
|
|
||||||
config.version,
|
|
||||||
path,
|
|
||||||
config.checksum,
|
|
||||||
config.size_bytes,
|
|
||||||
1 if config.applied else 0,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
config_id = int(cursor.lastrowid or 0)
|
|
||||||
saved = replace(
|
|
||||||
config, id=config_id, name=name, path=path, created_at=now, updated_at=now
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="create",
|
|
||||||
entity=Entity.CONFIG.value,
|
|
||||||
entity_id=config_id,
|
|
||||||
old_value=None,
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
config_id = int(existing["id"])
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE configs SET version = ?, path = ?, checksum = ?, size_bytes = ?,"
|
|
||||||
" applied = ?, updated_at = ? WHERE id = ?",
|
|
||||||
(
|
|
||||||
config.version,
|
|
||||||
path,
|
|
||||||
config.checksum,
|
|
||||||
config.size_bytes,
|
|
||||||
1 if config.applied else 0,
|
|
||||||
now,
|
|
||||||
config_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
saved = replace(
|
|
||||||
config,
|
|
||||||
id=config_id,
|
|
||||||
name=name,
|
|
||||||
path=path,
|
|
||||||
created_at=str(existing["created_at"]),
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="update",
|
|
||||||
entity=Entity.CONFIG.value,
|
|
||||||
entity_id=config_id,
|
|
||||||
old_value=_config_from_row(existing).as_dict(),
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
def mark_config_applied(self, name: str, *, applied: bool, actor: str) -> ConfigMeta:
|
|
||||||
"""Отметить конфиг применённым/снятым и записать аудит."""
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM configs WHERE name = ?", (name,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise StorageError(f"конфиг {name!r} не найден")
|
|
||||||
now = utcnow()
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE configs SET applied = ?, updated_at = ? WHERE id = ?",
|
|
||||||
(1 if applied else 0, now, int(row["id"])),
|
|
||||||
)
|
|
||||||
updated = replace(_config_from_row(row), applied=applied, updated_at=now)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="set_applied",
|
|
||||||
entity=Entity.CONFIG.value,
|
|
||||||
entity_id=int(row["id"]),
|
|
||||||
old_value=_config_from_row(row).as_dict(),
|
|
||||||
new_value=updated.as_dict(),
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
# -- мутации: устройства ----------------------------------------------
|
|
||||||
def register_device(self, device: Device, *, actor: str) -> Device:
|
|
||||||
mac = validation.normalize_mac(device.mac)
|
|
||||||
name = validation.validate_name("name", device.name)
|
|
||||||
policy = validation.validate_device_policy(device.policy)
|
|
||||||
now = utcnow()
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
existing = conn.execute("SELECT * FROM devices WHERE mac = ?", (mac,)).fetchone()
|
|
||||||
if existing is None:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO devices (mac, name, address, policy, last_seen, created_at,"
|
|
||||||
" updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(mac, name, device.address, policy, device.last_seen, now, now),
|
|
||||||
)
|
|
||||||
device_id = int(cursor.lastrowid or 0)
|
|
||||||
saved = replace(
|
|
||||||
device,
|
|
||||||
id=device_id,
|
|
||||||
mac=mac,
|
|
||||||
name=name,
|
|
||||||
policy=policy,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="create",
|
|
||||||
entity=Entity.DEVICE.value,
|
|
||||||
entity_id=device_id,
|
|
||||||
old_value=None,
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
device_id = int(existing["id"])
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE devices SET name = ?, address = ?, policy = ?, last_seen = ?,"
|
|
||||||
" updated_at = ? WHERE id = ?",
|
|
||||||
(name, device.address, policy, device.last_seen, now, device_id),
|
|
||||||
)
|
|
||||||
saved = replace(
|
|
||||||
device,
|
|
||||||
id=device_id,
|
|
||||||
mac=mac,
|
|
||||||
name=name,
|
|
||||||
policy=policy,
|
|
||||||
created_at=str(existing["created_at"]),
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="update",
|
|
||||||
entity=Entity.DEVICE.value,
|
|
||||||
entity_id=device_id,
|
|
||||||
old_value=_device_from_row(existing).as_dict(),
|
|
||||||
new_value=saved.as_dict(),
|
|
||||||
)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
def delete_device(self, mac: str, *, actor: str) -> bool:
|
|
||||||
normalized = validation.normalize_mac(mac)
|
|
||||||
with self._db.transaction() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM devices WHERE mac = ?", (normalized,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return False
|
|
||||||
removed = _device_from_row(row)
|
|
||||||
conn.execute("DELETE FROM devices WHERE mac = ?", (normalized,))
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="delete",
|
|
||||||
entity=Entity.DEVICE.value,
|
|
||||||
entity_id=removed.id,
|
|
||||||
old_value=removed.as_dict(),
|
|
||||||
new_value=None,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# -- служебное --------------------------------------------------------
|
|
||||||
def counts(self) -> dict[str, int]:
|
|
||||||
"""Количество строк в изменяемых таблицах (для отчётов и тестов)."""
|
|
||||||
tables = {
|
|
||||||
"rules": "routing_rules",
|
|
||||||
"exceptions": "rule_exceptions",
|
|
||||||
"configs": "configs",
|
|
||||||
"devices": "devices",
|
|
||||||
"audit": "audit_log",
|
|
||||||
"snapshots": "state_snapshots",
|
|
||||||
}
|
|
||||||
result: dict[str, int] = {}
|
|
||||||
with self._db.read() as conn: # один снимок на все таблицы: счётчики согласованы
|
|
||||||
for key, table in tables.items():
|
|
||||||
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() # noqa: S608
|
|
||||||
result[key] = int(row[0]) if row is not None else 0
|
|
||||||
return result
|
|
||||||
|
|
||||||
def raw(self) -> sqlite3.Connection:
|
|
||||||
"""Соединение для кода, которому нужен доступ ниже уровня репозитория.
|
|
||||||
|
|
||||||
Лок не берётся: в рантайме используйте :meth:`Database.read` /
|
|
||||||
:meth:`Database.transaction` (#54), иначе возможны гонки на общем
|
|
||||||
соединении. Метод оставлен для однопоточного кода и миграций.
|
|
||||||
"""
|
|
||||||
return self._db.connection
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Точка входа слоя хранения: открыть БД, применить миграции, собрать движок."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from .apply import Applier, ApplyEngine
|
|
||||||
from .db import Database
|
|
||||||
from .repository import Store
|
|
||||||
from .state import StateSnapshot
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Storage:
|
|
||||||
"""Собранный слой хранения для приложения."""
|
|
||||||
|
|
||||||
db: Database
|
|
||||||
store: Store
|
|
||||||
engine: ApplyEngine
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.db.close()
|
|
||||||
|
|
||||||
def __enter__(self) -> Storage:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc_info: object) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
|
|
||||||
def open_storage(
|
|
||||||
path: str | os.PathLike[str] | None = None,
|
|
||||||
*,
|
|
||||||
applier: Applier | None = None,
|
|
||||||
actor: str = "system",
|
|
||||||
auto_migrate: bool = True,
|
|
||||||
) -> Storage:
|
|
||||||
"""Открыть хранилище: на пустом ``/data`` схема поднимается автоматически."""
|
|
||||||
db = Database(path, auto_migrate=auto_migrate)
|
|
||||||
store = Store(db)
|
|
||||||
return Storage(db=db, store=store, engine=ApplyEngine(store, applier, actor=actor))
|
|
||||||
|
|
||||||
|
|
||||||
def rollback_to(engine: ApplyEngine, plan_id: int, *, reason: str = "") -> StateSnapshot:
|
|
||||||
"""Явный откат состояния к снимку плана (тонкая обёртка для API)."""
|
|
||||||
return engine.rollback(plan_id, reason=reason)
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Снимки состояния и восстановление после отката (rollback, issue #20).
|
|
||||||
|
|
||||||
Снимок — это JSON-копия состояния (режим, расписание GeoIP, правила,
|
|
||||||
исключения) на момент перед применением. Снимок сохраняется ДО мутации,
|
|
||||||
поэтому даже при неудачном apply (внешний iptables упал) есть источник для
|
|
||||||
восстановления.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import audit
|
|
||||||
from .clock import utcnow
|
|
||||||
from .errors import StorageError
|
|
||||||
from .models import Entity
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StateSnapshot:
|
|
||||||
"""Сохранённый снимок состояния."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
entity: str
|
|
||||||
entity_id: str
|
|
||||||
created_at: str
|
|
||||||
payload: dict[str, Any]
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
def capture_state(conn: sqlite3.Connection, *, include_exceptions: bool = True) -> dict[str, Any]:
|
|
||||||
"""Снять текущее состояние в виде JSON-совместимого словаря."""
|
|
||||||
mode_row = conn.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
|
||||||
geoip_row = conn.execute(
|
|
||||||
"SELECT enabled, cron, provider FROM geoip_schedule WHERE id = 1"
|
|
||||||
).fetchone()
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
"name": str(row["name"]),
|
|
||||||
"kind": str(row["kind"]),
|
|
||||||
"match": str(row["match"]),
|
|
||||||
"target": str(row["target"]),
|
|
||||||
"priority": int(row["priority"]),
|
|
||||||
"enabled": bool(row["enabled"]),
|
|
||||||
}
|
|
||||||
for row in conn.execute(
|
|
||||||
"SELECT name, kind, match, target, priority, enabled FROM routing_rules"
|
|
||||||
" ORDER BY priority, id"
|
|
||||||
).fetchall()
|
|
||||||
]
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"mode": "off" if mode_row is None else str(mode_row["mode"]),
|
|
||||||
"geoip": {
|
|
||||||
"enabled": True if geoip_row is None else bool(geoip_row["enabled"]),
|
|
||||||
"cron": "0 4 * * *" if geoip_row is None else str(geoip_row["cron"]),
|
|
||||||
"provider": "ripe" if geoip_row is None else str(geoip_row["provider"]),
|
|
||||||
},
|
|
||||||
"rules": rules,
|
|
||||||
}
|
|
||||||
if include_exceptions:
|
|
||||||
payload["exceptions"] = [
|
|
||||||
{
|
|
||||||
"match": str(row["match"]),
|
|
||||||
"reason": str(row["reason"]),
|
|
||||||
"enabled": bool(row["enabled"]),
|
|
||||||
}
|
|
||||||
for row in conn.execute(
|
|
||||||
"SELECT match, reason, enabled FROM rule_exceptions ORDER BY id"
|
|
||||||
).fetchall()
|
|
||||||
]
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def save_snapshot(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
entity: str = Entity.APPLY.value,
|
|
||||||
entity_id: str = "state",
|
|
||||||
reason: str = "",
|
|
||||||
) -> int:
|
|
||||||
"""Сохранить снимок состояния и вернуть идентификатор."""
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO state_snapshots (entity, entity_id, created_at, payload, reason)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(entity, entity_id, utcnow(), json.dumps(payload, sort_keys=True), reason),
|
|
||||||
)
|
|
||||||
return int(cursor.lastrowid or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def load_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> StateSnapshot:
|
|
||||||
"""Прочитать снимок по id."""
|
|
||||||
row = conn.execute("SELECT * FROM state_snapshots WHERE id = ?", (snapshot_id,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise StorageError(f"снимок состояния {snapshot_id} не найден")
|
|
||||||
return StateSnapshot(
|
|
||||||
id=int(row["id"]),
|
|
||||||
entity=str(row["entity"]),
|
|
||||||
entity_id=str(row["entity_id"]),
|
|
||||||
created_at=str(row["created_at"]),
|
|
||||||
payload=json.loads(str(row["payload"])),
|
|
||||||
reason=str(row["reason"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def restore_state(conn: sqlite3.Connection, payload: dict[str, Any]) -> None:
|
|
||||||
"""Полностью заменить изменяемые таблицы состоянием из снимка."""
|
|
||||||
conn.execute("DELETE FROM routing_rules")
|
|
||||||
for rule in payload.get("rules", []):
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
|
|
||||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
rule["name"],
|
|
||||||
rule["kind"],
|
|
||||||
rule["match"],
|
|
||||||
rule["target"],
|
|
||||||
int(rule["priority"]),
|
|
||||||
1 if rule["enabled"] else 0,
|
|
||||||
utcnow(),
|
|
||||||
utcnow(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if "exceptions" in payload:
|
|
||||||
conn.execute("DELETE FROM rule_exceptions")
|
|
||||||
for exception in payload["exceptions"]:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO rule_exceptions (match, reason, enabled, created_at, updated_at)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
exception["match"],
|
|
||||||
exception.get("reason", ""),
|
|
||||||
1 if exception["enabled"] else 0,
|
|
||||||
utcnow(),
|
|
||||||
utcnow(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
geoip = payload.get("geoip", {})
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE routing_mode SET mode = ?, updated_at = ? WHERE id = 1",
|
|
||||||
(payload.get("mode", "off"), utcnow()),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE geoip_schedule SET enabled = ?, cron = ?, provider = ?,"
|
|
||||||
" updated_at = ? WHERE id = 1",
|
|
||||||
(
|
|
||||||
1 if geoip.get("enabled", True) else 0,
|
|
||||||
geoip.get("cron", "0 4 * * *"),
|
|
||||||
geoip.get("provider", "ripe"),
|
|
||||||
utcnow(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def rollback_to_snapshot(
|
|
||||||
conn: sqlite3.Connection, snapshot_id: int, *, actor: str, reason: str = ""
|
|
||||||
) -> StateSnapshot:
|
|
||||||
"""Восстановить состояние из снимка и записать это в журнал аудита."""
|
|
||||||
snapshot = load_snapshot(conn, snapshot_id)
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor=actor,
|
|
||||||
action="rollback",
|
|
||||||
entity=snapshot.entity,
|
|
||||||
entity_id=snapshot.entity_id,
|
|
||||||
old_value=capture_state(conn),
|
|
||||||
new_value=snapshot.payload,
|
|
||||||
detail=reason or f"rollback к снимку #{snapshot.id}",
|
|
||||||
)
|
|
||||||
restore_state(conn, snapshot.payload)
|
|
||||||
return snapshot
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Чистые функции валидации состояния (стадия ``validate`` перед apply, issue #20).
|
|
||||||
|
|
||||||
Валидация отделена от БД: те же функции вызывают и репозиторий (точечные
|
|
||||||
мутации), и движок атомарного применения (``validate -> stage -> apply``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import re
|
|
||||||
|
|
||||||
from .errors import ValidationError
|
|
||||||
from .models import DEVICE_POLICIES, RULE_KINDS, Mode
|
|
||||||
|
|
||||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,62}$")
|
|
||||||
_DOMAIN_RE = re.compile(r"^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
|
|
||||||
_IPSET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$")
|
|
||||||
_MAC_RE = re.compile(r"^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$")
|
|
||||||
_CRON_FIELD_RE = re.compile(r"^[0-9*,/-]{1,20}$")
|
|
||||||
_PROVIDER_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,31}$")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_mac(mac: str) -> str:
|
|
||||||
"""Привести MAC к канонической форме ``aa:bb:cc:dd:ee:ff``."""
|
|
||||||
cleaned = mac.strip().replace("-", ":").lower()
|
|
||||||
if len(cleaned) == 12 and ":" not in cleaned:
|
|
||||||
cleaned = ":".join(cleaned[i : i + 2] for i in range(0, 12, 2))
|
|
||||||
if not _MAC_RE.match(cleaned):
|
|
||||||
raise ValidationError("mac", f"некорректный MAC-адрес: {mac!r}")
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
def validate_name(field: str, value: str) -> str:
|
|
||||||
"""Проверить идентификатор (имя правила/конфига устройства)."""
|
|
||||||
if not _NAME_RE.match(value.strip()):
|
|
||||||
raise ValidationError(
|
|
||||||
field, f"допустимы строчные латинские буквы, цифры и «_.:-», до 63 символов: {value!r}"
|
|
||||||
)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_rule_kind(kind: str) -> str:
|
|
||||||
if kind not in RULE_KINDS:
|
|
||||||
raise ValidationError("kind", f"ожидается одно из {RULE_KINDS}, получено {kind!r}")
|
|
||||||
return kind
|
|
||||||
|
|
||||||
|
|
||||||
def validate_match(kind: str, match: str) -> str:
|
|
||||||
"""Проверить образец правила по типу записи."""
|
|
||||||
validate_rule_kind(kind)
|
|
||||||
value = match.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValidationError("match", "образец правила не может быть пустым")
|
|
||||||
if kind == "domain":
|
|
||||||
if not _DOMAIN_RE.match(value):
|
|
||||||
raise ValidationError("match", f"некорректный домен: {value!r}")
|
|
||||||
elif kind == "cidr":
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(value, strict=False)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError("match", f"некорректная сеть CIDR: {value!r}") from exc
|
|
||||||
elif not _IPSET_RE.match(value):
|
|
||||||
raise ValidationError("match", f"некорректное имя ipset: {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_exception_match(match: str) -> str:
|
|
||||||
"""Проверить образец исключения: домен, имя ipset или сеть CIDR."""
|
|
||||||
value = match.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValidationError("match", "образец исключения не может быть пустым")
|
|
||||||
if _DOMAIN_RE.match(value) or _IPSET_RE.match(value):
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(value, strict=False)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(
|
|
||||||
"match", f"ожидается домен, сеть CIDR или имя ipset: {match!r}"
|
|
||||||
) from exc
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_target(target: str) -> str:
|
|
||||||
value = target.strip()
|
|
||||||
if not _IPSET_RE.match(value):
|
|
||||||
raise ValidationError("target", f"некорректная цель маршрутизации: {target!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_priority(priority: int) -> int:
|
|
||||||
if not isinstance(priority, int) or isinstance(priority, bool):
|
|
||||||
raise ValidationError("priority", f"ожидается целое число, получено {priority!r}")
|
|
||||||
if not 0 <= priority <= 65535:
|
|
||||||
raise ValidationError("priority", f"вне диапазона 0..65535: {priority}")
|
|
||||||
return priority
|
|
||||||
|
|
||||||
|
|
||||||
def validate_mode(mode: str | Mode) -> Mode:
|
|
||||||
try:
|
|
||||||
return Mode(mode)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(
|
|
||||||
"mode", f"ожидается одно из {Mode.values()}, получено {mode!r}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def validate_cron(cron: str) -> str:
|
|
||||||
fields = cron.split()
|
|
||||||
if len(fields) != 5 or not all(_CRON_FIELD_RE.match(part) for part in fields):
|
|
||||||
raise ValidationError("cron", f"ожидается 5-полевое cron-выражение: {cron!r}")
|
|
||||||
return " ".join(fields)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_provider(provider: str) -> str:
|
|
||||||
value = provider.strip().lower()
|
|
||||||
if not _PROVIDER_RE.match(value):
|
|
||||||
raise ValidationError("provider", f"некорректное имя провайдера GeoIP: {provider!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_device_policy(policy: str) -> str:
|
|
||||||
if policy not in DEVICE_POLICIES:
|
|
||||||
raise ValidationError("policy", f"ожидается одно из {DEVICE_POLICIES}, получено {policy!r}")
|
|
||||||
return policy
|
|
||||||
|
|
||||||
|
|
||||||
def validate_config_path(path: str) -> str:
|
|
||||||
value = path.strip()
|
|
||||||
if not value.startswith("/") or ".." in value.split("/"):
|
|
||||||
raise ValidationError("path", f"ожидается абсолютный путь без «..»: {path!r}")
|
|
||||||
return value
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""WebSocket-поток метрик."""
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""WebSocket-поток метрик: GET /api/v1/metrics/stream."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
||||||
|
|
||||||
from awg_api.deps import SESSION_COOKIE_NAME, token_matches
|
|
||||||
from awg_api.state import MockState
|
|
||||||
|
|
||||||
router = APIRouter(tags=["metrics"])
|
|
||||||
|
|
||||||
WS_ACCESS_DENIED = 4401
|
|
||||||
MIN_INTERVAL_MS = 250
|
|
||||||
|
|
||||||
|
|
||||||
def _authorized(websocket: WebSocket, token: str | None) -> bool:
|
|
||||||
expected: str = websocket.app.state.api_token
|
|
||||||
header = websocket.headers.get("authorization", "")
|
|
||||||
scheme, _, credentials = header.partition(" ")
|
|
||||||
if scheme.lower() == "bearer" and token_matches(credentials.strip(), expected):
|
|
||||||
return True
|
|
||||||
if token_matches(token, expected):
|
|
||||||
return True
|
|
||||||
session_id = websocket.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
state: MockState = websocket.app.state.mock_state
|
|
||||||
return token_matches(session_id, state.session_token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/api/v1/metrics/stream")
|
|
||||||
async def stream_metrics(
|
|
||||||
websocket: WebSocket, token: str | None = None, interval_ms: int = 1000
|
|
||||||
) -> None:
|
|
||||||
"""Отправляет кадры ``WsFrame``: метрики до отключения клиента."""
|
|
||||||
if not _authorized(websocket, token):
|
|
||||||
await websocket.close(code=WS_ACCESS_DENIED)
|
|
||||||
return
|
|
||||||
state: MockState = websocket.app.state.mock_state
|
|
||||||
await websocket.accept()
|
|
||||||
interval_s = max(MIN_INTERVAL_MS, interval_ms) / 1000
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await websocket.send_json(state.metrics_frame())
|
|
||||||
await asyncio.sleep(interval_s)
|
|
||||||
except (WebSocketDisconnect, RuntimeError):
|
|
||||||
return
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Общие фикстуры контрактных тестов."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from awg_api.app import create_app
|
|
||||||
from awg_api.contract import load_spec
|
|
||||||
from awg_api.state import MockState
|
|
||||||
|
|
||||||
TEST_TOKEN = "test-token-contract"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def spec() -> dict[str, Any]:
|
|
||||||
"""Спецификация контракта (docs/openapi.yaml)."""
|
|
||||||
return load_spec()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def state() -> MockState:
|
|
||||||
return MockState()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app(state: MockState) -> FastAPI:
|
|
||||||
return create_app(state=state, token=TEST_TOKEN)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app: FastAPI) -> Iterator[TestClient]:
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_headers() -> dict[str, str]:
|
|
||||||
return {"Authorization": f"Bearer {TEST_TOKEN}"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def token() -> str:
|
|
||||||
return TEST_TOKEN
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""Негативные проверки контрактного теста: отклонение реализации обязано его ронять.
|
|
||||||
|
|
||||||
Эти тесты — доказательство AC «GIVEN реализованный эндпоинт с отклонением от схемы,
|
|
||||||
WHEN запущен контрактный тест, THEN CI падает с указанием расхождения»: они подменяют
|
|
||||||
реализацию на заведомо расходящуюся и проверяют, что тот же самый валидатор её ловит.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from awg_api.app import create_app
|
|
||||||
from awg_api.contract import ContractViolationError, assert_routes_match, validate_response
|
|
||||||
from awg_api.state import PRIVATE_KEY_SENTINEL, MockState
|
|
||||||
|
|
||||||
|
|
||||||
class DeviatingStatusState(MockState):
|
|
||||||
"""Реализация, «забывшая» контракт: неверный тип и лишнее поле."""
|
|
||||||
|
|
||||||
def get_status(self) -> dict[str, Any]:
|
|
||||||
payload = super().get_status()
|
|
||||||
payload["tunnel"]["rx_bytes"] = "много" # намеренно неверный тип вместо integer
|
|
||||||
payload["extra_field"] = True # additionalProperties: false
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class LeakingConfigState(MockState):
|
|
||||||
"""Реализация, возвращающая приватный ключ в ответе."""
|
|
||||||
|
|
||||||
def get_config(self) -> dict[str, Any]:
|
|
||||||
payload = super().get_config()
|
|
||||||
payload["tunnel"]["private_key"] = PRIVATE_KEY_SENTINEL
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class DroppingFieldState(MockState):
|
|
||||||
"""Реализация, потерявшая обязательное поле."""
|
|
||||||
|
|
||||||
def get_config(self) -> dict[str, Any]:
|
|
||||||
payload = super().get_config()
|
|
||||||
del payload["routing"]["kill_switch"]
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _client_for(state: MockState, token: str) -> TestClient:
|
|
||||||
return TestClient(create_app(state=state, token=token))
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_implementation_passes(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
||||||
"""Контроль ложных срабатываний: корректная реализация проходит без замечаний."""
|
|
||||||
response = client.get("/api/v1/status", headers=auth_headers)
|
|
||||||
validate_response(spec, "/api/v1/status", "get", 200, response.json())
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_type_and_extra_field_are_caught(spec: dict[str, Any], token: str) -> None:
|
|
||||||
with _client_for(DeviatingStatusState(), token) as client:
|
|
||||||
response = client.get("/api/v1/status", headers={"Authorization": f"Bearer {token}"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
validate_response(spec, "/api/v1/status", "get", 200, response.json())
|
|
||||||
message = str(excinfo.value)
|
|
||||||
assert "tunnel/rx_bytes" in message
|
|
||||||
assert "extra_field" in message
|
|
||||||
|
|
||||||
|
|
||||||
def test_private_key_in_response_is_caught(spec: dict[str, Any], token: str) -> None:
|
|
||||||
with _client_for(LeakingConfigState(), token) as client:
|
|
||||||
response = client.get("/api/v1/config", headers={"Authorization": f"Bearer {token}"})
|
|
||||||
assert PRIVATE_KEY_SENTINEL in response.text, "подготовка теста: ключ действительно утёк"
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
validate_response(spec, "/api/v1/config", "get", 200, response.json())
|
|
||||||
assert "private_key" in str(excinfo.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_required_field_is_caught(spec: dict[str, Any], token: str) -> None:
|
|
||||||
with _client_for(DroppingFieldState(), token) as client:
|
|
||||||
response = client.get("/api/v1/config", headers={"Authorization": f"Bearer {token}"})
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
validate_response(spec, "/api/v1/config", "get", 200, response.json())
|
|
||||||
assert "kill_switch" in str(excinfo.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_undocumented_route_is_caught(spec: dict[str, Any], token: str) -> None:
|
|
||||||
app = create_app(token=token)
|
|
||||||
|
|
||||||
@app.get("/api/v1/undocumented")
|
|
||||||
async def undocumented(request: Request) -> dict[str, bool]:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
assert_routes_match(spec, app)
|
|
||||||
assert "нет в контракте" in str(excinfo.value)
|
|
||||||
assert "/api/v1/undocumented" in str(excinfo.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_route_is_caught(spec: dict[str, Any]) -> None:
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
assert_routes_match(spec, FastAPI())
|
|
||||||
assert "нет в реализации" in str(excinfo.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_undeclared_status_code_is_caught(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
||||||
response = client.get("/api/v1/status", headers=auth_headers)
|
|
||||||
with pytest.raises(ContractViolationError) as excinfo:
|
|
||||||
validate_response(spec, "/api/v1/status", "get", 202, response.json())
|
|
||||||
assert "не описан в спецификации" in str(excinfo.value)
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
"""Контрактный тест «схема ↔ реализация»: каждый эндпоинт сверяется со схемой из docs/openapi.yaml.
|
|
||||||
|
|
||||||
Любое расхождение (лишнее поле, неверный тип, отсутствующая обязательная ветка,
|
|
||||||
утечка writeOnly-секрета) — падение с указанием пути до поля.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from awg_api.app import create_app
|
|
||||||
from awg_api.contract import (
|
|
||||||
ContractViolationError,
|
|
||||||
assert_routes_match,
|
|
||||||
response_schema,
|
|
||||||
validator_for,
|
|
||||||
validate_response,
|
|
||||||
)
|
|
||||||
from awg_api.state import PRIVATE_KEY_SENTINEL, PRESHARED_KEY_SENTINEL, MockState
|
|
||||||
|
|
||||||
RULE_ID_PATH = "/api/v1/routing/rules/{rule_id}"
|
|
||||||
LEAKED_KEY = "NEW-PRIVATE-KEY-MUST-NOT-LEAK"
|
|
||||||
SECRET_SENTINELS = (PRIVATE_KEY_SENTINEL, PRESHARED_KEY_SENTINEL, LEAKED_KEY)
|
|
||||||
SECRET_FIELD_NAMES = ("private_key", "preshared_key", "password")
|
|
||||||
|
|
||||||
TUNNEL_PAYLOAD: dict[str, Any] = {
|
|
||||||
"address": "10.8.0.9/32",
|
|
||||||
"listen_port": 51821,
|
|
||||||
"mtu": 1420,
|
|
||||||
"private_key": LEAKED_KEY,
|
|
||||||
"dns": ["10.8.0.1"],
|
|
||||||
}
|
|
||||||
ROUTING_PAYLOAD: dict[str, Any] = {
|
|
||||||
"mode": "all-vpn",
|
|
||||||
"kill_switch": True,
|
|
||||||
"mtu_clamping": True,
|
|
||||||
"ipv6": "block",
|
|
||||||
"geoip": {"source": "cache", "prefix_count": 11412, "updated_at": None},
|
|
||||||
}
|
|
||||||
RULE_PAYLOAD: dict[str, Any] = {
|
|
||||||
"name": "Правило контрактного теста",
|
|
||||||
"kind": "domain",
|
|
||||||
"value": "contract.example.net",
|
|
||||||
"action": "vpn",
|
|
||||||
}
|
|
||||||
IMPORT_PAYLOAD: dict[str, Any] = {
|
|
||||||
"format": "amneziawg-conf",
|
|
||||||
"content": "[Peer]\nPublicKey = abc\nPresharedKey = def\n",
|
|
||||||
}
|
|
||||||
|
|
||||||
# (метод, путь, тело) — успешный путь для каждой операции контракта.
|
|
||||||
HAPPY_PATHS: tuple[tuple[str, str, dict[str, Any] | None], ...] = (
|
|
||||||
("get", "/api/v1/health", None),
|
|
||||||
("get", "/api/v1/ready", None),
|
|
||||||
("get", "/metrics", None),
|
|
||||||
("get", "/api/v1/status", None),
|
|
||||||
("get", "/api/v1/config", None),
|
|
||||||
("put", "/api/v1/config", {"tunnel": TUNNEL_PAYLOAD}),
|
|
||||||
("post", "/api/v1/config/validate", {"tunnel": TUNNEL_PAYLOAD}),
|
|
||||||
("get", "/api/v1/routing/rules", None),
|
|
||||||
("post", "/api/v1/routing/rules", RULE_PAYLOAD),
|
|
||||||
("put", "/api/v1/routing/mode", {"mode": "dns"}),
|
|
||||||
("post", "/api/v1/routing/geoip/update", {"force": True}),
|
|
||||||
("post", "/api/v1/routing/import", IMPORT_PAYLOAD),
|
|
||||||
("get", "/api/v1/devices", None),
|
|
||||||
("get", "/api/v1/logs", None),
|
|
||||||
("get", "/api/v1/auth/session", None),
|
|
||||||
("post", "/api/v1/auth/logout", None),
|
|
||||||
("post", "/api/v1/auth/login", {"username": "admin", "password": "awg-dash-dev-password"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
EXPECTED_STATUS: dict[tuple[str, str], int] = {("post", "/api/v1/routing/rules"): 201}
|
|
||||||
|
|
||||||
|
|
||||||
def _check(spec: dict[str, Any], method: str, path: str, response: Any) -> None:
|
|
||||||
"""Валидирует ответ против схемы контракта (JSON — как объект, иначе — как текст)."""
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
payload = response.json() if content_type.startswith("application/json") else response.text
|
|
||||||
validate_response(spec, path, method, response.status_code, payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_spec_operation_is_implemented(spec: dict[str, Any], app: Any) -> None:
|
|
||||||
assert_routes_match(spec, app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_json_serves_the_contract(client: TestClient, spec: dict[str, Any]) -> None:
|
|
||||||
response = client.get("/openapi.json")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == spec
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("method", "path", "body"), HAPPY_PATHS)
|
|
||||||
def test_success_response_matches_spec(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
auth_headers: dict[str, str],
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict[str, Any] | None,
|
|
||||||
) -> None:
|
|
||||||
response = client.request(method.upper(), path, json=body, headers=auth_headers)
|
|
||||||
expected = EXPECTED_STATUS.get((method, path), 200)
|
|
||||||
assert response.status_code == expected, f"{method.upper()} {path}: {response.status_code} {response.text}"
|
|
||||||
_check(spec, method, path, response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule_crud_cycle_matches_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
||||||
created = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers)
|
|
||||||
assert created.status_code == 201, created.text
|
|
||||||
_check(spec, "post", "/api/v1/routing/rules", created)
|
|
||||||
rule_id = created.json()["id"]
|
|
||||||
|
|
||||||
fetched = client.get(f"/api/v1/routing/rules/{rule_id}", headers=auth_headers)
|
|
||||||
assert fetched.status_code == 200
|
|
||||||
_check(spec, "get", RULE_ID_PATH, fetched)
|
|
||||||
|
|
||||||
updated = client.put(f"/api/v1/routing/rules/{rule_id}", json={"enabled": False}, headers=auth_headers)
|
|
||||||
assert updated.status_code == 200
|
|
||||||
assert updated.json()["enabled"] is False
|
|
||||||
_check(spec, "put", RULE_ID_PATH, updated)
|
|
||||||
|
|
||||||
deleted = client.delete(f"/api/v1/routing/rules/{rule_id}", headers=auth_headers)
|
|
||||||
assert deleted.status_code == 204
|
|
||||||
assert deleted.content == b""
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_list_filters_match_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
||||||
response = client.get("/api/v1/routing/rules", params={"enabled": "true", "kind": "cidr"}, headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert all(rule["kind"] == "cidr" and rule["enabled"] for rule in payload["items"])
|
|
||||||
_check(spec, "get", "/api/v1/routing/rules", response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_pagination_matches_spec(spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
||||||
first = client.get("/api/v1/logs", params={"limit": 2}, headers=auth_headers)
|
|
||||||
assert first.status_code == 200
|
|
||||||
payload = first.json()
|
|
||||||
assert len(payload["items"]) == 2
|
|
||||||
assert payload["next_cursor"] is not None
|
|
||||||
_check(spec, "get", "/api/v1/logs", first)
|
|
||||||
|
|
||||||
second = client.get(
|
|
||||||
"/api/v1/logs",
|
|
||||||
params={"limit": 2, "cursor": payload["next_cursor"], "level": "info"},
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert second.status_code == 200
|
|
||||||
assert all(entry["level"] == "info" for entry in second.json()["items"])
|
|
||||||
_check(spec, "get", "/api/v1/logs", second)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("method", "path", "body", "expected_status", "expected_code"),
|
|
||||||
(
|
|
||||||
("get", "/api/v1/status", None, 401, "unauthorized"),
|
|
||||||
("get", "/api/v1/config", None, 401, "unauthorized"),
|
|
||||||
("get", "/api/v1/routing/rules", None, 401, "unauthorized"),
|
|
||||||
("get", "/api/v1/devices", None, 401, "unauthorized"),
|
|
||||||
("get", "/api/v1/logs", None, 401, "unauthorized"),
|
|
||||||
("post", "/api/v1/routing/rules", {"name": "x", "kind": "domain", "value": "x", "action": "vpn"}, 401, "unauthorized"),
|
|
||||||
(
|
|
||||||
"put",
|
|
||||||
"/api/v1/config",
|
|
||||||
{"tunnel": {**TUNNEL_PAYLOAD, "unknown_field": 1}},
|
|
||||||
422,
|
|
||||||
"validation_failed",
|
|
||||||
),
|
|
||||||
("post", "/api/v1/config/validate", {"tunnel": {"address": "10.8.0.2/32", "listen_port": 99999}}, 422, "validation_failed"),
|
|
||||||
("post", "/api/v1/routing/rules", {"name": "", "kind": "nope", "value": "x"}, 422, "validation_failed"),
|
|
||||||
("put", "/api/v1/routing/mode", {"mode": "turbo"}, 422, "validation_failed"),
|
|
||||||
("post", "/api/v1/auth/login", {"username": "admin", "password": "wrong"}, 401, "unauthorized"),
|
|
||||||
("post", "/api/v1/auth/login", {"username": "", "password": ""}, 422, "validation_failed"),
|
|
||||||
("get", "/api/v1/logs", None, 422, "validation_failed"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_error_response_matches_error_schema(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
auth_headers: dict[str, str],
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict[str, Any] | None,
|
|
||||||
expected_status: int,
|
|
||||||
expected_code: str,
|
|
||||||
) -> None:
|
|
||||||
headers = dict(auth_headers)
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if expected_status == 401 and path != "/api/v1/auth/login":
|
|
||||||
headers = {}
|
|
||||||
if path == "/api/v1/logs" and expected_status == 422:
|
|
||||||
params = {"cursor": "not-a-number"}
|
|
||||||
response = client.request(method.upper(), path, json=body, headers=headers, params=params)
|
|
||||||
assert response.status_code == expected_status, response.text
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["code"] == expected_code
|
|
||||||
assert set(payload) == {"code", "message", "details"}
|
|
||||||
_check(spec, method, path, response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_found_rule_matches_error_schema(
|
|
||||||
spec: dict[str, Any], client: TestClient, auth_headers: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
missing = "00000000-0000-4000-8000-000000000000"
|
|
||||||
url = f"/api/v1/routing/rules/{missing}"
|
|
||||||
responses = [
|
|
||||||
("get", client.get(url, headers=auth_headers)),
|
|
||||||
("put", client.put(url, json={"enabled": True}, headers=auth_headers)),
|
|
||||||
("delete", client.delete(url, headers=auth_headers)),
|
|
||||||
]
|
|
||||||
for method, response in responses:
|
|
||||||
assert response.status_code == 404, response.text
|
|
||||||
assert response.json()["code"] == "not_found"
|
|
||||||
_check(spec, method, RULE_ID_PATH, response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_conflict_on_duplicate_rule_matches_error_schema(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
auth_headers: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
first = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers)
|
|
||||||
assert first.status_code == 201
|
|
||||||
duplicate = client.post("/api/v1/routing/rules", json=RULE_PAYLOAD, headers=auth_headers)
|
|
||||||
assert duplicate.status_code == 409
|
|
||||||
assert duplicate.json()["code"] == "conflict"
|
|
||||||
_check(spec, "post", "/api/v1/routing/rules", duplicate)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ready_503_matches_error_schema(spec: dict[str, Any], token: str) -> None:
|
|
||||||
app = create_app(state=MockState(ready=False), token=token)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
response = client.get("/api/v1/ready")
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert response.json()["code"] == "agent_unavailable"
|
|
||||||
_check(spec, "get", "/api/v1/ready", response)
|
|
||||||
health = client.get("/api/v1/health")
|
|
||||||
assert health.status_code == 200
|
|
||||||
assert health.json()["status"] == "degraded"
|
|
||||||
_check(spec, "get", "/api/v1/health", health)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_endpoint_reports_issues_without_applying(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
auth_headers: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/config/validate",
|
|
||||||
json={"tunnel": {"address": "10.8.0.2", "listen_port": 51820, "mtu": 1500, "private_key": "k"}},
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["valid"] is False
|
|
||||||
assert {issue["code"] for issue in payload["errors"]} == {"invalid_cidr"}
|
|
||||||
assert {issue["severity"] for issue in payload["warnings"]} == {"warning"}
|
|
||||||
_check(spec, "post", "/api/v1/config/validate", response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_endpoint_returns_prometheus_text(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
) -> None:
|
|
||||||
response = client.get("/metrics")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/plain")
|
|
||||||
assert "awg_dash_tunnel_up 1" in response.text
|
|
||||||
_check(spec, "get", "/metrics", response)
|
|
||||||
|
|
||||||
|
|
||||||
def test_responses_contain_no_secret_material(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
client: TestClient,
|
|
||||||
auth_headers: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""AC: в ответах нет приватных ключей — ни значений, ни имён полей."""
|
|
||||||
responses = [
|
|
||||||
client.get("/api/v1/config", headers=auth_headers),
|
|
||||||
client.put("/api/v1/config", json={"tunnel": TUNNEL_PAYLOAD}, headers=auth_headers),
|
|
||||||
client.get("/api/v1/status", headers=auth_headers),
|
|
||||||
client.get("/api/v1/devices", headers=auth_headers),
|
|
||||||
client.get("/api/v1/logs", headers=auth_headers),
|
|
||||||
client.get("/api/v1/auth/session", headers=auth_headers),
|
|
||||||
client.post("/api/v1/auth/logout", headers=auth_headers),
|
|
||||||
]
|
|
||||||
for response in responses:
|
|
||||||
body = response.text
|
|
||||||
for sentinel in SECRET_SENTINELS:
|
|
||||||
assert sentinel not in body, f"утечка секрета {sentinel} в {response.request.url}"
|
|
||||||
for field in SECRET_FIELD_NAMES:
|
|
||||||
assert field not in body, f"поле {field} присутствует в ответе {response.request.url}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_frames_match_spec(spec: dict[str, Any], client: TestClient, token: str) -> None:
|
|
||||||
frame_schema = spec["components"]["schemas"]["WsFrame"]
|
|
||||||
metrics_schema = spec["components"]["schemas"]["MetricsSample"]
|
|
||||||
frame_validator = validator_for(frame_schema, spec)
|
|
||||||
data_validator = validator_for(metrics_schema, spec)
|
|
||||||
with client.websocket_connect(f"/api/v1/metrics/stream?token={token}&interval_ms=250") as websocket:
|
|
||||||
frame = websocket.receive_json()
|
|
||||||
assert list(frame_validator.iter_errors(frame)) == []
|
|
||||||
assert frame["type"] == "metrics"
|
|
||||||
assert list(data_validator.iter_errors(frame["data"])) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_requires_token(client: TestClient) -> None:
|
|
||||||
with pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/metrics/stream") as websocket:
|
|
||||||
websocket.receive_json()
|
|
||||||
|
|
||||||
|
|
||||||
def test_response_schema_lookup_fails_for_unknown_response(spec: dict[str, Any], client: TestClient) -> None:
|
|
||||||
"""Неописанный в контракте ответ — ошибка контракта, а не молчаливый пропуск."""
|
|
||||||
response = client.get("/api/v1/health")
|
|
||||||
with pytest.raises(ContractViolationError):
|
|
||||||
validate_response(spec, "/api/v1/health", "get", 418, response.json())
|
|
||||||
assert response_schema(spec, "/api/v1/health", "get", 418) is None
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
"""Проверки самой спецификации: валидность 3.1, полнота по ТЗ, writeOnly-секреты, единый Error."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from openapi_spec_validator import validate as validate_openapi
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from awg_api.contract import (
|
|
||||||
SECRET_FIELD_NAMES,
|
|
||||||
error_codes,
|
|
||||||
iter_operations,
|
|
||||||
response_schema,
|
|
||||||
validator_for,
|
|
||||||
write_only_fields,
|
|
||||||
)
|
|
||||||
from awg_api.schemas import (
|
|
||||||
AuthLoginRequest,
|
|
||||||
ConfigUpdateRequest,
|
|
||||||
DnsConfigIn,
|
|
||||||
GeoIpUpdateRequest,
|
|
||||||
ImportRequest,
|
|
||||||
ModeUpdateRequest,
|
|
||||||
PeerConfigIn,
|
|
||||||
RoutingConfigIn,
|
|
||||||
RoutingRuleCreate,
|
|
||||||
RoutingRuleUpdate,
|
|
||||||
TunnelConfigIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Операции, требуемые ТЗ/issue #19.
|
|
||||||
REQUIRED_OPERATIONS: tuple[tuple[str, str], ...] = (
|
|
||||||
("/api/v1/status", "get"),
|
|
||||||
("/api/v1/config", "get"),
|
|
||||||
("/api/v1/config", "put"),
|
|
||||||
("/api/v1/config/validate", "post"),
|
|
||||||
("/api/v1/routing/rules", "get"),
|
|
||||||
("/api/v1/routing/rules", "post"),
|
|
||||||
("/api/v1/routing/rules/{rule_id}", "get"),
|
|
||||||
("/api/v1/routing/rules/{rule_id}", "put"),
|
|
||||||
("/api/v1/routing/rules/{rule_id}", "delete"),
|
|
||||||
("/api/v1/routing/mode", "put"),
|
|
||||||
("/api/v1/routing/geoip/update", "post"),
|
|
||||||
("/api/v1/routing/import", "post"),
|
|
||||||
("/api/v1/metrics/stream", "get"),
|
|
||||||
("/api/v1/health", "get"),
|
|
||||||
("/api/v1/ready", "get"),
|
|
||||||
("/metrics", "get"),
|
|
||||||
("/api/v1/devices", "get"),
|
|
||||||
("/api/v1/logs", "get"),
|
|
||||||
("/api/v1/auth/login", "post"),
|
|
||||||
("/api/v1/auth/logout", "post"),
|
|
||||||
("/api/v1/auth/session", "get"),
|
|
||||||
)
|
|
||||||
|
|
||||||
REQUEST_MODEL_CASES: tuple[tuple[str, type[BaseModel]], ...] = (
|
|
||||||
("TunnelConfig", TunnelConfigIn),
|
|
||||||
("PeerConfig", PeerConfigIn),
|
|
||||||
("DnsConfig", DnsConfigIn),
|
|
||||||
("RoutingConfig", RoutingConfigIn),
|
|
||||||
("ConfigUpdateRequest", ConfigUpdateRequest),
|
|
||||||
("RoutingRuleCreate", RoutingRuleCreate),
|
|
||||||
("RoutingRuleUpdate", RoutingRuleUpdate),
|
|
||||||
("ModeUpdateRequest", ModeUpdateRequest),
|
|
||||||
("GeoIpUpdateRequest", GeoIpUpdateRequest),
|
|
||||||
("ImportRequest", ImportRequest),
|
|
||||||
("AuthLoginRequest", AuthLoginRequest),
|
|
||||||
)
|
|
||||||
|
|
||||||
STATUS_OK_RANGE = range(200, 300)
|
|
||||||
# 101 Switching Protocols — WebSocket-апгрейд, не ошибка и не JSON-ответ.
|
|
||||||
STATUS_PROTOCOL_SWITCH = 101
|
|
||||||
|
|
||||||
|
|
||||||
def _spec_schema(spec: dict[str, Any], name: str) -> dict[str, Any]:
|
|
||||||
return cast(dict[str, Any], spec["components"]["schemas"][name])
|
|
||||||
|
|
||||||
|
|
||||||
def test_spec_is_valid_openapi_31(spec: dict[str, Any]) -> None:
|
|
||||||
assert spec["openapi"].startswith("3.1")
|
|
||||||
validate_openapi(spec)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("path", "method"), REQUIRED_OPERATIONS)
|
|
||||||
def test_required_operation_declared(spec: dict[str, Any], path: str, method: str) -> None:
|
|
||||||
assert method in spec["paths"].get(path, {}), f"{method.upper()} {path} отсутствует в контракте"
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_non_2xx_response_uses_error_schema(spec: dict[str, Any]) -> None:
|
|
||||||
"""Единый формат ошибок: любой не-2xx ответ описан схемой Error."""
|
|
||||||
offenders: list[str] = []
|
|
||||||
for path, method, operation in iter_operations(spec):
|
|
||||||
for status in operation.get("responses", {}):
|
|
||||||
if int(status) in STATUS_OK_RANGE or int(status) == STATUS_PROTOCOL_SWITCH:
|
|
||||||
continue
|
|
||||||
schema = response_schema(spec, path, method, status)
|
|
||||||
if not isinstance(schema, dict) or not str(schema.get("$ref", "")).endswith("/Error"):
|
|
||||||
offenders.append(f"{method.upper()} {path} -> {status}")
|
|
||||||
assert offenders == [], f"ответы без схемы Error: {offenders}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_error_codes_are_closed_enum(spec: dict[str, Any]) -> None:
|
|
||||||
codes = error_codes(spec)
|
|
||||||
assert {"validation_failed", "unauthorized", "not_found", "internal_error"} <= codes
|
|
||||||
|
|
||||||
|
|
||||||
def test_secret_fields_are_write_only(spec: dict[str, Any]) -> None:
|
|
||||||
declared = write_only_fields(spec)
|
|
||||||
assert SECRET_FIELD_NAMES <= declared, f"секреты без writeOnly: {SECRET_FIELD_NAMES - declared}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_only_secrets_removed_from_response_schema(spec: dict[str, Any]) -> None:
|
|
||||||
"""Схема ответа (после вырезания writeOnly) не содержит приватных ключей."""
|
|
||||||
schema = response_schema(spec, "/api/v1/config", "get", 200)
|
|
||||||
assert schema is not None
|
|
||||||
response_document = validator_for(schema, spec).schema
|
|
||||||
tunnel = response_document["components"]["schemas"]["TunnelConfig"]
|
|
||||||
peer = response_document["components"]["schemas"]["PeerConfig"]
|
|
||||||
assert "private_key" not in tunnel["properties"]
|
|
||||||
assert "private_key" not in tunnel.get("required", [])
|
|
||||||
assert "preshared_key" not in peer["properties"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_private_key_in_response_fails_validation(spec: dict[str, Any]) -> None:
|
|
||||||
schema = response_schema(spec, "/api/v1/config", "get", 200)
|
|
||||||
assert schema is not None
|
|
||||||
validator = validator_for(schema, spec)
|
|
||||||
payload = {
|
|
||||||
"tunnel": {
|
|
||||||
"address": "10.8.0.2/32",
|
|
||||||
"listen_port": 51820,
|
|
||||||
"mtu": 1420,
|
|
||||||
"private_key": "must-not-be-here",
|
|
||||||
},
|
|
||||||
"peers": [],
|
|
||||||
"dns": {"mode": "internal", "servers": []},
|
|
||||||
"routing": {
|
|
||||||
"mode": "geoip",
|
|
||||||
"kill_switch": True,
|
|
||||||
"mtu_clamping": True,
|
|
||||||
"ipv6": "direct",
|
|
||||||
"geoip": {"source": "cache", "prefix_count": 1, "updated_at": None},
|
|
||||||
},
|
|
||||||
"updated_at": "2026-10-22T00:00:00Z",
|
|
||||||
}
|
|
||||||
assert list(validator.iter_errors(payload)), "приватный ключ в ответе обязан ломать валидацию"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("schema_name", "model"), REQUEST_MODEL_CASES)
|
|
||||||
def test_request_models_match_spec(spec: dict[str, Any], schema_name: str, model: type[BaseModel]) -> None:
|
|
||||||
"""Модель запроса и схема контракта совпадают по полям и обязательности."""
|
|
||||||
spec_properties = set(_spec_schema(spec, schema_name).get("properties", {}))
|
|
||||||
spec_required = set(_spec_schema(spec, schema_name).get("required", []))
|
|
||||||
model_schema = model.model_json_schema()
|
|
||||||
model_properties = set(model_schema.get("properties", {}))
|
|
||||||
model_required = set(model_schema.get("required", []))
|
|
||||||
assert model_properties == spec_properties, (
|
|
||||||
f"{schema_name}: поля расходятся (только в контракте: "
|
|
||||||
f"{spec_properties - model_properties}, только в модели: {model_properties - spec_properties})"
|
|
||||||
)
|
|
||||||
assert model_required == spec_required, (
|
|
||||||
f"{schema_name}: обязательность расходится (только в контракте: "
|
|
||||||
f"{spec_required - model_required}, только в модели: {model_required - spec_required})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_secret_fields_write_only_in_request_models() -> None:
|
|
||||||
tunnel_schema = TunnelConfigIn.model_json_schema()
|
|
||||||
peer_schema = PeerConfigIn.model_json_schema()
|
|
||||||
auth_schema = AuthLoginRequest.model_json_schema()
|
|
||||||
assert tunnel_schema["properties"]["private_key"].get("writeOnly") is True
|
|
||||||
assert peer_schema["properties"]["preshared_key"].get("writeOnly") is True
|
|
||||||
assert auth_schema["properties"]["password"].get("writeOnly") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_spec_paths_are_versioned(spec: dict[str, Any]) -> None:
|
|
||||||
"""Все прикладные эндпоинты живут под /api/v1 (кроме служебных /metrics и /ready-хелперов)."""
|
|
||||||
allowed_unversioned = {"/metrics"}
|
|
||||||
offenders = [
|
|
||||||
path
|
|
||||||
for path, _method, _operation in iter_operations(spec)
|
|
||||||
if not path.startswith("/api/v1/") and path not in allowed_unversioned
|
|
||||||
]
|
|
||||||
assert offenders == [], f"эндпоинты вне /api/v1: {offenders}"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Скелетные тесты: пакеты импортируются, версия объявлена."""
|
|
||||||
|
|
||||||
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__
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"""Тесты атомарного применения: validate -> stage -> apply -> rollback (issue #20)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
AUDIT_STATUS_FAILED,
|
|
||||||
AUDIT_STATUS_OK,
|
|
||||||
AUDIT_STATUS_STAGED,
|
|
||||||
ApplyEngine,
|
|
||||||
ApplyError,
|
|
||||||
Database,
|
|
||||||
DesiredState,
|
|
||||||
Entity,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
NullApplier,
|
|
||||||
Rule,
|
|
||||||
RuleException,
|
|
||||||
Store,
|
|
||||||
ValidationError,
|
|
||||||
capture_state,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MockIptables:
|
|
||||||
"""Мок внешнего применителя: умеет «падать» как реальный iptables."""
|
|
||||||
|
|
||||||
def __init__(self, *, fail_apply: bool = False, fail_revert: bool = False) -> None:
|
|
||||||
self.applied: list[dict[str, Any]] = []
|
|
||||||
self.reverted: list[dict[str, Any]] = []
|
|
||||||
self.fail_apply = fail_apply
|
|
||||||
self.fail_revert = fail_revert
|
|
||||||
|
|
||||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
if self.fail_apply:
|
|
||||||
self.applied.append(dict(payload))
|
|
||||||
raise RuntimeError("mock iptables: exit 4")
|
|
||||||
self.applied.append(dict(payload))
|
|
||||||
|
|
||||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
if self.fail_revert:
|
|
||||||
raise RuntimeError("mock iptables: revert failed")
|
|
||||||
self.reverted.append(dict(payload))
|
|
||||||
|
|
||||||
|
|
||||||
def make_engine(
|
|
||||||
tmp_path: Path, applier: MockIptables | None = None
|
|
||||||
) -> tuple[Database, Store, ApplyEngine, MockIptables]:
|
|
||||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
|
||||||
store = Store(db)
|
|
||||||
mock = applier if applier is not None else MockIptables()
|
|
||||||
return db, store, ApplyEngine(store, mock, actor="ui:admin"), mock
|
|
||||||
|
|
||||||
|
|
||||||
def desired(*rules: Rule, mode: Mode = Mode.SPLIT) -> DesiredState:
|
|
||||||
return DesiredState(mode=mode, rules=rules, geoip=GeoIpSchedule())
|
|
||||||
|
|
||||||
|
|
||||||
# -- validate ------------------------------------------------------------------
|
|
||||||
def test_validate_accepts_good_state(tmp_path: Path) -> None:
|
|
||||||
db, _store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
state = desired(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"))
|
|
||||||
assert engine.validate(state) is state
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_rejects_bad_state(tmp_path: Path) -> None:
|
|
||||||
db, _store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
cases: list[DesiredState] = [
|
|
||||||
DesiredState(mode="turbo"), # type: ignore[arg-type]
|
|
||||||
desired(Rule(name="dup", kind="domain", match="a.example.com", target="tun0"),
|
|
||||||
Rule(name="dup", kind="domain", match="b.example.com", target="tun0")),
|
|
||||||
desired(Rule(name="x", kind="cidr", match="10.0.0.0/99", target="tun0")),
|
|
||||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(cron="всякий раз")),
|
|
||||||
DesiredState(
|
|
||||||
mode=Mode.SPLIT,
|
|
||||||
rules=(),
|
|
||||||
geoip=GeoIpSchedule(),
|
|
||||||
exceptions=(RuleException(match="!!!"),),
|
|
||||||
),
|
|
||||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(provider="!")),
|
|
||||||
]
|
|
||||||
for case in cases:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
engine.validate(case)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_does_not_touch_database(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
engine.apply_payload({"mode": "split", "rules": [{"name": "x", "kind": "cidr",
|
|
||||||
"match": "bad", "target": "tun0"}]})
|
|
||||||
assert store.counts() == {
|
|
||||||
"rules": 0,
|
|
||||||
"exceptions": 0,
|
|
||||||
"configs": 0,
|
|
||||||
"devices": 0,
|
|
||||||
"audit": 0,
|
|
||||||
"snapshots": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# -- stage ---------------------------------------------------------------------
|
|
||||||
def test_stage_saves_snapshot_and_staged_audit(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(Rule(name="old", kind="domain", match="old.example.com", target="tun0"),
|
|
||||||
actor="system")
|
|
||||||
store.set_mode(Mode.OFF, actor="system")
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
|
|
||||||
plan = engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
|
||||||
target="tun0")), reason="тест")
|
|
||||||
|
|
||||||
assert plan.previous == before
|
|
||||||
assert plan.desired.mode == Mode.SPLIT
|
|
||||||
# состояние ещё не изменено
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["old"]
|
|
||||||
snapshots = store.counts()["snapshots"]
|
|
||||||
assert snapshots == 1
|
|
||||||
staged = store.list_audit(entity=Entity.APPLY.value)[0]
|
|
||||||
assert staged.status == AUDIT_STATUS_STAGED
|
|
||||||
assert staged.action == "stage"
|
|
||||||
assert staged.detail == "тест"
|
|
||||||
assert staged.old_value == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_stage_rejects_invalid_state_without_snapshot(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
engine.stage(DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(cron="bad")))
|
|
||||||
assert store.counts()["snapshots"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
# -- apply ---------------------------------------------------------------------
|
|
||||||
def test_apply_writes_state_and_audit(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
plan = engine.stage(desired(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
|
||||||
mode=Mode.FULL), reason="включение")
|
|
||||||
result = engine.apply(plan)
|
|
||||||
|
|
||||||
assert result.ok is True
|
|
||||||
assert result.plan_id == plan.plan_id
|
|
||||||
assert result.audit_id > 0
|
|
||||||
assert store.get_mode() == Mode.FULL
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["yt"]
|
|
||||||
assert mock.applied == [plan.desired.to_payload()]
|
|
||||||
entry = store.list_audit(entity=Entity.APPLY.value)[0]
|
|
||||||
assert entry.status == AUDIT_STATUS_OK
|
|
||||||
assert entry.action == "apply"
|
|
||||||
assert entry.old_value == plan.previous
|
|
||||||
assert entry.new_value == plan.desired.to_payload()
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_apply_restores_previous_state(tmp_path: Path) -> None:
|
|
||||||
"""GIVEN ошибочный apply (мок падения iptables), THEN состояние прежнее + ApplyError."""
|
|
||||||
db, store, engine, mock = make_engine(tmp_path, MockIptables(fail_apply=True))
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
|
||||||
actor="system")
|
|
||||||
store.set_mode(Mode.SPLIT, actor="system")
|
|
||||||
store.set_geoip_schedule(cron="0 5 * * *", actor="system")
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
|
|
||||||
plan = engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
|
||||||
target="tun0"), mode=Mode.FULL), reason="сломается")
|
|
||||||
|
|
||||||
with pytest.raises(ApplyError) as excinfo:
|
|
||||||
engine.apply(plan)
|
|
||||||
|
|
||||||
error = excinfo.value
|
|
||||||
assert error.restored is True
|
|
||||||
assert error.audit_id is not None and error.audit_id > 0
|
|
||||||
# состояние, а не «частично применённая конфигурация»
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["keep"]
|
|
||||||
assert store.get_mode() == Mode.SPLIT
|
|
||||||
assert store.get_geoip_schedule().cron == "0 5 * * *"
|
|
||||||
|
|
||||||
assert mock.reverted == [before]
|
|
||||||
failed = store.list_audit(entity=Entity.APPLY.value)[0]
|
|
||||||
assert failed.status == AUDIT_STATUS_FAILED
|
|
||||||
assert failed.id == error.audit_id
|
|
||||||
assert failed.new_value == before
|
|
||||||
assert "mock iptables" in (failed.detail or "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_apply_with_failing_revert_still_restores_db(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(
|
|
||||||
tmp_path, MockIptables(fail_apply=True, fail_revert=True)
|
|
||||||
)
|
|
||||||
with db:
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
plan = engine.stage(desired(Rule(name="x", kind="domain", match="x.example.com", target="tun0")))
|
|
||||||
with pytest.raises(ApplyError) as excinfo:
|
|
||||||
engine.apply(plan)
|
|
||||||
assert excinfo.value.restored is True
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_can_succeed_after_failed_apply(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, mock = make_engine(tmp_path, MockIptables(fail_apply=True))
|
|
||||||
with db:
|
|
||||||
bad = engine.stage(desired(Rule(name="bad", kind="domain", match="bad.example.com",
|
|
||||||
target="tun0")))
|
|
||||||
with pytest.raises(ApplyError):
|
|
||||||
engine.apply(bad)
|
|
||||||
|
|
||||||
mock.fail_apply = False
|
|
||||||
good_rule = Rule(name="good", kind="domain", match="good.example.com", target="tun0")
|
|
||||||
result = engine.apply(engine.stage(desired(good_rule, mode=Mode.FULL)))
|
|
||||||
assert result.ok is True
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["good"]
|
|
||||||
assert store.get_mode() == Mode.FULL
|
|
||||||
|
|
||||||
|
|
||||||
# -- rollback ------------------------------------------------------------------
|
|
||||||
def test_rollback_returns_previous_state(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(Rule(name="old", kind="domain", match="old.example.com", target="tun0"),
|
|
||||||
actor="ui:admin")
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
|
|
||||||
result = engine.apply(
|
|
||||||
engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
|
||||||
target="tun0"), mode=Mode.FULL), reason="применение"),
|
|
||||||
)
|
|
||||||
assert result.ok is True
|
|
||||||
|
|
||||||
snapshot = engine.rollback(result.plan_id, reason="откат оператором")
|
|
||||||
assert snapshot.id == result.plan_id
|
|
||||||
assert snapshot.payload == before
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["old"]
|
|
||||||
assert store.get_mode() == Mode.OFF
|
|
||||||
|
|
||||||
rollback_entry = store.list_audit(entity=Entity.APPLY.value)[0]
|
|
||||||
assert rollback_entry.action == "rollback"
|
|
||||||
assert rollback_entry.detail == "откат оператором"
|
|
||||||
assert rollback_entry.new_value == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_payload_sugar_and_null_applier(tmp_path: Path) -> None:
|
|
||||||
db, store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
result = engine.apply_payload(
|
|
||||||
{
|
|
||||||
"mode": "split",
|
|
||||||
"rules": [{"name": "yt", "kind": "domain", "match": "youtube.com", "target": "tun0"}],
|
|
||||||
"geoip": {"enabled": False, "cron": "0 3 * * *", "provider": "ripe"},
|
|
||||||
"exceptions": [{"match": "bank.example.com", "reason": "банк"}],
|
|
||||||
},
|
|
||||||
reason="api: применить",
|
|
||||||
)
|
|
||||||
assert result.ok is True
|
|
||||||
assert store.get_mode() == Mode.SPLIT
|
|
||||||
assert store.get_geoip_schedule().enabled is False
|
|
||||||
assert store.list_exceptions()[0].match == "bank.example.com"
|
|
||||||
|
|
||||||
engine_null = ApplyEngine(store, NullApplier(), actor="system")
|
|
||||||
assert engine_null.apply_payload({"mode": "off"}).ok is True
|
|
||||||
assert store.get_mode() == Mode.OFF
|
|
||||||
|
|
||||||
|
|
||||||
def test_desired_state_payload_roundtrip() -> None:
|
|
||||||
state = DesiredState(
|
|
||||||
mode=Mode.FULL,
|
|
||||||
rules=(Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=7),),
|
|
||||||
geoip=GeoIpSchedule(enabled=False, cron="0 1 * * *", provider="maxmind"),
|
|
||||||
exceptions=(RuleException(match="bank.example.com", reason="банк", enabled=False),),
|
|
||||||
)
|
|
||||||
restored = DesiredState.from_payload(state.to_payload())
|
|
||||||
assert restored.mode == Mode.FULL
|
|
||||||
assert restored.rules == state.rules
|
|
||||||
assert restored.geoip == state.geoip
|
|
||||||
assert restored.exceptions == state.exceptions
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"payload",
|
|
||||||
[
|
|
||||||
{"rules": "не список"},
|
|
||||||
{"rules": [{"kind": "domain", "match": "a.example.com", "target": "tun0"}]},
|
|
||||||
{"rules": ["строка"]},
|
|
||||||
{"exceptions": "не список"},
|
|
||||||
{"exceptions": [{"reason": "нет match"}]},
|
|
||||||
{"geoip": "не объект"},
|
|
||||||
{"mode": "turbo"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_desired_state_from_payload_rejects_bad_payload(payload: dict[str, Any]) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DesiredState.from_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_engine_requires_store(tmp_path: Path) -> None:
|
|
||||||
db, _store, engine, _mock = make_engine(tmp_path)
|
|
||||||
with db:
|
|
||||||
assert engine.validate(desired()) == desired()
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""Тесты журнала аудита «кто/когда/что/old→new» (issue #20)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
AUDIT_STATUS_FAILED,
|
|
||||||
AUDIT_STATUS_OK,
|
|
||||||
Database,
|
|
||||||
Entity,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
Store,
|
|
||||||
)
|
|
||||||
from awg_api.store import audit
|
|
||||||
|
|
||||||
|
|
||||||
def make_db(tmp_path: Path) -> Database:
|
|
||||||
return Database(tmp_path / "data" / "awg-dash.db")
|
|
||||||
|
|
||||||
|
|
||||||
def test_record_and_read_back(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with db.transaction() as conn:
|
|
||||||
entry_id = audit.record(
|
|
||||||
conn,
|
|
||||||
actor="ui:admin",
|
|
||||||
action="update",
|
|
||||||
entity=Entity.RULE.value,
|
|
||||||
entity_id=7,
|
|
||||||
old_value={"match": "old.example.com"},
|
|
||||||
new_value={"match": "new.example.com"},
|
|
||||||
)
|
|
||||||
assert entry_id > 0
|
|
||||||
entries = audit.list_entries(db.connection)
|
|
||||||
assert len(entries) == 1
|
|
||||||
entry = entries[0]
|
|
||||||
assert entry.actor == "ui:admin"
|
|
||||||
assert entry.entity == "rule"
|
|
||||||
assert entry.entity_id == "7"
|
|
||||||
assert entry.old_value == {"match": "old.example.com"}
|
|
||||||
assert entry.new_value == {"match": "new.example.com"}
|
|
||||||
assert entry.status == AUDIT_STATUS_OK
|
|
||||||
assert entry.ts.endswith("Z")
|
|
||||||
assert entry.as_dict()["entity_id"] == "7"
|
|
||||||
|
|
||||||
|
|
||||||
def test_actor_is_required(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with db.transaction() as conn, pytest.raises(ValueError, match="actor"):
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor="",
|
|
||||||
action="update",
|
|
||||||
entity=Entity.RULE.value,
|
|
||||||
new_value={"x": 1},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_old_and_new_can_be_absent(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with db.transaction() as conn:
|
|
||||||
audit.record(conn, actor="system", action="noop", entity=Entity.APPLY.value)
|
|
||||||
entry = audit.last_entry(db.connection)
|
|
||||||
assert entry is not None
|
|
||||||
assert entry.old_value is None
|
|
||||||
assert entry.new_value is None
|
|
||||||
assert entry.entity_id is None
|
|
||||||
assert entry.detail is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_filters_limit_and_ordering(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
store = Store(db)
|
|
||||||
store.set_mode(Mode.SPLIT, actor="ui:admin")
|
|
||||||
store.set_mode(Mode.FULL, actor="ui:admin")
|
|
||||||
store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
|
||||||
actor="ui:admin")
|
|
||||||
|
|
||||||
mode_entries = audit.list_entries(db.connection, entity=Entity.MODE.value)
|
|
||||||
assert len(mode_entries) == 2
|
|
||||||
assert mode_entries[0].new_value == {"mode": "full"} # свежие первыми
|
|
||||||
assert mode_entries[0].old_value == {"mode": "split"}
|
|
||||||
|
|
||||||
by_id = audit.list_entries(
|
|
||||||
db.connection, entity=Entity.MODE.value, entity_id=1, limit=1
|
|
||||||
)
|
|
||||||
assert len(by_id) == 1
|
|
||||||
|
|
||||||
assert audit.count(db.connection) == 3
|
|
||||||
assert audit.count(db.connection, entity=Entity.RULE.value) == 1
|
|
||||||
assert audit.last_entry(db.connection, entity=Entity.RULE.value) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_ts_can_be_overridden(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with db.transaction() as conn:
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor="system",
|
|
||||||
action="stage",
|
|
||||||
entity=Entity.APPLY.value,
|
|
||||||
ts="2026-01-02T03:04:05.678Z",
|
|
||||||
)
|
|
||||||
entry = audit.last_entry(db.connection)
|
|
||||||
assert entry is not None and entry.ts == "2026-01-02T03:04:05.678Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scalar_and_list_payloads_roundtrip(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with db.transaction() as conn:
|
|
||||||
audit.record(
|
|
||||||
conn,
|
|
||||||
actor="system",
|
|
||||||
action="apply",
|
|
||||||
entity=Entity.APPLY.value,
|
|
||||||
old_value=["a", "b"],
|
|
||||||
new_value="plain",
|
|
||||||
status=AUDIT_STATUS_FAILED,
|
|
||||||
detail="iptables: exit 4",
|
|
||||||
)
|
|
||||||
entry = audit.last_entry(db.connection)
|
|
||||||
assert entry is not None
|
|
||||||
assert entry.old_value == ["a", "b"]
|
|
||||||
assert entry.new_value == "plain"
|
|
||||||
assert entry.status == AUDIT_STATUS_FAILED
|
|
||||||
assert entry.detail == "iptables: exit 4"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_audit_row_when_transaction_rolls_back(tmp_path: Path) -> None:
|
|
||||||
"""Мутация и её аудит коммитятся вместе: откат транзакции не оставляет следа."""
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
with pytest.raises(RuntimeError), db.transaction() as conn:
|
|
||||||
audit.record(conn, actor="system", action="update", entity=Entity.RULE.value)
|
|
||||||
raise RuntimeError("мутация не удалась")
|
|
||||||
assert audit.count(db.connection) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_entry_returns_none_on_empty_log(tmp_path: Path) -> None:
|
|
||||||
with make_db(tmp_path) as db:
|
|
||||||
assert audit.last_entry(db.connection) is None
|
|
||||||
assert audit.list_entries(db.connection) == []
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
"""Тесты потокобезопасности слоя хранения (#54, D-1/D-2 из #55; issue #20).
|
|
||||||
|
|
||||||
Контракт (задокументирован в ``backend/awg_api/store/README.md``): одно
|
|
||||||
соединение SQLite, все операции — чтения и записи — сериализуются одним
|
|
||||||
``RLock``. Читатель никогда не видит незафиксированных строк и состояния,
|
|
||||||
которое затем будет откачено, а конкурентные мутации дают согласованный
|
|
||||||
журнал аудита.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
AUDIT_STATUS_FAILED,
|
|
||||||
ApplyEngine,
|
|
||||||
ApplyError,
|
|
||||||
Database,
|
|
||||||
DesiredState,
|
|
||||||
Entity,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
Store,
|
|
||||||
capture_state,
|
|
||||||
)
|
|
||||||
|
|
||||||
THREADS = 4
|
|
||||||
PER_THREAD = 25
|
|
||||||
JOIN_TIMEOUT = 30.0
|
|
||||||
# окно наблюдения: за это время незаблокированное чтение гарантированно успело бы вернуться
|
|
||||||
OBSERVE_SECONDS = 0.2
|
|
||||||
|
|
||||||
RULE_INSERT = (
|
|
||||||
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
|
|
||||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def open_store(tmp_path: Path) -> tuple[Database, Store]:
|
|
||||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
|
||||||
return db, Store(db)
|
|
||||||
|
|
||||||
|
|
||||||
def rule(tid: int, idx: int) -> Rule:
|
|
||||||
return Rule(
|
|
||||||
name=f"r{tid}x{idx}",
|
|
||||||
kind="domain",
|
|
||||||
match=f"t{tid}r{idx}.example.com",
|
|
||||||
target="tun0",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_threads(workers: list[Any]) -> list[threading.Thread]:
|
|
||||||
threads = [threading.Thread(target=worker) for worker in workers]
|
|
||||||
for thread in threads:
|
|
||||||
thread.start()
|
|
||||||
for thread in threads:
|
|
||||||
thread.join(JOIN_TIMEOUT)
|
|
||||||
assert not thread.is_alive(), f"поток {thread.name} не завершился"
|
|
||||||
return threads
|
|
||||||
|
|
||||||
|
|
||||||
# -- #54: конкурентные мутации вместе с чтениями ---------------------------------
|
|
||||||
def test_concurrent_upserts_and_reads_are_consistent(tmp_path: Path) -> None:
|
|
||||||
"""N потоков × M upsert с чтениями: без ошибок, rules == audit == N*M."""
|
|
||||||
db, store = open_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
errors: list[str] = []
|
|
||||||
barrier = threading.Barrier(THREADS)
|
|
||||||
|
|
||||||
def worker(tid: int) -> None:
|
|
||||||
try:
|
|
||||||
barrier.wait(timeout=JOIN_TIMEOUT)
|
|
||||||
for idx in range(PER_THREAD):
|
|
||||||
store.upsert_rule(rule(tid, idx), actor=f"thread{tid}")
|
|
||||||
store.list_rules()
|
|
||||||
store.get_mode()
|
|
||||||
store.get_rule_by_name(f"r{tid}x{idx}")
|
|
||||||
except BaseException as exc: # noqa: BLE001 - фиксируем любое падение потока
|
|
||||||
errors.append(f"thread{tid}: {type(exc).__name__}: {exc}")
|
|
||||||
|
|
||||||
run_threads([lambda tid=tid: worker(tid) for tid in range(THREADS)])
|
|
||||||
|
|
||||||
total = THREADS * PER_THREAD
|
|
||||||
assert errors == []
|
|
||||||
assert store.counts()["rules"] == total
|
|
||||||
assert store.counts()["audit"] == total
|
|
||||||
assert len(store.list_rules()) == total
|
|
||||||
assert len(store.list_audit(limit=total * 2)) == total
|
|
||||||
|
|
||||||
|
|
||||||
# -- #54: чтение не видит незафиксированных строк -------------------------------
|
|
||||||
def test_reads_never_see_uncommitted_rows(tmp_path: Path) -> None:
|
|
||||||
"""Пока транзакция другого потока открыта, читатель ждёт коммита, а не читает грязь."""
|
|
||||||
db, store = open_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(
|
|
||||||
Rule(name="baseline", kind="domain", match="baseline.example.com", target="tun0"),
|
|
||||||
actor="system",
|
|
||||||
)
|
|
||||||
transaction_open = threading.Event()
|
|
||||||
reader_started = threading.Event()
|
|
||||||
release = threading.Event()
|
|
||||||
observed: list[list[str]] = []
|
|
||||||
writer_errors: list[str] = []
|
|
||||||
|
|
||||||
def writer() -> None:
|
|
||||||
try:
|
|
||||||
with db.transaction() as conn:
|
|
||||||
conn.execute(
|
|
||||||
RULE_INSERT,
|
|
||||||
(
|
|
||||||
"uncommitted",
|
|
||||||
"domain",
|
|
||||||
"uncommitted.example.com",
|
|
||||||
"tun0",
|
|
||||||
100,
|
|
||||||
1,
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
transaction_open.set()
|
|
||||||
release.wait(JOIN_TIMEOUT)
|
|
||||||
except BaseException as exc: # noqa: BLE001 - фиксируем падение писателя
|
|
||||||
writer_errors.append(f"{type(exc).__name__}: {exc}")
|
|
||||||
|
|
||||||
def reader() -> None:
|
|
||||||
reader_started.set()
|
|
||||||
observed.append([item.name for item in store.list_rules()])
|
|
||||||
|
|
||||||
writer_thread = threading.Thread(target=writer)
|
|
||||||
writer_thread.start()
|
|
||||||
assert transaction_open.wait(JOIN_TIMEOUT)
|
|
||||||
reader_thread = threading.Thread(target=reader)
|
|
||||||
reader_thread.start()
|
|
||||||
assert reader_started.wait(JOIN_TIMEOUT)
|
|
||||||
time.sleep(OBSERVE_SECONDS)
|
|
||||||
# незафиксированная строка наружу не видна: чтение ждёт завершения транзакции
|
|
||||||
assert observed == []
|
|
||||||
|
|
||||||
release.set()
|
|
||||||
writer_thread.join(JOIN_TIMEOUT)
|
|
||||||
reader_thread.join(JOIN_TIMEOUT)
|
|
||||||
assert writer_errors == []
|
|
||||||
assert observed == [["baseline", "uncommitted"]]
|
|
||||||
|
|
||||||
|
|
||||||
# -- #54 п.4: во время падающего apply наружу не отдаётся откаченное состояние ----
|
|
||||||
class BlockingApplier:
|
|
||||||
"""Применитель, который «висит» внутри apply и затем падает как iptables."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.entered = threading.Event()
|
|
||||||
self.release = threading.Event()
|
|
||||||
self.reverted: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
self.entered.set()
|
|
||||||
assert self.release.wait(JOIN_TIMEOUT)
|
|
||||||
raise RuntimeError("mock iptables: exit 4")
|
|
||||||
|
|
||||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
self.reverted.append(dict(payload))
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_during_failing_apply_never_sees_discarded_state(tmp_path: Path) -> None:
|
|
||||||
"""AC #20: снаружи не видно «полуприменённой» конфигурации, которую затем откатят."""
|
|
||||||
db, store = open_store(tmp_path)
|
|
||||||
applier = BlockingApplier()
|
|
||||||
engine = ApplyEngine(store, applier, actor="ui:admin")
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(
|
|
||||||
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
|
||||||
actor="system",
|
|
||||||
)
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
plan = engine.stage(
|
|
||||||
DesiredState(
|
|
||||||
mode=Mode.FULL,
|
|
||||||
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
|
|
||||||
),
|
|
||||||
reason="сломается",
|
|
||||||
)
|
|
||||||
errors: list[BaseException] = []
|
|
||||||
observed: list[list[str]] = []
|
|
||||||
reader_started = threading.Event()
|
|
||||||
|
|
||||||
def apply_worker() -> None:
|
|
||||||
try:
|
|
||||||
engine.apply(plan)
|
|
||||||
except BaseException as exc: # noqa: BLE001 - ожидаем ApplyError
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
def reader() -> None:
|
|
||||||
reader_started.set()
|
|
||||||
observed.append([item.name for item in store.list_rules()])
|
|
||||||
|
|
||||||
apply_thread = threading.Thread(target=apply_worker)
|
|
||||||
apply_thread.start()
|
|
||||||
assert applier.entered.wait(JOIN_TIMEOUT)
|
|
||||||
reader_thread = threading.Thread(target=reader)
|
|
||||||
reader_thread.start()
|
|
||||||
assert reader_started.wait(JOIN_TIMEOUT)
|
|
||||||
time.sleep(OBSERVE_SECONDS)
|
|
||||||
assert observed == [], "чтение вернуло состояние, которого ещё нет в БД"
|
|
||||||
|
|
||||||
applier.release.set()
|
|
||||||
apply_thread.join(JOIN_TIMEOUT)
|
|
||||||
reader_thread.join(JOIN_TIMEOUT)
|
|
||||||
|
|
||||||
assert len(errors) == 1
|
|
||||||
error = errors[0]
|
|
||||||
assert isinstance(error, ApplyError)
|
|
||||||
assert error.restored is True
|
|
||||||
assert observed == [["keep"]]
|
|
||||||
assert [item.name for item in store.list_rules()] == ["keep"]
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
assert applier.reverted == [before]
|
|
||||||
|
|
||||||
|
|
||||||
# -- #55 D-1: BaseException не должен оставлять staged без исхода ---------------
|
|
||||||
class InterruptingApplier:
|
|
||||||
"""Применитель, прерывающий apply исключением из ``BaseException``."""
|
|
||||||
|
|
||||||
def __init__(self, exc_type: type[BaseException]) -> None:
|
|
||||||
self.exc_type = exc_type
|
|
||||||
self.applied = 0
|
|
||||||
self.reverted: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
self.applied += 1
|
|
||||||
raise self.exc_type("внешний применитель прерван")
|
|
||||||
|
|
||||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
|
||||||
self.reverted.append(dict(payload))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit, asyncio.CancelledError])
|
|
||||||
def test_base_exception_from_applier_reverts_and_records_failure(
|
|
||||||
tmp_path: Path, exc_type: type[BaseException]
|
|
||||||
) -> None:
|
|
||||||
"""Отмена/прерывание apply → revert внешней подсистемы + failed в audit_log."""
|
|
||||||
db, store = open_store(tmp_path)
|
|
||||||
applier = InterruptingApplier(exc_type)
|
|
||||||
engine = ApplyEngine(store, applier, actor="ui:admin")
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(
|
|
||||||
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
|
||||||
actor="system",
|
|
||||||
)
|
|
||||||
before = capture_state(db.connection)
|
|
||||||
plan = engine.stage(
|
|
||||||
DesiredState(
|
|
||||||
mode=Mode.FULL,
|
|
||||||
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
|
|
||||||
),
|
|
||||||
reason="отмена",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(exc_type):
|
|
||||||
engine.apply(plan)
|
|
||||||
|
|
||||||
assert applier.applied == 1
|
|
||||||
assert applier.reverted == [before]
|
|
||||||
assert capture_state(db.connection) == before
|
|
||||||
assert [item.name for item in store.list_rules()] == ["keep"]
|
|
||||||
|
|
||||||
entries = store.list_audit(entity=Entity.APPLY.value, entity_id=plan.plan_id, limit=10)
|
|
||||||
assert entries[0].status == AUDIT_STATUS_FAILED
|
|
||||||
assert exc_type.__name__ in (entries[0].detail or "")
|
|
||||||
assert entries[0].new_value == before
|
|
||||||
|
|
||||||
|
|
||||||
# -- #55 D-2: конкурентные set_geoip_schedule дают связный журнал ---------------
|
|
||||||
def test_concurrent_geoip_updates_keep_audit_chain_consistent(tmp_path: Path) -> None:
|
|
||||||
"""Каждое изменение пишет корректный old_value: цепочка old→new не рвётся."""
|
|
||||||
db, store = open_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
initial = store.get_geoip_schedule()
|
|
||||||
crons = ("0 1 * * *", "0 2 * * *")
|
|
||||||
errors: list[str] = []
|
|
||||||
barrier = threading.Barrier(len(crons))
|
|
||||||
|
|
||||||
def worker(cron: str) -> None:
|
|
||||||
try:
|
|
||||||
barrier.wait(timeout=JOIN_TIMEOUT)
|
|
||||||
store.set_geoip_schedule(cron=cron, actor=f"ui:{cron}")
|
|
||||||
except BaseException as exc: # noqa: BLE001 - фиксируем падение потока
|
|
||||||
errors.append(f"{cron}: {type(exc).__name__}: {exc}")
|
|
||||||
|
|
||||||
run_threads([lambda cron=cron: worker(cron) for cron in crons])
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
entries = list(reversed(store.list_audit(entity=Entity.GEOIP_SCHEDULE.value, limit=10)))
|
|
||||||
assert len(entries) == len(crons)
|
|
||||||
first, second = entries
|
|
||||||
assert first.old_value["cron"] == initial.cron
|
|
||||||
assert first.new_value["cron"] == second.old_value["cron"]
|
|
||||||
assert second.new_value["cron"] == store.get_geoip_schedule().cron
|
|
||||||
assert {first.new_value["cron"], second.new_value["cron"]} == set(crons)
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"""Тесты схемы, миграций и проверки версии при старте (issue #20)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
EXPECTED_TABLES,
|
|
||||||
LATEST_VERSION,
|
|
||||||
MIGRATIONS,
|
|
||||||
Database,
|
|
||||||
Migration,
|
|
||||||
SchemaError,
|
|
||||||
StorageError,
|
|
||||||
current_version,
|
|
||||||
migrate,
|
|
||||||
resolve_db_path,
|
|
||||||
verify_schema,
|
|
||||||
)
|
|
||||||
from awg_api.store.migrations import applied_migrations, table_columns, table_names
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_data_dir_creates_schema(tmp_path: Path) -> None:
|
|
||||||
"""GIVEN пустой /data, WHEN старт, THEN схема v1 создана автоматически."""
|
|
||||||
db_path = tmp_path / "data" / "awg-dash.db"
|
|
||||||
assert not db_path.parent.exists()
|
|
||||||
|
|
||||||
with Database(db_path) as db:
|
|
||||||
assert db.schema_version == LATEST_VERSION == 1
|
|
||||||
assert db.migration_report is not None
|
|
||||||
assert db.migration_report.applied == (1,)
|
|
||||||
names = table_names(db.connection)
|
|
||||||
assert set(EXPECTED_TABLES) <= names
|
|
||||||
for table, columns in EXPECTED_TABLES.items():
|
|
||||||
assert columns <= table_columns(db.connection, table), table
|
|
||||||
|
|
||||||
|
|
||||||
def test_wal_mode_is_enabled(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
assert db.journal_mode.lower() == "wal"
|
|
||||||
assert db.is_wal is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_rows_present(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
row = db.connection.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
|
||||||
assert row is not None and row["mode"] == "off"
|
|
||||||
geoip = db.connection.execute("SELECT cron, provider FROM geoip_schedule WHERE id = 1").fetchone()
|
|
||||||
assert geoip is not None
|
|
||||||
assert geoip["provider"] == "ripe"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_is_idempotent(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
report = db.migrate()
|
|
||||||
assert report.applied == ()
|
|
||||||
assert report.changed is False
|
|
||||||
assert report.from_version == report.to_version == LATEST_VERSION
|
|
||||||
|
|
||||||
|
|
||||||
def test_applied_migrations_map(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
assert applied_migrations(db.connection) == {1: "initial-state-schema"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_auto_migrate_false_verifies_existing_schema(tmp_path: Path) -> None:
|
|
||||||
path = tmp_path / "awg-dash.db"
|
|
||||||
with Database(path):
|
|
||||||
pass
|
|
||||||
with Database(path, auto_migrate=False) as db:
|
|
||||||
assert db.migration_report is None
|
|
||||||
assert current_version(db.connection) == LATEST_VERSION
|
|
||||||
|
|
||||||
|
|
||||||
def test_auto_migrate_false_on_unmigrated_db_raises(tmp_path: Path) -> None:
|
|
||||||
conn = sqlite3.connect(str(tmp_path / "empty.db"))
|
|
||||||
try:
|
|
||||||
with pytest.raises(SchemaError, match="не мигрирована"):
|
|
||||||
verify_schema(conn)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_schema_detects_missing_table(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
db.connection.execute("DROP TABLE routing_rules")
|
|
||||||
with pytest.raises(SchemaError, match="routing_rules"):
|
|
||||||
db.verify_schema()
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_schema_detects_missing_column(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
db.connection.execute("ALTER TABLE devices DROP COLUMN address")
|
|
||||||
with pytest.raises(SchemaError, match="address"):
|
|
||||||
db.verify_schema()
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_schema_rejects_newer_schema(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
db.connection.execute(
|
|
||||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
|
||||||
(99, "from-the-future", "2099-01-01T00:00:00.000Z"),
|
|
||||||
)
|
|
||||||
with pytest.raises(SchemaError, match="новее"):
|
|
||||||
db.verify_schema()
|
|
||||||
with pytest.raises(SchemaError, match="новее"):
|
|
||||||
db.migrate()
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_migration_leaves_schema_untouched(tmp_path: Path) -> None:
|
|
||||||
broken = MIGRATIONS + (
|
|
||||||
Migration(version=2, name="broken", statements=("CREATE TABLE t2 (id INTEGER)", "NOT SQL AT ALL")),
|
|
||||||
)
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
with pytest.raises(StorageError, match="миграция v2"):
|
|
||||||
migrate(db.connection, migrations=broken)
|
|
||||||
assert current_version(db.connection) == 1
|
|
||||||
assert "t2" not in table_names(db.connection)
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_rejects_downgrade(tmp_path: Path) -> None:
|
|
||||||
with Database(tmp_path / "awg-dash.db") as db:
|
|
||||||
db.connection.execute(
|
|
||||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
|
||||||
(2, "newer", "2099-01-01T00:00:00.000Z"),
|
|
||||||
)
|
|
||||||
with pytest.raises(SchemaError, match="новее"):
|
|
||||||
migrate(db.connection, migrations=MIGRATIONS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_db_path_defaults_and_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("AWG_DASH_DB", raising=False)
|
|
||||||
assert resolve_db_path() == Path("/data/awg-dash.db")
|
|
||||||
monkeypatch.setenv("AWG_DASH_DB", str(tmp_path / "custom.db"))
|
|
||||||
assert resolve_db_path() == tmp_path / "custom.db"
|
|
||||||
assert resolve_db_path(tmp_path / "explicit.db") == tmp_path / "explicit.db"
|
|
||||||
|
|
||||||
|
|
||||||
def test_database_uses_env_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setenv("AWG_DASH_DB", str(tmp_path / "nested" / "env.db"))
|
|
||||||
with Database() as db:
|
|
||||||
assert db.path == tmp_path / "nested" / "env.db"
|
|
||||||
assert db.path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_unwritable_path_raises_storage_error(tmp_path: Path) -> None:
|
|
||||||
blocker = tmp_path / "blocked"
|
|
||||||
blocker.write_text("not a directory")
|
|
||||||
with pytest.raises(StorageError, match="каталог"):
|
|
||||||
Database(blocker / "awg-dash.db")
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Персистентность: рестарт «контейнера» сохраняет правила, конфиг и аудит (issue #20)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
ConfigMeta,
|
|
||||||
Database,
|
|
||||||
DesiredState,
|
|
||||||
Device,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
StorageError,
|
|
||||||
Store,
|
|
||||||
ValidationError,
|
|
||||||
open_storage,
|
|
||||||
rollback_to,
|
|
||||||
)
|
|
||||||
from awg_api.store import validation
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_survives_container_restart(tmp_path: Path) -> None:
|
|
||||||
"""GIVEN рестарт контейнера, WHEN он завершён, THEN правила и конфиг на месте."""
|
|
||||||
db_path = tmp_path / "data" / "awg-dash.db"
|
|
||||||
|
|
||||||
with Database(db_path) as first:
|
|
||||||
store = Store(first)
|
|
||||||
store.set_mode(Mode.SPLIT, actor="ui:admin")
|
|
||||||
store.set_geoip_schedule(cron="0 3 * * *", actor="ui:admin")
|
|
||||||
store.upsert_rule(
|
|
||||||
Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=10),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
store.upsert_rule(
|
|
||||||
Rule(name="lan", kind="cidr", match="10.0.0.0/8", target="direct", priority=200),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
store.upsert_config(
|
|
||||||
ConfigMeta(name="peer1", path="/config/peer1.conf", checksum="deadbeef", size_bytes=1024),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="notebook"), actor="ui:admin")
|
|
||||||
audit_before = store.counts()["audit"]
|
|
||||||
assert audit_before == 6
|
|
||||||
|
|
||||||
# «рестарт контейнера»: новое соединение к тому же /data
|
|
||||||
with Database(db_path) as second:
|
|
||||||
store = Store(second)
|
|
||||||
assert second.is_wal is True
|
|
||||||
assert store.get_mode() == Mode.SPLIT
|
|
||||||
assert store.get_geoip_schedule().cron == "0 3 * * *"
|
|
||||||
assert [(rule.name, rule.target) for rule in store.list_rules()] == [
|
|
||||||
("yt", "tun0"),
|
|
||||||
("lan", "direct"),
|
|
||||||
]
|
|
||||||
config = store.get_config("peer1")
|
|
||||||
assert config is not None
|
|
||||||
assert config.checksum == "deadbeef"
|
|
||||||
assert store.get_device("aa:bb:cc:dd:ee:ff") is not None
|
|
||||||
assert store.counts()["audit"] == audit_before
|
|
||||||
assert second.migration_report is not None # миграции уже применены, повтор не нужен
|
|
||||||
assert second.migration_report.applied == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_wal_file_next_to_database(tmp_path: Path) -> None:
|
|
||||||
db_path = tmp_path / "data" / "awg-dash.db"
|
|
||||||
with Database(db_path) as db:
|
|
||||||
Store(db).set_mode(Mode.FULL, actor="system")
|
|
||||||
assert db_path.with_name("awg-dash.db-wal").exists()
|
|
||||||
assert db_path.with_name("awg-dash.db-shm").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_open_storage_builds_full_stack(tmp_path: Path) -> None:
|
|
||||||
with open_storage(tmp_path / "data" / "awg-dash.db", actor="api:token") as storage:
|
|
||||||
storage.store.set_mode(Mode.SPLIT, actor="api:token")
|
|
||||||
plan = storage.engine.stage(
|
|
||||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule()),
|
|
||||||
reason="через storage",
|
|
||||||
)
|
|
||||||
result = storage.engine.apply(plan)
|
|
||||||
assert result.ok is True
|
|
||||||
assert storage.store.counts()["snapshots"] == 1
|
|
||||||
assert storage.db.schema_version == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_rollback_helper_and_missing_snapshot(tmp_path: Path) -> None:
|
|
||||||
with open_storage(tmp_path / "data" / "awg-dash.db") as storage:
|
|
||||||
storage.store.set_mode(Mode.OFF, actor="system")
|
|
||||||
plan = storage.engine.stage(DesiredState(mode=Mode.FULL))
|
|
||||||
storage.engine.apply(plan)
|
|
||||||
assert storage.store.get_mode() == Mode.FULL
|
|
||||||
|
|
||||||
snapshot = rollback_to(storage.engine, plan.plan_id, reason="ручной откат")
|
|
||||||
assert snapshot.payload["mode"] == "off"
|
|
||||||
assert storage.store.get_mode() == Mode.OFF
|
|
||||||
|
|
||||||
with pytest.raises(StorageError, match="не найден"):
|
|
||||||
storage.engine.rollback(999999)
|
|
||||||
|
|
||||||
|
|
||||||
def test_transactions_are_isolated_per_connection(tmp_path: Path) -> None:
|
|
||||||
db_path = tmp_path / "data" / "awg-dash.db"
|
|
||||||
with Database(db_path) as writer, Database(db_path) as reader:
|
|
||||||
Store(writer).set_mode(Mode.SPLIT, actor="system")
|
|
||||||
assert Store(reader).get_mode() == Mode.SPLIT
|
|
||||||
with pytest.raises(RuntimeError, match="откат"), writer.transaction() as conn:
|
|
||||||
conn.execute("UPDATE routing_mode SET mode = 'full' WHERE id = 1")
|
|
||||||
raise RuntimeError("откат")
|
|
||||||
assert Store(reader).get_mode() == Mode.SPLIT
|
|
||||||
|
|
||||||
|
|
||||||
# -- валидация (чистые функции) -------------------------------------------------
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
("AA:BB:CC:DD:EE:FF", "aa:bb:cc:dd:ee:ff"),
|
|
||||||
("aa-bb-cc-dd-ee-ff", "aa:bb:cc:dd:ee:ff"),
|
|
||||||
("aabbccddeeff", "aa:bb:cc:dd:ee:ff"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_normalize_mac(raw: str, expected: str) -> None:
|
|
||||||
assert validation.normalize_mac(raw) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mac_rejects_garbage() -> None:
|
|
||||||
with pytest.raises(ValidationError, match="MAC"):
|
|
||||||
validation.normalize_mac("not-a-mac")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["ok", "peer_1", "rule-2", "a.b:c"])
|
|
||||||
def test_validate_name_accepts(value: str) -> None:
|
|
||||||
assert validation.validate_name("name", f" {value} ") == value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["", "UPPER", "-lead", "with space", "юникод", "x" * 64])
|
|
||||||
def test_validate_name_rejects(value: str) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
validation.validate_name("name", value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["/config/peer1.conf", "/etc/awg-dash/rules.conf"])
|
|
||||||
def test_validate_config_path_accepts(value: str) -> None:
|
|
||||||
assert validation.validate_config_path(value) == value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["relative.conf", "/config/../../etc/shadow", ""])
|
|
||||||
def test_validate_config_path_rejects(value: str) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
validation.validate_config_path(value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("value", "expected"), [("RIPE", "ripe"), ("maxmind", "maxmind")])
|
|
||||||
def test_validate_provider(value: str, expected: str) -> None:
|
|
||||||
assert validation.validate_provider(value) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["tunnel", "direct", "block", "default"])
|
|
||||||
def test_validate_device_policy_accepts(value: str) -> None:
|
|
||||||
assert validation.validate_device_policy(value) == value
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_device_policy_rejects() -> None:
|
|
||||||
with pytest.raises(ValidationError, match="policy"):
|
|
||||||
validation.validate_device_policy("root")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["*.example.com", "youtube.com", "a.b.c.example.co.uk"])
|
|
||||||
def test_validate_match_domain(value: str) -> None:
|
|
||||||
assert validation.validate_match("domain", value) == value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["10.0.0.0/8", "2001:db8::/32", "192.168.1.7"])
|
|
||||||
def test_validate_match_cidr(value: str) -> None:
|
|
||||||
assert validation.validate_match("cidr", value) == value
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_match_ipset_and_empty() -> None:
|
|
||||||
assert validation.validate_match("ipset", "awg_ru") == "awg_ru"
|
|
||||||
with pytest.raises(ValidationError, match="пустым"):
|
|
||||||
validation.validate_match("domain", " ")
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
"""Тесты репозитория: CRUD-мутации с аудитом «old→new» (issue #20)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from awg_api.store import (
|
|
||||||
ConfigMeta,
|
|
||||||
Database,
|
|
||||||
Device,
|
|
||||||
Entity,
|
|
||||||
GeoIpSchedule,
|
|
||||||
Mode,
|
|
||||||
Rule,
|
|
||||||
RuleException,
|
|
||||||
StorageError,
|
|
||||||
Store,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
from awg_api.store import audit
|
|
||||||
from awg_api.store.validation import validate_priority
|
|
||||||
|
|
||||||
|
|
||||||
def make_store(tmp_path: Path) -> tuple[Database, Store]:
|
|
||||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
|
||||||
return db, Store(db)
|
|
||||||
|
|
||||||
|
|
||||||
# -- правила -----------------------------------------------------------------
|
|
||||||
def test_upsert_rule_creates_with_audit(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
saved = store.upsert_rule(
|
|
||||||
Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=10),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
assert saved.id is not None and saved.created_at == saved.updated_at
|
|
||||||
assert store.get_rule(saved.id) == saved
|
|
||||||
assert store.get_rule_by_name("yt") == saved
|
|
||||||
entries = store.list_audit(entity=Entity.RULE.value)
|
|
||||||
assert len(entries) == 1
|
|
||||||
assert entries[0].action == "create"
|
|
||||||
assert entries[0].old_value is None
|
|
||||||
assert entries[0].new_value is not None
|
|
||||||
assert entries[0].new_value["name"] == "yt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_rule_updates_by_name_and_records_old_new(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
first = store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
|
||||||
actor="ui:admin")
|
|
||||||
second = store.upsert_rule(Rule(name="yt", kind="domain", match="youtu.be", target="tun1"),
|
|
||||||
actor="ui:admin")
|
|
||||||
assert second.id == first.id
|
|
||||||
assert second.match == "youtu.be" and second.target == "tun1"
|
|
||||||
assert store.counts()["rules"] == 1
|
|
||||||
entries = store.list_audit(entity=Entity.RULE.value)
|
|
||||||
assert [entry.action for entry in entries] == ["update", "create"]
|
|
||||||
assert entries[0].old_value["match"] == "youtube.com"
|
|
||||||
assert entries[0].new_value["match"] == "youtu.be"
|
|
||||||
assert entries[0].old_value["target"] == "tun0"
|
|
||||||
assert entries[0].new_value["target"] == "tun1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_rule_by_id(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
saved = store.upsert_rule(Rule(name="a", kind="cidr", match="10.0.0.0/8", target="tun0"),
|
|
||||||
actor="system")
|
|
||||||
updated = store.upsert_rule(
|
|
||||||
Rule(id=saved.id, name="a", kind="cidr", match="10.1.0.0/16", target="tun0", enabled=False),
|
|
||||||
actor="system",
|
|
||||||
)
|
|
||||||
assert updated.id == saved.id
|
|
||||||
assert updated.match == "10.1.0.0/16"
|
|
||||||
assert updated.enabled is False
|
|
||||||
assert store.list_rules(enabled_only=True) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule_validation_rejects_bad_input(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
cases = [
|
|
||||||
Rule(name="Bad Name", kind="domain", match="youtube.com", target="tun0"),
|
|
||||||
Rule(name="ok", kind="wrong", match="youtube.com", target="tun0"),
|
|
||||||
Rule(name="ok2", kind="domain", match="not a domain", target="tun0"),
|
|
||||||
Rule(name="ok3", kind="cidr", match="10.0.0.0/99", target="tun0"),
|
|
||||||
Rule(name="ok4", kind="domain", match="youtube.com", target="bad target!"),
|
|
||||||
Rule(name="ok5", kind="domain", match="youtube.com", target="tun0", priority=-1),
|
|
||||||
]
|
|
||||||
for rule in cases:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
store.upsert_rule(rule, actor="system")
|
|
||||||
assert store.counts()["rules"] == 0
|
|
||||||
assert audit.count(db.connection) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_priority_must_be_int() -> None:
|
|
||||||
with pytest.raises(ValidationError, match="целое"):
|
|
||||||
validate_priority(True) # bool не является допустимым приоритетом
|
|
||||||
with pytest.raises(ValidationError, match="диапазона"):
|
|
||||||
validate_priority(70000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_rule(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
saved = store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
|
||||||
actor="ui:admin")
|
|
||||||
assert saved.id is not None
|
|
||||||
assert store.delete_rule(saved.id, actor="ui:admin") is True
|
|
||||||
assert store.delete_rule(saved.id, actor="ui:admin") is False
|
|
||||||
assert store.get_rule(saved.id) is None
|
|
||||||
entries = store.list_audit(entity=Entity.RULE.value)
|
|
||||||
assert [entry.action for entry in entries] == ["delete", "create"]
|
|
||||||
assert entries[0].new_value is None
|
|
||||||
assert entries[0].old_value["name"] == "yt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_sorted_by_priority(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
store.upsert_rule(Rule(name="b", kind="domain", match="b.example.com", target="tun0",
|
|
||||||
priority=200), actor="system")
|
|
||||||
store.upsert_rule(Rule(name="a", kind="domain", match="a.example.com", target="tun0",
|
|
||||||
priority=5), actor="system")
|
|
||||||
assert [rule.name for rule in store.list_rules()] == ["a", "b"]
|
|
||||||
|
|
||||||
|
|
||||||
# -- исключения ---------------------------------------------------------------
|
|
||||||
def test_exception_upsert_update_delete(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
created = store.upsert_exception(RuleException(match="bank.example.com", reason="банк"),
|
|
||||||
actor="ui:admin")
|
|
||||||
assert created.id is not None
|
|
||||||
assert store.get_exception("bank.example.com") == created
|
|
||||||
|
|
||||||
updated = store.upsert_exception(RuleException(match="bank.example.com", reason="банк2"),
|
|
||||||
actor="ui:admin")
|
|
||||||
assert updated.id == created.id and updated.reason == "банк2"
|
|
||||||
assert store.counts()["exceptions"] == 1
|
|
||||||
|
|
||||||
assert store.list_exceptions() == [updated]
|
|
||||||
assert store.delete_exception(updated.id or 0, actor="ui:admin") is True
|
|
||||||
assert store.delete_exception(updated.id or 0, actor="ui:admin") is False
|
|
||||||
|
|
||||||
actions = [entry.action for entry in store.list_audit(entity=Entity.EXCEPTION.value)]
|
|
||||||
assert actions == ["delete", "update", "create"]
|
|
||||||
update_entry = store.list_audit(entity=Entity.EXCEPTION.value)[1]
|
|
||||||
assert update_entry.old_value["reason"] == "банк"
|
|
||||||
assert update_entry.new_value["reason"] == "банк2"
|
|
||||||
|
|
||||||
|
|
||||||
def test_exception_accepts_cidr_and_rejects_garbage(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
assert store.upsert_exception(RuleException(match="192.168.1.0/24"), actor="system").match == (
|
|
||||||
"192.168.1.0/24"
|
|
||||||
)
|
|
||||||
assert store.upsert_exception(RuleException(match="10.0.0.1"), actor="system").match == "10.0.0.1"
|
|
||||||
with pytest.raises(ValidationError, match="домен"):
|
|
||||||
store.upsert_exception(RuleException(match="!!!"), actor="system")
|
|
||||||
with pytest.raises(ValidationError, match="пустым"):
|
|
||||||
store.upsert_exception(RuleException(match=" "), actor="system")
|
|
||||||
|
|
||||||
|
|
||||||
# -- режим и GeoIP -------------------------------------------------------------
|
|
||||||
def test_set_mode_defaults_off_and_audits(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
assert store.get_mode() == Mode.OFF
|
|
||||||
assert store.set_mode("split", actor="ui:admin") == Mode.SPLIT
|
|
||||||
assert store.get_mode() == Mode.SPLIT
|
|
||||||
assert store.set_mode(Mode("full"), actor="ui:admin") == Mode.FULL
|
|
||||||
entries = store.list_audit(entity=Entity.MODE.value)
|
|
||||||
assert len(entries) == 2
|
|
||||||
assert entries[0].old_value == {"mode": "split"}
|
|
||||||
assert entries[0].new_value == {"mode": "full"}
|
|
||||||
assert entries[0].actor == "ui:admin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_mode_rejects_unknown(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
with pytest.raises(ValidationError, match="mode"):
|
|
||||||
store.set_mode("turbo", actor="ui:admin")
|
|
||||||
assert store.get_mode() == Mode.OFF
|
|
||||||
|
|
||||||
|
|
||||||
def test_geoip_schedule_defaults_and_partial_update(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
default = store.get_geoip_schedule()
|
|
||||||
assert default == GeoIpSchedule(enabled=True, cron="0 4 * * *", provider="ripe",
|
|
||||||
updated_at=default.updated_at)
|
|
||||||
updated = store.set_geoip_schedule(cron="*/30 * * * *", actor="ui:admin")
|
|
||||||
assert updated.cron == "*/30 * * * *"
|
|
||||||
assert updated.provider == "ripe" and updated.enabled is True
|
|
||||||
|
|
||||||
disabled = store.set_geoip_schedule(enabled=False, provider="MAXMIND", actor="ui:admin")
|
|
||||||
assert disabled.enabled is False and disabled.provider == "maxmind"
|
|
||||||
assert store.get_geoip_schedule() == disabled
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="cron"):
|
|
||||||
store.set_geoip_schedule(cron="ежечасно", actor="ui:admin")
|
|
||||||
with pytest.raises(ValidationError, match="provider"):
|
|
||||||
store.set_geoip_schedule(provider="!", actor="ui:admin")
|
|
||||||
|
|
||||||
entries = store.list_audit(entity=Entity.GEOIP_SCHEDULE.value)
|
|
||||||
assert [entry.action for entry in entries] == ["set_geoip_schedule"] * 2
|
|
||||||
assert entries[0].old_value["provider"] == "ripe"
|
|
||||||
assert entries[0].new_value["provider"] == "maxmind"
|
|
||||||
|
|
||||||
|
|
||||||
# -- конфиги ------------------------------------------------------------------
|
|
||||||
def test_config_upsert_and_mark_applied(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
created = store.upsert_config(
|
|
||||||
ConfigMeta(name="client-peer1", path="/config/peer1.conf", version=1, checksum="abc",
|
|
||||||
size_bytes=512), actor="ui:admin",
|
|
||||||
)
|
|
||||||
assert created.id is not None
|
|
||||||
assert store.get_config("client-peer1") == created
|
|
||||||
|
|
||||||
updated = store.upsert_config(
|
|
||||||
ConfigMeta(name="client-peer1", path="/config/peer1.conf", version=2, checksum="def",
|
|
||||||
size_bytes=640, applied=True), actor="ui:admin",
|
|
||||||
)
|
|
||||||
assert updated.version == 2 and updated.applied is True
|
|
||||||
assert store.counts()["configs"] == 1
|
|
||||||
|
|
||||||
marked = store.mark_config_applied("client-peer1", applied=False, actor="ui:admin")
|
|
||||||
assert marked.applied is False
|
|
||||||
|
|
||||||
entries = store.list_audit(entity=Entity.CONFIG.value)
|
|
||||||
assert [entry.action for entry in entries] == ["set_applied", "update", "create"]
|
|
||||||
assert entries[0].old_value["applied"] is True
|
|
||||||
assert entries[0].new_value["applied"] is False
|
|
||||||
assert entries[1].old_value["version"] == 1
|
|
||||||
assert entries[1].new_value["version"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_validation_and_missing(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
with pytest.raises(ValidationError, match="path"):
|
|
||||||
store.upsert_config(ConfigMeta(name="x", path="relative.conf"), actor="system")
|
|
||||||
with pytest.raises(ValidationError, match="path"):
|
|
||||||
store.upsert_config(ConfigMeta(name="x", path="/config/../etc/passwd"), actor="system")
|
|
||||||
with pytest.raises(ValidationError, match="name"):
|
|
||||||
store.upsert_config(ConfigMeta(name="Bad Name", path="/config/x.conf"), actor="system")
|
|
||||||
with pytest.raises(StorageError, match="не найден"):
|
|
||||||
store.mark_config_applied("ghost", applied=True, actor="system")
|
|
||||||
|
|
||||||
|
|
||||||
# -- устройства -----------------------------------------------------------------
|
|
||||||
def test_device_register_normalizes_mac_and_audits(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
created = store.register_device(
|
|
||||||
Device(mac="AA-BB-CC-DD-EE-FF", name="notebook", address="192.168.1.50"),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
assert created.mac == "aa:bb:cc:dd:ee:ff"
|
|
||||||
assert store.get_device("AA:BB:CC:DD:EE:FF") == created
|
|
||||||
|
|
||||||
updated = store.register_device(
|
|
||||||
Device(mac="aabbccddeeff", name="notebook", policy="direct", last_seen="2026-01-01T00:00:00.000Z"),
|
|
||||||
actor="ui:admin",
|
|
||||||
)
|
|
||||||
assert updated.id == created.id and updated.policy == "direct"
|
|
||||||
assert store.counts()["devices"] == 1
|
|
||||||
|
|
||||||
assert store.delete_device("aa:bb:cc:dd:ee:ff", actor="ui:admin") is True
|
|
||||||
assert store.delete_device("aa:bb:cc:dd:ee:ff", actor="ui:admin") is False
|
|
||||||
assert store.list_devices() == []
|
|
||||||
|
|
||||||
actions = [entry.action for entry in store.list_audit(entity=Entity.DEVICE.value)]
|
|
||||||
assert actions == ["delete", "update", "create"]
|
|
||||||
update_entry = store.list_audit(entity=Entity.DEVICE.value)[1]
|
|
||||||
assert update_entry.old_value["policy"] == "default"
|
|
||||||
assert update_entry.new_value["policy"] == "direct"
|
|
||||||
|
|
||||||
|
|
||||||
def test_device_validation(tmp_path: Path) -> None:
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
with pytest.raises(ValidationError, match="MAC"):
|
|
||||||
store.register_device(Device(mac="zz:zz", name="x"), actor="system")
|
|
||||||
with pytest.raises(ValidationError, match="policy"):
|
|
||||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="x", policy="кибератака"),
|
|
||||||
actor="system")
|
|
||||||
with pytest.raises(ValidationError, match="name"):
|
|
||||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="Not A Name"), actor="system")
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_actor_recorded_per_mutation(tmp_path: Path) -> None:
|
|
||||||
"""GIVEN изменение через сервисный слой, THEN в audit_log есть actor и old/new."""
|
|
||||||
db, store = make_store(tmp_path)
|
|
||||||
with db:
|
|
||||||
store.set_mode(Mode.SPLIT, actor="api:token-42")
|
|
||||||
entry = store.list_audit(entity=Entity.MODE.value)[0]
|
|
||||||
assert entry.actor == "api:token-42"
|
|
||||||
assert entry.ts.endswith("Z")
|
|
||||||
assert entry.old_value == {"mode": "off"}
|
|
||||||
assert entry.new_value == {"mode": "split"}
|
|
||||||
assert entry.status == "ok"
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
| `docker-compose.stub.yml` | DEV-ONLY оверрайд: health-заглушка до мержа #31 | #21 |
|
|
||||||
| `api_stub.py` | минимальный `/api/v1/health` для проверки плумбинга стенда | #21 |
|
|
||||||
| `verify-dev-stand.sh` | проверка AC #21 на Docker-хосте (`make dev-verify`) | #21 |
|
|
||||||
| `dev-stand.md` | runbook dev-стенда: запуск, AC, откат, pitfalls | #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`.
|
|
||||||
|
|
||||||
## Dev-стенд (задача #21)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-up # поднять стенд (compose project awg-dash-dev)
|
|
||||||
make dev-verify # AC: health 200 + /config /data переживают restart
|
|
||||||
make dev-up-stub # + health-заглушка, пока нет API (#31)
|
|
||||||
make dev-logs
|
|
||||||
make dev-down
|
|
||||||
```
|
|
||||||
|
|
||||||
* Состояние: `/opt/awg-dash-dev/{config,data}` на Docker-хосте (`AWG_DASH_HOST_DIR`).
|
|
||||||
* Хост-порт: `AWG_DASH_PORT` (по умолчанию 8180 — 8080 занят на dev-хосте; в контейнере всегда 8080).
|
|
||||||
* Подробности, таблица AC, откат и pitfalls — [dev-stand.md](dev-stand.md).
|
|
||||||
|
|
||||||
### Тестовый стенд без реального провайдера
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test-netns # netns-топология client/gw/uplink + мок WireGuard-endpoint
|
|
||||||
make test-netns-ci # то же в привилегированном контейнере (путь для CI, #17)
|
|
||||||
make test-manifests # статическая проверка compose-файлов (без Docker)
|
|
||||||
make lint-shell # shellcheck для скриптов стенда
|
|
||||||
```
|
|
||||||
|
|
||||||
Описание топологии, тестов и pitfalls — [../tests/netns/README.md](../tests/netns/README.md).
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# Проверка dev/staging стенда (#21) — фактические прогоны на Docker-хосте
|
|
||||||
|
|
||||||
Дата: 2026-10-22 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17)
|
|
||||||
Ветка: `feat/21-dev-stand`, HEAD `d63b9cea` · Ref: #21, #15, #17, #18, #31, #56
|
|
||||||
|
|
||||||
## 0. Стенд запускает именно то, что в ветке
|
|
||||||
|
|
||||||
Актуальные блобы для раунда 3 — в §7.3 (эта таблица фиксирует состояние раунда 2).
|
|
||||||
|
|
||||||
Каталог стенда `(/opt/awg-dash-dev/repo)` не является git-чекаутом (`git clone` по HTTP обрывается
|
|
||||||
на этом Gitea — `fetch-pack: invalid index-pack output`), поэтому файлы сверены побайтово
|
|
||||||
через git-blob-SHA с деревом HEAD:
|
|
||||||
|
|
||||||
| файл | git blob (HEAD == хост) |
|
|
||||||
|---|---|
|
|
||||||
| `Makefile` | `638ec381f3d1e57a8ab84f194e40ff6a0c51d8cf` |
|
|
||||||
| `deploy/docker-compose.dev.yml` | `6bed89a5ea2a197aa9f6fd6bcda6d8131303114d` |
|
|
||||||
| `deploy/docker-compose.stub.yml` | `936efb38dfff4ba8b942c9757acd4ba9c39c4064` |
|
|
||||||
| `deploy/docker-compose.test.yml` | `b628c129ae4de9b33875503b2c8273675b235a84` |
|
|
||||||
| `deploy/verify-dev-stand.sh` | `c4e13f11f9023700be531ebb312ad33d7beea4d8` |
|
|
||||||
| `deploy/api_stub.py` | `93663113f118676a7e896e0e9d32bd20a21f6a0e` |
|
|
||||||
| `tests/deploy/validate_compose.py` | `0fa8b3c02675a364f4f5b447220680a7818faa5a` |
|
|
||||||
| `tests/deploy/pytest.ini` | `7299b6b45487dd4f214f0444e2f0f5c237eb2fac` |
|
|
||||||
| `tests/deploy/.coveragerc` | `9b41183e29a155b5498c3722cf4639e9c69268c6` |
|
|
||||||
| `tests/deploy/test_api_stub.py` | `1d5cec3a72b02da6cb48a6ca433e7699b768f631` |
|
|
||||||
| `tests/deploy/test_validate_compose.py` | `fb045949848cb70a1880614201930c2d4ecd9d0a` |
|
|
||||||
| `tests/netns/lib.sh` | `806375299b7c3932d20b275d98b72f65e48940ec` |
|
|
||||||
| `tests/netns/run.sh` | `c6797bf9c42abe7c4f72cf5402af82e2192ee867` |
|
|
||||||
| `tests/netns/topology.sh` | `6c13a7cfd4d9368ee7d6cb2efca85d890b803e59` |
|
|
||||||
| `tests/netns/netns_common.py` | `9d3bfd94cd8e8b321af92274835b3f0ebc48c842` |
|
|
||||||
| `tests/netns/README.md` | `845863188ffb26c1aae7e475addc0c511e0d8f1f` |
|
|
||||||
| остальные файлы `tests/netns/` (5) | без изменений с раунда 1 |
|
|
||||||
|
|
||||||
## 1. Предварительные пакеты на стенд-хосте
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt-get install -y make python3-yaml python3-pytest python3-pytest-cov shellcheck
|
|
||||||
```
|
|
||||||
|
|
||||||
(`make` на хосте отсутствовал — без него гейты `make *` невоспроизводимы; пакеты добавлены
|
|
||||||
сверх существующего набора, ничего не удалялось и не переконфигурировалось.)
|
|
||||||
|
|
||||||
## 2. Гейты (реальные прогоны)
|
|
||||||
|
|
||||||
Лог целиком: `/opt/awg-dash-dev/verify-final-1792617226.log` (на хосте).
|
|
||||||
|
|
||||||
| гейт | команда | результат |
|
|
||||||
|---|---|---|
|
|
||||||
| манифесты | `make test-manifests` | exit 0, `validate-compose: ok (1 предупреждений)`¹ |
|
|
||||||
| статический анализ шелла | `make lint-shell` | exit 0 |
|
|
||||||
| unit-тесты манифестов | `python3 -m pytest tests/deploy -q --no-cov` | exit 0 |
|
|
||||||
| netns-харнесс (AC-3) | `make test-netns` | exit 0, `8 ok / 0 fail / 0 err`, отчёт `/tmp/awg-netns/report.json` |
|
|
||||||
| AC-1 (health 200) | `make dev-up` + `curl :8180/api/v1/health` | HTTP 200, контейнер `healthy` |
|
|
||||||
| AC-2 (состояние переживает рестарт) | `make dev-verify` | `[ ok ] AC-1`, `[ ok ] AC-2` ×2, exit 0 |
|
|
||||||
| логи | `make dev-logs` | стрим логов, есть `[api-stub] ... GET /api/v1/health 200` |
|
|
||||||
|
|
||||||
¹ предупреждение: `build.dockerfile deploy/Dockerfile отсутствует в дереве (ожидаемо до мержа #18)`.
|
|
||||||
Образ `awg-dash:dev` для стенда собран из Dockerfile PR #49 (ветка `feat/18-docker-runtime`), в дерево
|
|
||||||
этой ветки он не входит: `make build` вернёт внятную ошибку до мержа #18. Валидатор переключается в
|
|
||||||
режим ошибки флагом `make test-manifests MANIFEST_ARGS=--require-dockerfile` — этот режим нужно включить
|
|
||||||
в CI гейте после мержа #18.
|
|
||||||
|
|
||||||
### AC-1 — детали
|
|
||||||
|
|
||||||
```
|
|
||||||
$ make dev-up
|
|
||||||
# (авто-режим: backend/awg_api/app.py отсутствует (#31) → + deploy/docker-compose.stub.yml)
|
|
||||||
$ curl -s -w '\nHTTP=%{http_code}\n' http://127.0.0.1:8180/api/v1/health
|
|
||||||
{"issue": "#31", "note": "заглушка dev-стенда: реальный API появится в #31", "status": "ok",
|
|
||||||
"stub": true, "stub_env": "1", "uptime_s": 14.909}
|
|
||||||
HTTP=200
|
|
||||||
$ docker ps --filter name=awg-dash-dev
|
|
||||||
awg-dash-dev Up 15 seconds (healthy) 0.0.0.0:8180->8080/tcp, [::]:8180->8080/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Почему с заглушкой: FastAPI-каркас (#31) ещё не реализован, сам образ HTTP не поднимает (entrypoint
|
|
||||||
пишет `awg_api/app.py ещё не реализован` и ждёт). Чтобы AC-1 #21 проверялся на живом compose-стенде
|
|
||||||
уже в Sprint 0, `make dev-up` добавляет оверрайд `docker-compose.stub.yml` **только пока
|
|
||||||
`backend/awg_api/app.py` отсутствует**; ответ явно помечен `"stub": true`. После мержа #31 оверрайд
|
|
||||||
не добавляется, `make dev-up` поднимает реальный API без правок Makefile.
|
|
||||||
|
|
||||||
### AC-2 — детали
|
|
||||||
|
|
||||||
```
|
|
||||||
$ make dev-verify
|
|
||||||
[verify][ ok ] AC-1: http://127.0.0.1:8180/api/v1/health -> 200 (api=stub)
|
|
||||||
[verify] AC-2: пишу маркеры в /config и /data и перезапускаю контейнер
|
|
||||||
[verify][ ok ] AC-2: /config и /data пережили restart и пересоздание контейнера
|
|
||||||
[verify][ ok ] AC-2: маркеры видны на хосте (/opt/awg-dash-dev/{config,data}) — это bind mount, а не слой контейнера
|
|
||||||
|
|
||||||
$ ls /opt/awg-dash-dev/config /opt/awg-dash-dev/data
|
|
||||||
/opt/awg-dash-dev/config/.ac2-marker (24 байта)
|
|
||||||
/opt/awg-dash-dev/data/.ac2-marker (24 байта)
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверяется два сценария: `docker compose restart` (как в AC) и `up -d --force-recreate` (сильнее).
|
|
||||||
|
|
||||||
### AC-3 (netns) — детали
|
|
||||||
|
|
||||||
```
|
|
||||||
[netns] создаю netns: client=awgt21-client gw=awgt21-gw uplink=awgt21-uplink
|
|
||||||
[ ok ] стенд поднят: awgt21-client(10.213.0.2) -> awgt21-gw(10.213.0.1/10.214.0.1) -> awgt21-uplink(10.214.0.2)
|
|
||||||
[ ok ] policy routing: fwmark 51820 -> table 51820 (default via 10.214.0.2)
|
|
||||||
[ ok ] mock endpoint: udp/51820 в awgt21-uplink; детектор утечки: 198.51.100.9:9999/udp
|
|
||||||
Ran 8 tests in 5.447s — OK
|
|
||||||
test_01_namespaces_and_links ... test_08_killswitch_blocks_direct_egress — все ok
|
|
||||||
[ ok ] топология снесена, namespace'ы удалены (ip netns list пусто)
|
|
||||||
```
|
|
||||||
|
|
||||||
`report.json`: `{"suite": "tests/netns", "tests": 8, "passed": 8, "failures": 0, "errors": 0,
|
|
||||||
"skipped": 0, "returncode": 0, "duration_s": 5.447}`
|
|
||||||
|
|
||||||
## 3. Что чинилось в этой итерации
|
|
||||||
|
|
||||||
| проблема | причина | правка |
|
|
||||||
|---|---|---|
|
|
||||||
| `make dev-up` давал `unhealthy` и AC-1 не выполнялся | API (#31) не реализован | `Makefile`: авто-подключение stub-оверрайда, пока нет `backend/awg_api/app.py`; `dev-up-app` / `dev-up-stub` для явных режимов |
|
|
||||||
| `make dev-verify` печатал `[verify][fail] нет монтирования /config` при реально сохранённых томах и падал | `verify-dev-stand.sh` кэшировал id контейнера и делал `docker inspect` по нему после `--force-recreate` → `no such object` | перезапрос id (`cid()`) после пересоздания и явный `fatal_env` вместо ложного fail |
|
|
||||||
| `dev-up` падал без `deploy/Dockerfile` (`--build`) | Dockerfile живёт в #18, ветка не смёржена | `--build` форсируется только при наличии `deploy/Dockerfile` |
|
|
||||||
|
|
||||||
## 4. Ограничения и что делать дальше
|
|
||||||
|
|
||||||
1. **CI-гейты (DoD) не автоматизированы:** Gitea Actions runner для `embeddevio/awg-dash` не зарегистрирован
|
|
||||||
(см. отчёт в #17, run #1 остаётся `queued`). Все гейты в таблице §2 прогнаны **вручную** на стенд-хосте;
|
|
||||||
после регистрации runner'а в #17 они переносятся в workflow без изменений команд.
|
|
||||||
2. **Порт:** на `192.168.1.147` хост-порт 8080 занят (`drawio`), поэтому стенд публикуется как `8180:8080`
|
|
||||||
(контейнерный порт приложения — 8080, `AWG_DASH_PORT` переопределяет хост-порт).
|
|
||||||
3. **Сборка образа** — только после мержа #18; до этого образ `awg-dash:dev` собирается из ветки #18 вручную.
|
|
||||||
4. **Prod/staging:** stub-оверрайд и `deploy/api_stub.py` — DEV-ONLY. В staging/prod поднимается только
|
|
||||||
`docker-compose.dev.yml` (или прод-манифест) без stub-файла.
|
|
||||||
|
|
||||||
## 5. Откат / очистка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-down # остановить стенд, каталоги /opt/awg-dash-dev/{config,data} сохраняются
|
|
||||||
make dev-reset # + удалить осиротевшие контейнеры/сети проекта
|
|
||||||
make test-netns # netns-харнесс сносит топологию сам; аварийно: bash tests/netns/topology.sh down
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Раунд 2 (issue #56): netns в контейнере и покрытие тестов
|
|
||||||
|
|
||||||
Дата: 2026-10-28 · Хост: `192.168.1.147` · Ветка: `feat/21-dev-stand` · Ref: #21, #15, #17, #56
|
|
||||||
|
|
||||||
Ревью qa-engineer (раунд 1) нашло два блокера: `make test-netns-ci` падал в контейнере
|
|
||||||
(`mount of /sys failed: Operation not permitted`, exit 255 вместо 0/1/3/4) и гейт покрытия
|
|
||||||
`pytest tests/deploy` обходился флагом `--no-cov` (0.00 %, `deploy/api_stub.py` 0 %).
|
|
||||||
|
|
||||||
### 6.1 Причина падения netns в контейнере
|
|
||||||
|
|
||||||
Воспроизведено на образе `awg-dash:dev` (iproute2 6.11.0):
|
|
||||||
|
|
||||||
| команда в контейнере | результат |
|
|
||||||
|---|---|
|
|
||||||
| `ip netns add <ns>` | rc=0 |
|
|
||||||
| `ip netns exec <ns> true` | `mount of /sys failed: Operation not permitted`, rc=255 |
|
|
||||||
| `ip -n <ns> link set lo up` | `mount of /sys failed: Operation not permitted`, rc=255 |
|
|
||||||
| `nsenter --net=/var/run/netns/<ns> true` | rc=0 |
|
|
||||||
|
|
||||||
`ip netns exec` и `ip -n` перемонтируют `/sys` внутри namespace; в контейнере это падает.
|
|
||||||
Поэтому харнесс больше не использует эти формы: вход в namespace идёт через `ns_exec()` /
|
|
||||||
`ns_prefix()` (`tests/netns/lib.sh`) и `netns_common.ns_sh()`, которые один раз пробуют
|
|
||||||
`ip netns exec`, а при отказе переключаются на `nsenter --net=...` (`AWG_NS_EXEC_MODE=auto|ip|nsenter`).
|
|
||||||
Все `ip -n "$NS"` заменены на `ns_exec "$NS" ip ...`.
|
|
||||||
|
|
||||||
Коды возврата приведены к контракту `0/1/3/4` (`run.sh`, `topology.sh`): неожиданный код
|
|
||||||
утилиты (255) нормализуется в `4` («ошибка стенда»), нет прав — `3`, упавшие тесты — `1`.
|
|
||||||
|
|
||||||
**Контракт действует на уровне контейнера, а не через make.** GNU make возвращает `2`
|
|
||||||
на любой упавший рецепт, поэтому `make test-netns-ci` при коде контейнера `3` или `4`
|
|
||||||
сам отдаёт `2` (в выводе остаётся только `make: *** [Makefile:…] Error 4`). Для CI #17
|
|
||||||
добавлена точка входа без make — `bash tests/netns/ci.sh`: она запускает тот же контейнер
|
|
||||||
(`docker compose … run --rm -T netns`) и возвращает его код как есть. Проверено в §7.2.
|
|
||||||
|
|
||||||
### 6.2 Реальные прогоны
|
|
||||||
|
|
||||||
| прогон | команда | результат |
|
|
||||||
|---|---|---|
|
|
||||||
| стенд на хосте | `make test-netns` | exit 0, 8 ok / 0 fail, 5.46 s, `ip netns list` пуст, мок и sink не остались в процессах |
|
|
||||||
| стенд в контейнере (путь CI) | `make test-netns-ci` | exit 0, `[netns] вход в netns: nsenter`, backend `iptables`, 8 ok / 0 fail, 5.50 s, `[ ok ] топология снесена`, отчёт `/artifacts/netns-report.json` |
|
|
||||||
| нет прав | `docker run` без `--privileged` | exit **3**, `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN` |
|
|
||||||
| форс нерабочего режима | `AWG_NS_EXEC_MODE=ip` в контейнере | exit **4**, `mount of /sys failed` → `[warn] run.sh: непредвиденный код 255 — трактую как ошибку стенда (4)` |
|
|
||||||
| упавшие тесты | синтетический проваленный тест в контейнере | exit **1**, `[warn] netns-стенд: тесты/очистка упали (код 1)` |
|
|
||||||
| гейт покрытия | `make test-deploy` | exit 0, 51 passed, coverage **98.86 %** (`deploy/api_stub.py` 98 %, `tests/deploy/validate_compose.py` 99 %) |
|
|
||||||
| манифесты / shellcheck | `make test-manifests`, `make lint-shell` | exit 0 / exit 0 |
|
|
||||||
| стабильность в контейнере | 5 прогонов `make test-netns-ci` подряд | 5/5 exit 0, 8 ok каждый, ни одного «остался netns» |
|
|
||||||
| AC-1 после правок | `curl :8180/api/v1/health` | HTTP 200, `"stub": true`, контейнер `healthy` |
|
|
||||||
| AC-2 после правок | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | exit 0: маркеры `/config` и `/data` пережили restart и пересоздание, видны на хосте (bind mount) |
|
|
||||||
|
|
||||||
### 6.3 Что изменено для гейта покрытия
|
|
||||||
|
|
||||||
* `tests/deploy/pytest.ini` — отдельный профиль: `--cov=deploy --cov=tests/deploy
|
|
||||||
--cov-config=tests/deploy/.coveragerc --cov-fail-under=70`. Раньше `pytest tests/deploy`
|
|
||||||
наследовал addopts корневого `pyproject.toml` (`--cov=awg_agent --cov=awg_api`), поэтому
|
|
||||||
покрытие всегда было 0.00 % и RC=1 — гейт гоняли с `--no-cov` в обход порога.
|
|
||||||
* `tests/deploy/.coveragerc` — из покрытия исключены сами тестовые модули
|
|
||||||
(`tests/deploy/test_*.py`), иначе «покрытие» измеряет тестовый код.
|
|
||||||
* `tests/deploy/test_api_stub.py` — 9 тестов на живой HTTP-сервер заглушки: 200 и
|
|
||||||
`"stub": true` на `/api/v1/health`, алиас `/healthz`, игнор query-string, 404
|
|
||||||
`not_implemented` на прочие пути, HEAD, отказ на POST, разбор аргументов `main()`.
|
|
||||||
* `tests/deploy/test_validate_compose.py` — 39 негативных/граничных тестов валидатора:
|
|
||||||
каждая инварианта AC #21 ломается по отдельности (порт, тома, healthcheck, NET_ADMIN,
|
|
||||||
`/dev/net/tun`, `ip_forward`, privileged netns-сервис, DEV-ONLY-метка, `--require-dockerfile`).
|
|
||||||
* `deploy/api_stub.py` — `make_server()` выделен из `main()` (тесты поднимают сервер на порту 0).
|
|
||||||
* `make test-deploy` — гейт покрытия. Утверждение раунда 2 «`make test-netns-ci` прокидывает
|
|
||||||
`AWG_NS_EXEC_MODE`» было **неверным**: make-алиас `NETNS_EXEC_MODE ?= auto` перекрывал
|
|
||||||
переменную. Исправлено в §7.1 (проверено прогоном в §7.2).
|
|
||||||
* `topology.sh`: снос топологии удаляет namespace'ы безусловно и до 5 раз проверяет
|
|
||||||
результат — в контейнере `ip netns delete` иногда «терялся» и `run.sh` отдавал код 4
|
|
||||||
на зелёных тестах (флак 1 из 5 прогонов, после правки — 5/5 зелёных).
|
|
||||||
|
|
||||||
### 6.4 Что закрыто и что осталось
|
|
||||||
|
|
||||||
Закрыты оба блокера раунда 1 (`make test-netns-ci` работает в контейнере, покрытие
|
|
||||||
измеряется и ≥70 %). `CI green` по-прежнему недостижим: Gitea Actions runner не
|
|
||||||
зарегистрирован (#17), все прогоны в таблице 6.2 — ручные на `192.168.1.147`; команды
|
|
||||||
`make test-netns-ci` и `make test-deploy` — ровно те, что должны попасть в workflow #17.
|
|
||||||
|
|
||||||
Порт и stub-оверрайд (см. §4) без изменений: `privileged: true` и `cap_add SYS_MODULE`
|
|
||||||
из `deploy/docker-compose.test.yml` — только DEV/TEST, для staging/prod непригодны.
|
|
||||||
|
|
||||||
## 7. Раунд 3 (ревью PR #53, раунд 2): контракт кодов, env-алиас, сборка без сети
|
|
||||||
|
|
||||||
Дата: 2026-10-28 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17)
|
|
||||||
Ветка: `feat/21-dev-stand` · Ref: #21, #15, #17, #56
|
|
||||||
|
|
||||||
Ревью qa-engineer (раунд 2, PR #53 `issuecomment-664`) дало три воспроизводимых замечания:
|
|
||||||
контракт `0/1/3/4` теряется на уровне make, документированный `AWG_NS_EXEC_MODE` перекрыт
|
|
||||||
make-алиасом, `make dev-up` падает при сборке без DNS. Все три закрыты правками ниже.
|
|
||||||
|
|
||||||
### 7.1 Что изменено
|
|
||||||
|
|
||||||
| замечание | правка |
|
|
||||||
|---|---|
|
|
||||||
| `make test-netns-ci` отдаёт `2` на любом провале → CI #17 не отличит 3/4/1 | новая точка входа `tests/netns/ci.sh`: `docker compose -f deploy/docker-compose.test.yml run --rm -T netns`, код контейнера возвращается как есть (`2` у неё = сломан вызов/нет docker/образа). `make test-netns-ci` делегирует в неё, в комментарии таргета и в §6.1 зафиксировано «make → 2» |
|
|
||||||
| `AWG_NS_EXEC_MODE=ip make test-netns-ci` игнорировался (`Makefile:19` `NETNS_EXEC_MODE ?= auto` + `:117`) | `NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto)` — документированное имя имеет приоритет, make-алиас остался для совместимости; README и `dev-stand.md` приведены к факту |
|
|
||||||
| `make dev-up` форсит `--build` и падает без DNS | `DEV_SKIP_BUILD=1` → `up -d --no-build`; `DEV_UP_BUILD` переопределяем (`?=`) и по-прежнему принимается из командной строки (`DEV_UP_BUILD=`); строка в `dev-stand.md` (быстрый старт + pitfalls + таблица переменных) |
|
|
||||||
|
|
||||||
### 7.2 Реальные прогоны (дерево сверено по git-blob-SHA, см. §7.3)
|
|
||||||
|
|
||||||
| прогон | команда | код | что видно в выводе |
|
|
||||||
|---|---|---|---|
|
|
||||||
| CI-точка входа, зелёный | `bash tests/netns/ci.sh` | **0** | `[ci] образ=awg-dash:dev режим netns=auto`, `вход в netns: nsenter`, `Ran 8 tests`, `8 ok`, `[ ok ] топология снесена`, `[ci][ ok ]` |
|
|
||||||
| make-путь, зелёный | `make test-netns-ci` | **0** | те же 8 ok через `ci.sh` |
|
|
||||||
| код 4 (форс нерабочего режима) | `AWG_NS_EXEC_MODE=ip bash tests/netns/ci.sh` | **4** | `[ci] … режим netns=ip`, `mount of /sys failed` → `[warn] код 255 → 4` |
|
|
||||||
| код 3 (нет прав на netns) | `AWG_NS_PRIVILEGED=0 bash tests/netns/ci.sh` | **3** | `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN`, `[ci][blocked]` |
|
|
||||||
| код 1 (упавшие тесты) | синтетический падающий тест в `tests/netns/` → `bash tests/netns/ci.sh` | **1** | `Ran 9 tests`, `FAILED (failures=1)`, `8 ok, 1 fail` (тест удалён после прогона) |
|
|
||||||
| контракт make при коде 4 | `AWG_NS_EXEC_MODE=ip make test-netns-ci` | **2** (контейнер 4) | `[ci] … режим netns=ip` — **env больше не перекрывается**; `make: *** [Makefile:133: test-netns-ci] Error 4` |
|
|
||||||
| контракт make при коде 1 | синтетический падающий тест → `make test-netns-ci` | **2** (контейнер 1) | `make: *** [Makefile:133: test-netns-ci] Error 1` |
|
|
||||||
| AC-1 | `make dev-up DEV_SKIP_BUILD=1` + `curl :8180/api/v1/health` | **0**, HTTP **200** | `up -d --no-build`, контейнер `Up (healthy)`, ответ `"stub": true` |
|
|
||||||
| AC-1 со сборкой (кеш тёплый) | `make dev-up` (без `DEV_SKIP_BUILD`) | **0** | `222` шагов `CACHED`, `Image awg-dash:dev Built`, контейнер пересоздан и `healthy` — на холодном кеше этот путь требует DNS, поэтому кноб нужен |
|
|
||||||
| AC-2 | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | **0** | маркеры `/config` и `/data` пережили `restart` и `--force-recreate`, видны на хосте (bind mount) |
|
|
||||||
| гейт покрытия | `make test-deploy` | **0** | `51 passed`, `Required test coverage of 70% reached. Total coverage: 98.86%` (`TOTAL 176 строк, 2 miss`) |
|
|
||||||
| манифесты | `make test-manifests` | **0** | `validate-compose: ok (1 предупреждений)` — предупреждение про `deploy/Dockerfile` (до мержа #18) |
|
|
||||||
| shellcheck | `make lint-shell` | **0** | проверен и новый `tests/netns/ci.sh` |
|
|
||||||
| уборка | `ip netns list`; `docker ps -a --filter name=awg-dash-test` | — | пусто, контейнеров после прогонов не осталось |
|
|
||||||
|
|
||||||
Логи прогонов на стенд-хосте: `/tmp/r3_a.log` … `/tmp/r3_m.log`
|
|
||||||
(`a` — зелёный `ci.sh`, `b` — код 4, `c` — код 3, `d` — make при коде 4, `e`/`f` — код 1 через `ci.sh`/make,
|
|
||||||
`g` — `make test-netns-ci`, `h` — `dev-up DEV_SKIP_BUILD=1` + health, `i` — AC-2, `j` — `test-deploy`,
|
|
||||||
`k` — `test-manifests`, `l` — `lint-shell`, `m` — `dev-up` со сборкой).
|
|
||||||
|
|
||||||
### 7.3 Блобы: файлы на стенд-хосте == дерево ветки
|
|
||||||
|
|
||||||
| файл | git blob |
|
|
||||||
|---|---|
|
|
||||||
| `Makefile` | `55c3ea5c98c7980c9bc3fcec8483d1140e670b2e` |
|
|
||||||
| `tests/netns/ci.sh` | `561ca3976ad3749fbde79b8d163e007bace145de` |
|
|
||||||
| `tests/netns/README.md` | `a4642f845f38fca1da0019af7e91d95be9e4d97c` |
|
|
||||||
| `deploy/dev-stand.md` | `60495f7cd8eba10b40dc9aef469bea210b50e8e1` |
|
|
||||||
| `deploy/VERIFICATION-21.md` | этот файл (блоб меняется этим же коммитом) |
|
|
||||||
| остальные файлы стенда | без изменений с раунда 2 (§0) |
|
|
||||||
|
|
||||||
### 7.4 Что осталось
|
|
||||||
|
|
||||||
* **CI green (DoD)** — за задачей #17: у репозитория появился runner (контейнер
|
|
||||||
`gitea-runner-awg-repo`, запуски runs 22–31), но workflow, покрывающего ветку #21, в `main`
|
|
||||||
ещё нет, поэтому все гейты выше прогнаны вручную. Команды для workflow:
|
|
||||||
`bash tests/netns/ci.sh`, `make test-deploy`, `make test-manifests`, `make lint-shell` —
|
|
||||||
они уже отдают нужные коды без make-обёртки.
|
|
||||||
* Merge в `main` и любой prod/staging-деплой — за TechLead (ADVISORY): автор ревью не мержит.
|
|
||||||
* `verify-dev-stand.sh` после `--force-recreate` печатает статус до перехода в `healthy`,
|
|
||||||
но AC-проверки при этом ждут health (`wait_health 20`) и прогон возвращает `0` — на вердикт
|
|
||||||
не влияет.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""api_stub.py — DEV-ONLY заглушка health-эндпоинта (задача #21).
|
|
||||||
|
|
||||||
Существует ровно для одной цели: до реализации FastAPI-каркаса (#31) проверить
|
|
||||||
плумбинг dev-стенда — маппинг порта, healthcheck контейнера, тома /config и
|
|
||||||
/data, переживание `docker compose restart`.
|
|
||||||
|
|
||||||
Отдаёт:
|
|
||||||
GET /api/v1/health -> 200 {"status":"ok","stub":true,...}
|
|
||||||
GET /healthz -> 200 (алиас для отладки)
|
|
||||||
всё остальное -> 404 {"error":"not_implemented","issue":"#31"}
|
|
||||||
|
|
||||||
Чего НЕ делает: никакой логики шлюза, никакого доступа к awg/iptables.
|
|
||||||
В staging/prod запускать запрещено (см. deploy/dev-stand.md).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
STARTED_AT = time.time()
|
|
||||||
STUB_ENV = os.environ.get("AWG_API_STUB", "0")
|
|
||||||
|
|
||||||
HEALTH_PATHS = ("/api/v1/health", "/healthz")
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "awg-dash-stub/0.1"
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def _json(self, code: int, payload: dict) -> None:
|
|
||||||
body = (json.dumps(payload, sort_keys=True) + "\n").encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802 (HTTP-метод)
|
|
||||||
path = self.path.split("?", 1)[0]
|
|
||||||
if path in HEALTH_PATHS:
|
|
||||||
self._json(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"stub": True,
|
|
||||||
"issue": "#31",
|
|
||||||
"note": "заглушка dev-стенда: реальный API появится в #31",
|
|
||||||
"uptime_s": round(time.time() - STARTED_AT, 3),
|
|
||||||
"stub_env": STUB_ENV,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._json(404, {"error": "not_implemented", "issue": "#31", "path": path})
|
|
||||||
|
|
||||||
def do_HEAD(self) -> None: # noqa: N802
|
|
||||||
self.send_response(200 if self.path.split("?", 1)[0] in HEALTH_PATHS else 404)
|
|
||||||
self.send_header("Content-Length", "0")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args) -> None: # лог в stdout контейнера
|
|
||||||
print(f"[api-stub] {self.address_string()} {fmt % args}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def make_server(host: str, port: int) -> ThreadingHTTPServer:
|
|
||||||
"""HTTP-сервер заглушки.
|
|
||||||
|
|
||||||
Вынесен из main(), чтобы тесты (tests/deploy/test_api_stub.py) поднимали
|
|
||||||
настоящий HTTP-сервер на свободном порту (port=0) и проверяли коды ответов.
|
|
||||||
"""
|
|
||||||
return ThreadingHTTPServer((host, port), Handler)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="DEV-ONLY заглушка health для dev-стенда (#21)")
|
|
||||||
ap.add_argument("--host", default="0.0.0.0")
|
|
||||||
ap.add_argument("--port", type=int, default=8080)
|
|
||||||
ap.add_argument("--domain", default="/config", help="каталог, доступность которого проверяем (для лога)")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[api-stub] DEV-ONLY заглушка на {args.host}:{args.port}; каталог {args.domain} "
|
|
||||||
f"{'доступен' if os.path.isdir(args.domain) else 'НЕ доступен'}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
make_server(args.host, args.port).serve_forever()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# dev/staging стенд awg-dash — runbook
|
|
||||||
|
|
||||||
Задача **#21** (эпик **#15**). Стенд для локальной разработки и проверки
|
|
||||||
acceptance criteria до появления staging-окружения.
|
|
||||||
|
|
||||||
* Docker-хост: **192.168.1.147** (`ssh root@192.168.1.147`), Docker 29.4.x + compose v5.
|
|
||||||
* Каталог состояния: **`/opt/awg-dash-dev/{config,data}`** (bind mount → `/config`, `/data`).
|
|
||||||
* Compose project: **`awg-dash-dev`** (изолирован от PoC-контейнеров `amneziawg-client`).
|
|
||||||
* Хост-порт приложения: **8180** (`AWG_DASH_PORT`), внутри контейнера — **8080**.
|
|
||||||
Порт 8080 на dev-хосте занят другим сервисом (drawio), поэтому проброс нестандартный.
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# на Docker-хосте, из корня репозитория
|
|
||||||
export AWG_DASH_HOST_DIR=/opt/awg-dash-dev
|
|
||||||
make dev-up # docker compose up -d --build (проект awg-dash-dev)
|
|
||||||
make dev-verify # проверка AC: health 200 + состояние переживает restart
|
|
||||||
curl -fsS http://127.0.0.1:8180/api/v1/health
|
|
||||||
make dev-logs # логи контейнера
|
|
||||||
make dev-down # остановить (каталоги состояния сохраняются)
|
|
||||||
```
|
|
||||||
|
|
||||||
`dev-up` добавляет `--build`, когда в дереве есть `deploy/Dockerfile` (после мержа #18).
|
|
||||||
Если у хоста нет сети/DNS для `docker build` (офлайн-стенд, CI без прокси), сборку
|
|
||||||
нужно пропустить — образ `awg-dash:dev` уже должен существовать:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-up DEV_SKIP_BUILD=1 # up -d --no-build — использовать готовый образ
|
|
||||||
make dev-up DEV_UP_BUILD= # вообще не передавать флаги сборки
|
|
||||||
```
|
|
||||||
|
|
||||||
До реализации FastAPI-каркаса (**#31**) `/api/v1/health` не отвечает — контейнер
|
|
||||||
работает как агент и помечается `unhealthy`. Чтобы проверить плумбинг стенда
|
|
||||||
(порт, healthcheck, тома, restart) **до** #31, используйте health-заглушку:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-up-stub # + deploy/docker-compose.stub.yml (DEV-ONLY, api_stub.py)
|
|
||||||
make dev-verify-stub # AC-1 проходит на заглушке, в отчёте api=stub
|
|
||||||
```
|
|
||||||
|
|
||||||
Заглушка отвечает `{"status":"ok","stub":true,...}` только на `/api/v1/health` и
|
|
||||||
никогда не должна использоваться в staging/prod.
|
|
||||||
|
|
||||||
## Проверка AC (#21)
|
|
||||||
|
|
||||||
| AC | Команда | Ожидание |
|
|
||||||
|---|---|---|
|
|
||||||
| AC-1: health 200 | `curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8180/api/v1/health` | `200` (после мержа #31; до него — заглушка через `dev-verify-stub`) |
|
|
||||||
| AC-2: состояние переживает restart | `make dev-verify` | маркеры в `/config` и `/data` совпадают до и после `docker compose restart` и `--force-recreate` |
|
|
||||||
| AC-3: netns + мок-endpoint | `make test-netns` | 8/8 тестов, netns `awgt21-{client,gw,uplink}` создаются и сносятся |
|
|
||||||
|
|
||||||
`deploy/verify-dev-stand.sh` (за `make dev-verify`) печатает отчёт и возвращает:
|
|
||||||
|
|
||||||
| Код | Значение |
|
|
||||||
|---|---|
|
|
||||||
| 0 | AC-1 и AC-2 выполнены |
|
|
||||||
| 1 | регрессия стенда (health не 200 с заглушкой, состояние не сохранилось) |
|
|
||||||
| 2 | AC-1 заблокирован: API (#31) ещё не реализован, стенд при этом поднят |
|
|
||||||
| 3 | заблокировано окружением: нет docker/compose или compose-файлов |
|
|
||||||
|
|
||||||
## Состав стенда
|
|
||||||
|
|
||||||
| Файл | Назначение |
|
|
||||||
|---|---|
|
|
||||||
| `docker-compose.dev.yml` | приложение: порт 8080 (наружу `${AWG_DASH_PORT:-8180}`), bind mount `/config` `/data`, healthcheck, NET_ADMIN/NET_RAW/SYS_MODULE, `/dev/net/tun` |
|
|
||||||
| `docker-compose.stub.yml` | DEV-ONLY оверрайд: health-заглушка до #31 (`api_stub.py`) |
|
|
||||||
| `docker-compose.test.yml` | тестовый стенд: привилегированный `netns`-раннер + `app` для API-тестов |
|
|
||||||
| `api_stub.py` | минимальный HTTP-сервер `/api/v1/health` (только dev) |
|
|
||||||
| `verify-dev-stand.sh` | проверка AC на Docker-хосте + JSON-отчёт |
|
|
||||||
| `../tests/netns/` | netns-харнесс: топология client/gw/uplink + мок WireGuard-endpoint |
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
|
||||||
|---|---|---|
|
|
||||||
| `AWG_DASH_PORT` | `8180` | хост-порт приложения |
|
|
||||||
| `AWG_DASH_HOST_DIR` | `/opt/awg-dash-dev` | каталог состояния на хосте |
|
|
||||||
| `AWG_DASH_IMAGE` | `awg-dash:dev` | имя образа (build или pull) |
|
|
||||||
| `AWG_DASH_DOCKERFILE` | `deploy/Dockerfile` | путь к Dockerfile для build |
|
|
||||||
| `COMPOSE_PROJECT_NAME` | `awg-dash-dev` | изоляция от других стендов |
|
|
||||||
| `AWG_LOG_LEVEL` | `info` | уровень логирования приложения |
|
|
||||||
| `DEV_SKIP_BUILD` | — | `1` → `make dev-up` выполняет `up -d --no-build` (образ уже собран) |
|
|
||||||
| `DEV_UP_BUILD` | авто | переопределение флагов сборки (`--build`, пусто, `--no-build`) |
|
|
||||||
| `AWG_NS_EXEC_MODE` | `auto` | режим входа в netns: `auto|ip|nsenter` (issue #56) |
|
|
||||||
| `NETNS_EXEC_MODE` | `auto` | make-алиас `AWG_NS_EXEC_MODE` (приоритет у `AWG_NS_EXEC_MODE`) |
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
| Зависимость | Состояние | Влияние на стенд |
|
|
||||||
|---|---|---|
|
|
||||||
| #16 (каркас репо, Makefile) | PR #47 | нужен `Makefile`, `requirements.txt`, `deploy/` |
|
|
||||||
| #18 (образ, entrypoint) | PR #49 | нужен `deploy/Dockerfile` для `--build` |
|
|
||||||
| #31 (каркас FastAPI, `/api/v1/health`) | Sprint 2 | без него AC-1 выполняется только с заглушкой |
|
|
||||||
| #17 (CI-пайплайн) | Sprint 0 | гейты для пайплайна: `bash tests/netns/ci.sh` (коды 0/1/3/4), `make test-manifests`, `make test-deploy`, `make lint-shell` |
|
|
||||||
|
|
||||||
## Откат и уборка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-down # остановить контейнеры проекта awg-dash-dev
|
|
||||||
make dev-reset # down --remove-orphans (состояние сохраняется)
|
|
||||||
docker image rm awg-dash:dev # откатить образ (пересобрать из предыдущего коммита)
|
|
||||||
du -sh /opt/awg-dash-dev # состояние стенда
|
|
||||||
```
|
|
||||||
|
|
||||||
Перед откатом образа проверьте проект: `docker compose -p awg-dash-dev ls`.
|
|
||||||
Состояние (`/opt/awg-dash-dev`) не удаляется автоматически — это осознанно: там
|
|
||||||
конфиги туннеля (`/config/awg0.conf`). Удаляйте вручную и только после бэкапа.
|
|
||||||
|
|
||||||
## Pitfalls
|
|
||||||
|
|
||||||
* **Порт 8080 занят** на dev-хосте — наружу публикуется 8180. Меняйте `AWG_DASH_PORT`.
|
|
||||||
* **`/dev/net/tun` обязателен**: без него entrypoint падает (`fail '/dev/net/tun не проброшен'`).
|
|
||||||
* **Права на каталоги состояния**: entrypoint делает `chown awg:awg /config /data`;
|
|
||||||
каталоги создаются на хосте от root — не меняйте владельца на хосте вручную.
|
|
||||||
* **`docker compose restart` сохраняет тома**; `down -v` — нет. Никогда не используйте
|
|
||||||
`down -v` на этом стенде: это удалит `/config` и `/data`.
|
|
||||||
* **Заглушка `api_stub.py`** — только dev: она отвечает 200 на `/api/v1/health` и
|
|
||||||
маскирует отсутствие API. Не включайте `docker-compose.stub.yml` в staging.
|
|
||||||
* **netns-стенд вне Docker** требует root: `make test-netns` вернёт код 3, если нет
|
|
||||||
CAP_NET_ADMIN/CAP_SYS_ADMIN (например, в непривилегированном CI-раннере).
|
|
||||||
* **netns в контейнере (`make test-netns-ci`, `bash tests/netns/ci.sh`)**: `ip netns exec`
|
|
||||||
падает (`mount of /sys failed`), харнесс сам переходит на `nsenter`; контракт кодов
|
|
||||||
`0/1/3/4` действует на уровне контейнера. **make возвращает 2 на любом провале** —
|
|
||||||
в CI вызывайте `bash tests/netns/ci.sh`, иначе ветки 3 и 4 неразличимы (#56).
|
|
||||||
* **`--build` без сети**: после мержа #18 `dev-up` собирает образ; если на хосте нет
|
|
||||||
DNS/доступа к registry, используйте `DEV_SKIP_BUILD=1` (готовый `awg-dash:dev`).
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# dev/staging стенд awg-dash (задача #21, эпик #15).
|
|
||||||
#
|
|
||||||
# Отдельный compose-project `awg-dash-dev` — не пересекается с контейнерами PoC
|
|
||||||
# (amneziawg-client) и другими сервисами dev-хоста.
|
|
||||||
#
|
|
||||||
# Запуск: make dev-up (поднимает стек с образом awg-dash:dev)
|
|
||||||
# Проверка AC: make dev-verify (health 200 + переживание restart для /config /data)
|
|
||||||
# Логи: make dev-logs
|
|
||||||
# Остановка: make dev-down (тома/каталоги состояния сохраняются)
|
|
||||||
#
|
|
||||||
# AC-1 (health 200) до реализации FastAPI-каркаса (#31): сам образ API не поднимает,
|
|
||||||
# поэтому `make dev-up` автоматически добавляет оверрайд с health-заглушкой
|
|
||||||
# (deploy/docker-compose.stub.yml, ответ помечен "stub": true) и стенд проходит
|
|
||||||
# healthcheck. После появления backend/awg_api/app.py оверрайд не добавляется.
|
|
||||||
# Явные режимы: `make dev-up-app` (только реальный образ), `make dev-up-stub`.
|
|
||||||
|
|
||||||
name: ${COMPOSE_PROJECT_NAME:-awg-dash-dev}
|
|
||||||
|
|
||||||
services:
|
|
||||||
awg-dash:
|
|
||||||
image: ${AWG_DASH_IMAGE:-awg-dash:dev}
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: ${AWG_DASH_DOCKERFILE:-deploy/Dockerfile}
|
|
||||||
args:
|
|
||||||
AMNEZIAWG_GO_REF: ${AMNEZIAWG_GO_REF:-master}
|
|
||||||
AMNEZIAWG_TOOLS_REF: ${AMNEZIAWG_TOOLS_REF:-master}
|
|
||||||
container_name: awg-dash-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
# Порт приложения внутри образа всегда 8080 (EXPOSE/AWG_API_PORT).
|
|
||||||
# На dev-хосте 192.168.1.147 порт 8080 занят (drawio) — по умолчанию 8180.
|
|
||||||
ports:
|
|
||||||
- "${AWG_DASH_PORT:-8180}:8080"
|
|
||||||
volumes:
|
|
||||||
# Состояние вынесено на хост, чтобы правки конфигурации переживали
|
|
||||||
# `docker compose restart` (AC задачи #21) и пересоздание контейнера.
|
|
||||||
- ${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config
|
|
||||||
- ${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/data:/data
|
|
||||||
devices:
|
|
||||||
- /dev/net/tun:/dev/net/tun
|
|
||||||
cap_add:
|
|
||||||
# NET_ADMIN — iptables/ipset/ip rule; NET_RAW — ping/socket-проверки;
|
|
||||||
# SYS_MODULE — sysctl net.ipv4.ip_forward и модули ядра (как в ТЗ).
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
- SYS_MODULE
|
|
||||||
sysctls:
|
|
||||||
net.ipv4.ip_forward: "1"
|
|
||||||
net.ipv4.conf.all.src_valid_mark: "1"
|
|
||||||
environment:
|
|
||||||
AWG_CONFIG_DIR: /config
|
|
||||||
AWG_DATA_DIR: /data
|
|
||||||
AWG_RUN_DIR: /run/awg
|
|
||||||
AWG_API_HOST: 0.0.0.0
|
|
||||||
AWG_API_PORT: "8080"
|
|
||||||
AWG_LOG_LEVEL: ${AWG_LOG_LEVEL:-info}
|
|
||||||
AWG_DEV_STAND: "1"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 30s
|
|
||||||
retries: 4
|
|
||||||
stop_grace_period: 20s
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Оверрайд для dev-стенда: health-заглушка вместо ещё не реализованного API.
|
|
||||||
#
|
|
||||||
# Зачем: AC-1 задачи #21 (`curl :8080/api/v1/health` -> 200) физически не может
|
|
||||||
# выполниться до появления FastAPI-каркаса (#31, Sprint 2). Чтобы проверить
|
|
||||||
# плумбинг стенда (порт, healthcheck, тома, restart) уже сейчас, этот оверрайд
|
|
||||||
# подменяет entrypoint на минимальный HTTP-сервер из deploy/api_stub.py.
|
|
||||||
#
|
|
||||||
# Это НЕ приложение: заглушка отдаёт 200 только на /api/v1/health и явно
|
|
||||||
# помечает ответ `"stub": true`. В prod/staging не использовать.
|
|
||||||
#
|
|
||||||
# Запуск: docker compose -f deploy/docker-compose.dev.yml -f deploy/docker-compose.stub.yml up -d
|
|
||||||
# (или make dev-up-stub / make dev-verify-stub)
|
|
||||||
#
|
|
||||||
# После мержа #31 оверрайд не нужен: `make dev-up` поднимает реальный API.
|
|
||||||
|
|
||||||
services:
|
|
||||||
awg-dash:
|
|
||||||
entrypoint: ["python3", "/opt/awg-dash-stub/api_stub.py"]
|
|
||||||
command: ["--host", "0.0.0.0", "--port", "8080", "--domain", "/config"]
|
|
||||||
volumes:
|
|
||||||
- ../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro
|
|
||||||
environment:
|
|
||||||
AWG_API_STUB: "1"
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Тестовый стенд awg-dash (задача #21): netns-топология + мок WireGuard-endpoint.
|
|
||||||
#
|
|
||||||
# Использование:
|
|
||||||
# docker compose -f deploy/docker-compose.test.yml run --rm netns # прогон стенда
|
|
||||||
# docker compose -f deploy/docker-compose.test.yml run --rm netns --keep-topology
|
|
||||||
# docker compose -f deploy/docker-compose.test.yml up -d app # приложение для API-тестов
|
|
||||||
# (make test-netns-ci — обёртка над первым вызовом)
|
|
||||||
#
|
|
||||||
# Контейнеру `netns` нужен --privileged: внутри создаются сетевые namespace
|
|
||||||
# (`ip netns add`) и veth-пары, для этого требуются CAP_SYS_ADMIN + CAP_NET_ADMIN.
|
|
||||||
# Репозиторий монтируется только для чтения; артефакты пишутся в /artifacts и /tmp.
|
|
||||||
|
|
||||||
name: ${COMPOSE_PROJECT_NAME:-awg-dash-test}
|
|
||||||
|
|
||||||
services:
|
|
||||||
netns:
|
|
||||||
image: ${AWG_DASH_IMAGE:-awg-dash:dev}
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: ${AWG_DASH_DOCKERFILE:-deploy/Dockerfile}
|
|
||||||
container_name: awg-dash-test-netns
|
|
||||||
privileged: true
|
|
||||||
working_dir: /repo
|
|
||||||
entrypoint: ["/bin/bash", "/repo/tests/netns/run.sh"]
|
|
||||||
command: ["--report", "/artifacts/netns-report.json"]
|
|
||||||
volumes:
|
|
||||||
- ..:/repo:ro
|
|
||||||
- ${AWG_DASH_TEST_ARTIFACTS:-./artifacts}:/artifacts
|
|
||||||
environment:
|
|
||||||
AWG_NETNS_WORKDIR: /tmp/awg-netns
|
|
||||||
AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21}
|
|
||||||
# nft в образе приложения нет — там iptables (см. tests/netns/README.md)
|
|
||||||
AWG_FW_BACKEND: ${AWG_FW_BACKEND:-auto}
|
|
||||||
# В контейнере `ip netns exec` падает (mount of /sys, issue #56) — харнесс
|
|
||||||
# сам переключается на nsenter --net; переменная позволяет форсировать режим.
|
|
||||||
AWG_NS_EXEC_MODE: ${AWG_NS_EXEC_MODE:-auto}
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:mode=1777
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "5m"
|
|
||||||
max-file: "2"
|
|
||||||
|
|
||||||
app:
|
|
||||||
image: ${AWG_DASH_IMAGE:-awg-dash:dev}
|
|
||||||
container_name: awg-dash-test-app
|
|
||||||
restart: "no"
|
|
||||||
volumes:
|
|
||||||
- awg-dash-test-config:/config
|
|
||||||
- awg-dash-test-data:/data
|
|
||||||
devices:
|
|
||||||
- /dev/net/tun:/dev/net/tun
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
- SYS_MODULE
|
|
||||||
environment:
|
|
||||||
AWG_CONFIG_DIR: /config
|
|
||||||
AWG_DATA_DIR: /data
|
|
||||||
AWG_API_HOST: 0.0.0.0
|
|
||||||
AWG_API_PORT: "8080"
|
|
||||||
AWG_TEST_STAND: "1"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 20s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
awg-dash-test-config:
|
|
||||||
awg-dash-test-data:
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# verify-dev-stand.sh — проверка acceptance criteria задачи #21 на Docker-хосте.
|
|
||||||
#
|
|
||||||
# Запускать из корня репозитория на машине с Docker:
|
|
||||||
# bash deploy/verify-dev-stand.sh # реальный API (после мержа #31)
|
|
||||||
# bash deploy/verify-dev-stand.sh --stub # проверка плумбинга до #31 (health-заглушка)
|
|
||||||
#
|
|
||||||
# Что проверяет:
|
|
||||||
# AC-1: `curl http://<host>:<port>/api/v1/health` -> 200;
|
|
||||||
# AC-2: правки в /config и /data переживают `docker compose restart`
|
|
||||||
# (и дополнительно пересоздание контейнера);
|
|
||||||
# пласт: монтирования /config и /data указывают на ожидаемый каталог хоста,
|
|
||||||
# контейнер healthy, образ и compose-файлы на месте.
|
|
||||||
#
|
|
||||||
# Коды возврата:
|
|
||||||
# 0 — AC-1 и AC-2 выполнены;
|
|
||||||
# 1 — AC нарушены (регрессия стенда);
|
|
||||||
# 2 — AC-1 заблокирован: API ещё не реализован (#31), стенд при этом поднят;
|
|
||||||
# 3 — заблокировано окружением (нет docker/compose/файлов).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
COMPOSE_DEV="$ROOT/deploy/docker-compose.dev.yml"
|
|
||||||
COMPOSE_STUB="$ROOT/deploy/docker-compose.stub.yml"
|
|
||||||
|
|
||||||
STUB=0
|
|
||||||
DO_UP=1
|
|
||||||
DO_BUILD=1
|
|
||||||
KEEP_STAND=0
|
|
||||||
HOST="127.0.0.1"
|
|
||||||
PORT="${AWG_DASH_PORT:-8180}"
|
|
||||||
HOST_DIR="${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}"
|
|
||||||
JSON_OUT=""
|
|
||||||
HEALTH_PATH="/api/v1/health"
|
|
||||||
|
|
||||||
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--stub) STUB=1 ;;
|
|
||||||
--no-up) DO_UP=0 ;;
|
|
||||||
--skip-build) DO_BUILD=0 ;;
|
|
||||||
--keep) KEEP_STAND=1 ;;
|
|
||||||
--host) HOST="${2:?}"; shift ;;
|
|
||||||
--port) PORT="${2:?}"; shift ;;
|
|
||||||
--host-dir) HOST_DIR="${2:?}"; shift ;;
|
|
||||||
--compose) COMPOSE_DEV="${2:?}"; shift ;;
|
|
||||||
--json) JSON_OUT="${2:?}"; shift ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) echo "неизвестная опция: $1" >&2; exit 3 ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
fatal_env() { printf '[verify][blocked] %s\n' "$*" >&2; exit 3; }
|
|
||||||
say() { printf '[verify] %s\n' "$*"; }
|
|
||||||
ok() { printf '[verify][ ok ] %s\n' "$*"; }
|
|
||||||
bad() { printf '[verify][fail] %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
# --- предусловия -------------------------------------------------------------
|
|
||||||
command -v docker >/dev/null 2>&1 || fatal_env "нет docker в PATH"
|
|
||||||
docker compose version >/dev/null 2>&1 || fatal_env "нет плагина 'docker compose'"
|
|
||||||
[ -f "$COMPOSE_DEV" ] || fatal_env "нет файла $COMPOSE_DEV"
|
|
||||||
|
|
||||||
COMPOSE_ARGS=(-f "$COMPOSE_DEV")
|
|
||||||
if [ "$STUB" -eq 1 ]; then
|
|
||||||
[ -f "$COMPOSE_STUB" ] || fatal_env "нет файла $COMPOSE_STUB"
|
|
||||||
COMPOSE_ARGS+=(-f "$COMPOSE_STUB")
|
|
||||||
fi
|
|
||||||
compose() { docker compose "${COMPOSE_ARGS[@]}" "$@"; }
|
|
||||||
|
|
||||||
AC1="not_run"
|
|
||||||
AC2="not_run"
|
|
||||||
API_MODE="unknown"
|
|
||||||
NOTES=()
|
|
||||||
FAILURES=0
|
|
||||||
|
|
||||||
# --- поднятие стенда ---------------------------------------------------------
|
|
||||||
if [ "$DO_UP" -eq 1 ]; then
|
|
||||||
UP_ARGS=(up -d)
|
|
||||||
[ "$DO_BUILD" -eq 1 ] && UP_ARGS+=(--build)
|
|
||||||
say "docker compose ${COMPOSE_ARGS[*]} ${UP_ARGS[*]}"
|
|
||||||
compose "${UP_ARGS[@]}" || fatal_env "не удалось поднять dev-стенд (см. вывод docker compose)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
compose ps >/dev/null 2>&1 || fatal_env "compose project не поднят"
|
|
||||||
cid() { compose ps -q awg-dash | head -n 1; }
|
|
||||||
CID="$(cid)"
|
|
||||||
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project"
|
|
||||||
|
|
||||||
wait_health() {
|
|
||||||
local tries="${1:-30}" code i
|
|
||||||
for ((i = 1; i <= tries; i++)); do
|
|
||||||
code="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
|
||||||
[ "$code" = "200" ] && return 0
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- AC-1: health 200 --------------------------------------------------------
|
|
||||||
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
|
||||||
if wait_health 20; then
|
|
||||||
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
|
||||||
if printf '%s' "$BODY" | grep -q '"stub": *true'; then
|
|
||||||
API_MODE="stub"
|
|
||||||
NOTES+=("health отвечает заглушка (#31): реальный API ещё не смёржен")
|
|
||||||
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=stub)"
|
|
||||||
else
|
|
||||||
API_MODE="real"
|
|
||||||
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=real)"
|
|
||||||
fi
|
|
||||||
AC1="pass"
|
|
||||||
else
|
|
||||||
CODE="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || echo "000")"
|
|
||||||
say "health-check вернул ${CODE}; последние логи контейнера:"
|
|
||||||
compose logs --tail=15 awg-dash || true
|
|
||||||
if [ "$STUB" -eq 0 ]; then
|
|
||||||
AC1="blocked"
|
|
||||||
NOTES+=("AC-1 заблокирован: FastAPI-каркас (#31) ещё не реализован — /api/v1/health не отвечает")
|
|
||||||
printf '[verify][blocked] AC-1 требует #31 (см. deploy/dev-stand.md)\n' >&2
|
|
||||||
else
|
|
||||||
AC1="fail"
|
|
||||||
FAILURES=$((FAILURES + 1))
|
|
||||||
bad "AC-1: даже с заглушкой health не 200 (код ${CODE})"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- AC-2: состояние переживает restart --------------------------------------
|
|
||||||
if [ "$AC1" = "blocked" ]; then
|
|
||||||
NOTES+=("AC-2 проверяем без API: маркерные файлы в /config и /data")
|
|
||||||
fi
|
|
||||||
|
|
||||||
STAMP="$(date +%s)"
|
|
||||||
MARKER_TEXT="awg-dash-ac2-${STAMP}"
|
|
||||||
say "AC-2: пишу маркеры в /config и /data и перезапускаю контейнер"
|
|
||||||
if compose exec -T awg-dash sh -c "printf '%s\n' '$MARKER_TEXT' > /config/.ac2-marker && printf '%s\n' '$MARKER_TEXT' > /data/.ac2-marker"; then
|
|
||||||
# 1) restart (как в AC)
|
|
||||||
compose restart awg-dash >/dev/null
|
|
||||||
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после restart health не поднялся за 40с"); fi
|
|
||||||
CFG_AFTER="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
|
||||||
DATA_AFTER="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
|
||||||
|
|
||||||
# 2) пересоздание контейнера (сильнее, чем restart)
|
|
||||||
compose up -d --force-recreate >/dev/null
|
|
||||||
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после пересоздания health не поднялся за 40с"); fi
|
|
||||||
# контейнер пересоздан — id сменился; без перезапроса docker inspect падает с 'no such object'
|
|
||||||
CID="$(cid)"
|
|
||||||
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден после пересоздания"
|
|
||||||
CFG_RECREATE="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
|
||||||
DATA_RECREATE="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
|
||||||
|
|
||||||
HOST_CFG="$(tr -d '\r\n' < "${HOST_DIR}/config/.ac2-marker" 2>/dev/null || true)"
|
|
||||||
HOST_DATA="$(tr -d '\r\n' < "${HOST_DIR}/data/.ac2-marker" 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [ "$CFG_AFTER" = "$MARKER_TEXT" ] && [ "$DATA_AFTER" = "$MARKER_TEXT" ] \
|
|
||||||
&& [ "$CFG_RECREATE" = "$MARKER_TEXT" ] && [ "$DATA_RECREATE" = "$MARKER_TEXT" ]; then
|
|
||||||
AC2="pass"
|
|
||||||
ok "AC-2: /config и /data пережили restart и пересоздание контейнера"
|
|
||||||
if [ "$HOST_CFG" = "$MARKER_TEXT" ] && [ "$HOST_DATA" = "$MARKER_TEXT" ]; then
|
|
||||||
ok "AC-2: маркеры видны на хосте (${HOST_DIR}/{config,data}) — это bind mount, а не слой контейнера"
|
|
||||||
else
|
|
||||||
NOTES+=("маркеры не видны в ${HOST_DIR} — проверьте, что это bind mount, а не volume")
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
AC2="fail"
|
|
||||||
FAILURES=$((FAILURES + 1))
|
|
||||||
bad "AC-2: маркеры не пережили restart/пересоздание (cfg=$CFG_AFTER data=$DATA_AFTER recreate=$CFG_RECREATE/$DATA_RECREATE)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
AC2="fail"
|
|
||||||
FAILURES=$((FAILURES + 1))
|
|
||||||
bad "AC-2: не удалось записать маркеры в /config и /data (exec в контейнер не работает)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- монтирования и здоровье -------------------------------------------------
|
|
||||||
CID="$(cid)"
|
|
||||||
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project (после перезапуска)"
|
|
||||||
MOUNTS="$(docker inspect -f '{{range .Mounts}}{{.Destination}}<-{{.Source}}{{"\n"}}{{end}}' "$CID" 2>/dev/null || true)"
|
|
||||||
printf '%s\n' "$MOUNTS" | grep -q '/config' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /config"; }
|
|
||||||
printf '%s\n' "$MOUNTS" | grep -q '/data' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /data"; }
|
|
||||||
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo unknown)"
|
|
||||||
|
|
||||||
say "контейнер: $(docker inspect -f '{{.Name}} {{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")"
|
|
||||||
say "монтирования:"; printf '%s\n' "$MOUNTS" | sed 's/^/ /'
|
|
||||||
say "образ: $(docker inspect -f '{{.Config.Image}}' "$CID")"
|
|
||||||
|
|
||||||
if [ "$JSON_OUT" != "" ]; then
|
|
||||||
mkdir -p "$(dirname "$JSON_OUT")"
|
|
||||||
cat > "$JSON_OUT" <<EOF
|
|
||||||
{
|
|
||||||
"task": "#21",
|
|
||||||
"host": "$(hostname)",
|
|
||||||
"container": "$CID",
|
|
||||||
"image": "$(docker inspect -f '{{.Config.Image}}' "$CID")",
|
|
||||||
"api_mode": "$API_MODE",
|
|
||||||
"ac1_health_200": "$AC1",
|
|
||||||
"ac2_state_persists_restart": "$AC2",
|
|
||||||
"container_health": "$HEALTH_STATUS",
|
|
||||||
"host_dir": "${HOST_DIR}",
|
|
||||||
"host_port": "${PORT}",
|
|
||||||
"failures": ${FAILURES},
|
|
||||||
"notes": [$(printf '"%s",' "${NOTES[@]:-}" 2>/dev/null | sed 's/,$//')]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
ok "отчёт: ${JSON_OUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$KEEP_STAND" -eq 0 ]; then
|
|
||||||
say "стенд оставлен запущенным (снять: docker compose ${COMPOSE_ARGS[*]} down)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$FAILURES" -gt 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
[ "$AC1" = "blocked" ] && exit 2
|
|
||||||
exit 0
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# NNN. Название решения
|
|
||||||
|
|
||||||
- **Статус:** предложено | принято | устарело
|
|
||||||
- **Дата:** YYYY-MM-DD
|
|
||||||
- **Контекст/issue:** #N
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Что вынуждает принять решение и какие есть ограничения (хостовое окружение, привилегии, один шлюз, целевой UX).
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
Что именно решили, одним абзацем.
|
|
||||||
|
|
||||||
## Альтернативы
|
|
||||||
|
|
||||||
Что рассмотрели и почему отклонили.
|
|
||||||
|
|
||||||
## Последствия
|
|
||||||
|
|
||||||
Положительные и отрицательные следствия; что решение накладывает на задачи эпика.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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 нет.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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 не разрывает туннель.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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, для которого решение принято.
|
|
||||||
-1254
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Зависимости контрактных тестов API (issue #19).
|
|
||||||
# После мержа каркаса монорепо (PR #47) строки переезжают в requirements-dev.txt.
|
|
||||||
jsonschema>=4.23
|
|
||||||
openapi-spec-validator>=0.7
|
|
||||||
PyYAML>=6
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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-зависимостей для сети не требуется.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Конфиг покрытия для тестов dev/тест-стенда (#21, issue #56).
|
|
||||||
#
|
|
||||||
# Из покрытия исключаются сами тестовые модули: они исполняются по определению,
|
|
||||||
# и без omit «покрытие 100 %» означало бы только то, что тесты запустились.
|
|
||||||
# Измеряется код стенда: deploy/*.py и tests/deploy/validate_compose.py
|
|
||||||
# (валидатор манифестов, который вызывает `make test-manifests`).
|
|
||||||
[report]
|
|
||||||
omit =
|
|
||||||
tests/deploy/test_*.py
|
|
||||||
tests/deploy/conftest.py
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Профиль тестов dev/тест-стенда (#21, issue #56).
|
|
||||||
#
|
|
||||||
# Отдельный pytest.ini обязателен: без него `pytest tests/deploy` наследует
|
|
||||||
# addopts корневого pyproject.toml (--cov=awg_agent --cov=awg_api), покрытие
|
|
||||||
# выходит 0.00 % и RC=1 — гейт приходилось запускать с --no-cov в обход порога.
|
|
||||||
#
|
|
||||||
# Измеряется код стенда: deploy/*.py (api_stub.py — то, что реально работает в
|
|
||||||
# контейнере) и tests/deploy/validate_compose.py (валидатор манифестов).
|
|
||||||
# Тестовые модули из покрытия исключены через tests/deploy/.coveragerc.
|
|
||||||
[pytest]
|
|
||||||
testpaths = .
|
|
||||||
addopts = -q --cov=deploy --cov=tests/deploy --cov-config=tests/deploy/.coveragerc --cov-report=term-missing --cov-fail-under=70
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"""Тесты DEV-ONLY заглушки health-эндпоинта deploy/api_stub.py (задача #21, issue #56).
|
|
||||||
|
|
||||||
Заглушка — единственный код стенда, который реально отдаёт HTTP в контейнере,
|
|
||||||
поэтому она проверяется настоящим HTTP-сервером на свободном порту (port=0),
|
|
||||||
а не моками: проверяются коды ответов и тело для /api/v1/health, алиаса
|
|
||||||
/healthz, HEAD и «всего остального» (404 not_implemented).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
ROOT = HERE.parents[1]
|
|
||||||
STUB_PATH = ROOT / "deploy" / "api_stub.py"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_stub() -> object:
|
|
||||||
"""Импорт deploy/api_stub.py по пути (deploy/ — не пакет)."""
|
|
||||||
spec = importlib.util.spec_from_file_location("awg_api_stub", STUB_PATH)
|
|
||||||
if spec is None or spec.loader is None: # pragma: no cover - защита от битого дерева
|
|
||||||
raise RuntimeError(f"не удалось загрузить {STUB_PATH}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
api_stub = _load_stub()
|
|
||||||
|
|
||||||
|
|
||||||
class ApiStubHttpTest(unittest.TestCase):
|
|
||||||
"""Живой HTTP-сервер заглушки: коды ответов и контракт тела."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls._stdout = contextlib.redirect_stdout(io.StringIO())
|
|
||||||
cls._stdout.__enter__()
|
|
||||||
cls.server = api_stub.make_server("127.0.0.1", 0)
|
|
||||||
cls.port = cls.server.server_address[1]
|
|
||||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
||||||
cls.thread.start()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls) -> None:
|
|
||||||
cls.server.shutdown()
|
|
||||||
cls.server.server_close()
|
|
||||||
cls.thread.join(timeout=5)
|
|
||||||
cls._stdout.__exit__(None, None, None)
|
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
|
||||||
return f"http://127.0.0.1:{self.port}{path}"
|
|
||||||
|
|
||||||
def _get(self, path: str) -> tuple[int, dict]:
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(self._url(path), timeout=5) as resp:
|
|
||||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
||||||
except urllib.error.HTTPError as exc: # 4xx/5xx — тоже ответ
|
|
||||||
return exc.code, json.loads(exc.read().decode("utf-8"))
|
|
||||||
|
|
||||||
def test_health_returns_200_and_stub_marker(self) -> None:
|
|
||||||
status, payload = self._get("/api/v1/health")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertEqual("ok", payload["status"])
|
|
||||||
self.assertIs(True, payload["stub"], "health-заглушка обязана помечать ответ stub=true")
|
|
||||||
self.assertEqual("#31", payload["issue"], "в ответе должна быть ссылка на задачу реального API")
|
|
||||||
self.assertIsInstance(payload["uptime_s"], float)
|
|
||||||
|
|
||||||
def test_healthz_is_alias_of_health(self) -> None:
|
|
||||||
status, payload = self._get("/healthz")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertIs(True, payload["stub"])
|
|
||||||
|
|
||||||
def test_query_string_is_ignored(self) -> None:
|
|
||||||
status, _ = self._get("/api/v1/health?verbose=1&x=2")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
|
|
||||||
def test_unknown_path_returns_404_not_implemented(self) -> None:
|
|
||||||
status, payload = self._get("/api/v1/tunnels")
|
|
||||||
self.assertEqual(404, status)
|
|
||||||
self.assertEqual("not_implemented", payload["error"])
|
|
||||||
self.assertEqual("#31", payload["issue"])
|
|
||||||
self.assertEqual("/api/v1/tunnels", payload["path"])
|
|
||||||
|
|
||||||
def test_head_mirrors_get_status(self) -> None:
|
|
||||||
for path, expected in (("/api/v1/health", 200), ("/api/v1/tunnels", 404)):
|
|
||||||
with self.subTest(path=path):
|
|
||||||
req = urllib.request.Request(self._url(path), method="HEAD")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
||||||
self.assertEqual(expected, resp.status)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
self.assertEqual(expected, exc.code)
|
|
||||||
|
|
||||||
def test_post_is_rejected(self) -> None:
|
|
||||||
req = urllib.request.Request(self._url("/api/v1/health"), data=b"{}", method="POST")
|
|
||||||
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
self.assertEqual(501, ctx.exception.code, "заглушка не должна поддерживать POST")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiStubMainTest(unittest.TestCase):
|
|
||||||
"""main(): сборка сервера из аргументов командной строки."""
|
|
||||||
|
|
||||||
class _FakeServer:
|
|
||||||
instances: list[tuple[tuple, object]] = []
|
|
||||||
served = 0
|
|
||||||
|
|
||||||
def __init__(self, address: tuple, handler: object) -> None:
|
|
||||||
type(self).instances.append((address, handler))
|
|
||||||
|
|
||||||
def serve_forever(self) -> None:
|
|
||||||
type(self).served += 1
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._fake = type("Fake", (self._FakeServer,), {"instances": [], "served": 0})
|
|
||||||
self._patch = mock.patch.object(api_stub, "ThreadingHTTPServer", self._fake)
|
|
||||||
self._patch.start()
|
|
||||||
self.addCleanup(self._patch.stop)
|
|
||||||
|
|
||||||
def _run_main(self, argv: list[str]) -> tuple[int, str]:
|
|
||||||
out = io.StringIO()
|
|
||||||
with mock.patch.object(sys, "argv", ["api_stub.py", *argv]), contextlib.redirect_stdout(out):
|
|
||||||
rc = api_stub.main()
|
|
||||||
return rc, out.getvalue()
|
|
||||||
|
|
||||||
def test_main_binds_requested_host_and_port(self) -> None:
|
|
||||||
rc, out = self._run_main(["--host", "127.0.0.1", "--port", "0", "--domain", str(HERE)])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertEqual([(("127.0.0.1", 0), api_stub.Handler)], self._fake.instances)
|
|
||||||
self.assertEqual(1, self._fake.served)
|
|
||||||
self.assertIn("DEV-ONLY", out)
|
|
||||||
self.assertIn("доступен", out)
|
|
||||||
|
|
||||||
def test_main_reports_missing_domain_directory(self) -> None:
|
|
||||||
rc, out = self._run_main(["--port", "0", "--domain", str(HERE / "нет-такого-каталога")])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertIn("НЕ доступен", out)
|
|
||||||
|
|
||||||
def test_defaults_listen_on_all_interfaces(self) -> None:
|
|
||||||
rc, _ = self._run_main(["--domain", str(HERE)])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertEqual((("0.0.0.0", 8080), api_stub.Handler), self._fake.instances[0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Тесты статических инвариантов compose-файлов стенда (задача #21).
|
|
||||||
|
|
||||||
Проверяют то, что иначе выяснилось бы только на Docker-хосте: порт 8080,
|
|
||||||
монтирования /config и /data, healthcheck, capabilities, privileged netns-раннер.
|
|
||||||
Работают без Docker — только PyYAML (см. requirements-dev.txt).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import validate_compose
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
ROOT = HERE.parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(validate_compose.yaml is not None, "нужен PyYAML (make venv)")
|
|
||||||
class ComposeManifestsTest(unittest.TestCase):
|
|
||||||
def test_dev_compose_matches_acceptance_criteria(self) -> None:
|
|
||||||
errors, _ = validate_compose.validate(require_dockerfile=False)
|
|
||||||
self.assertEqual([], errors, "compose-файлы стенда нарушают инварианты AC #21")
|
|
||||||
|
|
||||||
def test_dev_compose_publishes_app_port_to_host(self) -> None:
|
|
||||||
doc = validate_compose.load(validate_compose.DEV)
|
|
||||||
ports = " ".join(str(p) for p in doc["services"]["awg-dash"]["ports"])
|
|
||||||
self.assertIn(":8080", ports, "наружу должен публиковаться контейнерный порт приложения 8080")
|
|
||||||
|
|
||||||
def test_dev_compose_mounts_state_directories(self) -> None:
|
|
||||||
doc = validate_compose.load(validate_compose.DEV)
|
|
||||||
targets = validate_compose._mount_targets(doc["services"]["awg-dash"])
|
|
||||||
self.assertIn("/config", targets)
|
|
||||||
self.assertIn("/data", targets)
|
|
||||||
|
|
||||||
def test_healthcheck_hits_api_v1_health(self) -> None:
|
|
||||||
doc = validate_compose.load(validate_compose.DEV)
|
|
||||||
test_cmd = validate_compose.jsonish(doc["services"]["awg-dash"]["healthcheck"]["test"])
|
|
||||||
self.assertIn("/api/v1/health", test_cmd)
|
|
||||||
|
|
||||||
def test_test_compose_runs_netns_harness_privileged(self) -> None:
|
|
||||||
doc = validate_compose.load(validate_compose.TEST)
|
|
||||||
svc = doc["services"]["netns"]
|
|
||||||
self.assertTrue(svc["privileged"])
|
|
||||||
self.assertIn("tests/netns/run.sh", " ".join(svc["entrypoint"]))
|
|
||||||
|
|
||||||
def test_stub_override_is_labelled_dev_only(self) -> None:
|
|
||||||
text = validate_compose.STUB.read_text(encoding="utf-8")
|
|
||||||
self.assertIn("api_stub.py", text)
|
|
||||||
self.assertIn("DEV", text.upper())
|
|
||||||
|
|
||||||
def test_netns_harness_entrypoints_exist(self) -> None:
|
|
||||||
for rel in (
|
|
||||||
"tests/netns/run.sh",
|
|
||||||
"tests/netns/topology.sh",
|
|
||||||
"tests/netns/lib.sh",
|
|
||||||
"tests/netns/probe.py",
|
|
||||||
"tests/netns/mock_wg_endpoint.py",
|
|
||||||
"tests/netns/leak_sink.py",
|
|
||||||
"deploy/api_stub.py",
|
|
||||||
"deploy/verify-dev-stand.sh",
|
|
||||||
):
|
|
||||||
with self.subTest(rel=rel):
|
|
||||||
self.assertTrue((ROOT / rel).exists(), f"нет файла {rel}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
"""Негативные и граничные тесты tests/deploy/validate_compose.py (задача #21, issue #56).
|
|
||||||
|
|
||||||
test_compose_manifests.py проверяет, что реальные compose-файлы стенда проходят
|
|
||||||
валидатор. Здесь — обратная сторона: каждая инварианта AC #21 ломается по
|
|
||||||
отдельности, и валидатор обязан это поймать (иначе гейт `make test-manifests`
|
|
||||||
пропускает регресс). Файлы-фикстуры создаются во временном каталоге, реальные
|
|
||||||
манифесты не трогаются.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import pathlib
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
import validate_compose as vc
|
|
||||||
|
|
||||||
HEALTH_CMD = ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"]
|
|
||||||
|
|
||||||
|
|
||||||
def dev_service(**overrides) -> dict:
|
|
||||||
svc = {
|
|
||||||
"image": "awg-dash:dev",
|
|
||||||
"build": {"context": "..", "dockerfile": "deploy/Dockerfile"},
|
|
||||||
"ports": ["8180:8080"],
|
|
||||||
"volumes": [
|
|
||||||
"${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config",
|
|
||||||
"${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/data:/data",
|
|
||||||
],
|
|
||||||
"devices": ["/dev/net/tun:/dev/net/tun"],
|
|
||||||
"cap_add": ["NET_ADMIN", "NET_RAW", "SYS_MODULE"],
|
|
||||||
"sysctls": {"net.ipv4.ip_forward": "1"},
|
|
||||||
"healthcheck": {"test": HEALTH_CMD},
|
|
||||||
"logging": {"driver": "json-file"},
|
|
||||||
}
|
|
||||||
svc.update(overrides)
|
|
||||||
return svc
|
|
||||||
|
|
||||||
|
|
||||||
def dev_doc(**overrides) -> dict:
|
|
||||||
doc = {"name": "awg-dash-dev", "services": {"awg-dash": dev_service(**overrides)}}
|
|
||||||
doc.update({k: v for k, v in overrides.items() if k in ("name", "services")})
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
def netns_doc(**overrides) -> dict:
|
|
||||||
svc = {
|
|
||||||
"image": "awg-dash:dev",
|
|
||||||
"privileged": True,
|
|
||||||
"working_dir": "/repo",
|
|
||||||
"entrypoint": ["/bin/bash", "/repo/tests/netns/run.sh"],
|
|
||||||
"volumes": ["..:/repo:ro", "/tmp/artifacts:/artifacts"],
|
|
||||||
"tmpfs": ["/tmp:mode=1777"],
|
|
||||||
}
|
|
||||||
svc.update(overrides)
|
|
||||||
return {"name": "awg-dash-test", "services": {"netns": svc}}
|
|
||||||
|
|
||||||
|
|
||||||
def stub_doc(**overrides) -> dict:
|
|
||||||
svc = {
|
|
||||||
"entrypoint": ["python3", "/opt/awg-dash-stub/api_stub.py"],
|
|
||||||
"command": ["--port", "8080"],
|
|
||||||
"volumes": ["../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro"],
|
|
||||||
}
|
|
||||||
svc.update(overrides)
|
|
||||||
return {"services": {"awg-dash": svc}}
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(vc.yaml is not None, "нужен PyYAML (make venv)")
|
|
||||||
class ValidateComposeTestCase(unittest.TestCase):
|
|
||||||
"""Общая обвязка: временный «репозиторий» и подмена путей модуля."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(tmp.cleanup)
|
|
||||||
self.root = pathlib.Path(tmp.name)
|
|
||||||
(self.root / "deploy").mkdir()
|
|
||||||
(self.root / "tests" / "netns").mkdir(parents=True)
|
|
||||||
(self.root / "tests" / "netns" / "run.sh").write_text("#!/bin/bash\n", encoding="utf-8")
|
|
||||||
self.dev = self.root / "deploy" / "docker-compose.dev.yml"
|
|
||||||
self.test = self.root / "deploy" / "docker-compose.test.yml"
|
|
||||||
self.stub = self.root / "deploy" / "docker-compose.stub.yml"
|
|
||||||
for name, value in (("ROOT", self.root), ("DEV", self.dev), ("TEST", self.test), ("STUB", self.stub)):
|
|
||||||
patcher = mock.patch.object(vc, name, value)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def write(self, path: pathlib.Path, doc: dict | str) -> pathlib.Path:
|
|
||||||
text = doc if isinstance(doc, str) else yaml.safe_dump(doc, allow_unicode=True)
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def errors(self, **kwargs) -> list[str]:
|
|
||||||
errors, _warnings = vc.validate(**kwargs)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
def warnings(self, **kwargs) -> list[str]:
|
|
||||||
_errors, warnings = vc.validate(**kwargs)
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
class MountTargetTest(unittest.TestCase):
|
|
||||||
def test_env_substitution_with_default_colon(self) -> None:
|
|
||||||
svc = {"volumes": ["${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config"]}
|
|
||||||
self.assertEqual(["/config"], vc._mount_targets(svc))
|
|
||||||
|
|
||||||
def test_mode_suffix_is_stripped(self) -> None:
|
|
||||||
svc = {"volumes": ["../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro"]}
|
|
||||||
self.assertEqual(["/opt/awg-dash-stub/api_stub.py"], vc._mount_targets(svc))
|
|
||||||
|
|
||||||
def test_missing_volumes_key(self) -> None:
|
|
||||||
self.assertEqual([], vc._mount_targets({}))
|
|
||||||
self.assertEqual([], vc._volumes({}))
|
|
||||||
self.assertEqual([], vc._ports({}))
|
|
||||||
|
|
||||||
def test_jsonish_flattens_lists(self) -> None:
|
|
||||||
self.assertEqual("a b", vc.jsonish(["a", "b"]))
|
|
||||||
self.assertEqual("1", vc.jsonish(1))
|
|
||||||
|
|
||||||
|
|
||||||
class LoadTest(unittest.TestCase):
|
|
||||||
def test_load_without_pyyaml_fails_loudly(self) -> None:
|
|
||||||
with mock.patch.object(vc, "yaml", None), tempfile.NamedTemporaryFile(suffix=".yml") as tmp:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
vc.load(pathlib.Path(tmp.name))
|
|
||||||
|
|
||||||
|
|
||||||
class ValidateDevTest(ValidateComposeTestCase):
|
|
||||||
def test_good_manifest_has_no_errors(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.write(self.test, netns_doc())
|
|
||||||
self.write(self.stub, stub_doc())
|
|
||||||
(self.root / "deploy" / "api_stub.py").write_text("# DEV-ONLY\n", encoding="utf-8")
|
|
||||||
self.assertEqual([], self.errors())
|
|
||||||
|
|
||||||
def test_missing_file(self) -> None:
|
|
||||||
self.assertTrue(any("не найден" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_missing_service(self) -> None:
|
|
||||||
self.write(self.dev, {"name": "x", "services": {"other": {}}})
|
|
||||||
self.assertTrue(any("нет сервиса awg-dash" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_missing_project_name(self) -> None:
|
|
||||||
doc = dev_doc()
|
|
||||||
doc.pop("name")
|
|
||||||
self.write(self.dev, doc)
|
|
||||||
self.assertTrue(any("не задан `name`" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_port_not_published(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(ports=["127.0.0.1:9000:9000"]))
|
|
||||||
self.assertTrue(any("порт 8080 не публикуется" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_state_directories_not_mounted(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(volumes=["/tmp:/tmp"]))
|
|
||||||
errors = " ".join(self.errors())
|
|
||||||
self.assertIn("/config", errors)
|
|
||||||
self.assertIn("/data", errors)
|
|
||||||
self.assertIn("AC-2", errors)
|
|
||||||
|
|
||||||
def test_healthcheck_must_hit_api_v1_health(self) -> None:
|
|
||||||
self.write(
|
|
||||||
self.dev,
|
|
||||||
dev_doc(healthcheck={"test": ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ || exit 1"]}),
|
|
||||||
)
|
|
||||||
self.assertTrue(any("/api/v1/health" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_healthcheck_absent(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(healthcheck=None))
|
|
||||||
self.assertTrue(any("healthcheck не ходит" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_requires_net_admin(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(cap_add=["NET_RAW"]))
|
|
||||||
self.assertTrue(any("NET_ADMIN" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_requires_tun_device(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(devices=["/dev/null:/dev/null"]))
|
|
||||||
self.assertTrue(any("/dev/net/tun" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_requires_ip_forward(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(sysctls={}))
|
|
||||||
self.assertTrue(any("ip_forward" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_ip_forward_accepts_boolean_true(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(sysctls={"net.ipv4.ip_forward": True}))
|
|
||||||
self.assertFalse(any("ip_forward" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_unbounded_logging_is_a_warning(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc(logging=None))
|
|
||||||
self.assertTrue(any("json-file" in w for w in self.warnings()))
|
|
||||||
|
|
||||||
def test_missing_dockerfile_is_warning_before_issue_18(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.assertTrue(any("dockerfile" in w for w in self.warnings()))
|
|
||||||
|
|
||||||
def test_missing_dockerfile_is_error_when_required(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.assertTrue(any("dockerfile" in e for e in self.errors(require_dockerfile=True)))
|
|
||||||
|
|
||||||
def test_present_dockerfile_produces_no_dockerfile_message(self) -> None:
|
|
||||||
(self.root / "deploy" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.assertFalse(any("dockerfile" in m for m in self.errors(require_dockerfile=True)))
|
|
||||||
|
|
||||||
|
|
||||||
class ValidateTestTest(ValidateComposeTestCase):
|
|
||||||
def test_missing_file(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.assertTrue(any("docker-compose.test.yml: файл не найден" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_missing_netns_service(self) -> None:
|
|
||||||
self.write(self.test, {"services": {"app": {}}})
|
|
||||||
self.assertTrue(any("нет сервиса netns" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_netns_must_be_privileged(self) -> None:
|
|
||||||
self.write(self.test, netns_doc(privileged=False))
|
|
||||||
self.assertTrue(any("не privileged" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_entrypoint_must_point_to_run_sh(self) -> None:
|
|
||||||
self.write(self.test, netns_doc(entrypoint=["python3", "-m", "http.server"]))
|
|
||||||
self.assertTrue(any("entrypoint" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_missing_run_sh_is_an_error(self) -> None:
|
|
||||||
(self.root / "tests" / "netns" / "run.sh").unlink()
|
|
||||||
self.write(self.test, netns_doc())
|
|
||||||
self.assertTrue(any("нет файла tests/netns/run.sh" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_artifacts_and_tmpfs_are_warnings(self) -> None:
|
|
||||||
self.write(self.test, netns_doc(volumes=["..:/repo:ro"], tmpfs=[]))
|
|
||||||
warnings = " ".join(self.warnings())
|
|
||||||
self.assertIn("/artifacts", warnings)
|
|
||||||
self.assertIn("/tmp", warnings)
|
|
||||||
|
|
||||||
|
|
||||||
class ValidateStubTest(ValidateComposeTestCase):
|
|
||||||
def test_missing_stub_is_a_warning(self) -> None:
|
|
||||||
self.assertTrue(any("health-заглушкой отсутствует" in w for w in self.warnings()))
|
|
||||||
|
|
||||||
def test_missing_service(self) -> None:
|
|
||||||
self.write(self.stub, {"services": {"other": {}}})
|
|
||||||
self.assertTrue(any("stub: нет сервиса awg-dash" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_entrypoint_must_point_to_api_stub(self) -> None:
|
|
||||||
self.write(self.stub, stub_doc(entrypoint=["/bin/sh"]))
|
|
||||||
self.assertTrue(any("api_stub.py" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_missing_api_stub_file_is_an_error(self) -> None:
|
|
||||||
self.write(self.stub, stub_doc())
|
|
||||||
self.assertTrue(any("нет файла deploy/api_stub.py" in e for e in self.errors()))
|
|
||||||
|
|
||||||
def test_dev_only_marker_is_a_warning(self) -> None:
|
|
||||||
(self.root / "deploy" / "api_stub.py").write_text("# заглушка\n", encoding="utf-8")
|
|
||||||
self.write(self.stub, stub_doc())
|
|
||||||
self.assertTrue(any("DEV-ONLY" in w for w in self.warnings()))
|
|
||||||
|
|
||||||
|
|
||||||
class MainTest(ValidateComposeTestCase):
|
|
||||||
def test_ok_manifest_returns_zero(self) -> None:
|
|
||||||
self.write(self.dev, dev_doc())
|
|
||||||
self.write(self.test, netns_doc())
|
|
||||||
self.write(self.stub, stub_doc())
|
|
||||||
(self.root / "deploy" / "api_stub.py").write_text("# DEV-ONLY\n", encoding="utf-8")
|
|
||||||
out = io.StringIO()
|
|
||||||
with contextlib.redirect_stdout(out):
|
|
||||||
rc = vc.main([])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertIn("ok (", out.getvalue())
|
|
||||||
|
|
||||||
def test_errors_return_one_and_are_printed(self) -> None:
|
|
||||||
out = io.StringIO()
|
|
||||||
with contextlib.redirect_stdout(out):
|
|
||||||
rc = vc.main(["--require-dockerfile"])
|
|
||||||
self.assertEqual(1, rc)
|
|
||||||
self.assertIn("[error]", out.getvalue())
|
|
||||||
self.assertIn("ошибок", out.getvalue())
|
|
||||||
|
|
||||||
def test_quiet_suppresses_details_but_keeps_exit_code(self) -> None:
|
|
||||||
out = io.StringIO()
|
|
||||||
with contextlib.redirect_stdout(out):
|
|
||||||
rc = vc.main(["--quiet"])
|
|
||||||
self.assertEqual(1, rc)
|
|
||||||
self.assertNotIn("[error]", out.getvalue())
|
|
||||||
self.assertIn("ошибок", out.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""validate_compose.py — статическая проверка compose-файлов стенда (задача #21).
|
|
||||||
|
|
||||||
Проверяет инварианты, которые нужны acceptance criteria #21 и не проверяются
|
|
||||||
самим `docker compose config`:
|
|
||||||
|
|
||||||
deploy/docker-compose.dev.yml
|
|
||||||
* приложение слушает контейнерный порт 8080 и публикуется на хост;
|
|
||||||
* /config и /data смонтированы (состояние переживает restart);
|
|
||||||
* healthcheck ходит в /api/v1/health;
|
|
||||||
* выданы NET_ADMIN-возможности, проброшен /dev/net/tun, включён ip_forward;
|
|
||||||
* включён ограниченный json-file-лог (иначе логи съедят диск на dev-хосте).
|
|
||||||
|
|
||||||
deploy/docker-compose.test.yml
|
|
||||||
* сервис netns привилегированный (нужен CAP_SYS_ADMIN/NET_ADMIN для `ip netns add`);
|
|
||||||
* entrypoint — tests/netns/run.sh, отчёт пишется в /artifacts;
|
|
||||||
* существуют все файлы, на которые ссылаются тома.
|
|
||||||
|
|
||||||
deploy/docker-compose.stub.yml
|
|
||||||
* подменяет entrypoint на deploy/api_stub.py и явно помечен как DEV-ONLY.
|
|
||||||
|
|
||||||
Запуск: make test-manifests / python3 tests/deploy/validate_compose.py
|
|
||||||
Коды возврата: 0 — ок (только warnings), 1 — есть ошибки.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
import yaml
|
|
||||||
except ImportError: # pragma: no cover - зависит от окружения
|
|
||||||
yaml = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
||||||
DEV = ROOT / "deploy" / "docker-compose.dev.yml"
|
|
||||||
TEST = ROOT / "deploy" / "docker-compose.test.yml"
|
|
||||||
STUB = ROOT / "deploy" / "docker-compose.stub.yml"
|
|
||||||
|
|
||||||
HEALTH_PATH = "/api/v1/health"
|
|
||||||
|
|
||||||
|
|
||||||
def load(path: pathlib.Path) -> dict:
|
|
||||||
if yaml is None:
|
|
||||||
raise SystemExit("нужен PyYAML: pip install -r requirements-dev.txt (или make venv)")
|
|
||||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
||||||
|
|
||||||
|
|
||||||
def _ports(service: dict) -> list[str]:
|
|
||||||
return [str(p) for p in service.get("ports", []) or []]
|
|
||||||
|
|
||||||
|
|
||||||
def _volumes(service: dict) -> list[str]:
|
|
||||||
return [str(v) for v in service.get("volumes", []) or []]
|
|
||||||
|
|
||||||
|
|
||||||
def _mount_targets(service: dict) -> list[str]:
|
|
||||||
"""Точки монтирования внутри контейнера.
|
|
||||||
|
|
||||||
Строки вида `${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config` содержат
|
|
||||||
':' внутри подстановки переменной, поэтому перед разбором подстановки заменяем
|
|
||||||
на плейсхолдер без двоеточий.
|
|
||||||
"""
|
|
||||||
targets = []
|
|
||||||
for spec in _volumes(service):
|
|
||||||
clean = re.sub(r"\$\{[^}]*\}", "SRC", spec)
|
|
||||||
parts = clean.rsplit(":", 2)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
# 2 части: src:target; 3 части: src:target:mode
|
|
||||||
targets.append(parts[-2] if len(parts) == 3 else parts[-1])
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def validate_dev(errors: list[str], warnings: list[str], require_dockerfile: bool) -> None:
|
|
||||||
if not DEV.exists():
|
|
||||||
errors.append(f"{DEV.name}: файл не найден")
|
|
||||||
return
|
|
||||||
doc = load(DEV)
|
|
||||||
services = doc.get("services") or {}
|
|
||||||
svc = services.get("awg-dash")
|
|
||||||
if not svc:
|
|
||||||
errors.append("dev: нет сервиса awg-dash")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not doc.get("name"):
|
|
||||||
errors.append("dev: не задан `name` — compose project не изолирован от других стендов")
|
|
||||||
|
|
||||||
ports = _ports(svc)
|
|
||||||
if "8080" not in " ".join(ports):
|
|
||||||
errors.append("dev: порт 8080 не публикуется наружу")
|
|
||||||
targets = _mount_targets(svc)
|
|
||||||
for needle in ("/config", "/data"):
|
|
||||||
if needle not in targets:
|
|
||||||
errors.append(f"dev: не смонтирован {needle} (состояние не переживёт restart — AC-2)")
|
|
||||||
|
|
||||||
health = (svc.get("healthcheck") or {}).get("test")
|
|
||||||
if not health or HEALTH_PATH not in jsonish(health):
|
|
||||||
errors.append(f"dev: healthcheck не ходит в {HEALTH_PATH}")
|
|
||||||
|
|
||||||
caps = [str(c) for c in svc.get("cap_add", []) or []]
|
|
||||||
if "NET_ADMIN" not in caps:
|
|
||||||
errors.append("dev: нет cap_add NET_ADMIN (iptables/ipset/ip rule не заработают)")
|
|
||||||
devices = " ".join(str(d) for d in svc.get("devices", []) or [])
|
|
||||||
if "/dev/net/tun" not in devices:
|
|
||||||
errors.append("dev: не проброшен /dev/net/tun")
|
|
||||||
if str((svc.get("sysctls") or {}).get("net.ipv4.ip_forward", "")) not in ("1", "true", "True"):
|
|
||||||
errors.append("dev: не включён net.ipv4.ip_forward")
|
|
||||||
|
|
||||||
logging = svc.get("logging") or {}
|
|
||||||
if str(logging.get("driver", "")) != "json-file":
|
|
||||||
warnings.append("dev: не задан json-file logging — на dev-хосте логи могут расти без ограничений")
|
|
||||||
|
|
||||||
dockerfile = ((svc.get("build") or {}).get("dockerfile")) or "deploy/Dockerfile"
|
|
||||||
if not (ROOT / dockerfile).exists():
|
|
||||||
msg = f"dev: build.dockerfile {dockerfile} отсутствует в дереве"
|
|
||||||
(errors if require_dockerfile else warnings).append(
|
|
||||||
msg + ("" if require_dockerfile else " (ожидаемо до мержа #18)")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_test(errors: list[str], warnings: list[str]) -> None:
|
|
||||||
if not TEST.exists():
|
|
||||||
errors.append(f"{TEST.name}: файл не найден")
|
|
||||||
return
|
|
||||||
doc = load(TEST)
|
|
||||||
services = doc.get("services") or {}
|
|
||||||
svc = services.get("netns")
|
|
||||||
if not svc:
|
|
||||||
errors.append("test: нет сервиса netns (netns-харнесс)")
|
|
||||||
return
|
|
||||||
if svc.get("privileged") is not True:
|
|
||||||
errors.append("test: сервис netns не privileged — `ip netns add` внутри контейнера не сработает")
|
|
||||||
entry = " ".join(str(x) for x in svc.get("entrypoint", []) or [])
|
|
||||||
if "tests/netns/run.sh" not in entry:
|
|
||||||
errors.append("test: entrypoint сервиса netns не указывает на tests/netns/run.sh")
|
|
||||||
if not (ROOT / "tests" / "netns" / "run.sh").exists():
|
|
||||||
errors.append("test: нет файла tests/netns/run.sh")
|
|
||||||
if "/artifacts" not in _mount_targets(svc):
|
|
||||||
warnings.append("test: отчёт стенда не выносится в /artifacts")
|
|
||||||
tmpfs = " ".join(str(t) for t in svc.get("tmpfs", []) or [])
|
|
||||||
if "/tmp" not in tmpfs:
|
|
||||||
warnings.append("test: /tmp не tmpfs — логи стенда останутся в слое контейнера")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_stub(errors: list[str], warnings: list[str]) -> None:
|
|
||||||
if not STUB.exists():
|
|
||||||
warnings.append("stub: оверрайд с health-заглушкой отсутствует")
|
|
||||||
return
|
|
||||||
doc = load(STUB)
|
|
||||||
svc = (doc.get("services") or {}).get("awg-dash")
|
|
||||||
if not svc:
|
|
||||||
errors.append("stub: нет сервиса awg-dash")
|
|
||||||
return
|
|
||||||
entry = " ".join(str(x) for x in svc.get("entrypoint", []) or [])
|
|
||||||
if "api_stub.py" not in entry:
|
|
||||||
errors.append("stub: entrypoint не указывает на deploy/api_stub.py")
|
|
||||||
if not (ROOT / "deploy" / "api_stub.py").exists():
|
|
||||||
errors.append("stub: нет файла deploy/api_stub.py")
|
|
||||||
if "DEV" not in STUB.read_text(encoding="utf-8").upper():
|
|
||||||
warnings.append("stub: в файле нет явной пометки DEV-ONLY")
|
|
||||||
|
|
||||||
|
|
||||||
def jsonish(value: object) -> str:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return " ".join(str(v) for v in value)
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def validate(require_dockerfile: bool = False) -> tuple[list[str], list[str]]:
|
|
||||||
errors: list[str] = []
|
|
||||||
warnings: list[str] = []
|
|
||||||
validate_dev(errors, warnings, require_dockerfile)
|
|
||||||
validate_test(errors, warnings)
|
|
||||||
validate_stub(errors, warnings)
|
|
||||||
return errors, warnings
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Проверка compose-файлов dev/тест-стенда awg-dash (#21)")
|
|
||||||
ap.add_argument("--require-dockerfile", action="store_true", help="требовать наличие deploy/Dockerfile (#18 смёржен)")
|
|
||||||
ap.add_argument("--quiet", action="store_true")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
errors, warnings = validate(args.require_dockerfile)
|
|
||||||
if not args.quiet:
|
|
||||||
for warning in warnings:
|
|
||||||
print(f"[warn] {warning}")
|
|
||||||
for error in errors:
|
|
||||||
print(f"[error] {error}")
|
|
||||||
if errors:
|
|
||||||
print(f"validate-compose: {len(errors)} ошибок, {len(warnings)} предупреждений")
|
|
||||||
return 1
|
|
||||||
print(f"validate-compose: ok ({len(warnings)} предупреждений)")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# tests/netns — сетевой тестовый стенд
|
|
||||||
|
|
||||||
Задача **#21**, используется тестами split-tunnel (#40, #41, #43).
|
|
||||||
|
|
||||||
Цель: проверять маршрутизацию, policy routing, метки трафика и fail-closed
|
|
||||||
**без реального VPN-провайдера** и без доступа к продакшн-сети.
|
|
||||||
|
|
||||||
## Топология
|
|
||||||
|
|
||||||
```
|
|
||||||
client ns gw ns (шлюз awg-dash) uplink ns («ISP»)
|
|
||||||
10.213.0.2/24 ──veth──► 10.213.0.1/24 10.214.0.1/24 ──veth──► 10.214.0.2/24
|
|
||||||
(LAN-устройство) ip_forward=1, ip rule fwmark 51820 мок-endpoint udp/51820
|
|
||||||
→ table 51820 → default via uplink детектор утечки
|
|
||||||
198.51.100.9:9999
|
|
||||||
```
|
|
||||||
|
|
||||||
| Namespace | По умолчанию | Роль | Адреса |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `awgt21-client` | `AWG_NS_CLIENT` | LAN-устройство за шлюзом | `10.213.0.2/24` |
|
|
||||||
| `awgt21-gw` | `AWG_NS_GW` | хост со шлюзом: forwarding, policy routing, netfilter | `10.213.0.1/24`, `10.214.0.1/24` |
|
|
||||||
| `awgt21-uplink` | `AWG_NS_UPLINK` | «ISP»: мок WireGuard-endpoint + детектор утечки | `10.214.0.2/24`, `198.51.100.9/32` |
|
|
||||||
|
|
||||||
Правила в `gw`: `ip rule fwmark 51820 lookup 51820` + `table 51820 default via 10.214.0.2`
|
|
||||||
(как в `wg-quick`), метки ставит netfilter на исходящий трафик шлюза (OUTPUT) и на
|
|
||||||
транзитный трафик LAN (FORWARD). Kill-switch — правило DROP в FORWARD для трафика
|
|
||||||
LAN, уходящего напрямую в «публичную» сеть `198.51.100.0/24` (RFC 5737 TEST-NET-2).
|
|
||||||
Адрес детектора утечки поднят на `lo` аплинка: если при включённом kill-switch пакет
|
|
||||||
дошёл до детектора — тест падает.
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test-netns # поднять стенд, прогнать тесты, снести стенд
|
|
||||||
make test-netns-keep # оставить топологию и логи для отладки
|
|
||||||
make test-netns NETNS_ARGS="--no-tests" # только поднять стенд
|
|
||||||
make test-netns-ci # то же в привилегированном контейнере (путь для CI)
|
|
||||||
|
|
||||||
bash tests/netns/ci.sh # точка входа для CI #17: контейнер без make
|
|
||||||
# (make отдаёт 2 на любом провале — см. ниже)
|
|
||||||
|
|
||||||
bash tests/netns/topology.sh status # состояние топологии
|
|
||||||
bash tests/netns/topology.sh down # снести топологию вручную
|
|
||||||
bash tests/netns/topology.sh killswitch on # включить fail-closed правило
|
|
||||||
```
|
|
||||||
|
|
||||||
Требования: Linux, **root** (или CAP_NET_ADMIN + CAP_SYS_ADMIN), `ip`, `python3`;
|
|
||||||
backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только
|
|
||||||
`iptables`, на Debian/Ubuntu — оба).
|
|
||||||
|
|
||||||
Коды возврата: `0` — тесты прошли, `1` — тесты упали, `3` — «заблокировано окружением»
|
|
||||||
(нет прав на создание netns), `4` — ошибка самого стенда (сюда же нормализуются
|
|
||||||
неожиданные коды утилит, например `255` от `ip netns exec` в контейнере).
|
|
||||||
|
|
||||||
**В CI (#17) используйте `bash tests/netns/ci.sh`, а не `make test-netns-ci`:**
|
|
||||||
GNU make возвращает `2` на любой упавший рецепт, поэтому через make контракт
|
|
||||||
`0/1/3/4` не виден и пайплайн не отличит «раннеру не дали прав» (`3`) от
|
|
||||||
«регрессия стенда» (`4`). `ci.sh` запускает тот же контейнер напрямую и отдаёт
|
|
||||||
его код как есть (`2` у него означает «сломан вызов/нет docker/нет образа»).
|
|
||||||
|
|
||||||
Вход в namespace выполняется через `ns_exec()`: `ip netns exec` в контейнере падает
|
|
||||||
(`mount of /sys failed`, issue #56), поэтому харнесс сам переключается на
|
|
||||||
`nsenter --net=/var/run/netns/<ns>`; режим форсируется `AWG_NS_EXEC_MODE`
|
|
||||||
(`auto|ip|nsenter`). Тот же режим через make — `NETNS_EXEC_MODE` (алиас;
|
|
||||||
`AWG_NS_EXEC_MODE` имеет приоритет), например:
|
|
||||||
`AWG_NS_EXEC_MODE=ip make test-netns-ci`.
|
|
||||||
|
|
||||||
## Состав
|
|
||||||
|
|
||||||
| Файл | Назначение |
|
|
||||||
|---|---|
|
|
||||||
| `run.sh` | точка входа: preflight → топология → мок → тесты → снос |
|
|
||||||
| `ci.sh` | точка входа для CI #17: тот же прогон в контейнере без make, коды `0/1/3/4` доходят до пайплайна |
|
|
||||||
| `lib.sh` | конфигурация стенда + правила netfilter (nft/iptables) |
|
|
||||||
| `topology.sh` | `up`/`down`/`status`/`killswitch on|off` |
|
|
||||||
| `mock_wg_endpoint.py` | мок WireGuard-endpoint: отвечает на handshake initiation |
|
|
||||||
| `leak_sink.py` | детектор утечки: UDP+TCP `198.51.100.9:9999` |
|
|
||||||
| `probe.py` | генератор тестового трафика из указанного netns |
|
|
||||||
| `netns_common.py` | утилиты для тестов (ns_exec, счётчики правил, JSONL-логи) |
|
|
||||||
| `run_tests.py` | прогон unittest + JSON-отчёт |
|
|
||||||
| `test_netns_stand.py` | 8 интеграционных тестов стенда |
|
|
||||||
|
|
||||||
## Что проверяют тесты (`test_netns_stand.py`)
|
|
||||||
|
|
||||||
1. `test_01` — netns и veth-пары созданы, адреса выданы, `ip_forward=1`.
|
|
||||||
2. `test_02` — policy routing: `ip rule fwmark 51820` → `table 51820` → аплинк.
|
|
||||||
3. `test_03` — правила меток (OUTPUT/FORWARD) и DROP kill-switch на месте.
|
|
||||||
4. `test_04` — мок-endpoint и детектор утечки слушают и залогировали `listen`.
|
|
||||||
5. `test_05` — handshake initiation из LAN доходит до мока, мок отвечает
|
|
||||||
(148 → 92 байта), счётчик правила метки растёт.
|
|
||||||
6. `test_06` — то же для трафика, инициированного самим шлюзом (OUTPUT-метка).
|
|
||||||
7. `test_07` — режим `drop` мока имитирует недоступность провайдера: ответа нет,
|
|
||||||
пакеты фиксируются; возврат в `respond` восстанавливает стенд.
|
|
||||||
8. `test_08` — kill-switch: при выключенном правиле трафик доходит до детектора
|
|
||||||
(положительный контроль), при включённом — утечки нет.
|
|
||||||
|
|
||||||
## Параметры (переменные окружения)
|
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
|
||||||
|---|---|---|
|
|
||||||
| `AWG_NETNS_PREFIX` | `awgt21` | префикс имён namespace/veth |
|
|
||||||
| `AWG_NS_CLIENT` / `AWG_NS_GW` / `AWG_NS_UPLINK` | `awgt21-*` | имена namespace |
|
|
||||||
| `AWG_MOCK_PORT` | `51820` | порт мока WireGuard-endpoint |
|
|
||||||
| `AWG_LEAK_IP` / `AWG_LEAK_PORT` | `198.51.100.9` / `9999` | адрес и порт детектора утечки |
|
|
||||||
| `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing |
|
|
||||||
| `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение |
|
|
||||||
| `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда |
|
|
||||||
| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56; make-алиас — `NETNS_EXEC_MODE` |
|
|
||||||
| `AWG_NS_PRIVILEGED` | `1` | только для `ci.sh`: `0` — прогон без `privileged` (проверка ветки «код 3») |
|
|
||||||
| `AWG_DASH_TEST_ARTIFACTS` | `<repo>/artifacts` | каталог для `netns-report.json` (`ci.sh`) |
|
|
||||||
|
|
||||||
## Pitfalls
|
|
||||||
|
|
||||||
* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе
|
|
||||||
`run.sh` завершится кодом 3 с сообщением «заблокировано окружением».
|
|
||||||
* **`ip netns exec` и `ip -n <ns>` в контейнере не работают** (iproute2 6.x
|
|
||||||
перемонтирует `/sys`: `mount of /sys failed: Operation not permitted`, rc=255),
|
|
||||||
хотя `ip netns add` и `nsenter --net=...` работают. Поэтому в скриптах только
|
|
||||||
`ns_exec`/`ns_prefix` (`lib.sh`) и `ns_exec_mode()` (`netns_common.py`), а все
|
|
||||||
`ip -n <ns>` заменены на `ns_exec <ns> ip ...` (issue #56). Форсировать режим:
|
|
||||||
`AWG_NS_EXEC_MODE=nsenter|ip`.
|
|
||||||
* **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт
|
|
||||||
асимметрию reverse-пути, strict-режим молча дропает ответы.
|
|
||||||
* **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard
|
|
||||||
(тип и длины), но не выполняет Noise IK handshake. Годится для топологии,
|
|
||||||
меток, policy routing и fail-closed — **не** для проверки шифрования, MTU и
|
|
||||||
пропускной способности.
|
|
||||||
* **`nft` в alpine-образе нет**: в контейнере используется `iptables`
|
|
||||||
(nft-backend). Стенд поддерживает оба backend; при отладке форсируйте
|
|
||||||
`AWG_FW_BACKEND=iptables`.
|
|
||||||
* **`ping` в образе нет** (пакет `iputils` не установлен) — тесты не используют
|
|
||||||
ICMP: связность проверяется UDP-трафиком через мок и детектор утечки.
|
|
||||||
* **Остатки после падения**: `bash tests/netns/topology.sh down` и `ip netns list`.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci.sh — точка входа netns-стенда для CI (#17), минуя make (issue #56).
|
|
||||||
#
|
|
||||||
# Зачем отдельный скрипт: GNU make возвращает 2 на любой упавший рецепт, поэтому
|
|
||||||
# контракт кодов run.sh (0 — тесты прошли, 1 — тесты упали, 3 — нет прав на netns,
|
|
||||||
# 4 — сломан стенд) через `make test-netns-ci` до пайплайна не доходит. ci.sh
|
|
||||||
# запускает контейнер напрямую и возвращает его код как есть — workflow отличает
|
|
||||||
# «раннеру не дали CAP_NET_ADMIN» от «регрессия стенда».
|
|
||||||
#
|
|
||||||
# tests/netns/ci.sh # прогон стенда, код 0/1/3/4
|
|
||||||
# tests/netns/ci.sh --no-tests # только поднять топологию
|
|
||||||
# tests/netns/ci.sh --keep-topology # оставить топологию для отладки
|
|
||||||
# AWG_NS_PRIVILEGED=0 tests/netns/ci.sh # ветка «нет прав» → ожидаем код 3
|
|
||||||
#
|
|
||||||
# Переменные окружения:
|
|
||||||
# AWG_DASH_IMAGE образ с харнессом (по умолчанию awg-dash:dev)
|
|
||||||
# AWG_NS_EXEC_MODE auto|ip|nsenter — вход в netns (issue #56)
|
|
||||||
# NETNS_EXEC_MODE make-алиас того же (Makefile прокидывает его сюда)
|
|
||||||
# AWG_NS_PRIVILEGED 1 (по умолчанию) — как в compose; 0 — прогон без
|
|
||||||
# privileged (проверка ветки «код 3»)
|
|
||||||
# AWG_DASH_TEST_ARTIFACTS каталог артефактов (по умолчанию <repo>/artifacts)
|
|
||||||
# COMPOSE_PROJECT_NAME имя compose-проекта (по умолчанию awg-dash-test)
|
|
||||||
#
|
|
||||||
# Коды возврата: 0/1/3/4 — как у run.sh; 2 — ошибка вызова или окружения
|
|
||||||
# (нет docker/compose/образа, неизвестный аргумент). Ветку 2 CI не должен
|
|
||||||
# путать с 3: 2 — сломан сам пайплайн/раннер, 3 — раннер без прав на netns.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
COMPOSE_FILE="$ROOT/deploy/docker-compose.test.yml"
|
|
||||||
IMAGE="${AWG_DASH_IMAGE:-awg-dash:dev}"
|
|
||||||
ARTIFACTS="${AWG_DASH_TEST_ARTIFACTS:-$ROOT/artifacts}"
|
|
||||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-awg-dash-test}"
|
|
||||||
|
|
||||||
# AWG_NS_EXEC_MODE — документированное имя, NETNS_EXEC_MODE — алиас из Makefile.
|
|
||||||
NS_MODE="${AWG_NS_EXEC_MODE:-${NETNS_EXEC_MODE:-auto}}"
|
|
||||||
case "$NS_MODE" in
|
|
||||||
auto|ip|nsenter) ;;
|
|
||||||
*)
|
|
||||||
echo "[ci][fail] AWG_NS_EXEC_MODE='${NS_MODE}' — ожидается auto|ip|nsenter" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
export AWG_NS_EXEC_MODE="$NS_MODE"
|
|
||||||
|
|
||||||
die() { echo "[ci][fail] $*" >&2; exit 2; }
|
|
||||||
|
|
||||||
[ -f "$COMPOSE_FILE" ] || die "нет ${COMPOSE_FILE} (задача #21)"
|
|
||||||
command -v docker >/dev/null 2>&1 || die "нет docker в PATH — CI-раннер должен быть с docker (#17)"
|
|
||||||
docker compose version >/dev/null 2>&1 || die "нет docker compose v2 в PATH (#17)"
|
|
||||||
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
|
||||||
|| die "нет образа ${IMAGE}: соберите его (make build после мержа #18) или задайте AWG_DASH_IMAGE"
|
|
||||||
|
|
||||||
mkdir -p "$ARTIFACTS"
|
|
||||||
export AWG_DASH_TEST_ARTIFACTS="$ARTIFACTS"
|
|
||||||
export AWG_DASH_IMAGE="$IMAGE"
|
|
||||||
|
|
||||||
COMPOSE_ARGS=(-f "$COMPOSE_FILE")
|
|
||||||
CLEANUP_DIR=""
|
|
||||||
PRIVILEGED="${AWG_NS_PRIVILEGED:-1}"
|
|
||||||
if [ "$PRIVILEGED" = "0" ]; then
|
|
||||||
# Оверрайд снимает privileged у сервиса netns — проверка ветки «код 3».
|
|
||||||
CLEANUP_DIR="$(mktemp -d -t awg-ci-XXXXXX)"
|
|
||||||
printf 'services:\n netns:\n privileged: false\n' >"${CLEANUP_DIR}/no-privileged.yml"
|
|
||||||
COMPOSE_ARGS+=(-f "${CLEANUP_DIR}/no-privileged.yml")
|
|
||||||
# shellcheck disable=SC2064 # раскрытие $CLEANUP_DIR нужно на момент trap
|
|
||||||
trap "rm -rf '${CLEANUP_DIR}'" EXIT
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[ci] образ=${IMAGE} режим netns=${NS_MODE} privileged=${PRIVILEGED} артефакты=${ARTIFACTS}"
|
|
||||||
|
|
||||||
rc=0
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm -T netns "$@" || rc=$?
|
|
||||||
|
|
||||||
case "$rc" in
|
|
||||||
0) echo "[ci][ ok ] netns-стенд: тесты прошли (код 0)" ;;
|
|
||||||
1) echo "[ci][fail] netns-стенд: тесты упали (код 1)" >&2 ;;
|
|
||||||
3) echo "[ci][blocked] netns-стенд: окружение не даёт прав на netns (код 3) — раннеру нужен --privileged (#17)" >&2 ;;
|
|
||||||
4) echo "[ci][fail] netns-стенд: сломан стенд (код 4) — см. лог выше" >&2 ;;
|
|
||||||
*) echo "[ci][fail] netns-стенд: неожиданный код ${rc}" >&2 ;;
|
|
||||||
esac
|
|
||||||
exit "$rc"
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""leak_sink.py — детектор утечки трафика для netns-стенда (задача #21).
|
|
||||||
|
|
||||||
Слушает UDP и TCP порт 9999 на «публичном» адресе 198.51.100.9 внутри netns
|
|
||||||
`uplink` (адрес из TEST-NET-2, RFC 5737). Если при включённом kill-switch
|
|
||||||
шлюза сюда пришёл хоть один пакет — значит трафик, предназначенный для
|
|
||||||
туннеля, утёк напрямую: тест падает.
|
|
||||||
|
|
||||||
Лог — JSONL, пишется немедленно (тесты читают файл, не дожидаясь остановки).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlLog:
|
|
||||||
def __init__(self, path: str | None) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._fh = None
|
|
||||||
if path:
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
||||||
self._fh = open(path, "a", buffering=1, encoding="utf-8")
|
|
||||||
|
|
||||||
def write(self, **event: object) -> None:
|
|
||||||
if self._fh is None:
|
|
||||||
return
|
|
||||||
event.setdefault("ts", round(time.time(), 6))
|
|
||||||
with self._lock:
|
|
||||||
self._fh.write(json.dumps(event, sort_keys=True) + "\n")
|
|
||||||
self._fh.flush()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._fh is not None:
|
|
||||||
self._fh.close()
|
|
||||||
self._fh = None
|
|
||||||
|
|
||||||
|
|
||||||
def udp_loop(bind: tuple[str, int], log: JsonlLog, stop: threading.Event) -> None:
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
sock.bind(bind)
|
|
||||||
sock.settimeout(0.25)
|
|
||||||
while not stop.is_set():
|
|
||||||
try:
|
|
||||||
payload, peer = sock.recvfrom(65535)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
log.write(event="leak", proto="udp", length=len(payload), src=f"{peer[0]}:{peer[1]}")
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_loop(bind: tuple[str, int], log: JsonlLog, stop: threading.Event) -> None:
|
|
||||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
srv.bind(bind)
|
|
||||||
srv.listen(8)
|
|
||||||
srv.settimeout(0.25)
|
|
||||||
while not stop.is_set():
|
|
||||||
try:
|
|
||||||
conn, peer = srv.accept()
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
log.write(event="leak", proto="tcp", src=f"{peer[0]}:{peer[1]}")
|
|
||||||
conn.close()
|
|
||||||
srv.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Детектор утечки трафика для netns-стенда (#21)")
|
|
||||||
ap.add_argument("--ip", default=os.environ.get("AWG_LEAK_IP", "198.51.100.9"))
|
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("AWG_LEAK_PORT", "9999")))
|
|
||||||
ap.add_argument("--log", default=os.environ.get("AWG_LEAK_LOG", ""))
|
|
||||||
ap.add_argument("--ready", default=os.environ.get("AWG_LEAK_READY", ""))
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
bind = (args.ip, args.port)
|
|
||||||
log = JsonlLog(args.log or None)
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
udp = threading.Thread(target=udp_loop, args=(bind, log, stop), daemon=True)
|
|
||||||
tcp = threading.Thread(target=tcp_loop, args=(bind, log, stop), daemon=True)
|
|
||||||
udp.start()
|
|
||||||
tcp.start()
|
|
||||||
|
|
||||||
if args.ready:
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(args.ready)) or ".", exist_ok=True)
|
|
||||||
with open(args.ready, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(f"{os.getpid()}\n")
|
|
||||||
|
|
||||||
log.write(event="listen", addr=f"{args.ip}:{args.port}", pid=os.getpid())
|
|
||||||
print(f"leak-sink: udp+tcp {args.ip}:{args.port} listen", flush=True)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(0.5)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
udp.join(timeout=2)
|
|
||||||
tcp.join(timeout=2)
|
|
||||||
log.write(event="stop")
|
|
||||||
log.close()
|
|
||||||
if args.ready and os.path.exists(args.ready):
|
|
||||||
os.unlink(args.ready)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# lib.sh — общие настройки и утилиты netns-стенда awg-dash (задача #21, эпик #15).
|
|
||||||
#
|
|
||||||
# Подключается через `source` из topology.sh / run.sh. Ничего не выполняет при
|
|
||||||
# подключении, ничего не создаёт — только конфигурация и вспомогательные функции.
|
|
||||||
#
|
|
||||||
# Все имена/адреса переопределяются переменными окружения (см. tests/netns/README.md).
|
|
||||||
|
|
||||||
# shellcheck shell=bash
|
|
||||||
|
|
||||||
# --- имена сетевых namespace'ов ---------------------------------------------
|
|
||||||
: "${AWG_NETNS_PREFIX:=awgt21}"
|
|
||||||
: "${AWG_NS_CLIENT:=${AWG_NETNS_PREFIX}-client}" # LAN-клиент за шлюзом
|
|
||||||
: "${AWG_NS_GW:=${AWG_NETNS_PREFIX}-gw}" # хост со шлюзом awg-dash
|
|
||||||
: "${AWG_NS_UPLINK:=${AWG_NETNS_PREFIX}-uplink}" # «ISP» + мок WireGuard-endpoint
|
|
||||||
|
|
||||||
# --- адресация --------------------------------------------------------------
|
|
||||||
: "${AWG_NET_CLIENT:=10.213.0.0/24}"
|
|
||||||
: "${AWG_CLIENT_IP:=10.213.0.2}"
|
|
||||||
: "${AWG_GW_LAN_IP:=10.213.0.1}"
|
|
||||||
: "${AWG_NET_WAN:=10.214.0.0/24}"
|
|
||||||
: "${AWG_GW_WAN_IP:=10.214.0.1}"
|
|
||||||
: "${AWG_UPLINK_IP:=10.214.0.2}"
|
|
||||||
|
|
||||||
# «Провайдер»/мок-endpoint: порт как в wg-quick.
|
|
||||||
: "${AWG_MOCK_PORT:=51820}"
|
|
||||||
# Адрес из TEST-NET-2 (RFC 5737) — «публичный» получатель для детектора утечки.
|
|
||||||
: "${AWG_LEAK_IP:=198.51.100.9}"
|
|
||||||
: "${AWG_LEAK_PORT:=9999}"
|
|
||||||
: "${AWG_LEAK_NET:=198.51.100.0/24}"
|
|
||||||
|
|
||||||
# --- policy routing ---------------------------------------------------------
|
|
||||||
# Метка и таблица как в wg-quick: fwmark 51820 -> table 51820.
|
|
||||||
: "${AWG_FWMARK:=51820}"
|
|
||||||
: "${AWG_ROUTE_TABLE:=51820}"
|
|
||||||
|
|
||||||
# --- имена интерфейсов и константы стенда -----------------------------------
|
|
||||||
: "${AWG_VETH_CLIENT:=veth-c-gw}"
|
|
||||||
: "${AWG_VETH_GW_LAN:=veth-gw-lan}"
|
|
||||||
: "${AWG_VETH_GW_WAN:=veth-gw-wan}"
|
|
||||||
: "${AWG_VETH_UPLINK:=veth-uplink}"
|
|
||||||
|
|
||||||
NFT_TABLE="awgtest"
|
|
||||||
C_MARK_OUT="awgtest-mark-out"
|
|
||||||
C_MARK_FWD="awgtest-mark-fwd"
|
|
||||||
C_DROP="awgtest-killswitch-drop"
|
|
||||||
IPT_CHAIN_MARK="awgtest-mark"
|
|
||||||
IPT_CHAIN_FWD="awgtest-mark-fwd"
|
|
||||||
IPT_CHAIN_KS="awgtest-ks"
|
|
||||||
|
|
||||||
# Рабочий каталог стенда (логи мока, sink'а, отчёты). Задаётся run.sh.
|
|
||||||
: "${AWG_NETNS_WORKDIR:=/tmp/awg-netns}"
|
|
||||||
|
|
||||||
# --- вывод ------------------------------------------------------------------
|
|
||||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
|
||||||
_c_red=$'\033[31m'; _c_grn=$'\033[32m'; _c_yel=$'\033[33m'; _c_dim=$'\033[2m'; _c_off=$'\033[0m'
|
|
||||||
else
|
|
||||||
_c_red=''; _c_grn=''; _c_yel=''; _c_dim=''; _c_off=''
|
|
||||||
fi
|
|
||||||
|
|
||||||
log() { printf '%s[netns]%s %s\n' "$_c_dim" "$_c_off" "$*"; }
|
|
||||||
ok() { printf '%s[ ok ]%s %s\n' "$_c_grn" "$_c_off" "$*"; }
|
|
||||||
warn() { printf '%s[warn]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; }
|
|
||||||
fail() { printf '%s[fail]%s %s\n' "$_c_red" "$_c_off" "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Код возврата 3 — «заблокировано окружением» (нет root/CAP_NET_ADMIN/ip),
|
|
||||||
# см. deploy/dev-stand.md. 4 — внутренняя ошибка стенда.
|
|
||||||
blocked() { printf '%s[blocked]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; exit 3; }
|
|
||||||
|
|
||||||
# Ошибка самого стенда (не тестов и не окружения) — код 4.
|
|
||||||
stand_fail() { echo "[fail] $*" >&2; exit 4; }
|
|
||||||
|
|
||||||
# --- вход в netns -----------------------------------------------------------
|
|
||||||
# `ip netns exec` и `ip -n <ns>` (iproute2 6.x) перемонтируют /sys внутри
|
|
||||||
# namespace и в контейнере падают с "mount of /sys failed: Operation not
|
|
||||||
# permitted", хотя `ip netns add` работает (issue #56). nsenter входит в тот же
|
|
||||||
# /var/run/netns/<ns> без перемонтирования — используется как fallback.
|
|
||||||
# Режим: AWG_NS_EXEC_MODE=auto|ip|nsenter (auto — определить один раз пробой).
|
|
||||||
: "${AWG_NS_EXEC_MODE:=auto}"
|
|
||||||
NS_EXEC_RESOLVED=""
|
|
||||||
NS_EXEC_IP_CMD=(ip netns exec)
|
|
||||||
|
|
||||||
_ns_exec_probe() {
|
|
||||||
# $1 — режим; 0 — вход в свежесозданный netns работает
|
|
||||||
local mode="$1" probe="${AWG_NETNS_PREFIX}-probe" rc=0
|
|
||||||
ip netns add "$probe" 2>/dev/null || return 1
|
|
||||||
case "$mode" in
|
|
||||||
ip)
|
|
||||||
timeout 5 "${NS_EXEC_IP_CMD[@]}" "$probe" true >/dev/null 2>&1 || rc=1
|
|
||||||
;;
|
|
||||||
nsenter)
|
|
||||||
timeout 5 nsenter --net="/var/run/netns/${probe}" true >/dev/null 2>&1 || rc=1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
rc=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
ip netns delete "$probe" 2>/dev/null || true
|
|
||||||
return "$rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
select_ns_exec_mode() {
|
|
||||||
# Печатает выбранный режим; код 3 — окружение не даёт войти в netns.
|
|
||||||
case "$AWG_NS_EXEC_MODE" in
|
|
||||||
ip|nsenter)
|
|
||||||
echo "$AWG_NS_EXEC_MODE"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if _ns_exec_probe ip; then
|
|
||||||
echo ip
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if command -v nsenter >/dev/null 2>&1 && _ns_exec_probe nsenter; then
|
|
||||||
warn "'ip netns exec' не работает (mount of /sys: Operation not permitted, issue #56) — использую nsenter --net"
|
|
||||||
echo nsenter
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
blocked "ни 'ip netns exec', ни 'nsenter --net' не работают: нет CAP_SYS_ADMIN/NET_ADMIN (см. issue #56)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ns_exec <netns> <команда...> — выполнить команду внутри namespace.
|
|
||||||
ns_exec() {
|
|
||||||
local ns="$1"
|
|
||||||
shift
|
|
||||||
if [ -z "$NS_EXEC_RESOLVED" ]; then
|
|
||||||
NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3
|
|
||||||
fi
|
|
||||||
case "$NS_EXEC_RESOLVED" in
|
|
||||||
nsenter) nsenter --net="/var/run/netns/${ns}" "$@" ;;
|
|
||||||
*) "${NS_EXEC_IP_CMD[@]}" "$ns" "$@" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ns_prefix <netns> — напечатать команду входа в netns ПО СЛОВУ НА СТРОКУ.
|
|
||||||
# Нужна для фонового запуска: `mapfile -t P < <(ns_prefix NS)` +
|
|
||||||
# `( exec "${P[@]}" python3 ... ) &` даёт в $! pid самого процесса, тогда cleanup
|
|
||||||
# убивает его, а не обёртку-функцию (иначе мок и детектор утечки остаются жить
|
|
||||||
# после прогона и держат stdout).
|
|
||||||
ns_prefix() {
|
|
||||||
local ns="$1"
|
|
||||||
if [ -z "$NS_EXEC_RESOLVED" ]; then
|
|
||||||
NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3
|
|
||||||
fi
|
|
||||||
if [ "$NS_EXEC_RESOLVED" = nsenter ]; then
|
|
||||||
echo nsenter
|
|
||||||
echo --net="/var/run/netns/${ns}"
|
|
||||||
else
|
|
||||||
echo ip
|
|
||||||
echo netns
|
|
||||||
echo exec
|
|
||||||
echo "$ns"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- предусловия ------------------------------------------------------------
|
|
||||||
require_root() {
|
|
||||||
[ "$(id -u)" -eq 0 ] || blocked "нужен root (или CAP_NET_ADMIN): запустите под sudo"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || blocked "нет утилиты '$1' в PATH"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Проверяем, что ядро/контейнер вообще позволяют netns (иначе AC невыполним).
|
|
||||||
require_netns_capability() {
|
|
||||||
local probe="${AWG_NETNS_PREFIX}-probe"
|
|
||||||
if ! ip netns add "$probe" 2>/dev/null; then
|
|
||||||
blocked "не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN (запустите контейнер с --privileged)"
|
|
||||||
fi
|
|
||||||
ip netns del "$probe" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
ns_exists() { ip netns list | awk '{print $1}' | grep -qx "$1"; }
|
|
||||||
|
|
||||||
# --- netfilter: метки policy routing и kill-switch --------------------------
|
|
||||||
# Стенд работает и на nftables (хост Debian/Ubuntu с nft), и на iptables
|
|
||||||
# (alpine-образ приложения: iptables без бинаря nft). Backend выбирается по
|
|
||||||
# наличию бинаря в netns шлюза; семантика правил одинаковая.
|
|
||||||
|
|
||||||
fw_kind() {
|
|
||||||
# AWG_FW_BACKEND=nft|iptables|auto — принудительный выбор backend (в alpine-образе
|
|
||||||
# приложения есть только iptables, на хосте обычно nft).
|
|
||||||
local want="${AWG_FW_BACKEND:-auto}"
|
|
||||||
case "$want" in
|
|
||||||
nft)
|
|
||||||
ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; }
|
|
||||||
echo none; return
|
|
||||||
;;
|
|
||||||
iptables)
|
|
||||||
ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; }
|
|
||||||
echo none; return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
# command -v — builtin shell, поэтому проверяем через sh -c внутри netns
|
|
||||||
if ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then
|
|
||||||
echo nft
|
|
||||||
elif ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then
|
|
||||||
echo iptables
|
|
||||||
else
|
|
||||||
echo none
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fw_available() {
|
|
||||||
local kind; kind="$(fw_kind)"
|
|
||||||
case "$kind" in
|
|
||||||
nft) ns_exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;;
|
|
||||||
iptables) ns_exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# Правило метки для трафика шлюза (OUTPUT) и для транзитного трафика LAN (FORWARD).
|
|
||||||
fw_setup() {
|
|
||||||
local kind; kind="$(fw_kind)"
|
|
||||||
case "$kind" in
|
|
||||||
nft)
|
|
||||||
ns_exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
|
|
||||||
ns_exec "$AWG_NS_GW" nft -f - <<EOF
|
|
||||||
table inet $NFT_TABLE {
|
|
||||||
chain mark_out {
|
|
||||||
type route hook output priority mangle; policy accept;
|
|
||||||
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_OUT"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chain mark_fwd {
|
|
||||||
type filter hook forward priority mangle; policy accept;
|
|
||||||
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_FWD"
|
|
||||||
}
|
|
||||||
chain forward_ks {
|
|
||||||
type filter hook forward priority filter; policy accept;
|
|
||||||
ip saddr $AWG_NET_CLIENT ip daddr $AWG_LEAK_NET counter drop comment "$C_DROP"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
iptables)
|
|
||||||
local ipt="ns_exec $AWG_NS_GW iptables"
|
|
||||||
$ipt -t mangle -N "$IPT_CHAIN_MARK" 2>/dev/null || true
|
|
||||||
$ipt -t mangle -F "$IPT_CHAIN_MARK"
|
|
||||||
$ipt -t mangle -A "$IPT_CHAIN_MARK" -p udp --dport "$AWG_MOCK_PORT" \
|
|
||||||
-m comment --comment "$C_MARK_OUT" -j MARK --set-mark "$AWG_FWMARK"
|
|
||||||
$ipt -t mangle -C OUTPUT -j "$IPT_CHAIN_MARK" 2>/dev/null \
|
|
||||||
|| $ipt -t mangle -A OUTPUT -j "$IPT_CHAIN_MARK"
|
|
||||||
$ipt -t mangle -N "$IPT_CHAIN_FWD" 2>/dev/null || true
|
|
||||||
$ipt -t mangle -F "$IPT_CHAIN_FWD"
|
|
||||||
$ipt -t mangle -A "$IPT_CHAIN_FWD" -p udp --dport "$AWG_MOCK_PORT" \
|
|
||||||
-m comment --comment "$C_MARK_FWD" -j MARK --set-mark "$AWG_FWMARK"
|
|
||||||
$ipt -t mangle -C FORWARD -j "$IPT_CHAIN_FWD" 2>/dev/null \
|
|
||||||
|| $ipt -t mangle -A FORWARD -j "$IPT_CHAIN_FWD"
|
|
||||||
$ipt -t filter -N "$IPT_CHAIN_KS" 2>/dev/null || true
|
|
||||||
$ipt -t filter -F "$IPT_CHAIN_KS"
|
|
||||||
$ipt -t filter -A "$IPT_CHAIN_KS" -s "$AWG_NET_CLIENT" -d "$AWG_LEAK_NET" \
|
|
||||||
-m comment --comment "$C_DROP" -j DROP
|
|
||||||
$ipt -t filter -C FORWARD -j "$IPT_CHAIN_KS" 2>/dev/null \
|
|
||||||
|| $ipt -t filter -A FORWARD -j "$IPT_CHAIN_KS"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
fail "нет ни nft, ни iptables в ${AWG_NS_GW} — стенд не может проверить маркировку"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
log "правила меток/kill-switch установлены (backend: $kind)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# killswitch on|off — включить/выключить fail-closed правило FORWARD.
|
|
||||||
fw_set_killswitch() {
|
|
||||||
local state="$1" kind; kind="$(fw_kind)"
|
|
||||||
case "$kind:$state" in
|
|
||||||
nft:on)
|
|
||||||
ns_exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
|
|
||||||
fw_setup
|
|
||||||
;;
|
|
||||||
nft:off)
|
|
||||||
ns_exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks
|
|
||||||
;;
|
|
||||||
iptables:on)
|
|
||||||
fw_setup
|
|
||||||
;;
|
|
||||||
iptables:off)
|
|
||||||
ns_exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
fail "fw_set_killswitch: неизвестное состояние '$state' (ожидается on|off)"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""mock_wg_endpoint.py — мок WireGuard/AmneziaWG-endpoint для netns-стенда (задача #21).
|
|
||||||
|
|
||||||
Зачем: интеграционные тесты split-tunnel должны идти без реального VPN-провайдера и
|
|
||||||
без настоящего туннеля. Мок слушает UDP-порт 51820 в netns `uplink` («ISP»),
|
|
||||||
распознаёт тип сообщения WireGuard по первому байту и отвечает на handshake
|
|
||||||
initiation (type 1, 148 байт) корректным по структуре handshake response
|
|
||||||
(type 2, 92 байта), подставляя sender_index клиента как receiver_index.
|
|
||||||
|
|
||||||
ЧЕГО МОК НЕ ДЕЛАЕТ (важно, чтобы не принять его за настоящий peer):
|
|
||||||
* не выполняет Noise IK handshake и никакой криптографии — поля «зашифрованы»
|
|
||||||
случайными байтами;
|
|
||||||
* не маршрутизирует трафик и не поднимает TUN-интерфейс.
|
|
||||||
Поэтому мок НЕ подходит для проверки шифрования/MTU/throughput — только для
|
|
||||||
проверки топологии, policy routing, метки трафика и fail-closed сценариев.
|
|
||||||
|
|
||||||
Режим работы переключается на лету через файл `--mode-file`:
|
|
||||||
respond — отвечать на handshake initiation (провайдер доступен);
|
|
||||||
drop — молча считать пакеты и не отвечать (имитация «провайдер недоступен»).
|
|
||||||
|
|
||||||
Лог: JSONL (по строке на событие), пишется и сбрасывается на диск сразу, чтобы
|
|
||||||
тесты могли его читать без гонок. Файл `--ready` создаётся после bind().
|
|
||||||
|
|
||||||
Только stdlib — запускается и на хосте, и в alpine-образе приложения.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
MSG_HANDSHAKE_INITIATION = 1
|
|
||||||
MSG_HANDSHAKE_RESPONSE = 2
|
|
||||||
MSG_COOKIE_REPLY = 3
|
|
||||||
MSG_TRANSPORT_DATA = 4
|
|
||||||
|
|
||||||
TYPE_NAMES = {
|
|
||||||
MSG_HANDSHAKE_INITIATION: "handshake_initiation",
|
|
||||||
MSG_HANDSHAKE_RESPONSE: "handshake_response",
|
|
||||||
MSG_COOKIE_REPLY: "cookie_reply",
|
|
||||||
MSG_TRANSPORT_DATA: "transport_data",
|
|
||||||
}
|
|
||||||
|
|
||||||
HANDSHAKE_INITIATION_LEN = 148
|
|
||||||
HANDSHAKE_RESPONSE_LEN = 92
|
|
||||||
|
|
||||||
|
|
||||||
def msg_type(payload: bytes) -> int:
|
|
||||||
"""WireGuard message type: младший байт little-endian uint32 (3 байта reserved)."""
|
|
||||||
if len(payload) < 4:
|
|
||||||
return -1
|
|
||||||
return payload[0]
|
|
||||||
|
|
||||||
|
|
||||||
def build_handshake_response(initiation: bytes) -> bytes:
|
|
||||||
"""Собрать структурно корректный type 2 (92 байта) в ответ на type 1.
|
|
||||||
|
|
||||||
Раскладка: type(1)+reserved(3) sender(4) receiver(4) ephemeral(32)
|
|
||||||
encrypted_nothing(16) mac1(16) mac2(16) = 92
|
|
||||||
"""
|
|
||||||
sender_index = initiation[4:8] if len(initiation) >= 8 else b"\x00" * 4
|
|
||||||
out = bytearray(HANDSHAKE_RESPONSE_LEN)
|
|
||||||
out[0] = MSG_HANDSHAKE_RESPONSE
|
|
||||||
# sender_index мока — случайный, receiver_index = sender_index клиента
|
|
||||||
out[4:8] = struct.pack("<I", random.getrandbits(32))
|
|
||||||
out[8:12] = sender_index
|
|
||||||
out[12:44] = os.urandom(32) # unencrypted_ephemeral
|
|
||||||
out[44:60] = os.urandom(16) # encrypted_nothing + poly1305 tag
|
|
||||||
out[60:76] = os.urandom(16) # mac1
|
|
||||||
out[76:92] = os.urandom(16) # mac2
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlLog:
|
|
||||||
"""Пишущий JSONL-лог с немедленным flush (тесты читают файл параллельно)."""
|
|
||||||
|
|
||||||
def __init__(self, path: str | None) -> None:
|
|
||||||
self._fh = None
|
|
||||||
if path:
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
||||||
self._fh = open(path, "a", buffering=1, encoding="utf-8")
|
|
||||||
|
|
||||||
def write(self, **event: object) -> None:
|
|
||||||
if self._fh is None:
|
|
||||||
return
|
|
||||||
event.setdefault("ts", round(time.time(), 6))
|
|
||||||
self._fh.write(json.dumps(event, sort_keys=True) + "\n")
|
|
||||||
self._fh.flush()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._fh is not None:
|
|
||||||
self._fh.close()
|
|
||||||
self._fh = None
|
|
||||||
|
|
||||||
|
|
||||||
def read_mode(mode_file: str | None, default: str) -> str:
|
|
||||||
"""Режим читается из файла на каждом пакете: тесты переключают без рестарта."""
|
|
||||||
if not mode_file:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
with open(mode_file, encoding="utf-8") as fh:
|
|
||||||
mode = fh.read().strip()
|
|
||||||
except OSError:
|
|
||||||
return default
|
|
||||||
return mode if mode in ("respond", "drop") else default
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Мок WireGuard-endpoint для netns-стенда (#21)")
|
|
||||||
ap.add_argument("--bind", default="0.0.0.0:51820", help="адрес:порт для прослушивания UDP")
|
|
||||||
ap.add_argument("--log", default=os.environ.get("AWG_MOCK_LOG", ""), help="JSONL-лог событий")
|
|
||||||
ap.add_argument("--ready", default=os.environ.get("AWG_MOCK_READY", ""), help="файл-флаг готовности")
|
|
||||||
ap.add_argument(
|
|
||||||
"--mode-file",
|
|
||||||
default=os.environ.get("AWG_MOCK_MODE_FILE", ""),
|
|
||||||
help="файл с режимом respond|drop (переключение на лету)",
|
|
||||||
)
|
|
||||||
ap.add_argument("--mode", default=os.environ.get("AWG_MOCK_MODE", "respond"), choices=["respond", "drop"])
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
host, _, port_s = args.bind.rpartition(":")
|
|
||||||
host = host or "0.0.0.0"
|
|
||||||
port = int(port_s or "51820")
|
|
||||||
|
|
||||||
log = JsonlLog(args.log or None)
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
try:
|
|
||||||
sock.bind((host, port))
|
|
||||||
except OSError as exc:
|
|
||||||
log.write(event="bind_error", addr=f"{host}:{port}", error=str(exc))
|
|
||||||
print(f"mock-endpoint: bind {host}:{port} failed: {exc}", file=sys.stderr)
|
|
||||||
return 4
|
|
||||||
sock.settimeout(0.25)
|
|
||||||
|
|
||||||
if args.ready:
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(args.ready)) or ".", exist_ok=True)
|
|
||||||
with open(args.ready, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(f"{os.getpid()}\n")
|
|
||||||
|
|
||||||
log.write(event="listen", addr=f"{host}:{port}", pid=os.getpid(), mode=args.mode)
|
|
||||||
print(f"mock-endpoint: udp/{port} listen, mode={args.mode}", flush=True)
|
|
||||||
|
|
||||||
rx_total = 0
|
|
||||||
tx_total = 0
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
payload, peer = sock.recvfrom(65535)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
|
|
||||||
rx_total += 1
|
|
||||||
mtype = msg_type(payload)
|
|
||||||
mode = read_mode(args.mode_file or None, args.mode)
|
|
||||||
log.write(
|
|
||||||
event="rx",
|
|
||||||
msg_type=mtype,
|
|
||||||
msg_name=TYPE_NAMES.get(mtype, "unknown"),
|
|
||||||
length=len(payload),
|
|
||||||
src=f"{peer[0]}:{peer[1]}",
|
|
||||||
rx_total=rx_total,
|
|
||||||
mode=mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "drop":
|
|
||||||
continue
|
|
||||||
if mtype != MSG_HANDSHAKE_INITIATION:
|
|
||||||
log.write(event="ignored", reason="not_handshake_initiation", msg_type=mtype)
|
|
||||||
continue
|
|
||||||
if len(payload) != HANDSHAKE_INITIATION_LEN:
|
|
||||||
log.write(
|
|
||||||
event="ignored",
|
|
||||||
reason="bad_initiation_length",
|
|
||||||
length=len(payload),
|
|
||||||
expected=HANDSHAKE_INITIATION_LEN,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
reply = build_handshake_response(payload)
|
|
||||||
sock.sendto(reply, peer)
|
|
||||||
tx_total += 1
|
|
||||||
log.write(
|
|
||||||
event="tx",
|
|
||||||
msg_type=MSG_HANDSHAKE_RESPONSE,
|
|
||||||
length=len(reply),
|
|
||||||
dst=f"{peer[0]}:{peer[1]}",
|
|
||||||
tx_total=tx_total,
|
|
||||||
)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
log.write(event="stop", rx_total=rx_total, tx_total=tx_total)
|
|
||||||
log.close()
|
|
||||||
if args.ready and os.path.exists(args.ready):
|
|
||||||
os.unlink(args.ready)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""netns_common.py — утилиты проверок netns-стенда (задача #21). Только stdlib.
|
|
||||||
|
|
||||||
Используется тестами (tests/netns/test_netns_stand.py) и run_tests.py.
|
|
||||||
Значения по умолчанию совпадают с tests/netns/lib.sh — переменные окружения
|
|
||||||
позволяют переопределить топологию и передаются run.sh в дочерние процессы.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Any, Iterable
|
|
||||||
|
|
||||||
# --- топология ---------------------------------------------------------------
|
|
||||||
CLIENT_NS = os.environ.get("AWG_NS_CLIENT", "awgt21-client")
|
|
||||||
GW_NS = os.environ.get("AWG_NS_GW", "awgt21-gw")
|
|
||||||
UPLINK_NS = os.environ.get("AWG_NS_UPLINK", "awgt21-uplink")
|
|
||||||
|
|
||||||
CLIENT_IP = os.environ.get("AWG_CLIENT_IP", "10.213.0.2")
|
|
||||||
GW_LAN_IP = os.environ.get("AWG_GW_LAN_IP", "10.213.0.1")
|
|
||||||
GW_WAN_IP = os.environ.get("AWG_GW_WAN_IP", "10.214.0.1")
|
|
||||||
UPLINK_IP = os.environ.get("AWG_UPLINK_IP", "10.214.0.2")
|
|
||||||
|
|
||||||
MOCK_PORT = int(os.environ.get("AWG_MOCK_PORT", "51820"))
|
|
||||||
LEAK_IP = os.environ.get("AWG_LEAK_IP", "198.51.100.9")
|
|
||||||
LEAK_PORT = int(os.environ.get("AWG_LEAK_PORT", "9999"))
|
|
||||||
|
|
||||||
# имена veth-интерфейсов (совпадают с tests/netns/lib.sh)
|
|
||||||
VETH_CLIENT = os.environ.get("AWG_VETH_CLIENT", "veth-c-gw")
|
|
||||||
VETH_GW_LAN = os.environ.get("AWG_VETH_GW_LAN", "veth-gw-lan")
|
|
||||||
VETH_GW_WAN = os.environ.get("AWG_VETH_GW_WAN", "veth-gw-wan")
|
|
||||||
VETH_UPLINK = os.environ.get("AWG_VETH_UPLINK", "veth-uplink")
|
|
||||||
|
|
||||||
FWMARK = int(os.environ.get("AWG_FWMARK", "51820"))
|
|
||||||
ROUTE_TABLE = int(os.environ.get("AWG_ROUTE_TABLE", "51820"))
|
|
||||||
|
|
||||||
WORKDIR = pathlib.Path(os.environ.get("AWG_NETNS_WORKDIR", "/tmp/awg-netns"))
|
|
||||||
MOCK_LOG = pathlib.Path(os.environ.get("AWG_MOCK_LOG", str(WORKDIR / "mock-endpoint.jsonl")))
|
|
||||||
MOCK_READY = pathlib.Path(os.environ.get("AWG_MOCK_READY", str(WORKDIR / "mock-endpoint.ready")))
|
|
||||||
MOCK_MODE_FILE = pathlib.Path(os.environ.get("AWG_MOCK_MODE_FILE", str(WORKDIR / "mock-mode")))
|
|
||||||
LEAK_LOG = pathlib.Path(os.environ.get("AWG_LEAK_LOG", str(WORKDIR / "leak-sink.jsonl")))
|
|
||||||
LEAK_READY = pathlib.Path(os.environ.get("AWG_LEAK_READY", str(WORKDIR / "leak-sink.ready")))
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
PROBE = HERE / "probe.py"
|
|
||||||
|
|
||||||
NFT_TABLE = "awgtest"
|
|
||||||
C_MARK_OUT = "awgtest-mark-out"
|
|
||||||
C_MARK_FWD = "awgtest-mark-fwd"
|
|
||||||
C_DROP = "awgtest-killswitch-drop"
|
|
||||||
IPT_CHAIN_MARK = "awgtest-mark"
|
|
||||||
IPT_CHAIN_FWD = "awgtest-mark-fwd"
|
|
||||||
IPT_CHAIN_KS = "awgtest-ks"
|
|
||||||
|
|
||||||
|
|
||||||
class StandError(RuntimeError):
|
|
||||||
"""Ошибка стенда (не теста): топология/утилиты не отвечают как ожидается."""
|
|
||||||
|
|
||||||
|
|
||||||
# Кэш выбранного режима входа в netns (ip|nsenter), см. ns_exec_mode().
|
|
||||||
_NS_EXEC_MODE: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
|
|
||||||
"""Запуск команды в корневом netns."""
|
|
||||||
cp = subprocess.run(
|
|
||||||
list(args),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if check and cp.returncode != 0:
|
|
||||||
raise StandError(f"команда {list(args)!r} завершилась с кодом {cp.returncode}: {cp.stderr.strip()}")
|
|
||||||
return cp
|
|
||||||
|
|
||||||
|
|
||||||
def ns_exec_direct(
|
|
||||||
mode: str,
|
|
||||||
ns: str,
|
|
||||||
args: Iterable[str],
|
|
||||||
check: bool = True,
|
|
||||||
timeout: float = 20.0,
|
|
||||||
) -> subprocess.CompletedProcess:
|
|
||||||
"""Одна попытка входа в netns выбранным способом (ip|nsenter)."""
|
|
||||||
cmd = (["nsenter", f"--net=/var/run/netns/{ns}"] if mode == "nsenter" else ["ip", "netns", "exec", ns]) + list(args)
|
|
||||||
return sh(cmd, check=check, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_ns_exec(mode: str) -> bool:
|
|
||||||
probe = os.environ.get("AWG_NETNS_PREFIX", "awgt21") + "-probe"
|
|
||||||
if sh(["ip", "netns", "add", probe], check=False).returncode != 0:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return ns_exec_direct(mode, probe, ["sh", "-c", "true"], check=False, timeout=10.0).returncode == 0
|
|
||||||
finally:
|
|
||||||
sh(["ip", "netns", "delete", probe], check=False)
|
|
||||||
|
|
||||||
|
|
||||||
def ns_exec_mode() -> str:
|
|
||||||
"""Режим входа в netns: ip | nsenter (issue #56).
|
|
||||||
|
|
||||||
`ip netns exec` (iproute2 6.x) перемонтирует /sys и в контейнере падает с
|
|
||||||
"mount of /sys failed: Operation not permitted"; nsenter входит в
|
|
||||||
/var/run/netns/<ns> без перемонтирования. Режим кэшируется на процесс,
|
|
||||||
AWG_NS_EXEC_MODE=ip|nsenter|auto переопределяет выбор.
|
|
||||||
"""
|
|
||||||
global _NS_EXEC_MODE
|
|
||||||
if _NS_EXEC_MODE is None:
|
|
||||||
want = os.environ.get("AWG_NS_EXEC_MODE", "auto")
|
|
||||||
if want in ("ip", "nsenter"):
|
|
||||||
_NS_EXEC_MODE = want
|
|
||||||
elif _probe_ns_exec("ip"):
|
|
||||||
_NS_EXEC_MODE = "ip"
|
|
||||||
elif shutil.which("nsenter") and _probe_ns_exec("nsenter"):
|
|
||||||
_NS_EXEC_MODE = "nsenter"
|
|
||||||
else:
|
|
||||||
raise StandError(
|
|
||||||
"ни 'ip netns exec', ни 'nsenter --net' не работают в этом окружении "
|
|
||||||
"(нет CAP_SYS_ADMIN/NET_ADMIN) — issue #56"
|
|
||||||
)
|
|
||||||
return _NS_EXEC_MODE
|
|
||||||
|
|
||||||
|
|
||||||
def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
|
|
||||||
"""Запуск команды внутри указанного netns (ip или nsenter — см. ns_exec_mode)."""
|
|
||||||
return ns_exec_direct(ns_exec_mode(), ns, args, check=check, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def ns_exists(ns: str) -> bool:
|
|
||||||
cp = sh(["ip", "netns", "list"], check=False)
|
|
||||||
return any(line.split()[0] == ns for line in cp.stdout.splitlines() if line.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def addrs(ns: str) -> str:
|
|
||||||
return ns_sh(ns, ["ip", "-br", "addr", "show"]).stdout
|
|
||||||
|
|
||||||
|
|
||||||
def links(ns: str) -> str:
|
|
||||||
return ns_sh(ns, ["ip", "-br", "link", "show"]).stdout
|
|
||||||
|
|
||||||
|
|
||||||
def link_is_up(ns: str, ifname: str) -> bool:
|
|
||||||
cp = ns_sh(ns, ["ip", "-br", "link", "show", ifname], check=False)
|
|
||||||
return cp.returncode == 0 and "UP" in cp.stdout
|
|
||||||
|
|
||||||
|
|
||||||
# --- netfilter: backend, правила, счётчики -----------------------------------
|
|
||||||
def fw_kind() -> str:
|
|
||||||
"""Backend netfilter в netns шлюза: nft|iptables (AWG_FW_BACKEND форсирует выбор)."""
|
|
||||||
want = os.environ.get("AWG_FW_BACKEND", "auto")
|
|
||||||
if want in ("nft", "iptables"):
|
|
||||||
if ns_sh(GW_NS, ["sh", "-c", f"command -v {want}"], check=False).returncode == 0:
|
|
||||||
return want
|
|
||||||
return "none"
|
|
||||||
if ns_sh(GW_NS, ["sh", "-c", "command -v nft"], check=False).returncode == 0:
|
|
||||||
return "nft"
|
|
||||||
if ns_sh(GW_NS, ["sh", "-c", "command -v iptables"], check=False).returncode == 0:
|
|
||||||
return "iptables"
|
|
||||||
return "none"
|
|
||||||
|
|
||||||
|
|
||||||
def nft_rules() -> list[dict[str, Any]]:
|
|
||||||
"""Правила таблицы inet awgtest в JSON-виде (nft -j list table)."""
|
|
||||||
cp = ns_sh(GW_NS, ["nft", "-j", "list", "table", "inet", NFT_TABLE], check=False)
|
|
||||||
if cp.returncode != 0:
|
|
||||||
raise StandError(f"nft list table inet {NFT_TABLE} вернул {cp.returncode}: {cp.stderr.strip()}")
|
|
||||||
payload = json.loads(cp.stdout)
|
|
||||||
return [obj["rule"] for obj in payload.get("nftables", []) if "rule" in obj]
|
|
||||||
|
|
||||||
|
|
||||||
def _nft_rule_counter(rule: dict[str, Any]) -> int | None:
|
|
||||||
for expr in rule.get("expr", []):
|
|
||||||
if isinstance(expr, dict) and "counter" in expr:
|
|
||||||
return int(expr["counter"].get("packets", 0))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def fw_counters() -> dict[str, Any]:
|
|
||||||
"""Счётчики правил: {fw, mark_out, mark_fwd, killswitch_drop} (None — недоступно/нет правила)."""
|
|
||||||
kind = fw_kind()
|
|
||||||
out: dict[str, Any] = {"fw": kind, "mark_out": None, "mark_fwd": None, "killswitch_drop": None}
|
|
||||||
if kind == "nft":
|
|
||||||
for rule in nft_rules():
|
|
||||||
comment = str(rule.get("comment", ""))
|
|
||||||
counter = _nft_rule_counter(rule)
|
|
||||||
if comment == C_MARK_OUT:
|
|
||||||
out["mark_out"] = counter
|
|
||||||
elif comment == C_MARK_FWD:
|
|
||||||
out["mark_fwd"] = counter
|
|
||||||
elif comment == C_DROP:
|
|
||||||
out["killswitch_drop"] = counter
|
|
||||||
elif kind == "iptables":
|
|
||||||
out["mark_out"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_MARK)
|
|
||||||
out["mark_fwd"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_FWD)
|
|
||||||
out["killswitch_drop"] = _iptables_counter("-t", "filter", "-L", IPT_CHAIN_KS)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _iptables_counter(*args: str) -> int | None:
|
|
||||||
"""Пакеты первого правила в цепочке iptables (в alpine iptables — nft-backend)."""
|
|
||||||
cp = ns_sh(GW_NS, ["iptables", *args, "-v", "-n", "-x"], check=False)
|
|
||||||
if cp.returncode != 0:
|
|
||||||
return None
|
|
||||||
for line in cp.stdout.splitlines():
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) >= 2 and fields[0].isdigit() and fields[1].isdigit():
|
|
||||||
return int(fields[0])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def iptables_rules() -> str:
|
|
||||||
parts = []
|
|
||||||
for args in (("-t", "mangle", "-S", IPT_CHAIN_MARK), ("-t", "filter", "-S", IPT_CHAIN_KS)):
|
|
||||||
cp = ns_sh(GW_NS, ["iptables", *args], check=False)
|
|
||||||
parts.append(cp.stdout.strip() or cp.stderr.strip())
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
# --- артефакты процесса (логи мока и детектора утечки) -----------------------
|
|
||||||
def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]:
|
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
events: list[dict[str, Any]] = []
|
|
||||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
events.append(json.loads(line))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
def wait_ready(path: pathlib.Path, timeout: float = 10.0, what: str = "процесс") -> None:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if path.exists():
|
|
||||||
return
|
|
||||||
time.sleep(0.1)
|
|
||||||
raise StandError(f"{what} не поднялся за {timeout}с (нет файла {path})")
|
|
||||||
|
|
||||||
|
|
||||||
def wait_jsonl(path: pathlib.Path, predicate, timeout: float = 5.0) -> dict[str, Any] | None:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
for event in read_jsonl(path):
|
|
||||||
if predicate(event):
|
|
||||||
return event
|
|
||||||
time.sleep(0.1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --- управление моком --------------------------------------------------------
|
|
||||||
def set_mock_mode(mode: str) -> None:
|
|
||||||
if mode not in ("respond", "drop"):
|
|
||||||
raise ValueError(f"неизвестный режим мока: {mode}")
|
|
||||||
MOCK_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
MOCK_MODE_FILE.write_text(mode + "\n", encoding="utf-8")
|
|
||||||
time.sleep(0.3) # мок читает режим на каждом пакете
|
|
||||||
|
|
||||||
|
|
||||||
# --- трафик ------------------------------------------------------------------
|
|
||||||
def probe(
|
|
||||||
src_ns: str,
|
|
||||||
dst: str,
|
|
||||||
port: int,
|
|
||||||
*,
|
|
||||||
expect_timeout: bool = False,
|
|
||||||
raw: str | None = None,
|
|
||||||
timeout: float = 2.0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Отправить тестовый трафик из netns и вернуть разобранный JSON-ответ probe.py."""
|
|
||||||
args = ["python3", str(PROBE), "--dst", dst, "--port", str(port), "--timeout", str(timeout)]
|
|
||||||
if raw is not None:
|
|
||||||
args += ["--raw", raw]
|
|
||||||
if expect_timeout:
|
|
||||||
args.append("--expect-timeout")
|
|
||||||
cp = ns_sh(src_ns, args, check=False, timeout=timeout + 10)
|
|
||||||
line = cp.stdout.strip().splitlines()[-1] if cp.stdout.strip() else "{}"
|
|
||||||
try:
|
|
||||||
result = json.loads(line)
|
|
||||||
except json.JSONDecodeError as exc: # pragma: no cover - диагностика
|
|
||||||
raise StandError(f"probe.py вернул не-JSON ({exc}): stdout={cp.stdout!r} stderr={cp.stderr!r}") from exc
|
|
||||||
result["exit_code"] = cp.returncode
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def mock_events() -> list[dict[str, Any]]:
|
|
||||||
return read_jsonl(MOCK_LOG)
|
|
||||||
|
|
||||||
|
|
||||||
def leak_events() -> list[dict[str, Any]]:
|
|
||||||
return [e for e in read_jsonl(LEAK_LOG) if e.get("event") == "leak"]
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""probe.py — генератор тестового трафика из netns-стенда (задача #21).
|
|
||||||
|
|
||||||
Запускается внутри конкретного netns (`ip netns exec <ns> python3 probe.py ...`)
|
|
||||||
и умеет:
|
|
||||||
|
|
||||||
* `--wg-initiation` (по умолчанию) — отправить WireGuard handshake initiation
|
|
||||||
(type 1, 148 байт, раскладка как в протоколе) и дождаться ответа мока;
|
|
||||||
* `--raw TEXT` — отправить произвольный UDP-datagram (для проверок утечки /
|
|
||||||
kill-switch), по умолчанию ответа не ожидает.
|
|
||||||
|
|
||||||
Результат печатается одной JSON-строкой в stdout, код возврата:
|
|
||||||
0 — ожидаемое поведение достигнуто, 1 — нет, 2 — ошибка аргументов/сокета.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
MSG_HANDSHAKE_INITIATION = 1
|
|
||||||
MSG_HANDSHAKE_RESPONSE = 2
|
|
||||||
HANDSHAKE_INITIATION_LEN = 148
|
|
||||||
|
|
||||||
|
|
||||||
def build_handshake_initiation(sender_index: int | None = None) -> bytes:
|
|
||||||
"""148 байт: type(1)+reserved(3) sender(4) ephemeral(32) enc_static(48) enc_ts(28) mac1(16) mac2(16)."""
|
|
||||||
if sender_index is None:
|
|
||||||
sender_index = struct.unpack("<I", os.urandom(4))[0]
|
|
||||||
out = bytearray(HANDSHAKE_INITIATION_LEN)
|
|
||||||
out[0] = MSG_HANDSHAKE_INITIATION
|
|
||||||
out[4:8] = struct.pack("<I", sender_index)
|
|
||||||
out[8:40] = os.urandom(32) # unencrypted_ephemeral
|
|
||||||
out[40:88] = os.urandom(48) # encrypted_static (32) + poly1305 tag (16)
|
|
||||||
out[88:116] = os.urandom(28) # encrypted_timestamp (12) + tag (16)
|
|
||||||
out[116:132] = os.urandom(16) # mac1
|
|
||||||
out[132:148] = os.urandom(16) # mac2
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Тестовый трафик для netns-стенда (#21)")
|
|
||||||
ap.add_argument("--dst", required=True, help="IP назначения")
|
|
||||||
ap.add_argument("--port", type=int, required=True, help="UDP-порт назначения")
|
|
||||||
ap.add_argument("--timeout", type=float, default=2.0, help="ожидание ответа, с")
|
|
||||||
ap.add_argument("--raw", default=None, help="отправить произвольный текст вместо handshake initiation")
|
|
||||||
ap.add_argument("--expect-timeout", action="store_true", help="ожидать отсутствия ответа (для kill-switch/drop)")
|
|
||||||
ap.add_argument("--source-port", type=int, default=0, help="локальный порт (0 — случайный)")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
if args.raw is None:
|
|
||||||
payload = build_handshake_initiation()
|
|
||||||
expect_reply = not args.expect_timeout
|
|
||||||
kind = "wg_handshake_initiation"
|
|
||||||
else:
|
|
||||||
payload = args.raw.encode("utf-8")
|
|
||||||
# для raw-трафика ответ не ожидается, если явно не сказано обратное
|
|
||||||
expect_reply = not args.expect_timeout
|
|
||||||
kind = "raw_udp"
|
|
||||||
|
|
||||||
import socket
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
if args.source_port:
|
|
||||||
sock.bind(("", args.source_port))
|
|
||||||
sock.settimeout(args.timeout)
|
|
||||||
|
|
||||||
started = time.time()
|
|
||||||
try:
|
|
||||||
sent = sock.sendto(payload, (args.dst, args.port))
|
|
||||||
except OSError as exc:
|
|
||||||
print(json.dumps({"ok": False, "kind": kind, "error": f"sendto: {exc}"}))
|
|
||||||
return 2
|
|
||||||
|
|
||||||
reply = None
|
|
||||||
try:
|
|
||||||
data, peer = sock.recvfrom(65535)
|
|
||||||
reply = {
|
|
||||||
"type": data[0] if data else -1,
|
|
||||||
"length": len(data or b""),
|
|
||||||
"src": f"{peer[0]}:{peer[1]}",
|
|
||||||
"receiver_index": (
|
|
||||||
struct.unpack("<I", data[8:12])[0] if len(data) >= 12 and data[0] == MSG_HANDSHAKE_RESPONSE else None
|
|
||||||
),
|
|
||||||
"sender_index_echo": struct.unpack("<I", payload[4:8])[0],
|
|
||||||
}
|
|
||||||
except socket.timeout:
|
|
||||||
reply = None
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
got_reply = reply is not None
|
|
||||||
ok = got_reply if expect_reply else (not got_reply)
|
|
||||||
out = {
|
|
||||||
"ok": ok,
|
|
||||||
"kind": kind,
|
|
||||||
"sent": sent,
|
|
||||||
"dst": f"{args.dst}:{args.port}",
|
|
||||||
"expect_reply": expect_reply,
|
|
||||||
"reply_received": got_reply,
|
|
||||||
"reply": reply,
|
|
||||||
"rtt_ms": round((time.time() - started) * 1000, 2),
|
|
||||||
}
|
|
||||||
print(json.dumps(out, sort_keys=True))
|
|
||||||
return 0 if ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run.sh — точка входа netns-стенда awg-dash (вызывается из `make test-netns`).
|
|
||||||
#
|
|
||||||
# tests/netns/run.sh [опции]
|
|
||||||
#
|
|
||||||
# Что делает:
|
|
||||||
# 1) preflight: root/CAP_NET_ADMIN/ip/python3;
|
|
||||||
# 2) поднимает netns-топологию (topology.sh up);
|
|
||||||
# 3) поднимает мок WireGuard-endpoint и детектор утечки внутри netns `uplink`;
|
|
||||||
# 4) прогоняет интеграционные тесты стенда (tests/netns/test_netns_stand.py);
|
|
||||||
# 5) сносит стенд и проверяет, что namespace'ы удалены.
|
|
||||||
#
|
|
||||||
# Опции:
|
|
||||||
# --keep-topology не удалять топологию после прогона (для отладки: make test-netns-keep)
|
|
||||||
# --keep-logs не удалять рабочий каталог с логами (по умолчанию сохраняется всегда
|
|
||||||
# при провале или при --keep-topology)
|
|
||||||
# --no-tests только поднять стенд и напечатать сводку
|
|
||||||
# --report PATH путь к JSON-отчёту (по умолчанию $AWG_NETNS_WORKDIR/report.json)
|
|
||||||
# --workdir PATH рабочий каталог стенда (логи, флаги готовности)
|
|
||||||
# -h|--help эта справка
|
|
||||||
#
|
|
||||||
# Коды возврата: 0 — успех; 1 — тесты упали; 3 — заблокировано окружением
|
|
||||||
# (нет root/CAP_NET_ADMIN); 4 — ошибка стенда.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=tests/netns/lib.sh
|
|
||||||
source "$HERE/lib.sh"
|
|
||||||
|
|
||||||
# Контракт кодов возврата: 0 — успех, 1 — тесты упали, 3 — заблокировано
|
|
||||||
# окружением, 4 — ошибка стенда. Неожиданные коды утилит (например 255 от
|
|
||||||
# `ip netns exec` в контейнере — issue #56) нормализуем в 4, чтобы CI #17
|
|
||||||
# различал «нет прав» и «сломался стенд».
|
|
||||||
trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "run.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR
|
|
||||||
|
|
||||||
KEEP_TOPO=0
|
|
||||||
NO_TESTS=0
|
|
||||||
REPORT=""
|
|
||||||
WORKDIR_OPT=""
|
|
||||||
|
|
||||||
usage() { sed -n '2,25p' "$HERE/run.sh" | sed 's/^# \{0,1\}//'; }
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--keep-topology) KEEP_TOPO=1 ;;
|
|
||||||
--keep-logs) : ;;
|
|
||||||
--no-tests) NO_TESTS=1 ;;
|
|
||||||
--report) REPORT="${2:?--report требует путь}"; shift ;;
|
|
||||||
--workdir) WORKDIR_OPT="${2:?--workdir требует путь}"; shift ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) fail "неизвестная опция: $1 (см. --help)" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- preflight ---------------------------------------------------------------
|
|
||||||
require_root
|
|
||||||
require_cmd ip
|
|
||||||
require_cmd python3
|
|
||||||
require_netns_capability
|
|
||||||
NS_EXEC_RESOLVED="$(select_ns_exec_mode)"
|
|
||||||
export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED"
|
|
||||||
log "вход в netns: ${NS_EXEC_RESOLVED}"
|
|
||||||
|
|
||||||
if [ -n "$WORKDIR_OPT" ]; then
|
|
||||||
export AWG_NETNS_WORKDIR="$WORKDIR_OPT"
|
|
||||||
else
|
|
||||||
export AWG_NETNS_WORKDIR="${AWG_NETNS_WORKDIR:-$(mktemp -d -t awg-netns-XXXXXX)}"
|
|
||||||
fi
|
|
||||||
mkdir -p "$AWG_NETNS_WORKDIR"
|
|
||||||
: "${REPORT:=${AWG_NETNS_WORKDIR}/report.json}"
|
|
||||||
|
|
||||||
# пути артефактов процессов — в дочерние процессы уходят через окружение
|
|
||||||
export AWG_MOCK_LOG="${AWG_NETNS_WORKDIR}/mock-endpoint.jsonl"
|
|
||||||
export AWG_MOCK_READY="${AWG_NETNS_WORKDIR}/mock-endpoint.ready"
|
|
||||||
export AWG_MOCK_MODE_FILE="${AWG_NETNS_WORKDIR}/mock-mode"
|
|
||||||
export AWG_LEAK_LOG="${AWG_NETNS_WORKDIR}/leak-sink.jsonl"
|
|
||||||
export AWG_LEAK_READY="${AWG_NETNS_WORKDIR}/leak-sink.ready"
|
|
||||||
|
|
||||||
# чистое состояние артефактов прошлого прогона
|
|
||||||
rm -f "$AWG_MOCK_LOG" "$AWG_MOCK_READY" "$AWG_LEAK_LOG" "$AWG_LEAK_READY"
|
|
||||||
printf 'respond\n' > "$AWG_MOCK_MODE_FILE"
|
|
||||||
|
|
||||||
MOCK_PID=""
|
|
||||||
SINK_PID=""
|
|
||||||
TEST_RC=0
|
|
||||||
CLEANED=0
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[ "$CLEANED" -eq 1 ] && return 0
|
|
||||||
CLEANED=1
|
|
||||||
for pid in "$MOCK_PID" "$SINK_PID"; do
|
|
||||||
[ -n "$pid" ] || continue
|
|
||||||
kill -0 "$pid" 2>/dev/null || continue
|
|
||||||
kill -TERM "$pid" 2>/dev/null || true
|
|
||||||
# до 2с на штатное завершение, затем SIGKILL — иначе прогон «висит» на wait
|
|
||||||
for _ in $(seq 1 20); do
|
|
||||||
kill -0 "$pid" 2>/dev/null || break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
kill -KILL "$pid" 2>/dev/null || true
|
|
||||||
wait "$pid" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
if [ "$KEEP_TOPO" -eq 1 ]; then
|
|
||||||
warn "топология сохранена (--keep-topology): ${AWG_NS_CLIENT} ${AWG_NS_GW} ${AWG_NS_UPLINK}"
|
|
||||||
warn "снести: bash tests/netns/topology.sh down"
|
|
||||||
else
|
|
||||||
bash "$HERE/topology.sh" down >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# --- стенд -------------------------------------------------------------------
|
|
||||||
log "рабочий каталог: ${AWG_NETNS_WORKDIR}"
|
|
||||||
bash "$HERE/topology.sh" up
|
|
||||||
|
|
||||||
log "запускаю мок WireGuard-endpoint (udp/${AWG_MOCK_PORT}) в ${AWG_NS_UPLINK}"
|
|
||||||
mapfile -t NS_PREFIX < <(ns_prefix "$AWG_NS_UPLINK")
|
|
||||||
( exec "${NS_PREFIX[@]}" python3 "$HERE/mock_wg_endpoint.py" \
|
|
||||||
--bind "0.0.0.0:${AWG_MOCK_PORT}" --log "$AWG_MOCK_LOG" --ready "$AWG_MOCK_READY" \
|
|
||||||
--mode-file "$AWG_MOCK_MODE_FILE" --mode respond ) &
|
|
||||||
MOCK_PID=$!
|
|
||||||
|
|
||||||
log "запускаю детектор утечки (${AWG_LEAK_IP}:${AWG_LEAK_PORT}) в ${AWG_NS_UPLINK}"
|
|
||||||
( exec "${NS_PREFIX[@]}" python3 "$HERE/leak_sink.py" \
|
|
||||||
--ip "$AWG_LEAK_IP" --port "$AWG_LEAK_PORT" --log "$AWG_LEAK_LOG" --ready "$AWG_LEAK_READY" ) &
|
|
||||||
SINK_PID=$!
|
|
||||||
|
|
||||||
# ждём готовности обоих процессов (файлы-флаги пишутся после bind)
|
|
||||||
for _ in $(seq 1 100); do
|
|
||||||
if [ -f "$AWG_MOCK_READY" ] && [ -f "$AWG_LEAK_READY" ]; then break; fi
|
|
||||||
if ! kill -0 "$MOCK_PID" 2>/dev/null; then stand_fail "мок WireGuard-endpoint упал при старте"; fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
[ -f "$AWG_MOCK_READY" ] || stand_fail "мок WireGuard-endpoint не поднялся за 10с"
|
|
||||||
[ -f "$AWG_LEAK_READY" ] || stand_fail "детектор утечки не поднялся за 10с"
|
|
||||||
ok "мок-endpoint и детектор утечки готовы"
|
|
||||||
|
|
||||||
# --- тесты -------------------------------------------------------------------
|
|
||||||
if [ "$NO_TESTS" -eq 1 ]; then
|
|
||||||
log "--no-tests: стенд поднят, тесты пропущены"
|
|
||||||
else
|
|
||||||
log "прогон интеграционных тестов стенда"
|
|
||||||
AWG_NETNS_RUN=1 AWG_NETNS_WORKDIR="$AWG_NETNS_WORKDIR" \
|
|
||||||
python3 "$HERE/run_tests.py" --report "$REPORT" || TEST_RC=$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- проверка сноса ----------------------------------------------------------
|
|
||||||
TOPOLOGY_REMOVED=true
|
|
||||||
if [ "$KEEP_TOPO" -eq 0 ]; then
|
|
||||||
cleanup
|
|
||||||
trap - EXIT INT TERM
|
|
||||||
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
|
|
||||||
if ns_exists "$ns"; then
|
|
||||||
warn "после сноса остался netns ${ns} — стенд не очищен"
|
|
||||||
TOPOLOGY_REMOVED=false
|
|
||||||
TEST_RC=4
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ "$TOPOLOGY_REMOVED" = true ] && ok "топология снесена, namespace'ы удалены"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$NO_TESTS" -eq 1 ] && [ "$TOPOLOGY_REMOVED" = true ]; then
|
|
||||||
ok "стенд поднят успешно (AC-3: netns + мок-endpoint без реального провайдера)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$TEST_RC" -eq 0 ]; then
|
|
||||||
ok "netns-стенд: тесты пройдены; отчёт: ${REPORT}"
|
|
||||||
else
|
|
||||||
warn "netns-стенд: тесты/очистка упали (код ${TEST_RC}); логи: ${AWG_NETNS_WORKDIR}"
|
|
||||||
fi
|
|
||||||
exit "$TEST_RC"
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""run_tests.py — прогон netns-тестов с JSON-отчётом (задача #21). Только stdlib.
|
|
||||||
|
|
||||||
Запускается из tests/netns/run.sh. Не требует pytest: unittest есть в stdlib,
|
|
||||||
поэтому стенд работает и на хосте, и в alpine-образе приложения.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Прогон netns-тестов awg-dash (#21)")
|
|
||||||
ap.add_argument("--report", default=os.environ.get("AWG_NETNS_REPORT", ""))
|
|
||||||
ap.add_argument("--pattern", default="test_*.py")
|
|
||||||
ap.add_argument("--verbosity", type=int, default=2)
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
here = pathlib.Path(__file__).resolve().parent
|
|
||||||
# тесты стенда пропускаются вне run.sh (нужны root + поднятый стенд)
|
|
||||||
os.environ.setdefault("AWG_NETNS_RUN", "1")
|
|
||||||
|
|
||||||
loader = unittest.TestLoader()
|
|
||||||
loader.sortTestMethodsUsing = staticmethod(lambda a, b: (a > b) - (a < b)) # стабильный алфавитный порядок
|
|
||||||
suite = loader.discover(str(here), pattern=args.pattern, top_level_dir=str(here))
|
|
||||||
|
|
||||||
started = time.time()
|
|
||||||
result = unittest.TextTestRunner(verbosity=args.verbosity).run(suite)
|
|
||||||
duration = round(time.time() - started, 3)
|
|
||||||
|
|
||||||
details = [
|
|
||||||
{"test": str(test), "status": "failed", "message": message}
|
|
||||||
for test, message in list(result.failures) + list(result.errors)
|
|
||||||
]
|
|
||||||
details += [{"test": str(test), "status": "skipped", "message": message} for test, message in result.skipped]
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"suite": "tests/netns",
|
|
||||||
"tests": result.testsRun,
|
|
||||||
"passed": result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped),
|
|
||||||
"failures": len(result.failures),
|
|
||||||
"errors": len(result.errors),
|
|
||||||
"skipped": len(result.skipped),
|
|
||||||
"duration_s": duration,
|
|
||||||
"details": details,
|
|
||||||
"returncode": 0 if result.wasSuccessful() else 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if args.report:
|
|
||||||
path = pathlib.Path(args.report)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
print(
|
|
||||||
"[netns] тесты: {tests} всего, {passed} ok, {failures} fail, {errors} err, "
|
|
||||||
"{skipped} skip, {duration_s}s".format(**payload)
|
|
||||||
)
|
|
||||||
return int(payload["returncode"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"""Интеграционные тесты netns-стенда awg-dash (задача #21, эпик #15).
|
|
||||||
|
|
||||||
Проверяют, что стенд реально поднимает топологию client/gw/uplink, мок
|
|
||||||
WireGuard-endpoint отвечает без настоящего провайдера, тестовый трафик
|
|
||||||
маркируется, а kill-switch не пускает трафик напрямую.
|
|
||||||
|
|
||||||
Запускать только через `tests/netns/run.sh` (или `make test-netns`): тестам
|
|
||||||
нужны root, поднятая топология и запущенные процессы стенда. Вне run.sh тесты
|
|
||||||
помечаются skipped, чтобы случайный прогон pytest/CI не падал.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import netns_common as nc
|
|
||||||
|
|
||||||
REASON = "netns-стенд не запущен: используйте tests/netns/run.sh (нужны root и CAP_NET_ADMIN)"
|
|
||||||
KILLSWITCH_SCRIPT = str(nc.HERE / "topology.sh")
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get("AWG_NETNS_RUN") == "1", REASON)
|
|
||||||
class NetnsStandTest(unittest.TestCase):
|
|
||||||
"""Порядок методов значим: стенд stateful, шаги идут как test_01..test_08."""
|
|
||||||
|
|
||||||
maxDiff = None
|
|
||||||
|
|
||||||
def _killswitch(self, state: str) -> None:
|
|
||||||
nc.sh(["bash", KILLSWITCH_SCRIPT, "killswitch", state])
|
|
||||||
|
|
||||||
# --- AC-3, шаг 1: топология ---------------------------------------------
|
|
||||||
def test_01_namespaces_and_links(self) -> None:
|
|
||||||
for ns in (nc.CLIENT_NS, nc.GW_NS, nc.UPLINK_NS):
|
|
||||||
self.assertTrue(nc.ns_exists(ns), f"netns {ns} не создан")
|
|
||||||
|
|
||||||
self.assertIn(nc.CLIENT_IP, nc.addrs(nc.CLIENT_NS), "у клиента нет ожидаемого адреса")
|
|
||||||
gw_addrs = nc.addrs(nc.GW_NS)
|
|
||||||
self.assertIn(nc.GW_LAN_IP, gw_addrs, "у шлюза нет LAN-адреса")
|
|
||||||
self.assertIn(nc.GW_WAN_IP, gw_addrs, "у шлюза нет WAN-адреса")
|
|
||||||
self.assertIn(nc.UPLINK_IP, nc.addrs(nc.UPLINK_NS), "у аплинка нет ожидаемого адреса")
|
|
||||||
|
|
||||||
for ns, ifname in (
|
|
||||||
(nc.CLIENT_NS, nc.VETH_CLIENT),
|
|
||||||
(nc.GW_NS, nc.VETH_GW_LAN),
|
|
||||||
(nc.GW_NS, nc.VETH_GW_WAN),
|
|
||||||
(nc.UPLINK_NS, nc.VETH_UPLINK),
|
|
||||||
):
|
|
||||||
self.assertTrue(nc.link_is_up(ns, ifname), f"{ifname} в {ns} не UP")
|
|
||||||
|
|
||||||
forwarding = nc.ns_sh(nc.GW_NS, ["sysctl", "-n", "net.ipv4.ip_forward"]).stdout.strip()
|
|
||||||
self.assertEqual("1", forwarding, "в шлюзе не включён ip_forward — транзит не пойдёт")
|
|
||||||
|
|
||||||
# --- policy routing без реального провайдера ------------------------------
|
|
||||||
def test_02_policy_routing_by_fwmark(self) -> None:
|
|
||||||
rules = nc.ns_sh(nc.GW_NS, ["ip", "rule", "show"]).stdout
|
|
||||||
self.assertIn(f"lookup {nc.ROUTE_TABLE}", rules, "нет ip rule fwmark -> table")
|
|
||||||
self.assertIn("fwmark", rules, "правило policy routing не содержит fwmark")
|
|
||||||
|
|
||||||
table = nc.ns_sh(nc.GW_NS, ["ip", "route", "show", "table", str(nc.ROUTE_TABLE)]).stdout
|
|
||||||
self.assertIn(f"via {nc.UPLINK_IP}", table, f"в таблице {nc.ROUTE_TABLE} нет default via аплинк")
|
|
||||||
|
|
||||||
route_get = nc.ns_sh(
|
|
||||||
nc.GW_NS, ["ip", "route", "get", nc.UPLINK_IP, "mark", str(nc.FWMARK)]
|
|
||||||
).stdout
|
|
||||||
self.assertIn(
|
|
||||||
f"table {nc.ROUTE_TABLE}",
|
|
||||||
route_get,
|
|
||||||
f"маршрут для fwmark {nc.FWMARK} не берётся из таблицы {nc.ROUTE_TABLE}: {route_get}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_03_netfilter_mark_and_killswitch_rules(self) -> None:
|
|
||||||
kind = nc.fw_kind()
|
|
||||||
self.assertIn(kind, ("nft", "iptables"), "в netns шлюза нет ни nft, ни iptables")
|
|
||||||
|
|
||||||
if kind == "nft":
|
|
||||||
comments = {str(rule.get("comment", "")) for rule in nc.nft_rules()}
|
|
||||||
for needle in (nc.C_MARK_OUT, nc.C_MARK_FWD, nc.C_DROP):
|
|
||||||
self.assertIn(needle, comments, f"нет правила nft с комментарием {needle}")
|
|
||||||
else:
|
|
||||||
rules = nc.iptables_rules()
|
|
||||||
self.assertIn(nc.IPT_CHAIN_MARK, rules, "нет цепочки метки iptables")
|
|
||||||
self.assertIn(nc.IPT_CHAIN_KS, rules, "нет цепочки kill-switch iptables")
|
|
||||||
self.assertIn("MARK", rules, "нет правила MARK --set-mark")
|
|
||||||
self.assertIn("DROP", rules, "нет правила DROP kill-switch")
|
|
||||||
|
|
||||||
# --- мок-endpoint --------------------------------------------------------
|
|
||||||
def test_04_mock_endpoint_and_leak_sink_ready(self) -> None:
|
|
||||||
self.assertTrue(nc.MOCK_READY.exists(), f"нет флага готовности мока {nc.MOCK_READY}")
|
|
||||||
self.assertTrue(nc.LEAK_READY.exists(), f"нет флага готовности детектора {nc.LEAK_READY}")
|
|
||||||
|
|
||||||
listen = nc.wait_jsonl(nc.MOCK_LOG, lambda e: e.get("event") == "listen", timeout=3)
|
|
||||||
self.assertIsNotNone(listen, "в логе мока нет события listen")
|
|
||||||
self.assertIn(str(nc.MOCK_PORT), str(listen.get("addr")), "мок слушает не тот порт")
|
|
||||||
|
|
||||||
sink = nc.wait_jsonl(nc.LEAK_LOG, lambda e: e.get("event") == "listen", timeout=3)
|
|
||||||
self.assertIsNotNone(sink, "детектор утечки не залогировал listen")
|
|
||||||
|
|
||||||
# --- AC-3, шаг 2: трафик маркируется и доходит до мока --------------------
|
|
||||||
def test_05_forwarded_handshake_reaches_mock_and_is_marked(self) -> None:
|
|
||||||
before = nc.fw_counters()
|
|
||||||
|
|
||||||
result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0)
|
|
||||||
self.assertTrue(
|
|
||||||
result.get("reply_received"),
|
|
||||||
f"мок-endpoint не ответил на handshake initiation: {result}",
|
|
||||||
)
|
|
||||||
|
|
||||||
rx = nc.wait_jsonl(
|
|
||||||
nc.MOCK_LOG,
|
|
||||||
lambda e: e.get("event") == "rx" and e.get("msg_type") == 1 and str(e.get("src", "")).startswith(nc.CLIENT_IP),
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(rx, "мок не увидел handshake initiation от клиента")
|
|
||||||
self.assertEqual(148, rx["length"], "длина handshake initiation не 148 байт")
|
|
||||||
self.assertEqual("handshake_initiation", rx["msg_name"])
|
|
||||||
|
|
||||||
tx = nc.wait_jsonl(
|
|
||||||
nc.MOCK_LOG,
|
|
||||||
lambda e: e.get("event") == "tx" and e.get("msg_type") == 2,
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(tx, "мок не отправил handshake response")
|
|
||||||
self.assertEqual(92, tx["length"], "длина handshake response не 92 байта")
|
|
||||||
|
|
||||||
reply = result.get("reply") or {}
|
|
||||||
self.assertEqual(
|
|
||||||
reply.get("sender_index_echo"),
|
|
||||||
reply.get("receiver_index"),
|
|
||||||
"мок не подставил sender_index клиента как receiver_index",
|
|
||||||
)
|
|
||||||
|
|
||||||
after = nc.fw_counters()
|
|
||||||
self.assertIsNotNone(after["mark_fwd"], "нет счётчика правила метки транзитного трафика")
|
|
||||||
self.assertGreater(
|
|
||||||
after["mark_fwd"], before["mark_fwd"] or 0, "счётчик правила метки (FORWARD) не вырос — трафик не маркируется"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_06_gateway_originated_handshake_is_marked(self) -> None:
|
|
||||||
before = nc.fw_counters()
|
|
||||||
result = nc.probe(nc.GW_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0)
|
|
||||||
self.assertTrue(result.get("reply_received"), f"мок не ответил на handshake из netns шлюза: {result}")
|
|
||||||
|
|
||||||
after = nc.fw_counters()
|
|
||||||
self.assertIsNotNone(after["mark_out"], "нет счётчика правила метки исходящего трафика шлюза")
|
|
||||||
self.assertGreater(
|
|
||||||
after["mark_out"], before["mark_out"] or 0, "счётчик правила метки (OUTPUT) не вырос"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- имитация недоступности провайдера ------------------------------------
|
|
||||||
def test_07_provider_outage_drop_mode(self) -> None:
|
|
||||||
nc.set_mock_mode("drop")
|
|
||||||
try:
|
|
||||||
result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, expect_timeout=True, timeout=1.5)
|
|
||||||
self.assertTrue(result.get("ok"), f"в режиме drop ожидалось отсутствие ответа: {result}")
|
|
||||||
self.assertFalse(result.get("reply_received"), "мок ответил, хотя провайдер «недоступен»")
|
|
||||||
|
|
||||||
rx = nc.wait_jsonl(
|
|
||||||
nc.MOCK_LOG,
|
|
||||||
lambda e: e.get("event") == "rx" and e.get("mode") == "drop",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(rx, "мок в режиме drop не зафиксировал пакет")
|
|
||||||
finally:
|
|
||||||
nc.set_mock_mode("respond")
|
|
||||||
|
|
||||||
recovered = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0)
|
|
||||||
self.assertTrue(recovered.get("reply_received"), "стенд не восстановился после возврата режима respond")
|
|
||||||
|
|
||||||
# --- kill-switch (fail-closed) -------------------------------------------
|
|
||||||
def test_08_killswitch_blocks_direct_egress(self) -> None:
|
|
||||||
marker = "awgtest-leak-probe"
|
|
||||||
|
|
||||||
# 1) положительный контроль: при выключенном kill-switch трафик доходит
|
|
||||||
self._killswitch("off")
|
|
||||||
nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0)
|
|
||||||
control = nc.wait_jsonl(
|
|
||||||
nc.LEAK_LOG,
|
|
||||||
lambda e: e.get("event") == "leak" and e.get("length") == len(marker),
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(
|
|
||||||
control,
|
|
||||||
"контрольная проверка не прошла: при выключенном kill-switch трафик не дошёл до детектора",
|
|
||||||
)
|
|
||||||
baseline = len(nc.leak_events())
|
|
||||||
|
|
||||||
# 2) kill-switch включён -> трафик, предназначенный туннелю, не должен утечь
|
|
||||||
self._killswitch("on")
|
|
||||||
before_drop = nc.fw_counters()
|
|
||||||
nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0)
|
|
||||||
time.sleep(1.0)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
baseline,
|
|
||||||
len(nc.leak_events()),
|
|
||||||
"трафик утёк напрямую при включённом kill-switch (fail-closed нарушен)",
|
|
||||||
)
|
|
||||||
after_drop = nc.fw_counters()
|
|
||||||
if after_drop["killswitch_drop"] is not None:
|
|
||||||
self.assertGreater(
|
|
||||||
after_drop["killswitch_drop"],
|
|
||||||
before_drop["killswitch_drop"] or 0,
|
|
||||||
"счётчик DROP kill-switch не вырос — правило не сработало",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# topology.sh — netns-топология тестового стенда awg-dash (задача #21).
|
|
||||||
#
|
|
||||||
# topology.sh up создать netns, veth-пары, адреса, маршруты, правила меток
|
|
||||||
# topology.sh down удалить netns и veth-пары (идемпотентно)
|
|
||||||
# topology.sh status показать состояние топологии (exit 0 — стенд поднят)
|
|
||||||
# topology.sh killswitch on включить fail-closed правило в шлюзе
|
|
||||||
# topology.sh killswitch off выключить fail-closed правило в шлюзе
|
|
||||||
# topology.sh fw-kind напечатать backend netfilter (nft|iptables)
|
|
||||||
#
|
|
||||||
# Требует root и CAP_NET_ADMIN (в контейнере — --privileged либо NET_ADMIN+SYS_ADMIN).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=tests/netns/lib.sh
|
|
||||||
source "$HERE/lib.sh"
|
|
||||||
|
|
||||||
# Контракт кодов возврата (issue #56): 0 — ок, 1 — проверка не прошла,
|
|
||||||
# 3 — заблокировано окружением, 4 — ошибка стенда. Всё остальное, что прилетает
|
|
||||||
# от утилит (например 255 от `ip netns exec`), нормализуем в 4.
|
|
||||||
trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "topology.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR
|
|
||||||
|
|
||||||
topology_present() {
|
|
||||||
ns_exists "$AWG_NS_CLIENT" && ns_exists "$AWG_NS_GW" && ns_exists "$AWG_NS_UPLINK"
|
|
||||||
}
|
|
||||||
|
|
||||||
topology_down() {
|
|
||||||
local quiet="${1:-}"
|
|
||||||
[ "$quiet" = quiet ] || log "удаляю топологию"
|
|
||||||
# netns удаляем первыми: вместе с ними уходят veth-концы и процессы внутри.
|
|
||||||
# `ip netns delete` в контейнере иногда «теряется»: команда отработала, а
|
|
||||||
# namespace остаётся в `ip netns list` (issue #56). Поэтому удаляем
|
|
||||||
# безусловно (операция идемпотентна) и до 5 раз проверяем результат.
|
|
||||||
local ns
|
|
||||||
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
|
|
||||||
for _ in 1 2 3 4 5; do
|
|
||||||
ip netns delete "$ns" 2>/dev/null || true
|
|
||||||
if ! ns_exists "$ns"; then break; fi
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
if ns_exists "$ns"; then
|
|
||||||
warn "netns ${ns} не удалился за 5 попыток"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# страховка на случай, если veth остался в корневом netns
|
|
||||||
for ifc in "$AWG_VETH_CLIENT" "$AWG_VETH_GW_LAN" "$AWG_VETH_GW_WAN" "$AWG_VETH_UPLINK"; do
|
|
||||||
if ip link show "$ifc" >/dev/null 2>&1; then ip link del "$ifc" 2>/dev/null || true; fi
|
|
||||||
done
|
|
||||||
[ "$quiet" = quiet ] || ok "топология удалена"
|
|
||||||
}
|
|
||||||
|
|
||||||
topology_up() {
|
|
||||||
require_root
|
|
||||||
require_cmd ip
|
|
||||||
require_cmd python3
|
|
||||||
require_netns_capability
|
|
||||||
NS_EXEC_RESOLVED="$(select_ns_exec_mode)"
|
|
||||||
export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED"
|
|
||||||
log "вход в netns: ${NS_EXEC_RESOLVED}"
|
|
||||||
|
|
||||||
log "создаю netns: client=$AWG_NS_CLIENT gw=$AWG_NS_GW uplink=$AWG_NS_UPLINK"
|
|
||||||
topology_down quiet
|
|
||||||
|
|
||||||
local ns
|
|
||||||
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
|
|
||||||
ip netns add "$ns"
|
|
||||||
ns_exec "$ns" ip link set lo up
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- client <-> gw (LAN) --------------------------------------------------
|
|
||||||
ip link add "$AWG_VETH_CLIENT" type veth peer name "$AWG_VETH_GW_LAN"
|
|
||||||
ip link set "$AWG_VETH_CLIENT" netns "$AWG_NS_CLIENT"
|
|
||||||
ip link set "$AWG_VETH_GW_LAN" netns "$AWG_NS_GW"
|
|
||||||
ns_exec "$AWG_NS_CLIENT" ip addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT"
|
|
||||||
ns_exec "$AWG_NS_CLIENT" ip link set "$AWG_VETH_CLIENT" up
|
|
||||||
ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN"
|
|
||||||
ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_LAN" up
|
|
||||||
|
|
||||||
# --- gw <-> uplink (WAN) --------------------------------------------------
|
|
||||||
ip link add "$AWG_VETH_GW_WAN" type veth peer name "$AWG_VETH_UPLINK"
|
|
||||||
ip link set "$AWG_VETH_GW_WAN" netns "$AWG_NS_GW"
|
|
||||||
ip link set "$AWG_VETH_UPLINK" netns "$AWG_NS_UPLINK"
|
|
||||||
ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN"
|
|
||||||
ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_WAN" up
|
|
||||||
ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK"
|
|
||||||
ns_exec "$AWG_NS_UPLINK" ip link set "$AWG_VETH_UPLINK" up
|
|
||||||
|
|
||||||
# --- forwarding и rp_filter в шлюзе ---------------------------------------
|
|
||||||
ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1
|
|
||||||
# loose rp_filter: маркированная политика даёт асимметрию reverse-пути
|
|
||||||
ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2
|
|
||||||
ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2"
|
|
||||||
ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2"
|
|
||||||
|
|
||||||
# --- маршруты -------------------------------------------------------------
|
|
||||||
ns_exec "$AWG_NS_CLIENT" ip route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT"
|
|
||||||
ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100
|
|
||||||
ns_exec "$AWG_NS_UPLINK" ip route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK"
|
|
||||||
# «публичный» адрес-приманка для детектора утечки (RFC 5737 TEST-NET-2)
|
|
||||||
ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_LEAK_IP}/32" dev lo
|
|
||||||
|
|
||||||
# --- policy routing: fwmark 51820 -> table 51820 -> аплинк ----------------
|
|
||||||
ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE"
|
|
||||||
ns_exec "$AWG_NS_GW" ip rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE"
|
|
||||||
|
|
||||||
# --- netfilter: метки + kill-switch ---------------------------------------
|
|
||||||
fw_setup
|
|
||||||
|
|
||||||
ok "стенд поднят: ${AWG_NS_CLIENT}(${AWG_CLIENT_IP}) -> ${AWG_NS_GW}(${AWG_GW_LAN_IP}/${AWG_GW_WAN_IP}) -> ${AWG_NS_UPLINK}(${AWG_UPLINK_IP})"
|
|
||||||
ok "policy routing: fwmark ${AWG_FWMARK} -> table ${AWG_ROUTE_TABLE} (default via ${AWG_UPLINK_IP})"
|
|
||||||
ok "mock endpoint: udp/${AWG_MOCK_PORT} в ${AWG_NS_UPLINK}; детектор утечки: ${AWG_LEAK_IP}:${AWG_LEAK_PORT}/udp"
|
|
||||||
}
|
|
||||||
|
|
||||||
topology_status() {
|
|
||||||
local rc=0
|
|
||||||
if ! topology_present; then
|
|
||||||
printf 'topology: down\n'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf 'topology: up\n'
|
|
||||||
local ns
|
|
||||||
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
|
|
||||||
printf '\n[%s] links:\n' "$ns"
|
|
||||||
ns_exec "$ns" ip -br addr show || rc=1
|
|
||||||
done
|
|
||||||
printf '\n[%s] ip rule:\n' "$AWG_NS_GW"
|
|
||||||
ns_exec "$AWG_NS_GW" ip rule show
|
|
||||||
printf '\n[%s] table %s:\n' "$AWG_NS_GW" "$AWG_ROUTE_TABLE"
|
|
||||||
ns_exec "$AWG_NS_GW" ip route show table "$AWG_ROUTE_TABLE"
|
|
||||||
printf '\n[%s] netfilter:\n' "$AWG_NS_GW"
|
|
||||||
if [ "$(fw_kind)" = nft ]; then
|
|
||||||
ns_exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1
|
|
||||||
else
|
|
||||||
ns_exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1
|
|
||||||
ns_exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1
|
|
||||||
fi
|
|
||||||
return $rc
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-status}" in
|
|
||||||
up)
|
|
||||||
topology_up
|
|
||||||
;;
|
|
||||||
down)
|
|
||||||
require_root
|
|
||||||
topology_down
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
topology_status
|
|
||||||
;;
|
|
||||||
killswitch)
|
|
||||||
require_root
|
|
||||||
fw_set_killswitch "${2:?usage: topology.sh killswitch on|off}"
|
|
||||||
ok "kill-switch: ${2}"
|
|
||||||
;;
|
|
||||||
fw-kind)
|
|
||||||
require_root
|
|
||||||
fw_kind
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
fail "usage: topology.sh {up|down|status|killswitch on|off|fw-kind}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Генерация TypeScript-типов клиента из контракта (docs/openapi.yaml, issue #19).
|
|
||||||
#
|
|
||||||
# Требуется Node.js >= 20 (генератор запускается через npx, версия запинена).
|
|
||||||
# Результат коммитится в ui/src/api/generated/schema.d.ts и проверяется в CI.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
spec="${root_dir}/docs/openapi.yaml"
|
|
||||||
out_dir="${root_dir}/ui/src/api/generated"
|
|
||||||
generator="openapi-typescript@7"
|
|
||||||
|
|
||||||
if [[ ! -f "${spec}" ]]; then
|
|
||||||
echo "contract not found: ${spec}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "${out_dir}"
|
|
||||||
npx --yes "${generator}" "${spec}" -o "${out_dir}/schema.d.ts"
|
|
||||||
echo "OK: ${out_dir}/schema.d.ts <- ${spec}"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# ui/src/api/generated — сгенерированный API-клиент
|
|
||||||
|
|
||||||
**Файлы в этой папке не редактируются руками.**
|
|
||||||
|
|
||||||
| Файл | Источник |
|
|
||||||
|---|---|
|
|
||||||
| `schema.d.ts` | `docs/openapi.yaml` (контракт API v1, issue #19) через `openapi-typescript@7` |
|
|
||||||
|
|
||||||
## Перегенерация
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash ui/scripts/generate-api.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Требуется Node.js >= 20. CI-джоба `ts-client-is-current` повторяет генерацию и падает,
|
|
||||||
если закоммиченный файл расходится со спецификацией.
|
|
||||||
|
|
||||||
## Секреты
|
|
||||||
|
|
||||||
Поля `private_key` (TunnelConfig), `preshared_key` (PeerConfig) и `password`
|
|
||||||
(AuthLoginRequest) помечены в контракте `writeOnly`:
|
|
||||||
|
|
||||||
* их **можно** передавать в запросах (`PUT /api/v1/config`, `POST /api/v1/auth/login`);
|
|
||||||
* их **нельзя** ожидать в ответах — сервер обязан их вырезать (см. контрактный тест
|
|
||||||
`backend/tests/test_contract_responses.py::test_responses_contain_no_secret_material`).
|
|
||||||
|
|
||||||
Поэтому UI читает секреты только из полей ввода и никогда не рендерит их из ответов API.
|
|
||||||
Vendored
-1229
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user