Files
NexusGate/server.js
T

966 lines
54 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.
'use strict';
const http = require('node:http');
const path = require('node:path');
const crypto = require('node:crypto');
const net = require('node:net');
const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
const { formatSubscription } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact');
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
const APP_ROOT = __dirname;
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
const DATA_FILE = process.env.NG_DATA_FILE || path.join(APP_ROOT, 'data', 'nexusgate.json');
const HOST = process.env.NG_HOST || '127.0.0.1';
const PORT = Number(process.env.NG_PORT || 8787);
const COOKIE_SECURE = process.env.NG_COOKIE_SECURE !== 'false';
const VERSION = '0.6.8';
const store = new Store(DATA_FILE);
let sessions;
let orchestrator;
const loginAttempts = new Map();
function cleanText(value, max = 120) {
return String(value == null ? '' : value).trim().slice(0, max);
}
function requiredText(value, label, max = 120) {
const result = cleanText(value, max);
if (!result) {
const error = new Error(`${label}不能为空`);
error.statusCode = 400;
throw error;
}
return result;
}
function asIds(value) {
return Array.isArray(value) ? [...new Set(value.map(String).filter(Boolean))] : [];
}
function tokenFingerprint(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
function optionalIso(value, label = '日期') {
if (value == null || value === '') return null;
const date = new Date(value);
if (!Number.isFinite(date.getTime())) { const error = new Error(`${label}格式无效`); error.statusCode = 400; throw error; }
return date.toISOString();
}
function nonNegativeNumber(value, label, integer = false) {
const number = Number(value == null || value === '' ? 0 : value);
if (!Number.isFinite(number) || number < 0 || (integer && !Number.isInteger(number))) {
const error = new Error(`${label}必须是非负${integer ? '整数' : '数字'}`); error.statusCode = 400; throw error;
}
return number;
}
function normalizeServer(body, current = {}) {
const next = {
...current,
name: 'name' in body ? requiredText(body.name, '服务器名称') : current.name,
role: 'role' in body && ['relay', 'exit', 'hybrid'].includes(body.role) ? body.role : (current.role || 'hybrid'),
region: 'region' in body ? cleanText(body.region, 80) : (current.region || ''),
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
};
if (!Number.isInteger(next.portRangeStart) || !Number.isInteger(next.portRangeEnd) || next.portRangeStart < 1024 || next.portRangeEnd > 65535 || next.portRangeStart > next.portRangeEnd) {
const error = new Error('端口范围必须是 1024–65535 之间的整数'); error.statusCode = 400; throw error;
}
if (next.tlsDomain && (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(next.tlsDomain))) {
const error = new Error('TLS 域名必须是有效的公网域名'); error.statusCode = 400; throw error;
}
return next;
}
function normalizeChain(body, current = {}) {
const topology = 'topology' in body ? (body.topology === 'direct' ? 'direct' : 'forward') : (current.topology || 'forward');
const next = {
...current,
name: 'name' in body ? requiredText(body.name, '线路名称') : current.name,
topology,
relayServerIds: 'relayServerIds' in body ? asIds(body.relayServerIds) : (current.relayServerIds || []),
exitServerId: topology === 'direct' ? null : ('exitServerId' in body ? requiredText(body.exitServerId, '出口服务器 ID') : current.exitServerId),
customerIds: 'customerIds' in body ? asIds(body.customerIds) : (current.customerIds || []),
relayProtocol: 'relayProtocol' in body ? cleanText(body.relayProtocol, 64) : current.relayProtocol,
exitProtocol: topology === 'direct' ? null : ('exitProtocol' in body ? cleanText(body.exitProtocol, 64) : current.exitProtocol),
relayPortMode: 'relayPortMode' in body ? (body.relayPortMode === 'fixed' ? 'fixed' : 'random') : (current.relayPortMode || 'random'),
relayPort: 'relayPort' in body ? (body.relayPort ? Number(body.relayPort) : null) : (current.relayPort || null),
exitPortMode: topology === 'direct' ? null : ('exitPortMode' in body ? (body.exitPortMode === 'fixed' ? 'fixed' : 'random') : (current.exitPortMode || 'random')),
exitPort: topology === 'direct' ? null : ('exitPort' in body ? (body.exitPort ? Number(body.exitPort) : null) : (current.exitPort || null)),
networkMode: 'networkMode' in body && ['ipv4', 'ipv6', 'dual'].includes(body.networkMode) ? body.networkMode : (current.networkMode || 'ipv4'),
realityServerName: 'realityServerName' in body ? cleanText(body.realityServerName, 255) : (current.realityServerName || 'www.tesla.com'),
realityDestPort: 'realityDestPort' in body ? Number(body.realityDestPort || 443) : Number(current.realityDestPort || 443)
};
if (!next.relayServerIds.length || !next.customerIds.length) { const error = new Error('请选择入口服务器和客户'); error.statusCode = 400; throw error; }
if (next.relayPortMode === 'fixed' && (!Number.isInteger(next.relayPort) || next.relayPort < 1024 || next.relayPort > 65535)) { const error = new Error('固定入口端口无效'); error.statusCode = 400; throw error; }
if (topology === 'forward' && next.exitPortMode === 'fixed' && (!Number.isInteger(next.exitPort) || next.exitPort < 1024 || next.exitPort > 65535)) { const error = new Error('固定出口端口无效'); error.statusCode = 400; throw error; }
if (topology === 'direct') validateEntryProtocol(next.relayProtocol); else validateProtocolPair(next.relayProtocol, next.exitProtocol);
if (isRealityProtocol(next.relayProtocol) && !next.realityServerName) { const error = new Error('Reality SNI 不能为空'); error.statusCode = 400; throw error; }
if (isRealityProtocol(next.relayProtocol) && (!Number.isInteger(next.realityDestPort) || next.realityDestPort < 1 || next.realityDestPort > 65535)) { const error = new Error('Reality 目标端口无效'); error.statusCode = 400; throw error; }
return next;
}
function publicDeployment(item) {
const { credentials, clientTemplate, ...safe } = item;
return { ...safe, ...(safe.error ? { error: redactSecrets(safe.error) } : {}) };
}
function publicServer(item) {
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
pendingCleanup: store.data.deployments.filter((entry) => entry.serverId === item.id && entry.archived && entry.status !== 'deleted').length };
}
function publicCustomer(item) {
return { ...item, pendingCleanup: store.data.deployments.filter((entry) => entry.customerId === item.id && entry.archived && entry.status !== 'deleted').length,
observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(store.data, item.id).length };
}
function customerBlockReason(data, customer) {
if (customer.expiresAt && Date.parse(customer.expiresAt) <= Date.now()) return 'expired';
if (customer.trafficLimitBytes > 0 && customer.usedBytes >= customer.trafficLimitBytes) return 'traffic_limit';
if (customer.ipLimit > 0 && new Set(observedIps(data, customer.id).map((item) => item.ip)).size > customer.ipLimit) return 'ip_limit';
return null;
}
function getIp(req) {
return cleanText((req.headers['x-forwarded-for'] || '').split(',')[0] || req.socket.remoteAddress, 80);
}
function securityHeaders(res) {
res.setHeader('x-frame-options', 'DENY');
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('permissions-policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('content-security-policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
}
function currentAdmin(req) {
const session = sessions.get(req);
if (!session) return null;
const user = store.data.users.find((item) => item.id === session.userId && item.status === 'active');
return user ? { session, user } : null;
}
function requireAdmin(req, res, csrf = false) {
const auth = currentAdmin(req);
if (!auth) {
sendError(res, 401, '请先登录', 'unauthorized');
return null;
}
if (csrf && req.headers['x-csrf-token'] !== auth.session.csrf) {
sendError(res, 403, '请求校验失败,请刷新页面后重试', 'csrf_failed');
return null;
}
return auth;
}
function findAgentByBearer(req) {
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) return null;
const token = header.slice(7);
const fingerprint = tokenFingerprint(token);
const fastMatch = store.data.agents.find((agent) => agent.status === 'active' && agent.keyFingerprint === fingerprint);
if (fastMatch && verifySecret(token, fastMatch.keyHash)) return fastMatch;
return store.data.agents.find((agent) => agent.status === 'active' && !agent.keyFingerprint && verifySecret(token, agent.keyHash)) || null;
}
async function handleAuth(req, res, pathname) {
if (req.method === 'POST' && pathname === '/api/auth/login') {
const ip = getIp(req);
const record = loginAttempts.get(ip) || { count: 0, blockedUntil: 0 };
if (record.blockedUntil > Date.now()) {
sendError(res, 429, '尝试次数过多,请稍后再试', 'rate_limited');
return true;
}
const body = await readJson(req);
const username = cleanText(body.username, 64);
const user = store.data.users.find((item) => item.username === username && item.status === 'active');
if (!user || !verifySecret(body.password, user.passwordHash)) {
record.count += 1;
if (record.count >= 6) {
record.blockedUntil = Date.now() + 15 * 60 * 1000;
record.count = 0;
}
loginAttempts.set(ip, record);
sendError(res, 401, '账号或密码不正确', 'invalid_credentials');
return true;
}
loginAttempts.delete(ip);
const session = sessions.create(user.id);
const secure = COOKIE_SECURE ? '; Secure' : '';
sendJson(res, 200, { user: { id: user.id, username: user.username, role: user.role }, csrf: session.csrf }, {
'set-cookie': `ng_session=${encodeURIComponent(session.id)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(sessions.ttlMs / 1000)}${secure}`
});
return true;
}
if (req.method === 'POST' && pathname === '/api/auth/logout') {
const auth = requireAdmin(req, res, true);
if (!auth) return true;
sessions.delete(req);
sendJson(res, 200, { ok: true }, { 'set-cookie': 'ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
return true;
}
if (req.method === 'GET' && pathname === '/api/session') {
const auth = currentAdmin(req);
if (!auth) {
sendJson(res, 200, { authenticated: false });
} else {
sendJson(res, 200, { authenticated: true, csrf: auth.session.csrf, user: { id: auth.user.id, username: auth.user.username, role: auth.user.role } });
}
return true;
}
return false;
}
async function handleAgent(req, res, pathname) {
if (req.method === 'POST' && pathname === '/api/agent/enroll') {
const body = await readJson(req);
const token = String(body.token || '');
const enrollment = store.data.enrollmentTokens.find((item) => !item.usedAt && new Date(item.expiresAt).getTime() > Date.now() && verifySecret(token, item.tokenHash));
if (!enrollment) {
sendError(res, 401, '注册令牌无效或已过期', 'invalid_enrollment');
return true;
}
const agentKey = randomToken(40);
const result = await store.transaction((data) => {
const target = data.enrollmentTokens.find((item) => item.id === enrollment.id);
if (!target || target.usedAt) throw Object.assign(new Error('注册令牌已被使用'), { statusCode: 409 });
target.usedAt = nowIso();
const server = data.servers.find((item) => item.id === target.serverId);
if (!server) throw Object.assign(new Error('目标服务器不存在'), { statusCode: 404 });
for (const old of data.agents.filter((item) => item.serverId === server.id)) old.status = 'revoked';
const agent = {
id: id('agt'), serverId: server.id, keyHash: hashSecret(agentKey), keyFingerprint: tokenFingerprint(agentKey), status: 'active',
hostname: cleanText(body.hostname, 128), version: cleanText(body.version, 32),
createdAt: nowIso(), lastSeenAt: nowIso()
};
data.agents.push(agent);
server.status = 'online';
server.lastSeenAt = nowIso();
server.system = body.system || {};
server.updatedAt = nowIso();
audit(data, `agent:${agent.id}`, 'enroll_agent', server.id);
return { agentId: agent.id, serverId: server.id, serverName: server.name };
});
let reconciled = 0;
try { reconciled = await orchestrator.reconcileServer(result.serverId, `agent:${result.agentId}`); }
catch (error) { console.error('Agent reconciliation failed:', error); }
sendJson(res, 201, { ...result, agentKey, reconciled });
return true;
}
if (!pathname.startsWith('/api/agent/')) return false;
const agent = findAgentByBearer(req);
if (!agent) {
sendError(res, 401, 'Agent 身份校验失败', 'invalid_agent');
return true;
}
if (req.method === 'POST' && pathname === '/api/agent/heartbeat') {
const body = await readJson(req);
await store.transient((data) => {
const current = data.agents.find((item) => item.id === agent.id);
const server = data.servers.find((item) => item.id === agent.serverId);
if (current) {
current.lastSeenAt = nowIso();
current.version = cleanText(body.version || current.version, 32);
}
if (server) {
server.status = 'online';
server.lastSeenAt = nowIso();
server.system = body.system || server.system || {};
server.engine = body.engine && ['ready','error'].includes(body.engine.status)
? { status: body.engine.status, detail: redactSecrets(cleanText(body.engine.detail, 400)),
singBoxInstalled: body.engine.singBoxInstalled === true,
certificates: Array.isArray(body.engine.certificates) ? body.engine.certificates.slice(0, 50)
.map((value) => cleanText(value, 253).toLowerCase())
.filter((value) => /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(value)) : [],
singBoxStatus: body.engine.singBoxStatus === 'ready' ? 'ready' : 'inactive', at: nowIso() }
: server.engine || null;
}
});
sendJson(res, 200, { ok: true, serverTime: nowIso() });
return true;
}
if (req.method === 'POST' && pathname === '/api/agent/tls-domain') {
const body = await readJson(req);
const domain = requiredText(body.domain, '节点 TLS 域名', 253).toLowerCase();
await store.transaction((data) => {
const server = data.servers.find((item) => item.id === agent.serverId);
if (!server) throw Object.assign(new Error('设备不存在'), { statusCode:404 });
normalizeServer({ tlsDomain:domain }, server);
if (server.tlsDomain && server.tlsDomain !== domain && data.deployments.some((item) =>
item.serverId === server.id && ['relay','direct'].includes(item.role) &&
['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) &&
['active','queued','applying'].includes(item.status) && !item.archived)) {
throw Object.assign(new Error('设备已有使用其他域名的 TLS 节点;请先处理现有线路'), { statusCode:409 });
}
if (server.tlsDomain !== domain) {
server.tlsDomain = domain;
server.updatedAt = nowIso();
audit(data, `agent:${agent.id}`, 'set_tls_domain', server.id, { domain });
}
server.engine ||= {};
server.engine.certificates = [...new Set([...(server.engine.certificates || []), domain])];
});
sendJson(res, 200, { ok:true, domain });
return true;
}
if (req.method === 'POST' && pathname === '/api/agent/poll') {
if (!store.data.jobs.some((item) => item.serverId === agent.serverId && item.status === 'queued')) {
sendJson(res, 200, { job: null });
return true;
}
let picked = null;
await store.transaction((data) => {
const job = data.jobs.find((item) => item.serverId === agent.serverId && item.status === 'queued');
if (!job) return Store.SKIP;
job.status = 'running';
job.attempts += 1;
job.startedAt = nowIso();
job.updatedAt = nowIso();
job.leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
picked = structuredClone(job);
});
sendJson(res, 200, { job: picked });
return true;
}
const completed = route('/api/agent/jobs/:id/complete', pathname);
if (req.method === 'POST' && completed) {
const job = store.data.jobs.find((item) => item.id === completed.id && item.serverId === agent.serverId);
if (!job) {
sendError(res, 404, '任务不存在', 'not_found');
return true;
}
const body = await readJson(req);
const updated = await orchestrator.completeJob(job.id, Boolean(body.success), body.result || {}, body.error);
sendJson(res, 200, { job: updated });
return true;
}
if (req.method === 'POST' && pathname === '/api/agent/usage') {
const body = await readJson(req);
await orchestrator.recordUsage(agent.serverId, body.samples, redactSecrets(cleanText(body.error, 240)), cleanText(body.epoch, 80));
sendJson(res, 200, { ok: true });
return true;
}
if (req.method === 'POST' && pathname === '/api/agent/observations') {
const body = await readJson(req);
await store.transaction((data) => {
const ttlMs = (data.settings.observationTtlMinutes || 10) * 60000;
const cutoff = Date.now() - ttlMs;
data.observations = data.observations.filter((item) => new Date(item.lastSeenAt).getTime() > cutoff);
const affected = new Set();
for (const sample of Array.isArray(body.observations) ? body.observations.slice(0, 1000) : []) {
const customerId = cleanText(sample.customerId, 100);
const ip = cleanText(sample.ip, 64).replace(/^\[|\]$/g, '');
if (!net.isIP(ip) || !data.deployments.some((item) => item.serverId === agent.serverId &&
item.customerId === customerId && ['relay', 'direct'].includes(item.role) && item.status === 'active' && !item.archived)) continue;
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
if (existing) existing.lastSeenAt = nowIso();
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
affected.add(customerId);
}
for (const customerId of affected) {
const customer = data.customers.find((item) => item.id === customerId);
if (!customer || customer.status !== 'active' || !(customer.ipLimit > 0)) continue;
const ipCount = new Set(data.observations.filter((item) => item.customerId === customerId).map((item) => item.ip)).size;
if (ipCount <= customer.ipLimit) continue;
customer.status = 'suspended';
customer.suspendReason = 'ip_limit';
customer.updatedAt = nowIso();
suspendCustomerResources(data, customer, `agent:${agent.id}`);
audit(data, `agent:${agent.id}`, 'suspend_ip_limit', customer.id, { ipCount, limit: customer.ipLimit });
}
});
sendJson(res, 200, { ok: true });
return true;
}
return false;
}
async function handleAdminApi(req, res, pathname) {
if (!pathname.startsWith('/api/')) return false;
const changing = !['GET', 'HEAD'].includes(req.method);
const auth = requireAdmin(req, res, changing);
if (!auth) return true;
const actor = auth.user.username;
if (req.method === 'GET' && pathname === '/api/overview') {
const data = store.data;
const online = data.servers.filter((item) => item.status === 'online' && Date.now() - new Date(item.lastSeenAt || 0).getTime() < 120000).length;
sendJson(res, 200, {
counts: {
servers: data.servers.length, online, customers: data.customers.length,
chains: data.chains.length, activeDeployments: data.deployments.filter((item) => item.status === 'active').length,
queuedJobs: data.jobs.filter((item) => ['queued', 'running'].includes(item.status)).length
},
version: VERSION,
activity: data.activity.slice(0, 12),
degraded: data.deployments.filter((item) => !item.archived && item.status === 'failed').map(publicDeployment).slice(0, 10)
});
return true;
}
if (req.method === 'GET' && pathname === '/api/protocols') {
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
return true;
}
if (req.method === 'PATCH' && pathname === '/api/account') {
const body = await readJson(req);
if (!verifySecret(body.currentPassword, auth.user.passwordHash)) throw Object.assign(new Error('当前密码不正确'), { statusCode: 400 });
const username = 'username' in body ? requiredText(body.username, '登录账号', 64) : auth.user.username;
const newPassword = body.newPassword == null ? '' : String(body.newPassword).slice(0, 256);
if (newPassword && newPassword.length < 10) throw Object.assign(new Error('新密码至少需要 10 位'), { statusCode: 400 });
await store.transaction((data) => {
const item = data.users.find((entry) => entry.id === auth.user.id);
if (!item) throw Object.assign(new Error('账号不存在'), { statusCode: 404 });
if (data.users.some((entry) => entry.id !== item.id && entry.username === username)) throw Object.assign(new Error('该登录账号已被使用'), { statusCode: 409 });
item.username = username;
if (newPassword) item.passwordHash = hashSecret(newPassword);
item.updatedAt = nowIso();
audit(data, actor, 'update_account', item.id, { username });
});
sessions = new SessionManager(store.data.settings.sessionHours || 12);
sendJson(res, 200, { ok: true, reauthenticate: true }, { 'set-cookie': 'ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
return true;
}
if (req.method === 'GET' && pathname === '/api/servers') {
sendJson(res, 200, { servers: store.data.servers.map(publicServer) });
return true;
}
if (req.method === 'POST' && pathname === '/api/servers') {
const body = await readJson(req);
const server = await store.transaction((data) => {
const next = {
...normalizeServer(body), id: id('srv'), status: 'pending', createdAt: nowIso(), updatedAt: nowIso()
};
data.servers.push(next);
audit(data, actor, 'create_server', next.id, { name: next.name });
return structuredClone(next);
});
sendJson(res, 201, { server });
return true;
}
const serverItem = route('/api/servers/:id', pathname);
if (serverItem && req.method === 'PATCH') {
const body = await readJson(req);
const server = await store.transaction((data) => {
const item = data.servers.find((entry) => entry.id === serverItem.id);
if (!item) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
Object.assign(item, normalizeServer(body, item), { updatedAt: nowIso() });
audit(data, actor, 'update_server', item.id, { name: item.name });
return structuredClone(item);
});
sendJson(res, 200, { server });
return true;
}
if (serverItem && req.method === 'DELETE') {
await store.transaction((data) => {
const index = data.servers.findIndex((item) => item.id === serverItem.id);
if (index < 0) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
if (data.deployments.some((item) => item.serverId === serverItem.id && item.status !== 'deleted')) {
throw Object.assign(new Error('设备仍有待清理资源。先删除关联线路并等待 Agent 上线确认清理,再删除设备'), { statusCode: 409 });
}
if (data.chains.some((item) => item.exitServerId === serverItem.id || item.relayServerIds.includes(serverItem.id))) {
throw Object.assign(new Error('设备仍被线路引用。请先编辑或删除关联线路'), { statusCode: 409 });
}
data.servers.splice(index, 1);
for (const agent of data.agents.filter((item) => item.serverId === serverItem.id)) agent.status = 'revoked';
audit(data, actor, 'delete_server', serverItem.id);
});
sendJson(res, 200, { ok: true });
return true;
}
const enroll = route('/api/servers/:id/enrollment-token', pathname);
if (enroll && req.method === 'POST') {
const raw = randomToken(30);
const token = await store.transaction((data) => {
if (!data.servers.some((item) => item.id === enroll.id)) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
const next = {
id: id('enr'), serverId: enroll.id, tokenHash: hashSecret(raw), createdAt: nowIso(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), usedAt: null
};
data.enrollmentTokens.push(next);
audit(data, actor, 'create_enrollment', enroll.id);
return next;
});
sendJson(res, 201, { token: raw, expiresAt: token.expiresAt });
return true;
}
const cleanup = route('/api/servers/:id/cleanup', pathname);
if (cleanup && req.method === 'POST') {
const count = await store.transaction((data) => {
if (!data.servers.some((item) => item.id === cleanup.id)) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
let queued = 0;
for (const deployment of data.deployments.filter((item) => item.serverId === cleanup.id && item.archived && item.status !== 'deleted')) {
const pending = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status));
if (pending) continue;
data.jobs.push({ id: id('job'), serverId: cleanup.id, deploymentId: deployment.id, action: 'delete_resource',
payload: { resourceId: deployment.resourceId }, status: 'queued', attempts: 0, createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null });
deployment.status = 'removing'; deployment.updatedAt = nowIso(); queued += 1;
}
audit(data, actor, 'retry_cleanup', cleanup.id, { queued });
return queued;
});
sendJson(res, 202, { queued: count }); return true;
}
const forget = route('/api/servers/:id/forget', pathname);
if (forget && req.method === 'POST') {
const body = await readJson(req);
const result = await store.transaction((data) => {
const index = data.servers.findIndex((item) => item.id === forget.id);
if (index < 0) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
const server = data.servers[index];
if (body.confirm !== 'FORGET' || body.name !== server.name) throw Object.assign(new Error('请输入准确设备名称以确认'), { statusCode: 400 });
if (server.status === 'online') throw Object.assign(new Error('在线设备不能强制遗忘,请先正常停用并清理资源'), { statusCode: 409 });
if (data.chains.some((item) => item.exitServerId === server.id || item.relayServerIds.includes(server.id))) {
throw Object.assign(new Error('设备仍被线路引用,请先删除关联线路'), { statusCode: 409 });
}
const residual = data.deployments.filter((item) => item.serverId === server.id && item.status !== 'deleted');
if (residual.some((item) => !item.archived)) throw Object.assign(new Error('仍有未归档部署,请先删除关联线路'), { statusCode: 409 });
const residualIds = new Set(residual.map((item) => item.id));
if (data.jobs.some((item) => residualIds.has(item.deploymentId) && item.status === 'running')) {
throw Object.assign(new Error('设备任务尚在执行,不能强制遗忘'), { statusCode: 409 });
}
data.jobs = data.jobs.filter((item) => !residualIds.has(item.deploymentId));
data.deployments = data.deployments.filter((item) => !residualIds.has(item.id));
for (const agent of data.agents.filter((item) => item.serverId === server.id)) agent.status = 'revoked';
data.servers.splice(index, 1);
audit(data, actor, 'force_forget_server', server.id, { name: server.name, unconfirmedResources: residual.length });
return { unconfirmedResources: residual.length };
});
sendJson(res, 200, { ok: true, ...result }); return true;
}
if (req.method === 'GET' && pathname === '/api/customers') {
sendJson(res, 200, { customers: store.data.customers.map(publicCustomer) });
return true;
}
const accessRoute = route('/api/customers/:id/access', pathname);
if (accessRoute && req.method === 'GET') {
const customer = store.data.customers.find((item) => item.id === accessRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
sendJson(res, 200, { limits: { ip: customer.ipLimit, subscriptionClients: customer.deviceLimit },
observedIps: observedIps(store.data, customer.id).map(({ ip, firstSeenAt, lastSeenAt }) => ({ ip, firstSeenAt, lastSeenAt })),
subscriptionClients: activeClients(store.data, customer.id).length,
events: store.data.subscriptionAccess.filter((item) => item.customerId === customer.id).slice(-100).reverse().map(({ clientKey, ...event }) => event) });
return true;
}
if (accessRoute && req.method === 'DELETE') {
await store.transaction((data) => {
if (!data.customers.some((item) => item.id === accessRoute.id)) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== accessRoute.id);
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== accessRoute.id);
audit(data, actor, 'reset_subscription_access', accessRoute.id);
});
sendJson(res, 200, { ok: true }); return true;
}
if (req.method === 'POST' && pathname === '/api/customers') {
const body = await readJson(req);
const customer = await store.transaction((data) => {
const next = {
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
expiresAt: optionalIso(body.expiresAt, '到期日期'),
ipLimit: nonNegativeNumber(body.ipLimit, 'IP 上限', true), deviceLimit: nonNegativeNumber(body.deviceLimit, '设备上限', true),
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
createdAt: nowIso(), updatedAt: nowIso()
};
data.customers.push(next);
audit(data, actor, 'create_customer', next.id, { name: next.name });
return structuredClone(next);
});
sendJson(res, 201, { customer });
return true;
}
const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') {
const body = await readJson(req);
const customer = await store.transaction((data) => {
const item = data.customers.find((entry) => entry.id === customerItem.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
const wasActive = item.status === 'active';
const previousReason = item.suspendReason;
if ('name' in body) item.name = requiredText(body.name, '客户名称');
if ('group' in body) item.group = cleanText(body.group, 80);
if ('notes' in body) item.notes = cleanText(body.notes, 1000);
if ('trafficLimitBytes' in body) item.trafficLimitBytes = nonNegativeNumber(body.trafficLimitBytes, '流量上限');
if ('ipLimit' in body) item.ipLimit = nonNegativeNumber(body.ipLimit, 'IP 上限', true);
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
const reason = customerBlockReason(data, item);
if (body.status === 'active' && reason) {
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
error.statusCode = 409; throw error;
}
if (body.status === 'suspended') { item.status = 'suspended'; item.suspendReason = 'manual'; }
else if (reason && wasActive) { item.status = 'suspended'; item.suspendReason = reason; }
else if (body.status === 'active' || (!wasActive && !reason && ['traffic_limit','expired','ip_limit'].includes(previousReason))) {
item.status = 'active'; item.suspendReason = null;
}
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
if (!wasActive && item.status === 'active') resumeCustomerResources(data, item, actor);
item.updatedAt = nowIso();
audit(data, actor, 'update_customer', item.id);
return structuredClone(item);
});
sendJson(res, 200, { customer });
return true;
}
const resetUsage = route('/api/customers/:id/reset-usage', pathname);
if (resetUsage && req.method === 'POST') {
await store.transaction((data) => {
const item = data.customers.find((entry) => entry.id === resetUsage.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
item.usedBytes = 0; item.usedUplinkBytes = 0; item.usedDownlinkBytes = 0;
if (item.status === 'suspended' && item.suspendReason === 'traffic_limit' && !customerBlockReason(data, item)) {
item.status = 'active'; item.suspendReason = null; resumeCustomerResources(data, item, actor);
}
item.updatedAt = nowIso(); audit(data, actor, 'reset_customer_usage', item.id);
});
sendJson(res, 200, { ok: true }); return true;
}
const rotateSubscription = route('/api/customers/:id/rotate-subscription', pathname);
if (rotateSubscription && req.method === 'POST') {
const customer = await store.transaction((data) => {
const item = data.customers.find((entry) => entry.id === rotateSubscription.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
item.subscriptionToken = randomToken(32);
item.updatedAt = nowIso();
audit(data, actor, 'rotate_subscription', item.id);
return publicCustomer(item);
});
sendJson(res, 200, { customer }); return true;
}
if (customerItem && req.method === 'DELETE') {
await store.transaction((data) => {
const index = data.customers.findIndex((item) => item.id === customerItem.id);
if (index < 0) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
if (data.chains.some((item) => item.customerIds.includes(customerItem.id))) {
throw Object.assign(new Error('客户仍被线路引用。请先移除或删除关联线路'), { statusCode: 409 });
}
if (data.deployments.some((item) => item.customerId === customerItem.id && !item.archived && item.status !== 'deleted')) {
throw Object.assign(new Error('客户仍有活动部署。请先停用并删除关联线路'), { statusCode: 409 });
}
data.customers.splice(index, 1);
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
// Archived cleanup records contain resource IDs and can finish without a customer row.
audit(data, actor, 'delete_customer', customerItem.id, { pendingCleanup: data.deployments.filter((item) => item.customerId === customerItem.id && item.archived && item.status !== 'deleted').length });
});
sendJson(res, 200, { ok: true });
return true;
}
if (req.method === 'GET' && pathname === '/api/chains') {
sendJson(res, 200, { chains: store.data.chains, deployments: store.data.deployments.filter((item) => !item.archived).map(publicDeployment) });
return true;
}
if (req.method === 'POST' && pathname === '/api/chains') {
const body = await readJson(req);
const chain = await store.transaction((data) => {
const next = {
...normalizeChain(body), id: id('chn'), status: 'draft', generation: 0,
createdAt: nowIso(), updatedAt: nowIso(), redeployPending: false
};
data.chains.push(next);
audit(data, actor, 'create_chain', next.id, { name: next.name });
return structuredClone(next);
});
sendJson(res, 201, { chain });
return true;
}
const deploy = route('/api/chains/:id/deploy', pathname);
if (deploy && req.method === 'POST') {
const deployments = await orchestrator.deployChain(deploy.id, actor);
sendJson(res, 202, { deployments: deployments.map(publicDeployment) });
return true;
}
const remove = route('/api/chains/:id/remove', pathname);
if (remove && req.method === 'POST') {
const count = await orchestrator.removeChain(remove.id, actor);
sendJson(res, 202, { jobs: count });
return true;
}
const redeploy = route('/api/chains/:id/redeploy', pathname);
if (redeploy && req.method === 'POST') { const count = await orchestrator.removeChain(redeploy.id, actor, true); sendJson(res, 202, { jobs: count }); return true; }
const repair = route('/api/chains/:id/repair', pathname);
if (repair && req.method === 'POST') { const count = await orchestrator.repairChain(repair.id, actor); sendJson(res, 202, { jobs: count }); return true; }
const restoreChain = route('/api/chains/:id/restore', pathname);
if (restoreChain && req.method === 'POST') { const count = await orchestrator.restoreChain(restoreChain.id, actor); sendJson(res, 202, { jobs: count }); return true; }
const chainItem = route('/api/chains/:id', pathname);
if (chainItem && req.method === 'PATCH') {
const body = await readJson(req);
const chain = await store.transaction((data) => {
const item = data.chains.find((entry) => entry.id === chainItem.id);
if (!item) throw Object.assign(new Error('线路不存在'), { statusCode: 404 });
const hasActive = data.deployments.some((entry) => entry.chainId === item.id && !['deleted', 'failed'].includes(entry.status));
Object.assign(item, normalizeChain(body, item), { updatedAt: nowIso() });
item.status = hasActive ? 'changes_pending' : 'draft';
audit(data, actor, 'update_route', item.id, { requiresRedeploy: hasActive });
return { item: structuredClone(item), requiresRedeploy: hasActive };
});
sendJson(res, 200, { chain: chain.item, requiresRedeploy: chain.requiresRedeploy }); return true;
}
if (chainItem && req.method === 'DELETE') {
const cleanupPending = await store.transaction((data) => {
const index = data.chains.findIndex((item) => item.id === chainItem.id);
if (index < 0) throw Object.assign(new Error('链路不存在'), { statusCode: 404 });
const deployments = data.deployments.filter((item) => item.chainId === chainItem.id && item.status !== 'deleted');
if (deployments.some((item) => item.status === 'active')) {
throw Object.assign(new Error('线路仍有运行中的资源。请先停用并等待 Agent 确认'), { statusCode: 409 });
}
if (data.jobs.some((job) => deployments.some((item) => item.id === job.deploymentId) && job.status === 'running')) {
throw Object.assign(new Error('部署任务正在设备上执行,请稍后刷新再删除'), { statusCode: 409 });
}
// Retain hidden deployment tombstones until the agent confirms deletion. This prevents
// reusing occupied ports or silently leaving orphaned Xray resources on offline machines.
for (const deployment of deployments) {
for (const job of data.jobs.filter((entry) => entry.deploymentId === deployment.id && entry.status === 'queued')) {
job.status = 'failed'; job.error = '线路已删除,原任务取消'; job.updatedAt = nowIso();
}
data.jobs.push({ id: id('job'), serverId: deployment.serverId, deploymentId: deployment.id,
action: 'delete_resource', payload: { resourceId: deployment.resourceId }, status: 'queued', attempts: 0,
createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null });
deployment.status = 'removing'; deployment.archived = true; deployment.clientUri = null; deployment.updatedAt = nowIso();
}
data.chains.splice(index, 1);
audit(data, actor, 'delete_chain', chainItem.id, { cleanupPending: deployments.length });
return deployments.length;
});
sendJson(res, 200, { ok: true, cleanupPending });
return true;
}
if (req.method === 'GET' && pathname === '/api/jobs') {
sendJson(res, 200, { jobs: store.data.jobs.slice(-200).reverse().map(({ payload, ...job }) => ({ ...job, ...(job.error ? { error: redactSecrets(job.error) } : {}) })) });
return true;
}
if (req.method === 'GET' && pathname === '/api/backup') {
const body = JSON.stringify(store.snapshot(), null, 2);
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="nexusgate-backup-${new Date().toISOString().slice(0, 10)}.json"`,
'content-length': Buffer.byteLength(body)
});
res.end(body);
return true;
}
if (req.method === 'POST' && pathname === '/api/restore') {
const body = await readJson(req, 20 * 1024 * 1024);
if (body.confirm !== 'RESTORE' || !body.data) throw Object.assign(new Error('恢复确认信息不正确'), { statusCode: 400 });
await store.replace(body.data);
if (store.data.customers.some((item) => !item.subscriptionToken)) {
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
}
sessions = new SessionManager(store.data.settings.sessionHours || 12);
sendJson(res, 200, { ok: true, message: '备份已恢复,请重新登录' }, { 'set-cookie': 'ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
return true;
}
return false;
}
async function handleSubscription(req, res, pathname) {
const match = /^\/s\/([A-Za-z0-9_-]{32,100})\/(auto|raw|base64|v2ray|shadowrocket|clash|clash-smart|mihomo|singbox|surge)$/.exec(pathname);
if (!match) return false;
const headers = { 'cache-control':'no-store, private', 'x-robots-tag':'noindex, nofollow', 'vary':'User-Agent' };
if (req.method !== 'GET') { sendError(res, 405, '只支持 GET 请求'); return true; }
// Store only one random secret per customer; never accept a numeric ID as a subscription credential.
const token = Buffer.from(match[1]);
const customer = store.data.customers.find((item) => item.subscriptionToken && Buffer.byteLength(item.subscriptionToken) === token.length &&
crypto.timingSafeEqual(Buffer.from(item.subscriptionToken), token));
if (!customer) { sendJson(res, 404, { message:'订阅链接不存在或已重置' }, headers); return true; }
const identity = clientIdentity(req.headers, store.data.createdAt);
const access = { id: id('acc'), customerId: customer.id, at: nowIso(), ip: getIp(req),
userAgent: identity.userAgent, identityType: identity.kind, clientKey: identity.key,
format: match[2], status: 0, reason: '', bytes: 0 };
if (customer.status !== 'active' || (customer.expiresAt && Date.parse(customer.expiresAt) <= Date.now()) ||
(customer.trafficLimitBytes > 0 && customer.usedBytes >= customer.trafficLimitBytes)) {
access.status = 403; access.reason = '客户停用、到期或流量用尽';
await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); });
sendJson(res, 403, { message:'客户已停用、到期或流量用尽' }, headers); return true;
}
const format = ({ v2ray:'base64', shadowrocket:'base64', mihomo:'clash-smart' })[match[2]] || match[2];
const result = formatSubscription(store.data, customer, format, String(req.headers['user-agent'] || ''));
if (!result) {
const restoring = store.data.deployments.some((item) => item.customerId === customer.id && !item.archived &&
['relay','direct'].includes(item.role) && ['queued','removing'].includes(item.status) &&
store.data.jobs.some((job) => job.deploymentId === item.id && ['queued','running'].includes(job.status)));
access.status = 503; access.reason = restoring ? '入口节点恢复中' : '没有已部署的入口节点';
await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); });
sendJson(res, 503, { message:restoring ? '节点正在恢复,请稍后更新订阅' : '当前没有部署成功的入口节点,请在“转发与节点”恢复原节点或部署线路' },
{ ...headers, ...(restoring ? { 'retry-after':'15' } : {}) }); return true;
}
const body = Buffer.from(result.body);
const allowed = await store.transaction((data) => {
const current = data.customers.find((item) => item.id === customer.id);
// Serialize the check with the write so concurrent refreshes cannot take extra slots.
const clients = activeClients(data, customer.id);
const permitted = current && current.subscriptionToken === match[1] && current.status === 'active' &&
(!current.expiresAt || Date.parse(current.expiresAt) > Date.now()) &&
(!(current.trafficLimitBytes > 0) || current.usedBytes < current.trafficLimitBytes) &&
(!current.deviceLimit || clients.includes(identity.key) || clients.length < current.deviceLimit);
access.status = permitted ? 200 : 429;
access.reason = permitted ? '已返回' : '订阅客户端估计数超限';
access.bytes = permitted ? body.length : 0;
if (permitted) {
const session = data.subscriptionClients.find((item) => item.customerId === customer.id && item.clientKey === identity.key);
if (session) session.lastSeenAt = access.at;
else data.subscriptionClients.push({ customerId: customer.id, clientKey: identity.key, identityType: identity.kind,
firstSeenAt: access.at, lastSeenAt: access.at });
}
data.subscriptionAccess.push(access);
pruneAccess(data);
return permitted;
});
if (!allowed) { sendJson(res, 429, { message:'订阅客户端数达到上限。可在客户访问记录中检查并重置观察窗口。' }, headers); return true; }
const historical = Math.max(0, (customer.usedBytes || 0) - (customer.usedUplinkBytes || 0) - (customer.usedDownlinkBytes || 0));
res.writeHead(200, { ...headers, 'content-type':result.contentType, 'content-length':body.length,
'subscription-userinfo':`upload=${Math.floor((customer.usedUplinkBytes || 0) + historical)}; download=${Math.floor(customer.usedDownlinkBytes || 0)}; total=${Math.max(0, Math.floor(customer.trafficLimitBytes || 0))}; expire=${customer.expiresAt ? Math.floor(Date.parse(customer.expiresAt) / 1000) : 0}` });
res.end(body);
return true;
}
async function requestHandler(req, res) {
securityHeaders(res);
const url = new URL(req.url, 'http://localhost');
const pathname = url.pathname;
try {
if (pathname === '/healthz') {
sendJson(res, 200, { ok: true, version: VERSION, time: nowIso() });
return;
}
if (await handleAuth(req, res, pathname)) return;
if (await handleAgent(req, res, pathname)) return;
if (await handleSubscription(req, res, pathname)) return;
if (await handleAdminApi(req, res, pathname)) return;
if (req.method === 'GET' && await serveStatic(PUBLIC_DIR, pathname, res)) return;
if (req.method === 'GET' && !pathname.startsWith('/api/')) {
await serveStatic(PUBLIC_DIR, '/', res);
return;
}
sendError(res, 404, '接口不存在', 'not_found');
} catch (error) {
console.error(`[${nowIso()}]`, error);
if (!res.headersSent) sendError(res, error.statusCode || 500, error.statusCode ? error.message : '服务器内部错误', error.statusCode ? 'request_error' : 'internal_error');
else res.end();
}
}
async function housekeeping() {
try {
const redeployIds = await store.transaction((data) => {
const now = Date.now();
for (const server of data.servers) {
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
}
for (const job of data.jobs.filter((item) => item.status === 'running' && item.leaseUntil && new Date(item.leaseUntil).getTime() <= now)) {
job.leaseUntil = null; job.updatedAt = nowIso();
if (Number(job.attempts || 0) < 3) { job.status = 'queued'; job.error = 'Agent 未在租约内确认,任务已自动重试'; }
else {
job.status = 'failed'; job.error = 'Agent 连续 3 次未在租约内确认';
const deployment = data.deployments.find((item) => item.id === job.deploymentId);
if (deployment) {
deployment.status = 'failed'; deployment.error = job.error; deployment.updatedAt = nowIso();
const chain = data.chains.find((item) => item.id === deployment.chainId);
if (chain) { chain.status = 'degraded'; chain.updatedAt = nowIso(); }
}
}
}
for (const customer of data.customers) {
const expired = customer.expiresAt && new Date(customer.expiresAt).getTime() <= now;
const exhausted = customer.trafficLimitBytes > 0 && customer.usedBytes >= customer.trafficLimitBytes;
if ((expired || exhausted) && customer.status === 'active') {
customer.status = 'suspended';
customer.suspendReason = expired ? 'expired' : 'traffic_limit';
customer.updatedAt = nowIso();
suspendCustomerResources(data, customer, 'system:quota');
} else if (customer.status === 'suspended' && customer.suspendReason === 'ip_limit' && !customerBlockReason(data, customer)) {
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
resumeCustomerResources(data, customer, 'system:ip_window');
}
}
const jobCutoff = now - (data.settings.completedJobRetentionDays || 7) * 86400000;
data.jobs = data.jobs.filter((job) => !['completed', 'failed'].includes(job.status) || new Date(job.updatedAt).getTime() > jobCutoff);
const activityCutoff = now - (data.settings.activityRetentionDays || 30) * 86400000;
data.activity = data.activity.filter((event) => new Date(event.at).getTime() > activityCutoff).slice(0, 2000);
pruneAccess(data, now);
return data.chains.filter((item) => item.status === 'redeploy_pending').map((item) => item.id);
});
for (const chainId of redeployIds) {
try { await orchestrator.deployChain(chainId, 'system:redeploy'); }
catch (error) {
console.error(`Automatic redeploy failed for ${chainId}:`, error);
await store.transaction((data) => {
const chain = data.chains.find((item) => item.id === chainId);
if (chain) { chain.status = 'degraded'; chain.lastError = String(error.message || error); chain.updatedAt = nowIso(); }
});
}
}
sessions.prune();
} catch (error) {
console.error('Housekeeping failed:', error);
}
}
async function main() {
await store.init();
// Earlier Agents reported the full x25519 output on parse failures.
// Remove any private key from persisted job/deployment history on upgrade.
if ([...store.data.jobs, ...store.data.deployments, ...store.data.chains, ...store.data.servers].some((item) =>
item.error && item.error !== redactSecrets(item.error) || item.lastError && item.lastError !== redactSecrets(item.lastError) ||
item.engine?.detail && item.engine.detail !== redactSecrets(item.engine.detail))) {
await store.transaction((data) => {
for (const item of [...data.jobs, ...data.deployments, ...data.chains, ...data.servers]) {
if (item.error) item.error = redactSecrets(item.error);
if (item.lastError) item.lastError = redactSecrets(item.lastError);
if (item.engine?.detail) item.engine.detail = redactSecrets(item.engine.detail);
}
});
}
if (store.data.customers.some((item) => !item.subscriptionToken)) {
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
}
if (!store.data.users.length) {
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
await store.transaction((data) => {
data.users.push({
id: id('usr'), username: process.env.NG_ADMIN_USERNAME || 'admin',
passwordHash: hashSecret(password), role: 'owner', status: 'active', createdAt: nowIso()
});
});
if (!process.env.NG_ADMIN_PASSWORD) console.log(`BOOTSTRAP_PASSWORD=${password}`);
}
sessions = new SessionManager(store.data.settings.sessionHours || 12);
orchestrator = new Orchestrator(store);
const server = http.createServer(requestHandler);
server.requestTimeout = 30000;
server.headersTimeout = 15000;
server.listen(PORT, HOST, () => console.log(`NexusGate v${VERSION} listening on http://${HOST}:${PORT}`));
setInterval(housekeeping, 60000).unref();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});