import { runCommandWithTimeout } from "../process/exec.js"; import { isTailnetIPv4 } from "./tailnet.js"; import { resolveWideAreaDiscoveryDomain } from "./widearea-dns.js"; export type GatewayBonjourBeacon = { instanceName: string; domain?: string; displayName?: string; host?: string; port?: number; lanHost?: string; tailnetDns?: string; gatewayPort?: number; sshPort?: number; gatewayTls?: boolean; gatewayTlsFingerprintSha256?: string; cliPath?: string; role?: string; transport?: string; txt?: Record; }; export type GatewayBonjourDiscoverOpts = { timeoutMs?: number; domains?: string[]; wideAreaDomain?: string | null; platform?: NodeJS.Platform; run?: typeof runCommandWithTimeout; }; const DEFAULT_TIMEOUT_MS = 2000; const GATEWAY_SERVICE_TYPE = "_openclaw-gw._tcp"; function decodeDnsSdEscapes(value: string): string { let decoded = false; const bytes: number[] = []; let pending = ""; const flush = () => { if (!pending) { return; } bytes.push(...Buffer.from(pending, "utf8")); pending = ""; }; for (let i = 0; i < value.length; i += 1) { const ch = value[i] ?? ""; if (ch === "\\" && i + 3 < value.length) { const escaped = value.slice(i + 1, i + 4); if (/^[0-9]{3}$/.test(escaped)) { const byte = Number.parseInt(escaped, 10); if (!Number.isFinite(byte) || byte < 0 || byte > 255) { pending += ch; continue; } flush(); bytes.push(byte); decoded = true; i += 3; continue; } } pending += ch; } if (!decoded) { return value; } flush(); return Buffer.from(bytes).toString("utf8"); } function parseDigShortLines(stdout: string): string[] { return stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); } function parseDigTxt(stdout: string): string[] { // dig +short TXT prints one or more lines of quoted strings: // "k=v" "k2=v2" const tokens: string[] = []; for (const raw of stdout.split("\n")) { const line = raw.trim(); if (!line) { continue; } const matches = Array.from(line.matchAll(/"([^"]*)"/g), (m) => m[1] ?? ""); for (const m of matches) { const unescaped = m.replaceAll("\\\\", "\\").replaceAll('\\"', '"').replaceAll("\\n", "\n"); tokens.push(unescaped); } } return tokens; } function parseDigSrv(stdout: string): { host: string; port: number } | null { // dig +short SRV: "0 0 18790 host.domain." const line = stdout .split("\n") .map((l) => l.trim()) .find(Boolean); if (!line) { return null; } const parts = line.split(/\s+/).filter(Boolean); if (parts.length < 4) { return null; } const port = Number.parseInt(parts[2] ?? "", 10); const hostRaw = parts[3] ?? ""; if (!Number.isFinite(port) || port <= 0) { return null; } const host = hostRaw.replace(/\.$/, ""); if (!host) { return null; } return { host, port }; } function parseTailscaleStatusIPv4s(stdout: string): string[] { const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; const out: string[] = []; const addIps = (value: unknown) => { if (!value || typeof value !== "object") { return; } const ips = (value as { TailscaleIPs?: unknown }).TailscaleIPs; if (!Array.isArray(ips)) { return; } for (const ip of ips) { if (typeof ip !== "string") { continue; } const trimmed = ip.trim(); if (trimmed && isTailnetIPv4(trimmed)) { out.push(trimmed); } } }; addIps((parsed as { Self?: unknown }).Self); const peerObj = (parsed as { Peer?: unknown }).Peer; if (peerObj && typeof peerObj === "object") { for (const peer of Object.values(peerObj as Record)) { addIps(peer); } } return [...new Set(out)]; } 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 = decodeDnsSdEscapes(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(GATEWAY_SERVICE_TYPE)) { continue; } if (!line.includes("Add")) { continue; } const match = line.match(/_openclaw-gw\._tcp\.?\s+(.+)$/); if (match?.[1]) { instances.add(decodeDnsSdEscapes(match[1].trim())); } } return Array.from(instances.values()); } function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjourBeacon | null { const decodedInstanceName = decodeDnsSdEscapes(instanceName); const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName }; 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 = decodeDnsSdEscapes(txt.displayName); } if (txt.lanHost) { beacon.lanHost = txt.lanHost; } if (txt.tailnetDns) { beacon.tailnetDns = txt.tailnetDns; } if (txt.cliPath) { beacon.cliPath = txt.cliPath; } beacon.gatewayPort = parseIntOrNull(txt.gatewayPort); beacon.sshPort = parseIntOrNull(txt.sshPort); if (txt.gatewayTls) { const raw = txt.gatewayTls.trim().toLowerCase(); beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txt.gatewayTlsSha256) { beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256; } if (txt.role) { beacon.role = txt.role; } if (txt.transport) { beacon.transport = txt.transport; } if (!beacon.displayName) { beacon.displayName = decodedInstanceName; } return beacon; } async function discoverViaDnsSd( domain: string, timeoutMs: number, run: typeof runCommandWithTimeout, ): Promise { const browse = await run(["dns-sd", "-B", GATEWAY_SERVICE_TYPE, domain], { timeoutMs, }); const instances = parseDnsSdBrowse(browse.stdout); const results: GatewayBonjourBeacon[] = []; for (const instance of instances) { const resolved = await run(["dns-sd", "-L", instance, GATEWAY_SERVICE_TYPE, domain], { timeoutMs, }); const parsed = parseDnsSdResolve(resolved.stdout, instance); if (parsed) { results.push({ ...parsed, domain }); } } return results; } async function discoverWideAreaViaTailnetDns( domain: string, timeoutMs: number, run: typeof runCommandWithTimeout, ): Promise { if (!domain || domain === "local.") { return []; } const startedAt = Date.now(); const remainingMs = () => timeoutMs - (Date.now() - startedAt); const tailscaleCandidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; let ips: string[] = []; for (const candidate of tailscaleCandidates) { try { const res = await run([candidate, "status", "--json"], { timeoutMs: Math.max(1, Math.min(700, remainingMs())), }); ips = parseTailscaleStatusIPv4s(res.stdout); if (ips.length > 0) { break; } } catch { // ignore } } if (ips.length === 0) { return []; } if (remainingMs() <= 0) { return []; } // Keep scans bounded: this is a fallback and should not block long. ips = ips.slice(0, 40); const probeName = `${GATEWAY_SERVICE_TYPE}.${domain.replace(/\.$/, "")}`; const concurrency = 6; let nextIndex = 0; let nameserver: string | null = null; let ptrs: string[] = []; const worker = async () => { while (nameserver === null) { const budget = remainingMs(); if (budget <= 0) { return; } const i = nextIndex; nextIndex += 1; if (i >= ips.length) { return; } const ip = ips[i] ?? ""; if (!ip) { continue; } try { const probe = await run( ["dig", "+short", "+time=1", "+tries=1", `@${ip}`, probeName, "PTR"], { timeoutMs: Math.max(1, Math.min(250, budget)) }, ); const lines = parseDigShortLines(probe.stdout); if (lines.length === 0) { continue; } nameserver = ip; ptrs = lines; return; } catch { // ignore } } }; await Promise.all(Array.from({ length: Math.min(concurrency, ips.length) }, () => worker())); if (!nameserver || ptrs.length === 0) { return []; } if (remainingMs() <= 0) { return []; } const nameserverArg = `@${String(nameserver)}`; const results: GatewayBonjourBeacon[] = []; for (const ptr of ptrs) { const budget = remainingMs(); if (budget <= 0) { break; } const ptrName = ptr.trim().replace(/\.$/, ""); if (!ptrName) { continue; } const instanceName = ptrName.replace(/\.?_openclaw-gw\._tcp\..*$/, ""); const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], { timeoutMs: Math.max(1, Math.min(350, budget)), }).catch(() => null); const srvParsed = srv ? parseDigSrv(srv.stdout) : null; if (!srvParsed) { continue; } const txtBudget = remainingMs(); if (txtBudget <= 0) { results.push({ instanceName: instanceName || ptrName, displayName: instanceName || ptrName, domain, host: srvParsed.host, port: srvParsed.port, }); continue; } const txt = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "TXT"], { timeoutMs: Math.max(1, Math.min(350, txtBudget)), }).catch(() => null); const txtTokens = txt ? parseDigTxt(txt.stdout) : []; const txtMap = txtTokens.length > 0 ? parseTxtTokens(txtTokens) : {}; const beacon: GatewayBonjourBeacon = { instanceName: instanceName || ptrName, displayName: txtMap.displayName || instanceName || ptrName, domain, host: srvParsed.host, port: srvParsed.port, txt: Object.keys(txtMap).length ? txtMap : undefined, gatewayPort: parseIntOrNull(txtMap.gatewayPort), sshPort: parseIntOrNull(txtMap.sshPort), tailnetDns: txtMap.tailnetDns || undefined, cliPath: txtMap.cliPath || undefined, }; if (txtMap.gatewayTls) { const raw = txtMap.gatewayTls.trim().toLowerCase(); beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txtMap.gatewayTlsSha256) { beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256; } if (txtMap.role) { beacon.role = txtMap.role; } if (txtMap.transport) { beacon.transport = txtMap.transport; } results.push(beacon); } 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(GATEWAY_SERVICE_TYPE)) { if (current) { results.push(current); } const marker = ` ${GATEWAY_SERVICE_TYPE}`; 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.gatewayPort = parseIntOrNull(txt.gatewayPort); current.sshPort = parseIntOrNull(txt.sshPort); if (txt.gatewayTls) { const raw = txt.gatewayTls.trim().toLowerCase(); current.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txt.gatewayTlsSha256) { current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256; } if (txt.role) { current.role = txt.role; } if (txt.transport) { current.transport = txt.transport; } } } if (current) { results.push(current); } return results; } async function discoverViaAvahi( domain: string, timeoutMs: number, run: typeof runCommandWithTimeout, ): Promise { const args = ["avahi-browse", "-rt", GATEWAY_SERVICE_TYPE]; if (domain && domain !== "local.") { // avahi-browse wants a plain domain (no trailing dot) args.push("-d", domain.replace(/\.$/, "")); } const browse = await run(args, { timeoutMs }); return parseAvahiBrowse(browse.stdout).map((beacon) => ({ ...beacon, domain, })); } export async function discoverGatewayBeacons( opts: GatewayBonjourDiscoverOpts = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const platform = opts.platform ?? process.platform; const run = opts.run ?? runCommandWithTimeout; const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: opts.wideAreaDomain }); const domainsRaw = Array.isArray(opts.domains) ? opts.domains : []; const defaultDomains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])]; const domains = (domainsRaw.length > 0 ? domainsRaw : defaultDomains) .map((d) => String(d).trim()) .filter(Boolean) .map((d) => (d.endsWith(".") ? d : `${d}.`)); try { if (platform === "darwin") { const perDomain = await Promise.allSettled( domains.map(async (domain) => await discoverViaDnsSd(domain, timeoutMs, run)), ); const discovered = perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); const wantsWideArea = wideAreaDomain ? domains.includes(wideAreaDomain) : false; const hasWideArea = wideAreaDomain ? discovered.some((b) => b.domain === wideAreaDomain) : false; if (wantsWideArea && !hasWideArea && wideAreaDomain) { const fallback = await discoverWideAreaViaTailnetDns(wideAreaDomain, timeoutMs, run).catch( () => [], ); return [...discovered, ...fallback]; } return discovered; } if (platform === "linux") { const perDomain = await Promise.allSettled( domains.map(async (domain) => await discoverViaAvahi(domain, timeoutMs, run)), ); return perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); } } catch { return []; } return []; }