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).
This commit is contained in:
luyao618
2026-04-27 20:35:44 +08:00
committed by Peter Steinberger
parent f5b01c1e0e
commit 9ac0b7edbc
2 changed files with 67 additions and 3 deletions

View File

@@ -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;

View File

@@ -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()