Files
proxy-station/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

143 lines
7.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# End-to-end smoke test for all 6 inbounds (vmess/trojan/shadowsocks x direct/warp),
# plus the landing page, both panels, and the anti-scan default-444 catch-all.
# Run from anywhere with DNS resolving to the box.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
COVER="$SUB_COVER.$DOMAIN_ROOT"
echo "== credential integrity (catches the post-upgrade client wipe) =="
# An upgrade that runs `x-ui migrate` rebuilds client_inbounds from each inbound's
# settings.clients JSON, deleting the old rows first. If settings.clients is empty
# the node keeps answering TLS + WS-101 while silently authenticating nobody, so the
# transport checks below all pass on a completely dead node. Assert the credentials
# are actually present, in the DB and in the generated xray config. See fix #5 in
# 08-xui-bootstrap.py; retrofit with 08b-embed-settings-clients.py.
run 'python3 - <<PY
import json, sqlite3, sys
PROXY = ("vmess", "vless", "trojan", "shadowsocks")
bad = []
con = sqlite3.connect("/etc/x-ui/x-ui.db")
rows = con.execute("SELECT port,protocol,settings FROM inbounds ORDER BY port").fetchall()
proxy_rows = [r for r in rows if r[1] in PROXY]
for port, proto, s in proxy_rows:
try:
cl = (json.loads(s) or {}).get("clients")
except (TypeError, ValueError):
cl = None
if not isinstance(cl, list) or not cl:
bad.append("inbound :%s (%s) settings.clients is EMPTY" % (port, proto))
links = con.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
if links < len(proxy_rows):
bad.append("client_inbounds has %d row(s), expected >= %d" % (links, len(proxy_rows)))
cfg = json.load(open("/usr/local/x-ui/bin/config.json"))
for i in cfg.get("inbounds", []):
if i.get("protocol") not in PROXY:
continue
cl = (i.get("settings") or {}).get("clients")
if not isinstance(cl, list) or not cl:
bad.append("generated config: inbound :%s (%s) has NO clients" % (i.get("port"), i.get("protocol")))
if bad:
print("FAIL -- credentials missing; every inbound will reject all traffic:")
for b in bad:
print(" -", b)
print(" fix: run scripts/08b-embed-settings-clients.py on the box")
sys.exit(1)
print("OK -- %d inbounds have embedded clients, %d client_inbounds links, config has credentials"
% (len(proxy_rows), links))
PY' || echo "!! credential check FAILED (see above) -- transport checks below may still pass on a dead node"
echo "== cover WS upgrade, all 6 paths (expect 101 each) =="
# curl reports the upgrade status via -w before it ever exits, but the connection stays
# open as a live WS tunnel afterwards, so curl commonly hits --max-time and returns a
# non-zero exit even on a successful 101 -- `|| true` so one check's exit code can't
# abort the rest of the script under `set -e`.
for P in "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP"; do
curl -sk --http1.1 --max-time 20 -o /dev/null -w "$P -> %{http_code}\n" \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \
"https://$COVER$P" || true
done
echo "== landing + panels (expect 200) =="
curl -sk --max-time 10 -o /dev/null -w "landing -> %{http_code}\n" "https://$DOMAIN_ROOT/" || true
curl -sk --max-time 10 -o /dev/null -w "3x-ui -> %{http_code}\n" "https://$SUB_XUI.$DOMAIN_ROOT$XUI_PATH" || true
# aaPanel's anti-scan check needs a UA that actually looks like a real browser -- a bare
# "Mozilla/5.0 (Macintosh)" gets judged as a scanner and served a fake 404 (see
# references/pitfalls.md). Don't "fix" this by loosening CDN_PROXY or re-checking the
# domain binding; check the UA string first, every time.
curl -sk --max-time 10 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15" \
-o /dev/null -w "aaPanel -> %{http_code}\n" "https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE" || true
echo "== unmatched host (expect 000/444, connection dropped) =="
curl -sk --max-time 8 -o /dev/null -w "random -> %{http_code}\n" "https://nope-$RANDOM.$DOMAIN_ROOT/" || true
echo "== e2e egress via temp xray client, all 6 outbounds (direct vs WARP per protocol) =="
# node-main carries one uuid (vmess) + one shared password (trojan+ss).
python3 - "$COVER" "$XUI_UUID" "$NODE_PASSWORD" "${SS_METHOD:-chacha20-poly1305}" \
"$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP" <<'PYEOF'
import json, sys
(cover, uuid, node_pw, ss_method,
vd, vw, td, tw, sd, sw) = sys.argv[1:11]
trojan_pw = ss_pw = node_pw # single shared credential
def ws(path):
return {"network": "ws", "security": "tls",
"tlsSettings": {"serverName": cover},
"wsSettings": {"path": path, "headers": {"Host": cover}}}
outbounds = [
{"tag": "vmess-d", "protocol": "vmess",
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
"streamSettings": ws(vd)},
{"tag": "vmess-w", "protocol": "vmess",
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
"streamSettings": ws(vw)},
{"tag": "trojan-d", "protocol": "trojan",
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
"streamSettings": ws(td)},
{"tag": "trojan-w", "protocol": "trojan",
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
"streamSettings": ws(tw)},
{"tag": "ss-d", "protocol": "shadowsocks",
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
"streamSettings": ws(sd)},
{"tag": "ss-w", "protocol": "shadowsocks",
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
"streamSettings": ws(sw)},
]
inbounds = []
rules = []
for i, tag in enumerate(("vmess-d", "vmess-w", "trojan-d", "trojan-w", "ss-d", "ss-w")):
port = 10801 + i
inbounds.append({"port": port, "listen": "127.0.0.1", "protocol": "socks",
"tag": "in-%s" % tag, "settings": {"udp": True}})
rules.append({"type": "field", "inboundTag": ["in-%s" % tag], "outboundTag": tag})
cfg = {"inbounds": inbounds, "outbounds": outbounds, "routing": {"rules": rules}}
json.dump(cfg, open("/tmp/_smoke_tc.json", "w"))
print("local socks ports: vmess-d=10801 vmess-w=10802 trojan-d=10803 trojan-w=10804 ss-d=10805 ss-w=10806")
PYEOF
push /tmp/_smoke_tc.json /tmp/_smoke_tc.json
run '
cd /usr/local/x-ui/bin
XRAY=$(ls xray-linux-* 2>/dev/null | head -1); [ -n "$XRAY" ] || XRAY=$(ls xray* 2>/dev/null | head -1)
./$XRAY -c /tmp/_smoke_tc.json >/tmp/_smoke_tc.log 2>&1 &
XPID=$!; sleep 4
for pair in "vmess-direct:10801" "vmess-warp:10802" "trojan-direct:10803" "trojan-warp:10804" "ss-direct:10805" "ss-warp:10806"; do
name="${pair%%:*}"; port="${pair##*:}"
printf "%-14s " "$name"
curl -s -x socks5h://127.0.0.1:$port --max-time 15 https://1.1.1.1/cdn-cgi/trace | grep -E "ip=|warp=" | tr "\n" " "
echo
done
kill $XPID 2>/dev/null
rm -f /tmp/_smoke_tc.json /tmp/_smoke_tc.log
'
rm -f /tmp/_smoke_tc.json
echo "Expected: *-direct -> ip=$SERVER_IP warp=off ; *-warp -> ip=<a Cloudflare address> warp=on"