Files

2143 lines
71 KiB
JavaScript

#!/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();