@ctrlpi/ctrlpi-lab 0.9.25 → 0.9.26

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/ctrlpi-lab.js CHANGED
@@ -4,12 +4,14 @@ import http from "http";
4
4
  import { spawn, execFile } from "child_process";
5
5
  import dgram from "dgram";
6
6
  import fs from "fs";
7
+ import net from "net";
7
8
  import os from "os";
8
9
  import path from "path";
9
10
  import { fileURLToPath } from "url";
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
 
14
+ // Opens the dashboard in the default browser on macOS, Windows or Linux.
13
15
  function openBrowser(url) {
14
16
  const plat = process.platform;
15
17
  if (plat === 'darwin') {
@@ -23,8 +25,8 @@ function openBrowser(url) {
23
25
 
24
26
  // --- Config file paths ---------------------------------------------------
25
27
  // Config files are stored in ~/.ctrlpi/ (or overridden via --config <path>):
26
- // config.json shared agent list ({name, ip, port, api_key}) across ctrlPi tools
27
- // lab-config.json dashboard state: selected_agent, mcp configuration, online_agents
28
+ // agents-config.json shared agent list ({name, ip, port, api_key, updated_at}) across ctrlPi tools
29
+ // lab-config.json dashboard state: selected_agent, online_agents
28
30
  function resolveConfigFile(fileName) {
29
31
  const args = process.argv.slice(2);
30
32
  const i = args.indexOf("--config");
@@ -39,11 +41,14 @@ function resolveConfigFile(fileName) {
39
41
  return file;
40
42
  }
41
43
 
44
+ // UTC "YYYY-MM-DD HH:MM:SS", the stamp format shared by every ctrlPi config file.
45
+ function nowStamp() {
46
+ return new Date().toISOString().slice(0, 19).replace("T", " ");
47
+ }
48
+
42
49
  const CONFIG_FILE = resolveConfigFile("lab-config.json");
43
- const SHARED_FILE = path.join(path.dirname(CONFIG_FILE), "config.json");
44
- const STATE_DIR = path.dirname(CONFIG_FILE);
50
+ const SHARED_FILE = path.join(path.dirname(CONFIG_FILE), "agents-config.json");
45
51
  const PAGES_DIR = path.join(__dirname, "pages");
46
- const IMAGES_DIR = path.join(__dirname, "images");
47
52
  const portArgIndex = process.argv.indexOf("--port");
48
53
  if (portArgIndex >= 0 && (!process.argv[portArgIndex + 1] || process.argv[portArgIndex + 1].startsWith("--"))) {
49
54
  throw new Error("--port requires a port number, e.g. --port 7474");
@@ -60,81 +65,196 @@ const NO_STARTUP_SCAN = process.argv.includes("--no-startup-scan");
60
65
  const MAX_BODY = 64 * 1024;
61
66
 
62
67
  // ── small helpers ────────────────────────────────────────────────────
68
+ // An error that carries the HTTP status the router answers with.
63
69
  class HttpError extends Error {
64
70
  constructor(status, detail) { super(detail); this.status = status; this.detail = detail; }
65
71
  }
66
72
 
67
- // Reads lab-config.json (selected_agent, mcp, online_agents).
73
+ // Reads lab-config.json (selected_agent, online_agents).
68
74
  function readConfig() {
69
75
  if (fs.existsSync(CONFIG_FILE)) {
70
76
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { /* fall through */ }
71
77
  }
72
78
  return {};
73
79
  }
80
+ // Writes lab-config.json as a whole.
74
81
  function writeConfig(data) {
75
82
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
76
83
  }
77
84
 
78
- // Reads shared config.json (agents list).
85
+ const HOMEBRIDGE_AGENTS_FILE = "/var/lib/homebridge/ctrlpi-agents-config.json";
86
+
87
+ // Merges two agent lists by name: new names are added, and an existing entry is replaced
88
+ // only when the overlay copy has a newer updated_at.
89
+ function reconcileAgentList(baseAgents, overlayAgents) {
90
+ const list = Array.isArray(baseAgents) ? [...baseAgents] : [];
91
+ if (!Array.isArray(overlayAgents)) return list;
92
+ for (const overlay of overlayAgents) {
93
+ if (!overlay || !overlay.name) continue;
94
+ const idx = list.findIndex(a => a && a.name === overlay.name);
95
+ if (idx === -1) {
96
+ list.push({ ...overlay });
97
+ } else {
98
+ const existing = list[idx];
99
+ const overlayDate = overlay.updated_at || "";
100
+ const existingDate = existing.updated_at || "";
101
+ if (overlayDate > existingDate) {
102
+ list[idx] = { ...existing, ...overlay };
103
+ }
104
+ }
105
+ }
106
+ return list;
107
+ }
108
+
109
+ // Reads the shared agents-config.json, then merges in newer entries from Homebridge's own
110
+ // list (/var/lib/homebridge/ctrlpi-agents-config.json), which is only ever read.
79
111
  function readShared() {
112
+ let result = {};
80
113
  if (fs.existsSync(SHARED_FILE)) {
81
- try { return JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")); } catch { /* fall through */ }
114
+ try { result = JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")) || {}; } catch { /* fall through */ }
82
115
  }
83
- return {};
116
+ if (fs.existsSync(HOMEBRIDGE_AGENTS_FILE)) {
117
+ try {
118
+ const hb = JSON.parse(fs.readFileSync(HOMEBRIDGE_AGENTS_FILE, "utf-8"));
119
+ if (hb && Array.isArray(hb.agents)) {
120
+ result.agents = reconcileAgentList(result.agents || [], hb.agents);
121
+ }
122
+ } catch { /* ignore read error */ }
123
+ }
124
+ return result;
84
125
  }
126
+ // Replaces only the agents key of the shared file, keeping anything else in it.
85
127
  function writeSharedAgents(agents) {
86
- const shared = readShared();
128
+ let shared = {};
129
+ if (fs.existsSync(SHARED_FILE)) {
130
+ try { shared = JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")) || {}; } catch { /* fall through */ }
131
+ }
87
132
  shared.agents = agents;
88
133
  fs.writeFileSync(SHARED_FILE, JSON.stringify(shared, null, 2));
89
134
  }
90
135
 
91
136
  // ── config: agents / scan results ────────────────────────────────────
137
+ // "http://host:port/" becomes "host:port".
92
138
  function stripScheme(v) {
93
139
  return String(v || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
94
140
  }
95
141
 
96
- // Agent records use {name, ip, port, api_key}. Default agent port is 8314.
142
+ // Agent records use {name, ip, port, api_key, updated_at}. Default agent port is 8314.
97
143
  const DEFAULT_AGENT_PORT = 8314;
98
144
 
145
+ // The dashboard speaks URLs; these two convert to and from the stored ip + port pair.
99
146
  function agentToUrl(a) {
100
147
  if (!a || !a.ip) return "";
101
148
  const host = /:\/\//.test(a.ip) ? a.ip : `http://${a.ip}`;
102
149
  return `${host}:${a.port || DEFAULT_AGENT_PORT}`;
103
150
  }
104
- function urlToAgent(url, api_key = "", name = "") {
151
+ function urlToAgent(url, api_key = "", name = "", updated_at = "") {
105
152
  let host = String(url || "").trim().replace(/\/+$/, "");
106
153
  let port = DEFAULT_AGENT_PORT;
107
154
  const m = host.match(/^(.*?):(\d+)$/);
108
155
  if (m && !/^https?:$/i.test(m[1])) { host = m[1]; port = Number(m[2]); }
109
156
  const ip = host.replace(/^http:\/\//i, "");
110
- // mcp-bridge identifies agents by name; omit when not specified.
111
- return { ...(name ? { name } : {}), ip, port, api_key: api_key || "" };
157
+ // Other ctrlPi tools find agents by name, so it is kept whenever known.
158
+ return {
159
+ ...(name ? { name } : {}),
160
+ ip,
161
+ port,
162
+ api_key: api_key || "",
163
+ updated_at: updated_at || nowStamp()
164
+ };
112
165
  }
113
166
 
114
- // Loads [{url, api_key, name?}] from shared config.json.
167
+ // Loads [{url, api_key, name?, updated_at?}] from shared agents-config.json.
115
168
  function loadServers() {
116
169
  const raw = readShared().agents;
117
170
  return (Array.isArray(raw) ? raw : [])
118
- .map(a => ({ url: agentToUrl(a), api_key: a.api_key || "", ...(a.name ? { name: a.name } : {}) }))
171
+ .map(a => ({
172
+ url: agentToUrl(a),
173
+ api_key: a.api_key || "",
174
+ ...(a.name ? { name: a.name } : {}),
175
+ ...(a.updated_at ? { updated_at: a.updated_at } : {})
176
+ }))
119
177
  .filter(s => s.url);
120
178
  }
179
+ // Writes the list back to the shared file, one entry per URL, sorted.
121
180
  function saveServers(servers) {
122
181
  const merged = {};
123
182
  for (const s of servers) if (s.url) merged[s.url] = s;
124
183
  const agents = Object.keys(merged).sort()
125
- .map(u => urlToAgent(u, merged[u].api_key, merged[u].name));
184
+ .map(u => urlToAgent(u, merged[u].api_key, merged[u].name, merged[u].updated_at));
126
185
  try { writeSharedAgents(agents); } catch { /* best effort */ }
127
186
  }
128
187
  // apiKey=null preserves an existing key (scan rediscovery); "" clears it.
129
188
  // name: agent name from /hello - stored when non-empty, preserved otherwise.
130
189
  function upsertServer(url, apiKey, name) {
131
- const byUrl = {};
132
- for (const s of loadServers()) byUrl[s.url] = s;
133
- const existing = byUrl[url] || { url, api_key: "" };
134
- if (apiKey !== null && apiKey !== undefined) existing.api_key = apiKey;
135
- if (name) existing.name = name;
136
- byUrl[url] = existing;
137
- saveServers(Object.values(byUrl));
190
+ const servers = loadServers();
191
+ const cleanName = (name || "").trim();
192
+
193
+ // Reconciles agent when discovered under a new URL
194
+ let existing = null;
195
+ let oldUrl = null;
196
+ if (cleanName) {
197
+ const matchByName = servers.find(s => s.name === cleanName && s.url !== url);
198
+ if (matchByName) {
199
+ existing = { ...matchByName, url };
200
+ oldUrl = matchByName.url;
201
+ }
202
+ }
203
+
204
+ if (!existing) {
205
+ existing = servers.find(s => s.url === url) || { url, api_key: "" };
206
+ }
207
+
208
+ let changed = oldUrl ? true : !servers.some(s => s.url === url);
209
+ if (apiKey !== null && apiKey !== undefined && existing.api_key !== apiKey) {
210
+ existing.api_key = apiKey;
211
+ changed = true;
212
+ }
213
+ if (cleanName && existing.name !== cleanName) {
214
+ existing.name = cleanName;
215
+ changed = true;
216
+ }
217
+ if (changed || !existing.updated_at) {
218
+ existing.updated_at = nowStamp();
219
+ }
220
+
221
+ // Filter out oldUrl if moved, or replace url if existing
222
+ const updatedServers = servers
223
+ .filter(s => s.url !== url && (!oldUrl || s.url !== oldUrl));
224
+ updatedServers.push(existing);
225
+ saveServers(updatedServers);
226
+
227
+ if (oldUrl) {
228
+ const online = (loadOnlineAgents() || []).filter(u => u !== oldUrl);
229
+ saveOnlineAgents(online);
230
+
231
+ // If the moved agent was the currently selected agent, update its selected host/port
232
+ try {
233
+ const cfg = readConfig();
234
+ if (cfg.selected_agent && cfg.selected_agent.ip) {
235
+ const oldHost = stripScheme(oldUrl).split(":")[0];
236
+ const oldPort = stripScheme(oldUrl).split(":")[1] || "8314";
237
+ if (stripScheme(cfg.selected_agent.ip) === oldHost &&
238
+ String(cfg.selected_agent.port ?? "8314") === oldPort) {
239
+ const newHost = stripScheme(url).split(":")[0];
240
+ const newPort = stripScheme(url).split(":")[1] || "8314";
241
+ cfg.selected_agent.ip = newHost;
242
+ cfg.selected_agent.port = Number(newPort) || newPort;
243
+ writeConfig(cfg);
244
+ }
245
+ }
246
+ } catch { /* best effort */ }
247
+ }
248
+
249
+ return loadServers();
250
+ }
251
+ // Removes an agent from the shared list and from the scan history.
252
+ function deleteServer(url) {
253
+ const norm = String(url || "").trim().replace(/\/+$/, "");
254
+ const servers = loadServers().filter(s => s.url !== norm);
255
+ saveServers(servers);
256
+ const online = (loadOnlineAgents() || []).filter(u => u !== norm);
257
+ saveOnlineAgents(online);
138
258
  return loadServers();
139
259
  }
140
260
  // Returns online agents from the most recent scan snapshot as URLs, or null if no scan has run.
@@ -144,6 +264,7 @@ function loadOnlineAgents() {
144
264
  const list = data.online_agents;
145
265
  return Array.isArray(list) ? list.map(e => `http://${stripScheme(e)}`) : list;
146
266
  }
267
+ // Records what the last scan reached, as sorted "ip:port" strings.
147
268
  function saveOnlineAgents(found) {
148
269
  const data = readConfig();
149
270
  data.online_agents = [...found].map(stripScheme).sort();
@@ -164,31 +285,22 @@ function loadSelected() {
164
285
  return { ip, port, api_key: key };
165
286
  }
166
287
 
288
+ // The selected agent as the dashboard's form fields expect it; API_KEY overrides the key.
167
289
  function getDefaultApiConfig() {
168
- let mcpUrl = "http://localhost:8315";
169
- let mcpKey = process.env.MCP_API_KEY;
170
-
171
- const data = readConfig();
172
290
  const sel = loadSelected();
173
- // Format agent target as URL for the dashboard.
174
291
  const apiHost = `http://${sel.ip}`;
175
292
  const apiPort = sel.port;
176
293
  const apiKey = process.env.API_KEY || sel.api_key;
177
- const mcp = data.mcp || {};
178
- if (mcp.ip) mcpUrl = `http://${stripScheme(mcp.ip)}:${mcp.port || 8315}`;
179
- else if (mcp.url) mcpUrl = mcp.url;
180
- if (!mcpKey) mcpKey = mcp.api_key;
181
-
182
294
  return {
183
295
  api_key: apiKey || "your-secret-key",
184
296
  api_host: apiHost,
185
297
  api_port: apiPort,
186
298
  api_url: `${apiHost}:${apiPort}`,
187
- mcp: { url: mcpUrl, api_key: mcpKey || "your-mcp-key" },
188
299
  };
189
300
  }
190
301
 
191
302
  // ── merged dashboard HTML (recursive <!--INCLUDE:--> + in-memory cache) ─
303
+ // Splices each <!--INCLUDE:path--> in place, up to ten levels deep.
192
304
  function resolveIncludes(html, baseDir, depth = 0) {
193
305
  if (depth > 10) return html;
194
306
  return html.replace(/<!--INCLUDE:([^>]+)-->/g, (_m, rel) => {
@@ -198,6 +310,7 @@ function resolveIncludes(html, baseDir, depth = 0) {
198
310
  });
199
311
  }
200
312
  let indexHtmlCache = null;
313
+ // Builds the dashboard page once and caches it until /api/reload-cache.
201
314
  function loadIndexHtml() {
202
315
  const htmlPath = path.join(PAGES_DIR, "main.html");
203
316
  if (!fs.existsSync(htmlPath)) throw new HttpError(404, "pages/main.html file not found");
@@ -205,6 +318,7 @@ function loadIndexHtml() {
205
318
  return indexHtmlCache;
206
319
  }
207
320
 
321
+ // Response helpers that always set the type and length.
208
322
  function sendJson(res, status, obj) {
209
323
  const body = JSON.stringify(obj);
210
324
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
@@ -267,14 +381,14 @@ function sendStatic(res, root, filename) {
267
381
  }
268
382
 
269
383
  // ── route handlers ───────────────────────────────────────────────────
270
- // General HTTP proxy - forwards any method/body to the ?url= target, relaying the
271
- // Mcp-Session-Id response header the MCP Streamable HTTP transport relies on.
384
+ // HTTP proxy for the dashboard: forwards the method, body and a few headers to the ?url=
385
+ // target and relays its status, since the agents send no CORS headers.
272
386
  async function handleProxy(req, res, url) {
273
387
  const target = url.searchParams.get("url");
274
388
  if (!target) throw new HttpError(400, "url query param required");
275
389
 
276
390
  const forwardHeaders = {};
277
- for (const name of ["api-key", "content-type", "accept", "mcp-session-id"]) {
391
+ for (const name of ["api-key", "content-type", "accept"]) {
278
392
  const val = req.headers[name];
279
393
  if (val) forwardHeaders[name] = val;
280
394
  }
@@ -293,9 +407,6 @@ async function handleProxy(req, res, url) {
293
407
  throw new HttpError(502, `Could not reach target: ${e.message}`);
294
408
  }
295
409
 
296
- const sessionId = r.headers.get("mcp-session-id");
297
- if (sessionId) res.setHeader("Mcp-Session-Id", sessionId);
298
-
299
410
  const text = await r.text();
300
411
  if ((r.headers.get("content-type") || "").includes("application/json")) {
301
412
  try { return sendJson(res, r.status, JSON.parse(text)); } catch { /* fall through */ }
@@ -303,6 +414,7 @@ async function handleProxy(req, res, url) {
303
414
  return sendJson(res, r.status, { text });
304
415
  }
305
416
 
417
+ // Saves the agent the dashboard is pointed at (selected_agent in lab-config.json).
306
418
  async function handleSaveConfig(req, res) {
307
419
  const body = await readJsonBody(req, MAX_BODY);
308
420
  const data = readConfig();
@@ -315,65 +427,18 @@ async function handleSaveConfig(req, res) {
315
427
  if ("api_key" in body) sel.api_key = body.api_key;
316
428
  data.selected_agent = sel;
317
429
  }
318
- // The MCP tab sends one URL; on disk it is ip + port, matching agents[] and selected.
319
- const setMcpUrl = (mcp, url) => {
320
- const bare = stripScheme(url);
321
- const m = bare.match(/^(.*?):(\d+)$/);
322
- mcp.ip = m ? m[1] : bare;
323
- mcp.port = m ? Number(m[2]) : 8315;
324
- delete mcp.url;
325
- };
326
- if (body.mcp && typeof body.mcp === "object") {
327
- const mcp = data.mcp || {};
328
- if ("url" in body.mcp) setMcpUrl(mcp, body.mcp.url);
329
- if ("api_key" in body.mcp) mcp.api_key = body.mcp.api_key;
330
- data.mcp = mcp;
331
- }
332
- if ("mcp_url" in body || "mcp_api_key" in body) {
333
- const mcp = data.mcp || {};
334
- if ("mcp_url" in body) setMcpUrl(mcp, body.mcp_url);
335
- if ("mcp_api_key" in body) mcp.api_key = body.mcp_api_key;
336
- data.mcp = mcp;
337
- }
338
430
  try { writeConfig(data); sendJson(res, 200, { status: "success" }); }
339
431
  catch (e) { throw new HttpError(500, `Failed to save config: ${e.message}`); }
340
432
  }
341
433
 
342
- async function handleLogs(req, res, url) {
343
- const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "300", 10);
344
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 300;
345
- const search = url.searchParams.get("search");
346
- const category = url.searchParams.get("category");
347
-
348
- const config = getDefaultApiConfig();
349
- let data;
350
- try {
351
- const r = await fetch(`${config.api_url}/logs`, {
352
- headers: { "Api-Key": config.api_key },
353
- signal: AbortSignal.timeout(10000),
354
- });
355
- if (!r.ok) throw new HttpError(r.status, `Pi error: ${r.status}`);
356
- data = await r.json();
357
- } catch (e) {
358
- if (e instanceof HttpError) throw e;
359
- throw new HttpError(502, `Could not reach Pi: ${e.message}`);
360
- }
361
-
362
- const filtered = (data.logs || []).filter(line => {
363
- if (!line) return false;
364
- if (search && !line.toLowerCase().includes(String(search).toLowerCase())) return false;
365
- if (category && category !== "ALL" && !line.includes(`[${category}]`)) return false;
366
- return true;
367
- });
368
- sendJson(res, 200, { logs: filtered.slice(-limit) });
369
- }
370
-
434
+ // GET /api/servers: the saved agents, the shared file's mtime and the scan history.
371
435
  function handleServersGet(res) {
372
436
  // The agent list lives in the shared file, so that is the one whose mtime says whether
373
437
  // it changed (another project may have updated it since this page loaded).
374
438
  const lastModified = fs.existsSync(SHARED_FILE) ? fs.statSync(SHARED_FILE).mtimeMs / 1000 : null;
375
439
  sendJson(res, 200, { servers: loadServers(), last_modified: lastModified, online_agents: loadOnlineAgents() });
376
440
  }
441
+ // POST /api/servers: adds or updates one agent and marks it online.
377
442
  async function handleServersPost(req, res) {
378
443
  const body = await readJsonBody(req, MAX_BODY);
379
444
  const url = ((body.url || "").trim()).replace(/\/+$/, "");
@@ -389,6 +454,18 @@ async function handleServersPost(req, res) {
389
454
 
390
455
  sendJson(res, 200, { servers, online_agents: loadOnlineAgents() });
391
456
  }
457
+ // DELETE /api/servers: the URL comes from the query or the JSON body.
458
+ async function handleServersDelete(req, res, url) {
459
+ let target = url.searchParams.get("url");
460
+ if (!target) {
461
+ const body = await readJsonBody(req, MAX_BODY).catch(() => ({}));
462
+ target = body.url;
463
+ }
464
+ const cleanUrl = ((target || "").trim()).replace(/\/+$/, "");
465
+ if (!cleanUrl) throw new HttpError(400, "url is required");
466
+ const servers = deleteServer(cleanUrl);
467
+ sendJson(res, 200, { servers, online_agents: loadOnlineAgents() });
468
+ }
392
469
 
393
470
  const DEFAULT_AGENT_KEY = "your-secret-key"; // agents' out-of-the-box Api-Key
394
471
 
@@ -430,6 +507,7 @@ async function scanSubnet(port = 8314, announce = false, onFound = () => {}) {
430
507
  return { subnet: `${prefix}.0/24`, found: [...found].sort(), names };
431
508
  }
432
509
 
510
+ // True when the agent accepts the shipped default key on an authenticated route.
433
511
  async function defaultKeyWorks(baseUrl) {
434
512
  try {
435
513
  const r = await fetch(`${baseUrl}/config/read`, {
@@ -442,6 +520,8 @@ async function defaultKeyWorks(baseUrl) {
442
520
  }
443
521
  }
444
522
 
523
+ // GET /api/scan: with stream=1 it answers NDJSON, one "agent" event per find and a
524
+ // final "done", so the page can connect before the sweep ends.
445
525
  async function handleScan(req, res, url) {
446
526
  const port = parseInt(url.searchParams.get("port") ?? "8314", 10);
447
527
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new HttpError(400, "Invalid port");
@@ -465,14 +545,34 @@ async function handleScan(req, res, url) {
465
545
  sendJson(res, 200, result);
466
546
  }
467
547
 
548
+ // ── request guard ───────────────────────────────────────────────────
549
+ // Against DNS rebinding: another domain pointed at this machine still sends its own name as
550
+ // Host. IP addresses, localhost, this machine's name (with or without .local) and the
551
+ // --host value pass; anything else is refused.
552
+ function hostAllowed(hostHeader, hostname = os.hostname()) {
553
+ const match = /^(?:\[([0-9a-f:.]+)\]|([a-z0-9.-]+))(?::\d+)?$/i.exec(String(hostHeader || ""));
554
+ if (!match) return false;
555
+ const name = (match[1] || match[2]).toLowerCase().replace(/\.$/, "");
556
+ if (net.isIP(name) || name === "localhost") return true;
557
+ const own = String(hostname || "").toLowerCase().replace(/\.local$/, "");
558
+ if (own && (name === own || name === own + ".local")) return true;
559
+ return name === String(HOST).toLowerCase();
560
+ }
561
+ function originHost(origin) {
562
+ try { return new URL(origin).host; } catch { return null; }
563
+ }
564
+
468
565
  // ── router ───────────────────────────────────────────────────────────
566
+ // Routes one request; any HttpError becomes a JSON {error} answer.
469
567
  async function dispatch(req, res) {
470
- // CORS: allow all for local dashboard development.
471
- res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
472
- res.setHeader("Access-Control-Allow-Credentials", "true");
473
- res.setHeader("Access-Control-Allow-Methods", "*");
474
- res.setHeader("Access-Control-Allow-Headers", "*");
475
- if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; }
568
+ // Only the dashboard's own page may call it: no CORS headers, an allowed Host, and an
569
+ // Origin (when sent) that matches the Host.
570
+ if (!hostAllowed(req.headers.host)) {
571
+ return sendJson(res, 403, { error: "Host not allowed. Open ctrlpi-lab by IP address or localhost." });
572
+ }
573
+ if (req.headers.origin && originHost(req.headers.origin) !== req.headers.host) {
574
+ return sendJson(res, 403, { error: "Cross-origin request rejected" });
575
+ }
476
576
 
477
577
  const url = new URL(req.url, "http://localhost");
478
578
  const p = url.pathname;
@@ -491,11 +591,10 @@ async function dispatch(req, res) {
491
591
  return sendJson(res, 200, { ...getDefaultApiConfig(), local_network_prefix: localNetworkPrefix, dev_mode: process.env.NODE_ENV === "development" });
492
592
  }
493
593
  if (p === "/api/save-config" && m === "POST") return await handleSaveConfig(req, res);
494
- if (p === "/api/logs" && m === "GET") return await handleLogs(req, res, url);
495
594
  if (p === "/api/servers" && m === "GET") return handleServersGet(res);
496
595
  if (p === "/api/servers" && m === "POST") return await handleServersPost(req, res);
596
+ if (p === "/api/servers" && m === "DELETE") return await handleServersDelete(req, res, url);
497
597
  if (p === "/api/scan" && m === "GET") return await handleScan(req, res, url);
498
- if (p.startsWith("/images/") && m === "GET") return sendStatic(res, IMAGES_DIR, decodeURIComponent(p.slice("/images/".length)));
499
598
  if (p.startsWith("/pages/") && m === "GET") return sendStatic(res, PAGES_DIR, decodeURIComponent(p.slice("/pages/".length)));
500
599
  throw new HttpError(404, "not found");
501
600
  } catch (err) {
@@ -506,16 +605,44 @@ async function dispatch(req, res) {
506
605
  }
507
606
 
508
607
  // ── utilities ────────────────────────────────────────────────────────
608
+ // The LAN address: a UDP socket "connected" to 8.8.8.8 (nothing is sent) shows the
609
+ // routing choice; offline, the first non-internal IPv4 interface is used instead.
509
610
  function detectLocalIp() {
510
611
  return new Promise((resolve, reject) => {
612
+ const fallback = () => {
613
+ try {
614
+ const ifaces = os.networkInterfaces();
615
+ for (const list of Object.values(ifaces)) {
616
+ for (const iface of list || []) {
617
+ if ((iface.family === "IPv4" || iface.family === 4) && !iface.internal && iface.address && !iface.address.startsWith("127.")) {
618
+ return resolve(iface.address);
619
+ }
620
+ }
621
+ }
622
+ } catch { /* ignore */ }
623
+ reject(new Error("no network interface with an IPv4 address"));
624
+ };
625
+
511
626
  const s = dgram.createSocket("udp4");
512
- s.once("error", e => { try { s.close(); } catch { /* ignore */ } reject(e); });
627
+ s.once("error", () => {
628
+ try { s.close(); } catch { /* ignore */ }
629
+ fallback();
630
+ });
513
631
  try {
514
632
  s.connect(80, "8.8.8.8", () => {
515
- try { const ip = s.address().address; s.close(); resolve(ip); }
516
- catch (e) { try { s.close(); } catch { /* ignore */ } reject(e); }
633
+ try {
634
+ const ip = s.address().address;
635
+ s.close();
636
+ if (ip && ip !== "0.0.0.0" && !ip.startsWith("127.")) return resolve(ip);
637
+ } catch {
638
+ try { s.close(); } catch { /* ignore */ }
639
+ }
640
+ fallback();
517
641
  });
518
- } catch (e) { try { s.close(); } catch { /* ignore */ } reject(e); }
642
+ } catch {
643
+ try { s.close(); } catch { /* ignore */ }
644
+ fallback();
645
+ }
519
646
  });
520
647
  }
521
648
 
@@ -543,7 +670,7 @@ async function startupScan() {
543
670
  try {
544
671
  const stats = await fs.promises.stat(SHARED_FILE);
545
672
  if (Date.now() - stats.mtimeMs < 5 * 60 * 1000) {
546
- console.log("Startup scan skipped: config.json was updated less than 5 minutes ago. Running quick reachability test.");
673
+ console.log("Startup scan skipped: agents-config.json was updated less than 5 minutes ago. Running quick reachability test.");
547
674
  const saved = loadServers();
548
675
  const alive = [];
549
676
  await Promise.all(saved.map(async s => {
@@ -575,6 +702,7 @@ server.listen(PORT, HOST, () => {
575
702
  if (!NO_OPEN) openBrowser(localAddr);
576
703
  if (!NO_STARTUP_SCAN) startupScan();
577
704
  });
705
+ // Closes the server, forcing the exit after one second if connections linger.
578
706
  function shutdown() { server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 1000); }
579
707
  process.on("SIGINT", shutdown);
580
708
  process.on("SIGTERM", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrlpi/ctrlpi-lab",
3
- "version": "0.9.25",
3
+ "version": "0.9.26",
4
4
  "description": "A local browser dashboard for your ctrlPi setup. Test GPIO and sensor endpoints, watch webhook events, and tail agent logs, with zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "ctrlpi-lab.js",
@@ -16,7 +16,8 @@
16
16
  "test": "node --test",
17
17
  "start": "node ctrlpi-lab.js",
18
18
  "run": "node ctrlpi-lab.js",
19
- "test:ci": "npm test"
19
+ "test:ci": "npm test",
20
+ "prepublishOnly": "npm test"
20
21
  },
21
22
  "engines": {
22
23
  "node": ">=20"
package/pages/api.html CHANGED
@@ -133,15 +133,6 @@
133
133
  </div>
134
134
  </div>
135
135
 
136
- <!-- Pinout viewer (replaces workbench content when a pinout is selected) -->
137
- <div id="pinoutPanel" style="display:none; flex-direction:column; flex:1; overflow:hidden; padding:0;">
138
- <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 20px; border-bottom:1px solid var(--border-muted); flex-shrink:0;">
139
- <span id="pinoutLabel" style="font-size:0.95rem; font-weight:600; color:var(--text-main);"></span>
140
- <button onclick="closePinout()" style="background:none; border:1px solid var(--border-muted); border-radius:6px; padding:4px 12px; cursor:pointer; color:var(--text-muted); font-size:0.8rem;">Close</button>
141
- </div>
142
- <iframe id="pinoutFrame" src="" style="flex:1; width:100%; border:none;" title="Pinout"></iframe>
143
- </div>
144
-
145
136
  <!--INCLUDE:pages/welcome.html-->
146
137
  <!--INCLUDE:pages/api_curl.html-->
147
138
  </div>
@@ -1,7 +1,7 @@
1
1
  <div id="apiCurlPanel" class="info-panel">
2
2
  <div class="info-panel-header">
3
3
  <span class="info-panel-title">API - cURL Examples</span>
4
- <button class="info-panel-close" onclick="closePinout()">Close</button>
4
+ <button class="info-panel-close" onclick="closeInfoPanel()">Close</button>
5
5
  </div>
6
6
  <p style="font-size:0.85rem; color:var(--text-muted);">Examples use the current API URL and key from the header. All endpoints except <code>/hello</code> require the <code>Api-Key</code> header.</p>
7
7
 
@@ -36,7 +36,7 @@
36
36
  </div>
37
37
 
38
38
  <div class="info-section">
39
- <h3>6. Configure a watched input</h3>
39
+ <h3>6. Configure a watched input (fires a webhook on change)</h3>
40
40
  <p>Set GPIO 6 as a watched input. State changes send webhooks when a target is configured; with no webhook or bridge target, events are logged instead.</p>
41
41
  <div class="info-code-block" id="curl6"></div>
42
42
  </div>