diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index da01e80..b628c12 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -31,6 +31,9 @@ services: AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21} # nft в образе приложения нет — там iptables (см. tests/netns/README.md) AWG_FW_BACKEND: ${AWG_FW_BACKEND:-auto} + # В контейнере `ip netns exec` падает (mount of /sys, issue #56) — харнесс + # сам переключается на nsenter --net; переменная позволяет форсировать режим. + AWG_NS_EXEC_MODE: ${AWG_NS_EXEC_MODE:-auto} tmpfs: - /tmp:mode=1777 logging: diff --git a/tests/netns/README.md b/tests/netns/README.md index e75510f..8458631 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -35,6 +35,7 @@ make test-netns # поднять стенд, прогнать т make test-netns-keep # оставить топологию и логи для отладки make test-netns NETNS_ARGS="--no-tests" # только поднять стенд make test-netns-ci # то же в привилегированном контейнере (путь для CI) + # в контейнере вход в netns идёт через nsenter (issue #56) bash tests/netns/topology.sh status # состояние топологии bash tests/netns/topology.sh down # снести топологию вручную @@ -43,8 +44,14 @@ bash tests/netns/topology.sh killswitch on # включить fail-closed пр Требования: Linux, **root** (или CAP_NET_ADMIN + CAP_SYS_ADMIN), `ip`, `python3`; backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только -`iptables`, на Debian/Ubuntu — оба). Код возврата `3` означает «заблокировано -окружением»: нет прав на создание netns. +`iptables`, на Debian/Ubuntu — оба). + +Коды возврата: `0` — тесты прошли, `1` — тесты упали, `3` — «заблокировано окружением» +(нет прав на создание netns), `4` — ошибка самого стенда (сюда же нормализуются +неожиданные коды утилит, например `255` от `ip netns exec` в контейнере). +Вход в namespace выполняется через `ns_exec()`: `ip netns exec` в контейнере падает +(`mount of /sys failed`, issue #56), поэтому харнесс сам переключается на +`nsenter --net=/var/run/netns/`; режим форсируется `AWG_NS_EXEC_MODE`. ## Состав @@ -85,11 +92,18 @@ backend netfilter — `nft` или `iptables` (в alpine-образе прило | `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing | | `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение | | `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда | +| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56 | ## Pitfalls * **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе `run.sh` завершится кодом 3 с сообщением «заблокировано окружением». +* **`ip netns exec` и `ip -n ` в контейнере не работают** (iproute2 6.x + перемонтирует `/sys`: `mount of /sys failed: Operation not permitted`, rc=255), + хотя `ip netns add` и `nsenter --net=...` работают. Поэтому в скриптах только + `ns_exec`/`ns_prefix` (`lib.sh`) и `ns_exec_mode()` (`netns_common.py`), а все + `ip -n ` заменены на `ns_exec ip ...` (issue #56). Форсировать режим: + `AWG_NS_EXEC_MODE=nsenter|ip`. * **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт асимметрию reverse-пути, strict-режим молча дропает ответы. * **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard diff --git a/tests/netns/netns_common.py b/tests/netns/netns_common.py index 92bf54b..9d3bfd9 100644 --- a/tests/netns/netns_common.py +++ b/tests/netns/netns_common.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import os import pathlib +import shutil import subprocess import time from typing import Any, Iterable @@ -61,6 +62,10 @@ 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( @@ -75,9 +80,56 @@ def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subpro 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/ без перемонтирования. Режим кэшируется на процесс, + 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.""" - return sh(["ip", "netns", "exec", ns, *args], check=check, timeout=timeout) + """Запуск команды внутри указанного 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: @@ -86,15 +138,15 @@ def ns_exists(ns: str) -> bool: def addrs(ns: str) -> str: - return sh(["ip", "-n", ns, "-br", "addr", "show"]).stdout + return ns_sh(ns, ["ip", "-br", "addr", "show"]).stdout def links(ns: str) -> str: - return sh(["ip", "-n", ns, "-br", "link", "show"]).stdout + return ns_sh(ns, ["ip", "-br", "link", "show"]).stdout def link_is_up(ns: str, ifname: str) -> bool: - cp = sh(["ip", "-n", ns, "-br", "link", "show", ifname], check=False) + cp = ns_sh(ns, ["ip", "-br", "link", "show", ifname], check=False) return cp.returncode == 0 and "UP" in cp.stdout diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh index 53a4198..16aa764 100644 --- a/tests/netns/topology.sh +++ b/tests/netns/topology.sh @@ -15,6 +15,11 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/netns/lib.sh source "$HERE/lib.sh" +# Контракт кодов возврата (issue #56): 0 — ок, 1 — проверка не прошла, +# 3 — заблокировано окружением, 4 — ошибка стенда. Всё остальное, что прилетает +# от утилит (например 255 от `ip netns exec`), нормализуем в 4. +trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "topology.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR + topology_present() { ns_exists "$AWG_NS_CLIENT" && ns_exists "$AWG_NS_GW" && ns_exists "$AWG_NS_UPLINK" } @@ -38,6 +43,9 @@ topology_up() { require_cmd ip require_cmd python3 require_netns_capability + NS_EXEC_RESOLVED="$(select_ns_exec_mode)" + export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED" + log "вход в netns: ${NS_EXEC_RESOLVED}" log "создаю netns: client=$AWG_NS_CLIENT gw=$AWG_NS_GW uplink=$AWG_NS_UPLINK" topology_down quiet @@ -45,44 +53,44 @@ topology_up() { local ns for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do ip netns add "$ns" - ip -n "$ns" link set lo up + ns_exec "$ns" ip link set lo up done # --- client <-> gw (LAN) -------------------------------------------------- ip link add "$AWG_VETH_CLIENT" type veth peer name "$AWG_VETH_GW_LAN" ip link set "$AWG_VETH_CLIENT" netns "$AWG_NS_CLIENT" ip link set "$AWG_VETH_GW_LAN" netns "$AWG_NS_GW" - ip -n "$AWG_NS_CLIENT" addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT" - ip -n "$AWG_NS_CLIENT" link set "$AWG_VETH_CLIENT" up - ip -n "$AWG_NS_GW" addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" - ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_LAN" up + ns_exec "$AWG_NS_CLIENT" ip addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT" + ns_exec "$AWG_NS_CLIENT" ip link set "$AWG_VETH_CLIENT" up + ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" + ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_LAN" up # --- gw <-> uplink (WAN) -------------------------------------------------- ip link add "$AWG_VETH_GW_WAN" type veth peer name "$AWG_VETH_UPLINK" ip link set "$AWG_VETH_GW_WAN" netns "$AWG_NS_GW" ip link set "$AWG_VETH_UPLINK" netns "$AWG_NS_UPLINK" - ip -n "$AWG_NS_GW" addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN" - ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_WAN" up - ip -n "$AWG_NS_UPLINK" addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" - ip -n "$AWG_NS_UPLINK" link set "$AWG_VETH_UPLINK" up + ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN" + ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_WAN" up + ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" + ns_exec "$AWG_NS_UPLINK" ip link set "$AWG_VETH_UPLINK" up # --- forwarding и rp_filter в шлюзе --------------------------------------- - ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1 + ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1 # loose rp_filter: маркированная политика даёт асимметрию reverse-пути - ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2 - ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2" - ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2" + ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2 + ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2" + ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2" # --- маршруты ------------------------------------------------------------- - ip -n "$AWG_NS_CLIENT" route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" - ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100 - ip -n "$AWG_NS_UPLINK" route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK" + ns_exec "$AWG_NS_CLIENT" ip route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" + ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100 + ns_exec "$AWG_NS_UPLINK" ip route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK" # «публичный» адрес-приманка для детектора утечки (RFC 5737 TEST-NET-2) - ip -n "$AWG_NS_UPLINK" addr add "${AWG_LEAK_IP}/32" dev lo + ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_LEAK_IP}/32" dev lo # --- policy routing: fwmark 51820 -> table 51820 -> аплинк ---------------- - ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE" - ip -n "$AWG_NS_GW" rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE" # --- netfilter: метки + kill-switch --------------------------------------- fw_setup @@ -102,18 +110,18 @@ topology_status() { local ns for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do printf '\n[%s] links:\n' "$ns" - ip -n "$ns" -br addr show || rc=1 + ns_exec "$ns" ip -br addr show || rc=1 done printf '\n[%s] ip rule:\n' "$AWG_NS_GW" - ip -n "$AWG_NS_GW" rule show + ns_exec "$AWG_NS_GW" ip rule show printf '\n[%s] table %s:\n' "$AWG_NS_GW" "$AWG_ROUTE_TABLE" - ip -n "$AWG_NS_GW" route show table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip route show table "$AWG_ROUTE_TABLE" printf '\n[%s] netfilter:\n' "$AWG_NS_GW" if [ "$(fw_kind)" = nft ]; then - ip netns exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1 else - ip netns exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 - ip netns exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1 fi return $rc }