diff --git a/.gitea/workflows/contract-openapi.yml b/.gitea/workflows/contract-openapi.yml index 6745e31..6b15806 100644 --- a/.gitea/workflows/contract-openapi.yml +++ b/.gitea/workflows/contract-openapi.yml @@ -1,6 +1,11 @@ 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: @@ -16,7 +21,17 @@ jobs: 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 @@ -42,7 +57,17 @@ jobs: 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 @@ -51,5 +76,13 @@ jobs: - name: regenerate client and compare run: | - bash ui/scripts/generate-api.sh - git diff --exit-code -- ui/src/api/generated/schema.d.ts + 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/Makefile b/Makefile index ae4f41a..55c3ea5 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,27 @@ 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-down dev-logs test-netns +.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 @@ -29,16 +48,102 @@ 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; } - docker compose -f $(COMPOSE_DEV) up -d --build + @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: - docker compose -f $(COMPOSE_DEV) down + $(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down dev-logs: - docker compose -f $(COMPOSE_DEV) logs -f --tail=100 + $(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 + 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/deploy/README.md b/deploy/README.md index 77c784a..c4254f3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,7 +6,36 @@ | `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/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 index e6139e1..a4642f8 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -1,13 +1,133 @@ # tests/netns — сетевой тестовый стенд -Задача **#21**, используется тестами #41 и #43. +Задача **#21**, используется тестами split-tunnel (#40, #41, #43). -Цель: проверять split-tunnel без реального VPN-провайдера и без доступа к продакшн-сети. +Цель: проверять маршрутизацию, policy routing, метки трафика и fail-closed +**без реального VPN-провайдера** и без доступа к продакшн-сети. -План харнесса: -1. Создать netns: `client`, `gw` (шлюз с awg-интерфейсом), `uplink` (имитация ISP). -2. Поднять мок WireGuard-endpoint (userspace) вместо реального сервера. -3. Запустить шлюз в netns с теми же правилами, что в контейнере. -4. Проверить, куда ушёл пакет: интерфейс/метка/счётчики ipset и iptables. +## Топология -Точки входа: `tests/netns/run.sh` (вызывается через `make test-netns`). +``` + 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