3 Commits

Author SHA1 Message Date
Stefano Fiorini
09b1c1e37a fix: simplify mac nordvpn tailscale coordination 2026-03-12 01:55:46 -05:00
Stefano Fiorini
916d8bf95a docs: add nordvpn tailscale coordination plan 2026-03-12 01:46:52 -05:00
Stefano Fiorini
6bc21219a7 docs: add nordvpn macos dns plan 2026-03-12 01:35:32 -05:00
8 changed files with 248 additions and 12 deletions

View File

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

View File

@@ -0,0 +1,40 @@
# NordVPN macOS DNS Design
## Goal
Keep NordVPN DNS while connected on macOS, but only apply it to active physical services so the WireGuard backend does not break Tailscale or other virtual interfaces.
## Behavior
- Keep the generated WireGuard config free of `DNS = ...`
- During `connect` on macOS:
- detect active physical network services
- snapshot current DNS/search-domain settings
- set NordVPN DNS only on those physical services
- During `disconnect`:
- restore the saved DNS/search-domain settings
- During failed `connect` after DNS changes:
- restore DNS before returning the error
## DNS Values
- IPv4 primary: `103.86.96.100`
- IPv4 secondary: `103.86.99.100`
- No IPv6 DNS for now
## Service Selection
Include only enabled physical services from `networksetup`.
Exclude names matching:
- Tailscale
- Bridge
- Thunderbolt Bridge
- Loopback
- VPN
- utun
## Persistence
- Save DNS snapshot under `~/.nordvpn-client`
- Overwrite on each successful connect
- Clear after successful disconnect restore
## Verification
- Unit tests for service selection and DNS snapshot/restore helpers
- Direct logic/config tests
- Avoid live connect tests from this session unless explicitly requested because they can drop connectivity

View File

@@ -0,0 +1,11 @@
# NordVPN macOS DNS Plan
1. Add macOS DNS state file support under `~/.nordvpn-client`.
2. Implement helpers to enumerate eligible physical services and snapshot existing DNS/search-domain settings.
3. Implement helpers to apply NordVPN DNS only to eligible physical services.
4. Implement helpers to restore previous DNS/search-domain settings on disconnect or failed connect.
5. Add unit tests for service filtering and DNS state transitions.
6. Update skill/docs to explain macOS physical-service DNS management.
7. Sync the installed workspace copy.
8. Run tests and non-destructive verification.
9. Commit and push.

View File

@@ -0,0 +1,26 @@
# NordVPN Tailscale Coordination Design
## Goal
Stabilize macOS NordVPN connects by explicitly stopping Tailscale before bringing up the NordVPN WireGuard tunnel, then restarting Tailscale after NordVPN disconnects.
## Behavior
- macOS only
- on `connect`:
- detect whether Tailscale is active
- if active, stop it and record that state
- bring up NordVPN
- on `disconnect`:
- tear down NordVPN
- if the skill stopped Tailscale earlier, start it again
- clear the saved state
- on connect failure after stopping Tailscale:
- attempt to start Tailscale again before returning the error
## State
- persist `tailscaleWasActive` under `~/.nordvpn-client`
- only restart Tailscale if the skill actually stopped it
## Rollback target if successful
- remove the temporary macOS physical-service DNS management patch
- restore the simpler NordVPN config path that uses NordVPN DNS directly in the WireGuard config
- keep Tailscale suspend/resume as the macOS coexistence solution

View File

@@ -0,0 +1,10 @@
# NordVPN Tailscale Coordination Plan
1. Add macOS Tailscale state file support under `~/.nordvpn-client`.
2. Implement helpers to detect, stop, and start Tailscale on macOS.
3. Add unit tests for Tailscale state transitions.
4. Wire Tailscale stop into macOS `connect` before WireGuard up.
5. Wire Tailscale restart into macOS `disconnect` and connect-failure rollback.
6. Sync the installed workspace copy.
7. Run tests and non-destructive verification.
8. Commit and push.

View File

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

View File

@@ -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) {

View File

@@ -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");