feat(nordvpn-client): gate macos connect on stable wireguard persistence

This commit is contained in:
2026-03-30 11:55:20 -05:00
parent 8d2c162849
commit a796481875
3 changed files with 547 additions and 104 deletions

View File

@@ -16,6 +16,7 @@ const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
const OPERATION_LOCK_PATH = path.join(STATE_DIR, "operation.lock");
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
os.homedir(),
".openclaw",
@@ -38,6 +39,9 @@ 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"];
const CONNECT_PERSISTENCE_ATTEMPTS = 6;
const CONNECT_PERSISTENCE_DELAY_MS = 2000;
const CONNECT_TOTAL_TIMEOUT_MS = 90000;
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]);
function sanitizeOutputPayload(payload) {
@@ -253,6 +257,78 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function processExists(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error && error.code === "EPERM";
}
}
function isOperationLockStale(lockRecord, options = {}) {
if (!lockRecord || typeof lockRecord !== "object") return true;
const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
const startedAtMs = Number.isFinite(lockRecord.startedAtMs)
? lockRecord.startedAtMs
: Date.parse(lockRecord.startedAt || "");
if (!processExists(lockRecord.pid)) {
return true;
}
if (!Number.isFinite(startedAtMs)) {
return true;
}
return now - startedAtMs > maxAgeMs;
}
function cleanupOperationLock(lockPath = OPERATION_LOCK_PATH) {
try {
if (lockPath && fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore cleanup errors.
}
}
function acquireOperationLock(action, options = {}) {
const lockPath = options.lockPath || OPERATION_LOCK_PATH;
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
const existing = readJsonFile(lockPath);
if (existing) {
if (isOperationLockStale(existing, { nowMs, maxAgeMs })) {
cleanupOperationLock(lockPath);
} else {
const activeAction = existing.action || "unknown";
throw new Error(`Another nordvpn-client ${activeAction} operation is already running.`);
}
}
const record = {
action,
pid: process.pid,
startedAt: new Date(nowMs).toISOString(),
startedAtMs: nowMs,
};
writeJsonFile(lockPath, record);
return {
lockPath,
record,
release() {
const current = readJsonFile(lockPath);
if (current && current.pid === record.pid && current.startedAtMs === record.startedAtMs) {
cleanupOperationLock(lockPath);
}
},
};
}
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
let inUtunBlock = false;
const lines = `${ifconfigOutput || ""}`.split("\n");
@@ -428,6 +504,28 @@ function normalizeMacDnsCommandOutput(output) {
.filter(Boolean);
}
function isNordDnsServerList(dnsServers) {
if (!Array.isArray(dnsServers) || !dnsServers.length) return false;
const normalized = dnsServers.map((value) => `${value}`.trim()).filter(Boolean);
if (!normalized.length) return false;
return normalized.every((value) => NORDVPN_MAC_DNS_SERVERS.includes(value));
}
function buildAutomaticMacDnsState(serviceNames) {
return {
services: (serviceNames || []).map((name) => ({
name,
dnsServers: [],
searchDomains: [],
})),
};
}
function shouldRejectMacDnsBaseline(state) {
if (!state || !Array.isArray(state.services) || !state.services.length) return false;
return state.services.every((service) => isNordDnsServerList(service.dnsServers || []));
}
async function listMacDnsServices() {
const result = await runExec("networksetup", ["-listallnetworkservices"]);
if (!result.ok) {
@@ -482,7 +580,14 @@ async function setMacSearchDomains(serviceName, searchDomains) {
}
async function applyMacNordDns() {
const snapshot = readJsonFile(MAC_DNS_STATE_PATH) || (await snapshotMacDnsState());
let snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
snapshot = await snapshotMacDnsState();
} else if (shouldRejectMacDnsBaseline(snapshot)) {
const services = await listMacDnsServices();
snapshot = buildAutomaticMacDnsState(services);
writeJsonFile(MAC_DNS_STATE_PATH, snapshot);
}
for (const service of snapshot.services || []) {
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
await setMacSearchDomains(service.name, []);
@@ -495,7 +600,10 @@ async function restoreMacDnsIfNeeded() {
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
return { restored: false };
}
for (const service of snapshot.services) {
const servicesToRestore = shouldRejectMacDnsBaseline(snapshot)
? buildAutomaticMacDnsState(snapshot.services.map((service) => service.name)).services
: snapshot.services;
for (const service of servicesToRestore) {
await setMacDnsServers(service.name, service.dnsServers || []);
await setMacSearchDomains(service.name, service.searchDomains || []);
}
@@ -658,8 +766,100 @@ function isBenignMacWireguardAbsentError(message) {
);
}
function shouldFinalizeMacWireguardConnect(connectResult, verified) {
return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok);
function parseMacWireguardHelperStatus(output) {
const parsed = {};
for (const line of `${output || ""}`.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
parsed[key] = value;
}
return {
active: ["1", "true", "yes", "on"].includes(`${parsed.active || ""}`.toLowerCase()),
interfaceName: parsed.interfaceName || MAC_WG_INTERFACE,
configPath: parsed.configPath || null,
raw: `${output || ""}`.trim(),
};
}
async function getMacWireguardHelperStatus(installProbe, options = {}) {
const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard;
const result = await runSudoWireguardFn(installProbe, "status");
const parsed = parseMacWireguardHelperStatus(result.stdout || result.stderr || "");
return {
...parsed,
ok: result.ok,
error: result.ok ? "" : (result.stderr || result.stdout || result.error).trim(),
};
}
async function checkMacWireguardPersistence(target, options = {}) {
const attempts = Math.max(1, Number(options.attempts || CONNECT_PERSISTENCE_ATTEMPTS));
const delayMs = Math.max(0, Number(options.delayMs || CONNECT_PERSISTENCE_DELAY_MS));
const getHelperStatus = options.getHelperStatus || (async () => ({ active: false, interfaceName: MAC_WG_INTERFACE }));
const verifyConnectionFn = options.verifyConnection || verifyConnection;
const sleepFn = options.sleep || sleep;
let lastHelperStatus = { active: false, interfaceName: MAC_WG_INTERFACE };
let lastVerified = { ok: false, ipInfo: null };
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
lastHelperStatus = await getHelperStatus();
} catch (error) {
lastHelperStatus = {
active: false,
interfaceName: MAC_WG_INTERFACE,
error: error.message || String(error),
};
}
try {
lastVerified = await verifyConnectionFn(target);
} catch (error) {
lastVerified = {
ok: false,
ipInfo: {
ok: false,
error: error.message || String(error),
},
};
}
if (lastHelperStatus.active && lastVerified.ok) {
return {
stable: true,
attempts: attempt,
helperStatus: lastHelperStatus,
verified: lastVerified,
};
}
if (attempt < attempts) {
await sleepFn(delayMs);
}
}
return {
stable: false,
attempts,
helperStatus: lastHelperStatus,
verified: lastVerified,
};
}
function shouldFinalizeMacWireguardConnect(connectResult, verified, persistence) {
return Boolean(
connectResult &&
connectResult.backend === "wireguard" &&
verified &&
verified.ok &&
persistence &&
persistence.stable
);
}
function normalizeSuccessfulConnectState(state, connectResult, verified) {
@@ -812,14 +1012,20 @@ async function probeMacWireguard() {
const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
const helperStatus =
helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus((await runExec("sudo", ["-n", helperPath, "status"])).stdout) : null;
let active = false;
let showRaw = "";
let endpoint = "";
let ifconfigRaw = "";
if (helperStatus) {
active = helperStatus.active;
}
if (wgPath) {
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
active = show.ok;
active = active || show.ok;
showRaw = (show.stdout || show.stderr).trim();
if (show.ok) {
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
@@ -846,8 +1052,8 @@ async function probeMacWireguard() {
helperSecurity,
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
sudoReady: sudoProbe.ok,
interfaceName: MAC_WG_INTERFACE,
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null,
interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE,
configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null),
active,
endpoint: endpoint || null,
showRaw,
@@ -1462,6 +1668,7 @@ async function disconnectNordvpn(installProbe) {
if (!down.ok) {
const message = (down.stderr || down.stdout || down.error).trim();
if (isBenignMacWireguardAbsentError(message)) {
await runSudoWireguard(installProbe, "cleanup");
const dnsState = await restoreMacDnsIfNeeded();
const cleaned = cleanupMacWireguardAndDnsState();
const tailscale = await resumeMacTailscaleIfNeeded();
@@ -1476,6 +1683,7 @@ async function disconnectNordvpn(installProbe) {
}
throw new Error(message || "wg-quick down failed");
}
await runSudoWireguard(installProbe, "cleanup");
const dnsState = await restoreMacDnsIfNeeded();
const cleaned = cleanupMacWireguardAndDnsState();
const tailscale = await resumeMacTailscaleIfNeeded();
@@ -1623,117 +1831,204 @@ async function main() {
}
if (action === "connect") {
const target = buildConnectTarget(args);
let connectResult;
const lock = acquireOperationLock(action);
try {
const target = buildConnectTarget(args);
let connectResult;
let verified;
let persistence = null;
if (installProbe.cliPath) {
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) {
connectResult = await connectViaMacApp(target);
} else if (platform === "darwin" && !installProbe.tokenAvailable) {
throw new Error(`macOS automated NordLynx/WireGuard connects require NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or a token at ${DEFAULT_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 {
throw new Error("No usable NordVPN control path found.");
}
if (installProbe.cliPath) {
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) {
connectResult = await connectViaMacApp(target);
} else if (platform === "darwin" && !installProbe.tokenAvailable) {
throw new Error(`macOS automated NordLynx/WireGuard connects require NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or a token at ${DEFAULT_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 {
throw new Error("No usable NordVPN control path found.");
}
if (connectResult.manualActionRequired) {
emitJson({ action, requestedTarget: target, ...connectResult });
}
if (connectResult.manualActionRequired) {
lock.release();
emitJson({ action, requestedTarget: target, ...connectResult });
}
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
const refreshed = await probeInstallation(platform);
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
emitJson(
{
action,
requestedTarget: target,
connectResult,
verified: false,
verification: verified.ipInfo,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified)) {
try {
await snapshotMacDnsState();
await applyMacNordDns();
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: connectResult.requestedTarget,
resolvedTarget: connectResult.resolvedTarget,
server: {
hostname: connectResult.server.hostname,
city: connectResult.server.city,
country: connectResult.server.country,
load: connectResult.server.load,
},
connectedAt: new Date().toISOString(),
if (connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
persistence = await checkMacWireguardPersistence(target, {
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
getHelperStatus: async () => getMacWireguardHelperStatus(installProbe),
verifyConnection: verifyConnection,
});
} catch (error) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
verified = persistence.verified;
if (!persistence.stable) {
const refreshed = await probeInstallation(platform);
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: verified && verified.ipInfo ? verified.ipInfo : null,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = await verifyConnection(target);
} else {
verified = await verifyConnectionWithRetry(target, {
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
});
}
const refreshed = await probeInstallation(platform);
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
verified: true,
persistence,
verified: false,
verification: verified.ipInfo,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
}
const connectState = normalizeSuccessfulConnectState(
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
connectResult,
verified
);
emitJson(
{
action,
requestedTarget: target,
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified, persistence)) {
try {
await snapshotMacDnsState();
const liveness = await verifyConnection(target);
if (!liveness.ok) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: liveness.ipInfo,
rollback,
error: "Connected but failed the final liveness check before DNS finalization.",
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = liveness;
await applyMacNordDns();
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: connectResult.requestedTarget,
resolvedTarget: connectResult.resolvedTarget,
server: {
hostname: connectResult.server.hostname,
city: connectResult.server.city,
country: connectResult.server.country,
load: connectResult.server.load,
},
connectedAt: new Date().toISOString(),
});
} catch (error) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: true,
verification: verified.ipInfo,
rollback,
error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
}
const connectState = normalizeSuccessfulConnectState(
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
connectResult,
verified: verified.ok,
verification: verified.ipInfo,
state: connectState,
},
verified.ok ? 0 : 1,
!verified.ok
);
verified
);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: verified.ok,
verification: verified.ipInfo,
state: connectState,
},
verified.ok ? 0 : 1,
!verified.ok
);
} finally {
lock.release();
}
}
if (action === "disconnect") {
const result = await disconnectNordvpn(installProbe);
const refreshed = await probeInstallation(platform);
emitJson({
action,
...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()),
});
const lock = acquireOperationLock(action);
try {
const result = await disconnectNordvpn(installProbe);
const refreshed = await probeInstallation(platform);
lock.release();
emitJson({
action,
...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()),
});
} finally {
lock.release();
}
}
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);