feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable file
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable 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"
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable 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"
|
||||
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable file
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable 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)
|
||||
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable file
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable 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"
|
||||
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable file
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable 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"
|
||||
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable file
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable 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'
|
||||
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable file
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable 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'
|
||||
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable file
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable 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'."
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable 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='"
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable 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'
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable 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'"
|
||||
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable 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()
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable 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"
|
||||
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable 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()
|
||||
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable file
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable 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()
|
||||
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable file
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable 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"
|
||||
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable file
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable 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"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable 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"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable 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'"
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable 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"'
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable 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"'
|
||||
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable file
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable 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
|
||||
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable file
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable 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"
|
||||
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable file
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable 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"
|
||||
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable file
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable 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"
|
||||
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable 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)))
|
||||
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable file
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable 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()
|
||||
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable file
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable 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.")
|
||||
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable file
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable 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()
|
||||
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable file
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable 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'; }
|
||||
@@ -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
|
||||
@@ -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
|
||||
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable file
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user