Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca14ae6819 | |||
| a4475113bc | |||
| a3396b2be6 | |||
| 9d2222455a | |||
| 94d3a28adf | |||
| b612a3450c | |||
| e672901741 | |||
| dbb5d1fe5c | |||
| 221d8740c9 | |||
| e6ba503c20 | |||
| 6c5d739cfc | |||
| 63e75e4b7f | |||
| 156d791020 | |||
| d63b9ceafe | |||
| edf22d7c93 | |||
| 8e76f41f63 | |||
| 8db92df8f2 | |||
| c7f2ea128e | |||
| 4aa69132ad | |||
| f0847001ee | |||
| f5e8272c3a | |||
| 7af9cabad3 | |||
| 73e23bc3e4 | |||
| 8e0d76147e | |||
| b8f715d38a | |||
| 59c3ad4918 | |||
| 7d46df5ede | |||
| da1c750297 | |||
| 87cf5fe05f | |||
| 6de43622fa | |||
| 18b911d582 | |||
| 79cbcab397 |
@@ -1,6 +1,11 @@
|
|||||||
name: contract-openapi
|
name: contract-openapi
|
||||||
# Контракт OpenAPI 3.1 (issue #19): линтеры, контрактный тест «схема ↔ реализация»
|
# Контракт OpenAPI 3.1 (issue #19): линтеры, контрактный тест «схема ↔ реализация»
|
||||||
# и проверка, что сгенерированный TS-клиент актуален относительно docs/openapi.yaml.
|
# и проверка, что сгенерированный TS-клиент актуален относительно docs/openapi.yaml.
|
||||||
|
#
|
||||||
|
# Обход дефекта Gitea git-http (issue #59): `git-upload-pack` отдаёт пустой pack
|
||||||
|
# (`bad pack header`), из-за чего actions/checkout падает и оба job'а красные на любом
|
||||||
|
# коммите. Основной путь оставлен, но при падении checkout источники забираются
|
||||||
|
# архивом `/archive/<sha>.tar.gz` — тот же приём, что в .gitea/workflows/ci.yml.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -16,7 +21,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
id: checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -42,7 +57,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
id: checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: node 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -51,5 +76,13 @@ jobs:
|
|||||||
|
|
||||||
- name: regenerate client and compare
|
- name: regenerate client and compare
|
||||||
run: |
|
run: |
|
||||||
bash ui/scripts/generate-api.sh
|
set -euo pipefail
|
||||||
git diff --exit-code -- ui/src/api/generated/schema.d.ts
|
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
|
||||||
|
|||||||
@@ -3,8 +3,27 @@ VENV ?= .venv
|
|||||||
BIN := $(VENV)/bin
|
BIN := $(VENV)/bin
|
||||||
IMAGE ?= awg-dash:dev
|
IMAGE ?= awg-dash:dev
|
||||||
COMPOSE_DEV := deploy/docker-compose.dev.yml
|
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:
|
help:
|
||||||
@grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | sed 's/:.*//' | sort -u
|
@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; }
|
@test -f deploy/Dockerfile || { echo 'deploy/Dockerfile ещё нет — задача #18'; exit 1; }
|
||||||
docker build -f deploy/Dockerfile -t $(IMAGE) .
|
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:
|
dev-up:
|
||||||
@test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; }
|
@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:
|
dev-down:
|
||||||
docker compose -f $(COMPOSE_DEV) down
|
$(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down
|
||||||
|
|
||||||
dev-logs:
|
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-netns:
|
||||||
@test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; }
|
@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/<ns>` (issue #56). Режим задаётся
|
||||||
|
# AWG_NS_EXEC_MODE=ip|nsenter|auto (make-алиас — NETNS_EXEC_MODE).
|
||||||
|
#
|
||||||
|
# ВАЖНО: make возвращает 2 на любой упавший рецепт, поэтому контракт кодов
|
||||||
|
# 0/1/3/4 через этот таргет НЕ виден (см. deploy/VERIFICATION-21.md §6.1).
|
||||||
|
# В пайплайне #17 используйте точку входа без make:
|
||||||
|
# bash tests/netns/ci.sh # те же 0/1/3/4, что отдаёт контейнер
|
||||||
|
test-netns-ci:
|
||||||
|
@test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; }
|
||||||
|
AWG_DASH_IMAGE=$(IMAGE) AWG_NS_EXEC_MODE=$(NETNS_EXEC_MODE) bash tests/netns/ci.sh $(NETNS_ARGS)
|
||||||
|
|
||||||
|
# --- статические проверки манифестов и скриптов ------------------------------
|
||||||
|
test-manifests:
|
||||||
|
$(PY) tests/deploy/validate_compose.py $(MANIFEST_ARGS)
|
||||||
|
|
||||||
|
# Тесты стенда с покрытием: гейт DoD «тесты ≥70 %» для #21/#56.
|
||||||
|
# Отдельный tests/deploy/pytest.ini обязателен: без него `pytest tests/deploy`
|
||||||
|
# наследует addopts корневого pyproject.toml (--cov=awg_agent --cov=awg_api),
|
||||||
|
# покрытие выходит 0.00 % и RC=1 — гейт приходилось гонять с --no-cov.
|
||||||
|
test-deploy:
|
||||||
|
@test -d tests/deploy || { echo 'tests/deploy ещё нет — задача #21'; exit 1; }
|
||||||
|
$(PY) -m pytest tests/deploy -c tests/deploy/pytest.ini
|
||||||
|
|
||||||
|
lint-shell:
|
||||||
|
@command -v shellcheck >/dev/null 2>&1 || { echo 'shellcheck не установлен (apt-get install shellcheck)'; exit 1; }
|
||||||
|
shellcheck -x -S style tests/netns/*.sh deploy/*.sh
|
||||||
|
|||||||
@@ -6,7 +6,36 @@
|
|||||||
| `entrypoint.sh` | супервизор: `awg-agent` (root) + `awg-api` | #18 |
|
| `entrypoint.sh` | супервизор: `awg-agent` (root) + `awg-api` | #18 |
|
||||||
| `docker-compose.dev.yml` | dev-стенд: порт 8080, тома `/config` `/data`, healthcheck | #21 |
|
| `docker-compose.dev.yml` | dev-стенд: порт 8080, тома `/config` `/data`, healthcheck | #21 |
|
||||||
| `docker-compose.test.yml` | стенд для тестов | #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 |
|
| `grafana/` | дашборд JSON (опциональный профиль monitoring) | #46 |
|
||||||
| `awg0.conf.example` | пример клиентского конфига | #22 |
|
| `awg0.conf.example` | пример клиентского конфига | #22 |
|
||||||
|
|
||||||
Запуск по ТЗ (после релиза): `--network=host`, `--device=/dev/net/tun`, `--cap-add=NET_ADMIN --cap-add=SYS_MODULE`, `--restart always`.
|
Запуск по ТЗ (после релиза): `--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).
|
||||||
|
|||||||
@@ -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 <ns>` | rc=0 |
|
||||||
|
| `ip netns exec <ns> true` | `mount of /sys failed: Operation not permitted`, rc=255 |
|
||||||
|
| `ip -n <ns> link set lo up` | `mount of /sys failed: Operation not permitted`, rc=255 |
|
||||||
|
| `nsenter --net=/var/run/netns/<ns> true` | rc=0 |
|
||||||
|
|
||||||
|
`ip netns exec` и `ip -n` перемонтируют `/sys` внутри namespace; в контейнере это падает.
|
||||||
|
Поэтому харнесс больше не использует эти формы: вход в namespace идёт через `ns_exec()` /
|
||||||
|
`ns_prefix()` (`tests/netns/lib.sh`) и `netns_common.ns_sh()`, которые один раз пробуют
|
||||||
|
`ip netns exec`, а при отказе переключаются на `nsenter --net=...` (`AWG_NS_EXEC_MODE=auto|ip|nsenter`).
|
||||||
|
Все `ip -n "$NS"` заменены на `ns_exec "$NS" ip ...`.
|
||||||
|
|
||||||
|
Коды возврата приведены к контракту `0/1/3/4` (`run.sh`, `topology.sh`): неожиданный код
|
||||||
|
утилиты (255) нормализуется в `4` («ошибка стенда»), нет прав — `3`, упавшие тесты — `1`.
|
||||||
|
|
||||||
|
**Контракт действует на уровне контейнера, а не через make.** GNU make возвращает `2`
|
||||||
|
на любой упавший рецепт, поэтому `make test-netns-ci` при коде контейнера `3` или `4`
|
||||||
|
сам отдаёт `2` (в выводе остаётся только `make: *** [Makefile:…] Error 4`). Для CI #17
|
||||||
|
добавлена точка входа без make — `bash tests/netns/ci.sh`: она запускает тот же контейнер
|
||||||
|
(`docker compose … run --rm -T netns`) и возвращает его код как есть. Проверено в §7.2.
|
||||||
|
|
||||||
|
### 6.2 Реальные прогоны
|
||||||
|
|
||||||
|
| прогон | команда | результат |
|
||||||
|
|---|---|---|
|
||||||
|
| стенд на хосте | `make test-netns` | exit 0, 8 ok / 0 fail, 5.46 s, `ip netns list` пуст, мок и sink не остались в процессах |
|
||||||
|
| стенд в контейнере (путь CI) | `make test-netns-ci` | exit 0, `[netns] вход в netns: nsenter`, backend `iptables`, 8 ok / 0 fail, 5.50 s, `[ ok ] топология снесена`, отчёт `/artifacts/netns-report.json` |
|
||||||
|
| нет прав | `docker run` без `--privileged` | exit **3**, `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN` |
|
||||||
|
| форс нерабочего режима | `AWG_NS_EXEC_MODE=ip` в контейнере | exit **4**, `mount of /sys failed` → `[warn] run.sh: непредвиденный код 255 — трактую как ошибку стенда (4)` |
|
||||||
|
| упавшие тесты | синтетический проваленный тест в контейнере | exit **1**, `[warn] netns-стенд: тесты/очистка упали (код 1)` |
|
||||||
|
| гейт покрытия | `make test-deploy` | exit 0, 51 passed, coverage **98.86 %** (`deploy/api_stub.py` 98 %, `tests/deploy/validate_compose.py` 99 %) |
|
||||||
|
| манифесты / shellcheck | `make test-manifests`, `make lint-shell` | exit 0 / exit 0 |
|
||||||
|
| стабильность в контейнере | 5 прогонов `make test-netns-ci` подряд | 5/5 exit 0, 8 ok каждый, ни одного «остался netns» |
|
||||||
|
| AC-1 после правок | `curl :8180/api/v1/health` | HTTP 200, `"stub": true`, контейнер `healthy` |
|
||||||
|
| AC-2 после правок | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | exit 0: маркеры `/config` и `/data` пережили restart и пересоздание, видны на хосте (bind mount) |
|
||||||
|
|
||||||
|
### 6.3 Что изменено для гейта покрытия
|
||||||
|
|
||||||
|
* `tests/deploy/pytest.ini` — отдельный профиль: `--cov=deploy --cov=tests/deploy
|
||||||
|
--cov-config=tests/deploy/.coveragerc --cov-fail-under=70`. Раньше `pytest tests/deploy`
|
||||||
|
наследовал addopts корневого `pyproject.toml` (`--cov=awg_agent --cov=awg_api`), поэтому
|
||||||
|
покрытие всегда было 0.00 % и RC=1 — гейт гоняли с `--no-cov` в обход порога.
|
||||||
|
* `tests/deploy/.coveragerc` — из покрытия исключены сами тестовые модули
|
||||||
|
(`tests/deploy/test_*.py`), иначе «покрытие» измеряет тестовый код.
|
||||||
|
* `tests/deploy/test_api_stub.py` — 9 тестов на живой HTTP-сервер заглушки: 200 и
|
||||||
|
`"stub": true` на `/api/v1/health`, алиас `/healthz`, игнор query-string, 404
|
||||||
|
`not_implemented` на прочие пути, HEAD, отказ на POST, разбор аргументов `main()`.
|
||||||
|
* `tests/deploy/test_validate_compose.py` — 39 негативных/граничных тестов валидатора:
|
||||||
|
каждая инварианта AC #21 ломается по отдельности (порт, тома, healthcheck, NET_ADMIN,
|
||||||
|
`/dev/net/tun`, `ip_forward`, privileged netns-сервис, DEV-ONLY-метка, `--require-dockerfile`).
|
||||||
|
* `deploy/api_stub.py` — `make_server()` выделен из `main()` (тесты поднимают сервер на порту 0).
|
||||||
|
* `make test-deploy` — гейт покрытия. Утверждение раунда 2 «`make test-netns-ci` прокидывает
|
||||||
|
`AWG_NS_EXEC_MODE`» было **неверным**: make-алиас `NETNS_EXEC_MODE ?= auto` перекрывал
|
||||||
|
переменную. Исправлено в §7.1 (проверено прогоном в §7.2).
|
||||||
|
* `topology.sh`: снос топологии удаляет namespace'ы безусловно и до 5 раз проверяет
|
||||||
|
результат — в контейнере `ip netns delete` иногда «терялся» и `run.sh` отдавал код 4
|
||||||
|
на зелёных тестах (флак 1 из 5 прогонов, после правки — 5/5 зелёных).
|
||||||
|
|
||||||
|
### 6.4 Что закрыто и что осталось
|
||||||
|
|
||||||
|
Закрыты оба блокера раунда 1 (`make test-netns-ci` работает в контейнере, покрытие
|
||||||
|
измеряется и ≥70 %). `CI green` по-прежнему недостижим: Gitea Actions runner не
|
||||||
|
зарегистрирован (#17), все прогоны в таблице 6.2 — ручные на `192.168.1.147`; команды
|
||||||
|
`make test-netns-ci` и `make test-deploy` — ровно те, что должны попасть в workflow #17.
|
||||||
|
|
||||||
|
Порт и stub-оверрайд (см. §4) без изменений: `privileged: true` и `cap_add SYS_MODULE`
|
||||||
|
из `deploy/docker-compose.test.yml` — только DEV/TEST, для staging/prod непригодны.
|
||||||
|
|
||||||
|
## 7. Раунд 3 (ревью PR #53, раунд 2): контракт кодов, env-алиас, сборка без сети
|
||||||
|
|
||||||
|
Дата: 2026-10-28 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17)
|
||||||
|
Ветка: `feat/21-dev-stand` · Ref: #21, #15, #17, #56
|
||||||
|
|
||||||
|
Ревью qa-engineer (раунд 2, PR #53 `issuecomment-664`) дало три воспроизводимых замечания:
|
||||||
|
контракт `0/1/3/4` теряется на уровне make, документированный `AWG_NS_EXEC_MODE` перекрыт
|
||||||
|
make-алиасом, `make dev-up` падает при сборке без DNS. Все три закрыты правками ниже.
|
||||||
|
|
||||||
|
### 7.1 Что изменено
|
||||||
|
|
||||||
|
| замечание | правка |
|
||||||
|
|---|---|
|
||||||
|
| `make test-netns-ci` отдаёт `2` на любом провале → CI #17 не отличит 3/4/1 | новая точка входа `tests/netns/ci.sh`: `docker compose -f deploy/docker-compose.test.yml run --rm -T netns`, код контейнера возвращается как есть (`2` у неё = сломан вызов/нет docker/образа). `make test-netns-ci` делегирует в неё, в комментарии таргета и в §6.1 зафиксировано «make → 2» |
|
||||||
|
| `AWG_NS_EXEC_MODE=ip make test-netns-ci` игнорировался (`Makefile:19` `NETNS_EXEC_MODE ?= auto` + `:117`) | `NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto)` — документированное имя имеет приоритет, make-алиас остался для совместимости; README и `dev-stand.md` приведены к факту |
|
||||||
|
| `make dev-up` форсит `--build` и падает без DNS | `DEV_SKIP_BUILD=1` → `up -d --no-build`; `DEV_UP_BUILD` переопределяем (`?=`) и по-прежнему принимается из командной строки (`DEV_UP_BUILD=`); строка в `dev-stand.md` (быстрый старт + pitfalls + таблица переменных) |
|
||||||
|
|
||||||
|
### 7.2 Реальные прогоны (дерево сверено по git-blob-SHA, см. §7.3)
|
||||||
|
|
||||||
|
| прогон | команда | код | что видно в выводе |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CI-точка входа, зелёный | `bash tests/netns/ci.sh` | **0** | `[ci] образ=awg-dash:dev режим netns=auto`, `вход в netns: nsenter`, `Ran 8 tests`, `8 ok`, `[ ok ] топология снесена`, `[ci][ ok ]` |
|
||||||
|
| make-путь, зелёный | `make test-netns-ci` | **0** | те же 8 ok через `ci.sh` |
|
||||||
|
| код 4 (форс нерабочего режима) | `AWG_NS_EXEC_MODE=ip bash tests/netns/ci.sh` | **4** | `[ci] … режим netns=ip`, `mount of /sys failed` → `[warn] код 255 → 4` |
|
||||||
|
| код 3 (нет прав на netns) | `AWG_NS_PRIVILEGED=0 bash tests/netns/ci.sh` | **3** | `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN`, `[ci][blocked]` |
|
||||||
|
| код 1 (упавшие тесты) | синтетический падающий тест в `tests/netns/` → `bash tests/netns/ci.sh` | **1** | `Ran 9 tests`, `FAILED (failures=1)`, `8 ok, 1 fail` (тест удалён после прогона) |
|
||||||
|
| контракт make при коде 4 | `AWG_NS_EXEC_MODE=ip make test-netns-ci` | **2** (контейнер 4) | `[ci] … режим netns=ip` — **env больше не перекрывается**; `make: *** [Makefile:133: test-netns-ci] Error 4` |
|
||||||
|
| контракт make при коде 1 | синтетический падающий тест → `make test-netns-ci` | **2** (контейнер 1) | `make: *** [Makefile:133: test-netns-ci] Error 1` |
|
||||||
|
| AC-1 | `make dev-up DEV_SKIP_BUILD=1` + `curl :8180/api/v1/health` | **0**, HTTP **200** | `up -d --no-build`, контейнер `Up (healthy)`, ответ `"stub": true` |
|
||||||
|
| AC-1 со сборкой (кеш тёплый) | `make dev-up` (без `DEV_SKIP_BUILD`) | **0** | `222` шагов `CACHED`, `Image awg-dash:dev Built`, контейнер пересоздан и `healthy` — на холодном кеше этот путь требует DNS, поэтому кноб нужен |
|
||||||
|
| AC-2 | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | **0** | маркеры `/config` и `/data` пережили `restart` и `--force-recreate`, видны на хосте (bind mount) |
|
||||||
|
| гейт покрытия | `make test-deploy` | **0** | `51 passed`, `Required test coverage of 70% reached. Total coverage: 98.86%` (`TOTAL 176 строк, 2 miss`) |
|
||||||
|
| манифесты | `make test-manifests` | **0** | `validate-compose: ok (1 предупреждений)` — предупреждение про `deploy/Dockerfile` (до мержа #18) |
|
||||||
|
| shellcheck | `make lint-shell` | **0** | проверен и новый `tests/netns/ci.sh` |
|
||||||
|
| уборка | `ip netns list`; `docker ps -a --filter name=awg-dash-test` | — | пусто, контейнеров после прогонов не осталось |
|
||||||
|
|
||||||
|
Логи прогонов на стенд-хосте: `/tmp/r3_a.log` … `/tmp/r3_m.log`
|
||||||
|
(`a` — зелёный `ci.sh`, `b` — код 4, `c` — код 3, `d` — make при коде 4, `e`/`f` — код 1 через `ci.sh`/make,
|
||||||
|
`g` — `make test-netns-ci`, `h` — `dev-up DEV_SKIP_BUILD=1` + health, `i` — AC-2, `j` — `test-deploy`,
|
||||||
|
`k` — `test-manifests`, `l` — `lint-shell`, `m` — `dev-up` со сборкой).
|
||||||
|
|
||||||
|
### 7.3 Блобы: файлы на стенд-хосте == дерево ветки
|
||||||
|
|
||||||
|
| файл | git blob |
|
||||||
|
|---|---|
|
||||||
|
| `Makefile` | `55c3ea5c98c7980c9bc3fcec8483d1140e670b2e` |
|
||||||
|
| `tests/netns/ci.sh` | `561ca3976ad3749fbde79b8d163e007bace145de` |
|
||||||
|
| `tests/netns/README.md` | `a4642f845f38fca1da0019af7e91d95be9e4d97c` |
|
||||||
|
| `deploy/dev-stand.md` | `60495f7cd8eba10b40dc9aef469bea210b50e8e1` |
|
||||||
|
| `deploy/VERIFICATION-21.md` | этот файл (блоб меняется этим же коммитом) |
|
||||||
|
| остальные файлы стенда | без изменений с раунда 2 (§0) |
|
||||||
|
|
||||||
|
### 7.4 Что осталось
|
||||||
|
|
||||||
|
* **CI green (DoD)** — за задачей #17: у репозитория появился runner (контейнер
|
||||||
|
`gitea-runner-awg-repo`, запуски runs 22–31), но workflow, покрывающего ветку #21, в `main`
|
||||||
|
ещё нет, поэтому все гейты выше прогнаны вручную. Команды для workflow:
|
||||||
|
`bash tests/netns/ci.sh`, `make test-deploy`, `make test-manifests`, `make lint-shell` —
|
||||||
|
они уже отдают нужные коды без make-обёртки.
|
||||||
|
* Merge в `main` и любой prod/staging-деплой — за TechLead (ADVISORY): автор ревью не мержит.
|
||||||
|
* `verify-dev-stand.sh` после `--force-recreate` печатает статус до перехода в `healthy`,
|
||||||
|
но AC-проверки при этом ждут health (`wait_health 20`) и прогон возвращает `0` — на вердикт
|
||||||
|
не влияет.
|
||||||
@@ -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())
|
||||||
@@ -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`).
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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:
|
||||||
@@ -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://<host>:<port>/api/v1/health` -> 200;
|
||||||
|
# AC-2: правки в /config и /data переживают `docker compose restart`
|
||||||
|
# (и дополнительно пересоздание контейнера);
|
||||||
|
# пласт: монтирования /config и /data указывают на ожидаемый каталог хоста,
|
||||||
|
# контейнер healthy, образ и compose-файлы на месте.
|
||||||
|
#
|
||||||
|
# Коды возврата:
|
||||||
|
# 0 — AC-1 и AC-2 выполнены;
|
||||||
|
# 1 — AC нарушены (регрессия стенда);
|
||||||
|
# 2 — AC-1 заблокирован: API ещё не реализован (#31), стенд при этом поднят;
|
||||||
|
# 3 — заблокировано окружением (нет docker/compose/файлов).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_DEV="$ROOT/deploy/docker-compose.dev.yml"
|
||||||
|
COMPOSE_STUB="$ROOT/deploy/docker-compose.stub.yml"
|
||||||
|
|
||||||
|
STUB=0
|
||||||
|
DO_UP=1
|
||||||
|
DO_BUILD=1
|
||||||
|
KEEP_STAND=0
|
||||||
|
HOST="127.0.0.1"
|
||||||
|
PORT="${AWG_DASH_PORT:-8180}"
|
||||||
|
HOST_DIR="${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}"
|
||||||
|
JSON_OUT=""
|
||||||
|
HEALTH_PATH="/api/v1/health"
|
||||||
|
|
||||||
|
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--stub) STUB=1 ;;
|
||||||
|
--no-up) DO_UP=0 ;;
|
||||||
|
--skip-build) DO_BUILD=0 ;;
|
||||||
|
--keep) KEEP_STAND=1 ;;
|
||||||
|
--host) HOST="${2:?}"; shift ;;
|
||||||
|
--port) PORT="${2:?}"; shift ;;
|
||||||
|
--host-dir) HOST_DIR="${2:?}"; shift ;;
|
||||||
|
--compose) COMPOSE_DEV="${2:?}"; shift ;;
|
||||||
|
--json) JSON_OUT="${2:?}"; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "неизвестная опция: $1" >&2; exit 3 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
fatal_env() { printf '[verify][blocked] %s\n' "$*" >&2; exit 3; }
|
||||||
|
say() { printf '[verify] %s\n' "$*"; }
|
||||||
|
ok() { printf '[verify][ ok ] %s\n' "$*"; }
|
||||||
|
bad() { printf '[verify][fail] %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
# --- предусловия -------------------------------------------------------------
|
||||||
|
command -v docker >/dev/null 2>&1 || fatal_env "нет docker в PATH"
|
||||||
|
docker compose version >/dev/null 2>&1 || fatal_env "нет плагина 'docker compose'"
|
||||||
|
[ -f "$COMPOSE_DEV" ] || fatal_env "нет файла $COMPOSE_DEV"
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(-f "$COMPOSE_DEV")
|
||||||
|
if [ "$STUB" -eq 1 ]; then
|
||||||
|
[ -f "$COMPOSE_STUB" ] || fatal_env "нет файла $COMPOSE_STUB"
|
||||||
|
COMPOSE_ARGS+=(-f "$COMPOSE_STUB")
|
||||||
|
fi
|
||||||
|
compose() { docker compose "${COMPOSE_ARGS[@]}" "$@"; }
|
||||||
|
|
||||||
|
AC1="not_run"
|
||||||
|
AC2="not_run"
|
||||||
|
API_MODE="unknown"
|
||||||
|
NOTES=()
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
# --- поднятие стенда ---------------------------------------------------------
|
||||||
|
if [ "$DO_UP" -eq 1 ]; then
|
||||||
|
UP_ARGS=(up -d)
|
||||||
|
[ "$DO_BUILD" -eq 1 ] && UP_ARGS+=(--build)
|
||||||
|
say "docker compose ${COMPOSE_ARGS[*]} ${UP_ARGS[*]}"
|
||||||
|
compose "${UP_ARGS[@]}" || fatal_env "не удалось поднять dev-стенд (см. вывод docker compose)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose ps >/dev/null 2>&1 || fatal_env "compose project не поднят"
|
||||||
|
cid() { compose ps -q awg-dash | head -n 1; }
|
||||||
|
CID="$(cid)"
|
||||||
|
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project"
|
||||||
|
|
||||||
|
wait_health() {
|
||||||
|
local tries="${1:-30}" code i
|
||||||
|
for ((i = 1; i <= tries; i++)); do
|
||||||
|
code="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
||||||
|
[ "$code" = "200" ] && return 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- AC-1: health 200 --------------------------------------------------------
|
||||||
|
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
||||||
|
if wait_health 20; then
|
||||||
|
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
|
||||||
|
if printf '%s' "$BODY" | grep -q '"stub": *true'; then
|
||||||
|
API_MODE="stub"
|
||||||
|
NOTES+=("health отвечает заглушка (#31): реальный API ещё не смёржен")
|
||||||
|
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=stub)"
|
||||||
|
else
|
||||||
|
API_MODE="real"
|
||||||
|
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=real)"
|
||||||
|
fi
|
||||||
|
AC1="pass"
|
||||||
|
else
|
||||||
|
CODE="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || echo "000")"
|
||||||
|
say "health-check вернул ${CODE}; последние логи контейнера:"
|
||||||
|
compose logs --tail=15 awg-dash || true
|
||||||
|
if [ "$STUB" -eq 0 ]; then
|
||||||
|
AC1="blocked"
|
||||||
|
NOTES+=("AC-1 заблокирован: FastAPI-каркас (#31) ещё не реализован — /api/v1/health не отвечает")
|
||||||
|
printf '[verify][blocked] AC-1 требует #31 (см. deploy/dev-stand.md)\n' >&2
|
||||||
|
else
|
||||||
|
AC1="fail"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
bad "AC-1: даже с заглушкой health не 200 (код ${CODE})"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- AC-2: состояние переживает restart --------------------------------------
|
||||||
|
if [ "$AC1" = "blocked" ]; then
|
||||||
|
NOTES+=("AC-2 проверяем без API: маркерные файлы в /config и /data")
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(date +%s)"
|
||||||
|
MARKER_TEXT="awg-dash-ac2-${STAMP}"
|
||||||
|
say "AC-2: пишу маркеры в /config и /data и перезапускаю контейнер"
|
||||||
|
if compose exec -T awg-dash sh -c "printf '%s\n' '$MARKER_TEXT' > /config/.ac2-marker && printf '%s\n' '$MARKER_TEXT' > /data/.ac2-marker"; then
|
||||||
|
# 1) restart (как в AC)
|
||||||
|
compose restart awg-dash >/dev/null
|
||||||
|
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после restart health не поднялся за 40с"); fi
|
||||||
|
CFG_AFTER="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
||||||
|
DATA_AFTER="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
||||||
|
|
||||||
|
# 2) пересоздание контейнера (сильнее, чем restart)
|
||||||
|
compose up -d --force-recreate >/dev/null
|
||||||
|
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после пересоздания health не поднялся за 40с"); fi
|
||||||
|
# контейнер пересоздан — id сменился; без перезапроса docker inspect падает с 'no such object'
|
||||||
|
CID="$(cid)"
|
||||||
|
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден после пересоздания"
|
||||||
|
CFG_RECREATE="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
||||||
|
DATA_RECREATE="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
|
||||||
|
|
||||||
|
HOST_CFG="$(tr -d '\r\n' < "${HOST_DIR}/config/.ac2-marker" 2>/dev/null || true)"
|
||||||
|
HOST_DATA="$(tr -d '\r\n' < "${HOST_DIR}/data/.ac2-marker" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ "$CFG_AFTER" = "$MARKER_TEXT" ] && [ "$DATA_AFTER" = "$MARKER_TEXT" ] \
|
||||||
|
&& [ "$CFG_RECREATE" = "$MARKER_TEXT" ] && [ "$DATA_RECREATE" = "$MARKER_TEXT" ]; then
|
||||||
|
AC2="pass"
|
||||||
|
ok "AC-2: /config и /data пережили restart и пересоздание контейнера"
|
||||||
|
if [ "$HOST_CFG" = "$MARKER_TEXT" ] && [ "$HOST_DATA" = "$MARKER_TEXT" ]; then
|
||||||
|
ok "AC-2: маркеры видны на хосте (${HOST_DIR}/{config,data}) — это bind mount, а не слой контейнера"
|
||||||
|
else
|
||||||
|
NOTES+=("маркеры не видны в ${HOST_DIR} — проверьте, что это bind mount, а не volume")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
AC2="fail"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
bad "AC-2: маркеры не пережили restart/пересоздание (cfg=$CFG_AFTER data=$DATA_AFTER recreate=$CFG_RECREATE/$DATA_RECREATE)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
AC2="fail"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
bad "AC-2: не удалось записать маркеры в /config и /data (exec в контейнер не работает)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- монтирования и здоровье -------------------------------------------------
|
||||||
|
CID="$(cid)"
|
||||||
|
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project (после перезапуска)"
|
||||||
|
MOUNTS="$(docker inspect -f '{{range .Mounts}}{{.Destination}}<-{{.Source}}{{"\n"}}{{end}}' "$CID" 2>/dev/null || true)"
|
||||||
|
printf '%s\n' "$MOUNTS" | grep -q '/config' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /config"; }
|
||||||
|
printf '%s\n' "$MOUNTS" | grep -q '/data' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /data"; }
|
||||||
|
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
|
say "контейнер: $(docker inspect -f '{{.Name}} {{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")"
|
||||||
|
say "монтирования:"; printf '%s\n' "$MOUNTS" | sed 's/^/ /'
|
||||||
|
say "образ: $(docker inspect -f '{{.Config.Image}}' "$CID")"
|
||||||
|
|
||||||
|
if [ "$JSON_OUT" != "" ]; then
|
||||||
|
mkdir -p "$(dirname "$JSON_OUT")"
|
||||||
|
cat > "$JSON_OUT" <<EOF
|
||||||
|
{
|
||||||
|
"task": "#21",
|
||||||
|
"host": "$(hostname)",
|
||||||
|
"container": "$CID",
|
||||||
|
"image": "$(docker inspect -f '{{.Config.Image}}' "$CID")",
|
||||||
|
"api_mode": "$API_MODE",
|
||||||
|
"ac1_health_200": "$AC1",
|
||||||
|
"ac2_state_persists_restart": "$AC2",
|
||||||
|
"container_health": "$HEALTH_STATUS",
|
||||||
|
"host_dir": "${HOST_DIR}",
|
||||||
|
"host_port": "${PORT}",
|
||||||
|
"failures": ${FAILURES},
|
||||||
|
"notes": [$(printf '"%s",' "${NOTES[@]:-}" 2>/dev/null | sed 's/,$//')]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
ok "отчёт: ${JSON_OUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$KEEP_STAND" -eq 0 ]; then
|
||||||
|
say "стенд оставлен запущенным (снять: docker compose ${COMPOSE_ARGS[*]} down)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FAILURES" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$AC1" = "blocked" ] && exit 2
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
+128
-8
@@ -1,13 +1,133 @@
|
|||||||
# tests/netns — сетевой тестовый стенд
|
# 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/<ns>`; режим форсируется `AWG_NS_EXEC_MODE`
|
||||||
|
(`auto|ip|nsenter`). Тот же режим через make — `NETNS_EXEC_MODE` (алиас;
|
||||||
|
`AWG_NS_EXEC_MODE` имеет приоритет), например:
|
||||||
|
`AWG_NS_EXEC_MODE=ip make test-netns-ci`.
|
||||||
|
|
||||||
|
## Состав
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `run.sh` | точка входа: preflight → топология → мок → тесты → снос |
|
||||||
|
| `ci.sh` | точка входа для CI #17: тот же прогон в контейнере без make, коды `0/1/3/4` доходят до пайплайна |
|
||||||
|
| `lib.sh` | конфигурация стенда + правила netfilter (nft/iptables) |
|
||||||
|
| `topology.sh` | `up`/`down`/`status`/`killswitch on|off` |
|
||||||
|
| `mock_wg_endpoint.py` | мок WireGuard-endpoint: отвечает на handshake initiation |
|
||||||
|
| `leak_sink.py` | детектор утечки: UDP+TCP `198.51.100.9:9999` |
|
||||||
|
| `probe.py` | генератор тестового трафика из указанного netns |
|
||||||
|
| `netns_common.py` | утилиты для тестов (ns_exec, счётчики правил, JSONL-логи) |
|
||||||
|
| `run_tests.py` | прогон unittest + JSON-отчёт |
|
||||||
|
| `test_netns_stand.py` | 8 интеграционных тестов стенда |
|
||||||
|
|
||||||
|
## Что проверяют тесты (`test_netns_stand.py`)
|
||||||
|
|
||||||
|
1. `test_01` — netns и veth-пары созданы, адреса выданы, `ip_forward=1`.
|
||||||
|
2. `test_02` — policy routing: `ip rule fwmark 51820` → `table 51820` → аплинк.
|
||||||
|
3. `test_03` — правила меток (OUTPUT/FORWARD) и DROP kill-switch на месте.
|
||||||
|
4. `test_04` — мок-endpoint и детектор утечки слушают и залогировали `listen`.
|
||||||
|
5. `test_05` — handshake initiation из LAN доходит до мока, мок отвечает
|
||||||
|
(148 → 92 байта), счётчик правила метки растёт.
|
||||||
|
6. `test_06` — то же для трафика, инициированного самим шлюзом (OUTPUT-метка).
|
||||||
|
7. `test_07` — режим `drop` мока имитирует недоступность провайдера: ответа нет,
|
||||||
|
пакеты фиксируются; возврат в `respond` восстанавливает стенд.
|
||||||
|
8. `test_08` — kill-switch: при выключенном правиле трафик доходит до детектора
|
||||||
|
(положительный контроль), при включённом — утечки нет.
|
||||||
|
|
||||||
|
## Параметры (переменные окружения)
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `AWG_NETNS_PREFIX` | `awgt21` | префикс имён namespace/veth |
|
||||||
|
| `AWG_NS_CLIENT` / `AWG_NS_GW` / `AWG_NS_UPLINK` | `awgt21-*` | имена namespace |
|
||||||
|
| `AWG_MOCK_PORT` | `51820` | порт мока WireGuard-endpoint |
|
||||||
|
| `AWG_LEAK_IP` / `AWG_LEAK_PORT` | `198.51.100.9` / `9999` | адрес и порт детектора утечки |
|
||||||
|
| `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing |
|
||||||
|
| `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение |
|
||||||
|
| `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда |
|
||||||
|
| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56; make-алиас — `NETNS_EXEC_MODE` |
|
||||||
|
| `AWG_NS_PRIVILEGED` | `1` | только для `ci.sh`: `0` — прогон без `privileged` (проверка ветки «код 3») |
|
||||||
|
| `AWG_DASH_TEST_ARTIFACTS` | `<repo>/artifacts` | каталог для `netns-report.json` (`ci.sh`) |
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе
|
||||||
|
`run.sh` завершится кодом 3 с сообщением «заблокировано окружением».
|
||||||
|
* **`ip netns exec` и `ip -n <ns>` в контейнере не работают** (iproute2 6.x
|
||||||
|
перемонтирует `/sys`: `mount of /sys failed: Operation not permitted`, rc=255),
|
||||||
|
хотя `ip netns add` и `nsenter --net=...` работают. Поэтому в скриптах только
|
||||||
|
`ns_exec`/`ns_prefix` (`lib.sh`) и `ns_exec_mode()` (`netns_common.py`), а все
|
||||||
|
`ip -n <ns>` заменены на `ns_exec <ns> ip ...` (issue #56). Форсировать режим:
|
||||||
|
`AWG_NS_EXEC_MODE=nsenter|ip`.
|
||||||
|
* **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт
|
||||||
|
асимметрию reverse-пути, strict-режим молча дропает ответы.
|
||||||
|
* **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard
|
||||||
|
(тип и длины), но не выполняет Noise IK handshake. Годится для топологии,
|
||||||
|
меток, policy routing и fail-closed — **не** для проверки шифрования, MTU и
|
||||||
|
пропускной способности.
|
||||||
|
* **`nft` в alpine-образе нет**: в контейнере используется `iptables`
|
||||||
|
(nft-backend). Стенд поддерживает оба backend; при отладке форсируйте
|
||||||
|
`AWG_FW_BACKEND=iptables`.
|
||||||
|
* **`ping` в образе нет** (пакет `iputils` не установлен) — тесты не используют
|
||||||
|
ICMP: связность проверяется UDP-трафиком через мок и детектор утечки.
|
||||||
|
* **Остатки после падения**: `bash tests/netns/topology.sh down` и `ip netns list`.
|
||||||
|
|||||||
@@ -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 каталог артефактов (по умолчанию <repo>/artifacts)
|
||||||
|
# COMPOSE_PROJECT_NAME имя compose-проекта (по умолчанию awg-dash-test)
|
||||||
|
#
|
||||||
|
# Коды возврата: 0/1/3/4 — как у run.sh; 2 — ошибка вызова или окружения
|
||||||
|
# (нет docker/compose/образа, неизвестный аргумент). Ветку 2 CI не должен
|
||||||
|
# путать с 3: 2 — сломан сам пайплайн/раннер, 3 — раннер без прав на netns.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
COMPOSE_FILE="$ROOT/deploy/docker-compose.test.yml"
|
||||||
|
IMAGE="${AWG_DASH_IMAGE:-awg-dash:dev}"
|
||||||
|
ARTIFACTS="${AWG_DASH_TEST_ARTIFACTS:-$ROOT/artifacts}"
|
||||||
|
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-awg-dash-test}"
|
||||||
|
|
||||||
|
# AWG_NS_EXEC_MODE — документированное имя, NETNS_EXEC_MODE — алиас из Makefile.
|
||||||
|
NS_MODE="${AWG_NS_EXEC_MODE:-${NETNS_EXEC_MODE:-auto}}"
|
||||||
|
case "$NS_MODE" in
|
||||||
|
auto|ip|nsenter) ;;
|
||||||
|
*)
|
||||||
|
echo "[ci][fail] AWG_NS_EXEC_MODE='${NS_MODE}' — ожидается auto|ip|nsenter" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
export AWG_NS_EXEC_MODE="$NS_MODE"
|
||||||
|
|
||||||
|
die() { echo "[ci][fail] $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
[ -f "$COMPOSE_FILE" ] || die "нет ${COMPOSE_FILE} (задача #21)"
|
||||||
|
command -v docker >/dev/null 2>&1 || die "нет docker в PATH — CI-раннер должен быть с docker (#17)"
|
||||||
|
docker compose version >/dev/null 2>&1 || die "нет docker compose v2 в PATH (#17)"
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| die "нет образа ${IMAGE}: соберите его (make build после мержа #18) или задайте AWG_DASH_IMAGE"
|
||||||
|
|
||||||
|
mkdir -p "$ARTIFACTS"
|
||||||
|
export AWG_DASH_TEST_ARTIFACTS="$ARTIFACTS"
|
||||||
|
export AWG_DASH_IMAGE="$IMAGE"
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(-f "$COMPOSE_FILE")
|
||||||
|
CLEANUP_DIR=""
|
||||||
|
PRIVILEGED="${AWG_NS_PRIVILEGED:-1}"
|
||||||
|
if [ "$PRIVILEGED" = "0" ]; then
|
||||||
|
# Оверрайд снимает privileged у сервиса netns — проверка ветки «код 3».
|
||||||
|
CLEANUP_DIR="$(mktemp -d -t awg-ci-XXXXXX)"
|
||||||
|
printf 'services:\n netns:\n privileged: false\n' >"${CLEANUP_DIR}/no-privileged.yml"
|
||||||
|
COMPOSE_ARGS+=(-f "${CLEANUP_DIR}/no-privileged.yml")
|
||||||
|
# shellcheck disable=SC2064 # раскрытие $CLEANUP_DIR нужно на момент trap
|
||||||
|
trap "rm -rf '${CLEANUP_DIR}'" EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ci] образ=${IMAGE} режим netns=${NS_MODE} privileged=${PRIVILEGED} артефакты=${ARTIFACTS}"
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" run --rm -T netns "$@" || rc=$?
|
||||||
|
|
||||||
|
case "$rc" in
|
||||||
|
0) echo "[ci][ ok ] netns-стенд: тесты прошли (код 0)" ;;
|
||||||
|
1) echo "[ci][fail] netns-стенд: тесты упали (код 1)" >&2 ;;
|
||||||
|
3) echo "[ci][blocked] netns-стенд: окружение не даёт прав на netns (код 3) — раннеру нужен --privileged (#17)" >&2 ;;
|
||||||
|
4) echo "[ci][fail] netns-стенд: сломан стенд (код 4) — см. лог выше" >&2 ;;
|
||||||
|
*) echo "[ci][fail] netns-стенд: неожиданный код ${rc}" >&2 ;;
|
||||||
|
esac
|
||||||
|
exit "$rc"
|
||||||
@@ -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())
|
||||||
@@ -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 <ns>` (iproute2 6.x) перемонтируют /sys внутри
|
||||||
|
# namespace и в контейнере падают с "mount of /sys failed: Operation not
|
||||||
|
# permitted", хотя `ip netns add` работает (issue #56). nsenter входит в тот же
|
||||||
|
# /var/run/netns/<ns> без перемонтирования — используется как fallback.
|
||||||
|
# Режим: AWG_NS_EXEC_MODE=auto|ip|nsenter (auto — определить один раз пробой).
|
||||||
|
: "${AWG_NS_EXEC_MODE:=auto}"
|
||||||
|
NS_EXEC_RESOLVED=""
|
||||||
|
NS_EXEC_IP_CMD=(ip netns exec)
|
||||||
|
|
||||||
|
_ns_exec_probe() {
|
||||||
|
# $1 — режим; 0 — вход в свежесозданный netns работает
|
||||||
|
local mode="$1" probe="${AWG_NETNS_PREFIX}-probe" rc=0
|
||||||
|
ip netns add "$probe" 2>/dev/null || return 1
|
||||||
|
case "$mode" in
|
||||||
|
ip)
|
||||||
|
timeout 5 "${NS_EXEC_IP_CMD[@]}" "$probe" true >/dev/null 2>&1 || rc=1
|
||||||
|
;;
|
||||||
|
nsenter)
|
||||||
|
timeout 5 nsenter --net="/var/run/netns/${probe}" true >/dev/null 2>&1 || rc=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
rc=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
ip netns delete "$probe" 2>/dev/null || true
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_ns_exec_mode() {
|
||||||
|
# Печатает выбранный режим; код 3 — окружение не даёт войти в netns.
|
||||||
|
case "$AWG_NS_EXEC_MODE" in
|
||||||
|
ip|nsenter)
|
||||||
|
echo "$AWG_NS_EXEC_MODE"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if _ns_exec_probe ip; then
|
||||||
|
echo ip
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v nsenter >/dev/null 2>&1 && _ns_exec_probe nsenter; then
|
||||||
|
warn "'ip netns exec' не работает (mount of /sys: Operation not permitted, issue #56) — использую nsenter --net"
|
||||||
|
echo nsenter
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
blocked "ни 'ip netns exec', ни 'nsenter --net' не работают: нет CAP_SYS_ADMIN/NET_ADMIN (см. issue #56)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ns_exec <netns> <команда...> — выполнить команду внутри namespace.
|
||||||
|
ns_exec() {
|
||||||
|
local ns="$1"
|
||||||
|
shift
|
||||||
|
if [ -z "$NS_EXEC_RESOLVED" ]; then
|
||||||
|
NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3
|
||||||
|
fi
|
||||||
|
case "$NS_EXEC_RESOLVED" in
|
||||||
|
nsenter) nsenter --net="/var/run/netns/${ns}" "$@" ;;
|
||||||
|
*) "${NS_EXEC_IP_CMD[@]}" "$ns" "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ns_prefix <netns> — напечатать команду входа в netns ПО СЛОВУ НА СТРОКУ.
|
||||||
|
# Нужна для фонового запуска: `mapfile -t P < <(ns_prefix NS)` +
|
||||||
|
# `( exec "${P[@]}" python3 ... ) &` даёт в $! pid самого процесса, тогда cleanup
|
||||||
|
# убивает его, а не обёртку-функцию (иначе мок и детектор утечки остаются жить
|
||||||
|
# после прогона и держат stdout).
|
||||||
|
ns_prefix() {
|
||||||
|
local ns="$1"
|
||||||
|
if [ -z "$NS_EXEC_RESOLVED" ]; then
|
||||||
|
NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3
|
||||||
|
fi
|
||||||
|
if [ "$NS_EXEC_RESOLVED" = nsenter ]; then
|
||||||
|
echo nsenter
|
||||||
|
echo --net="/var/run/netns/${ns}"
|
||||||
|
else
|
||||||
|
echo ip
|
||||||
|
echo netns
|
||||||
|
echo exec
|
||||||
|
echo "$ns"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- предусловия ------------------------------------------------------------
|
||||||
|
require_root() {
|
||||||
|
[ "$(id -u)" -eq 0 ] || blocked "нужен root (или CAP_NET_ADMIN): запустите под sudo"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || blocked "нет утилиты '$1' в PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем, что ядро/контейнер вообще позволяют netns (иначе AC невыполним).
|
||||||
|
require_netns_capability() {
|
||||||
|
local probe="${AWG_NETNS_PREFIX}-probe"
|
||||||
|
if ! ip netns add "$probe" 2>/dev/null; then
|
||||||
|
blocked "не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN (запустите контейнер с --privileged)"
|
||||||
|
fi
|
||||||
|
ip netns del "$probe" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ns_exists() { ip netns list | awk '{print $1}' | grep -qx "$1"; }
|
||||||
|
|
||||||
|
# --- netfilter: метки policy routing и kill-switch --------------------------
|
||||||
|
# Стенд работает и на nftables (хост Debian/Ubuntu с nft), и на iptables
|
||||||
|
# (alpine-образ приложения: iptables без бинаря nft). Backend выбирается по
|
||||||
|
# наличию бинаря в netns шлюза; семантика правил одинаковая.
|
||||||
|
|
||||||
|
fw_kind() {
|
||||||
|
# AWG_FW_BACKEND=nft|iptables|auto — принудительный выбор backend (в alpine-образе
|
||||||
|
# приложения есть только iptables, на хосте обычно nft).
|
||||||
|
local want="${AWG_FW_BACKEND:-auto}"
|
||||||
|
case "$want" in
|
||||||
|
nft)
|
||||||
|
ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; }
|
||||||
|
echo none; return
|
||||||
|
;;
|
||||||
|
iptables)
|
||||||
|
ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; }
|
||||||
|
echo none; return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# command -v — builtin shell, поэтому проверяем через sh -c внутри netns
|
||||||
|
if ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then
|
||||||
|
echo nft
|
||||||
|
elif ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then
|
||||||
|
echo iptables
|
||||||
|
else
|
||||||
|
echo none
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fw_available() {
|
||||||
|
local kind; kind="$(fw_kind)"
|
||||||
|
case "$kind" in
|
||||||
|
nft) ns_exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;;
|
||||||
|
iptables) ns_exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Правило метки для трафика шлюза (OUTPUT) и для транзитного трафика LAN (FORWARD).
|
||||||
|
fw_setup() {
|
||||||
|
local kind; kind="$(fw_kind)"
|
||||||
|
case "$kind" in
|
||||||
|
nft)
|
||||||
|
ns_exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
|
||||||
|
ns_exec "$AWG_NS_GW" nft -f - <<EOF
|
||||||
|
table inet $NFT_TABLE {
|
||||||
|
chain mark_out {
|
||||||
|
type route hook output priority mangle; policy accept;
|
||||||
|
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_OUT"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chain mark_fwd {
|
||||||
|
type filter hook forward priority mangle; policy accept;
|
||||||
|
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_FWD"
|
||||||
|
}
|
||||||
|
chain forward_ks {
|
||||||
|
type filter hook forward priority filter; policy accept;
|
||||||
|
ip saddr $AWG_NET_CLIENT ip daddr $AWG_LEAK_NET counter drop comment "$C_DROP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
iptables)
|
||||||
|
local ipt="ns_exec $AWG_NS_GW iptables"
|
||||||
|
$ipt -t mangle -N "$IPT_CHAIN_MARK" 2>/dev/null || true
|
||||||
|
$ipt -t mangle -F "$IPT_CHAIN_MARK"
|
||||||
|
$ipt -t mangle -A "$IPT_CHAIN_MARK" -p udp --dport "$AWG_MOCK_PORT" \
|
||||||
|
-m comment --comment "$C_MARK_OUT" -j MARK --set-mark "$AWG_FWMARK"
|
||||||
|
$ipt -t mangle -C OUTPUT -j "$IPT_CHAIN_MARK" 2>/dev/null \
|
||||||
|
|| $ipt -t mangle -A OUTPUT -j "$IPT_CHAIN_MARK"
|
||||||
|
$ipt -t mangle -N "$IPT_CHAIN_FWD" 2>/dev/null || true
|
||||||
|
$ipt -t mangle -F "$IPT_CHAIN_FWD"
|
||||||
|
$ipt -t mangle -A "$IPT_CHAIN_FWD" -p udp --dport "$AWG_MOCK_PORT" \
|
||||||
|
-m comment --comment "$C_MARK_FWD" -j MARK --set-mark "$AWG_FWMARK"
|
||||||
|
$ipt -t mangle -C FORWARD -j "$IPT_CHAIN_FWD" 2>/dev/null \
|
||||||
|
|| $ipt -t mangle -A FORWARD -j "$IPT_CHAIN_FWD"
|
||||||
|
$ipt -t filter -N "$IPT_CHAIN_KS" 2>/dev/null || true
|
||||||
|
$ipt -t filter -F "$IPT_CHAIN_KS"
|
||||||
|
$ipt -t filter -A "$IPT_CHAIN_KS" -s "$AWG_NET_CLIENT" -d "$AWG_LEAK_NET" \
|
||||||
|
-m comment --comment "$C_DROP" -j DROP
|
||||||
|
$ipt -t filter -C FORWARD -j "$IPT_CHAIN_KS" 2>/dev/null \
|
||||||
|
|| $ipt -t filter -A FORWARD -j "$IPT_CHAIN_KS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "нет ни nft, ни iptables в ${AWG_NS_GW} — стенд не может проверить маркировку"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
log "правила меток/kill-switch установлены (backend: $kind)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# killswitch on|off — включить/выключить fail-closed правило FORWARD.
|
||||||
|
fw_set_killswitch() {
|
||||||
|
local state="$1" kind; kind="$(fw_kind)"
|
||||||
|
case "$kind:$state" in
|
||||||
|
nft:on)
|
||||||
|
ns_exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
|
||||||
|
fw_setup
|
||||||
|
;;
|
||||||
|
nft:off)
|
||||||
|
ns_exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks
|
||||||
|
;;
|
||||||
|
iptables:on)
|
||||||
|
fw_setup
|
||||||
|
;;
|
||||||
|
iptables:off)
|
||||||
|
ns_exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "fw_set_killswitch: неизвестное состояние '$state' (ожидается on|off)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -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("<I", random.getrandbits(32))
|
||||||
|
out[8:12] = sender_index
|
||||||
|
out[12:44] = os.urandom(32) # unencrypted_ephemeral
|
||||||
|
out[44:60] = os.urandom(16) # encrypted_nothing + poly1305 tag
|
||||||
|
out[60:76] = os.urandom(16) # mac1
|
||||||
|
out[76:92] = os.urandom(16) # mac2
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonlLog:
|
||||||
|
"""Пишущий JSONL-лог с немедленным flush (тесты читают файл параллельно)."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | None) -> None:
|
||||||
|
self._fh = None
|
||||||
|
if path:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||||
|
self._fh = open(path, "a", buffering=1, encoding="utf-8")
|
||||||
|
|
||||||
|
def write(self, **event: object) -> None:
|
||||||
|
if self._fh is None:
|
||||||
|
return
|
||||||
|
event.setdefault("ts", round(time.time(), 6))
|
||||||
|
self._fh.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
self._fh.flush()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._fh is not None:
|
||||||
|
self._fh.close()
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
|
||||||
|
def read_mode(mode_file: str | None, default: str) -> str:
|
||||||
|
"""Режим читается из файла на каждом пакете: тесты переключают без рестарта."""
|
||||||
|
if not mode_file:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
with open(mode_file, encoding="utf-8") as fh:
|
||||||
|
mode = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return default
|
||||||
|
return mode if mode in ("respond", "drop") else default
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Мок WireGuard-endpoint для netns-стенда (#21)")
|
||||||
|
ap.add_argument("--bind", default="0.0.0.0:51820", help="адрес:порт для прослушивания UDP")
|
||||||
|
ap.add_argument("--log", default=os.environ.get("AWG_MOCK_LOG", ""), help="JSONL-лог событий")
|
||||||
|
ap.add_argument("--ready", default=os.environ.get("AWG_MOCK_READY", ""), help="файл-флаг готовности")
|
||||||
|
ap.add_argument(
|
||||||
|
"--mode-file",
|
||||||
|
default=os.environ.get("AWG_MOCK_MODE_FILE", ""),
|
||||||
|
help="файл с режимом respond|drop (переключение на лету)",
|
||||||
|
)
|
||||||
|
ap.add_argument("--mode", default=os.environ.get("AWG_MOCK_MODE", "respond"), choices=["respond", "drop"])
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
host, _, port_s = args.bind.rpartition(":")
|
||||||
|
host = host or "0.0.0.0"
|
||||||
|
port = int(port_s or "51820")
|
||||||
|
|
||||||
|
log = JsonlLog(args.log or None)
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
sock.bind((host, port))
|
||||||
|
except OSError as exc:
|
||||||
|
log.write(event="bind_error", addr=f"{host}:{port}", error=str(exc))
|
||||||
|
print(f"mock-endpoint: bind {host}:{port} failed: {exc}", file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
sock.settimeout(0.25)
|
||||||
|
|
||||||
|
if args.ready:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.ready)) or ".", exist_ok=True)
|
||||||
|
with open(args.ready, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(f"{os.getpid()}\n")
|
||||||
|
|
||||||
|
log.write(event="listen", addr=f"{host}:{port}", pid=os.getpid(), mode=args.mode)
|
||||||
|
print(f"mock-endpoint: udp/{port} listen, mode={args.mode}", flush=True)
|
||||||
|
|
||||||
|
rx_total = 0
|
||||||
|
tx_total = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload, peer = sock.recvfrom(65535)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
rx_total += 1
|
||||||
|
mtype = msg_type(payload)
|
||||||
|
mode = read_mode(args.mode_file or None, args.mode)
|
||||||
|
log.write(
|
||||||
|
event="rx",
|
||||||
|
msg_type=mtype,
|
||||||
|
msg_name=TYPE_NAMES.get(mtype, "unknown"),
|
||||||
|
length=len(payload),
|
||||||
|
src=f"{peer[0]}:{peer[1]}",
|
||||||
|
rx_total=rx_total,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == "drop":
|
||||||
|
continue
|
||||||
|
if mtype != MSG_HANDSHAKE_INITIATION:
|
||||||
|
log.write(event="ignored", reason="not_handshake_initiation", msg_type=mtype)
|
||||||
|
continue
|
||||||
|
if len(payload) != HANDSHAKE_INITIATION_LEN:
|
||||||
|
log.write(
|
||||||
|
event="ignored",
|
||||||
|
reason="bad_initiation_length",
|
||||||
|
length=len(payload),
|
||||||
|
expected=HANDSHAKE_INITIATION_LEN,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
reply = build_handshake_response(payload)
|
||||||
|
sock.sendto(reply, peer)
|
||||||
|
tx_total += 1
|
||||||
|
log.write(
|
||||||
|
event="tx",
|
||||||
|
msg_type=MSG_HANDSHAKE_RESPONSE,
|
||||||
|
length=len(reply),
|
||||||
|
dst=f"{peer[0]}:{peer[1]}",
|
||||||
|
tx_total=tx_total,
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
log.write(event="stop", rx_total=rx_total, tx_total=tx_total)
|
||||||
|
log.close()
|
||||||
|
if args.ready and os.path.exists(args.ready):
|
||||||
|
os.unlink(args.ready)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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/<ns> без перемонтирования. Режим кэшируется на процесс,
|
||||||
|
AWG_NS_EXEC_MODE=ip|nsenter|auto переопределяет выбор.
|
||||||
|
"""
|
||||||
|
global _NS_EXEC_MODE
|
||||||
|
if _NS_EXEC_MODE is None:
|
||||||
|
want = os.environ.get("AWG_NS_EXEC_MODE", "auto")
|
||||||
|
if want in ("ip", "nsenter"):
|
||||||
|
_NS_EXEC_MODE = want
|
||||||
|
elif _probe_ns_exec("ip"):
|
||||||
|
_NS_EXEC_MODE = "ip"
|
||||||
|
elif shutil.which("nsenter") and _probe_ns_exec("nsenter"):
|
||||||
|
_NS_EXEC_MODE = "nsenter"
|
||||||
|
else:
|
||||||
|
raise StandError(
|
||||||
|
"ни 'ip netns exec', ни 'nsenter --net' не работают в этом окружении "
|
||||||
|
"(нет CAP_SYS_ADMIN/NET_ADMIN) — issue #56"
|
||||||
|
)
|
||||||
|
return _NS_EXEC_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
|
||||||
|
"""Запуск команды внутри указанного netns (ip или nsenter — см. ns_exec_mode)."""
|
||||||
|
return ns_exec_direct(ns_exec_mode(), ns, args, check=check, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def ns_exists(ns: str) -> bool:
|
||||||
|
cp = sh(["ip", "netns", "list"], check=False)
|
||||||
|
return any(line.split()[0] == ns for line in cp.stdout.splitlines() if line.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def addrs(ns: str) -> str:
|
||||||
|
return ns_sh(ns, ["ip", "-br", "addr", "show"]).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def links(ns: str) -> str:
|
||||||
|
return ns_sh(ns, ["ip", "-br", "link", "show"]).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def link_is_up(ns: str, ifname: str) -> bool:
|
||||||
|
cp = ns_sh(ns, ["ip", "-br", "link", "show", ifname], check=False)
|
||||||
|
return cp.returncode == 0 and "UP" in cp.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# --- netfilter: backend, правила, счётчики -----------------------------------
|
||||||
|
def fw_kind() -> str:
|
||||||
|
"""Backend netfilter в netns шлюза: nft|iptables (AWG_FW_BACKEND форсирует выбор)."""
|
||||||
|
want = os.environ.get("AWG_FW_BACKEND", "auto")
|
||||||
|
if want in ("nft", "iptables"):
|
||||||
|
if ns_sh(GW_NS, ["sh", "-c", f"command -v {want}"], check=False).returncode == 0:
|
||||||
|
return want
|
||||||
|
return "none"
|
||||||
|
if ns_sh(GW_NS, ["sh", "-c", "command -v nft"], check=False).returncode == 0:
|
||||||
|
return "nft"
|
||||||
|
if ns_sh(GW_NS, ["sh", "-c", "command -v iptables"], check=False).returncode == 0:
|
||||||
|
return "iptables"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def nft_rules() -> list[dict[str, Any]]:
|
||||||
|
"""Правила таблицы inet awgtest в JSON-виде (nft -j list table)."""
|
||||||
|
cp = ns_sh(GW_NS, ["nft", "-j", "list", "table", "inet", NFT_TABLE], check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
raise StandError(f"nft list table inet {NFT_TABLE} вернул {cp.returncode}: {cp.stderr.strip()}")
|
||||||
|
payload = json.loads(cp.stdout)
|
||||||
|
return [obj["rule"] for obj in payload.get("nftables", []) if "rule" in obj]
|
||||||
|
|
||||||
|
|
||||||
|
def _nft_rule_counter(rule: dict[str, Any]) -> int | None:
|
||||||
|
for expr in rule.get("expr", []):
|
||||||
|
if isinstance(expr, dict) and "counter" in expr:
|
||||||
|
return int(expr["counter"].get("packets", 0))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fw_counters() -> dict[str, Any]:
|
||||||
|
"""Счётчики правил: {fw, mark_out, mark_fwd, killswitch_drop} (None — недоступно/нет правила)."""
|
||||||
|
kind = fw_kind()
|
||||||
|
out: dict[str, Any] = {"fw": kind, "mark_out": None, "mark_fwd": None, "killswitch_drop": None}
|
||||||
|
if kind == "nft":
|
||||||
|
for rule in nft_rules():
|
||||||
|
comment = str(rule.get("comment", ""))
|
||||||
|
counter = _nft_rule_counter(rule)
|
||||||
|
if comment == C_MARK_OUT:
|
||||||
|
out["mark_out"] = counter
|
||||||
|
elif comment == C_MARK_FWD:
|
||||||
|
out["mark_fwd"] = counter
|
||||||
|
elif comment == C_DROP:
|
||||||
|
out["killswitch_drop"] = counter
|
||||||
|
elif kind == "iptables":
|
||||||
|
out["mark_out"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_MARK)
|
||||||
|
out["mark_fwd"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_FWD)
|
||||||
|
out["killswitch_drop"] = _iptables_counter("-t", "filter", "-L", IPT_CHAIN_KS)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _iptables_counter(*args: str) -> int | None:
|
||||||
|
"""Пакеты первого правила в цепочке iptables (в alpine iptables — nft-backend)."""
|
||||||
|
cp = ns_sh(GW_NS, ["iptables", *args, "-v", "-n", "-x"], check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return None
|
||||||
|
for line in cp.stdout.splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 2 and fields[0].isdigit() and fields[1].isdigit():
|
||||||
|
return int(fields[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def iptables_rules() -> str:
|
||||||
|
parts = []
|
||||||
|
for args in (("-t", "mangle", "-S", IPT_CHAIN_MARK), ("-t", "filter", "-S", IPT_CHAIN_KS)):
|
||||||
|
cp = ns_sh(GW_NS, ["iptables", *args], check=False)
|
||||||
|
parts.append(cp.stdout.strip() or cp.stderr.strip())
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# --- артефакты процесса (логи мока и детектора утечки) -----------------------
|
||||||
|
def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def wait_ready(path: pathlib.Path, timeout: float = 10.0, what: str = "процесс") -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise StandError(f"{what} не поднялся за {timeout}с (нет файла {path})")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_jsonl(path: pathlib.Path, predicate, timeout: float = 5.0) -> dict[str, Any] | None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
for event in read_jsonl(path):
|
||||||
|
if predicate(event):
|
||||||
|
return event
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- управление моком --------------------------------------------------------
|
||||||
|
def set_mock_mode(mode: str) -> None:
|
||||||
|
if mode not in ("respond", "drop"):
|
||||||
|
raise ValueError(f"неизвестный режим мока: {mode}")
|
||||||
|
MOCK_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
MOCK_MODE_FILE.write_text(mode + "\n", encoding="utf-8")
|
||||||
|
time.sleep(0.3) # мок читает режим на каждом пакете
|
||||||
|
|
||||||
|
|
||||||
|
# --- трафик ------------------------------------------------------------------
|
||||||
|
def probe(
|
||||||
|
src_ns: str,
|
||||||
|
dst: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
expect_timeout: bool = False,
|
||||||
|
raw: str | None = None,
|
||||||
|
timeout: float = 2.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Отправить тестовый трафик из netns и вернуть разобранный JSON-ответ probe.py."""
|
||||||
|
args = ["python3", str(PROBE), "--dst", dst, "--port", str(port), "--timeout", str(timeout)]
|
||||||
|
if raw is not None:
|
||||||
|
args += ["--raw", raw]
|
||||||
|
if expect_timeout:
|
||||||
|
args.append("--expect-timeout")
|
||||||
|
cp = ns_sh(src_ns, args, check=False, timeout=timeout + 10)
|
||||||
|
line = cp.stdout.strip().splitlines()[-1] if cp.stdout.strip() else "{}"
|
||||||
|
try:
|
||||||
|
result = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc: # pragma: no cover - диагностика
|
||||||
|
raise StandError(f"probe.py вернул не-JSON ({exc}): stdout={cp.stdout!r} stderr={cp.stderr!r}") from exc
|
||||||
|
result["exit_code"] = cp.returncode
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mock_events() -> list[dict[str, Any]]:
|
||||||
|
return read_jsonl(MOCK_LOG)
|
||||||
|
|
||||||
|
|
||||||
|
def leak_events() -> list[dict[str, Any]]:
|
||||||
|
return [e for e in read_jsonl(LEAK_LOG) if e.get("event") == "leak"]
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""probe.py — генератор тестового трафика из netns-стенда (задача #21).
|
||||||
|
|
||||||
|
Запускается внутри конкретного netns (`ip netns exec <ns> python3 probe.py ...`)
|
||||||
|
и умеет:
|
||||||
|
|
||||||
|
* `--wg-initiation` (по умолчанию) — отправить WireGuard handshake initiation
|
||||||
|
(type 1, 148 байт, раскладка как в протоколе) и дождаться ответа мока;
|
||||||
|
* `--raw TEXT` — отправить произвольный UDP-datagram (для проверок утечки /
|
||||||
|
kill-switch), по умолчанию ответа не ожидает.
|
||||||
|
|
||||||
|
Результат печатается одной JSON-строкой в stdout, код возврата:
|
||||||
|
0 — ожидаемое поведение достигнуто, 1 — нет, 2 — ошибка аргументов/сокета.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
MSG_HANDSHAKE_INITIATION = 1
|
||||||
|
MSG_HANDSHAKE_RESPONSE = 2
|
||||||
|
HANDSHAKE_INITIATION_LEN = 148
|
||||||
|
|
||||||
|
|
||||||
|
def build_handshake_initiation(sender_index: int | None = None) -> bytes:
|
||||||
|
"""148 байт: type(1)+reserved(3) sender(4) ephemeral(32) enc_static(48) enc_ts(28) mac1(16) mac2(16)."""
|
||||||
|
if sender_index is None:
|
||||||
|
sender_index = struct.unpack("<I", os.urandom(4))[0]
|
||||||
|
out = bytearray(HANDSHAKE_INITIATION_LEN)
|
||||||
|
out[0] = MSG_HANDSHAKE_INITIATION
|
||||||
|
out[4:8] = struct.pack("<I", sender_index)
|
||||||
|
out[8:40] = os.urandom(32) # unencrypted_ephemeral
|
||||||
|
out[40:88] = os.urandom(48) # encrypted_static (32) + poly1305 tag (16)
|
||||||
|
out[88:116] = os.urandom(28) # encrypted_timestamp (12) + tag (16)
|
||||||
|
out[116:132] = os.urandom(16) # mac1
|
||||||
|
out[132:148] = os.urandom(16) # mac2
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Тестовый трафик для netns-стенда (#21)")
|
||||||
|
ap.add_argument("--dst", required=True, help="IP назначения")
|
||||||
|
ap.add_argument("--port", type=int, required=True, help="UDP-порт назначения")
|
||||||
|
ap.add_argument("--timeout", type=float, default=2.0, help="ожидание ответа, с")
|
||||||
|
ap.add_argument("--raw", default=None, help="отправить произвольный текст вместо handshake initiation")
|
||||||
|
ap.add_argument("--expect-timeout", action="store_true", help="ожидать отсутствия ответа (для kill-switch/drop)")
|
||||||
|
ap.add_argument("--source-port", type=int, default=0, help="локальный порт (0 — случайный)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.raw is None:
|
||||||
|
payload = build_handshake_initiation()
|
||||||
|
expect_reply = not args.expect_timeout
|
||||||
|
kind = "wg_handshake_initiation"
|
||||||
|
else:
|
||||||
|
payload = args.raw.encode("utf-8")
|
||||||
|
# для raw-трафика ответ не ожидается, если явно не сказано обратное
|
||||||
|
expect_reply = not args.expect_timeout
|
||||||
|
kind = "raw_udp"
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
if args.source_port:
|
||||||
|
sock.bind(("", args.source_port))
|
||||||
|
sock.settimeout(args.timeout)
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
sent = sock.sendto(payload, (args.dst, args.port))
|
||||||
|
except OSError as exc:
|
||||||
|
print(json.dumps({"ok": False, "kind": kind, "error": f"sendto: {exc}"}))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
reply = None
|
||||||
|
try:
|
||||||
|
data, peer = sock.recvfrom(65535)
|
||||||
|
reply = {
|
||||||
|
"type": data[0] if data else -1,
|
||||||
|
"length": len(data or b""),
|
||||||
|
"src": f"{peer[0]}:{peer[1]}",
|
||||||
|
"receiver_index": (
|
||||||
|
struct.unpack("<I", data[8:12])[0] if len(data) >= 12 and data[0] == MSG_HANDSHAKE_RESPONSE else None
|
||||||
|
),
|
||||||
|
"sender_index_echo": struct.unpack("<I", payload[4:8])[0],
|
||||||
|
}
|
||||||
|
except socket.timeout:
|
||||||
|
reply = None
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
got_reply = reply is not None
|
||||||
|
ok = got_reply if expect_reply else (not got_reply)
|
||||||
|
out = {
|
||||||
|
"ok": ok,
|
||||||
|
"kind": kind,
|
||||||
|
"sent": sent,
|
||||||
|
"dst": f"{args.dst}:{args.port}",
|
||||||
|
"expect_reply": expect_reply,
|
||||||
|
"reply_received": got_reply,
|
||||||
|
"reply": reply,
|
||||||
|
"rtt_ms": round((time.time() - started) * 1000, 2),
|
||||||
|
}
|
||||||
|
print(json.dumps(out, sort_keys=True))
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run.sh — точка входа netns-стенда awg-dash (вызывается из `make test-netns`).
|
||||||
|
#
|
||||||
|
# tests/netns/run.sh [опции]
|
||||||
|
#
|
||||||
|
# Что делает:
|
||||||
|
# 1) preflight: root/CAP_NET_ADMIN/ip/python3;
|
||||||
|
# 2) поднимает netns-топологию (topology.sh up);
|
||||||
|
# 3) поднимает мок WireGuard-endpoint и детектор утечки внутри netns `uplink`;
|
||||||
|
# 4) прогоняет интеграционные тесты стенда (tests/netns/test_netns_stand.py);
|
||||||
|
# 5) сносит стенд и проверяет, что namespace'ы удалены.
|
||||||
|
#
|
||||||
|
# Опции:
|
||||||
|
# --keep-topology не удалять топологию после прогона (для отладки: make test-netns-keep)
|
||||||
|
# --keep-logs не удалять рабочий каталог с логами (по умолчанию сохраняется всегда
|
||||||
|
# при провале или при --keep-topology)
|
||||||
|
# --no-tests только поднять стенд и напечатать сводку
|
||||||
|
# --report PATH путь к JSON-отчёту (по умолчанию $AWG_NETNS_WORKDIR/report.json)
|
||||||
|
# --workdir PATH рабочий каталог стенда (логи, флаги готовности)
|
||||||
|
# -h|--help эта справка
|
||||||
|
#
|
||||||
|
# Коды возврата: 0 — успех; 1 — тесты упали; 3 — заблокировано окружением
|
||||||
|
# (нет root/CAP_NET_ADMIN); 4 — ошибка стенда.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=tests/netns/lib.sh
|
||||||
|
source "$HERE/lib.sh"
|
||||||
|
|
||||||
|
# Контракт кодов возврата: 0 — успех, 1 — тесты упали, 3 — заблокировано
|
||||||
|
# окружением, 4 — ошибка стенда. Неожиданные коды утилит (например 255 от
|
||||||
|
# `ip netns exec` в контейнере — issue #56) нормализуем в 4, чтобы CI #17
|
||||||
|
# различал «нет прав» и «сломался стенд».
|
||||||
|
trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "run.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR
|
||||||
|
|
||||||
|
KEEP_TOPO=0
|
||||||
|
NO_TESTS=0
|
||||||
|
REPORT=""
|
||||||
|
WORKDIR_OPT=""
|
||||||
|
|
||||||
|
usage() { sed -n '2,25p' "$HERE/run.sh" | sed 's/^# \{0,1\}//'; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--keep-topology) KEEP_TOPO=1 ;;
|
||||||
|
--keep-logs) : ;;
|
||||||
|
--no-tests) NO_TESTS=1 ;;
|
||||||
|
--report) REPORT="${2:?--report требует путь}"; shift ;;
|
||||||
|
--workdir) WORKDIR_OPT="${2:?--workdir требует путь}"; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) fail "неизвестная опция: $1 (см. --help)" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- preflight ---------------------------------------------------------------
|
||||||
|
require_root
|
||||||
|
require_cmd ip
|
||||||
|
require_cmd python3
|
||||||
|
require_netns_capability
|
||||||
|
NS_EXEC_RESOLVED="$(select_ns_exec_mode)"
|
||||||
|
export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED"
|
||||||
|
log "вход в netns: ${NS_EXEC_RESOLVED}"
|
||||||
|
|
||||||
|
if [ -n "$WORKDIR_OPT" ]; then
|
||||||
|
export AWG_NETNS_WORKDIR="$WORKDIR_OPT"
|
||||||
|
else
|
||||||
|
export AWG_NETNS_WORKDIR="${AWG_NETNS_WORKDIR:-$(mktemp -d -t awg-netns-XXXXXX)}"
|
||||||
|
fi
|
||||||
|
mkdir -p "$AWG_NETNS_WORKDIR"
|
||||||
|
: "${REPORT:=${AWG_NETNS_WORKDIR}/report.json}"
|
||||||
|
|
||||||
|
# пути артефактов процессов — в дочерние процессы уходят через окружение
|
||||||
|
export AWG_MOCK_LOG="${AWG_NETNS_WORKDIR}/mock-endpoint.jsonl"
|
||||||
|
export AWG_MOCK_READY="${AWG_NETNS_WORKDIR}/mock-endpoint.ready"
|
||||||
|
export AWG_MOCK_MODE_FILE="${AWG_NETNS_WORKDIR}/mock-mode"
|
||||||
|
export AWG_LEAK_LOG="${AWG_NETNS_WORKDIR}/leak-sink.jsonl"
|
||||||
|
export AWG_LEAK_READY="${AWG_NETNS_WORKDIR}/leak-sink.ready"
|
||||||
|
|
||||||
|
# чистое состояние артефактов прошлого прогона
|
||||||
|
rm -f "$AWG_MOCK_LOG" "$AWG_MOCK_READY" "$AWG_LEAK_LOG" "$AWG_LEAK_READY"
|
||||||
|
printf 'respond\n' > "$AWG_MOCK_MODE_FILE"
|
||||||
|
|
||||||
|
MOCK_PID=""
|
||||||
|
SINK_PID=""
|
||||||
|
TEST_RC=0
|
||||||
|
CLEANED=0
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[ "$CLEANED" -eq 1 ] && return 0
|
||||||
|
CLEANED=1
|
||||||
|
for pid in "$MOCK_PID" "$SINK_PID"; do
|
||||||
|
[ -n "$pid" ] || continue
|
||||||
|
kill -0 "$pid" 2>/dev/null || continue
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
# до 2с на штатное завершение, затем SIGKILL — иначе прогон «висит» на wait
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [ "$KEEP_TOPO" -eq 1 ]; then
|
||||||
|
warn "топология сохранена (--keep-topology): ${AWG_NS_CLIENT} ${AWG_NS_GW} ${AWG_NS_UPLINK}"
|
||||||
|
warn "снести: bash tests/netns/topology.sh down"
|
||||||
|
else
|
||||||
|
bash "$HERE/topology.sh" down >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# --- стенд -------------------------------------------------------------------
|
||||||
|
log "рабочий каталог: ${AWG_NETNS_WORKDIR}"
|
||||||
|
bash "$HERE/topology.sh" up
|
||||||
|
|
||||||
|
log "запускаю мок WireGuard-endpoint (udp/${AWG_MOCK_PORT}) в ${AWG_NS_UPLINK}"
|
||||||
|
mapfile -t NS_PREFIX < <(ns_prefix "$AWG_NS_UPLINK")
|
||||||
|
( exec "${NS_PREFIX[@]}" python3 "$HERE/mock_wg_endpoint.py" \
|
||||||
|
--bind "0.0.0.0:${AWG_MOCK_PORT}" --log "$AWG_MOCK_LOG" --ready "$AWG_MOCK_READY" \
|
||||||
|
--mode-file "$AWG_MOCK_MODE_FILE" --mode respond ) &
|
||||||
|
MOCK_PID=$!
|
||||||
|
|
||||||
|
log "запускаю детектор утечки (${AWG_LEAK_IP}:${AWG_LEAK_PORT}) в ${AWG_NS_UPLINK}"
|
||||||
|
( exec "${NS_PREFIX[@]}" python3 "$HERE/leak_sink.py" \
|
||||||
|
--ip "$AWG_LEAK_IP" --port "$AWG_LEAK_PORT" --log "$AWG_LEAK_LOG" --ready "$AWG_LEAK_READY" ) &
|
||||||
|
SINK_PID=$!
|
||||||
|
|
||||||
|
# ждём готовности обоих процессов (файлы-флаги пишутся после bind)
|
||||||
|
for _ in $(seq 1 100); do
|
||||||
|
if [ -f "$AWG_MOCK_READY" ] && [ -f "$AWG_LEAK_READY" ]; then break; fi
|
||||||
|
if ! kill -0 "$MOCK_PID" 2>/dev/null; then stand_fail "мок WireGuard-endpoint упал при старте"; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[ -f "$AWG_MOCK_READY" ] || stand_fail "мок WireGuard-endpoint не поднялся за 10с"
|
||||||
|
[ -f "$AWG_LEAK_READY" ] || stand_fail "детектор утечки не поднялся за 10с"
|
||||||
|
ok "мок-endpoint и детектор утечки готовы"
|
||||||
|
|
||||||
|
# --- тесты -------------------------------------------------------------------
|
||||||
|
if [ "$NO_TESTS" -eq 1 ]; then
|
||||||
|
log "--no-tests: стенд поднят, тесты пропущены"
|
||||||
|
else
|
||||||
|
log "прогон интеграционных тестов стенда"
|
||||||
|
AWG_NETNS_RUN=1 AWG_NETNS_WORKDIR="$AWG_NETNS_WORKDIR" \
|
||||||
|
python3 "$HERE/run_tests.py" --report "$REPORT" || TEST_RC=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- проверка сноса ----------------------------------------------------------
|
||||||
|
TOPOLOGY_REMOVED=true
|
||||||
|
if [ "$KEEP_TOPO" -eq 0 ]; then
|
||||||
|
cleanup
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
|
||||||
|
if ns_exists "$ns"; then
|
||||||
|
warn "после сноса остался netns ${ns} — стенд не очищен"
|
||||||
|
TOPOLOGY_REMOVED=false
|
||||||
|
TEST_RC=4
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$TOPOLOGY_REMOVED" = true ] && ok "топология снесена, namespace'ы удалены"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NO_TESTS" -eq 1 ] && [ "$TOPOLOGY_REMOVED" = true ]; then
|
||||||
|
ok "стенд поднят успешно (AC-3: netns + мок-endpoint без реального провайдера)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$TEST_RC" -eq 0 ]; then
|
||||||
|
ok "netns-стенд: тесты пройдены; отчёт: ${REPORT}"
|
||||||
|
else
|
||||||
|
warn "netns-стенд: тесты/очистка упали (код ${TEST_RC}); логи: ${AWG_NETNS_WORKDIR}"
|
||||||
|
fi
|
||||||
|
exit "$TEST_RC"
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user