迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
109 lines
4.9 KiB
Python
Executable File
109 lines
4.9 KiB
Python
Executable File
#!/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()
|