@camstack/server 1.1.76 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-cap-dispatch-service.js +93 -0
- package/dist/agent/agent-config.js +127 -0
- package/dist/agent/agent-deploy-swap.js +137 -0
- package/dist/agent/agent-group-runner.js +175 -0
- package/dist/agent/agent-http-auth.js +76 -0
- package/dist/agent/agent-http.js +444 -0
- package/dist/agent/agent-service.js +595 -0
- package/dist/agent/agent-update-service.js +184 -0
- package/dist/agent/apply-model-distribution.js +14 -0
- package/dist/agent/derive-hub-url.js +139 -0
- package/dist/agent/fetch-bundle-from-hub.js +46 -0
- package/dist/agent/main.js +1137 -0
- package/dist/agent/register-agent-cap-dispatch.js +37 -0
- package/dist/core/agent/agent-registry.service.js +4 -3
- package/dist/core/server-update/server-update.service.js +14 -5
- package/dist/core/server-update/system-exec-npm.js +33 -0
- package/dist/launcher.js +28 -5
- package/dist/node-role.js +11 -0
- package/dist/server-root/index.js +0 -8
- package/package.json +27 -14
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Agent HTTP server -- lightweight Fastify instance for agent status,
|
|
4
|
+
* process management, config editing, and static UI serving.
|
|
5
|
+
*/
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
40
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.pickIpv4 = void 0;
|
|
44
|
+
exports.compareVersions = compareVersions;
|
|
45
|
+
exports.readUiDistCandidate = readUiDistCandidate;
|
|
46
|
+
exports.pickUiDist = pickUiDist;
|
|
47
|
+
exports.resolveUiDistDir = resolveUiDistDir;
|
|
48
|
+
exports.getRegistryNodes = getRegistryNodes;
|
|
49
|
+
exports.resolveHubEndpoint = resolveHubEndpoint;
|
|
50
|
+
exports.mapDiscoveredNodes = mapDiscoveredNodes;
|
|
51
|
+
exports.createAgentHttpServer = createAgentHttpServer;
|
|
52
|
+
exports.startAgentHttpServer = startAgentHttpServer;
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
const fastify_1 = __importDefault(require("fastify"));
|
|
56
|
+
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
57
|
+
Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
|
|
58
|
+
const agent_http_auth_js_1 = require("./agent-http-auth.js");
|
|
59
|
+
/** Dotted-numeric version compare; `null` sorts lowest. */
|
|
60
|
+
function compareVersions(a, b) {
|
|
61
|
+
if (a === null && b === null)
|
|
62
|
+
return 0;
|
|
63
|
+
if (a === null)
|
|
64
|
+
return -1;
|
|
65
|
+
if (b === null)
|
|
66
|
+
return 1;
|
|
67
|
+
const pa = a.split('.').map((s) => Number.parseInt(s, 10) || 0);
|
|
68
|
+
const pb = b.split('.').map((s) => Number.parseInt(s, 10) || 0);
|
|
69
|
+
const len = Math.max(pa.length, pb.length);
|
|
70
|
+
for (let i = 0; i < len; i++) {
|
|
71
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
72
|
+
if (diff !== 0)
|
|
73
|
+
return diff;
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read an agent-ui package dir (`<base>/package.json` + `<base>/dist/index.html`)
|
|
79
|
+
* into a candidate. Returns `null` when the dist is absent/unusable.
|
|
80
|
+
*/
|
|
81
|
+
function readUiDistCandidate(baseDir) {
|
|
82
|
+
const dir = path.join(baseDir, 'dist');
|
|
83
|
+
if (!fs.existsSync(path.join(dir, 'index.html')))
|
|
84
|
+
return null;
|
|
85
|
+
let version = null;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(baseDir, 'package.json'), 'utf-8'));
|
|
88
|
+
version = typeof parsed.version === 'string' ? parsed.version : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
version = null;
|
|
92
|
+
}
|
|
93
|
+
return { dir, version };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pick between the dataDir-installed copy and the app-bundled copy: the
|
|
97
|
+
* NEWER version wins; on a tie the installed copy wins (it is the one
|
|
98
|
+
* `camstack deploy` updates).
|
|
99
|
+
*
|
|
100
|
+
* Rationale: bootstrap addon install is seed-only (first boot), so a
|
|
101
|
+
* packaged desktop app that ships a newer agent-ui in its resources would
|
|
102
|
+
* otherwise keep serving the UI frozen at whatever version was first
|
|
103
|
+
* installed (observed live: an app at v1.1.27 serving the v1.1.1 UI).
|
|
104
|
+
*/
|
|
105
|
+
function pickUiDist(installed, bundled) {
|
|
106
|
+
if (installed && bundled) {
|
|
107
|
+
return compareVersions(bundled.version, installed.version) > 0 ? bundled.dir : installed.dir;
|
|
108
|
+
}
|
|
109
|
+
return installed?.dir ?? bundled?.dir ?? null;
|
|
110
|
+
}
|
|
111
|
+
function resolveUiDistDir(dataDir, bundledAddonsDir,
|
|
112
|
+
// Workspace / npm-package sibling — dev checkouts and Docker images where
|
|
113
|
+
// @camstack/addon-agent-ui sits next to @camstack/agent in node_modules.
|
|
114
|
+
// Always version-matched to the agent, so it keeps top priority.
|
|
115
|
+
// Injectable so tests are independent of the workspace checkout.
|
|
116
|
+
siblingDistDir = path.resolve(__dirname, '../../addon-agent-ui/dist')) {
|
|
117
|
+
if (fs.existsSync(path.join(siblingDistDir, 'index.html')))
|
|
118
|
+
return siblingDistDir;
|
|
119
|
+
const installed = readUiDistCandidate(path.join(dataDir, 'addons', '@camstack', 'addon-agent-ui'));
|
|
120
|
+
const bundled = bundledAddonsDir
|
|
121
|
+
? readUiDistCandidate(path.join(bundledAddonsDir, 'addon-agent-ui'))
|
|
122
|
+
: null;
|
|
123
|
+
const picked = pickUiDist(installed, bundled);
|
|
124
|
+
if (picked)
|
|
125
|
+
return picked;
|
|
126
|
+
const legacy = path.join(dataDir, 'agent-ui');
|
|
127
|
+
if (fs.existsSync(path.join(legacy, 'index.html')))
|
|
128
|
+
return legacy;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function readConfigFile(configPath) {
|
|
132
|
+
if (!fs.existsSync(configPath))
|
|
133
|
+
return {};
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function writeConfigFile(configPath, data) {
|
|
142
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
143
|
+
fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
144
|
+
}
|
|
145
|
+
/** Live Moleculer registry snapshot — reused by agent-bootstrap's Option A. */
|
|
146
|
+
function getRegistryNodes(broker) {
|
|
147
|
+
try {
|
|
148
|
+
const registry = broker.registry;
|
|
149
|
+
return registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A human-readable, reachable address for the hub node as the agent actually
|
|
157
|
+
* sees it in the Moleculer registry. Used to fill in the effective address
|
|
158
|
+
* even in UDP-discovery mode where no address was manually configured.
|
|
159
|
+
*/
|
|
160
|
+
function resolveHubEndpoint(nodes) {
|
|
161
|
+
const hub = nodes.find((n) => n.id === 'hub');
|
|
162
|
+
if (!hub)
|
|
163
|
+
return null;
|
|
164
|
+
return (0, derive_hub_url_js_1.pickIpv4)(hub.ipList) ?? hub.hostname ?? null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Map raw registry nodes (minus self) to the UI-facing shape: the hub carries
|
|
168
|
+
* its real hostname so the UI can render "hostname (hub)" instead of the bare
|
|
169
|
+
* `hub` node id.
|
|
170
|
+
*/
|
|
171
|
+
function mapDiscoveredNodes(nodes, selfId) {
|
|
172
|
+
return nodes
|
|
173
|
+
.filter((n) => n.id !== selfId)
|
|
174
|
+
.map((n) => ({ id: n.id, hostname: n.hostname ?? n.id, isHub: n.id === 'hub' }));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Derive the hub connection state from broker registry + config when no
|
|
178
|
+
* external getter is wired. Does NOT detect `secret-mismatch` — that
|
|
179
|
+
* requires the bootstrap to inject `getHubConnectionState`.
|
|
180
|
+
*/
|
|
181
|
+
function deriveHubConnectionState(nodes, discoveryMode) {
|
|
182
|
+
const hubInRegistry = nodes.some((n) => n.id === 'hub');
|
|
183
|
+
if (hubInRegistry)
|
|
184
|
+
return 'connected';
|
|
185
|
+
if (discoveryMode)
|
|
186
|
+
return 'searching';
|
|
187
|
+
return 'disconnected';
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Read the current effective config from the persisted file.
|
|
191
|
+
* This is the single source of truth -- always reflects what the UI wrote.
|
|
192
|
+
*/
|
|
193
|
+
function getEffectiveConfig(configPath, nodeId) {
|
|
194
|
+
const raw = readConfigFile(configPath);
|
|
195
|
+
return {
|
|
196
|
+
name: typeof raw.name === 'string' ? raw.name : nodeId,
|
|
197
|
+
hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
|
|
198
|
+
hasSecret: typeof raw.secret === 'string' && raw.secret.length > 0,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Factory
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
async function createAgentHttpServer(getBroker, config) {
|
|
205
|
+
const app = (0, fastify_1.default)({ logger: false });
|
|
206
|
+
const cors = await Promise.resolve().then(() => __importStar(require('@fastify/cors')));
|
|
207
|
+
await app.register(cors.default);
|
|
208
|
+
// -- Auth gate (port-4444 hardening, mirrors the hub's /health work) --
|
|
209
|
+
// Loopback (Electron renderer, in-container probes) always passes;
|
|
210
|
+
// remote callers need `Authorization: Bearer <cluster secret>`. While
|
|
211
|
+
// the agent is UNPAIRED (no secret configured) only the pairing surface
|
|
212
|
+
// is reachable remotely. Registered as an onRequest hook over the /api
|
|
213
|
+
// + /health/details prefixes so any FUTURE route is gated fail-closed.
|
|
214
|
+
const currentSecret = () => {
|
|
215
|
+
const raw = readConfigFile(config.configPath);
|
|
216
|
+
return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
|
|
217
|
+
};
|
|
218
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
219
|
+
const pathOnly = req.url.split('?')[0] ?? req.url;
|
|
220
|
+
const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
|
|
221
|
+
if (!isProtected)
|
|
222
|
+
return;
|
|
223
|
+
const secret = currentSecret();
|
|
224
|
+
const authInput = {
|
|
225
|
+
remoteAddress: req.socket.remoteAddress ?? undefined,
|
|
226
|
+
...(typeof req.headers.authorization === 'string'
|
|
227
|
+
? { authorization: req.headers.authorization }
|
|
228
|
+
: {}),
|
|
229
|
+
};
|
|
230
|
+
if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret))
|
|
231
|
+
return;
|
|
232
|
+
if (secret === null && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly))
|
|
233
|
+
return;
|
|
234
|
+
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
235
|
+
});
|
|
236
|
+
// -- Health ---------------------------------------------------------
|
|
237
|
+
// PUBLIC probe: liveness only — `{ok}`, no nodeId/version/topology (a
|
|
238
|
+
// public endpoint must not enumerate the deployment; same policy as the
|
|
239
|
+
// hub's /health). The detailed shape lives on `$agent.health` (Moleculer
|
|
240
|
+
// action) for the hub, and on the AUTHENTICATED /health/details below
|
|
241
|
+
// for external monitors.
|
|
242
|
+
app.get('/health', async (_req, reply) => {
|
|
243
|
+
try {
|
|
244
|
+
await getBroker().call('$agent.health');
|
|
245
|
+
return { ok: true };
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return reply.status(503).send({ ok: false });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
// AUTHENTICATED detailed health — the payload /health used to return.
|
|
252
|
+
app.get('/health/details', async (_req, reply) => {
|
|
253
|
+
try {
|
|
254
|
+
const result = await getBroker().call('$agent.health');
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
259
|
+
return reply.status(503).send({
|
|
260
|
+
ok: false,
|
|
261
|
+
nodeId: config.nodeId,
|
|
262
|
+
error: message,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
// -- Agent status (enriched with connection state) ------------------
|
|
267
|
+
app.get('/api/agent/status', async () => {
|
|
268
|
+
const broker = getBroker();
|
|
269
|
+
const eff = getEffectiveConfig(config.configPath, config.nodeId);
|
|
270
|
+
const nodes = getRegistryNodes(broker);
|
|
271
|
+
const hubConnected = nodes.some((n) => n.id === 'hub');
|
|
272
|
+
const discoveryMode = !eff.hubAddress;
|
|
273
|
+
const hubConnectionState = config.getHubConnectionState
|
|
274
|
+
? config.getHubConnectionState()
|
|
275
|
+
: deriveHubConnectionState(nodes, discoveryMode);
|
|
276
|
+
// The address the agent actually reaches the hub at (from the Moleculer
|
|
277
|
+
// registry) — lets the UI show the real endpoint even under UDP discovery
|
|
278
|
+
// where `eff.hubAddress` is null.
|
|
279
|
+
const resolvedHubAddress = resolveHubEndpoint(nodes);
|
|
280
|
+
// Version of the Electron wrapper (injected by the desktop app); absent
|
|
281
|
+
// when the agent runs headless / in Docker.
|
|
282
|
+
const appVersion = process.env['CAMSTACK_AGENT_APP_VERSION'] ?? null;
|
|
283
|
+
const discoveredNodes = mapDiscoveredNodes(nodes, broker.nodeID);
|
|
284
|
+
try {
|
|
285
|
+
const status = (await broker.call('$agent.status'));
|
|
286
|
+
return {
|
|
287
|
+
...status,
|
|
288
|
+
name: eff.name,
|
|
289
|
+
hubAddress: eff.hubAddress,
|
|
290
|
+
resolvedHubAddress,
|
|
291
|
+
appVersion,
|
|
292
|
+
hubConnected,
|
|
293
|
+
hubConnectionState,
|
|
294
|
+
discoveryMode,
|
|
295
|
+
hasSecret: eff.hasSecret,
|
|
296
|
+
discoveredNodes,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return {
|
|
301
|
+
nodeId: config.nodeId,
|
|
302
|
+
name: eff.name,
|
|
303
|
+
hubAddress: eff.hubAddress,
|
|
304
|
+
resolvedHubAddress,
|
|
305
|
+
appVersion,
|
|
306
|
+
hubConnected,
|
|
307
|
+
hubConnectionState,
|
|
308
|
+
discoveryMode,
|
|
309
|
+
hasSecret: eff.hasSecret,
|
|
310
|
+
discoveredNodes,
|
|
311
|
+
addons: [],
|
|
312
|
+
localIps: [],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
// -- Processes ------------------------------------------------------
|
|
317
|
+
app.get('/api/agent/processes', async () => {
|
|
318
|
+
try {
|
|
319
|
+
return await getBroker().call('$process.list');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// -- Addon restart --------------------------------------------------
|
|
326
|
+
app.post('/api/agent/addon/restart', async (req, reply) => {
|
|
327
|
+
const addonId = req.body?.addonId;
|
|
328
|
+
if (!addonId)
|
|
329
|
+
return reply.status(400).send({ error: 'addonId required' });
|
|
330
|
+
return getBroker().call('$agent.restart', { addonId });
|
|
331
|
+
});
|
|
332
|
+
// -- Process restart ------------------------------------------------
|
|
333
|
+
app.post('/api/agent/process/restart', async (req, reply) => {
|
|
334
|
+
const name = req.body?.name;
|
|
335
|
+
if (!name)
|
|
336
|
+
return reply.status(400).send({ error: 'name required' });
|
|
337
|
+
return getBroker().call('$process.restart', { name });
|
|
338
|
+
});
|
|
339
|
+
// -- Config read (always from file -- single source of truth) -------
|
|
340
|
+
app.get('/api/agent/config', async () => {
|
|
341
|
+
const persisted = readConfigFile(config.configPath);
|
|
342
|
+
const eff = getEffectiveConfig(config.configPath, config.nodeId);
|
|
343
|
+
return {
|
|
344
|
+
nodeId: config.nodeId,
|
|
345
|
+
name: eff.name,
|
|
346
|
+
hubAddress: eff.hubAddress,
|
|
347
|
+
hasSecret: eff.hasSecret,
|
|
348
|
+
configPath: config.configPath,
|
|
349
|
+
dataDir: config.dataDir,
|
|
350
|
+
// Include all persisted fields except raw secret
|
|
351
|
+
...Object.fromEntries(Object.entries(persisted).filter(([k]) => k !== 'secret')),
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
// -- Config write (merge-patch + persist) ---------------------------
|
|
355
|
+
// Fastify (not Express) natively awaits async route handlers and serializes
|
|
356
|
+
// the returned value / catches rejections, so an async handler is correct here.
|
|
357
|
+
// eslint-disable-next-line oxc/no-async-endpoint-handlers
|
|
358
|
+
app.post('/api/agent/config', async (req) => {
|
|
359
|
+
const patch = req.body ?? {};
|
|
360
|
+
const existing = readConfigFile(config.configPath);
|
|
361
|
+
const merged = { ...existing, ...patch };
|
|
362
|
+
writeConfigFile(config.configPath, merged);
|
|
363
|
+
// Apply name change immediately (no reconnect needed)
|
|
364
|
+
if (typeof patch.name === 'string' && patch.name.trim()) {
|
|
365
|
+
try {
|
|
366
|
+
await getBroker().call('$agent.rename', { name: patch.name.trim() });
|
|
367
|
+
console.log(`[Agent] Name changed to "${patch.name.trim()}"`);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
/* best-effort */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Only flag reconnect when a connection-affecting field actually
|
|
374
|
+
// CHANGED. The form posts every visible field on every Save, so
|
|
375
|
+
// `patch.hubAddress !== undefined` was always true and produced
|
|
376
|
+
// false-positive "Restart required" prompts on no-op saves.
|
|
377
|
+
const reconnectRelevant = ['hubAddress', 'secret'];
|
|
378
|
+
const needsReconnect = reconnectRelevant.some((k) => Object.prototype.hasOwnProperty.call(patch, k) && patch[k] !== existing[k]);
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
restartRequired: needsReconnect,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
// -- Agent reconnect (applies new hub/secret config) ----------------
|
|
385
|
+
app.post('/api/agent/restart', async () => {
|
|
386
|
+
if (!config.onReconnect) {
|
|
387
|
+
return { success: false, message: 'Reconnect not available' };
|
|
388
|
+
}
|
|
389
|
+
console.log('[Agent] Reconnect requested from UI');
|
|
390
|
+
void config.onReconnect().catch((err) => {
|
|
391
|
+
console.error('[Agent] Reconnect failed:', err);
|
|
392
|
+
});
|
|
393
|
+
return { success: true, message: 'Agent reconnecting...' };
|
|
394
|
+
});
|
|
395
|
+
// -- Discovered nodes -----------------------------------------------
|
|
396
|
+
app.get('/api/agent/discovered-nodes', async () => {
|
|
397
|
+
const b = getBroker();
|
|
398
|
+
const nodes = getRegistryNodes(b);
|
|
399
|
+
return mapDiscoveredNodes(nodes, b.nodeID);
|
|
400
|
+
});
|
|
401
|
+
// -- Static file serving (agent-ui) ---------------------------------
|
|
402
|
+
const uiDir = resolveUiDistDir(config.dataDir, process.env['CAMSTACK_BUNDLED_ADDONS_DIR']);
|
|
403
|
+
if (uiDir) {
|
|
404
|
+
const fastifyStatic = await Promise.resolve().then(() => __importStar(require('@fastify/static')));
|
|
405
|
+
await app.register(fastifyStatic.default, {
|
|
406
|
+
root: uiDir,
|
|
407
|
+
prefix: '/',
|
|
408
|
+
wildcard: false,
|
|
409
|
+
// MUST stay true: the SPA-fallback below uses `reply.sendFile`, which
|
|
410
|
+
// only exists when the plugin decorates Reply. With `false` every
|
|
411
|
+
// fallback request 500'd with "reply.sendFile is not a function".
|
|
412
|
+
decorateReply: true,
|
|
413
|
+
});
|
|
414
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
415
|
+
if (req.url.startsWith('/api/') || req.url.startsWith('/health')) {
|
|
416
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
417
|
+
}
|
|
418
|
+
// Never serve index.html to an asset request: a stale index.html
|
|
419
|
+
// referencing missing hashed assets must fail VISIBLY (404 in the
|
|
420
|
+
// renderer console) instead of feeding HTML to a module script,
|
|
421
|
+
// which renders as a silent blank page.
|
|
422
|
+
if (req.url.startsWith('/assets/')) {
|
|
423
|
+
console.warn(`[Agent] UI asset not found (stale index.html?): ${req.url}`);
|
|
424
|
+
return reply.status(404).send({ error: 'Asset not found' });
|
|
425
|
+
}
|
|
426
|
+
return reply.type('text/html').sendFile('index.html');
|
|
427
|
+
});
|
|
428
|
+
console.log(`[Agent] UI served from: ${uiDir}`);
|
|
429
|
+
}
|
|
430
|
+
return app;
|
|
431
|
+
}
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Start helper
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
async function startAgentHttpServer(getBroker, config) {
|
|
436
|
+
try {
|
|
437
|
+
const app = await createAgentHttpServer(getBroker, config);
|
|
438
|
+
await app.listen({ port: config.port, host: '0.0.0.0' });
|
|
439
|
+
console.log(`[Agent] HTTP server: http://localhost:${config.port}`);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.warn(`[Agent] HTTP server failed to start on port ${config.port}:`, err);
|
|
443
|
+
}
|
|
444
|
+
}
|