4 Commits

5 changed files with 1220 additions and 85 deletions

View File

@@ -69,13 +69,19 @@ Current macOS backend:
- NordLynx/WireGuard - NordLynx/WireGuard
- `wireguard-go` - `wireguard-go`
- `wireguard-tools` - `wireguard-tools`
- NordVPN DNS in the generated WireGuard config: - explicit macOS DNS management on eligible physical services:
- `103.86.96.100` - `103.86.96.100`
- `103.86.99.100` - `103.86.99.100`
Important behavior: Important behavior:
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state. - `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
- the generated WireGuard config intentionally stays free of `DNS = ...` so `wg-quick` does not rewrite every macOS network service behind the skills back.
- during `connect`, the skill first proves the tunnel is stable with a bounded persistence gate that reuses the allowed helper `probe` action and a verified public exit.
- during `connect`, the skill snapshots current DNS/search-domain settings on eligible physical services and then applies NordVPN DNS only after that stable gate, one last liveness check, and a post-DNS system-hostname-resolution check succeed.
- during `disconnect`, or after a failed/stale teardown, the skill restores the saved DNS/search-domain snapshot.
- if persistence, exit verification, or post-DNS hostname resolution fails, the skill rolls back before treating the connect as successful and resumes Tailscale if it stopped it.
- when the skill intentionally stops Tailscale for a VPN session, it writes a short-lived suppression marker so host watchdogs do not immediately run `tailscale up` and fight the VPN route change.
- The skill automatically suspends Tailscale before connect if Tailscale is active. - The skill automatically suspends Tailscale before connect if Tailscale is active.
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it. - The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
- The Homebrew NordVPN app does not need to be uninstalled. - The Homebrew NordVPN app does not need to be uninstalled.
@@ -144,6 +150,8 @@ Add this exact rule:
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
``` ```
Do not add extra helper actions just for persistence checks unless you are also updating host sudoers. The current implementation intentionally rides the persistence check on `probe` so the existing `probe/up/down` rule remains sufficient.
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly. If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
## Common Flows ## Common Flows
@@ -188,7 +196,9 @@ Expected macOS behavior:
- stop Tailscale if active - stop Tailscale if active
- select a NordVPN server for the target - select a NordVPN server for the target
- bring up the WireGuard tunnel - bring up the WireGuard tunnel
- prove persistence of the live `utun*` runtime via the helper `probe` path
- verify the public exit location - verify the public exit location
- run one final liveness check before applying NordVPN DNS
- return JSON describing the chosen server and final verified location - return JSON describing the chosen server and final verified location
### Verify ### Verify
@@ -209,6 +219,7 @@ Expected macOS behavior:
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state - attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
- remove stale local NordVPN state files after teardown - remove stale local NordVPN state files after teardown
- restore automatic DNS when the saved DNS snapshot is obviously just NordVPN-pinned leftovers
- resume Tailscale if the skill had suspended it - resume Tailscale if the skill had suspended it
## Output Model ## Output Model
@@ -238,7 +249,9 @@ For deeper troubleshooting, use:
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
``` ```
`--debug` keeps the internal local paths and other low-level metadata in the JSON output. `--debug` keeps the internal local paths, helper-hardening diagnostics, and other low-level metadata in the JSON output.
If you also run local watchdogs such as `healthwatch.sh`, they should honor the NordVPN Tailscale suppression marker at `~/.nordvpn-client/tailscale-suppressed` and skip automatic `tailscale up` while the marker is fresh or the NordVPN WireGuard tunnel is active.
## Troubleshooting ## Troubleshooting

View File

@@ -41,7 +41,13 @@ node scripts/nordvpn-client.js status --debug
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools` - use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
- `install` bootstraps them with Homebrew - `install` bootstraps them with Homebrew
- `login` validates the token for the WireGuard backend - `login` validates the token for the WireGuard backend
- the generated WireGuard config stays free of `DNS = ...`
- `connect` now requires a bounded persistence gate plus a verified exit before success is declared
- the skill snapshots and applies NordVPN DNS only to eligible physical services while connected
- NordVPN DNS is applied only after the tunnel remains up, the final liveness check still shows the requested exit, and system hostname resolution still works afterward
- `disconnect` restores the saved DNS/search-domain state even if the tunnel state is stale
- Tailscale is suspended before connect and resumed after disconnect or failed connect - Tailscale is suspended before connect and resumed after disconnect or failed connect
- the skill writes a short-lived Tailscale suppression marker during VPN connect so host watchdogs do not immediately re-run `tailscale up`
- `NordVPN.app` may remain installed but is only the manual fallback - `NordVPN.app` may remain installed but is only the manual fallback
## Credentials ## Credentials
@@ -75,6 +81,10 @@ Exact `visudo` rule for the installed OpenClaw skill:
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
``` ```
Operational note:
- the persistence gate reuses the already-allowed `probe` action to confirm the live `utun*` WireGuard runtime and does not require extra sudoers actions beyond `probe`, `up`, and `down`
## Agent Guidance ## Agent Guidance
- run `status` first when the machine state is unclear - run `status` first when the machine state is unclear
@@ -83,10 +93,11 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
- use `connect` before location-sensitive skills such as `web-automation` - use `connect` before location-sensitive skills such as `web-automation`
- use `verify` after connect when you need an explicit location check - use `verify` after connect when you need an explicit location check
- use `disconnect` after the follow-up task - use `disconnect` after the follow-up task
- if `connect` fails its persistence or final verification gate, treat that as a safe rollback, not a partial success
## Output Rules ## Output Rules
- normal JSON output redacts local path metadata - normal JSON output redacts local path metadata and helper-hardening diagnostics
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata - use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
## Troubleshooting Cues ## Troubleshooting Cues
@@ -98,6 +109,7 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
- connect succeeds but final state looks inconsistent: - connect succeeds but final state looks inconsistent:
- rely on the verified public IP/location first - rely on the verified public IP/location first
- then inspect `status --debug` - then inspect `status --debug`
- `verified: true` but `persistence.stable: false` should not happen anymore; if it does, the skill should roll back instead of pinning DNS
- disconnect should leave: - disconnect should leave:
- normal public IP restored - normal public IP restored
- no active WireGuard state - no active WireGuard state

