From 5abe4aa8522c4717db050d471c17f0840f40e3f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 08:00:05 +0000 Subject: [PATCH] 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> --- .github/workflows/deploy-brain-proxy.yml | 69 ++++++ server/proxy/deploy-brain-proxy.sh | 21 ++ .../proxy/service/subscription-server-v2.js | 220 +++++++++++++----- 3 files changed, 248 insertions(+), 62 deletions(-) diff --git a/.github/workflows/deploy-brain-proxy.yml b/.github/workflows/deploy-brain-proxy.yml index def11ade..219173ab 100644 --- a/.github/workflows/deploy-brain-proxy.yml +++ b/.github/workflows/deploy-brain-proxy.yml @@ -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订阅' diff --git a/server/proxy/deploy-brain-proxy.sh b/server/proxy/deploy-brain-proxy.sh index d4680eb8..7cece96f 100644 --- a/server/proxy/deploy-brain-proxy.sh +++ b/server/proxy/deploy-brain-proxy.sh @@ -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 "" diff --git a/server/proxy/service/subscription-server-v2.js b/server/proxy/service/subscription-server-v2.js index 0faffe3d..658ef9f3 100644 --- a/server/proxy/service/subscription-server-v2.js +++ b/server/proxy/service/subscription-server-v2.js @@ -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)),