Compare commits
4 Commits
4919edcec1
...
57f6b132b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f6b132b2 | |||
| b3a59b5b45 | |||
| a796481875 | |||
| 8d2c162849 |
@@ -69,13 +69,19 @@ Current macOS backend:
|
||||
- NordLynx/WireGuard
|
||||
- `wireguard-go`
|
||||
- `wireguard-tools`
|
||||
- NordVPN DNS in the generated WireGuard config:
|
||||
- explicit macOS DNS management on eligible physical services:
|
||||
- `103.86.96.100`
|
||||
- `103.86.99.100`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `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 skill’s 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 resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
|
||||
- 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Common Flows
|
||||
@@ -188,7 +196,9 @@ Expected macOS behavior:
|
||||
- stop Tailscale if active
|
||||
- select a NordVPN server for the target
|
||||
- bring up the WireGuard tunnel
|
||||
- prove persistence of the live `utun*` runtime via the helper `probe` path
|
||||
- verify the public exit location
|
||||
- run one final liveness check before applying NordVPN DNS
|
||||
- return JSON describing the chosen server and final verified location
|
||||
|
||||
### Verify
|
||||
@@ -209,6 +219,7 @@ Expected macOS behavior:
|
||||
|
||||
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
|
||||
- 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
|
||||
|
||||
## Output Model
|
||||
@@ -238,7 +249,9 @@ For deeper troubleshooting, use:
|
||||
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
|
||||
|
||||
|
||||
@@ -41,7 +41,13 @@ node scripts/nordvpn-client.js status --debug
|
||||
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
|
||||
- `install` bootstraps them with Homebrew
|
||||
- `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
|
||||
- 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
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- 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 `verify` after connect when you need an explicit location check
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
## Troubleshooting Cues
|
||||
@@ -98,6 +109,7 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
|
||||
- connect succeeds but final state looks inconsistent:
|
||||
- rely on the verified public IP/location first
|
||||
- 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:
|
||||
- normal public IP restored
|
||||
- no active WireGuard state
|
||||
|
||||
@@ -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 AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
||||
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
|
||||
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
|
||||
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
|
||||
const 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(
|
||||
os.homedir(),
|
||||
".openclaw",
|
||||
@@ -37,7 +40,13 @@ const MAC_WG_HELPER_PATH = path.join(
|
||||
const CLIENT_IPV4 = "10.5.0.2";
|
||||
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
|
||||
const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"];
|
||||
const 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) {
|
||||
if (Array.isArray(payload)) {
|
||||
@@ -252,6 +261,78 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function processExists(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error && error.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function isOperationLockStale(lockRecord, options = {}) {
|
||||
if (!lockRecord || typeof lockRecord !== "object") return true;
|
||||
const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
||||
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
|
||||
const startedAtMs = Number.isFinite(lockRecord.startedAtMs)
|
||||
? lockRecord.startedAtMs
|
||||
: Date.parse(lockRecord.startedAt || "");
|
||||
|
||||
if (!processExists(lockRecord.pid)) {
|
||||
return true;
|
||||
}
|
||||
if (!Number.isFinite(startedAtMs)) {
|
||||
return true;
|
||||
}
|
||||
return now - startedAtMs > maxAgeMs;
|
||||
}
|
||||
|
||||
function cleanupOperationLock(lockPath = OPERATION_LOCK_PATH) {
|
||||
try {
|
||||
if (lockPath && fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors.
|
||||
}
|
||||
}
|
||||
|
||||
function acquireOperationLock(action, options = {}) {
|
||||
const lockPath = options.lockPath || OPERATION_LOCK_PATH;
|
||||
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
||||
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
|
||||
const existing = readJsonFile(lockPath);
|
||||
|
||||
if (existing) {
|
||||
if (isOperationLockStale(existing, { nowMs, maxAgeMs })) {
|
||||
cleanupOperationLock(lockPath);
|
||||
} else {
|
||||
const activeAction = existing.action || "unknown";
|
||||
throw new Error(`Another nordvpn-client ${activeAction} operation is already running.`);
|
||||
}
|
||||
}
|
||||
|
||||
const record = {
|
||||
action,
|
||||
pid: process.pid,
|
||||
startedAt: new Date(nowMs).toISOString(),
|
||||
startedAtMs: nowMs,
|
||||
};
|
||||
writeJsonFile(lockPath, record);
|
||||
|
||||
return {
|
||||
lockPath,
|
||||
record,
|
||||
release() {
|
||||
const current = readJsonFile(lockPath);
|
||||
if (current && current.pid === record.pid && current.startedAtMs === record.startedAtMs) {
|
||||
cleanupOperationLock(lockPath);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
||||
let inUtunBlock = false;
|
||||
const lines = `${ifconfigOutput || ""}`.split("\n");
|
||||
@@ -276,6 +357,281 @@ function buildMacTailscaleState(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 = {}) {
|
||||
const commandExistsFn = deps.commandExists || commandExists;
|
||||
const fileExistsFn = deps.fileExists || fileExists;
|
||||
@@ -308,12 +664,15 @@ async function getMacTailscaleStatus() {
|
||||
async function stopMacTailscaleIfActive() {
|
||||
const status = await getMacTailscaleStatus();
|
||||
if (!status.active) {
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false));
|
||||
return { tailscaleWasActive: false };
|
||||
}
|
||||
markMacTailscaleRecoverySuppressed();
|
||||
const tailscale = getMacTailscalePath();
|
||||
const result = await runExec(tailscale, ["down"]);
|
||||
if (!result.ok) {
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed");
|
||||
}
|
||||
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true));
|
||||
@@ -322,7 +681,19 @@ async function stopMacTailscaleIfActive() {
|
||||
|
||||
async function resumeMacTailscaleIfNeeded() {
|
||||
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
|
||||
if (!state || !state.tailscaleWasActive) {
|
||||
let currentStatus = null;
|
||||
try {
|
||||
currentStatus = await getMacTailscaleStatus();
|
||||
} catch {
|
||||
currentStatus = null;
|
||||
}
|
||||
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
try {
|
||||
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
||||
} catch {
|
||||
// Ignore unlink errors.
|
||||
}
|
||||
return { restored: false };
|
||||
}
|
||||
const tailscale = getMacTailscalePath();
|
||||
@@ -330,6 +701,7 @@ async function resumeMacTailscaleIfNeeded() {
|
||||
if (!result.ok) {
|
||||
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed");
|
||||
}
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
try {
|
||||
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
||||
} catch {
|
||||
@@ -362,6 +734,52 @@ async function resolveHostnameWithFallback(hostname, options = {}) {
|
||||
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 = {}) {
|
||||
if (options && options.all) {
|
||||
return [{ address, family: 4 }];
|
||||
@@ -385,12 +803,134 @@ function cleanupMacWireguardState(paths = {}) {
|
||||
return { cleaned };
|
||||
}
|
||||
|
||||
function cleanupMacWireguardAndDnsState(paths = {}) {
|
||||
const cleaned = cleanupMacWireguardState(paths).cleaned;
|
||||
const dnsStatePath = paths.dnsStatePath || MAC_DNS_STATE_PATH;
|
||||
let dnsCleaned = false;
|
||||
try {
|
||||
if (dnsStatePath && fs.existsSync(dnsStatePath)) {
|
||||
fs.unlinkSync(dnsStatePath);
|
||||
dnsCleaned = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors; caller will rely on current runtime state.
|
||||
}
|
||||
return { cleaned: cleaned || dnsCleaned };
|
||||
}
|
||||
|
||||
function shouldAttemptMacWireguardDisconnect(wireguardState) {
|
||||
if (!wireguardState) return false;
|
||||
if (wireguardState.active) return true;
|
||||
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
|
||||
}
|
||||
|
||||
function isBenignMacWireguardAbsentError(message) {
|
||||
const normalized = `${message || ""}`.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("is not a wireguard interface") ||
|
||||
normalized.includes("is not a known interface") ||
|
||||
normalized.includes("unable to access interface") ||
|
||||
normalized.includes("not found")
|
||||
);
|
||||
}
|
||||
|
||||
function 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) {
|
||||
if (
|
||||
!state ||
|
||||
@@ -539,15 +1079,21 @@ async function probeMacWireguard() {
|
||||
const wgQuickPath = commandExists("wg-quick");
|
||||
const wireguardGoPath = commandExists("wireguard-go");
|
||||
const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
|
||||
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
|
||||
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
||||
const helperStatus = helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus(sudoProbe.stdout || sudoProbe.stderr || "") : null;
|
||||
let active = false;
|
||||
let showRaw = "";
|
||||
let endpoint = "";
|
||||
let ifconfigRaw = "";
|
||||
|
||||
if (helperStatus) {
|
||||
active = helperStatus.active;
|
||||
}
|
||||
|
||||
if (wgPath) {
|
||||
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
||||
active = show.ok;
|
||||
active = active || show.ok;
|
||||
showRaw = (show.stdout || show.stderr).trim();
|
||||
if (show.ok) {
|
||||
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
||||
@@ -571,10 +1117,11 @@ async function probeMacWireguard() {
|
||||
wgQuickPath: wgQuickPath || null,
|
||||
wireguardGoPath: wireguardGoPath || null,
|
||||
helperPath,
|
||||
helperSecurity,
|
||||
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
||||
sudoReady: sudoProbe.ok,
|
||||
interfaceName: MAC_WG_INTERFACE,
|
||||
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null,
|
||||
interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE,
|
||||
configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null),
|
||||
active,
|
||||
endpoint: endpoint || null,
|
||||
showRaw,
|
||||
@@ -716,6 +1263,7 @@ function buildStateSummary(installProbe, ipInfo) {
|
||||
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
||||
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
||||
helperPath: installProbe.wireguard.helperPath,
|
||||
helperSecurity: installProbe.wireguard.helperSecurity,
|
||||
authCache: installProbe.wireguard.authCache,
|
||||
lastConnection: installProbe.wireguard.lastConnection,
|
||||
}
|
||||
@@ -996,7 +1544,6 @@ function buildWireguardConfig(server, privateKey) {
|
||||
"[Interface]",
|
||||
`PrivateKey = ${privateKey}`,
|
||||
`Address = ${addresses.join(", ")}`,
|
||||
`DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`,
|
||||
"",
|
||||
"[Peer]",
|
||||
`PublicKey = ${publicKey}`,
|
||||
@@ -1123,10 +1670,7 @@ async function connectViaMacWireguard(installProbe, target) {
|
||||
|
||||
const down = await runSudoWireguard(installProbe, "down");
|
||||
if (!down.ok) {
|
||||
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase();
|
||||
if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) {
|
||||
// Ignore only the common no-active-interface case.
|
||||
}
|
||||
// Ignore the common no-active-interface case before reconnecting.
|
||||
}
|
||||
|
||||
const up = await runSudoWireguard(installProbe, "up");
|
||||
@@ -1137,23 +1681,6 @@ async function connectViaMacWireguard(installProbe, target) {
|
||||
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
|
||||
}
|
||||
|
||||
writeJsonFile(LAST_CONNECTION_PATH, {
|
||||
backend: "wireguard",
|
||||
interfaceName: MAC_WG_INTERFACE,
|
||||
requestedTarget: target,
|
||||
resolvedTarget: {
|
||||
country: targetMeta.country.name,
|
||||
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
|
||||
},
|
||||
server: {
|
||||
hostname: selectedServer.hostname,
|
||||
city: getServerCityName(selectedServer),
|
||||
country: getServerCountryName(selectedServer),
|
||||
load: selectedServer.load,
|
||||
},
|
||||
connectedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
backend: "wireguard",
|
||||
server: {
|
||||
@@ -1193,10 +1720,12 @@ async function disconnectNordvpn(installProbe) {
|
||||
|
||||
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
||||
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
|
||||
const dnsState = await restoreMacDnsIfNeeded();
|
||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||
return {
|
||||
backend: "wireguard",
|
||||
changed: false,
|
||||
dnsRestored: dnsState.restored,
|
||||
tailscaleRestored: tailscale.restored,
|
||||
message: "No active macOS WireGuard NordVPN connection found.",
|
||||
};
|
||||
@@ -1204,30 +1733,29 @@ async function disconnectNordvpn(installProbe) {
|
||||
const down = await runSudoWireguard(installProbe, "down");
|
||||
if (!down.ok) {
|
||||
const message = (down.stderr || down.stdout || down.error).trim();
|
||||
const normalized = message.toLowerCase();
|
||||
if (
|
||||
normalized.includes("is not a known interface") ||
|
||||
normalized.includes("unable to access interface") ||
|
||||
normalized.includes("not found")
|
||||
) {
|
||||
const cleaned = cleanupMacWireguardState();
|
||||
if (isBenignMacWireguardAbsentError(message)) {
|
||||
const dnsState = await restoreMacDnsIfNeeded();
|
||||
const cleaned = cleanupMacWireguardAndDnsState();
|
||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||
return {
|
||||
backend: "wireguard",
|
||||
changed: false,
|
||||
stateCleaned: cleaned.cleaned,
|
||||
dnsRestored: dnsState.restored,
|
||||
tailscaleRestored: tailscale.restored,
|
||||
message: "No active macOS WireGuard NordVPN connection found.",
|
||||
};
|
||||
}
|
||||
throw new Error(message || "wg-quick down failed");
|
||||
}
|
||||
const cleaned = cleanupMacWireguardState();
|
||||
const dnsState = await restoreMacDnsIfNeeded();
|
||||
const cleaned = cleanupMacWireguardAndDnsState();
|
||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||
return {
|
||||
backend: "wireguard",
|
||||
changed: true,
|
||||
stateCleaned: cleaned.cleaned,
|
||||
dnsRestored: dnsState.restored,
|
||||
tailscaleRestored: tailscale.restored,
|
||||
message: "Disconnected the macOS NordLynx/WireGuard session.",
|
||||
};
|
||||
@@ -1367,8 +1895,12 @@ async function main() {
|
||||
}
|
||||
|
||||
if (action === "connect") {
|
||||
const lock = acquireOperationLock(action);
|
||||
try {
|
||||
const target = buildConnectTarget(args);
|
||||
let connectResult;
|
||||
let verified;
|
||||
let persistence = null;
|
||||
|
||||
if (installProbe.cliPath) {
|
||||
connectResult = await connectViaCli(installProbe.cliPath, target);
|
||||
@@ -1387,17 +1919,175 @@ async function main() {
|
||||
}
|
||||
|
||||
if (connectResult.manualActionRequired) {
|
||||
lock.release();
|
||||
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 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(
|
||||
{
|
||||
action,
|
||||
requestedTarget: target,
|
||||
connectResult,
|
||||
persistence,
|
||||
verified: false,
|
||||
verification: verified && verified.ipInfo ? verified.ipInfo : null,
|
||||
diagnostics: debugOutput ? diagnostics : undefined,
|
||||
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
|
||||
rollback,
|
||||
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||
},
|
||||
1,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
verified = await verifyConnection(target);
|
||||
} else {
|
||||
verified = await verifyConnectionWithRetry(target, {
|
||||
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
|
||||
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await probeInstallation(platform);
|
||||
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
|
||||
const diagnostics = await collectMacWireguardDiagnostics({
|
||||
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
|
||||
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
|
||||
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
|
||||
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
||||
});
|
||||
const rollback = await disconnectNordvpn(refreshed);
|
||||
lock.release();
|
||||
emitJson(
|
||||
{
|
||||
action,
|
||||
requestedTarget: target,
|
||||
connectResult,
|
||||
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,
|
||||
verification: verified.ipInfo,
|
||||
state: connectState,
|
||||
@@ -1405,21 +2095,47 @@ async function main() {
|
||||
verified.ok ? 0 : 1,
|
||||
!verified.ok
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "disconnect") {
|
||||
const lock = acquireOperationLock(action);
|
||||
try {
|
||||
const result = await disconnectNordvpn(installProbe);
|
||||
const refreshed = await probeInstallation(platform);
|
||||
lock.release();
|
||||
emitJson({
|
||||
action,
|
||||
...result,
|
||||
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
||||
} 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,28 +11,58 @@ function loadInternals() {
|
||||
module.exports = {
|
||||
buildMacTailscaleState:
|
||||
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
||||
markMacTailscaleRecoverySuppressed:
|
||||
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
|
||||
clearMacTailscaleRecoverySuppressed:
|
||||
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : undefined,
|
||||
buildMacDnsState:
|
||||
typeof buildMacDnsState === "function" ? buildMacDnsState : undefined,
|
||||
buildWireguardConfig:
|
||||
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
||||
buildLookupResult:
|
||||
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
||||
cleanupMacWireguardState:
|
||||
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:
|
||||
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
||||
isBenignMacWireguardAbsentError:
|
||||
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
||||
isMacTailscaleActive:
|
||||
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
||||
checkMacWireguardPersistence:
|
||||
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
|
||||
normalizeSuccessfulConnectState:
|
||||
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
||||
normalizeStatusState:
|
||||
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
||||
parseMacWireguardHelperStatus:
|
||||
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
|
||||
shouldRejectMacDnsBaseline:
|
||||
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
|
||||
shouldManageMacDnsService:
|
||||
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
||||
sanitizeOutputPayload:
|
||||
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
||||
shouldFinalizeMacWireguardConnect:
|
||||
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
|
||||
shouldResumeMacTailscale:
|
||||
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
|
||||
shouldAttemptMacWireguardDisconnect:
|
||||
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
||||
detectMacWireguardActiveFromIfconfig:
|
||||
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
||||
resolveHostnameWithFallback:
|
||||
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
||||
verifySystemHostnameResolution:
|
||||
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
|
||||
verifyConnectionWithRetry:
|
||||
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]));
|
||||
});
|
||||
|
||||
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();
|
||||
assert.equal(typeof buildWireguardConfig, "function");
|
||||
|
||||
@@ -91,10 +121,71 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat
|
||||
"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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { getMacTailscalePath } = loadInternals();
|
||||
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", () => {
|
||||
const { cleanupMacWireguardState } = loadInternals();
|
||||
assert.equal(typeof cleanupMacWireguardState, "function");
|
||||
@@ -138,6 +252,190 @@ test("cleanupMacWireguardState removes stale config and last-connection files",
|
||||
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", () => {
|
||||
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
||||
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", () => {
|
||||
const { normalizeSuccessfulConnectState } = loadInternals();
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { normalizeStatusState } = loadInternals();
|
||||
assert.equal(typeof normalizeStatusState, "function");
|
||||
@@ -243,6 +561,10 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
|
||||
wireguard: {
|
||||
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
|
||||
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: {
|
||||
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.wireguard.configPath, null);
|
||||
assert.equal(sanitized.wireguard.helperPath, null);
|
||||
assert.equal(sanitized.wireguard.helperSecurity, null);
|
||||
assert.equal(sanitized.wireguard.authCache.tokenSource, null);
|
||||
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.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/);
|
||||
});
|
||||
|
||||
@@ -3,21 +3,53 @@ set -eu
|
||||
|
||||
ACTION="${1:-}"
|
||||
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
|
||||
;;
|
||||
esac
|
||||
|
||||
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
||||
WG="/opt/homebrew/bin/wg"
|
||||
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
||||
WG_INTERFACE="nordvpnctl"
|
||||
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
|
||||
if [ "$ACTION" = "probe" ]; then
|
||||
if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user