Compare commits
4 Commits
4919edcec1
...
57f6b132b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f6b132b2 | |||
| b3a59b5b45 | |||
| a796481875 | |||
| 8d2c162849 |
@@ -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 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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user