#!/usr/bin/env python3 """Runs ON the box. Emits /etc/sing-box/config.json for the QUIC plane. Which inbounds get built is driven purely by which port variables are set, so the same script serves a Hysteria2-only node, a TUIC-only node, or both -- no editing required to add or drop a protocol. Why a second daemon at all: the TCP inbounds (VMess/Trojan/Shadowsocks) hide behind nginx:443 as WebSocket paths. Hysteria2 and TUIC are QUIC over UDP -- they terminate TLS themselves and cannot be reverse-proxied, so each needs a real public UDP port. Xray-core (what 3x-ui drives) implements neither, hence sing-box running alongside 3x-ui. Nothing here touches 3x-ui, nginx, or the existing inbounds: separate service, separate ports, separate config file. WARP egress reuses the cloudflare-warp SOCKS5 proxy the Xray plane already uses, routed by inbound tag -- one WARP install serves both planes. Env in: COVER, CERT_DIR, WARP_SOCKS_PORT, NODE_PASSWORD, XUI_UUID, PORT_HY2_DIRECT / PORT_HY2_WARP / HY2_OBFS_PASSWORD / HY2_UP_MBPS / HY2_DOWN_MBPS, PORT_TUIC_DIRECT / PORT_TUIC_WARP / TUIC_CC Unset port pairs are simply skipped. """ import json, os, sys COVER = os.environ["COVER"] CERT_DIR = os.environ.get("CERT_DIR", "/etc/nginx/ssl/" + COVER.split(".", 1)[1]) WARP_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000")) OUT = os.environ.get("SINGBOX_CONFIG", "/etc/sing-box/config.json") def tls(): return {"enabled": True, "server_name": COVER, "alpn": ["h3"], "certificate_path": os.path.join(CERT_DIR, "fullchain.pem"), "key_path": os.path.join(CERT_DIR, "privkey.pem")} def port(name): v = os.environ.get(name, "").strip() return int(v) if v else None inbounds = [] # --- Hysteria2 ------------------------------------------------------------- # Brutal congestion control needs a DECLARED bandwidth; it ignores loss as a # congestion signal, so an over-stated figure causes a loss storm rather than # speed. up/down are the server's view of the link. up = int(os.environ.get("HY2_UP_MBPS", "500")) down = int(os.environ.get("HY2_DOWN_MBPS", "500")) obfs_pw = os.environ.get("HY2_OBFS_PASSWORD", "").strip() for tag, pvar in (("hy2-direct", "PORT_HY2_DIRECT"), ("hy2-warp", "PORT_HY2_WARP")): p = port(pvar) if not p: continue ib = {"type": "hysteria2", "tag": tag, "listen": "0.0.0.0", "listen_port": p, "up_mbps": up, "down_mbps": down, "users": [{"name": "node-main", "password": os.environ["NODE_PASSWORD"]}], "tls": tls()} if obfs_pw: ib["obfs"] = {"type": "salamander", "password": obfs_pw} inbounds.append(ib) # --- TUIC v5 -------------------------------------------------------------- tuic_cc = os.environ.get("TUIC_CC", "bbr") for tag, pvar in (("tuic-direct", "PORT_TUIC_DIRECT"), ("tuic-warp", "PORT_TUIC_WARP")): p = port(pvar) if not p: continue inbounds.append({ "type": "tuic", "tag": tag, "listen": "0.0.0.0", "listen_port": p, "users": [{"name": "node-main", "uuid": os.environ["XUI_UUID"], "password": os.environ["NODE_PASSWORD"]}], "congestion_control": tuic_cc, "auth_timeout": "3s", "zero_rtt_handshake": False, "heartbeat": "10s", "tls": tls()}) if not inbounds: sys.exit("ERROR: no QUIC ports configured (set PORT_HY2_* and/or PORT_TUIC_*)") warp_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-warp")] direct_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-direct")] rules = [ {"action": "resolve", "strategy": "ipv4_only", "server": "ipv4-dns"}, {"ip_cidr": ["::/0"], "action": "reject"}, ] if warp_tags: rules.append({"inbound": warp_tags, "outbound": "warp"}) if direct_tags: rules.append({"inbound": direct_tags, "outbound": "direct"}) rules.append({"protocol": "bittorrent", "outbound": "block"}) cfg = { "log": {"level": "warn", "timestamp": True}, "dns": {"servers": [{"type": "udp", "tag": "ipv4-dns", "server": "1.1.1.1", "server_port": 53}], "strategy": "ipv4_only", "final": "ipv4-dns"}, "inbounds": inbounds, "outbounds": [ {"type": "direct", "tag": "direct"}, {"type": "socks", "tag": "warp", "server": "127.0.0.1", "server_port": WARP_PORT, "version": "5"}, {"type": "block", "tag": "block"}, ], "route": {"rules": rules, "final": "direct"}, } os.makedirs(os.path.dirname(OUT), exist_ok=True) with open(OUT, "w") as f: json.dump(cfg, f, indent=2) os.chmod(OUT, 0o600) print("wrote %s with %d inbound(s): %s" % (OUT, len(inbounds), ", ".join(i["tag"] for i in inbounds)))