feat(nordvpn-client): implement milestone M1 diagnostics and classification
This commit is contained in:
@@ -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_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 OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
@@ -276,6 +277,236 @@ function buildMacTailscaleState(tailscaleWasActive) {
|
|||||||
return { tailscaleWasActive: Boolean(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 = {}) {
|
function getMacTailscalePath(deps = {}) {
|
||||||
const commandExistsFn = deps.commandExists || commandExists;
|
const commandExistsFn = deps.commandExists || commandExists;
|
||||||
const fileExistsFn = deps.fileExists || fileExists;
|
const fileExistsFn = deps.fileExists || fileExists;
|
||||||
@@ -322,7 +553,18 @@ async function stopMacTailscaleIfActive() {
|
|||||||
|
|
||||||
async function resumeMacTailscaleIfNeeded() {
|
async function resumeMacTailscaleIfNeeded() {
|
||||||
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
|
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 };
|
return { restored: false };
|
||||||
}
|
}
|
||||||
const tailscale = getMacTailscalePath();
|
const tailscale = getMacTailscalePath();
|
||||||
@@ -385,12 +627,41 @@ function cleanupMacWireguardState(paths = {}) {
|
|||||||
return { cleaned };
|
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) {
|
function shouldAttemptMacWireguardDisconnect(wireguardState) {
|
||||||
if (!wireguardState) return false;
|
if (!wireguardState) return false;
|
||||||
if (wireguardState.active) return true;
|
if (wireguardState.active) return true;
|
||||||
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
|
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) {
|
function normalizeSuccessfulConnectState(state, connectResult, verified) {
|
||||||
if (
|
if (
|
||||||
!state ||
|
!state ||
|
||||||
@@ -539,6 +810,7 @@ async function probeMacWireguard() {
|
|||||||
const wgQuickPath = commandExists("wg-quick");
|
const wgQuickPath = commandExists("wg-quick");
|
||||||
const wireguardGoPath = commandExists("wireguard-go");
|
const wireguardGoPath = commandExists("wireguard-go");
|
||||||
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 sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
||||||
let active = false;
|
let active = false;
|
||||||
let showRaw = "";
|
let showRaw = "";
|
||||||
@@ -571,6 +843,7 @@ async function probeMacWireguard() {
|
|||||||
wgQuickPath: wgQuickPath || null,
|
wgQuickPath: wgQuickPath || null,
|
||||||
wireguardGoPath: wireguardGoPath || null,
|
wireguardGoPath: wireguardGoPath || null,
|
||||||
helperPath,
|
helperPath,
|
||||||
|
helperSecurity,
|
||||||
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
||||||
sudoReady: sudoProbe.ok,
|
sudoReady: sudoProbe.ok,
|
||||||
interfaceName: MAC_WG_INTERFACE,
|
interfaceName: MAC_WG_INTERFACE,
|
||||||
@@ -668,7 +941,9 @@ function buildStateSummary(installProbe, ipInfo) {
|
|||||||
connectMode = "wireguard";
|
connectMode = "wireguard";
|
||||||
recommendedAction = installProbe.tokenAvailable
|
recommendedAction = installProbe.tokenAvailable
|
||||||
? installProbe.wireguard.sudoReady
|
? 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.`
|
: `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.`;
|
: `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) {
|
} else if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
||||||
@@ -716,6 +991,7 @@ function buildStateSummary(installProbe, ipInfo) {
|
|||||||
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
||||||
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
||||||
helperPath: installProbe.wireguard.helperPath,
|
helperPath: installProbe.wireguard.helperPath,
|
||||||
|
helperSecurity: installProbe.wireguard.helperSecurity,
|
||||||
authCache: installProbe.wireguard.authCache,
|
authCache: installProbe.wireguard.authCache,
|
||||||
lastConnection: installProbe.wireguard.lastConnection,
|
lastConnection: installProbe.wireguard.lastConnection,
|
||||||
}
|
}
|
||||||
@@ -996,7 +1272,6 @@ 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}`,
|
||||||
@@ -1123,10 +1398,7 @@ async function connectViaMacWireguard(installProbe, target) {
|
|||||||
|
|
||||||
const down = await runSudoWireguard(installProbe, "down");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase();
|
// Ignore the common no-active-interface case before reconnecting.
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const up = await runSudoWireguard(installProbe, "up");
|
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");
|
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 {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
server: {
|
server: {
|
||||||
@@ -1193,10 +1448,12 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
|
|
||||||
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
||||||
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
|
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
|
||||||
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: false,
|
changed: false,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "No active macOS WireGuard NordVPN connection found.",
|
message: "No active macOS WireGuard NordVPN connection found.",
|
||||||
};
|
};
|
||||||
@@ -1204,30 +1461,29 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
const down = await runSudoWireguard(installProbe, "down");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
const message = (down.stderr || down.stdout || down.error).trim();
|
const message = (down.stderr || down.stdout || down.error).trim();
|
||||||
const normalized = message.toLowerCase();
|
if (isBenignMacWireguardAbsentError(message)) {
|
||||||
if (
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
normalized.includes("is not a known interface") ||
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
normalized.includes("unable to access interface") ||
|
|
||||||
normalized.includes("not found")
|
|
||||||
) {
|
|
||||||
const cleaned = cleanupMacWireguardState();
|
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: false,
|
changed: false,
|
||||||
stateCleaned: cleaned.cleaned,
|
stateCleaned: cleaned.cleaned,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "No active macOS WireGuard NordVPN connection found.",
|
message: "No active macOS WireGuard NordVPN connection found.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(message || "wg-quick down failed");
|
throw new Error(message || "wg-quick down failed");
|
||||||
}
|
}
|
||||||
const cleaned = cleanupMacWireguardState();
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: true,
|
changed: true,
|
||||||
stateCleaned: cleaned.cleaned,
|
stateCleaned: cleaned.cleaned,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "Disconnected the macOS NordLynx/WireGuard session.",
|
message: "Disconnected the macOS NordLynx/WireGuard session.",
|
||||||
};
|
};
|
||||||
@@ -1392,7 +1648,70 @@ async function main() {
|
|||||||
|
|
||||||
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
||||||
const refreshed = await probeInstallation(platform);
|
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(
|
emitJson(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
@@ -1419,7 +1738,24 @@ async function main() {
|
|||||||
|
|
||||||
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
||||||
} catch (error) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,38 @@ function loadInternals() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildMacTailscaleState:
|
buildMacTailscaleState:
|
||||||
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
||||||
|
buildMacDnsState:
|
||||||
|
typeof buildMacDnsState === "function" ? buildMacDnsState : 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,
|
||||||
cleanupMacWireguardState:
|
cleanupMacWireguardState:
|
||||||
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
||||||
|
cleanupMacWireguardAndDnsState:
|
||||||
|
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
|
||||||
|
collectMacWireguardDiagnostics:
|
||||||
|
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
|
||||||
|
inspectMacWireguardHelperSecurity:
|
||||||
|
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
|
||||||
getMacTailscalePath:
|
getMacTailscalePath:
|
||||||
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
||||||
|
isBenignMacWireguardAbsentError:
|
||||||
|
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
||||||
isMacTailscaleActive:
|
isMacTailscaleActive:
|
||||||
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : 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,
|
||||||
|
shouldManageMacDnsService:
|
||||||
|
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
||||||
sanitizeOutputPayload:
|
sanitizeOutputPayload:
|
||||||
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
||||||
|
shouldFinalizeMacWireguardConnect:
|
||||||
|
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
|
||||||
|
shouldResumeMacTailscale:
|
||||||
|
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
|
||||||
shouldAttemptMacWireguardDisconnect:
|
shouldAttemptMacWireguardDisconnect:
|
||||||
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
||||||
detectMacWireguardActiveFromIfconfig:
|
detectMacWireguardActiveFromIfconfig:
|
||||||
@@ -78,7 +94,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 includes NordVPN DNS for the vanilla macOS config path", () => {
|
test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => {
|
||||||
const { buildWireguardConfig } = loadInternals();
|
const { buildWireguardConfig } = loadInternals();
|
||||||
assert.equal(typeof buildWireguardConfig, "function");
|
assert.equal(typeof buildWireguardConfig, "function");
|
||||||
|
|
||||||
@@ -91,10 +107,41 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat
|
|||||||
"PRIVATEKEY"
|
"PRIVATEKEY"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true);
|
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false);
|
||||||
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => {
|
||||||
|
const { shouldManageMacDnsService } = loadInternals();
|
||||||
|
assert.equal(typeof shouldManageMacDnsService, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldManageMacDnsService("Wi-Fi"), true);
|
||||||
|
assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true);
|
||||||
|
assert.equal(shouldManageMacDnsService("Tailscale"), false);
|
||||||
|
assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false);
|
||||||
|
assert.equal(shouldManageMacDnsService("Acme VPN"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildMacDnsState records DNS and search domains per service", () => {
|
||||||
|
const { buildMacDnsState } = loadInternals();
|
||||||
|
assert.equal(typeof buildMacDnsState, "function");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(
|
||||||
|
buildMacDnsState([
|
||||||
|
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||||
|
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
JSON.stringify({
|
||||||
|
services: [
|
||||||
|
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||||
|
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
@@ -118,6 +165,16 @@ test("buildMacTailscaleState records whether tailscale was active", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldResumeMacTailscale only resumes when previously active and not already running", () => {
|
||||||
|
const { shouldResumeMacTailscale } = loadInternals();
|
||||||
|
assert.equal(typeof shouldResumeMacTailscale, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true);
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false);
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false);
|
||||||
|
assert.equal(shouldResumeMacTailscale(null, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
||||||
const { cleanupMacWireguardState } = loadInternals();
|
const { cleanupMacWireguardState } = loadInternals();
|
||||||
assert.equal(typeof cleanupMacWireguardState, "function");
|
assert.equal(typeof cleanupMacWireguardState, "function");
|
||||||
@@ -138,6 +195,104 @@ test("cleanupMacWireguardState removes stale config and last-connection files",
|
|||||||
assert.equal(fs.existsSync(lastConnectionPath), false);
|
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => {
|
||||||
|
const { cleanupMacWireguardAndDnsState } = loadInternals();
|
||||||
|
assert.equal(typeof cleanupMacWireguardAndDnsState, "function");
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
|
||||||
|
const configPath = path.join(tmpDir, "nordvpnctl.conf");
|
||||||
|
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
|
||||||
|
const dnsStatePath = path.join(tmpDir, "dns.json");
|
||||||
|
fs.writeFileSync(configPath, "wireguard-config");
|
||||||
|
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
|
||||||
|
fs.writeFileSync(dnsStatePath, "{\"services\":[]}");
|
||||||
|
|
||||||
|
const result = cleanupMacWireguardAndDnsState({
|
||||||
|
configPath,
|
||||||
|
lastConnectionPath,
|
||||||
|
dnsStatePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.cleaned, true);
|
||||||
|
assert.equal(fs.existsSync(configPath), false);
|
||||||
|
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||||
|
assert.equal(fs.existsSync(dnsStatePath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => {
|
||||||
|
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||||
|
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||||
|
|
||||||
|
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||||
|
fileExists: () => true,
|
||||||
|
statSync: () => ({
|
||||||
|
uid: 501,
|
||||||
|
gid: 20,
|
||||||
|
mode: 0o100755,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.exists, true);
|
||||||
|
assert.equal(result.hardened, false);
|
||||||
|
assert.match(result.reason, /root-owned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => {
|
||||||
|
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||||
|
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||||
|
|
||||||
|
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||||
|
fileExists: () => true,
|
||||||
|
statSync: () => ({
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
mode: 0o100755,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.exists, true);
|
||||||
|
assert.equal(result.hardened, true);
|
||||||
|
assert.equal(result.reason, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => {
|
||||||
|
const { collectMacWireguardDiagnostics } = loadInternals();
|
||||||
|
assert.equal(typeof collectMacWireguardDiagnostics, "function");
|
||||||
|
|
||||||
|
const seen = [];
|
||||||
|
const result = await collectMacWireguardDiagnostics({
|
||||||
|
interfaceName: "nordvpnctl",
|
||||||
|
runExec: async (command, args) => {
|
||||||
|
seen.push(`${command} ${args.join(" ")}`);
|
||||||
|
if (command === "/opt/homebrew/bin/wg") {
|
||||||
|
return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "ifconfig") {
|
||||||
|
return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "route") {
|
||||||
|
return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "pgrep") {
|
||||||
|
return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected command: ${command}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
"/opt/homebrew/bin/wg show nordvpnctl",
|
||||||
|
"ifconfig nordvpnctl",
|
||||||
|
"route -n get default",
|
||||||
|
"pgrep -fl wireguard-go|wg-quick|nordvpnctl",
|
||||||
|
]);
|
||||||
|
assert.equal(result.interfaceName, "nordvpnctl");
|
||||||
|
assert.equal(result.wgShow.includes("peer: abc123"), true);
|
||||||
|
assert.equal(result.ifconfig.includes("10.5.0.2"), true);
|
||||||
|
assert.equal(result.routes.includes("default 10.5.0.2"), true);
|
||||||
|
assert.equal(result.processes.includes("wireguard-go"), true);
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
@@ -173,6 +328,15 @@ test("shouldAttemptMacWireguardDisconnect does not trust active=false when resid
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => {
|
||||||
|
const { isBenignMacWireguardAbsentError } = loadInternals();
|
||||||
|
assert.equal(typeof isBenignMacWireguardAbsentError, "function");
|
||||||
|
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true);
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true);
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("permission denied"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
||||||
const { normalizeSuccessfulConnectState } = loadInternals();
|
const { normalizeSuccessfulConnectState } = loadInternals();
|
||||||
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
||||||
@@ -206,6 +370,16 @@ 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", () => {
|
||||||
|
const { shouldFinalizeMacWireguardConnect } = loadInternals();
|
||||||
|
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }), true);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: 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", () => {
|
||||||
const { normalizeStatusState } = loadInternals();
|
const { normalizeStatusState } = loadInternals();
|
||||||
assert.equal(typeof normalizeStatusState, "function");
|
assert.equal(typeof normalizeStatusState, "function");
|
||||||
|
|||||||
Reference in New Issue
Block a user