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