reconnect_vpn
reconnect_vpn
Disconnects and reconnects a VPN profile by name. Use when a VPN connection is stale, showing connected but not routing traffic, or after network changes.
Metadata
Name
reconnect_vpn
Updated
2 weeks ago
Source
Risk
Medium
Requires consent
true
Affected scope
network
Code
/**
* mcp/skills/reconnectVpn.ts — reconnect_vpn skill
*
* Disconnects and reconnects a VPN profile by name. Use when a VPN connection
* is stale, showing connected but not routing traffic, or after network changes.
*
* Platform strategy
* -----------------
* darwin `scutil --nc stop` then `scutil --nc start` for the named profile
* win32 PowerShell Disconnect-VpnConnection then Connect-VpnConnection
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/reconnectVpn.ts
*/
import * as os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import { detectVendorForProfile, WIN32_VPN_VENDOR_PROCS, type VpnVendor } from "./_shared/vpnProfiles";
const execAsync = promisify(exec);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "reconnect_vpn",
description:
"Disconnects and reconnects a VPN profile by name. " +
"Use when a VPN connection is stale, showing connected but not routing traffic, " +
"or after network changes.",
riskLevel: "medium",
destructive: true,
requiresConsent: true,
supportsDryRun: true,
affectedScope: ["network"],
auditRequired: true,
timeoutMs: 90_000,
schema: {
profileName: z
.string()
.describe("VPN profile name to reconnect (from get_vpn_profiles)"),
dryRun: z
.boolean()
.optional()
.describe("If true, show what would happen without reconnecting. Default: true"),
},
} as const;
// -- Types --------------------------------------------------------------------
interface ReconnectVpnResult {
profileName: string;
disconnected: boolean;
reconnected: boolean;
dryRun: boolean;
newStatus: string | null;
vendorManaged?: VpnVendor;
message?: string;
}
// -- PowerShell helper --------------------------------------------------------
async function runPS(script: string): Promise<string> {
const encoded = Buffer.from(script, "utf16le").toString("base64");
const { stdout } = await execAsync(
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
{ maxBuffer: 20 * 1024 * 1024, timeout: 30_000 },
);
return stdout.trim();
}
// -- darwin status helper -----------------------------------------------------
async function readNativeStatus(profileName: string): Promise<string | null> {
try {
const { stdout } = await execAsync("scutil --nc list 2>/dev/null", {
maxBuffer: 5 * 1024 * 1024,
timeout: 5_000,
});
for (const line of stdout.split("\n")) {
if (line.includes(`"${profileName}"`)) {
return line.match(/\((\w+)\)/)?.[1] ?? null;
}
}
} catch { /* ignore */ }
return null;
}
// -- darwin implementation ----------------------------------------------------
async function reconnectVpnDarwin(
profileName: string,
dryRun: boolean,
): Promise<ReconnectVpnResult> {
let profileExists = false;
try {
const { stdout } = await execAsync("scutil --nc list 2>/dev/null", {
maxBuffer: 5 * 1024 * 1024,
timeout: 5_000,
});
profileExists = stdout.includes(`"${profileName}"`);
} catch { /* ignore */ }
if (!profileExists) {
const vendor = await detectVendorForProfile(profileName);
if (vendor) {
return {
profileName,
disconnected: false,
reconnected: false,
dryRun,
newStatus: "vendor-managed — not reconnected",
vendorManaged: vendor,
message:
`"${profileName}" is a ${vendor} VPN managed by its own client; ` +
`macOS scutil cannot reconnect it. Quit and relaunch the ${vendor} ` +
`app (or use its menu-bar Connect) to re-establish the tunnel.`,
};
}
throw new Error(
`[reconnect_vpn] Profile not found: "${profileName}". ` +
"Use get_vpn_profiles to list available profiles."
);
}
if (dryRun) {
return {
profileName,
disconnected: false,
reconnected: false,
dryRun: true,
newStatus: "DryRun — no changes made",
};
}
const safeName = profileName.replace(/"/g, '\"');
let disconnected = false;
let reconnected = false;
// Disconnect
try {
await execAsync(`scutil --nc stop "${safeName}" 2>/dev/null`, {
maxBuffer: 1 * 1024 * 1024,
timeout: 15_000,
});
disconnected = true;
} catch { /* may not be connected */ }
// Brief pause to allow teardown
await new Promise((res) => setTimeout(res, 2000));
// Reconnect
try {
await execAsync(`scutil --nc start "${safeName}" 2>/dev/null`, {
maxBuffer: 1 * 1024 * 1024,
timeout: 30_000,
});
} catch (err) {
throw new Error(
`[reconnect_vpn] Failed to start profile "${profileName}": ${(err as Error).message}`,
);
}
const DEADLINE_MS = 25_000;
const POLL_MS = 1_500;
const startedAt = Date.now();
let newStatus = await readNativeStatus(profileName);
while (Date.now() - startedAt < DEADLINE_MS) {
if (newStatus === "Connected") break;
if (newStatus === "Disconnected" || newStatus === "Invalid") break;
await new Promise((res) => setTimeout(res, POLL_MS));
newStatus = await readNativeStatus(profileName);
}
reconnected = newStatus === "Connected";
const waited = Math.round((Date.now() - startedAt) / 1000);
const message = reconnected
? `VPN profile "${profileName}" reconnected — status: Connected.`
: newStatus === "Connecting" || newStatus === null
? `VPN profile "${profileName}" is still establishing the tunnel (status: ${newStatus ?? "unknown"}) after ${waited}s. ` +
"It may be waiting on credentials/MFA, a vendor app or system extension, or an unresponsive server. " +
"Check your VPN client's menu-bar icon and complete any sign-in, or try again."
: `VPN profile "${profileName}" did not connect — status: ${newStatus}. ` +
"Toggle Disconnect → Connect from the VPN menu-bar icon, or escalate to IT if it persists.";
return { profileName, disconnected, reconnected, dryRun: false, newStatus, message };
}
// -- win32 vendor detection ---------------------------------------------------
async function detectVendorForProfileWin32(profileName: string): Promise<VpnVendor | null> {
const lower = profileName.toLowerCase();
for (const { label } of WIN32_VPN_VENDOR_PROCS) {
if (lower.includes(label.toLowerCase().split(" ")[0].toLowerCase())) {
const procEntry = WIN32_VPN_VENDOR_PROCS.find((e) => e.label === label);
if (!procEntry) continue;
const safeName = procEntry.proc.replace(/'/g, "''");
try {
const out = await runPS(
`if (Get-Process -Name '${safeName}' -ErrorAction SilentlyContinue) { 'running' } else { 'notfound' }`,
);
if (out.trim() === "running") return label;
} catch { /* ignore */ }
}
}
const procListPs = WIN32_VPN_VENDOR_PROCS
.map((e) => `[PSCustomObject]@{proc='${e.proc}';label='${e.label}'}`)
.join(",\n ");
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$map = @(${procListPs})
foreach ($e in $map) {
if (Get-Process -Name $e.proc -ErrorAction SilentlyContinue) { $e.label; break }
}`.trim();
try {
const out = await runPS(ps);
const found = out.trim();
if (found) return found as VpnVendor;
} catch { /* ignore */ }
return null;
}
// -- win32 implementation -----------------------------------------------------
async function reconnectVpnWin32(
profileName: string,
dryRun: boolean,
): Promise<ReconnectVpnResult> {
const safeName = profileName.replace(/'/g, "''");
const checkPs = `
$ErrorActionPreference = 'SilentlyContinue'
$c = Get-VpnConnection -Name '${safeName}' -ErrorAction SilentlyContinue
if (-not $c) { $c = Get-VpnConnection -AllUserConnection -Name '${safeName}' -ErrorAction SilentlyContinue }
if ($c) { 'found' } else { 'notfound' }`.trim();
const checkResult = await runPS(checkPs);
if (checkResult !== "found") {
const vendor = await detectVendorForProfileWin32(profileName);
if (vendor) {
return {
profileName,
disconnected: false,
reconnected: false,
dryRun,
newStatus: "vendor-managed — not reconnected",
vendorManaged: vendor,
message:
`"${profileName}" is managed by ${vendor}, which uses its own tunnel driver ` +
`(WireGuard/OpenVPN) that Windows cannot reconnect via the built-in VPN stack. ` +
`Open the ${vendor} app in the system tray, disconnect, wait 5 seconds, ` +
`then click Connect.`,
};
}
throw new Error(
`[reconnect_vpn] Profile not found: "${profileName}". ` +
"Use get_vpn_profiles to list available profiles."
);
}
if (dryRun) {
return {
profileName,
disconnected: false,
reconnected: false,
dryRun: true,
newStatus: "DryRun — no changes made",
};
}
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
try { Disconnect-VpnConnection -Name '${safeName}' -Force -ErrorAction SilentlyContinue } catch {}
Start-Sleep -Seconds 2
$connected = $false
try {
rasdial '${safeName}' | Out-Null
$connected = $true
} catch {}
$status = $null
$c = Get-VpnConnection -Name '${safeName}' -ErrorAction SilentlyContinue
if ($c) { $status = $c.ConnectionStatus }
[PSCustomObject]@{ reconnected = $connected; status = $status } |
ConvertTo-Json -Compress`.trim();
const raw = await runPS(ps);
let parsed: { reconnected: boolean; status: string | null } = {
reconnected: false,
status: null,
};
try {
parsed = JSON.parse(raw);
} catch { /* ignore */ }
return {
profileName,
disconnected: true,
reconnected: parsed.reconnected,
dryRun: false,
newStatus: parsed.status,
};
}
// -- Exported run function ----------------------------------------------------
export async function run({
profileName,
dryRun = true,
}: {
profileName: string;
dryRun?: boolean;
}): Promise<ReconnectVpnResult> {
if (!profileName || profileName.trim() === "") {
throw new Error("[reconnect_vpn] profileName is required.");
}
const platform = os.platform();
return platform === "win32"
? reconnectVpnWin32(profileName, dryRun)
: reconnectVpnDarwin(profileName, dryRun);
}
// -- Smoke test ---------------------------------------------------------------
if (false) {
run({} as { profileName: string })
.then(r => console.log(JSON.stringify(r, null, 2)))
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}
``