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,61 @@
#!/usr/bin/env bash
# No-aaPanel preflight for the SG node: verify SSH, add swap (low-RAM box),
# set timezone, install nginx + acme deps directly from apt, authorize your key.
. "$(dirname "$0")/_lib.sh"
echo "== SSH + specs =="
run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1'
echo "== swap (2G, low-RAM box needs it for installs) =="
run 'if ! swapon --show | grep -q /swapfile; then
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile; mkswap /swapfile >/dev/null; swapon /swapfile
grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab
sysctl -w vm.swappiness=10 >/dev/null
grep -q "vm.swappiness" /etc/sysctl.conf || echo "vm.swappiness=10" >> /etc/sysctl.conf
fi
free -h | awk "/Swap/{print \"swap: \"\$2}"'
echo "== timezone (${TZ:-UTC}) + base packages (nginx, acme deps) =="
run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true
export DEBIAN_FRONTEND=noninteractive
# Freshly-booted cloud images run unattended-upgrades, which holds the dpkg lock
# for minutes; without this wait apt fails while the script reports success.
for i in \$(seq 1 60); do
fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break
[ \$i = 1 ] && echo 'waiting for dpkg lock (unattended-upgrades)...'
sleep 10
done
apt-get update -qq
apt-get install -y -qq curl wget gnupg ufw ca-certificates socat cron nginx >/dev/null || { echo 'ERROR: apt install failed'; exit 1; }
command -v nginx >/dev/null || { echo 'ERROR: nginx missing after install'; exit 1; }
systemctl enable --now nginx >/dev/null 2>&1 || true
nginx -v 2>&1
echo base-packages-ok"
# BBR + fq. Stock cloud images generally do NOT have it: this AWS Ubuntu 24.04 AMI
# came up on cubic + fq_codel with tcp_bbr not even loaded, while the DMIT box ships
# BBR pre-enabled in its VM template. Lifts every TCP inbound at once, no client change.
echo "== kernel tuning: BBR congestion control =="
run 'modprobe tcp_bbr 2>/dev/null || true
echo tcp_bbr > /etc/modules-load.d/tcp_bbr.conf
cat > /etc/sysctl.d/99-edge-node-bbr.conf <<CONF
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
CONF
sysctl --system >/dev/null 2>&1
CC=$(sysctl -n net.ipv4.tcp_congestion_control)
QD=$(sysctl -n net.core.default_qdisc)
echo "congestion=$CC qdisc=$QD"
[ "$CC" = bbr ] || echo "WARN: BBR not active — kernel may lack tcp_bbr (available: $(sysctl -n net.ipv4.tcp_available_congestion_control))"'
# Authorize YOUR public key for passwordless login.
MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)"
if [ -n "$MYPUB" ]; then
run "mkdir -p ~/.ssh && chmod 700 ~/.ssh
grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added"
else
echo "WARN: no local public key found; add yours to the box manually"
fi
echo "preflight done"

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Verify SSH, record specs, set timezone, install base packages, authorize your key.
. "$(dirname "$0")/_lib.sh"
echo "== SSH + specs =="
run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1'
echo "== timezone (${TZ:-UTC}) + base packages =="
run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl wget gnupg ufw ca-certificates >/dev/null
echo base-packages-ok"
# Authorize YOUR public key for passwordless login (edit path if needed).
MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)"
if [ -n "$MYPUB" ]; then
run "mkdir -p ~/.ssh && chmod 700 ~/.ssh
grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added"
else
echo "WARN: no local public key found; add yours to the box manually for passwordless login"
fi
echo "preflight done"

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Upsert <DOMAIN_ROOT> + *.<DOMAIN_ROOT> A/AAAA records in Cloudflare (DNS-only).
Run wherever the CF token lives (your laptop / a bastion) — the token never touches the VPS.
Reads config.env from the same dir. No real domains/IPs are baked in.
"""
import json, os, re, sys, urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
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")
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()
API = "https://api.cloudflare.com/client/v4"
TOKEN = E["CF_API_TOKEN"]
ROOT = E["DOMAIN_ROOT"]
IPV4 = E.get("SERVER_IP", "")
IPV6 = E.get("SERVER_IPV6", "")
NAMES = [ROOT, "*." + ROOT]
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(API + path, data=data, method=method)
r.add_header("Authorization", "Bearer " + TOKEN)
r.add_header("Content-Type", "application/json")
with urllib.request.urlopen(r, timeout=20) as f:
return json.load(f)
def zone_id():
zid = E.get("CF_ZONE_ID", "")
if zid:
return zid
parent = E["PARENT_ZONE"]
res = req("GET", "/zones?name=%s" % parent)
if not res.get("result"):
sys.exit("ERROR: zone %s not found for this token" % parent)
return res["result"][0]["id"]
ZONE = zone_id()
def upsert(rtype, content):
if not content:
return
existing = req("GET", "/zones/%s/dns_records?per_page=200&type=%s" % (ZONE, rtype))["result"]
by_name = {r["name"]: r for r in existing}
for name in NAMES:
payload = {"type": rtype, "name": name, "content": content, "ttl": 300, "proxied": False}
if name in by_name:
res = req("PUT", "/zones/%s/dns_records/%s" % (ZONE, by_name[name]["id"]), payload)
print("UPDATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors")))
else:
res = req("POST", "/zones/%s/dns_records" % ZONE, payload)
print("CREATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors")))
upsert("A", IPV4)
upsert("AAAA", IPV6)
print("DNS done for *.%s (DNS-only / grey cloud)" % ROOT)

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Issue a wildcard cert for *.<DOMAIN_ROOT> via acme.sh DNS-01 and install it into
# a STANDALONE nginx cert dir (/etc/nginx/ssl/<DOMAIN_ROOT>/), with a renew reloadcmd
# that reloads the system (apt) nginx. No aaPanel.
. "$(dirname "$0")/_lib.sh"
PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}"
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
CRED_EXPORTS=""
for v in CF_API_TOKEN DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do
[ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; "
done
# acme.sh's Cloudflare plugin expects CF_Token; alias it from CF_API_TOKEN.
[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; "
run "$CRED_EXPORTS
mkdir -p '$CERTDIR'
curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true
~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -5
~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \
--key-file '$CERTDIR/privkey.pem' \
--fullchain-file '$CERTDIR/fullchain.pem' \
--reloadcmd 'systemctl reload nginx' 2>&1 | tail -3
ls -l '$CERTDIR'
echo cert-done"

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Issue a wildcard cert for *.<DOMAIN_ROOT> on the box via acme.sh DNS-01,
# deploy into the aaPanel BT cert dirs, and register a renew --reloadcmd.
# DNS provider is set by ACME_DNS_PLUGIN (default dns_cf); creds are passed through from config.env.
# Fleet alternative (sign once, scp to many): references/centralized-certs.md
. "$(dirname "$0")/_lib.sh"
PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}"
# Export whichever acme.sh DNS credential vars are set in config.env (only non-empty ones).
CRED_EXPORTS=""
for v in CF_API_TOKEN CF_Zone_ID DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do
[ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; "
done
# acme.sh's Cloudflare plugin expects CF_Token, so alias it from CF_API_TOKEN.
[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; "
SITES=("$DOMAIN_ROOT" "$SUB_COVER.$DOMAIN_ROOT" "$SUB_XUI.$DOMAIN_ROOT" "$SUB_PANEL.$DOMAIN_ROOT")
SITES_CSV=$(IFS=,; echo "${SITES[*]}")
run "$CRED_EXPORTS
curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true
~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -4
# deploy into BT cert dirs + reload nginx
SRC=~/.acme.sh/${DOMAIN_ROOT}_ecc
for s in ${SITES_CSV//,/ }; do
d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d
install -m 644 \$SRC/fullchain.cer \$d/fullchain.pem
install -m 600 \$SRC/${DOMAIN_ROOT}.key \$d/privkey.pem
done
( /www/server/nginx/sbin/nginx -t && /www/server/nginx/sbin/nginx -s reload ) 2>/dev/null || echo 'nginx not up yet (fine — reload after aaPanel install)'
# register reloadcmd so renewals re-deploy automatically
~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \
--reloadcmd \"for s in ${SITES_CSV//,/ }; do d=/www/server/panel/vhost/cert/\\\$s; install -m 644 \\\$SRC/fullchain.cer \\\$d/fullchain.pem; install -m 600 \\\$SRC/${DOMAIN_ROOT}.key \\\$d/privkey.pem; done; /www/server/nginx/sbin/nginx -s reload\" 2>&1 | tail -2
echo cert-done"

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Install aaPanel International (7.x) headless, then print the default credentials.
. "$(dirname "$0")/_lib.sh"
run 'cd /root && curl -sSO https://www.aapanel.com/script/install_7.0_en.sh && ls -la install_7.0_en.sh'
echo "== installing aaPanel in background (5-10 min) =="
run 'cd /root && (echo y | bash install_7.0_en.sh aapanel > /root/aapanel-install.log 2>&1 &) && echo started'
echo "== waiting for panel binary =="
# Do NOT check "! pgrep -f install_7.0" here -- the check command's own argv contains
# that literal string, so pgrep -f matches the check itself and this never goes false.
# The installer's own log prints a completion banner; watch for that instead.
until run 'test -f /www/server/panel/BT-Panel && grep -q "Congratulations" /root/aapanel-install.log 2>/dev/null'; do sleep 15; done
echo "== aaPanel installed. Default credentials (change in 05-harden): =="
run 'grep -iE "Address|username|password" /root/aapanel-install.log | grep -ivE "wget|Resolving|Connecting|HTTP request|Saving|node.aapanel|systemd" | tail -6'

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Install nginx 1.24 via aaPanel's install_soft.sh (compile; a few minutes).
. "$(dirname "$0")/_lib.sh"
run 'cd /www/server/panel/install && (bash install_soft.sh 0 install nginx 1.24 > /root/nginx-install.log 2>&1 &) && echo started'
echo "== waiting for nginx binary (compile) =="
until run 'test -x /www/server/nginx/sbin/nginx'; do sleep 20; done
run '/www/server/nginx/sbin/nginx -v 2>&1'

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Harden aaPanel: set username/password, secret entrance, bind panel domain, enable CDN_PROXY
# (required so the panel works behind an nginx reverse proxy).
. "$(dirname "$0")/_lib.sh"
[ -n "${AAPANEL_PASS:-}" ] || AAPANEL_PASS="$(gen_pass 20)"
echo "AAPANEL PASSWORD (save to your password manager): $AAPANEL_PASS"
run "cd /www/server/panel
/www/server/panel/pyenv/bin/python tools.py panel '$AAPANEL_PASS' >/dev/null 2>&1 && echo password-set
# username: set directly in the panel DB (tools.py username is interactive)
/www/server/panel/pyenv/bin/python -c \"import sqlite3;c=sqlite3.connect('/www/server/panel/data/default.db');c.execute(\\\"update users set username='$AAPANEL_USER'\\\");c.commit();print('username ->',c.execute('select username from users').fetchall())\"
echo '$AAPANEL_ENTRANCE' > data/admin_path.pl && echo entrance-set
echo '$SUB_PANEL.$DOMAIN_ROOT' > data/domain.conf && echo domain-bound
echo 'CDN_PROXY=True' > config/cdn.conf && echo cdn-proxy-on
/etc/init.d/bt restart >/dev/null 2>&1; sleep 3
P=\$(cat /www/server/panel/data/port.pl | tr -d '[:space:]')
ss -lnt | grep -q \":\$P\" && echo \"panel-listening-\$P\" || echo 'WARN: panel not listening (check with: bt 14)'"
echo "NOTE: reverse-proxy access at https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE (after 09-aapanel-sites)."
echo "NOTE: direct IP:port returns 403 by design (domain binding). Restart panel with 'bt 1', never 'systemctl restart btpanel'."

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Install cloudflare-warp, register, set proxy (SOCKS5) mode, connect, verify warp=on.
. "$(dirname "$0")/_lib.sh"
run "
# keyring needs gnupg present (else 'sqv' keyring-parse error on apt update)
apt-get install -y -qq gnupg >/dev/null
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ bookworm main' > /etc/apt/sources.list.d/cloudflare-client.list
apt-get update -qq
apt-get install -y -qq cloudflare-warp >/dev/null
warp-cli --accept-tos registration new >/dev/null 2>&1 || true
warp-cli --accept-tos mode proxy >/dev/null 2>&1
warp-cli --accept-tos proxy port $WARP_SOCKS_PORT >/dev/null 2>&1
warp-cli --accept-tos connect >/dev/null 2>&1
sleep 5
echo '== verify (expect warp=on) =='
curl -s -x socks5h://127.0.0.1:$WARP_SOCKS_PORT --max-time 10 https://1.1.1.1/cdn-cgi/trace | grep -E 'ip=|warp='"

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Install the WARP health-check watchdog (systemd timer, every 60s).
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
# adjust the proxy port in the shipped script to your configured one
sed "s#127.0.0.1:40000#127.0.0.1:$WARP_SOCKS_PORT#g" "$HERE/warp-healthcheck.sh" | run "cat > /usr/local/bin/warp-healthcheck.sh"
push "$HERE/warp-healthcheck.service" /etc/systemd/system/warp-healthcheck.service
push "$HERE/warp-healthcheck.timer" /etc/systemd/system/warp-healthcheck.timer
run 'chmod +x /usr/local/bin/warp-healthcheck.sh
systemctl daemon-reload
systemctl enable --now warp-healthcheck.timer 2>&1 | tail -1
systemctl is-active warp-healthcheck.timer'

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Install 3x-ui and set the panel to listen on 127.0.0.1:XUI_PORT with your path/creds.
. "$(dirname "$0")/_lib.sh"
[ -n "${XUI_PASS:-}" ] || XUI_PASS="$(gen_pass 18)"
echo "3X-UI PASSWORD (save to your password manager): $XUI_PASS"
run "echo -e 'n' | bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) >/dev/null 2>&1 || true
/usr/local/x-ui/x-ui setting -username '$XUI_USER' -password '$XUI_PASS' -port $XUI_PORT -webBasePath '$XUI_PATH' 2>&1 | tail -4
/usr/local/x-ui/x-ui setting -listenIP 127.0.0.1 2>&1 | tail -1
systemctl restart x-ui; sleep 4
echo '== status =='; systemctl is-active x-ui
ss -lnt | grep -q ':$XUI_PORT' && echo panel-listening-$XUI_PORT || echo 'WARN: panel not listening'"

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API
403s scripted requests on the builds this targets -- see references/pitfalls.md).
Builds the standard target of this skill in ONE pass: SIX inbounds (vmess,
trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client
"node-main", so the panel's Clients page shows a single row whose Info dialog
lists all six share links / QR codes and one subscription that carries all six.
Why a single client instead of one-per-protocol
------------------------------------------------
3x-ui's `clients` table is protocol-generic: one client row carries BOTH a
`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/
shadowsocks inbounds). Which field an inbound reads is decided by that
*inbound's* protocol when 3x-ui builds the live xray config -- not by anything
on the client row. So one node-main row linked (via client_inbounds) to all six
inbounds authenticates vmess by its uuid and trojan+ss by its (shared)
password. Verified by reading the generated /usr/local/x-ui/bin/config.json.
Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses
the uuid. That's intended -- a single-user self node -- and is what makes the
"one client, six links" panel view work.
Four version-compatibility fixes are baked in here (all cost real debugging on
a live v3.5.0 box; see references/pitfalls.md for the full story):
1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the
logged-in admin's user_id; an inbound with a NULL/empty user_id exists and
proxies traffic fine but is INVISIBLE in the UI.
2. externalProxy on every inbound's streamSettings. These inbounds listen on
127.0.0.1 with security:none (nginx terminates TLS at the edge), so without
a hint 3x-ui generates useless share links pointing at 127.0.0.1:<internal
port> with no TLS. An externalProxy entry {forceTls:"tls", dest:<cover>,
port:443, sni:<cover>} makes every generated link/QR/subscription point at
<cover>:443 over TLS -- i.e. actually usable.
3. Shadowsocks inbound settings need a TOP-LEVEL `password` field. v3.5.0's
share-link generator does `settings["password"].(string)` -- an UNCHECKED
type assertion. A normal (non-2022) SS inbound has no top-level password
(the per-client password lives on the client), so that assertion panics,
the panic is swallowed by gin's recovery, and the whole subLinks endpoint
returns HTTP 200 with an EMPTY body -> the panel shows "No response data"
and drops every link. We write a top-level password (auth still goes through
the client table; this field only exists to keep the assertion from
panicking and does not appear in the emitted ss:// link for chacha20 ciphers).
4. WARP routing rule is read-modify-write, never clobbered -- so re-running
this (e.g. to add a 4th protocol) doesn't delete the existing warp routing.
5. settings.clients is ALWAYS embedded, on every schema -- never left as `[]`.
This one cost a live outage on two nodes. `inbounds.settings.clients` is
3x-ui's AUTHORITATIVE copy of the credentials; the clients / client_inbounds
tables are a derived projection of that JSON, not an alternative to it:
* client_link.go SyncInbound() does an UNCONDITIONAL
`DELETE FROM client_inbounds WHERE inbound_id=?` and then rebuilds the
rows only from the clients slice handed to it -- empty slice means
delete-and-insert-nothing, with no guard distinguishing "this inbound
has no clients" from "the caller found no data".
* inbound_migration.go MigrationRequirements() feeds SyncInbound the
clients from GetClients(inbound), i.e. parsed out of the settings JSON.
* The panel UI path (client_inbound_apply.go addInboundClient) always
writes the client into the settings JSON first, then projects to the
tables -- which is why UI-managed installs never hit this.
So a node with `settings.clients == []` loses every client_inbounds row the
moment the migration path runs -- and `x-ui migrate` is exactly what
install.sh/update.sh invoke on upgrade (DB import/restore and the
SQLite->PostgreSQL copy call it too). The generated xray config then has
`"clients": []` on every inbound: TLS and the WS upgrade still succeed, so
the node looks healthy, but every connection fails authentication.
Verified empirically on a live 3.6.0 box: with settings.clients empty,
`x-ui migrate` emptied client_inbounds instantly; with it populated, the
same command left all six links intact. The logic is unchanged since 3.5.0,
so this is not a one-release regression -- it fires on ANY future upgrade.
To retrofit an already-broken node, run 08b-embed-settings-clients.py.
Schema-adaptive: newer 3x-ui stores clients in separate tables (clients /
client_inbounds / client_traffics); older builds embed them in
inbound.settings.clients. The new-schema path is the live-verified one; the
embedded path is kept for older installs.
"""
import json, os, sqlite3, subprocess, time
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
WARP_SOCKS_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
COVER = os.environ["COVER"] # e.g. cc.<DOMAIN_ROOT> -- the public cover host for every link
XUI_UUID = os.environ["XUI_UUID"] # vmess credential on node-main
XUI_SUBID = os.environ.get("XUI_SUBID", "")
NODE_PASSWORD = os.environ["NODE_PASSWORD"] # shared trojan+ss credential on node-main
SS_METHOD = os.environ.get("SS_METHOD", "chacha20-poly1305")
EMAIL = "node-main"
# (protocol, direct port, warp port, direct path, warp path)
PROTOCOLS = [
("vmess", int(os.environ["PORT_VMESS_DIRECT"]), int(os.environ["PORT_VMESS_WARP"]),
os.environ["WS_PATH_VMESS_DIRECT"], os.environ["WS_PATH_VMESS_WARP"]),
("trojan", int(os.environ["PORT_TROJAN_DIRECT"]), int(os.environ["PORT_TROJAN_WARP"]),
os.environ["WS_PATH_TROJAN_DIRECT"], os.environ["WS_PATH_TROJAN_WARP"]),
("shadowsocks", int(os.environ["PORT_SS_DIRECT"]), int(os.environ["PORT_SS_WARP"]),
os.environ["WS_PATH_SS_DIRECT"], os.environ["WS_PATH_SS_WARP"]),
]
TEMPLATE = {
"log": {"access": "/var/log/v2ray/access.log", "error": "/var/log/v2ray/error.log", "loglevel": "warning"},
"api": {"services": ["HandlerService", "LoggerService", "StatsService"], "tag": "api"},
"inbounds": [{"listen": "127.0.0.1", "port": 62789, "protocol": "dokodemo-door",
"settings": {"address": "127.0.0.1"}, "tag": "api"}],
"outbounds": [
{"protocol": "freedom", "settings": {}},
{"tag": "warp-outbound", "protocol": "freedom",
"settings": {"domainStrategy": "UseIPv6v4"}, "proxySettings": {"tag": "warp-socks"}},
{"tag": "warp-socks", "protocol": "socks",
"settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}},
{"protocol": "blackhole", "settings": {}, "tag": "blocked"},
],
"policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}},
"routing": {"rules": [
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
{"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below
{"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"},
{"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"},
]},
"stats": {},
"dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIP"},
}
def cols(cur, table):
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
def has_table(cur, name):
return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None
def stream_settings(path, remark):
# security:"none" -- nginx already terminates TLS at the edge and reverse-proxies
# plain HTTP to this inbound; don't handshake TLS a second time here.
# externalProxy -> share links point at the public cover host:443 over TLS (fix #2).
return json.dumps({
"network": "ws", "security": "none",
"wsSettings": {"path": path, "headers": {}},
"externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}],
})
def embedded_client(protocol, now):
"""One settings.clients entry (fix #5).
ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy
of the credentials; clients/client_inbounds are a derived projection of it.
See fix #5 in the module docstring for why leaving this empty bricks the node
on the next upgrade.
Field semantics (client_link.go applyClientRecordMerge):
* non-empty-only overwrite: id / password / security -> a vmess entry may
carry just `id` and a trojan/ss entry just `password`; merging the six
inbounds for one email yields the union rather than clobbering.
* unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime
/ tgId / comment / reset / flow -> must be IDENTICAL in all six entries.
* `enable` must be explicit: omitting it unmarshals to false, and xray.go
then skips the client on EVERY inbound.
"""
c = {
"email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "",
"limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0,
"comment": "", "reset": 0, "created_at": now, "updated_at": now,
}
if protocol == "vmess":
c.update({"id": XUI_UUID, "security": "auto", "alterId": 0})
elif protocol == "trojan":
c["password"] = NODE_PASSWORD
elif protocol == "shadowsocks":
c.update({"password": NODE_PASSWORD, "method": SS_METHOD})
else:
raise ValueError(protocol)
return c
def inbound_settings(protocol, now):
clients = [embedded_client(protocol, now)]
if protocol == "vmess":
return json.dumps({"clients": clients, "disableInsecureEncryption": False})
if protocol == "trojan":
return json.dumps({"clients": clients, "fallbacks": []})
if protocol == "shadowsocks":
# top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client.
return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"})
raise ValueError(protocol)
def main():
conn = sqlite3.connect(DB)
cur = conn.cursor()
ic = cols(cur, "inbounds")
new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds")
existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()}
now = int(time.time() * 1000)
warp_tags = []
inbound_ids = {}
for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS:
for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)):
tag = "inbound-%s-%s" % (protocol, suffix)
remark = "%s-%s" % (protocol, suffix)
if port in existing_ports:
print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix))
inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0]
else:
row = {
"user_id": 1, # fix #1: NULL user_id -> invisible in panel lists
"up": 0, "down": 0, "total": 0, "remark": remark,
"enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol,
"settings": inbound_settings(protocol, now),
"stream_settings": stream_settings(path, remark),
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
"tag": tag,
}
keys = [k for k in row if k in ic]
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
[row[k] for k in keys])
inbound_ids[tag] = cur.lastrowid
print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path))
if suffix == "warp":
warp_tags += [tag, "inbound-127.0.0.1:%d" % port]
if new_schema:
# One node-main client carrying BOTH uuid (vmess) and password (trojan+ss),
# linked to all six inbounds. 3x-ui picks the right credential per inbound.
row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone()
if row:
cid = row[0]
cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? "
"WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid))
else:
cur.execute(
"INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb,"
"expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)",
(EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now),
)
cid = cur.lastrowid
for tag, iid in inbound_ids.items():
if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?",
(cid, iid)).fetchone():
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
"VALUES (?,?,?,?)", (cid, iid, "", now))
# client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main.
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
any_iid = inbound_ids["inbound-vmess-direct"]
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
"VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL))
print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)")
# WARP routing rule: read-modify-write (fix #4) -- append, never clobber.
row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone()
tmpl = json.loads(row[0]) if row else TEMPLATE
for rule in tmpl["routing"]["rules"]:
if rule.get("outboundTag") == "warp-outbound":
for t in warp_tags:
if t not in rule["inboundTag"]:
rule["inboundTag"].append(t)
cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),))
if cur.rowcount == 0:
cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),))
conn.commit()
conn.close()
print(" xrayTemplateConfig written, warp rule tags:", tmpl["routing"]["rules"][1]["inboundTag"])
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
time.sleep(6)
print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Push 08-xui-bootstrap.py to the box and run it with your config injected as env vars.
# Adds all SIX inbounds in one pass, under ONE node-main client: vmess/trojan/shadowsocks x direct/warp.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
require_vars XUI_UUID NODE_PASSWORD SUB_COVER DOMAIN_ROOT \
PORT_VMESS_DIRECT PORT_VMESS_WARP WS_PATH_VMESS_DIRECT WS_PATH_VMESS_WARP \
PORT_TROJAN_DIRECT PORT_TROJAN_WARP WS_PATH_TROJAN_DIRECT WS_PATH_TROJAN_WARP \
PORT_SS_DIRECT PORT_SS_WARP WS_PATH_SS_DIRECT WS_PATH_SS_WARP
COVER="$SUB_COVER.$DOMAIN_ROOT"
push "$HERE/08-xui-bootstrap.py" /root/_xui-bootstrap.py
run "COVER='$COVER' XUI_UUID='$XUI_UUID' XUI_SUBID='$XUI_SUBID' \
NODE_PASSWORD='$NODE_PASSWORD' SS_METHOD='${SS_METHOD:-chacha20-poly1305}' \
PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \
WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \
PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \
WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \
PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \
WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \
WARP_SOCKS_PORT='$WARP_SOCKS_PORT' \
python3 /root/_xui-bootstrap.py; rm -f /root/_xui-bootstrap.py"

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Runs ON the box. Makes a DB-provisioned node upgrade-safe.
WHY THIS EXISTS
---------------
3x-ui's real source of truth for client credentials is the per-inbound
`inbounds.settings.clients` JSON array -- NOT the normalized `clients` /
`client_inbounds` tables. Those tables are a *derived projection* of the JSON:
* client_link.go SyncInbound(tx, inboundId, clients) does an UNCONDITIONAL
`DELETE FROM client_inbounds WHERE inbound_id = ?` and then rebuilds the
rows only from the `clients` slice it was handed. Empty slice -> delete,
insert nothing.
* inbound_migration.go MigrationRequirements() hands SyncInbound the clients
it got from GetClients(inbound), i.e. parsed from the settings JSON.
* The panel UI path (client_inbound_apply.go addInboundClient) ALWAYS writes
the client into the settings JSON first, saves the inbound, then projects
to the tables via SyncInbound. So a UI-managed install always has a
populated settings.clients and migration is a harmless no-op for it.
A node provisioned by writing the tables directly (settings.clients left as
`[]`) is therefore off-model: any run of the migration path -- `x-ui migrate`,
which install.sh/update.sh invoke on upgrade, plus DB import/restore and the
SQLite->PostgreSQL copy -- wipes every client_inbounds row and silently drops
all credentials from the generated xray config. Verified empirically: running
`x-ui migrate` on a table-only node emptied client_inbounds immediately.
This is NOT specific to any one release; the logic is unchanged since 3.5.0.
WHAT THIS DOES
--------------
Backfills `settings.clients` for every proxy inbound from the node-main client
row, then re-asserts the client_inbounds links. Idempotent -- safe to re-run,
and re-running after any 3x-ui upgrade is the recommended belt-and-braces step.
FIELD SEMANTICS (client_link.go applyClientRecordMerge) -- get these wrong and
you corrupt the merged client row:
* Credential-ish fields overwrite ONLY when non-empty: id, password, auth,
secret, security, privateKey, publicKey. That's why a vmess entry may carry
only `id` and a trojan/ss entry only `password` -- across the six inbounds
the same email merges to a union instead of clobbering.
* These overwrite UNCONDITIONALLY, so all six entries must agree:
enable, subId, limitIp, totalGB, expiryTime, tgId, comment, reset, flow.
* `enable` is the dangerous one: omit it and JSON unmarshals it to false,
which makes xray.go skip the client on EVERY inbound -> node dead.
"""
import json, os, sqlite3, subprocess, sys, time
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
EMAIL = os.environ.get("XUI_EMAIL", "node-main")
RESTART = os.environ.get("RESTART_XUI", "1") == "1"
PROXY_PROTOCOLS = ("vmess", "vless", "trojan", "shadowsocks")
def build_entry(protocol, uuid, password, sub_id, method, now):
"""One settings.clients entry. Unconditional fields identical across inbounds."""
e = {
"email": EMAIL,
"enable": True, # MUST be explicit -- see module docstring
"subId": sub_id or "",
"flow": "",
"limitIp": 0,
"totalGB": 0,
"expiryTime": 0,
"tgId": 0,
"comment": "",
"reset": 0,
"created_at": now,
"updated_at": now,
}
if protocol in ("vmess", "vless"):
e["id"] = uuid
e["security"] = "auto"
if protocol == "vmess":
e["alterId"] = 0
else:
e["password"] = password
if protocol == "shadowsocks":
e["method"] = method
return e
def main():
con = sqlite3.connect(DB)
cur = con.cursor()
row = cur.execute(
"SELECT id, uuid, password, sub_id FROM clients WHERE email=?", (EMAIL,)
).fetchone()
if not row:
sys.exit("ERROR: no client row for email=%r -- nothing to embed" % EMAIL)
cid, uuid, password, sub_id = row
now = int(time.time() * 1000)
inbounds = cur.execute(
"SELECT id, port, protocol, settings FROM inbounds ORDER BY port"
).fetchall()
changed = 0
for iid, port, protocol, settings_raw in inbounds:
if protocol not in PROXY_PROTOCOLS:
continue
try:
st = json.loads(settings_raw) if settings_raw else {}
except (TypeError, ValueError):
print(" :%s %s -- settings not valid JSON, SKIPPED" % (port, protocol))
continue
method = st.get("method", "chacha20-poly1305")
entry = build_entry(protocol, uuid, password, sub_id, method, now)
existing = st.get("clients")
if not isinstance(existing, list):
existing = []
# Replace only our own email's entry; leave any other client untouched.
kept = [c for c in existing if isinstance(c, dict) and c.get("email") != EMAIL]
new_clients = kept + [entry]
if existing == new_clients:
print(" :%s %-12s settings.clients already correct" % (port, protocol))
else:
st["clients"] = new_clients
cur.execute(
"UPDATE inbounds SET settings=? WHERE id=?",
(json.dumps(st, indent=2, ensure_ascii=False), iid),
)
changed += 1
print(" :%s %-12s settings.clients embedded" % (port, protocol))
# Re-assert the derived link (harmless if already there).
cur.execute(
"INSERT OR IGNORE INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
"VALUES (?,?,?,?)", (cid, iid, "", now),
)
# client_traffics.email is globally unique -> exactly one row for this client.
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
first = cur.execute("SELECT id FROM inbounds ORDER BY port").fetchone()
if first:
cur.execute(
"INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
"VALUES (?,1,?,0,0,0,0,0)", (first[0], EMAIL),
)
con.commit()
links = cur.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
con.close()
print(" settings.clients updated on %d inbound(s); client_inbounds rows now: %d" % (changed, links))
if RESTART:
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
time.sleep(6)
print("OK -- x-ui restarted; node is now upgrade-safe (settings.clients is authoritative)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Runs ON the box with aaPanel's bundled python:
/www/server/panel/pyenv/bin/python /root/_aapanel-sites.py
Registers 4 domains as panel-native sites (AddSite + SetSSL + CreateProxy + HttpToHttps)
so they are visible/manageable in the aaPanel UI (never hand-write vhosts — the UI won't
see those). Reads subdomain labels + cover paths from environment variables.
"""
import json, os, sys
sys.path.insert(0, "/www/server/panel")
sys.path.insert(0, "/www/server/panel/class")
sys.path.insert(0, "/www/server/panel/class_v2") # aaPanel 7.x moved some modules here (e.g. theme_config)
import public # noqa: E402
import panelSite # noqa: E402
ROOT = os.environ["DOMAIN_ROOT"]
SUB_PANEL = os.environ["SUB_PANEL"]
SUB_XUI = os.environ["SUB_XUI"]
SUB_COVER = os.environ["SUB_COVER"]
XUI_PORT = os.environ.get("XUI_PORT", "14533")
PANEL_PORT = public.readFile("/www/server/panel/data/port.pl").strip()
D_LANDING = ROOT
D_COVER = "%s.%s" % (SUB_COVER, ROOT)
D_XUI = "%s.%s" % (SUB_XUI, ROOT)
D_PANEL = "%s.%s" % (SUB_PANEL, ROOT)
site = panelSite.panelSite()
SITES = [
(D_LANDING, "/www/wwwroot/" + D_LANDING, "edge landing"),
(D_COVER, "/www/wwwroot/" + D_COVER, "node cover"),
(D_XUI, "/www/wwwroot/" + D_XUI, "3x-ui panel proxy"),
(D_PANEL, "/www/wwwroot/" + D_PANEL, "aaPanel proxy"),
]
# 3 protocols x 2 egress = 6 cover proxy rules, all on the same D_COVER site/cert.
_PROTOCOLS = ["VMESS", "TROJAN", "SS"]
PROXIES = [
("xui-panel", D_XUI, "/", "http://127.0.0.1:%s" % XUI_PORT, D_XUI),
("aapanel", D_PANEL, "/", "https://127.0.0.1:%s" % PANEL_PORT, D_PANEL),
]
for proto in _PROTOCOLS:
for suffix in ("DIRECT", "WARP"):
path = os.environ["WS_PATH_%s_%s" % (proto, suffix)]
port = os.environ["PORT_%s_%s" % (proto, suffix)]
PROXIES.append(("cover-%s-%s" % (proto.lower(), suffix.lower()), D_COVER, path,
"http://127.0.0.1:%s" % port, D_COVER))
def obj(d):
return public.to_dict_obj(d)
def main():
for name, path, ps in SITES:
get = obj({"webname": json.dumps({"domain": name, "domainlist": [], "count": 0}),
"path": path, "port": "80", "version": "00", "ps": ps,
"type_id": "0", "type": "static", "ftp": "false", "sql": "false"})
print("AddSite", name, "->", site.AddSite(get).get("siteStatus", "?"))
for name, _p, _ps in SITES:
cdir = "/www/server/panel/vhost/cert/" + name
key = public.readFile(cdir + "/privkey.pem"); csr = public.readFile(cdir + "/fullchain.pem")
if key and csr:
print("SetSSL", name, "->", site.SetSSL(obj({"siteName": name, "key": key, "csr": csr})).get("status"))
for pname, sname, pdir, target, todomain in PROXIES:
get = obj({"proxyname": pname, "sitename": sname, "proxydir": pdir, "proxysite": target,
"todomain": todomain, "type": "1", "cache": "0", "subfilter": "[]",
"advanced": "0", "cachetime": "1", "nocheck": "1"})
print("CreateProxy", sname, pdir, "->", site.CreateProxy(get).get("status"))
for name, _p, _ps in SITES:
print("HttpToHttps", name, "->", site.HttpToHttps(obj({"siteName": name})).get("status"))
print("DONE — reload nginx: /etc/init.d/nginx reload")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Push 09-aapanel-sites.py and run it with aaPanel's bundled python + your config.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
push "$HERE/09-aapanel-sites.py" /root/_aapanel-sites.py
run "DOMAIN_ROOT='$DOMAIN_ROOT' SUB_PANEL='$SUB_PANEL' SUB_XUI='$SUB_XUI' SUB_COVER='$SUB_COVER' XUI_PORT='$XUI_PORT' \
WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \
PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \
WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \
PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \
WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \
PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \
/www/server/panel/pyenv/bin/python /root/_aapanel-sites.py
rm -f /root/_aapanel-sites.py
/etc/init.d/nginx reload 2>&1 | tail -1"

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Hand-written nginx vhosts (no aaPanel):
# - landing : <DOMAIN_ROOT> -> static page
# - cover : <SUB_COVER>.<root> -> 6 WS reverse proxies (one per inbound) + benign root
# - xui panel : <SUB_XUI>.<root> -> 127.0.0.1:<XUI_PORT>
# - default : unmatched SNI -> return 444
# All TLS via the standalone wildcard cert; HTTP :80 -> 301 HTTPS.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
COVER="$SUB_COVER.$DOMAIN_ROOT"
XUI_HOST="$SUB_XUI.$DOMAIN_ROOT"
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
# landing page html (shared by landing + cover root so neither looks dead)
LANDING_HTML="$(sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html")"
# build the 6 WS location blocks
WS_BLOCKS=""
add_ws() { # $1=path $2=port $3=name
WS_BLOCKS+="
# $3
location = $1 {
proxy_pass http://127.0.0.1:$2;
proxy_http_version 1.1;
proxy_set_header Upgrade \\\$http_upgrade;
proxy_set_header Connection \\\$connection_upgrade;
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}"
}
add_ws "$WS_PATH_VMESS_DIRECT" "$PORT_VMESS_DIRECT" "vmess-direct"
add_ws "$WS_PATH_VMESS_WARP" "$PORT_VMESS_WARP" "vmess-warp"
add_ws "$WS_PATH_TROJAN_DIRECT" "$PORT_TROJAN_DIRECT" "trojan-direct"
add_ws "$WS_PATH_TROJAN_WARP" "$PORT_TROJAN_WARP" "trojan-warp"
add_ws "$WS_PATH_SS_DIRECT" "$PORT_SS_DIRECT" "ss-direct"
add_ws "$WS_PATH_SS_WARP" "$PORT_SS_WARP" "ss-warp"
# push landing content to the box
printf '%s' "$LANDING_HTML" | run "mkdir -p /var/www/$DOMAIN_ROOT /var/www/$COVER && cat > /var/www/$DOMAIN_ROOT/index.html && cp /var/www/$DOMAIN_ROOT/index.html /var/www/$COVER/index.html"
# write the vhost config
run "cat > /etc/nginx/conf.d/edge.conf <<CONF
map \\\$http_upgrade \\\$connection_upgrade { default upgrade; '' close; }
# HTTP -> HTTPS
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 301 https://\\\$host\\\$request_uri;
}
# default TLS server: unmatched SNI -> drop
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate $CERTDIR/fullchain.pem;
ssl_certificate_key $CERTDIR/privkey.pem;
return 444;
}
# landing
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $DOMAIN_ROOT;
ssl_certificate $CERTDIR/fullchain.pem;
ssl_certificate_key $CERTDIR/privkey.pem;
root /var/www/$DOMAIN_ROOT;
index index.html;
location / { try_files \\\$uri \\\$uri/ =404; }
}
# node cover: 6 WS reverse proxies + benign root
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $COVER;
ssl_certificate $CERTDIR/fullchain.pem;
ssl_certificate_key $CERTDIR/privkey.pem;
root /var/www/$COVER;
index index.html;
$WS_BLOCKS
location / { try_files \\\$uri \\\$uri/ =404; }
}
# 3x-ui panel
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $XUI_HOST;
ssl_certificate $CERTDIR/fullchain.pem;
ssl_certificate_key $CERTDIR/privkey.pem;
location / {
proxy_pass http://127.0.0.1:$XUI_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \\\$http_upgrade;
proxy_set_header Connection \\\$connection_upgrade;
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
CONF
# apt nginx ships a default_server on :80 -> remove so ours wins
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx && echo nginx-reloaded"

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Unmatched server_name -> return 444 (drop). Also drop the landing page.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
# landing page
sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html" | run "mkdir -p /www/wwwroot/$DOMAIN_ROOT && cat > /www/wwwroot/$DOMAIN_ROOT/index.html"
run "cat > /www/server/panel/vhost/nginx/default_444.conf <<'CONF'
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /www/server/panel/vhost/cert/$DOMAIN_ROOT/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/$DOMAIN_ROOT/privkey.pem;
return 444;
}
CONF
/www/server/nginx/sbin/nginx -t && /etc/init.d/nginx reload 2>&1 | tail -1"

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Firewall for a node that runs BOTH planes:
# * TCP 22/80/443 — ssh + nginx (the six WS inbounds and the 3x-ui panel are all
# 127.0.0.1-only and reach the world only through nginx:443).
# * UDP x4 — Hysteria2 / TUIC terminate QUIC themselves, so unlike the TCP
# inbounds they need genuinely public UDP ports. UDP 443 (hy2-direct) coexists
# with nginx's TCP 443: different protocol, different socket.
# RackNerd KVM has no provider-side firewall, so ufw is the only gate here.
. "$(dirname "$0")/_lib.sh"
require_vars PORT_HY2_DIRECT PORT_HY2_WARP PORT_TUIC_DIRECT PORT_TUIC_WARP
run "for p in 22 80 443; do ufw allow \$p/tcp >/dev/null 2>&1; done
for p in $PORT_HY2_DIRECT $PORT_HY2_WARP $PORT_TUIC_DIRECT $PORT_TUIC_WARP; do
ufw allow \$p/udp >/dev/null 2>&1
done
yes | ufw enable >/dev/null 2>&1 || true
ufw status verbose | grep -E 'Status|/tcp|/udp'"

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Firewall: only 22/80/443 need to be public (all 6 inbounds + 3x-ui are 127.0.0.1-only,
# reverse-proxied through nginx on 443). NOTE: on AWS the Security Group is the outer gate
# and must also allow inbound 22/80/443 — ufw alone is not enough.
. "$(dirname "$0")/_lib.sh"
run 'for p in 22 80 443; do ufw allow "$p"/tcp >/dev/null 2>&1; done
yes | ufw enable >/dev/null 2>&1 || true
ufw status verbose | grep -E "22|80|443|Status"'

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Allow web + panel + aaPanel FTP/passive ports. Reads the real panel port from the box.
. "$(dirname "$0")/_lib.sh"
run 'P=$(cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"); P=${P:-8888}
for p in 22 80 443 "$P" 888; do ufw allow "$p"/tcp >/dev/null 2>&1; done
ufw allow 39000:40000/tcp >/dev/null 2>&1
yes | ufw enable >/dev/null 2>&1 || true
ufw status | grep -E "22|80|443|$P|888|39000"'

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Optional: install Nezha + Komari agents pointing at YOUR panels (placeholders in config.env).
# Both are skipped unless the relevant config.env fields are set. No panel URLs/keys ship here.
. "$(dirname "$0")/_lib.sh"
# --- Komari (auto-discovery) ---
if [ -n "${KOMARI_ENDPOINT:-}" ] && [ -n "${KOMARI_DISCOVERY_KEY:-}" ]; then
run "curl -sSL https://raw.githubusercontent.com/komari-monitor/komari-agent/main/install.sh \
| bash -s -- --endpoint '$KOMARI_ENDPOINT' --auto-discovery '$KOMARI_DISCOVERY_KEY' 2>&1 | tail -3
systemctl is-active komari-agent 2>/dev/null || true"
echo "Komari: agent installed. Rename/group the node in your panel (it auto-registers as 'Auto-<hostname>')."
else
echo "Komari: skipped (set KOMARI_ENDPOINT + KOMARI_DISCOVERY_KEY to enable)."
fi
# --- Nezha (agent v2): download binary + write config.yml + systemd (robust across arch;
# the one-liner installer changes often, so we do it explicitly). ---
if [ -n "${NEZHA_SERVER:-}" ] && [ -n "${NEZHA_SECRET:-}" ]; then
ARCH="$(box_arch)"
[ -n "${NEZHA_UUID:-}" ] || NEZHA_UUID="$(run 'cat /proc/sys/kernel/random/uuid')"
run "set -e
mkdir -p /opt/nezha/agent
ver=\$(curl -sL https://api.github.com/repos/nezhahq/agent/releases/latest | grep -oE '\"tag_name\": *\"[^\"]+' | head -1 | grep -oE 'v[0-9.]+')
url=https://github.com/nezhahq/agent/releases/download/\$ver/nezha-agent_linux_${ARCH}.zip
curl -fsSL \"\$url\" -o /tmp/nezha-agent.zip
(command -v unzip >/dev/null || (apt-get update -qq && apt-get install -y -qq unzip >/dev/null))
unzip -oq /tmp/nezha-agent.zip -d /opt/nezha/agent && rm -f /tmp/nezha-agent.zip
chmod +x /opt/nezha/agent/nezha-agent
cat > /opt/nezha/agent/config.yml <<CFG
client_secret: $NEZHA_SECRET
server: $NEZHA_SERVER
tls: ${NEZHA_TLS:-false}
uuid: $NEZHA_UUID
disable_auto_update: true
disable_force_update: true
CFG
cat > /etc/systemd/system/nezha-agent.service <<'SVC'
[Unit]
Description=Nezha Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/nezha/agent/nezha-agent -c /opt/nezha/agent/config.yml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVC
systemctl daemon-reload
systemctl enable --now nezha-agent
sleep 3; systemctl is-active nezha-agent"
echo "Nezha: agent installed (uuid $NEZHA_UUID, arch $ARCH). Name/group the node in your panel."
else
echo "Nezha: skipped (set NEZHA_SERVER + NEZHA_SECRET to enable)."
fi

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# End-to-end smoke test (no aaPanel): all 6 inbounds (vmess/trojan/ss x direct/warp),
# landing page, 3x-ui panel, and the anti-scan default-444 catch-all.
. "$(dirname "$0")/_lib.sh"
COVER="$SUB_COVER.$DOMAIN_ROOT"
echo "== credential integrity (catches the post-upgrade client wipe) =="
# `x-ui migrate` (run by install.sh/update.sh on every upgrade) 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
# authenticating nobody -- so the transport checks below pass on a dead node.
# 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) =="
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 + 3x-ui panel (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
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) =="
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
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"

View File

@@ -0,0 +1,142 @@
#!/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"

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# QUIC plane: install sing-box and serve whichever of Hysteria2 / TUIC has ports
# configured in config.env, each with a direct and a WARP-clean-exit inbound.
#
# Additive by design -- safe to run on a node already serving the six TCP inbounds:
# * sing-box is its own service on its own UDP ports; 3x-ui / Xray / nginx are
# never touched, and nothing here writes to 3x-ui's database or nginx's config.
# * The one shared resource is the acme.sh wildcard cert. sing-box reads it at
# startup only, so the renew hook is EXTENDED (nginx reload + sing-box restart)
# rather than replaced -- otherwise the QUIC plane quietly serves an expired
# cert about 60 days later.
#
# Ports: set PORT_HY2_DIRECT / PORT_HY2_WARP and/or PORT_TUIC_DIRECT / PORT_TUIC_WARP.
# Unset pairs are skipped. Putting a Hysteria2 inbound on UDP 443 is deliberate: it
# reads as ordinary HTTP/3 traffic to that host, and it does not collide with nginx,
# which holds TCP 443 (different protocol, different socket).
#
# Remember the firewall has two layers on some providers: this script opens the UDP
# ports in ufw, but a cloud security group (AWS/GCP/Azure) must allow them too.
. "$(dirname "$0")/_lib.sh"
HERE="$(cd "$(dirname "$0")" && pwd)"
require_vars NODE_PASSWORD SUB_COVER DOMAIN_ROOT
[ -n "${PORT_HY2_DIRECT:-}${PORT_HY2_WARP:-}${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ] || {
echo "ERROR: no QUIC ports set in config.env (PORT_HY2_* / PORT_TUIC_*)" >&2; exit 1; }
# TUIC authenticates with uuid+password, so it needs XUI_UUID.
if [ -n "${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ]; then require_vars XUI_UUID; fi
COVER="$SUB_COVER.$DOMAIN_ROOT"
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
echo "== install sing-box =="
run 'if ! command -v sing-box >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
for i in $(seq 1 60); do fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break; sleep 10; done
mkdir -p /etc/apt/keyrings
curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc
chmod a+r /etc/apt/keyrings/sagernet.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" > /etc/apt/sources.list.d/sagernet.list
apt-get update -qq
apt-get install -y -qq sing-box >/dev/null || { echo "ERROR: sing-box install failed"; exit 1; }
fi
sing-box version | head -1'
echo "== verify the cert the QUIC plane will serve =="
run "[ -s '$CERTDIR/fullchain.pem' ] && [ -s '$CERTDIR/privkey.pem' ] || { echo 'ERROR: wildcard cert missing at $CERTDIR — run 03-sign-*-cert.sh first'; exit 1; }
echo cert-present"
echo "== generate config =="
push "$HERE/13-singbox-config.py" /root/_singbox_config.py
run "COVER='$COVER' CERT_DIR='$CERTDIR' WARP_SOCKS_PORT='${WARP_SOCKS_PORT:-40000}' \
NODE_PASSWORD='$NODE_PASSWORD' XUI_UUID='${XUI_UUID:-}' \
PORT_HY2_DIRECT='${PORT_HY2_DIRECT:-}' PORT_HY2_WARP='${PORT_HY2_WARP:-}' \
HY2_OBFS_PASSWORD='${HY2_OBFS_PASSWORD:-}' \
HY2_UP_MBPS='${HY2_UP_MBPS:-500}' HY2_DOWN_MBPS='${HY2_DOWN_MBPS:-500}' \
PORT_TUIC_DIRECT='${PORT_TUIC_DIRECT:-}' PORT_TUIC_WARP='${PORT_TUIC_WARP:-}' \
TUIC_CC='${TUIC_CC:-bbr}' \
python3 /root/_singbox_config.py
rm -f /root/_singbox_config.py
sing-box check -c /etc/sing-box/config.json && echo config-valid"
echo "== open the UDP ports in ufw =="
UDP_PORTS="${PORT_HY2_DIRECT:-} ${PORT_HY2_WARP:-} ${PORT_TUIC_DIRECT:-} ${PORT_TUIC_WARP:-}"
run "for p in $UDP_PORTS; do [ -n \"\$p\" ] && ufw allow \$p/udp >/dev/null 2>&1; done
ufw status | grep -E '/udp' || echo 'no udp rules (is ufw active?)'"
echo "== enable + start =="
run 'systemctl enable sing-box >/dev/null 2>&1 || true
systemctl restart sing-box
sleep 4
systemctl is-active sing-box
ss -ulnp | grep sing-box || echo "WARN: sing-box has no UDP sockets"'
echo "== extend the cert-renew hook to restart sing-box =="
# Resolve acme.sh on the box rather than writing `~/.acme.sh`: on a sudo-user login
# the tilde expands in the *login* shell (HOME=/home/<user>) before sudo runs, so a
# root-installed acme.sh is missed and the hook silently never gets updated.
run "ACME=\$(for c in /root/.acme.sh/acme.sh \"\$HOME/.acme.sh/acme.sh\"; do [ -x \"\$c\" ] && echo \"\$c\" && break; done)
[ -n \"\$ACME\" ] || { echo 'ERROR: acme.sh not found — cannot update the renew hook'; exit 1; }
echo \"using \$ACME\"
\$ACME --install-cert -d '$DOMAIN_ROOT' --ecc \
--key-file '$CERTDIR/privkey.pem' \
--fullchain-file '$CERTDIR/fullchain.pem' \
--reloadcmd 'systemctl reload nginx; systemctl restart sing-box' 2>&1 | tail -2"
echo
echo "NOTE: on AWS/GCP/Azure the cloud security group must also allow inbound UDP:$UDP_PORTS"

View File

@@ -0,0 +1,105 @@
#!/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": "::", "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": "::", "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 = []
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},
"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)))

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()

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Run LOCALLY. Emits share links + QR codes + sing-box snippets for the QUIC plane
(Hysteria2 / TUIC x direct/WARP). Companion to 14-generate-links-and-qr.py, which
covers the six TCP inbounds.
QR codes are produced offline with the local `qrencode` binary -- these carry live
credentials and must never be pasted into an online QR generator.
"""
import json, os, re, subprocess, sys
from urllib.parse import quote
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = 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: config.env not found")
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"])
NAME = E.get("NODE_NAME", "Edge Node")
PW = E["NODE_PASSWORD"]
UUID = E["XUI_UUID"]
OBFS = E["HY2_OBFS_PASSWORD"]
CC = E.get("TUIC_CC", "bbr")
os.makedirs(OUT, exist_ok=True)
def write(fname, content):
with open(os.path.join(OUT, fname), "w") as f:
f.write(content + "\n")
print(" wrote", fname)
def qr(fname, data):
try:
subprocess.run(["qrencode", "-o", os.path.join(OUT, fname), "-s", "6", data], check=True)
print(" wrote", fname)
except (FileNotFoundError, subprocess.CalledProcessError):
print(" (skipped %s -- install qrencode for QR codes)" % fname)
def p(name):
"""Empty/absent port -> protocol not deployed on this node; skip it."""
return (E.get(name) or "").strip()
# --- Hysteria2: hy2:// is the format sing-box / v2rayN / Shadowrocket accept ---
for suffix, port in (("direct", p("PORT_HY2_DIRECT")), ("warp", p("PORT_HY2_WARP"))):
if not port:
continue
tag = "%s-hy2-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit")
link = ("hy2://%s@%s:%s/?sni=%s&alpn=h3&obfs=salamander&obfs-password=%s#%s"
% (quote(PW, safe=""), COVER, port, COVER, quote(OBFS, safe=""), quote(tag)))
write("hy2-%s.link" % suffix, link)
qr("hy2-%s-qr.png" % suffix, link)
write("hy2-%s-singbox.json" % suffix, json.dumps({
"type": "hysteria2", "tag": tag, "server": COVER, "server_port": int(port),
"password": PW,
"obfs": {"type": "salamander", "password": OBFS},
"up_mbps": int(E.get("HY2_UP_MBPS", 500)), "down_mbps": int(E.get("HY2_DOWN_MBPS", 500)),
"tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]},
}, indent=2))
# --- TUIC v5 ---
for suffix, port in (("direct", p("PORT_TUIC_DIRECT")), ("warp", p("PORT_TUIC_WARP"))):
if not port:
continue
tag = "%s-tuic-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit")
link = ("tuic://%s:%s@%s:%s?sni=%s&alpn=h3&congestion_control=%s&udp_relay_mode=native#%s"
% (UUID, quote(PW, safe=""), COVER, port, COVER, CC, quote(tag)))
write("tuic-%s.link" % suffix, link)
qr("tuic-%s-qr.png" % suffix, link)
write("tuic-%s-singbox.json" % suffix, json.dumps({
"type": "tuic", "tag": tag, "server": COVER, "server_port": int(port),
"uuid": UUID, "password": PW, "congestion_control": CC,
"udp_relay_mode": "native", "zero_rtt_handshake": False, "heartbeat": "10s",
"tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]},
}, indent=2))
print("DONE — QUIC links + QR + sing-box snippets in", OUT)
print("NOTE: hy2:// and tuic:// share-link formats are client-dependent; the sing-box")
print(" JSON snippets are the unambiguous fallback if a client rejects a link.")

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Runs ON the box. Adds a password-authenticated SOCKS5 inbound to 3x-ui.
Reuses the existing Xray instance -- no new daemon. Xray implements SOCKS5 with
RFC 1929 username/password auth natively, so this is purely a new inbound row.
Additive and idempotent: skips if the port already exists, touches no other
inbound, writes nothing to nginx or sing-box.
Two things worth knowing about this inbound versus the other ten on this node:
* It is NOT wrapped in TLS. SOCKS5 auth per RFC 1929 sends the username and
password in CLEARTEXT, and the tunnelled traffic is unencrypted too. Anyone
on the path can read both. Raw SOCKS5 is also trivially fingerprinted. Treat
the credentials as disposable and prefer binding to 127.0.0.1 + an SSH tunnel
if the client can be pointed at a local port (see LISTEN below).
* `socks` is not one of the protocols 3x-ui projects through its clients /
client_inbounds tables (that set is vmess/vless/trojan/shadowsocks/hysteria),
so the accounts live directly in this inbound's settings JSON and the
settings.clients migration hazard documented in 08-xui-bootstrap.py fix #5
does not apply here.
Env in: SOCKS_PORT, SOCKS_USER, SOCKS_PASS, SERVER_IP
LISTEN (default 0.0.0.0; set 127.0.0.1 for SSH-tunnel-only access)
REMARK (default socks5-direct), XUI_DB
"""
import json, os, sqlite3, subprocess, sys, time
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
PORT = int(os.environ["SOCKS_PORT"])
USER = os.environ["SOCKS_USER"]
PASS = os.environ["SOCKS_PASS"]
LISTEN = os.environ.get("LISTEN", "0.0.0.0")
REMARK = os.environ.get("REMARK", "socks5-direct")
# Xray announces this address in the UDP ASSOCIATE reply. For a remote server it
# must be the public IP, or UDP-over-SOCKS5 breaks (TCP still works either way).
SERVER_IP = os.environ.get("SERVER_IP", "127.0.0.1")
TAG = "inbound-" + REMARK
def cols(cur, table):
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
def main():
con = sqlite3.connect(DB)
cur = con.cursor()
if cur.execute("SELECT 1 FROM inbounds WHERE port=?", (PORT,)).fetchone():
print(" inbound :%d already exists, nothing to do" % PORT)
return
row = {
"user_id": 1, # NULL user_id -> proxies fine but INVISIBLE in the panel
"up": 0, "down": 0, "total": 0, "remark": REMARK,
"enable": 1, "expiry_time": 0,
"listen": LISTEN, "port": PORT, "protocol": "socks",
"settings": json.dumps({
"auth": "password",
"accounts": [{"user": USER, "pass": PASS}],
"udp": True,
"ip": SERVER_IP,
}, indent=2),
"stream_settings": json.dumps({"network": "tcp", "security": "none"}, indent=2),
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
"tag": TAG,
}
ic = cols(cur, "inbounds")
keys = [k for k in row if k in ic]
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
[row[k] for k in keys])
con.commit()
con.close()
print(" inbound :%d socks5 added (listen=%s, auth=password, udp=on)" % (PORT, LISTEN))
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
time.sleep(6)
print(" x-ui restarted")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Shared helpers. Every script sources this, which sources config.env.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$HERE/config.env" ] || { echo "ERROR: copy config.env.example -> config.env and fill it in"; exit 1; }
# shellcheck disable=SC1091
. "$HERE/config.env"
# --- config sanity (fail fast) ---
require_vars() {
local miss=()
for v in "$@"; do [ -n "${!v:-}" ] || miss+=("$v"); done
if [ ${#miss[@]} -gt 0 ]; then echo "ERROR: config.env missing: ${miss[*]}" >&2; exit 1; fi
}
require_vars SERVER_IP SSH_USER DOMAIN_ROOT
if [ -n "${SSH_KEY:-}" ]; then
[ -f "$SSH_KEY" ] || { echo "ERROR: SSH_KEY not found: $SSH_KEY" >&2; exit 1; }
chmod 600 "$SSH_KEY" 2>/dev/null || true
fi
SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
[ -n "${SSH_KEY:-}" ] && SSH_OPTS+=(-i "$SSH_KEY")
# Non-root login -> escalate on the box via sudo. Redirects/pipes must run as root too,
# so wrap the whole command string in `sudo bash -c`.
_ROOT_LOGIN=1; [ "$SSH_USER" != "root" ] && _ROOT_LOGIN=0
# run <remote command...> — execute on the VPS (as root, escalating if needed)
run() {
if [ "$_ROOT_LOGIN" = 1 ]; then
ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "$@"
else
ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "sudo bash -c $(printf '%q' "$*")"
fi
}
# push <localfile> <remotepath> — copy to the box (stages through /tmp + sudo install if non-root)
push() {
if [ "$_ROOT_LOGIN" = 1 ]; then
scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$2"
else
local stage="/tmp/.stage.$$.$(basename "$2")"
scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$stage"
run "install -D -m 644 '$stage' '$2'; rm -f '$stage'"
fi
}
gen_pass() { python3 -c "import secrets,string;print(''.join(secrets.choice(string.ascii_letters+string.digits) for _ in range($1)))"; }
# panel_port — read the aaPanel port the installer actually assigned (do not trust config guesses)
panel_port() { run 'cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"'; }
# box_arch — normalized arch token: amd64 | arm64
box_arch() { run 'case "$(uname -m)" in x86_64) echo amd64;; aarch64|arm64) echo arm64;; *) uname -m;; esac'; }

View File

@@ -0,0 +1,120 @@
# Copy to config.env and fill in. config.env is YOURS — keep it out of git.
# Nothing here ships with real values; every field is a placeholder.
# --- target VPS ---
SERVER_IP="203.0.113.10" # public IPv4
SERVER_IPV6="2001:db8::10" # public IPv6 (optional; leave empty to skip AAAA)
SSH_USER="root" # root, or a sudo user (scripts escalate via sudo)
SSH_KEY="$HOME/.ssh/your-vps-key.pem" # private key for the VPS
TZ="UTC" # server timezone (e.g. UTC, Asia/Shanghai, America/Los_Angeles)
# --- domain ---
PARENT_ZONE="example.com" # your DNS zone
DOMAIN_ROOT="nodeA.example.com" # this node owns *.nodeA.example.com
# --- DNS provider ---
# Cloudflare is the default & has a turnkey record-creation script (02-cf-dns.py).
# Any acme.sh-supported DNS provider works for the wildcard cert — set ACME_DNS_PLUGIN and
# the matching credential vars below. For A/AAAA records on a non-CF provider, create them
# yourself (see references/dns-records.md — it's two names, A + AAAA, DNS-only).
DNS_PROVIDER="cloudflare" # informational: cloudflare | dnspod | aliyun | route53 | other
ACME_DNS_PLUGIN="dns_cf" # acme.sh plugin: dns_cf | dns_dp | dns_ali | dns_aws | dns_gd | ...
# Cloudflare (default). Token needs Zone:DNS:Edit. NEVER commit.
CF_API_TOKEN="cf-token-with-Zone-DNS-Edit"
CF_ZONE_ID="" # optional; 02-cf-dns.py looks it up if empty
# Other providers — uncomment the pair your ACME_DNS_PLUGIN needs (acme.sh reads these env names):
# DP_Id=""; DP_Key="" # dns_dp (DNSPod)
# Ali_Key=""; Ali_Secret="" # dns_ali (Aliyun)
# AWS_ACCESS_KEY_ID=""; AWS_SECRET_ACCESS_KEY="" # dns_aws (Route53)
# GD_Key=""; GD_Secret="" # dns_gd (GoDaddy)
# --- subdomain labels (CHOOSE YOUR OWN, NON-OBVIOUS ONES) ---
# Your panels sit at <SUB_*>.<DOMAIN_ROOT>. If your zone is public, do NOT reuse an
# obvious convention (a label that names the service it fronts) — anyone can then
# enumerate your control planes from DNS. Pick random-ish labels and keep them to
# yourself. These examples are neutral placeholders, deliberately NOT a real scheme.
SUB_PANEL="console" # aaPanel -> console.<root>
SUB_XUI="gateway" # 3x-ui panel -> gateway.<root>
SUB_COVER="edge" # node cover -> edge.<root>
# landing page uses the bare <DOMAIN_ROOT>.
# --- aaPanel ---
AAPANEL_USER="admin_you"
AAPANEL_ENTRANCE="/change-this-entrance" # secret path, e.g. /8f3a1c
AAPANEL_PORT="" # leave EMPTY — the installer assigns a random port;
# scripts read it from the box (/www/server/panel/data/port.pl).
# Only set this to force a specific value.
AAPANEL_PASS="" # generate at runtime; store in password manager
# --- 3x-ui ---
XUI_USER="panel_you"
XUI_PATH="/change-this-path/" # webBasePath, trailing slash
XUI_PORT="14533" # panel listen 127.0.0.1:XUI_PORT
XUI_PASS="" # generate at runtime; store in password manager
WARP_SOCKS_PORT="40000" # cloudflare-warp proxy SOCKS5 port
# --- three protocols x two egress paths = six inbounds, ONE client ---
# All six inbounds (vmess/trojan/shadowsocks x direct/warp) are owned by a single
# 3x-ui client "node-main", so the panel shows one client whose Info dialog lists
# all six share links + one subscription. That one client carries BOTH credentials:
# - XUI_UUID -> authenticates the two VMess inbounds
# - NODE_PASSWORD -> authenticates the two Trojan AND two Shadowsocks inbounds
# (trojan+ss share this one password by design — it's one client row; that's
# what makes the single-client panel view work. See references/3x-ui-schema.md.)
# Ports are internal (127.0.0.1, behind nginx) — only 443 is ever exposed publicly.
# Paths are the WS cover paths: CHOOSE YOUR OWN, NON-OBVIOUS ONES (don't reuse a
# convention, don't publish them — on a public zone they map your tunnels). These
# examples are neutral placeholders, deliberately NOT a real scheme.
# Shared client credentials (node-main)
XUI_UUID="" # generate at runtime (uuidgen); VMess auth
NODE_PASSWORD="" # generate at runtime; Trojan + Shadowsocks auth; store in password manager
XUI_SUBID="" # generate at runtime; subscription id
SS_METHOD="chacha20-poly1305" # non-2022 cipher: plain-string password, no exact-length base64 PSK to misconfigure
# VMess
PORT_VMESS_DIRECT="57707"
PORT_VMESS_WARP="57708"
WS_PATH_VMESS_DIRECT="/assets"
WS_PATH_VMESS_WARP="/media"
# Trojan
PORT_TROJAN_DIRECT="57709"
PORT_TROJAN_WARP="57710"
WS_PATH_TROJAN_DIRECT="/static"
WS_PATH_TROJAN_WARP="/uploads"
# Shadowsocks
PORT_SS_DIRECT="57711"
PORT_SS_WARP="57712"
WS_PATH_SS_DIRECT="/files"
WS_PATH_SS_WARP="/download"
# --- monitoring (optional; leave empty to skip) ---
NEZHA_SERVER="" # e.g. report.example.com:8966
NEZHA_TLS="false"
NEZHA_SECRET="" # shared agent key for your Nezha panel
NEZHA_UUID="" # generate at runtime
KOMARI_ENDPOINT="" # e.g. https://komari.example.com
KOMARI_DISCOVERY_KEY="" # auto-discovery key for your Komari panel
# --- node display name (monitoring / labels) ---
NODE_NAME="Edge Node A"
# --- optional QUIC plane (sing-box; blank ports skip that inbound) ---
PORT_HY2_DIRECT=""
PORT_HY2_WARP=""
HY2_OBFS_PASSWORD="" # generate privately for salamander links
HY2_UP_MBPS="500" # server-side bandwidth; match the actual link
HY2_DOWN_MBPS="500"
PORT_TUIC_DIRECT=""
PORT_TUIC_WARP=""
TUIC_CC="bbr"
# --- optional internal SOCKS5 (16-add-socks5.py runs ON the VPS via env) ---
SOCKS_PORT=""
SOCKS_USER=""
SOCKS_PASS=""
LISTEN="127.0.0.1" # SSH-tunnel-only example

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Check Cloudflare WARP proxy health and recover if needed
After=network-online.target warp-svc.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/warp-healthcheck.sh

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# WARP proxy watchdog: WARP can show "Connected" while the SOCKS proxy stops egressing.
# Checks warp=on through the SOCKS proxy; restarts warp-svc + reconnects if not.
set -euo pipefail
PROXY_ADDR="${PROXY_ADDR:-socks5h://127.0.0.1:40000}"
TRACE_URL="${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}"
TIMEOUT="${CURL_TIMEOUT:-15}"
log() { logger -t warp-healthcheck "$*"; echo "warp-healthcheck: $*"; }
if ! ss -lnt | grep -q '127.0.0.1:40000'; then
log "SOCKS listener missing; restarting warp-svc"
elif trace="$(curl -k -sS --max-time "$TIMEOUT" --proxy "$PROXY_ADDR" "$TRACE_URL" 2>/dev/null)" && grep -q 'warp=on' <<<"$trace"; then
exit 0 # healthy
else
log "warp not on via proxy; recovering"
fi
systemctl restart warp-svc 2>/dev/null || true
sleep 8
warp-cli --accept-tos connect 2>/dev/null || true

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run Cloudflare WARP health check every minute
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=15s
Persistent=true
[Install]
WantedBy=timers.target