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 = "" ;
let settled = false ;
let timer: ReturnType<typeof setTimeout>;
const finish = (result: { code: number; stdout: string }) => {
if (settled) {
return ;
}
settled = true ;
clearTimeout(timer);
resolve(result);
};
proc.stdout.on("data" , (data) => {
stdout += data;
});
timer = setTimeout(() => {
proc.kill("SIGKILL" );
finish({ code: -1 , stdout: "" });
}, timeoutMs);
proc.on("error" , () => {
finish({ code: -1 , stdout: "" });
});
proc.on("close" , (code) => {
finish({ 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 });
}
Messung V0.5 in Prozent C=100 H=98 G=98
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland