@fanboynz/network-scanner 2.0.66 → 3.0.0

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.
@@ -2,10 +2,12 @@
2
2
  // Per-site VPN configuration for network scanner
3
3
  // Manages WireGuard interfaces, routing, and lifecycle
4
4
 
5
- const { execSync, exec } = require('child_process');
5
+ const { spawnSync } = require('child_process');
6
+ const crypto = require('crypto');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
- const { formatLogMessage } = require('./colorize');
9
+ const { formatLogMessage, messageColors } = require('./colorize');
10
+ const VPN_TAG = messageColors.processing('[vpn]');
9
11
 
10
12
  /**
11
13
  * Fetch external IP address through the active tunnel
@@ -15,10 +17,15 @@ const { formatLogMessage } = require('./colorize');
15
17
  function getExternalIP(interfaceName) {
16
18
  const services = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
17
19
  for (const service of services) {
18
- try {
19
- const iface = interfaceName ? `--interface ${interfaceName}` : '';
20
- return execSync(`curl -s -m 5 ${iface} ${service}`, { encoding: 'utf8', timeout: 8000 }).trim();
21
- } catch {}
20
+ const args = ['-s', '-m', '5'];
21
+ if (interfaceName) args.push('--interface', interfaceName);
22
+ args.push(service);
23
+ // spawnSync (no shell) so a malicious interfaceName like
24
+ // "wg-foo; rm -rf ~" can't be split into a second command.
25
+ const result = spawnSync('curl', args, { encoding: 'utf8', timeout: 8000 });
26
+ if (result.status === 0 && result.stdout) {
27
+ return result.stdout.trim();
28
+ }
22
29
  }
23
30
  return null;
24
31
  }
@@ -29,24 +36,6 @@ const activeInterfaces = new Map();
29
36
  // Temp config directory for inline configs
30
37
  const TEMP_CONFIG_DIR = '/tmp/nwss-wireguard';
31
38
 
32
- /**
33
- * Validate WireGuard availability on the system
34
- * @returns {Object} { isAvailable, version, error }
35
- */
36
- function validateWireGuardAvailability() {
37
- try {
38
- const version = execSync('wg --version 2>&1', { encoding: 'utf8' }).trim();
39
- // Check wg-quick is also available
40
- execSync('which wg-quick', { encoding: 'utf8' });
41
- return { isAvailable: true, version };
42
- } catch (error) {
43
- return {
44
- isAvailable: false,
45
- error: 'WireGuard not found. Install with: sudo apt install wireguard'
46
- };
47
- }
48
- }
49
-
50
39
  /**
51
40
  * Check if running with sufficient privileges
52
41
  * @returns {boolean}
@@ -69,12 +58,24 @@ function resolveInterfaceName(vpnConfig) {
69
58
  return vpnConfig.interface;
70
59
  }
71
60
  if (vpnConfig.config) {
72
- // Extract name from /etc/wireguard/wg-example.conf ? wg-example
61
+ // Extract name from /etc/wireguard/wg-example.conf wg-example
73
62
  return path.basename(vpnConfig.config, '.conf');
74
63
  }
75
- // Auto-generate from index
76
- const index = activeInterfaces.size;
77
- return `wg-nwss${index}`;
64
+ // Inline-only config without an explicit interface: derive a stable
65
+ // name from a hash of the content so connect and disconnect resolve
66
+ // to the same name across calls. The old `wg-nwss${activeInterfaces.size}`
67
+ // used the live Map size, so disconnect computed a DIFFERENT name
68
+ // than connect did (size had grown in between) and silently failed
69
+ // to find the entry — the interface would leak until disconnectAll.
70
+ //
71
+ // Truncated SHA-1 to 8 hex chars keeps the total under Linux's
72
+ // 15-char IFNAMSIZ limit ('wg-nwss' = 7 + 8 = 15).
73
+ if (vpnConfig.config_inline) {
74
+ const hash = crypto.createHash('sha1').update(vpnConfig.config_inline).digest('hex').slice(0, 8);
75
+ return `wg-nwss${hash}`;
76
+ }
77
+ // Last resort — should be unreachable if validation ran first.
78
+ return 'wg-nwss-unknown';
78
79
  }
79
80
 
80
81
  /**
@@ -103,17 +104,24 @@ function writeInlineConfig(interfaceName, configContent) {
103
104
  function interfaceUp(configPath, interfaceName, forceDebug = false) {
104
105
  if (activeInterfaces.has(interfaceName)) {
105
106
  if (forceDebug) {
106
- console.log(formatLogMessage('debug', `[vpn] Interface ${interfaceName} already active`));
107
+ console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} already active`));
107
108
  }
108
109
  return { success: true, interface: interfaceName, alreadyActive: true };
109
110
  }
110
111
 
111
112
  try {
112
- // wg-quick accepts a config path or interface name in /etc/wireguard/
113
- execSync(`sudo wg-quick up "${configPath}"`, {
113
+ // wg-quick accepts a config path or interface name in /etc/wireguard/.
114
+ // spawnSync with arg array (no shell) configPath comes from user
115
+ // JSON, so naive `sudo wg-quick up "${configPath}"` was vulnerable
116
+ // to a `";rm -rf ~;"` payload escaping the quotes.
117
+ const upRes = spawnSync('sudo', ['wg-quick', 'up', configPath], {
114
118
  encoding: 'utf8',
115
119
  timeout: 15000
116
120
  });
121
+ if (upRes.error) throw upRes.error;
122
+ if (upRes.status !== 0) {
123
+ throw new Error((upRes.stderr || '').trim() || `wg-quick up exited with status ${upRes.status}`);
124
+ }
117
125
 
118
126
  activeInterfaces.set(interfaceName, {
119
127
  configPath,
@@ -122,11 +130,19 @@ function interfaceUp(configPath, interfaceName, forceDebug = false) {
122
130
  });
123
131
 
124
132
  if (forceDebug) {
125
- console.log(formatLogMessage('debug', `[vpn] Interface ${interfaceName} is up`));
133
+ console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} is up`));
134
+ // Only fetch the external IP when debug-logging would actually
135
+ // display it — getExternalIP runs 3 sequential 8s-timeout curls
136
+ // (~24s worst case of blocking event loop). The result was
137
+ // previously included in the return shape but no caller read
138
+ // it; the work was pure waste outside debug runs.
139
+ const externalIP = getExternalIP(interfaceName);
140
+ if (externalIP) {
141
+ console.log(formatLogMessage('debug', `${VPN_TAG} ${interfaceName} external IP: ${externalIP}`));
142
+ }
126
143
  }
127
144
 
128
- const externalIP = getExternalIP(interfaceName);
129
- return { success: true, interface: interfaceName, externalIP };
145
+ return { success: true, interface: interfaceName };
130
146
  } catch (error) {
131
147
  return {
132
148
  success: false,
@@ -149,21 +165,20 @@ function interfaceDown(interfaceName, forceDebug = false) {
149
165
  }
150
166
 
151
167
  try {
152
- execSync(`sudo wg-quick down "${info.configPath}"`, {
168
+ // spawnSync with arg array (see interfaceUp comment for rationale).
169
+ const downRes = spawnSync('sudo', ['wg-quick', 'down', info.configPath], {
153
170
  encoding: 'utf8',
154
171
  timeout: 10000
155
172
  });
173
+ if (downRes.error) throw downRes.error;
174
+ if (downRes.status !== 0) {
175
+ throw new Error((downRes.stderr || '').trim() || `wg-quick down exited with status ${downRes.status}`);
176
+ }
156
177
 
157
178
  activeInterfaces.delete(interfaceName);
158
179
 
159
- // Clean up temp config if it was inline
160
- const tempPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
161
- if (fs.existsSync(tempPath)) {
162
- try { fs.unlinkSync(tempPath); } catch {}
163
- }
164
-
165
180
  if (forceDebug) {
166
- console.log(formatLogMessage('debug', `[vpn] Interface ${interfaceName} is down`));
181
+ console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} is down`));
167
182
  }
168
183
 
169
184
  return { success: true };
@@ -171,6 +186,17 @@ function interfaceDown(interfaceName, forceDebug = false) {
171
186
  // Force remove from tracking even if wg-quick fails
172
187
  activeInterfaces.delete(interfaceName);
173
188
  return { success: false, error: error.message.trim() };
189
+ } finally {
190
+ // Clean up temp config regardless of wg-quick outcome — a leaked
191
+ // .conf in TEMP_CONFIG_DIR could collide on a re-connect with the
192
+ // same hash-derived interface name, especially after a wg-quick
193
+ // down failure where the kernel interface might persist briefly.
194
+ // Was previously only inside the try block, so failure paths
195
+ // leaked the temp file.
196
+ const tempPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
197
+ if (fs.existsSync(tempPath)) {
198
+ try { fs.unlinkSync(tempPath); } catch {}
199
+ }
174
200
  }
175
201
  }
176
202
 
@@ -183,21 +209,32 @@ function interfaceDown(interfaceName, forceDebug = false) {
183
209
  */
