4bnode 4.2.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +52 -3
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
|
@@ -21,13 +21,23 @@
|
|
|
21
21
|
// - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger
|
|
22
22
|
// in client caches for the record's full TTL (can be over an hour).
|
|
23
23
|
//
|
|
24
|
-
//
|
|
24
|
+
// - OFF by default. Advertising only starts with BONJOUR_ENABLED=on.
|
|
25
|
+
// - On a machine with several networks (e.g. Wi-Fi AND Ethernet), mDNS is bound
|
|
26
|
+
// to ONE chosen interface (BONJOUR_INTERFACE=en0) and only that network's
|
|
27
|
+
// addresses are advertised. Otherwise a phone on Wi-Fi can be handed the
|
|
28
|
+
// Ethernet IP it cannot reach. The interface NAME is stored, not its IP, so a
|
|
29
|
+
// new DHCP lease does not break it.
|
|
30
|
+
//
|
|
31
|
+
// Configure via .env:
|
|
32
|
+
// BONJOUR_ENABLED=on | BONJOUR_NAME=... | BONJOUR_TYPE=http | BONJOUR_INTERFACE=en0
|
|
25
33
|
|
|
26
34
|
import os from "os";
|
|
35
|
+
import { execFileSync } from "child_process";
|
|
27
36
|
|
|
28
37
|
let bonjour = null; // shared mDNS controller (created lazily, one per process)
|
|
29
38
|
let published = null; // handle to the currently-advertised service, if any
|
|
30
39
|
let BonjourCtor = null; // cached constructor after the first successful import
|
|
40
|
+
let boundInterface = null; // interface name the current controller is bound to ("" = all)
|
|
31
41
|
|
|
32
42
|
let selfState = {
|
|
33
43
|
status: "idle", // idle | advertising | error | unsupported
|
|
@@ -37,6 +47,8 @@ let selfState = {
|
|
|
37
47
|
host: null,
|
|
38
48
|
txt: null,
|
|
39
49
|
error: null,
|
|
50
|
+
interface: null, // { name, label, address } actually used, or null for all
|
|
51
|
+
warning: null,
|
|
40
52
|
};
|
|
41
53
|
|
|
42
54
|
// Import bonjour-service on demand. Returns the constructor, or null if the
|
|
@@ -74,9 +86,89 @@ function defaultServiceName() {
|
|
|
74
86
|
return host ? base + " (" + host + ")" : base;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
//
|
|
89
|
+
// Off by default; opt in with BONJOUR_ENABLED=on.
|
|
78
90
|
export function isDiscoveryEnabled() {
|
|
79
|
-
return process.env.BONJOUR_ENABLED
|
|
91
|
+
return process.env.BONJOUR_ENABLED === "on";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Virtual / tunnel / container interfaces that never reach other LAN devices.
|
|
95
|
+
const VIRTUAL_IFACE = /^(lo|utun|awdl|llw|gif|stf|anpi|ap|bridge|vmnet|vboxnet|docker|br-|veth|virbr|tun|tap|zt|tailscale|wg)/i;
|
|
96
|
+
|
|
97
|
+
// macOS: map device names (en0) to their hardware port ("Wi-Fi", "Ethernet").
|
|
98
|
+
let macPorts = null;
|
|
99
|
+
function macHardwarePorts() {
|
|
100
|
+
if (macPorts) return macPorts;
|
|
101
|
+
macPorts = {};
|
|
102
|
+
try {
|
|
103
|
+
const out = execFileSync("networksetup", ["-listallhardwareports"], { encoding: "utf8", timeout: 3000 });
|
|
104
|
+
let current = null;
|
|
105
|
+
for (const raw of out.split(String.fromCharCode(10))) {
|
|
106
|
+
const line = raw.trim(); // trim also drops a Windows CR
|
|
107
|
+
const port = line.match(/^Hardware Port: (.+)$/);
|
|
108
|
+
if (port) current = port[1].trim();
|
|
109
|
+
const dev = line.match(/^Device: (.+)$/);
|
|
110
|
+
if (dev && current) macPorts[dev[1].trim()] = current;
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
return macPorts;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function interfaceKind(name) {
|
|
117
|
+
if (os.platform() === "darwin") {
|
|
118
|
+
const hw = macHardwarePorts()[name];
|
|
119
|
+
if (hw) return /wi-?fi|airport/i.test(hw) ? "Wi-Fi" : hw;
|
|
120
|
+
}
|
|
121
|
+
if (/^(wl|wlan|wifi)/i.test(name) || /wi-?fi|wireless|wlan/i.test(name)) return "Wi-Fi";
|
|
122
|
+
if (/^(eth|en|em|eno|ens|enp)/i.test(name) || /ethernet|lan/i.test(name)) return "Ethernet";
|
|
123
|
+
return "Network";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Physical networks this machine is connected to, each with its IPv4 address.
|
|
127
|
+
// Used by the dashboard / CLI to let the user choose which one to advertise on.
|
|
128
|
+
export function listNetworkInterfaces() {
|
|
129
|
+
const all = os.networkInterfaces();
|
|
130
|
+
const result = [];
|
|
131
|
+
for (const name of Object.keys(all)) {
|
|
132
|
+
if (VIRTUAL_IFACE.test(name)) continue;
|
|
133
|
+
const addrs = all[name] || [];
|
|
134
|
+
const v4 = addrs.find((a) => a.family === "IPv4" && !a.internal);
|
|
135
|
+
if (!v4) continue;
|
|
136
|
+
const kind = interfaceKind(name);
|
|
137
|
+
result.push({
|
|
138
|
+
name,
|
|
139
|
+
kind,
|
|
140
|
+
label: kind + " (" + name + ")",
|
|
141
|
+
address: v4.address,
|
|
142
|
+
// 169.254.x.x = self-assigned: the cable/adapter is up but no network answered.
|
|
143
|
+
selfAssigned: v4.address.startsWith("169.254."),
|
|
144
|
+
ipv6: addrs.filter((a) => a.family === "IPv6" && !a.internal).map((a) => a.address),
|
|
145
|
+
mac: v4.mac,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolve BONJOUR_INTERFACE to a live interface. Returns { iface, warning }:
|
|
152
|
+
// iface null means "all interfaces".
|
|
153
|
+
function resolveInterface() {
|
|
154
|
+
const wanted = (process.env.BONJOUR_INTERFACE || "").trim();
|
|
155
|
+
const list = listNetworkInterfaces();
|
|
156
|
+
if (wanted) {
|
|
157
|
+
const hit = list.find((i) => i.name === wanted);
|
|
158
|
+
if (hit) return { iface: hit, warning: null };
|
|
159
|
+
return {
|
|
160
|
+
iface: null,
|
|
161
|
+
warning: "Network " + JSON.stringify(wanted) + " is not connected; advertising on all networks instead.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (list.length > 1) {
|
|
165
|
+
return {
|
|
166
|
+
iface: null,
|
|
167
|
+
warning: "Several networks are connected (" + list.map((i) => i.label).join(", ") +
|
|
168
|
+
"). Advertising on all of them; choose one with BONJOUR_INTERFACE or in the dashboard.",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { iface: null, warning: null };
|
|
80
172
|
}
|
|
81
173
|
|
|
82
174
|
// Snapshot of what this process is currently advertising (read by the dashboard).
|
|
@@ -84,6 +176,29 @@ export function getSelfState() {
|
|
|
84
176
|
return { ...selfState, enabled: isDiscoveryEnabled() };
|
|
85
177
|
}
|
|
86
178
|
|
|
179
|
+
// (Re)create the mDNS controller bound to the chosen interface. Binding the
|
|
180
|
+
// socket to 0.0.0.0 while pinning multicast to the interface IP keeps receiving
|
|
181
|
+
// queries reliable on macOS/Linux.
|
|
182
|
+
function ensureController(Ctor, iface) {
|
|
183
|
+
const key = iface ? iface.name : "";
|
|
184
|
+
if (bonjour && boundInterface === key) return;
|
|
185
|
+
if (bonjour) {
|
|
186
|
+
try { bonjour.destroy(); } catch {}
|
|
187
|
+
}
|
|
188
|
+
bonjour = iface ? new Ctor({ interface: iface.address, bind: "0.0.0.0" }) : new Ctor();
|
|
189
|
+
boundInterface = key;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Keep only the chosen interface's addresses in A/AAAA records, so clients on
|
|
193
|
+
// that network are never handed an IP from another network.
|
|
194
|
+
function restrictAddresses(service, iface) {
|
|
195
|
+
if (!iface || typeof service.records !== "function") return;
|
|
196
|
+
const allowed = new Set([iface.address].concat(iface.ipv6));
|
|
197
|
+
const original = service.records.bind(service);
|
|
198
|
+
service.records = () =>
|
|
199
|
+
original().filter((r) => (r.type !== "A" && r.type !== "AAAA") || allowed.has(r.data));
|
|
200
|
+
}
|
|
201
|
+
|
|
87
202
|
// Advertise this app on the local network. Safe to call repeatedly; each call
|
|
88
203
|
// replaces any prior advertisement, so a port change re-publishes cleanly. Must
|
|
89
204
|
// be called AFTER the HTTP server is actually listening (so the port is bound).
|
|
@@ -113,7 +228,9 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
113
228
|
|
|
114
229
|
// Replace any previous advertisement so two conflicting records never coexist.
|
|
115
230
|
await stopAdvertising();
|
|
116
|
-
|
|
231
|
+
const { iface, warning } = resolveInterface();
|
|
232
|
+
ensureController(Ctor, iface);
|
|
233
|
+
if (warning) console.warn("mDNS: " + warning);
|
|
117
234
|
|
|
118
235
|
const serviceName = name || defaultServiceName();
|
|
119
236
|
const serviceType = type || process.env.BONJOUR_TYPE || "http";
|
|
@@ -125,6 +242,7 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
125
242
|
|
|
126
243
|
try {
|
|
127
244
|
published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt });
|
|
245
|
+
restrictAddresses(published, iface);
|
|
128
246
|
selfState = {
|
|
129
247
|
status: "advertising",
|
|
130
248
|
name: serviceName,
|
|
@@ -133,10 +251,13 @@ export async function startAdvertising({ port, name, type } = {}) {
|
|
|
133
251
|
host,
|
|
134
252
|
txt,
|
|
135
253
|
error: null,
|
|
254
|
+
interface: iface ? { name: iface.name, label: iface.label, address: iface.address } : null,
|
|
255
|
+
warning,
|
|
136
256
|
};
|
|
137
257
|
published.on("up", () => {
|
|
138
258
|
console.log(
|
|
139
|
-
"mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)"
|
|
259
|
+
"mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)" +
|
|
260
|
+
(iface ? " on " + iface.label + " " + iface.address : " on all networks"),
|
|
140
261
|
);
|
|
141
262
|
});
|
|
142
263
|
published.on("error", (err) => {
|
|
@@ -186,6 +307,7 @@ export async function destroyDiscovery() {
|
|
|
186
307
|
if (bonjour) bonjour.destroy();
|
|
187
308
|
} catch {}
|
|
188
309
|
bonjour = null;
|
|
310
|
+
boundInterface = null;
|
|
189
311
|
}
|
|
190
312
|
|
|
191
313
|
// Browse the LAN for services of a given type. Collects results for timeoutMs
|
|
@@ -194,7 +316,7 @@ export async function destroyDiscovery() {
|
|
|
194
316
|
export async function browse({ type = "http", timeoutMs = 2500 } = {}) {
|
|
195
317
|
const Ctor = await loadBonjour();
|
|
196
318
|
if (!Ctor) return { available: false, services: [] };
|
|
197
|
-
if (!bonjour)
|
|
319
|
+
if (!bonjour) ensureController(Ctor, resolveInterface().iface);
|
|
198
320
|
|
|
199
321
|
return new Promise((resolve) => {
|
|
200
322
|
const found = new Map();
|
|
@@ -1,49 +1,92 @@
|
|
|
1
|
-
// API key authentication.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// API key authentication.
|
|
2
|
+
//
|
|
3
|
+
// Every request to this app's API must carry a valid key in the x-api-key
|
|
4
|
+
// header, including login and registration, unless the gate is switched off
|
|
5
|
+
// with API_KEY_REQUIRED=off. Keys are created and revoked in the 4bnode
|
|
6
|
+
// dashboard (Security -> API keys) and stored HASHED in src/api-keys.json, so
|
|
7
|
+
// that file never holds a usable key. Revoking a key takes effect immediately.
|
|
8
|
+
//
|
|
9
|
+
// index.js (whole app, built in): app.use(apiKeyGate);
|
|
10
|
+
// a single router only: app.use('/api/private', apiKey);
|
|
11
|
+
//
|
|
12
|
+
// Never needs a key: the /docs page, CORS preflight (OPTIONS) requests, the
|
|
13
|
+
// /_dev dashboard, and any path prefix listed in API_KEY_PUBLIC_PATHS
|
|
14
|
+
// (comma-separated, e.g. /health,/webhooks/stripe).
|
|
15
|
+
//
|
|
16
|
+
// Routes that need a signed-in user still check the JWT (auth middleware) on
|
|
17
|
+
// top of this: the API key identifies the calling app, the JWT the user.
|
|
5
18
|
import fs from 'fs';
|
|
6
19
|
import path from 'path';
|
|
7
20
|
import crypto from 'crypto';
|
|
8
21
|
import { fileURLToPath } from 'url';
|
|
9
22
|
|
|
10
23
|
const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
|
|
11
|
-
let _cache = { mtimeMs: -1,
|
|
24
|
+
let _cache = { mtimeMs: -1, hashes: [] };
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
export function hashApiKey(key) {
|
|
27
|
+
return crypto.createHash('sha256').update(String(key)).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Read the keys file fresh when it changes (mtime-cached), so keys created or
|
|
31
|
+
// revoked in the dashboard apply without a restart. Older files stored the
|
|
32
|
+
// plaintext key; those entries are hashed on read.
|
|
33
|
+
function loadKeyHashes() {
|
|
16
34
|
try {
|
|
17
35
|
const { mtimeMs } = fs.statSync(KEYS_FILE);
|
|
18
36
|
if (mtimeMs !== _cache.mtimeMs) {
|
|
19
37
|
const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
|
|
20
|
-
|
|
38
|
+
const hashes = (Array.isArray(list) ? list : [])
|
|
39
|
+
.map((k) => k && (k.hash || (k.key ? hashApiKey(k.key) : null)))
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
_cache = { mtimeMs, hashes };
|
|
21
42
|
}
|
|
22
43
|
} catch {
|
|
23
|
-
_cache = { mtimeMs: -1,
|
|
44
|
+
_cache = { mtimeMs: -1, hashes: [] };
|
|
24
45
|
}
|
|
25
|
-
return _cache.
|
|
46
|
+
return _cache.hashes;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
// Constant-time membership test
|
|
29
|
-
//
|
|
30
|
-
function keyMatches(provided
|
|
31
|
-
const p =
|
|
49
|
+
// Constant-time membership test over fixed-length digests: no early return,
|
|
50
|
+
// so timing reveals neither which key matched nor the key length.
|
|
51
|
+
function keyMatches(provided) {
|
|
52
|
+
const p = Buffer.from(hashApiKey(provided), 'hex');
|
|
32
53
|
let ok = false;
|
|
33
|
-
for (const
|
|
34
|
-
const
|
|
35
|
-
if (crypto.timingSafeEqual(p,
|
|
54
|
+
for (const h of loadKeyHashes()) {
|
|
55
|
+
const v = Buffer.from(h, 'hex');
|
|
56
|
+
if (v.length === p.length && crypto.timingSafeEqual(p, v)) ok = true;
|
|
36
57
|
}
|
|
37
58
|
return ok;
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
const apiKey = (req, res, next) => {
|
|
41
62
|
const provided = req.header('x-api-key');
|
|
42
|
-
|
|
43
|
-
if (!provided || !keyMatches(provided, valid)) {
|
|
63
|
+
if (!provided || !keyMatches(provided)) {
|
|
44
64
|
return res.status(401).json({ message: 'Invalid or missing API key' });
|
|
45
65
|
}
|
|
46
66
|
next();
|
|
47
67
|
};
|
|
48
68
|
|
|
69
|
+
function publicPaths() {
|
|
70
|
+
const extra = String(process.env.API_KEY_PUBLIC_PATHS || '')
|
|
71
|
+
.split(',')
|
|
72
|
+
.map((p) => p.trim())
|
|
73
|
+
.filter((p) => p.startsWith('/'));
|
|
74
|
+
return ['/_dev', '/docs'].concat(extra);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPublicPath(p) {
|
|
78
|
+
return publicPaths().some((pre) => {
|
|
79
|
+
const base = pre.length > 1 && pre.endsWith('/') ? pre.slice(0, -1) : pre;
|
|
80
|
+
return p === base || p.startsWith(base + '/');
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// App-wide gate. Mount after static files and before any routes.
|
|
85
|
+
export function apiKeyGate(req, res, next) {
|
|
86
|
+
if (process.env.API_KEY_REQUIRED === 'off') return next();
|
|
87
|
+
if (req.method === 'OPTIONS') return next();
|
|
88
|
+
if (isPublicPath(req.path)) return next();
|
|
89
|
+
return apiKey(req, res, next);
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
export default apiKey;
|