feat: heartbeat reconciliation — panel re-dispatches resources a node lost
Agents (Go + Node) now report the resource IDs they actually hold in each heartbeat. When an expected resource is missing locally (e.g. the node's /etc/nexusgate state was wiped and only the agent restarted, so no re-enrollment happens), the panel re-queues applies for just the affected nodes via reconcileFromHeartbeat: gated on no queued/running jobs for the server (in-flight jobs are never failed by this path) and throttled to one trigger per server per 120s, audited as system:heartbeat. reconcileServer gains optional onlyNodeIds / failInFlight parameters; the enrollment call is unchanged. Heartbeats without a resources array (old agents) behave exactly as before.
This commit is contained in:
+19
-10
@@ -201,13 +201,9 @@ type engineReport struct {
|
|||||||
SingBoxStatus string `json:"singBoxStatus"`
|
SingBoxStatus string `json:"singBoxStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu.
|
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
|
||||||
// A readResources error propagates (JS throws, so the heartbeat is skipped).
|
// and pass the resources they read under the same lock.
|
||||||
func engineHealthLocked() (engineReport, error) {
|
func engineHealthLocked(resources []map[string]interface{}) engineReport {
|
||||||
resources, err := readResources()
|
|
||||||
if err != nil {
|
|
||||||
return engineReport{}, err
|
|
||||||
}
|
|
||||||
installed := fileExists(cfg.SingBoxBin) && fileExists(cfg.SingBoxStatsBin)
|
installed := fileExists(cfg.SingBoxBin) && fileExists(cfg.SingBoxStatsBin)
|
||||||
service := serviceHealth("nexusgate-sing-box")
|
service := serviceHealth("nexusgate-sing-box")
|
||||||
result := engineReport{
|
result := engineReport{
|
||||||
@@ -236,20 +232,33 @@ func engineHealthLocked() (engineReport, error) {
|
|||||||
result.Detail = "Hysteria 2 UDP 未监听:" + strings.Join(parts, ", ")
|
result.Detail = "Hysteria 2 UDP 未监听:" + strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func heartbeat() error {
|
func heartbeat() error {
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
engine, err := engineHealthLocked()
|
// A readResources error skips the heartbeat entirely (JS throws).
|
||||||
stateMu.Unlock()
|
resources, err := readResources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
stateMu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
engine := engineHealthLocked(resources)
|
||||||
|
stateMu.Unlock()
|
||||||
|
// The panel reconciles against this list: any expected resource the node
|
||||||
|
// no longer holds (e.g. local state was wiped) is re-dispatched
|
||||||
|
// automatically. An empty list is meaningful, so keep it non-nil.
|
||||||
|
resourceIDs := []string{}
|
||||||
|
for _, res := range resources {
|
||||||
|
if s, ok := res["id"].(string); ok && s != "" {
|
||||||
|
resourceIDs = append(resourceIDs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"version": version,
|
"version": version,
|
||||||
"system": systemInfo(),
|
"system": systemInfo(),
|
||||||
"engine": engine,
|
"engine": engine,
|
||||||
|
"resources": resourceIDs,
|
||||||
}
|
}
|
||||||
if _, err := requestJSON("/api/agent/heartbeat", body); err != nil {
|
if _, err := requestJSON("/api/agent/heartbeat", body); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+5
-1
@@ -339,7 +339,11 @@ function serviceHealth(service) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function heartbeat() {
|
async function heartbeat() {
|
||||||
await request('/api/agent/heartbeat', { method: 'POST', body: JSON.stringify({ version: VERSION, system: systemInfo(), engine: engineHealth() }) });
|
const engine = engineHealth();
|
||||||
|
// Report the resources actually held locally; the panel re-dispatches any
|
||||||
|
// expected resource missing from this list (e.g. after a state wipe).
|
||||||
|
const resources = readResources().map((resource) => resource && resource.id).filter((value) => typeof value === 'string' && value);
|
||||||
|
await request('/api/agent/heartbeat', { method: 'POST', body: JSON.stringify({ version: VERSION, system: systemInfo(), engine, resources }) });
|
||||||
try { fs.writeFileSync('/etc/nexusgate/last-heartbeat.json', `${JSON.stringify({ at:new Date().toISOString() })}\n`, { mode:0o600 }); }
|
try { fs.writeFileSync('/etc/nexusgate/last-heartbeat.json', `${JSON.stringify({ at:new Date().toISOString() })}\n`, { mode:0o600 }); }
|
||||||
catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); }
|
catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-31
@@ -12,6 +12,9 @@ const {
|
|||||||
const nowIso = () => new Date().toISOString();
|
const nowIso = () => new Date().toISOString();
|
||||||
const id = (prefix) => `${prefix}_${crypto.randomUUID()}`;
|
const id = (prefix) => `${prefix}_${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
// Minimum gap between two heartbeat-triggered reconciliations of one server.
|
||||||
|
const HEARTBEAT_RECONCILE_INTERVAL_MS = 120000;
|
||||||
|
|
||||||
function assertFound(value, label) {
|
function assertFound(value, label) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
const error = new Error(`${label}不存在`);
|
const error = new Error(`${label}不存在`);
|
||||||
@@ -270,8 +273,53 @@ function validateNodeDeploy(data, node) {
|
|||||||
if (!direct && nodePortInUse(data, node.exitServerId, node.exitPort, node.id)) { const error = new Error(`出口端口 ${node.exitPort} 已被该服务器上的其他节点占用`); 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-queue apply/delete jobs so the agent's local resources converge to the
|
||||||
|
// panel's desired state. onlyNodeIds restricts the pass to those nodes (used
|
||||||
|
// by heartbeat reconciliation to heal just the nodes with missing resources);
|
||||||
|
// failInFlight marks queued/running jobs as superseded (enrollment semantics:
|
||||||
|
// a re-registering agent abandons its old leases). Heartbeat reconciliation
|
||||||
|
// gates on "no in-flight jobs" instead and passes failInFlight: false.
|
||||||
|
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
|
||||||
|
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
|
||||||
|
if (failInFlight) {
|
||||||
|
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)) {
|
||||||
|
if (onlyNodeIds && !onlyNodeIds.has(node.id)) continue;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
class Orchestrator {
|
class Orchestrator {
|
||||||
constructor(store) { this.store = store; }
|
constructor(store) {
|
||||||
|
this.store = store;
|
||||||
|
// serverId -> epoch ms of the last heartbeat-triggered reconciliation.
|
||||||
|
this.heartbeatReconcileAt = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
async deployNode(nodeId, actor = 'admin') {
|
async deployNode(nodeId, actor = 'admin') {
|
||||||
return this.store.transaction(async (data) => {
|
return this.store.transaction(async (data) => {
|
||||||
@@ -360,38 +408,44 @@ class Orchestrator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileServer(serverId, actor = 'system') {
|
async reconcileServer(serverId, actor = 'system', options = {}) {
|
||||||
return this.store.transaction(async (data) => {
|
return this.store.transaction(async (data) => reconcileServerData(data, serverId, actor, options));
|
||||||
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)) {
|
// Heartbeat-driven reconciliation. Newer agents report the resource IDs
|
||||||
const isEntry = node.serverId === serverId;
|
// they actually hold; when a resource the panel expects is missing locally
|
||||||
const isExit = node.exitServerId === serverId && forwardNode(node);
|
// (e.g. the node's /etc/nexusgate state was wiped and only the agent was
|
||||||
if (!isEntry && !isExit) continue;
|
// restarted, so no re-enrollment ever happens), re-queue applies for just
|
||||||
if (node.status === 'draft') continue;
|
// the affected nodes. Old agents never send `resources`, and callers only
|
||||||
if (node.status === 'removing') {
|
// invoke this for array reports, so their behavior is unchanged. Resources
|
||||||
if (isEntry) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' });
|
// the agent holds but the panel no longer expects are NOT cleaned up here.
|
||||||
if (isExit && node.exitResourceId) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' });
|
// Gates: never run while the server has queued/running jobs (an in-flight
|
||||||
} else {
|
// deploy explains the gap), and at most one trigger per server per
|
||||||
const clients = activeNodeClients(data, node);
|
// HEARTBEAT_RECONCILE_INTERVAL_MS so a node that cannot heal does not spin.
|
||||||
if (!clients.length) continue;
|
async reconcileFromHeartbeat(serverId, reportedResourceIds) {
|
||||||
if (isEntry) {
|
if (!Array.isArray(reportedResourceIds)) return 0;
|
||||||
const payload = { resource: await resourceForNode(data, node, clients), kind: 'node' };
|
const last = this.heartbeatReconcileAt.get(serverId) || 0;
|
||||||
const keyMaterial = keyMaterialForNode(data, node);
|
if (Date.now() - last < HEARTBEAT_RECONCILE_INTERVAL_MS) return 0;
|
||||||
if (keyMaterial) payload.keyMaterial = keyMaterial;
|
const reported = new Set(reportedResourceIds.slice(0, 1000).filter((value) => typeof value === 'string' && value));
|
||||||
queueJob(data, serverId, node.id, 'apply_resource', payload);
|
let triggered = false;
|
||||||
|
const count = await this.store.transaction(async (data) => {
|
||||||
|
const server = data.servers.find((item) => item.id === serverId);
|
||||||
|
if (!server) return 0;
|
||||||
|
const missingNodeIds = new Set();
|
||||||
|
for (const node of data.nodes) {
|
||||||
|
if (node.status === 'draft' || node.status === 'removing') continue;
|
||||||
|
if (!activeNodeClients(data, node).length) continue;
|
||||||
|
if (node.serverId === serverId && node.resourceId && !reported.has(node.resourceId)) missingNodeIds.add(node.id);
|
||||||
|
if (node.exitServerId === serverId && forwardNode(node) && node.exitResourceId && !reported.has(node.exitResourceId)) missingNodeIds.add(node.id);
|
||||||
}
|
}
|
||||||
if (isExit) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
|
if (!missingNodeIds.size) return 0;
|
||||||
node.status = 'deploying';
|
const inFlight = data.jobs.some((job) => job.serverId === serverId && ['queued', 'running'].includes(job.status));
|
||||||
}
|
if (inFlight) return 0;
|
||||||
node.updatedAt = nowIso(); count += 1;
|
triggered = true;
|
||||||
}
|
return reconcileServerData(data, serverId, 'system:heartbeat', { onlyNodeIds: missingNodeIds, failInFlight: false });
|
||||||
audit(data, actor, 'reconcile_server', server.id, { nodes: count });
|
|
||||||
return count;
|
|
||||||
});
|
});
|
||||||
|
if (triggered) this.heartbeatReconcileAt.set(serverId, Date.now());
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeJob(jobId, success, result = {}, errorMessage = null) {
|
async completeJob(jobId, success, result = {}, errorMessage = null) {
|
||||||
@@ -487,4 +541,4 @@ class Orchestrator {
|
|||||||
|
|
||||||
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
|
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
|
||||||
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
|
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
|
||||||
suspendCustomerResources, resumeCustomerResources };
|
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS };
|
||||||
|
|||||||
@@ -308,6 +308,14 @@ async function handleAgent(req, res, pathname) {
|
|||||||
: server.engine || null;
|
: server.engine || null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Agents that report their locally held resource IDs get automatic
|
||||||
|
// re-dispatch of anything the panel expects but the node lost (e.g. a
|
||||||
|
// wiped /etc/nexusgate with no re-enrollment). Reports without the field
|
||||||
|
// (older agents) skip this entirely.
|
||||||
|
if (Array.isArray(body.resources)) {
|
||||||
|
try { await orchestrator.reconcileFromHeartbeat(agent.serverId, body.resources); }
|
||||||
|
catch (error) { console.error('Heartbeat reconciliation failed:', error); }
|
||||||
|
}
|
||||||
sendJson(res, 200, { ok: true, serverTime: nowIso() });
|
sendJson(res, 200, { ok: true, serverTime: nowIso() });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const net = require('node:net');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { Store } = require('../lib/store');
|
||||||
|
const { Orchestrator } = require('../lib/orchestrator');
|
||||||
|
|
||||||
|
const B64URL_43 = /^[A-Za-z0-9_-]{43}$/;
|
||||||
|
|
||||||
|
// One server, one customer, one active direct Reality node whose resource the
|
||||||
|
// panel believes is deployed on the agent.
|
||||||
|
async function fixture(t) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-hb-'));
|
||||||
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const store = await new Store(path.join(dir, 'data.json')).init();
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
await store.transaction((data) => {
|
||||||
|
data.servers.push({ id: 'srv_hb', name: '入口', publicAddress: '203.0.113.10', portRangeStart: 20000, portRangeEnd: 50000, status: 'online', lastSeenAt: stamp });
|
||||||
|
data.customers.push({ id: 'cus_hb', name: '客户', status: 'active' });
|
||||||
|
data.nodes.push({
|
||||||
|
id: 'nod_hb', serverId: 'srv_hb', name: 'Reality 节点', protocol: 'vless-reality-vision',
|
||||||
|
topology: 'direct', networkMode: 'ipv4', port: 23456, resourceId: 'res_nod_hb_1',
|
||||||
|
status: 'active', entryReady: true, realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp,
|
||||||
|
});
|
||||||
|
data.nodeClients.push({
|
||||||
|
id: 'ncl_hb', nodeId: 'nod_hb', customerId: 'cus_hb', status: 'active',
|
||||||
|
credentials: { clientId: crypto.randomUUID(), relayPassword: 'secret', shortId: 'abcd1234' },
|
||||||
|
createdAt: stamp, updatedAt: stamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { store, orchestrator: new Orchestrator(store) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJobs(store, nodeId) {
|
||||||
|
return store.data.jobs.filter((job) => job.action === 'apply_resource' && (!nodeId || job.nodeId === nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('heartbeat reconcile requeues an apply with keyMaterial for a missing resource', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
const count = await orchestrator.reconcileFromHeartbeat('srv_hb', []);
|
||||||
|
assert.equal(count, 1);
|
||||||
|
const jobs = applyJobs(store, 'nod_hb');
|
||||||
|
assert.equal(jobs.length, 1);
|
||||||
|
assert.equal(jobs[0].status, 'queued');
|
||||||
|
assert.equal(jobs[0].serverId, 'srv_hb');
|
||||||
|
assert.equal(jobs[0].payload.kind, 'node');
|
||||||
|
const material = jobs[0].payload.keyMaterial;
|
||||||
|
assert.deepEqual(Object.keys(material), ['reality-nod_hb']);
|
||||||
|
assert.match(material['reality-nod_hb'].privateKey, B64URL_43);
|
||||||
|
assert.equal(store.data.keyStore['reality-nod_hb'].privateKey, material['reality-nod_hb'].privateKey);
|
||||||
|
assert.equal(store.data.nodes[0].status, 'deploying');
|
||||||
|
assert.ok(store.data.activity.some((event) => event.action === 'reconcile_server' && event.actor === 'system:heartbeat'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat reconcile is a no-op when the agent holds every expected resource', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', ['res_nod_hb_1']), 0);
|
||||||
|
assert.equal(store.data.jobs.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat reconcile does not trigger or disturb jobs while a deploy is in flight', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
await store.transaction((data) => {
|
||||||
|
data.jobs.push({ id: 'job_busy', serverId: 'srv_hb', nodeId: 'nod_hb', action: 'apply_resource',
|
||||||
|
payload: { kind: 'node' }, status: 'queued', attempts: 0, createdAt: stamp, updatedAt: stamp });
|
||||||
|
});
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', []), 0);
|
||||||
|
assert.equal(store.data.jobs.length, 1);
|
||||||
|
// The in-flight job must NOT be failed the way enrollment reconcile fails it.
|
||||||
|
assert.equal(store.data.jobs[0].status, 'queued');
|
||||||
|
assert.equal(store.data.activity.some((event) => event.action === 'reconcile_server'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat reconcile is throttled per server', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', []), 1);
|
||||||
|
assert.equal(applyJobs(store, 'nod_hb').length, 1);
|
||||||
|
// Clear the in-flight gate so only the throttle can explain a no-op.
|
||||||
|
await store.transaction((data) => { data.jobs[0].status = 'completed'; });
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', []), 0);
|
||||||
|
assert.equal(applyJobs(store, 'nod_hb').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat reconcile ignores legacy reports and out-of-scope nodes', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
// Old agents: no resources field at all.
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', undefined), 0);
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', 'res_nod_hb_1'), 0);
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
await store.transaction((data) => {
|
||||||
|
// Draft node (never deployed), clientless node, and a removing node:
|
||||||
|
// none of their resources are "expected" on the agent.
|
||||||
|
data.nodes.push(
|
||||||
|
{ id: 'nod_draft', serverId: 'srv_hb', name: '草稿', protocol: 'vless-reality-vision', topology: 'direct', networkMode: 'ipv4', port: 23457, resourceId: 'res_draft', status: 'draft', realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp },
|
||||||
|
{ id: 'nod_idle', serverId: 'srv_hb', name: '无客户', protocol: 'vless-reality-vision', topology: 'direct', networkMode: 'ipv4', port: 23458, resourceId: 'res_idle', status: 'active', entryReady: true, realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp },
|
||||||
|
{ id: 'nod_rm', serverId: 'srv_hb', name: '删除中', protocol: 'vless-reality-vision', topology: 'direct', networkMode: 'ipv4', port: 23459, resourceId: 'res_rm', status: 'removing', entryReady: true, realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp },
|
||||||
|
);
|
||||||
|
data.nodeClients.push(
|
||||||
|
{ id: 'ncl_draft', nodeId: 'nod_draft', customerId: 'cus_hb', status: 'active', credentials: { clientId: crypto.randomUUID(), relayPassword: 'x', shortId: 'aa' }, createdAt: stamp, updatedAt: stamp },
|
||||||
|
{ id: 'ncl_rm', nodeId: 'nod_rm', customerId: 'cus_hb', status: 'active', credentials: { clientId: crypto.randomUUID(), relayPassword: 'x', shortId: 'bb' }, createdAt: stamp, updatedAt: stamp },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Only the in-scope node (nod_hb) is requeued; the heartbeat holds nothing.
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_hb', []), 1);
|
||||||
|
assert.deepEqual(applyJobs(store).map((job) => job.nodeId), ['nod_hb']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat reconcile requeues a forward node whose exit resource is missing', async (t) => {
|
||||||
|
const { store, orchestrator } = await fixture(t);
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
await store.transaction((data) => {
|
||||||
|
data.servers.push({ id: 'srv_exit', name: '出口', publicAddress: '203.0.113.20', portRangeStart: 20000, portRangeEnd: 50000, status: 'online', lastSeenAt: stamp });
|
||||||
|
data.nodes.push({
|
||||||
|
id: 'nod_fwd', serverId: 'srv_hb', name: '中转节点', protocol: 'vless-reality-vision', topology: 'forward',
|
||||||
|
exitServerId: 'srv_exit', exitProtocol: 'shadowsocks-2022-aes128', exitPort: 23460, exitResourceId: 'res_fwd_exit',
|
||||||
|
exitCredentials: { password: 'exit-secret' },
|
||||||
|
networkMode: 'ipv4', port: 23461, resourceId: 'res_fwd_in',
|
||||||
|
status: 'active', entryReady: true, exitReady: true, realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp,
|
||||||
|
});
|
||||||
|
data.nodeClients.push({
|
||||||
|
id: 'ncl_fwd', nodeId: 'nod_fwd', customerId: 'cus_hb', status: 'active',
|
||||||
|
credentials: { clientId: crypto.randomUUID(), relayPassword: 'secret', shortId: 'ccdd1234' },
|
||||||
|
createdAt: stamp, updatedAt: stamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// The exit server's agent holds nothing: the exit resource is re-dispatched.
|
||||||
|
assert.equal(await orchestrator.reconcileFromHeartbeat('srv_exit', []), 1);
|
||||||
|
const exitJobs = applyJobs(store, 'nod_fwd').filter((job) => job.serverId === 'srv_exit');
|
||||||
|
assert.equal(exitJobs.length, 1);
|
||||||
|
assert.equal(exitJobs[0].payload.kind, 'node-exit');
|
||||||
|
// The entry server's agent holds its resource: nothing is queued for it.
|
||||||
|
assert.equal(applyJobs(store, 'nod_fwd').filter((job) => job.serverId === 'srv_hb').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(url) {
|
||||||
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error('test server did not start');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('E2E: wiped agent state is healed by a heartbeat reporting no resources', async (t) => {
|
||||||
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-hb-e2e-'));
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); });
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
await waitFor(`${base}/healthz`);
|
||||||
|
|
||||||
|
const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) });
|
||||||
|
assert.equal(login.status, 200);
|
||||||
|
const session = await login.json();
|
||||||
|
const cookie = login.headers.get('set-cookie').split(';')[0];
|
||||||
|
const request = async (pathname, method = 'GET', body) => {
|
||||||
|
const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
const json = await response.json();
|
||||||
|
assert.ok(response.ok, JSON.stringify(json));
|
||||||
|
return json;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = (await request('/api/servers', 'POST', { name: '入口 01', role: 'relay', publicAddress: 'entry.example.com' })).server;
|
||||||
|
const customer = (await request('/api/customers', 'POST', { name: '客户 A' })).customer;
|
||||||
|
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
|
||||||
|
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'sim-agent', version: '0.6.8' }) });
|
||||||
|
assert.equal(enrolled.status, 201);
|
||||||
|
const agentKey = (await enrolled.json()).agentKey;
|
||||||
|
const agentHeaders = { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' };
|
||||||
|
const heartbeat = async (body) => {
|
||||||
|
const response = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: agentHeaders, body: JSON.stringify(body) });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
};
|
||||||
|
const poll = async () => {
|
||||||
|
const response = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: agentHeaders, body: '{}' });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
return (await response.json()).job;
|
||||||
|
};
|
||||||
|
|
||||||
|
await heartbeat({ version: '0.6.8', system: {} });
|
||||||
|
const node = (await request('/api/nodes', 'POST', { name: 'Reality 直连', serverId: server.id, protocol: 'vless-reality-vision', topology: 'direct', realityServerName: 'www.amd.com' })).node;
|
||||||
|
await request(`/api/nodes/${node.id}/assign`, 'POST', { customerId: customer.id });
|
||||||
|
await request(`/api/nodes/${node.id}/deploy`, 'POST', {});
|
||||||
|
|
||||||
|
const keyId = `reality-${node.id}`;
|
||||||
|
const first = await poll();
|
||||||
|
assert.ok(first, 'initial deploy job was dispatched');
|
||||||
|
assert.equal(first.action, 'apply_resource');
|
||||||
|
assert.match(first.payload.keyMaterial[keyId].privateKey, B64URL_43);
|
||||||
|
const completed = await fetch(`${base}/api/agent/jobs/${first.id}/complete`, { method: 'POST', headers: agentHeaders, body: JSON.stringify({ success: true, result: { artifacts: {} } }) });
|
||||||
|
assert.equal(completed.status, 200);
|
||||||
|
assert.equal(await poll(), null);
|
||||||
|
|
||||||
|
// The node's local state is wiped and only the agent restarts: its next
|
||||||
|
// heartbeat reports no resources, and the panel must re-dispatch on its own.
|
||||||
|
await heartbeat({ version: '0.6.8', system: {}, resources: [] });
|
||||||
|
const healed = await poll();
|
||||||
|
assert.ok(healed, 'heartbeat with empty resources triggered a re-dispatch');
|
||||||
|
assert.equal(healed.action, 'apply_resource');
|
||||||
|
assert.equal(healed.nodeId, node.id);
|
||||||
|
assert.equal(healed.payload.keyMaterial[keyId].privateKey, first.payload.keyMaterial[keyId].privateKey);
|
||||||
|
assert.equal(healed.payload.keyMaterial[keyId].publicKey, first.payload.keyMaterial[keyId].publicKey);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user