@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/README.md +45 -85
- package/ctrlpi-lab.js +231 -103
- package/package.json +3 -2
- package/pages/api.html +0 -9
- package/pages/api_curl.html +2 -2
- package/pages/main.html +423 -112
- package/pages/welcome.html +88 -20
- package/pages/Pico Pinout.html +0 -186
- package/pages/Raspberry Pi Pinout.html +0 -184
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
|
|
27
|
-
// lab-config.json
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
114
|
+
try { result = JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")) || {}; } catch { /* fall through */ }
|
|
82
115
|
}
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
111
|
-
return {
|
|
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 => ({
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
271
|
-
//
|
|
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"
|
|
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
|
-
|
|
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
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (req.
|
|
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",
|
|
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 {
|
|
516
|
-
|
|
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
|
|
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.
|
|
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>
|
package/pages/api_curl.html
CHANGED
|
@@ -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="
|
|
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>
|