9ca581c1e9
Co-Authored-By: Claude <noreply@anthropic.com>
473 lines
24 KiB
Bash
Executable File
473 lines
24 KiB
Bash
Executable File
#!/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 端口块路由行为
|
||
# (事故背景 1:bare 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_redirects;8003 重映射避免占用本机端口,
|
||
# 补 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 1(PROXY 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_ENC:DRY_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 直占 80(standalone),不报错
|
||
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 "全部测试通过"
|