View File

@@ -14,7 +14,10 @@ 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 MAC_TAILSCALE_SUPPRESS_PATH = path.join(STATE_DIR, "tailscale-suppressed");
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",
@@ -37,7 +40,13 @@ 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 REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]); const CONNECT_PERSISTENCE_ATTEMPTS = 6;
const CONNECT_PERSISTENCE_DELAY_MS = 2000;
const CONNECT_TOTAL_TIMEOUT_MS = 90000;
const POST_DNS_RESOLUTION_HOSTNAMES = ["www.google.com", "api.openai.com", "docs.openclaw.ai"];
const POST_DNS_RESOLUTION_TIMEOUT_MS = 4000;
const POST_DNS_SETTLE_DELAY_MS = 1500;
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource", "helperSecurity"]);
function sanitizeOutputPayload(payload) { function sanitizeOutputPayload(payload) {
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
@@ -252,6 +261,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");
@@ -276,6 +357,281 @@ function buildMacTailscaleState(tailscaleWasActive) {
return { tailscaleWasActive: Boolean(tailscaleWasActive) }; return { tailscaleWasActive: Boolean(tailscaleWasActive) };
} }
function markMacTailscaleRecoverySuppressed() {
ensureDir(STATE_DIR);
writeTextFile(MAC_TAILSCALE_SUPPRESS_PATH, `${new Date().toISOString()}\n`, 0o600);
}
function clearMacTailscaleRecoverySuppressed() {
try {
fs.unlinkSync(MAC_TAILSCALE_SUPPRESS_PATH);
} catch {
// Ignore cleanup errors.
}
}
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);
}
function isNordDnsServerList(dnsServers) {
if (!Array.isArray(dnsServers) || !dnsServers.length) return false;
const normalized = dnsServers.map((value) => `${value}`.trim()).filter(Boolean);
if (!normalized.length) return false;
return normalized.every((value) => NORDVPN_MAC_DNS_SERVERS.includes(value));
}
function buildAutomaticMacDnsState(serviceNames) {
return {
services: (serviceNames || []).map((name) => ({
name,
dnsServers: [],
searchDomains: [],
})),
};
}
function shouldRejectMacDnsBaseline(state) {
if (!state || !Array.isArray(state.services) || !state.services.length) return false;
return state.services.every((service) => isNordDnsServerList(service.dnsServers || []));
}
async function listMacDnsServices() {
const result = await runExec("networksetup", ["-listallnetworkservices"]);
if (!result.ok) {
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() {
let snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
snapshot = await snapshotMacDnsState();
} else if (shouldRejectMacDnsBaseline(snapshot)) {
const services = await listMacDnsServices();
snapshot = buildAutomaticMacDnsState(services);
writeJsonFile(MAC_DNS_STATE_PATH, snapshot);
}
for (const service of snapshot.services || []) {
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
await setMacSearchDomains(service.name, []);
}
return snapshot;
}
async function restoreMacDnsIfNeeded() {
const snapshot = readJsonFile(MAC_DNS_STATE_PATH);
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
return { restored: false };
}
const servicesToRestore = shouldRejectMacDnsBaseline(snapshot)
? buildAutomaticMacDnsState(snapshot.services.map((service) => service.name)).services
: snapshot.services;
for (const service of servicesToRestore) {
await setMacDnsServers(service.name, service.dnsServers || []);
await setMacSearchDomains(service.name, service.searchDomains || []);
}
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;
@@ -308,12 +664,15 @@ async function getMacTailscaleStatus() {
async function stopMacTailscaleIfActive() { async function stopMacTailscaleIfActive() {
const status = await getMacTailscaleStatus(); const status = await getMacTailscaleStatus();
if (!status.active) { if (!status.active) {
clearMacTailscaleRecoverySuppressed();
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false)); writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false));
return { tailscaleWasActive: false }; return { tailscaleWasActive: false };
} }
markMacTailscaleRecoverySuppressed();
const tailscale = getMacTailscalePath(); const tailscale = getMacTailscalePath();
const result = await runExec(tailscale, ["down"]); const result = await runExec(tailscale, ["down"]);
if (!result.ok) { if (!result.ok) {
clearMacTailscaleRecoverySuppressed();
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed"); throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed");
} }
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true)); writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true));
@@ -322,7 +681,19 @@ 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)) {
clearMacTailscaleRecoverySuppressed();
try {
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
} catch {
// Ignore unlink errors.
}
return { restored: false }; return { restored: false };
} }
const tailscale = getMacTailscalePath(); const tailscale = getMacTailscalePath();
@@ -330,6 +701,7 @@ async function resumeMacTailscaleIfNeeded() {
if (!result.ok) { if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed"); throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed");
} }
clearMacTailscaleRecoverySuppressed();
try { try {
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH); fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
} catch { } catch {
@@ -362,6 +734,52 @@ async function resolveHostnameWithFallback(hostname, options = {}) {
return ""; return "";
} }
async function verifySystemHostnameResolution(hostnames = POST_DNS_RESOLUTION_HOSTNAMES, options = {}) {
const lookup =
options.lookup ||
((hostname) =>
dns.promises.lookup(hostname, {
family: 4,
}));
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : POST_DNS_RESOLUTION_TIMEOUT_MS;
const settleDelayMs =
Number.isFinite(options.settleDelayMs) && options.settleDelayMs >= 0 ? options.settleDelayMs : POST_DNS_SETTLE_DELAY_MS;
const errors = [];
if (settleDelayMs > 0) {
await sleep(settleDelayMs);
}
for (const hostname of hostnames) {
try {
const result = await Promise.race([
Promise.resolve().then(() => lookup(hostname)),
sleep(timeoutMs).then(() => {
throw new Error(`timeout after ${timeoutMs}ms`);
}),
]);
const address = Array.isArray(result) ? result[0] && result[0].address : result && result.address;
if (address) {
return {
ok: true,
hostname,
address,
};
}
errors.push(`${hostname}: no address returned`);
} catch (error) {
errors.push(`${hostname}: ${error.message || String(error)}`);
}
}
return {
ok: false,
hostname: "",
address: "",
error: errors.join("; "),
};
}
function buildLookupResult(address, options = {}) { function buildLookupResult(address, options = {}) {
if (options && options.all) { if (options && options.all) {
return [{ address, family: 4 }]; return [{ address, family: 4 }];
@@ -385,12 +803,134 @@ 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 parseMacWireguardHelperStatus(output) {
const parsed = {};
for (const line of `${output || ""}`.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
parsed[key] = value;
}
return {
active: ["1", "true", "yes", "on"].includes(`${parsed.active || ""}`.toLowerCase()),
interfaceName: parsed.interfaceName || MAC_WG_INTERFACE,
wireguardInterface: parsed.wireguardInterface || null,
configPath: parsed.configPath || null,
raw: `${output || ""}`.trim(),
};
}
async function getMacWireguardHelperStatus(installProbe, options = {}) {
const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard;
const result = await runSudoWireguardFn(installProbe, "probe");
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) {
if ( if (
!state || !state ||
@@ -539,15 +1079,21 @@ 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 };
const helperStatus = helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus(sudoProbe.stdout || sudoProbe.stderr || "") : 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"]);
@@ -571,10 +1117,11 @@ 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: 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,
@@ -716,6 +1263,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 +1544,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 +1670,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 +1681,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 +1720,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 +1733,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.",
}; };
@@ -1367,8 +1895,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);
@@ -1387,17 +1919,175 @@ 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 refreshed = await probeInstallation(platform);
const connectState = normalizeSuccessfulConnectState(buildStateSummary(refreshed, verified.ipInfo), connectResult, verified); 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( emitJson(
{ {
action, action,
requestedTarget: target, requestedTarget: target,
connectResult, connectResult,
persistence,
verified: false,
verification: verified && verified.ipInfo ? verified.ipInfo : null,
diagnostics: debugOutput ? diagnostics : undefined,
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
rollback,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = await verifyConnection(target);
} else {
verified = await verifyConnectionWithRetry(target, {
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
});
}
const refreshed = await probeInstallation(platform);
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
const diagnostics = await collectMacWireguardDiagnostics({
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
});
const rollback = await disconnectNordvpn(refreshed);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
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, persistence)) {
try {
await snapshotMacDnsState();
const liveness = await verifyConnection(target);
if (!liveness.ok) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: liveness.ipInfo,
rollback,
error: "Connected but failed the final liveness check before DNS finalization.",
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
verified = liveness;
await applyMacNordDns();
const dnsResolution = await verifySystemHostnameResolution();
if (!dnsResolution.ok) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: false,
verification: verified.ipInfo,
dnsResolution,
rollback,
error: `Connected but system DNS resolution failed after DNS finalization: ${dnsResolution.error}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
writeJsonFile(LAST_CONNECTION_PATH, {
backend: "wireguard",
interfaceName: MAC_WG_INTERFACE,
requestedTarget: connectResult.requestedTarget,
resolvedTarget: connectResult.resolvedTarget,
server: {
hostname: connectResult.server.hostname,
city: connectResult.server.city,
country: connectResult.server.country,
load: connectResult.server.load,
},
connectedAt: new Date().toISOString(),
});
} catch (error) {
const rollback = await disconnectNordvpn(await probeInstallation(platform));
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: true,
verification: verified.ipInfo,
rollback,
error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`,
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
},
1,
true
);
}
}
const connectState = normalizeSuccessfulConnectState(
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
connectResult,
verified
);
lock.release();
emitJson(
{
action,
requestedTarget: target,
connectResult,
persistence,
verified: verified.ok, verified: verified.ok,
verification: verified.ipInfo, verification: verified.ipInfo,
state: connectState, state: connectState,
@@ -1405,21 +2095,47 @@ 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);
} 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);
} }
} }

