UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

152 lines (151 loc) 5.92 kB
import * as net from "node:net"; import { logInfo, logError, logSuccess } from "../../utils/log.js"; import { getContextConfig } from "../login.js"; import { ensureTailscaleTunnels, stopDaemon, logout, daemonState, readTunnelState, clearTunnelState, readDaemonMeta, runTunnels, stopTunnelRunners, verifyTunnelOwnership, } from "../../utils/tailscale.js"; function deriveLoginServer(context) { const cfg = getContextConfig(context); if (!cfg?.issuer) { throw new Error(`No login config for context '${context}'. Run: mesh login ${context}`); } const u = new URL(cfg.issuer); const parts = u.hostname.split("."); parts[0] = "vpn"; return `https://${parts.join(".")}`; } function findFreePort() { return new Promise((resolve, reject) => { const s = net.createServer(); s.on("error", reject); s.listen(0, "127.0.0.1", () => { const port = s.address().port; s.close(() => resolve(port)); }); }); } function canConnect(port) { return new Promise((resolve) => { const s = new net.Socket(); let settled = false; const done = (ok) => { if (settled) return; settled = true; s.destroy(); resolve(ok); }; s.setTimeout(500); s.once("connect", () => done(true)); s.once("timeout", () => done(false)); s.once("error", () => done(false)); s.connect(port, "127.0.0.1"); }); } async function tunnelUp(vpn, cmdOpts) { const { tenant, env } = vpn; const context = cmdOpts.context ?? `${tenant}.${env}`; const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; const loginServer = deriveLoginServer(context); const prior = readTunnelState(tenant); const daemonUp = daemonState(tenant).backendState !== "Down"; const socksPort = daemonUp ? (readDaemonMeta(tenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort())) : await findFreePort(); logInfo(`Bringing up userspace VPN tunnels for tenant '${tenant}'...`); const targets = await ensureTailscaleTunnels(tenant, env, { region, loginServer, socksPort }); logSuccess(`VPN tunnels up for '${tenant}' (${targets.length}):`); for (const t of targets) { logInfo(` ${t.name.padEnd(14)} 127.0.0.1:${t.localPort}${t.targetHost}:${t.remotePort}`); } } function tunnelDown(vpn, cmdOpts) { const { tenant } = vpn; const st = readTunnelState(tenant); if (!st && !cmdOpts.stop && !cmdOpts.logout) { logInfo(`No tunnels running for tenant '${tenant}'.`); return; } stopTunnelRunners(tenant, st); if (cmdOpts.logout) { logout(tenant); logInfo("Logged the VPN daemon out of Headscale."); } if (cmdOpts.stop || cmdOpts.logout) { stopDaemon(tenant); logInfo("Stopped the userspace VPN daemon."); } clearTunnelState(tenant); logSuccess(`VPN tunnels down for '${tenant}'.`); } async function tunnelStatus(vpn, cmdOpts) { const { tenant } = vpn; const st = readTunnelState(tenant); const ds = daemonState(tenant); const owned = st ? await verifyTunnelOwnership(tenant, { socksPort: st.socksPort, ports: st.tunnels.map((t) => t.localPort), }) : null; if (cmdOpts.json) { console.log(JSON.stringify({ tenant, daemon: ds, up: !!st, managed: st?.managed ?? null, owned: owned?.ok ?? false, ownership: owned ? (owned.ok ? "owned" : owned.reason) : "no-state", tunnels: st?.tunnels ?? [], }, null, 2)); return; } logInfo(`Daemon: ${ds.backendState}${ds.selfName ? ` (${ds.selfName})` : ""}`); if (!st) { logInfo(`No tunnels running for tenant '${tenant}'.`); return; } logInfo(`Managed: ${st.managed ?? "detached"}`); logInfo(owned?.ok ? `Runner: owned (verified)` : `Runner: NOT OWNED (${owned?.reason}) — ports below may be foreign listeners`); const listening = owned?.ok ? new Set(owned.resp?.listening ?? []) : null; for (const t of st.tunnels) { const up = listening ? listening.has(t.localPort) : await canConnect(t.localPort); logInfo(` ${up ? "✓" : "✗"} ${t.name.padEnd(14)} 127.0.0.1:${t.localPort}${t.targetHost}:${t.remotePort}`); } } async function guard(fn) { try { await fn(); } catch (e) { logError(e instanceof Error ? e.message : String(e)); process.exit(1); } } export function registerTunnelSubcommands(vpn) { const tunnel = vpn .command("tunnel") .description("Userspace-Tailscale tunnels to VPC services (localhost:PORT set)"); tunnel .command("up", { isDefault: true }) .description("Start the tenant's VPC tunnels over userspace Tailscale") .option("--context <ctx>", "Login context for the VPN (default: <tenant>.<env>)") .action((cmdOpts) => guard(() => tunnelUp(vpn.opts(), cmdOpts))); tunnel .command("down") .description("Stop the tenant's VPC tunnels") .option("--stop", "Also stop the userspace tailscaled daemon (keeps VPN login)") .option("--logout", "Log the daemon out of Headscale and stop it") .action((cmdOpts) => guard(() => tunnelDown(vpn.opts(), cmdOpts))); tunnel .command("status") .description("Show daemon + tunnel status") .option("--json", "Machine-readable output") .action((cmdOpts) => guard(() => tunnelStatus(vpn.opts(), cmdOpts))); tunnel .command("__run <tenant>", { hidden: true }) .action((tenant) => runTunnels(tenant)); tunnel .command("__supervise <tenant>", { hidden: true }) .action((tenant) => runTunnels(tenant)); }