@fanboynz/network-scanner 3.0.3 → 3.1.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.
@@ -583,7 +583,11 @@ function validateRulesetFile(filePath, options = {}) {
583
583
  errors.push(`Line ${lineNumber}: ${validation.error} - ${line}`);
584
584
 
585
585
  if (errors.length >= maxErrors) {
586
- errors.push(`... (stopping after ${maxErrors} errors, ${stats.total - i - 1} lines remaining)`);
586
+ // Lines remaining in the file = total lines current index 1.
587
+ // (Previously `stats.total - i - 1`, which mixed "non-empty lines
588
+ // processed" with "file line index" and went negative when empties
589
+ // were interleaved.)
590
+ errors.push(`... (stopping after ${maxErrors} errors, ${lines.length - i - 1} lines remaining)`);
587
591
  break;
588
592
  }
589
593
  }
@@ -1075,9 +1079,286 @@ function testDomainValidation() {
1075
1079
  return allPassed;
1076
1080
  }
1077
1081
 
1082
+ // ─── Per-site config normalization (runs on every scan, not just --validate-config) ───
1083
+ //
1084
+ // Catches the silent-failure class that bit a user across multiple scan iterations:
1085
+ // 1. Typo'd siteConfig keys (whois_terms vs whois) silently ignored.
1086
+ // 2. Boolean fields given truthy/falsy non-boolean values (interact: 1 vs interact: true)
1087
+ // silently disabled by strict `=== true` checks downstream.
1088
+ // 3. Misleading downstream warnings that blame the wrong field.
1089
+ //
1090
+ // normalizeSiteConfig() mutates siteConfig in place (coercing 1→true, etc) and returns
1091
+ // warnings the caller surfaces. Designed to run at scan startup, ALWAYS, not gated on
1092
+ // --validate-config (which most users never run).
1093
+
1094
+ // Whitelist of every siteConfig.X key read across nwss.js + lib/*.js.
1095
+ // Regenerate via BOTH:
1096
+ // grep -hoE "siteConfig\.[a-zA-Z_][a-zA-Z0-9_]*" nwss.js lib/*.js | sort -u
1097
+ // grep -hoE "siteConfig\[['\"][^'\"]+['\"]\]" nwss.js lib/*.js | sort -u
1098
+ // The second pattern catches bracket-notation access required for keys with
1099
+ // hyphens (e.g. 'dig-or', 'whois-or'). Dot-notation grep alone missed these
1100
+ // and produced false 'unknown siteConfig key' warnings for valid config.
1101
+ // Also grep for destructured siteConfig keys (master destructure block in
1102
+ // processUrl) — those don't show up in either pattern.
1103
+ const KNOWN_SITE_CONFIG_KEYS = new Set([
1104
+ 'adblock_rules', 'blocked', 'bypass_cache', 'capture_popups',
1105
+ 'capture_popups_max_depth', 'capture_popups_window_ms', 'cdp', 'cdp_specific',
1106
+ 'clear_sitedata', 'clear_sitedata_full_on_reload',
1107
+ 'cloudflare_bypass', 'cloudflare_max_retries', 'comments',
1108
+ 'cloudflare_parallel_detection', 'cloudflare_phish', 'cloudflare_retry_on_error',
1109
+ 'css_blocked', 'curl', 'cursor_mode', 'custom_headers', 'delay',
1110
+ 'delay_uncapped', 'detect_js_patterns', 'dig', 'dig-or', 'digRecordType', 'dig_subdomain',
1111
+ 'disable_adblock', 'dnsmasq', 'dnsmasq_old', 'evaluateOnNewDocument',
1112
+ 'even_blocked',
1113
+ 'filterRegex', 'fingerprint_protection', 'firstParty', 'flowproxy_additional_delay',
1114
+ 'flowproxy_delay', 'flowproxy_detection', 'flowproxy_js_timeout', 'flowproxy_nav_timeout',
1115
+ 'flowproxy_page_timeout', 'forcereload', 'ghost_cursor_duration',
1116
+ 'ghost_cursor_hesitate', 'ghost_cursor_overshoot', 'ghost_cursor_speed',
1117
+ 'goto_options', 'grep', 'headful', 'ignore_similar', 'ignore_similar_ignored_domains',
1118
+ 'ignore_similar_threshold', 'interact', 'interact_click_count', 'interact_clicks',
1119
+ 'interact_duration', 'interact_intensity', 'interact_scrolling', 'isBrave',
1120
+ 'js_redirect_timeout', 'localhost', 'max_redirects', 'openvpn', 'pihole',
1121
+ 'plain', 'privoxy', 'proxy', 'proxy_bypass', 'proxy_debug', 'proxy_remote_dns',
1122
+ 'realistic_click', 'referrer_disable', 'referrer_headers', 'regex_and',
1123
+ 'reload', 'resourceTypes', 'screenshot', 'searchstring', 'searchstring_and',
1124
+ 'socks5_bypass', 'socks5_debug', 'socks5_proxy', 'socks5_remote_dns',
1125
+ 'subDomains',
1126
+ 'thirdParty', 'timeout', 'unbound', 'url', 'userAgent', 'verbose', 'vpn',
1127
+ 'whois', 'whois-or', 'whois_delay', 'whois_max_retries', 'whois_retry_on_error',
1128
+ 'whois_retry_on_timeout', 'whois_server', 'whois_server_mode',
1129
+ 'whois_timeout_multiplier', 'whois_use_fallback', 'window_cleanup',
1130
+ 'window_cleanup_threshold',
1131
+ // Internal sentinel added by nwss.js when fanning array URLs into tasks.
1132
+ '_originalUrl',
1133
+ ]);
1134
+
1135
+ // Boolean siteConfig fields where strict `=== true` is used downstream.
1136
+ // Listed only for fields with UNAMBIGUOUS boolean semantics — fields with
1137
+ // multi-type overloads stay out:
1138
+ // forcereload : true | string[]
1139
+ // cloudflare_bypass : true | 'debug'
1140
+ // cloudflare_phish : true | 'debug'
1141
+ // window_cleanup : true | 'all' | 'realtime'
1142
+ // cursor_mode : string ('ghost')
1143
+ // Update both this set AND the strict-equality call sites if a new boolean
1144
+ // siteConfig field is added.
1145
+ const BOOLEAN_SITE_CONFIG_FIELDS = new Set([
1146
+ 'adblock_rules', 'bypass_cache', 'capture_popups', 'cdp', 'clear_sitedata',
1147
+ 'clear_sitedata_full_on_reload', 'curl', 'delay_uncapped',
1148
+ 'detect_js_patterns', 'dig_subdomain',
1149
+ 'disable_adblock', 'dnsmasq', 'dnsmasq_old', 'evaluateOnNewDocument',
1150
+ 'even_blocked', 'firstParty', 'flowproxy_detection',
1151
+ 'grep', 'headful', 'ignore_similar', 'ignore_similar_ignored_domains',
1152
+ 'interact', 'interact_clicks', 'interact_scrolling', 'isBrave', 'localhost',
1153
+ 'pihole', 'plain', 'privoxy', 'proxy_debug', 'proxy_remote_dns',
1154
+ 'realistic_click', 'referrer_disable', 'regex_and', 'screenshot',
1155
+ 'searchstring_and', 'socks5_debug', 'socks5_remote_dns', 'thirdParty',
1156
+ 'unbound', 'whois_retry_on_error', 'whois_retry_on_timeout', 'whois_use_fallback',
1157
+ ]);
1158
+
1159
+ // Fields that accept BOTH `"x"` (single term) and `["x", "y"]` (multi-term).
1160
+ // Downstream consumers (nwss.js line ~2824, lib/nettools.js line ~1149-1152)
1161
+ // do `Array.isArray(val) && val.length > 0` checks, so a string value
1162
+ // previously caused silent feature-disable. normalizeSiteConfig() now wraps
1163
+ // any string value in a single-element array so both forms are first-class.
1164
+ // Non-string non-array values still warn (and stay as-is, since we don't
1165
+ // know how to coerce them).
1166
+ const STRING_TO_ARRAY_FIELDS = new Set([
1167
+ 'dig', 'dig-or', 'whois', 'whois-or',
1168
+ ]);
1169
+
1170
+ // Truthy-but-not-true → true. Falsy-but-not-false → false. Otherwise leave alone.
1171
+ // Strings are lower-cased before matching so "True"/"TRUE"/"Yes"/etc all match.
1172
+ function _coerceBooleanLike(val) {
1173
+ if (val === true || val === false) return { coerced: false, value: val };
1174
+ const s = typeof val === 'string' ? val.toLowerCase() : val;
1175
+ if (s === 1 || s === '1' || s === 'true' || s === 'yes' || s === 'on') {
1176
+ return { coerced: true, value: true };
1177
+ }
1178
+ if (s === 0 || s === '0' || s === 'false' || s === 'no' || s === 'off') {
1179
+ return { coerced: true, value: false };
1180
+ }
1181
+ return { coerced: false, value: val };
1182
+ }
1183
+
1184
+ // Tiny Levenshtein for "did you mean?" suggestions. Inlined rather than
1185
+ // imported from lib/ignore_similar (which has its own dependency tree we
1186
+ // don't want to drag into validation) -- 18 lines of well-known algorithm.
1187
+ function _editDistance(a, b) {
1188
+ if (a === b) return 0;
1189
+ if (!a) return b.length;
1190
+ if (!b) return a.length;
1191
+ const m = a.length, n = b.length;
1192
+ let prev = new Array(n + 1);
1193
+ let curr = new Array(n + 1);
1194
+ for (let j = 0; j <= n; j++) prev[j] = j;
1195
+ for (let i = 1; i <= m; i++) {
1196
+ curr[0] = i;
1197
+ for (let j = 1; j <= n; j++) {
1198
+ curr[j] = a[i - 1] === b[j - 1]
1199
+ ? prev[j - 1]
1200
+ : 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
1201
+ }
1202
+ [prev, curr] = [curr, prev];
1203
+ }
1204
+ return prev[n];
1205
+ }
1206
+
1207
+ // Suggest a known key for an unknown one. Two parallel candidate searches,
1208
+ // then pick the better signal:
1209
+ //
1210
+ // 1. EDIT-DISTANCE candidate — classic typo case ('intract' → 'interact').
1211
+ // Threshold scales with the unknown key's length (40%, min 2) so short
1212
+ // typos stay matchable.
1213
+ //
1214
+ // 2. PREFIX candidate — "user added a suffix to a known root" case.
1215
+ // 'whois_terms' starts with 'whois' (known key) → suggest 'whois'.
1216
+ // Requires the prefix to be at least 3 chars to avoid spurious matches
1217
+ // on accidental 1-2 letter prefixes. Among multiple prefix candidates,
1218
+ // we take the LONGEST (most specific category boundary).
1219
+ //
1220
+ // Ranking: if there's a very close edit-distance match (≤2 edits), prefer
1221
+ // it — almost certainly a misspelling of that specific key (e.g.
1222
+ // 'whois_max_retri' → 'whois_max_retries' at distance 2 beats the prefix
1223
+ // match 'whois'). Otherwise prefer the prefix match when present, since
1224
+ // "extra suffix on a known root" is a stronger signal than a 4+-edit
1225
+ // distance to an unrelated key.
1226
+ function _suggestKey(unknownKey, knownKeys) {
1227
+ const threshold = Math.max(2, Math.floor(unknownKey.length * 0.4));
1228
+ let distBest = null, distBestVal = Infinity;
1229
+ let prefixBest = null, prefixBestLen = 0;
1230
+
1231
+ for (const k of knownKeys) {
1232
+ const d = _editDistance(unknownKey, k);
1233
+ if (d < distBestVal && d <= threshold) {
1234
+ distBestVal = d;
1235
+ distBest = k;
1236
+ }
1237
+ if (k.length >= 3 && unknownKey !== k &&
1238
+ unknownKey.startsWith(k) && k.length > prefixBestLen) {
1239
+ prefixBest = k;
1240
+ prefixBestLen = k.length;
1241
+ }
1242
+ }
1243
+
1244
+ if (distBest && distBestVal <= 2) return distBest;
1245
+ return prefixBest || distBest;
1246
+ }
1247
+
1248
+ /**
1249
+ * Per-site validation + boolean coercion run at scan startup (always, not
1250
+ * gated on --validate-config).
1251
+ *
1252
+ * Mutates siteConfig in place to coerce boolean-like values (1, 0, "true",
1253
+ * "false", "yes", "no", "on", "off") to true/false for fields in
1254
+ * BOOLEAN_SITE_CONFIG_FIELDS. Returns warnings the caller surfaces via the
1255
+ * usual logging path.
1256
+ *
1257
+ * Catches the failure classes:
1258
+ * 1. Unknown siteConfig keys → typo warning + "did you mean?" suggestion.
1259
+ * Example: 'whois_terms' → "did you mean 'whois'?"
1260
+ * 2. Boolean field with truthy non-boolean value → coerce + warn.
1261
+ * Example: 'interact: 1' → coerced to 'interact: true', warning emitted.
1262
+ * 3. Boolean field with non-boolean non-truthy value → warn only, no coerce.
1263
+ * Example: 'interact: "maybe"' → warned, left alone.
1264
+ *
1265
+ * @param {object} siteConfig - mutated in place
1266
+ * @param {number} siteIndex - for warning messages
1267
+ * @returns {{warnings: string[], errors: string[]}}
1268
+ */
1269
+ function normalizeSiteConfig(siteConfig, siteIndex = 0) {
1270
+ const warnings = [];
1271
+ const errors = [];
1272
+ if (!siteConfig || typeof siteConfig !== 'object') {
1273
+ errors.push(`Site ${siteIndex}: not an object`);
1274
+ return { warnings, errors };
1275
+ }
1276
+ const tag = siteConfig.url ? `Site ${siteIndex} (${siteConfig.url})` : `Site ${siteIndex}`;
1277
+
1278
+ // 1. Unknown-key detection. Scan every top-level key; report with
1279
+ // Levenshtein-based suggestion when close to a known key.
1280
+ for (const key of Object.keys(siteConfig)) {
1281
+ if (KNOWN_SITE_CONFIG_KEYS.has(key)) continue;
1282
+ const suggestion = _suggestKey(key, KNOWN_SITE_CONFIG_KEYS);
1283
+ warnings.push(
1284
+ `${tag}: unknown siteConfig key '${key}'` +
1285
+ (suggestion ? ` — did you mean '${suggestion}'?` : '') +
1286
+ ' — value will be ignored at runtime'
1287
+ );
1288
+ }
1289
+
1290
+ // 2. Boolean coercion for known boolean fields. Mutates siteConfig.
1291
+ for (const field of BOOLEAN_SITE_CONFIG_FIELDS) {
1292
+ if (!(field in siteConfig)) continue;
1293
+ const original = siteConfig[field];
1294
+ if (original === undefined || original === null) continue;
1295
+ const { coerced, value } = _coerceBooleanLike(original);
1296
+ if (coerced) {
1297
+ siteConfig[field] = value;
1298
+ warnings.push(
1299
+ `${tag}: '${field}' value ${JSON.stringify(original)} should be ${value} ` +
1300
+ `(boolean) — coerced for compatibility; please update config to use ${value}`
1301
+ );
1302
+ } else if (typeof original !== 'boolean') {
1303
+ warnings.push(
1304
+ `${tag}: '${field}' should be boolean (true/false), got ${JSON.stringify(original)} ` +
1305
+ `— may not work as expected (downstream strict-equality check will treat as disabled)`
1306
+ );
1307
+ }
1308
+ }
1309
+
1310
+ // 3. String → single-element array coercion for fields that accept both
1311
+ // forms (dig, dig-or, whois, whois-or). Downstream consumers all gate on
1312
+ // Array.isArray(), so a bare string value previously silently disabled
1313
+ // the feature. Wrapping in [val] is the canonical "user gave one term"
1314
+ // outcome and matches user intent. Both forms are first-class — no
1315
+ // warning is emitted on the string path, just the in-place mutation.
1316
+ //
1317
+ // Empty string is left alone: the downstream `siteConfig.dig && ...`
1318
+ // check sees the empty string as falsy and disables the feature. If we
1319
+ // coerced "" to [""], nettools' array.length>0 check would PASS and then
1320
+ // every dig/whois output would match (`"".includes(anything)` is true),
1321
+ // turning a clearly-empty config into a match-everything one.
1322
+ //
1323
+ // Non-string non-array values DO warn since we can't sensibly coerce.
1324
+ for (const field of STRING_TO_ARRAY_FIELDS) {
1325
+ if (!(field in siteConfig)) continue;
1326
+ const val = siteConfig[field];
1327
+ if (val === undefined || val === null) continue;
1328
+ if (typeof val === 'string') {
1329
+ if (val.length > 0) siteConfig[field] = [val];
1330
+ // empty string: leave as-is (preserves disable-on-falsy semantics)
1331
+ } else if (!Array.isArray(val)) {
1332
+ warnings.push(
1333
+ `${tag}: '${field}' should be a string or array of strings, got ${typeof val} ` +
1334
+ `(${JSON.stringify(val).slice(0, 60)}) — feature will be disabled at runtime`
1335
+ );
1336
+ }
1337
+ }
1338
+
1339
+ // 4. Dependent-flag implication: clear_sitedata_full_on_reload only takes
1340
+ // effect inside the `if (clear_sitedata === true)` guard at nwss.js:4627
1341
+ // — setting it WITHOUT clear_sitedata: true silently does nothing. That's
1342
+ // the same silent-failure pattern this validator was created to prevent,
1343
+ // so auto-enable clear_sitedata and warn the user. They almost certainly
1344
+ // intended both to be true; opt-in to heavy-storage clearing without
1345
+ // opt-in to clearing-at-all doesn't make sense as a configuration.
1346
+ if (siteConfig.clear_sitedata_full_on_reload === true &&
1347
+ siteConfig.clear_sitedata !== true) {
1348
+ siteConfig.clear_sitedata = true;
1349
+ warnings.push(
1350
+ `${tag}: 'clear_sitedata_full_on_reload: true' requires 'clear_sitedata: true' ` +
1351
+ `— auto-enabled clear_sitedata for compatibility; please add 'clear_sitedata: true' ` +
1352
+ `to your config explicitly`
1353
+ );
1354
+ }
1355
+
1356
+ return { warnings, errors };
1357
+ }
1358
+
1078
1359
  // Public surface used by nwss.js (validateRulesetFile, validateFullConfig,
