feat(nordvpn-client): gate macos connect on stable wireguard persistence
This commit is contained in:
@@ -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 LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
|
||||||
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
|
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
|
||||||
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.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(
|
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
".openclaw",
|
".openclaw",
|
||||||
@@ -38,6 +39,9 @@ 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"];
|
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"]);
|
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]);
|
||||||
|
|
||||||
function sanitizeOutputPayload(payload) {
|
function sanitizeOutputPayload(payload) {
|
||||||
@@ -253,6 +257,78 @@ function sleep(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 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) {
|
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
||||||
let inUtunBlock = false;
|
let inUtunBlock = false;
|
||||||
const lines = `${ifconfigOutput || ""}`.split("\n");
|
const lines = `${ifconfigOutput || ""}`.split("\n");
|
||||||
@@ -428,6 +504,28 @@ function normalizeMacDnsCommandOutput(output) {
|
|||||||
.filter(Boolean);
|
.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() {
|
async function listMacDnsServices() {
|
||||||
const result = await runExec("networksetup", ["-listallnetworkservices"]);
|
const result = await runExec("networksetup", ["-listallnetworkservices"]);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -482,7 +580,14 @@ async function setMacSearchDomains(serviceName, searchDomains) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyMacNordDns() {
|
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 || []) {
|
for (const service of snapshot.services || []) {
|
||||||
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
|
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
|
||||||
await setMacSearchDomains(service.name, []);
|
await setMacSearchDomains(service.name, []);
|
||||||
@@ -495,7 +600,10 @@ async function restoreMacDnsIfNeeded() {
|
|||||||
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
|
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
|
||||||
return { restored: false };
|
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 setMacDnsServers(service.name, service.dnsServers || []);
|
||||||
await setMacSearchDomains(service.name, service.searchDomains || []);
|
await setMacSearchDomains(service.name, service.searchDomains || []);
|
||||||
}
|
}
|
||||||
@@ -658,8 +766,100 @@ function isBenignMacWireguardAbsentError(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFinalizeMacWireguardConnect(connectResult, verified) {
|
function parseMacWireguardHelperStatus(output) {
|
||||||
return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok);
|
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) {
|
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 helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
|
||||||
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
|
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
|
||||||
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
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 active = false;
|
||||||
let showRaw = "";
|
let showRaw = "";
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
let ifconfigRaw = "";
|
let ifconfigRaw = "";
|
||||||
|
|
||||||
|
if (helperStatus) {
|
||||||
|
active = helperStatus.active;
|
||||||
|
}
|
||||||
|
|
||||||
if (wgPath) {
|
if (wgPath) {
|
||||||
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
||||||
active = show.ok;
|
active = active || show.ok;
|
||||||
showRaw = (show.stdout || show.stderr).trim();
|
showRaw = (show.stdout || show.stderr).trim();
|
||||||
if (show.ok) {
|
if (show.ok) {
|
||||||
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
||||||
@@ -846,8 +1052,8 @@ async function probeMacWireguard() {
|
|||||||
helperSecurity,
|
helperSecurity,
|
||||||
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
||||||
sudoReady: sudoProbe.ok,
|
sudoReady: sudoProbe.ok,
|
||||||
interfaceName: MAC_WG_INTERFACE,
|
interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE,
|
||||||
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null,
|
configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null),
|
||||||
active,
|
active,
|
||||||
endpoint: endpoint || null,
|
endpoint: endpoint || null,
|
||||||
showRaw,
|
showRaw,
|
||||||
@@ -1462,6 +1668,7 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
const message = (down.stderr || down.stdout || down.error).trim();
|
const message = (down.stderr || down.stdout || down.error).trim();
|
||||||
if (isBenignMacWireguardAbsentError(message)) {
|
if (isBenignMacWireguardAbsentError(message)) {
|
||||||
|
await runSudoWireguard(installProbe, "cleanup");
|
||||||
const dnsState = await restoreMacDnsIfNeeded();
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
const cleaned = cleanupMacWireguardAndDnsState();
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
@@ -1476,6 +1683,7 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
}
|
}
|
||||||
throw new Error(message || "wg-quick down failed");
|
throw new Error(message || "wg-quick down failed");
|
||||||
}
|
}
|
||||||
|
await runSudoWireguard(installProbe, "cleanup");
|
||||||
const dnsState = await restoreMacDnsIfNeeded();
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
const cleaned = cleanupMacWireguardAndDnsState();
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
@@ -1623,8 +1831,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "connect") {
|
if (action === "connect") {
|
||||||
|
const lock = acquireOperationLock(action);
|
||||||
|
try {
|
||||||
const target = buildConnectTarget(args);
|
const target = buildConnectTarget(args);
|
||||||
let connectResult;
|
let connectResult;
|
||||||
|
let verified;
|
||||||
|
let persistence = null;
|
||||||
|
|
||||||
if (installProbe.cliPath) {
|
if (installProbe.cliPath) {
|
||||||
connectResult = await connectViaCli(installProbe.cliPath, target);
|
connectResult = await connectViaCli(installProbe.cliPath, target);
|
||||||
@@ -1643,10 +1855,55 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connectResult.manualActionRequired) {
|
if (connectResult.manualActionRequired) {
|
||||||
|
lock.release();
|
||||||
emitJson({ action, requestedTarget: target, ...connectResult });
|
emitJson({ action, requestedTarget: target, ...connectResult });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
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,
|
||||||
|
});
|
||||||
|
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);
|
const refreshed = await probeInstallation(platform);
|
||||||
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
|
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
|
||||||
const diagnostics = await collectMacWireguardDiagnostics({
|
const diagnostics = await collectMacWireguardDiagnostics({
|
||||||
@@ -1656,11 +1913,13 @@ async function main() {
|
|||||||
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
||||||
});
|
});
|
||||||
const rollback = await disconnectNordvpn(refreshed);
|
const rollback = await disconnectNordvpn(refreshed);
|
||||||
|
lock.release();
|
||||||
emitJson(
|
emitJson(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
requestedTarget: target,
|
requestedTarget: target,
|
||||||
connectResult,
|
connectResult,
|
||||||
|
persistence,
|
||||||
verified: false,
|
verified: false,
|
||||||
verification: verified.ipInfo,
|
verification: verified.ipInfo,
|
||||||
diagnostics: debugOutput ? diagnostics : undefined,
|
diagnostics: debugOutput ? diagnostics : undefined,
|
||||||
@@ -1672,9 +1931,31 @@ async function main() {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified)) {
|
|
||||||
|
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified, persistence)) {
|
||||||
try {
|
try {
|
||||||
await snapshotMacDnsState();
|
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();
|
await applyMacNordDns();
|
||||||
writeJsonFile(LAST_CONNECTION_PATH, {
|
writeJsonFile(LAST_CONNECTION_PATH, {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
@@ -1691,11 +1972,13 @@ async function main() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollback = await disconnectNordvpn(await probeInstallation(platform));
|
const rollback = await disconnectNordvpn(await probeInstallation(platform));
|
||||||
|
lock.release();
|
||||||
emitJson(
|
emitJson(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
requestedTarget: target,
|
requestedTarget: target,
|
||||||
connectResult,
|
connectResult,
|
||||||
|
persistence,
|
||||||
verified: true,
|
verified: true,
|
||||||
verification: verified.ipInfo,
|
verification: verified.ipInfo,
|
||||||
rollback,
|
rollback,
|
||||||
@@ -1707,16 +1990,19 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectState = normalizeSuccessfulConnectState(
|
const connectState = normalizeSuccessfulConnectState(
|
||||||
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
|
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
|
||||||
connectResult,
|
connectResult,
|
||||||
verified
|
verified
|
||||||
);
|
);
|
||||||
|
lock.release();
|
||||||
emitJson(
|
emitJson(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
requestedTarget: target,
|
requestedTarget: target,
|
||||||
connectResult,
|
connectResult,
|
||||||
|
persistence,
|
||||||
verified: verified.ok,
|
verified: verified.ok,
|
||||||
verification: verified.ipInfo,
|
verification: verified.ipInfo,
|
||||||
state: connectState,
|
state: connectState,
|
||||||
@@ -1724,16 +2010,25 @@ async function main() {
|
|||||||
verified.ok ? 0 : 1,
|
verified.ok ? 0 : 1,
|
||||||
!verified.ok
|
!verified.ok
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "disconnect") {
|
if (action === "disconnect") {
|
||||||
|
const lock = acquireOperationLock(action);
|
||||||
|
try {
|
||||||
const result = await disconnectNordvpn(installProbe);
|
const result = await disconnectNordvpn(installProbe);
|
||||||
const refreshed = await probeInstallation(platform);
|
const refreshed = await probeInstallation(platform);
|
||||||
|
lock.release();
|
||||||
emitJson({
|
emitJson({
|
||||||
action,
|
action,
|
||||||
...result,
|
...result,
|
||||||
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ module.exports = {
|
|||||||
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
|
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
|
||||||
collectMacWireguardDiagnostics:
|
collectMacWireguardDiagnostics:
|
||||||
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
|
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
|
||||||
|
acquireOperationLock:
|
||||||
|
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
|
||||||
inspectMacWireguardHelperSecurity:
|
inspectMacWireguardHelperSecurity:
|
||||||
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
|
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
|
||||||
getMacTailscalePath:
|
getMacTailscalePath:
|
||||||
@@ -31,10 +33,16 @@ module.exports = {
|
|||||||
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
||||||
isMacTailscaleActive:
|
isMacTailscaleActive:
|
||||||
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
||||||
|
checkMacWireguardPersistence:
|
||||||
|
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
|
||||||
normalizeSuccessfulConnectState:
|
normalizeSuccessfulConnectState:
|
||||||
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
||||||
normalizeStatusState:
|
normalizeStatusState:
|
||||||
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
||||||
|
parseMacWireguardHelperStatus:
|
||||||
|
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
|
||||||
|
shouldRejectMacDnsBaseline:
|
||||||
|
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
|
||||||
shouldManageMacDnsService:
|
shouldManageMacDnsService:
|
||||||
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
||||||
sanitizeOutputPayload:
|
sanitizeOutputPayload:
|
||||||
@@ -142,6 +150,36 @@ test("buildMacDnsState records DNS and search domains per service", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
|
||||||
|
const { shouldRejectMacDnsBaseline } = loadInternals();
|
||||||
|
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRejectMacDnsBaseline({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "Wi-Fi",
|
||||||
|
dnsServers: ["103.86.96.100", "103.86.99.100"],
|
||||||
|
searchDomains: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldRejectMacDnsBaseline({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "Wi-Fi",
|
||||||
|
dnsServers: ["1.1.1.1"],
|
||||||
|
searchDomains: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
||||||
const { getMacTailscalePath } = loadInternals();
|
const { getMacTailscalePath } = loadInternals();
|
||||||
assert.equal(typeof getMacTailscalePath, "function");
|
assert.equal(typeof getMacTailscalePath, "function");
|
||||||
@@ -293,6 +331,92 @@ test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process
|
|||||||
assert.equal(result.processes.includes("wireguard-go"), true);
|
assert.equal(result.processes.includes("wireguard-go"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseMacWireguardHelperStatus reads active helper key-value output", () => {
|
||||||
|
const { parseMacWireguardHelperStatus } = loadInternals();
|
||||||
|
assert.equal(typeof parseMacWireguardHelperStatus, "function");
|
||||||
|
|
||||||
|
const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n");
|
||||||
|
|
||||||
|
assert.equal(result.active, true);
|
||||||
|
assert.equal(result.interfaceName, "nordvpnctl");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => {
|
||||||
|
const { checkMacWireguardPersistence } = loadInternals();
|
||||||
|
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||||
|
|
||||||
|
const helperStatuses = [
|
||||||
|
{ active: false, interfaceName: "nordvpnctl" },
|
||||||
|
{ active: true, interfaceName: "nordvpnctl" },
|
||||||
|
];
|
||||||
|
const verifications = [
|
||||||
|
{ ok: false, ipInfo: { ok: false, error: "timeout" } },
|
||||||
|
{ ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await checkMacWireguardPersistence(
|
||||||
|
{ country: "Germany", city: "" },
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
delayMs: 1,
|
||||||
|
getHelperStatus: async () => helperStatuses.shift(),
|
||||||
|
verifyConnection: async () => verifications.shift(),
|
||||||
|
sleep: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.stable, true);
|
||||||
|
assert.equal(result.attempts, 2);
|
||||||
|
assert.equal(result.helperStatus.active, true);
|
||||||
|
assert.equal(result.verified.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => {
|
||||||
|
const { checkMacWireguardPersistence } = loadInternals();
|
||||||
|
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||||
|
|
||||||
|
const result = await checkMacWireguardPersistence(
|
||||||
|
{ country: "Germany", city: "" },
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
delayMs: 1,
|
||||||
|
getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }),
|
||||||
|
verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }),
|
||||||
|
sleep: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.stable, false);
|
||||||
|
assert.equal(result.attempts, 2);
|
||||||
|
assert.equal(result.helperStatus.active, false);
|
||||||
|
assert.equal(result.verified.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => {
|
||||||
|
const { acquireOperationLock } = loadInternals();
|
||||||
|
assert.equal(typeof acquireOperationLock, "function");
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-"));
|
||||||
|
const lockPath = path.join(tmpDir, "operation.lock");
|
||||||
|
fs.writeFileSync(
|
||||||
|
lockPath,
|
||||||
|
JSON.stringify({
|
||||||
|
action: "connect",
|
||||||
|
pid: 0,
|
||||||
|
startedAt: new Date(0).toISOString(),
|
||||||
|
startedAtMs: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const lock = acquireOperationLock("disconnect", { lockPath });
|
||||||
|
const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||||
|
|
||||||
|
assert.equal(lockFile.action, "disconnect");
|
||||||
|
assert.equal(lockFile.pid, process.pid);
|
||||||
|
lock.release();
|
||||||
|
assert.equal(fs.existsSync(lockPath), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
||||||
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
||||||
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
||||||
@@ -370,14 +494,15 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve
|
|||||||
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shouldFinalizeMacWireguardConnect requires a verified wireguard connect", () => {
|
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
|
||||||
const { shouldFinalizeMacWireguardConnect } = loadInternals();
|
const { shouldFinalizeMacWireguardConnect } = loadInternals();
|
||||||
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
|
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
|
||||||
|
|
||||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }), true);
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
|
||||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }), false);
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
|
||||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }), false);
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false);
|
||||||
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }), false);
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
|
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ set -eu
|
|||||||
|
|
||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
probe|up|down)
|
probe|up|down|status|cleanup)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down]" >&2
|
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
||||||
|
WG="/opt/homebrew/bin/wg"
|
||||||
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
||||||
|
WG_INTERFACE="nordvpnctl"
|
||||||
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
export PATH
|
export PATH
|
||||||
|
|
||||||
@@ -21,4 +23,25 @@ if [ "$ACTION" = "probe" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" = "status" ]; then
|
||||||
|
ACTIVE=0
|
||||||
|
if [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "active=$ACTIVE"
|
||||||
|
echo "interfaceName=$WG_INTERFACE"
|
||||||
|
if [ -f "$WG_CONFIG" ]; then
|
||||||
|
echo "configPath=$WG_CONFIG"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" = "cleanup" ]; then
|
||||||
|
"$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$WG_QUICK" "$ACTION" "$WG_CONFIG"
|
exec "$WG_QUICK" "$ACTION" "$WG_CONFIG"
|
||||||
|
|||||||
Reference in New Issue
Block a user