491 lines
25 KiB
JavaScript
491 lines
25 KiB
JavaScript
'use strict';
|
||
|
||
const crypto = require('node:crypto');
|
||
const { redactSecrets } = require('./redact');
|
||
const {
|
||
buildNodeResource, buildNodeExitResource, nodeEmail,
|
||
newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
|
||
keyMaterialForNode, dropRealityKey,
|
||
SHADOWSOCKS_METHODS,
|
||
} = require('./protocols');
|
||
|
||
const nowIso = () => new Date().toISOString();
|
||
const id = (prefix) => `${prefix}_${crypto.randomUUID()}`;
|
||
|
||
function assertFound(value, label) {
|
||
if (!value) {
|
||
const error = new Error(`${label}不存在`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
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 span = end - start + 1;
|
||
const offset = crypto.randomInt(span);
|
||
for (let i = 0; i < span; i += 1) {
|
||
const candidate = start + ((offset + i) % span);
|
||
if (!nodePortInUse(data, server.id, candidate)) return candidate;
|
||
}
|
||
throw new Error(`服务器 ${server.name} 没有可用端口`);
|
||
}
|
||
|
||
function selectPort(data, server, mode, requested, excludeNodeId = null) {
|
||
if (mode !== 'fixed' || !requested) return allocatePort(data, server);
|
||
const port = Number(requested);
|
||
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, nodeId, action, payload) {
|
||
const job = {
|
||
id: id('job'), serverId, nodeId, action, payload, status: 'queued', attempts: 0,
|
||
createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null
|
||
};
|
||
data.jobs.push(job);
|
||
return job;
|
||
}
|
||
|
||
function audit(data, actor, action, target, detail = {}) {
|
||
data.activity.unshift({ id: id('evt'), at: nowIso(), actor, action, target, detail });
|
||
data.activity = data.activity.slice(0, 2000);
|
||
}
|
||
|
||
function activeNodeClients(data, node) {
|
||
return data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active');
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
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;
|
||
const payload = { resource: await resourceForNode(data, node, clients), kind: 'node' };
|
||
// Reality keys are panel-hosted: ship the stored keypair with the deploy so
|
||
// the agent materializes the same private key after any reinstall.
|
||
const keyMaterial = keyMaterialForNode(data, node);
|
||
if (keyMaterial) payload.keyMaterial = keyMaterial;
|
||
queueJob(data, node.serverId, node.id, 'apply_resource', payload);
|
||
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 };
|
||
}
|
||
|
||
// Merge agent-reported keypairs (artifacts.keyMaterial) into the panel key
|
||
// store. The panel store always wins: an agent report only fills a gap (e.g.
|
||
// a node created before panel hosting, whose key only exists on the node).
|
||
// A conflicting report is audited, never applied — the next deploy re-ships
|
||
// the panel key and the agent overwrites its local copy with it.
|
||
function mergeKeyMaterialBackfill(data, node, artifacts) {
|
||
if (!artifacts || typeof artifacts !== 'object') return;
|
||
const reported = artifacts.keyMaterial;
|
||
// keyMaterial is transport data for the store, not a node artifact.
|
||
delete artifacts.keyMaterial;
|
||
if (reported && typeof reported === 'object') {
|
||
data.keyStore ||= {};
|
||
for (const [keyId, pair] of Object.entries(reported)) {
|
||
if (!pair || typeof pair.privateKey !== 'string' || typeof pair.publicKey !== 'string') continue;
|
||
const existing = data.keyStore[keyId];
|
||
if (!existing) {
|
||
data.keyStore[keyId] = { privateKey: pair.privateKey, publicKey: pair.publicKey, updatedAt: nowIso() };
|
||
} else if (existing.privateKey !== pair.privateKey || existing.publicKey !== pair.publicKey) {
|
||
audit(data, 'agent', 'reality_key_conflict', node.id, { keyId });
|
||
}
|
||
}
|
||
}
|
||
// The subscription-facing publicKey always comes from the panel store, so
|
||
// it is identical before and after any agent report.
|
||
const keyId = node.reality && node.reality.keyId;
|
||
if (keyId && data.keyStore && data.keyStore[keyId]) {
|
||
artifacts.realityPublicKey = data.keyStore[keyId].publicKey;
|
||
}
|
||
}
|
||
|
||
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 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);
|
||
});
|
||
}
|
||
|
||
// 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 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) {
|
||
dropRealityKey(data, node);
|
||
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
||
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
||
}
|
||
node.updatedAt = nowIso();
|
||
audit(data, actor, deleteRecord ? 'delete_node' : 'remove_node', node.id, { name: node.name });
|
||
return true;
|
||
});
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
// 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(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 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) {
|
||
const payload = { resource: await resourceForNode(data, node, clients), kind: 'node' };
|
||
const keyMaterial = keyMaterialForNode(data, node);
|
||
if (keyMaterial) payload.keyMaterial = keyMaterial;
|
||
queueJob(data, serverId, node.id, 'apply_resource', payload);
|
||
}
|
||
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, { nodes: count });
|
||
return count;
|
||
});
|
||
}
|
||
|
||
async completeJob(jobId, success, result = {}, errorMessage = null) {
|
||
return this.store.transaction((data) => {
|
||
const job = assertFound(data.jobs.find((item) => item.id === jobId), '任务');
|
||
if (job.status !== 'running') {
|
||
if (job.status === 'completed' && success) return structuredClone(job);
|
||
const stale = new Error('任务已被重试或取消,请等待当前任务完成'); stale.statusCode = 409; throw stale;
|
||
}
|
||
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 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 || {};
|
||
mergeKeyMaterialBackfill(data, node, node.artifacts);
|
||
}
|
||
deriveNodeStatus(node);
|
||
} else if (success && job.action === 'delete_resource') {
|
||
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) {
|
||
dropRealityKey(data, node);
|
||
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);
|
||
});
|
||
}
|
||
|
||
async recordUsage(serverId, samples, error = null, epoch = null) {
|
||
// Empty reports carry diagnostic status, not billable counters. Keep them
|
||
// visible without rewriting the full database on every idle Agent tick.
|
||
if (!Array.isArray(samples) || samples.length === 0) {
|
||
return this.store.transient((data) => {
|
||
const server = data.servers.find((item) => item.id === serverId);
|
||
if (server) server.usage = { lastReportAt: nowIso(), sampleCount: 0,
|
||
error: error ? String(error).slice(0, 240) : null };
|
||
});
|
||
}
|
||
return this.store.transaction((data) => {
|
||
const reportedAt = nowIso();
|
||
const server = data.servers.find((item) => item.id === serverId);
|
||
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 uplink = Number(sample.uplink);
|
||
const downlink = Number(sample.downlink);
|
||
if (![uplink, downlink].every((value) => Number.isSafeInteger(value) && value >= 0)) continue;
|
||
const sampleEpoch = typeof sample.epoch === 'string' ? sample.epoch.slice(0, 80) : epoch;
|
||
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;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
|
||
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
|
||
suspendCustomerResources, resumeCustomerResources };
|