#!/usr/bin/env node const { execFile, spawn } = require("node:child_process"); const dns = require("node:dns"); const fs = require("node:fs"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const https = require("node:https"); const MAC_WG_INTERFACE = "nordvpnctl"; const STATE_DIR = path.join(os.homedir(), ".nordvpn-client"); 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 MAC_TAILSCALE_SUPPRESS_PATH = path.join(STATE_DIR, "tailscale-suppressed"); const OPERATION_LOCK_PATH = path.join(STATE_DIR, "operation.lock"); const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join( os.homedir(), ".openclaw", "workspace", ".clawdbot", "credentials", "nordvpn" ); const DEFAULT_TOKEN_FILE = path.join(OPENCLAW_NORDVPN_CREDENTIALS_DIR, "token.txt"); const DEFAULT_PASSWORD_FILE = path.join(OPENCLAW_NORDVPN_CREDENTIALS_DIR, "password.txt"); const MAC_WG_HELPER_PATH = path.join( os.homedir(), ".openclaw", "workspace", "skills", "nordvpn-client", "scripts", "nordvpn-wireguard-helper.sh" ); 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 POST_DNS_RESOLUTION_HOSTNAMES = ["www.google.com", "api.openai.com", "docs.openclaw.ai"]; const POST_DNS_RESOLUTION_TIMEOUT_MS = 4000; const POST_DNS_SETTLE_DELAY_MS = 1500; const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource", "helperSecurity"]); function sanitizeOutputPayload(payload) { if (Array.isArray(payload)) { return payload.map((value) => sanitizeOutputPayload(value)); } if (!payload || typeof payload !== "object") { return payload; } const sanitized = {}; for (const [key, value] of Object.entries(payload)) { if (REDACTED_PATH_KEYS.has(key)) { sanitized[key] = null; continue; } sanitized[key] = sanitizeOutputPayload(value); } return sanitized; } function printJson(payload, exitCode = 0, errorStream = false, debugOutput = false) { const body = `${JSON.stringify(debugOutput ? payload : sanitizeOutputPayload(payload), null, 2)}\n`; (errorStream ? process.stderr : process.stdout).write(body); process.exit(exitCode); } function usage() { return { usage: [ "node scripts/nordvpn-client.js status", "node scripts/nordvpn-client.js install", "node scripts/nordvpn-client.js login", "node scripts/nordvpn-client.js verify", 'node scripts/nordvpn-client.js verify --country "Italy"', 'node scripts/nordvpn-client.js verify --country "Italy" --city "Milan"', 'node scripts/nordvpn-client.js connect --country "Italy"', 'node scripts/nordvpn-client.js connect --city "Milan"', 'node scripts/nordvpn-client.js connect --country "Italy" --city "Milan"', "node scripts/nordvpn-client.js disconnect", ], env: [ "NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE", `default token file: ${DEFAULT_TOKEN_FILE}`, "NORDVPN_USERNAME", "NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE", `default password file: ${DEFAULT_PASSWORD_FILE}`, ], }; } function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (token.startsWith("--")) { const key = token.slice(2); const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[key] = true; } else { args[key] = next; i += 1; } } else { args._.push(token); } } return args; } function detectPlatform() { const raw = os.platform(); if (raw === "darwin") return "darwin"; if (raw === "linux") return "linux"; return raw; } function readSecret(envName, fileEnvName) { if (process.env[envName]) return process.env[envName]; const candidates = []; if (process.env[fileEnvName]) candidates.push(process.env[fileEnvName]); if (envName === "NORDVPN_TOKEN") candidates.push(DEFAULT_TOKEN_FILE); if (envName === "NORDVPN_PASSWORD") candidates.push(DEFAULT_PASSWORD_FILE); for (const candidate of candidates) { try { const value = fs.readFileSync(candidate, "utf8").trim(); if (value) return value; } catch { // Continue. } } return ""; } function normalizeLocation(value) { return `${value || ""}`.trim(); } function normalizeForMatch(value) { return `${value || ""}` .normalize("NFKD") .replace(/[^\p{L}\p{N}\s-]/gu, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function commandExists(name) { const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean); for (const entry of pathEntries) { const full = path.join(entry, name); try { fs.accessSync(full, fs.constants.X_OK); return full; } catch { // Continue. } } const fallbackDirs = ["/usr/sbin", "/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"]; for (const entry of fallbackDirs) { const full = path.join(entry, name); try { fs.accessSync(full, fs.constants.X_OK); return full; } catch { // Continue. } } return ""; } function fileExists(target) { try { fs.accessSync(target); return true; } catch { return false; } } function ensureDir(target, mode = 0o700) { fs.mkdirSync(target, { recursive: true, mode }); try { fs.chmodSync(target, mode); } catch { // Ignore chmod errors on existing directories. } } function readJsonFile(target) { try { return JSON.parse(fs.readFileSync(target, "utf8")); } catch { return null; } } function writeJsonFile(target, payload, mode = 0o600) { ensureDir(path.dirname(target)); fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`, { mode }); try { fs.chmodSync(target, mode); } catch { // Ignore chmod errors. } } function writeTextFile(target, contents, mode = 0o600) { ensureDir(path.dirname(target)); fs.writeFileSync(target, contents, { mode }); try { fs.chmodSync(target, mode); } catch { // Ignore chmod errors. } } function runExec(command, args = [], options = {}) { return new Promise((resolve) => { execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { resolve({ ok: !error, code: error && typeof error.code === "number" ? error.code : 0, stdout: stdout || "", stderr: stderr || "", error: error ? error.message : "", }); }); }); } function runInteractive(command, args = [], options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: "inherit", ...options }); child.on("error", reject); child.on("exit", (code) => resolve(code === 0)); }); } function runShell(command) { return runInteractive("bash", ["-lc", command]); } 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"); for (const line of lines) { if (/^utun\d+:/.test(line)) { inUtunBlock = true; continue; } if (/^\S/.test(line)) { inUtunBlock = false; } if (inUtunBlock && line.includes(`inet ${CLIENT_IPV4}`)) { return true; } } return false; } function buildMacTailscaleState(tailscaleWasActive) { return { tailscaleWasActive: Boolean(tailscaleWasActive) }; } function markMacTailscaleRecoverySuppressed() { ensureDir(STATE_DIR); writeTextFile(MAC_TAILSCALE_SUPPRESS_PATH, `${new Date().toISOString()}\n`, 0o600); } function clearMacTailscaleRecoverySuppressed() { try { fs.unlinkSync(MAC_TAILSCALE_SUPPRESS_PATH); } catch { // Ignore cleanup errors. } } 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); } 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) { 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() { 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, []); } return snapshot; } async function restoreMacDnsIfNeeded() { const snapshot = readJsonFile(MAC_DNS_STATE_PATH); if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) { return { restored: false }; } 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 || []); } 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; const discovered = commandExistsFn("tailscale"); if (discovered) return discovered; const fallbacks = ["/opt/homebrew/bin/tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; for (const fallback of fallbacks) { if (fileExistsFn(fallback)) return fallback; } return "tailscale"; } function isMacTailscaleActive(status) { return Boolean(status && status.BackendState === "Running"); } async function getMacTailscaleStatus() { const tailscale = getMacTailscalePath(); const result = await runExec(tailscale, ["status", "--json"]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale status --json failed"); } const json = JSON.parse(result.stdout || "{}"); return { active: isMacTailscaleActive(json), raw: json, }; } async function stopMacTailscaleIfActive() { const status = await getMacTailscaleStatus(); if (!status.active) { clearMacTailscaleRecoverySuppressed(); writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false)); return { tailscaleWasActive: false }; } markMacTailscaleRecoverySuppressed(); const tailscale = getMacTailscalePath(); const result = await runExec(tailscale, ["down"]); if (!result.ok) { clearMacTailscaleRecoverySuppressed(); throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed"); } writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true)); return { tailscaleWasActive: true }; } async function resumeMacTailscaleIfNeeded() { const state = readJsonFile(MAC_TAILSCALE_STATE_PATH); let currentStatus = null; try { currentStatus = await getMacTailscaleStatus(); } catch { currentStatus = null; } if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) { clearMacTailscaleRecoverySuppressed(); try { fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); } catch { // Ignore unlink errors. } return { restored: false }; } const tailscale = getMacTailscalePath(); const result = await runExec(tailscale, ["up"]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed"); } clearMacTailscaleRecoverySuppressed(); try { fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); } catch { // Ignore cleanup errors. } return { restored: true }; } async function resolveHostnameWithFallback(hostname, options = {}) { const resolvers = options.resolvers || DNS_FALLBACK_RESOLVERS; const resolveWithResolver = options.resolveWithResolver || (async (targetHostname, resolverIp) => { const resolver = new dns.promises.Resolver(); resolver.setServers([resolverIp]); return resolver.resolve4(targetHostname); }); for (const resolverIp of resolvers) { try { const addresses = await resolveWithResolver(hostname, resolverIp); if (Array.isArray(addresses) && addresses.length) { return addresses[0]; } } catch { // Try the next resolver. } } return ""; } async function verifySystemHostnameResolution(hostnames = POST_DNS_RESOLUTION_HOSTNAMES, options = {}) { const lookup = options.lookup || ((hostname) => dns.promises.lookup(hostname, { family: 4, })); const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : POST_DNS_RESOLUTION_TIMEOUT_MS; const settleDelayMs = Number.isFinite(options.settleDelayMs) && options.settleDelayMs >= 0 ? options.settleDelayMs : POST_DNS_SETTLE_DELAY_MS; const errors = []; if (settleDelayMs > 0) { await sleep(settleDelayMs); } for (const hostname of hostnames) { try { const result = await Promise.race([ Promise.resolve().then(() => lookup(hostname)), sleep(timeoutMs).then(() => { throw new Error(`timeout after ${timeoutMs}ms`); }), ]); const address = Array.isArray(result) ? result[0] && result[0].address : result && result.address; if (address) { return { ok: true, hostname, address, }; } errors.push(`${hostname}: no address returned`); } catch (error) { errors.push(`${hostname}: ${error.message || String(error)}`); } } return { ok: false, hostname: "", address: "", error: errors.join("; "), }; } function buildLookupResult(address, options = {}) { if (options && options.all) { return [{ address, family: 4 }]; } return [address, 4]; } function cleanupMacWireguardState(paths = {}) { const targets = [paths.configPath || WG_CONFIG_PATH, paths.lastConnectionPath || LAST_CONNECTION_PATH]; let cleaned = false; for (const target of targets) { try { if (target && fs.existsSync(target)) { fs.unlinkSync(target); cleaned = true; } } catch { // Ignore cleanup errors; caller will rely on current runtime state. } } 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 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, wireguardInterface: parsed.wireguardInterface || null, configPath: parsed.configPath || null, raw: `${output || ""}`.trim(), }; } async function getMacWireguardHelperStatus(installProbe, options = {}) { const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard; const result = await runSudoWireguardFn(installProbe, "probe"); 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) { if ( !state || state.platform !== "darwin" || state.controlMode !== "wireguard" || !connectResult || connectResult.backend !== "wireguard" || !verified || !verified.ok ) { return state; } return { ...state, connected: true, wireguard: state.wireguard ? { ...state.wireguard, active: true, endpoint: connectResult.server && connectResult.server.hostname ? `${connectResult.server.hostname}:51820` : state.wireguard.endpoint, } : state.wireguard, }; } function normalizeStatusState(state) { if ( !state || state.platform !== "darwin" || state.controlMode !== "wireguard" || state.connected || !state.wireguard || state.wireguard.active || !state.wireguard.endpoint || !state.wireguard.lastConnection || !state.publicIp || !state.publicIp.ok ) { return state; } const target = state.wireguard.lastConnection.resolvedTarget || state.wireguard.lastConnection.requestedTarget; if (!target || !locationMatches(state.publicIp, target)) { return state; } return { ...state, connected: true, wireguard: { ...state.wireguard, active: true, }, }; } function fetchJson(url, headers = {}) { return new Promise((resolve) => { const targetUrl = new URL(url); const req = https.get( targetUrl, { headers: { "User-Agent": "nordvpn-client-skill/1.0", Accept: "application/json", ...headers, }, lookup: (hostname, options, callback) => { const ipVersion = net.isIP(hostname); if (ipVersion) { callback(null, hostname, ipVersion); return; } resolveHostnameWithFallback(hostname) .then((address) => { if (address) { const result = buildLookupResult(address, options); if (options && options.all) { callback(null, result); } else { callback(null, result[0], result[1]); } return; } dns.lookup(hostname, options, callback); }) .catch(() => dns.lookup(hostname, options, callback)); }, }, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { try { resolve({ ok: true, statusCode: res.statusCode || 0, json: JSON.parse(data) }); } catch (error) { resolve({ ok: false, statusCode: res.statusCode || 0, error: error.message, raw: data, }); } }); } ); req.on("error", (error) => resolve({ ok: false, error: error.message })); req.setTimeout(15000, () => { req.destroy(new Error("timeout")); }); }); } async function getPublicIpInfo() { const lookup = await fetchJson("https://ipapi.co/json/"); if (!lookup.ok) return { ok: false, error: lookup.error || "lookup failed" }; const json = lookup.json || {}; return { ok: true, ip: json.ip || "", city: json.city || "", region: json.region || "", country: json.country_name || json.country || "", countryCode: json.country_code || "", org: json.org || "", latitude: Number.isFinite(Number(json.latitude)) ? Number(json.latitude) : null, longitude: Number.isFinite(Number(json.longitude)) ? Number(json.longitude) : null, }; } async function probeCliStatus(cliPath) { const status = await runExec(cliPath, ["status"]); const account = await runExec(cliPath, ["account"]); const countries = await runExec(cliPath, ["countries"]); return { status, account, countries }; } async function probeMacWireguard() { const wgPath = commandExists("wg"); 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 }; const helperStatus = helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus(sudoProbe.stdout || sudoProbe.stderr || "") : 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 = active || show.ok; showRaw = (show.stdout || show.stderr).trim(); if (show.ok) { const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]); endpoint = (endpointResult.stdout || "").trim(); } } const ifconfig = await runExec("ifconfig"); ifconfigRaw = (ifconfig.stdout || ifconfig.stderr).trim(); if (!active) { active = detectMacWireguardActiveFromIfconfig(ifconfigRaw); } if (!endpoint && fileExists(WG_CONFIG_PATH)) { const configText = fs.readFileSync(WG_CONFIG_PATH, "utf8"); const match = configText.match(/^Endpoint\s*=\s*(.+)$/m); if (match) endpoint = match[1].trim(); } return { wgPath: wgPath || null, wgQuickPath: wgQuickPath || null, wireguardGoPath: wireguardGoPath || null, helperPath, helperSecurity, dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath), sudoReady: sudoProbe.ok, 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, ifconfigRaw, authCache: readJsonFile(AUTH_CACHE_PATH), lastConnection: readJsonFile(LAST_CONNECTION_PATH), }; } async function probeInstallation(platform) { const cliPath = commandExists("nordvpn"); const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : ""; const brewPath = platform === "darwin" ? commandExists("brew") : ""; const tokenAvailable = Boolean(readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE")); let cliProbe = null; if (cliPath) { cliProbe = await probeCliStatus(cliPath); } let wireguard = null; if (platform === "darwin") { wireguard = await probeMacWireguard(); } return { platform, cliPath, appPath, appInstalled: Boolean(appPath && fileExists(appPath)), brewPath, tokenAvailable, installed: platform === "darwin" ? Boolean(cliPath) || Boolean(appPath && fileExists(appPath)) || Boolean(wireguard && wireguard.dependenciesReady) : Boolean(cliPath), cliProbe, wireguard, }; } function inferAuthState(probe, installProbe) { if (probe && probe.account) { const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase(); if (!blob.trim()) return null; if (probe.account.ok && !blob.includes("not logged")) return true; if (blob.includes("not logged") || blob.includes("login")) return false; } if (installProbe.platform === "darwin" && installProbe.wireguard) { if (installProbe.wireguard.authCache) return true; if (installProbe.tokenAvailable) return null; } return null; } function inferConnectionState(probe, installProbe) { if (probe && probe.status) { const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase(); if (!blob.trim()) return null; if (blob.includes("connected")) return true; if (blob.includes("disconnected") || blob.includes("not connected")) return false; } if (installProbe.platform === "darwin" && installProbe.wireguard) { return installProbe.wireguard.active; } return null; } function buildStateSummary(installProbe, ipInfo) { const cliProbe = installProbe.cliProbe; let controlMode = "unavailable"; let automaticControl = false; let loginMode = "unsupported"; let connectMode = "unsupported"; let recommendedAction = "Install NordVPN first."; if (installProbe.cliPath) { controlMode = "cli"; automaticControl = true; loginMode = "cli"; connectMode = "cli"; recommendedAction = "Use login/connect/disconnect through the nordvpn CLI."; } else if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) { controlMode = "wireguard"; automaticControl = true; loginMode = "wireguard-token"; connectMode = "wireguard"; recommendedAction = installProbe.tokenAvailable ? installProbe.wireguard.sudoReady ? "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) { controlMode = "app-manual"; automaticControl = false; loginMode = "app-manual"; connectMode = "app-manual"; recommendedAction = installProbe.tokenAvailable ? "NordVPN.app is installed, but automated macOS connects also require wireguard-go and wireguard-tools. Run 'node scripts/nordvpn-client.js install' to install them with Homebrew." : `NordVPN.app is installed. For automated macOS connects, run 'node scripts/nordvpn-client.js install' to install wireguard-go and wireguard-tools with Homebrew, then set NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or place the token at ${DEFAULT_TOKEN_FILE} and run login.`; } else if (installProbe.platform === "darwin" && installProbe.brewPath) { controlMode = "wireguard-bootstrap"; automaticControl = false; loginMode = "wireguard-token"; connectMode = "wireguard"; recommendedAction = "Run 'node scripts/nordvpn-client.js install' to install wireguard-go and wireguard-tools with Homebrew for automated macOS NordLynx connects."; } return { platform: installProbe.platform, installed: installProbe.installed, cliAvailable: Boolean(installProbe.cliPath), cliPath: installProbe.cliPath || null, appInstalled: installProbe.appInstalled, appPath: installProbe.appInstalled ? installProbe.appPath : null, brewAvailable: Boolean(installProbe.brewPath), tokenAvailable: installProbe.tokenAvailable, controlMode, automaticControl, loginMode, connectMode, recommendedAction, authenticated: inferAuthState(cliProbe, installProbe), connected: inferConnectionState(cliProbe, installProbe), localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "", wireguard: installProbe.wireguard ? { dependenciesReady: installProbe.wireguard.dependenciesReady, sudoReady: installProbe.wireguard.sudoReady, interfaceName: installProbe.wireguard.interfaceName, active: installProbe.wireguard.active, configPath: installProbe.wireguard.configPath, endpoint: installProbe.wireguard.endpoint, wgPath: installProbe.wireguard.wgPath, wgQuickPath: installProbe.wireguard.wgQuickPath, wireguardGoPath: installProbe.wireguard.wireguardGoPath, helperPath: installProbe.wireguard.helperPath, helperSecurity: installProbe.wireguard.helperSecurity, authCache: installProbe.wireguard.authCache, lastConnection: installProbe.wireguard.lastConnection, } : null, publicIp: ipInfo && ipInfo.ok ? ipInfo : null, }; } async function installNordvpn(installProbe) { if (installProbe.platform === "darwin") { if (!installProbe.brewPath) { throw new Error("Homebrew is required on macOS to bootstrap wireguard-go and wireguard-tools."); } const toInstall = []; if (!installProbe.wireguard || !installProbe.wireguard.wireguardGoPath) toInstall.push("wireguard-go"); if (!installProbe.wireguard || !installProbe.wireguard.wgPath || !installProbe.wireguard.wgQuickPath) { toInstall.push("wireguard-tools"); } if (!toInstall.length) { return { changed: false, message: "macOS WireGuard tooling is already installed." }; } const ok = await runInteractive(installProbe.brewPath, ["install", ...toInstall]); if (!ok) { throw new Error(`brew install ${toInstall.join(" ")} failed`); } return { changed: true, message: `Installed ${toInstall.join(", ")} for the macOS WireGuard/NordLynx backend.`, }; } if (installProbe.installed) { return { changed: false, message: "NordVPN is already installed." }; } if (installProbe.platform === "linux") { const command = [ 'TMPFILE="$(mktemp)"', 'if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o "${TMPFILE}"; ' + 'elif command -v wget >/dev/null 2>&1; then wget -qO "${TMPFILE}" https://downloads.nordcdn.com/apps/linux/install.sh; ' + "else echo 'curl or wget required' >&2; exit 1; fi", 'sh "${TMPFILE}"', 'rm -f "${TMPFILE}"', ].join(" && "); const ok = await runShell(command); if (!ok) { throw new Error("NordVPN Linux installer failed"); } return { changed: true, message: "Installed NordVPN via the official Linux installer." }; } throw new Error(`Unsupported platform: ${installProbe.platform}`); } async function openMacApp() { const ok = await runInteractive("open", ["-a", "NordVPN"]); if (!ok) throw new Error("Failed to open NordVPN.app"); } async function fetchNordCredentials(token) { const result = await fetchJson("https://api.nordvpn.com/v1/users/services/credentials", { Authorization: `Bearer token:${token}`, }); if (!result.ok) { throw new Error(result.error || `NordVPN credentials request failed (${result.statusCode || "unknown"})`); } if (result.statusCode >= 400) { const apiMessage = result.json && result.json.errors && result.json.errors.message; throw new Error(apiMessage || `NordVPN credentials request failed (${result.statusCode})`); } if (!result.json || !result.json.nordlynx_private_key) { throw new Error("NordVPN credentials response did not include nordlynx_private_key."); } return result.json; } async function fetchNordCountries() { const result = await fetchJson("https://api.nordvpn.com/v1/servers/countries"); if (!result.ok || result.statusCode >= 400 || !Array.isArray(result.json)) { throw new Error(result.error || `NordVPN countries request failed (${result.statusCode || "unknown"})`); } return result.json; } function matchByName(candidates, nameAccessor, input, label) { const needle = normalizeForMatch(input); if (!needle) return []; const exact = candidates.filter((item) => normalizeForMatch(nameAccessor(item)) === needle); if (exact.length) return exact; const prefix = candidates.filter((item) => normalizeForMatch(nameAccessor(item)).startsWith(needle)); if (prefix.length) return prefix; return candidates.filter((item) => normalizeForMatch(nameAccessor(item)).includes(needle)); } function resolveTargetMetadata(countries, target) { let country = null; if (target.country) { const countryMatches = matchByName(countries, (item) => item.name, target.country, "country"); if (!countryMatches.length) { throw new Error(`Could not find NordVPN country match for \"${target.country}\".`); } if (countryMatches.length > 1) { throw new Error( `Country \"${target.country}\" is ambiguous: ${countryMatches.slice(0, 5).map((item) => item.name).join(", ")}.` ); } country = countryMatches[0]; } let city = null; if (target.city) { if (country) { const cities = Array.isArray(country.cities) ? country.cities : []; const cityMatches = matchByName(cities, (item) => item.name, target.city, "city"); if (!cityMatches.length) { throw new Error(`Could not find city \"${target.city}\" in ${country.name}.`); } if (cityMatches.length > 1) { throw new Error( `City \"${target.city}\" is ambiguous in ${country.name}: ${cityMatches.slice(0, 5).map((item) => item.name).join(", ")}.` ); } city = cityMatches[0]; } else { const globalMatches = []; for (const candidateCountry of countries) { for (const candidateCity of candidateCountry.cities || []) { if (matchByName([candidateCity], (item) => item.name, target.city, "city").length) { globalMatches.push({ country: candidateCountry, city: candidateCity }); } } } if (!globalMatches.length) { throw new Error(`Could not find NordVPN city match for \"${target.city}\".`); } if (globalMatches.length > 1) { const suggestions = globalMatches .slice(0, 5) .map((item) => `${item.city.name}, ${item.country.name}`) .join(", "); throw new Error(`City \"${target.city}\" is ambiguous. Specify --country. Matches: ${suggestions}.`); } country = globalMatches[0].country; city = globalMatches[0].city; } } return { country: country ? { id: country.id, code: country.code, name: country.name, } : null, city: city ? { id: city.id, name: city.name, latitude: city.latitude, longitude: city.longitude, dnsName: city.dns_name || null, } : null, }; } async function fetchNordRecommendations(targetMeta, ipInfo) { const url = new URL("https://api.nordvpn.com/v1/servers/recommendations"); const params = url.searchParams; params.append("limit", targetMeta.city ? "100" : "25"); params.append("filters[servers.status]", "online"); params.append("filters[servers_technologies]", "35"); params.append("filters[servers_technologies][pivot][status]", "online"); params.append("fields[servers.hostname]", "1"); params.append("fields[servers.load]", "1"); params.append("fields[servers.station]", "1"); params.append("fields[servers.ips]", "1"); params.append("fields[servers.technologies.identifier]", "1"); params.append("fields[servers.technologies.metadata]", "1"); params.append("fields[servers.locations.country.name]", "1"); params.append("fields[servers.locations.country.city.name]", "1"); if (targetMeta.country) { params.append("filters[country_id]", String(targetMeta.country.id)); } const latitude = targetMeta.city ? targetMeta.city.latitude : ipInfo && ipInfo.latitude; const longitude = targetMeta.city ? targetMeta.city.longitude : ipInfo && ipInfo.longitude; if (Number.isFinite(Number(latitude)) && Number.isFinite(Number(longitude))) { params.append("coordinates[latitude]", String(latitude)); params.append("coordinates[longitude]", String(longitude)); } const result = await fetchJson(url.toString()); if (!result.ok || result.statusCode >= 400 || !Array.isArray(result.json)) { throw new Error(result.error || `NordVPN server recommendation request failed (${result.statusCode || "unknown"})`); } return result.json; } function getServerCityName(server) { return ( server && Array.isArray(server.locations) && server.locations[0] && server.locations[0].country && server.locations[0].country.city && server.locations[0].country.city.name ) || ""; } function getServerCountryName(server) { return ( server && Array.isArray(server.locations) && server.locations[0] && server.locations[0].country && server.locations[0].country.name ) || ""; } function getServerWireguardPublicKey(server) { const tech = (server.technologies || []).find((item) => item.identifier === "wireguard_udp"); if (!tech) return ""; const metadata = (tech.metadata || []).find((item) => item.name === "public_key"); return metadata ? metadata.value : ""; } function getServerIpAddresses(server) { return (server.ips || []).map((item) => item && item.ip && item.ip.ip).filter(Boolean); } function deriveClientIpv6(serverIpv6) { const parts = `${serverIpv6}`.split(":"); if (parts.length < 4) return ""; const expanded = []; let skipped = false; for (const part of parts) { if (!part && !skipped) { const missing = 8 - (parts.filter(Boolean).length); for (let i = 0; i < missing; i += 1) expanded.push("0000"); skipped = true; } else if (part) { expanded.push(part.padStart(4, "0")); } } while (expanded.length < 8) expanded.push("0000"); expanded[4] = "0000"; expanded[5] = "0011"; expanded[6] = "0005"; expanded[7] = "0002"; return expanded.map((item) => item.replace(/^0+(?=[0-9a-f])/i, "") || "0").join(":"); } function buildWireguardConfig(server, privateKey) { const ipAddresses = getServerIpAddresses(server); const ipv6 = ipAddresses.find((value) => `${value}`.includes(":")); const addresses = [CLIENT_IPV4]; const allowedIps = ["0.0.0.0/0"]; if (ipv6) { const clientIpv6 = deriveClientIpv6(ipv6); if (clientIpv6) addresses.push(clientIpv6); allowedIps.push("::/0"); } const publicKey = getServerWireguardPublicKey(server); if (!publicKey) { throw new Error(`Server ${server.hostname} does not expose a WireGuard public key.`); } return [ "[Interface]", `PrivateKey = ${privateKey}`, `Address = ${addresses.join(", ")}`, "", "[Peer]", `PublicKey = ${publicKey}`, `AllowedIPs = ${allowedIps.join(", ")}`, `Endpoint = ${server.hostname}:51820`, "PersistentKeepalive = 25", "", ].join("\n"); } function buildConnectTarget(args) { const country = normalizeLocation(args.country); const city = normalizeLocation(args.city); if (!country && !city) { throw new Error("connect requires --country, --city, or both."); } return { country, city }; } function locationMatches(ipInfo, target) { if (!ipInfo || !ipInfo.ok) return false; const countryMatch = !target.country || normalizeForMatch(ipInfo.country).includes(normalizeForMatch(target.country)); const cityMatch = !target.city || normalizeForMatch(ipInfo.city).includes(normalizeForMatch(target.city)); return countryMatch && cityMatch; } async function verifyConnection(target) { const ipInfo = await getPublicIpInfo(); return { ok: target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok), ipInfo, }; } async function verifyConnectionWithRetry(target, options = {}) { const attempts = Math.max(1, Number(options.attempts || 4)); const delayMs = Math.max(0, Number(options.delayMs || 1500)); const getIpInfo = options.getPublicIpInfo || getPublicIpInfo; let lastIpInfo = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const ipInfo = await getIpInfo(); lastIpInfo = ipInfo; const ok = target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok); if (ok) { return { ok: true, ipInfo }; } if (attempt < attempts) { await sleep(delayMs); } } return { ok: false, ipInfo: lastIpInfo }; } async function connectViaCli(cliPath, target) { const attempts = []; if (target.city) attempts.push([target.city]); if (target.country) attempts.push([target.country]); if (target.country && target.city) attempts.push([`${target.country} ${target.city}`]); let lastFailure = ""; for (const attemptArgs of attempts) { const result = await runExec(cliPath, ["connect", ...attemptArgs]); if (result.ok) { return { backend: "cli", cliTarget: attemptArgs.join(" "), raw: result.stdout.trim() || result.stderr.trim() }; } lastFailure = (result.stderr || result.stdout || result.error).trim(); } throw new Error(lastFailure || "NordVPN connect failed"); } async function runSudoWireguard(installProbe, action) { const helperPath = installProbe.wireguard && installProbe.wireguard.helperPath; if (!helperPath) throw new Error(`WireGuard helper is missing at ${MAC_WG_HELPER_PATH}.`); if (!installProbe.wireguard.sudoReady) { throw new Error(`Non-interactive sudo is required for macOS WireGuard connect/disconnect. Allow sudo for ${MAC_WG_HELPER_PATH}, then rerun login/connect.`); } return runExec("sudo", ["-n", helperPath, action]); } async function connectViaMacWireguard(installProbe, target) { const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"); if (!token) { throw new Error(`macOS NordLynx/WireGuard automation requires NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or a token at ${DEFAULT_TOKEN_FILE}.`); } if (!installProbe.wireguard || !installProbe.wireguard.dependenciesReady) { throw new Error("wireguard-go and wireguard-tools are required on macOS. Run install first."); } const ipInfo = await getPublicIpInfo(); const countries = await fetchNordCountries(); const targetMeta = resolveTargetMetadata(countries, target); if (!targetMeta.country) { throw new Error("A country is required to select a NordVPN WireGuard server."); } const credentials = await fetchNordCredentials(token); const recommendations = await fetchNordRecommendations(targetMeta, ipInfo); let candidates = recommendations.filter((server) => Boolean(getServerWireguardPublicKey(server))); if (targetMeta.city) { candidates = candidates.filter( (server) => normalizeForMatch(getServerCityName(server)) === normalizeForMatch(targetMeta.city.name) ); } if (!candidates.length) { throw new Error( targetMeta.city ? `No WireGuard-capable NordVPN server found for ${targetMeta.city.name}, ${targetMeta.country.name}.` : `No WireGuard-capable NordVPN server found for ${targetMeta.country.name}.` ); } candidates.sort((a, b) => (a.load || 999) - (b.load || 999)); const selectedServer = candidates[0]; const config = buildWireguardConfig(selectedServer, credentials.nordlynx_private_key); ensureDir(WG_STATE_DIR); writeTextFile(WG_CONFIG_PATH, config, 0o600); let tailscaleStopped = false; const tailscaleState = await stopMacTailscaleIfActive(); tailscaleStopped = tailscaleState.tailscaleWasActive; const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { // Ignore the common no-active-interface case before reconnecting. } const up = await runSudoWireguard(installProbe, "up"); if (!up.ok) { if (tailscaleStopped) { await resumeMacTailscaleIfNeeded(); } throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed"); } return { backend: "wireguard", server: { hostname: selectedServer.hostname, city: getServerCityName(selectedServer), country: getServerCountryName(selectedServer), load: selectedServer.load, }, requestedTarget: target, resolvedTarget: { country: targetMeta.country.name, city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer), }, interfaceName: MAC_WG_INTERFACE, tailscaleStopped, raw: up.stdout.trim() || up.stderr.trim(), }; } async function connectViaMacApp(target) { await openMacApp(); return { backend: "app-manual", manualActionRequired: true, message: `Opened NordVPN.app. Connect manually to ${target.city ? `${target.city}, ` : ""}${target.country || "the requested location"} and rerun status/verify before the follow-up task.`, }; } async function disconnectNordvpn(installProbe) { if (installProbe.cliPath) { const result = await runExec(installProbe.cliPath, ["disconnect"]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed"); } return { backend: "cli", message: "Disconnected from NordVPN." }; } 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.", }; } const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { const message = (down.stderr || down.stdout || down.error).trim(); 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 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.", }; } if (installProbe.platform === "darwin" && installProbe.appInstalled) { await openMacApp(); return { backend: "app-manual", manualActionRequired: true, message: "Opened NordVPN.app. Disconnect manually because no automated control backend is available.", }; } throw new Error("NordVPN is not installed."); } async function loginNordvpn(installProbe) { const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"); const username = process.env.NORDVPN_USERNAME || ""; const password = readSecret("NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE"); if (installProbe.cliPath) { if (token) { const result = await runExec(installProbe.cliPath, ["login", "--token", token]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed"); } return { mode: "cli-token", backend: "cli", message: "Logged in using token." }; } if (username && password && installProbe.platform === "darwin") { // macOS CLI login support is not documented. Fall back to interactive CLI login. } const ok = await runInteractive(installProbe.cliPath, ["login"]); if (!ok) throw new Error("nordvpn login failed"); return { mode: "cli-interactive", backend: "cli", message: "Interactive NordVPN login completed." }; } if (installProbe.platform === "darwin" && token) { const credentials = await fetchNordCredentials(token); const cache = { backend: "wireguard", validatedAt: new Date().toISOString(), hasNordlynxPrivateKey: Boolean(credentials.nordlynx_private_key), tokenSource: process.env.NORDVPN_TOKEN ? "env:NORDVPN_TOKEN" : process.env.NORDVPN_TOKEN_FILE ? "file:NORDVPN_TOKEN_FILE" : fileExists(DEFAULT_TOKEN_FILE) ? `default:${DEFAULT_TOKEN_FILE}` : "unknown", }; writeJsonFile(AUTH_CACHE_PATH, cache); return { mode: "wireguard-token", backend: "wireguard", message: installProbe.wireguard && installProbe.wireguard.dependenciesReady ? "Validated NordVPN token for the macOS NordLynx/WireGuard backend." : "Validated NordVPN token. Run install to bootstrap wireguard-go and wireguard-tools before connecting.", auth: cache, }; } if (installProbe.platform === "darwin" && installProbe.appInstalled) { await openMacApp(); return { mode: "app-manual", backend: "app-manual", manualActionRequired: true, message: `Opened NordVPN.app. Complete login in the app/browser flow, or set NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or place the token at ${DEFAULT_TOKEN_FILE} to use the automated macOS WireGuard backend.`, }; } throw new Error("NordVPN is not installed."); } async function main() { const args = parseArgs(process.argv.slice(2)); const debugOutput = Boolean(args.debug); const emitJson = (payload, exitCode = 0, errorStream = false) => printJson(payload, exitCode, errorStream, debugOutput); const action = args._[0]; if (!action || args.help) { emitJson(usage(), action ? 0 : 1, !action); } const platform = detectPlatform(); if (!["darwin", "linux"].includes(platform)) { emitJson({ error: `Unsupported platform: ${platform}` }, 1, true); } const installProbe = await probeInstallation(platform); try { if (action === "status") { const ipInfo = await getPublicIpInfo(); emitJson(normalizeStatusState(buildStateSummary(installProbe, ipInfo))); } if (action === "install") { const result = await installNordvpn(installProbe); const refreshed = await probeInstallation(platform); emitJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } if (action === "login") { const result = await loginNordvpn(installProbe); const refreshed = await probeInstallation(platform); emitJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } if (action === "verify") { const target = args.country || args.city ? buildConnectTarget(args) : null; const verified = await verifyConnectionWithRetry(target); const refreshed = await probeInstallation(platform); emitJson( { action, requestedTarget: target, verified: verified.ok, verification: verified.ipInfo, state: buildStateSummary(refreshed, verified.ipInfo), }, verified.ok ? 0 : 1, !verified.ok ); } if (action === "connect") { 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 (connectResult.manualActionRequired) { lock.release(); emitJson({ action, requestedTarget: target, ...connectResult }); } 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, }); 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, persistence, 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, 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(); const dnsResolution = await verifySystemHostnameResolution(); if (!dnsResolution.ok) { const rollback = await disconnectNordvpn(await probeInstallation(platform)); lock.release(); emitJson( { action, requestedTarget: target, connectResult, persistence, verified: false, verification: verified.ipInfo, dnsResolution, rollback, error: `Connected but system DNS resolution failed after DNS finalization: ${dnsResolution.error}`, state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()), }, 1, true ); } 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 ); 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 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); } catch (error) { 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); } } main();