View File

@@ -11,28 +11,58 @@ function loadInternals() {
module.exports = { module.exports = {
buildMacTailscaleState: buildMacTailscaleState:
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined, typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
markMacTailscaleRecoverySuppressed:
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
clearMacTailscaleRecoverySuppressed:
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : 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,
acquireOperationLock:
typeof acquireOperationLock === "function" ? acquireOperationLock : 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,
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:
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:
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined, typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
resolveHostnameWithFallback: resolveHostnameWithFallback:
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined, typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
verifySystemHostnameResolution:
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
verifyConnectionWithRetry: verifyConnectionWithRetry:
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined, typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
};`; };`;
@@ -78,7 +108,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 +121,71 @@ 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("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");
@@ -118,6 +209,29 @@ test("buildMacTailscaleState records whether tailscale was active", () => {
); );
}); });
test("tailscale recovery suppression marker can be created and cleared", () => {
const { markMacTailscaleRecoverySuppressed, clearMacTailscaleRecoverySuppressed } = loadInternals();
assert.equal(typeof markMacTailscaleRecoverySuppressed, "function");
assert.equal(typeof clearMacTailscaleRecoverySuppressed, "function");
const markerPath = path.join(process.env.HOME || "", ".nordvpn-client", "tailscale-suppressed");
clearMacTailscaleRecoverySuppressed();
markMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), true);
clearMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), false);
});
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 +252,190 @@ 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("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");
@@ -173,6 +471,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 +513,17 @@ 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 and stable wireguard connect", () => {
const { shouldFinalizeMacWireguardConnect } = loadInternals();
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: 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", () => {
const { normalizeStatusState } = loadInternals(); const { normalizeStatusState } = loadInternals();
assert.equal(typeof normalizeStatusState, "function"); assert.equal(typeof normalizeStatusState, "function");
@@ -243,6 +561,10 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
wireguard: { wireguard: {
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf", configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh", helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
helperSecurity: {
hardened: false,
reason: "Helper must be root-owned before privileged actions are trusted.",
},
authCache: { authCache: {
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt", tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
}, },
@@ -254,6 +576,7 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
assert.equal(sanitized.appPath, null); assert.equal(sanitized.appPath, null);
assert.equal(sanitized.wireguard.configPath, null); assert.equal(sanitized.wireguard.configPath, null);
assert.equal(sanitized.wireguard.helperPath, null); assert.equal(sanitized.wireguard.helperPath, null);
assert.equal(sanitized.wireguard.helperSecurity, null);
assert.equal(sanitized.wireguard.authCache.tokenSource, null); assert.equal(sanitized.wireguard.authCache.tokenSource, null);
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820"); assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
}); });
@@ -307,3 +630,42 @@ test("resolveHostnameWithFallback uses fallback resolvers when system lookup fai
assert.equal(address, "104.26.9.44"); assert.equal(address, "104.26.9.44");
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]); assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
}); });
test("verifySystemHostnameResolution succeeds when any system lookup resolves", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const calls = [];
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
calls.push(hostname);
if (hostname === "www.google.com") {
throw new Error("ENOTFOUND");
}
return { address: "104.18.33.45", family: 4 };
},
});
assert.equal(result.ok, true);
assert.equal(result.hostname, "api.openai.com");
assert.equal(result.address, "104.18.33.45");
assert.deepEqual(calls, ["www.google.com", "api.openai.com"]);
});
test("verifySystemHostnameResolution fails when all hostnames fail system lookup", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
throw new Error(`${hostname}: timeout`);
},
});
assert.equal(result.ok, false);
assert.equal(result.hostname, "");
assert.match(result.error, /www\.google\.com/);
assert.match(result.error, /api\.openai\.com/);
});

View File

@@ -3,21 +3,53 @@ 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
if [ "$ACTION" = "probe" ]; then if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
test -x "$WG_QUICK" test -x "$WG_QUICK"
ACTIVE=0
RUNTIME_INTERFACE=""
if [ -x "$WG" ]; then
RUNTIME_INTERFACE=$("$WG" show interfaces 2>/dev/null | awk 'NF { print $1; exit }')
fi
if [ -n "$RUNTIME_INTERFACE" ]; then
ACTIVE=1
elif [ -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
elif pgrep -f "wg-quick up $WG_CONFIG" >/dev/null 2>&1; then
ACTIVE=1
elif pgrep -f "wireguard-go utun" >/dev/null 2>&1; then
ACTIVE=1
fi
echo "active=$ACTIVE"
echo "interfaceName=$WG_INTERFACE"
if [ -n "$RUNTIME_INTERFACE" ]; then
echo "wireguardInterface=$RUNTIME_INTERFACE"
fi
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 exit 0
fi fi