Sprint 0 / #21: dev/staging стенд — docker-compose, тома, netns-харнесс #53

Merged
embeddevio merged 30 commits from feat/21-dev-stand into main 2026-10-28 22:12:03 +03:00
4 changed files with 109 additions and 32 deletions
Showing only changes of commit 4aa69132ad - Show all commits
+3
View File
@@ -31,6 +31,9 @@ services:
AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21} AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21}
# nft в образе приложения нет — там iptables (см. tests/netns/README.md) # nft в образе приложения нет — там iptables (см. tests/netns/README.md)
AWG_FW_BACKEND: ${AWG_FW_BACKEND:-auto} 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: tmpfs:
- /tmp:mode=1777 - /tmp:mode=1777
logging: logging:
+16 -2
View File
@@ -35,6 +35,7 @@ make test-netns # поднять стенд, прогнать т
make test-netns-keep # оставить топологию и логи для отладки make test-netns-keep # оставить топологию и логи для отладки
make test-netns NETNS_ARGS="--no-tests" # только поднять стенд make test-netns NETNS_ARGS="--no-tests" # только поднять стенд
make test-netns-ci # то же в привилегированном контейнере (путь для CI) make test-netns-ci # то же в привилегированном контейнере (путь для CI)
# в контейнере вход в netns идёт через nsenter (issue #56)
bash tests/netns/topology.sh status # состояние топологии bash tests/netns/topology.sh status # состояние топологии
bash tests/netns/topology.sh down # снести топологию вручную 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`; Требования: Linux, **root** (или CAP_NET_ADMIN + CAP_SYS_ADMIN), `ip`, `python3`;
backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только
`iptables`, на Debian/Ubuntu — оба). Код возврата `3` означает «заблокировано `iptables`, на Debian/Ubuntu — оба).
окружением»: нет прав на создание netns.
Коды возврата: `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/<ns>`; режим форсируется `AWG_NS_EXEC_MODE`.
## Состав ## Состав
@@ -85,11 +92,18 @@ backend netfilter — `nft` или `iptables` (в alpine-образе прило
| `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing | | `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing |
| `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение | | `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение |
| `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда | | `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда |
| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56 |
## Pitfalls ## Pitfalls
* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе * **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе
`run.sh` завершится кодом 3 с сообщением «заблокировано окружением». `run.sh` завершится кодом 3 с сообщением «заблокировано окружением».
* **`ip netns exec` и `ip -n <ns>` в контейнере не работают** (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>` заменены на `ns_exec <ns> ip ...` (issue #56). Форсировать режим:
`AWG_NS_EXEC_MODE=nsenter|ip`.
* **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт * **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт
асимметрию reverse-пути, strict-режим молча дропает ответы. асимметрию reverse-пути, strict-режим молча дропает ответы.
* **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard * **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard
+57 -5
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
import os import os
import pathlib import pathlib
import shutil
import subprocess import subprocess
import time import time
from typing import Any, Iterable 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: def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
"""Запуск команды в корневом netns.""" """Запуск команды в корневом netns."""
cp = subprocess.run( cp = subprocess.run(
@@ -75,9 +80,56 @@ def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subpro
return cp 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: def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess:
"""Запуск команды внутри указанного netns.""" """Запуск команды внутри указанного netns (ip или nsenter — см. ns_exec_mode)."""
return sh(["ip", "netns", "exec", ns, *args], check=check, timeout=timeout) return ns_exec_direct(ns_exec_mode(), ns, args, check=check, timeout=timeout)
def ns_exists(ns: str) -> bool: def ns_exists(ns: str) -> bool:
@@ -86,15 +138,15 @@ def ns_exists(ns: str) -> bool:
def addrs(ns: str) -> str: 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: 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: 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 return cp.returncode == 0 and "UP" in cp.stdout
+33 -25
View File
@@ -15,6 +15,11 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=tests/netns/lib.sh # shellcheck source=tests/netns/lib.sh
source "$HERE/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() { topology_present() {
ns_exists "$AWG_NS_CLIENT" && ns_exists "$AWG_NS_GW" && ns_exists "$AWG_NS_UPLINK" 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 ip
require_cmd python3 require_cmd python3
require_netns_capability 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" log "создаю netns: client=$AWG_NS_CLIENT gw=$AWG_NS_GW uplink=$AWG_NS_UPLINK"
topology_down quiet topology_down quiet
@@ -45,44 +53,44 @@ topology_up() {
local ns local ns
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
ip netns add "$ns" ip netns add "$ns"
ip -n "$ns" link set lo up ns_exec "$ns" ip link set lo up
done done
# --- client <-> gw (LAN) -------------------------------------------------- # --- client <-> gw (LAN) --------------------------------------------------
ip link add "$AWG_VETH_CLIENT" type veth peer name "$AWG_VETH_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_CLIENT" netns "$AWG_NS_CLIENT"
ip link set "$AWG_VETH_GW_LAN" netns "$AWG_NS_GW" 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" ns_exec "$AWG_NS_CLIENT" ip addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT"
ip -n "$AWG_NS_CLIENT" link set "$AWG_VETH_CLIENT" up ns_exec "$AWG_NS_CLIENT" ip link set "$AWG_VETH_CLIENT" up
ip -n "$AWG_NS_GW" addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" ns_exec "$AWG_NS_GW" ip 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_GW" ip link set "$AWG_VETH_GW_LAN" up
# --- gw <-> uplink (WAN) -------------------------------------------------- # --- gw <-> uplink (WAN) --------------------------------------------------
ip link add "$AWG_VETH_GW_WAN" type veth peer name "$AWG_VETH_UPLINK" 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_GW_WAN" netns "$AWG_NS_GW"
ip link set "$AWG_VETH_UPLINK" netns "$AWG_NS_UPLINK" 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" ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN"
ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_WAN" up ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_WAN" up
ip -n "$AWG_NS_UPLINK" addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" ns_exec "$AWG_NS_UPLINK" ip 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_UPLINK" ip link set "$AWG_VETH_UPLINK" up
# --- forwarding и rp_filter в шлюзе --------------------------------------- # --- 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-пути # loose rp_filter: маркированная политика даёт асимметрию reverse-пути
ip netns 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.all.rp_filter=2
ip netns 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_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.${AWG_VETH_GW_WAN}.rp_filter=2"
# --- маршруты ------------------------------------------------------------- # --- маршруты -------------------------------------------------------------
ip -n "$AWG_NS_CLIENT" route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" ns_exec "$AWG_NS_CLIENT" ip 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 ns_exec "$AWG_NS_GW" ip 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_UPLINK" ip route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK"
# «публичный» адрес-приманка для детектора утечки (RFC 5737 TEST-NET-2) # «публичный» адрес-приманка для детектора утечки (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 -> аплинк ---------------- # --- 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" ns_exec "$AWG_NS_GW" ip 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 rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE"
# --- netfilter: метки + kill-switch --------------------------------------- # --- netfilter: метки + kill-switch ---------------------------------------
fw_setup fw_setup
@@ -102,18 +110,18 @@ topology_status() {
local ns local ns
for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do
printf '\n[%s] links:\n' "$ns" printf '\n[%s] links:\n' "$ns"
ip -n "$ns" -br addr show || rc=1 ns_exec "$ns" ip -br addr show || rc=1
done done
printf '\n[%s] ip rule:\n' "$AWG_NS_GW" 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" 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" printf '\n[%s] netfilter:\n' "$AWG_NS_GW"
if [ "$(fw_kind)" = nft ]; then 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 else
ip netns exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 ns_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 filter -S FORWARD 2>/dev/null || rc=1
fi fi
return $rc return $rc
} }