3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据

- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
This commit is contained in:
Muse
2026-10-19 22:23:40 +08:00
parent 1678f766ac
commit 084afeb8f4
35 changed files with 2142 additions and 1749 deletions
+331 -154
View File
@@ -7,9 +7,9 @@ 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 { 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');
@@ -83,47 +83,63 @@ function normalizeServer(body, current = {}) {
return next;
}
function normalizeChain(body, current = {}) {
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,
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)),
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)
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; }
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 publicDeployment(item) {
const { credentials, clientTemplate, ...safe } = item;
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.deployments.filter((entry) => entry.serverId === item.id && entry.archived && entry.status !== 'deleted').length };
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
}
function publicCustomer(item) {
return { ...item, pendingCleanup: store.data.deployments.filter((entry) => entry.customerId === item.id && entry.archived && entry.status !== 'deleted').length,
return { ...item,
observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(store.data, item.id).length };
}
@@ -303,11 +319,10 @@ async function handleAgent(req, res, pathname) {
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 && 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;
@@ -370,8 +385,8 @@ async function handleAgent(req, res, pathname) {
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;
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() });
@@ -408,12 +423,12 @@ async function handleAdminApi(req, res, pathname) {
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,
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.deployments.filter((item) => !item.archived && item.status === 'failed').map(publicDeployment).slice(0, 10)
degraded: data.nodes.filter((item) => ['failed', 'degraded'].includes(item.status)).map(publicNode).slice(0, 10)
});
return true;
}
@@ -475,11 +490,12 @@ async function handleAdminApi(req, res, pathname) {
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 });
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 (data.chains.some((item) => item.exitServerId === serverItem.id || item.relayServerIds.includes(serverItem.id))) {
throw Object.assign(new Error('设备仍被线路引用。请先编辑或删除关联线路'), { 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';
@@ -509,12 +525,17 @@ async function handleAdminApi(req, res, pathname) {
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;
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;
@@ -530,17 +551,15 @@ async function handleAdminApi(req, res, pathname) {
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 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.deploymentId) && item.status === 'running')) {
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.deploymentId));
data.deployments = data.deployments.filter((item) => !residualIds.has(item.id));
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 });
@@ -593,7 +612,7 @@ async function handleAdminApi(req, res, pathname) {
const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') {
const body = await readJson(req);
const customer = await store.transaction((data) => {
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';
@@ -617,7 +636,7 @@ async function handleAdminApi(req, res, pathname) {
item.status = 'active'; item.suspendReason = null;
}
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
if (!wasActive && item.status === 'active') resumeCustomerResources(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);
@@ -627,12 +646,12 @@ async function handleAdminApi(req, res, pathname) {
}
const resetUsage = route('/api/customers/:id/reset-usage', pathname);
if (resetUsage && req.method === 'POST') {
await store.transaction((data) => {
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; resumeCustomerResources(data, item, actor);
item.status = 'active'; item.suspendReason = null; await resumeCustomerResources(data, item, actor);
}
item.updatedAt = nowIso(); audit(data, actor, 'reset_customer_usage', item.id);
});
@@ -654,100 +673,125 @@ async function handleAdminApi(req, res, pathname) {
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 });
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);
// 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 });
audit(data, actor, 'delete_customer', customerItem.id);
});
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) });
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/chains') {
if (req.method === 'POST' && pathname === '/api/nodes') {
const body = await readJson(req);
const chain = await store.transaction((data) => {
const node = await store.transaction((data) => {
const normalized = normalizeNode(body, {}, data);
const next = {
...normalizeChain(body), id: id('chn'), status: 'draft', generation: 0,
createdAt: nowIso(), updatedAt: nowIso(), redeployPending: false
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(),
};
data.chains.push(next);
audit(data, actor, 'create_chain', next.id, { name: next.name });
ensureNodeSecrets(next);
data.nodes.push(next);
audit(data, actor, 'create_node', next.id, { name: next.name });
return structuredClone(next);
});
sendJson(res, 201, { chain });
sendJson(res, 201, { node: publicNode(node) });
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 nodeItem = route('/api/nodes/:id', pathname);
if (nodeItem && 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();
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();
}
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;
// 服务器变更:先在旧设备上清理资源。
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, { ok: true, cleanupPending });
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;
}
@@ -779,8 +823,144 @@ async function handleAdminApi(req, res, pathname) {
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)$/.exec(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; }
@@ -799,15 +979,22 @@ async function handleSubscription(req, res, pathname) {
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.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)));
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 ? '节点正在恢复,请稍后更新订阅' : '当前没有部署成功的入口节点,请在“转发与节点”恢复原节点或部署线路' },
sendJson(res, 503, { message:restoring ? '节点正在恢复,请稍后更新订阅' : '当前没有部署成功的入口节点,请在“转发与节点”部署节点' },
{ ...headers, ...(restoring ? { 'retry-after':'15' } : {}) }); return true;
}
const body = Buffer.from(result.body);
@@ -868,7 +1055,7 @@ async function requestHandler(req, res) {
async function housekeeping() {
try {
const redeployIds = await store.transaction((data) => {
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';
@@ -878,11 +1065,11 @@ async function housekeeping() {
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(); }
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();
}
}
}
@@ -893,10 +1080,10 @@ async function housekeeping() {
customer.status = 'suspended';
customer.suspendReason = expired ? 'expired' : 'traffic_limit';
customer.updatedAt = nowIso();
suspendCustomerResources(data, customer, 'system:quota');
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();
resumeCustomerResources(data, customer, 'system:ip_window');
await resumeCustomerResources(data, customer, 'system:ip_window');
}
}
const jobCutoff = now - (data.settings.completedJobRetentionDays || 7) * 86400000;
@@ -904,18 +1091,8 @@ async function housekeeping() {
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);
return null;
});
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);
@@ -926,11 +1103,11 @@ 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) =>
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.deployments, ...data.chains, ...data.servers]) {
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);