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

File diff suppressed because it is too large Load Diff

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