test(#21): netns-стенд — топология client/gw/uplink, lib и точка входа

Основы харнесса для тестов без реального VPN-провайдера:
- lib.sh — конфигурация стенда (netns, адреса, fwmark/таблицы) и правила
  netfilter с поддержкой nft и iptables (в alpine-образе приложения nft нет);
- topology.sh — up/down/status/killswitch: veth-пары client<->gw<->uplink,
  ip_forward, ip rule fwmark 51820 -> table 51820, метки OUTPUT/FORWARD,
  DROP kill-switch для прямого выхода в «публичную» сеть;
- run.sh — preflight, запуск мока и детектора утечки в netns uplink, прогон
  тестов, снос стенда с проверкой отсутствия остатков.

Refs #21
Refs #15
This commit is contained in:
2026-10-21 23:47:48 +03:00
parent 1db3824cb4
commit 79cbcab397
3 changed files with 502 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# lib.sh — общие настройки и утилиты netns-стенда awg-dash (задача #21, эпик #15).
#
# Подключается через `source` из topology.sh / run.sh. Ничего не выполняет при
# подключении, ничего не создаёт — только конфигурация и вспомогательные функции.
#
# Все имена/адреса переопределяются переменными окружения (см. tests/netns/README.md).
# shellcheck shell=bash
# --- имена сетевых namespace'ов ---------------------------------------------
: "${AWG_NETNS_PREFIX:=awgt21}"
: "${AWG_NS_CLIENT:=${AWG_NETNS_PREFIX}-client}" # LAN-клиент за шлюзом
: "${AWG_NS_GW:=${AWG_NETNS_PREFIX}-gw}" # хост со шлюзом awg-dash
: "${AWG_NS_UPLINK:=${AWG_NETNS_PREFIX}-uplink}" # «ISP» + мок WireGuard-endpoint
# --- адресация --------------------------------------------------------------
: "${AWG_NET_CLIENT:=10.213.0.0/24}"
: "${AWG_CLIENT_IP:=10.213.0.2}"
: "${AWG_GW_LAN_IP:=10.213.0.1}"
: "${AWG_NET_WAN:=10.214.0.0/24}"
: "${AWG_GW_WAN_IP:=10.214.0.1}"
: "${AWG_UPLINK_IP:=10.214.0.2}"
# «Провайдер»/мок-endpoint: порт как в wg-quick.
: "${AWG_MOCK_PORT:=51820}"
# Адрес из TEST-NET-2 (RFC 5737) — «публичный» получатель для детектора утечки.
: "${AWG_LEAK_IP:=198.51.100.9}"
: "${AWG_LEAK_PORT:=9999}"
: "${AWG_LEAK_NET:=198.51.100.0/24}"
# --- policy routing ---------------------------------------------------------
# Метка и таблица как в wg-quick: fwmark 51820 -> table 51820.
: "${AWG_FWMARK:=51820}"
: "${AWG_ROUTE_TABLE:=51820}"
# --- имена интерфейсов и константы стенда -----------------------------------
: "${AWG_VETH_CLIENT:=veth-c-gw}"
: "${AWG_VETH_GW_LAN:=veth-gw-lan}"
: "${AWG_VETH_GW_WAN:=veth-gw-wan}"
: "${AWG_VETH_UPLINK:=veth-uplink}"
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"
# Рабочий каталог стенда (логи мока, sink'а, отчёты). Задаётся run.sh.
: "${AWG_NETNS_WORKDIR:=/tmp/awg-netns}"
# --- вывод ------------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
_c_red=$'\033[31m'; _c_grn=$'\033[32m'; _c_yel=$'\033[33m'; _c_dim=$'\033[2m'; _c_off=$'\033[0m'
else
_c_red=''; _c_grn=''; _c_yel=''; _c_dim=''; _c_off=''
fi
log() { printf '%s[netns]%s %s\n' "$_c_dim" "$_c_off" "$*"; }
ok() { printf '%s[ ok ]%s %s\n' "$_c_grn" "$_c_off" "$*"; }
warn() { printf '%s[warn]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; }
fail() { printf '%s[fail]%s %s\n' "$_c_red" "$_c_off" "$*" >&2; exit 1; }
# Код возврата 3 — «заблокировано окружением» (нет root/CAP_NET_ADMIN/ip),
# см. deploy/dev-stand.md. 4 — внутренняя ошибка стенда.
blocked() { printf '%s[blocked]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; exit 3; }
# --- предусловия ------------------------------------------------------------
require_root() {
[ "$(id -u)" -eq 0 ] || blocked "нужен root (или CAP_NET_ADMIN): запустите под sudo"
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || blocked "нет утилиты '$1' в PATH"
}
# Проверяем, что ядро/контейнер вообще позволяют netns (иначе AC невыполним).
require_netns_capability() {
local probe="${AWG_NETNS_PREFIX}-probe"
if ! ip netns add "$probe" 2>/dev/null; then
blocked "не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN (запустите контейнер с --privileged)"
fi
ip netns del "$probe" 2>/dev/null || true
}
ns_exists() { ip netns list | awk '{print $1}' | grep -qx "$1"; }
# --- netfilter: метки policy routing и kill-switch --------------------------
# Стенд работает и на nftables (хост Debian/Ubuntu с nft), и на iptables
# (alpine-образ приложения: iptables без бинаря nft). Backend выбирается по
# наличию бинаря в netns шлюза; семантика правил одинаковая.
fw_kind() {
# AWG_FW_BACKEND=nft|iptables|auto — принудительный выбор backend (в alpine-образе
# приложения есть только iptables, на хосте обычно nft).
local want="${AWG_FW_BACKEND:-auto}"
case "$want" in
nft)
ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; }
echo none; return
;;
iptables)
ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; }
echo none; return
;;
esac
# command -v — builtin shell, поэтому проверяем через sh -c внутри netns
if ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then
echo nft
elif ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then
echo iptables
else
echo none
fi
}
fw_available() {
local kind; kind="$(fw_kind)"
case "$kind" in
nft) ip netns exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;;
iptables) ip netns exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
# Правило метки для трафика шлюза (OUTPUT) и для транзитного трафика LAN (FORWARD).
fw_setup() {
local kind; kind="$(fw_kind)"
case "$kind" in
nft)
ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
ip netns exec "$AWG_NS_GW" nft -f - <<EOF
table inet $NFT_TABLE {
chain mark_out {
type route hook output priority mangle; policy accept;
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_OUT"
return
}
chain mark_fwd {
type filter hook forward priority mangle; policy accept;
udp dport $AWG_MOCK_PORT counter meta mark set $AWG_FWMARK comment "$C_MARK_FWD"
}
chain forward_ks {
type filter hook forward priority filter; policy accept;
ip saddr $AWG_NET_CLIENT ip daddr $AWG_LEAK_NET counter drop comment "$C_DROP"
}
}
EOF
;;
iptables)
local ipt="ip netns exec $AWG_NS_GW iptables"
$ipt -t mangle -N "$IPT_CHAIN_MARK" 2>/dev/null || true
$ipt -t mangle -F "$IPT_CHAIN_MARK"
$ipt -t mangle -A "$IPT_CHAIN_MARK" -p udp --dport "$AWG_MOCK_PORT" \
-m comment --comment "$C_MARK_OUT" -j MARK --set-mark "$AWG_FWMARK"
$ipt -t mangle -C OUTPUT -j "$IPT_CHAIN_MARK" 2>/dev/null \
|| $ipt -t mangle -A OUTPUT -j "$IPT_CHAIN_MARK"
$ipt -t mangle -N "$IPT_CHAIN_FWD" 2>/dev/null || true
$ipt -t mangle -F "$IPT_CHAIN_FWD"
$ipt -t mangle -A "$IPT_CHAIN_FWD" -p udp --dport "$AWG_MOCK_PORT" \
-m comment --comment "$C_MARK_FWD" -j MARK --set-mark "$AWG_FWMARK"
$ipt -t mangle -C FORWARD -j "$IPT_CHAIN_FWD" 2>/dev/null \
|| $ipt -t mangle -A FORWARD -j "$IPT_CHAIN_FWD"
$ipt -t filter -N "$IPT_CHAIN_KS" 2>/dev/null || true
$ipt -t filter -F "$IPT_CHAIN_KS"
$ipt -t filter -A "$IPT_CHAIN_KS" -s "$AWG_NET_CLIENT" -d "$AWG_LEAK_NET" \
-m comment --comment "$C_DROP" -j DROP
$ipt -t filter -C FORWARD -j "$IPT_CHAIN_KS" 2>/dev/null \
|| $ipt -t filter -A FORWARD -j "$IPT_CHAIN_KS"
;;
*)
fail "нет ни nft, ни iptables в ${AWG_NS_GW} — стенд не может проверить маркировку"
;;
esac
log "правила меток/kill-switch установлены (backend: $kind)"
}
# killswitch on|off — включить/выключить fail-closed правило FORWARD.
fw_set_killswitch() {
local state="$1" kind; kind="$(fw_kind)"
case "$kind:$state" in
nft:on)
ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true
fw_setup
;;
nft:off)
ip netns exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks
;;
iptables:on)
fw_setup
;;
iptables:off)
ip netns exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS"
;;
*)
fail "fw_set_killswitch: неизвестное состояние '$state' (ожидается on|off)"
;;
esac
}