From 8d2c16284995c4634f9d163792c2be9a45636e57 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 30 Mar 2026 11:34:20 -0500 Subject: [PATCH] feat(nordvpn-client): implement milestone M1 diagnostics and classification --- .../nordvpn-client/scripts/nordvpn-client.js | 404 ++++++++++++++++-- .../scripts/nordvpn-client.test.js | 178 +++++++- 2 files changed, 546 insertions(+), 36 deletions(-) diff --git a/skills/nordvpn-client/scripts/nordvpn-client.js b/skills/nordvpn-client/scripts/nordvpn-client.js index 5d8ab17..9058abb 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -14,6 +14,7 @@ const WG_STATE_DIR = path.join(STATE_DIR, "wireguard"); const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`); const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json"); const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json"); +const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json"); const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json"); const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join( os.homedir(), @@ -276,6 +277,236 @@ function buildMacTailscaleState(tailscaleWasActive) { return { tailscaleWasActive: Boolean(tailscaleWasActive) }; } +function inspectMacWireguardHelperSecurity(helperPath, deps = {}) { + const fileExistsFn = deps.fileExists || fileExists; + const statSyncFn = deps.statSync || fs.statSync; + if (!helperPath || !fileExistsFn(helperPath)) { + return { + exists: false, + hardened: false, + ownerUid: null, + groupGid: null, + mode: null, + reason: "Helper is missing.", + }; + } + + let stat; + try { + stat = statSyncFn(helperPath); + } catch (error) { + return { + exists: true, + hardened: false, + ownerUid: null, + groupGid: null, + mode: null, + reason: error.message || "Unable to inspect helper security.", + }; + } + + const mode = stat.mode & 0o777; + if (stat.uid !== 0) { + return { + exists: true, + hardened: false, + ownerUid: stat.uid, + groupGid: stat.gid, + mode, + reason: "Helper must be root-owned before privileged actions are trusted.", + }; + } + if ((mode & 0o022) !== 0) { + return { + exists: true, + hardened: false, + ownerUid: stat.uid, + groupGid: stat.gid, + mode, + reason: "Helper must not be group- or world-writable.", + }; + } + + return { + exists: true, + hardened: true, + ownerUid: stat.uid, + groupGid: stat.gid, + mode, + reason: "", + }; +} + +function trimDiagnosticOutput(value, maxChars = 4000) { + const text = `${value || ""}`.trim(); + if (!text) return ""; + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n...[truncated]`; +} + +function summarizeMacWireguardDiagnostics(diagnostics) { + if (!diagnostics) return null; + return { + interfaceName: diagnostics.interfaceName || MAC_WG_INTERFACE, + wgShowCaptured: Boolean(diagnostics.wgShow), + ifconfigCaptured: Boolean(diagnostics.ifconfig), + routesCaptured: Boolean(diagnostics.routes), + processesCaptured: Boolean(diagnostics.processes), + helperSecurity: + diagnostics.helperSecurity && typeof diagnostics.helperSecurity === "object" + ? { + hardened: Boolean(diagnostics.helperSecurity.hardened), + reason: diagnostics.helperSecurity.reason || "", + } + : null, + }; +} + +async function collectMacWireguardDiagnostics(options = {}) { + const runExecFn = options.runExec || runExec; + const interfaceName = options.interfaceName || MAC_WG_INTERFACE; + const wgPath = options.wgPath || commandExists("wg") || "/opt/homebrew/bin/wg"; + const helperSecurity = + options.helperSecurity || + inspectMacWireguardHelperSecurity(options.helperPath || MAC_WG_HELPER_PATH, options.securityDeps || {}); + + const [wgShow, ifconfig, routes, processes] = await Promise.all([ + runExecFn(wgPath, ["show", interfaceName]), + runExecFn("ifconfig", [interfaceName]), + runExecFn("route", ["-n", "get", "default"]), + runExecFn("pgrep", ["-fl", "wireguard-go|wg-quick|nordvpnctl"]), + ]); + + return { + interfaceName, + helperSecurity, + wgShow: trimDiagnosticOutput(wgShow.stdout || wgShow.stderr || wgShow.error), + ifconfig: trimDiagnosticOutput(ifconfig.stdout || ifconfig.stderr || ifconfig.error), + routes: trimDiagnosticOutput(routes.stdout || routes.stderr || routes.error), + processes: trimDiagnosticOutput(processes.stdout || processes.stderr || processes.error), + }; +} + +function shouldResumeMacTailscale(state, currentlyActive) { + return Boolean(state && state.tailscaleWasActive && !currentlyActive); +} + +function buildMacDnsState(services) { + return { + services: (services || []).map((service) => ({ + name: service.name, + dnsServers: Array.isArray(service.dnsServers) ? service.dnsServers : [], + searchDomains: Array.isArray(service.searchDomains) ? service.searchDomains : [], + })), + }; +} + +function shouldManageMacDnsService(serviceName) { + const normalized = `${serviceName || ""}`.trim().toLowerCase(); + if (!normalized) return false; + return !["tailscale", "bridge", "thunderbolt bridge", "loopback", "vpn", "utun"].some((token) => normalized.includes(token)); +} + +function normalizeMacNetworksetupList(output) { + return `${output || ""}` + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.startsWith("An asterisk")) + .map((line) => (line.startsWith("*") ? line.slice(1).trim() : line)) + .filter((line) => shouldManageMacDnsService(line)); +} + +function normalizeMacDnsCommandOutput(output) { + const text = `${output || ""}`.trim(); + if (!text || text.includes("aren't any") || text.includes("There aren't any")) { + return []; + } + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +async function listMacDnsServices() { + const result = await runExec("networksetup", ["-listallnetworkservices"]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "networksetup -listallnetworkservices failed"); + } + return normalizeMacNetworksetupList(result.stdout); +} + +async function readMacDnsStateForService(serviceName) { + const dnsResult = await runExec("networksetup", ["-getdnsservers", serviceName]); + if (!dnsResult.ok) { + throw new Error((dnsResult.stderr || dnsResult.stdout || dnsResult.error).trim() || `networksetup -getdnsservers failed for ${serviceName}`); + } + const searchResult = await runExec("networksetup", ["-getsearchdomains", serviceName]); + if (!searchResult.ok) { + throw new Error((searchResult.stderr || searchResult.stdout || searchResult.error).trim() || `networksetup -getsearchdomains failed for ${serviceName}`); + } + return { + name: serviceName, + dnsServers: normalizeMacDnsCommandOutput(dnsResult.stdout), + searchDomains: normalizeMacDnsCommandOutput(searchResult.stdout), + }; +} + +async function snapshotMacDnsState() { + const services = await listMacDnsServices(); + const snapshot = []; + for (const serviceName of services) { + snapshot.push(await readMacDnsStateForService(serviceName)); + } + const state = buildMacDnsState(snapshot); + writeJsonFile(MAC_DNS_STATE_PATH, state); + return state; +} + +async function setMacDnsServers(serviceName, dnsServers) { + const args = ["-setdnsservers", serviceName]; + args.push(...(dnsServers && dnsServers.length ? dnsServers : ["Empty"])); + const result = await runExec("networksetup", args); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setdnsservers failed for ${serviceName}`); + } +} + +async function setMacSearchDomains(serviceName, searchDomains) { + const args = ["-setsearchdomains", serviceName]; + args.push(...(searchDomains && searchDomains.length ? searchDomains : ["Empty"])); + const result = await runExec("networksetup", args); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setsearchdomains failed for ${serviceName}`); + } +} + +async function applyMacNordDns() { + const snapshot = readJsonFile(MAC_DNS_STATE_PATH) || (await snapshotMacDnsState()); + for (const service of snapshot.services || []) { + await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS); + await setMacSearchDomains(service.name, []); + } + return snapshot; +} + +async function restoreMacDnsIfNeeded() { + const snapshot = readJsonFile(MAC_DNS_STATE_PATH); + if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) { + return { restored: false }; + } + for (const service of snapshot.services) { + await setMacDnsServers(service.name, service.dnsServers || []); + await setMacSearchDomains(service.name, service.searchDomains || []); + } + try { + fs.unlinkSync(MAC_DNS_STATE_PATH); + } catch { + // Ignore unlink errors. + } + return { restored: true }; +} + function getMacTailscalePath(deps = {}) { const commandExistsFn = deps.commandExists || commandExists; const fileExistsFn = deps.fileExists || fileExists; @@ -322,7 +553,18 @@ async function stopMacTailscaleIfActive() { async function resumeMacTailscaleIfNeeded() { const state = readJsonFile(MAC_TAILSCALE_STATE_PATH); - if (!state || !state.tailscaleWasActive) { + let currentStatus = null; + try { + currentStatus = await getMacTailscaleStatus(); + } catch { + currentStatus = null; + } + if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) { + try { + fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); + } catch { + // Ignore unlink errors. + } return { restored: false }; } const tailscale = getMacTailscalePath(); @@ -385,12 +627,41 @@ function cleanupMacWireguardState(paths = {}) { return { cleaned }; } +function cleanupMacWireguardAndDnsState(paths = {}) { + const cleaned = cleanupMacWireguardState(paths).cleaned; + const dnsStatePath = paths.dnsStatePath || MAC_DNS_STATE_PATH; + let dnsCleaned = false; + try { + if (dnsStatePath && fs.existsSync(dnsStatePath)) { + fs.unlinkSync(dnsStatePath); + dnsCleaned = true; + } + } catch { + // Ignore cleanup errors; caller will rely on current runtime state. + } + return { cleaned: cleaned || dnsCleaned }; +} + function shouldAttemptMacWireguardDisconnect(wireguardState) { if (!wireguardState) return false; if (wireguardState.active) return true; return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection); } +function isBenignMacWireguardAbsentError(message) { + const normalized = `${message || ""}`.trim().toLowerCase(); + return ( + normalized.includes("is not a wireguard interface") || + normalized.includes("is not a known interface") || + normalized.includes("unable to access interface") || + normalized.includes("not found") + ); +} + +function shouldFinalizeMacWireguardConnect(connectResult, verified) { + return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok); +} + function normalizeSuccessfulConnectState(state, connectResult, verified) { if ( !state || @@ -539,6 +810,7 @@ async function probeMacWireguard() { const wgQuickPath = commandExists("wg-quick"); const wireguardGoPath = commandExists("wireguard-go"); const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null; + const helperSecurity = inspectMacWireguardHelperSecurity(helperPath); const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false }; let active = false; let showRaw = ""; @@ -571,6 +843,7 @@ async function probeMacWireguard() { wgQuickPath: wgQuickPath || null, wireguardGoPath: wireguardGoPath || null, helperPath, + helperSecurity, dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath), sudoReady: sudoProbe.ok, interfaceName: MAC_WG_INTERFACE, @@ -668,7 +941,9 @@ function buildStateSummary(installProbe, ipInfo) { connectMode = "wireguard"; recommendedAction = installProbe.tokenAvailable ? installProbe.wireguard.sudoReady - ? "Use token-based WireGuard automation on macOS." + ? installProbe.wireguard.helperSecurity && !installProbe.wireguard.helperSecurity.hardened + ? `WireGuard tooling is available, but the helper at ${MAC_WG_HELPER_PATH} is not hardened yet: ${installProbe.wireguard.helperSecurity.reason}` + : "Use token-based WireGuard automation on macOS." : `WireGuard tooling and token are available, but connect/disconnect require non-interactive sudo for ${MAC_WG_HELPER_PATH}. Allow that helper in sudoers, then rerun login/connect.` : `Set NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or place the token at ${DEFAULT_TOKEN_FILE} for automated macOS NordLynx/WireGuard connects.`; } else if (installProbe.platform === "darwin" && installProbe.appInstalled) { @@ -716,6 +991,7 @@ function buildStateSummary(installProbe, ipInfo) { wgQuickPath: installProbe.wireguard.wgQuickPath, wireguardGoPath: installProbe.wireguard.wireguardGoPath, helperPath: installProbe.wireguard.helperPath, + helperSecurity: installProbe.wireguard.helperSecurity, authCache: installProbe.wireguard.authCache, lastConnection: installProbe.wireguard.lastConnection, } @@ -996,7 +1272,6 @@ function buildWireguardConfig(server, privateKey) { "[Interface]", `PrivateKey = ${privateKey}`, `Address = ${addresses.join(", ")}`, - `DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`, "", "[Peer]", `PublicKey = ${publicKey}`, @@ -1123,10 +1398,7 @@ async function connectViaMacWireguard(installProbe, target) { const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { - const message = `${down.stderr || down.stdout || down.error}`.toLowerCase(); - if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) { - // Ignore only the common no-active-interface case. - } + // Ignore the common no-active-interface case before reconnecting. } const up = await runSudoWireguard(installProbe, "up"); @@ -1137,23 +1409,6 @@ async function connectViaMacWireguard(installProbe, target) { throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed"); } - writeJsonFile(LAST_CONNECTION_PATH, { - backend: "wireguard", - interfaceName: MAC_WG_INTERFACE, - requestedTarget: target, - resolvedTarget: { - country: targetMeta.country.name, - city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer), - }, - server: { - hostname: selectedServer.hostname, - city: getServerCityName(selectedServer), - country: getServerCountryName(selectedServer), - load: selectedServer.load, - }, - connectedAt: new Date().toISOString(), - }); - return { backend: "wireguard", server: { @@ -1193,10 +1448,12 @@ async function disconnectNordvpn(installProbe) { if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) { if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) { + const dnsState = await restoreMacDnsIfNeeded(); const tailscale = await resumeMacTailscaleIfNeeded(); return { backend: "wireguard", changed: false, + dnsRestored: dnsState.restored, tailscaleRestored: tailscale.restored, message: "No active macOS WireGuard NordVPN connection found.", }; @@ -1204,30 +1461,29 @@ async function disconnectNordvpn(installProbe) { const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { const message = (down.stderr || down.stdout || down.error).trim(); - const normalized = message.toLowerCase(); - if ( - normalized.includes("is not a known interface") || - normalized.includes("unable to access interface") || - normalized.includes("not found") - ) { - const cleaned = cleanupMacWireguardState(); + if (isBenignMacWireguardAbsentError(message)) { + const dnsState = await restoreMacDnsIfNeeded(); + const cleaned = cleanupMacWireguardAndDnsState(); const tailscale = await resumeMacTailscaleIfNeeded(); return { backend: "wireguard", changed: false, stateCleaned: cleaned.cleaned, + dnsRestored: dnsState.restored, tailscaleRestored: tailscale.restored, message: "No active macOS WireGuard NordVPN connection found.", }; } throw new Error(message || "wg-quick down failed"); } - const cleaned = cleanupMacWireguardState(); + const dnsState = await restoreMacDnsIfNeeded(); + const cleaned = cleanupMacWireguardAndDnsState(); const tailscale = await resumeMacTailscaleIfNeeded(); return { backend: "wireguard", changed: true, stateCleaned: cleaned.cleaned, + dnsRestored: dnsState.restored, tailscaleRestored: tailscale.restored, message: "Disconnected the macOS NordLynx/WireGuard session.", }; @@ -1392,7 +1648,70 @@ async function main() { const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 }); const refreshed = await probeInstallation(platform); - const connectState = normalizeSuccessfulConnectState(buildStateSummary(refreshed, verified.ipInfo), connectResult, verified); + if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") { + const diagnostics = await collectMacWireguardDiagnostics({ + interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE, + wgPath: refreshed.wireguard && refreshed.wireguard.wgPath, + helperPath: refreshed.wireguard && refreshed.wireguard.helperPath, + helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity, + }); + const rollback = await disconnectNordvpn(refreshed); + emitJson( + { + action, + requestedTarget: target, + connectResult, + verified: false, + verification: verified.ipInfo, + diagnostics: debugOutput ? diagnostics : undefined, + diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics), + rollback, + state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()), + }, + 1, + true + ); + } + if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified)) { + try { + await snapshotMacDnsState(); + await applyMacNordDns(); + writeJsonFile(LAST_CONNECTION_PATH, { + backend: "wireguard", + interfaceName: MAC_WG_INTERFACE, + requestedTarget: connectResult.requestedTarget, + resolvedTarget: connectResult.resolvedTarget, + server: { + hostname: connectResult.server.hostname, + city: connectResult.server.city, + country: connectResult.server.country, + load: connectResult.server.load, + }, + connectedAt: new Date().toISOString(), + }); + } catch (error) { + const rollback = await disconnectNordvpn(await probeInstallation(platform)); + emitJson( + { + action, + requestedTarget: target, + connectResult, + verified: true, + verification: verified.ipInfo, + rollback, + error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`, + state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()), + }, + 1, + true + ); + } + } + const connectState = normalizeSuccessfulConnectState( + buildStateSummary(await probeInstallation(platform), verified.ipInfo), + connectResult, + verified + ); emitJson( { action, @@ -1419,7 +1738,24 @@ async function main() { emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); } catch (error) { - emitJson({ error: error.message || String(error), action }, 1, true); + const payload = { error: error.message || String(error), action }; + if (action === "connect" && platform === "darwin" && installProbe && installProbe.wireguard) { + try { + const refreshed = await probeInstallation(platform); + const diagnostics = await collectMacWireguardDiagnostics({ + interfaceName: MAC_WG_INTERFACE, + wgPath: refreshed.wireguard && refreshed.wireguard.wgPath, + helperPath: refreshed.wireguard && refreshed.wireguard.helperPath, + helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity, + }); + payload.state = buildStateSummary(refreshed, await getPublicIpInfo()); + payload.diagnostics = debugOutput ? diagnostics : undefined; + payload.diagnosticsSummary = summarizeMacWireguardDiagnostics(diagnostics); + } catch { + // Fall back to the base error payload if diagnostic capture also fails. + } + } + emitJson(payload, 1, true); } } diff --git a/skills/nordvpn-client/scripts/nordvpn-client.test.js b/skills/nordvpn-client/scripts/nordvpn-client.test.js index d2a72eb..4c96333 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.test.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.test.js @@ -11,22 +11,38 @@ function loadInternals() { module.exports = { buildMacTailscaleState: typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined, + buildMacDnsState: + typeof buildMacDnsState === "function" ? buildMacDnsState : undefined, buildWireguardConfig: typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined, buildLookupResult: typeof buildLookupResult === "function" ? buildLookupResult : undefined, cleanupMacWireguardState: typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined, + cleanupMacWireguardAndDnsState: + typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined, + collectMacWireguardDiagnostics: + typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined, + inspectMacWireguardHelperSecurity: + typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined, getMacTailscalePath: typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined, + isBenignMacWireguardAbsentError: + typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined, isMacTailscaleActive: typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined, normalizeSuccessfulConnectState: typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined, normalizeStatusState: typeof normalizeStatusState === "function" ? normalizeStatusState : undefined, + shouldManageMacDnsService: + typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined, sanitizeOutputPayload: typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined, + shouldFinalizeMacWireguardConnect: + typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined, + shouldResumeMacTailscale: + typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined, shouldAttemptMacWireguardDisconnect: typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined, detectMacWireguardActiveFromIfconfig: @@ -78,7 +94,7 @@ test("buildLookupResult supports lookup all=true mode", () => { assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4])); }); -test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => { +test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => { const { buildWireguardConfig } = loadInternals(); assert.equal(typeof buildWireguardConfig, "function"); @@ -91,10 +107,41 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat "PRIVATEKEY" ); - assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true); + assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false); assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true); }); +test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => { + const { shouldManageMacDnsService } = loadInternals(); + assert.equal(typeof shouldManageMacDnsService, "function"); + + assert.equal(shouldManageMacDnsService("Wi-Fi"), true); + assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true); + assert.equal(shouldManageMacDnsService("Tailscale"), false); + assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false); + assert.equal(shouldManageMacDnsService("Acme VPN"), false); +}); + +test("buildMacDnsState records DNS and search domains per service", () => { + const { buildMacDnsState } = loadInternals(); + assert.equal(typeof buildMacDnsState, "function"); + + assert.equal( + JSON.stringify( + buildMacDnsState([ + { name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] }, + { name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] }, + ]) + ), + JSON.stringify({ + services: [ + { name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] }, + { name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] }, + ], + }) + ); +}); + test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => { const { getMacTailscalePath } = loadInternals(); assert.equal(typeof getMacTailscalePath, "function"); @@ -118,6 +165,16 @@ test("buildMacTailscaleState records whether tailscale was active", () => { ); }); +test("shouldResumeMacTailscale only resumes when previously active and not already running", () => { + const { shouldResumeMacTailscale } = loadInternals(); + assert.equal(typeof shouldResumeMacTailscale, "function"); + + assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true); + assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false); + assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false); + assert.equal(shouldResumeMacTailscale(null, false), false); +}); + test("cleanupMacWireguardState removes stale config and last-connection files", () => { const { cleanupMacWireguardState } = loadInternals(); assert.equal(typeof cleanupMacWireguardState, "function"); @@ -138,6 +195,104 @@ test("cleanupMacWireguardState removes stale config and last-connection files", assert.equal(fs.existsSync(lastConnectionPath), false); }); +test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => { + const { cleanupMacWireguardAndDnsState } = loadInternals(); + assert.equal(typeof cleanupMacWireguardAndDnsState, "function"); + + const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-")); + const configPath = path.join(tmpDir, "nordvpnctl.conf"); + const lastConnectionPath = path.join(tmpDir, "last-connection.json"); + const dnsStatePath = path.join(tmpDir, "dns.json"); + fs.writeFileSync(configPath, "wireguard-config"); + fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}"); + fs.writeFileSync(dnsStatePath, "{\"services\":[]}"); + + const result = cleanupMacWireguardAndDnsState({ + configPath, + lastConnectionPath, + dnsStatePath, + }); + + assert.equal(result.cleaned, true); + assert.equal(fs.existsSync(configPath), false); + assert.equal(fs.existsSync(lastConnectionPath), false); + assert.equal(fs.existsSync(dnsStatePath), false); +}); + +test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => { + const { inspectMacWireguardHelperSecurity } = loadInternals(); + assert.equal(typeof inspectMacWireguardHelperSecurity, "function"); + + const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", { + fileExists: () => true, + statSync: () => ({ + uid: 501, + gid: 20, + mode: 0o100755, + }), + }); + + assert.equal(result.exists, true); + assert.equal(result.hardened, false); + assert.match(result.reason, /root-owned/i); +}); + +test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => { + const { inspectMacWireguardHelperSecurity } = loadInternals(); + assert.equal(typeof inspectMacWireguardHelperSecurity, "function"); + + const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", { + fileExists: () => true, + statSync: () => ({ + uid: 0, + gid: 0, + mode: 0o100755, + }), + }); + + assert.equal(result.exists, true); + assert.equal(result.hardened, true); + assert.equal(result.reason, ""); +}); + +test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => { + const { collectMacWireguardDiagnostics } = loadInternals(); + assert.equal(typeof collectMacWireguardDiagnostics, "function"); + + const seen = []; + const result = await collectMacWireguardDiagnostics({ + interfaceName: "nordvpnctl", + runExec: async (command, args) => { + seen.push(`${command} ${args.join(" ")}`); + if (command === "/opt/homebrew/bin/wg") { + return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" }; + } + if (command === "ifconfig") { + return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" }; + } + if (command === "route") { + return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" }; + } + if (command === "pgrep") { + return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" }; + } + throw new Error(`unexpected command: ${command}`); + }, + }); + + assert.deepEqual(seen, [ + "/opt/homebrew/bin/wg show nordvpnctl", + "ifconfig nordvpnctl", + "route -n get default", + "pgrep -fl wireguard-go|wg-quick|nordvpnctl", + ]); + assert.equal(result.interfaceName, "nordvpnctl"); + assert.equal(result.wgShow.includes("peer: abc123"), true); + assert.equal(result.ifconfig.includes("10.5.0.2"), true); + assert.equal(result.routes.includes("default 10.5.0.2"), true); + assert.equal(result.processes.includes("wireguard-go"), true); +}); + test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => { const { shouldAttemptMacWireguardDisconnect } = loadInternals(); assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function"); @@ -173,6 +328,15 @@ test("shouldAttemptMacWireguardDisconnect does not trust active=false when resid ); }); +test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => { + const { isBenignMacWireguardAbsentError } = loadInternals(); + assert.equal(typeof isBenignMacWireguardAbsentError, "function"); + + assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true); + assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true); + assert.equal(isBenignMacWireguardAbsentError("permission denied"), false); +}); + test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => { const { normalizeSuccessfulConnectState } = loadInternals(); assert.equal(typeof normalizeSuccessfulConnectState, "function"); @@ -206,6 +370,16 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820"); }); +test("shouldFinalizeMacWireguardConnect requires a verified wireguard connect", () => { + const { shouldFinalizeMacWireguardConnect } = loadInternals(); + assert.equal(typeof shouldFinalizeMacWireguardConnect, "function"); + + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }), true); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }), false); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }), false); + assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }), false); +}); + test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => { const { normalizeStatusState } = loadInternals(); assert.equal(typeof normalizeStatusState, "function");