Sprint 0 / #21: dev/staging стенд — docker-compose, тома, netns-харнесс #53
+99
-8
@@ -1,13 +1,104 @@
|
||||
# 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/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 — оба). Код возврата `3` означает «заблокировано
|
||||
окружением»: нет прав на создание netns.
|
||||
|
||||
## Состав
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `run.sh` | точка входа: preflight → топология → мок → тесты → снос |
|
||||
| `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` | каталог логов и отчёта стенда |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе
|
||||
`run.sh` завершится кодом 3 с сообщением «заблокировано окружением».
|
||||
* **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,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)
|
||||
Reference in New Issue
Block a user