D57: Three-node smart routing + UUID sync to face server

Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/24c233b1-db72-4d9c-b668-22679479825b

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-05 08:00:05 +00:00 committed by GitHub
parent eb7e05306f
commit 5abe4aa852
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 248 additions and 62 deletions

View File

@ -174,6 +174,75 @@ jobs:
;;
esac
# 同步UUID到面孔服务器 (add/remove后自动同步)
- name: '🔄 同步UUID到面孔服务器'
if: >-
(github.event.inputs.action == 'add-user' ||
github.event.inputs.action == 'remove-user') &&
success()
env:
ZY_FACE_KEY: ${{ secrets.ZY_SERVER_KEY }}
ZY_FACE_HOST: ${{ secrets.ZY_SERVER_HOST }}
ZY_FACE_USER: ${{ secrets.ZY_SERVER_USER }}
run: |
# 只在面孔服务器密钥可用时同步
if [ -z "$ZY_FACE_KEY" ] || [ -z "$ZY_FACE_HOST" ]; then
echo "⚠️ 面孔服务器密钥未配置跳过UUID同步"
echo " 用户仍可使用大脑服务器节点"
exit 0
fi
echo "$ZY_FACE_KEY" > ~/.ssh/id_face
chmod 600 ~/.ssh/id_face
ssh-keyscan -H $ZY_FACE_HOST >> ~/.ssh/known_hosts 2>/dev/null
echo "🔄 从大脑服务器获取V2用户列表..."
V2_USERS=$(ssh -i ~/.ssh/id_brain \
${{ secrets.ZY_BRAIN_USER }}@${{ secrets.ZY_BRAIN_HOST }} \
"cd /opt/zhuyuan-brain/proxy && \
node -e \"
const um = require('./service/user-manager');
const users = um.getEnabledUsers();
const clients = users.map(u => ({id: u.uuid, flow: 'xtls-rprx-vision', email: u.email}));
console.log(JSON.stringify(clients));
\"")
echo " V2用户数: $(echo "$V2_USERS" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.length)")"
echo "🔄 同步到面孔服务器Xray配置..."
ssh -i ~/.ssh/id_face \
${ZY_FACE_USER}@${ZY_FACE_HOST} \
"XRAY_CONF='/usr/local/etc/xray/config.json'
if [ ! -f \"\$XRAY_CONF\" ]; then
echo '⚠️ 面孔服务器Xray配置不存在跳过'
exit 0
fi
# 保存V2用户列表
echo '${V2_USERS}' > /opt/zhuyuan/proxy/data/v2-clients.json 2>/dev/null || true
# 将V2用户追加到Xray配置的clients数组
# 先移除旧的V2用户(email包含@)再添加新的保留V1原始用户
if command -v jq &>/dev/null; then
V2_CLIENTS=\$(cat /opt/zhuyuan/proxy/data/v2-clients.json)
jq --argjson v2 \"\$V2_CLIENTS\" '
.inbounds[0].settings.clients = (
[.inbounds[0].settings.clients[] | select(.email == \"bingshuo@zy-proxy\")] + \$v2
)
' \"\$XRAY_CONF\" > /tmp/xray-config-merged.json
if xray run -test -c /tmp/xray-config-merged.json 2>/dev/null; then
mv /tmp/xray-config-merged.json \"\$XRAY_CONF\"
systemctl restart xray
echo '✅ 面孔服务器Xray已同步V2用户并重启'
else
echo '❌ 合并后配置验证失败,保持原配置'
rm -f /tmp/xray-config-merged.json
fi
else
echo '⚠️ jq未安装请在面孔服务器安装: apt install jq'
fi"
# ═══ §3 发送订阅 ═══
send-subscription:
name: '📧 发送V2订阅'

View File

@ -147,6 +147,27 @@ EOF
echo "ZY_SERVER_HOST=${ZY_SERVER_HOST}" >> "$KEYS_FILE"
fi
# 保存面孔服务器节点信息 (多节点智能选路)
if [ -n "${ZY_FACE_HOST:-}" ]; then
echo "" >> "$KEYS_FILE"
echo "# 面孔服务器节点 (ZY-SVR-002 · SG2 · 多节点智能选路)" >> "$KEYS_FILE"
echo "ZY_FACE_HOST=${ZY_FACE_HOST}" >> "$KEYS_FILE"
fi
if [ -n "${ZY_FACE_REALITY_PUBLIC_KEY:-}" ]; then
echo "ZY_FACE_REALITY_PUBLIC_KEY=${ZY_FACE_REALITY_PUBLIC_KEY}" >> "$KEYS_FILE"
fi
if [ -n "${ZY_FACE_REALITY_SHORT_ID:-}" ]; then
echo "ZY_FACE_REALITY_SHORT_ID=${ZY_FACE_REALITY_SHORT_ID}" >> "$KEYS_FILE"
fi
# 保存CN中转节点信息
if [ -n "${ZY_CN_RELAY_HOST:-}" ]; then
echo "" >> "$KEYS_FILE"
echo "# CN中转节点 (国内→SG · 低延迟)" >> "$KEYS_FILE"
echo "ZY_CN_RELAY_HOST=${ZY_CN_RELAY_HOST}" >> "$KEYS_FILE"
echo "ZY_CN_RELAY_PORT=${ZY_CN_RELAY_PORT:-2053}" >> "$KEYS_FILE"
fi
chmod 600 "$KEYS_FILE"
echo ""

View File

@ -48,25 +48,87 @@ function loadKeys() {
return keys;
}
// ── 获取服务器IP ────────────────────────────
function getServerHost() {
if (process.env.ZY_BRAIN_HOST) return process.env.ZY_BRAIN_HOST;
if (process.env.ZY_SERVER_HOST) return process.env.ZY_SERVER_HOST;
// ── 从环境变量或密钥文件读取值 ──────────────
function getEnvOrKey(envName) {
if (process.env[envName]) return process.env[envName];
try {
const content = fs.readFileSync(KEYS_FILE, 'utf8');
for (const line of content.split('\n')) {
if (line.startsWith('#') || !line.includes('=')) continue;
const [key, ...vals] = line.split('=');
const k = key.trim();
if (k === 'ZY_BRAIN_HOST' || k === 'ZY_SERVER_HOST') {
if (key.trim() === envName) {
const val = vals.join('=').trim();
if (val) return val;
}
}
} catch { /* ignore */ }
return '0.0.0.0';
return '';
}
// ── 获取服务器IP ────────────────────────────
function getServerHost() {
return getEnvOrKey('ZY_BRAIN_HOST') || getEnvOrKey('ZY_SERVER_HOST') || '0.0.0.0';
}
// ── 构建所有可用VPN节点 ──────────────────────
// 三台服务器智能选路大脑SG1 + 面孔SG2 + CN中转
function buildVpnNodes() {
const nodes = [];
// 节点1: 大脑服务器 (ZY-SVR-005 · 新加坡一区 · 4核8G · 主力)
const brainHost = getEnvOrKey('ZY_BRAIN_HOST');
const brainPbk = getEnvOrKey('ZY_PROXY_REALITY_PUBLIC_KEY');
const brainSid = getEnvOrKey('ZY_PROXY_REALITY_SHORT_ID');
if (brainHost && brainPbk && brainSid) {
nodes.push({
id: 'zy-brain-sg1',
name: '🧠 铸渊专线V2-SG1(大脑)',
host: brainHost,
port: 443,
pbk: brainPbk,
sid: brainSid,
region: 'sg-zone1',
priority: 1
});
}
// 节点2: 面孔服务器 (ZY-SVR-002 · 新加坡二区 · 2核8G · 备用)
const faceHost = getEnvOrKey('ZY_FACE_HOST');
const facePbk = getEnvOrKey('ZY_FACE_REALITY_PUBLIC_KEY');
const faceSid = getEnvOrKey('ZY_FACE_REALITY_SHORT_ID');
if (faceHost && facePbk && faceSid) {
nodes.push({
id: 'zy-face-sg2',
name: '🏛️ 铸渊专线V2-SG2(面孔)',
host: faceHost,
port: 443,
pbk: facePbk,
sid: faceSid,
region: 'sg-zone2',
priority: 2
});
}
// 节点3: CN中转 (国内→SG · 对国内用户低延迟)
const cnHost = getEnvOrKey('ZY_CN_RELAY_HOST');
const cnPort = parseInt(getEnvOrKey('ZY_CN_RELAY_PORT') || '2053', 10);
// CN中转是TCP透传Reality密钥用主力节点的大脑
if (cnHost && brainPbk && brainSid) {
nodes.push({
id: 'zy-cn-relay',
name: '🇨🇳 铸渊专线V2-CN中转',
host: cnHost,
port: cnPort,
pbk: brainPbk,
sid: brainSid,
region: 'cn-relay',
priority: 3
});
}
return nodes;
}
// ── 生成subscription-userinfo头 ──────────────
@ -79,30 +141,72 @@ function generateUserInfoHeader(user) {
return `upload=${user.traffic.upload_bytes}; download=${user.traffic.download_bytes}; total=${user.quota_bytes}; expire=${Math.floor(nextMonth.getTime() / 1000)}`;
}
// ── 生成VLESS URI (Shadowrocket) ─────────────
function generateVlessUri(user, keys, serverHost) {
const params = new URLSearchParams({
encryption: 'none',
flow: 'xtls-rprx-vision',
security: 'reality',
sni: 'www.microsoft.com',
fp: 'chrome',
pbk: keys.ZY_PROXY_REALITY_PUBLIC_KEY,
sid: keys.ZY_PROXY_REALITY_SHORT_ID,
type: 'tcp',
headerType: 'none'
});
const label = encodeURIComponent(`ZY-V2-${user.label}`);
return `vless://${user.uuid}@${serverHost}:443?${params.toString()}#${label}`;
// ── 生成VLESS URI (Shadowrocket · 多节点) ─────
function generateVlessUris(user, nodes) {
return nodes.map(node => {
const params = new URLSearchParams({
encryption: 'none',
flow: 'xtls-rprx-vision',
security: 'reality',
sni: 'www.microsoft.com',
fp: 'chrome',
pbk: node.pbk,
sid: node.sid,
type: 'tcp',
headerType: 'none'
});
const label = encodeURIComponent(node.name);
return `vless://${user.uuid}@${node.host}:${node.port}?${params.toString()}#${label}`;
}).join('\n');
}
// ── 生成Clash YAML配置 (用户专属) ────────────
function generateClashYaml(user, keys, serverHost) {
// ── 生成Clash YAML配置 (多节点智能选路) ──────
function generateClashYaml(user, nodes) {
// 构建proxies块 — 每个节点一条
const proxyBlocks = nodes.map(node => ` - name: "${node.name}"
type: vless
server: ${node.host}
port: ${node.port}
uuid: ${user.uuid}
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
skip-cert-verify: false
servername: www.microsoft.com
reality-opts:
public-key: ${node.pbk}
short-id: ${node.sid}
client-fingerprint: chrome`).join('\n');
// 节点名列表
const nodeNames = nodes.map(n => ` - "${n.name}"`).join('\n');
// url-test自动选择组 (延迟最低的自动生效)
const autoSelectBlock = nodes.length > 1 ? `
- name: "♻️ 自动选择"
type: url-test
proxies:
${nodeNames}
url: "http://www.gstatic.com/generate_204"
interval: 300
tolerance: 50
` : '';
// 主代理组
const mainProxies = nodes.length > 1
? ` - "♻️ 自动选择"\n${nodeNames}\n - DIRECT`
: `${nodeNames}\n - DIRECT`;
// AI/开发工具代理组的节点列表
const toolProxies = nodes.length > 1
? ` - "♻️ 自动选择"\n${nodeNames}`
: nodeNames;
return `# 铸渊专线V2 · ${user.label} 的独立专线
# 自动生成 · ${new Date().toISOString()}
# 此配置为 ${user.email} 专属请勿分享
# 每人一条独立线路流量独立计算
# 每人一条独立线路 · ${nodes.length}个节点智能选路
# 全局设置
mixed-port: 7890
@ -200,41 +304,26 @@ sniffer:
- "Mijia Cloud"
- "+.push.apple.com"
# 代理节点 (${user.label} 专属)
# 代理节点 (${nodes.length} · 智能选路)
proxies:
- name: "🏛️ 铸渊专线V2-${user.label}"
type: vless
server: ${serverHost}
port: 443
uuid: ${user.uuid}
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
skip-cert-verify: false
servername: www.microsoft.com
reality-opts:
public-key: ${keys.ZY_PROXY_REALITY_PUBLIC_KEY}
short-id: ${keys.ZY_PROXY_REALITY_SHORT_ID}
client-fingerprint: chrome
${proxyBlocks}
# 代理组
proxy-groups:
- name: "🌐 铸渊专线"
type: select
proxies:
- "🏛️ 铸渊专线V2-${user.label}"
- DIRECT
${mainProxies}
${autoSelectBlock}
- name: "🤖 AI服务"
type: select
proxies:
- "🏛️ 铸渊专线V2-${user.label}"
${toolProxies}
- name: "💻 开发工具"
type: select
proxies:
- "🏛️ 铸渊专线V2-${user.label}"
${toolProxies}
# 路由规则
rules:
@ -328,12 +417,15 @@ const server = http.createServer((req, res) => {
// 健康检查
if (pathname === '/health') {
const users = userManager.getEnabledUsers();
const nodes = buildVpnNodes();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
service: 'zy-proxy-v2-subscription',
version: '2.0.0',
users_count: users.length,
nodes_count: nodes.length,
smart_routing: nodes.length > 1 ? 'url-test' : 'single',
server: 'ZY-SVR-005 · Brain'
}));
return;
@ -352,13 +444,18 @@ const server = http.createServer((req, res) => {
return;
}
const keys = loadKeys();
const serverHost = getServerHost();
const nodes = buildVpnNodes();
if (nodes.length === 0) {
res.writeHead(503, { 'Content-Type': 'text/plain' });
res.end('No VPN nodes configured');
return;
}
const clientType = detectClientType(req.headers['user-agent']);
const userInfoHeader = generateUserInfoHeader(user);
if (clientType === 'clash') {
const yaml = generateClashYaml(user, keys, serverHost);
const yaml = generateClashYaml(user, nodes);
res.writeHead(200, {
'Content-Type': 'text/yaml; charset=utf-8',
'Content-Disposition': `attachment; filename="zy-proxy-v2-${user.label}.yaml"`,
@ -368,8 +465,8 @@ const server = http.createServer((req, res) => {
});
res.end(yaml);
} else {
const vlessUri = generateVlessUri(user, keys, serverHost);
const encoded = Buffer.from(vlessUri).toString('base64');
const vlessUris = generateVlessUris(user, nodes);
const encoded = Buffer.from(vlessUris).toString('base64');
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'subscription-userinfo': userInfoHeader,
@ -423,7 +520,7 @@ const server = http.createServer((req, res) => {
const totalGB = user.quota_bytes / (1024 ** 3);
const usedGB = (user.traffic.upload_bytes + user.traffic.download_bytes) / (1024 ** 3);
const serverHost = getServerHost();
const nodes = buildVpnNodes();
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
@ -437,17 +534,16 @@ const server = http.createServer((req, res) => {
version: '2.0.0',
uptime_seconds: Math.floor(process.uptime()),
region: 'sg',
region_name: 'Singapore Zone 1',
region_name: 'Singapore',
server_code: 'ZY-SVR-005'
},
node: {
id: 'zy-brain-direct',
name: `🏛️ 铸渊专线V2-${user.label}`,
server: serverHost,
port: 443,
protocol: 'vless',
security: 'reality'
},
nodes: nodes.map(n => ({
id: n.id,
name: n.name,
region: n.region,
port: n.port
})),
smart_routing: nodes.length > 1 ? 'url-test (自动选最快)' : 'single-node',
quota: {
total_gb: parseFloat(totalGB.toFixed(1)),
used_gb: parseFloat(usedGB.toFixed(2)),