feat: 合并边缘节点运维并完善 Surge 配置安全

迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

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

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
2026-10-03 23:19:59 +08:00
parent 7358008936
commit 5326c572a2
93 changed files with 6780 additions and 320 deletions

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Run LOCALLY (not on the box) after 08/09 have succeeded. Reads config.env from
the same directory and writes share links + QR codes for all 6 nodes into
--out (default: ./node-links/ next to this script). Needs `qrencode` on PATH
(brew install qrencode / apt install qrencode) -- QR codes are generated fully
offline, nothing here is ever sent to a third-party service, because these
links carry live credentials.
All three protocols get a share link + QR. Shadowsocks over ws+tls uses the
SIP002 ss:// URI with Xray-style transport query params -- userinfo is
base64(method:password) with padding stripped, and the transport is carried in
the query string (type=ws&path=...&security=tls&sni=...) rather than in a SIP003
`plugin=` field. That is exactly what 3x-ui's own genShadowsocksLink emits, so
the links here match what the panel's subscription endpoint hands out; verified
against a live box by decoding /sub/<sub_id>. The sing-box JSON snippet is still
written alongside as an unambiguous fallback for clients that reject the URI.
"""
import base64, json, os, re, subprocess, sys, urllib.parse as up
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else os.path.join(HERE, "node-links")
def load_env():
env = {}
path = os.path.join(HERE, "config.env")
if not os.path.exists(path):
sys.exit("ERROR: copy config.env.example -> config.env and fill it in")
for line in open(path):
m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line)
if m:
env[m.group(1)] = m.group(2).strip()
return env
E = load_env()
COVER = "%s.%s" % (E["SUB_COVER"], E["DOMAIN_ROOT"])
NODE_NAME = E.get("NODE_NAME", "edge-node")
def vmess_link(remark, path):
cfg = {
"v": "2", "ps": remark, "add": COVER, "port": "443", "id": E["XUI_UUID"], "aid": "0",
"scy": "auto", "net": "ws", "type": "none", "host": COVER, "path": path,
"tls": "tls", "sni": COVER, "alpn": "",
}
raw = json.dumps(cfg, separators=(",", ":"), ensure_ascii=False)
return "vmess://" + base64.b64encode(raw.encode()).decode()
# trojan + shadowsocks share NODE_PASSWORD (one node-main client); vmess uses XUI_UUID.
def trojan_link(remark, path):
q = up.urlencode({"security": "tls", "type": "ws", "host": COVER, "path": path, "sni": COVER})
return "trojan://%s@%s:443?%s#%s" % (E["NODE_PASSWORD"], COVER, q, up.quote(remark))
def ss_link(remark, path):
"""SIP002 ss:// with Xray transport params -- byte-identical to what 3x-ui emits.
Param order matches the panel's (alphabetical) so the two can be diffed."""
method = E.get("SS_METHOD", "chacha20-poly1305")
userinfo = base64.b64encode(("%s:%s" % (method, E["NODE_PASSWORD"])).encode()).decode().rstrip("=")
q = up.urlencode({"host": "", "path": path, "security": "tls", "sni": COVER, "type": "ws"})
return "ss://%s@%s:443?%s#%s" % (userinfo, COVER, q, up.quote(remark))
def ss_singbox(tag, path):
return {
"type": "shadowsocks", "tag": tag, "server": COVER, "server_port": 443,
"method": E.get("SS_METHOD", "chacha20-poly1305"), "password": E["NODE_PASSWORD"],
"tls": {"enabled": True, "server_name": COVER},
"transport": {"type": "ws", "path": path, "headers": {"Host": COVER}},
}
def main():
os.makedirs(OUT, exist_ok=True)
links = {
"vmess-direct.link": vmess_link("%s-vmess-direct" % NODE_NAME, E["WS_PATH_VMESS_DIRECT"]),
"vmess-warp.link": vmess_link("%s-vmess-warp-clean-exit" % NODE_NAME, E["WS_PATH_VMESS_WARP"]),
"trojan-direct.link": trojan_link("%s-trojan-direct" % NODE_NAME, E["WS_PATH_TROJAN_DIRECT"]),
"trojan-warp.link": trojan_link("%s-trojan-warp-clean-exit" % NODE_NAME, E["WS_PATH_TROJAN_WARP"]),
"ss-direct.link": ss_link("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"]),
"ss-warp.link": ss_link("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"]),
}
for name, link in links.items():
path = os.path.join(OUT, name)
open(path, "w").write(link)
png = path.replace(".link", "-qr.png")
try:
subprocess.run(["qrencode", "-o", png, "-s", "8", "-m", "2"], input=link.encode(), check=True)
except FileNotFoundError:
print(" (qrencode not found on PATH -- skipping QR for %s; link file written)" % name)
print(" wrote", name)
for tag, path, filename in [
("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"], "ss-direct-singbox.json"),
("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"], "ss-warp-singbox.json"),
]:
with open(os.path.join(OUT, filename), "w") as f:
json.dump(ss_singbox(tag, path), f, indent=2)
print(" wrote", filename)
print("DONE — links + QR codes in", OUT)
print("Reminder: never upload these files/links to a third-party QR or paste service — they carry live credentials.")
if __name__ == "__main__":
main()