#!/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_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json"); 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"]; function printJson(payload, exitCode = 0, errorStream = false) { const body = `${JSON.stringify(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 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 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) { writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false)); return { tailscaleWasActive: false }; } const tailscale = getMacTailscalePath(); const result = await runExec(tailscale, ["down"]); if (!result.ok) { 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); if (!state || !state.tailscaleWasActive) { 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"); } 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 ""; } function buildLookupResult(address, options = {}) { if (options && options.all) { return [{ address, family: 4 }]; } return [address, 4]; } 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 sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false }; let active = false; let showRaw = ""; let endpoint = ""; let ifconfigRaw = ""; if (wgPath) { const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]); 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, dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath), sudoReady: sudoProbe.ok, interfaceName: MAC_WG_INTERFACE, 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, 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(", ")}`, `DNS = ${NORDVPN_MAC_DNS_SERVERS.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) { const message = `${down.stderr || down.stdout || down.error}`.toLowerCase(); if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) { // Ignore only the common no-active-interface case. } } 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"); } writeJsonFile(LAST_CONNECTION_PATH, { backend: "wireguard", interfaceName: MAC_WG_INTERFACE, requestedTarget: target, resolvedTarget: { country: targetMeta.country.name, city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer), }, server: { hostname: selectedServer.hostname, city: getServerCityName(selectedServer), country: getServerCountryName(selectedServer), load: selectedServer.load, }, connectedAt: new Date().toISOString(), }); return { backend: "wireguard", server: { 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 (!installProbe.wireguard.active) { const tailscale = await resumeMacTailscaleIfNeeded(); return { backend: "wireguard", changed: false, tailscaleRestored: tailscale.restored, message: "No active macOS WireGuard NordVPN connection found.", }; } const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { throw new Error((down.stderr || down.stdout || down.error).trim() || "wg-quick down failed"); } const tailscale = await resumeMacTailscaleIfNeeded(); return { backend: "wireguard", changed: true, 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 action = args._[0]; if (!action || args.help) { printJson(usage(), action ? 0 : 1, !action); } const platform = detectPlatform(); if (!["darwin", "linux"].includes(platform)) { printJson({ error: `Unsupported platform: ${platform}` }, 1, true); } const installProbe = await probeInstallation(platform); try { if (action === "status") { const ipInfo = await getPublicIpInfo(); printJson(buildStateSummary(installProbe, ipInfo)); } if (action === "install") { const result = await installNordvpn(installProbe); const refreshed = await probeInstallation(platform); printJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } if (action === "login") { const result = await loginNordvpn(installProbe); const refreshed = await probeInstallation(platform); printJson({ 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); printJson( { action, requestedTarget: target, verified: verified.ok, verification: verified.ipInfo, state: buildStateSummary(refreshed, verified.ipInfo), }, verified.ok ? 0 : 1, !verified.ok ); } if (action === "connect") { const target = buildConnectTarget(args); let connectResult; 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) { printJson({ action, requestedTarget: target, ...connectResult }); } const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 }); const refreshed = await probeInstallation(platform); printJson( { action, requestedTarget: target, connectResult, verified: verified.ok, verification: verified.ipInfo, state: buildStateSummary(refreshed, verified.ipInfo), }, verified.ok ? 0 : 1, !verified.ok ); } if (action === "disconnect") { const result = await disconnectNordvpn(installProbe); const refreshed = await probeInstallation(platform); printJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } printJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); } catch (error) { printJson({ error: error.message || String(error), action }, 1, true); } } main();