fix: harden nordvpn wireguard verification
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execFile, spawn } = require("node:child_process");
|
||||
const dns = require("node:dns");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const https = require("node:https");
|
||||
@@ -34,6 +36,7 @@ const MAC_WG_HELPER_PATH = path.join(
|
||||
const DEFAULT_DNS_IPV4 = "103.86.96.100";
|
||||
const DEFAULT_DNS_IPV6 = "2400:bb40:4444::100";
|
||||
const CLIENT_IPV4 = "10.5.0.2";
|
||||
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
|
||||
|
||||
function printJson(payload, exitCode = 0, errorStream = false) {
|
||||
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
@@ -217,16 +220,89 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
||||
let inUtunBlock = false;
|
||||
const lines = `${ifconfigOutput || ""}`.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^utun\d+:/.test(line)) {
|
||||
inUtunBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (/^\S/.test(line)) {
|
||||
inUtunBlock = false;
|
||||
}
|
||||
if (inUtunBlock && line.includes(`inet ${CLIENT_IPV4}`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveHostnameWithFallback(hostname, options = {}) {
|
||||
const resolvers = options.resolvers || DNS_FALLBACK_RESOLVERS;
|
||||
const resolveWithResolver =
|
||||
options.resolveWithResolver ||
|
||||
(async (targetHostname, resolverIp) => {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
resolver.setServers([resolverIp]);
|
||||
return resolver.resolve4(targetHostname);
|
||||
});
|
||||
|
||||
for (const resolverIp of resolvers) {
|
||||
try {
|
||||
const addresses = await resolveWithResolver(hostname, resolverIp);
|
||||
if (Array.isArray(addresses) && addresses.length) {
|
||||
return addresses[0];
|
||||
}
|
||||
} catch {
|
||||
// Try the next resolver.
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildLookupResult(address, options = {}) {
|
||||
if (options && options.all) {
|
||||
return [{ address, family: 4 }];
|
||||
}
|
||||
return [address, 4];
|
||||
}
|
||||
|
||||
function fetchJson(url, headers = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const targetUrl = new URL(url);
|
||||
const req = https.get(
|
||||
url,
|
||||
targetUrl,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "nordvpn-client-skill/1.0",
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
lookup: (hostname, options, callback) => {
|
||||
const ipVersion = net.isIP(hostname);
|
||||
if (ipVersion) {
|
||||
callback(null, hostname, ipVersion);
|
||||
return;
|
||||
}
|
||||
resolveHostnameWithFallback(hostname)
|
||||
.then((address) => {
|
||||
if (address) {
|
||||
const result = buildLookupResult(address, options);
|
||||
if (options && options.all) {
|
||||
callback(null, result);
|
||||
} else {
|
||||
callback(null, result[0], result[1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
dns.lookup(hostname, options, callback);
|
||||
})
|
||||
.catch(() => dns.lookup(hostname, options, callback));
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
@@ -288,6 +364,7 @@ async function probeMacWireguard() {
|
||||
let active = false;
|
||||
let showRaw = "";
|
||||
let endpoint = "";
|
||||
let ifconfigRaw = "";
|
||||
|
||||
if (wgPath) {
|
||||
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
||||
@@ -299,6 +376,17 @@ async function probeMacWireguard() {
|
||||
}
|
||||
}
|
||||
|
||||
const ifconfig = await runExec("ifconfig");
|
||||
ifconfigRaw = (ifconfig.stdout || ifconfig.stderr).trim();
|
||||
if (!active) {
|
||||
active = detectMacWireguardActiveFromIfconfig(ifconfigRaw);
|
||||
}
|
||||
if (!endpoint && fileExists(WG_CONFIG_PATH)) {
|
||||
const configText = fs.readFileSync(WG_CONFIG_PATH, "utf8");
|
||||
const match = configText.match(/^Endpoint\s*=\s*(.+)$/m);
|
||||
if (match) endpoint = match[1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
wgPath: wgPath || null,
|
||||
wgQuickPath: wgQuickPath || null,
|
||||
@@ -311,6 +399,7 @@ async function probeMacWireguard() {
|
||||
active,
|
||||
endpoint: endpoint || null,
|
||||
showRaw,
|
||||
ifconfigRaw,
|
||||
authCache: readJsonFile(AUTH_CACHE_PATH),
|
||||
lastConnection: readJsonFile(LAST_CONNECTION_PATH),
|
||||
};
|
||||
@@ -762,6 +851,27 @@ async function verifyConnection(target) {
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyConnectionWithRetry(target, options = {}) {
|
||||
const attempts = Math.max(1, Number(options.attempts || 4));
|
||||
const delayMs = Math.max(0, Number(options.delayMs || 1500));
|
||||
const getIpInfo = options.getPublicIpInfo || getPublicIpInfo;
|
||||
let lastIpInfo = null;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const ipInfo = await getIpInfo();
|
||||
lastIpInfo = ipInfo;
|
||||
const ok = target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok);
|
||||
if (ok) {
|
||||
return { ok: true, ipInfo };
|
||||
}
|
||||
if (attempt < attempts) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, ipInfo: lastIpInfo };
|
||||
}
|
||||
|
||||
async function connectViaCli(cliPath, target) {
|
||||
const attempts = [];
|
||||
if (target.city) attempts.push([target.city]);
|
||||
@@ -1020,7 +1130,7 @@ async function main() {
|
||||
|
||||
if (action === "verify") {
|
||||
const target = args.country || args.city ? buildConnectTarget(args) : null;
|
||||
const verified = await verifyConnection(target);
|
||||
const verified = await verifyConnectionWithRetry(target);
|
||||
const refreshed = await probeInstallation(platform);
|
||||
printJson(
|
||||
{
|
||||
@@ -1059,8 +1169,7 @@ async function main() {
|
||||
printJson({ action, requestedTarget: target, ...connectResult });
|
||||
}
|
||||
|
||||
await sleep(3000);
|
||||
const verified = await verifyConnection(target);
|
||||
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
||||
const refreshed = await probeInstallation(platform);
|
||||
printJson(
|
||||
{
|
||||
|
||||
104
skills/nordvpn-client/scripts/nordvpn-client.test.js
Normal file
104
skills/nordvpn-client/scripts/nordvpn-client.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
function loadInternals() {
|
||||
const scriptPath = path.join(__dirname, "nordvpn-client.js");
|
||||
const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n");
|
||||
const wrapped = `${source}
|
||||
module.exports = {
|
||||
buildLookupResult:
|
||||
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
||||
detectMacWireguardActiveFromIfconfig:
|
||||
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
||||
resolveHostnameWithFallback:
|
||||
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
||||
verifyConnectionWithRetry:
|
||||
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
|
||||
};`;
|
||||
|
||||
const sandbox = {
|
||||
require,
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
__dirname,
|
||||
__filename: scriptPath,
|
||||
process: { ...process, exit() {} },
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Buffer,
|
||||
};
|
||||
|
||||
vm.runInNewContext(wrapped, sandbox, { filename: scriptPath });
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
test("detectMacWireguardActiveFromIfconfig detects nordvpn utun client address", () => {
|
||||
const { detectMacWireguardActiveFromIfconfig } = loadInternals();
|
||||
assert.equal(typeof detectMacWireguardActiveFromIfconfig, "function");
|
||||
|
||||
const ifconfig = `
|
||||
utun8: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1380
|
||||
utun9: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1420
|
||||
\tinet 10.5.0.2 --> 10.5.0.2 netmask 0xff000000
|
||||
`;
|
||||
|
||||
assert.equal(detectMacWireguardActiveFromIfconfig(ifconfig), true);
|
||||
assert.equal(detectMacWireguardActiveFromIfconfig("utun7: flags=8051\n\tinet 100.64.0.4"), false);
|
||||
});
|
||||
|
||||
test("buildLookupResult supports lookup all=true mode", () => {
|
||||
const { buildLookupResult } = loadInternals();
|
||||
assert.equal(typeof buildLookupResult, "function");
|
||||
assert.equal(
|
||||
JSON.stringify(buildLookupResult("104.26.9.44", { all: true })),
|
||||
JSON.stringify([{ address: "104.26.9.44", family: 4 }])
|
||||
);
|
||||
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
|
||||
});
|
||||
|
||||
test("verifyConnectionWithRetry retries transient reachability failures", async () => {
|
||||
const { verifyConnectionWithRetry } = loadInternals();
|
||||
assert.equal(typeof verifyConnectionWithRetry, "function");
|
||||
|
||||
let attempts = 0;
|
||||
const result = await verifyConnectionWithRetry(
|
||||
{ country: "Italy", city: "Milan" },
|
||||
{
|
||||
attempts: 3,
|
||||
delayMs: 1,
|
||||
getPublicIpInfo: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
return { ok: false, error: "read EHOSTUNREACH" };
|
||||
}
|
||||
return { ok: true, country: "Italy", city: "Milan" };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.ipInfo.country, "Italy");
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test("resolveHostnameWithFallback uses fallback resolvers when system lookup fails", async () => {
|
||||
const { resolveHostnameWithFallback } = loadInternals();
|
||||
assert.equal(typeof resolveHostnameWithFallback, "function");
|
||||
|
||||
const calls = [];
|
||||
const address = await resolveHostnameWithFallback("ipapi.co", {
|
||||
resolvers: ["1.1.1.1", "8.8.8.8"],
|
||||
resolveWithResolver: async (hostname, resolver) => {
|
||||
calls.push(`${resolver}:${hostname}`);
|
||||
if (resolver === "1.1.1.1") return [];
|
||||
return ["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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user