1763 lines
59 KiB
JavaScript
1763 lines
59 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 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 REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]);
|
|
|
|
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 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 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);
|
|
}
|
|
|
|
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() {
|
|
const snapshot = readJsonFile(MAC_DNS_STATE_PATH) || (await snapshotMacDnsState());
|
|
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 };
|
|
}
|
|
for (const service of snapshot.services) {
|
|
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) {
|
|
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);
|
|
let currentStatus = null;
|
|
try {
|
|
currentStatus = await getMacTailscaleStatus();
|
|
} catch {
|
|
currentStatus = null;
|
|
}
|
|
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
|
|
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");
|
|
}
|
|
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 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 shouldFinalizeMacWireguardConnect(connectResult, verified) {
|
|
return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok);
|
|
}
|
|
|
|
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 };
|
|
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,
|
|
helperSecurity,
|
|
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
|
|
? installProbe.wireguard.helperSecurity && !installProbe.wireguard.helperSecurity.hardened
|
|
? `WireGuard tooling is available, but the helper at ${MAC_WG_HELPER_PATH} is not hardened yet: ${installProbe.wireguard.helperSecurity.reason}`
|
|
: "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 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) {
|
|
emitJson({ action, requestedTarget: target, ...connectResult });
|
|
}
|
|
|
|
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
|
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);
|
|
emitJson(
|
|
{
|
|
action,
|
|
requestedTarget: target,
|
|
connectResult,
|
|
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)) {
|
|
try {
|
|
await snapshotMacDnsState();
|
|
await applyMacNordDns();
|
|
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));
|
|
emitJson(
|
|
{
|
|
action,
|
|
requestedTarget: target,
|
|
connectResult,
|
|
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
|
|
);
|
|
emitJson(
|
|
{
|
|
action,
|
|
requestedTarget: target,
|
|
connectResult,
|
|
verified: verified.ok,
|
|
verification: verified.ipInfo,
|
|
state: connectState,
|
|
},
|
|
verified.ok ? 0 : 1,
|
|
!verified.ok
|
|
);
|
|
}
|
|
|
|
if (action === "disconnect") {
|
|
const result = await disconnectNordvpn(installProbe);
|
|
const refreshed = await probeInstallation(platform);
|
|
emitJson({
|
|
action,
|
|
...result,
|
|
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
|
});
|
|
}
|
|
|
|
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();
|