import { runCommandWithTimeout } from "../process/exec.js"; export type GatewayBonjourBeacon = { instanceName: string; displayName?: string; host?: string; port?: number; lanHost?: string; tailnetDns?: string; bridgePort?: number; gatewayPort?: number; sshPort?: number; cliPath?: string; txt?: Record; }; export type GatewayBonjourDiscoverOpts = { timeoutMs?: number; }; const DEFAULT_TIMEOUT_MS = 2000; function parseIntOrNull(value: string | undefined): number | undefined { if (!value) return undefined; const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : undefined; } function parseTxtTokens(tokens: string[]): Record { const txt: Record = {}; for (const token of tokens) { const idx = token.indexOf("="); if (idx <= 0) continue; const key = token.slice(0, idx).trim(); const value = token.slice(idx + 1).trim(); if (!key) continue; txt[key] = value; } return txt; } function parseDnsSdBrowse(stdout: string): string[] { const instances = new Set(); for (const raw of stdout.split("\n")) { const line = raw.trim(); if (!line || !line.includes("_clawdis-bridge._tcp")) continue; if (!line.includes("Add")) continue; const match = line.match(/_clawdis-bridge\._tcp\.?\s+(.+)$/); if (match?.[1]) { instances.add(match[1].trim()); } } return Array.from(instances.values()); } function parseDnsSdResolve( stdout: string, instanceName: string, ): GatewayBonjourBeacon | null { const beacon: GatewayBonjourBeacon = { instanceName }; let txt: Record = {}; for (const raw of stdout.split("\n")) { const line = raw.trim(); if (!line) continue; if (line.includes("can be reached at")) { const match = line.match(/can be reached at\s+([^\s:]+):(\d+)/i); if (match?.[1]) { beacon.host = match[1].replace(/\.$/, ""); } if (match?.[2]) { beacon.port = parseIntOrNull(match[2]); } continue; } if (line.startsWith("txt") || line.includes("txtvers=")) { const tokens = line.split(/\s+/).filter(Boolean); txt = parseTxtTokens(tokens); } } beacon.txt = Object.keys(txt).length ? txt : undefined; if (txt.displayName) beacon.displayName = txt.displayName; if (txt.lanHost) beacon.lanHost = txt.lanHost; if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns; if (txt.cliPath) beacon.cliPath = txt.cliPath; beacon.bridgePort = parseIntOrNull(txt.bridgePort); beacon.gatewayPort = parseIntOrNull(txt.gatewayPort); beacon.sshPort = parseIntOrNull(txt.sshPort); if (!beacon.displayName) beacon.displayName = instanceName; return beacon; } async function discoverViaDnsSd( timeoutMs: number, ): Promise { const browse = await runCommandWithTimeout( ["dns-sd", "-B", "_clawdis-bridge._tcp", "local."], { timeoutMs }, ); const instances = parseDnsSdBrowse(browse.stdout); const results: GatewayBonjourBeacon[] = []; for (const instance of instances) { const resolved = await runCommandWithTimeout( ["dns-sd", "-L", instance, "_clawdis-bridge._tcp", "local."], { timeoutMs }, ); const parsed = parseDnsSdResolve(resolved.stdout, instance); if (parsed) results.push(parsed); } return results; } function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] { const results: GatewayBonjourBeacon[] = []; let current: GatewayBonjourBeacon | null = null; for (const raw of stdout.split("\n")) { const line = raw.trimEnd(); if (!line) continue; if (line.startsWith("=") && line.includes("_clawdis-bridge._tcp")) { if (current) results.push(current); const marker = " _clawdis-bridge._tcp"; const idx = line.indexOf(marker); const left = idx >= 0 ? line.slice(0, idx).trim() : line; const parts = left.split(/\s+/); const instanceName = parts.length > 3 ? parts.slice(3).join(" ") : left; current = { instanceName, displayName: instanceName, }; continue; } if (!current) continue; const trimmed = line.trim(); if (trimmed.startsWith("hostname =")) { const match = trimmed.match(/hostname\s*=\s*\[([^\]]+)\]/); if (match?.[1]) current.host = match[1]; continue; } if (trimmed.startsWith("port =")) { const match = trimmed.match(/port\s*=\s*\[(\d+)\]/); if (match?.[1]) current.port = parseIntOrNull(match[1]); continue; } if (trimmed.startsWith("txt =")) { const tokens = Array.from(trimmed.matchAll(/"([^"]*)"/g), (m) => m[1]); const txt = parseTxtTokens(tokens); current.txt = Object.keys(txt).length ? txt : undefined; if (txt.displayName) current.displayName = txt.displayName; if (txt.lanHost) current.lanHost = txt.lanHost; if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns; if (txt.cliPath) current.cliPath = txt.cliPath; current.bridgePort = parseIntOrNull(txt.bridgePort); current.gatewayPort = parseIntOrNull(txt.gatewayPort); current.sshPort = parseIntOrNull(txt.sshPort); } } if (current) results.push(current); return results; } async function discoverViaAvahi( timeoutMs: number, ): Promise { const browse = await runCommandWithTimeout( ["avahi-browse", "-rt", "_clawdis-bridge._tcp"], { timeoutMs }, ); return parseAvahiBrowse(browse.stdout); } export async function discoverGatewayBeacons( opts: GatewayBonjourDiscoverOpts = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; try { if (process.platform === "darwin") { return await discoverViaDnsSd(timeoutMs); } if (process.platform === "linux") { return await discoverViaAvahi(timeoutMs); } } catch { return []; } return []; }