From 9ac0b7edbc25e2b6cece2ef58e68a48b7d4cd79e Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Mon, 27 Apr 2026 20:35:44 +0800 Subject: [PATCH] fix(bonjour): truncate mDNS service name and hostname to 63-byte DNS label limit When the system hostname exceeds 63 bytes (common with Kubernetes pod names), the @homebridge/ciao DNS label encoder throws an AssertionError that crashes the gateway on startup. Add truncateToDnsLabel() that safely truncates UTF-8 strings at byte boundaries, applied to both the service instance name and hostname before passing them to ciao. Closes #37705 AI-assisted (built with Hermes orchestration). --- extensions/bonjour/src/advertiser.test.ts | 48 +++++++++++++++++++++++ extensions/bonjour/src/advertiser.ts | 22 +++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 219b608b083..c5c79dd08d9 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -735,6 +735,54 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); + it("truncates service name exceeding 63-byte DNS label limit", async () => { + const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf"; + enableAdvertiserUnitMode(longHostname); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; + const serviceName = gatewayCall?.[0]?.name as string; + const hostname = gatewayCall?.[0]?.hostname as string; + + // Both name and hostname must be within the 63-byte DNS label limit + expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63); + expect(new TextEncoder().encode(hostname).byteLength).toBeLessThanOrEqual(63); + + await started.stop(); + }); + + it("truncates multi-byte hostname within DNS label byte limit", async () => { + // 21 CJK characters = 63 bytes in UTF-8, adding " (OpenClaw)" pushes over + const cjkHostname = "你".repeat(21); + enableAdvertiserUnitMode(cjkHostname); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; + const serviceName = gatewayCall?.[0]?.name as string; + + expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63); + // Should not end with a replacement character from incomplete multi-byte truncation + expect(serviceName).not.toMatch(/\uFFFD$/); + + await started.stop(); + }); + it("uses system hostname when OPENCLAW_MDNS_HOSTNAME is unset", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index f258c22d158..68cd938a58e 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -183,9 +183,24 @@ function resolveSystemMdnsHostname(): string | null { return firstLabel; } +const MAX_DNS_LABEL_BYTES = 63; + +function truncateToDnsLabel(name: string): string { + const encoder = new TextEncoder(); + const encoded = encoder.encode(name); + if (encoded.byteLength <= MAX_DNS_LABEL_BYTES) { + return name; + } + // Truncate at byte boundary, then decode back (TextDecoder handles incomplete sequences) + const truncated = encoded.slice(0, MAX_DNS_LABEL_BYTES); + const decoded = new TextDecoder("utf-8", { fatal: false }).decode(truncated); + // Strip any replacement character from incomplete multi-byte sequence at the end + return decoded.replace(/\uFFFD$/, "").trim() || "OpenClaw"; +} + function safeServiceName(name: string) { const trimmed = name.trim(); - return trimmed.length > 0 ? trimmed : "OpenClaw"; + return trimmed.length > 0 ? truncateToDnsLabel(trimmed) : "OpenClaw"; } function prettifyInstanceName(name: string) { @@ -353,11 +368,12 @@ export async function startGatewayBonjourAdvertiser( const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || resolveSystemMdnsHostname() || "openclaw"; - const hostname = + const hostname = truncateToDnsLabel( hostnameRaw .replace(/\.local$/i, "") .split(".")[0] - .trim() || "openclaw"; + .trim() || "openclaw", + ); const instanceName = typeof opts.instanceName === "string" && opts.instanceName.trim() ? opts.instanceName.trim()