fix: simplify mac nordvpn tailscale coordination
This commit is contained in:
@@ -36,8 +36,11 @@ node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
|
|||||||
- `wireguard-go`
|
- `wireguard-go`
|
||||||
- `wireguard-tools`
|
- `wireguard-tools`
|
||||||
- non-interactive `sudo` for `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh`
|
- non-interactive `sudo` for `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh`
|
||||||
- the macOS WireGuard config intentionally omits `DNS = ...`
|
- the macOS WireGuard config uses NordVPN DNS directly:
|
||||||
- reason: `wg-quick` on macOS rewrites system DNS across services when `DNS` is present, which can break connectivity and other tunnels
|
- `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
|
- `NordVPN.app` may stay installed but is only the manual fallback
|
||||||
- the app login is not reused by the automated WireGuard backend
|
- 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.
|
- 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 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.
|
- The Homebrew `nordvpn` app does not need to be uninstalled.
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ node scripts/nordvpn-client.js disconnect
|
|||||||
- `install` bootstraps those tools with Homebrew
|
- `install` bootstraps those tools with Homebrew
|
||||||
- equivalent Homebrew command: `brew install wireguard-go wireguard-tools`
|
- equivalent Homebrew command: `brew install wireguard-go wireguard-tools`
|
||||||
- `login` validates `NORDVPN_TOKEN` / `NORDVPN_TOKEN_FILE` for the WireGuard backend
|
- `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
|
- `NordVPN.app` can remain installed, but it is only the manual fallback
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
@@ -98,5 +102,6 @@ For an automated macOS flow:
|
|||||||
- `wireguard-go`
|
- `wireguard-go`
|
||||||
- `wireguard-tools`
|
- `wireguard-tools`
|
||||||
- non-interactive `sudo` for `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh`
|
- 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.
|
- `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.
|
- 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 WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`);
|
||||||
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
||||||
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.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(
|
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
".openclaw",
|
".openclaw",
|
||||||
@@ -35,6 +36,7 @@ const MAC_WG_HELPER_PATH = path.join(
|
|||||||
);
|
);
|
||||||
const CLIENT_IPV4 = "10.5.0.2";
|
const CLIENT_IPV4 = "10.5.0.2";
|
||||||
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
|
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) {
|
function printJson(payload, exitCode = 0, errorStream = false) {
|
||||||
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
||||||
@@ -139,6 +141,18 @@ function commandExists(name) {
|
|||||||
// Continue.
|
// 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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +252,72 @@ function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
|||||||
return false;
|
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 = {}) {
|
async function resolveHostnameWithFallback(hostname, options = {}) {
|
||||||
const resolvers = options.resolvers || DNS_FALLBACK_RESOLVERS;
|
const resolvers = options.resolvers || DNS_FALLBACK_RESOLVERS;
|
||||||
const resolveWithResolver =
|
const resolveWithResolver =
|
||||||
@@ -574,11 +654,14 @@ async function installNordvpn(installProbe) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (installProbe.platform === "linux") {
|
if (installProbe.platform === "linux") {
|
||||||
const command =
|
const command = [
|
||||||
"TMP=$(mktemp) && " +
|
'TMPFILE="$(mktemp)"',
|
||||||
"if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " +
|
'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 \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " +
|
'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 \"$TMP\" && rm -f \"$TMP\"";
|
"else echo 'curl or wget required' >&2; exit 1; fi",
|
||||||
|
'sh "${TMPFILE}"',
|
||||||
|
'rm -f "${TMPFILE}"',
|
||||||
|
].join(" && ");
|
||||||
const ok = await runShell(command);
|
const ok = await runShell(command);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new Error("NordVPN Linux installer failed");
|
throw new Error("NordVPN Linux installer failed");
|
||||||
@@ -812,6 +895,7 @@ function buildWireguardConfig(server, privateKey) {
|
|||||||
"[Interface]",
|
"[Interface]",
|
||||||
`PrivateKey = ${privateKey}`,
|
`PrivateKey = ${privateKey}`,
|
||||||
`Address = ${addresses.join(", ")}`,
|
`Address = ${addresses.join(", ")}`,
|
||||||
|
`DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`,
|
||||||
"",
|
"",
|
||||||
"[Peer]",
|
"[Peer]",
|
||||||
`PublicKey = ${publicKey}`,
|
`PublicKey = ${publicKey}`,
|
||||||
@@ -931,6 +1015,10 @@ async function connectViaMacWireguard(installProbe, target) {
|
|||||||
const config = buildWireguardConfig(selectedServer, credentials.nordlynx_private_key);
|
const config = buildWireguardConfig(selectedServer, credentials.nordlynx_private_key);
|
||||||
ensureDir(WG_STATE_DIR);
|
ensureDir(WG_STATE_DIR);
|
||||||
writeTextFile(WG_CONFIG_PATH, config, 0o600);
|
writeTextFile(WG_CONFIG_PATH, config, 0o600);
|
||||||
|
let tailscaleStopped = false;
|
||||||
|
|
||||||
|
const tailscaleState = await stopMacTailscaleIfActive();
|
||||||
|
tailscaleStopped = tailscaleState.tailscaleWasActive;
|
||||||
|
|
||||||
const down = await runSudoWireguard(installProbe, "down");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
@@ -942,6 +1030,9 @@ async function connectViaMacWireguard(installProbe, target) {
|
|||||||
|
|
||||||
const up = await runSudoWireguard(installProbe, "up");
|
const up = await runSudoWireguard(installProbe, "up");
|
||||||
if (!up.ok) {
|
if (!up.ok) {
|
||||||
|
if (tailscaleStopped) {
|
||||||
|
await resumeMacTailscaleIfNeeded();
|
||||||
|
}
|
||||||
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
|
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),
|
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
|
||||||
},
|
},
|
||||||
interfaceName: MAC_WG_INTERFACE,
|
interfaceName: MAC_WG_INTERFACE,
|
||||||
|
tailscaleStopped,
|
||||||
raw: up.stdout.trim() || up.stderr.trim(),
|
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.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
||||||
if (!installProbe.wireguard.active) {
|
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");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
throw new Error((down.stderr || down.stdout || down.error).trim() || "wg-quick down failed");
|
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) {
|
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ function loadInternals() {
|
|||||||
const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n");
|
const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n");
|
||||||
const wrapped = `${source}
|
const wrapped = `${source}
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
buildMacTailscaleState:
|
||||||
|
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
||||||
buildWireguardConfig:
|
buildWireguardConfig:
|
||||||
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
||||||
buildLookupResult:
|
buildLookupResult:
|
||||||
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
||||||
|
getMacTailscalePath:
|
||||||
|
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
||||||
|
isMacTailscaleActive:
|
||||||
|
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
||||||
detectMacWireguardActiveFromIfconfig:
|
detectMacWireguardActiveFromIfconfig:
|
||||||
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
||||||
resolveHostnameWithFallback:
|
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]));
|
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();
|
const { buildWireguardConfig } = loadInternals();
|
||||||
assert.equal(typeof buildWireguardConfig, "function");
|
assert.equal(typeof buildWireguardConfig, "function");
|
||||||
|
|
||||||
@@ -75,10 +81,40 @@ test("buildWireguardConfig omits DNS so macOS wg-quick does not rewrite system r
|
|||||||
"PRIVATEKEY"
|
"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);
|
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 () => {
|
test("verifyConnectionWithRetry retries transient reachability failures", async () => {
|
||||||
const { verifyConnectionWithRetry } = loadInternals();
|
const { verifyConnectionWithRetry } = loadInternals();
|
||||||
assert.equal(typeof verifyConnectionWithRetry, "function");
|
assert.equal(typeof verifyConnectionWithRetry, "function");
|
||||||
|
|||||||
Reference in New Issue
Block a user