diff --git a/skills/nordvpn-client/scripts/nordvpn-client.js b/skills/nordvpn-client/scripts/nordvpn-client.js index 0eabc20..7f8dd39 100644 --- a/skills/nordvpn-client/scripts/nordvpn-client.js +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -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( { diff --git a/skills/nordvpn-client/scripts/nordvpn-client.test.js b/skills/nordvpn-client/scripts/nordvpn-client.test.js new file mode 100644 index 0000000..7cc440d --- /dev/null +++ b/skills/nordvpn-client/scripts/nordvpn-client.test.js @@ -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 mtu 1380 +utun9: flags=8051 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"]); +});