diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2044482 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.{yml,yaml,json,ts,tsx,js,css,html}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a4bf52c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,325 @@ +name: ci + +# CI-пайплайн с гейтами (issue #17, Refs #15) +# lint -> unit (coverage >= 70%) -> security (bandit/pip-audit) -> build -> image-scan (trivy) -> push staging- +# Гейты (роняют pipeline): +# * unit — покрытие < 70 % (--cov-fail-under=70) +# * security — bandit HIGH, pip-audit: любая известная уязвимость +# * image-scan — trivy: HIGH/CRITICAL в образе +# * build стартует только после зелёных lint+unit+security; push — только после зелёного image-scan. + +on: + push: + branches: ["feat/**", "fix/**", "chore/**", "ci/**", "main"] + pull_request: + branches: [main] + workflow_dispatch: + +env: + PYTHON_VERSION: "3.12" + REGISTRY: 192.168.1.111:3000 + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ─────────────────────────────── 1. LINT ──────────────────────────────── + lint: + name: lint + runs-on: ubuntu-latest + outputs: + has_dockerfile: ${{ steps.dockerfile.outputs.has_dockerfile }} + steps: + - name: checkout + id: checkout + uses: actions/checkout@v4 + continue-on-error: true + + # Дефект Gitea git-http: upload-pack отдаёт пустой pack (Content-Length: 90), + # git clone/fetch падает "early EOF / invalid index-pack output". + # Обход: эндпоинт archive/.tar.gz работает — забираем исходники им. + # Когда git-http починят, шаг просто перестанет срабатывать (outcome != failure). + - 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/${IMAGE_NAME}/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: ${{ env.PYTHON_VERSION }} + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt -r requirements-contract.txt + + - name: ruff check + run: ruff check backend + + - name: ruff format --check + run: ruff format --check backend + + - name: "mypy (strict)" + run: mypy backend + + - name: detect deploy/Dockerfile + id: dockerfile + run: | + if [ -f deploy/Dockerfile ]; then + echo "has_dockerfile=true" >> "$GITHUB_OUTPUT" + echo "deploy/Dockerfile найден — jobs build/image-scan/push активны" + else + echo "has_dockerfile=false" >> "$GITHUB_OUTPUT" + echo "::warning::deploy/Dockerfile отсутствует — build/image-scan/push пропущены (ждут merge #18 / PR #49)" + fi + + # ─────────────────── 2. UNIT + ГЕЙТ ПОКРЫТИЯ >= 70 % ─────────────────── + unit: + name: unit + needs: lint + 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/${IMAGE_NAME}/archive/${GITHUB_SHA}.tar.gz" + tar -xzf /tmp/src.tar.gz --strip-components=1 -C . + + - name: "python 3.12" + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt -r requirements-contract.txt + + - name: "pytest + coverage gate (fail below 70%)" + run: | + python -m pytest \ + --cov=awg_agent --cov=awg_api \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + --cov-report=html:htmlcov \ + --cov-fail-under=70 + + - name: coverage summary + if: always() + run: python -m coverage report --sort=cover + + - name: upload coverage artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: coverage + path: | + coverage.xml + htmlcov/ + retention-days: 14 + + # ───────────── 3. SECURITY: bandit (HIGH) + pip-audit ────────────────── + security: + name: security + needs: lint + 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/${IMAGE_NAME}/archive/${GITHUB_SHA}.tar.gz" + tar -xzf /tmp/src.tar.gz --strip-components=1 -C . + + - name: "python 3.12" + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: install scanners + run: | + python -m pip install --upgrade pip + pip install "bandit[toml]>=1.7" "pip-audit>=2.7" + pip install -r requirements.txt -r requirements-contract.txt + + - name: "bandit -lll (HIGH severity gate)" + run: | + set -o pipefail + bandit -lll -r backend 2>&1 | tee bandit-report.txt + bandit -lll -f json -o bandit-report.json -r backend || true + echo "bandit: HIGH/CRITICAL не найдены" + + - name: "pip-audit (known vulnerabilities gate)" + run: | + pip-audit -r requirements.txt -r requirements-contract.txt --progress-spinner off + + - name: pip-audit json report + if: always() + run: pip-audit -r requirements.txt -r requirements-contract.txt --progress-spinner off --format json -o pip-audit-report.json || true + + - name: upload security artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: security-reports + path: | + bandit-report.txt + bandit-report.json + pip-audit-report.json + retention-days: 14 + + # ───────────────── 4. BUILD: docker build (нужен #18) ────────────────── + build: + name: build + needs: [lint, unit, security] + if: needs.lint.outputs.has_dockerfile == 'true' + 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/${IMAGE_NAME}/archive/${GITHUB_SHA}.tar.gz" + tar -xzf /tmp/src.tar.gz --strip-components=1 -C . + + - name: "guard: deploy/Dockerfile" + run: | + if [ ! -f deploy/Dockerfile ]; then + echo "::error::deploy/Dockerfile отсутствует: сборка образа ждёт merge #18 (PR #49)." + echo "Пайплайн целиком проверен на ветке feat/17-verify (она же + deploy/Dockerfile)." + exit 1 + fi + + - name: image meta + id: meta + run: | + SHA_SHORT="${GITHUB_SHA::8}" + IMAGE="${REGISTRY}/${IMAGE_NAME}:staging-${SHA_SHORT}" + echo "sha_short=${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV" + echo "image: ${IMAGE}" + + - name: docker build + run: | + set -o pipefail + docker build --pull -f deploy/Dockerfile -t "$IMAGE" . 2>&1 | tee build.log + + - name: image digest + run: | + docker image inspect "$IMAGE" --format 'id={{.Id}} size={{.Size}}' + docker image inspect "$IMAGE" --format '{{.Id}}' > image-id.txt + + - name: upload build artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: "build-${{ github.sha }}" + path: | + build.log + image-id.txt + retention-days: 14 + + # ──────── 5. IMAGE-SCAN: trivy, гейт HIGH/CRITICAL ──────────────────── + image-scan: + name: image-scan + needs: [lint, build] + if: needs.lint.outputs.has_dockerfile == 'true' + runs-on: ubuntu-latest + env: + TRIVY_VERSION: "0.76.1" + TRIVY_SEVERITY: "HIGH,CRITICAL" + steps: + # trivy запускается официальным образом через docker-сокет: не требует + # скачивания бинаря (github releases отдаёт медленно) + БД кэшируется в + # /opt/trivy-cache на build-хосте. + - name: "trivy image (HIGH/CRITICAL -> fail)" + run: | + set -o pipefail + IMAGE="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::8}" + echo "scan: ${IMAGE}" + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /opt/trivy-cache:/root/.cache/trivy \ + "aquasec/trivy:${TRIVY_VERSION}" image \ + --image-src docker --scanners vuln \ + --severity "$TRIVY_SEVERITY" --ignore-unfixed \ + --exit-code 1 --format table "$IMAGE" 2>&1 | tee trivy-report.txt + + - name: trivy json report + if: always() + run: | + set -o pipefail + IMAGE="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::8}" + mkdir -p reports + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /opt/trivy-cache:/root/.cache/trivy \ + -v "$PWD/reports:/reports" \ + "aquasec/trivy:${TRIVY_VERSION}" image \ + --image-src docker --scanners vuln \ + --severity "$TRIVY_SEVERITY" --ignore-unfixed \ + --format json -o /reports/trivy-report.json "$IMAGE" || true + cp reports/trivy-report.json ./trivy-report.json 2>/dev/null || true + + - name: upload trivy report + if: always() + uses: actions/upload-artifact@v3 + with: + name: trivy-report + path: | + trivy-report.txt + trivy-report.json + retention-days: 14 + + # ─────── 6. PUSH: staging- в registry Gitea ────────────────────── + push: + name: push + needs: [lint, build, image-scan] + if: needs.lint.outputs.has_dockerfile == 'true' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: docker login + run: | + set -o pipefail + # Приоритет — секрет с правом write:package (REGISTRY_TOKEN), иначе встроенный токен Actions. + TOKEN="${{ secrets.REGISTRY_TOKEN }}" + if [ -z "$TOKEN" ]; then + TOKEN="${{ secrets.GITHUB_TOKEN }}" + fi + echo "$TOKEN" | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + + - name: "push staging-" + run: | + set -o pipefail + IMAGE="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::8}" + docker push "$IMAGE" 2>&1 | tee push.log + echo "${IMAGE}" | tee pushed-image.txt + + - name: upload push record + if: always() + uses: actions/upload-artifact@v3 + with: + name: pushed-image + path: | + push.log + pushed-image.txt + retention-days: 14 diff --git a/.gitea/workflows/contract-openapi.yml b/.gitea/workflows/contract-openapi.yml new file mode 100644 index 0000000..6b15806 --- /dev/null +++ b/.gitea/workflows/contract-openapi.yml @@ -0,0 +1,88 @@ +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/.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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc74b5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.venv*/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ + +# UI +node_modules/ +ui/dist/ +ui/.vite/ + +# Runtime / состояние шлюза (живёт в томах, не в репозитории) +data/ +config/ +*.db +*.db-shm +*.db-wal + +# Секреты и конфиги клиентов +.env +.env.* +!.env.example +*.conf +!deploy/*.conf.example + +# IDE / OS +.idea/ +.vscode/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ca1bcb1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + files: ^backend/ + additional_dependencies: [pydantic>=2.9] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=512] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..55c3ea5 --- /dev/null +++ b/Makefile @@ -0,0 +1,149 @@ +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/` (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 diff --git a/README.md b/README.md index 0b0ddfb..c7709a7 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,64 @@ # awg-dash -# awg-dash 🚀 - -**awg-dash** — это легковесная и удобная веб-панель (Dashboard) для визуального управления сервером **Amnezia WireGuard (AWG)**. Она позволяет создавать, удалять и просматривать конфигурации пользователей в один клик через современный веб-интерфейс, защищенный от блокировок. - -[![License: MIT](https://shields.io)](https://opensource.org) -[![GitHub Stars](https://shields.io)](https://github.com) - ---- - -## ✨ Особенности / Features - -- 🛠️ **Полная поддержка Amnezia WireGuard:** Тонкая настройка параметров обфускации (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`, `H2`, `H3`, `H4`) прямо из панели. -- 👥 **Управление пользователями:** Создание, удаление, временная заморозка и управление лимитами пиров (клиентов). -- 📊 **Мониторинг в реальном времени:** Графики потребления трафика, статус подключения клиентов и общая нагрузка на сервер. -- 📱 **QR-коды и конфиги:** Мгновенная генерация QR-кодов и скачивание `.conf` файлов для импорта в официальное приложение Amnezia. -- 🔒 **Безопасность:** Авторизация по паролю/токену, поддержка сессий и возможность работы за обратным прокси (Nginx, Caddy). -- 🐳 **Docker-first:** Быстрый запуск одной командой через Docker Compose. - ---- - -## 🛠️ Технологический стек / Tech Stack - -- **Backend:** Node.js (Express) / Python (FastAPI) / Go *(выберите нужное)* -- **Frontend:** Vue.js / React / Простой HTML+JS *(выберите нужное)* -- **База данных:** SQLite / JSON-file (минималистично, без тяжелых БД) -- **Ядро:** Amnezia WireGuard (модифицированная утилита `wg`) - ---- - -## 🚀 Быстрый старт / Quick Start - -### Способ 1: Через Docker Compose (Рекомендуемый) - -1. Клонируйте репозиторий: - ```bash - git clone https://github.com - cd awg-dash - ``` \ No newline at end of file +VPN-шлюз **AmneziaWG** с интеллектуальной маршрутизацией: один Docker-образ = туннель + policy routing + split-tunnel (GeoIP / DNS) + REST API v1 + Web UI (тёмная тема). + +- Эпик и декомпозиция: Gitea issue **#15** (`embeddevio/awg-dash`). +- Исполнение: Hermes Kanban, борда **`awg-dash`** (`hermes kanban --board awg-dash list`). +- PoC-референс: `embeddevio/amneziawg-docker-gateway` (bash + iptables, `docs/technical-specification.md`). + +## Архитектура + +Два процесса в одном образе: + +| Процесс | Пользователь | Ответственность | +|---|---|---| +| `awg-agent` | root | awg/wg-интерфейс, iptables/ipset, dnsmasq, policy routing, метрики | +| `awg-api` | непривилегированный | FastAPI: REST API v1, WebSocket, статика UI | + +Обмен — unix-socket `/run/awg/agent.sock`; состояние — SQLite в `/data`, конфиги `.conf` в `/config`. +Принятые решения зафиксированы в `docs/adr/`. + +## Быстрый старт (после релиза 1.0) + +```bash +docker run -dit --name=vpn-gateway \ + --network=host \ + --device=/dev/net/tun:/dev/net/tun \ + --cap-add=NET_ADMIN --cap-add=SYS_MODULE \ + --restart always \ + -v /srv/awg/config:/config -v /srv/awg/data:/data \ + registry.example.com/vpn-gateway:latest +``` + +Web UI: `http://:8080`. До релиза образ собирается локально: `make build`. + +## Разработка + +```bash +make venv # локальное окружение (.venv) +make lint # ruff + mypy +make test # pytest + coverage (порог 70%) +make build # docker build образа (задача #18) +make dev-up # dev-стенд (задача #21) +``` + +Сетевые тесты выполняются в netns-стенде (`tests/netns/`), без реального VPN-провайдера. + +## Структура репозитория + +| Путь | Назначение | +|---|---| +| `backend/awg_agent/` | привилегированный агент: туннель, маршрутизация, GeoIP, DNS, метрики | +| `backend/awg_api/` | FastAPI-приложение (REST + WS + статика UI) | +| `backend/tests/` | unit/integration-тесты (pytest) | +| `ui/` | React + TypeScript + Tailwind + shadcn-ui | +| `deploy/` | Dockerfile, compose-профили, entrypoint, Grafana-дашборд | +| `docs/adr/` | архитектурные решения | +| `tests/netns/` | сетевой тестовый стенд | + +## Процесс разработки + +1 задача = 1 issue = 1 ветка `feat/-slug` = 1 PR. + +Гейты мержа: CI green, coverage нового кода ≥ 70 %, security scan low/none, ревью владельца роли. +Merge в `main` и деплой в prod — только TechLead после гейтов. Все операции с репозиторием — через Gitea MCP. diff --git a/backend/awg_agent/README.md b/backend/awg_agent/README.md new file mode 100644 index 0000000..6c9b353 --- /dev/null +++ b/backend/awg_agent/README.md @@ -0,0 +1,16 @@ +# awg_agent — привилегированный агент + +Запускается как root в контейнере: всё, что требует NET_ADMIN и доступа к ядру. + +| Модуль | Ответственность | Задача | +|---|---|---| +| `vpn/` | парсинг/валидация `.conf`, up/down/restart туннеля, `awg show` → статус | #22 | +| `watchdog/` | контроль handshake, авто-восстановление, fail-closed | #23 | +| `netfilter/` | iptables/ipset, policy routing, atomic swap, MSS clamping, IPv6 | #24 | +| `geoip/` | RIPE Stat, кэш-снапшот, расписание, fallback | #25 | +| `dns/` | dnsmasq, домены → ipset, защита от DNS-утечки | #26 | +| `routing/` | режимы (GeoIP / DNS / All-VPN / Direct-only), reconcile, исключения | #27 | +| `metrics/` | RX/TX, direct vs VPN, история, агрегаты | #28 | +| `devices/` | активные устройства LAN и их политики | #29 | + +Интерфейс для `awg_api` — unix-socket `/run/awg/agent.sock` (см. ADR-002). diff --git a/backend/awg_agent/__init__.py b/backend/awg_agent/__init__.py new file mode 100644 index 0000000..ec9c356 --- /dev/null +++ b/backend/awg_agent/__init__.py @@ -0,0 +1,5 @@ +"""Привилегированный агент awg-dash: туннель, маршрутизация, GeoIP, DNS, метрики.""" + +__version__ = "0.0.1" + +__all__ = ["__version__"] diff --git a/backend/awg_api/README.md b/backend/awg_api/README.md new file mode 100644 index 0000000..288c805 --- /dev/null +++ b/backend/awg_api/README.md @@ -0,0 +1,13 @@ +# awg_api — control plane + +Непривилегированный процесс: FastAPI + REST API v1 + WebSocket + отдача статики UI. + +| Модуль | Ответственность | Задача | +|---|---|---| +| `app.py` | lifespan, конфиг, логирование, error model, статика | #31 | +| `routers/` | эндпоинты по OpenAPI (status, config, routing, devices, logs, auth) | #32 | +| `ws/` | `/api/v1/metrics/stream` | #33 | +| `security/` | токен, CSRF, rate limit, маскировка секретов | #34 | +| `store/` | SQLite (WAL), миграции, audit log, apply/rollback | #20 | + +Контракт — `docs/openapi.yaml` (задача #19), TS-клиент генерируется в `ui/src/api/generated/`. diff --git a/backend/awg_api/__init__.py b/backend/awg_api/__init__.py new file mode 100644 index 0000000..e02b912 --- /dev/null +++ b/backend/awg_api/__init__.py @@ -0,0 +1,5 @@ +"""FastAPI-приложение awg-dash: REST API v1, WebSocket, статика UI.""" + +__version__ = "0.0.1" + +__all__ = ["__version__"] diff --git a/backend/awg_api/app.py b/backend/awg_api/app.py new file mode 100644 index 0000000..6d8050e --- /dev/null +++ b/backend/awg_api/app.py @@ -0,0 +1,47 @@ +"""Сборка 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 diff --git a/backend/awg_api/contract.py b/backend/awg_api/contract.py new file mode 100644 index 0000000..30ebe8f --- /dev/null +++ b/backend/awg_api/contract.py @@ -0,0 +1,242 @@ +"""Загрузка 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 "" + 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) + ) diff --git a/backend/awg_api/deps.py b/backend/awg_api/deps.py new file mode 100644 index 0000000..9cafeca --- /dev/null +++ b/backend/awg_api/deps.py @@ -0,0 +1,53 @@ +"""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)] diff --git a/backend/awg_api/errors.py b/backend/awg_api/errors.py new file mode 100644 index 0000000..df2d9f5 --- /dev/null +++ b/backend/awg_api/errors.py @@ -0,0 +1,88 @@ +"""Единый формат ошибок 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), + ) diff --git a/backend/awg_api/routers/__init__.py b/backend/awg_api/routers/__init__.py new file mode 100644 index 0000000..00e59d1 --- /dev/null +++ b/backend/awg_api/routers/__init__.py @@ -0,0 +1 @@ +"""Роутеры API v1.""" diff --git a/backend/awg_api/routers/auth.py b/backend/awg_api/routers/auth.py new file mode 100644 index 0000000..97b50e4 --- /dev/null +++ b/backend/awg_api/routers/auth.py @@ -0,0 +1,34 @@ +"""Аутентификация 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() diff --git a/backend/awg_api/routers/config.py b/backend/awg_api/routers/config.py new file mode 100644 index 0000000..ec418dc --- /dev/null +++ b/backend/awg_api/routers/config.py @@ -0,0 +1,27 @@ +"""Конфигурация 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) diff --git a/backend/awg_api/routers/devices.py b/backend/awg_api/routers/devices.py new file mode 100644 index 0000000..945567d --- /dev/null +++ b/backend/awg_api/routers/devices.py @@ -0,0 +1,29 @@ +"""Устройства и журнал: /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) diff --git a/backend/awg_api/routers/ops.py b/backend/awg_api/routers/ops.py new file mode 100644 index 0000000..51ec542 --- /dev/null +++ b/backend/awg_api/routers/ops.py @@ -0,0 +1,36 @@ +"""Служебные эндпоинты: /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()) diff --git a/backend/awg_api/routers/routing.py b/backend/awg_api/routers/routing.py new file mode 100644 index 0000000..b2ae4ce --- /dev/null +++ b/backend/awg_api/routers/routing.py @@ -0,0 +1,67 @@ +"""Маршрутизация: правила, режим, 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) diff --git a/backend/awg_api/routers/status.py b/backend/awg_api/routers/status.py new file mode 100644 index 0000000..bfe3579 --- /dev/null +++ b/backend/awg_api/routers/status.py @@ -0,0 +1,16 @@ +"""Состояние туннеля: /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() diff --git a/backend/awg_api/schemas.py b/backend/awg_api/schemas.py new file mode 100644 index 0000000..13ed6d1 --- /dev/null +++ b/backend/awg_api/schemas.py @@ -0,0 +1,120 @@ +"""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}) diff --git a/backend/awg_api/state.py b/backend/awg_api/state.py new file mode 100644 index 0000000..518ff36 --- /dev/null +++ b/backend/awg_api/state.py @@ -0,0 +1,471 @@ +"""In-memory состояние мок-сервера (замена агента и SQLite до Sprint 1). + +Секреты (private key, preshared key, пароль UI) хранятся отдельно от словарей-ответов +и никогда в них не попадают — это проверяется тестом «в ответах нет приватных ключей». +""" + +from __future__ import annotations + +import time +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +from awg_api.errors import ApiError +from awg_api.schemas import ( + ConfigUpdateRequest, + ImportRequest, + RoutingRuleCreate, + RoutingRuleUpdate, +) + +VERSION = "0.1.0-mock" +GEOIP_PREFIX_COUNT = 11412 +DEV_TOKEN = "awg-dash-dev-token" # noqa: S105 — документированный dev-дефолт мок-сервера +DEV_UI_PASSWORD = "awg-dash-dev-password" # noqa: S105 — документированный dev-дефолт +PRIVATE_KEY_SENTINEL = "PRIVATE-KEY-MUST-NOT-LEAK" +PRESHARED_KEY_SENTINEL = "PRESHARED-KEY-MUST-NOT-LEAK" +LOG_LEVELS = ("debug", "info", "warning", "error") + + +def _now() -> str: + """RFC 3339 (UTC), суффикс Z — формат всех таймстемпов контракта.""" + return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + + +class MockState: + """Состояние мок-контрол-плейна: туннель, правила, устройства, журнал, сессия.""" + + def __init__(self, *, ready: bool = True) -> None: + self._started = time.monotonic() + self.ready = ready + self.token = DEV_TOKEN + self.ui_password = DEV_UI_PASSWORD + self.session_token: str | None = None + self.username: str | None = None + self.expires_at: str | None = None + + self.mode: str = "geoip" + self.kill_switch = True + self.dns_leak_protection = True + self.mtu_clamping = True + self.ipv6: str = "direct" + self.geoip: dict[str, Any] = { + "source": "ripe-stat", + "prefix_count": GEOIP_PREFIX_COUNT, + "updated_at": _now(), + } + self.tunnel_state: str = "up" + self.handshake_age_s: int | None = 27 + self.rx_bytes = 5_812_664 + self.tx_bytes = 1_204_331 + self.peer_endpoint = "vpn.example.net:51820" + self.secrets: dict[str, str] = { + "private_key": PRIVATE_KEY_SENTINEL, + "preshared_key": PRESHARED_KEY_SENTINEL, + } + self.tunnel_config: dict[str, Any] = { + "address": "10.8.0.2/32", + "listen_port": 51820, + "mtu": 1420, + "dns": ["10.8.0.1"], + } + self.peers: list[dict[str, Any]] = [ + { + "public_key": "kZ9vT4publicKeyExampleOnly0000000000000000000=", + "endpoint": "vpn.example.net:51820", + "allowed_ips": ["0.0.0.0/0", "::/0"], + "persistent_keepalive": 25, + } + ] + self.dns: dict[str, Any] = {"mode": "internal", "servers": ["10.8.0.1", "1.1.1.1"]} + self.rules: dict[str, dict[str, Any]] = {} + self.devices: list[dict[str, Any]] = [ + { + "name": "laptop-anna", + "ip": "10.8.0.11", + "mac": "3c:22:fb:11:22:33", + "vpn": True, + "ipsets": ["awg_geoip", "awg_domains"], + "last_seen_at": _now(), + }, + { + "name": "phone-max", + "ip": "10.8.0.12", + "mac": None, + "vpn": False, + "ipsets": [], + "last_seen_at": _now(), + }, + ] + self.logs: list[dict[str, Any]] = [] + self.updated_at = _now() + self._seed_rules() + self._seed_logs() + + # ------------------------------------------------------------------ служебное + + def uptime_s(self) -> int: + return max(0, int(time.monotonic() - self._started)) + + def _seed_rules(self) -> None: + self.rules["0192a1b2-0000-7000-8000-000000000001"] = { + "id": "0192a1b2-0000-7000-8000-000000000001", + "name": "Маршрутизировать домены сервисов", + "kind": "domain", + "value": "*.example.net", + "action": "vpn", + "enabled": True, + "priority": 100, + "comment": None, + "created_at": _now(), + "updated_at": _now(), + } + self.rules["0192a1b2-0000-7000-8000-000000000002"] = { + "id": "0192a1b2-0000-7000-8000-000000000002", + "name": "Локальная подсеть напрямую", + "kind": "cidr", + "value": "192.168.1.0/24", + "action": "direct", + "enabled": True, + "priority": 10, + "comment": "LAN исключаем из туннеля", + "created_at": _now(), + "updated_at": _now(), + } + + def _seed_logs(self) -> None: + seeds = [ + ( + "info", + "awg_agent.reconcile", + "Применён desired state: 2 правила, 1 пир", + {"mode": "geoip"}, + ), + ( + "info", + "awg_api.routing", + "Режим split-tunnel: geoip", + {"prefix_count": GEOIP_PREFIX_COUNT}, + ), + ("warning", "awg_agent.geoip", "Снапшот GeoIP старше 24 часов", {"age_h": 26}), + ("error", "awg_agent.tunnel", "Краткий разрыв handshake", {"duration_s": 4}), + ("debug", "awg_api.auth", "Создана сессия UI", {"username": "admin"}), + ] + for level, logger, message, fields in seeds: + self.logs.append( + { + "ts": _now(), + "level": level, + "logger": logger, + "message": message, + "fields": fields, + } + ) + + # ------------------------------------------------------------------ ops + + def get_health(self) -> dict[str, Any]: + return { + "status": "ok" if self.ready else "degraded", + "version": VERSION, + "uptime_s": self.uptime_s(), + "checks": [ + {"name": "api", "ok": True, "detail": None}, + { + "name": "agent_socket", + "ok": self.ready, + "detail": None if self.ready else "unix-socket не отвечает", + }, + ], + } + + def get_ready(self) -> dict[str, Any]: + return { + "ready": self.ready, + "checks": [ + {"name": "tunnel", "ok": self.ready, "detail": None}, + {"name": "geoip_cache", "ok": True, "detail": None}, + ], + } + + def prometheus_text(self) -> str: + tunnel_up = 1 if self.tunnel_state == "up" else 0 + lines = [ + "# HELP awg_dash_tunnel_up Tunnel state (1 = up)", + "# TYPE awg_dash_tunnel_up gauge", + f"awg_dash_tunnel_up {tunnel_up}", + "# HELP awg_dash_rx_bytes_total Received bytes on the tunnel", + "# TYPE awg_dash_rx_bytes_total counter", + f"awg_dash_rx_bytes_total {self.rx_bytes}", + "# HELP awg_dash_tx_bytes_total Transmitted bytes on the tunnel", + "# TYPE awg_dash_tx_bytes_total counter", + f"awg_dash_tx_bytes_total {self.tx_bytes}", + "# HELP awg_dash_geoip_prefixes GeoIP prefix count in the active snapshot", + "# TYPE awg_dash_geoip_prefixes gauge", + f"awg_dash_geoip_prefixes {self.geoip['prefix_count']}", + ] + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ status + + def get_status(self) -> dict[str, Any]: + return { + "version": VERSION, + "uptime_s": self.uptime_s(), + "mode": self.mode, + "tunnel": { + "interface": "awg0", + "state": self.tunnel_state, + "handshake_age_s": self.handshake_age_s, + "rx_bytes": self.rx_bytes, + "tx_bytes": self.tx_bytes, + "endpoint": self.peer_endpoint, + "last_handshake_at": _now(), + }, + "geoip": dict(self.geoip), + "kill_switch": self.kill_switch, + "dns_leak_protection": self.dns_leak_protection, + } + + def metrics_sample(self) -> dict[str, Any]: + return { + "rx_bytes": self.rx_bytes, + "tx_bytes": self.tx_bytes, + "rx_rate_bps": 1_048_576, + "tx_rate_bps": 262_144, + "handshake_age_s": self.handshake_age_s, + "active_peers": len(self.peers), + } + + def metrics_frame(self) -> dict[str, Any]: + return {"type": "metrics", "ts": _now(), "data": self.metrics_sample()} + + # ------------------------------------------------------------------ config + + def get_config(self) -> dict[str, Any]: + """Конфигурация без секретов: private_key/preshared_key не сериализуются.""" + return { + "tunnel": dict(self.tunnel_config), + "peers": [dict(peer) for peer in self.peers], + "dns": dict(self.dns), + "routing": self._routing_config(), + "updated_at": self.updated_at, + } + + def _routing_config(self) -> dict[str, Any]: + return { + "mode": self.mode, + "kill_switch": self.kill_switch, + "mtu_clamping": self.mtu_clamping, + "ipv6": self.ipv6, + "geoip": dict(self.geoip), + } + + def apply_config(self, update: ConfigUpdateRequest) -> dict[str, Any]: + if update.tunnel is not None: + payload = update.tunnel.model_dump(exclude={"private_key"}) + self.tunnel_config = {**self.tunnel_config, **payload} + self.secrets["private_key"] = update.tunnel.private_key + if update.peers is not None: + self.peers = [peer.model_dump(exclude={"preshared_key"}) for peer in update.peers] + sentinel = next( + (peer.preshared_key for peer in update.peers if peer.preshared_key is not None), + None, + ) + if sentinel is not None: + self.secrets["preshared_key"] = sentinel + if update.dns is not None: + self.dns = update.dns.model_dump() + if update.routing is not None: + routing = update.routing.model_dump() + self.mode = routing["mode"] + self.kill_switch = routing["kill_switch"] + self.mtu_clamping = routing["mtu_clamping"] + self.ipv6 = routing["ipv6"] + self.geoip = routing["geoip"] + self.updated_at = _now() + return self.get_config() + + def validate_config(self, update: ConfigUpdateRequest) -> dict[str, Any]: + errors: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + if update.tunnel is not None: + address = update.tunnel.address + if "/" not in address: + errors.append( + { + "path": "/tunnel/address", + "code": "invalid_cidr", + "message": "Адрес интерфейса должен быть в CIDR-нотации", + "severity": "error", + } + ) + if update.tunnel.mtu > 1420: + warnings.append( + { + "path": "/tunnel/mtu", + "code": "mtu_above_recommended", + "message": "MTU выше 1420 может требовать MSS clamping", + "severity": "warning", + } + ) + for index, peer in enumerate(update.peers or []): + if ":" not in peer.endpoint: + errors.append( + { + "path": f"/peers/{index}/endpoint", + "code": "invalid_endpoint", + "message": "Endpoint должен содержать host:port", + "severity": "error", + } + ) + return {"valid": not errors, "errors": errors, "warnings": warnings} + + # ------------------------------------------------------------------ routing + + def list_rules(self, *, enabled: bool | None = None, kind: str | None = None) -> dict[str, Any]: + items = [ + dict(rule) + for rule in sorted( + self.rules.values(), key=lambda rule: (rule["priority"], rule["created_at"]) + ) + if (enabled is None or rule["enabled"] is enabled) + and (kind is None or rule["kind"] == kind) + ] + return {"items": items, "total": len(items)} + + def create_rule(self, payload: RoutingRuleCreate) -> dict[str, Any]: + if any( + rule["kind"] == payload.kind and rule["value"] == payload.value + for rule in self.rules.values() + ): + raise ApiError(409, "conflict", "Правило для такого kind/value уже существует") + timestamp = _now() + rule_id = str(uuid.uuid4()) + rule = { + "id": rule_id, + "name": payload.name, + "kind": payload.kind, + "value": payload.value, + "action": payload.action, + "enabled": payload.enabled, + "priority": payload.priority, + "comment": payload.comment, + "created_at": timestamp, + "updated_at": timestamp, + } + self.rules[rule_id] = rule + return dict(rule) + + def get_rule(self, rule_id: str) -> dict[str, Any]: + rule = self.rules.get(rule_id) + if rule is None: + raise ApiError(404, "not_found", f"Правило {rule_id} не найдено") + return dict(rule) + + def update_rule(self, rule_id: str, payload: RoutingRuleUpdate) -> dict[str, Any]: + rule = self.get_rule(rule_id) + rule.update(payload.model_dump(exclude_unset=True)) + rule["updated_at"] = _now() + self.rules[rule_id] = rule + return dict(rule) + + def delete_rule(self, rule_id: str) -> None: + if rule_id not in self.rules: + raise ApiError(404, "not_found", f"Правило {rule_id} не найдено") + del self.rules[rule_id] + + def set_mode(self, mode: str) -> dict[str, Any]: + self.mode = mode + self.updated_at = _now() + return {"mode": mode, "applied_at": _now(), "reconcile_id": str(uuid.uuid4())} + + def update_geoip(self, *, force: bool = False) -> dict[str, Any]: + status = "updated" if force or self.geoip["source"] == "none" else "cached" + if status == "updated": + self.geoip = { + "source": "ripe-stat", + "prefix_count": GEOIP_PREFIX_COUNT, + "updated_at": _now(), + } + return { + "status": status, + "source": self.geoip["source"], + "prefix_count": self.geoip["prefix_count"], + "updated_at": self.geoip["updated_at"] or _now(), + "duration_ms": 1870 if status == "updated" else 3, + } + + def import_config(self, payload: ImportRequest) -> dict[str, Any]: + warnings: list[str] = [] + peers = 0 + for line in payload.content.splitlines(): + stripped = line.strip() + if stripped.startswith("[Peer]"): + peers += 1 + elif stripped.startswith("PresharedKey") and payload.format != "json": + warnings.append("PresharedKey импортирован в секреты и не будет возвращён в API") + return { + "imported_peers": peers, + "imported_rules": 0, + "warnings": warnings, + } + + # ------------------------------------------------------------------ devices / logs + + def list_devices(self) -> dict[str, Any]: + return {"items": [dict(device) for device in self.devices], "total": len(self.devices)} + + def list_logs( + self, + *, + limit: int = 100, + cursor: str | None = None, + level: str | None = None, + query: str | None = None, + ) -> dict[str, Any]: + offset = 0 + if cursor: + try: + offset = int(cursor) + except ValueError as exc: + raise ApiError(422, "validation_failed", "Некорректный курсор пагинации") from exc + entries = [entry for entry in self.logs if level is None or entry["level"] == level] + if query: + entries = [entry for entry in entries if query.lower() in entry["message"].lower()] + page = entries[offset : offset + limit] + next_offset = offset + len(page) + return { + "items": [dict(entry) for entry in page], + "total": len(entries), + "next_cursor": str(next_offset) if next_offset < len(entries) else None, + } + + # ------------------------------------------------------------------ auth + + def login(self, username: str, password: str) -> dict[str, Any]: + if password != self.ui_password: + raise ApiError(401, "unauthorized", "Неверные учётные данные") + self.username = username + self.session_token = str(uuid.uuid4()) + self.expires_at = ( + (datetime.now(UTC) + timedelta(hours=12)) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + return self.get_session() + + def logout(self) -> dict[str, Any]: + self.session_token = None + self.username = None + self.expires_at = None + return self.get_session() + + def get_session(self) -> dict[str, Any]: + return { + "authenticated": self.session_token is not None, + "username": self.username, + "expires_at": self.expires_at, + "csrf_token": self.session_token, + } diff --git a/backend/awg_api/store/README.md b/backend/awg_api/store/README.md new file mode 100644 index 0000000..631e124 --- /dev/null +++ b/backend/awg_api/store/README.md @@ -0,0 +1,66 @@ +# 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`). diff --git a/backend/awg_api/store/__init__.py b/backend/awg_api/store/__init__.py new file mode 100644 index 0000000..c13009f --- /dev/null +++ b/backend/awg_api/store/__init__.py @@ -0,0 +1,103 @@ +"""Слой хранения состояния 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", +] diff --git a/backend/awg_api/store/apply.py b/backend/awg_api/store/apply.py new file mode 100644 index 0000000..3ecc2fe --- /dev/null +++ b/backend/awg_api/store/apply.py @@ -0,0 +1,325 @@ +"""Атомарное применение состояния: ``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 diff --git a/backend/awg_api/store/audit.py b/backend/awg_api/store/audit.py new file mode 100644 index 0000000..3d77bd3 --- /dev/null +++ b/backend/awg_api/store/audit.py @@ -0,0 +1,125 @@ +"""Журнал аудита «кто/когда/что/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 diff --git a/backend/awg_api/store/clock.py b/backend/awg_api/store/clock.py new file mode 100644 index 0000000..0b3f33d --- /dev/null +++ b/backend/awg_api/store/clock.py @@ -0,0 +1,16 @@ +"""Источник времени для 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") diff --git a/backend/awg_api/store/db.py b/backend/awg_api/store/db.py new file mode 100644 index 0000000..bf03f2d --- /dev/null +++ b/backend/awg_api/store/db.py @@ -0,0 +1,175 @@ +"""Открытие и жизненный цикл 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() diff --git a/backend/awg_api/store/errors.py b/backend/awg_api/store/errors.py new file mode 100644 index 0000000..4e2c752 --- /dev/null +++ b/backend/awg_api/store/errors.py @@ -0,0 +1,33 @@ +"""Иерархия ошибок хранилища 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 diff --git a/backend/awg_api/store/migrations.py b/backend/awg_api/store/migrations.py new file mode 100644 index 0000000..7fd594e --- /dev/null +++ b/backend/awg_api/store/migrations.py @@ -0,0 +1,299 @@ +"""Версионированные миграции схемы состояния (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)}") diff --git a/backend/awg_api/store/models.py b/backend/awg_api/store/models.py new file mode 100644 index 0000000..f095955 --- /dev/null +++ b/backend/awg_api/store/models.py @@ -0,0 +1,186 @@ +"""Модель данных хранилища 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, + } diff --git a/backend/awg_api/store/repository.py b/backend/awg_api/store/repository.py new file mode 100644 index 0000000..a21dcb2 --- /dev/null +++ b/backend/awg_api/store/repository.py @@ -0,0 +1,599 @@ +"""Репозиторий сущностей состояния (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 diff --git a/backend/awg_api/store/service.py b/backend/awg_api/store/service.py new file mode 100644 index 0000000..dbe9169 --- /dev/null +++ b/backend/awg_api/store/service.py @@ -0,0 +1,47 @@ +"""Точка входа слоя хранения: открыть БД, применить миграции, собрать движок.""" + +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) diff --git a/backend/awg_api/store/state.py b/backend/awg_api/store/state.py new file mode 100644 index 0000000..5ccfcce --- /dev/null +++ b/backend/awg_api/store/state.py @@ -0,0 +1,174 @@ +"""Снимки состояния и восстановление после отката (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 diff --git a/backend/awg_api/store/validation.py b/backend/awg_api/store/validation.py new file mode 100644 index 0000000..30c3801 --- /dev/null +++ b/backend/awg_api/store/validation.py @@ -0,0 +1,131 @@ +"""Чистые функции валидации состояния (стадия ``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 diff --git a/backend/awg_api/ws/__init__.py b/backend/awg_api/ws/__init__.py new file mode 100644 index 0000000..646c17e --- /dev/null +++ b/backend/awg_api/ws/__init__.py @@ -0,0 +1 @@ +"""WebSocket-поток метрик.""" diff --git a/backend/awg_api/ws/metrics.py b/backend/awg_api/ws/metrics.py new file mode 100644 index 0000000..cc28cf4 --- /dev/null +++ b/backend/awg_api/ws/metrics.py @@ -0,0 +1,47 @@ +"""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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..323b9af --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,48 @@ +"""Общие фикстуры контрактных тестов.""" + +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 diff --git a/backend/tests/test_contract_negative.py b/backend/tests/test_contract_negative.py new file mode 100644 index 0000000..0fc9773 --- /dev/null +++ b/backend/tests/test_contract_negative.py @@ -0,0 +1,110 @@ +"""Негативные проверки контрактного теста: отклонение реализации обязано его ронять. + +Эти тесты — доказательство 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) diff --git a/backend/tests/test_contract_responses.py b/backend/tests/test_contract_responses.py new file mode 100644 index 0000000..4c6ba6e --- /dev/null +++ b/backend/tests/test_contract_responses.py @@ -0,0 +1,321 @@ +"""Контрактный тест «схема ↔ реализация»: каждый эндпоинт сверяется со схемой из 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 diff --git a/backend/tests/test_openapi_spec.py b/backend/tests/test_openapi_spec.py new file mode 100644 index 0000000..1555a68 --- /dev/null +++ b/backend/tests/test_openapi_spec.py @@ -0,0 +1,186 @@ +"""Проверки самой спецификации: валидность 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}" diff --git a/backend/tests/test_skeleton.py b/backend/tests/test_skeleton.py new file mode 100644 index 0000000..3e572ae --- /dev/null +++ b/backend/tests/test_skeleton.py @@ -0,0 +1,13 @@ +"""Скелетные тесты: пакеты импортируются, версия объявлена.""" + +import awg_agent +import awg_api + + +def test_versions_match() -> None: + assert awg_agent.__version__ == awg_api.__version__ + + +def test_public_api_declared() -> None: + assert "__version__" in awg_agent.__all__ + assert "__version__" in awg_api.__all__ diff --git a/backend/tests/test_store_apply.py b/backend/tests/test_store_apply.py new file mode 100644 index 0000000..75bdbb0 --- /dev/null +++ b/backend/tests/test_store_apply.py @@ -0,0 +1,311 @@ +"""Тесты атомарного применения: 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() diff --git a/backend/tests/test_store_audit.py b/backend/tests/test_store_audit.py new file mode 100644 index 0000000..4de1802 --- /dev/null +++ b/backend/tests/test_store_audit.py @@ -0,0 +1,145 @@ +"""Тесты журнала аудита «кто/когда/что/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) == [] diff --git a/backend/tests/test_store_concurrency.py b/backend/tests/test_store_concurrency.py new file mode 100644 index 0000000..1b879cf --- /dev/null +++ b/backend/tests/test_store_concurrency.py @@ -0,0 +1,308 @@ +"""Тесты потокобезопасности слоя хранения (#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) diff --git a/backend/tests/test_store_migrations.py b/backend/tests/test_store_migrations.py new file mode 100644 index 0000000..b00bbfb --- /dev/null +++ b/backend/tests/test_store_migrations.py @@ -0,0 +1,153 @@ +"""Тесты схемы, миграций и проверки версии при старте (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") diff --git a/backend/tests/test_store_persistence.py b/backend/tests/test_store_persistence.py new file mode 100644 index 0000000..46d0edb --- /dev/null +++ b/backend/tests/test_store_persistence.py @@ -0,0 +1,184 @@ +"""Персистентность: рестарт «контейнера» сохраняет правила, конфиг и аудит (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", " ") diff --git a/backend/tests/test_store_repository.py b/backend/tests/test_store_repository.py new file mode 100644 index 0000000..ff54771 --- /dev/null +++ b/backend/tests/test_store_repository.py @@ -0,0 +1,312 @@ +"""Тесты репозитория: 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" diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c4254f3 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,41 @@ +# 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). diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md new file mode 100644 index 0000000..6835ed2 --- /dev/null +++ b/deploy/VERIFICATION-21.md @@ -0,0 +1,283 @@ +# Проверка 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 ` | rc=0 | +| `ip netns exec true` | `mount of /sys failed: Operation not permitted`, rc=255 | +| `ip -n link set lo up` | `mount of /sys failed: Operation not permitted`, rc=255 | +| `nsenter --net=/var/run/netns/ 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` — на вердикт + не влияет. diff --git a/deploy/api_stub.py b/deploy/api_stub.py new file mode 100644 index 0000000..9366311 --- /dev/null +++ b/deploy/api_stub.py @@ -0,0 +1,95 @@ +#!/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()) diff --git a/deploy/dev-stand.md b/deploy/dev-stand.md new file mode 100644 index 0000000..60495f7 --- /dev/null +++ b/deploy/dev-stand.md @@ -0,0 +1,127 @@ +# 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`). diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..6bed89a --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,69 @@ +# 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" diff --git a/deploy/docker-compose.stub.yml b/deploy/docker-compose.stub.yml new file mode 100644 index 0000000..936efb3 --- /dev/null +++ b/deploy/docker-compose.stub.yml @@ -0,0 +1,23 @@ +# Оверрайд для 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" diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml new file mode 100644 index 0000000..b628c12 --- /dev/null +++ b/deploy/docker-compose.test.yml @@ -0,0 +1,73 @@ +# Тестовый стенд 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: diff --git a/deploy/verify-dev-stand.sh b/deploy/verify-dev-stand.sh new file mode 100644 index 0000000..c4e13f1 --- /dev/null +++ b/deploy/verify-dev-stand.sh @@ -0,0 +1,217 @@ +#!/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://:/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" </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 diff --git a/docs/adr/000-template.md b/docs/adr/000-template.md new file mode 100644 index 0000000..328cca6 --- /dev/null +++ b/docs/adr/000-template.md @@ -0,0 +1,21 @@ +# NNN. Название решения + +- **Статус:** предложено | принято | устарело +- **Дата:** YYYY-MM-DD +- **Контекст/issue:** #N + +## Контекст + +Что вынуждает принять решение и какие есть ограничения (хостовое окружение, привилегии, один шлюз, целевой UX). + +## Решение + +Что именно решили, одним абзацем. + +## Альтернативы + +Что рассмотрели и почему отклонили. + +## Последствия + +Положительные и отрицательные следствия; что решение накладывает на задачи эпика. diff --git a/docs/adr/001-backend-python-fastapi.md b/docs/adr/001-backend-python-fastapi.md new file mode 100644 index 0000000..9a96158 --- /dev/null +++ b/docs/adr/001-backend-python-fastapi.md @@ -0,0 +1,27 @@ +# 001. Backend — Python 3.12 + FastAPI + +- **Статус:** принято +- **Контекст/issue:** #15, #31 + +## Контекст + +Нужен control plane: REST API, WebSocket, планировщик, парсинг конфигов, работа с SQLite. +Низкоуровневая работа сети и шифрования выполняется не нашим кодом, а бинарями `awg` и `amneziawg-go`, +трафик маршрутизирует ядро. + +## Решение + +Control plane — **Python 3.12 + FastAPI (async)**. Go остаётся только там, где он уже есть: +`amneziawg-go` и `amneziawg-tools` собираются в multi-stage Dockerfile (ADR-002). + +## Альтернативы + +- **Go целиком** — быстрее на сетевом I/O, но маршрутизацией занимается ядро, а не процесс; + выигрыш не оправдывает рост стоимости контракта с UI (схемы, генерация клиента, миграции, веб-фреймворк). +- **Node.js** — преимуществ перед Python на нагрузке в единичного пользователя нет. + +## Последствия + +- OpenAPI-first: FastAPI отдаёт схему, что прямо соответствует задаче #19. +- В образе появляется Python runtime (плюс 40–60 MB к размеру). +- Дисциплина async: внешние команды — только `asyncio.create_subprocess_exec`, блокирующего кода в event loop нет. diff --git a/docs/adr/002-single-image-modular-monolith.md b/docs/adr/002-single-image-modular-monolith.md new file mode 100644 index 0000000..765dc1a --- /dev/null +++ b/docs/adr/002-single-image-modular-monolith.md @@ -0,0 +1,32 @@ +# 002. Модульный монолит в одном образе, два процесса + +- **Статус:** принято +- **Контекст/issue:** #15, #18 + +## Контекст + +ТЗ требует единый Docker-образ с минимальными параметрами запуска. Часть операций требует root +(интерфейс awg, iptables/ipset, dnsmasq на порту 53), а веб-часть должна быть скомпрометирована безопасно — без root. + +## Решение + +Один образ, два процесса под супервизором `deploy/entrypoint.sh`: + +| Процесс | Права | Ответственность | +|---|---|---| +| `awg-agent` | root, NET_ADMIN | туннель, netfilter, routing, dnsmasq, метрики | +| `awg-api` | непривилегированный | FastAPI, REST/WS, статика UI | + +Обмен — unix-socket `/run/awg/agent.sock`, общее состояние — SQLite в `/data` и конфиги в `/config`. + +## Альтернативы + +- **Микросервисы** (отдельные контейнеры agent/api/dns) — общая сеть, координация и дублирование + привилегий; выгода масштабирования на одном шлюзе отсутствует. +- **Один процесс под root** — проще, но даёт веб-слою полные права; неприемлемо. + +## Последствия + +- Супервизор обязан пробрасывать сигналы и коды выхода, иначе Docker не заметит падения процесса. +- Права на unix-socket входят в security-проверки (#44). +- Перезапуск API не разрывает туннель. diff --git a/docs/adr/003-reconcile-desired-state.md b/docs/adr/003-reconcile-desired-state.md new file mode 100644 index 0000000..82f1e47 --- /dev/null +++ b/docs/adr/003-reconcile-desired-state.md @@ -0,0 +1,28 @@ +# 003. Декларативный reconcile вместо императивных скриптов + +- **Статус:** принято +- **Контекст/issue:** #15, #24, #27 + +## Контекст + +PoC (`entrypoint.sh`) применяет правила императивно и только при старте: изменения не откатываются, +GeoIP не обновляется без рестарта, частичная ошибка оставляет систему в неопределённом состоянии. + +## Решение + +Единый reconcile-цикл: desired state (SQLite) -> diff -> идемпотентное применение -> проверка -> +rollback при ошибке. Операции netfilter идемпотентны (проверка `-C` перед `-A`), замена больших +наборов атомарна (`ipset create tmp`, `swap`, `destroy`), применение конфига транзакционно +(validate -> stage -> apply -> rollback). + +## Альтернативы + +- **Скрипты по событию** (как в PoC) — быстро написать, но невозможно гарантировать сходимость после сбоя. +- **Готовый IaC-инструмент** — избыточен, требует внешнего рантайма в образе и не покрывает ipset/dnsmasq. + +## Последствия + +- Старт контейнера = reconcile с нуля: состояние всегда восстановимо. +- Нужны тесты на идемпотентность (повторный apply не создаёт дублей) — #41. +- Сложнее скрипта: оправдано требованиями FR-2 (смена режимов и правил без рестарта). +- Diff reconcile логируется и отдаётся в UI (`/logs`) и audit log. diff --git a/docs/adr/004-geoip-ripe-provider.md b/docs/adr/004-geoip-ripe-provider.md new file mode 100644 index 0000000..e656736 --- /dev/null +++ b/docs/adr/004-geoip-ripe-provider.md @@ -0,0 +1,28 @@ +# 004. GeoIP: RIPE Stat + локальный кэш + абстракция провайдера + +- **Статус:** принято +- **Контекст/issue:** #15, #25 + +## Контекст + +Для split-tunnel нужны списки префиксов страны (в PoC — около 11 400 записей RIPE). +Варианты: RIPE Stat (бесплатно), ip2location и MaxMind (точнее, но лицензия и платный доступ). + +## Решение + +Основной источник — **RIPE Stat** (`country-resource-list`, IPv4 и IPv6): бесплатно и уже проверено в PoC. +Обязательные элементы: локальный кэш-снапшот в `/data/geoip/`, расписание обновления, ручное +обновление, явный статус источника (API или кэш с датой). Внутри — интерфейс `GeoProvider`, чтобы +добавить ip2location/MaxMind без переделки маршрутизации. + +## Альтернативы + +- **MaxMind GeoLite2** — точнее по ASN, но требует регистрации и обновления лицензии; оставляем как опцию. +- **Собственный список в репозитории** — устаревает и не покрывает смену маршрутной политики. + +## Последствия + +- Старт не зависит от внешней сети: при недоступности API работаем на кэше (#25, тест #41). +- Fallback не должен молча менять политику split-tunnel: неявные переходы в режим «всё в VPN» или + «всё напрямую» запрещены, поведение задаётся явно (#23). +- Загрузка 11k+ префиксов — измеримая операция; время старта и swap — часть AC #24 и #25. diff --git a/docs/adr/005-dns-dnsmasq.md b/docs/adr/005-dns-dnsmasq.md new file mode 100644 index 0000000..7677486 --- /dev/null +++ b/docs/adr/005-dns-dnsmasq.md @@ -0,0 +1,27 @@ +# 005. DNS: встроенный dnsmasq и перехват порта 53 + +- **Статус:** принято +- **Контекст/issue:** #15, #26 + +## Контекст + +DNS-based split-tunnel требует знать, какие IP принадлежат доменам из списка. Если клиенты сами +резолвят домены (свой DNS, DoH, другой upstream), шлюз не узнаёт об адресах и правило не срабатывает. + +## Решение + +В образе поднимается **встроенный dnsmasq** (процесс агента): +- домены из списка → `ipset` (через `--ipset`), трафик на эти адреса идёт в туннель; +- перехват порта 53 от LAN-клиентов шлюзом — устраняет DNS-утечку и лечит клиентов с ручным DNS; +- upstream-форвардинг и кэш по TTL; опция «внешний DNS» остаётся для нестандартных схем. + +## Альтернативы + +- **Отдельный DNS-контейнер** — лишняя зависимость и точка отказа, ломает требование единого образа. +- **Только DNS клиентов** — не работает без ручной настройки каждого устройства (противоречит FR-2). + +## Последствия + +- Порт 53 занят контейнером: в README/runbook нужно предупреждение (конфликт с локальным резольвером хоста). +- Обязательно тестировать домены с CNAME и динамическими IP (#26, #41). +- Список доменов и его размеры ограничиваются явно (валидация в API, #30). diff --git a/docs/adr/006-state-sqlite.md b/docs/adr/006-state-sqlite.md new file mode 100644 index 0000000..a450b9d --- /dev/null +++ b/docs/adr/006-state-sqlite.md @@ -0,0 +1,29 @@ +# 006. Состояние: SQLite (WAL) + /config + /data + +- **Статус:** принято +- **Контекст/issue:** #15, #20 + +## Контекст + +Нужно хранить правила маршрутизации, исключения, режим, расписание GeoIP, метаданные конфигов, +устройства, audit log и историю метрик. Профиль нагрузки: единичный пользователь, десятки записей в секунду, +важна надёжность и отсутствие внешних зависимостей. + +## Решение + +- **SQLite в режиме WAL** в `/data/awg-dash.db` — всё структурированное состояние и audit log; +- `/config` — файлы `.conf`, права 0600, секреты в подкаталоге с ограниченным доступом; +- `/data` — кэш GeoIP и история метрик с retention. + +Оба каталога объявляются томами образа: без них конфигурация не переживёт пересоздание контейнера. + +## Альтернативы + +- **Postgres/Redis** — лишний сервис в образе для единичного пользователя. +- **Только файлы JSON** — нет транзакций, аудита и атомарного apply/rollback. + +## Последствия + +- Миграции схемы обязательны с первого дня (#20) и проверяются при старте. +- Backup/restore = копирование `/data` и `/config` — отражается в runbook (#45). +- История метрик растёт: нужен retention и периодическая очистка (#28). diff --git a/docs/adr/007-embedded-web-server.md b/docs/adr/007-embedded-web-server.md new file mode 100644 index 0000000..84be090 --- /dev/null +++ b/docs/adr/007-embedded-web-server.md @@ -0,0 +1,26 @@ +# 007. Web-сервер: встроенный uvicorn, без nginx + +- **Статус:** принято +- **Контекст/issue:** #15, #31 + +## Контекст + +UI — SPA, API — REST и WebSocket, всё в одном контейнере. Нагрузка: LAN, единичный пользователь. +Установка должна быть в одну команду `docker run` без дополнительных контейнеров. + +## Решение + +Один uvicorn-процесс `awg-api` отдаёт и API, и собранную статику UI, и WebSocket. Сборка UI выполняется +на этапе билда образа и попадает в `ui/dist`. Закладывается совместимость с внешним reverse-proxy: +поддержка base-path и заголовков `X-Forwarded-*`, без обязательной настройки хоста. + +## Альтернативы + +- **nginx в образе** — плюс к размеру и к конфигу, преимуществ при такой нагрузке нет. +- **Отдельный контейнер для UI** — нарушает требование единого образа. + +## Последствия + +- Заголовки кэширования статики и `index.html` настраиваются в FastAPI (иначе UI кэшируется после обновления). +- WebSocket идёт через тот же порт: учитывается при настройке reverse-proxy в runbook (#45). +- Docker HEALTHCHECK опирается на `/api/v1/health` того же процесса (#31). diff --git a/docs/adr/008-api-ui-security.md b/docs/adr/008-api-ui-security.md new file mode 100644 index 0000000..098fc8d --- /dev/null +++ b/docs/adr/008-api-ui-security.md @@ -0,0 +1,31 @@ +# 008. Безопасность API/UI: токен, CSRF, rate limit, маскировка секретов + +- **Статус:** принято +- **Контекст/issue:** #15, #34 + +## Контекст + +Приложение управляет VPN-туннелем и принимает приватные ключи AmneziaWG. Оно доступно из LAN, +а в перспективе может быть выставлено через reverse-proxy. В ТЗ этот слой не описан, но без него +загрузка `.conf` через веб-интерфейс превращается в дыру. + +## Решение + +- API — токен доступа в заголовке (Bearer), токен только в виде хеша в БД; +- UI — сессия + CSRF-токен на мутирующие запросы; +- приватные ключи (`PrivateKey`, `PresharedKey`) — `writeOnly` в OpenAPI, маска в UI и логах, + файлы 0600 в отдельном каталоге; +- rate limit и lockout на `/auth`, ограничение допустимых источников, bind по умолчанию на LAN; +- audit log всех изменений конфигурации. + +## Альтернативы + +- **Без аутентификации, доступ только по LAN** — не защищает от заражённого устройства в сети и от + ошибочной публикации порта. +- **OIDC-провайдер** — не для локального однопользовательского приложения. + +## Последствия + +- Начальный токен генерируется при первом старте и выводится в лог один раз — это шаг в runbook (#45). +- Маскировка секретов проверяется автотестами (#41) и security-прогоном (#44). +- UI обязан корректно вести себя при 401 (переход на форму входа) — часть AC #35. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..9cf9ed5 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,16 @@ +# ADR — архитектурные решения awg-dash + +Один файл = одно решение. Формат: контекст -> решение -> последствия. + +| ADR | Решение | +|---|---| +| [001](001-backend-python-fastapi.md) | Backend — Python 3.12 + FastAPI | +| [002](002-single-image-modular-monolith.md) | Модульный монолит в одном образе, 2 процесса | +| [003](003-reconcile-desired-state.md) | Декларативный reconcile вместо императивных скриптов | +| [004](004-geoip-ripe-provider.md) | GeoIP: RIPE Stat + локальный кэш + абстракция провайдера | +| [005](005-dns-dnsmasq.md) | DNS: встроенный dnsmasq + перехват порта 53 | +| [006](006-state-sqlite.md) | Состояние: SQLite (WAL) + /config + /data | +| [007](007-embedded-web-server.md) | Web-сервер: встроенный uvicorn, без nginx | +| [008](008-api-ui-security.md) | Безопасность API/UI: токен, CSRF, rate limit, маскировка секретов | + +Новые решения добавляются со следующим номером и ссылкой на issue, для которого решение принято. diff --git a/docs/ci/CI.md b/docs/ci/CI.md new file mode 100644 index 0000000..e82aa13 --- /dev/null +++ b/docs/ci/CI.md @@ -0,0 +1,199 @@ +# CI/CD: пайплайн с гейтами (`ci.yml`) + +**Владелец:** devops · **Issue:** #17 (Refs #15) · **Файл:** `.gitea/workflows/ci.yml` +**Runner:** `awg-dash-147-repo` (repo-scoped) на Docker-хосте `192.168.1.147` + +--- + +## 1. Триггеры + +| Событие | Ветки | Что запускается | +|---|---|---| +| `push` | `feat/**`, `fix/**`, `chore/**`, `ci/**`, `main` | полный пайплайн | +| `pull_request` (sync) | базис `main` | полный пайплайн | +| `workflow_dispatch` | любая | полный пайплайн (ручной перезапуск) | + +--- + +## 2. Стадии и гейты + +``` +lint ──┬── unit ────────┐ + │ ├── build ── image-scan ── push (только push-событие) + └── security ────┘ +``` + +| Job | Что делает | Гейт (роняет pipeline) | Артефакты | +|---|---|---|---| +| `lint` | `ruff check backend`, `ruff format --check backend`, `mypy backend` (strict) | любая ошибка линтера/типов | — | +| `unit` | `pytest --cov=awg_agent --cov=awg_api --cov-fail-under=70` | **покрытие < 70 %** | `coverage.xml`, `htmlcov/` | +| `security` | `bandit -lll -r backend`, `pip-audit -r requirements.txt` | **bandit severity HIGH**, любая известная уязвимость в зависимостях | `bandit-report.txt/.json`, `pip-audit-report.json` | +| `build` | `docker build -f deploy/Dockerfile -t /:staging-` | ошибка сборки | `build.log`, `image-id.txt` | +| `image-scan` | `docker run aquasec/trivy: image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1` (по сокету) | **HIGH/CRITICAL в образе** | `trivy-report.txt`, `trivy-report.json` | +| `push` | `docker login` + `docker push ...:staging-` | ошибка авторизации/пуша | `push.log`, `pushed-image.txt` | + +**trivy** запускается официальным образом через docker-сокет (бинарь с github releases +в homelab качается крайне медленно), БД кэшируется в `/opt/trivy-cache` на build-хосте. + +**Токен для push**: приоритет — секрет `REGISTRY_TOKEN` (PAT с правом `write:package`), +fallback — встроенный `secrets.GITHUB_TOKEN`. Проверено: PAT без `write:package` получает +`401 Unauthorized` на `POST /v2//blobs/uploads/` — если push падает с 401, заведите секрет. + +**Порядок гарантий:** + +- `build` стартует только после зелёных `lint` + `unit` + `security`. +- `image-scan` — только после `build`; `push` — только после зелёного `image-scan`. +- Тег образа: `staging-<первые 8 символов GITHUB_SHA>` в registry `192.168.1.111:3000/embeddevio/awg-dash`. + +## 3. Условная активация build/image-scan/push + +`build`/`image-scan`/`push` выполняются, только если в репозитории есть `deploy/Dockerfile` +(вычисляется в `lint` → output `has_dockerfile`). До merge #18 (PR #49) они **скипаются** +с предупреждением в логе — это ожидаемое поведение, а не сбой. + +## 4. Runner + +| Параметр | Значение | +|---|---| +| Контейнер | `gitea-runner-awg-repo` (`docker.io/gitea/act_runner:latest`, v0.6.1) на `192.168.1.147` | +| Область | **repo-scoped** (зарегистрирован repo-runner-token'ом для `embeddevio/awg-dash`) | +| Label | `ubuntu-latest` → образ `catthehacker/ubuntu:act-22.04` | +| Параллелизм | `capacity: 1`, `maxParallel=1` | +| Сеть job-контейнеров | `container.network: "host"` (см. п. 7 — иначе исчерпывается пул адресов Docker) | +| Docker socket | проброшен (`/var/run/docker.sock`), `container.options: ""` — v0.6.1 монтирует сокет сам, дубль в `options` даёт `Duplicate mount point` | +| Кэш инструментов | volume `act-toolcache` → `/opt/hostedtoolcache` (Python 3.12 скачивается один раз) | +| Конфиг | `/opt/gitea-runner-awg-repo/config.yaml`, состояние — `/opt/gitea-runner-awg-repo/.runner` | + +### Диагностика runner'а + +```bash +ssh root@192.168.1.147 +docker ps --filter name=gitea-runner # жив ли +docker logs --tail 50 gitea-runner-awg-repo # задачи, ошибки +docker logs gitea-runner-awg-repo 2>&1 | grep -E "task|Error|failed" +``` + +Лог job'а с его шагами и результатом доступен через API Gitea: + +```bash +curl -H "Authorization: token $GITEA_TOKEN" \ + http://192.168.1.111:3000/api/v1/repos/embeddevio/awg-dash/actions/jobs//logs +``` + +Перезапуск конкретного run'а без нового коммита: + +```bash +curl -X POST -H "Authorization: token $GITEA_TOKEN" \ + http://192.168.1.111:3000/api/v1/repos/embeddevio/awg-dash/actions/runs//rerun +``` + +## 5. Registry (push staging-) + +Gitea-registry `192.168.1.111:3000` работает по **HTTP**, поэтому Docker-демон на +build-хосте должен знать его как insecure: + +```json +// /etc/docker/daemon.json +{ "log-driver": "journald", "insecure-registries": ["192.168.1.111:3000"] } +``` + +Применяется **без перезапуска контейнеров**: + +```bash +systemctl reload docker # SIGHUP, контейнеры продолжают работать +docker info | grep -A3 "Insecure Registries" +``` + +Авторизация в job'е — `${{ secrets.GITHUB_TOKEN }}` (выдаётся Gitea Actions автоматически), пользователь `${{ github.actor }}`. + +## 6. Известные дефекты инфраструктуры + +### 6.1 Gitea git-http отдаёт пустой pack → `actions/checkout` не работает + +**Симптом:** `git clone`/`git fetch` с любого хоста падает: + +``` +fatal: early EOF +fatal: fetch-pack: invalid index-pack output +``` + +**Причина (измерено):** ответ `git-upload-pack` содержит `Content-Length: 90` — только +`shallow-info` + заголовок `packfile`, без самих объектов. То есть обрыв на стороне +Gitea (git-http), а не у клиента. `ls-remote` и эндпоинт `archive/.tar.gz` при этом +работают (архив 200 OK, валидный tar). + +**Обход в `ci.yml`:** основной путь — `actions/checkout@v4` (`continue-on-error: true`); +если он падает, следующий шаг забирает исходники архивом: + +```bash +curl -fsSL -o /tmp/src.tar.gz "http://192.168.1.111:3000/${IMAGE_NAME}/archive/${GITHUB_SHA}.tar.gz" +tar -xzf /tmp/src.tar.gz --strip-components=1 -C . +``` + +Когда git-http починят, fallback перестанет срабатывать сам (условие `outcome == 'failure'`). +Исправление на стороне Gitea-хоста (`192.168.1.111`) — вне доступа агентов (нет SSH). + +### 6.2 «all predefined address pools have been fully subnetted» + +Каждый job создаёт Docker-сеть `GITEA-ACTIONS-*`; прерванные job'ы оставляли сети, +и пул `172.17.0.0/16`-диапазонов исчерпался → job'ы падали на создании контейнера. + +Лечение (безопасно, только сети без контейнеров): + +```bash +for n in $(docker network ls --filter name=GITEA-ACTIONS -q); do + [ "$(docker network inspect "$n" --format '{{len .Containers}}')" = "0" ] && docker network rm "$n" +done +``` + +Профилактика: `container.network: "host"` в конфиге runner'а (job-сети не создаются). +Кардинальное решение — `default-address-pools` в `daemon.json`, но оно требует +**перезапуска** демона (в отличие от `insecure-registries`), поэтому только по согласованию. + +### 6.3 Instance-scoped runner не отчитывается о статусах + +Runner, зарегистрированный instance-токеном (`gitea-runner-awg`), забирал задачи, но +Gitea не фиксировал результаты: jobs вечно в `queued`, `runner_id: 0`, логов нет. +Repo-scoped runner (`gitea-runner-awg-repo`, id 2) с тем же образом пишет статусы и логи +корректно. Legacy-контейнер остановлен. + +### 6.4 Артефакты Actions не публикуются + +`actions/upload-artifact@v3` в логе рапортует `Artifact has been successfully uploaded!`, +но `GET /actions/runs//artifacts` → `{"artifacts":[],"total_count":0}` и в UI пусто; +`@v4` зависает на старте экшена (>6 мин). Платформенный дефект Gitea 1.26.0 + act_runner v0.6.1 — issue #62. + +**Мера в пайплайне:** `unit` печатает coverage-таблицу (`coverage report --sort=cover`), `security` — вывод +`bandit`/`pip-audit`, `image-scan` — trivy-таблицу. Эти строки в логе job'а и есть доступный +артефакт: ссылка на job лежит в commit status. Скачать `coverage.xml`/`htmlcov`/`trivy-report.json` +из UI нельзя, пока #62 не закрыт. + +### 6.5 `contract-openapi.yml` — тот же дефект git-http + +Оба job'а (`openapi-contract`, `ts-client-is-current`) падали на шаге `checkout` за 28–50 с +(`bad pack header`, exit 128) и делали combined status любого PR = `failure`, хотя `ci / *` зелёные. +В ветке `fix/59-checkout-fallback` (PR #63) применён тот же archive-fallback; `ts-client-is-current` +после fallback сравнивает `sha256sum` перегенерированного `ui/src/api/generated/schema.d.ts` +вместо `git diff` (в распакованном архиве нет `.git`). Проверено: run #64 — success. + +## 7. Локальное воспроизведение гейтов + +```bash +python3.12 -m venv .venv && . .venv/bin/activate +pip install -r requirements-dev.txt + +ruff check backend && ruff format --check backend && mypy backend # lint +pytest # unit + coverage >= 70% +bandit -lll -r backend && pip-audit -r requirements.txt # security +docker build -f deploy/Dockerfile -t awg-dash:local . # build +trivy image --severity HIGH,CRITICAL --exit-code 1 awg-dash:local # image-scan +``` + +Проверка, что гейты действительно роняют пайплайн: + +```bash +# покрытие: временно сузить --cov до одного модуля или добавить непокрытый код +pytest --cov=awg_agent --cov-fail-under=70 # FAIL: покрытие ниже порога +# security: HIGH-находка (eval / shell=True / weak hash) +bandit -lll -r backend # exit 1 при Severity: High +``` diff --git a/docs/ci/RUNNER-CHECK-17-v2.md b/docs/ci/RUNNER-CHECK-17-v2.md new file mode 100644 index 0000000..eee7267 --- /dev/null +++ b/docs/ci/RUNNER-CHECK-17-v2.md @@ -0,0 +1,138 @@ +# Отчёт: Gitea Actions runner + CI-пайплайн `embeddevio/awg-dash` (v2) + +**Владелец:** devops · **Дата:** 2026-10-28 · **Refs #17, Refs #15** +**Ветка:** `feat/17-ci` · **Ветка проверки:** `feat/17-verify` (= #17 + `deploy/*` из #18) + +## 1. Итог + +Runner, поднятый 28.10, **фактически не работал**: задачи забирались, но падали на создании +контейнера, а результаты не фиксировались в Gitea. Причины найдены и устранены на build-хосте +`192.168.1.147`. Пайплайн `ci.yml` написан, закоммичен и **прогнан на живом runner'е** — +все три гейта подтверждены реальными падениями jobs. + +Осталось три внешних пункта, которые агент закрыть не может (нет SSH к `192.168.1.111`, +нет прав на merge): git-http дефект Gitea (#59), артефакты Gitea не публикуются, +`deploy/Dockerfile` появится в `main` только после merge #18. + +## 2. Что было сломано в runner'е и как исправлено + +| # | Симптом | Причина | Исправление | +|---|---|---|---| +| 1 | jobs вечно `queued`, `runner_id: 0`, логов нет | runner зарегистрирован **instance-token'ом** (`gitea-runner-awg`): задачи забирались, но результат не привязывался к репозиторию | зарегистрирован **repo-scoped** runner `awg-dash-147-repo` (repo registration token через API), `online`, id 2; legacy-контейнер остановлен | +| 2 | `failed to create container: Duplicate mount point: /var/run/docker.sock` | `container.options` монтировал сокет, а act_runner v0.6.1 монтирует его сам | `options: ""` + `valid_volumes: ["/var/run/docker.sock"]` | +| 3 | `all predefined address pools have been fully subnetted` | 23 «висимых» сети `GITEA-ACTIONS-*` от прерванных job'ов вычерпали пул Docker | удалены все `GITEA-ACTIONS-*` с 0 контейнеров (31 → 8 сетей); `container.network: "host"` как профилактика | +| 4 | `git clone` в шаге checkout: `bad pack header` | дефект Gitea git-http (issue #59), не runner | в `ci.yml` — fallback на `archive/.tar.gz` (archive работает: 200, валидный tar) | +| 5 | trivy: `install.sh` + `v0.57.1` → 404, tarball с github releases качается минутами | неверная версия в скрипте + медленная отдача github releases | trivy запускается официальным образом `aquasec/trivy:0.76.1` через docker-сокет, БД кэшируется в `/opt/trivy-cache` | + +## 3. Итоговая конфигурация runner'а + +``` +контейнер : gitea-runner-awg-repo (docker.io/gitea/act_runner:latest v0.6.1), 192.168.1.147 +область : repo-scoped (embeddevio/awg-dash), runner id 2, online +label : ubuntu-latest -> catthehacker/ubuntu:act-22.04 +capacity : 1 (maxParallel=1) +сеть job : host, socket /var/run/docker.sock проброшен +toolcache : volume act-toolcache -> /opt/hostedtoolcache (Python 3.12.16 установлен один раз) +trivy : образ aquasec/trivy:0.76.1 + БД-кэш /opt/trivy-cache +registry : 192.168.1.111:3000 добавлен в insecure-registries на .147 + (применено `systemctl reload docker`, контейнеры не перезапускались) +``` + +## 4. Пайплайн `.gitea/workflows/ci.yml` + +``` +lint ─┬─ unit ───────┐ + │ ├─ build ── image-scan ── push (только push-событие) + └─ security ───┘ +``` + +| Job | Команды | Гейт | +|---|---|---| +| lint | `ruff check backend`, `ruff format --check backend`, `mypy backend` | любая ошибка | +| unit | `pytest --cov=awg_agent --cov=awg_api --cov-report=xml/html --cov-fail-under=70` | **coverage < 70 %** | +| security | `bandit -lll -r backend`, `pip-audit -r requirements.txt -r requirements-contract.txt` | **bandit HIGH**, известные CVE в зависимостях | +| build | `docker build --pull -f deploy/Dockerfile -t 192.168.1.111:3000/embeddevio/awg-dash:staging-` | ошибка сборки | +| image-scan | `trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1` | **HIGH/CRITICAL в образе** | +| push | `docker login` + `docker push ...:staging-` | ошибка авторизации/пуша | + +`build`/`image-scan`/`push` активируются только при наличии `deploy/Dockerfile` +(output `has_dockerfile` из `lint`); до merge #18 они скипаются с предупреждением. + +## 5. Прогоны на живом runner'е (доказательства по AC) + +### 5.1 `feat/17-ci` (ветка от `main`, Dockerfile ещё нет) — run #39 + +http://192.168.1.111:3000/embeddevio/awg-dash/actions/runs/39 + +| Job | Результат | Детали | +|---|---|---| +| lint | ✅ success | ruff check/format + mypy strict | +| unit | ✅ success | **198 passed**, coverage **96.80 %** (порог ≥ 70 %) | +| security | ✅ success | bandit: 0 HIGH, pip-audit: CVE не найдены | +| build / image-scan / push | ⏭ skipped | нет `deploy/Dockerfile` (ждёт #18) — штатное поведение | + +### 5.2 `feat/17-verify` (= #17 + `deploy/*` из #18) — run #49 + +http://192.168.1.111:3000/embeddevio/awg-dash/actions/runs/49 + +| Job | Результат | Детали | +|---|---|---| +| lint | ✅ success | | +| unit | ✅ success | 198 passed, coverage 96.80 % | +| security | ✅ success | | +| build | ✅ success | образ `192.168.1.111:3000/embeddevio/awg-dash:staging-` собран на runner'е | +| image-scan | ❌ failure | `Total: 22 (HIGH: 21, CRITICAL: 1)` — golang stdlib v1.25.14, `golang.org/x/crypto` — см. п. 6.1 | +| push | ⏭ skipped | fail-closed: при красном image-scan образ не публикуется | + +### 5.3 Негативные прогоны гейтов — `ci/17-gate-negative` (ветка-пробник, удалена) + +| Проверка | Run | Что в логе | +|---|---|---| +| покрытие < порога | #51 http://192.168.1.111:3000/embeddevio/awg-dash/actions/runs/51 | `FAIL Required test coverage of 99% not reached. Total coverage: 96.80%` → job `unit` **failure** | +| HIGH-находка bandit | #55 http://192.168.1.111:3000/embeddevio/awg-dash/actions/runs/55 | `Issue: [B602 ...] Severity: High Confidence: High` → job `security` **failure**, pipeline красный | +| HIGH/CRITICAL в образе | #49 (см. 5.2) | 22 находки → `image-scan` **failure** | + +**Вывод по AC:** пуш в `feat/*` даёт jobs `lint`/`unit`/`security`/`build`; снижение покрытия +ниже порога роняет job; HIGH-уязвимость (код или образ) роняет pipeline. + +## 6. Открытые вопросы (эскалация) + +### 6.1 Образ содержит 21 HIGH + 1 CRITICAL (нужен апгрейд сборки, #18) + +Trivy по образу, собранному из `deploy/Dockerfile` (#18): + +- `stdlib` Go **v1.25.14** → CVE-2026-56857 (CRITICAL), CVE-2026-111943, CVE-2026-78667, CVE-2026-97031 (HIGH); +- `golang.org/x/crypto v0.42.0` → CVE-2025-47913 (HIGH, fix в 0.43.0). + +Причина: `ARG GO_IMAGE=golang:1.25-alpine` в `deploy/Dockerfile`. Гейт работает правильно — +публикация образа заблокирована. Требуется бамп Go до ≥ 1.26.10 и `golang.org/x/crypto` ≥ 0.43.0 +(владелец #18), после этого `image-scan` и `push` пойдут зелёными без правок CI. + +### 6.2 Артефакты Gitea не публикуются + +`actions/upload-artifact@v3` отрабатывает без ошибок («Artifact has been successfully uploaded!», +173 КБ на coverage), но ни API (`/actions/runs//artifacts`, `/actions/artifacts`), ни UI +артефактов не показывают (`total_count: 0`). `upload-artifact@v4` **зависает** (job висел >6 мин +на старте экшена, контейнер пришлось снять). Итог: содержимое артефактов в Gitea 1.26 + runner +v0.6.1 не сохраняется — поэтому в шагах дополнительно печатается сводка в лог job +(coverage-таблица, bandit-отчёт, trivy-таблица) — эти логи стабильны и доступны по ссылке run'а. + +### 6.3 Git-http Gitea (issue #59) + +Checkout штатным `actions/checkout` падает (`bad pack header`), работает только архивный +fallback. Нужен доступ к хосту Gitea `192.168.1.111` (fsck/repack, проверка диска) — у агентов +его нет. + +### 6.4 Token для registry push + +PAT агента не имеет `write:package` → `401 Unauthorized` на `POST /v2/.../blobs/uploads/` +(проверено вручную на .147). В `ci.yml` логин берёт `secrets.REGISTRY_TOKEN`, если он задан, +иначе `secrets.GITHUB_TOKEN`. Для гарантированного push нужен секрет `REGISTRY_TOKEN` +(owner, PAT с `write:package`). + +## 7. Эксплуатация + +- Перезапуск run'а без коммита: `POST /api/v1/repos/embeddevio/awg-dash/actions/runs//rerun`. +- Ручной запуск: `workflow_dispatch` у `ci.yml`. +- Логи job'а: `GET /api/v1/repos/embeddevio/awg-dash/actions/jobs//logs`. +- Полный runbook: `docs/ci/CI.md` в ветке `feat/17-ci`. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..4f46a1e --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1254 @@ +openapi: 3.1.0 +info: + title: awg-dash API + version: 0.1.0 + summary: REST + WebSocket control plane of the AmneziaWG gateway dashboard + description: | + Контракт API v1 (source of truth, ADR-008). Спецификация — источник истины: + реализация и UI-клиент генерируются/проверяются от неё. + + Соглашения: + * Единый формат ошибки — схема `Error` (`{code, message, details}`) для любого + не-2xx ответа, включая 422 и ошибки внутренних исключений. + * Секреты (`private_key`, `preshared_key`, `password`) помечены `writeOnly`: + они допустимы в запросах и ЗАПРЕЩЕНЫ в ответах (`additionalProperties: false` + + удаление `writeOnly`-свойств из ответной валидации). + * Аутентификация: `Authorization: Bearer ` для REST, cookie-сессия + + `X-CSRF-Token` для UI. `/api/v1/health`, `/api/v1/ready`, `/metrics` — открыты. + * Все таймстемпы — RFC 3339 (UTC), целые размеры — байты, длительности — секунды. + + Генерация TS-клиента: `ui/scripts/generate-api.sh` → + `ui/src/api/generated/schema.d.ts`. Контрактный тест: + `pytest backend/tests/test_contract_responses.py`. + license: + name: MIT + identifier: MIT +servers: + - url: / + description: Встроенный uvicorn (ADR-007), тот же origin, что и UI +tags: + - name: ops + description: Служебные эндпоинты (health/ready/metrics), без аутентификации + - name: status + description: Состояние туннеля и сводный статус + - name: config + description: Чтение, запись и валидация конфигурации AmneziaWG + - name: routing + description: Правила маршрутизации, режим split-tunnel, GeoIP, импорт + - name: devices + description: Устройства за шлюзом и их маршрутизация + - name: logs + description: Журнал событий шлюза + - name: auth + description: Аутентификация UI и API + - name: metrics + description: Потоковые метрики (WebSocket) + +security: + - bearerAuth: [] + +paths: + /api/v1/health: + get: + tags: [ops] + operationId: getHealth + summary: Liveness-проба + security: [] + responses: + "200": + description: Процесс жив + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/ready: + get: + tags: [ops] + operationId: getReady + summary: Readiness-проба (готовность агента и зависимостей) + security: [] + responses: + "200": + description: Состояние готовности (в том числе при ready=false) + content: + application/json: + schema: + $ref: "#/components/schemas/ReadyResponse" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /metrics: + get: + tags: [ops] + operationId: getPrometheusMetrics + summary: Метрики в формате Prometheus text exposition + security: [] + responses: + "200": + description: Текстовый экспорт метрик + content: + text/plain: + schema: + type: string + description: Prometheus text exposition format (version 0.0.4) + + /api/v1/status: + get: + tags: [status] + operationId: getStatus + summary: Сводный статус шлюза, туннеля и GeoIP + responses: + "200": + description: Статус + content: + application/json: + schema: + $ref: "#/components/schemas/StatusResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/config: + get: + tags: [config] + operationId: getConfig + summary: Текущая конфигурация (без секретов) + responses: + "200": + description: Конфигурация; секретные поля отсутствуют + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [config] + operationId: putConfig + summary: Атомарное применение конфигурации (validate → staging → apply → rollback) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigUpdateRequest" + responses: + "200": + description: Конфигурация применена + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/config/validate: + post: + tags: [config] + operationId: validateConfig + summary: Сухая валидация конфигурации без применения + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigUpdateRequest" + responses: + "200": + description: Результат валидации (в том числе при valid=false) + content: + application/json: + schema: + $ref: "#/components/schemas/ValidateResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/rules: + get: + tags: [routing] + operationId: listRoutingRules + summary: Список правил маршрутизации + parameters: + - name: enabled + in: query + required: false + description: Фильтр по признаку включённости + schema: + type: boolean + - name: kind + in: query + required: false + schema: + $ref: "#/components/schemas/RuleKind" + responses: + "200": + description: Список правил + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleList" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: [routing] + operationId: createRoutingRule + summary: Создать правило маршрутизации + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleCreate" + responses: + "201": + description: Правило создано + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/rules/{rule_id}: + parameters: + - $ref: "#/components/parameters/RuleId" + get: + tags: [routing] + operationId: getRoutingRule + summary: Правило по идентификатору + responses: + "200": + description: Правило + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [routing] + operationId: updateRoutingRule + summary: Изменить правило + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRuleUpdate" + responses: + "200": + description: Обновлённое правило + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + delete: + tags: [routing] + operationId: deleteRoutingRule + summary: Удалить правило + responses: + "204": + description: Правило удалено + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/mode: + put: + tags: [routing] + operationId: putRoutingMode + summary: Переключить режим split-tunnel + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModeUpdateRequest" + responses: + "200": + description: Режим применён + content: + application/json: + schema: + $ref: "#/components/schemas/ModeUpdateResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "503": + $ref: "#/components/responses/AgentUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/geoip/update: + post: + tags: [routing] + operationId: updateGeoIp + summary: Обновить снапшот GeoIP (RIPE Stat → локальный кэш) + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/GeoIpUpdateRequest" + responses: + "200": + description: Обновление выполнено (или взято из кэша) + content: + application/json: + schema: + $ref: "#/components/schemas/GeoIpUpdateResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "503": + $ref: "#/components/responses/UpstreamUnavailable" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/routing/import: + post: + tags: [routing] + operationId: importRoutingConfig + summary: Импорт .conf / JSON (пиры и правила) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ImportRequest" + responses: + "200": + description: Результат импорта + content: + application/json: + schema: + $ref: "#/components/schemas/ImportResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/metrics/stream: + get: + tags: [metrics] + operationId: streamMetrics + summary: WebSocket-поток метрик и статуса + description: | + WebSocket-апгрейд на том же пути. Клиент передаёт токен либо заголовком + `Authorization: Bearer `, либо query-параметром `token`. + Сервер отправляет текстовые JSON-кадры схемы `WsFrame`; при `type=metrics` + поле `data` соответствует `MetricsSample`, при `type=status` — `StatusResponse`, + при `type=error` — `Error`. + parameters: + - name: token + in: query + required: false + description: Токен доступа для сред, где нельзя задать заголовок + schema: + type: string + - name: interval_ms + in: query + required: false + description: Период отправки кадров, мс + schema: + type: integer + minimum: 250 + maximum: 60000 + default: 1000 + responses: + "101": + description: Переключение протокола на WebSocket + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/devices: + get: + tags: [devices] + operationId: listDevices + summary: Устройства за шлюзом + responses: + "200": + description: Список устройств + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceList" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/logs: + get: + tags: [logs] + operationId: listLogs + summary: Журнал событий + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + - name: cursor + in: query + required: false + description: Курсор пагинации из предыдущего ответа (`next_cursor`) + schema: + type: string + - name: level + in: query + required: false + schema: + $ref: "#/components/schemas/LogLevel" + - name: q + in: query + required: false + description: Подстрока поиска по сообщению + schema: + type: string + responses: + "200": + description: Страница журнала + content: + application/json: + schema: + $ref: "#/components/schemas/LogList" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/login: + post: + tags: [auth] + operationId: login + summary: Аутентификация (сессия UI + токен API) + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuthLoginRequest" + responses: + "200": + description: Сессия создана + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationFailed" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/logout: + post: + tags: [auth] + operationId: logout + summary: Завершить сессию + responses: + "200": + description: Сессия завершена + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/auth/session: + get: + tags: [auth] + operationId: getAuthSession + summary: Текущая сессия и CSRF-токен + responses: + "200": + description: Состояние сессии + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: "Токен доступа API (заголовок `Authorization: Bearer `)" + sessionCookie: + type: apiKey + in: cookie + name: awg_session + description: Сессионная cookie UI (требуется вместе с `X-CSRF-Token` для изменяющих запросов) + + parameters: + RuleId: + name: rule_id + in: path + required: true + description: Идентификатор правила + schema: + type: string + format: uuid + + responses: + BadRequest: + description: Некорректный запрос + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Отсутствует или недействителен токен доступа + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Недостаточно прав + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Объект не найден + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Конфликт состояния (например, apply уже выполняется) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + ValidationFailed: + description: Тело запроса не соответствует схеме + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + RateLimited: + description: Превышен лимит запросов + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + UpstreamUnavailable: + description: Внешний источник (RIPE Stat) недоступен + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + AgentUnavailable: + description: Агент шлюза недоступен (unix-socket не отвечает) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalError: + description: Внутренняя ошибка + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + ErrorCode: + type: string + description: Машиночитаемый код ошибки + enum: + - bad_request + - validation_failed + - unauthorized + - forbidden + - not_found + - conflict + - rate_limited + - upstream_unavailable + - agent_unavailable + - internal_error + + Error: + type: object + description: Единый формат ошибки для всех не-2xx ответов + additionalProperties: false + required: [code, message, details] + properties: + code: + $ref: "#/components/schemas/ErrorCode" + message: + type: string + minLength: 1 + description: Человекочитаемое описание + details: + description: Машиночитаемые детали (для validation_failed — список issues) + type: [object, "null"] + additionalProperties: true + default: null + + Check: + type: object + additionalProperties: false + required: [name, ok] + properties: + name: + type: string + ok: + type: boolean + detail: + type: [string, "null"] + + HealthResponse: + type: object + additionalProperties: false + required: [status, version, uptime_s, checks] + properties: + status: + type: string + enum: [ok, degraded, down] + version: + type: string + uptime_s: + type: integer + minimum: 0 + checks: + type: array + items: + $ref: "#/components/schemas/Check" + + ReadyResponse: + type: object + additionalProperties: false + required: [ready, checks] + properties: + ready: + type: boolean + checks: + type: array + items: + $ref: "#/components/schemas/Check" + + RoutingMode: + type: string + description: Режим split-tunnel + enum: [geoip, dns, all-vpn, direct-only] + + RuleKind: + type: string + enum: [domain, cidr, geoip, asn] + + RuleAction: + type: string + enum: [vpn, direct] + + LogLevel: + type: string + enum: [debug, info, warning, error] + + TunnelState: + type: string + enum: [up, down, error] + + TunnelStatus: + type: object + additionalProperties: false + required: [interface, state, handshake_age_s, rx_bytes, tx_bytes, endpoint, last_handshake_at] + properties: + interface: + type: string + examples: [awg0] + state: + $ref: "#/components/schemas/TunnelState" + handshake_age_s: + type: [integer, "null"] + minimum: 0 + rx_bytes: + type: integer + minimum: 0 + tx_bytes: + type: integer + minimum: 0 + endpoint: + type: [string, "null"] + last_handshake_at: + type: [string, "null"] + format: date-time + + GeoIpSource: + type: string + enum: [ripe-stat, cache, none] + + GeoIpStatus: + type: object + additionalProperties: false + required: [source, prefix_count, updated_at] + properties: + source: + $ref: "#/components/schemas/GeoIpSource" + prefix_count: + type: integer + minimum: 0 + updated_at: + type: [string, "null"] + format: date-time + + StatusResponse: + type: object + additionalProperties: false + required: [version, uptime_s, mode, tunnel, geoip, kill_switch, dns_leak_protection] + properties: + version: + type: string + uptime_s: + type: integer + minimum: 0 + mode: + $ref: "#/components/schemas/RoutingMode" + tunnel: + $ref: "#/components/schemas/TunnelStatus" + geoip: + $ref: "#/components/schemas/GeoIpStatus" + kill_switch: + type: boolean + dns_leak_protection: + type: boolean + + TunnelConfig: + type: object + additionalProperties: false + required: [address, listen_port, mtu, private_key] + properties: + address: + type: string + description: Адрес интерфейса в CIDR + examples: ["10.8.0.2/32"] + listen_port: + type: integer + minimum: 1 + maximum: 65535 + mtu: + type: integer + minimum: 576 + maximum: 1500 + private_key: + type: string + writeOnly: true + description: Приватный ключ интерфейса; только запись, в ответах отсутствует + dns: + type: array + items: + type: string + + PeerConfig: + type: object + additionalProperties: false + required: [public_key, endpoint, allowed_ips] + properties: + public_key: + type: string + preshared_key: + type: string + writeOnly: true + description: Preshared key; только запись, в ответах отсутствует + endpoint: + type: string + allowed_ips: + type: array + items: + type: string + persistent_keepalive: + type: [integer, "null"] + minimum: 0 + maximum: 65535 + + DnsConfig: + type: object + additionalProperties: false + required: [mode, servers] + properties: + mode: + type: string + enum: [internal, external] + servers: + type: array + items: + type: string + + Ipv6Policy: + type: string + enum: [direct, tunnel, block] + + RoutingConfig: + type: object + additionalProperties: false + required: [mode, kill_switch, mtu_clamping, ipv6, geoip] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + kill_switch: + type: boolean + mtu_clamping: + type: boolean + ipv6: + $ref: "#/components/schemas/Ipv6Policy" + geoip: + $ref: "#/components/schemas/GeoIpStatus" + + ConfigResponse: + type: object + additionalProperties: false + required: [tunnel, peers, dns, routing, updated_at] + properties: + tunnel: + $ref: "#/components/schemas/TunnelConfig" + peers: + type: array + items: + $ref: "#/components/schemas/PeerConfig" + dns: + $ref: "#/components/schemas/DnsConfig" + routing: + $ref: "#/components/schemas/RoutingConfig" + updated_at: + type: string + format: date-time + + ConfigUpdateRequest: + type: object + description: Частичное обновление конфигурации (merge в текущее состояние) + additionalProperties: false + minProperties: 1 + properties: + tunnel: + $ref: "#/components/schemas/TunnelConfig" + peers: + type: array + items: + $ref: "#/components/schemas/PeerConfig" + dns: + $ref: "#/components/schemas/DnsConfig" + routing: + $ref: "#/components/schemas/RoutingConfig" + + ValidationIssue: + type: object + additionalProperties: false + required: [path, code, message, severity] + properties: + path: + type: string + description: JSON Pointer до проблемного поля + code: + type: string + message: + type: string + severity: + type: string + enum: [error, warning] + + ValidateResponse: + type: object + additionalProperties: false + required: [valid, errors, warnings] + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + warnings: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + + RoutingRule: + type: object + additionalProperties: false + required: [id, name, kind, value, action, enabled, priority, comment, created_at, updated_at] + properties: + id: + type: string + format: uuid + name: + type: string + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + priority: + type: integer + comment: + type: [string, "null"] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + RoutingRuleCreate: + type: object + additionalProperties: false + required: [name, kind, value, action] + properties: + name: + type: string + minLength: 1 + maxLength: 128 + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + minLength: 1 + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + default: true + priority: + type: integer + minimum: 0 + maximum: 10000 + default: 100 + comment: + type: [string, "null"] + + RoutingRuleUpdate: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: + type: string + minLength: 1 + maxLength: 128 + kind: + $ref: "#/components/schemas/RuleKind" + value: + type: string + minLength: 1 + action: + $ref: "#/components/schemas/RuleAction" + enabled: + type: boolean + priority: + type: integer + minimum: 0 + maximum: 10000 + comment: + type: [string, "null"] + + RoutingRuleList: + type: object + additionalProperties: false + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/RoutingRule" + total: + type: integer + minimum: 0 + + ModeUpdateRequest: + type: object + additionalProperties: false + required: [mode] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + + ModeUpdateResponse: + type: object + additionalProperties: false + required: [mode, applied_at, reconcile_id] + properties: + mode: + $ref: "#/components/schemas/RoutingMode" + applied_at: + type: string + format: date-time + reconcile_id: + type: string + format: uuid + + GeoIpUpdateRequest: + type: object + additionalProperties: false + properties: + force: + type: boolean + default: false + description: Игнорировать локальный кэш и обновить из RIPE Stat + + GeoIpUpdateResponse: + type: object + additionalProperties: false + required: [status, source, prefix_count, updated_at, duration_ms] + properties: + status: + type: string + enum: [updated, cached, failed] + source: + $ref: "#/components/schemas/GeoIpSource" + prefix_count: + type: integer + minimum: 0 + updated_at: + type: string + format: date-time + duration_ms: + type: integer + minimum: 0 + + ImportRequest: + type: object + additionalProperties: false + required: [format, content] + properties: + format: + type: string + enum: [amneziawg-conf, wireguard-conf, json] + content: + type: string + minLength: 1 + apply: + type: boolean + default: false + description: Применить импорт сразу, а не только вернуть разбор + + ImportResponse: + type: object + additionalProperties: false + required: [imported_peers, imported_rules, warnings] + properties: + imported_peers: + type: integer + minimum: 0 + imported_rules: + type: integer + minimum: 0 + warnings: + type: array + items: + type: string + + Device: + type: object + additionalProperties: false + required: [name, ip, mac, vpn, ipsets, last_seen_at] + properties: + name: + type: string + ip: + type: string + mac: + type: [string, "null"] + vpn: + type: boolean + ipsets: + type: array + items: + type: string + last_seen_at: + type: [string, "null"] + format: date-time + + DeviceList: + type: object + additionalProperties: false + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/Device" + total: + type: integer + minimum: 0 + + LogEntry: + type: object + additionalProperties: false + required: [ts, level, logger, message, fields] + properties: + ts: + type: string + format: date-time + level: + $ref: "#/components/schemas/LogLevel" + logger: + type: string + message: + type: string + fields: + type: object + additionalProperties: true + + LogList: + type: object + additionalProperties: false + required: [items, total, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/LogEntry" + total: + type: integer + minimum: 0 + next_cursor: + type: [string, "null"] + + AuthLoginRequest: + type: object + additionalProperties: false + required: [username, password] + properties: + username: + type: string + minLength: 1 + password: + type: string + minLength: 1 + writeOnly: true + + AuthSession: + type: object + additionalProperties: false + required: [authenticated, username, expires_at, csrf_token] + properties: + authenticated: + type: boolean + username: + type: [string, "null"] + expires_at: + type: [string, "null"] + format: date-time + csrf_token: + type: [string, "null"] + + MetricsSample: + type: object + additionalProperties: false + required: [rx_bytes, tx_bytes, rx_rate_bps, tx_rate_bps, handshake_age_s, active_peers] + properties: + rx_bytes: + type: integer + minimum: 0 + tx_bytes: + type: integer + minimum: 0 + rx_rate_bps: + type: integer + minimum: 0 + tx_rate_bps: + type: integer + minimum: 0 + handshake_age_s: + type: [integer, "null"] + minimum: 0 + active_peers: + type: integer + minimum: 0 + + WsFrame: + type: object + additionalProperties: false + required: [type, ts, data] + properties: + type: + type: string + enum: [metrics, status, error] + ts: + type: string + format: date-time + data: + description: | + MetricsSample при type=metrics, StatusResponse при type=status, + Error при type=error. + type: object + additionalProperties: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4fb2f12 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "awg-dash" +version = "0.0.1" +description = "AmneziaWG gateway with intelligent routing: REST API + Web UI + split-tunnel" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["ui", ".venv", "backend/tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "ASYNC", "S", "RUF"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"backend/tests/*" = ["S", "D"] + +[tool.mypy] +python_version = "3.12" +mypy_path = "backend" +files = ["backend"] +strict = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +pythonpath = ["backend"] +addopts = "-q --cov=awg_agent --cov=awg_api --cov-report=term-missing --cov-fail-under=70" +asyncio_mode = "auto" diff --git a/requirements-contract.txt b/requirements-contract.txt new file mode 100644 index 0000000..d5faa3e --- /dev/null +++ b/requirements-contract.txt @@ -0,0 +1,5 @@ +# Зависимости контрактных тестов API (issue #19). +# После мержа каркаса монорепо (PR #47) строки переезжают в requirements-dev.txt. +jsonschema>=4.23 +openapi-spec-validator>=0.7 +PyYAML>=6 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9b729f2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +ruff>=0.6.9 +mypy>=1.11 +pytest>=8.3 +pytest-cov>=5.0 +pytest-asyncio>=0.24 +pre-commit>=3.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd3b550 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# Runtime (контроль-плоскость) +fastapi>=0.115 +uvicorn[standard]>=0.32 +pydantic>=2.9 +pydantic-settings>=2.5 +aiosqlite>=0.20 +httpx>=0.27 +structlog>=24.4 +prometheus-client>=0.21 + +# Сетевой слой вызывается через бинари (awg, iptables, ipset, dnsmasq), +# поэтому python-зависимостей для сети не требуется. diff --git a/tests/deploy/.coveragerc b/tests/deploy/.coveragerc new file mode 100644 index 0000000..9b41183 --- /dev/null +++ b/tests/deploy/.coveragerc @@ -0,0 +1,10 @@ +# Конфиг покрытия для тестов 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 diff --git a/tests/deploy/pytest.ini b/tests/deploy/pytest.ini new file mode 100644 index 0000000..7299b6b --- /dev/null +++ b/tests/deploy/pytest.ini @@ -0,0 +1,12 @@ +# Профиль тестов 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 diff --git a/tests/deploy/test_api_stub.py b/tests/deploy/test_api_stub.py new file mode 100644 index 0000000..1d5cec3 --- /dev/null +++ b/tests/deploy/test_api_stub.py @@ -0,0 +1,157 @@ +"""Тесты 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) diff --git a/tests/deploy/test_compose_manifests.py b/tests/deploy/test_compose_manifests.py new file mode 100644 index 0000000..1ec81c8 --- /dev/null +++ b/tests/deploy/test_compose_manifests.py @@ -0,0 +1,68 @@ +"""Тесты статических инвариантов 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) diff --git a/tests/deploy/test_validate_compose.py b/tests/deploy/test_validate_compose.py new file mode 100644 index 0000000..fb04594 --- /dev/null +++ b/tests/deploy/test_validate_compose.py @@ -0,0 +1,291 @@ +"""Негативные и граничные тесты 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) diff --git a/tests/deploy/validate_compose.py b/tests/deploy/validate_compose.py new file mode 100644 index 0000000..0fa8b3c --- /dev/null +++ b/tests/deploy/validate_compose.py @@ -0,0 +1,201 @@ +#!/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()) diff --git a/tests/netns/README.md b/tests/netns/README.md new file mode 100644 index 0000000..a4642f8 --- /dev/null +++ b/tests/netns/README.md @@ -0,0 +1,133 @@ +# 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/`; режим форсируется `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` | `/artifacts` | каталог для `netns-report.json` (`ci.sh`) | + +## Pitfalls + +* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе + `run.sh` завершится кодом 3 с сообщением «заблокировано окружением». +* **`ip netns exec` и `ip -n ` в контейнере не работают** (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_exec 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`. diff --git a/tests/netns/ci.sh b/tests/netns/ci.sh new file mode 100644 index 0000000..561ca39 --- /dev/null +++ b/tests/netns/ci.sh @@ -0,0 +1,82 @@ +#!/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 каталог артефактов (по умолчанию /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" diff --git a/tests/netns/leak_sink.py b/tests/netns/leak_sink.py new file mode 100644 index 0000000..bd2b752 --- /dev/null +++ b/tests/netns/leak_sink.py @@ -0,0 +1,120 @@ +#!/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()) diff --git a/tests/netns/lib.sh b/tests/netns/lib.sh new file mode 100644 index 0000000..8063752 --- /dev/null +++ b/tests/netns/lib.sh @@ -0,0 +1,287 @@ +#!/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 ` (iproute2 6.x) перемонтируют /sys внутри +# namespace и в контейнере падают с "mount of /sys failed: Operation not +# permitted", хотя `ip netns add` работает (issue #56). nsenter входит в тот же +# /var/run/netns/ без перемонтирования — используется как 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 <команда...> — выполнить команду внутри 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 ПО СЛОВУ НА СТРОКУ. +# Нужна для фонового запуска: `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 - </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 +} diff --git a/tests/netns/mock_wg_endpoint.py b/tests/netns/mock_wg_endpoint.py new file mode 100644 index 0000000..deb1ead --- /dev/null +++ b/tests/netns/mock_wg_endpoint.py @@ -0,0 +1,210 @@ +#!/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(" 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()) diff --git a/tests/netns/netns_common.py b/tests/netns/netns_common.py new file mode 100644 index 0000000..9d3bfd9 --- /dev/null +++ b/tests/netns/netns_common.py @@ -0,0 +1,300 @@ +#!/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/ без перемонтирования. Режим кэшируется на процесс, + 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"] diff --git a/tests/netns/probe.py b/tests/netns/probe.py new file mode 100644 index 0000000..4ff0fe8 --- /dev/null +++ b/tests/netns/probe.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""probe.py — генератор тестового трафика из netns-стенда (задача #21). + +Запускается внутри конкретного netns (`ip netns exec 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(" 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("= 12 and data[0] == MSG_HANDSHAKE_RESPONSE else None + ), + "sender_index_echo": struct.unpack(" "$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" diff --git a/tests/netns/run_tests.py b/tests/netns/run_tests.py new file mode 100644 index 0000000..94bbff0 --- /dev/null +++ b/tests/netns/run_tests.py @@ -0,0 +1,69 @@ +#!/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()) diff --git a/tests/netns/test_netns_stand.py b/tests/netns/test_netns_stand.py new file mode 100644 index 0000000..0675223 --- /dev/null +++ b/tests/netns/test_netns_stand.py @@ -0,0 +1,210 @@ +"""Интеграционные тесты 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) diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh new file mode 100644 index 0000000..6c13a7c --- /dev/null +++ b/tests/netns/topology.sh @@ -0,0 +1,163 @@ +#!/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 diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..5a4d4bd --- /dev/null +++ b/ui/README.md @@ -0,0 +1,14 @@ +# ui — Web UI (React + TypeScript + Tailwind + shadcn-ui) + +Тёмная тема, 6 страниц из ТЗ. Каркас — задача **#35**, страницы — #36–#39, E2E — #42. + +| Маршрут | Страница | Задача | +|---|---|---| +| `/` | Dashboard: статус, трафик, handshake, аптайм | #36 | +| `/routing` | редактор правил, ipset, домены, режимы, DnD+diff | #37 | +| `/config` | загрузка `.conf`, валидация, маскировка ключей | #38 | +| `/devices` | устройства и политики | #39 | +| `/logs` | журнал, счётчики RETURN vs MARK | #39 | +| `/settings` | GeoIP-расписание, обновления, интеграции | #39 | + +API-клиент генерируется из OpenAPI (#19) в `src/api/generated/`. Взаимодействие: REST + WebSocket. diff --git a/ui/scripts/generate-api.sh b/ui/scripts/generate-api.sh new file mode 100644 index 0000000..7ffd2b6 --- /dev/null +++ b/ui/scripts/generate-api.sh @@ -0,0 +1,20 @@ +#!/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}" diff --git a/ui/src/api/generated/README.md b/ui/src/api/generated/README.md new file mode 100644 index 0000000..f16f70b --- /dev/null +++ b/ui/src/api/generated/README.md @@ -0,0 +1,27 @@ +# 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. diff --git a/ui/src/api/generated/schema.d.ts b/ui/src/api/generated/schema.d.ts new file mode 100644 index 0000000..dec3734 --- /dev/null +++ b/ui/src/api/generated/schema.d.ts @@ -0,0 +1,1229 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Liveness-проба */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Readiness-проба (готовность агента и зависимостей) */ + get: operations["getReady"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Метрики в формате Prometheus text exposition */ + get: operations["getPrometheusMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Сводный статус шлюза, туннеля и GeoIP */ + get: operations["getStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Текущая конфигурация (без секретов) */ + get: operations["getConfig"]; + /** Атомарное применение конфигурации (validate → staging → apply → rollback) */ + put: operations["putConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/config/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Сухая валидация конфигурации без применения */ + post: operations["validateConfig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Список правил маршрутизации */ + get: operations["listRoutingRules"]; + put?: never; + /** Создать правило маршрутизации */ + post: operations["createRoutingRule"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/rules/{rule_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + /** Правило по идентификатору */ + get: operations["getRoutingRule"]; + /** Изменить правило */ + put: operations["updateRoutingRule"]; + post?: never; + /** Удалить правило */ + delete: operations["deleteRoutingRule"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/mode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Переключить режим split-tunnel */ + put: operations["putRoutingMode"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/geoip/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Обновить снапшот GeoIP (RIPE Stat → локальный кэш) */ + post: operations["updateGeoIp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/routing/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Импорт .conf / JSON (пиры и правила) */ + post: operations["importRoutingConfig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/metrics/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * WebSocket-поток метрик и статуса + * @description WebSocket-апгрейд на том же пути. Клиент передаёт токен либо заголовком + * `Authorization: Bearer `, либо query-параметром `token`. + * Сервер отправляет текстовые JSON-кадры схемы `WsFrame`; при `type=metrics` + * поле `data` соответствует `MetricsSample`, при `type=status` — `StatusResponse`, + * при `type=error` — `Error`. + */ + get: operations["streamMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Устройства за шлюзом */ + get: operations["listDevices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Журнал событий */ + get: operations["listLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Аутентификация (сессия UI + токен API) */ + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Завершить сессию */ + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Текущая сессия и CSRF-токен */ + get: operations["getAuthSession"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description Машиночитаемый код ошибки + * @enum {string} + */ + ErrorCode: "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "upstream_unavailable" | "agent_unavailable" | "internal_error"; + /** @description Единый формат ошибки для всех не-2xx ответов */ + Error: { + code: components["schemas"]["ErrorCode"]; + /** @description Человекочитаемое описание */ + message: string; + /** + * @description Машиночитаемые детали (для validation_failed — список issues) + * @default null + */ + details: { + [key: string]: unknown; + } | null; + }; + Check: { + name: string; + ok: boolean; + detail?: string | null; + }; + HealthResponse: { + /** @enum {string} */ + status: "ok" | "degraded" | "down"; + version: string; + uptime_s: number; + checks: components["schemas"]["Check"][]; + }; + ReadyResponse: { + ready: boolean; + checks: components["schemas"]["Check"][]; + }; + /** + * @description Режим split-tunnel + * @enum {string} + */ + RoutingMode: "geoip" | "dns" | "all-vpn" | "direct-only"; + /** @enum {string} */ + RuleKind: "domain" | "cidr" | "geoip" | "asn"; + /** @enum {string} */ + RuleAction: "vpn" | "direct"; + /** @enum {string} */ + LogLevel: "debug" | "info" | "warning" | "error"; + /** @enum {string} */ + TunnelState: "up" | "down" | "error"; + TunnelStatus: { + /** @example awg0 */ + interface: string; + state: components["schemas"]["TunnelState"]; + handshake_age_s: number | null; + rx_bytes: number; + tx_bytes: number; + endpoint: string | null; + /** Format: date-time */ + last_handshake_at: string | null; + }; + /** @enum {string} */ + GeoIpSource: "ripe-stat" | "cache" | "none"; + GeoIpStatus: { + source: components["schemas"]["GeoIpSource"]; + prefix_count: number; + /** Format: date-time */ + updated_at: string | null; + }; + StatusResponse: { + version: string; + uptime_s: number; + mode: components["schemas"]["RoutingMode"]; + tunnel: components["schemas"]["TunnelStatus"]; + geoip: components["schemas"]["GeoIpStatus"]; + kill_switch: boolean; + dns_leak_protection: boolean; + }; + TunnelConfig: { + /** + * @description Адрес интерфейса в CIDR + * @example 10.8.0.2/32 + */ + address: string; + listen_port: number; + mtu: number; + /** @description Приватный ключ интерфейса; только запись, в ответах отсутствует */ + private_key: string; + dns?: string[]; + }; + PeerConfig: { + public_key: string; + /** @description Preshared key; только запись, в ответах отсутствует */ + preshared_key?: string; + endpoint: string; + allowed_ips: string[]; + persistent_keepalive?: number | null; + }; + DnsConfig: { + /** @enum {string} */ + mode: "internal" | "external"; + servers: string[]; + }; + /** @enum {string} */ + Ipv6Policy: "direct" | "tunnel" | "block"; + RoutingConfig: { + mode: components["schemas"]["RoutingMode"]; + kill_switch: boolean; + mtu_clamping: boolean; + ipv6: components["schemas"]["Ipv6Policy"]; + geoip: components["schemas"]["GeoIpStatus"]; + }; + ConfigResponse: { + tunnel: components["schemas"]["TunnelConfig"]; + peers: components["schemas"]["PeerConfig"][]; + dns: components["schemas"]["DnsConfig"]; + routing: components["schemas"]["RoutingConfig"]; + /** Format: date-time */ + updated_at: string; + }; + /** @description Частичное обновление конфигурации (merge в текущее состояние) */ + ConfigUpdateRequest: { + tunnel?: components["schemas"]["TunnelConfig"]; + peers?: components["schemas"]["PeerConfig"][]; + dns?: components["schemas"]["DnsConfig"]; + routing?: components["schemas"]["RoutingConfig"]; + }; + ValidationIssue: { + /** @description JSON Pointer до проблемного поля */ + path: string; + code: string; + message: string; + /** @enum {string} */ + severity: "error" | "warning"; + }; + ValidateResponse: { + valid: boolean; + errors: components["schemas"]["ValidationIssue"][]; + warnings: components["schemas"]["ValidationIssue"][]; + }; + RoutingRule: { + /** Format: uuid */ + id: string; + name: string; + kind: components["schemas"]["RuleKind"]; + value: string; + action: components["schemas"]["RuleAction"]; + enabled: boolean; + priority: number; + comment: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + RoutingRuleCreate: { + name: string; + kind: components["schemas"]["RuleKind"]; + value: string; + action: components["schemas"]["RuleAction"]; + /** @default true */ + enabled: boolean; + /** @default 100 */ + priority: number; + comment?: string | null; + }; + RoutingRuleUpdate: { + name?: string; + kind?: components["schemas"]["RuleKind"]; + value?: string; + action?: components["schemas"]["RuleAction"]; + enabled?: boolean; + priority?: number; + comment?: string | null; + }; + RoutingRuleList: { + items: components["schemas"]["RoutingRule"][]; + total: number; + }; + ModeUpdateRequest: { + mode: components["schemas"]["RoutingMode"]; + }; + ModeUpdateResponse: { + mode: components["schemas"]["RoutingMode"]; + /** Format: date-time */ + applied_at: string; + /** Format: uuid */ + reconcile_id: string; + }; + GeoIpUpdateRequest: { + /** + * @description Игнорировать локальный кэш и обновить из RIPE Stat + * @default false + */ + force: boolean; + }; + GeoIpUpdateResponse: { + /** @enum {string} */ + status: "updated" | "cached" | "failed"; + source: components["schemas"]["GeoIpSource"]; + prefix_count: number; + /** Format: date-time */ + updated_at: string; + duration_ms: number; + }; + ImportRequest: { + /** @enum {string} */ + format: "amneziawg-conf" | "wireguard-conf" | "json"; + content: string; + /** + * @description Применить импорт сразу, а не только вернуть разбор + * @default false + */ + apply: boolean; + }; + ImportResponse: { + imported_peers: number; + imported_rules: number; + warnings: string[]; + }; + Device: { + name: string; + ip: string; + mac: string | null; + vpn: boolean; + ipsets: string[]; + /** Format: date-time */ + last_seen_at: string | null; + }; + DeviceList: { + items: components["schemas"]["Device"][]; + total: number; + }; + LogEntry: { + /** Format: date-time */ + ts: string; + level: components["schemas"]["LogLevel"]; + logger: string; + message: string; + fields: { + [key: string]: unknown; + }; + }; + LogList: { + items: components["schemas"]["LogEntry"][]; + total: number; + next_cursor: string | null; + }; + AuthLoginRequest: { + username: string; + password: string; + }; + AuthSession: { + authenticated: boolean; + username: string | null; + /** Format: date-time */ + expires_at: string | null; + csrf_token: string | null; + }; + MetricsSample: { + rx_bytes: number; + tx_bytes: number; + rx_rate_bps: number; + tx_rate_bps: number; + handshake_age_s: number | null; + active_peers: number; + }; + WsFrame: { + /** @enum {string} */ + type: "metrics" | "status" | "error"; + /** Format: date-time */ + ts: string; + /** + * @description MetricsSample при type=metrics, StatusResponse при type=status, + * Error при type=error. + */ + data: { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Некорректный запрос */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Отсутствует или недействителен токен доступа */ + Unauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Недостаточно прав */ + Forbidden: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Объект не найден */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Конфликт состояния (например, apply уже выполняется) */ + Conflict: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Тело запроса не соответствует схеме */ + ValidationFailed: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Превышен лимит запросов */ + RateLimited: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Внешний источник (RIPE Stat) недоступен */ + UpstreamUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Агент шлюза недоступен (unix-socket не отвечает) */ + AgentUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Внутренняя ошибка */ + InternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description Идентификатор правила */ + RuleId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Процесс жив */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + getReady: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Состояние готовности (в том числе при ready=false) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadyResponse"]; + }; + }; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + getPrometheusMetrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Текстовый экспорт метрик */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + getStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Статус */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + getConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Конфигурация; секретные поля отсутствуют */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + putConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfigUpdateRequest"]; + }; + }; + responses: { + /** @description Конфигурация применена */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + validateConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfigUpdateRequest"]; + }; + }; + responses: { + /** @description Результат валидации (в том числе при valid=false) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidateResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + listRoutingRules: { + parameters: { + query?: { + /** @description Фильтр по признаку включённости */ + enabled?: boolean; + kind?: components["schemas"]["RuleKind"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Список правил */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRuleList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + createRoutingRule: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoutingRuleCreate"]; + }; + }; + responses: { + /** @description Правило создано */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + getRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Правило */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + updateRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoutingRuleUpdate"]; + }; + }; + responses: { + /** @description Обновлённое правило */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoutingRule"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + deleteRoutingRule: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Идентификатор правила */ + rule_id: components["parameters"]["RuleId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Правило удалено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + putRoutingMode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ModeUpdateRequest"]; + }; + }; + responses: { + /** @description Режим применён */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModeUpdateResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["AgentUnavailable"]; + }; + }; + updateGeoIp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["GeoIpUpdateRequest"]; + }; + }; + responses: { + /** @description Обновление выполнено (или взято из кэша) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeoIpUpdateResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["UpstreamUnavailable"]; + }; + }; + importRoutingConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImportRequest"]; + }; + }; + responses: { + /** @description Результат импорта */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + streamMetrics: { + parameters: { + query?: { + /** @description Токен доступа для сред, где нельзя задать заголовок */ + token?: string; + /** @description Период отправки кадров, мс */ + interval_ms?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Переключение протокола на WebSocket */ + 101: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + listDevices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Список устройств */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeviceList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + listLogs: { + parameters: { + query?: { + limit?: number; + /** @description Курсор пагинации из предыдущего ответа (`next_cursor`) */ + cursor?: string; + level?: components["schemas"]["LogLevel"]; + /** @description Подстрока поиска по сообщению */ + q?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Страница журнала */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogList"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 500: components["responses"]["InternalError"]; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthLoginRequest"]; + }; + }; + responses: { + /** @description Сессия создана */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 422: components["responses"]["ValidationFailed"]; + 429: components["responses"]["RateLimited"]; + 500: components["responses"]["InternalError"]; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Сессия завершена */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; + getAuthSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Состояние сессии */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalError"]; + }; + }; +}