UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

321 lines (320 loc) • 11.5 kB
import { c as normalizeOptionalLowercaseString } from "./string-coerce-1VuiLaaR.mjs"; import "./src-BirUqOZp.mjs"; import { t as expectDefined } from "./expect-lbe3Hgrh.mjs"; import { d as normalizeStringEntries, y as uniqueStrings } from "./string-normalization-D6DKCpwE.mjs"; import { n as parseTcpPort } from "./tcp-port-B-4SirsX.mjs"; import { i as runCommandWithTimeout } from "./exec-DdJWnONN.mjs"; import { t as isTailnetIPv4 } from "./tailnet-CpQajnSe.mjs"; import { a as resolveWideAreaDiscoveryDomain } from "./widearea-dns-BIz0F_nE.mjs"; import pLimit from "p-limit"; //#region src/infra/bonjour-discovery.ts function resolveGatewayDiscoveryEndpoint(beacon) { const host = beacon.host?.trim(); const port = beacon.port; if (!host || typeof port !== "number" || !Number.isSafeInteger(port) || port <= 0 || port > 65535) return null; const gatewayTls = beacon.gatewayTls === true; const scheme = gatewayTls ? "wss" : "ws"; return { host, port, gatewayTls, gatewayTlsFingerprintSha256: beacon.gatewayTlsFingerprintSha256, scheme, wsUrl: `${scheme}://${host}:${port}` }; } const DEFAULT_TIMEOUT_MS = 2e3; const GATEWAY_SERVICE_TYPE = "_openclaw-gw._tcp"; function decodeDnsSdEscapes(value) { const parts = []; let offset = 0; for (const match of value.matchAll(/\\[0-9]{3}/g)) { const byte = Number.parseInt(match[0].slice(1), 10); if (byte > 255) continue; parts.push(Buffer.from(value.slice(offset, match.index), "utf8"), Buffer.from([byte])); offset = match.index + match[0].length; } if (offset === 0) return value; parts.push(Buffer.from(value.slice(offset), "utf8")); return Buffer.concat(parts).toString("utf8"); } function parseDigTxt(stdout) { return stdout.split("\n").flatMap((line) => Array.from(line.matchAll(/"([^"]*)"/g), (match) => (match[1] ?? "").replaceAll("\\\\", "\\").replaceAll("\\\"", "\"").replaceAll("\\n", "\n"))); } function parseDigSrv(stdout) { 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 = parseTcpPort(parts[2]); const hostRaw = parts[3] ?? ""; if (port === null) return null; const host = hostRaw.replace(/\.$/, ""); if (!host) return null; return { host, port }; } function parseTailscaleStatusIPv4s(stdout) { const parsed = stdout ? JSON.parse(stdout) : {}; const out = []; const addIps = (value) => { if (!value || typeof value !== "object") return; const ips = value.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.Self); const peerObj = parsed.Peer; if (peerObj && typeof peerObj === "object") for (const peer of Object.values(peerObj)) addIps(peer); return uniqueStrings(out); } function parseTxtTokens(tokens) { const txt = {}; 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 applyBeaconTxt(beacon, txt) { for (const field of [ "tailnetDns", "cliPath", "role", "transport" ]) if (txt[field]) beacon[field] = txt[field]; beacon.gatewayPort = parseTcpPort(txt.gatewayPort) ?? void 0; beacon.sshPort = parseTcpPort(txt.sshPort) ?? void 0; if (txt.gatewayTls) { const raw = normalizeOptionalLowercaseString(txt.gatewayTls); beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txt.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256; } function parseDnsSdBrowse(stdout) { const instances = /* @__PURE__ */ 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, instanceName) { const decodedInstanceName = decodeDnsSdEscapes(instanceName); const beacon = { instanceName: decodedInstanceName }; let txt = {}; 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:]+):([^\s]+)/i); if (match?.[1]) beacon.host = match[1].replace(/\.$/, ""); if (match?.[2]) beacon.port = parseTcpPort(match[2]) ?? void 0; continue; } if (line.startsWith("txt") || line.includes("txtvers=")) txt = parseTxtTokens(line.split(/\s+/).filter(Boolean)); } beacon.txt = Object.keys(txt).length ? txt : void 0; if (txt.displayName) beacon.displayName = decodeDnsSdEscapes(txt.displayName); if (txt.lanHost) beacon.lanHost = txt.lanHost; applyBeaconTxt(beacon, txt); if (!beacon.displayName) beacon.displayName = decodedInstanceName; return beacon; } async function discoverViaDnsSd(domain, timeoutMs, run) { const instances = parseDnsSdBrowse((await run([ "dns-sd", "-B", GATEWAY_SERVICE_TYPE, domain ], { timeoutMs })).stdout); const results = []; for (const instance of instances) { const resolved = await run([ "dns-sd", "-L", instance, GATEWAY_SERVICE_TYPE, domain ], { timeoutMs }); results.push({ ...parseDnsSdResolve(resolved.stdout, instance), domain }); } return results; } async function discoverWideAreaViaTailnetDns(domain, timeoutMs, run) { 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 = []; for (const candidate of tailscaleCandidates) try { ips = parseTailscaleStatusIPv4s((await run([ candidate, "status", "--json" ], { timeoutMs: Math.max(1, Math.min(700, remainingMs())) })).stdout); if (ips.length > 0) break; } catch {} if (ips.length === 0) return []; if (remainingMs() <= 0) return []; ips = ips.slice(0, 40); const probeName = `${GATEWAY_SERVICE_TYPE}.${domain.replace(/\.$/, "")}`; let nameserver = null; let ptrs = []; await pLimit(6).map(ips, async (ip) => { if (nameserver !== null) return; const budget = remainingMs(); if (budget <= 0) return; try { const probe = await run([ "dig", "+short", "+time=1", "+tries=1", `@${ip}`, probeName, "PTR" ], { timeoutMs: Math.max(1, Math.min(250, budget)) }); const lines = normalizeStringEntries(probe.stdout.split("\n")); if (lines.length > 0) { nameserver = ip; ptrs = lines; } } catch {} }); if (!nameserver || ptrs.length === 0) return []; if (remainingMs() <= 0) return []; const nameserverArg = `@${String(nameserver)}`; const results = []; 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 beacon = { instanceName: instanceName || ptrName, displayName: instanceName || ptrName, domain, host: srvParsed.host, port: srvParsed.port }; results.push(beacon); const txtBudget = remainingMs(); if (txtBudget <= 0) 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) : {}; beacon.displayName = txtMap.displayName || beacon.displayName; beacon.txt = Object.keys(txtMap).length ? txtMap : void 0; beacon.tailnetDns = txtMap.tailnetDns || void 0; beacon.cliPath = txtMap.cliPath || void 0; applyBeaconTxt(beacon, txtMap); } return results; } function parseAvahiBrowse(stdout) { const results = []; let current = 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 = parseTcpPort(match[1]) ?? void 0; continue; } if (trimmed.startsWith("txt =")) { const txt = parseTxtTokens(Array.from(trimmed.matchAll(/"([^"]*)"/g), (match) => expectDefined(match.at(1), "Bonjour TXT token"))); current.txt = Object.keys(txt).length ? txt : void 0; if (txt.displayName) current.displayName = txt.displayName; if (txt.lanHost) current.lanHost = txt.lanHost; applyBeaconTxt(current, txt); } } if (current) results.push(current); return results; } async function discoverViaAvahi(domain, timeoutMs, run) { const args = [ "avahi-browse", "-rt", GATEWAY_SERVICE_TYPE ]; if (domain && domain !== "local.") args.push("-d", domain.replace(/\.$/, "")); return parseAvahiBrowse((await run(args, { timeoutMs })).stdout).map((beacon) => Object.assign({}, beacon, { domain })); } async function discoverGatewayBeacons(opts = {}) { 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 = normalizeStringEntries(domainsRaw.length > 0 ? domainsRaw : defaultDomains).map((d) => d.endsWith(".") ? d : `${d}.`); const discover = platform === "darwin" ? discoverViaDnsSd : platform === "linux" ? discoverViaAvahi : void 0; if (!discover) return []; const discovered = (await Promise.allSettled(domains.map((domain) => discover(domain, timeoutMs, run)))).flatMap((result) => result.status === "fulfilled" ? result.value : []); if (platform === "darwin" && wideAreaDomain && domains.includes(wideAreaDomain) && !discovered.some((beacon) => beacon.domain === wideAreaDomain)) { const fallback = await discoverWideAreaViaTailnetDns(wideAreaDomain, timeoutMs, run).catch(() => []); return [...discovered, ...fallback]; } return discovered; } //#endregion export { resolveGatewayDiscoveryEndpoint as n, discoverGatewayBeacons as t };