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:
@@ -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"]
|
||||
Reference in New Issue
Block a user