@ctrlpi/ctrlpi-lab 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +192 -2
- package/ctrlpi-lab.js +713 -0
- package/package.json +33 -4
- package/pages/api.html +139 -0
- package/pages/api_curl.html +60 -0
- package/pages/logs.html +86 -0
- package/pages/main.html +5391 -0
- package/pages/webhooks.html +52 -0
- package/pages/welcome.html +151 -0
package/ctrlpi-lab.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Served on Node's built-in http module with zero external dependencies.
|
|
3
|
+
import http from "http";
|
|
4
|
+
import { spawn, execFile } from "child_process";
|
|
5
|
+
import dgram from "dgram";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import net from "net";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
// Opens the dashboard in the default browser on macOS, Windows or Linux.
|
|
15
|
+
function openBrowser(url) {
|
|
16
|
+
const plat = process.platform;
|
|
17
|
+
if (plat === 'darwin') {
|
|
18
|
+
execFile('open', [url], () => {});
|
|
19
|
+
} else if (plat === 'win32') {
|
|
20
|
+
execFile('cmd.exe', ['/c', 'start', '""', url], () => {});
|
|
21
|
+
} else {
|
|
22
|
+
execFile('xdg-open', [url], () => {});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Config file paths ---------------------------------------------------
|
|
27
|
+
// Config files are stored in ~/.ctrlpi/ (or overridden via --config <path>):
|
|
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
|
|
30
|
+
function resolveConfigFile(fileName) {
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const i = args.indexOf("--config");
|
|
33
|
+
let file = path.join(os.homedir(), ".ctrlpi", fileName);
|
|
34
|
+
if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) {
|
|
35
|
+
const p = path.resolve(args[i + 1]);
|
|
36
|
+
file = p.toLowerCase().endsWith(".json") ? p : path.join(p, fileName);
|
|
37
|
+
}
|
|
38
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (e) {
|
|
39
|
+
console.error(`Could not create ${path.dirname(file)}: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
return file;
|
|
42
|
+
}
|
|
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
|
+
|
|
49
|
+
const CONFIG_FILE = resolveConfigFile("lab-config.json");
|
|
50
|
+
const SHARED_FILE = path.join(path.dirname(CONFIG_FILE), "agents-config.json");
|
|
51
|
+
const PAGES_DIR = path.join(__dirname, "pages");
|
|
52
|
+
const portArgIndex = process.argv.indexOf("--port");
|
|
53
|
+
if (portArgIndex >= 0 && (!process.argv[portArgIndex + 1] || process.argv[portArgIndex + 1].startsWith("--"))) {
|
|
54
|
+
throw new Error("--port requires a port number, e.g. --port 7474");
|
|
55
|
+
}
|
|
56
|
+
const PORT = Number(portArgIndex >= 0 ? process.argv[portArgIndex + 1] : (process.env.PORT ?? 7474));
|
|
57
|
+
const hostArgIndex = process.argv.indexOf("--host");
|
|
58
|
+
if (hostArgIndex >= 0 && (!process.argv[hostArgIndex + 1] || process.argv[hostArgIndex + 1].startsWith("--"))) {
|
|
59
|
+
throw new Error("--host requires an address, e.g. --host 0.0.0.0");
|
|
60
|
+
}
|
|
61
|
+
const HOST = hostArgIndex >= 0 ? process.argv[hostArgIndex + 1] : process.env.HOST || "127.0.0.1";
|
|
62
|
+
const NO_OPEN = process.argv.includes("--no-open");
|
|
63
|
+
const NO_STARTUP_SCAN = process.argv.includes("--no-startup-scan");
|
|
64
|
+
// Maximum accepted request body size (64 KB). Rejects larger payloads with 413.
|
|
65
|
+
const MAX_BODY = 64 * 1024;
|
|
66
|
+
|
|
67
|
+
// ── small helpers ────────────────────────────────────────────────────
|
|
68
|
+
// An error that carries the HTTP status the router answers with.
|
|
69
|
+
class HttpError extends Error {
|
|
70
|
+
constructor(status, detail) { super(detail); this.status = status; this.detail = detail; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reads lab-config.json (selected_agent, online_agents).
|
|
74
|
+
function readConfig() {
|
|
75
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
76
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { /* fall through */ }
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
// Writes lab-config.json as a whole.
|
|
81
|
+
function writeConfig(data) {
|
|
82
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
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.
|
|
111
|
+
function readShared() {
|
|
112
|
+
let result = {};
|
|
113
|
+
if (fs.existsSync(SHARED_FILE)) {
|
|
114
|
+
try { result = JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")) || {}; } catch { /* fall through */ }
|
|
115
|
+
}
|
|
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;
|
|
125
|
+
}
|
|
126
|
+
// Replaces only the agents key of the shared file, keeping anything else in it.
|
|
127
|
+
function writeSharedAgents(agents) {
|
|
128
|
+
let shared = {};
|
|
129
|
+
if (fs.existsSync(SHARED_FILE)) {
|
|
130
|
+
try { shared = JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")) || {}; } catch { /* fall through */ }
|
|
131
|
+
}
|
|
132
|
+
shared.agents = agents;
|
|
133
|
+
fs.writeFileSync(SHARED_FILE, JSON.stringify(shared, null, 2));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── config: agents / scan results ────────────────────────────────────
|
|
137
|
+
// "http://host:port/" becomes "host:port".
|
|
138
|
+
function stripScheme(v) {
|
|
139
|
+
return String(v || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Agent records use {name, ip, port, api_key, updated_at}. Default agent port is 8314.
|
|
143
|
+
const DEFAULT_AGENT_PORT = 8314;
|
|
144
|
+
|
|
145
|
+
// The dashboard speaks URLs; these two convert to and from the stored ip + port pair.
|
|
146
|
+
function agentToUrl(a) {
|
|
147
|
+
if (!a || !a.ip) return "";
|
|
148
|
+
const host = /:\/\//.test(a.ip) ? a.ip : `http://${a.ip}`;
|
|
149
|
+
return `${host}:${a.port || DEFAULT_AGENT_PORT}`;
|
|
150
|
+
}
|
|
151
|
+
function urlToAgent(url, api_key = "", name = "", updated_at = "") {
|
|
152
|
+
let host = String(url || "").trim().replace(/\/+$/, "");
|
|
153
|
+
let port = DEFAULT_AGENT_PORT;
|
|
154
|
+
const m = host.match(/^(.*?):(\d+)$/);
|
|
155
|
+
if (m && !/^https?:$/i.test(m[1])) { host = m[1]; port = Number(m[2]); }
|
|
156
|
+
const ip = host.replace(/^http:\/\//i, "");
|
|
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
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Loads [{url, api_key, name?, updated_at?}] from shared agents-config.json.
|
|
168
|
+
function loadServers() {
|
|
169
|
+
const raw = readShared().agents;
|
|
170
|
+
return (Array.isArray(raw) ? raw : [])
|
|
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
|
+
}))
|
|
177
|
+
.filter(s => s.url);
|
|
178
|
+
}
|
|
179
|
+
// Writes the list back to the shared file, one entry per URL, sorted.
|
|
180
|
+
function saveServers(servers) {
|
|
181
|
+
const merged = {};
|
|
182
|
+
for (const s of servers) if (s.url) merged[s.url] = s;
|
|
183
|
+
const agents = Object.keys(merged).sort()
|
|
184
|
+
.map(u => urlToAgent(u, merged[u].api_key, merged[u].name, merged[u].updated_at));
|
|
185
|
+
try { writeSharedAgents(agents); } catch { /* best effort */ }
|
|
186
|
+
}
|
|
187
|
+
// apiKey=null preserves an existing key (scan rediscovery); "" clears it.
|
|
188
|
+
// name: agent name from /hello - stored when non-empty, preserved otherwise.
|
|
189
|
+
function upsertServer(url, apiKey, name) {
|
|
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);
|
|
258
|
+
return loadServers();
|
|
259
|
+
}
|
|
260
|
+
// Returns online agents from the most recent scan snapshot as URLs, or null if no scan has run.
|
|
261
|
+
function loadOnlineAgents() {
|
|
262
|
+
const data = readConfig();
|
|
263
|
+
if (!("online_agents" in data)) return null;
|
|
264
|
+
const list = data.online_agents;
|
|
265
|
+
return Array.isArray(list) ? list.map(e => `http://${stripScheme(e)}`) : list;
|
|
266
|
+
}
|
|
267
|
+
// Records what the last scan reached, as sorted "ip:port" strings.
|
|
268
|
+
function saveOnlineAgents(found) {
|
|
269
|
+
const data = readConfig();
|
|
270
|
+
data.online_agents = [...found].map(stripScheme).sort();
|
|
271
|
+
try { writeConfig(data); } catch { /* best effort */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Loads the currently selected agent ({ip, port, api_key}).
|
|
275
|
+
function loadSelected() {
|
|
276
|
+
const sel = readConfig().selected_agent || {};
|
|
277
|
+
const ip = stripScheme(sel.ip) || "localhost";
|
|
278
|
+
const port = String(sel.port ?? "8314");
|
|
279
|
+
// Prefer explicit selection key; fallback to matching entry in shared agents list.
|
|
280
|
+
let key = sel.api_key;
|
|
281
|
+
if (!key) {
|
|
282
|
+
const match = loadServers().find(s => s.url === `http://${ip}:${port}`);
|
|
283
|
+
key = match && match.api_key;
|
|
284
|
+
}
|
|
285
|
+
return { ip, port, api_key: key };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The selected agent as the dashboard's form fields expect it; API_KEY overrides the key.
|
|
289
|
+
function getDefaultApiConfig() {
|
|
290
|
+
const sel = loadSelected();
|
|
291
|
+
const apiHost = `http://${sel.ip}`;
|
|
292
|
+
const apiPort = sel.port;
|
|
293
|
+
const apiKey = process.env.API_KEY || sel.api_key;
|
|
294
|
+
return {
|
|
295
|
+
api_key: apiKey || "your-secret-key",
|
|
296
|
+
api_host: apiHost,
|
|
297
|
+
api_port: apiPort,
|
|
298
|
+
api_url: `${apiHost}:${apiPort}`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── merged dashboard HTML (recursive <!--INCLUDE:--> + in-memory cache) ─
|
|
303
|
+
// Splices each <!--INCLUDE:path--> in place, up to ten levels deep.
|
|
304
|
+
function resolveIncludes(html, baseDir, depth = 0) {
|
|
305
|
+
if (depth > 10) return html;
|
|
306
|
+
return html.replace(/<!--INCLUDE:([^>]+)-->/g, (_m, rel) => {
|
|
307
|
+
const p = path.join(baseDir, rel.trim());
|
|
308
|
+
try { return resolveIncludes(fs.readFileSync(p, "utf-8"), baseDir, depth + 1); }
|
|
309
|
+
catch { return `<!-- INCLUDE NOT FOUND: ${rel} -->`; }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
let indexHtmlCache = null;
|
|
313
|
+
// Builds the dashboard page once and caches it until /api/reload-cache.
|
|
314
|
+
function loadIndexHtml() {
|
|
315
|
+
const htmlPath = path.join(PAGES_DIR, "main.html");
|
|
316
|
+
if (!fs.existsSync(htmlPath)) throw new HttpError(404, "pages/main.html file not found");
|
|
317
|
+
indexHtmlCache = resolveIncludes(fs.readFileSync(htmlPath, "utf-8"), __dirname);
|
|
318
|
+
return indexHtmlCache;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Response helpers that always set the type and length.
|
|
322
|
+
function sendJson(res, status, obj) {
|
|
323
|
+
const body = JSON.stringify(obj);
|
|
324
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
|
|
325
|
+
res.end(body);
|
|
326
|
+
}
|
|
327
|
+
function sendHtml(res, status, html) {
|
|
328
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html) });
|
|
329
|
+
res.end(html);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Buffer the raw request body up to the given limit.
|
|
333
|
+
function readRawBody(req, limit) {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const chunks = [];
|
|
336
|
+
let size = 0;
|
|
337
|
+
let over = false;
|
|
338
|
+
req.on("data", chunk => {
|
|
339
|
+
// Reject with 413 and stop accumulating when body exceeds limit.
|
|
340
|
+
if (over) return;
|
|
341
|
+
size += chunk.length;
|
|
342
|
+
if (size > limit) {
|
|
343
|
+
over = true;
|
|
344
|
+
chunks.length = 0;
|
|
345
|
+
reject(new HttpError(413, "payload too large"));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
chunks.push(chunk);
|
|
349
|
+
});
|
|
350
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
351
|
+
req.on("error", reject);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// Parse a JSON request body; empty body => {}.
|
|
355
|
+
async function readJsonBody(req, limit) {
|
|
356
|
+
const buf = await readRawBody(req, limit);
|
|
357
|
+
if (buf.length === 0) return {};
|
|
358
|
+
try { return JSON.parse(buf.toString("utf-8")); }
|
|
359
|
+
catch { throw new HttpError(400, "invalid JSON body"); }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const CONTENT_TYPES = {
|
|
363
|
+
".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
|
364
|
+
".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8",
|
|
365
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
366
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon", ".webp": "image/webp", ".txt": "text/plain; charset=utf-8",
|
|
367
|
+
};
|
|
368
|
+
// Serve a static file from root directory, rejecting path traversal and dotfiles.
|
|
369
|
+
function sendStatic(res, root, filename) {
|
|
370
|
+
if (!filename || filename.startsWith(".") || filename.includes("/") || filename.includes("\\")) {
|
|
371
|
+
throw new HttpError(404, "not found");
|
|
372
|
+
}
|
|
373
|
+
const full = path.join(root, filename);
|
|
374
|
+
if (path.dirname(full) !== root) throw new HttpError(404, "not found"); // defense in depth
|
|
375
|
+
let data;
|
|
376
|
+
try { data = fs.readFileSync(full); }
|
|
377
|
+
catch { throw new HttpError(404, "not found"); }
|
|
378
|
+
const ext = path.extname(full).toLowerCase();
|
|
379
|
+
res.writeHead(200, { "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", "Content-Length": data.length });
|
|
380
|
+
res.end(data);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── route handlers ───────────────────────────────────────────────────
|
|
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.
|
|
386
|
+
async function handleProxy(req, res, url) {
|
|
387
|
+
const target = url.searchParams.get("url");
|
|
388
|
+
if (!target) throw new HttpError(400, "url query param required");
|
|
389
|
+
|
|
390
|
+
const forwardHeaders = {};
|
|
391
|
+
for (const name of ["api-key", "content-type", "accept"]) {
|
|
392
|
+
const val = req.headers[name];
|
|
393
|
+
if (val) forwardHeaders[name] = val;
|
|
394
|
+
}
|
|
395
|
+
const bodyBuf = await readRawBody(req, MAX_BODY);
|
|
396
|
+
const hasBody = bodyBuf.length > 0;
|
|
397
|
+
|
|
398
|
+
let r;
|
|
399
|
+
try {
|
|
400
|
+
r = await fetch(target, {
|
|
401
|
+
method: req.method,
|
|
402
|
+
headers: forwardHeaders,
|
|
403
|
+
body: hasBody ? bodyBuf : undefined,
|
|
404
|
+
signal: AbortSignal.timeout(30000),
|
|
405
|
+
});
|
|
406
|
+
} catch (e) {
|
|
407
|
+
throw new HttpError(502, `Could not reach target: ${e.message}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const text = await r.text();
|
|
411
|
+
if ((r.headers.get("content-type") || "").includes("application/json")) {
|
|
412
|
+
try { return sendJson(res, r.status, JSON.parse(text)); } catch { /* fall through */ }
|
|
413
|
+
}
|
|
414
|
+
return sendJson(res, r.status, { text });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Saves the agent the dashboard is pointed at (selected_agent in lab-config.json).
|
|
418
|
+
async function handleSaveConfig(req, res) {
|
|
419
|
+
const body = await readJsonBody(req, MAX_BODY);
|
|
420
|
+
const data = readConfig();
|
|
421
|
+
// The wire format keeps api_host/api_port (the dashboard's field names); on disk they
|
|
422
|
+
// are the `selected_agent` section of this project's own file.
|
|
423
|
+
if ("api_host" in body || "api_port" in body || "api_key" in body) {
|
|
424
|
+
const sel = data.selected_agent || {};
|
|
425
|
+
if ("api_host" in body) sel.ip = stripScheme(body.api_host);
|
|
426
|
+
if ("api_port" in body) sel.port = Number(body.api_port) || body.api_port;
|
|
427
|
+
if ("api_key" in body) sel.api_key = body.api_key;
|
|
428
|
+
data.selected_agent = sel;
|
|
429
|
+
}
|
|
430
|
+
try { writeConfig(data); sendJson(res, 200, { status: "success" }); }
|
|
431
|
+
catch (e) { throw new HttpError(500, `Failed to save config: ${e.message}`); }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// GET /api/servers: the saved agents, the shared file's mtime and the scan history.
|
|
435
|
+
function handleServersGet(res) {
|
|
436
|
+
// The agent list lives in the shared file, so that is the one whose mtime says whether
|
|
437
|
+
// it changed (another project may have updated it since this page loaded).
|
|
438
|
+
const lastModified = fs.existsSync(SHARED_FILE) ? fs.statSync(SHARED_FILE).mtimeMs / 1000 : null;
|
|
439
|
+
sendJson(res, 200, { servers: loadServers(), last_modified: lastModified, online_agents: loadOnlineAgents() });
|
|
440
|
+
}
|
|
441
|
+
// POST /api/servers: adds or updates one agent and marks it online.
|
|
442
|
+
async function handleServersPost(req, res) {
|
|
443
|
+
const body = await readJsonBody(req, MAX_BODY);
|
|
444
|
+
const url = ((body.url || "").trim()).replace(/\/+$/, "");
|
|
445
|
+
if (!url) throw new HttpError(400, "url is required");
|
|
446
|
+
const apiKey = "api_key" in body ? body.api_key : null;
|
|
447
|
+
const servers = upsertServer(url, apiKey, (body.name || "").trim());
|
|
448
|
+
|
|
449
|
+
let online = loadOnlineAgents() || [];
|
|
450
|
+
if (!online.includes(url)) {
|
|
451
|
+
online.push(url);
|
|
452
|
+
saveOnlineAgents(online);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
sendJson(res, 200, { servers, online_agents: loadOnlineAgents() });
|
|
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
|
+
}
|
|
469
|
+
|
|
470
|
+
const DEFAULT_AGENT_KEY = "your-secret-key"; // agents' out-of-the-box Api-Key
|
|
471
|
+
|
|
472
|
+
// Sweep the local /24 subnet for agents answering GET /hello.
|
|
473
|
+
// Updates online agents and saves new agents with default key if verified.
|
|
474
|
+
const SCAN_TIMEOUT = 1500; // /hello timeout during a /24 sweep
|
|
475
|
+
const SCAN_BATCH = 48; // hosts probed at once
|
|
476
|
+
|
|
477
|
+
async function scanSubnet(port = 8314, announce = false, onFound = () => {}) {
|
|
478
|
+
const localIp = await detectLocalIp();
|
|
479
|
+
const prefix = localIp.split(".").slice(0, 3).join(".");
|
|
480
|
+
if (announce) console.log(`scanning: ${prefix}.0/24 (port ${port})`);
|
|
481
|
+
const saved = new Map(loadServers().map(server => [server.url, server]));
|
|
482
|
+
const found = [];
|
|
483
|
+
const names = {}; // url -> agent name from its /hello response
|
|
484
|
+
const hosts = Array.from({ length: 255 }, (_, i) => `${prefix}.${i + 1}`);
|
|
485
|
+
// Probe timeout and concurrency tuned for low-power boards (Zero 2 W / Pico).
|
|
486
|
+
await runLimited(hosts, SCAN_BATCH, async host => {
|
|
487
|
+
try {
|
|
488
|
+
const r = await fetch(`http://${host}:${port}/hello`, { signal: AbortSignal.timeout(SCAN_TIMEOUT) });
|
|
489
|
+
if (r.status === 200) {
|
|
490
|
+
const u = `http://${host}:${port}`;
|
|
491
|
+
found.push(u);
|
|
492
|
+
try { names[u] = ((await r.json()).name || "").trim(); } catch { /* non-JSON hello */ }
|
|
493
|
+
onFound({ url: u, name: names[u] || "", api_key: saved.get(u)?.api_key ?? DEFAULT_AGENT_KEY });
|
|
494
|
+
}
|
|
495
|
+
} catch { /* unreachable */ }
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const storedKey = new Map(loadServers().map(s => [s.url, s.api_key || ""]));
|
|
499
|
+
for (const u of found) {
|
|
500
|
+
// Preserve saved key; for new agents, store default key only if verified.
|
|
501
|
+
const key = storedKey.get(u)
|
|
502
|
+
? null
|
|
503
|
+
: (await defaultKeyWorks(u)) ? DEFAULT_AGENT_KEY : "";
|
|
504
|
+
upsertServer(u, key, names[u]);
|
|
505
|
+
}
|
|
506
|
+
saveOnlineAgents(found);
|
|
507
|
+
return { subnet: `${prefix}.0/24`, found: [...found].sort(), names };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// True when the agent accepts the shipped default key on an authenticated route.
|
|
511
|
+
async function defaultKeyWorks(baseUrl) {
|
|
512
|
+
try {
|
|
513
|
+
const r = await fetch(`${baseUrl}/config/read`, {
|
|
514
|
+
headers: { "Api-Key": DEFAULT_AGENT_KEY },
|
|
515
|
+
signal: AbortSignal.timeout(2000),
|
|
516
|
+
});
|
|
517
|
+
return r.status === 200;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
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.
|
|
525
|
+
async function handleScan(req, res, url) {
|
|
526
|
+
const port = parseInt(url.searchParams.get("port") ?? "8314", 10);
|
|
527
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new HttpError(400, "Invalid port");
|
|
528
|
+
if (url.searchParams.get("stream") === "1") {
|
|
529
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
|
|
530
|
+
res.flushHeaders();
|
|
531
|
+
const emit = event => { if (!res.destroyed) res.write(JSON.stringify(event) + "\n"); };
|
|
532
|
+
try {
|
|
533
|
+
const result = await scanSubnet(port, false, agent => emit({ type: "agent", agent }));
|
|
534
|
+
emit({ type: "done", ...result });
|
|
535
|
+
} catch (error) {
|
|
536
|
+
emit({ type: "error", error: error.message });
|
|
537
|
+
} finally {
|
|
538
|
+
res.end();
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
let result;
|
|
543
|
+
try { result = await scanSubnet(port); }
|
|
544
|
+
catch { throw new HttpError(500, "Could not determine local network interface"); }
|
|
545
|
+
sendJson(res, 200, result);
|
|
546
|
+
}
|
|
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
|
+
|
|
565
|
+
// ── router ───────────────────────────────────────────────────────────
|
|
566
|
+
// Routes one request; any HttpError becomes a JSON {error} answer.
|
|
567
|
+
async function dispatch(req, res) {
|
|
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
|
+
}
|
|
576
|
+
|
|
577
|
+
const url = new URL(req.url, "http://localhost");
|
|
578
|
+
const p = url.pathname;
|
|
579
|
+
const m = req.method;
|
|
580
|
+
try {
|
|
581
|
+
if (p === "/api/proxy/url") return await handleProxy(req, res, url); // any method
|
|
582
|
+
if (p === "/" && m === "GET") {
|
|
583
|
+
if (indexHtmlCache === null) loadIndexHtml();
|
|
584
|
+
return sendHtml(res, 200, indexHtmlCache);
|
|
585
|
+
}
|
|
586
|
+
if (p === "/api/reload-cache" && m === "POST") { loadIndexHtml(); return sendJson(res, 200, { status: "reloaded" }); }
|
|
587
|
+
if (p === "/api/default-config" && m === "GET") {
|
|
588
|
+
let localNetworkPrefix = "";
|
|
589
|
+
try { localNetworkPrefix = (await detectLocalIp()).split(".").slice(0, 3).join(".") + "."; }
|
|
590
|
+
catch { /* The address can still be entered manually when offline. */ }
|
|
591
|
+
return sendJson(res, 200, { ...getDefaultApiConfig(), local_network_prefix: localNetworkPrefix, dev_mode: process.env.NODE_ENV === "development" });
|
|
592
|
+
}
|
|
593
|
+
if (p === "/api/save-config" && m === "POST") return await handleSaveConfig(req, res);
|
|
594
|
+
if (p === "/api/servers" && m === "GET") return handleServersGet(res);
|
|
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);
|
|
597
|
+
if (p === "/api/scan" && m === "GET") return await handleScan(req, res, url);
|
|
598
|
+
if (p.startsWith("/pages/") && m === "GET") return sendStatic(res, PAGES_DIR, decodeURIComponent(p.slice("/pages/".length)));
|
|
599
|
+
throw new HttpError(404, "not found");
|
|
600
|
+
} catch (err) {
|
|
601
|
+
// JSON error response {error: detail}.
|
|
602
|
+
if (res.headersSent) return;
|
|
603
|
+
sendJson(res, err.status || 500, { error: err.detail || err.message || "error" });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
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.
|
|
610
|
+
function detectLocalIp() {
|
|
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
|
+
|
|
626
|
+
const s = dgram.createSocket("udp4");
|
|
627
|
+
s.once("error", () => {
|
|
628
|
+
try { s.close(); } catch { /* ignore */ }
|
|
629
|
+
fallback();
|
|
630
|
+
});
|
|
631
|
+
try {
|
|
632
|
+
s.connect(80, "8.8.8.8", () => {
|
|
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();
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
try { s.close(); } catch { /* ignore */ }
|
|
644
|
+
fallback();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Run an async worker over items with a bounded concurrency.
|
|
650
|
+
async function runLimited(items, limit, worker) {
|
|
651
|
+
let i = 0;
|
|
652
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
653
|
+
while (i < items.length) { const item = items[i++]; await worker(item); }
|
|
654
|
+
});
|
|
655
|
+
await Promise.all(runners);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── start ────────────────────────────────────────────────────────────
|
|
659
|
+
loadIndexHtml();
|
|
660
|
+
const server = http.createServer((req, res) => {
|
|
661
|
+
dispatch(req, res).catch(err => {
|
|
662
|
+
if (!res.headersSent) sendJson(res, err.status || 500, { error: err.detail || err.message || "error" });
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
// Scan once at startup so the agent list is current without anyone pressing Scan. It runs
|
|
666
|
+
// after the listen callback and never blocks it: the dashboard is up immediately, and the
|
|
667
|
+
// list fills in when the sweep finishes.
|
|
668
|
+
async function startupScan() {
|
|
669
|
+
try {
|
|
670
|
+
try {
|
|
671
|
+
const stats = await fs.promises.stat(SHARED_FILE);
|
|
672
|
+
if (Date.now() - stats.mtimeMs < 5 * 60 * 1000) {
|
|
673
|
+
console.log("Startup scan skipped: agents-config.json was updated less than 5 minutes ago. Running quick reachability test.");
|
|
674
|
+
const saved = loadServers();
|
|
675
|
+
const alive = [];
|
|
676
|
+
await Promise.all(saved.map(async s => {
|
|
677
|
+
try {
|
|
678
|
+
const r = await fetch(`${s.url}/hello`, { signal: AbortSignal.timeout(1500) });
|
|
679
|
+
if (r.status === 200) alive.push(s.url);
|
|
680
|
+
} catch (e) { }
|
|
681
|
+
}));
|
|
682
|
+
saveOnlineAgents(alive);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
} catch (e) {
|
|
686
|
+
// File might not exist yet, proceed with scan
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { found } = await scanSubnet(DEFAULT_AGENT_PORT, true);
|
|
690
|
+
console.log(found.length
|
|
691
|
+
? `Startup scan: ${found.length} agent(s) found - ${found.join(", ")}`
|
|
692
|
+
: "Startup scan: no agents found on this subnet.");
|
|
693
|
+
} catch (e) {
|
|
694
|
+
console.log(`Startup scan skipped: ${e.message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
server.listen(PORT, HOST, () => {
|
|
699
|
+
const localAddr = `http://localhost:${server.address().port}`;
|
|
700
|
+
const divider = "-".repeat(Math.max(21, localAddr.length));
|
|
701
|
+
console.log(`\nctrlPi Lab started on →\n${divider}\n${localAddr}\n${divider}`);
|
|
702
|
+
if (!NO_OPEN) openBrowser(localAddr);
|
|
703
|
+
if (!NO_STARTUP_SCAN) startupScan();
|
|
704
|
+
});
|
|
705
|
+
// Closes the server, forcing the exit after one second if connections linger.
|
|
706
|
+
function shutdown() { server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 1000); }
|
|
707
|
+
process.on("SIGINT", shutdown);
|
|
708
|
+
process.on("SIGTERM", shutdown);
|
|
709
|
+
|
|
710
|
+
// A parent process can request graceful shutdown over IPC (also works on Windows).
|
|
711
|
+
if (process.send) process.on("message", message => {
|
|
712
|
+
if (message === "shutdown") shutdown();
|
|
713
|
+
});
|