Files
flynn/src/gateway/tailscale.ts
T

95 lines
3.2 KiB
TypeScript

import { execFile } from 'child_process';
export interface TailscaleServeConfig {
/** The gateway's local port (e.g. 18800). */
localPort: number;
/** Tailscale Serve HTTPS port (default 443). */
servePort?: number;
/** Custom hostname (default: machine hostname). */
hostname?: string;
}
/** Execute a command with a timeout, returning stdout. */
function exec(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || error.message}`));
} else {
resolve(stdout.trim());
}
});
});
}
/**
* Check if Tailscale CLI is available and the daemon is running.
*/
export async function isTailscaleAvailable(): Promise<{ available: boolean; version?: string; error?: string }> {
try {
const version = await exec('tailscale', ['version', '--short']);
// Check if daemon is running by getting status
await exec('tailscale', ['status', '--json']);
return { available: true, version };
} catch (err) {
return { available: false, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Get the MagicDNS hostname from Tailscale status.
*/
async function getTailscaleHostname(): Promise<string | undefined> {
try {
const statusJson = await exec('tailscale', ['status', '--json']);
const status = JSON.parse(statusJson) as { Self?: { DNSName?: string } };
const dnsName = status.Self?.DNSName;
// DNSName ends with a trailing dot — strip it
return dnsName?.replace(/\.$/, '');
} catch {
return undefined;
}
}
/**
* Start Tailscale Serve to expose the local gateway port on the tailnet.
*
* Runs: `tailscale serve --bg --https=<servePort> http://127.0.0.1:<localPort>`
*/
export async function startTailscaleServe(config: TailscaleServeConfig): Promise<string | undefined> {
const servePort = config.servePort ?? 443;
const target = `http://127.0.0.1:${config.localPort}`;
try {
await exec('tailscale', ['serve', '--bg', `--https=${servePort}`, target]);
const hostname = config.hostname ?? await getTailscaleHostname();
if (hostname) {
const url = servePort === 443 ? `https://${hostname}` : `https://${hostname}:${servePort}`;
console.log(`Tailscale Serve: Flynn available at ${url}`);
return url;
}
console.log('Tailscale Serve: started (could not determine hostname)');
return undefined;
} catch (err) {
console.error('Failed to start Tailscale Serve:', err instanceof Error ? err.message : err);
throw err;
}
}
/**
* Stop Tailscale Serve for the configured port.
*
* Runs: `tailscale serve --https=<servePort> off`
*/
export async function stopTailscaleServe(config: TailscaleServeConfig): Promise<void> {
const servePort = config.servePort ?? 443;
try {
await exec('tailscale', ['serve', `--https=${servePort}`, 'off']);
console.log('Tailscale Serve: stopped');
} catch (err) {
// Best effort — don't fail shutdown
console.error('Failed to stop Tailscale Serve:', err instanceof Error ? err.message : err);
}
}