feat(nordvpn-client): gate macos connect on stable wireguard persistence

This commit is contained in:
2026-03-30 11:55:20 -05:00
parent 8d2c162849
commit a796481875
3 changed files with 547 additions and 104 deletions

View File

@@ -23,6 +23,8 @@ module.exports = {
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
collectMacWireguardDiagnostics:
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
acquireOperationLock:
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
inspectMacWireguardHelperSecurity:
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
getMacTailscalePath:
@@ -31,10 +33,16 @@ module.exports = {
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:
@@ -142,6 +150,36 @@ test("buildMacDnsState records DNS and search domains per service", () => {
);
});
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
const { shouldRejectMacDnsBaseline } = loadInternals();
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["103.86.96.100", "103.86.99.100"],
searchDomains: [],
},
],
}),
true
);
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["1.1.1.1"],
searchDomains: [],
},
],
}),
false
);
});
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
const { getMacTailscalePath } = loadInternals();
assert.equal(typeof getMacTailscalePath, "function");
@@ -293,6 +331,92 @@ test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process
assert.equal(result.processes.includes("wireguard-go"), true);
});
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");
@@ -370,14 +494,15 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
});
test("shouldFinalizeMacWireguardConnect requires a verified wireguard connect", () => {
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
const { shouldFinalizeMacWireguardConnect } = loadInternals();
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }), true);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }), false);
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }), false);
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", () => {