From 4a539a33c9b5d54e5631a9fd3d2e929daa72d3f8 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Wed, 11 Mar 2026 23:44:22 -0500 Subject: [PATCH] feat: add mac wireguard nordvpn backend --- README.md | 2 +- docs/README.md | 2 +- docs/nordvpn-client.md | 29 +- ...26-03-11-nordvpn-wireguard-macos-design.md | 34 + .../2026-03-11-nordvpn-wireguard-macos.md | 11 + skills/nordvpn-client/SKILL.md | 27 +- .../nordvpn-client/scripts/nordvpn-client.js | 714 +++++++++++++++--- 7 files changed, 700 insertions(+), 119 deletions(-) create mode 100644 docs/plans/2026-03-11-nordvpn-wireguard-macos-design.md create mode 100644 docs/plans/2026-03-11-nordvpn-wireguard-macos.md diff --git a/README.md b/README.md index 6497e9c..e4710fa 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This repository contains practical OpenClaw skills and companion integrations. I |---|---|---| | `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` | | `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` | -| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions on macOS and Linux. | `skills/nordvpn-client` | +| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` | | `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` | | `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` | | `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` | diff --git a/docs/README.md b/docs/README.md index 71027a3..339baf3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ This folder contains detailed docs for each skill in this repository. - [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text - [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required) -- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification +- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support - [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning) - [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance - [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping diff --git a/docs/nordvpn-client.md b/docs/nordvpn-client.md index dc430eb..641ecbc 100644 --- a/docs/nordvpn-client.md +++ b/docs/nordvpn-client.md @@ -4,9 +4,9 @@ Cross-platform NordVPN lifecycle skill for macOS and Linux. ## What it does -- Probes whether NordVPN is already installed -- Bootstraps NordVPN if missing -- Handles login bootstrap +- Probes whether NordVPN is already installed or automation-ready +- Bootstraps the required backend if missing +- Handles login/bootstrap - Connects to a country or city target - Disconnects and reports status - Verifies public IP and geolocation after connect @@ -29,12 +29,15 @@ node skills/nordvpn-client/scripts/nordvpn-client.js disconnect ### macOS -- install path: `brew install --cask nordvpn` -- probe order: - - `nordvpn` CLI if present - - `NordVPN.app` -- if the installed package exposes a usable CLI, the skill uses it -- otherwise it opens the app and returns a clear manual-action-required result for login/connect/disconnect +- preferred backend: NordLynx/WireGuard +- install path: `brew install wireguard-go wireguard-tools` +- automation requirements: + - `NORDVPN_TOKEN` or `NORDVPN_TOKEN_FILE` + - `wireguard-go` + - `wireguard-tools` + - non-interactive `sudo` for `wg-quick` +- `NordVPN.app` may stay installed but is only the manual fallback +- the app login is not reused by the automated WireGuard backend ### Linux @@ -60,7 +63,7 @@ Do not put secrets in the skill docs or repo. - platform - install state -- control mode (`cli` vs `app-manual`) +- control mode (`cli`, `wireguard`, `app-manual`) - auth state - connection state - requested target @@ -74,6 +77,6 @@ After `connect`, the intended workflow is: ## Limitations -- Linux city targeting is attempted through the CLI target string and then validated by public IP/location checks. -- macOS app-only fallback cannot guarantee non-interactive control if the app does not expose a CLI. -- On macOS, the Homebrew cask may install only the GUI app. That is still a supported install state; `status` reports `controlMode: "app-manual"` so agents should continue with the app flow instead of concluding NordVPN is unavailable. +- Linux behavior still depends on the official `nordvpn` CLI. +- macOS automated connects require token-based WireGuard setup; GUI-app login alone is insufficient. +- The Homebrew `nordvpn` app does not need to be uninstalled. diff --git a/docs/plans/2026-03-11-nordvpn-wireguard-macos-design.md b/docs/plans/2026-03-11-nordvpn-wireguard-macos-design.md new file mode 100644 index 0000000..979a9bb --- /dev/null +++ b/docs/plans/2026-03-11-nordvpn-wireguard-macos-design.md @@ -0,0 +1,34 @@ +# NordVPN macOS WireGuard Backend Design + +## Goal +Replace the current macOS app-manual fallback in `nordvpn-client` with a scripted WireGuard/NordLynx backend inspired by `wg-nord` and `wgnord`, while preserving the official Linux `nordvpn` CLI backend. + +## Key decisions +- Keep Linux on the official `nordvpn` CLI. +- Prefer a native macOS WireGuard backend over the GUI app. +- Do not vendor third-party scripts directly; reimplement the needed logic in our own JSON-based Node skill. +- Do not require uninstalling the Homebrew `nordvpn` app. The new backend can coexist with it. + +## macOS backend model +- Bootstrap via Homebrew: + - `wireguard-tools` + - `wireguard-go` +- Read NordVPN token from existing env/file inputs. +- Discover a WireGuard-capable NordVPN server via the public Nord API. +- Generate a private key locally. +- Exchange the private key for Nord-provided interface credentials using the token. +- Materialize a temporary WireGuard config under a skill-owned state directory. +- Connect and disconnect via `wg-quick`. +- Verify with public IP/geolocation after connect. + +## Data/state +- Keep state under a skill-owned directory in the user's home, not `/etc`. +- Persist only what is needed for reconnect/disconnect/status. +- Never store secrets in docs. + +## Rollout +1. Implement the macOS WireGuard backend in the skill. +2. Update status output so backend selection is explicit. +3. Update skill docs and repo docs. +4. Verify non-destructive flows on this host. +5. Commit, push, and then decide whether to run a live connect test. diff --git a/docs/plans/2026-03-11-nordvpn-wireguard-macos.md b/docs/plans/2026-03-11-nordvpn-wireguard-macos.md new file mode 100644 index 0000000..ee9439e --- /dev/null +++ b/docs/plans/2026-03-11-nordvpn-wireguard-macos.md @@ -0,0 +1,11 @@ +# NordVPN macOS WireGuard Backend Plan + +1. Add a backend selector to `nordvpn-client`. +2. Keep Linux CLI behavior unchanged. +3. Add macOS WireGuard dependency probing and install guidance. +4. Implement token-based NordLynx config generation inspired by `wg-nord`/`wgnord`. +5. Replace the current preferred macOS control mode from `app-manual` to WireGuard when dependencies and token are available. +6. Keep app-manual as the last fallback only. +7. Update `status`, `login`, `connect`, `disconnect`, and `verify` JSON to expose the backend in use. +8. Update repo docs and skill docs to reflect the new model and required token/dependencies. +9. Verify command behavior locally without forcing a live VPN connection unless requested. diff --git a/skills/nordvpn-client/SKILL.md b/skills/nordvpn-client/SKILL.md index b547289..0e72c60 100644 --- a/skills/nordvpn-client/SKILL.md +++ b/skills/nordvpn-client/SKILL.md @@ -9,9 +9,9 @@ Cross-platform NordVPN lifecycle management for macOS and Linux hosts. ## What This Skill Is For -- Probing whether NordVPN is already installed -- Bootstrapping NordVPN if it is missing -- Logging in through the Linux CLI or the macOS app/CLI path +- Probing whether NordVPN is already installed or automation-ready +- Bootstrapping the required NordVPN backend if it is missing +- Logging in through the Linux CLI or validating a NordVPN token for the macOS WireGuard backend - Connecting to a country or city before a follow-up action such as `web-automation` - Disconnecting and checking VPN status - Verifying public IP and geolocation after connect @@ -38,9 +38,10 @@ node scripts/nordvpn-client.js disconnect - install path follows NordVPN's Linux installer - token login is supported through `NORDVPN_TOKEN` - macOS: - - prefers Homebrew cask install: `brew install --cask nordvpn` - - prefers a usable `nordvpn` CLI if the installed package exposes one - - otherwise falls back to opening the NordVPN app and guiding the manual login/connect path + - preferred backend is NordLynx/WireGuard using `wireguard-go` and `wireguard-tools` + - `install` bootstraps those tools with Homebrew + - `login` validates `NORDVPN_TOKEN` / `NORDVPN_TOKEN_FILE` for the WireGuard backend + - `NordVPN.app` can remain installed, but it is only the manual fallback ## Credentials @@ -63,7 +64,7 @@ Optional credential file env vars: - platform - install state -- control mode (`cli` vs `app-manual`) +- control mode (`cli`, `wireguard`, `app-manual`) - auth state - connection state - requested target @@ -74,7 +75,11 @@ Use `verify` when you want an explicit post-connect location check without chang ## Known Boundaries -- Linux country connect is official CLI behavior. -- Linux city connect is attempted through the CLI target string and then validated by post-connect IP/location checks. -- macOS app-only fallback cannot guarantee non-interactive login/connect if the installed app does not expose a CLI. In that case the skill will open the app and return a clear manual-action-required result. -- On macOS, Homebrew can install only the GUI app. That still counts as a supported install state; `status` will report `controlMode: "app-manual"` rather than treating the app as missing. +- Linux country/city connect remains whatever the official `nordvpn` CLI supports. +- macOS automated connects require all of: + - `NORDVPN_TOKEN` or `NORDVPN_TOKEN_FILE` + - `wireguard-go` + - `wireguard-tools` + - non-interactive `sudo` for `wg-quick` +- `NordVPN.app` login on macOS is not reused by the WireGuard backend. +- The Homebrew `nordvpn` app does not need to be uninstalled. It can coexist with the WireGuard backend. diff --git a/skills/nordvpn-client/scripts/nordvpn-client.js b/skills/nordvpn-client/scripts/nordvpn-client.js index e73e466..9952ef2 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -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; - 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; +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; - 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; +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"); +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; +} - 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"); +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}.`); } - return { mode: "cli-token", message: "Logged in using token." }; + 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 (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", message: "Interactive NordVPN login completed." }; } - 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.", - }; + 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)); } - throw new Error("NordVPN is not installed."); + 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({ - action, - requestedTarget: target, - verified: verified.ok, - verification: verified.ipInfo, - state: buildStateSummary(refreshed, verified.ipInfo), - }, verified.ok ? 0 : 1, !verified.ok); + printJson( + { + action, + requestedTarget: target, + verified: verified.ok, + verification: verified.ipInfo, + state: buildStateSummary(refreshed, verified.ipInfo), + }, + verified.ok ? 0 : 1, + !verified.ok + ); } if (action === "connect") { const target = buildConnectTarget(args); - 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({ - action, - requestedTarget: target, - connectResult, - verified: verified.ok, - verification: verified.ipInfo, - state: buildStateSummary(refreshed, verified.ipInfo), - }, verified.ok ? 0 : 1, !verified.ok); + printJson( + { + action, + requestedTarget: target, + connectResult, + verified: verified.ok, + verification: verified.ipInfo, + state: buildStateSummary(refreshed, verified.ipInfo), + }, + verified.ok ? 0 : 1, + !verified.ok + ); } if (action === "disconnect") {