411 lines
12 KiB
JavaScript
Raw Normal View History

#!/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);
});