#!/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 "全部测试通过"