UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

442 lines (441 loc) 16.3 kB
import { execFileSync } from "child_process"; import * as net from "node:net"; import { logInfo, logSuccess, logError, logWarn } from "../../utils/index.js"; import { getContextConfig } from "../login.js"; import { registerTunnelSubcommands } from "./tunnel.js"; import { ensureDaemon, joinHeadscale, daemonState, stopDaemon, tailscaleAvailable, readDaemonMeta, findRunningDaemon, } from "../../utils/tailscale.js"; function defaultNamespace(tenant, env) { return `${tenant}-${env}-headscale`; } const HEADSCALE_POD = "headscale-0"; const HEADSCALE_CONTAINER = "headscale"; function resolveNamespace(options) { return options.namespace ?? defaultNamespace(options.tenant, options.env); } function headscaleExec(namespace, args, opts) { const cmd = ["headscale", ...args]; if (opts?.json) { cmd.push("--output", "json"); } const execOpts = { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }; try { const result = execFileSync("kubectl", [ "exec", "-n", namespace, HEADSCALE_POD, "-c", HEADSCALE_CONTAINER, "--", ...cmd, ], execOpts); return result.trim(); } catch (error) { const execError = error; const stderr = execError.stderr?.toString().trim() ?? ""; if (stderr) { throw new Error(stderr); } throw error; } } function clusterName(tenant, env) { return `${tenant}-${env}-eks`; } function assertPodReady(namespace, options) { try { const output = execFileSync("kubectl", [ "get", "pod", HEADSCALE_POD, "-n", namespace, "-o", "jsonpath={.status.phase}", ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); if (output.trim() !== "Running") { logError(`Headscale pod is not running (status: ${output.trim()})`); process.exit(1); } } catch (err) { const stderr = err.stderr?.toString() ?? ""; logError(`Cannot reach headscale pod in namespace ${namespace}`); if (stderr.includes("expired") || stderr.includes("token")) { logInfo("Your AWS credentials appear to be expired. Refresh them and retry."); } else { const cluster = clusterName(options.tenant, options.env); logInfo("Make sure your kubeconfig is configured for the target cluster:"); const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; logInfo(` aws eks update-kubeconfig --name ${cluster} --region ${region}`); } process.exit(1); } } async function vpnStatus(options) { const ns = resolveNamespace(options); logInfo(`Checking Headscale in namespace ${ns}...`); assertPodReady(ns, options); const podJson = execFileSync("kubectl", [ "get", "pod", HEADSCALE_POD, "-n", ns, "-o", "json", ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); const pod = JSON.parse(podJson); const container = pod.status?.containerStatuses?.find((c) => c.name === HEADSCALE_CONTAINER); const image = container?.image ?? "unknown"; const ready = container?.ready ?? false; const restarts = container?.restartCount ?? 0; let endpoint = "unknown"; try { endpoint = execFileSync("kubectl", [ "get", "httproute", "-n", ns, "-o", "jsonpath={.items[0].spec.hostnames[0]}", ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); if (endpoint) { endpoint = `https://${endpoint}`; } } catch { } let userCount = 0; try { const usersJson = headscaleExec(ns, ["users", "list"], { json: true }); const users = JSON.parse(usersJson); userCount = Array.isArray(users) ? users.length : 0; } catch { } let nodeCount = 0; try { const nodesJson = headscaleExec(ns, ["nodes", "list"], { json: true }); const nodes = JSON.parse(nodesJson); nodeCount = Array.isArray(nodes) ? nodes.length : 0; } catch { } console.log(""); console.log(` Endpoint: ${endpoint}`); console.log(` Image: ${image}`); console.log(` Ready: ${ready ? "yes" : "no"}`); console.log(` Restarts: ${restarts}`); console.log(` Namespace: ${ns}`); console.log(` Users: ${userCount}`); console.log(` Nodes: ${nodeCount}`); console.log(""); if (ready) { logSuccess("Headscale is healthy"); } else { logWarn("Headscale pod is not ready"); } } async function createApiKey(options) { const ns = resolveNamespace(options); logInfo(`Creating Headscale API key in namespace ${ns}...`); assertPodReady(ns, options); try { const output = headscaleExec(ns, ["apikeys", "create"]); console.log(""); logSuccess("API key created:"); console.log(""); console.log(` ${output}`); console.log(""); logWarn("Store this key securely — it cannot be retrieved again."); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError(`Failed to create API key: ${message}`); process.exit(1); } } async function createPreAuthKey(options) { const ns = resolveNamespace(options); const { user, expiration, reusable, ephemeral } = options; logInfo(`Creating pre-auth key for user "${user}" in namespace ${ns}...`); assertPodReady(ns, options); try { headscaleExec(ns, ["users", "create", user]); logInfo(`Created user "${user}"`); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("already exists")) { logError(`Failed to create user: ${message}`); process.exit(1); } logInfo(`User "${user}" already exists`); } const args = ["preauthkeys", "create", "--user", user]; if (expiration) { args.push("--expiration", expiration); } if (reusable) { args.push("--reusable"); } if (ephemeral) { args.push("--ephemeral"); } try { const output = headscaleExec(ns, args); console.log(""); logSuccess("Pre-authentication key created:"); console.log(""); console.log(` ${output}`); console.log(""); logInfo("Use this key to register a node:"); logInfo(` tailscale up --login-server <endpoint> --authkey ${output}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError(`Failed to create pre-auth key: ${message}`); process.exit(1); } } async function listUsers(options) { const ns = resolveNamespace(options); logInfo(`Listing Headscale users in namespace ${ns}...`); assertPodReady(ns, options); try { const output = headscaleExec(ns, ["users", "list"]); console.log(""); console.log(output); console.log(""); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError(`Failed to list users: ${message}`); process.exit(1); } } function vpnEndpointFromContext(context) { const config = getContextConfig(context); if (!config) { throw new Error(`No configuration found for "${context}".\n` + `Run: mesh login ${context} (or add the context to ~/.config/mesh/config.json)`); } const issuerUrl = new URL(config.issuer); const hostParts = issuerUrl.hostname.split("."); if (hostParts.length < 3) { throw new Error(`Cannot derive VPN endpoint from issuer "${config.issuer}". ` + `Expected a subdomain like identity.<env>.<domain>.`); } hostParts[0] = "vpn"; return `https://${hostParts.join(".")}`; } export function parseContextTenantEnv(context) { const [tenant, env] = context.split("."); return { tenant: tenant || "mesh", env: env || "dev" }; } function findFreePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.on("error", reject); srv.listen(0, "127.0.0.1", () => { const addr = srv.address(); const port = typeof addr === "object" && addr ? addr.port : 0; srv.close(() => resolve(port)); }); }); } function findTailscale() { try { return execFileSync("which", ["tailscale"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim() || null; } catch { return null; } } function tailscaleStatus() { try { const json = execFileSync("tailscale", ["status", "--json"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); return JSON.parse(json); } catch { return null; } } export function tailscaleBackendState() { return tailscaleStatus()?.BackendState ?? null; } function normalizeControlUrl(url) { try { const u = new URL(url); return `${u.protocol}//${u.host}`.toLowerCase(); } catch { return url.replace(/\/+$/, "").toLowerCase(); } } async function vpnConnect(context, opts = {}) { const endpoint = vpnEndpointFromContext(context); if (opts.system) { await vpnConnectSystem(endpoint); return; } await vpnConnectUserspace(context, endpoint); } async function vpnConnectUserspace(context, loginServer) { if (!tailscaleAvailable()) { logError("Standalone tailscale/tailscaled not found."); logInfo("Install it: brew install tailscale"); logInfo("(The GUI Tailscale.app is not used here — for whole-machine VPN see `mesh vpn connect --system`.)"); process.exit(1); } const { tenant } = parseContextTenantEnv(context); const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; if (daemonState(tenant).backendState === "Running") { const port = readDaemonMeta(tenant)?.socksPort ?? findRunningDaemon(tenant)?.socksPort; logSuccess(`Already connected to the platform VPN for '${tenant}' (userspace).`); if (port) logInfo(` SOCKS5 proxy: 127.0.0.1:${port} (route a tool via ALL_PROXY=socks5://127.0.0.1:${port})`); return; } logInfo(`Connecting to the platform VPN for '${tenant}' (userspace tailscaled)…`); const socksPort = readDaemonMeta(tenant)?.socksPort ?? (await findFreePort()); const realPort = await ensureDaemon(tenant, { region, socksPort }); await joinHeadscale(tenant, loginServer); logSuccess(`VPN connected (userspace) for '${tenant}'.`); logInfo(` SOCKS5 proxy: 127.0.0.1:${realPort} — route a tool via ALL_PROXY=socks5://127.0.0.1:${realPort}`); logInfo(" For auto-forwarded VPC services (Temporal, RDS, …): mesh vpn tunnel up (or mesh dev)"); } async function vpnConnectSystem(endpoint) { if (!findTailscale()) { logError("Tailscale CLI not found."); logInfo("Install it: brew install tailscale (or see https://tailscale.com/download)"); process.exit(1); } const status = tailscaleStatus(); if (status?.BackendState === "Running") { const current = status.ControlURL ? normalizeControlUrl(status.ControlURL) : null; const target = normalizeControlUrl(endpoint); if (current === target) { logSuccess(`Already connected to VPN at ${endpoint}.`); logInfo("Run: tailscale status to see connected nodes."); return; } if (current) { logWarn(`Currently connected to ${current}. Reconnecting to ${target}...`); } } logInfo(`Connecting to VPN at ${endpoint}...`); logInfo("A browser window will open for Zitadel authentication."); console.log(""); const upArgs = [ "up", "--reset", "--login-server", endpoint, "--accept-routes", ]; try { execFileSync("tailscale", upArgs, { stdio: "inherit" }); console.log(""); logSuccess("VPN connected."); } catch (error) { const code = error.status; const isPermissionError = process.platform === "linux" && (code === 1 || code === 2); console.log(""); logError(`tailscale up failed (exit ${code}).`); if (isPermissionError) { logInfo("On Linux, tailscaled runs as root — you may need sudo:"); logInfo(` sudo tailscale ${upArgs.join(" ")}`); } process.exit(1); } } async function vpnDisconnect(opts) { if (opts.system) { if (!findTailscale()) { logError("Tailscale CLI not found."); process.exit(1); } if (tailscaleBackendState() !== "Running") { logInfo("System VPN is not connected."); return; } logInfo("Disconnecting system VPN…"); try { execFileSync("tailscale", ["down"], { stdio: "inherit" }); logSuccess("VPN disconnected."); } catch (error) { const code = error.status; logError(`tailscale down failed (exit ${code}). Try: sudo tailscale down`); process.exit(1); } return; } const { tenant } = opts; if (daemonState(tenant).backendState === "Down") { logInfo(`No userspace VPN daemon running for '${tenant}'.`); return; } logInfo(`Disconnecting userspace VPN for '${tenant}'…`); stopDaemon(tenant); logSuccess(`VPN disconnected for '${tenant}'.`); } export function registerVpnCommands(program) { const vpn = program .command("vpn") .description("Headscale VPN management") .option("-t, --tenant <tenant>", "Platform tenant", "mesh") .option("-e, --env <env>", "Platform environment", "dev") .option("-n, --namespace <namespace>", "Override K8s namespace (default: {tenant}-{env}-headscale)"); vpn .command("status") .description("Show VPN control plane status") .action(async () => { const opts = vpn.opts(); await vpnStatus(opts); }); vpn .command("connect") .description("Connect to the platform VPN (userspace tailscaled; opens browser for Zitadel auth)") .argument("<context>", 'Platform context (e.g., "mesh.dev")') .option("--system", "Use the whole-machine GUI Tailscale.app instead (system TUN; cannot run headless/sandboxed)") .action(async (context, cmdOpts) => { await vpnConnect(context, cmdOpts); }); vpn .command("disconnect") .description("Disconnect from the platform VPN — stops the tenant's shared userspace daemon " + "(also ends any active `mesh vpn tunnel` / `mesh dev` forwards for it); --system for GUI Tailscale") .option("--system", "Disconnect the whole-machine GUI Tailscale instead of the userspace daemon") .action(async (cmdOpts) => { const { tenant } = vpn.opts(); await vpnDisconnect({ tenant, system: cmdOpts.system }); }); vpn .command("api-key") .description("Create a Headscale API key") .action(async () => { const opts = vpn.opts(); await createApiKey(opts); }); vpn .command("pre-auth-key") .description("Create a pre-authentication key for node registration") .requiredOption("-u, --user <user>", "User/namespace to create the key for (e.g., matt@trabian.com)") .option("--expiration <duration>", "Key expiration (e.g., 24h, 7d)", "24h") .option("--reusable", "Allow key to be used multiple times") .option("--ephemeral", "Nodes registered with this key are ephemeral") .action(async (cmdOpts) => { const parentOpts = vpn.opts(); await createPreAuthKey({ ...parentOpts, ...cmdOpts }); }); vpn .command("users") .description("List registered VPN users") .action(async () => { const opts = vpn.opts(); await listUsers(opts); }); registerTunnelSubcommands(vpn); }