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