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.
@@ -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
- // Configure via .env: BONJOUR_ENABLED=off | BONJOUR_NAME=... | BONJOUR_TYPE=http
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
- // On by default; opt out with BONJOUR_ENABLED=off.
89
+ // Off by default; opt in with BONJOUR_ENABLED=on.
78
90
  export function isDiscoveryEnabled() {
79
- return process.env.BONJOUR_ENABLED !== "off";
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
- if (!bonjour) bonjour = new Ctor();
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) bonjour = new Ctor();
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. Named keys are managed by the 4bnode dashboard and
2
- // stored in src/api-keys.json. Send a key in the `x-api-key` request header:
3
- // import apiKey from './src/middleware/apiKey.js';
4
- // app.use('/api/private', apiKey);
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, keys: [] };
24
+ let _cache = { mtimeMs: -1, hashes: [] };
12
25
 
13
- // Read the keys file fresh when it changes (mtime-cached), so keys generated or
14
- // revoked in the dashboard take effect immediately — no restart needed.
15
- function loadKeys() {
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
- _cache = { mtimeMs, keys: (Array.isArray(list) ? list : []).map((k) => k && k.key).filter(Boolean) };
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, keys: [] };
44
+ _cache = { mtimeMs: -1, hashes: [] };
24
45
  }
25
- return _cache.keys;
46
+ return _cache.hashes;
26
47
  }
27
48
 
28
- // Constant-time membership test: hash both sides to a fixed length so neither the
29
- // match result nor the key length leaks through string-comparison timing.
30
- function keyMatches(provided, valid) {
31
- const p = crypto.createHash('sha256').update(String(provided)).digest();
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 k of valid) {
34
- const h = crypto.createHash('sha256').update(String(k)).digest();
35
- if (crypto.timingSafeEqual(p, h)) ok = true; // no early return — keep timing flat
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
- const valid = loadKeys();
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;