Files
awg-dash/tests/netns/netns_common.py
embeddevio 4aa69132ad netns: nsenter в topology.sh, netns_common и документации (#56)
- topology.sh: ns_exec для всех ip -n <ns> и ip netns exec, коды 0/1/3/4.
- netns_common.py: ns_exec_mode()/ns_exec_direct(), addrs/links/link_is_up через ns_sh.
- docker-compose.test.yml: проброс AWG_NS_EXEC_MODE в netns-сервис.
- tests/netns/README.md: режимы входа в netns, коды возврата, новый pitfall.

Refs #21, Refs #15, Refs #56
2026-10-28 21:30:22 +03:00

301 lines
12 KiB
Python
Raw Permalink 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 shutil
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):
"""Ошибка стенда (не теста): топология/утилиты не отвечают как ожидается."""
# Кэш выбранного режима входа в netns (ip|nsenter), см. ns_exec_mode().
_NS_EXEC_MODE: str | None = None
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_exec_direct(
mode: str,
ns: str,
args: Iterable[str],
check: bool = True,
timeout: float = 20.0,
) -> subprocess.CompletedProcess:
"""Одна попытка входа в netns выбранным способом (ip|nsenter)."""
cmd = (["nsenter", f"--net=/var/run/netns/{ns}"] if mode == "nsenter" else ["ip", "netns", "exec", ns]) + list(args)
return sh(cmd, check=check, timeout=timeout)
def _probe_ns_exec(mode: str) -> bool:
probe = os.environ.get("AWG_NETNS_PREFIX", "awgt21") + "-probe"
if sh(["ip", "netns", "add", probe], check=False).returncode != 0:
return False
try:
return ns_exec_direct(mode, probe, ["sh", "-c", "true"], check=False, timeout=10.0).returncode == 0
finally:
sh(["ip", "netns", "delete", probe], check=False)
def ns_exec_mode() -> str:
"""Режим входа в netns: ip | nsenter (issue #56).
`ip netns exec` (iproute2 6.x) перемонтирует /sys и в контейнере падает с
"mount of /sys failed: Operation not permitted"; nsenter входит в
/var/run/netns/<ns> без перемонтирования. Режим кэшируется на процесс,
AWG_NS_EXEC_MODE=ip|nsenter|auto переопределяет выбор.
"""
global _NS_EXEC_MODE
if _NS_EXEC_MODE is None:
want = os.environ.get("AWG_NS_EXEC_MODE", "auto")
if want in ("ip", "nsenter"):
_NS_EXEC_MODE = want
elif _probe_ns_exec("ip"):
_NS_EXEC_MODE = "ip"
elif shutil.which("nsenter") and _probe_ns_exec("nsenter"):
_NS_EXEC_MODE = "nsenter"
else:
raise StandError(
"ни 'ip netns exec', ни 'nsenter --net' не работают в этом окружении "
"(нет CAP_SYS_ADMIN/NET_ADMIN) — issue #56"
)
return _NS_EXEC_MODE
def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
"""Запуск команды внутри указанного netns (ip или nsenter — см. ns_exec_mode)."""
return ns_exec_direct(ns_exec_mode(), 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 ns_sh(ns, ["ip", "-br", "addr", "show"]).stdout
def links(ns: str) -> str:
return ns_sh(ns, ["ip", "-br", "link", "show"]).stdout
def link_is_up(ns: str, ifname: str) -> bool:
cp = ns_sh(ns, ["ip", "-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"]