'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, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols'); const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); const { formatSubscription, nodeClientUri } = 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 normalizeNode(body, current = {}, data) { const topology = 'topology' in body ? (body.topology === 'direct' ? 'direct' : 'forward') : (current.topology || 'forward'); const direct = topology === 'direct'; const protocol = 'protocol' in body ? cleanText(body.protocol, 64) : current.protocol; const serverId = 'serverId' in body ? requiredText(body.serverId, '服务器') : current.serverId; const server = data.servers.find((item) => item.id === serverId); if (!server) { const error = new Error('服务器不存在'); error.statusCode = 404; throw error; } const exitServerId = direct ? null : ('exitServerId' in body ? requiredText(body.exitServerId, '出口服务器 ID') : current.exitServerId); if (!direct && !data.servers.some((item) => item.id === exitServerId)) { const error = new Error('出口服务器不存在'); error.statusCode = 404; throw error; } const next = { ...current, name: 'name' in body ? requiredText(body.name, '节点名称') : current.name, serverId, protocol, topology, exitServerId, exitProtocol: direct ? null : ('exitProtocol' in body ? cleanText(body.exitProtocol, 64) : current.exitProtocol), 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), }; const portMode = 'portMode' in body ? (body.portMode === 'fixed' ? 'fixed' : 'random') : (current.portMode || 'random'); next.portMode = portMode; // Only touch the port when the request changes addressing; otherwise keep it. // (portMode 在表单里总是存在,只有真正改变时才重新分配,避免编辑保存时端口乱跳) const portModeChanged = ('portMode' in body) && portMode !== (current.portMode || 'random'); if (portModeChanged || 'port' in body || 'serverId' in body || !current.port) { next.port = selectPort(data, server, portMode, 'port' in body ? body.port : current.port, current.id || null); } if (!direct) { const exitPortMode = 'exitPortMode' in body ? (body.exitPortMode === 'fixed' ? 'fixed' : 'random') : (current.exitPortMode || 'random'); next.exitPortMode = exitPortMode; const exitServer = data.servers.find((item) => item.id === exitServerId); const exitPortModeChanged = ('exitPortMode' in body) && exitPortMode !== (current.exitPortMode || 'random'); if (exitPortModeChanged || 'exitPort' in body || 'exitServerId' in body || !current.exitPort) { next.exitPort = selectPort(data, exitServer, exitPortMode, 'exitPort' in body ? body.exitPort : current.exitPort, current.id || null); } } else { next.exitPortMode = null; next.exitPort = null; } if (direct) validateEntryProtocol(protocol); else validateProtocolPair(protocol, next.exitProtocol); if (isRealityProtocol(protocol) && !next.realityServerName) { const error = new Error('Reality SNI 不能为空'); error.statusCode = 400; throw error; } if (isRealityProtocol(protocol) && (!Number.isInteger(next.realityDestPort) || next.realityDestPort < 1 || next.realityDestPort > 65535)) { const error = new Error('Reality 目标端口无效'); error.statusCode = 400; throw error; } return next; } function publicNode(item) { const { exitCredentials, ssServerKey, ...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.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length }; } function publicCustomer(item) { return { ...item, 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.nodes.some((item) => item.serverId === server.id && ['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) && ['active','deploying'].includes(item.status))) { 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.nodeClients.some((nc) => nc.customerId === customerId && nc.status === 'active' && data.nodes.some((node) => node.id === nc.nodeId && node.serverId === agent.serverId && node.status === 'active'))) 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, nodes: data.nodes.length, activeNodes: data.nodes.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.nodes.filter((item) => ['failed', 'degraded'].includes(item.status)).map(publicNode).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 }); const referencing = data.nodes.filter((item) => item.serverId === serverItem.id || item.exitServerId === serverItem.id); if (referencing.some((item) => item.status === 'removing')) { throw Object.assign(new Error('设备仍有待清理资源。等待 Agent 上线确认清理后,再删除设备'), { statusCode: 409 }); } if (referencing.length) { 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 node of data.nodes.filter((item) => (item.serverId === cleanup.id || item.exitServerId === cleanup.id) && item.status === 'removing')) { const legs = []; if (node.serverId === cleanup.id) legs.push(['node', node.resourceId]); if (node.exitServerId === cleanup.id && forwardNode(node) && node.exitResourceId) legs.push(['node-exit', node.exitResourceId]); for (const [kind, resourceId] of legs) { const pending = data.jobs.some((job) => job.nodeId === node.id && job.action === 'delete_resource' && job.payload && job.payload.resourceId === resourceId && ['queued', 'running'].includes(job.status)); if (pending) continue; queueJob(data, cleanup.id, node.id, 'delete_resource', { resourceId, kind }); 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 }); const residual = data.nodes.filter((item) => item.serverId === server.id || item.exitServerId === server.id); if (residual.some((item) => item.status !== 'removing')) throw Object.assign(new Error('仍有未停用的节点,请先删除关联节点'), { statusCode: 409 }); const residualIds = new Set(residual.map((item) => item.id)); if (data.jobs.some((item) => residualIds.has(item.nodeId) && item.status === 'running')) { throw Object.assign(new Error('设备任务尚在执行,不能强制遗忘'), { statusCode: 409 }); } data.jobs = data.jobs.filter((item) => !residualIds.has(item.nodeId)); data.nodeClients = data.nodeClients.filter((item) => !residualIds.has(item.nodeId)); for (const item of residual) dropRealityKey(data, item); data.nodes = data.nodes.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(async (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') await 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(async (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; await 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.nodeClients.some((item) => item.customerId === customerItem.id && data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) { 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); audit(data, actor, 'delete_customer', customerItem.id); }); sendJson(res, 200, { ok: true }); return true; } if (req.method === 'GET' && pathname === '/api/nodes') { const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete); const visibleIds = new Set(visibleNodes.map((item) => item.id)); const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => { const { credentials, ...safe } = nc; return { ...safe, clientUri: nodeClientUri(store.data, nc) }; }); sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients }); return true; } if (req.method === 'POST' && pathname === '/api/nodes') { const body = await readJson(req); const node = await store.transaction((data) => { const normalized = normalizeNode(body, {}, data); const next = { id: id('nod'), status: 'draft', entryReady: false, exitReady: false, resourceId: id('res'), exitResourceId: id('res'), ...normalized, artifacts: {}, error: null, pendingDelete: false, usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null, createdAt: nowIso(), updatedAt: nowIso(), }; ensureNodeSecrets(next); data.nodes.push(next); audit(data, actor, 'create_node', next.id, { name: next.name }); return structuredClone(next); }); sendJson(res, 201, { node: publicNode(node) }); return true; } const nodeItem = route('/api/nodes/:id', pathname); if (nodeItem && req.method === 'PATCH') { const body = await readJson(req); const node = await store.transaction(async (data) => { const item = data.nodes.find((entry) => entry.id === nodeItem.id); if (!item) throw Object.assign(new Error('节点不存在'), { statusCode: 404 }); if (item.status === 'removing') throw Object.assign(new Error('节点正在移除中,请等待完成后再编辑'), { statusCode: 409 }); const oldServerId = item.serverId; const oldExitServerId = item.exitServerId; const oldForward = forwardNode(item); const oldProtocol = item.protocol; Object.assign(item, normalizeNode(body, item, data)); ensureNodeSecrets(item); // 协议变更可能导致旧凭据长度非法(如 SS2022 密钥长度),重发全部客户端凭据。 if (item.protocol !== oldProtocol) { for (const nc of data.nodeClients.filter((entry) => entry.nodeId === item.id)) { nc.credentials = newNodeClientCredentials(item.protocol); nc.updatedAt = nowIso(); } } // 服务器变更:先在旧设备上清理资源。 if (item.serverId !== oldServerId) { queueJob(data, oldServerId, item.id, 'delete_resource', { resourceId: item.resourceId, kind: 'node' }); item.entryReady = false; } if (oldForward && (!forwardNode(item) || item.exitServerId !== oldExitServerId)) { queueJob(data, oldExitServerId, item.id, 'delete_resource', { resourceId: item.exitResourceId, kind: 'node-exit' }); item.exitReady = false; } // 运行中的节点直接重建下发(对标 3x-ui 保存即生效)。 if (['active', 'deploying', 'failed', 'degraded'].includes(item.status)) { await rebuildNode(data, item, { includeExit: true }); } item.updatedAt = nowIso(); audit(data, actor, 'update_node', item.id, { name: item.name }); return structuredClone(item); }); sendJson(res, 200, { node: publicNode(node) }); return true; } if (nodeItem && req.method === 'DELETE') { if (store.data.jobs.some((job) => job.nodeId === nodeItem.id && job.status === 'running')) { throw Object.assign(new Error('节点任务正在设备上执行,请稍后刷新再删除'), { statusCode: 409 }); } await orchestrator.removeNode(nodeItem.id, actor, true); sendJson(res, 200, { ok: true }); return true; } const deploy = route('/api/nodes/:id/deploy', pathname); if (deploy && req.method === 'POST') { await orchestrator.deployNode(deploy.id, actor); sendJson(res, 202, { ok: true }); return true; } const remove = route('/api/nodes/:id/remove', pathname); if (remove && req.method === 'POST') { await orchestrator.removeNode(remove.id, actor, false); sendJson(res, 202, { ok: true }); return true; } const repair = route('/api/nodes/:id/repair', pathname); if (repair && req.method === 'POST') { await orchestrator.repairNode(repair.id, actor); sendJson(res, 202, { ok: true }); return true; } const assign = route('/api/nodes/:id/assign', pathname); if (assign && req.method === 'POST') { const body = await readJson(req); const nodeClient = await orchestrator.assignCustomer(assign.id, requiredText(body.customerId, '客户'), actor); const { credentials, ...safe } = nodeClient; sendJson(res, 200, { nodeClient: safe }); return true; } const unassign = route('/api/nodes/:id/unassign', pathname); if (unassign && req.method === 'POST') { const body = await readJson(req); await orchestrator.unassignCustomer(unassign.id, requiredText(body.customerId, '客户'), actor); sendJson(res, 200, { ok: true }); 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; } function formatBytes(bytes) { if (!bytes || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let v = bytes, i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(2)}${units[i]}`; } function buildSubscriptionInfoPage(data, customer, req) { const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const used = customer.usedBytes || 0; const up = customer.usedUplinkBytes || 0; const down = customer.usedDownlinkBytes || 0; const total = customer.trafficLimitBytes || 0; const remaining = total > 0 ? Math.max(0, total - used) : -1; const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0; const statusText = customer.status === 'active' ? '启用' : '停用'; const statusClass = customer.status === 'active' ? 'on' : 'off'; const expiresText = customer.expiresAt ? new Date(customer.expiresAt).toLocaleString('zh-CN') : '永不过期'; const lastOnline = customer.lastOnlineAt ? new Date(customer.lastOnlineAt).toLocaleString('zh-CN') : '从未'; // 该用户的活跃节点分配 const assignments = data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active'); const host = req.headers.host || ''; const proto = req.headers['x-forwarded-proto'] || 'http'; const subUrl = `${proto}://${host}/s/${customer.subscriptionToken}/auto`; const nodeRows = assignments.map((nc) => { const node = data.nodes.find((n) => n.id === nc.nodeId); if (!node || node.status !== 'active') return ''; const server = data.servers.find((s) => s.id === node.serverId); // clientUri 只在 /api/nodes 接口里实时计算,DB 里没有存,这里必须同样实时算 const uri = nodeClientUri(data, nc) || ''; if (!uri) return ''; // 协议标签 const protoLabel = node.protocol.includes('vless') ? 'Vless' : node.protocol.includes('shadowsock') ? 'Shadowsocks' : node.protocol.includes('socks') ? 'Socks' : node.protocol.includes('hysteria') ? 'Hysteria2' : 'AnyTLS'; const netLabel = 'TCP'; const secLabel = node.protocol.includes('reality') ? 'REALITY' : node.protocol.includes('tls') ? 'TLS' : ''; const nodeName = `${node.name}-${customer.name}`; return `
${esc(protoLabel)} ${netLabel} ${secLabel ? `${secLabel}` : ''} ${esc(nodeName)}
`; }).join(''); const allUris = assignments.map((nc) => nodeClientUri(data, nc)).filter(Boolean).join('\n'); return ` 订阅信息 - ${esc(customer.name)}

