Files
awg-dash/tests/netns/netns_common.py
T
embeddevio 6de43622fa 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
2026-10-21 23:48:22 +03:00

249 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"]