1079
- // testDomainValidation, cleanRulesetFile). The rest (isValidDomain,
1080
- // isValidDomainLabel, isValidTLD, isIPAddress, isIPv4, isIPv6,
1360
+ // testDomainValidation, cleanRulesetFile, normalizeSiteConfig). The rest
1361
+ // (isValidDomain, isValidDomainLabel, isValidTLD, isIPAddress, isIPv4, isIPv6,
1081
1362
  // validateRegexPattern, validateAdblockModifiers, validateAdblockRule,
1082
1363
  // validateSiteConfig) stay internal-helper-but-exported for now since
1083
1364
  // downstream callers MAY import them via the dotted path even if grep
@@ -1100,5 +1381,6 @@ module.exports = {
1100
1381
  cleanRulesetFile,
1101
1382
  validateSiteConfig,
1102
1383
  validateFullConfig,
1384
+ normalizeSiteConfig,
1103
1385
  testDomainValidation
1104
1386
  };
@@ -33,8 +33,15 @@ function getExternalIP(interfaceName) {
33
33
  // Track active interfaces for cleanup
34
34
  const activeInterfaces = new Map();
35
35
 
36
- // Temp config directory for inline configs
37
- const TEMP_CONFIG_DIR = '/tmp/nwss-wireguard';
36
+ // Temp config directory for inline configs — namespaced per process.
37
+ // disconnectAll() rmSync's this whole directory on exit; a fixed shared path
38
+ // meant two concurrent nwss processes using inline configs would clobber each
39
+ // other: the first to exit deleted the survivor's live .conf, so its
40
+ // `wg-quick down <path>` then failed (file gone) and the kernel interface
41
+ // leaked. Scoping to the PID keeps each process's teardown to its own files.
42
+ // (Stale dirs from a crashed process are left for manual cleanup — sweeping
43
+ // them would reintroduce the same cross-process destruction this avoids.)
44
+ const TEMP_CONFIG_DIR = path.join('/tmp/nwss-wireguard', String(process.pid));
38
45
 
39
46
  /**
40
47
  * Check if running with sufficient privileges
@@ -85,9 +92,11 @@ function resolveInterfaceName(vpnConfig) {
85
92
  * @returns {string} Path to temp config file
86
93
  */
87
94
  function writeInlineConfig(interfaceName, configContent) {
88
- if (!fs.existsSync(TEMP_CONFIG_DIR)) {
89
- fs.mkdirSync(TEMP_CONFIG_DIR, { recursive: true, mode: 0o700 });
90
- }
95
+ // mkdirSync with recursive:true is a no-op when the directory exists,
96
+ // so the prior existsSync gate was redundant. Saves one stat() syscall
97
+ // per VPN bringup and follows the standard "act, don't check-then-act"
98
+ // idiom (also avoids a microscopic TOCTOU window).
99
+ fs.mkdirSync(TEMP_CONFIG_DIR, { recursive: true, mode: 0o700 });
91
100
 
92
101
  const configPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
93
102
  fs.writeFileSync(configPath, configContent, { mode: 0o600 });
@@ -114,6 +123,14 @@ function interfaceUp(configPath, interfaceName, forceDebug = false) {
114
123
  // spawnSync with arg array (no shell) — configPath comes from user
115
124
  // JSON, so naive `sudo wg-quick up "${configPath}"` was vulnerable
116
125
  // to a `";rm -rf ~;"` payload escaping the quotes.
126
+ //
127
+ // NOTE: an "already exists" failure here usually means a previous
128
+ // run's teardown failed and left the kernel interface alive. Recover
129
+ // manually with `sudo wg-quick down <name>` or `sudo ip link delete
130
+ // <name>`. A self-heal mechanism was tried (commit e032bde) and
131
+ // reverted because it raced with concurrent nwss processes sharing
132
+ // the same VPN config — process B's self-heal would destroy process
133
+ // A's live interface. Keep the manual-recovery default for safety.
117
134
  const upRes = spawnSync('sudo', ['wg-quick', 'up', configPath], {
118
135
  encoding: 'utf8',
119
136
  timeout: 15000
@@ -193,10 +210,12 @@ function interfaceDown(interfaceName, forceDebug = false) {
193
210
  // down failure where the kernel interface might persist briefly.
194
211
  // Was previously only inside the try block, so failure paths
195
212
  // leaked the temp file.
213
+ //
214
+ // No existsSync gate — unlinkSync throws ENOENT for missing files
215
+ // and the try/catch already swallows it. Saves one stat() and
216
+ // closes a microscopic TOCTOU window.
196
217
  const tempPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
197
- if (fs.existsSync(tempPath)) {
198
- try { fs.unlinkSync(tempPath); } catch {}
199
- }
218
+ try { fs.unlinkSync(tempPath); } catch {}
200
219
  }
201
220
  }
202
221
 
@@ -310,6 +329,22 @@ function validateVpnConfig(vpnConfig) {
310
329
  result.warnings.push('Both "config" and "config_inline" provided; "config" takes precedence');
311
330
  }
312
331
 
332
+ // F1: Validate user-provided interface name to prevent path traversal via
333
+ // resolveInterfaceName → writeInlineConfig's path.join. Also enforces
334
+ // Linux IFNAMSIZ (15 chars max). User would need to attack their own
335
+ // config (same trust boundary as rest of nwss + must run as root for WG),
336
+ // so this is defensive rather than security-critical — but the validation
337
+ // catches typos that would otherwise produce confusing wg-quick errors.
338
+ if (vpnConfig.interface !== undefined && vpnConfig.interface !== null) {
339
+ if (typeof vpnConfig.interface !== 'string' || !/^[a-zA-Z0-9_-]{1,15}$/.test(vpnConfig.interface)) {
340
+ result.isValid = false;
341
+ result.errors.push(
342
+ `Invalid 'interface' name ${JSON.stringify(vpnConfig.interface)}: ` +
343
+ `must match /^[a-zA-Z0-9_-]{1,15}$/ (Linux IFNAMSIZ limit + path-safe chars)`
344
+ );
345
+ }
346
+ }
347
+
313
348
  // Validate config file exists
314
349
  if (vpnConfig.config) {
315
350
  const configPath = vpnConfig.config;
@@ -365,8 +400,11 @@ async function connectForSite(siteConfig, forceDebug = false) {
365
400
 
366
401
  const interfaceName = resolveInterfaceName(vpnConfig);
367
402
 
368
- // Resolve config path
369
- let configPath;
403
+ // Resolve config path. A file-based config path is fixed up-front; an
404
+ // inline config is a temp file we (re)write inside the retry loop below
405
+ // (it can't be hoisted — see the writeInlineConfig call for why).
406
+ const useInlineConfig = !vpnConfig.config;
407
+ let configPath = null;
370
408
  if (vpnConfig.config) {
371
409
  configPath = vpnConfig.config;
372
410
  // Resolve wg-quick style: if no path separators, look in /etc/wireguard/
@@ -376,8 +414,6 @@ async function connectForSite(siteConfig, forceDebug = false) {
376
414
  configPath = etcPath;
377
415
  }
378
416
  }
379
- } else {
380
- configPath = writeInlineConfig(interfaceName, vpnConfig.config_inline);
381
417
  }
382
418
 
383
419
  const maxAttempts = vpnConfig.retry ? vpnConfig.max_retries + 1 : 1;
@@ -395,9 +431,25 @@ async function connectForSite(siteConfig, forceDebug = false) {
395
431
  await new Promise(resolve => setTimeout(resolve, 2000));
396
432
  }
397
433
 
434
+ // (Re)write the inline temp config before each attempt. interfaceDown's
435
+ // finally unlinks the temp .conf (both the retry reset just above and any
436
+ // teardown from a prior run), so hoisting this above the loop meant a
437
+ // retry's `wg-quick up` ran against a deleted file and always failed.
438
+ // Idempotent on the first attempt.
439
+ if (useInlineConfig) {
440
+ configPath = writeInlineConfig(interfaceName, vpnConfig.config_inline);
441
+ }
442
+
398
443
  const upResult = interfaceUp(configPath, interfaceName, forceDebug);
399
444
  if (!upResult.success) {
400
445
  if (attempt === maxAttempts) {
446
+ // interfaceUp never registered the interface (no activeInterfaces
447
+ // entry), so interfaceDown's finally won't run to clean the temp.
448
+ // Unlink the inline config here so a fully-failed connect doesn't
449
+ // leave a PrivateKey-bearing .conf in /tmp until process exit.
450
+ if (useInlineConfig && configPath) {
451
+ try { fs.unlinkSync(configPath); } catch {}
452
+ }
401
453
  return upResult;
402
454
  }
403
455
  continue;