Files
stef-openclaw-skills/skills/nordvpn-client/scripts/nordvpn-client.js
2026-03-11 22:35:50 -05:00

503 lines
15 KiB
JavaScript

#!/usr/bin/env node
const { execFile, spawn } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const https = require("node:https");
function printJson(payload, exitCode = 0, errorStream = false) {
const body = `${JSON.stringify(payload, null, 2)}\n`;
(errorStream ? process.stderr : process.stdout).write(body);
process.exit(exitCode);
}
function usage() {
return {
usage: [
"node scripts/nordvpn-client.js status",
"node scripts/nordvpn-client.js install",
"node scripts/nordvpn-client.js login",
"node scripts/nordvpn-client.js verify",
'node scripts/nordvpn-client.js verify --country "Italy"',
'node scripts/nordvpn-client.js verify --country "Italy" --city "Milan"',
'node scripts/nordvpn-client.js connect --country "Italy"',
'node scripts/nordvpn-client.js connect --city "Milan"',
'node scripts/nordvpn-client.js connect --country "Italy" --city "Milan"',
"node scripts/nordvpn-client.js disconnect",
],
env: [
"NORDVPN_TOKEN",
"NORDVPN_TOKEN_FILE",
"NORDVPN_USERNAME",
"NORDVPN_PASSWORD",
"NORDVPN_PASSWORD_FILE",
],
};
}
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token.startsWith("--")) {
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
} else {
args._.push(token);
}
}
return args;
}
function detectPlatform() {
const raw = os.platform();
if (raw === "darwin") return "darwin";
if (raw === "linux") return "linux";
return raw;
}
function readSecret(envName, fileEnvName) {
if (process.env[envName]) return process.env[envName];
const filePath = process.env[fileEnvName];
if (!filePath) return "";
try {
return fs.readFileSync(filePath, "utf8").trim();
} catch {
return "";
}
}
function normalizeLocation(value) {
return `${value || ""}`.trim();
}
function commandExists(name) {
const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean);
for (const entry of pathEntries) {
const full = path.join(entry, name);
try {
fs.accessSync(full, fs.constants.X_OK);
return full;
} catch {
// Continue.
}
}
return "";
}
function fileExists(target) {
try {
fs.accessSync(target);
return true;
} catch {
return false;
}
}
function runExec(command, args = [], options = {}) {
return new Promise((resolve) => {
execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => {
resolve({
ok: !error,
code: error && typeof error.code === "number" ? error.code : 0,
stdout: stdout || "",
stderr: stderr || "",
error: error ? error.message : "",
});
});
});
}
function runInteractive(command, args = [], options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit", ...options });
child.on("error", reject);
child.on("exit", (code) => resolve(code === 0));
});
}
function runShell(command) {
return runInteractive("bash", ["-lc", command]);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fetchJson(url) {
return new Promise((resolve) => {
const req = https.get(
url,
{
headers: {
"User-Agent": "nordvpn-client-skill/1.0",
Accept: "application/json",
},
},
(res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
resolve({ ok: true, statusCode: res.statusCode || 0, json: JSON.parse(data) });
} catch (error) {
resolve({
ok: false,
statusCode: res.statusCode || 0,
error: error.message,
raw: data,
});
}
});
}
);
req.on("error", (error) => resolve({ ok: false, error: error.message }));
req.setTimeout(10000, () => {
req.destroy(new Error("timeout"));
});
});
}
async function getPublicIpInfo() {
const lookup = await fetchJson("https://ipapi.co/json/");
if (!lookup.ok) return { ok: false, error: lookup.error || "lookup failed" };
const json = lookup.json || {};
return {
ok: true,
ip: json.ip || "",
city: json.city || "",
region: json.region || "",
country: json.country_name || json.country || "",
countryCode: json.country_code || "",
org: json.org || "",
};
}
async function probeCliStatus(cliPath) {
const status = await runExec(cliPath, ["status"]);
const account = await runExec(cliPath, ["account"]);
const countries = await runExec(cliPath, ["countries"]);
return {
status,
account,
countries,
};
}
async function probeInstallation(platform) {
const cliPath = commandExists("nordvpn");
const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : "";
const brewPath = platform === "darwin" ? commandExists("brew") : "";
let cliProbe = null;
if (cliPath) {
cliProbe = await probeCliStatus(cliPath);
}
return {
platform,
cliPath,
appPath,
appInstalled: Boolean(appPath && fileExists(appPath)),
brewPath,
installed: Boolean(cliPath) || Boolean(appPath && fileExists(appPath)),
cliProbe,
};
}
function inferAuthState(probe) {
if (!probe || !probe.account) return null;
const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase();
if (!blob.trim()) return null;
if (probe.account.ok && !blob.includes("not logged")) return true;
if (blob.includes("not logged") || blob.includes("login")) return false;
return null;
}
function inferConnectionState(probe) {
if (!probe || !probe.status) return null;
const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase();
if (!blob.trim()) return null;
if (blob.includes("connected")) return true;
if (blob.includes("disconnected") || blob.includes("not connected")) return false;
return null;
}
function buildStateSummary(installProbe, ipInfo) {
const cliProbe = installProbe.cliProbe;
return {
platform: installProbe.platform,
installed: installProbe.installed,
cliAvailable: Boolean(installProbe.cliPath),
cliPath: installProbe.cliPath || null,
appInstalled: installProbe.appInstalled,
appPath: installProbe.appInstalled ? installProbe.appPath : null,
brewAvailable: Boolean(installProbe.brewPath),
authenticated: inferAuthState(cliProbe),
connected: inferConnectionState(cliProbe),
localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "",
publicIp: ipInfo.ok ? ipInfo : null,
};
}
async function installNordvpn(installProbe) {
if (installProbe.installed) {
return { changed: false, message: "NordVPN is already installed." };
}
if (installProbe.platform === "darwin") {
if (!installProbe.brewPath) {
throw new Error("Homebrew is required on macOS to bootstrap NordVPN via brew cask.");
}
const ok = await runInteractive(installProbe.brewPath, ["install", "--cask", "nordvpn"]);
if (!ok) {
throw new Error("brew install --cask nordvpn failed");
}
return { changed: true, message: "Installed NordVPN via Homebrew cask." };
}
if (installProbe.platform === "linux") {
const command =
"TMP=$(mktemp) && " +
"if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " +
"elif command -v wget >/dev/null 2>&1; then wget -qO \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " +
"else echo 'curl or wget required' >&2; exit 1; fi && sh \"$TMP\" && rm -f \"$TMP\"";
const ok = await runShell(command);
if (!ok) {
throw new Error("NordVPN Linux installer failed");
}
return { changed: true, message: "Installed NordVPN via the official Linux installer." };
}
throw new Error(`Unsupported platform: ${installProbe.platform}`);
}
async function openMacApp() {
const ok = await runInteractive("open", ["-a", "NordVPN"]);
if (!ok) throw new Error("Failed to open NordVPN.app");
}
async function loginNordvpn(installProbe, args) {
const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE");
const username = process.env.NORDVPN_USERNAME || "";
const password = readSecret("NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE");
if (installProbe.cliPath) {
if (token) {
const result = await runExec(installProbe.cliPath, ["login", "--token", token]);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed");
}
return { mode: "cli-token", message: "Logged in using token." };
}
if (username && password && installProbe.platform === "darwin") {
// macOS CLI login support is not documented. Fall back to interactive CLI login.
}
const ok = await runInteractive(installProbe.cliPath, ["login"]);
if (!ok) throw new Error("nordvpn login failed");
return { mode: "cli-interactive", message: "Interactive NordVPN login completed." };
}
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
await openMacApp();
return {
mode: "app-manual",
manualActionRequired: true,
message:
"Opened NordVPN.app. Complete login in the app/browser flow, then rerun status or connect. macOS app login is browser-based according to NordVPN support docs.",
};
}
throw new Error("NordVPN is not installed.");
}
function buildConnectTarget(args) {
const country = normalizeLocation(args.country);
const city = normalizeLocation(args.city);
if (!country && !city) {
throw new Error("connect requires --country, --city, or both.");
}
return { country, city };
}
function locationMatches(ipInfo, target) {
if (!ipInfo || !ipInfo.ok) return false;
const countryMatch = !target.country || ipInfo.country.toLowerCase().includes(target.country.toLowerCase());
const cityMatch = !target.city || ipInfo.city.toLowerCase().includes(target.city.toLowerCase());
return countryMatch && cityMatch;
}
async function verifyConnection(target) {
const ipInfo = await getPublicIpInfo();
return {
ok: target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok),
ipInfo,
};
}
async function connectViaCli(cliPath, target) {
const attempts = [];
if (target.city) {
attempts.push([target.city]);
}
if (target.country) {
attempts.push([target.country]);
}
if (target.country && target.city) {
attempts.push([`${target.country} ${target.city}`]);
}
let lastFailure = "";
for (const args of attempts) {
const result = await runExec(cliPath, ["connect", ...args]);
if (result.ok) {
return { cliTarget: args.join(" "), raw: result.stdout.trim() || result.stderr.trim() };
}
lastFailure = (result.stderr || result.stdout || result.error).trim();
}
throw new Error(lastFailure || "NordVPN connect failed");
}
async function connectViaMacApp(target) {
await openMacApp();
return {
manualActionRequired: true,
message: `Opened NordVPN.app. Connect manually to ${target.city ? `${target.city}, ` : ""}${target.country || "the requested location"} and rerun status/verify before the follow-up task.`,
};
}
async function disconnectNordvpn(installProbe) {
if (installProbe.cliPath) {
const result = await runExec(installProbe.cliPath, ["disconnect"]);
if (!result.ok) {
throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed");
}
return { message: "Disconnected from NordVPN." };
}
if (installProbe.platform === "darwin" && installProbe.appInstalled) {
await openMacApp();
return {
manualActionRequired: true,
message: "Opened NordVPN.app. Disconnect manually because no usable CLI was detected on macOS.",
};
}
throw new Error("NordVPN is not installed.");
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const action = args._[0];
if (!action || args.help) {
printJson(usage(), action ? 0 : 1, !action);
}
const platform = detectPlatform();
if (!["darwin", "linux"].includes(platform)) {
printJson({ error: `Unsupported platform: ${platform}` }, 1, true);
}
const installProbe = await probeInstallation(platform);
try {
if (action === "status") {
const ipInfo = await getPublicIpInfo();
printJson(buildStateSummary(installProbe, ipInfo));
}
if (action === "install") {
const result = await installNordvpn(installProbe);
const refreshed = await probeInstallation(platform);
printJson({
action,
...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()),
});
}
if (action === "login") {
const result = await loginNordvpn(installProbe, args);
const refreshed = await probeInstallation(platform);
printJson({
action,
...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()),
});
}
if (action === "verify") {
const target = args.country || args.city ? buildConnectTarget(args) : null;
const verified = await verifyConnection(target);
const refreshed = await probeInstallation(platform);
printJson({
action,
requestedTarget: target,
verified: verified.ok,
verification: verified.ipInfo,
state: buildStateSummary(refreshed, verified.ipInfo),
}, verified.ok ? 0 : 1, !verified.ok);
}
if (action === "connect") {
const target = buildConnectTarget(args);
if (!installProbe.installed) {
throw new Error("NordVPN is not installed.");
}
let connectResult;
if (installProbe.cliPath) {
connectResult = await connectViaCli(installProbe.cliPath, target);
} else if (platform === "darwin" && installProbe.appInstalled) {
connectResult = await connectViaMacApp(target);
} else {
throw new Error("No usable NordVPN control path found.");
}
if (connectResult.manualActionRequired) {
printJson({ action, requestedTarget: target, ...connectResult });
}
await sleep(3000);
const verified = await verifyConnection(target);
const refreshed = await probeInstallation(platform);
printJson({
action,
requestedTarget: target,
connectResult,
verified: verified.ok,
verification: verified.ipInfo,
state: buildStateSummary(refreshed, verified.ipInfo),
}, verified.ok ? 0 : 1, !verified.ok);
}
if (action === "disconnect") {
const result = await disconnectNordvpn(installProbe);
const refreshed = await probeInstallation(platform);
printJson({
action,
...result,
state: buildStateSummary(refreshed, await getPublicIpInfo()),
});
}
printJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
} catch (error) {
printJson({ error: error.message || String(error), action }, 1, true);
}
}
main();