diff --git a/docs/nordvpn-client.md b/docs/nordvpn-client.md index c4eeead..6da427c 100644 --- a/docs/nordvpn-client.md +++ b/docs/nordvpn-client.md @@ -36,8 +36,11 @@ node skills/nordvpn-client/scripts/nordvpn-client.js disconnect - `wireguard-go` - `wireguard-tools` - non-interactive `sudo` for `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh` -- the macOS WireGuard config intentionally omits `DNS = ...` - - reason: `wg-quick` on macOS rewrites system DNS across services when `DNS` is present, which can break connectivity and other tunnels +- the macOS WireGuard config uses NordVPN DNS directly: + - `103.86.96.100` + - `103.86.99.100` +- before connect, the skill automatically suspends Tailscale if it is active +- after disconnect, or after a failed connect, the skill brings Tailscale back up if it suspended it - `NordVPN.app` may stay installed but is only the manual fallback - the app login is not reused by the automated WireGuard backend @@ -94,4 +97,5 @@ After `connect`, the intended workflow is: - Linux behavior still depends on the official `nordvpn` CLI. - macOS automated connects require token-based WireGuard setup; GUI-app login alone is insufficient. +- macOS automated connects intentionally suspend Tailscale for the duration of the NordVPN session. - The Homebrew `nordvpn` app does not need to be uninstalled. diff --git a/skills/nordvpn-client/SKILL.md b/skills/nordvpn-client/SKILL.md index 0ca098a..4798a55 100644 --- a/skills/nordvpn-client/SKILL.md +++ b/skills/nordvpn-client/SKILL.md @@ -42,7 +42,11 @@ node scripts/nordvpn-client.js disconnect - `install` bootstraps those tools with Homebrew - equivalent Homebrew command: `brew install wireguard-go wireguard-tools` - `login` validates `NORDVPN_TOKEN` / `NORDVPN_TOKEN_FILE` for the WireGuard backend - - the generated WireGuard config intentionally omits `DNS = ...` so `wg-quick` does not rewrite system resolvers or break other interfaces such as Tailscale + - the generated WireGuard config uses NordVPN DNS directly: + - `103.86.96.100` + - `103.86.99.100` + - before connect, the skill automatically stops Tailscale if it is active + - after disconnect, or after a failed connect, the skill brings Tailscale back up if it stopped it - `NordVPN.app` can remain installed, but it is only the manual fallback ## Credentials @@ -98,5 +102,6 @@ For an automated macOS flow: - `wireguard-go` - `wireguard-tools` - non-interactive `sudo` for `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh` +- On macOS, Tailscale is intentionally suspended during an automated NordVPN session and resumed afterward. - `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 b1d25bc..376eda3 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -14,6 +14,7 @@ const WG_STATE_DIR = path.join(STATE_DIR, "wireguard"); const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`); const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json"); const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json"); +const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json"); const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join( os.homedir(), ".openclaw", @@ -35,6 +36,7 @@ const MAC_WG_HELPER_PATH = path.join( ); const CLIENT_IPV4 = "10.5.0.2"; const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"]; +const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"]; function printJson(payload, exitCode = 0, errorStream = false) { const body = `${JSON.stringify(payload, null, 2)}\n`; @@ -139,6 +141,18 @@ function commandExists(name) { // 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 ""; } @@ -238,6 +252,72 @@ function detectMacWireguardActiveFromIfconfig(ifconfigOutput) { return false; } +function buildMacTailscaleState(tailscaleWasActive) { + return { tailscaleWasActive: Boolean(tailscaleWasActive) }; +} + +function getMacTailscalePath(deps = {}) { + const commandExistsFn = deps.commandExists || commandExists; + const fileExistsFn = deps.fileExists || fileExists; + const discovered = commandExistsFn("tailscale"); + if (discovered) return discovered; + const fallbacks = ["/opt/homebrew/bin/tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; + for (const fallback of fallbacks) { + if (fileExistsFn(fallback)) return fallback; + } + return "tailscale"; +} + +function isMacTailscaleActive(status) { + return Boolean(status && status.BackendState === "Running"); +} + +async function getMacTailscaleStatus() { + const tailscale = getMacTailscalePath(); + const result = await runExec(tailscale, ["status", "--json"]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale status --json failed"); + } + const json = JSON.parse(result.stdout || "{}"); + return { + active: isMacTailscaleActive(json), + raw: json, + }; +} + +async function stopMacTailscaleIfActive() { + const status = await getMacTailscaleStatus(); + if (!status.active) { + writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false)); + return { tailscaleWasActive: false }; + } + const tailscale = getMacTailscalePath(); + const result = await runExec(tailscale, ["down"]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed"); + } + writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true)); + return { tailscaleWasActive: true }; +} + +async function resumeMacTailscaleIfNeeded() { + const state = readJsonFile(MAC_TAILSCALE_STATE_PATH); + if (!state || !state.tailscaleWasActive) { + return { restored: false }; + } + const tailscale = getMacTailscalePath(); + const result = await runExec(tailscale, ["up"]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed"); + } + try { + fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); + } catch { + // Ignore cleanup errors. + } + return { restored: true }; +} + async function resolveHostnameWithFallback(hostname, options = {}) { const resolvers = options.resolvers || DNS_FALLBACK_RESOLVERS; const resolveWithResolver = @@ -574,11 +654,14 @@ async function installNordvpn(installProbe) { } if (installProbe.platform === "linux") { - const command = - "TMP=$(mktemp) && " + - "if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " + - "elif command -v wget >/dev/null 2>&1; then wget -qO \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " + - "else echo 'curl or wget required' >&2; exit 1; fi && sh \"$TMP\" && rm -f \"$TMP\""; + 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"); @@ -812,6 +895,7 @@ function buildWireguardConfig(server, privateKey) { "[Interface]", `PrivateKey = ${privateKey}`, `Address = ${addresses.join(", ")}`, + `DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`, "", "[Peer]", `PublicKey = ${publicKey}`, @@ -931,6 +1015,10 @@ async function connectViaMacWireguard(installProbe, target) { 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) { @@ -942,6 +1030,9 @@ async function connectViaMacWireguard(installProbe, target) { 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"); } @@ -976,6 +1067,7 @@ async function connectViaMacWireguard(installProbe, target) { city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer), }, interfaceName: MAC_WG_INTERFACE, + tailscaleStopped, raw: up.stdout.trim() || up.stderr.trim(), }; } @@ -1000,13 +1092,25 @@ async function disconnectNordvpn(installProbe) { 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 tailscale = await resumeMacTailscaleIfNeeded(); + return { + backend: "wireguard", + changed: false, + tailscaleRestored: tailscale.restored, + message: "No active macOS WireGuard NordVPN connection found.", + }; } const down = await runSudoWireguard(installProbe, "down"); if (!down.ok) { 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." }; + const tailscale = await resumeMacTailscaleIfNeeded(); + return { + backend: "wireguard", + changed: true, + tailscaleRestored: tailscale.restored, + message: "Disconnected the macOS NordLynx/WireGuard session.", + }; } if (installProbe.platform === "darwin" && installProbe.appInstalled) { diff --git a/skills/nordvpn-client/scripts/nordvpn-client.test.js b/skills/nordvpn-client/scripts/nordvpn-client.test.js index 8e7f71e..a16b37d 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.test.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.test.js @@ -9,10 +9,16 @@ function loadInternals() { const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n"); const wrapped = `${source} module.exports = { + buildMacTailscaleState: + typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined, buildWireguardConfig: typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined, buildLookupResult: typeof buildLookupResult === "function" ? buildLookupResult : undefined, + getMacTailscalePath: + typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined, + isMacTailscaleActive: + typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined, detectMacWireguardActiveFromIfconfig: typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined, resolveHostnameWithFallback: @@ -62,7 +68,7 @@ test("buildLookupResult supports lookup all=true mode", () => { assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4])); }); -test("buildWireguardConfig omits DNS so macOS wg-quick does not rewrite system resolvers", () => { +test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => { const { buildWireguardConfig } = loadInternals(); assert.equal(typeof buildWireguardConfig, "function"); @@ -75,10 +81,40 @@ test("buildWireguardConfig omits DNS so macOS wg-quick does not rewrite system r "PRIVATEKEY" ); - assert.equal(config.includes("DNS ="), false); + assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true); assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true); }); +test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => { + const { getMacTailscalePath } = loadInternals(); + assert.equal(typeof getMacTailscalePath, "function"); + assert.equal( + getMacTailscalePath({ + commandExists: () => "", + fileExists: (target) => target === "/opt/homebrew/bin/tailscale", + }), + "/opt/homebrew/bin/tailscale" + ); +}); + +test("buildMacTailscaleState records whether tailscale was active", () => { + const { buildMacTailscaleState } = loadInternals(); + assert.equal(typeof buildMacTailscaleState, "function"); + assert.equal( + JSON.stringify(buildMacTailscaleState(true)), + JSON.stringify({ + tailscaleWasActive: true, + }) + ); +}); + +test("isMacTailscaleActive treats Running backend as active", () => { + const { isMacTailscaleActive } = loadInternals(); + assert.equal(typeof isMacTailscaleActive, "function"); + assert.equal(isMacTailscaleActive({ BackendState: "Running" }), true); + assert.equal(isMacTailscaleActive({ BackendState: "Stopped" }), false); +}); + test("verifyConnectionWithRetry retries transient reachability failures", async () => { const { verifyConnectionWithRetry } = loadInternals(); assert.equal(typeof verifyConnectionWithRetry, "function");