diff --git a/tests/netns/README.md b/tests/netns/README.md index e6139e1..e75510f 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -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`. diff --git a/tests/netns/test_netns_stand.py b/tests/netns/test_netns_stand.py new file mode 100644 index 0000000..0675223 --- /dev/null +++ b/tests/netns/test_netns_stand.py @@ -0,0 +1,210 @@ +"""Интеграционные тесты netns-стенда awg-dash (задача #21, эпик #15). + +Проверяют, что стенд реально поднимает топологию client/gw/uplink, мок +WireGuard-endpoint отвечает без настоящего провайдера, тестовый трафик +маркируется, а kill-switch не пускает трафик напрямую. + +Запускать только через `tests/netns/run.sh` (или `make test-netns`): тестам +нужны root, поднятая топология и запущенные процессы стенда. Вне run.sh тесты +помечаются skipped, чтобы случайный прогон pytest/CI не падал. +""" + +from __future__ import annotations + +import os +import time +import unittest + +import netns_common as nc + +REASON = "netns-стенд не запущен: используйте tests/netns/run.sh (нужны root и CAP_NET_ADMIN)" +KILLSWITCH_SCRIPT = str(nc.HERE / "topology.sh") + + +@unittest.skipUnless(os.environ.get("AWG_NETNS_RUN") == "1", REASON) +class NetnsStandTest(unittest.TestCase): + """Порядок методов значим: стенд stateful, шаги идут как test_01..test_08.""" + + maxDiff = None + + def _killswitch(self, state: str) -> None: + nc.sh(["bash", KILLSWITCH_SCRIPT, "killswitch", state]) + + # --- AC-3, шаг 1: топология --------------------------------------------- + def test_01_namespaces_and_links(self) -> None: + for ns in (nc.CLIENT_NS, nc.GW_NS, nc.UPLINK_NS): + self.assertTrue(nc.ns_exists(ns), f"netns {ns} не создан") + + self.assertIn(nc.CLIENT_IP, nc.addrs(nc.CLIENT_NS), "у клиента нет ожидаемого адреса") + gw_addrs = nc.addrs(nc.GW_NS) + self.assertIn(nc.GW_LAN_IP, gw_addrs, "у шлюза нет LAN-адреса") + self.assertIn(nc.GW_WAN_IP, gw_addrs, "у шлюза нет WAN-адреса") + self.assertIn(nc.UPLINK_IP, nc.addrs(nc.UPLINK_NS), "у аплинка нет ожидаемого адреса") + + for ns, ifname in ( + (nc.CLIENT_NS, nc.VETH_CLIENT), + (nc.GW_NS, nc.VETH_GW_LAN), + (nc.GW_NS, nc.VETH_GW_WAN), + (nc.UPLINK_NS, nc.VETH_UPLINK), + ): + self.assertTrue(nc.link_is_up(ns, ifname), f"{ifname} в {ns} не UP") + + forwarding = nc.ns_sh(nc.GW_NS, ["sysctl", "-n", "net.ipv4.ip_forward"]).stdout.strip() + self.assertEqual("1", forwarding, "в шлюзе не включён ip_forward — транзит не пойдёт") + + # --- policy routing без реального провайдера ------------------------------ + def test_02_policy_routing_by_fwmark(self) -> None: + rules = nc.ns_sh(nc.GW_NS, ["ip", "rule", "show"]).stdout + self.assertIn(f"lookup {nc.ROUTE_TABLE}", rules, "нет ip rule fwmark -> table") + self.assertIn("fwmark", rules, "правило policy routing не содержит fwmark") + + table = nc.ns_sh(nc.GW_NS, ["ip", "route", "show", "table", str(nc.ROUTE_TABLE)]).stdout + self.assertIn(f"via {nc.UPLINK_IP}", table, f"в таблице {nc.ROUTE_TABLE} нет default via аплинк") + + route_get = nc.ns_sh( + nc.GW_NS, ["ip", "route", "get", nc.UPLINK_IP, "mark", str(nc.FWMARK)] + ).stdout + self.assertIn( + f"table {nc.ROUTE_TABLE}", + route_get, + f"маршрут для fwmark {nc.FWMARK} не берётся из таблицы {nc.ROUTE_TABLE}: {route_get}", + ) + + def test_03_netfilter_mark_and_killswitch_rules(self) -> None: + kind = nc.fw_kind() + self.assertIn(kind, ("nft", "iptables"), "в netns шлюза нет ни nft, ни iptables") + + if kind == "nft": + comments = {str(rule.get("comment", "")) for rule in nc.nft_rules()} + for needle in (nc.C_MARK_OUT, nc.C_MARK_FWD, nc.C_DROP): + self.assertIn(needle, comments, f"нет правила nft с комментарием {needle}") + else: + rules = nc.iptables_rules() + self.assertIn(nc.IPT_CHAIN_MARK, rules, "нет цепочки метки iptables") + self.assertIn(nc.IPT_CHAIN_KS, rules, "нет цепочки kill-switch iptables") + self.assertIn("MARK", rules, "нет правила MARK --set-mark") + self.assertIn("DROP", rules, "нет правила DROP kill-switch") + + # --- мок-endpoint -------------------------------------------------------- + def test_04_mock_endpoint_and_leak_sink_ready(self) -> None: + self.assertTrue(nc.MOCK_READY.exists(), f"нет флага готовности мока {nc.MOCK_READY}") + self.assertTrue(nc.LEAK_READY.exists(), f"нет флага готовности детектора {nc.LEAK_READY}") + + listen = nc.wait_jsonl(nc.MOCK_LOG, lambda e: e.get("event") == "listen", timeout=3) + self.assertIsNotNone(listen, "в логе мока нет события listen") + self.assertIn(str(nc.MOCK_PORT), str(listen.get("addr")), "мок слушает не тот порт") + + sink = nc.wait_jsonl(nc.LEAK_LOG, lambda e: e.get("event") == "listen", timeout=3) + self.assertIsNotNone(sink, "детектор утечки не залогировал listen") + + # --- AC-3, шаг 2: трафик маркируется и доходит до мока -------------------- + def test_05_forwarded_handshake_reaches_mock_and_is_marked(self) -> None: + before = nc.fw_counters() + + result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue( + result.get("reply_received"), + f"мок-endpoint не ответил на handshake initiation: {result}", + ) + + rx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "rx" and e.get("msg_type") == 1 and str(e.get("src", "")).startswith(nc.CLIENT_IP), + timeout=3, + ) + self.assertIsNotNone(rx, "мок не увидел handshake initiation от клиента") + self.assertEqual(148, rx["length"], "длина handshake initiation не 148 байт") + self.assertEqual("handshake_initiation", rx["msg_name"]) + + tx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "tx" and e.get("msg_type") == 2, + timeout=3, + ) + self.assertIsNotNone(tx, "мок не отправил handshake response") + self.assertEqual(92, tx["length"], "длина handshake response не 92 байта") + + reply = result.get("reply") or {} + self.assertEqual( + reply.get("sender_index_echo"), + reply.get("receiver_index"), + "мок не подставил sender_index клиента как receiver_index", + ) + + after = nc.fw_counters() + self.assertIsNotNone(after["mark_fwd"], "нет счётчика правила метки транзитного трафика") + self.assertGreater( + after["mark_fwd"], before["mark_fwd"] or 0, "счётчик правила метки (FORWARD) не вырос — трафик не маркируется" + ) + + def test_06_gateway_originated_handshake_is_marked(self) -> None: + before = nc.fw_counters() + result = nc.probe(nc.GW_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue(result.get("reply_received"), f"мок не ответил на handshake из netns шлюза: {result}") + + after = nc.fw_counters() + self.assertIsNotNone(after["mark_out"], "нет счётчика правила метки исходящего трафика шлюза") + self.assertGreater( + after["mark_out"], before["mark_out"] or 0, "счётчик правила метки (OUTPUT) не вырос" + ) + + # --- имитация недоступности провайдера ------------------------------------ + def test_07_provider_outage_drop_mode(self) -> None: + nc.set_mock_mode("drop") + try: + result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, expect_timeout=True, timeout=1.5) + self.assertTrue(result.get("ok"), f"в режиме drop ожидалось отсутствие ответа: {result}") + self.assertFalse(result.get("reply_received"), "мок ответил, хотя провайдер «недоступен»") + + rx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "rx" and e.get("mode") == "drop", + timeout=3, + ) + self.assertIsNotNone(rx, "мок в режиме drop не зафиксировал пакет") + finally: + nc.set_mock_mode("respond") + + recovered = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue(recovered.get("reply_received"), "стенд не восстановился после возврата режима respond") + + # --- kill-switch (fail-closed) ------------------------------------------- + def test_08_killswitch_blocks_direct_egress(self) -> None: + marker = "awgtest-leak-probe" + + # 1) положительный контроль: при выключенном kill-switch трафик доходит + self._killswitch("off") + nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0) + control = nc.wait_jsonl( + nc.LEAK_LOG, + lambda e: e.get("event") == "leak" and e.get("length") == len(marker), + timeout=3, + ) + self.assertIsNotNone( + control, + "контрольная проверка не прошла: при выключенном kill-switch трафик не дошёл до детектора", + ) + baseline = len(nc.leak_events()) + + # 2) kill-switch включён -> трафик, предназначенный туннелю, не должен утечь + self._killswitch("on") + before_drop = nc.fw_counters() + nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0) + time.sleep(1.0) + + self.assertEqual( + baseline, + len(nc.leak_events()), + "трафик утёк напрямую при включённом kill-switch (fail-closed нарушен)", + ) + after_drop = nc.fw_counters() + if after_drop["killswitch_drop"] is not None: + self.assertGreater( + after_drop["killswitch_drop"], + before_drop["killswitch_drop"] or 0, + "счётчик DROP kill-switch не вырос — правило не сработало", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)