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:
+316
-302
@@ -3,8 +3,9 @@
|
||||
const crypto = require('node:crypto');
|
||||
const { redactSecrets } = require('./redact');
|
||||
const {
|
||||
newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri,
|
||||
validateEntryProtocol, validateProtocolPair, isRealityProtocol
|
||||
buildNodeResource, buildNodeExitResource, nodeEmail,
|
||||
newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
|
||||
SHADOWSOCKS_METHODS,
|
||||
} = require('./protocols');
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
@@ -19,31 +20,42 @@ function assertFound(value, label) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function forwardNode(node) { return (node.topology || 'forward') === 'forward'; }
|
||||
|
||||
function tagForNode(node) {
|
||||
return `ng-${node.id.replace(/[^a-zA-Z0-9]/g, '').slice(-18)}`;
|
||||
}
|
||||
|
||||
function nodePortInUse(data, serverId, port, excludeNodeId = null) {
|
||||
return data.nodes.some((n) => n.id !== excludeNodeId &&
|
||||
((n.serverId === serverId && Number(n.port) === Number(port)) ||
|
||||
(n.exitServerId === serverId && Number(n.exitPort) === Number(port))));
|
||||
}
|
||||
|
||||
function allocatePort(data, server) {
|
||||
const start = Number(server.portRangeStart || 20000);
|
||||
const end = Number(server.portRangeEnd || 50000);
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || start > end) throw new Error(`服务器 ${server.name} 的端口范围无效`);
|
||||
const used = new Set(data.deployments.filter((item) => item.serverId === server.id && item.status !== 'deleted').map((item) => item.port));
|
||||
const span = end - start + 1;
|
||||
const offset = crypto.randomInt(span);
|
||||
for (let i = 0; i < span; i += 1) {
|
||||
const candidate = start + ((offset + i) % span);
|
||||
if (!used.has(candidate)) return candidate;
|
||||
if (!nodePortInUse(data, server.id, candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`服务器 ${server.name} 没有可用端口`);
|
||||
}
|
||||
|
||||
function selectPort(data, server, mode, requested) {
|
||||
function selectPort(data, server, mode, requested, excludeNodeId = null) {
|
||||
if (mode !== 'fixed' || !requested) return allocatePort(data, server);
|
||||
const port = Number(requested);
|
||||
const conflict = data.deployments.some((item) => item.serverId === server.id && item.status !== 'deleted' && Number(item.port) === port);
|
||||
if (conflict) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; }
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) { const error = new Error(`服务器 ${server.name} 的固定端口无效`); error.statusCode = 400; throw error; }
|
||||
if (nodePortInUse(data, server.id, port, excludeNodeId)) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; }
|
||||
return port;
|
||||
}
|
||||
|
||||
function queueJob(data, serverId, deploymentId, action, payload) {
|
||||
function queueJob(data, serverId, nodeId, action, payload) {
|
||||
const job = {
|
||||
id: id('job'), serverId, deploymentId, action, payload, status: 'queued', attempts: 0,
|
||||
id: id('job'), serverId, nodeId, action, payload, status: 'queued', attempts: 0,
|
||||
createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null
|
||||
};
|
||||
data.jobs.push(job);
|
||||
@@ -55,263 +67,287 @@ function audit(data, actor, action, target, detail = {}) {
|
||||
data.activity = data.activity.slice(0, 2000);
|
||||
}
|
||||
|
||||
function tagFor(deployment) {
|
||||
return `ng-${deployment.id.replace(/[^a-zA-Z0-9]/g, '').slice(-18)}`;
|
||||
function activeNodeClients(data, node) {
|
||||
return data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active');
|
||||
}
|
||||
|
||||
function currentDeployments(data, chain) {
|
||||
const all = data.deployments.filter((item) => item.chainId === chain.id);
|
||||
if (!chain.generation) return all;
|
||||
return all.filter((item) => Number(item.generation || 0) === Number(chain.generation));
|
||||
}
|
||||
|
||||
function suspendCustomerResources(data, customer, actor = 'system') {
|
||||
let count = 0;
|
||||
for (const deployment of data.deployments.filter((item) => item.customerId === customer.id && !item.archived &&
|
||||
item.status !== 'deleted' && (item.status !== 'removing' || item.suspendedByCustomer))) {
|
||||
if (deployment.status === 'active') {
|
||||
const pending = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued','running'].includes(job.status));
|
||||
if (!pending) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId:deployment.resourceId });
|
||||
deployment.status = 'removing'; deployment.updatedAt = nowIso(); count += 1;
|
||||
}
|
||||
deployment.suspendedByCustomer = true;
|
||||
}
|
||||
for (const chain of data.chains.filter((item) => item.customerIds.includes(customer.id))) {
|
||||
if (['active','deploying','degraded','changes_pending'].includes(chain.status)) {
|
||||
if (chain.status === 'changes_pending') chain.pausedChangesPending = true;
|
||||
chain.status = 'suspending'; chain.updatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
audit(data, actor, 'suspend_customer_resources', customer.id, { count });
|
||||
return count;
|
||||
}
|
||||
|
||||
function resumeCustomerResources(data, customer, actor = 'admin', includeLegacy = false, onlyChainId = null) {
|
||||
let count = 0;
|
||||
for (const chain of data.chains.filter((item) => item.customerIds.includes(customer.id) && (!onlyChainId || item.id === onlyChainId))) {
|
||||
if (chain.pausedChangesPending || ['removing','redeploying','redeploy_pending','changes_pending'].includes(chain.status)) continue;
|
||||
const deployments = currentDeployments(data, chain).filter((item) => item.customerId === customer.id && !item.archived &&
|
||||
(item.suspendedByCustomer || (includeLegacy && ['deleted','suspended'].includes(item.status))));
|
||||
// A previously released port may now belong to another route. Never overwrite it.
|
||||
for (const deployment of deployments) {
|
||||
const occupied = data.deployments.some((item) => item.id !== deployment.id && item.serverId === deployment.serverId &&
|
||||
item.status !== 'deleted' && Number(item.port) === Number(deployment.port));
|
||||
if (occupied) { const error = new Error(`原端口 ${deployment.port} 已被其他线路使用,请编辑线路并重新部署`); error.statusCode = 409; throw error; }
|
||||
}
|
||||
for (const deployment of deployments) {
|
||||
for (const job of data.jobs.filter((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && item.status === 'queued')) {
|
||||
job.status = 'failed'; job.error = '客户恢复,清理任务已取消'; job.updatedAt = nowIso();
|
||||
}
|
||||
const removing = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && item.status === 'running');
|
||||
deployment.suspendedByCustomer = false;
|
||||
if (deployment.status === 'removing' && !removing) deployment.status = 'active';
|
||||
else if (['deleted','suspended','removing','failed'].includes(deployment.status)) {
|
||||
queueJob(data, deployment.serverId, deployment.id, 'apply_resource', { resource:resourceForDeployment(data, deployment) });
|
||||
deployment.status = 'queued'; deployment.error = null; count += 1;
|
||||
}
|
||||
deployment.updatedAt = nowIso();
|
||||
}
|
||||
if (deployments.length) {
|
||||
const related = currentDeployments(data, chain);
|
||||
chain.status = related.every((item) => item.status === 'active') ? 'active' : 'deploying';
|
||||
chain.updatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
if (count) audit(data, actor, 'resume_customer_resources', customer.id, { count });
|
||||
return count;
|
||||
}
|
||||
|
||||
function resourceForDeployment(data, deployment) {
|
||||
const chain = assertFound(data.chains.find((item) => item.id === deployment.chainId), '线路');
|
||||
const customer = assertFound(data.customers.find((item) => item.id === deployment.customerId), '客户');
|
||||
const server = assertFound(data.servers.find((item) => item.id === deployment.serverId), '服务器');
|
||||
const common = {
|
||||
resourceId: deployment.resourceId, tagPrefix: tagFor(deployment), port: deployment.port,
|
||||
credentials: deployment.credentials, customer, networkMode: chain.networkMode || 'ipv4', tlsDomain: server.tlsDomain
|
||||
};
|
||||
if (deployment.role === 'exit') return buildExitResource({ ...common, protocol: deployment.protocol });
|
||||
const reality = deployment.clientTemplate && deployment.clientTemplate.reality;
|
||||
if (deployment.role === 'direct') return buildDirectResource({ ...common, protocol: deployment.protocol, reality });
|
||||
const exitDeployment = assertFound(data.deployments.find((item) => item.id === deployment.exitDeploymentId), '出口部署');
|
||||
const exitServer = assertFound(data.servers.find((item) => item.id === exitDeployment.serverId), '出口服务器');
|
||||
return buildRelayResource({
|
||||
...common, protocol: deployment.protocol, exitProtocol: exitDeployment.protocol,
|
||||
exitServer, exitPort: exitDeployment.port, reality
|
||||
async function resourceForNode(data, node, clients) {
|
||||
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const bindAddress = await resolveBindAddress(networkMode, server);
|
||||
const exitServer = forwardNode(node)
|
||||
? assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器')
|
||||
: null;
|
||||
return buildNodeResource({
|
||||
resourceId: node.resourceId, tagPrefix: tagForNode(node), node,
|
||||
nodeClients: clients || activeNodeClients(data, node), server, bindAddress, exitServer,
|
||||
});
|
||||
}
|
||||
|
||||
function createEntryDeployment(data, chain, customer, entryServer, credentials, exitDeployment) {
|
||||
const port = selectPort(data, entryServer, chain.relayPortMode, chain.relayPort);
|
||||
const direct = (chain.topology || 'forward') === 'direct';
|
||||
const deployment = {
|
||||
id: id('dep'), chainId: chain.id, generation: chain.generation, customerId: customer.id,
|
||||
serverId: entryServer.id, role: direct ? 'direct' : 'relay', protocol: chain.relayProtocol,
|
||||
port, status: 'queued', resourceId: id('res'), credentials,
|
||||
...(exitDeployment ? { exitDeploymentId: exitDeployment.id } : {}),
|
||||
createdAt: nowIso(), updatedAt: nowIso(), clientUri: null
|
||||
};
|
||||
if (isRealityProtocol(chain.relayProtocol)) {
|
||||
deployment.clientTemplate = { reality: {
|
||||
keyId: `reality-${deployment.id}`, serverName: chain.realityServerName || 'www.tesla.com',
|
||||
destPort: Number(chain.realityDestPort || 443)
|
||||
} };
|
||||
async function resourceForNodeExit(data, node) {
|
||||
const server = assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const bindAddress = await resolveBindAddress(networkMode, server);
|
||||
return buildNodeExitResource({
|
||||
resourceId: node.exitResourceId, tagPrefix: tagForNode(node), node, server, bindAddress,
|
||||
});
|
||||
}
|
||||
|
||||
function cancelQueuedDeletes(data, nodeId, reason) {
|
||||
let cancelled = false;
|
||||
for (const job of data.jobs.filter((item) => item.nodeId === nodeId && item.action === 'delete_resource' && item.status === 'queued')) {
|
||||
job.status = 'failed'; job.error = reason; job.updatedAt = nowIso(); cancelled = true;
|
||||
}
|
||||
data.deployments.push(deployment);
|
||||
queueJob(data, entryServer.id, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) });
|
||||
return deployment;
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// Rebuild a node's entry resource from its current client list and queue the
|
||||
// apply job. With no active clients the resource is removed and the node
|
||||
// returns to draft instead of shipping an empty (and for some protocols
|
||||
// invalid) inbound. autoRemoving distinguishes this automatic teardown from an
|
||||
// explicit user undeploy so resume/assign can revive it without a full cycle.
|
||||
async function rebuildNode(data, node, { includeExit = false } = {}) {
|
||||
const clients = activeNodeClients(data, node);
|
||||
if (!clients.length) {
|
||||
if (node.status !== 'draft' && node.status !== 'removing') {
|
||||
queueJob(data, node.serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' });
|
||||
if (forwardNode(node) && node.exitResourceId) {
|
||||
queueJob(data, node.exitServerId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' });
|
||||
}
|
||||
node.status = 'removing'; node.autoRemoving = true; node.pendingDelete = false; node.error = null;
|
||||
}
|
||||
node.updatedAt = nowIso();
|
||||
return;
|
||||
}
|
||||
node.autoRemoving = false;
|
||||
queueJob(data, node.serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' });
|
||||
if (forwardNode(node) && includeExit) {
|
||||
queueJob(data, node.exitServerId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
|
||||
}
|
||||
node.status = 'deploying'; node.error = null; node.updatedAt = nowIso();
|
||||
}
|
||||
|
||||
function deriveNodeStatus(node) {
|
||||
if (!node.entryReady) { if (node.status !== 'failed') node.status = 'deploying'; return; }
|
||||
if (forwardNode(node) && !node.exitReady) { if (node.status !== 'degraded') node.status = 'deploying'; return; }
|
||||
node.status = 'active'; node.error = null;
|
||||
}
|
||||
|
||||
async function suspendCustomerResources(data, customer, actor = 'system') {
|
||||
const affected = new Set();
|
||||
for (const nc of data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active')) {
|
||||
nc.status = 'suspended'; nc.updatedAt = nowIso(); affected.add(nc.nodeId);
|
||||
}
|
||||
for (const nodeId of affected) {
|
||||
const node = data.nodes.find((n) => n.id === nodeId);
|
||||
if (node && node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
|
||||
}
|
||||
audit(data, actor, 'suspend_customer_resources', customer.id, { nodes: affected.size });
|
||||
return affected.size;
|
||||
}
|
||||
|
||||
async function resumeCustomerResources(data, customer, actor = 'admin') {
|
||||
const affected = new Set();
|
||||
for (const nc of data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'suspended')) {
|
||||
nc.status = 'active'; nc.updatedAt = nowIso(); affected.add(nc.nodeId);
|
||||
}
|
||||
for (const nodeId of affected) {
|
||||
const node = data.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) continue;
|
||||
// A manual undeploy is respected; only automatic teardowns are revived.
|
||||
if (node.status === 'removing' && !node.autoRemoving) continue;
|
||||
if (node.status === 'draft' && !node.autoRemoving) continue;
|
||||
if (node.status === 'removing' && node.autoRemoving) {
|
||||
const deleteRunning = data.jobs.some((job) => job.nodeId === nodeId && job.action === 'delete_resource' && job.status === 'running');
|
||||
cancelQueuedDeletes(data, nodeId, '客户恢复,清理任务已取消');
|
||||
node.autoRemoving = false;
|
||||
if (!deleteRunning) {
|
||||
// The resource never left the agent; restore service without re-applying.
|
||||
deriveNodeStatus(node);
|
||||
node.updatedAt = nowIso();
|
||||
continue;
|
||||
}
|
||||
// A delete is already running; queue the apply behind it.
|
||||
}
|
||||
await rebuildNode(data, node, { includeExit: forwardNode(node) && !node.exitReady });
|
||||
}
|
||||
audit(data, actor, 'resume_customer_resources', customer.id, { nodes: affected.size });
|
||||
return affected.size;
|
||||
}
|
||||
|
||||
// Shared delta/epoch accounting used for per-customer and per-node counters.
|
||||
// holder: { usageCounters, retiredUsageEpochs }. Returns null when the sample
|
||||
// must be skipped (retired epoch or out-of-order replay).
|
||||
function applyDelta(holder, uplink, downlink, sampleEpoch) {
|
||||
const previous = holder.usageCounters || { uplink: 0, downlink: 0 };
|
||||
const retiredEpochs = Array.isArray(holder.retiredUsageEpochs) ? holder.retiredUsageEpochs : [];
|
||||
if (sampleEpoch && retiredEpochs.includes(sampleEpoch)) return null;
|
||||
const restarted = Boolean(sampleEpoch && previous.epoch && sampleEpoch !== previous.epoch);
|
||||
if (!restarted && sampleEpoch && previous.epoch && !sampleEpoch.endsWith(':unknown') &&
|
||||
(uplink < previous.uplink || downlink < previous.downlink)) return null;
|
||||
// Counters reset when the engine restarts. Replayed reports have zero delta.
|
||||
const upDelta = !restarted && uplink >= previous.uplink ? uplink - previous.uplink : uplink;
|
||||
const downDelta = !restarted && downlink >= previous.downlink ? downlink - previous.downlink : downlink;
|
||||
if (restarted && previous.epoch) {
|
||||
holder.retiredUsageEpochs = [...retiredEpochs, previous.epoch].slice(-8);
|
||||
}
|
||||
holder.usageCounters = { uplink, downlink, epoch: sampleEpoch || null };
|
||||
return { upDelta, downDelta };
|
||||
}
|
||||
|
||||
function validateNodeDeploy(data, node) {
|
||||
const direct = !forwardNode(node);
|
||||
if (direct) validateEntryProtocol(node.protocol);
|
||||
else validateProtocolPair(node.protocol, node.exitProtocol);
|
||||
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
|
||||
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
|
||||
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
|
||||
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
|
||||
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
|
||||
if (!server.engine?.certificates?.includes(server.tlsDomain)) {
|
||||
const error = new Error('入口证书未由 Agent 确认。在入口机运行 ng-agent update && ng-agent cert,证书申请成功后刷新设备状态');
|
||||
error.statusCode = 409; throw error;
|
||||
}
|
||||
}
|
||||
if (node.protocol === 'anytls') {
|
||||
const agent = data.agents.find((item) => item.serverId === server.id && item.status === 'active');
|
||||
const parts = String(agent?.version || '').split('.').map(Number);
|
||||
if (!agent || !(parts[0] > 0 || (parts[0] === 0 && (parts[1] > 6 || (parts[1] === 6 && parts[2] >= 1))))) {
|
||||
const error = new Error(`AnyTLS 入口需要新版 Agent:请在 ${server.name} 运行 ng-agent update`); error.statusCode = 409; throw error;
|
||||
}
|
||||
if (!server.engine?.singBoxInstalled) {
|
||||
const error = new Error(`AnyTLS 入口缺少 sing-box 及统计组件:请在 ${server.name} 运行 ng-agent engine install,完成后等待 Agent 心跳并刷新页面`);
|
||||
error.statusCode = 409; throw error;
|
||||
}
|
||||
}
|
||||
const servers = direct ? [server] : [server, exitServer];
|
||||
const unavailable = servers.filter((s) => s.status !== 'online' || Date.now() - Date.parse(s.lastSeenAt || 0) > 120000);
|
||||
if (unavailable.length) {
|
||||
const error = new Error(`目标设备尚未就绪:${unavailable.map((s) => `${s.name}(Agent 离线;请在该机器运行 ng-agent doctor)`).join('、')}`);
|
||||
error.statusCode = 409; throw error;
|
||||
}
|
||||
if ((node.networkMode || 'ipv4') === 'ipv6') {
|
||||
const missing = servers.filter((s) => !s.publicAddressV6);
|
||||
if (missing.length) { const error = new Error(`IPv6 模式需要先为这些服务器填写 IPv6 地址:${missing.map((s) => s.name).join('、')}`); error.statusCode = 400; throw error; }
|
||||
}
|
||||
if (nodePortInUse(data, node.serverId, node.port, node.id)) { const error = new Error(`端口 ${node.port} 已被该服务器上的其他节点占用`); error.statusCode = 409; throw error; }
|
||||
if (!direct && nodePortInUse(data, node.exitServerId, node.exitPort, node.id)) { const error = new Error(`出口端口 ${node.exitPort} 已被该服务器上的其他节点占用`); error.statusCode = 409; throw error; }
|
||||
}
|
||||
|
||||
class Orchestrator {
|
||||
constructor(store) { this.store = store; }
|
||||
|
||||
async restoreChain(chainId, actor = 'admin') {
|
||||
return this.store.transaction((data) => {
|
||||
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路');
|
||||
const old = currentDeployments(data, chain).filter((item) => !item.archived);
|
||||
if (!old.length || !old.every((item) => ['deleted','suspended'].includes(item.status))) {
|
||||
const error = new Error('没有可恢复的原节点,请使用部署或等待当前任务完成'); error.statusCode = 409; throw error;
|
||||
}
|
||||
let count = 0;
|
||||
for (const customerId of chain.customerIds) {
|
||||
const customer = data.customers.find((item) => item.id === customerId);
|
||||
if (customer && customer.status === 'active' &&
|
||||
(!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) &&
|
||||
(!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes)) {
|
||||
count += resumeCustomerResources(data, customer, actor, true, chain.id);
|
||||
}
|
||||
}
|
||||
if (!count) { const error = new Error('客户仍受使用限制,或线路没有可恢复的部署'); error.statusCode = 409; throw error; }
|
||||
audit(data, actor, 'restore_route_resources', chain.id, { count });
|
||||
return count;
|
||||
async deployNode(nodeId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
validateNodeDeploy(data, node);
|
||||
await rebuildNode(data, node, { includeExit: true });
|
||||
audit(data, actor, 'deploy_node', node.id, { name: node.name });
|
||||
return structuredClone(node);
|
||||
});
|
||||
}
|
||||
|
||||
async deployChain(chainId, actor = 'admin') {
|
||||
// Undeploy (keep the node record) or delete it entirely once agents confirm cleanup.
|
||||
async removeNode(nodeId, actor = 'admin', deleteRecord = false) {
|
||||
return this.store.transaction((data) => {
|
||||
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路');
|
||||
const direct = (chain.topology || 'forward') === 'direct';
|
||||
if (direct) validateEntryProtocol(chain.relayProtocol);
|
||||
else validateProtocolPair(chain.relayProtocol, chain.exitProtocol);
|
||||
const entryServers = chain.relayServerIds.map((serverId) => assertFound(data.servers.find((item) => item.id === serverId), '入口服务器'));
|
||||
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(chain.relayProtocol)) {
|
||||
const missing = entryServers.filter((server) => !server.tlsDomain);
|
||||
if (missing.length) { const error = new Error(`${chain.relayProtocol} 需要先填写设备 TLS 域名并在节点上安装证书:${missing.map((server) => server.name).join('、')}`); error.statusCode = 400; throw error; }
|
||||
const unready = entryServers.filter((server) => !server.engine?.certificates?.includes(server.tlsDomain));
|
||||
if (unready.length) {
|
||||
const error = new Error(`入口证书未由 Agent 确认:${unready.map((server) => server.name).join('、')}。在入口机运行 ng-agent update && ng-agent cert,证书申请成功后刷新设备状态`);
|
||||
error.statusCode = 409; throw error;
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
node.pendingDelete = deleteRecord;
|
||||
node.autoRemoving = false;
|
||||
if (node.status !== 'draft') {
|
||||
queueJob(data, node.serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' });
|
||||
if (forwardNode(node) && node.exitResourceId) {
|
||||
queueJob(data, node.exitServerId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' });
|
||||
}
|
||||
node.status = 'removing'; node.error = null;
|
||||
} else if (deleteRecord) {
|
||||
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
||||
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
||||
}
|
||||
const exitServer = direct ? null : assertFound(data.servers.find((item) => item.id === chain.exitServerId), '出口服务器');
|
||||
if (chain.relayProtocol === 'anytls') {
|
||||
const outdated = entryServers.filter((server) => {
|
||||
const agent = data.agents.find((item) => item.serverId === server.id && item.status === 'active');
|
||||
const parts = String(agent?.version || '').split('.').map(Number);
|
||||
return !agent || !(parts[0] > 0 || (parts[0] === 0 && (parts[1] > 6 || (parts[1] === 6 && parts[2] >= 1))));
|
||||
});
|
||||
if (outdated.length) {
|
||||
const error = new Error(`AnyTLS 入口需要新版 Agent:请在 ${outdated.map((server) => server.name).join('、')} 运行 ng-agent update`);
|
||||
error.statusCode = 409; throw error;
|
||||
}
|
||||
const missing = entryServers.filter((server) => !server.engine?.singBoxInstalled);
|
||||
if (missing.length) {
|
||||
const error = new Error(`AnyTLS 入口缺少 sing-box 及统计组件:请在 ${missing.map((server) => server.name).join('、')} 运行 ng-agent engine install,完成后等待 Agent 心跳并刷新页面`);
|
||||
error.statusCode = 409; throw error;
|
||||
}
|
||||
}
|
||||
const unavailable = [...new Map([...entryServers, ...(exitServer ? [exitServer] : [])].map((server) => [server.id, server])).values()]
|
||||
.filter((server) => server.status !== 'online' || Date.now() - Date.parse(server.lastSeenAt || 0) > 120000);
|
||||
if (unavailable.length) {
|
||||
const detail = unavailable.map((server) => `${server.name}(Agent 离线;请在该机器运行 ng-agent doctor)`).join('、');
|
||||
const error = new Error(`目标设备尚未就绪:${detail}`); error.statusCode = 409; throw error;
|
||||
}
|
||||
const customers = chain.customerIds.map((customerId) => assertFound(data.customers.find((item) => item.id === customerId), '客户'));
|
||||
if (!entryServers.length || !customers.length) throw new Error('至少选择一台入口服务器和一个客户');
|
||||
if (!customers.some((customer) => customer.status === 'active')) { const error = new Error('所选客户均已停用,无法部署'); error.statusCode = 409; throw error; }
|
||||
if ((chain.networkMode || 'ipv4') === 'ipv6') {
|
||||
const missing = [...entryServers, ...(exitServer ? [exitServer] : [])].filter((server) => !server.publicAddressV6);
|
||||
if (missing.length) { const error = new Error(`IPv6 模式需要先为这些服务器填写 IPv6 地址:${missing.map((item) => item.name).join('、')}`); error.statusCode = 400; throw error; }
|
||||
}
|
||||
if (customers.length > 1 && (chain.relayPortMode === 'fixed' || (!direct && chain.exitPortMode === 'fixed'))) {
|
||||
const error = new Error('多客户批量部署需要使用随机端口;固定端口仅支持单一客户'); error.statusCode = 400; throw error;
|
||||
}
|
||||
const duplicate = data.deployments.some((item) => item.chainId === chain.id && !['deleted', 'failed'].includes(item.status));
|
||||
if (duplicate) { const error = new Error('该线路已有部署,请使用“重新部署”或先停用'); error.statusCode = 409; throw error; }
|
||||
|
||||
chain.generation = Number(chain.generation || 0) + 1;
|
||||
chain.redeployPending = false;
|
||||
chain.pausedChangesPending = false;
|
||||
const created = [];
|
||||
for (const customer of customers) {
|
||||
if (customer.status !== 'active') continue;
|
||||
const credentials = newCredentialSet(chain.relayProtocol, chain.exitProtocol);
|
||||
let exitDeployment = null;
|
||||
if (!direct) {
|
||||
const exitPort = selectPort(data, exitServer, chain.exitPortMode, chain.exitPort);
|
||||
exitDeployment = {
|
||||
id: id('dep'), chainId: chain.id, generation: chain.generation, customerId: customer.id, serverId: exitServer.id,
|
||||
role: 'exit', protocol: chain.exitProtocol, port: exitPort, status: 'queued', resourceId: id('res'), credentials,
|
||||
createdAt: nowIso(), updatedAt: nowIso(), clientUri: null
|
||||
};
|
||||
data.deployments.push(exitDeployment);
|
||||
queueJob(data, exitServer.id, exitDeployment.id, 'apply_resource', { resource: resourceForDeployment(data, exitDeployment) });
|
||||
created.push(exitDeployment);
|
||||
}
|
||||
for (const entryServer of entryServers) created.push(createEntryDeployment(data, chain, customer, entryServer, credentials, exitDeployment));
|
||||
}
|
||||
chain.status = 'deploying'; chain.updatedAt = nowIso();
|
||||
audit(data, actor, 'deploy_route', chain.id, { deployments: created.length, topology: direct ? 'direct' : 'forward' });
|
||||
return structuredClone(created);
|
||||
node.updatedAt = nowIso();
|
||||
audit(data, actor, deleteRecord ? 'delete_node' : 'remove_node', node.id, { name: node.name });
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async removeChain(chainId, actor = 'admin', redeploy = false) {
|
||||
return this.store.transaction((data) => {
|
||||
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路');
|
||||
const deployments = data.deployments.filter((item) => item.chainId === chainId && item.status !== 'deleted');
|
||||
for (const deployment of deployments) {
|
||||
deployment.suspendedByCustomer = false;
|
||||
const existing = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status));
|
||||
if (!existing) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId });
|
||||
deployment.status = 'removing'; deployment.updatedAt = nowIso();
|
||||
}
|
||||
chain.redeployPending = Boolean(redeploy);
|
||||
chain.pausedChangesPending = false;
|
||||
chain.status = redeploy ? (deployments.length ? 'redeploying' : 'redeploy_pending') : (deployments.length ? 'removing' : 'draft'); chain.updatedAt = nowIso();
|
||||
audit(data, actor, redeploy ? 'redeploy_route' : 'remove_route', chain.id, { deployments: deployments.length });
|
||||
return deployments.length;
|
||||
async repairNode(nodeId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
if (!['failed', 'degraded'].includes(node.status)) { const error = new Error('节点状态正常,无需修复'); error.statusCode = 409; throw error; }
|
||||
await rebuildNode(data, node, { includeExit: true });
|
||||
audit(data, actor, 'repair_node', node.id, { name: node.name });
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async repairChain(chainId, actor = 'admin') {
|
||||
return this.store.transaction((data) => {
|
||||
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路');
|
||||
const failed = currentDeployments(data, chain).filter((item) => item.status === 'failed');
|
||||
if (!failed.length) { const error = new Error('当前线路没有可修复的失败部署'); error.statusCode = 409; throw error; }
|
||||
for (const deployment of failed) {
|
||||
queueJob(data, deployment.serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) });
|
||||
deployment.status = 'queued'; deployment.error = null; deployment.updatedAt = nowIso();
|
||||
async assignCustomer(nodeId, customerId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
|
||||
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
|
||||
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
|
||||
if (nc) {
|
||||
nc.status = 'active'; nc.updatedAt = nowIso();
|
||||
} else {
|
||||
nc = {
|
||||
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
|
||||
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
|
||||
};
|
||||
data.nodeClients.push(nc);
|
||||
}
|
||||
chain.status = 'deploying'; chain.updatedAt = nowIso();
|
||||
audit(data, actor, 'repair_route', chain.id, { deployments: failed.length });
|
||||
return failed.length;
|
||||
// A manually undeployed node stays draft until explicitly deployed.
|
||||
// Automatic teardowns (suspend) are revived when clients return.
|
||||
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
|
||||
// record the assignment only
|
||||
} else {
|
||||
if (node.status === 'removing' && node.autoRemoving) {
|
||||
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
|
||||
node.autoRemoving = false;
|
||||
}
|
||||
await rebuildNode(data, node);
|
||||
}
|
||||
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
|
||||
return structuredClone(nc);
|
||||
});
|
||||
}
|
||||
|
||||
async unassignCustomer(nodeId, customerId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
|
||||
data.nodeClients.splice(index, 1);
|
||||
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
|
||||
audit(data, actor, 'unassign_customer', node.id, { customerId });
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileServer(serverId, actor = 'system') {
|
||||
return this.store.transaction((data) => {
|
||||
return this.store.transaction(async (data) => {
|
||||
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
|
||||
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
|
||||
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
|
||||
}
|
||||
let count = 0;
|
||||
for (const deployment of data.deployments.filter((item) => item.serverId === serverId && item.status !== 'deleted')) {
|
||||
const removing = deployment.archived || deployment.status === 'removing';
|
||||
if (removing) queueJob(data, serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId });
|
||||
else queueJob(data, serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) });
|
||||
deployment.status = removing ? 'removing' : 'queued'; deployment.updatedAt = nowIso(); count += 1;
|
||||
for (const node of data.nodes.filter((n) => n.serverId === serverId || n.exitServerId === serverId)) {
|
||||
const isEntry = node.serverId === serverId;
|
||||
const isExit = node.exitServerId === serverId && forwardNode(node);
|
||||
if (!isEntry && !isExit) continue;
|
||||
if (node.status === 'draft') continue;
|
||||
if (node.status === 'removing') {
|
||||
if (isEntry) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' });
|
||||
if (isExit && node.exitResourceId) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' });
|
||||
} else {
|
||||
const clients = activeNodeClients(data, node);
|
||||
if (!clients.length) continue;
|
||||
if (isEntry) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' });
|
||||
if (isExit) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
|
||||
node.status = 'deploying';
|
||||
}
|
||||
node.updatedAt = nowIso(); count += 1;
|
||||
}
|
||||
audit(data, actor, 'reconcile_server', server.id, { deployments: count });
|
||||
audit(data, actor, 'reconcile_server', server.id, { nodes: count });
|
||||
return count;
|
||||
});
|
||||
}
|
||||
@@ -325,49 +361,29 @@ class Orchestrator {
|
||||
}
|
||||
job.status = success ? 'completed' : 'failed'; job.updatedAt = nowIso(); job.completedAt = nowIso(); job.leaseUntil = null;
|
||||
job.error = success ? null : redactSecrets(errorMessage || 'Agent operation failed').slice(0, 1800);
|
||||
const deployment = data.deployments.find((item) => item.id === job.deploymentId);
|
||||
if (!deployment) return structuredClone(job);
|
||||
const chain = data.chains.find((item) => item.id === deployment.chainId);
|
||||
if (success && job.action === 'apply_resource' && (deployment.archived || !chain ||
|
||||
['removing','redeploying'].includes(chain.status) || (deployment.status === 'removing' && !deployment.suspendedByCustomer))) {
|
||||
const cleanupQueued = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status));
|
||||
if (!cleanupQueued) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId });
|
||||
deployment.status = 'removing'; deployment.clientUri = null;
|
||||
} else if (success && job.action === 'apply_resource') {
|
||||
deployment.status = 'active'; deployment.error = null; deployment.artifacts = result.artifacts || {};
|
||||
if (deployment.role === 'relay' || deployment.role === 'direct') {
|
||||
const server = data.servers.find((item) => item.id === deployment.serverId);
|
||||
const customer = data.customers.find((item) => item.id === deployment.customerId);
|
||||
deployment.clientUri = buildClientUri({
|
||||
protocol: deployment.protocol, relayServer: server, relayPort: deployment.port, credentials: deployment.credentials,
|
||||
reality: deployment.clientTemplate && deployment.clientTemplate.reality,
|
||||
publicKey: result.artifacts && result.artifacts.realityPublicKey,
|
||||
name: chain.name,
|
||||
tlsDomain: server && server.tlsDomain,
|
||||
networkMode: chain.networkMode || 'ipv4'
|
||||
});
|
||||
}
|
||||
const customer = data.customers.find((item) => item.id === deployment.customerId);
|
||||
if (customer && (customer.status !== 'active' || (customer.expiresAt && Date.parse(customer.expiresAt) <= Date.now()) ||
|
||||
(customer.trafficLimitBytes > 0 && customer.usedBytes >= customer.trafficLimitBytes))) {
|
||||
queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId });
|
||||
deployment.status = 'removing'; deployment.suspendedByCustomer = true;
|
||||
}
|
||||
const node = data.nodes.find((n) => n.id === job.nodeId);
|
||||
if (!node) return structuredClone(job);
|
||||
const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind));
|
||||
if (success && job.action === 'apply_resource') {
|
||||
if (kind === 'node-exit') node.exitReady = true;
|
||||
else { node.entryReady = true; node.artifacts = result.artifacts || {}; }
|
||||
deriveNodeStatus(node);
|
||||
} else if (success && job.action === 'delete_resource') {
|
||||
const restoring = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'apply_resource' && item.status === 'queued');
|
||||
deployment.status = restoring ? 'queued' : deployment.suspendedByCustomer && chain && !deployment.archived ? 'suspended' : 'deleted';
|
||||
}
|
||||
else if (!success) { deployment.status = 'failed'; deployment.error = job.error; }
|
||||
deployment.updatedAt = nowIso();
|
||||
if (chain) {
|
||||
const related = currentDeployments(data, chain);
|
||||
if (related.length && related.every((item) => item.status === 'active')) chain.status = chain.pausedChangesPending ? 'changes_pending' : 'active';
|
||||
else if (related.length && related.every((item) => item.status === 'deleted')) chain.status = chain.redeployPending ? 'redeploy_pending' : 'draft';
|
||||
else if (related.length && related.every((item) => ['suspended','deleted'].includes(item.status))) chain.status = chain.pausedChangesPending ? 'changes_pending' : 'suspended';
|
||||
else if (related.some((item) => item.status === 'active') && related.some((item) => item.status === 'suspended')) chain.status = 'partially_suspended';
|
||||
else if (related.some((item) => item.status === 'failed')) chain.status = 'degraded';
|
||||
chain.updatedAt = nowIso();
|
||||
if (kind === 'node-exit') node.exitReady = false; else node.entryReady = false;
|
||||
const pending = data.jobs.some((item) => item.nodeId === node.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status));
|
||||
if (!pending && node.status === 'removing') {
|
||||
if (node.pendingDelete) {
|
||||
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
||||
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
||||
} else {
|
||||
node.status = 'draft'; node.error = null;
|
||||
}
|
||||
}
|
||||
} else if (!success) {
|
||||
node.status = kind === 'node-exit' ? 'degraded' : 'failed';
|
||||
node.error = job.error;
|
||||
}
|
||||
node.updatedAt = nowIso();
|
||||
return structuredClone(job);
|
||||
});
|
||||
}
|
||||
@@ -388,43 +404,41 @@ class Orchestrator {
|
||||
if (server) server.usage = { lastReportAt: reportedAt, sampleCount: Array.isArray(samples) ? samples.length : 0,
|
||||
error: error ? String(error).slice(0, 240) : null };
|
||||
for (const sample of Array.isArray(samples) ? samples.slice(0, 1000) : []) {
|
||||
const deployment = data.deployments.find((item) => item.serverId === serverId && item.resourceId === sample.resourceId &&
|
||||
!item.archived && ['relay', 'direct'].includes(item.role));
|
||||
if (!deployment) continue;
|
||||
const customer = data.customers.find((item) => item.id === deployment.customerId);
|
||||
if (!customer) continue;
|
||||
const uplink = Number(sample.uplink);
|
||||
const downlink = Number(sample.downlink);
|
||||
if (![uplink, downlink].every((value) => Number.isSafeInteger(value) && value >= 0)) continue;
|
||||
const previous = deployment.usageCounters || { uplink: 0, downlink: 0 };
|
||||
const sampleEpoch = typeof sample.epoch === 'string' ? sample.epoch.slice(0, 80) : epoch;
|
||||
// A periodic report may finish after a resource job has flushed the old
|
||||
// process and started a new one. Never charge an already retired epoch
|
||||
// a second time if its HTTP response arrives out of order.
|
||||
const retiredEpochs = Array.isArray(deployment.retiredUsageEpochs) ? deployment.retiredUsageEpochs : [];
|
||||
if (sampleEpoch && retiredEpochs.includes(sampleEpoch)) continue;
|
||||
const restarted = Boolean(sampleEpoch && previous.epoch && sampleEpoch !== previous.epoch);
|
||||
if (!restarted && sampleEpoch && previous.epoch && !sampleEpoch.endsWith(':unknown') &&
|
||||
(uplink < previous.uplink || downlink < previous.downlink)) continue;
|
||||
// Counters reset when Xray restarts. Replayed reports have zero delta.
|
||||
const upDelta = !restarted && uplink >= previous.uplink ? uplink - previous.uplink : uplink;
|
||||
const downDelta = !restarted && downlink >= previous.downlink ? downlink - previous.downlink : downlink;
|
||||
if (restarted && previous.epoch) {
|
||||
deployment.retiredUsageEpochs = [...retiredEpochs, previous.epoch].slice(-8);
|
||||
if (sample.kind === 'user') {
|
||||
const customer = data.customers.find((item) => item.id === sample.id);
|
||||
if (!customer) continue;
|
||||
// Counters are per engine process, hence per server. Traffic itself
|
||||
// accumulates on the customer across all servers.
|
||||
const key = `srv:${serverId}`;
|
||||
customer.serverUsage = customer.serverUsage || {};
|
||||
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
|
||||
const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
|
||||
if (!delta) continue;
|
||||
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
|
||||
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
|
||||
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
|
||||
customer.lastUsageAt = reportedAt;
|
||||
customer.updatedAt = reportedAt;
|
||||
} else if (sample.kind === 'inbound') {
|
||||
// Node-level traffic comes from entry inbounds only; the exit leg is
|
||||
// transport and is never double counted (same rule as before).
|
||||
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
|
||||
if (!node) continue;
|
||||
const delta = applyDelta(node, uplink, downlink, sampleEpoch);
|
||||
if (!delta) continue;
|
||||
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
|
||||
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
|
||||
node.lastUsageAt = reportedAt;
|
||||
}
|
||||
deployment.usageCounters = { uplink, downlink, epoch: sampleEpoch || null };
|
||||
const metered = deployment.meteredTraffic || { uplink: 0, downlink: 0 };
|
||||
deployment.meteredTraffic = { uplink: metered.uplink + upDelta, downlink: metered.downlink + downDelta };
|
||||
deployment.lastUsageAt = reportedAt;
|
||||
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + upDelta;
|
||||
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + downDelta;
|
||||
customer.usedBytes = (customer.usedBytes || 0) + upDelta + downDelta;
|
||||
customer.lastUsageAt = reportedAt;
|
||||
customer.updatedAt = reportedAt;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Orchestrator, allocatePort, selectPort, queueJob, audit, id, nowIso, resourceForDeployment,
|
||||
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
|
||||
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
|
||||
suspendCustomerResources, resumeCustomerResources };
|
||||
|
||||
+236
-167
@@ -2,6 +2,7 @@
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const dns = require('node:dns/promises');
|
||||
|
||||
const REALITY_PRESETS = Object.freeze([
|
||||
{ id: 'tesla', label: 'Tesla', serverName: 'www.tesla.com', destPort: 443 },
|
||||
@@ -12,18 +13,17 @@ const REALITY_PRESETS = Object.freeze([
|
||||
]);
|
||||
|
||||
const PROFILE_CATALOG = Object.freeze([
|
||||
{ id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['reality'], description: '推荐公网入口,无需在节点机部署证书。' },
|
||||
{ id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['reality'], description: '不启用 Vision 流控的 Reality 入口。' },
|
||||
{ id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' },
|
||||
{ id: 'vmess-ws', role: 'relay-ingress', name: 'VMess · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '兼容旧客户端,建议仅在外层 TLS 环境使用。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: [], description: '轻量、推荐的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: [], description: '32 字节密钥的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: [], description: '适合受控网络或程序级代理。' },
|
||||
{ id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' },
|
||||
{ id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['udp', 'tls-domain'], description: '由新版 Xray 承载;入口须安装有效节点证书并放行所选 UDP 端口。' },
|
||||
{ id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain', 'sing-box'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' },
|
||||
{ id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '推荐公网入口,无需在节点机部署证书。' },
|
||||
{ id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '不启用 Vision 流控的 Reality 入口。' },
|
||||
{ id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['sing-box', 'websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '轻量、推荐的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '32 字节密钥的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '适合受控网络或程序级代理。' },
|
||||
{ id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' },
|
||||
{ id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['sing-box', 'udp', 'tls-domain'], description: '由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。' },
|
||||
{ id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'exit-transport', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '推荐的转发到出口传输。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'exit-transport', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '增强密钥长度的转发传输。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'exit-transport', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '兼容旧环境的转发传输。' },
|
||||
@@ -42,176 +42,240 @@ const SHADOWSOCKS_METHODS = Object.freeze({
|
||||
function b64(bytes) { return crypto.randomBytes(bytes).toString('base64'); }
|
||||
function shortId() { return crypto.randomBytes(8).toString('hex'); }
|
||||
|
||||
function newCredentialSet(relayProtocol, exitProtocol) {
|
||||
const relayCipher = SHADOWSOCKS_METHODS[relayProtocol];
|
||||
const exitCipher = SHADOWSOCKS_METHODS[exitProtocol];
|
||||
function listenAddress(networkMode, server, bindAddress) {
|
||||
// bindAddress 是控制面按所选网络栈解析好的字面 IP(登记的域名已在下发前解析),优先使用。
|
||||
if (bindAddress) return bindAddress;
|
||||
// 只绑定所选网络栈对应的具体 IP,避免通配符地址跨栈泄漏。
|
||||
// ipv6 绑定 `::` 时 Linux 默认是双栈 socket(bindv6only=0),IPv4 也能连进来。
|
||||
if (networkMode === 'ipv6') return (server && server.publicAddressV6) || '::';
|
||||
// dual 单个 inbound 只能填一个 listen 地址,`::` 即双栈语义;缺地址时回退通配符。
|
||||
if (networkMode === 'dual') return '::';
|
||||
return (server && server.publicAddress) || '0.0.0.0';
|
||||
}
|
||||
|
||||
// 把服务器登记的地址解析成字面 IP,供 inbound listen 绑定。
|
||||
// v4/v6 各自登记了单栈域名时,按所选网络栈只取对应记录(A / AAAA),避免绑到错误的栈。
|
||||
async function resolveBindAddress(networkMode, server) {
|
||||
if (networkMode === 'dual') return null;
|
||||
const raw = networkMode === 'ipv6' ? server?.publicAddressV6 : server?.publicAddress;
|
||||
if (!raw) return null;
|
||||
const text = String(raw).trim();
|
||||
const family = networkMode === 'ipv6' ? 6 : 4;
|
||||
if (net.isIP(text)) return text;
|
||||
try {
|
||||
const { address } = await dns.lookup(text, { family });
|
||||
return address;
|
||||
} catch (err) {
|
||||
const error = new Error(`绑定地址 DNS 解析失败:${text} 没有 ${family === 6 ? 'AAAA' : 'A'} 记录(${err.code || err.message}),请检查该服务器登记的地址`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Shared nodes: one inbound, many clients (3x-ui style) ----------
|
||||
|
||||
// 统计与日志里的用户身份统一为 ng:<customerId>:
|
||||
// VLESS/HY2/SS 用 email,SOCKS 用用户名,AnyTLS 用 sing-box 用户名。
|
||||
function nodeEmail(customerId) { return `ng:${customerId}`; }
|
||||
|
||||
function newNodeClientCredentials(protocol) {
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
return {
|
||||
clientId: crypto.randomUUID(),
|
||||
relayPassword: b64(relayCipher ? relayCipher.keyBytes : 24),
|
||||
exitPassword: b64(exitCipher ? exitCipher.keyBytes : 24),
|
||||
socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
|
||||
relayPassword: b64(cipher ? cipher.keyBytes : 24),
|
||||
shortId: shortId(),
|
||||
wsPath: `/${crypto.randomBytes(8).toString('hex')}`
|
||||
};
|
||||
}
|
||||
|
||||
function listenAddress(networkMode) { return networkMode === 'ipv6' || networkMode === 'dual' ? '::' : '0.0.0.0'; }
|
||||
|
||||
function inboundBase(tag, port, protocol, settings, streamSettings, networkMode = 'ipv4') {
|
||||
return {
|
||||
tag, listen: listenAddress(networkMode), port, protocol, settings,
|
||||
sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'], routeOnly: true },
|
||||
...(streamSettings ? { streamSettings } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function shadowsocksInbound(tag, port, protocol, password, networkMode) {
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (!cipher) return null;
|
||||
return inboundBase(tag, port, 'shadowsocks', { method: cipher.method, password, network: 'tcp,udp' }, null, networkMode);
|
||||
}
|
||||
|
||||
function socksInbound(tag, port, credentials, password, networkMode) {
|
||||
return inboundBase(tag, port, 'socks', {
|
||||
auth: 'password', accounts: [{ user: credentials.socksUser, pass: password }], udp: true, ip: '127.0.0.1', userLevel: 0
|
||||
}, null, networkMode);
|
||||
}
|
||||
|
||||
function buildExitResource({ resourceId, tagPrefix, port, protocol, credentials, customer, networkMode }) {
|
||||
let inbound = shadowsocksInbound(`${tagPrefix}-in`, port, protocol, credentials.exitPassword, networkMode);
|
||||
if (protocol === 'socks5-auth') inbound = socksInbound(`${tagPrefix}-in`, port, credentials, credentials.exitPassword, networkMode);
|
||||
if (protocol === 'vless-tcp') {
|
||||
inbound = inboundBase(`${tagPrefix}-in`, port, 'vless', {
|
||||
clients: [{ id: credentials.clientId, email: `ng:${customer.id}` }], decryption: 'none'
|
||||
}, { network: 'raw', security: 'none' }, networkMode);
|
||||
// Fill in node-level secrets that are missing for the node's current
|
||||
// protocol/topology. Called on node create and on node edit.
|
||||
function ensureNodeSecrets(node) {
|
||||
node.wsPath ||= `/${crypto.randomBytes(9).toString('hex')}`;
|
||||
const cipher = SHADOWSOCKS_METHODS[node.protocol];
|
||||
if (cipher && !node.ssServerKey) node.ssServerKey = b64(cipher.keyBytes);
|
||||
if (isRealityProtocol(node.protocol)) {
|
||||
node.reality ||= { keyId: `reality-${node.id}` };
|
||||
node.reality.serverName = node.realityServerName || 'www.tesla.com';
|
||||
node.reality.destPort = Number(node.realityDestPort || 443);
|
||||
}
|
||||
if (!inbound) throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
const directTag = `${tagPrefix}-direct`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'exit', customerId: customer.id, metricsTag: inbound.tag }, inbounds: [inbound],
|
||||
outbounds: [{ tag: directTag, protocol: 'freedom', settings: {} }],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag: directTag }]
|
||||
};
|
||||
if ((node.topology || 'forward') !== 'direct' && !node.exitCredentials) {
|
||||
const exitCipher = SHADOWSOCKS_METHODS[node.exitProtocol];
|
||||
node.exitCredentials = {
|
||||
clientId: crypto.randomUUID(),
|
||||
password: b64(exitCipher ? exitCipher.keyBytes : 24),
|
||||
socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildRelayInbound({ tag, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
const email = `ng:${customer.id}`;
|
||||
if (protocol === 'hysteria2') {
|
||||
if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名');
|
||||
return inboundBase(tag, port, 'hysteria', {
|
||||
// Xray 26.3.27 reads `clients`; newer cores read `users`.
|
||||
// Keep both until all supported cores accept the same spelling.
|
||||
version: 2,
|
||||
clients: [{ auth: credentials.relayPassword, level: 0, email }],
|
||||
users: [{ auth: credentials.relayPassword, level: 0, email }]
|
||||
}, {
|
||||
// Older Xray reads `network`; newer cores accept `method`.
|
||||
network: 'hysteria', method: 'hysteria', security: 'tls', hysteriaSettings: { version: 2 },
|
||||
tlsSettings: { serverName: tlsDomain, alpn: ['h3'], certificates: [{
|
||||
certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`
|
||||
}] }
|
||||
}, networkMode);
|
||||
}
|
||||
// sing-box 全协议 inbound 构建器
|
||||
function buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress }) {
|
||||
const protocol = node.protocol;
|
||||
const port = node.port;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const listen = listenAddress(networkMode, server, bindAddress);
|
||||
const tlsDomain = server.tlsDomain;
|
||||
|
||||
// VLESS Reality (Vision / 普通)
|
||||
if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') {
|
||||
const client = { id: credentials.clientId, email };
|
||||
if (protocol === 'vless-reality-vision') client.flow = 'xtls-rprx-vision';
|
||||
return inboundBase(tag, port, 'vless', { clients: [client], decryption: 'none' }, {
|
||||
network: 'raw', security: 'reality',
|
||||
realitySettings: {
|
||||
show: false, target: `${reality.serverName}:${reality.destPort}`, xver: 0,
|
||||
serverNames: [reality.serverName], privateKey: `\${REALITY_PRIVATE:${reality.keyId}}`, shortIds: [credentials.shortId]
|
||||
}
|
||||
}, networkMode);
|
||||
if (!node.reality) throw new Error('Reality 节点缺少密钥配置');
|
||||
const users = nodeClients.map((nc) => ({
|
||||
name: nodeEmail(nc.customerId),
|
||||
uuid: nc.credentials.clientId,
|
||||
...(protocol === 'vless-reality-vision' ? { flow: 'xtls-rprx-vision' } : {}),
|
||||
}));
|
||||
return {
|
||||
type: 'vless', tag, listen, listen_port: port, users,
|
||||
tls: {
|
||||
enabled: true, server_name: node.reality.serverName,
|
||||
reality: {
|
||||
enabled: true,
|
||||
handshake: { server: node.reality.serverName, server_port: node.reality.destPort || 443 },
|
||||
private_key: `\${REALITY_PRIVATE:${node.reality.keyId}}`,
|
||||
short_id: nodeClients.map((nc) => nc.credentials.shortId),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (SHADOWSOCKS_METHODS[protocol]) return shadowsocksInbound(tag, port, protocol, credentials.relayPassword, networkMode);
|
||||
if (protocol === 'socks5-auth') return socksInbound(tag, port, credentials, credentials.relayPassword, networkMode);
|
||||
|
||||
// VLESS WS / WS+TLS
|
||||
if (protocol === 'vless-ws' || protocol === 'vless-ws-tls') {
|
||||
if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名');
|
||||
return inboundBase(tag, port, 'vless', { clients: [{ id: credentials.clientId, email }], decryption: 'none' }, {
|
||||
network: 'ws', security: protocol === 'vless-ws-tls' ? 'tls' : 'none', wsSettings: { path: credentials.wsPath },
|
||||
...(protocol === 'vless-ws-tls' ? { tlsSettings: { serverName: tlsDomain,
|
||||
certificates: [{ certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` }] } } : {})
|
||||
}, networkMode);
|
||||
const users = nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), uuid: nc.credentials.clientId }));
|
||||
const inbound = {
|
||||
type: 'vless', tag, listen, listen_port: port, users,
|
||||
transport: { type: 'ws', path: node.wsPath || '/' },
|
||||
};
|
||||
if (protocol === 'vless-ws-tls') {
|
||||
inbound.tls = {
|
||||
enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
};
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
if (protocol === 'vmess-ws') {
|
||||
return inboundBase(tag, port, 'vmess', { clients: [{ id: credentials.clientId, email }] }, {
|
||||
network: 'ws', security: 'none', wsSettings: { path: credentials.wsPath }
|
||||
}, networkMode);
|
||||
|
||||
// Shadowsocks (2022 全系 + 旧版)
|
||||
if (SHADOWSOCKS_METHODS[protocol]) {
|
||||
return {
|
||||
type: 'shadowsocks', tag, listen, listen_port: port,
|
||||
method: SHADOWSOCKS_METHODS[protocol].method,
|
||||
password: node.ssServerKey,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported relay ingress: ${protocol}`);
|
||||
|
||||
// SOCKS5
|
||||
if (protocol === 'socks5-auth') {
|
||||
return {
|
||||
type: 'socks', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ username: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
};
|
||||
}
|
||||
|
||||
// Hysteria2
|
||||
if (protocol === 'hysteria2') {
|
||||
if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名');
|
||||
return {
|
||||
type: 'hysteria2', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
tls: {
|
||||
enabled: true, server_name: tlsDomain, alpn: ['h3'],
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// AnyTLS 走专用构建器
|
||||
if (protocol === 'anytls') {
|
||||
if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名');
|
||||
return {
|
||||
type: 'anytls', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
tls: {
|
||||
enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`sing-box 不支持的协议: ${protocol}`);
|
||||
}
|
||||
|
||||
function buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) {
|
||||
const tag = `${tagPrefix}-in`;
|
||||
const forward = (node.topology || 'forward') === 'forward';
|
||||
const outboundTag = `${tagPrefix}-${forward ? 'exit' : 'direct'}`;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
let outbound;
|
||||
if (!forward) {
|
||||
outbound = { type: 'direct', tag: outboundTag };
|
||||
} else {
|
||||
const base = { tag: outboundTag, server: serverAddress(exitServer, networkMode), server_port: node.exitPort };
|
||||
const creds = node.exitCredentials;
|
||||
const cipher = SHADOWSOCKS_METHODS[node.exitProtocol];
|
||||
if (cipher) outbound = { type: 'shadowsocks', ...base, method: cipher.method, password: creds.password };
|
||||
else if (node.exitProtocol === 'socks5-auth') outbound = { type: 'socks', ...base, version: '5', username: creds.socksUser, password: creds.password };
|
||||
else if (node.exitProtocol === 'vless-tcp') outbound = { type: 'vless', ...base, uuid: creds.clientId };
|
||||
else throw new Error(`Unsupported exit transport: ${node.exitProtocol}`);
|
||||
}
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: 'node', nodeId: node.id, metricsTag: tag, protocol: node.protocol },
|
||||
inbounds: [buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress })],
|
||||
outbounds: [outbound],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) {
|
||||
// 全部走 sing-box,不再生成 Xray 配置
|
||||
return buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer });
|
||||
}
|
||||
|
||||
function buildNodeExitResource({ resourceId, tagPrefix, node, server, bindAddress }) {
|
||||
const tag = `${tagPrefix}-exit-in`;
|
||||
const directTag = `${tagPrefix}-exit-direct`;
|
||||
const protocol = node.exitProtocol;
|
||||
const creds = node.exitCredentials;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const listen = listenAddress(networkMode, server, bindAddress);
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
let inbound;
|
||||
if (cipher) {
|
||||
inbound = { type: 'shadowsocks', tag, listen, listen_port: node.exitPort,
|
||||
method: cipher.method, password: creds.password };
|
||||
} else if (protocol === 'vless-tcp') {
|
||||
inbound = { type: 'vless', tag, listen, listen_port: node.exitPort,
|
||||
users: [{ uuid: creds.clientId }] };
|
||||
} else if (protocol === 'socks5-auth') {
|
||||
inbound = { type: 'socks', tag, listen, listen_port: node.exitPort,
|
||||
users: [{ username: creds.socksUser, password: creds.password }] };
|
||||
} else {
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: 'node-exit', nodeId: node.id, metricsTag: tag },
|
||||
inbounds: [inbound],
|
||||
outbounds: [{ type: 'direct', tag: directTag }],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: directTag }],
|
||||
};
|
||||
}
|
||||
|
||||
function serverAddress(server, networkMode) {
|
||||
return networkMode === 'ipv6' && server.publicAddressV6 ? server.publicAddressV6 : server.publicAddress;
|
||||
}
|
||||
|
||||
function buildExitOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) {
|
||||
const address = serverAddress(exitServer, networkMode);
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (cipher) return { tag, protocol: 'shadowsocks', settings: { servers: [{ address, port: exitPort, method: cipher.method, password: credentials.exitPassword }] } };
|
||||
if (protocol === 'socks5-auth') return { tag, protocol: 'socks', settings: { servers: [{ address, port: exitPort, users: [{ user: credentials.socksUser, pass: credentials.exitPassword }] }] } };
|
||||
if (protocol === 'vless-tcp') {
|
||||
return {
|
||||
tag, protocol: 'vless', settings: { vnext: [{ address, port: exitPort, users: [{ id: credentials.clientId, encryption: 'none' }] }] },
|
||||
streamSettings: { network: 'raw', security: 'none' }
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
|
||||
function singBoxOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) {
|
||||
const base = { tag, server: serverAddress(exitServer, networkMode), server_port: exitPort };
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (cipher) return { type: 'shadowsocks', ...base, method: cipher.method, password: credentials.exitPassword };
|
||||
if (protocol === 'socks5-auth') return { type: 'socks', ...base, version: '5', username: credentials.socksUser, password: credentials.exitPassword };
|
||||
if (protocol === 'vless-tcp') return { type: 'vless', ...base, uuid: credentials.clientId };
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
|
||||
function anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort }) {
|
||||
if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名');
|
||||
const tag = `${tagPrefix}-in`;
|
||||
const outboundTag = `${tagPrefix}-${exitServer ? 'exit' : 'direct'}`;
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: exitServer ? 'relay' : 'direct', customerId: customer.id, metricsTag: tag, protocol: 'anytls' },
|
||||
inbounds: [{ type: 'anytls', tag, listen: listenAddress(networkMode), listen_port: port,
|
||||
users: [{ name: `ng:${customer.id}`, password: credentials.relayPassword }],
|
||||
tls: { enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` } }],
|
||||
outbounds: [exitServer ? singBoxOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode)
|
||||
: { type: 'direct', tag: outboundTag }],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRelayResource({ resourceId, tagPrefix, port, protocol, exitProtocol, exitServer, exitPort, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort });
|
||||
const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain });
|
||||
const outboundTag = `${tagPrefix}-exit`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'relay', customerId: customer.id, metricsTag: inbound.tag, protocol },
|
||||
inbounds: [inbound], outbounds: [buildExitOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode)],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }]
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectResource({ resourceId, tagPrefix, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain });
|
||||
const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain });
|
||||
const outboundTag = `${tagPrefix}-direct`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'direct', customerId: customer.id, metricsTag: inbound.tag, protocol },
|
||||
inbounds: [inbound], outbounds: [{ tag: outboundTag, protocol: 'freedom', settings: {} }],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }]
|
||||
};
|
||||
if (!server) throw new Error('Missing server for address selection');
|
||||
if (networkMode === 'ipv6') return server.publicAddressV6;
|
||||
return server.publicAddress;
|
||||
}
|
||||
|
||||
function uriHost(address) { return net.isIP(String(address)) === 6 ? `[${address}]` : address; }
|
||||
|
||||
function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain }) {
|
||||
function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain, ssServerKey }) {
|
||||
const label = encodeURIComponent(name);
|
||||
const publicAddress = serverAddress(relayServer, networkMode);
|
||||
const host = uriHost(publicAddress);
|
||||
@@ -235,14 +299,18 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality
|
||||
return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`;
|
||||
}
|
||||
if (SHADOWSOCKS_METHODS[protocol]) {
|
||||
const user = Buffer.from(`${SHADOWSOCKS_METHODS[protocol].method}:${credentials.relayPassword}`).toString('base64url');
|
||||
const method = SHADOWSOCKS_METHODS[protocol].method;
|
||||
// SS2022 multi-user needs the EIH identity header: sing-box derives the
|
||||
// identity subkey from the node's main PSK, so the client password must be
|
||||
// "<server PSK>:<user PSK>" or the server rejects with "invalid request".
|
||||
// Classic AEAD methods identify users by trying keys: user password alone.
|
||||
const password = method.startsWith('2022-blake3-') && ssServerKey
|
||||
? `${ssServerKey}:${credentials.relayPassword}`
|
||||
: credentials.relayPassword;
|
||||
const user = Buffer.from(`${method}:${password}`).toString('base64url');
|
||||
return `ss://${user}@${host}:${relayPort}#${label}`;
|
||||
}
|
||||
if (protocol === 'socks5-auth') return `socks5://${encodeURIComponent(credentials.socksUser)}:${encodeURIComponent(credentials.relayPassword)}@${host}:${relayPort}#${label}`;
|
||||
if (protocol === 'vmess-ws') {
|
||||
const payload = { v: '2', ps: name, add: publicAddress, port: String(relayPort), id: credentials.clientId, aid: '0', scy: 'auto', net: 'ws', type: 'none', host: '', path: credentials.wsPath, tls: '' };
|
||||
return `vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -259,6 +327,7 @@ function validateProtocolPair(relayProtocol, exitProtocol) {
|
||||
function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); }
|
||||
|
||||
module.exports = {
|
||||
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, newCredentialSet, buildExitResource,
|
||||
buildRelayResource, buildDirectResource, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol
|
||||
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
|
||||
buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
|
||||
resolveBindAddress
|
||||
};
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const COLLECTIONS = [
|
||||
'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'chains',
|
||||
'deployments', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients'
|
||||
'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'nodes',
|
||||
'nodeClients', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients'
|
||||
];
|
||||
|
||||
function emptyData() {
|
||||
@@ -20,7 +20,7 @@ function emptyData() {
|
||||
completedJobRetentionDays: 7
|
||||
},
|
||||
users: [], servers: [], agents: [], enrollmentTokens: [], customers: [],
|
||||
chains: [], deployments: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: []
|
||||
nodes: [], nodeClients: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+38
-29
@@ -1,36 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
// Client URLs are only issued for confirmed, running entry resources.
|
||||
function entries(data, customerId) {
|
||||
const active = data.deployments.filter((item) => item.customerId === customerId && !item.archived && item.status === 'active' &&
|
||||
['relay', 'direct'].includes(item.role) && typeof item.clientUri === 'string' && item.clientUri.length > 0 &&
|
||||
(item.role === 'direct' || data.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active' && !exit.archived)));
|
||||
const seen = new Map();
|
||||
return active.map((item) => {
|
||||
const chainName = data.chains?.find((chain) => chain.id === item.chainId)?.name;
|
||||
if (!chainName) return item;
|
||||
// Use the current route name even for links generated by older Agents.
|
||||
// Multiple entries under one route still need distinct names in Clash.
|
||||
const index = (seen.get(chainName) || 0) + 1;
|
||||
seen.set(chainName, index);
|
||||
const name = index === 1 ? chainName : `${chainName} (${index})`;
|
||||
if (item.clientUri.startsWith('vmess://')) {
|
||||
const payload = JSON.parse(Buffer.from(item.clientUri.slice(8), 'base64').toString('utf8'));
|
||||
return { ...item, clientUri:`vmess://${Buffer.from(JSON.stringify({ ...payload, ps:name })).toString('base64')}` };
|
||||
}
|
||||
const separator = item.clientUri.lastIndexOf('#');
|
||||
return { ...item, clientUri:`${item.clientUri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}` };
|
||||
const { buildClientUri, nodeEmail } = require('./protocols');
|
||||
|
||||
// Client URLs are only issued for confirmed, running nodes. Each link is built
|
||||
// on the fly from the node's current config and the customer's own credentials
|
||||
// for that node (3x-ui style: shared inbound, per-client identity).
|
||||
function nodeClientUri(data, nodeClient) {
|
||||
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
|
||||
if (!node || node.status !== 'active') return null;
|
||||
const server = data.servers.find((item) => item.id === node.serverId);
|
||||
if (!server) return null;
|
||||
return buildClientUri({
|
||||
protocol: node.protocol, relayServer: server, relayPort: node.port,
|
||||
credentials: { ...nodeClient.credentials, wsPath: node.wsPath, socksUser: nodeEmail(nodeClient.customerId) },
|
||||
reality: node.reality, publicKey: node.artifacts && node.artifacts.realityPublicKey,
|
||||
name: node.name, networkMode: node.networkMode || 'ipv4', tlsDomain: server.tlsDomain,
|
||||
ssServerKey: node.ssServerKey,
|
||||
});
|
||||
}
|
||||
|
||||
function entries(data, customerId) {
|
||||
const seen = new Map();
|
||||
const items = [];
|
||||
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) {
|
||||
const uri = nodeClientUri(data, nodeClient);
|
||||
if (!uri) continue;
|
||||
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
|
||||
const nodeName = node ? node.name : nodeClient.nodeId;
|
||||
// Use the current node name even for links generated by older Agents.
|
||||
const index = (seen.get(nodeName) || 0) + 1;
|
||||
seen.set(nodeName, index);
|
||||
const name = index === 1 ? nodeName : `${nodeName} (${index})`;
|
||||
const separator = uri.lastIndexOf('#');
|
||||
const clientUri = `${uri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}`;
|
||||
items.push({ nodeId: nodeClient.nodeId, clientUri });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function q(value) { return JSON.stringify(String(value)); }
|
||||
|
||||
function clashProxy(item) {
|
||||
const uri = item.clientUri;
|
||||
if (uri.startsWith('vmess://')) {
|
||||
const value = JSON.parse(Buffer.from(uri.slice(8), 'base64').toString('utf8'));
|
||||
return { name:value.ps, type:'vmess', server:value.add, port:Number(value.port), uuid:value.id, alterId:0, cipher:'auto', network:'ws', 'ws-opts':{ path:value.path } };
|
||||
}
|
||||
const name = decodeURIComponent(uri.slice(uri.lastIndexOf('#') + 1));
|
||||
const url = new URL(uri);
|
||||
const base = { name, server:url.hostname.replace(/^\[|\]$/g, ''), port:Number(url.port) };
|
||||
@@ -104,14 +115,13 @@ function makeClash(items, smart = false) {
|
||||
function makeSurge(items) {
|
||||
// Surge does not accept VLESS/Reality policies. Omit unsupported nodes rather
|
||||
// than handing clients an importable profile that cannot establish a tunnel.
|
||||
const proxies = items.map(clashProxy).filter((item) => ['ss','socks5','vmess'].includes(item.type));
|
||||
const proxies = items.map(clashProxy).filter((item) => ['ss','socks5'].includes(item.type));
|
||||
if (!proxies.length) return null;
|
||||
const safe = (value) => String(value).replace(/[\r\n,=]/g, '_');
|
||||
const policies = proxies.map((proxy) => {
|
||||
const prefix = `${safe(proxy.name)} = `;
|
||||
if (proxy.type === 'ss') return `${prefix}ss, ${proxy.server}, ${proxy.port}, encrypt-method=${proxy.cipher}, password=${safe(proxy.password)}`;
|
||||
if (proxy.type === 'socks5') return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`;
|
||||
return `${prefix}vmess, ${proxy.server}, ${proxy.port}, username=${proxy.uuid}, vmess-aead=true, ws=true, ws-path=${safe(proxy['ws-opts'].path)}`;
|
||||
return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`;
|
||||
});
|
||||
const names = proxies.map((proxy) => safe(proxy.name)).join(', ');
|
||||
return `[General]\nloglevel = notify\n[Proxy]\n${policies.join('\n')}\n[Proxy Group]\n节点选择 = select, ${names}, DIRECT\n自动选择 = url-test, ${names}, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n`;
|
||||
@@ -124,7 +134,6 @@ function makeSingBox(items) {
|
||||
if (proxy.type === 'hysteria2') return { type:'hysteria2', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni, alpn:['h3'] } };
|
||||
if (proxy.type === 'anytls') return { type:'anytls', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni } };
|
||||
if (proxy.type === 'socks5') return { type:'socks', ...base, version:'5', username:proxy.username, password:proxy.password };
|
||||
if (proxy.type === 'vmess') return { type:'vmess', ...base, uuid:proxy.uuid, security:'auto', alter_id:0, transport:{ type:'ws', path:proxy['ws-opts'].path } };
|
||||
const outbound = { type:'vless', ...base, uuid:proxy.uuid };
|
||||
if (proxy.flow) outbound.flow = proxy.flow;
|
||||
if (proxy.network === 'ws') outbound.transport = { type:'ws', path:proxy['ws-opts'].path };
|
||||
@@ -151,4 +160,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = { entries, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
|
||||
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
|
||||
|
||||
Reference in New Issue
Block a user