fix: simplify mac nordvpn tailscale coordination
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user