Files

473 lines
24 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
#
# run_tests.sh — setup_caddy_xray_hysteria.sh 的 DRY_RUN 集成测试
# 在 dev 机上以固定测试值干跑主脚本,断言渲染出的配置文件内容正确。
#
set -euo pipefail
cd "$(dirname "$0")/.."
SCRIPT=./setup_caddy_xray_hysteria.sh
export PATH="$HOME/.local/bin:$PATH" # 本地安装的检查工具可能在这里
DRY_DIR="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR"' EXIT
fail() { echo "[FAIL] $*" >&2; exit 1; }
pass() { echo "[ ok ] $*"; }
# 断言文件包含某字符串(grep -F)
assert_contains() { # <文件> <字符串> <说明>
grep -qF -- "$2" "$1" || fail "$3$1 应包含 [$2]"
pass "$3"
}
assert_not_contains() {
if grep -qF -- "$2" "$1"; then fail "$3$1 不应包含 [$2]"; fi
pass "$3"
}
# ---------------------------------------------------------------------------
# 0. 静态检查
# ---------------------------------------------------------------------------
bash -n "$SCRIPT" || fail "bash -n 未通过"
pass "bash -n 语法检查"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck "$SCRIPT" || fail "shellcheck 未通过"
pass "shellcheck"
else
echo "[skip] shellcheck 未安装"
fi
# 回归测试:preflight 在非 DRY_RUN 下必须以零状态返回。
# 事故背景:preflight 最后一行曾是 `[ -n "$DRY_RUN" ] && info ...`DRY_RUN 为空时
# 该 && 列表返回 1,函数返回 1,set -e 在主流程第一句就静默杀掉整个脚本(零输出)。
PREFLIGHT_BODY="$(sed -n '/^preflight() {/,/^}/p' "$SCRIPT")"
MOCK_BIN="$(mktemp -d)"
printf '#!/bin/sh\n[ "${1:-}" = "-u" ] && echo 0 || echo mock\n' > "$MOCK_BIN/id"
printf '#!/bin/sh\nexit 0\n' > "$MOCK_BIN/apt-get"
chmod +x "$MOCK_BIN/id" "$MOCK_BIN/apt-get"
if ! env -i PATH="$MOCK_BIN:/usr/bin:/bin" bash -c "
set -euo pipefail
info() { echo \"[INFO] \$*\"; }
warn() { :; }
die() { echo \"[ERROR] \$*\" >&2; exit 1; }
DRY_RUN=''
$PREFLIGHT_BODY
preflight
"; then
fail "preflight 在非 DRY_RUN 下返回非零(set -e 静默退出回归)"
fi
pass "preflight 非 DRY_RUN 返回零"
# ---------------------------------------------------------------------------
# 固定测试值,干跑主脚本
# ---------------------------------------------------------------------------
run_dry() {
env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR" \
DOMAIN=test.example.com \
EMAIL=test@example.com \
CF_API_TOKEN=cf-test-token \
${CDN_DOMAIN:+CDN_DOMAIN="$CDN_DOMAIN"} \
${XHTTP_PADDING:+XHTTP_PADDING="$XHTTP_PADDING"} \
${XPADDING_HEADER:+XPADDING_HEADER="$XPADDING_HEADER"} \
${XPADDING_KEY:+XPADDING_KEY="$XPADDING_KEY"} \
${ECH_CONFIG:+ECH_CONFIG="$ECH_CONFIG"} \
XRAY_UUID=11111111-1111-1111-1111-111111111111 \
XRAY_WS_UUID=22222222-2222-2222-2222-222222222222 \
XRAY_XHTTP_UUID=33333333-3333-3333-3333-333333333333 \
XRAY_PRIVATE_KEY=testPrivateKey \
XRAY_SHORT_ID=3f9a2b1c \
XHTTP_PATH=/xh88ab \
XHTTP_PORT=8080 \
HY2_PASSWORD=hy2-test-password \
HY2_MASQUERADE_URL=https://engineersblog.net \
ENV_FILE="$DRY_DIR/nonexistent-default.env" \
bash "$SCRIPT" ${1:-}
}
# ---------------------------------------------------------------------------
# Task 2 断言:setup.env
# ---------------------------------------------------------------------------
OUT="$(run_dry)"
[ -f "$DRY_DIR/setup.env" ] || fail "setup.env 未生成"
pass "setup.env 已生成"
assert_contains "$DRY_DIR/setup.env" "DOMAIN=test.example.com" "setup.env 含 DOMAIN"
assert_contains "$DRY_DIR/setup.env" "BASE_DOMAIN=example.com" "BASE_DOMAIN 自动去掉首段子域名"
assert_contains "$DRY_DIR/setup.env" "XRAY_PRIVATE_KEY=testPrivateKey" "setup.env 含私钥"
mode="$(stat -c %a "$DRY_DIR/setup.env")"
[ "$mode" = "600" ] || fail "setup.env 权限应为 600,实际 $mode"
pass "setup.env 权限 600"
# ---------------------------------------------------------------------------
# Task 2 断言:只给必填项,其余自动生成
# ---------------------------------------------------------------------------
DRY_DIR2="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR" "$DRY_DIR2"' EXIT
env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR2" \
DOMAIN=auto.example.org CF_API_TOKEN=tok \
ENV_FILE="$DRY_DIR2/none.env" \
bash "$SCRIPT" >/dev/null
grep -qE '^XRAY_UUID=[0-9a-f-]{36}$' "$DRY_DIR2/setup.env" || fail "XRAY_UUID 应自动生成"
pass "XRAY_UUID 自动生成"
grep -qE '^HY2_PASSWORD=[0-9a-f]{32}$' "$DRY_DIR2/setup.env" || fail "HY2_PASSWORD 应自动生成 32 位 hex"
pass "HY2_PASSWORD 自动生成"
grep -qE '^XHTTP_PATH=/[0-9a-f]{8}$' "$DRY_DIR2/setup.env" || fail "XHTTP_PATH 应自动生成 /8hex"
pass "XHTTP_PATH 自动生成"
grep -qE '^EMAIL=admin@example\.org$' "$DRY_DIR2/setup.env" || fail "EMAIL 默认 admin@BASE_DOMAIN"
pass "EMAIL 默认值"
# 缺 DOMAIN 且无 tty 应报错退出
if env -i PATH="$PATH" HOME="$HOME" DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR2" \
ENV_FILE="$DRY_DIR2/none.env" bash "$SCRIPT" >/dev/null 2>&1; then
fail "缺 DOMAIN 应退出非零"
fi
pass "缺 DOMAIN 报错退出"
# ---------------------------------------------------------------------------
# Task 3 断言:Caddyfile
# ---------------------------------------------------------------------------
CF="$DRY_DIR/Caddyfile"
[ -f "$CF" ] || fail "Caddyfile 未生成"
assert_contains "$CF" "email test@example.com" "Caddyfile email 替换"
assert_contains "$CF" "test.example.com {" "Caddyfile 站点域名替换"
assert_contains "$CF" "path /xh88ab /xh88ab/*" "Caddyfile 含 \@xhttppaths"
assert_contains "$CF" "not path /portainer /portainer/* /scilad /scilad/* /xh88ab /xh88ab/*" \
"\@notpaths 排除 XHTTP 路径"
assert_contains "$CF" "reverse_proxy @xhttppaths 127.0.0.1:8080" "XHTTP 反代到 8080"
assert_contains "$CF" "servers 127.0.0.1:8003" "8003 PROXY protocol 监听"
assert_contains "$CF" "reverse_proxy @websockets 127.0.0.1:54442" "WS 反代到 54442"
assert_contains "$CF" "@acme_challenge path /.well-known/acme-challenge/*" "hy2 HTTP-01 challenge 匹配器"
assert_contains "$CF" "handle @acme_challenge" "challenge 反代 handle"
assert_contains "$CF" "reverse_proxy 127.0.0.1:9180" "challenge 反代到 hy2 altPort"
assert_contains "$CF" "@no_acme_challenge not path /.well-known/acme-challenge/*" "redir 排除 challenge 路径"
assert_contains "$CF" "redir @no_acme_challenge https://{host}:443{uri} permanent" "redir 带匹配器(防通吃)"
assert_contains "$CF" "auto_https disable_redirects" "关闭自动重定向(防运行时注入的路由 308 走 challenge"
assert_not_contains "$CF" "alpn h3" "TCP 监听器不得通告 h3(防客户端 ALPN 误协商挂死)"
assert_not_contains "$CF" "handle_path /scilad" "WS 路由不得剥离 /scilad 前缀(xray path 要匹配)"
assert_not_contains "$CF" "xxx.yyy.zzz" "无残留占位域名"
assert_not_contains "$CF" "__DOMAIN__" "无残留占位符"
assert_not_contains "$CF" "cdn.example.com" "未设 CDN_DOMAIN 时无第二站点块"
# 设 CDN_DOMAIN 时应多出第二个站点块(run_dry 渲染到 $DRY_DIR
CDN_DOMAIN=cdn.example.com run_dry >/dev/null
assert_contains "$DRY_DIR/Caddyfile" "cdn.example.com {" "CDN 站点块存在"
assert_contains "$DRY_DIR/Caddyfile" "test.example.com {" "主站点块仍存在"
# 可选活测试:本机有 caddy 二进制时,验证 80 端口块路由行为
# (事故背景 1bare redir 排在 handle 前通吃 challenge 请求,LE 验证被 301 走;
# 事故背景 2:Caddy 为管理证书的域名在运行时注入自动 HTTP→HTTPS 重定向路由
# ——静态 adapt 输出不可见——把 hy2 的 challenge token 308 到 443。必须带一个
# 被管理证书的站点块才能复现,且全局块必须保留 auto_https disable_redirects
if command -v caddy >/dev/null 2>&1; then
LIVE="$(mktemp -d)"
pkill -f 'MOCK_HY2_TOKEN' 2>/dev/null || true
python3 - "$DRY_DIR/Caddyfile" "$LIVE/Caddyfile" <<'PYEOF'
import sys
content = open(sys.argv[1]).read()
def extract_top_block(text, start):
depth = 0
for i in range(start, len(text)):
if text[i] == '{': depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0: return text[start:i+1]
raise ValueError('unbalanced braces')
# 全局块(含 auto_https disable_redirects8003 重映射避免占用本机端口,
# 补 http_port 使自动重定向注入目标端口与 http:// 站点一致——若有人移除
# disable_redirects,本测试会精确复现线上的 308 而非 caddy 起不来)
gstart = content.index('{')
gblock = extract_top_block(content, gstart).replace('8003', '18003')
gblock = gblock.replace('https_port 18003', 'https_port 18003\n http_port 18088')
# http:// 站点块
hstart = content.index('http:// {')
hblock = extract_top_block(content, hstart).replace('http:// {', 'http://:18088 {', 1).replace('9180', '19180')
# 被管理证书的站点块:触发 Caddy 运行时自动重定向注入,复现线上条件
managed = 'test.example.com {\n\tbind 127.0.0.1\n\ttls internal\n\trespond "managed ok"\n}\n'
open(sys.argv[2], 'w').write(gblock + '\n' + hblock + '\n' + managed)
PYEOF
python3 -c "
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = b'MOCK_HY2_TOKEN'
self.send_response(200); self.send_header('Content-Length', str(len(body))); self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
HTTPServer(('127.0.0.1', 19180), H).serve_forever()
" &
MOCK_PID=$!
caddy run --config "$LIVE/Caddyfile" --adapter caddyfile > "$LIVE/caddy.log" 2>&1 &
CADDY_PID=$!
sleep 2
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Host: test.example.com' 'http://127.0.0.1:18088/.well-known/acme-challenge/TOK' || true)"
[ "$code" = "200" ] || fail "challenge 路径应反代到 hy2(得到 $code,期望 200;若 308 则是 auto_https 自动重定向回归)"
pass "活测试:challenge 路径反代到 hy2(非 301/308"
body="$(curl -s --max-time 5 -H 'Host: test.example.com' \
'http://127.0.0.1:18088/.well-known/acme-challenge/TOK' || true)"
[ "$body" = "MOCK_HY2_TOKEN" ] || fail "challenge 响应应来自 hy2 mock(得到 $body"
pass "活测试:challenge 响应来自 hy2"
loc="$(curl -s -D- -o /dev/null --max-time 5 -H 'Host: test.example.com' \
'http://127.0.0.1:18088/other' | grep -i '^location:' || true)"
echo "$loc" | grep -qF 'https://test.example.com:443/other' || fail "其他路径应 301 到 443(得到 $loc"
pass "活测试:其他路径 301 到 443"
kill "$MOCK_PID" "$CADDY_PID" 2>/dev/null || true
wait "$MOCK_PID" "$CADDY_PID" 2>/dev/null || true
rm -rf "$LIVE"
else
echo "[skip] 无 caddy 二进制,跳过 80 端口块活测试(CADDY 放入 PATH 可启用)"
fi
# ---------------------------------------------------------------------------
# Task 4 断言:xray config.json
# ---------------------------------------------------------------------------
CDN_DOMAIN= run_dry >/dev/null # 重新渲染不带 CDN 的干净版本
XJ="$DRY_DIR/config.json"
[ -f "$XJ" ] || fail "xray config.json 未生成"
python3 -m json.tool "$XJ" >/dev/null || fail "config.json 不是合法 JSON"
pass "config.json 是合法 JSON"
assert_contains "$XJ" '"id": "11111111-1111-1111-1111-111111111111"' "REALITY UUID"
assert_contains "$XJ" '"id": "22222222-2222-2222-2222-222222222222"' "WS UUID"
assert_contains "$XJ" '"id": "33333333-3333-3333-3333-333333333333"' "XHTTP UUID"
assert_contains "$XJ" '"privateKey": "testPrivateKey"' "REALITY 私钥"
assert_contains "$XJ" '"3f9a2b1c"' "shortId"
assert_contains "$XJ" '"test.example.com"' "serverNames"
assert_contains "$XJ" '"dest": "8003"' "REALITY 回落 dest"
assert_contains "$XJ" '"xver": 1' "xver 1PROXY protocol"
assert_contains "$XJ" '"network": "ws"' "WS inbound"
assert_contains "$XJ" '"path": "/scilad"' "WS path"
assert_contains "$XJ" '"network": "xhttp"' "XHTTP inbound"
assert_contains "$XJ" '"port": 8080' "XHTTP 端口"
assert_contains "$XJ" '"path": "/xh88ab"' "XHTTP path"
assert_contains "$XJ" '"mode": "auto"' "XHTTP mode auto"
assert_contains "$XJ" '"xPaddingObfsMode": true' "默认 XHTTP 带 extra/padding 块(XHTTP_PADDING 默认开)"
assert_contains "$XJ" '"bittorrent"' "bittorrent 阻断"
assert_not_contains "$XJ" "__XRAY_VLESS_DECRYPTION__" "无残留占位符"
assert_not_contains "$XJ" "__XRAY_UUID__" "无残留占位符"
assert_not_contains "$XJ" "__XHTTP_EXTRA__" "XHTTP_EXTRA 占位符已展开"
# XHTTP_PADDING=1:服务端渲染 extra 块、链接带 extra= 参数
XHTTP_PADDING=1 XPADDING_HEADER=X-aa XPADDING_KEY=_bb run_dry >/dev/null
XJP="$DRY_DIR/config.json"
assert_contains "$XJP" '"xPaddingObfsMode": true' "padding 开启:extra 块渲染"
assert_contains "$XJP" '"xPaddingHeader": "X-aa"' "padding 开启:header"
assert_contains "$XJP" '"xPaddingKey": "_bb"' "padding 开启:key"
python3 -m json.tool "$XJP" >/dev/null || fail "padding 开启后 config.json 不是合法 JSON"
pass "padding 开启后 JSON 合法"
OUTP="$(XHTTP_PADDING=1 XPADDING_HEADER=X-aa XPADDING_KEY=_bb run_dry)"
echo "$OUTP" | grep -qF '&extra=' || fail "padding 开启:链接应带 extra= 参数"
pass "padding 开启:链接带 extra 参数"
# extra 参数 URL 解码后应是合法 JSON 且含键名
extra_enc="$(printf '%s\n' "$OUTP" | grep -o 'extra=[^#]*' | head -1 | sed 's/^extra=//')"
extra_json="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.unquote(sys.argv[1]))' "$extra_enc")"
echo "$extra_json" | python3 -m json.tool >/dev/null || fail "extra 参数解码后不是合法 JSON"
echo "$extra_json" | grep -qF '"xPaddingHeader":"X-aa"' || fail "extra JSON 缺 padding header"
pass "padding 开启:extra 参数为合法编码 JSON"
# setup.env 持久化 XHTTP_PADDING 与键名
assert_contains "$DRY_DIR/setup.env" "XHTTP_PADDING=1" "setup.env 持久化 XHTTP_PADDING"
assert_contains "$DRY_DIR/setup.env" "XPADDING_HEADER=X-aa" "setup.env 持久化 padding header"
# XHTTP_PADDING=0:服务端无 extra 块、链接不带 extra= 参数
XHTTP_PADDING=0 run_dry >/dev/null
assert_not_contains "$DRY_DIR/config.json" '"xPaddingObfsMode"' "padding=0:服务端无 extra 块"
OUT0="$(XHTTP_PADDING=0 run_dry)"
if printf '%s\n' "$OUT0" | grep -qF '&extra='; then fail "padding=0:链接不应带 extra= 参数"; fi
pass "padding=0:链接不带 extra 参数"
assert_contains "$DRY_DIR/setup.env" "XHTTP_PADDING=0" "setup.env 持久化 XHTTP_PADDING=0"
run_dry >/dev/null # 恢复默认渲染,供后续断言用
# 默认不开启 VLESS 加密:decryption 为 none,链接 encryption=none
n="$(grep -c '"decryption": "none"' "$XJ")"
[ "$n" = "3" ] || fail "默认三个 inbound 的 decryption 应为 none(实际 $n 处)"
pass "默认 decryption=none ×3"
echo "$OUT" | grep -qF "encryption=none&flow=xtls-rprx-vision" || fail "默认链接应 encryption=none"
pass "默认链接 encryption=none"
# 开启 XRAY_VLESS_ENCDRY_RUN 用占位密钥串
DRY_DIR6="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR" "$DRY_DIR2" "$DRY_DIR4" "$DRY_DIR5" "$DRY_DIR6"' EXIT
OUT6="$(env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR6" \
DOMAIN=test.example.com XRAY_VLESS_ENC=1 \
ENV_FILE="$DRY_DIR6/none.env" \
bash "$SCRIPT")"
n="$(grep -c '"decryption": "DRYRUN_VLESS_DECRYPTION"' "$DRY_DIR6/config.json")"
[ "$n" = "3" ] || fail "开启后三个 inbound 应用 vlessenc decryption(实际 $n 处)"
pass "XRAY_VLESS_ENC=1 时 decryption 替换 ×3"
python3 -m json.tool "$DRY_DIR6/config.json" >/dev/null || fail "vlessenc 开启后 config.json 不是合法 JSON"
pass "vlessenc 开启后 JSON 合法"
echo "$OUT6" | grep -qF "encryption=DRYRUN_VLESS_ENCRYPTION&flow=xtls-rprx-vision" \
|| fail "开启后 REALITY 链接应带 vlessenc encryption"
pass "REALITY 链接带后量子 encryption"
grep -qF "XRAY_VLESS_DECRYPTION=DRYRUN_VLESS_DECRYPTION" "$DRY_DIR6/setup.env" \
|| fail "setup.env 应保存 vlessenc 密钥"
pass "vlessenc 密钥持久化到 setup.env"
# ---------------------------------------------------------------------------
# Task 5 断言:hysteria config.yaml(默认 http 模式)
# ---------------------------------------------------------------------------
HY="$DRY_DIR/config.yaml"
[ -f "$HY" ] || fail "hysteria config.yaml 未生成"
assert_contains "$HY" "listen: :8443" "hy2 监听 8443"
assert_contains "$HY" '- "test.example.com"' "hy2 证书域名(非通配 DOMAIN"
assert_contains "$HY" "email: test@example.com" "hy2 ACME email"
assert_contains "$HY" "type: http" "hy2 默认 HTTP-01"
assert_contains "$HY" "listenHost: 127.0.0.1" "hy2 challenge 仅监听回环"
assert_contains "$HY" "altPort: 9180" "hy2 challenge altPort"
assert_contains "$HY" "dir: /etc/hysteria/acme_certs" "hy2 证书目录"
assert_contains "$HY" "password: hy2-test-password" "hy2 密码"
assert_contains "$HY" "url: https://engineersblog.net" "hy2 伪装 url"
assert_contains "$HY" "rewriteHost: true" "hy2 rewriteHost"
assert_not_contains "$HY" "__BASE_DOMAIN__" "无残留占位符"
assert_not_contains "$HY" "cloudflare_api_token" "http 模式不含 CF token"
# dns 备选模式:渲染 dns 块
DRY_DIR4="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR" "$DRY_DIR2" "$DRY_DIR4"' EXIT
env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR4" \
DOMAIN=test.example.com EMAIL=test@example.com \
HY2_CERT_MODE=dns CF_API_TOKEN=cf-test-token \
ENV_FILE="$DRY_DIR4/none.env" \
bash "$SCRIPT" >/dev/null
assert_contains "$DRY_DIR4/config.yaml" "type: dns" "dns 模式 type"
assert_contains "$DRY_DIR4/config.yaml" '"*.example.com"' "dns 模式通配符域名"
assert_contains "$DRY_DIR4/config.yaml" "cloudflare_api_token: cf-test-token" "dns 模式 CF token"
assert_not_contains "$DRY_DIR4/Caddyfile" "acme-challenge" "dns 模式不加 challenge 路由"
# dns 模式缺 CF_API_TOKEN 应报错
if env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR4" \
DOMAIN=test.example.com HY2_CERT_MODE=dns \
ENV_FILE="$DRY_DIR4/none2.env" \
bash "$SCRIPT" >/dev/null 2>&1; then
fail "dns 模式缺 CF_API_TOKEN 应退出非零"
fi
pass "dns 模式缺 CF_API_TOKEN 报错退出"
# SKIP_CADDY + http 模式:hy2 直占 80standalone),不报错
DRY_DIR5="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR" "$DRY_DIR2" "$DRY_DIR4" "$DRY_DIR5"' EXIT
env -i PATH="$PATH" HOME="$HOME" \
DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR5" \
DOMAIN=test.example.com EMAIL=test@example.com \
SKIP_CADDY=1 \
ENV_FILE="$DRY_DIR5/none.env" \
bash "$SCRIPT" >/dev/null || fail "SKIP_CADDY=1 + http 模式应可干跑"
pass "SKIP_CADDY=1 + http 模式正常(standalone"
assert_contains "$DRY_DIR5/config.yaml" "altPort: 80" "standalone 直占 80"
assert_not_contains "$DRY_DIR5/config.yaml" "listenHost" "standalone 不限回环"
[ ! -f "$DRY_DIR5/Caddyfile" ] || fail "SKIP_CADDY=1 不应渲染 Caddyfile"
pass "SKIP_CADDY=1 不渲染 Caddyfile"
# ---------------------------------------------------------------------------
# Task 6 断言:nftables
# ---------------------------------------------------------------------------
NFT="$DRY_DIR/hysteria.nft"
[ -f "$NFT" ] || fail "hysteria.nft 未生成"
assert_contains "$NFT" "table inet hysteria_nat" "nft 表名"
assert_contains "$NFT" "udp dport 20000-30000 redirect to :8443" "端口跳跃 redirect"
NFTCONF="$DRY_DIR/nftables.conf"
[ -f "$NFTCONF" ] || fail "nftables.conf(干跑渲染)未生成"
assert_contains "$NFTCONF" 'include "/etc/nftables/hysteria.nft"' "include 行"
# 再跑两次,include 行不应重复
run_dry >/dev/null; run_dry >/dev/null
n="$(grep -cF 'include "/etc/nftables/hysteria.nft"' "$NFTCONF")"
[ "$n" = "1" ] || fail "include 行重复($n 次)"
pass "include 行幂等(重复运行不追加)"
# ---------------------------------------------------------------------------
# Task 7 断言:print_summary 分享链接 + uninstall 干跑
# ---------------------------------------------------------------------------
OUT="$(CDN_DOMAIN=cdn.example.com run_dry)"
echo "$OUT" | grep -qF "vless://11111111-1111-1111-1111-111111111111@test.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality" \
|| fail "缺 REALITY 链接"
pass "REALITY 分享链接"
echo "$OUT" | grep -qF "pbk=DRYRUN_PUBLIC_KEY&sid=3f9a2b1c" || fail "REALITY 链接缺 pbk/sid"
pass "REALITY 链接 pbk/sid"
echo "$OUT" | grep -qF "vless://22222222-2222-2222-2222-222222222222@test.example.com:443" || fail "缺 WS 链接"
pass "WS 分享链接"
echo "$OUT" | grep -qF "path=%2Fscilad" || fail "WS 链接 path 未编码"
pass "WS path URL 编码"
echo "$OUT" | grep -qF "vless://33333333-3333-3333-3333-333333333333@test.example.com:443" || fail "缺 XHTTP 链接"
pass "XHTTP 分享链接"
echo "$OUT" | grep -qF "path=%2Fxh88ab&mode=auto" || fail "XHTTP 链接 path/mode 不对"
pass "XHTTP path/mode"
echo "$OUT" | grep -qF "vless://33333333-3333-3333-3333-333333333333@cdn.example.com:443" || fail "缺 CDN XHTTP 链接"
pass "CDN XHTTP 分享链接"
echo "$OUT" | grep -qF "vless://22222222-2222-2222-2222-222222222222@cdn.example.com:443" || fail "缺 CDN WS 链接"
pass "CDN WS 分享链接"
# 默认:两条 CDN 链接内嵌动态查询形式的 ech="域名+DoH"URL 编码)
n_ech="$(printf '%s\n' "$OUT" | grep -cF 'ech=cdn.example.com%2Budp%3A%2F%2F9.9.9.9' || true)"
[ "$n_ech" = "2" ] || fail "两条 CDN 链接应默认带动态 ech= 参数(实际 $n_ech/2"
pass "CDN 链接默认内嵌 ECH 动态查询串"
# ECH_CONFIG 手动指定时原样嵌入(静态 base64 快照的 +/= 需 URL 编码)
OUTE="$(CDN_DOMAIN=cdn.example.com ECH_CONFIG='AEX+DQB/==' run_dry)"
n_ech="$(printf '%s\n' "$OUTE" | grep -cF 'ech=AEX%2BDQB%2F%3D%3D' || true)"
[ "$n_ech" = "2" ] || fail "两条 CDN 链接应带编码后的 ech= 参数(实际 $n_ech/2"
pass "ECH_CONFIG 静态快照原样嵌入(URL 编码)"
# ECH_CONFIG=off:链接不带 ech=
OUTO="$(CDN_DOMAIN=cdn.example.com ECH_CONFIG=off run_dry)"
if printf '%s\n' "$OUTO" | grep -qF '&ech='; then fail "ECH_CONFIG=off 时链接不应带 ech="; fi
pass "ECH_CONFIG=off 关闭 ech 参数"
# 全部 TLS 链接(ws/xhttp,直连+CDN 共 4 条)必须显式带 uTLS 指纹,避免 Go 原生指纹被识别
n_fp="$(printf '%s\n' "$OUT" | grep -c 'security=tls.*fp=chrome' || true)"
[ "$n_fp" = "4" ] || fail "TLS 链接应全部带 fp=chrome(实际 $n_fp/4"
pass "TLS 链接全部带 fp=chrome"
echo "$OUT" | grep -qF "hysteria2://hy2-test-password@test.example.com:8443/?mport=20000-30000&sni=test.example.com" \
|| fail "缺 hy2 链接(http 模式 sni=DOMAIN"
pass "hysteria2 分享链接(sni=DOMAIN"
# 无 CDN_DOMAIN 时不应有 CDN 链接
OUT="$(CDN_DOMAIN= run_dry)"
if echo "$OUT" | grep -qF "xhttp-cdn"; then fail "未设 CDN_DOMAIN 不应有 CDN 链接"; fi
pass "无 CDN_DOMAIN 无 CDN 链接"
# uninstall 干跑不报错
env -i PATH="$PATH" HOME="$HOME" DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR" \
ACTION=uninstall NFTABLES_CONF="$DRY_DIR/nftables.conf" \
bash "$SCRIPT" >/dev/null || fail "ACTION=uninstall DRY_RUN 应正常退出"
pass "uninstall 干跑正常退出"
# uninstall 应删掉 include 行
if grep -qF 'include "/etc/nftables/hysteria.nft"' "$DRY_DIR/nftables.conf"; then
fail "uninstall 未删除 include 行"
fi
pass "uninstall 删除 include 行"
# ---------------------------------------------------------------------------
# ACTION=upgrade
# ---------------------------------------------------------------------------
# 未安装过(无 setup.env)应报错
if env -i PATH="$PATH" HOME="$HOME" DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR2" \
ACTION=upgrade ENV_FILE="$DRY_DIR2/definitely-not-there.env" \
bash "$SCRIPT" >/dev/null 2>&1; then
fail "upgrade 无 setup.env 应退出非零"
fi
pass "upgrade 无安装时报错"
# 有 setup.env 时正常干跑(沿用已保存参数,不交互)
env -i PATH="$PATH" HOME="$HOME" DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR" \
ACTION=upgrade ENV_FILE="$DRY_DIR/setup.env" \
bash "$SCRIPT" >/dev/null || fail "upgrade 干跑应正常退出"
pass "upgrade 干跑正常退出"
assert_contains "$DRY_DIR/config.json" '"id": "11111111-1111-1111-1111-111111111111"' \
"upgrade 沿用已保存 UUID(不重新生成)"
# 旧版 setup.env(无 HY2_CERT_MODE,有 CF_API_TOKEN)→ 推断 dns,保持原形态
DRY_DIR7="$(mktemp -d)"
trap 'rm -rf "$DRY_DIR" "$DRY_DIR2" "$DRY_DIR4" "$DRY_DIR5" "$DRY_DIR6" "$DRY_DIR7" "$MOCK_BIN"' EXIT
printf 'DOMAIN=legacy.example.com\nEMAIL=a@b.c\nCF_API_TOKEN=old-token\n' > "$DRY_DIR7/old.env"
env -i PATH="$PATH" HOME="$HOME" DRY_RUN=1 DRY_RUN_DIR="$DRY_DIR7" \
ACTION=upgrade ENV_FILE="$DRY_DIR7/old.env" \
bash "$SCRIPT" >/dev/null || fail "旧版 setup.env 升级应正常"
assert_contains "$DRY_DIR7/config.yaml" "type: dns" "旧版参数升级保持 dns 证书模式"
assert_contains "$DRY_DIR7/config.yaml" '"*.example.com"' "旧版升级保持通配符证书"
echo
echo "全部测试通过"