diff --git a/skills/nordvpn-client/scripts/nordvpn-client.js b/skills/nordvpn-client/scripts/nordvpn-client.js index 9058abb..32ef433 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -16,6 +16,7 @@ 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 OPERATION_LOCK_PATH = path.join(STATE_DIR, "operation.lock"); const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join( os.homedir(), ".openclaw", @@ -38,6 +39,9 @@ const MAC_WG_HELPER_PATH = path.join( const CLIENT_IPV4 = "10.5.0.2"; const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"]; const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"]; +const CONNECT_PERSISTENCE_ATTEMPTS = 6; +const CONNECT_PERSISTENCE_DELAY_MS = 2000; +const CONNECT_TOTAL_TIMEOUT_MS = 90000; const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]); function sanitizeOutputPayload(payload) { @@ -253,6 +257,78 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function processExists(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && error.code === "EPERM"; + } +} + +function isOperationLockStale(lockRecord, options = {}) { + if (!lockRecord || typeof lockRecord !== "object") return true; + const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS; + const startedAtMs = Number.isFinite(lockRecord.startedAtMs) + ? lockRecord.startedAtMs + : Date.parse(lockRecord.startedAt || ""); + + if (!processExists(lockRecord.pid)) { + return true; + } + if (!Number.isFinite(startedAtMs)) { + return true; + } + return now - startedAtMs > maxAgeMs; +} + +function cleanupOperationLock(lockPath = OPERATION_LOCK_PATH) { + try { + if (lockPath && fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } catch { + // Ignore cleanup errors. + } +} + +function acquireOperationLock(action, options = {}) { + const lockPath = options.lockPath || OPERATION_LOCK_PATH; + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS; + const existing = readJsonFile(lockPath); + + if (existing) { + if (isOperationLockStale(existing, { nowMs, maxAgeMs })) { + cleanupOperationLock(lockPath); + } else { + const activeAction = existing.action || "unknown"; + throw new Error(`Another nordvpn-client ${activeAction} operation is already running.`); + } + } + + const record = { + action, + pid: process.pid, + startedAt: new Date(nowMs).toISOString(), + startedAtMs: nowMs, + }; + writeJsonFile(lockPath, record); + + return { + lockPath, + record, + release() { + const current = readJsonFile(lockPath); + if (current && current.pid === record.pid && current.startedAtMs === record.startedAtMs) { + cleanupOperationLock(lockPath); + } + }, + }; +} + function detectMacWireguardActiveFromIfconfig(ifconfigOutput) { let inUtunBlock = false; const lines = `${ifconfigOutput || ""}`.split("\n"); @@ -428,6 +504,28 @@ function normalizeMacDnsCommandOutput(output) { .filter(Boolean); } +function isNordDnsServerList(dnsServers) { + if (!Array.isArray(dnsServers) || !dnsServers.length) return false; + const normalized = dnsServers.map((value) => `${value}`.trim()).filter(Boolean); + if (!normalized.length) return false; + return normalized.every((value) => NORDVPN_MAC_DNS_SERVERS.includes(value)); +} + +function buildAutomaticMacDnsState(serviceNames) { + return { + services: (serviceNames || []).map((name) => ({ + name, + dnsServers: [], + searchDomains: [], + })), + }; +} + +function shouldRejectMacDnsBaseline(state) { + if (!state || !Array.isArray(state.services) || !state.services.length) return false; + return state.services.every((service) => isNordDnsServerList(service.dnsServers || [])); +} + async function listMacDnsServices() { const result = await runExec("networksetup", ["-listallnetworkservices"]); if (!result.ok) { @@ -482,7 +580,14 @@ async function setMacSearchDomains(serviceName, searchDomains) { } async function applyMacNordDns() { - const snapshot = readJsonFile(MAC_DNS_STATE_PATH) || (await snapshotMacDnsState()); + let snapshot = readJsonFile(MAC_DNS_STATE_PATH); + if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) { + snapshot = await snapshotMacDnsState(); + } else if (shouldRejectMacDnsBaseline(snapshot)) { + const services = await listMacDnsServices(); + snapshot = buildAutomaticMacDnsState(services); + writeJsonFile(MAC_DNS_STATE_PATH, snapshot); + } for (const service of snapshot.services || []) { await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS); await setMacSearchDomains(service.name, []); @@ -495,7 +600,10 @@ async function restoreMacDnsIfNeeded() { if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) { return { restored: false }; } - for (const service of snapshot.services) { + const servicesToRestore = shouldRejectMacDnsBaseline(snapshot) + ? buildAutomaticMacDnsState(snapshot.services.map((service) => service.name)).services + : snapshot.services; + for (const service of servicesToRestore) { await setMacDnsServers(service.name, service.dnsServers || []); await setMacSearchDomains(service.name, service.searchDomains || []); } @@ -658,8 +766,100 @@ function isBenignMacWireguardAbsentError(message) { ); } -function shouldFinalizeMacWireguardConnect(connectResult, verified) { - return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok); +function parseMacWireguardHelperStatus(output) { + const parsed = {}; + for (const line of `${output || ""}`.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const separator = trimmed.indexOf("="); + if (separator <= 0) continue; + const key = trimmed.slice(0, separator).trim(); + const value = trimmed.slice(separator + 1).trim(); + parsed[key] = value; + } + + return { + active: ["1", "true", "yes", "on"].includes(`${parsed.active || ""}`.toLowerCase()), + interfaceName: parsed.interfaceName || MAC_WG_INTERFACE, + configPath: parsed.configPath || null, + raw: `${output || ""}`.trim(), + }; +} + +async function getMacWireguardHelperStatus(installProbe, options = {}) { + const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard; + const result = await runSudoWireguardFn(installProbe, "status"); + const parsed = parseMacWireguardHelperStatus(result.stdout || result.stderr || ""); + return { + ...parsed, + ok: result.ok, + error: result.ok ? "" : (result.stderr || result.stdout || result.error).trim(), + }; +} + +async function checkMacWireguardPersistence(target, options = {}) { + const attempts = Math.max(1, Number(options.attempts || CONNECT_PERSISTENCE_ATTEMPTS)); + const delayMs = Math.max(0, Number(options.delayMs || CONNECT_PERSISTENCE_DELAY_MS)); + const getHelperStatus = options.getHelperStatus || (async () => ({ active: false, interfaceName: MAC_WG_INTERFACE })); + const verifyConnectionFn = options.verifyConnection || verifyConnection; + const sleepFn = options.sleep || sleep; + let lastHelperStatus = { active: false, interfaceName: MAC_WG_INTERFACE }; + let lastVerified = { ok: false, ipInfo: null }; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + lastHelperStatus = await getHelperStatus(); + } catch (error) { + lastHelperStatus = { + active: false, + interfaceName: MAC_WG_INTERFACE, + error: error.message || String(error), + }; + } + + try { + lastVerified = await verifyConnectionFn(target); + } catch (error) { + lastVerified = { + ok: false, + ipInfo: { + ok: false, + error: error.message || String(error), + }, + }; + } + + if (lastHelperStatus.active && lastVerified.ok) { + return { + stable: true, + attempts: attempt, + helperStatus: lastHelperStatus, + verified: lastVerified, + }; + } + + if (attempt < attempts) { + await sleepFn(delayMs); + } + } + + return { + stable: false, + attempts, + helperStatus: lastHelperStatus, + verified: lastVerified, + }; +} + +function shouldFinalizeMacWireguardConnect(connectResult, verified, persistence) { + return Boolean( + connectResult && + connectResult.backend === "wireguard" && + verified && + verified.ok && + persistence && + persistence.stable + ); } function normalizeSuccessfulConnectState(state, connectResult, verified) { @@ -812,14 +1012,20 @@ async function probeMacWireguard() { 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 }; + const helperStatus = + helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus((await runExec("sudo", ["-n", helperPath, "status"])).stdout) : null; let active = false; let showRaw = ""; let endpoint = ""; let ifconfigRaw = ""; + if (helperStatus) { + active = helperStatus.active; + } + if (wgPath) { const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]); - active = show.ok; + active = active || show.ok; showRaw = (show.stdout || show.stderr).trim(); if (show.ok) { const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]); @@ -846,8 +1052,8 @@ async function probeMacWireguard() { helperSecurity, dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath), sudoReady: sudoProbe.ok, - interfaceName: MAC_WG_INTERFACE, - configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null, + interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE, + configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null), active, endpoint: endpoint || null, showRaw, @@ -1462,6 +1668,7 @@ async function disconnectNordvpn(installProbe) { if (!down.ok) { const message = (down.stderr || down.stdout || down.error).trim(); if (isBenignMacWireguardAbsentError(message)) { + await runSudoWireguard(installProbe, "cleanup"); const dnsState = await restoreMacDnsIfNeeded(); const cleaned = cleanupMacWireguardAndDnsState(); const tailscale = await resumeMacTailscaleIfNeeded(); @@ -1476,6 +1683,7 @@ async function disconnectNordvpn(installProbe) { } throw new Error(message || "wg-quick down failed"); } + await runSudoWireguard(installProbe, "cleanup"); const dnsState = await restoreMacDnsIfNeeded(); const cleaned = cleanupMacWireguardAndDnsState(); const tailscale = await resumeMacTailscaleIfNeeded(); @@ -1623,117 +1831,204 @@ async function main() { } if (action === "connect") { - const target = buildConnectTarget(args); - let connectResult; + const lock = acquireOperationLock(action); + try { + const target = buildConnectTarget(args); + let connectResult; + let verified; + let persistence = null; - if (installProbe.cliPath) { - connectResult = await connectViaCli(installProbe.cliPath, target); - } else if (platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady && installProbe.tokenAvailable) { - connectResult = await connectViaMacWireguard(installProbe, target); - } else if (platform === "darwin" && installProbe.appInstalled) { - connectResult = await connectViaMacApp(target); - } else if (platform === "darwin" && !installProbe.tokenAvailable) { - throw new Error(`macOS automated NordLynx/WireGuard connects require NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or a token at ${DEFAULT_TOKEN_FILE}.`); - } else if (platform === "darwin") { - throw new Error("No usable macOS NordVPN backend is ready. Run install to bootstrap WireGuard tooling."); - } else if (!installProbe.installed) { - throw new Error("NordVPN is not installed."); - } else { - throw new Error("No usable NordVPN control path found."); - } + if (installProbe.cliPath) { + connectResult = await connectViaCli(installProbe.cliPath, target); + } else if (platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady && installProbe.tokenAvailable) { + connectResult = await connectViaMacWireguard(installProbe, target); + } else if (platform === "darwin" && installProbe.appInstalled) { + connectResult = await connectViaMacApp(target); + } else if (platform === "darwin" && !installProbe.tokenAvailable) { + throw new Error(`macOS automated NordLynx/WireGuard connects require NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or a token at ${DEFAULT_TOKEN_FILE}.`); + } else if (platform === "darwin") { + throw new Error("No usable macOS NordVPN backend is ready. Run install to bootstrap WireGuard tooling."); + } else if (!installProbe.installed) { + throw new Error("NordVPN is not installed."); + } else { + throw new Error("No usable NordVPN control path found."); + } - if (connectResult.manualActionRequired) { - emitJson({ action, requestedTarget: target, ...connectResult }); - } + if (connectResult.manualActionRequired) { + lock.release(); + emitJson({ action, requestedTarget: target, ...connectResult }); + } - const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 }); - const refreshed = await probeInstallation(platform); - 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(), + if (connectResult && connectResult.backend === "wireguard" && platform === "darwin") { + persistence = await checkMacWireguardPersistence(target, { + attempts: CONNECT_PERSISTENCE_ATTEMPTS, + delayMs: CONNECT_PERSISTENCE_DELAY_MS, + getHelperStatus: async () => getMacWireguardHelperStatus(installProbe), + verifyConnection: verifyConnection, }); - } catch (error) { - const rollback = await disconnectNordvpn(await probeInstallation(platform)); + verified = persistence.verified; + + if (!persistence.stable) { + const refreshed = await probeInstallation(platform); + 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); + lock.release(); + emitJson( + { + action, + requestedTarget: target, + connectResult, + persistence, + verified: false, + verification: verified && verified.ipInfo ? verified.ipInfo : null, + diagnostics: debugOutput ? diagnostics : undefined, + diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics), + rollback, + state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()), + }, + 1, + true + ); + } + + verified = await verifyConnection(target); + } else { + verified = await verifyConnectionWithRetry(target, { + attempts: CONNECT_PERSISTENCE_ATTEMPTS, + delayMs: CONNECT_PERSISTENCE_DELAY_MS, + }); + } + + const refreshed = await probeInstallation(platform); + 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); + lock.release(); emitJson( { action, requestedTarget: target, connectResult, - verified: true, + persistence, + verified: false, verification: verified.ipInfo, + diagnostics: debugOutput ? diagnostics : undefined, + diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics), 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, - requestedTarget: target, + + if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified, persistence)) { + try { + await snapshotMacDnsState(); + const liveness = await verifyConnection(target); + if (!liveness.ok) { + const rollback = await disconnectNordvpn(await probeInstallation(platform)); + lock.release(); + emitJson( + { + action, + requestedTarget: target, + connectResult, + persistence, + verified: false, + verification: liveness.ipInfo, + rollback, + error: "Connected but failed the final liveness check before DNS finalization.", + state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()), + }, + 1, + true + ); + } + verified = liveness; + 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)); + lock.release(); + emitJson( + { + action, + requestedTarget: target, + connectResult, + persistence, + 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: verified.ok, - verification: verified.ipInfo, - state: connectState, - }, - verified.ok ? 0 : 1, - !verified.ok - ); + verified + ); + lock.release(); + emitJson( + { + action, + requestedTarget: target, + connectResult, + persistence, + verified: verified.ok, + verification: verified.ipInfo, + state: connectState, + }, + verified.ok ? 0 : 1, + !verified.ok + ); + } finally { + lock.release(); + } } if (action === "disconnect") { - const result = await disconnectNordvpn(installProbe); - const refreshed = await probeInstallation(platform); - emitJson({ - action, - ...result, - state: buildStateSummary(refreshed, await getPublicIpInfo()), - }); + const lock = acquireOperationLock(action); + try { + const result = await disconnectNordvpn(installProbe); + const refreshed = await probeInstallation(platform); + lock.release(); + emitJson({ + action, + ...result, + state: buildStateSummary(refreshed, await getPublicIpInfo()), + }); + } finally { + lock.release(); + } } emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); diff --git a/skills/nordvpn-client/scripts/nordvpn-client.test.js b/skills/nordvpn-client/scripts/nordvpn-client.test.js index 4c96333..7fbcfd0 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.test.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.test.js @@ -23,6 +23,8 @@ module.exports = { typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined, collectMacWireguardDiagnostics: typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined, + acquireOperationLock: + typeof acquireOperationLock === "function" ? acquireOperationLock : undefined, inspectMacWireguardHelperSecurity: typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined, getMacTailscalePath: @@ -31,10 +33,16 @@ module.exports = { typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined, isMacTailscaleActive: typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined, + checkMacWireguardPersistence: + typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined, normalizeSuccessfulConnectState: typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined, normalizeStatusState: typeof normalizeStatusState === "function" ? normalizeStatusState : undefined, + parseMacWireguardHelperStatus: + typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined, + shouldRejectMacDnsBaseline: + typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined, shouldManageMacDnsService: typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined, sanitizeOutputPayload: @@ -142,6 +150,36 @@ test("buildMacDnsState records DNS and search domains per service", () => { ); }); +test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => { + const { shouldRejectMacDnsBaseline } = loadInternals(); + assert.equal(typeof shouldRejectMacDnsBaseline, "function"); + + assert.equal( + shouldRejectMacDnsBaseline({ + services: [ + { + name: "Wi-Fi", + dnsServers: ["103.86.96.100", "103.86.99.100"], + searchDomains: [], + }, + ], + }), + true + ); + assert.equal( + shouldRejectMacDnsBaseline({ + services: [ + { + name: "Wi-Fi", + dnsServers: ["1.1.1.1"], + searchDomains: [], + }, + ], + }), + false + ); +}); + test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => { const { getMacTailscalePath } = loadInternals(); assert.equal(typeof getMacTailscalePath, "function"); @@ -293,6 +331,92 @@ test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process assert.equal(result.processes.includes("wireguard-go"), true); }); +test("parseMacWireguardHelperStatus reads active helper key-value output", () => { + const { parseMacWireguardHelperStatus } = loadInternals(); + assert.equal(typeof parseMacWireguardHelperStatus, "function"); + + const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n"); + + assert.equal(result.active, true); + assert.equal(result.interfaceName, "nordvpnctl"); +}); + +test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => { + const { checkMacWireguardPersistence } = loadInternals(); + assert.equal(typeof checkMacWireguardPersistence, "function"); + + const helperStatuses = [ + { active: false, interfaceName: "nordvpnctl" }, + { active: true, interfaceName: "nordvpnctl" }, + ]; + const verifications = [ + { ok: false, ipInfo: { ok: false, error: "timeout" } }, + { ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } }, + ]; + + const result = await checkMacWireguardPersistence( + { country: "Germany", city: "" }, + { + attempts: 2, + delayMs: 1, + getHelperStatus: async () => helperStatuses.shift(), + verifyConnection: async () => verifications.shift(), + sleep: async () => {}, + } + ); + + assert.equal(result.stable, true); + assert.equal(result.attempts, 2); + assert.equal(result.helperStatus.active, true); + assert.equal(result.verified.ok, true); +}); + +test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => { + const { checkMacWireguardPersistence } = loadInternals(); + assert.equal(typeof checkMacWireguardPersistence, "function"); + + const result = await checkMacWireguardPersistence( + { country: "Germany", city: "" }, + { + attempts: 2, + delayMs: 1, + getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }), + verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }), + sleep: async () => {}, + } + ); + + assert.equal(result.stable, false); + assert.equal(result.attempts, 2); + assert.equal(result.helperStatus.active, false); + assert.equal(result.verified.ok, false); +}); + +test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => { + const { acquireOperationLock } = loadInternals(); + assert.equal(typeof acquireOperationLock, "function"); + + const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-")); + const lockPath = path.join(tmpDir, "operation.lock"); + fs.writeFileSync( + lockPath, + JSON.stringify({ + action: "connect", + pid: 0, + startedAt: new Date(0).toISOString(), + startedAtMs: 0, + }) + ); + + const lock = acquireOperationLock("disconnect", { lockPath }); + const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8")); + + assert.equal(lockFile.action, "disconnect"); + assert.equal(lockFile.pid, process.pid); + lock.release(); + assert.equal(fs.existsSync(lockPath), false); +}); + test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => { const { shouldAttemptMacWireguardDisconnect } = loadInternals(); assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function"); @@ -370,14 +494,15 @@ 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", () => { +test("shouldFinalizeMacWireguardConnect requires a verified and stable 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); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false); + assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false); + assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false); }); test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => { diff --git a/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh b/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh index 294e981..62f76a3 100755 --- a/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh +++ b/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh @@ -3,16 +3,18 @@ set -eu ACTION="${1:-}" case "$ACTION" in - probe|up|down) + probe|up|down|status|cleanup) ;; *) - echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down]" >&2 + echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2 exit 2 ;; esac WG_QUICK="/opt/homebrew/bin/wg-quick" +WG="/opt/homebrew/bin/wg" WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf" +WG_INTERFACE="nordvpnctl" PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" export PATH @@ -21,4 +23,25 @@ if [ "$ACTION" = "probe" ]; then exit 0 fi +if [ "$ACTION" = "status" ]; then + ACTIVE=0 + if [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then + ACTIVE=1 + elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then + ACTIVE=1 + fi + + echo "active=$ACTIVE" + echo "interfaceName=$WG_INTERFACE" + if [ -f "$WG_CONFIG" ]; then + echo "configPath=$WG_CONFIG" + fi + exit 0 +fi + +if [ "$ACTION" = "cleanup" ]; then + "$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true + exit 0 +fi + exec "$WG_QUICK" "$ACTION" "$WG_CONFIG"