#!/usr/bin/env node const { execFile, spawn } = require("node:child_process"); const fs = require("node:fs"); 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 DEFAULT_DNS_IPV4 = "103.86.96.100"; const DEFAULT_DNS_IPV6 = "2400:bb40:4444::100"; const CLIENT_IPV4 = "10.5.0.2"; 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", "NORDVPN_USERNAME", "NORDVPN_PASSWORD", "NORDVPN_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 filePath = process.env[fileEnvName]; if (!filePath) return ""; try { return fs.readFileSync(filePath, "utf8").trim(); } catch { 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. } } 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 fetchJson(url, headers = {}) { return new Promise((resolve) => { const req = https.get( url, { headers: { "User-Agent": "nordvpn-client-skill/1.0", Accept: "application/json", ...headers, }, }, (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 sudoProbe = await runExec("sudo", ["-n", "true"]); let active = false; let showRaw = ""; let endpoint = ""; 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(); } } return { wgPath: wgPath || null, wgQuickPath: wgQuickPath || null, wireguardGoPath: wireguardGoPath || null, 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, 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 wg-quick." : "Set NORDVPN_TOKEN or NORDVPN_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 install to bootstrap them." : "NordVPN.app is installed. Without a PATH-visible CLI or a NordVPN token plus WireGuard tools, login/connect fall back to the app."; } else if (installProbe.platform === "darwin" && installProbe.brewPath) { controlMode = "wireguard-bootstrap"; automaticControl = false; loginMode = "wireguard-token"; connectMode = "wireguard"; recommendedAction = "Run install to bootstrap wireguard-go and wireguard-tools 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, 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 = "TMP=$(mktemp) && " + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " + "elif command -v wget >/dev/null 2>&1; then wget -qO \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " + "else echo 'curl or wget required' >&2; exit 1; fi && sh \"$TMP\" && rm -f \"$TMP\""; 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 dnsServers = [DEFAULT_DNS_IPV4]; const allowedIps = ["0.0.0.0/0"]; if (ipv6) { const clientIpv6 = deriveClientIpv6(ipv6); if (clientIpv6) addresses.push(clientIpv6); dnsServers.push(DEFAULT_DNS_IPV6); 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 = ${dnsServers.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 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 wgQuickPath = installProbe.wireguard && installProbe.wireguard.wgQuickPath; if (!wgQuickPath) throw new Error("wg-quick is not installed."); if (!installProbe.wireguard.sudoReady) { throw new Error("Non-interactive sudo is required for macOS WireGuard connect/disconnect. Authorize sudo first, then retry."); } return runExec("sudo", ["-n", "env", `PATH=${process.env.PATH || ""}`, wgQuickPath, action, WG_CONFIG_PATH]); } 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 or NORDVPN_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); 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) { 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, 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) { return { backend: "wireguard", changed: false, 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"); } return { backend: "wireguard", changed: true, 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" : "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 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 verifyConnection(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 or NORDVPN_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 }); } await sleep(3000); const verified = await verifyConnection(target); 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();