feat: add mac wireguard nordvpn backend

This commit is contained in:
Stefano Fiorini
2026-03-11 23:44:22 -05:00
parent b326153d26
commit 4a539a33c9
7 changed files with 700 additions and 119 deletions

View File

@@ -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` | | `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` | | `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` | | `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` | | `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` | | `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` |

View File

@@ -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 - [`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) - [`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) - [`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 - [`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 - [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping

View File

@@ -4,9 +4,9 @@ Cross-platform NordVPN lifecycle skill for macOS and Linux.
## What it does ## What it does
- Probes whether NordVPN is already installed - Probes whether NordVPN is already installed or automation-ready
- Bootstraps NordVPN if missing - Bootstraps the required backend if missing
- Handles login bootstrap - Handles login/bootstrap
- Connects to a country or city target - Connects to a country or city target
- Disconnects and reports status - Disconnects and reports status
- Verifies public IP and geolocation after connect - Verifies public IP and geolocation after connect
@@ -29,12 +29,15 @@ node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
### macOS ### macOS
- install path: `brew install --cask nordvpn` - preferred backend: NordLynx/WireGuard
- probe order: - install path: `brew install wireguard-go wireguard-tools`
- `nordvpn` CLI if present - automation requirements:
- `NordVPN.app` - `NORDVPN_TOKEN` or `NORDVPN_TOKEN_FILE`
- if the installed package exposes a usable CLI, the skill uses it - `wireguard-go`
- otherwise it opens the app and returns a clear manual-action-required result for login/connect/disconnect - `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 ### Linux
@@ -60,7 +63,7 @@ Do not put secrets in the skill docs or repo.
- platform - platform
- install state - install state
- control mode (`cli` vs `app-manual`) - control mode (`cli`, `wireguard`, `app-manual`)
- auth state - auth state
- connection state - connection state
- requested target - requested target
@@ -74,6 +77,6 @@ After `connect`, the intended workflow is:
## Limitations ## Limitations
- Linux city targeting is attempted through the CLI target string and then validated by public IP/location checks. - Linux behavior still depends on the official `nordvpn` CLI.
- macOS app-only fallback cannot guarantee non-interactive control if the app does not expose a CLI. - macOS automated connects require token-based WireGuard setup; GUI-app login alone is insufficient.
- 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. - The Homebrew `nordvpn` app does not need to be uninstalled.

View File

@@ -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.

View File

@@ -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.

View File

@@ -9,9 +9,9 @@ Cross-platform NordVPN lifecycle management for macOS and Linux hosts.
## What This Skill Is For ## What This Skill Is For
- Probing whether NordVPN is already installed - Probing whether NordVPN is already installed or automation-ready
- Bootstrapping NordVPN if it is missing - Bootstrapping the required NordVPN backend if it is missing
- Logging in through the Linux CLI or the macOS app/CLI path - 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` - Connecting to a country or city before a follow-up action such as `web-automation`
- Disconnecting and checking VPN status - Disconnecting and checking VPN status
- Verifying public IP and geolocation after connect - Verifying public IP and geolocation after connect
@@ -38,9 +38,10 @@ node scripts/nordvpn-client.js disconnect
- install path follows NordVPN's Linux installer - install path follows NordVPN's Linux installer
- token login is supported through `NORDVPN_TOKEN` - token login is supported through `NORDVPN_TOKEN`
- macOS: - macOS:
- prefers Homebrew cask install: `brew install --cask nordvpn` - preferred backend is NordLynx/WireGuard using `wireguard-go` and `wireguard-tools`
- prefers a usable `nordvpn` CLI if the installed package exposes one - `install` bootstraps those tools with Homebrew
- otherwise falls back to opening the NordVPN app and guiding the manual login/connect path - `login` validates `NORDVPN_TOKEN` / `NORDVPN_TOKEN_FILE` for the WireGuard backend
- `NordVPN.app` can remain installed, but it is only the manual fallback
## Credentials ## Credentials
@@ -63,7 +64,7 @@ Optional credential file env vars:
- platform - platform
- install state - install state
- control mode (`cli` vs `app-manual`) - control mode (`cli`, `wireguard`, `app-manual`)
- auth state - auth state
- connection state - connection state
- requested target - requested target
@@ -74,7 +75,11 @@ Use `verify` when you want an explicit post-connect location check without chang
## Known Boundaries ## Known Boundaries
- Linux country connect is official CLI behavior. - Linux country/city connect remains whatever the official `nordvpn` CLI supports.
- Linux city connect is attempted through the CLI target string and then validated by post-connect IP/location checks. - macOS automated connects require all of:
- 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. - `NORDVPN_TOKEN` or `NORDVPN_TOKEN_FILE`
- 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. - `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.

View File

@@ -6,6 +6,16 @@ const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const https = require("node:https"); 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) { function printJson(payload, exitCode = 0, errorStream = false) {
const body = `${JSON.stringify(payload, null, 2)}\n`; const body = `${JSON.stringify(payload, null, 2)}\n`;
(errorStream ? process.stderr : process.stdout).write(body); (errorStream ? process.stderr : process.stdout).write(body);
@@ -78,6 +88,15 @@ function normalizeLocation(value) {
return `${value || ""}`.trim(); 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) { function commandExists(name) {
const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean); const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean);
for (const entry of pathEntries) { 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 = {}) { function runExec(command, args = [], options = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => {
@@ -131,7 +187,7 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function fetchJson(url) { function fetchJson(url, headers = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = https.get( const req = https.get(
url, url,
@@ -139,6 +195,7 @@ function fetchJson(url) {
headers: { headers: {
"User-Agent": "nordvpn-client-skill/1.0", "User-Agent": "nordvpn-client-skill/1.0",
Accept: "application/json", Accept: "application/json",
...headers,
}, },
}, },
(res) => { (res) => {
@@ -161,7 +218,7 @@ function fetchJson(url) {
} }
); );
req.on("error", (error) => resolve({ ok: false, error: error.message })); req.on("error", (error) => resolve({ ok: false, error: error.message }));
req.setTimeout(10000, () => { req.setTimeout(15000, () => {
req.destroy(new Error("timeout")); req.destroy(new Error("timeout"));
}); });
}); });
@@ -180,6 +237,8 @@ async function getPublicIpInfo() {
country: json.country_name || json.country || "", country: json.country_name || json.country || "",
countryCode: json.country_code || "", countryCode: json.country_code || "",
org: json.org || "", 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 status = await runExec(cliPath, ["status"]);
const account = await runExec(cliPath, ["account"]); const account = await runExec(cliPath, ["account"]);
const countries = await runExec(cliPath, ["countries"]); 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 { return {
status, wgPath: wgPath || null,
account, wgQuickPath: wgQuickPath || null,
countries, 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 cliPath = commandExists("nordvpn");
const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : ""; const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : "";
const brewPath = platform === "darwin" ? commandExists("brew") : ""; const brewPath = platform === "darwin" ? commandExists("brew") : "";
const tokenAvailable = Boolean(readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"));
let cliProbe = null; let cliProbe = null;
if (cliPath) { if (cliPath) {
cliProbe = await probeCliStatus(cliPath); cliProbe = await probeCliStatus(cliPath);
} }
let wireguard = null;
if (platform === "darwin") {
wireguard = await probeMacWireguard();
}
return { return {
platform, platform,
cliPath, cliPath,
appPath, appPath,
appInstalled: Boolean(appPath && fileExists(appPath)), appInstalled: Boolean(appPath && fileExists(appPath)),
brewPath, 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, cliProbe,
wireguard,
}; };
} }
function inferAuthState(probe) { function inferAuthState(probe, installProbe) {
if (!probe || !probe.account) return null; if (probe && probe.account) {
const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase(); const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase();
if (!blob.trim()) return null; if (!blob.trim()) return null;
if (probe.account.ok && !blob.includes("not logged")) return true; if (probe.account.ok && !blob.includes("not logged")) return true;
if (blob.includes("not logged") || blob.includes("login")) return false; 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; return null;
} }
function inferConnectionState(probe) { function inferConnectionState(probe, installProbe) {
if (!probe || !probe.status) return null; if (probe && probe.status) {
const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase(); const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase();
if (!blob.trim()) return null; if (!blob.trim()) return null;
if (blob.includes("connected")) return true; if (blob.includes("connected")) return true;
if (blob.includes("disconnected") || blob.includes("not connected")) return false; if (blob.includes("disconnected") || blob.includes("not connected")) return false;
}
if (installProbe.platform === "darwin" && installProbe.wireguard) {
return installProbe.wireguard.active;
}
return null; return null;
} }
@@ -247,13 +361,30 @@ function buildStateSummary(installProbe, ipInfo) {
loginMode = "cli"; loginMode = "cli";
connectMode = "cli"; connectMode = "cli";
recommendedAction = "Use login/connect/disconnect through the nordvpn 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) { } else if (installProbe.platform === "darwin" && installProbe.appInstalled) {
controlMode = "app-manual"; controlMode = "app-manual";
automaticControl = false; automaticControl = false;
loginMode = "app-manual"; loginMode = "app-manual";
connectMode = "app-manual"; connectMode = "app-manual";
recommendedAction = recommendedAction = installProbe.tokenAvailable
"NordVPN is installed as a macOS app without a PATH-visible CLI. Use login/connect to open NordVPN.app and complete the action there."; ? "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 { return {
@@ -264,32 +395,62 @@ function buildStateSummary(installProbe, ipInfo) {
appInstalled: installProbe.appInstalled, appInstalled: installProbe.appInstalled,
appPath: installProbe.appInstalled ? installProbe.appPath : null, appPath: installProbe.appInstalled ? installProbe.appPath : null,
brewAvailable: Boolean(installProbe.brewPath), brewAvailable: Boolean(installProbe.brewPath),
tokenAvailable: installProbe.tokenAvailable,
controlMode, controlMode,
automaticControl, automaticControl,
loginMode, loginMode,
connectMode, connectMode,
recommendedAction, recommendedAction,
authenticated: inferAuthState(cliProbe), authenticated: inferAuthState(cliProbe, installProbe),
connected: inferConnectionState(cliProbe), connected: inferConnectionState(cliProbe, installProbe),
localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "", 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) { async function installNordvpn(installProbe) {
if (installProbe.installed) {
return { changed: false, message: "NordVPN is already installed." };
}
if (installProbe.platform === "darwin") { if (installProbe.platform === "darwin") {
if (!installProbe.brewPath) { 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) { 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") { if (installProbe.platform === "linux") {
@@ -313,40 +474,235 @@ async function openMacApp() {
if (!ok) throw new Error("Failed to open NordVPN.app"); if (!ok) throw new Error("Failed to open NordVPN.app");
} }
async function loginNordvpn(installProbe, args) { async function fetchNordCredentials(token) {
const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"); const result = await fetchJson("https://api.nordvpn.com/v1/users/services/credentials", {
const username = process.env.NORDVPN_USERNAME || ""; Authorization: `Bearer token:${token}`,
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) { if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed"); throw new Error(result.error || `NordVPN credentials request failed (${result.statusCode || "unknown"})`);
} }
return { mode: "cli-token", message: "Logged in using token." }; if (result.statusCode >= 400) {
const apiMessage = result.json && result.json.errors && result.json.errors.message;
throw new Error(apiMessage || `NordVPN credentials request failed (${result.statusCode})`);
}
if (!result.json || !result.json.nordlynx_private_key) {
throw new Error("NordVPN credentials response did not include nordlynx_private_key.");
}
return result.json;
}
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];
} }
if (username && password && installProbe.platform === "darwin") { let city = null;
// macOS CLI login support is not documented. Fall back to interactive CLI login. 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;
}
} }
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 { return {
mode: "app-manual", country: country
manualActionRequired: true, ? {
message: id: country.id,
"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.", 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) { function buildConnectTarget(args) {
@@ -360,8 +716,8 @@ function buildConnectTarget(args) {
function locationMatches(ipInfo, target) { function locationMatches(ipInfo, target) {
if (!ipInfo || !ipInfo.ok) return false; if (!ipInfo || !ipInfo.ok) return false;
const countryMatch = !target.country || ipInfo.country.toLowerCase().includes(target.country.toLowerCase()); const countryMatch = !target.country || normalizeForMatch(ipInfo.country).includes(normalizeForMatch(target.country));
const cityMatch = !target.city || ipInfo.city.toLowerCase().includes(target.city.toLowerCase()); const cityMatch = !target.city || normalizeForMatch(ipInfo.city).includes(normalizeForMatch(target.city));
return countryMatch && cityMatch; return countryMatch && cityMatch;
} }
@@ -375,21 +731,15 @@ async function verifyConnection(target) {
async function connectViaCli(cliPath, target) { async function connectViaCli(cliPath, target) {
const attempts = []; const attempts = [];
if (target.city) { if (target.city) attempts.push([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.country) {
attempts.push([target.country]);
}
if (target.country && target.city) {
attempts.push([`${target.country} ${target.city}`]);
}
let lastFailure = ""; let lastFailure = "";
for (const args of attempts) { for (const attemptArgs of attempts) {
const result = await runExec(cliPath, ["connect", ...args]); const result = await runExec(cliPath, ["connect", ...attemptArgs]);
if (result.ok) { 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(); lastFailure = (result.stderr || result.stdout || result.error).trim();
} }
@@ -397,9 +747,105 @@ async function connectViaCli(cliPath, target) {
throw new Error(lastFailure || "NordVPN connect failed"); 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) { async function connectViaMacApp(target) {
await openMacApp(); await openMacApp();
return { return {
backend: "app-manual",
manualActionRequired: true, 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.`, 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) { if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed"); 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) { if (installProbe.platform === "darwin" && installProbe.appInstalled) {
await openMacApp(); await openMacApp();
return { return {
backend: "app-manual",
manualActionRequired: true, 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") { if (action === "login") {
const result = await loginNordvpn(installProbe, args); const result = await loginNordvpn(installProbe);
const refreshed = await probeInstallation(platform); const refreshed = await probeInstallation(platform);
printJson({ printJson({
action, action,
@@ -469,25 +983,35 @@ async function main() {
const target = args.country || args.city ? buildConnectTarget(args) : null; const target = args.country || args.city ? buildConnectTarget(args) : null;
const verified = await verifyConnection(target); const verified = await verifyConnection(target);
const refreshed = await probeInstallation(platform); const refreshed = await probeInstallation(platform);
printJson({ printJson(
{
action, action,
requestedTarget: target, requestedTarget: target,
verified: verified.ok, verified: verified.ok,
verification: verified.ipInfo, verification: verified.ipInfo,
state: buildStateSummary(refreshed, verified.ipInfo), state: buildStateSummary(refreshed, verified.ipInfo),
}, verified.ok ? 0 : 1, !verified.ok); },
verified.ok ? 0 : 1,
!verified.ok
);
} }
if (action === "connect") { if (action === "connect") {
const target = buildConnectTarget(args); const target = buildConnectTarget(args);
if (!installProbe.installed) {
throw new Error("NordVPN is not installed.");
}
let connectResult; let connectResult;
if (installProbe.cliPath) { if (installProbe.cliPath) {
connectResult = await connectViaCli(installProbe.cliPath, target); 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) { } else if (platform === "darwin" && installProbe.appInstalled) {
connectResult = await connectViaMacApp(target); 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 { } else {
throw new Error("No usable NordVPN control path found."); throw new Error("No usable NordVPN control path found.");
} }
@@ -499,14 +1023,18 @@ async function main() {
await sleep(3000); await sleep(3000);
const verified = await verifyConnection(target); const verified = await verifyConnection(target);
const refreshed = await probeInstallation(platform); const refreshed = await probeInstallation(platform);
printJson({ printJson(
{
action, action,
requestedTarget: target, requestedTarget: target,
connectResult, connectResult,
verified: verified.ok, verified: verified.ok,
verification: verified.ipInfo, verification: verified.ipInfo,
state: buildStateSummary(refreshed, verified.ipInfo), state: buildStateSummary(refreshed, verified.ipInfo),
}, verified.ok ? 0 : 1, !verified.ok); },
verified.ok ? 0 : 1,
!verified.ok
);
} }
if (action === "disconnect") { if (action === "disconnect") {