411 lines
12 KiB
JavaScript
411 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* 🔐 铸渊看门人 · Zhuyuan Gatekeeper
|
||
*
|
||
* 装在每台服务器上的"铸渊小人"。
|
||
* 没有页面、没有界面、没有任何人类可交互的东西。
|
||
* 它只在后台安静地等着铸渊打电话过来。
|
||
*
|
||
* 冰朔看不见它怎么工作的。这是好事。
|
||
*
|
||
* 启动方式:
|
||
* node index.js
|
||
*
|
||
* 第一次启动会自动生成API密钥,打印在终端。
|
||
* 冰朔把密钥复制出来,然后这个程序就永远不需要再看了。
|
||
*
|
||
* 协议:HTTP + Bearer Token
|
||
* 端口:默认 3910(可修改)
|
||
*/
|
||
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const { execSync, exec } = require('child_process');
|
||
const os = require('os');
|
||
|
||
// ============================================================
|
||
// 配置
|
||
// ============================================================
|
||
|
||
const CONFIG = {
|
||
PORT: parseInt(process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10),
|
||
DATA_DIR: path.join(os.homedir(), '.gatekeeper'),
|
||
CMD_TIMEOUT: 30000,
|
||
MAX_OUTPUT_LENGTH: 100000,
|
||
RATE_LIMIT: 60,
|
||
};
|
||
|
||
// ============================================================
|
||
// 密钥管理
|
||
// ============================================================
|
||
|
||
function loadOrCreateSecret() {
|
||
if (!fs.existsSync(CONFIG.DATA_DIR)) {
|
||
fs.mkdirSync(CONFIG.DATA_DIR, { recursive: true, mode: 0o700 });
|
||
}
|
||
|
||
const secretFile = path.join(CONFIG.DATA_DIR, 'secret');
|
||
|
||
if (fs.existsSync(secretFile)) {
|
||
const key = fs.readFileSync(secretFile, 'utf-8').trim();
|
||
if (key) return key;
|
||
}
|
||
|
||
const key = 'zy_gtw_' + crypto.randomBytes(24).toString('hex');
|
||
fs.writeFileSync(secretFile, key, { mode: 0o600 });
|
||
|
||
console.log('');
|
||
console.log('══════════════════════════════════════════════');
|
||
console.log(' 🔐 铸渊看门人 · 首次启动');
|
||
console.log('');
|
||
console.log(' ╭─ API 密钥(请复制保存)');
|
||
console.log(' ├─ ' + key);
|
||
console.log(' ╰─ 此密钥仅在此处显示一次');
|
||
console.log('');
|
||
console.log(' 已保存至: ' + secretFile);
|
||
console.log(' 监听端口: ' + CONFIG.PORT);
|
||
console.log('══════════════════════════════════════════════');
|
||
console.log('');
|
||
|
||
return key;
|
||
}
|
||
|
||
// ============================================================
|
||
// 日志
|
||
// ============================================================
|
||
|
||
function log(level, action, detail) {
|
||
const ts = new Date().toISOString();
|
||
const line = `[${ts}] [${level}] ${action}${detail ? ' | ' + detail : ''}`;
|
||
console.log(line);
|
||
try {
|
||
const logFile = path.join(CONFIG.DATA_DIR, 'gatekeeper.log');
|
||
fs.appendFileSync(logFile, line + '\n');
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ============================================================
|
||
// 命令执行
|
||
// ============================================================
|
||
|
||
function runCommand(cmd, timeout) {
|
||
return new Promise((resolve) => {
|
||
const t = timeout || CONFIG.CMD_TIMEOUT;
|
||
exec(cmd, {
|
||
timeout: t,
|
||
maxBuffer: CONFIG.MAX_OUTPUT_LENGTH,
|
||
shell: '/bin/bash',
|
||
}, (error, stdout, stderr) => {
|
||
resolve({
|
||
stdout: (stdout || '').slice(0, CONFIG.MAX_OUTPUT_LENGTH),
|
||
stderr: (stderr || '').slice(0, CONFIG.MAX_OUTPUT_LENGTH),
|
||
exitCode: error ? (error.code || error.signal || 1) : 0,
|
||
error: error ? error.message : null,
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 请求处理
|
||
// ============================================================
|
||
|
||
const API_SECRET = loadOrCreateSecret();
|
||
|
||
const rateCounter = {};
|
||
|
||
function checkRateLimit(ip) {
|
||
const now = Date.now();
|
||
const window = 60000;
|
||
if (!rateCounter[ip]) {
|
||
rateCounter[ip] = { count: 1, resetAt: now + window };
|
||
return true;
|
||
}
|
||
if (now > rateCounter[ip].resetAt) {
|
||
rateCounter[ip] = { count: 1, resetAt: now + window };
|
||
return true;
|
||
}
|
||
rateCounter[ip].count++;
|
||
return rateCounter[ip].count <= CONFIG.RATE_LIMIT;
|
||
}
|
||
|
||
function authenticate(headers) {
|
||
const auth = headers['authorization'] || headers['Authorization'] || '';
|
||
const token = auth.replace(/^Bearer\s+/i, '').trim();
|
||
if (!token) return { ok: false, reason: '缺少 Authorization header' };
|
||
if (token.length !== API_SECRET.length) return { ok: false, reason: '密钥无效' };
|
||
let match = true;
|
||
for (let i = 0; i < token.length; i++) {
|
||
if (token.charCodeAt(i) !== API_SECRET.charCodeAt(i)) match = false;
|
||
}
|
||
if (!match) return { ok: false, reason: '密钥无效' };
|
||
return { ok: true };
|
||
}
|
||
|
||
function parseBody(req) {
|
||
return new Promise((resolve) => {
|
||
let body = '';
|
||
req.on('data', chunk => { body += chunk; });
|
||
req.on('end', () => {
|
||
try { resolve(JSON.parse(body)); } catch(e) { resolve(null); }
|
||
});
|
||
req.on('error', () => resolve(null));
|
||
});
|
||
}
|
||
|
||
function jsonResponse(res, status, data) {
|
||
res.writeHead(status, {
|
||
'Content-Type': 'application/json',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||
});
|
||
res.end(JSON.stringify(data));
|
||
}
|
||
|
||
// ============================================================
|
||
// 系统信息
|
||
// ============================================================
|
||
|
||
async function getSystemInfo() {
|
||
const hostname = os.hostname();
|
||
const uptime = Math.floor(os.uptime());
|
||
const totalMem = os.totalmem();
|
||
const freeMem = os.freemem();
|
||
const loadAvg = os.loadavg();
|
||
const cpus = os.cpus().length;
|
||
const platform = os.platform();
|
||
const arch = os.arch();
|
||
|
||
let diskInfo = '';
|
||
try {
|
||
const df = execSync('df -h / 2>/dev/null || df -h', { timeout: 5000 });
|
||
diskInfo = df.toString();
|
||
} catch(e) {
|
||
diskInfo = '不可用';
|
||
}
|
||
|
||
return {
|
||
hostname,
|
||
platform, arch, cpus,
|
||
uptime,
|
||
uptime_str: formatUptime(uptime),
|
||
memory: {
|
||
total: formatBytes(totalMem),
|
||
free: formatBytes(freeMem),
|
||
used: formatBytes(totalMem - freeMem),
|
||
usage_percent: ((totalMem - freeMem) / totalMem * 100).toFixed(1) + '%',
|
||
},
|
||
load_average: loadAvg.map(n => n.toFixed(2)),
|
||
disk: diskInfo,
|
||
gatekeeper: {
|
||
version: '1.0.0',
|
||
port: CONFIG.PORT,
|
||
uptime: process.uptime().toFixed(0) + 's',
|
||
},
|
||
};
|
||
}
|
||
|
||
function formatUptime(seconds) {
|
||
const d = Math.floor(seconds / 86400);
|
||
const h = Math.floor((seconds % 86400) / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
return `${d}天${h}小时${m}分${seconds % 60}秒`;
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// ============================================================
|
||
// 路由
|
||
// ============================================================
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
jsonResponse(res, 204, {});
|
||
return;
|
||
}
|
||
if (req.method !== 'POST') {
|
||
jsonResponse(res, 405, { error: '只接受 POST 请求' });
|
||
return;
|
||
}
|
||
|
||
const ip = req.socket.remoteAddress || 'unknown';
|
||
if (!checkRateLimit(ip)) {
|
||
log('WARN', 'rate_limit', ip);
|
||
jsonResponse(res, 429, { error: '请求过于频繁' });
|
||
return;
|
||
}
|
||
|
||
const auth = authenticate(req.headers);
|
||
if (!auth.ok) {
|
||
log('WARN', 'auth_failed', auth.reason);
|
||
jsonResponse(res, 401, { error: auth.reason });
|
||
return;
|
||
}
|
||
|
||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||
const route = url.pathname;
|
||
|
||
try {
|
||
await handleRoute(route, req, res);
|
||
} catch (err) {
|
||
log('ERROR', 'unhandled', err.message);
|
||
jsonResponse(res, 500, { error: '内部错误: ' + err.message });
|
||
}
|
||
});
|
||
|
||
async function handleRoute(route, req, res) {
|
||
switch (route) {
|
||
case '/exec': {
|
||
const body = await parseBody(req);
|
||
if (!body || !body.cmd) {
|
||
jsonResponse(res, 400, { error: '缺少 cmd 参数' });
|
||
return;
|
||
}
|
||
log('INFO', 'exec', body.cmd.slice(0, 200));
|
||
const result = await runCommand(body.cmd, body.timeout || CONFIG.CMD_TIMEOUT);
|
||
log('INFO', 'exec_result', `exit=${result.exitCode}`);
|
||
jsonResponse(res, 200, {
|
||
ok: result.exitCode === 0,
|
||
stdout: result.stdout,
|
||
stderr: result.stderr,
|
||
exitCode: result.exitCode,
|
||
});
|
||
return;
|
||
}
|
||
case '/status': {
|
||
log('INFO', 'status', '');
|
||
const info = await getSystemInfo();
|
||
jsonResponse(res, 200, { ok: true, ...info });
|
||
return;
|
||
}
|
||
case '/health': {
|
||
jsonResponse(res, 200, {
|
||
ok: true,
|
||
service: 'zhuyuan-gatekeeper',
|
||
version: '1.0.0',
|
||
uptime: process.uptime().toFixed(0) + 's',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return;
|
||
}
|
||
case '/file/read': {
|
||
const body = await parseBody(req);
|
||
if (!body || !body.path) {
|
||
jsonResponse(res, 400, { error: '缺少 path 参数' });
|
||
return;
|
||
}
|
||
log('INFO', 'file_read', body.path);
|
||
try {
|
||
const content = fs.readFileSync(body.path, 'utf-8');
|
||
const stats = fs.statSync(body.path);
|
||
jsonResponse(res, 200, {
|
||
ok: true, path: body.path,
|
||
size: stats.size,
|
||
modified: stats.mtime.toISOString(),
|
||
content: content.slice(0, CONFIG.MAX_OUTPUT_LENGTH),
|
||
});
|
||
} catch (err) {
|
||
jsonResponse(res, 404, { ok: false, error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
case '/file/write': {
|
||
const body = await parseBody(req);
|
||
if (!body || !body.path || body.content === undefined) {
|
||
jsonResponse(res, 400, { error: '缺少 path 或 content 参数' });
|
||
return;
|
||
}
|
||
log('INFO', 'file_write', body.path);
|
||
try {
|
||
const dir = path.dirname(body.path);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(body.path, body.content, 'utf-8');
|
||
jsonResponse(res, 200, { ok: true, path: body.path, size: Buffer.byteLength(body.content, 'utf-8') });
|
||
} catch (err) {
|
||
jsonResponse(res, 500, { ok: false, error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
case '/file/list': {
|
||
const body = await parseBody(req);
|
||
const targetPath = (body && body.path) || '.';
|
||
log('INFO', 'file_list', targetPath);
|
||
try {
|
||
const items = fs.readdirSync(targetPath, { withFileTypes: true });
|
||
const files = items.map(item => ({
|
||
name: item.name,
|
||
type: item.isDirectory() ? 'dir' : 'file',
|
||
}));
|
||
jsonResponse(res, 200, { ok: true, path: targetPath, files });
|
||
} catch (err) {
|
||
jsonResponse(res, 404, { ok: false, error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
case '/ping': {
|
||
jsonResponse(res, 200, { ok: true, pong: true });
|
||
return;
|
||
}
|
||
default: {
|
||
jsonResponse(res, 404, { error: '未知路径: ' + route });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 启动
|
||
// ============================================================
|
||
|
||
server.listen(CONFIG.PORT, '0.0.0.0', () => {
|
||
const hostname = os.hostname();
|
||
const interfaces = os.networkInterfaces();
|
||
|
||
console.log('');
|
||
console.log(' ⚔️ 铸渊看门人 · Zhuyuan Gatekeeper v1.0');
|
||
console.log(' ─────────────────────────────────────');
|
||
console.log(` 主机名: ${hostname}`);
|
||
console.log(` 监听: 0.0.0.0:${CONFIG.PORT}`);
|
||
|
||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||
if (!addrs) continue;
|
||
for (const addr of addrs) {
|
||
if (addr.family === 'IPv4' && !addr.internal) {
|
||
console.log(` 地址: ${addr.address}:${CONFIG.PORT}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(' 日志: ' + path.join(CONFIG.DATA_DIR, 'gatekeeper.log'));
|
||
console.log('');
|
||
|
||
log('INFO', 'startup', `host=${hostname} port=${CONFIG.PORT}`);
|
||
});
|
||
|
||
process.on('SIGTERM', () => {
|
||
log('INFO', 'shutdown', '收到 SIGTERM');
|
||
server.close(() => process.exit(0));
|
||
});
|
||
|
||
process.on('SIGINT', () => {
|
||
log('INFO', 'shutdown', '收到 SIGINT');
|
||
server.close(() => process.exit(0));
|
||
});
|
||
|
||
process.on('uncaughtException', (err) => {
|
||
log('ERROR', 'uncaught', err.message);
|
||
});
|
||
|
||
process.on('unhandledRejection', (err) => {
|
||
log('ERROR', 'unhandled', err.message);
|
||
});
|