订阅信息 ${esc(customer.subscriptionToken.slice(0,16))}

订阅 ID${esc(customer.subscriptionToken.slice(0,16))}
用户${esc(customer.name)}
状态${statusText}
已下载${formatBytes(down)}
已上传${formatBytes(up)}
使用情况${formatBytes(used)}
总配额${total > 0 ? formatBytes(total) : '无限制'}
剩余${remaining >= 0 ? formatBytes(remaining) : '无限制'}
上次在线${esc(lastOnline)}
到期${esc(expiresText)}
${total > 0 ? `
${formatBytes(used)} / ${formatBytes(total)}
${formatBytes(remaining)} 剩余${pct.toFixed(1)}%
` : ''}
订阅信息
SUB${esc(subUrl)}
复制链接
复制全部配置
${nodeRows || '

暂无可用节点

'}

`; } 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|info)$/.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; } // 订阅信息 HTML 页面(3x-ui 风格) if (match[2] === 'info') { const html = buildSubscriptionInfoPage(store.data, customer, req); res.writeHead(200, { ...headers, 'content-type': 'text/html; charset=utf-8' }); res.end(html); 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.nodeClients.some((nc) => nc.customerId === customer.id && nc.status === 'active' && store.data.nodes.some((node) => node.id === nc.nodeId && ['deploying','removing'].includes(node.status)) && store.data.jobs.some((job) => job.nodeId === nc.nodeId && ['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 { await store.transaction(async (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 node = data.nodes.find((item) => item.id === job.nodeId); if (node) { const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind)); node.status = kind === 'node-exit' ? 'degraded' : 'failed'; node.error = job.error; node.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(); await 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(); await 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 null; }); 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.nodes, ...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.nodes, ...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); });