|
|
|
|
@@ -6,6 +6,16 @@ const os = require("node:os");
|
|
|
|
|
const path = require("node:path");
|
|
|
|
|
const https = require("node:https");
|
|
|
|
|
|
|
|
|
|
const MAC_WG_INTERFACE = "nordvpnctl";
|
|
|
|
|
const STATE_DIR = path.join(os.homedir(), ".nordvpn-client");
|
|
|
|
|
const WG_STATE_DIR = path.join(STATE_DIR, "wireguard");
|
|
|
|
|
const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`);
|
|
|
|
|
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
|
|
|
|
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
|
|
|
|
|
const DEFAULT_DNS_IPV4 = "103.86.96.100";
|
|
|
|
|
const DEFAULT_DNS_IPV6 = "2400:bb40:4444::100";
|
|
|
|
|
const CLIENT_IPV4 = "10.5.0.2";
|
|
|
|
|
|
|
|
|
|
function printJson(payload, exitCode = 0, errorStream = false) {
|
|
|
|
|
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
|
|
|
|
(errorStream ? process.stderr : process.stdout).write(body);
|
|
|
|
|
@@ -78,6 +88,15 @@ 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) {
|
|
|
|
|
@@ -101,6 +120,43 @@ function fileExists(target) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
@@ -131,7 +187,7 @@ function sleep(ms) {
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fetchJson(url) {
|
|
|
|
|
function fetchJson(url, headers = {}) {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const req = https.get(
|
|
|
|
|
url,
|
|
|
|
|
@@ -139,6 +195,7 @@ function fetchJson(url) {
|
|
|
|
|
headers: {
|
|
|
|
|
"User-Agent": "nordvpn-client-skill/1.0",
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
...headers,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
(res) => {
|
|
|
|
|
@@ -161,7 +218,7 @@ function fetchJson(url) {
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
req.on("error", (error) => resolve({ ok: false, error: error.message }));
|
|
|
|
|
req.setTimeout(10000, () => {
|
|
|
|
|
req.setTimeout(15000, () => {
|
|
|
|
|
req.destroy(new Error("timeout"));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
@@ -180,6 +237,8 @@ async function getPublicIpInfo() {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -187,10 +246,41 @@ async function probeCliStatus(cliPath) {
|
|
|
|
|
const status = await runExec(cliPath, ["status"]);
|
|
|
|
|
const account = await runExec(cliPath, ["account"]);
|
|
|
|
|
const countries = await runExec(cliPath, ["countries"]);
|
|
|
|
|
return { status, account, countries };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function probeMacWireguard() {
|
|
|
|
|
const wgPath = commandExists("wg");
|
|
|
|
|
const wgQuickPath = commandExists("wg-quick");
|
|
|
|
|
const wireguardGoPath = commandExists("wireguard-go");
|
|
|
|
|
const sudoProbe = await runExec("sudo", ["-n", "true"]);
|
|
|
|
|
let active = false;
|
|
|
|
|
let showRaw = "";
|
|
|
|
|
let endpoint = "";
|
|
|
|
|
|
|
|
|
|
if (wgPath) {
|
|
|
|
|
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
|
|
|
|
active = show.ok;
|
|
|
|
|
showRaw = (show.stdout || show.stderr).trim();
|
|
|
|
|
if (show.ok) {
|
|
|
|
|
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
|
|
|
|
endpoint = (endpointResult.stdout || "").trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status,
|
|
|
|
|
account,
|
|
|
|
|
countries,
|
|
|
|
|
wgPath: wgPath || null,
|
|
|
|
|
wgQuickPath: wgQuickPath || null,
|
|
|
|
|
wireguardGoPath: wireguardGoPath || null,
|
|
|
|
|
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
|
|
|
|
sudoReady: sudoProbe.ok,
|
|
|
|
|
interfaceName: MAC_WG_INTERFACE,
|
|
|
|
|
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null,
|
|
|
|
|
active,
|
|
|
|
|
endpoint: endpoint || null,
|
|
|
|
|
showRaw,
|
|
|
|
|
authCache: readJsonFile(AUTH_CACHE_PATH),
|
|
|
|
|
lastConnection: readJsonFile(LAST_CONNECTION_PATH),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -198,38 +288,62 @@ 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,
|
|
|
|
|
installed: Boolean(cliPath) || Boolean(appPath && fileExists(appPath)),
|
|
|
|
|
tokenAvailable,
|
|
|
|
|
installed:
|
|
|
|
|
platform === "darwin"
|
|
|
|
|
? Boolean(cliPath) || Boolean(appPath && fileExists(appPath)) || Boolean(wireguard && wireguard.dependenciesReady)
|
|
|
|
|
: Boolean(cliPath),
|
|
|
|
|
cliProbe,
|
|
|
|
|
wireguard,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inferAuthState(probe) {
|
|
|
|
|
if (!probe || !probe.account) return null;
|
|
|
|
|
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) {
|
|
|
|
|
if (!probe || !probe.status) 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -247,13 +361,30 @@ function buildStateSummary(installProbe, ipInfo) {
|
|
|
|
|
loginMode = "cli";
|
|
|
|
|
connectMode = "cli";
|
|
|
|
|
recommendedAction = "Use login/connect/disconnect through the nordvpn CLI.";
|
|
|
|
|
} else if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
|
|
|
|
controlMode = "wireguard";
|
|
|
|
|
automaticControl = true;
|
|
|
|
|
loginMode = "wireguard-token";
|
|
|
|
|
connectMode = "wireguard";
|
|
|
|
|
recommendedAction = installProbe.tokenAvailable
|
|
|
|
|
? installProbe.wireguard.sudoReady
|
|
|
|
|
? "Use token-based WireGuard automation on macOS."
|
|
|
|
|
: "WireGuard tooling and token are available, but connect/disconnect require non-interactive sudo for wg-quick."
|
|
|
|
|
: "Set NORDVPN_TOKEN or NORDVPN_TOKEN_FILE for automated macOS NordLynx/WireGuard connects.";
|
|
|
|
|
} else if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
|
|
|
|
controlMode = "app-manual";
|
|
|
|
|
automaticControl = false;
|
|
|
|
|
loginMode = "app-manual";
|
|
|
|
|
connectMode = "app-manual";
|
|
|
|
|
recommendedAction =
|
|
|
|
|
"NordVPN is installed as a macOS app without a PATH-visible CLI. Use login/connect to open NordVPN.app and complete the action there.";
|
|
|
|
|
recommendedAction = installProbe.tokenAvailable
|
|
|
|
|
? "NordVPN.app is installed, but automated macOS connects also require wireguard-go and wireguard-tools. Run install to bootstrap them."
|
|
|
|
|
: "NordVPN.app is installed. Without a PATH-visible CLI or a NordVPN token plus WireGuard tools, login/connect fall back to the app.";
|
|
|
|
|
} else if (installProbe.platform === "darwin" && installProbe.brewPath) {
|
|
|
|
|
controlMode = "wireguard-bootstrap";
|
|
|
|
|
automaticControl = false;
|
|
|
|
|
loginMode = "wireguard-token";
|
|
|
|
|
connectMode = "wireguard";
|
|
|
|
|
recommendedAction = "Run install to bootstrap wireguard-go and wireguard-tools for automated macOS NordLynx connects.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
@@ -264,32 +395,62 @@ function buildStateSummary(installProbe, ipInfo) {
|
|
|
|
|
appInstalled: installProbe.appInstalled,
|
|
|
|
|
appPath: installProbe.appInstalled ? installProbe.appPath : null,
|
|
|
|
|
brewAvailable: Boolean(installProbe.brewPath),
|
|
|
|
|
tokenAvailable: installProbe.tokenAvailable,
|
|
|
|
|
controlMode,
|
|
|
|
|
automaticControl,
|
|
|
|
|
loginMode,
|
|
|
|
|
connectMode,
|
|
|
|
|
recommendedAction,
|
|
|
|
|
authenticated: inferAuthState(cliProbe),
|
|
|
|
|
connected: inferConnectionState(cliProbe),
|
|
|
|
|
authenticated: inferAuthState(cliProbe, installProbe),
|
|
|
|
|
connected: inferConnectionState(cliProbe, installProbe),
|
|
|
|
|
localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "",
|
|
|
|
|
publicIp: ipInfo.ok ? ipInfo : null,
|
|
|
|
|
wireguard: installProbe.wireguard
|
|
|
|
|
? {
|
|
|
|
|
dependenciesReady: installProbe.wireguard.dependenciesReady,
|
|
|
|
|
sudoReady: installProbe.wireguard.sudoReady,
|
|
|
|
|
interfaceName: installProbe.wireguard.interfaceName,
|
|
|
|
|
active: installProbe.wireguard.active,
|
|
|
|
|
configPath: installProbe.wireguard.configPath,
|
|
|
|
|
endpoint: installProbe.wireguard.endpoint,
|
|
|
|
|
wgPath: installProbe.wireguard.wgPath,
|
|
|
|
|
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
|
|
|
|
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
|
|
|
|
authCache: installProbe.wireguard.authCache,
|
|
|
|
|
lastConnection: installProbe.wireguard.lastConnection,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
publicIp: ipInfo && ipInfo.ok ? ipInfo : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function installNordvpn(installProbe) {
|
|
|
|
|
if (installProbe.installed) {
|
|
|
|
|
return { changed: false, message: "NordVPN is already installed." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin") {
|
|
|
|
|
if (!installProbe.brewPath) {
|
|
|
|
|
throw new Error("Homebrew is required on macOS to bootstrap NordVPN via brew cask.");
|
|
|
|
|
throw new Error("Homebrew is required on macOS to bootstrap wireguard-go and wireguard-tools.");
|
|
|
|
|
}
|
|
|
|
|
const ok = await runInteractive(installProbe.brewPath, ["install", "--cask", "nordvpn"]);
|
|
|
|
|
|
|
|
|
|
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 --cask nordvpn failed");
|
|
|
|
|
throw new Error(`brew install ${toInstall.join(" ")} failed`);
|
|
|
|
|
}
|
|
|
|
|
return { changed: true, message: "Installed NordVPN via Homebrew cask." };
|
|
|
|
|
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") {
|
|
|
|
|
@@ -313,40 +474,235 @@ async function openMacApp() {
|
|
|
|
|
if (!ok) throw new Error("Failed to open NordVPN.app");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loginNordvpn(installProbe, args) {
|
|
|
|
|
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]);
|
|
|
|
|
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.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed");
|
|
|
|
|
throw new Error(result.error || `NordVPN credentials request failed (${result.statusCode || "unknown"})`);
|
|
|
|
|
}
|
|
|
|
|
return { mode: "cli-token", message: "Logged in using token." };
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username && password && installProbe.platform === "darwin") {
|
|
|
|
|
// macOS CLI login support is not documented. Fall back to interactive CLI login.
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ok = await runInteractive(installProbe.cliPath, ["login"]);
|
|
|
|
|
if (!ok) throw new Error("nordvpn login failed");
|
|
|
|
|
return { mode: "cli-interactive", message: "Interactive NordVPN login completed." };
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
|
|
|
|
await openMacApp();
|
|
|
|
|
return {
|
|
|
|
|
mode: "app-manual",
|
|
|
|
|
manualActionRequired: true,
|
|
|
|
|
message:
|
|
|
|
|
"Opened NordVPN.app. Complete login in the app/browser flow, then rerun status or connect. macOS app login is browser-based according to NordVPN support docs.",
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error("NordVPN is not installed.");
|
|
|
|
|
async function fetchNordRecommendations(targetMeta, ipInfo) {
|
|
|
|
|
const url = new URL("https://api.nordvpn.com/v1/servers/recommendations");
|
|
|
|
|
const params = url.searchParams;
|
|
|
|
|
params.append("limit", targetMeta.city ? "100" : "25");
|
|
|
|
|
params.append("filters[servers.status]", "online");
|
|
|
|
|
params.append("filters[servers_technologies]", "35");
|
|
|
|
|
params.append("filters[servers_technologies][pivot][status]", "online");
|
|
|
|
|
params.append("fields[servers.hostname]", "1");
|
|
|
|
|
params.append("fields[servers.load]", "1");
|
|
|
|
|
params.append("fields[servers.station]", "1");
|
|
|
|
|
params.append("fields[servers.ips]", "1");
|
|
|
|
|
params.append("fields[servers.technologies.identifier]", "1");
|
|
|
|
|
params.append("fields[servers.technologies.metadata]", "1");
|
|
|
|
|
params.append("fields[servers.locations.country.name]", "1");
|
|
|
|
|
params.append("fields[servers.locations.country.city.name]", "1");
|
|
|
|
|
|
|
|
|
|
if (targetMeta.country) {
|
|
|
|
|
params.append("filters[country_id]", String(targetMeta.country.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const latitude = targetMeta.city ? targetMeta.city.latitude : ipInfo && ipInfo.latitude;
|
|
|
|
|
const longitude = targetMeta.city ? targetMeta.city.longitude : ipInfo && ipInfo.longitude;
|
|
|
|
|
if (Number.isFinite(Number(latitude)) && Number.isFinite(Number(longitude))) {
|
|
|
|
|
params.append("coordinates[latitude]", String(latitude));
|
|
|
|
|
params.append("coordinates[longitude]", String(longitude));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await fetchJson(url.toString());
|
|
|
|
|
if (!result.ok || result.statusCode >= 400 || !Array.isArray(result.json)) {
|
|
|
|
|
throw new Error(result.error || `NordVPN server recommendation request failed (${result.statusCode || "unknown"})`);
|
|
|
|
|
}
|
|
|
|
|
return result.json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getServerCityName(server) {
|
|
|
|
|
return (
|
|
|
|
|
server &&
|
|
|
|
|
Array.isArray(server.locations) &&
|
|
|
|
|
server.locations[0] &&
|
|
|
|
|
server.locations[0].country &&
|
|
|
|
|
server.locations[0].country.city &&
|
|
|
|
|
server.locations[0].country.city.name
|
|
|
|
|
) || "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getServerCountryName(server) {
|
|
|
|
|
return (
|
|
|
|
|
server &&
|
|
|
|
|
Array.isArray(server.locations) &&
|
|
|
|
|
server.locations[0] &&
|
|
|
|
|
server.locations[0].country &&
|
|
|
|
|
server.locations[0].country.name
|
|
|
|
|
) || "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getServerWireguardPublicKey(server) {
|
|
|
|
|
const tech = (server.technologies || []).find((item) => item.identifier === "wireguard_udp");
|
|
|
|
|
if (!tech) return "";
|
|
|
|
|
const metadata = (tech.metadata || []).find((item) => item.name === "public_key");
|
|
|
|
|
return metadata ? metadata.value : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getServerIpAddresses(server) {
|
|
|
|
|
return (server.ips || []).map((item) => item && item.ip && item.ip.ip).filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveClientIpv6(serverIpv6) {
|
|
|
|
|
const parts = `${serverIpv6}`.split(":");
|
|
|
|
|
if (parts.length < 4) return "";
|
|
|
|
|
const expanded = [];
|
|
|
|
|
let skipped = false;
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (!part && !skipped) {
|
|
|
|
|
const missing = 8 - (parts.filter(Boolean).length);
|
|
|
|
|
for (let i = 0; i < missing; i += 1) expanded.push("0000");
|
|
|
|
|
skipped = true;
|
|
|
|
|
} else if (part) {
|
|
|
|
|
expanded.push(part.padStart(4, "0"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while (expanded.length < 8) expanded.push("0000");
|
|
|
|
|
expanded[4] = "0000";
|
|
|
|
|
expanded[5] = "0011";
|
|
|
|
|
expanded[6] = "0005";
|
|
|
|
|
expanded[7] = "0002";
|
|
|
|
|
return expanded.map((item) => item.replace(/^0+(?=[0-9a-f])/i, "") || "0").join(":");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildWireguardConfig(server, privateKey) {
|
|
|
|
|
const ipAddresses = getServerIpAddresses(server);
|
|
|
|
|
const ipv6 = ipAddresses.find((value) => `${value}`.includes(":"));
|
|
|
|
|
const addresses = [CLIENT_IPV4];
|
|
|
|
|
const dnsServers = [DEFAULT_DNS_IPV4];
|
|
|
|
|
const allowedIps = ["0.0.0.0/0"];
|
|
|
|
|
|
|
|
|
|
if (ipv6) {
|
|
|
|
|
const clientIpv6 = deriveClientIpv6(ipv6);
|
|
|
|
|
if (clientIpv6) addresses.push(clientIpv6);
|
|
|
|
|
dnsServers.push(DEFAULT_DNS_IPV6);
|
|
|
|
|
allowedIps.push("::/0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const publicKey = getServerWireguardPublicKey(server);
|
|
|
|
|
if (!publicKey) {
|
|
|
|
|
throw new Error(`Server ${server.hostname} does not expose a WireGuard public key.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
"[Interface]",
|
|
|
|
|
`PrivateKey = ${privateKey}`,
|
|
|
|
|
`Address = ${addresses.join(", ")}`,
|
|
|
|
|
`DNS = ${dnsServers.join(", ")}`,
|
|
|
|
|
"",
|
|
|
|
|
"[Peer]",
|
|
|
|
|
`PublicKey = ${publicKey}`,
|
|
|
|
|
`AllowedIPs = ${allowedIps.join(", ")}`,
|
|
|
|
|
`Endpoint = ${server.hostname}:51820`,
|
|
|
|
|
"PersistentKeepalive = 25",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildConnectTarget(args) {
|
|
|
|
|
@@ -360,8 +716,8 @@ function buildConnectTarget(args) {
|
|
|
|
|
|
|
|
|
|
function locationMatches(ipInfo, target) {
|
|
|
|
|
if (!ipInfo || !ipInfo.ok) return false;
|
|
|
|
|
const countryMatch = !target.country || ipInfo.country.toLowerCase().includes(target.country.toLowerCase());
|
|
|
|
|
const cityMatch = !target.city || ipInfo.city.toLowerCase().includes(target.city.toLowerCase());
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -375,21 +731,15 @@ async function verifyConnection(target) {
|
|
|
|
|
|
|
|
|
|
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}`]);
|
|
|
|
|
}
|
|
|
|
|
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 args of attempts) {
|
|
|
|
|
const result = await runExec(cliPath, ["connect", ...args]);
|
|
|
|
|
for (const attemptArgs of attempts) {
|
|
|
|
|
const result = await runExec(cliPath, ["connect", ...attemptArgs]);
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
return { cliTarget: args.join(" "), raw: result.stdout.trim() || result.stderr.trim() };
|
|
|
|
|
return { backend: "cli", cliTarget: attemptArgs.join(" "), raw: result.stdout.trim() || result.stderr.trim() };
|
|
|
|
|
}
|
|
|
|
|
lastFailure = (result.stderr || result.stdout || result.error).trim();
|
|
|
|
|
}
|
|
|
|
|
@@ -397,9 +747,105 @@ async function connectViaCli(cliPath, target) {
|
|
|
|
|
throw new Error(lastFailure || "NordVPN connect failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runSudoWireguard(installProbe, action) {
|
|
|
|
|
const wgQuickPath = installProbe.wireguard && installProbe.wireguard.wgQuickPath;
|
|
|
|
|
if (!wgQuickPath) throw new Error("wg-quick is not installed.");
|
|
|
|
|
if (!installProbe.wireguard.sudoReady) {
|
|
|
|
|
throw new Error("Non-interactive sudo is required for macOS WireGuard connect/disconnect. Authorize sudo first, then retry.");
|
|
|
|
|
}
|
|
|
|
|
return runExec("sudo", ["-n", "env", `PATH=${process.env.PATH || ""}`, wgQuickPath, action, WG_CONFIG_PATH]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function connectViaMacWireguard(installProbe, target) {
|
|
|
|
|
const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE");
|
|
|
|
|
if (!token) {
|
|
|
|
|
throw new Error("macOS NordLynx/WireGuard automation requires NORDVPN_TOKEN or NORDVPN_TOKEN_FILE.");
|
|
|
|
|
}
|
|
|
|
|
if (!installProbe.wireguard || !installProbe.wireguard.dependenciesReady) {
|
|
|
|
|
throw new Error("wireguard-go and wireguard-tools are required on macOS. Run install first.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ipInfo = await getPublicIpInfo();
|
|
|
|
|
const countries = await fetchNordCountries();
|
|
|
|
|
const targetMeta = resolveTargetMetadata(countries, target);
|
|
|
|
|
if (!targetMeta.country) {
|
|
|
|
|
throw new Error("A country is required to select a NordVPN WireGuard server.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const credentials = await fetchNordCredentials(token);
|
|
|
|
|
const recommendations = await fetchNordRecommendations(targetMeta, ipInfo);
|
|
|
|
|
let candidates = recommendations.filter((server) => Boolean(getServerWireguardPublicKey(server)));
|
|
|
|
|
if (targetMeta.city) {
|
|
|
|
|
candidates = candidates.filter(
|
|
|
|
|
(server) => normalizeForMatch(getServerCityName(server)) === normalizeForMatch(targetMeta.city.name)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!candidates.length) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
targetMeta.city
|
|
|
|
|
? `No WireGuard-capable NordVPN server found for ${targetMeta.city.name}, ${targetMeta.country.name}.`
|
|
|
|
|
: `No WireGuard-capable NordVPN server found for ${targetMeta.country.name}.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
candidates.sort((a, b) => (a.load || 999) - (b.load || 999));
|
|
|
|
|
const selectedServer = candidates[0];
|
|
|
|
|
const config = buildWireguardConfig(selectedServer, credentials.nordlynx_private_key);
|
|
|
|
|
ensureDir(WG_STATE_DIR);
|
|
|
|
|
writeTextFile(WG_CONFIG_PATH, config, 0o600);
|
|
|
|
|
|
|
|
|
|
const down = await runSudoWireguard(installProbe, "down");
|
|
|
|
|
if (!down.ok) {
|
|
|
|
|
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase();
|
|
|
|
|
if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) {
|
|
|
|
|
// Ignore only the common no-active-interface case.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const up = await runSudoWireguard(installProbe, "up");
|
|
|
|
|
if (!up.ok) {
|
|
|
|
|
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJsonFile(LAST_CONNECTION_PATH, {
|
|
|
|
|
backend: "wireguard",
|
|
|
|
|
interfaceName: MAC_WG_INTERFACE,
|
|
|
|
|
requestedTarget: target,
|
|
|
|
|
resolvedTarget: {
|
|
|
|
|
country: targetMeta.country.name,
|
|
|
|
|
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
|
|
|
|
|
},
|
|
|
|
|
server: {
|
|
|
|
|
hostname: selectedServer.hostname,
|
|
|
|
|
city: getServerCityName(selectedServer),
|
|
|
|
|
country: getServerCountryName(selectedServer),
|
|
|
|
|
load: selectedServer.load,
|
|
|
|
|
},
|
|
|
|
|
connectedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
backend: "wireguard",
|
|
|
|
|
server: {
|
|
|
|
|
hostname: selectedServer.hostname,
|
|
|
|
|
city: getServerCityName(selectedServer),
|
|
|
|
|
country: getServerCountryName(selectedServer),
|
|
|
|
|
load: selectedServer.load,
|
|
|
|
|
},
|
|
|
|
|
requestedTarget: target,
|
|
|
|
|
resolvedTarget: {
|
|
|
|
|
country: targetMeta.country.name,
|
|
|
|
|
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
|
|
|
|
|
},
|
|
|
|
|
interfaceName: MAC_WG_INTERFACE,
|
|
|
|
|
raw: up.stdout.trim() || up.stderr.trim(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function connectViaMacApp(target) {
|
|
|
|
|
await openMacApp();
|
|
|
|
|
return {
|
|
|
|
|
backend: "app-manual",
|
|
|
|
|
manualActionRequired: true,
|
|
|
|
|
message: `Opened NordVPN.app. Connect manually to ${target.city ? `${target.city}, ` : ""}${target.country || "the requested location"} and rerun status/verify before the follow-up task.`,
|
|
|
|
|
};
|
|
|
|
|
@@ -411,14 +857,82 @@ async function disconnectNordvpn(installProbe) {
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed");
|
|
|
|
|
}
|
|
|
|
|
return { message: "Disconnected from NordVPN." };
|
|
|
|
|
return { backend: "cli", message: "Disconnected from NordVPN." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
|
|
|
|
if (!installProbe.wireguard.active) {
|
|
|
|
|
return { backend: "wireguard", changed: false, message: "No active macOS WireGuard NordVPN connection found." };
|
|
|
|
|
}
|
|
|
|
|
const down = await runSudoWireguard(installProbe, "down");
|
|
|
|
|
if (!down.ok) {
|
|
|
|
|
throw new Error((down.stderr || down.stdout || down.error).trim() || "wg-quick down failed");
|
|
|
|
|
}
|
|
|
|
|
return { backend: "wireguard", changed: true, message: "Disconnected the macOS NordLynx/WireGuard session." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
|
|
|
|
await openMacApp();
|
|
|
|
|
return {
|
|
|
|
|
backend: "app-manual",
|
|
|
|
|
manualActionRequired: true,
|
|
|
|
|
message: "Opened NordVPN.app. Disconnect manually because no usable CLI was detected on macOS.",
|
|
|
|
|
message: "Opened NordVPN.app. Disconnect manually because no automated control backend is available.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error("NordVPN is not installed.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loginNordvpn(installProbe) {
|
|
|
|
|
const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE");
|
|
|
|
|
const username = process.env.NORDVPN_USERNAME || "";
|
|
|
|
|
const password = readSecret("NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE");
|
|
|
|
|
|
|
|
|
|
if (installProbe.cliPath) {
|
|
|
|
|
if (token) {
|
|
|
|
|
const result = await runExec(installProbe.cliPath, ["login", "--token", token]);
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed");
|
|
|
|
|
}
|
|
|
|
|
return { mode: "cli-token", backend: "cli", message: "Logged in using token." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username && password && installProbe.platform === "darwin") {
|
|
|
|
|
// macOS CLI login support is not documented. Fall back to interactive CLI login.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ok = await runInteractive(installProbe.cliPath, ["login"]);
|
|
|
|
|
if (!ok) throw new Error("nordvpn login failed");
|
|
|
|
|
return { mode: "cli-interactive", backend: "cli", message: "Interactive NordVPN login completed." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin" && token) {
|
|
|
|
|
const credentials = await fetchNordCredentials(token);
|
|
|
|
|
const cache = {
|
|
|
|
|
backend: "wireguard",
|
|
|
|
|
validatedAt: new Date().toISOString(),
|
|
|
|
|
hasNordlynxPrivateKey: Boolean(credentials.nordlynx_private_key),
|
|
|
|
|
tokenSource: process.env.NORDVPN_TOKEN ? "env:NORDVPN_TOKEN" : process.env.NORDVPN_TOKEN_FILE ? "file:NORDVPN_TOKEN_FILE" : "unknown",
|
|
|
|
|
};
|
|
|
|
|
writeJsonFile(AUTH_CACHE_PATH, cache);
|
|
|
|
|
return {
|
|
|
|
|
mode: "wireguard-token",
|
|
|
|
|
backend: "wireguard",
|
|
|
|
|
message: installProbe.wireguard && installProbe.wireguard.dependenciesReady
|
|
|
|
|
? "Validated NordVPN token for the macOS NordLynx/WireGuard backend."
|
|
|
|
|
: "Validated NordVPN token. Run install to bootstrap wireguard-go and wireguard-tools before connecting.",
|
|
|
|
|
auth: cache,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
|
|
|
|
await openMacApp();
|
|
|
|
|
return {
|
|
|
|
|
mode: "app-manual",
|
|
|
|
|
backend: "app-manual",
|
|
|
|
|
manualActionRequired: true,
|
|
|
|
|
message:
|
|
|
|
|
"Opened NordVPN.app. Complete login in the app/browser flow, or set NORDVPN_TOKEN / NORDVPN_TOKEN_FILE to use the automated macOS WireGuard backend.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -456,7 +970,7 @@ async function main() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === "login") {
|
|
|
|
|
const result = await loginNordvpn(installProbe, args);
|
|
|
|
|
const result = await loginNordvpn(installProbe);
|
|
|
|
|
const refreshed = await probeInstallation(platform);
|
|
|
|
|
printJson({
|
|
|
|
|
action,
|
|
|
|
|
@@ -469,25 +983,35 @@ async function main() {
|
|
|
|
|
const target = args.country || args.city ? buildConnectTarget(args) : null;
|
|
|
|
|
const verified = await verifyConnection(target);
|
|
|
|
|
const refreshed = await probeInstallation(platform);
|
|
|
|
|
printJson({
|
|
|
|
|
printJson(
|
|
|
|
|
{
|
|
|
|
|
action,
|
|
|
|
|
requestedTarget: target,
|
|
|
|
|
verified: verified.ok,
|
|
|
|
|
verification: verified.ipInfo,
|
|
|
|
|
state: buildStateSummary(refreshed, verified.ipInfo),
|
|
|
|
|
}, verified.ok ? 0 : 1, !verified.ok);
|
|
|
|
|
},
|
|
|
|
|
verified.ok ? 0 : 1,
|
|
|
|
|
!verified.ok
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === "connect") {
|
|
|
|
|
const target = buildConnectTarget(args);
|
|
|
|
|
if (!installProbe.installed) {
|
|
|
|
|
throw new Error("NordVPN is not installed.");
|
|
|
|
|
}
|
|
|
|
|
let connectResult;
|
|
|
|
|
|
|
|
|
|
if (installProbe.cliPath) {
|
|
|
|
|
connectResult = await connectViaCli(installProbe.cliPath, target);
|
|
|
|
|
} else if (platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady && installProbe.tokenAvailable) {
|
|
|
|
|
connectResult = await connectViaMacWireguard(installProbe, target);
|
|
|
|
|
} else if (platform === "darwin" && installProbe.appInstalled) {
|
|
|
|
|
connectResult = await connectViaMacApp(target);
|
|
|
|
|
} else if (platform === "darwin" && !installProbe.tokenAvailable) {
|
|
|
|
|
throw new Error("macOS automated NordLynx/WireGuard connects require NORDVPN_TOKEN or NORDVPN_TOKEN_FILE.");
|
|
|
|
|
} else if (platform === "darwin") {
|
|
|
|
|
throw new Error("No usable macOS NordVPN backend is ready. Run install to bootstrap WireGuard tooling.");
|
|
|
|
|
} else if (!installProbe.installed) {
|
|
|
|
|
throw new Error("NordVPN is not installed.");
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error("No usable NordVPN control path found.");
|
|
|
|
|
}
|
|
|
|
|
@@ -499,14 +1023,18 @@ async function main() {
|
|
|
|
|
await sleep(3000);
|
|
|
|
|
const verified = await verifyConnection(target);
|
|
|
|
|
const refreshed = await probeInstallation(platform);
|
|
|
|
|
printJson({
|
|
|
|
|
printJson(
|
|
|
|
|
{
|
|
|
|
|
action,
|
|
|
|
|
requestedTarget: target,
|
|
|
|
|
connectResult,
|
|
|
|
|
verified: verified.ok,
|
|
|
|
|
verification: verified.ipInfo,
|
|
|
|
|
state: buildStateSummary(refreshed, verified.ipInfo),
|
|
|
|
|
}, verified.ok ? 0 : 1, !verified.ok);
|
|
|
|
|
},
|
|
|
|
|
verified.ok ? 0 : 1,
|
|
|
|
|
!verified.ok
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === "disconnect") {
|
|
|
|
|
|