Files
NexusGate/server.js
T
Muse 084afeb8f4 3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
2026-10-19 22:23:40 +08:00

1143 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const http = require('node:http');
const path = require('node:path');
const crypto = require('node:crypto');
const net = require('node:net');
const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = 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));
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
});