Agents (Go + Node) now report the resource IDs they actually hold in each heartbeat. When an expected resource is missing locally (e.g. the node's /etc/nexusgate state was wiped and only the agent restarted, so no re-enrollment happens), the panel re-queues applies for just the affected nodes via reconcileFromHeartbeat: gated on no queued/running jobs for the server (in-flight jobs are never failed by this path) and throttled to one trigger per server per 120s, audited as system:heartbeat. reconcileServer gains optional onlyNodeIds / failInFlight parameters; the enrollment call is unchanged. Heartbeats without a resources array (old agents) behave exactly as before.
1152 lines
64 KiB
JavaScript
1152 lines
64 KiB
JavaScript
'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;
|
||
}
|
||
});
|
||
// Agents that report their locally held resource IDs get automatic
|
||
// re-dispatch of anything the panel expects but the node lost (e.g. a
|
||
// wiped /etc/nexusgate with no re-enrollment). Reports without the field
|
||
// (older agents) skip this entirely.
|
||
if (Array.isArray(body.resources)) {
|
||
try { await orchestrator.reconcileFromHeartbeat(agent.serverId, body.resources); }
|
||
catch (error) { console.error('Heartbeat reconciliation failed:', error); }
|
||
}
|
||
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 `<div class="node-row">
|
||
<span class="badge proto">${esc(protoLabel)}</span>
|
||
<span class="badge net">${netLabel}</span>
|
||
${secLabel ? `<span class="badge sec">${secLabel}</span>` : ''}
|
||
<span class="node-name">${esc(nodeName)}</span>
|
||
<span class="spacer"></span>
|
||
<button class="icon-btn" onclick="copyText('${esc(uri)}', this)" title="复制链接">📋</button>
|
||
<button class="icon-btn" onclick="showQR('${esc(uri)}')" title="二维码">🔳</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const allUris = assignments.map((nc) => nodeClientUri(data, nc)).filter(Boolean).join('\n');
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>订阅信息 - ${esc(customer.name)}</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:#0f1115;color:#e8eaf0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;min-height:100vh;display:flex;justify-content:center;padding:24px 12px}
|
||
.card{width:100%;max-width:640px;background:#161a22;border:1px solid #262c38;border-radius:16px;padding:28px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||
.card h1{font-size:20px;margin-bottom:4px;display:flex;align-items:center;gap:10px}
|
||
.card h1 .tag{background:#2a3040;color:#9fb0d0;font-size:12px;padding:3px 10px;border-radius:6px;font-weight:400}
|
||
.info-table{width:100%;border-collapse:collapse;margin:20px 0;border:1px solid #2a3040;border-radius:12px;overflow:hidden}
|
||
.info-table td{padding:12px 16px;border-bottom:1px solid #232936;font-size:14px}
|
||
.info-table tr:last-child td{border-bottom:none}
|
||
.info-table td:first-child{color:#8b94a7;width:140px;background:#1a1f2a}
|
||
.badge-on{background:#123a24;color:#4ade80;font-size:12px;padding:3px 12px;border-radius:6px}
|
||
.badge-off{background:#3a1a1a;color:#f87171;font-size:12px;padding:3px 12px;border-radius:6px}
|
||
.progress-wrap{background:#1a1f2a;border:1px solid #2a3040;border-radius:12px;padding:18px;margin:16px 0}
|
||
.progress-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
|
||
.progress-top .used{font-size:22px;font-weight:700}
|
||
.progress-top .total{color:#8b94a7;font-size:14px}
|
||
.progress-top .days{background:#1e2a4a;color:#6ea8fe;font-size:12px;padding:4px 12px;border-radius:8px}
|
||
.progress-bar{height:10px;background:#2a3040;border-radius:5px;overflow:hidden}
|
||
.progress-bar .fill{height:100%;background:linear-gradient(90deg,#34d399,#10b981);border-radius:5px;transition:width .3s}
|
||
.progress-bottom{display:flex;justify-content:space-between;color:#8b94a7;font-size:13px;margin-top:8px}
|
||
.section-title{text-align:center;color:#c8d0e0;font-size:15px;margin:24px 0 12px;position:relative}
|
||
.section-title::before,.section-title::after{content:'';position:absolute;top:50%;width:32%;height:1px;background:#2a3040}
|
||
.section-title::before{left:0}.section-title::after{right:0}
|
||
.sub-row{display:flex;align-items:center;gap:10px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
|
||
.sub-row .sub-badge{background:#123a24;color:#4ade80;font-size:12px;font-weight:700;padding:4px 10px;border-radius:6px}
|
||
.sub-row .sub-url{flex:1;font-size:13px;color:#c8d0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.icon-btn{background:#2a3040;border:none;color:#c8d0e0;width:34px;height:34px;border-radius:8px;cursor:pointer;font-size:16px}
|
||
.icon-btn:hover{background:#354052}
|
||
.node-row{display:flex;align-items:center;gap:8px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
|
||
.badge{font-size:11px;font-weight:700;padding:4px 10px;border-radius:6px}
|
||
.badge.proto{background:#1e2a4a;color:#6ea8fe}
|
||
.badge.net{background:#3a2f1a;color:#fbbf24}
|
||
.badge.sec{background:#2a1a3a;color:#c084fc}
|
||
.node-name{font-size:14px;color:#e8eaf0}
|
||
.spacer{flex:1}
|
||
.copy-all{display:flex;align-items:center;justify-content:space-between;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px;font-size:14px}
|
||
.client-btns{display:flex;gap:16px;justify-content:center;margin-top:20px}
|
||
.client-btns button{background:#2563eb;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:15px;cursor:pointer}
|
||
.client-btns button:hover{background:#1d4ed8}
|
||
#qr-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);align-items:center;justify-content:center;z-index:100}
|
||
#qr-modal .box{background:#fff;padding:24px;border-radius:12px;text-align:center}
|
||
#qr-modal img{width:220px;height:220px}
|
||
#toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:10px 24px;border-radius:8px;display:none;z-index:200;font-size:14px}
|
||
</style></head><body>
|
||
<div class="card">
|
||
<h1>订阅信息 <span class="tag">${esc(customer.subscriptionToken.slice(0,16))}</span></h1>
|
||
<table class="info-table">
|
||
<tr><td>订阅 ID</td><td>${esc(customer.subscriptionToken.slice(0,16))}</td></tr>
|
||
<tr><td>用户</td><td>${esc(customer.name)}</td></tr>
|
||
<tr><td>状态</td><td><span class="badge-${statusClass}">${statusText}</span></td></tr>
|
||
<tr><td>已下载</td><td>${formatBytes(down)}</td></tr>
|
||
<tr><td>已上传</td><td>${formatBytes(up)}</td></tr>
|
||
<tr><td>使用情况</td><td>${formatBytes(used)}</td></tr>
|
||
<tr><td>总配额</td><td>${total > 0 ? formatBytes(total) : '无限制'}</td></tr>
|
||
<tr><td>剩余</td><td>${remaining >= 0 ? formatBytes(remaining) : '无限制'}</td></tr>
|
||
<tr><td>上次在线</td><td>${esc(lastOnline)}</td></tr>
|
||
<tr><td>到期</td><td>${esc(expiresText)}</td></tr>
|
||
</table>
|
||
${total > 0 ? `<div class="progress-wrap">
|
||
<div class="progress-top"><div><span class="used">${formatBytes(used)}</span> <span class="total">/ ${formatBytes(total)}</span></div></div>
|
||
<div class="progress-bar"><div class="fill" style="width:${pct.toFixed(1)}%"></div></div>
|
||
<div class="progress-bottom"><span>${formatBytes(remaining)} 剩余</span><span>${pct.toFixed(1)}%</span></div>
|
||
</div>` : ''}
|
||
<div class="section-title">订阅信息</div>
|
||
<div class="sub-row"><span class="sub-badge">SUB</span><span class="sub-url">${esc(subUrl)}</span>
|
||
<button class="icon-btn" onclick="copyText('${esc(subUrl)}', this)">📋</button>
|
||
<button class="icon-btn" onclick="showQR('${esc(subUrl)}')">🔳</button></div>
|
||
<div class="section-title">复制链接</div>
|
||
<div class="copy-all"><span>复制全部配置</span><button class="icon-btn" onclick="copyText(\`${esc(allUris)}\`, this)">📋</button></div>
|
||
${nodeRows || '<p style="text-align:center;color:#8b94a7;padding:20px">暂无可用节点</p>'}
|
||
<div class="client-btns"><button onclick="alert('请在应用商店搜索 Shadowrocket / Clash 等客户端')">📱 Android ▾</button><button onclick="alert('请在 App Store 搜索 Shadowrocket / Stash 等客户端')">🍎 iOS ▾</button></div>
|
||
</div>
|
||
<div id="qr-modal"><div class="box"><div id="qr-code"></div><br><button onclick="document.getElementById('qr-modal').style.display='none'" style="padding:8px 24px;border-radius:8px;border:none;background:#2563eb;color:#fff;cursor:pointer">关闭</button></div></div>
|
||
<div id="toast"></div>
|
||
<script>
|
||
function copyText(t, btn){(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(()=>toast('已复制')).catch(()=>{const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');toast('已复制')}catch(e){toast('复制失败')}document.body.removeChild(ta)})}
|
||
function toast(m){const e=document.getElementById('toast');e.textContent=m;e.style.display='block';setTimeout(()=>e.style.display='none',1800)}
|
||
function showQR(t){const m=document.getElementById('qr-modal');document.getElementById('qr-code').innerHTML='<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data='+encodeURIComponent(t)+'">';m.style.display='flex';m.onclick=(e)=>{if(e.target===m)m.style.display='none'}}
|
||
</script></body></html>`;
|
||
}
|
||
|
||
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);
|
||
});
|