import { spawn } from "node:child_process"; import type { VoiceCallConfig } from "../config.js"; export type TailscaleSelfInfo = { dnsName: string | null; nodeId: string | null; }; function runTailscaleCommand( args: string[], timeoutMs = 2500, ): Promise<{ code: number; stdout: string }> { return new Promise((resolve) => { const proc = spawn("tailscale", args, { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; proc.stdout.on("data", (data) => { stdout += data; }); const timer = setTimeout(() => { proc.kill("SIGKILL"); resolve({ code: -1, stdout: "" }); }, timeoutMs); proc.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stdout }); }); }); } export async function getTailscaleSelfInfo(): Promise { const { code, stdout } = await runTailscaleCommand(["status", "--json"]); if (code !== 0) { return null; } try { const status = JSON.parse(stdout); return { dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null, nodeId: status.Self?.ID || null, }; } catch { return null; } } export async function getTailscaleDnsName(): Promise { const info = await getTailscaleSelfInfo(); return info?.dnsName ?? null; } export async function setupTailscaleExposureRoute(opts: { mode: "serve" | "funnel"; path: string; localUrl: string; }): Promise { const dnsName = await getTailscaleDnsName(); if (!dnsName) { console.warn("[voice-call] Could not get Tailscale DNS name"); return null; } const { code } = await runTailscaleCommand([ opts.mode, "--bg", "--yes", "--set-path", opts.path, opts.localUrl, ]); if (code === 0) { const publicUrl = `https://${dnsName}${opts.path}`; console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`); return publicUrl; } console.warn(`[voice-call] Tailscale ${opts.mode} failed`); return null; } export async function cleanupTailscaleExposureRoute(opts: { mode: "serve" | "funnel"; path: string; }): Promise { await runTailscaleCommand([opts.mode, "off", opts.path]); } export async function setupTailscaleExposure(config: VoiceCallConfig): Promise { if (config.tailscale.mode === "off") { return null; } const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve"; const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`; return setupTailscaleExposureRoute({ mode, path: config.tailscale.path, localUrl, }); } export async function cleanupTailscaleExposure(config: VoiceCallConfig): Promise { if (config.tailscale.mode === "off") { return; } const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve"; await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path }); }