228 lines
9.5 KiB
JavaScript
228 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
||
// ============================================================================
|
||
// gatekeeper daemon —— 常驻守卫进程
|
||
//
|
||
// 收益:LLM 连接常驻(免每次冷启动的 DNS/TLS 握手)、决策缓存与任务状态驻内存、
|
||
// 配置文件热重载(每次请求比较四个配置文件的内容签名,不一致即重载)。
|
||
//
|
||
// 由 gatekeeper.mjs 客户端按需拉起(detached),无需手工运行;手工调试:
|
||
// node gatekeeper-daemon.mjs # 前台运行,日志同时打到 stdout
|
||
// node gatekeeper.mjs shutdown-daemon # 通过客户端停止
|
||
//
|
||
// 协议(127.0.0.1:daemonPort,行分隔 JSON):
|
||
// 请求/响应均使用 daemon-auth.mjs 的 nonce + timestamp + HMAC envelope;
|
||
// 业务 payload: {"op":"ping"} | {"op":"shutdown"} |
|
||
// {"op":"hook","mode":"review","layer":"hard|full","payload":{...hook输入...}}
|
||
// ============================================================================
|
||
|
||
import net from 'node:net';
|
||
import { appendFileSync, existsSync, statSync, renameSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import {
|
||
SCRIPT_DIR, STATE_DIR, loadAll, configSigs, memDeps, decide,
|
||
} from './core.mjs';
|
||
import {
|
||
daemonSecretFor, makeDaemonRequest, verifyDaemonRequest, makeDaemonResponse,
|
||
verifyDaemonResponse, daemonInstanceIdFor,
|
||
} from './daemon-auth.mjs';
|
||
|
||
const LOG = path.join(STATE_DIR, 'daemon.log');
|
||
function log(msg) {
|
||
const line = '[' + new Date().toISOString() + '] ' + msg;
|
||
try {
|
||
if (existsSync(LOG) && statSync(LOG).size > 2 * 1024 * 1024) renameSync(LOG, LOG + '.' + Date.now());
|
||
appendFileSync(LOG, line + '\n', { mode: 0o600 });
|
||
} catch { /* 忽略 */ }
|
||
if (!process.env.GATEKEEPER_DAEMON_QUIET) console.log(line);
|
||
}
|
||
|
||
let all = loadAll();
|
||
let deps = memDeps();
|
||
const PORT = all.config.daemonPort | 0 || 47811;
|
||
const SECRET = daemonSecretFor(STATE_DIR);
|
||
const DAEMON_INSTANCE_ID = daemonInstanceIdFor(SCRIPT_DIR);
|
||
let loadedSigs = configSigs();
|
||
let llmWarm = false;
|
||
const seenNonces = new Map();
|
||
let lastRejectedLog = 0;
|
||
function logRejected(msg) {
|
||
const now = Date.now();
|
||
if (now - lastRejectedLog < 5000) return;
|
||
lastRejectedLog = now;
|
||
log(msg);
|
||
}
|
||
|
||
function claimNonce(nonce) {
|
||
const now = Date.now();
|
||
for (const [n, ts] of seenNonces) if (now - ts > 60_000) seenNonces.delete(n);
|
||
if (seenNonces.has(nonce)) return false;
|
||
seenNonces.set(nonce, now);
|
||
return true;
|
||
}
|
||
|
||
function reloadIfStale() {
|
||
const cur = configSigs();
|
||
if (cur.some((v, i) => v !== loadedSigs[i])) {
|
||
all = loadAll();
|
||
loadedSigs = cur;
|
||
llmWarm = false;
|
||
log('config hot-reloaded (sigs changed)');
|
||
}
|
||
}
|
||
|
||
async function handleHook(req) {
|
||
const env = req.env || {};
|
||
const effective = env.fullAccessGuard != null
|
||
? { ...all, config: { ...all.config, fullAccessGuard: all.config.fullAccessGuard || env.fullAccessGuard === true } }
|
||
: all;
|
||
const t0 = Date.now();
|
||
// 不共享 review 决策:一次性批准必须严格只消费并放行一个请求。
|
||
const result = await decide({ mode: req.mode, payload: req.payload, layer: req.layer }, deps, effective);
|
||
log(`hook ${req.mode}/${req.payload?.tool_name || '?'} -> exit=${result.exitCode} (${Date.now() - t0}ms, llmWarm=${llmWarm})`);
|
||
return result;
|
||
}
|
||
|
||
// allowHalfOpen: true —— 客户端会半关闭(write+end),hook 判定是异步的(LLM 数秒),
|
||
// 默认模式下 Node 会在收到 FIN 时自动关闭本端,响应就写进了一条已死的连接。
|
||
// 空闲自动退出:ZCode 关闭/无调用一段时间后自动退出释放内存(~55MB),下次 hook 调用会自动重新拉起
|
||
let idleTimer = null;
|
||
function armIdle() {
|
||
const ms = all.config.daemonIdleMs | 0;
|
||
if (idleTimer) clearTimeout(idleTimer);
|
||
if (ms <= 0) return;
|
||
idleTimer = setTimeout(() => { log('idle ' + Math.round(ms / 1000) + 's — exit to free memory'); process.exit(0); }, ms);
|
||
}
|
||
|
||
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
||
let buf = '';
|
||
let bufferedBytes = 0;
|
||
socket.setEncoding('utf8');
|
||
socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时
|
||
|
||
const dispatch = async (line) => {
|
||
// 协议异常/未知 op 一律返回 {ok:false}(无 exitCode 字段)——客户端据此降级内联做完整审查,
|
||
// 绝不能返回 {exitCode:0}(会被当作"明确放行"决策,形成 fail-open)。
|
||
let res = { ok: false, error: 'unknown op' };
|
||
let nonce = '';
|
||
try {
|
||
const envelope = line.trim() ? JSON.parse(line.trim()) : null;
|
||
if (!verifyDaemonRequest(SECRET, envelope)) {
|
||
logRejected('rejected unauthenticated local request');
|
||
socket.destroy();
|
||
return;
|
||
}
|
||
if (!claimNonce(envelope.nonce)) {
|
||
logRejected('rejected replayed local request');
|
||
socket.destroy();
|
||
return;
|
||
}
|
||
nonce = envelope.nonce;
|
||
const req = envelope.payload;
|
||
if (req.instanceId !== DAEMON_INSTANCE_ID) throw new Error('daemon instance mismatch');
|
||
reloadIfStale();
|
||
armIdle(); // 只有通过认证的请求(含 ping)才重置空闲计时
|
||
if (!req || req.op === 'ping') {
|
||
res = { ok: true, llmWarm, version: 1 };
|
||
} else if (req.op === 'shutdown') {
|
||
socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, { ok: true, bye: true })) + '\n', () => {
|
||
log('shutdown by authenticated request'); process.exit(0);
|
||
});
|
||
return;
|
||
} else if (req.op === 'hook') {
|
||
res = await handleHook(req);
|
||
}
|
||
} catch (e) {
|
||
res = { ok: false, error: 'request error: ' + (e?.message || e) };
|
||
log('request error: ' + (e?.message || e));
|
||
}
|
||
if (!nonce) { socket.destroy(); return; }
|
||
socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, res)) + '\n', () => { try { socket.end(); } catch { /* 忽略 */ } });
|
||
};
|
||
|
||
socket.on('data', (chunk) => { // 行分帧:收到完整一行立即处理
|
||
buf += chunk;
|
||
bufferedBytes += Buffer.byteLength(chunk, 'utf8');
|
||
if (bufferedBytes > 2 * 1024 * 1024) {
|
||
logRejected('rejected oversized local request');
|
||
socket.destroy();
|
||
return;
|
||
}
|
||
let idx;
|
||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||
const line = buf.slice(0, idx);
|
||
buf = buf.slice(idx + 1);
|
||
bufferedBytes = Buffer.byteLength(buf, 'utf8');
|
||
if (line.trim()) dispatch(line.trim());
|
||
}
|
||
});
|
||
socket.on('end', () => { if (buf.trim()) { const rest = buf; buf = ''; dispatch(rest.trim()); } });
|
||
socket.on('error', () => { /* 客户端断开等,忽略 */ });
|
||
});
|
||
|
||
server.on('error', async (e) => {
|
||
if (e.code === 'EADDRINUSE') {
|
||
const alive = await ping(PORT, 400).catch(() => null);
|
||
if (alive?.ok) { log('daemon already running on port ' + PORT + ' — exit quietly'); process.exit(0); }
|
||
log('port ' + PORT + ' occupied but no daemon responding — exit 1');
|
||
process.exit(1);
|
||
}
|
||
log('server error: ' + (e?.message || e));
|
||
process.exit(1);
|
||
});
|
||
|
||
server.listen(PORT, '127.0.0.1', () => {
|
||
log('daemon listening on 127.0.0.1:' + PORT + ' (model=' + all.config.model + ', guard=' + all.config.fullAccessGuard + ', idleExit=' + Math.round((all.config.daemonIdleMs|0)/1000) + 's)');
|
||
armIdle(); // 启动即计时:无任何调用则按时退出
|
||
// 预热:开一条到审查端点的 TLS 连接,让第一次真实审查免握手
|
||
setTimeout(async () => {
|
||
try {
|
||
if (!all.config.apiKey || all.config.apiKey === 'PENDING' ||
|
||
!/^https?:\/\//i.test(String(all.config.baseUrl || '')) || !all.config.model) {
|
||
log('llm warmup skipped (endpoint not configured)');
|
||
return;
|
||
}
|
||
const t0 = Date.now();
|
||
const res = await fetch(all.config.baseUrl.replace(/\/+$/, '') + '/chat/completions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + all.config.apiKey },
|
||
body: JSON.stringify({ model: all.config.model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }),
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
await res.json().catch(() => {});
|
||
llmWarm = true;
|
||
log('llm connection warmed in ' + (Date.now() - t0) + 'ms');
|
||
} catch (e) {
|
||
llmWarm = false;
|
||
log('llm warmup failed (will retry implicitly on first review): ' + (e?.message || e));
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
async function ping(port, timeoutMs) {
|
||
return new Promise((resolve, reject) => {
|
||
const s = net.connect(port, '127.0.0.1');
|
||
let buf = '';
|
||
s.setEncoding('utf8');
|
||
const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs);
|
||
const envelope = makeDaemonRequest(SECRET, { op: 'ping', instanceId: DAEMON_INSTANCE_ID });
|
||
s.on('connect', () => s.write(JSON.stringify(envelope) + '\n'));
|
||
s.on('data', (c) => {
|
||
buf += c;
|
||
if (buf.includes('\n')) {
|
||
clearTimeout(to);
|
||
try {
|
||
const parsed = JSON.parse(buf.slice(0, buf.indexOf('\n')));
|
||
resolve(verifyDaemonResponse(SECRET, parsed, envelope.nonce) ? parsed.result : null);
|
||
} catch { resolve(null); }
|
||
s.end();
|
||
}
|
||
});
|
||
s.on('error', (e) => { clearTimeout(to); reject(e); });
|
||
});
|
||
}
|
||
|
||
// 兜底:防止被遗留为孤儿进程后无法退出。setTimeout 上限为 2^31-1 ≈ 24.8 天(再大会溢出 32 位
|
||
// 整数被截成 1ms,导致 daemon 启动即退出),到期后自动退出,客户端会按需重新拉起。
|
||
setTimeout(() => { log('max lifetime reached, exiting'); process.exit(0); }, 2 ** 31 - 1).unref();
|