feat(nordvpn-client): implement milestone M1 diagnostics and classification

This commit is contained in:
2026-03-30 11:34:20 -05:00
parent 4919edcec1
commit 8d2c162849
2 changed files with 546 additions and 36 deletions

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_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
os.homedir(),
@@ -276,6 +277,236 @@ function buildMacTailscaleState(tailscaleWasActive) {
return { tailscaleWasActive: Boolean(tailscaleWasActive) };
}
function inspectMacWireguardHelperSecurity(helperPath, deps = {}) {
const fileExistsFn = deps.fileExists || fileExists;
const statSyncFn = deps.statSync || fs.statSync;
if (!helperPath || !fileExistsFn(helperPath)) {
return {
exists: false,
hardened: false,
ownerUid: null,
groupGid: null,
mode: null,
reason: "Helper is missing.",
};
}
let stat;
try {
stat = statSyncFn(helperPath);
} catch (error) {
return {
exists: true,
hardened: false,
ownerUid: null,
groupGid: null,
mode: null,
reason: error.message || "Unable to inspect helper security.",
};
}
const mode = stat.mode & 0o777;
if (stat.uid !== 0) {
return {
exists: true,
hardened: false,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "Helper must be root-owned before privileged actions are trusted.",
};
}
if ((mode & 0o022) !== 0) {
return {
exists: true,
hardened: false,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "Helper must not be group- or world-writable.",
};
}
return {
exists: true,
hardened: true,
ownerUid: stat.uid,
groupGid: stat.gid,
mode,
reason: "",
};
}
function trimDiagnosticOutput(value, maxChars = 4000) {
const text = `${value || ""}`.trim();
if (!text) return "";
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n...[truncated]`;
}
function summarizeMacWireguardDiagnostics(diagnostics) {
if (!diagnostics) return null;
return {
interfaceName: diagnostics.interfaceName || MAC_WG_INTERFACE,
wgShowCaptured: Boolean(diagnostics.wgShow),
ifconfigCaptured: Boolean(diagnostics.ifconfig),
routesCaptured: Boolean(diagnostics.routes),
processesCaptured: Boolean(diagnostics.processes),
helperSecurity:
diagnostics.helperSecurity && typeof diagnostics.helperSecurity === "object"
? {
hardened: Boolean(diagnostics.helperSecurity.hardened),
reason: diagnostics.helperSecurity.reason || "",
}
: null,
};
}
async function collectMacWireguardDiagnostics(options = {}) {
const runExecFn = options.runExec || runExec;
const interfaceName = options.interfaceName || MAC_WG_INTERFACE;
const wgPath = options.wgPath || commandExists("wg") || "/opt/homebrew/bin/wg";
const helperSecurity =
options.helperSecurity ||
inspectMacWireguardHelperSecurity(options.helperPath || MAC_WG_HELPER_PATH, options.securityDeps || {});
const [wgShow, ifconfig, routes, processes] = await Promise.all([
runExecFn(wgPath, ["show", interfaceName]),
runExecFn("ifconfig", [interfaceName]),
runExecFn("route", ["-n", "get", "default"]),
runExecFn("pgrep", ["-fl", "wireguard-go|wg-quick|nordvpnctl"]),
]);
return {
interfaceName,
helperSecurity,
wgShow: trimDiagnosticOutput(wgShow.stdout || wgShow.stderr || wgShow.error),
ifconfig: trimDiagnosticOutput(ifconfig.stdout || ifconfig.stderr || ifconfig.error),
routes: trimDiagnosticOutput(routes.stdout || routes.stderr || routes.error),
processes: trimDiagnosticOutput(processes.stdout || processes.stderr || processes.error),
};
}
function shouldResumeMacTailscale(state, currentlyActive) {
return Boolean(state && state.tailscaleWasActive && !currentlyActive);
}
function buildMacDnsState(services) {
return {
services: (services || []).map((service) => ({
name: service.name,
dnsServers: Array.isArray(service.dnsServers) ? service.dnsServers : [],
searchDomains: Array.isArray(service.searchDomains) ? service.searchDomains : [],
})),
};
}
function shouldManageMacDnsService(serviceName) {
const normalized = `${serviceName || ""}`.trim().toLowerCase();
if (!normalized) return false;
return !["tailscale", "bridge", "thunderbolt bridge", "loopback", "vpn", "utun"].some((token) => normalized.includes(token));
}
function normalizeMacNetworksetupList(output) {
return `${output || ""}`
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.startsWith("An asterisk"))
.map((line) => (line.startsWith("*") ? line.slice(1).trim() : line))
.filter((line) => shouldManageMacDnsService(line));
}
function normalizeMacDnsCommandOutput(output) {
const text = `${output || ""}`.trim();
if (!text || text.includes("aren't any") || text.includes("There aren't any")) {
return [];
}
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function listMacDnsServices() {
const result = await runExec("networksetup", ["-listallnetworkservices"]);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "networksetup -listallnetworkservices failed");
}
return normalizeMacNetworksetupList(result.stdout);
}
async function readMacDnsStateForService(serviceName) {
const dnsResult = await runExec("networksetup", ["-getdnsservers", serviceName]);
if (!dnsResult.ok) {
throw new Error((dnsResult.stderr || dnsResult.stdout || dnsResult.error).trim() || `networksetup -getdnsservers failed for ${serviceName}`);
}
const searchResult = await runExec("networksetup", ["-getsearchdomains", serviceName]);
if (!searchResult.ok) {
throw new Error((searchResult.stderr || searchResult.stdout || searchResult.error).trim() || `networksetup -getsearchdomains failed for ${serviceName}`);
}
return {
name: serviceName,
dnsServers: normalizeMacDnsCommandOutput(dnsResult.stdout),
searchDomains: normalizeMacDnsCommandOutput(searchResult.stdout),
};
}
async function snapshotMacDnsState() {
const services = await listMacDnsServices();
const snapshot = [];
for (const serviceName of services) {
snapshot.push(await readMacDnsStateForService(serviceName));
}
const state = buildMacDnsState(snapshot);
writeJsonFile(MAC_DNS_STATE_PATH, state);
return state;
}
async function setMacDnsServers(serviceName, dnsServers) {
const args = ["-setdnsservers", serviceName];
args.push(...(dnsServers && dnsServers.length ? dnsServers : ["Empty"]));
const result = await runExec("networksetup", args);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setdnsservers failed for ${serviceName}`);
}
}
async function setMacSearchDomains(serviceName, searchDomains) {
const args = ["-setsearchdomains", serviceName];
args.push(...(searchDomains && searchDomains.length ? searchDomains : ["Empty"]));
const result = await runExec("networksetup", args);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setsearchdomains failed for ${serviceName}`);
}
}
async function applyMacNordDns() {
const snapshot = readJsonFile(MAC_DNS_STATE_PATH) || (await snapshotMacDnsState());
for (const service of snapshot.services || []) {
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
await setMacSearchDomains(service.name, []);
}
return snapshot;
}
async function restoreMacDnsIfNeeded() {
const snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
return { restored: false };
}
for (const service of snapshot.services) {
await setMacDnsServers(service.name, service.dnsServers || []);
await setMacSearchDomains(service.name, service.searchDomains || []);
}
try {
fs.unlinkSync(MAC_DNS_STATE_PATH);
} catch {
// Ignore unlink errors.
}
return { restored: true };
}
function getMacTailscalePath(deps = {}) {
const commandExistsFn = deps.commandExists || commandExists;
const fileExistsFn = deps.fileExists || fileExists;
@@ -322,7 +553,18 @@ async function stopMacTailscaleIfActive() {
async function resumeMacTailscaleIfNeeded() {
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
if (!state || !state.tailscaleWasActive) {
let currentStatus = null;
try {
currentStatus = await getMacTailscaleStatus();
} catch {
currentStatus = null;
}
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
try {
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
} catch {
// Ignore unlink errors.
}
return { restored: false };
}
const tailscale = getMacTailscalePath();
@@ -385,12 +627,41 @@ function cleanupMacWireguardState(paths = {}) {
return { cleaned };
}
function cleanupMacWireguardAndDnsState(paths = {}) {
const cleaned = cleanupMacWireguardState(paths).cleaned;
const dnsStatePath = paths.dnsStatePath || MAC_DNS_STATE_PATH;
let dnsCleaned = false;
try {
if (dnsStatePath && fs.existsSync(dnsStatePath)) {
fs.unlinkSync(dnsStatePath);
dnsCleaned = true;
}
} catch {
// Ignore cleanup errors; caller will rely on current runtime state.
}
return { cleaned: cleaned || dnsCleaned };
}
function shouldAttemptMacWireguardDisconnect(wireguardState) {
if (!wireguardState) return false;
if (wireguardState.active) return true;
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
}
function isBenignMacWireguardAbsentError(message) {
const normalized = `${message || ""}`.trim().toLowerCase();
return (
normalized.includes("is not a wireguard interface") ||
normalized.includes("is not a known interface") ||
normalized.includes("unable to access interface") ||
normalized.includes("not found")
);
}
function shouldFinalizeMacWireguardConnect(connectResult, verified) {
return Boolean(connectResult && connectResult.backend === "wireguard" && verified && verified.ok);
}
function normalizeSuccessfulConnectState(state, connectResult, verified) {
if (
!state ||
@@ -539,6 +810,7 @@ async function probeMacWireguard() {
const wgQuickPath = commandExists("wg-quick");
const wireguardGoPath = commandExists("wireguard-go");
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 };
let active = false;
let showRaw = "";
@@ -571,6 +843,7 @@ async function probeMacWireguard() {
wgQuickPath: wgQuickPath || null,
wireguardGoPath: wireguardGoPath || null,
helperPath,
helperSecurity,
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
sudoReady: sudoProbe.ok,
interfaceName: MAC_WG_INTERFACE,
@@ -668,7 +941,9 @@ function buildStateSummary(installProbe, ipInfo) {
connectMode = "wireguard";
recommendedAction = installProbe.tokenAvailable
? installProbe.wireguard.sudoReady
? "Use token-based WireGuard automation on macOS."
? installProbe.wireguard.helperSecurity && !installProbe.wireguard.helperSecurity.hardened
? `WireGuard tooling is available, but the helper at ${MAC_WG_HELPER_PATH} is not hardened yet: ${installProbe.wireguard.helperSecurity.reason}`
: "Use token-based WireGuard automation on macOS."
: `WireGuard tooling and token are available, but connect/disconnect require non-interactive sudo for ${MAC_WG_HELPER_PATH}. Allow that helper in sudoers, then rerun login/connect.`
: `Set NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or place the token at ${DEFAULT_TOKEN_FILE} for automated macOS NordLynx/WireGuard connects.`;
} else if (installProbe.platform === "darwin" && installProbe.appInstalled) {
@@ -716,6 +991,7 @@ function buildStateSummary(installProbe, ipInfo) {
wgQuickPath: installProbe.wireguard.wgQuickPath,
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
helperPath: installProbe.wireguard.helperPath,
helperSecurity: installProbe.wireguard.helperSecurity,
authCache: installProbe.wireguard.authCache,
lastConnection: installProbe.wireguard.lastConnection,
}
@@ -996,7 +1272,6 @@ function buildWireguardConfig(server, privateKey) {
"[Interface]",
`PrivateKey = ${privateKey}`,
`Address = ${addresses.join(", ")}`,
`DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`,
"",
"[Peer]",
`PublicKey = ${publicKey}`,
@@ -1123,10 +1398,7 @@ async function connectViaMacWireguard(installProbe, target) {
const down = await runSudoWireguard(installProbe, "down");
if (!down.ok) {
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase();
if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) {
// Ignore only the common no-active-interface case.
}
// Ignore the common no-active-interface case before reconnecting.
}
const up = await runSudoWireguard(installProbe, "up");
@@ -1137,23 +1409,6 @@ async function connectViaMacWireguard(installProbe, target) {
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
}
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: target,
resolvedTarget: {
country: targetMeta.country.name,
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
},
server: {
hostname: selectedServer.hostname,
city: getServerCityName(selectedServer),
country: getServerCountryName(selectedServer),
load: selectedServer.load,
},
connectedAt: new Date().toISOString(),
});
return {
backend: "wireguard",
server: {
@@ -1193,10 +1448,12 @@ async function disconnectNordvpn(installProbe) {
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
const dnsState = await restoreMacDnsIfNeeded();
const tailscale = await resumeMacTailscaleIfNeeded();
return {
backend: "wireguard",
changed: false,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored,
message: "No active macOS WireGuard NordVPN connection found.",
};
@@ -1204,30 +1461,29 @@ async function disconnectNordvpn(installProbe) {
const down = await runSudoWireguard(installProbe, "down");
if (!down.ok) {
const message = (down.stderr || down.stdout || down.error).trim();
const normalized = message.toLowerCase();
if (
normalized.includes("is not a known interface") ||
normalized.includes("unable to access interface") ||
normalized.includes("not found")
) {
const cleaned = cleanupMacWireguardState();
if (isBenignMacWireguardAbsentError(message)) {
const dnsState = await restoreMacDnsIfNeeded();
const cleaned = cleanupMacWireguardAndDnsState();
const tailscale = await resumeMacTailscaleIfNeeded();
return {
backend: "wireguard",
changed: false,
stateCleaned: cleaned.cleaned,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored,
message: "No active macOS WireGuard NordVPN connection found.",
};
}
throw new Error(message || "wg-quick down failed");
}
const cleaned = cleanupMacWireguardState();
const dnsState = await restoreMacDnsIfNeeded();
const cleaned = cleanupMacWireguardAndDnsState();
const tailscale = await resumeMacTailscaleIfNeeded();
return {
backend: "wireguard",
changed: true,
stateCleaned: cleaned.cleaned,
dnsRestored: dnsState.restored,
tailscaleRestored: tailscale.restored,
message: "Disconnected the macOS NordLynx/WireGuard session.",
};
@@ -1392,7 +1648,70 @@ async function main() {
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
const refreshed = await probeInstallation(platform);
const connectState = normalizeSuccessfulConnectState(buildStateSummary(refreshed, verified.ipInfo), connectResult, verified);
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(),
});
} catch (error) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
emitJson(
{
action,
requestedTarget: target,
connectResult,
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
);
emitJson(
{
action,
@@ -1419,7 +1738,24 @@ async function main() {
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
} catch (error) {
emitJson({ error: error.message || String(error), action }, 1, true);
const payload = { error: error.message || String(error), action };
if (action === "connect" && platform === "darwin" && installProbe && installProbe.wireguard) {
try {
const refreshed = await probeInstallation(platform);
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
payload.state = buildStateSummary(refreshed, await getPublicIpInfo());
payload.diagnostics = debugOutput ? diagnostics : undefined;
payload.diagnosticsSummary = summarizeMacWireguardDiagnostics(diagnostics);
} catch {
// Fall back to the base error payload if diagnostic capture also fails.
}
}
emitJson(payload, 1, true);
}
}