From e92e08e5ccdd28f9040e3e28a3db6bd696b3984b Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 11 Oct 2026 16:58:15 +0200 Subject: [PATCH] Document stream lab and test namespace isolation guards --- tests/stream_netns/README.md | 93 ++++++++++++++++++++++++++++ tests/stream_netns/test_isolation.py | 59 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/stream_netns/README.md create mode 100644 tests/stream_netns/test_isolation.py diff --git a/tests/stream_netns/README.md b/tests/stream_netns/README.md new file mode 100644 index 00000000..c0da0f0f --- /dev/null +++ b/tests/stream_netns/README.md @@ -0,0 +1,93 @@ +# Стенд стриминга в network namespace + +В стенде работают **16 отдельных процессов uTun**, каждый со своим veth, адресом, идентичностью и SQLite. Они создают CHAT-канал и вступают в него через штатный JOIN, ждут подписанные профили Merkle и BGP-маршруты, затем используют `stream_publish_open` / `stream_subscribe_open`. Пакеты, подписи, FEC, NCD, reverse и автоматический выбор поставщиков обрабатывает настоящее ядро. Время не ускоряется. + +Все интерфейсы, маршруты, forwarding, nftables и tc создаются внутри новых user/mount/PID/network namespace. В основной сети выполняется только чтение снимков адресов, маршрутов, правил и qdisc до/после запуска. Нет TUN, default route наружу, именованных `ip netns`, сетевых мостов или firewall-правил на хосте. Внешние инструменты и пакетные зависимости стенд не устанавливает. + +## Запуск + +Нужны Linux, Python **3.10+**, iproute2 (`ip`, `tc`), nftables, ethtool, util-linux (`unshare`, `nsenter`), sysctl и поддержка user namespace, veth, bridge, IFB, HTB, netem, TBF и NAT в ядре. Обычно достаточно обычного пользователя с разрешенными user namespace. Если политика системы запрещает их, запуск требует разрешения администратора; стенд сообщает ошибку и не меняет основную сеть. + +Из корня репозитория: + +```sh +make -C tests stream_netns/stream_lab_node +python3 tests/stream_netns/run.py --network-only +python3 tests/stream_netns/run.py +``` + +Полная baseline-проверка длится 120 секунд после старта доставки, плюс калибровка и JOIN. Она покрывает оборот 16-битного timestamp и несколько циклов проб. Короткая проверка: + +```sh +python3 tests/stream_netns/run.py --duration 20 +``` + +Ухудшение WAN, восстановление и отключение подписок: + +```sh +python3 tests/stream_netns/run.py --duration 120 --faults +``` + +Источник за строгим NAT и доступность TCP в дополнение к UDP: + +```sh +python3 tests/stream_netns/run.py --publisher n05 --tcp --faults +``` + +Ту же проверку запускает отдельная ручная цель: + +```sh +make -C tests check-stream-netns STREAM_NETNS_ARGS='--duration 120 --faults' +``` + +Стенд не входит в обычный `make check` / `check.sh`. `--rate` задает входной bitrate в битах/секунду **до FEC и протокольных накладных расходов** (по умолчанию 1 Мбит/с). `--output` указывает новый каталог результатов; существующий каталог не перезаписывается. `--seed` фиксирует идентичности и генераторы netem/нагрузки; расписание процессов и случайные ключи канала/эпохи остаются недетерминированными. + +## Сеть + +| Сегмент | Узлы с публичными адресами | Узлы за NAT | Подсеть | +| --- | --- | --- | --- | +| A | n01, n02 | n03–n06 | 192.0.2.0/24 | +| B | n07, n08 | n09–n11 | 198.51.100.0/24 | +| C | n12, n13 | n14–n16 | 203.0.113.0/24 | + +Всего 25 рабочих namespace: 16 узлов, 3 маршрутизатора стран, 6 NAT-шлюзов, плюс изолированный namespace контроллера. У каждого NAT своя частная LAN с одинаковой `10.64.1.0/24`: совпадение частных адресов между разными NAT намеренное. n16 имеет мобильный тип; его исключение из relay-кандидатов выполняет ядро. + +| WAN | RTT | Jitter в одну сторону | Loss в одну сторону | Общая полоса в каждом направлении | +| --- | ---: | ---: | ---: | ---: | +| A↔B | 50 мс | 2 мс | 0,2% | 50 Мбит/с | +| A↔C | 100 мс | 5 мс | 0,5% | 30 Мбит/с | +| B↔C | 150 мс | 10 мс | 1% | 20 Мбит/с | + +Внутри страны RTT около 2 мс, jitter 0,2 мс, без искусственных потерь. Задержка и loss применяются на **ingress узла через IFB** с классификацией по адресу физического отправителя. Это учитывает каждый переход потока через приложение и работает также для TCP, без искажения от TCP Small Queues на стороне отправителя. WAN-роутеры ограничивают совокупный трафик каждого межстранового линка через TBF; обходных IP-маршрутов нет. GSO/GRO/TSO на veth отключены. Размер очередей конечный; их состояние и drops сохраняются в снимках tc. + +UDP NAT-профили: + +- `g_a1`, `g_b1`: одинаковый внешний порт для разных назначений (EIM), вход от любого узла после открытия mapping (EIF). +- `g_c1`: EIM, вход только от ранее опрошенного IP/порта (APDF). +- `g_a2`, `g_b2`, `g_c2`: внешний UDP-порт зависит от **IP назначения** (ADM), возврат только по conntrack (APDF). Для известных внешних endpoints порты назначаются явно; это воспроизводимый строгий NAT, а не заявление, что обычный masquerade всегда симметричен. + +Cone/restricted mapping разрешается только после исходящего пакета, время активности 60 секунд обновляется на каждом исходящем пакете. TCP использует обычный stateful NAT без проброса входящих портов. Проверка перед uTun измеряет реальный внешний адрес/порт на двух публичных получателях и отдельно пробует вход от третьего, включая отсутствие mapping до исходящего трафика. Для проб до/после открытия используются разные внешние tuples, чтобы не переиспользовать отрицательный conntrack. + +## Что проверяется + +Для каждого приложения DATA содержит sequence, ID источника, monotonic timestamp, seed и SHA256. Одна запись равна одной входной пачке 6+2; штатная кодограмма около 1300 байт. Получатели проверяют checksum, порядок, явные GAP, итоговый cursor и подписанный END. Начальный пропущенный префикс допустим: подписка начинает с последнего доступного блока. Дальнейшие пропуски должны быть полностью объяснены GAP; поврежденные или повторно доставленные записи приводят к FAIL. + +One-way latency измеряется по общему CLOCK_MONOTONIC ядра Linux: time namespace не создается. Это внешняя проверка задержки; алгоритм стриминга продолжает использовать свой циклический timestamp. Histogram имеет шаг 1 мс, верхняя корзина 4095 мс; максимальная задержка записывается без ограничения. + +При `--faults` A↔C снижается до 2 Мбит/с, RTT увеличивается до 300 мс, loss до 5%. После восстановления проверяется возобновление доставки. n02 и n13 затем закрывают подписки; счетчик полученных DATA обязан перестать расти. Перед окончанием проверяется END у оставшихся подписчиков и соответствие числа принятых/пропущенных записей числу записей источника. Каждый снимок проверяет предел данных кольца 32 MiB. GAP в условиях перегрузки записывается в отчет и сам по себе не считается повреждением. + +Доступный TCP через `--tcp` не гарантирует, что ядро выберет TCP или несколько линков в конкретном запуске. Фактические соединения видны в `utun.log`; выбор поставщиков и пути DATA сохраняются в samples. Этот стенд проверяет доставку через реальные сетевые условия, но не подменяет отдельные unit-тесты криптографии и FEC. + +## Результаты и диагностика + +По умолчанию результаты находятся в новом `tests/stream_netns/runs//`: + +- `result.json` — PASS/FAIL, ошибка и фазы; `host-check.json` — сравнение основной сети, с исключением естественно уменьшающихся DHCP lifetime/cache timers. +- `calibration.json`, `topology.json`, `namespaces.json` — измерения, параметры, PID и inode namespace. +- `samples.jsonl`, `final-stats.json`, `summary.csv` — доставленные записи, GAP, p50/p95/p99/max latency, буферы, пробы, миграции и пути каждого субпотока. +- `network.jsonl`, `network-initial.json`, `network-final.json`, `g_*.nft` — точные команды, ошибки, маршруты, tc counters и правила NAT. +- `nXX/utun.log`, `stderr.log`, `rpc.jsonl`, `node.conf`, `db/` — диагностика и состояние каждого процесса. Конфиги содержат **тестовые** приватные ключи. + +Категория `stream=debug`, JOIN/Merkle/BGP/connection — `info`; ошибки остальных категорий сохраняются. Для диагностики очередей можно запросить RPC `{"id":"1","op":"stats","loads":"1"}`; обычные снимки не опрашивают отсутствующие peer-load записи, чтобы не засорять лог. Stdout агента зарезервирован для JSONL. + +Ctrl+C и нормальное завершение закрывают процессы и namespace. PID namespace с `--kill-child` также ограничивает время жизни процессов при аварии launcher; namespace не закрепляются mount-именами на хосте. Файлы результатов сохраняются для анализа. diff --git a/tests/stream_netns/test_isolation.py b/tests/stream_netns/test_isolation.py new file mode 100644 index 00000000..b563d5b2 --- /dev/null +++ b/tests/stream_netns/test_isolation.py @@ -0,0 +1,59 @@ +"""Непривилегированные проверки границы сетевых операций стенда.""" + +import tempfile +import unittest +from unittest.mock import Mock, patch + +from network import Network +from run import stable_snapshot + + +class IsolationTest(unittest.TestCase): + def test_refuse_main_namespace(self): + with tempfile.TemporaryDirectory() as directory, patch("network.os.readlink", return_value="net:[1]"): + with self.assertRaisesRegex(RuntimeError, "new network namespace"): + Network(directory, 1, "net:[1]") + + def test_lost_namespace_never_executes_command(self): + with tempfile.TemporaryDirectory() as directory, patch("network.os.readlink", return_value="net:[2]"): + network = Network(directory, 1, "net:[1]") + keeper = Mock(pid=42) + keeper.poll.return_value = 0 + network.namespaces["n01"] = (keeper, "net:[3]") + with patch("network.subprocess.run") as command: + with self.assertRaisesRegex(RuntimeError, "no longer owned"): + network.command("n01", ["ip", "link", "add", "x", "type", "dummy"]) + command.assert_not_called() + network.close() + + def test_controller_reentry_never_executes_command(self): + with tempfile.TemporaryDirectory() as directory, patch("network.os.readlink", return_value="net:[2]"): + network = Network(directory, 1, "net:[1]") + with patch("network.os.readlink", return_value="net:[1]"), patch("network.subprocess.run") as command: + with self.assertRaisesRegex(RuntimeError, "Controller changed"): + network.command(None, ["ip", "link", "add", "x", "type", "dummy"]) + command.assert_not_called() + network.close() + + def test_namespace_inode_replacement_never_executes_command(self): + with tempfile.TemporaryDirectory() as directory, patch("network.os.readlink", return_value="net:[2]"): + network = Network(directory, 1, "net:[1]") + keeper = Mock(pid=42) + keeper.poll.return_value = None + network.namespaces["n01"] = (keeper, "net:[3]") + with patch("network.os.readlink", side_effect=["net:[2]", "net:[4]"]), patch("network.subprocess.run") as command: + with self.assertRaisesRegex(RuntimeError, "no longer owned"): + network.command("n01", ["nft", "flush", "ruleset"]) + command.assert_not_called() + network.close() + + def test_lifetime_ignored_but_route_change_detected(self): + a = {"addr_info": [{"local": "192.0.2.1", "valid_life_time": 500}], "route": "192.0.2.0/24"} + b = {"addr_info": [{"local": "192.0.2.1", "valid_life_time": 400}], "route": "192.0.2.0/24"} + self.assertEqual(stable_snapshot(a), stable_snapshot(b)) + b["route"] = "0.0.0.0/0" + self.assertNotEqual(stable_snapshot(a), stable_snapshot(b)) + + +if __name__ == "__main__": + unittest.main()