Files
stef-openclaw-skills/skills/nordvpn-client/scripts/nordvpn-client.js
2026-03-12 02:22:50 -05:00

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