#!/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/. 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()