116 lines
2.9 KiB
TypeScript
116 lines
2.9 KiB
TypeScript
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<TailscaleSelfInfo | null> {
|
|
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<string | null> {
|
|
const info = await getTailscaleSelfInfo();
|
|
return info?.dnsName ?? null;
|
|
}
|
|
|
|
export async function setupTailscaleExposureRoute(opts: {
|
|
mode: "serve" | "funnel";
|
|
path: string;
|
|
localUrl: string;
|
|
}): Promise<string | null> {
|
|
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<void> {
|
|
await runTailscaleCommand([opts.mode, "off", opts.path]);
|
|
}
|
|
|
|
export async function setupTailscaleExposure(config: VoiceCallConfig): Promise<string | null> {
|
|
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<void> {
|
|
if (config.tailscale.mode === "off") {
|
|
return;
|
|
}
|
|
|
|
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path });
|
|
}
|