Fix NordVPN DNS and Tailscale recovery interlock
This commit is contained in:
@@ -16,6 +16,7 @@ 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(),
|
||||
@@ -42,7 +43,10 @@ const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"];
|
||||
const CONNECT_PERSISTENCE_ATTEMPTS = 6;
|
||||
const CONNECT_PERSISTENCE_DELAY_MS = 2000;
|
||||
const CONNECT_TOTAL_TIMEOUT_MS = 90000;
|
||||
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]);
|
||||
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)) {
|
||||
@@ -353,6 +357,19 @@ 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;
|
||||
@@ -647,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));
|
||||
@@ -668,6 +688,7 @@ async function resumeMacTailscaleIfNeeded() {
|
||||
currentStatus = null;
|
||||
}
|
||||
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
try {
|
||||
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
||||
} catch {
|
||||
@@ -680,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 {
|
||||
@@ -712,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 }];
|
||||
@@ -1147,9 +1215,7 @@ function buildStateSummary(installProbe, ipInfo) {
|
||||
connectMode = "wireguard";
|
||||
recommendedAction = installProbe.tokenAvailable
|
||||
? installProbe.wireguard.sudoReady
|
||||
? installProbe.wireguard.helperSecurity && !installProbe.wireguard.helperSecurity.hardened
|
||||
? `WireGuard tooling is available, but the helper at ${MAC_WG_HELPER_PATH} is not hardened yet: ${installProbe.wireguard.helperSecurity.reason}`
|
||||
: "Use token-based WireGuard automation on macOS."
|
||||
? "Use token-based WireGuard automation on macOS."
|
||||
: `WireGuard tooling and token are available, but connect/disconnect require non-interactive sudo for ${MAC_WG_HELPER_PATH}. Allow that helper in sudoers, then rerun login/connect.`
|
||||
: `Set NORDVPN_TOKEN, NORDVPN_TOKEN_FILE, or place the token at ${DEFAULT_TOKEN_FILE} for automated macOS NordLynx/WireGuard connects.`;
|
||||
} else if (installProbe.platform === "darwin" && installProbe.appInstalled) {
|
||||
@@ -1955,6 +2021,27 @@ async function main() {
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user