Files
vpsetup/tests/run_firewall_tests.sh
eli 1d5af2248b fix(firewall): forward 链改回"作用域白名单 + 默认丢弃"
之前把 forward 链清空成 policy accept 是错的——那不是标准,是相对基线的
**安全退化**。基线(init-iptables.sh)本来就是:

    -A FORWARD -o podman+ -j ACCEPT
    -A FORWARD -j REJECT

配合 net.ipv4.ip_forward=1,清空 forward 等于让主机变成转发器。

现在按基线语义实现:

    chain forward {
        type filter hook forward priority filter; policy drop;
        ct state established,related accept
        iifname/oifname "wt0" accept      # NetBird 覆盖网
        iifname/oifname "zt*" accept      # ZeroTier 覆盖网
        iifname/oifname "podman*" accept  # Podman
        iifname/oifname "docker0" accept
        iifname/oifname "br-*" accept
    }

两处必须显式列出接口的原因(都实测过):

1. **容器发布端口的入站在 forward 里没有任何现成规则可匹配**。实测
   NETAVARK_FORWARD 只有 `saddr 10.89.0.0/24 accept`(出站)和
   `daddr 10.89.0.0/24 established accept`(回程),**没有发布端口的入站
   accept**。基线是靠 `-o podman+ ACCEPT` 放行的,这里同理用 oifname 匹配。

2. **NetBird/ZeroTier 会自己往 `ip filter FORWARD` 写规则**。实测 oraclejp
   的 `ip filter FORWARD` 里有 netbird 加的 `iifname "wt0" accept`。本表的
   `policy drop` 是终局裁决,不同步放行就会把 VPN 转发打死。

验证(oraclezyus-arm):SSH/80/443/容器 9000 全通、容器 DNS 与出站正常、
容器到宿主机 443 正常。测试 77 → 79 项全绿。
2026-11-11 12:24:44 +08:00