184
210
  function checkConnection(interfaceName, testHost = '1.1.1.1', forceDebug = false) {
185
211
  try {
186
- // Check interface exists
187
- execSync(`ip link show ${interfaceName}`, { encoding: 'utf8', timeout: 3000 });
212
+ // Check interface exists. spawnSync with arg array — interfaceName
213
+ // can come from user JSON (siteConfig.vpn.interface) so the old
214
+ // shell-interpolated `execSync(\`ip link show ${interfaceName}\`)`
215
+ // was injection-vulnerable.
216
+ const linkRes = spawnSync('ip', ['link', 'show', interfaceName], { encoding: 'utf8', timeout: 3000 });
217
+ if (linkRes.status !== 0) {
218
+ throw new Error((linkRes.stderr || '').trim() || `ip link show failed for ${interfaceName}`);
219
+ }
188
220
 
189
- // Ping through the specific interface
190
- const result = execSync(
191
- `ping -c 1 -W 5 -I ${interfaceName} ${testHost} 2>&1`,
192
- { encoding: 'utf8', timeout: 8000 }
193
- );
221
+ // Ping through the specific interface. testHost defaults to '1.1.1.1'
222
+ // but can be overridden by user config — same injection concern.
223
+ const pingRes = spawnSync('ping', ['-c', '1', '-W', '5', '-I', interfaceName, testHost], {
224
+ encoding: 'utf8', timeout: 8000
225
+ });
226
+ if (pingRes.status !== 0) {
227
+ // Combine stderr + stdout (no shell `2>&1` available with spawnSync)
228
+ throw new Error((pingRes.stderr || pingRes.stdout || '').split('\n')[0] || `ping failed for ${testHost}`);
229
+ }
230
+ const result = pingRes.stdout;
194
231
 
195
232
  const latencyMatch = result.match(/time=([0-9.]+)\s*ms/);
196
233
  const latencyMs = latencyMatch ? parseFloat(latencyMatch[1]) : null;
197
234
 
198
235
  if (forceDebug) {
199
236
  console.log(formatLogMessage('debug',
200
- `[vpn] ${interfaceName} connected (${latencyMs ? latencyMs + 'ms' : 'ok'})`
237
+ `${VPN_TAG} ${interfaceName} connected (${latencyMs ? latencyMs + 'ms' : 'ok'})`
201
238
  ));
202
239
  }
203
240
 
@@ -205,44 +242,13 @@ function checkConnection(interfaceName, testHost = '1.1.1.1', forceDebug = false
205
242
  } catch (error) {
206
243
  if (forceDebug) {
207
244
  console.log(formatLogMessage('debug',
208
- `[vpn] ${interfaceName} health check failed: ${error.message.split('\n')[0]}`
245
+ `${VPN_TAG} ${interfaceName} health check failed: ${error.message.split('\n')[0]}`
209
246
  ));
210
247
  }
211
248
  return { connected: false, error: error.message.split('\n')[0] };
212
249
  }
213
250
  }
214
251
 
215
- /**
216
- * Get WireGuard status for an interface
217
- * @param {string} interfaceName - Interface name
218
- * @returns {Object} Parsed wg show output
219
- */
220
- function getInterfaceStatus(interfaceName) {
221
- try {
222
- const output = execSync(`wg show ${interfaceName}`, {
223
- encoding: 'utf8',
224
- timeout: 5000
225
- });
226
-
227
- const status = { interface: interfaceName, raw: output };
228
-
229
- // Parse key fields
230
- const endpointMatch = output.match(/endpoint:\s*(.+)/);
231
- const transferMatch = output.match(/transfer:\s*(.+)/);
232
- const handshakeMatch = output.match(/latest handshake:\s*(.+)/);
233
- const allowedMatch = output.match(/allowed ips:\s*(.+)/);
234
-
235
- if (endpointMatch) status.endpoint = endpointMatch[1].trim();
236
- if (transferMatch) status.transfer = transferMatch[1].trim();
237
- if (handshakeMatch) status.latestHandshake = handshakeMatch[1].trim();
238
- if (allowedMatch) status.allowedIps = allowedMatch[1].trim();
239
-
240
- return status;
241
- } catch (error) {
242
- return { interface: interfaceName, error: error.message.split('\n')[0] };
243
- }
244
- }
245
-
246
252
  /**
247
253
  * Parse and validate a VPN site config
248
254
  * @param {Object|string} vpnConfig - VPN config from site JSON
@@ -258,6 +264,17 @@ function normalizeVpnConfig(vpnConfig) {
258
264
  return null;
259
265
  }
260
266
 
267
+ // Accept non-negative integers only — rejects:
268
+ // - undefined/null/false (would have hit '|| 2' fallback anyway)
269
+ // - strings like "3" (the old `|| 2` accepted those, then
270
+ // `vpnConfig.max_retries + 1` downstream string-concatenated
271
+ // to "31" and ran 31 retry attempts instead of 4)
272
+ // - negative numbers / non-integers
273
+ // Explicit 0 IS accepted now ("no retries, fail fast") — the old
274
+ // `|| 2` treated 0 as falsy and silently substituted 2.
275
+ const mr = vpnConfig.max_retries;
276
+ const max_retries = (typeof mr === 'number' && Number.isInteger(mr) && mr >= 0) ? mr : 2;
277
+
261
278
  return {
262
279
  config: vpnConfig.config || null,
263
280
  config_inline: vpnConfig.config_inline || null,
@@ -265,7 +282,7 @@ function normalizeVpnConfig(vpnConfig) {
265
282
  health_check: vpnConfig.health_check !== false,
266
283
  test_host: vpnConfig.test_host || '1.1.1.1',
267
284
  retry: vpnConfig.retry !== false,
268
- max_retries: vpnConfig.max_retries || 2
285
+ max_retries
269
286
  };
270
287
  }
271
288
 
@@ -368,7 +385,7 @@ async function connectForSite(siteConfig, forceDebug = false) {
368
385
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
369
386
  if (forceDebug && attempt > 1) {
370
387
  console.log(formatLogMessage('debug',
371
- `[vpn] Retry ${attempt - 1}/${vpnConfig.max_retries} for ${interfaceName}`
388
+ `${VPN_TAG} Retry ${attempt - 1}/${vpnConfig.max_retries} for ${interfaceName}`
372
389
  ));
373
390
  }
374
391
 
@@ -448,7 +465,7 @@ function disconnectForSite(siteConfig, forceDebug = false) {
448
465
 
449
466
  if (forceDebug) {
450
467
  console.log(formatLogMessage('debug',
451
- `[vpn] ${interfaceName} still used by ${info.sites.size} site(s), keeping up`
468
+ `${VPN_TAG} ${interfaceName} still used by ${info.sites.size} site(s), keeping up`
452
469
  ));
453
470
  }
454
471
 
@@ -480,47 +497,24 @@ function disconnectAll(forceDebug = false) {
480
497
 
481
498
  if (forceDebug && results.tornDown > 0) {
482
499
  console.log(formatLogMessage('debug',
483
- `[vpn] Disconnected ${results.tornDown} interface(s)`
500
+ `${VPN_TAG} Disconnected ${results.tornDown} interface(s)`
484
501
  ));
485
502
  }
486
503
 
487
504
  return results;
488
505
  }
489
506
 
490
- /**
491
- * Get summary of all active VPN interfaces
492
- * @returns {Array} Array of interface status objects
493
- */
494
- function getActiveInterfaces() {
495
- const interfaces = [];
496
-
497
- for (const [name, info] of activeInterfaces) {
498
- const status = getInterfaceStatus(name);
499
- interfaces.push({
500
- name,
501
- configPath: info.configPath,
502
- uptime: Math.round((Date.now() - info.startedAt) / 1000),
503
- sites: Array.from(info.sites),
504
- ...status
505
- });
506
- }
507
-
508
- return interfaces;
509
- }
510
-
507
+ // Public surface used by nwss.js. Internal helpers (checkConnection,
508
+ // interfaceUp, interfaceDown, resolveInterfaceName, hasRootPrivileges,
509
+ // getExternalIP, writeInlineConfig) stay module-private none had
510
+ // external callers. validateWireGuardAvailability, getInterfaceStatus,
511
+ // and getActiveInterfaces were removed entirely (zero callers anywhere,
512
+ // including no internal ones once their downstream consumers were
513
+ // pruned).
511
514
  module.exports = {
512
- validateWireGuardAvailability,
513
515
  validateVpnConfig,
514
516
  normalizeVpnConfig,
515
517
  connectForSite,
516
518
  disconnectForSite,
517
- disconnectAll,
518
- checkConnection,
519
- getInterfaceStatus,
520
- getActiveInterfaces,
521
- // Low-level (for testing or advanced use)
522
- interfaceUp,
523
- interfaceDown,
524
- resolveInterfaceName,
525
- hasRootPrivileges
519
+ disconnectAll
526
520
  };