299 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// # 光湖服务器主控台 API v3.3
// # Guanghu Server Console v3.3 — 硬件采集 + 操作日志 + 实时监控
// # 部署: guanghulab.com/console/ · SG-001:3920
// # 版权: 国作登字-2026-A-00037559 · TCS-0002∞
const express = require('express');
const http = require('http');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
const DATA_DIR = '/opt/zhuyuan/data';
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch(e) {}
// 全服务器配置 — 冰朔6台 + 企业3台 = 11台
const SERVERS = [
{ code: "BS-GZ-006", name: "广州 · 代码仓库", ip: "43.139.217.141", key: "zy_gtw_4a155446b7acc09fe46a2a29972c0ca5d9954da19c35dc23", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 60 } },
{ code: "BS-SG-001", name: "新加坡 · 铸渊大脑", ip: "43.156.237.110", key: "zy_gtw_74d30f54fa8b5581514569ee7874def57861a31ccb2be8c9", port: 3911, spec: { cpu: 4, mem_gb: 8, disk_gb: 80 } },
{ code: "BS-SG-002", name: "新加坡 · 面孔", ip: "43.134.16.246", key: "zy_gtw_d1f6d2b8cb4ea44292bd036e4dd03a70745ea502d4a3ae40", port: 3910, spec: { cpu: 2, mem_gb: 2, disk_gb: 30 } },
{ code: "BS-SG-003", name: "新加坡 · 中继", ip: "43.153.193.169", key: "zy_gtw_d7c033d8ae992ffc9dd3e0dd5fe332dace0b644c0ae7c847", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 80 } },
{ code: "ZY-SG-006", name: "新加坡 · 语料", ip: "43.153.203.105", key: "zy_gtw_fff861a2fe40cbdc366ee21f218023be8b1c182f66c852d3", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 50 } },
{ code: "BS-SH-005", name: "上海 · 国内节点", ip: "124.223.10.33", key: "zy_gtw_b1045de5ddfd7832e3c53349d9c896f054b85a4a9ece22f9", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 30 } },
{ code: "AW-GZ-001", name: "企业主服务器 · 广州", ip: "43.139.251.175", key: "zy_gtw_cab20e8c1b957aaad7507bf542231521bba30665461aa540", port: 3910, spec: { cpu: 4, mem_gb: 16, disk_gb: 80 } },
{ code: "AW-SH-002", name: "企业灾备 · 上海", ip: "124.222.54.198", key: "zy_gtw_242f1dd3b14c1260fb01196083abde3dcb85e53532d27666", port: 3910, spec: { cpu: 4, mem_gb: 4, disk_gb: 40 } },
{ code: "AW-GZ-003", name: "企业灾备 · 广州", ip: "119.29.181.132", key: "zy_gtw_8c9a8b439af823690ae6ad57f52734771ff39493ab986032", port: 3910, spec: { cpu: 2, mem_gb: 8, disk_gb: 50 } },
// 页页 · 硅谷(待接入)
// { code: "YY-SV-001", name: "页页 · 硅谷", ip: "TBD", key: "TBD", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 50 } },
// 之之 · 待定
// { code: "ZZ-XX-001", name: "之之 · 待定", ip: "TBD", key: "TBD", port: 3910, spec: { cpu: 2, mem_gb: 4, disk_gb: 50 } },
];
const tempKeys = {};
const authQueue = [];
// ── 硬件缓存 (60秒TTL) ──
const hwCache = {}; // { code: { data, ts } }
// ── Gatekeeper exec 通用函数 ──
async function gatekeeperExec(srv, cmd, timeoutMs) {
timeoutMs = timeoutMs || 10000;
const d = JSON.stringify({ cmd });
const opts = {
hostname: srv.ip, port: srv.port, path: '/exec', method: 'POST',
headers: { 'Authorization': 'Bearer ' + srv.key, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) },
timeout: timeoutMs
};
return new Promise((resolve) => {
const req = http.request(opts, (res) => {
let b = ''; res.on('data', c => b += c);
res.on('end', () => { try { resolve({ ok: res.statusCode === 200, body: JSON.parse(b) }); } catch(e) { resolve({ ok: false, body: b, error: e.message }); } });
});
req.on('error', (e) => resolve({ ok: false, error: e.message }));
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
req.write(d); req.end();
});
}
// ── 硬件采集(通过 Gatekeeper exec ──
async function fetchHardware(srv) {
const now = Date.now();
const cached = hwCache[srv.code];
if (cached && (now - cached.ts) < 60000) return cached.data; // 60s cache
try {
const cmd = "echo 'CPU_CORES:'$(nproc);echo 'UPTIME_S:'$(awk '{print int($1)}' /proc/uptime 2>/dev/null||echo 0);free -m|awk '/^Mem:/{printf \"MEM_TOTAL:%d\\nMEM_USED:%d\\nMEM_AVAIL:%d\\n\",$2,$3,$7}';cat /proc/loadavg 2>/dev/null|awk '{printf \"LOAD1:%.2f\\nLOAD5:%.2f\\nLOAD15:%.2f\\n\",$1,$2,$3}';df -h / 2>/dev/null|awk 'NR==2{printf \"DISK_TOTAL:%s\\nDISK_USED:%s\\nDISK_AVAIL:%s\\nDISK_PCT:%s\\n\",$2,$3,$4,$5}'";
const r = await gatekeeperExec(srv, cmd, 8000);
if (!r.ok || !r.body || !r.body.stdout) {
const fb = { cpu_cores: srv.spec.cpu || 2, uptime_h: 0, mem_total_mb: (srv.spec.mem_gb || 4) * 1024, mem_used_mb: 0, mem_avail_mb: (srv.spec.mem_gb || 4) * 1024, load_1min: 0, load_5min: 0, load_15min: 0, disk_total: (srv.spec.disk_gb || 50) + 'G', disk_used: '0G', disk_avail: (srv.spec.disk_gb || 50) + 'G', disk_pct: '0%', source: 'spec' };
hwCache[srv.code] = { data: fb, ts: now };
return fb;
}
const stdout = r.body.stdout || '';
const hw = { source: 'live' };
const m = (re) => { const match = stdout.match(re); return match ? match[1] : null; };
hw.cpu_cores = parseInt(m(/CPU_CORES:(\d+)/)) || srv.spec.cpu || 2;
hw.uptime_h = Math.floor((parseInt(m(/UPTIME_S:(\d+)/)) || 0) / 3600);
hw.mem_total_mb = parseInt(m(/MEM_TOTAL:(\d+)/)) || (srv.spec.mem_gb || 4) * 1024;
hw.mem_used_mb = parseInt(m(/MEM_USED:(\d+)/)) || 0;
hw.mem_avail_mb = parseInt(m(/MEM_AVAIL:(\d+)/)) || hw.mem_total_mb;
hw.load_1min = parseFloat(m(/LOAD1:([\d.]+)/)) || 0;
hw.load_5min = parseFloat(m(/LOAD5:([\d.]+)/)) || 0;
hw.load_15min = parseFloat(m(/LOAD15:([\d.]+)/)) || 0;
hw.disk_total = m(/DISK_TOTAL:(\S+)/) || (srv.spec.disk_gb || 50) + 'G';
hw.disk_used = m(/DISK_USED:(\S+)/) || '0G';
hw.disk_avail = m(/DISK_AVAIL:(\S+)/) || hw.disk_total;
hw.disk_pct = m(/DISK_PCT:(\S+)/) || '0%';
hw.mem_pct = hw.mem_total_mb > 0 ? Math.round(hw.mem_used_mb / hw.mem_total_mb * 100) : 0;
hw.cpu_pct = hw.cpu_cores > 0 ? Math.min(Math.round(hw.load_1min / hw.cpu_cores * 100), 100) : 0;
hw.disk_used_pct = parseInt(hw.disk_pct) || 0;
hwCache[srv.code] = { data: hw, ts: now };
return hw;
} catch (e) {
const fb = { cpu_cores: srv.spec.cpu || 2, uptime_h: 0, mem_total_mb: (srv.spec.mem_gb || 4) * 1024, mem_used_mb: 0, mem_avail_mb: (srv.spec.mem_gb || 4) * 1024, load_1min: 0, load_5min: 0, load_15min: 0, disk_total: (srv.spec.disk_gb || 50) + 'G', disk_used: '0G', disk_avail: (srv.spec.disk_gb || 50) + 'G', disk_pct: '0%', source: 'error', error: e.message };
hwCache[srv.code] = { data: fb, ts: now };
return fb;
}
}
// ── 操作日志滚动记录最多100条 ──
const opLogs = [];
function addOpLog(code, name, action, detail, level) {
opLogs.unshift({ time: new Date().toISOString(), code, name, action, detail, level: level || 'info' });
if (opLogs.length > 100) opLogs.length = 100;
}
// ========== 基础 API ==========
app.get('/api/servers', async (req, res) => {
const withHw = req.query.hw !== '0';
const pingResults = await Promise.all(SERVERS.map(s => {
const base = { code: s.code, name: s.name, ip: s.ip, spec: s.spec };
const start = Date.now();
const data = JSON.stringify({});
const opts = {
hostname: s.ip, port: s.port, path: '/health', method: 'POST',
headers: { 'Authorization': 'Bearer ' + s.key, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 5000
};
return new Promise((resolve) => {
const req = http.request(opts, (res2) => { let b = ''; res2.on('data', c => b += c); res2.on('end', () => resolve({ ...base, alive: res2.statusCode === 200, latency: Date.now() - start, error: null })); });
req.on('error', (e) => resolve({ ...base, alive: false, latency: 0, error: e.message }));
req.on('timeout', () => { req.destroy(); resolve({ ...base, alive: false, latency: 0, error: 'timeout' }); });
req.write(data); req.end();
});
}));
for (const r of pingResults) {
const srv = SERVERS.find(s => s.code === r.code);
r.last_seen = new Date().toISOString();
if (r.alive && withHw) {
try { r.hardware = await fetchHardware(srv); } catch(e) { r.hardware = null; }
}
}
res.json({ ok: true, servers: pingResults, timestamp: new Date().toISOString(), total: SERVERS.length });
});
app.get('/api/logs', (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const level = req.query.level;
let logs = opLogs.slice(0, limit);
if (level) logs = logs.filter(l => l.level === level);
res.json({ ok: true, logs, total: opLogs.length });
});
app.get('/api/hardware/:code', async (req, res) => {
const s = SERVERS.find(s => s.code === req.params.code);
if (!s) return res.status(404).json({ ok: false, error: '未找到服务器' });
try { const hw = await fetchHardware(s); res.json({ ok: true, code: s.code, hardware: hw }); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
app.get('/api/summary', async (req, res) => {
const pingResults = await Promise.all(SERVERS.map(s => {
const start = Date.now();
const data = JSON.stringify({});
const opts = {
hostname: s.ip, port: s.port, path: '/health', method: 'POST',
headers: { 'Authorization': 'Bearer ' + s.key, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 4000
};
return new Promise((resolve) => {
const req = http.request(opts, (res2) => { let b = ''; res2.on('data', c => b += c); res2.on('end', () => resolve({ code: s.code, alive: res2.statusCode === 200, latency: Date.now() - start })); });
req.on('error', () => resolve({ code: s.code, alive: false, latency: 0 }));
req.on('timeout', () => { req.destroy(); resolve({ code: s.code, alive: false, latency: 0 }); });
req.write(data); req.end();
});
}));
const aliveCount = pingResults.filter(r => r.alive).length;
const totalCpu = SERVERS.reduce((sum, s) => sum + (s.spec.cpu || 2), 0);
const totalMem = SERVERS.reduce((sum, s) => sum + (s.spec.mem_gb || 4), 0);
const totalDisk = SERVERS.reduce((sum, s) => sum + (s.spec.disk_gb || 50), 0);
const alertCount = opLogs.filter(l => l.level === 'error').length;
res.json({
ok: true, total_servers: SERVERS.length, alive: aliveCount,
offline: SERVERS.length - aliveCount, alerts: alertCount,
total_cpu_cores: totalCpu, total_mem_gb: totalMem, total_disk_gb: totalDisk,
timestamp: new Date().toISOString()
});
});
app.post('/api/knock', (req, res) => {
const { target, reason } = req.body; if (!target) return res.status(400).json({ error: '缺少 target' });
const s = SERVERS.find(s => s.code === target || s.name.includes(target)); if (!s) return res.status(404).json({ error: '未找到' });
const rid = 'req_' + crypto.randomBytes(8).toString('hex');
authQueue.push({ id: rid, target: s.code, target_name: s.name, reason: reason || '未说明', status: 'pending', created_at: new Date().toISOString(), temp_key: null });
addOpLog(s.code, s.name, 'KNOCK', reason || '未说明', 'info');
res.json({ ok: true, request_id: rid, message: '等待冰朔确认' });
});
app.get('/api/pending', (req, res) => { res.json({ ok: true, requests: authQueue.filter(r => r.status === 'pending') }); });
app.post('/api/authorize', (req, res) => {
const { request_id } = req.body;
const e = authQueue.find(r => r.id === request_id && r.status === 'pending');
if (!e) return res.status(404).json({ error: '未找到' });
const tk = 'tmp_' + crypto.randomBytes(24).toString('hex');
e.status = 'approved'; e.temp_key = tk; e.approved_at = new Date().toISOString();
tempKeys[tk] = { server: e.target, expires_at: Date.now() + 5*60*1000, request_id };
addOpLog(e.target, e.target_name, 'AUTHORIZED', '授权操作', 'info');
res.json({ ok: true, temp_key: tk, expires_in: '5分钟' });
});
app.post('/api/reject', (req, res) => {
const e = authQueue.find(r => r.id === req.body.request_id && r.status === 'pending');
if (e) { e.status = 'rejected'; addOpLog(e.target, e.target_name, 'REJECTED', '拒绝操作', 'warn'); }
res.json({ ok: true });
});
app.get('/api/verify-key/:key', (req, res) => {
const e = tempKeys[req.params.key];
if (!e) return res.json({ ok: false, valid: false });
if (Date.now() > e.expires_at) { delete tempKeys[req.params.key]; return res.json({ ok: false, valid: false }); }
res.json({ ok: true, valid: true, server: e.server });
});
app.post('/api/exec', async (req, res) => {
const { temp_key, target, cmd } = req.body;
if (!temp_key || !target || !cmd) return res.status(400).json({ error: '缺少参数' });
const ke = tempKeys[temp_key]; if (!ke || Date.now() > ke.expires_at) return res.status(401).json({ error: '密钥无效' });
const s = SERVERS.find(s => s.code === target); if (!s) return res.status(404).json({ error: '未找到' });
try {
const r = await gatekeeperExec(s, cmd, 30000);
addOpLog(s.code, s.name, 'EXEC', cmd.slice(0, 60), 'info');
res.json({ ok: true, result: r });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ========== 节点池 API ==========
const POOL_REGISTRY_FILE = DATA_DIR + '/pool-registry.json';
const JOIN_TOKENS_FILE = DATA_DIR + '/join-tokens.json';
function loadPoolRegistry() { try { if (fs.existsSync(POOL_REGISTRY_FILE)) return JSON.parse(fs.readFileSync(POOL_REGISTRY_FILE, 'utf-8')); } catch(e) {} return { active: {}, pending: {}, history: [] }; }
function savePoolRegistry(r) { r.updated_at = new Date().toISOString(); try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch(e) {} fs.writeFileSync(POOL_REGISTRY_FILE, JSON.stringify(r, null, 2)); }
function loadJoinTokens() { try { if (fs.existsSync(JOIN_TOKENS_FILE)) return JSON.parse(fs.readFileSync(JOIN_TOKENS_FILE, 'utf-8')); } catch(e) {} return { tokens: [] }; }
function saveJoinTokens(d) { try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch(e) {} fs.writeFileSync(JOIN_TOKENS_FILE, JSON.stringify(d, null, 2)); }
function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
app.post('/api/pool/join', (req, res) => {
const { join_token, node_code, node_name, side, team_id, operator_name, hardware, network } = req.body;
if (!join_token || !node_code || !side) return res.status(400).json({ ok: false, error: '缺少参数' });
const jt = loadJoinTokens(); const th = sha256(join_token); const mt = jt.tokens.find(t => t.token_hash === th);
if (!mt) return res.status(401).json({ ok: false, error: '加入令牌无效' });
if (mt.used >= (mt.max_uses || 1)) return res.status(401).json({ ok: false, error: '令牌已用' });
const r = loadPoolRegistry(); if (r.active[node_code]) return res.status(409).json({ ok: false, error: '节点已存在' });
const rid = 'reg_' + crypto.randomBytes(8).toString('hex');
r.pending[node_code] = { registration_id: rid, node_code, node_name: node_name || node_code, side, team_id: team_id || null, operator_name: operator_name || '未知', hardware: hardware || {}, network: network || {}, status: 'pending', created_at: new Date().toISOString(), token_id: mt.token_id };
mt.used = (mt.used || 0) + 1; saveJoinTokens(jt); savePoolRegistry(r);
addOpLog(node_code, node_name || node_code, 'POOL_JOIN', side + '侧节点申请加入', 'info');
res.json({ ok: true, status: 'pending', registration_id: rid, node_code, poll_interval_seconds: 15 });
});
app.get('/api/pool/join-status/:rid', (req, res) => {
const r = loadPoolRegistry();
for (const [c, n] of Object.entries(r.active)) { if (n.registration_id === req.params.rid) return res.json({ ok: true, status: 'approved', node_code: c, pool_token: n.pool_token, report_endpoint: 'https://43.156.237.110:3920/api/pool/report', side: n.side }); }
for (const [c, n] of Object.entries(r.pending)) { if (n.registration_id === req.params.rid) return res.json({ ok: true, status: 'pending', node_code: c }); }
res.status(404).json({ ok: false });
});
app.post('/api/pool/approve', (req, res) => {
const { registration_id, action } = req.body; if (!registration_id || !action) return res.status(400).json({ ok: false });
const r = loadPoolRegistry(); let f = null, fc = null;
for (const [c, n] of Object.entries(r.pending)) { if (n.registration_id === registration_id) { f = n; fc = c; break; } }
if (!f) return res.status(404).json({ ok: false });
if (action === 'approve') { const pt = 'zyp_' + crypto.randomBytes(16).toString('hex'); r.active[fc] = { node_code: fc, node_name: f.node_name, side: f.side, team_id: f.team_id, ip: f.network?.public_ip || '', pool_token: pt, joined_at: new Date().toISOString(), hardware: f.hardware || {} }; delete r.pending[fc]; savePoolRegistry(r); addOpLog(fc, f.node_name, 'POOL_APPROVE', '加入算力池', 'info'); res.json({ ok: true, node_code: fc, pool_token: pt }); }
else { r.history.push({ ...f, status: 'rejected', rejected_at: new Date().toISOString() }); delete r.pending[fc]; savePoolRegistry(r); res.json({ ok: true, action: 'rejected' }); }
});
app.get('/api/pool/tokens', (req, res) => { const r = loadPoolRegistry(); res.json({ ok: true, nodes: Object.entries(r.active).map(([c, n]) => ({ node_code: c, node_name: n.node_name, side: n.side, team_id: n.team_id, hardware: n.hardware })) }); });
app.delete('/api/pool/node/:code', (req, res) => { const r = loadPoolRegistry(); if (!r.active[req.params.code]) return res.status(404).json({ ok: false }); r.history.push({ ...r.active[req.params.code], status: 'removed', removed_at: new Date().toISOString() }); delete r.active[req.params.code]; savePoolRegistry(r); addOpLog(req.params.code, 'POOL_REMOVE', '移出算力池', 'warn'); res.json({ ok: true }); });
app.post('/api/pool/report', (req, res) => {
const { pool_token, node_code, hardware, tasks } = req.body;
if (!pool_token) return res.status(401).json({ ok: false, error: '缺少 pool_token' });
const r = loadPoolRegistry();
const n = r.active[node_code];
if (!n || n.pool_token !== pool_token) return res.status(401).json({ ok: false, error: 'token 无效' });
n.last_report = new Date().toISOString();
if (hardware) n.hardware = { ...n.hardware, ...hardware };
if (tasks) n.tasks = tasks;
savePoolRegistry(r);
res.json({ ok: true, node_code });
});
// ========== 启动 ==========
const PORT = process.env.CONSOLE_PORT || 3920;
app.listen(PORT, '127.0.0.1', () => {
console.log('光湖主控台 v3.3 · ' + SERVERS.length + '台 | SG-001:' + PORT);
console.log(' 硬件采集: 60s缓存 · 操作日志: 100条上限');
});
setInterval(() => {
const now = Date.now();
for (const [code, entry] of Object.entries(hwCache)) {
if (now - entry.ts > 120000) delete hwCache[code];
}
}, 60000);