@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
442 lines (441 loc) • 16.3 kB
JavaScript
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);
}