test(#21): утилиты стенда, раннер с JSON-отчётом и README харнесса

- netns_common.py — обёртки над ip/nft/iptables: проверка netns и линков,
  счётчики правил меток и kill-switch (nft -j и iptables -v -x), чтение
  JSONL-логов мока и детектора, отправка трафика через probe.py;
- run_tests.py — прогон unittest с JSON-отчётом (pytest не нужен: работает
  и на хосте, и в alpine-образе);
- README.md — схема топологии, таблица тестов, переменные окружения и pitfalls
  (root/CAP_NET_ADMIN, rp_filter, отсутствие nft и ping в образе).

Refs #21
Refs #15
This commit is contained in:
2026-10-21 23:48:22 +03:00
parent 18b911d582
commit 6de43622fa
2 changed files with 317 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
#!/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 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):
"""Ошибка стенда (не теста): топология/утилиты не отвечают как ожидается."""
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_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
"""Запуск команды внутри указанного netns."""
return sh(["ip", "netns", "exec", 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 sh(["ip", "-n", ns, "-br", "addr", "show"]).stdout
def links(ns: str) -> str:
return sh(["ip", "-n", ns, "-br", "link", "show"]).stdout
def link_is_up(ns: str, ifname: str) -> bool:
cp = sh(["ip", "-n", ns, "-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"]