| import { logDebug, logWarn } from "../logger.js"; |
| import { getLogger } from "../logging.js"; |
| import { ignoreCiaoCancellationRejection } from "./bonjour-ciao.js"; |
| import { formatBonjourError } from "./bonjour-errors.js"; |
| import { isTruthyEnvValue } from "./env.js"; |
| import { registerUnhandledRejectionHandler } from "./unhandled-rejections.js"; |
|
|
| export type GatewayBonjourAdvertiser = { |
| stop: () => Promise<void>; |
| }; |
|
|
| export type GatewayBonjourAdvertiseOpts = { |
| instanceName?: string; |
| gatewayPort: number; |
| sshPort?: number; |
| gatewayTlsEnabled?: boolean; |
| gatewayTlsFingerprintSha256?: string; |
| canvasPort?: number; |
| tailnetDns?: string; |
| cliPath?: string; |
| |
| |
| |
| |
| minimal?: boolean; |
| }; |
|
|
| function isDisabledByEnv() { |
| if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR)) { |
| return true; |
| } |
| if (process.env.NODE_ENV === "test") { |
| return true; |
| } |
| if (process.env.VITEST) { |
| return true; |
| } |
| return false; |
| } |
|
|
| function safeServiceName(name: string) { |
| const trimmed = name.trim(); |
| return trimmed.length > 0 ? trimmed : "OpenClaw"; |
| } |
|
|
| function prettifyInstanceName(name: string) { |
| const normalized = name.trim().replace(/\s+/g, " "); |
| return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized; |
| } |
|
|
| type BonjourService = { |
| advertise: () => Promise<void>; |
| destroy: () => Promise<void>; |
| getFQDN: () => string; |
| getHostname: () => string; |
| getPort: () => number; |
| on: (event: string, listener: (...args: unknown[]) => void) => unknown; |
| serviceState: string; |
| }; |
|
|
| function serviceSummary(label: string, svc: BonjourService): string { |
| let fqdn = "unknown"; |
| let hostname = "unknown"; |
| let port = -1; |
| try { |
| fqdn = svc.getFQDN(); |
| } catch { |
| |
| } |
| try { |
| hostname = svc.getHostname(); |
| } catch { |
| |
| } |
| try { |
| port = svc.getPort(); |
| } catch { |
| |
| } |
| const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; |
| return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`; |
| } |
|
|
| export async function startGatewayBonjourAdvertiser( |
| opts: GatewayBonjourAdvertiseOpts, |
| ): Promise<GatewayBonjourAdvertiser> { |
| if (isDisabledByEnv()) { |
| return { stop: async () => {} }; |
| } |
|
|
| const { getResponder, Protocol } = await import("@homebridge/ciao"); |
| const responder = getResponder(); |
|
|
| |
| |
| |
| const hostnameRaw = |
| process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || |
| process.env.CLAWDBOT_MDNS_HOSTNAME?.trim() || |
| "openclaw"; |
| const hostname = |
| hostnameRaw |
| .replace(/\.local$/i, "") |
| .split(".")[0] |
| .trim() || "openclaw"; |
| const instanceName = |
| typeof opts.instanceName === "string" && opts.instanceName.trim() |
| ? opts.instanceName.trim() |
| : `${hostname} (OpenClaw)`; |
| const displayName = prettifyInstanceName(instanceName); |
|
|
| const txtBase: Record<string, string> = { |
| role: "gateway", |
| gatewayPort: String(opts.gatewayPort), |
| lanHost: `${hostname}.local`, |
| displayName, |
| }; |
| if (opts.gatewayTlsEnabled) { |
| txtBase.gatewayTls = "1"; |
| if (opts.gatewayTlsFingerprintSha256) { |
| txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256; |
| } |
| } |
| if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) { |
| txtBase.canvasPort = String(opts.canvasPort); |
| } |
| if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { |
| txtBase.tailnetDns = opts.tailnetDns.trim(); |
| } |
| |
| |
| if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) { |
| txtBase.cliPath = opts.cliPath.trim(); |
| } |
|
|
| const services: Array<{ label: string; svc: BonjourService }> = []; |
|
|
| |
| |
| const gatewayTxt: Record<string, string> = { |
| ...txtBase, |
| transport: "gateway", |
| }; |
| if (!opts.minimal) { |
| gatewayTxt.sshPort = String(opts.sshPort ?? 22); |
| } |
|
|
| const gateway = responder.createService({ |
| name: safeServiceName(instanceName), |
| type: "openclaw-gw", |
| protocol: Protocol.TCP, |
| port: opts.gatewayPort, |
| domain: "local", |
| hostname, |
| txt: gatewayTxt, |
| }); |
| services.push({ |
| label: "gateway", |
| svc: gateway as unknown as BonjourService, |
| }); |
|
|
| let ciaoCancellationRejectionHandler: (() => void) | undefined; |
| if (services.length > 0) { |
| ciaoCancellationRejectionHandler = registerUnhandledRejectionHandler( |
| ignoreCiaoCancellationRejection, |
| ); |
| } |
|
|
| logDebug( |
| `bonjour: starting (hostname=${hostname}, instance=${JSON.stringify( |
| safeServiceName(instanceName), |
| )}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`, |
| ); |
|
|
| for (const { label, svc } of services) { |
| try { |
| svc.on("name-change", (name: unknown) => { |
| const next = typeof name === "string" ? name : String(name); |
| logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`); |
| }); |
| svc.on("hostname-change", (nextHostname: unknown) => { |
| const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname); |
| logWarn( |
| `bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`, |
| ); |
| }); |
| } catch (err) { |
| logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`); |
| } |
| } |
|
|
| |
| |
| |
| for (const { label, svc } of services) { |
| try { |
| void svc |
| .advertise() |
| .then(() => { |
| |
| getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`); |
| }) |
| .catch((err) => { |
| logWarn( |
| `bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, |
| ); |
| }); |
| } catch (err) { |
| logWarn( |
| `bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, |
| ); |
| } |
| } |
|
|
| |
| |
| const lastRepairAttempt = new Map<string, number>(); |
| const watchdog = setInterval(() => { |
| for (const { label, svc } of services) { |
| const stateUnknown = (svc as { serviceState?: unknown }).serviceState; |
| if (typeof stateUnknown !== "string") { |
| continue; |
| } |
| if (stateUnknown === "announced" || stateUnknown === "announcing") { |
| continue; |
| } |
|
|
| let key = label; |
| try { |
| key = `${label}:${svc.getFQDN()}`; |
| } catch { |
| |
| } |
| const now = Date.now(); |
| const last = lastRepairAttempt.get(key) ?? 0; |
| if (now - last < 30_000) { |
| continue; |
| } |
| lastRepairAttempt.set(key, now); |
|
|
| logWarn( |
| `bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary( |
| label, |
| svc, |
| )})`, |
| ); |
| try { |
| void svc.advertise().catch((err) => { |
| logWarn( |
| `bonjour: watchdog advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, |
| ); |
| }); |
| } catch (err) { |
| logWarn( |
| `bonjour: watchdog advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, |
| ); |
| } |
| } |
| }, 60_000); |
| watchdog.unref?.(); |
|
|
| return { |
| stop: async () => { |
| clearInterval(watchdog); |
| for (const { svc } of services) { |
| try { |
| await svc.destroy(); |
| } catch { |
| |
| } |
| } |
| try { |
| await responder.shutdown(); |
| } catch { |
| |
| } finally { |
| ciaoCancellationRejectionHandler?.(); |
| } |
| }, |
| }; |
| } |
|
|