238 lines
12 KiB
Bash
Executable File
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 bash
#
# test-init-firewall.sh — init-firewall.sh 的 dry-run 测试
#
# 全部用 --local --dry-run,只生成规则文本,绝不调用 nft/sudo/ssh,可以在任何机器上跑。
# 唯一的例外是 nft -c 语法校验:需要 CAP_NET_ADMIN,没有权限时自动跳过。
#
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
SCRIPT=./init-firewall.sh
BASELINE=./dsh-firewall.nft # 现网在跑的规则,作为默认 profile 的黄金样本
OUT="$(mktemp -d)"
trap 'rm -rf "$OUT"' EXIT
PASS=0
FAIL=0
fail() { echo "[FAIL] $*" >&2; FAIL=$((FAIL+1)); }
pass() { PASS=$((PASS+1)); }
ok() { pass; echo "[ ok ] $*"; }
# 断言生成文件包含/不包含某字符串
assert_contains() { # <文件> <字符串> <说明>
if grep -qF -- "$2" "$1"; then ok "$3"; else fail "$3:应包含 [$2],实际没有"; fi
}
assert_not_contains() { # <文件> <字符串> <说明>
if grep -qF -- "$2" "$1"; then fail "$3:不应包含 [$2],实际有"; else ok "$3"; fi
}
# 干跑一次,把 dry-run 输出里的 nft 规则剥出来存成 $OUT/<name>.nft
# 只认第一行 " #!/usr/sbin/nft -f" 之后的内容,前面的横幅摘要一律丢掉。
gen() { # <名称> [参数...]
local name="$1"; shift
local rc=0
"$SCRIPT" --local --dry-run "$@" > "$OUT/$name.raw" 2>&1 || rc=$?
awk '/^ #!\/usr\/sbin\/nft/{f=1} f{sub(/^ /,"");print}' "$OUT/$name.raw" > "$OUT/$name.nft"
return "$rc"
}
# ---------------------------------------------------------------------------
# 0. 静态检查
# ---------------------------------------------------------------------------
if bash -n "$SCRIPT"; then ok "bash -n 语法检查"; else fail "bash -n 未通过"; fi
if command -v shellcheck >/dev/null 2>&1; then
if shellcheck "$SCRIPT"; then ok "shellcheck"; else fail "shellcheck 未通过"; fi
else
echo "[skip] shellcheck 未安装"
fi
# ---------------------------------------------------------------------------
# 1. 默认 profile 必须与现网 dsh-firewall.nft 逐条一致
# 这是最关键的一条:老机器重跑脚本不能有任何规则变化。
# ---------------------------------------------------------------------------
gen default
grep -v '^[[:space:]]*#' "$BASELINE" | grep -v '^[[:space:]]*$' > "$OUT/baseline.norm"
grep -v '^[[:space:]]*#' "$OUT/default.nft" | grep -v '^[[:space:]]*$' > "$OUT/default.norm"
if diff -u "$OUT/baseline.norm" "$OUT/default.norm" > "$OUT/default.diff" 2>&1; then
ok "默认 profile 与现网 $BASELINE 逐条一致(重跑为 no-op)"
else
fail "默认 profile 与现网规则不一致:"
sed 's/^/ /' "$OUT/default.diff" >&2
fi
# ---------------------------------------------------------------------------
# 2. 四个 profile 的端口集合
# ---------------------------------------------------------------------------
gen proxy --profile proxy
assert_contains "$OUT/proxy.nft" 'tcp dport 80 ct state new accept' "proxy 放行 80/tcp"
assert_contains "$OUT/proxy.nft" 'tcp dport 443 ct state new accept' "proxy 放行 443/tcp"
assert_contains "$OUT/proxy.nft" 'udp dport 443 accept' "proxy 放行 443/udp"
assert_contains "$OUT/proxy.nft" 'udp dport 8443 accept' "proxy 放行 8443/udp"
assert_contains "$OUT/proxy.nft" 'udp dport 20000-30000 accept' "proxy 放行端口跳跃"
gen hy2 --profile hy2
assert_not_contains "$OUT/hy2.nft" 'tcp dport 80 ' "hy2 不放行 80/tcp"
assert_not_contains "$OUT/hy2.nft" 'tcp dport 443 ' "hy2 不放行 443/tcp"
assert_not_contains "$OUT/hy2.nft" 'udp dport 443 ' "hy2 不放行 443/udp"
assert_contains "$OUT/hy2.nft" 'udp dport 8443 accept' "hy2 放行 8443/udp"
assert_contains "$OUT/hy2.nft" 'udp dport 20000-30000 accept' "hy2 放行端口跳跃"
gen web --profile web
assert_contains "$OUT/web.nft" 'tcp dport 443 ct state new accept' "web 放行 443/tcp"
assert_contains "$OUT/web.nft" 'udp dport 443 accept' "web 默认放行 443/udp"
assert_not_contains "$OUT/web.nft" 'udp dport 8443 ' "web 不放行 hy2"
assert_not_contains "$OUT/web.nft" '20000-30000' "web 不放行端口跳跃"
gen minimal --profile minimal
assert_contains "$OUT/minimal.nft" 'tcp dport 22 ct state new accept' "minimal 只放 SSH"
assert_not_contains "$OUT/minimal.nft" 'dport 80 ' "minimal 不放行 80"
assert_not_contains "$OUT/minimal.nft" 'dport 443 ' "minimal 不放行 443"
assert_not_contains "$OUT/minimal.nft" '8443' "minimal 不放行 hy2"
# ---------------------------------------------------------------------------
# 3. 端口/范围开关
# ---------------------------------------------------------------------------
gen sshport --profile minimal --ssh-port 26320
assert_contains "$OUT/sshport.nft" 'tcp dport 26320 ct state new accept' "--ssh-port 生效"
assert_not_contains "$OUT/sshport.nft" 'tcp dport 22 ' "--ssh-port 后不再放行 22"
gen hy2port --profile hy2 --hy2-port 9443
assert_contains "$OUT/hy2port.nft" 'udp dport 9443 accept' "--hy2-port 生效"
assert_not_contains "$OUT/hy2port.nft" 'udp dport 8443 ' "--hy2-port 后不再放行 8443"
gen nohop --profile hy2 --no-hy2-hop
assert_not_contains "$OUT/nohop.nft" '20000-30000' "--no-hy2-hop 关掉跳跃范围"
gen customhop --profile hy2 --hy2-hop 30000-40000
assert_contains "$OUT/customhop.nft" 'udp dport 30000-40000 accept' "--hy2-hop 自定义范围生效"
gen nohttp3 --profile web --no-http3
assert_not_contains "$OUT/nohttp3.nft" 'udp dport 443 ' "--no-http3 关掉 443/udp"
gen frps --profile minimal --enable-frps
assert_contains "$OUT/frps.nft" 'tcp dport 7102 ct state new accept' "frps TCP 放行"
assert_contains "$OUT/frps.nft" 'udp dport 7001 accept' "frps UDP 7001 放行"
gen xmpp --profile minimal --enable-xmpp
assert_contains "$OUT/xmpp.nft" 'tcp dport 5222 ct state new accept' "XMPP 放行"
gen noflags --profile minimal
assert_not_contains "$OUT/noflags.nft" '7102' "默认不开 frps"
assert_not_contains "$OUT/noflags.nft" '5222' "默认不开 XMPP"
# ---------------------------------------------------------------------------
# 4. 额外端口(可重复、逗号/空格分隔、区间)
# ---------------------------------------------------------------------------
gen extra --profile minimal --extra-tcp 8080,9090 --extra-tcp '9000-9100' --extra-udp 53
assert_contains "$OUT/extra.nft" 'tcp dport { 8080, 9090 } ct state new accept' "--extra-tcp 逗号列表"
assert_contains "$OUT/extra.nft" 'tcp dport { 9000-9100 } ct state new accept' "--extra-tcp 重复使用"
assert_contains "$OUT/extra.nft" 'udp dport { 53 } accept' "--extra-udp"
gen extraspace --profile minimal --extra-tcp '8080 9090'
assert_contains "$OUT/extraspace.nft" 'tcp dport { 8080, 9090 } ct state new accept' "--extra-tcp 空格分隔"
# ---------------------------------------------------------------------------
# 5. 安全不变式:任何 profile/开关组合都不能丢掉 REJECT 兜底
# ---------------------------------------------------------------------------
for f in default proxy hy2 web minimal extra frps xmpp nohop nohttp3; do
t="$OUT/$f.nft"
if ! grep -qx ' reject' "$t"; then
fail "$f 缺少兜底 reject"; continue
fi
# reject 必须是 input 链的最后一条:下一行只能是 chain 的收尾 " }"
nxt="$(grep -A1 -x ' reject' "$t" | sed -n '2p')"
if [ "$nxt" = " }" ]; then ok "$f 白名单后仍有 REJECT 兜底"; else fail "$f 的 reject 不在链尾(下一行是 '$nxt')"; fi
assert_contains "$t" 'ip6 nexthdr udp reject with icmpv6 port-unreachable' "$f 有 v6 兜底"
done
# ---------------------------------------------------------------------------
# 6. 非法输入必须 exit 2,且绝不产出半份规则
# ---------------------------------------------------------------------------
bad() { # <说明> [参数...]
local desc="$1"; shift
local rc=0
"$SCRIPT" --local --dry-run "$@" > "$OUT/bad.raw" 2>&1 || rc=$?
if [ "$rc" -ne 2 ]; then fail "$desc:期望 exit 2,实际 $rc"; return; fi
if awk '/^ #!\/usr\/sbin\/nft/{f=1} f' "$OUT/bad.raw" | grep -q .; then
fail "$desc:报错后仍输出了 nft 规则"; return
fi
ok "拒绝 $desc(exit 2,无规则输出)"
}
bad "端口表达式注入" --extra-tcp '443 accept;'
bad "端口越界" --extra-tcp 70000
bad "区间前后颠倒" --extra-tcp 9100-9000
bad "跳跃范围非法" --hy2-hop abc
bad "SSH 端口为 0" --ssh-port 0
bad "SSH 端口超界" --ssh-port 99999
bad "SSH 端口非数字" --ssh-port abc
bad "hy2 端口非数字" --hy2-port 8x
bad "未知 profile" --profile nosuch
bad "未知选项" --bogus-flag
missing() { # <选项> —— 缺参数也必须是 exit 2
local rc=0
"$SCRIPT" --local "$1" > "$OUT/miss.raw" 2>&1 || rc=$?
if [ "$rc" -ne 2 ]; then fail "$1 缺参数:期望 exit 2,实际 $rc"; else ok "$1 缺参数报错"; fi
}
for opt in --profile --ssh-port --hy2-port --hy2-hop --extra-tcp --extra-udp; do missing "$opt"; done
# 没有主机也没 --local
rc=0; "$SCRIPT" > "$OUT/nohost.raw" 2>&1 || rc=$?
if [ "$rc" -ne 2 ]; then fail "既无主机也无 --local 应 exit 2,实际 $rc"; else ok "既无主机也无 --local 时报错"; fi
# ---------------------------------------------------------------------------
# 7. 远程 dry-run 不得发起任何网络操作(给一个必然解析失败的主机名)
# ---------------------------------------------------------------------------
timeout 15 "$SCRIPT" user@invalid.invalid --dry-run > "$OUT/remote.raw" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then
fail "远程 dry-run 期望 exit 0,实际 $rc"
elif grep -q 'ssh\|scp' "$OUT/remote.raw" && grep -qi 'connection\|refused\|resolve' "$OUT/remote.raw"; then
fail "远程 dry-run 试图联网"
else
ok "远程 dry-run 只打印规则、不联网"
fi
# ---------------------------------------------------------------------------
# 8. nft 语法校验(可选:需要 CAP_NET_ADMIN,普通用户下自动跳过)
# ---------------------------------------------------------------------------
if [ "$(id -u)" -eq 0 ] || sudo -n true 2>/dev/null; then
NFT="nft"; [ "$(id -u)" -eq 0 ] || NFT="sudo -n nft"
for f in default proxy hy2 web minimal extra frps xmpp; do
if $NFT -c -f "$OUT/$f.nft" 2>"$OUT/nfterr"; then ok "nft -c 通过:$f"
else fail "nft -c 失败:$f"; sed 's/^/ /' "$OUT/nfterr" >&2; fi
done
else
echo "[skip] nft -c 需要 root/CAP_NET_ADMIN(远端 --apply 时会先跑 nft -c -f 再加载)"
fi
# ---------------------------------------------------------------------------
# 回归:forward 链必须是"作用域白名单 + 默认丢弃"
#
# 基线(init-iptables.sh)是 `-o podman+ ACCEPT` + 链尾 `REJECT`。
# 曾经为了修容器出站把整条 forward 清空成 policy accept —— 那等于把主机变成
# 转发器(ip_forward=1),是相对基线的安全退化。
#
# 同时必须放行 VPN 覆盖网卡:NetBird(wt0) / ZeroTier(zt*) 会自己往
# ip filter FORWARD 写 accept 规则,但本表默认丢弃是终局裁决,不放行就断。
OUTFW=$(bash init-firewall.sh --local --dry-run 2>/dev/null)
FWD=$(printf '%s\n' "$OUTFW" | sed -n '/chain forward {/,/^ *}/p')
if printf '%s\n' "$FWD" | grep -q "policy drop"; then
ok "forward 链默认丢弃(非容器/VPN 转发被拒)"
else
fail "forward 链是 policy accept —— 主机成了转发器"
fi
for ifc in 'podman*' 'br-*' 'wt0'; do
# 用 -F 固定字符串匹配:接口名里的 `*` 是通配符,不能让 grep 当正则量词
if printf '%s\n' "$FWD" | grep -qF "oifname \"$ifc\" accept"; then
ok "forward 放行 oifname $ifc"
else
fail "forward 缺 oifname $ifc —— 容器/VPN 入站会被丢"
fi
done
# ---------------------------------------------------------------------------
echo
echo "通过 $PASS 项,失败 $FAIL 项"
[ "$FAIL" -eq 0 ]