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

GitHub 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); });
}
``