fix(discovery): decode dns-sd escaped UTF-8
This commit is contained in:
@@ -7,6 +7,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
|||||||
describe("bonjour-discovery", () => {
|
describe("bonjour-discovery", () => {
|
||||||
it("discovers beacons on darwin across local + wide-area domains", async () => {
|
it("discovers beacons on darwin across local + wide-area domains", async () => {
|
||||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
||||||
|
const studioInstance = "Peter’s Mac Studio Bridge";
|
||||||
|
|
||||||
const run = vi.fn(
|
const run = vi.fn(
|
||||||
async (argv: string[], options: { timeoutMs: number }) => {
|
async (argv: string[], options: { timeoutMs: number }) => {
|
||||||
@@ -17,7 +18,7 @@ describe("bonjour-discovery", () => {
|
|||||||
if (domain === "local.") {
|
if (domain === "local.") {
|
||||||
return {
|
return {
|
||||||
stdout: [
|
stdout: [
|
||||||
"Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
|
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
|
||||||
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@@ -44,16 +45,20 @@ describe("bonjour-discovery", () => {
|
|||||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||||
const instance = argv[2] ?? "";
|
const instance = argv[2] ?? "";
|
||||||
const host =
|
const host =
|
||||||
instance === "Studio Bridge"
|
instance === studioInstance
|
||||||
? "studio.local"
|
? "studio.local"
|
||||||
: instance === "Laptop Bridge"
|
: instance === "Laptop Bridge"
|
||||||
? "laptop.local"
|
? "laptop.local"
|
||||||
: "tailnet.local";
|
: "tailnet.local";
|
||||||
const tailnetDns =
|
const tailnetDns =
|
||||||
instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
||||||
|
const displayName =
|
||||||
|
instance === studioInstance
|
||||||
|
? "Peter’s\\032Mac\\032Studio"
|
||||||
|
: instance.replace(" Bridge", "");
|
||||||
const txtParts = [
|
const txtParts = [
|
||||||
"txtvers=1",
|
"txtvers=1",
|
||||||
`displayName=${instance.replace(" Bridge", "")}`,
|
`displayName=${displayName}`,
|
||||||
`lanHost=${host}`,
|
`lanHost=${host}`,
|
||||||
"gatewayPort=18789",
|
"gatewayPort=18789",
|
||||||
"bridgePort=18790",
|
"bridgePort=18790",
|
||||||
@@ -85,6 +90,14 @@ describe("bonjour-discovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(beacons).toHaveLength(3);
|
expect(beacons).toHaveLength(3);
|
||||||
|
expect(beacons).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
instanceName: studioInstance,
|
||||||
|
displayName: "Peter’s Mac Studio",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(beacons.map((b) => b.domain)).toEqual(
|
expect(beacons.map((b) => b.domain)).toEqual(
|
||||||
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
|
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
|
||||||
);
|
);
|
||||||
@@ -98,6 +111,68 @@ describe("bonjour-discovery", () => {
|
|||||||
expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true);
|
expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("decodes dns-sd octal escapes in TXT displayName", async () => {
|
||||||
|
const run = vi.fn(
|
||||||
|
async (argv: string[], options: { timeoutMs: number }) => {
|
||||||
|
if (options.timeoutMs < 0) throw new Error("invalid timeout");
|
||||||
|
|
||||||
|
const domain = argv[3] ?? "";
|
||||||
|
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
||||||
|
return {
|
||||||
|
stdout: [
|
||||||
|
"Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||||
|
return {
|
||||||
|
stdout: [
|
||||||
|
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
|
||||||
|
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const beacons = await discoverGatewayBeacons({
|
||||||
|
platform: "darwin",
|
||||||
|
timeoutMs: 800,
|
||||||
|
domains: ["local."],
|
||||||
|
run: run as unknown as typeof runCommandWithTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(beacons).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
domain: "local.",
|
||||||
|
instanceName: "Studio Bridge",
|
||||||
|
displayName: "Peter’s Mac Studio",
|
||||||
|
txt: expect.objectContaining({
|
||||||
|
displayName: "Peter’s Mac Studio",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => {
|
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => {
|
||||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,42 @@ const DEFAULT_TIMEOUT_MS = 2000;
|
|||||||
|
|
||||||
const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const;
|
const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const;
|
||||||
|
|
||||||
|
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 isTailnetIPv4(address: string): boolean {
|
function isTailnetIPv4(address: string): boolean {
|
||||||
const parts = address.split(".");
|
const parts = address.split(".");
|
||||||
if (parts.length !== 4) return false;
|
if (parts.length !== 4) return false;
|
||||||
@@ -119,7 +155,7 @@ function parseTxtTokens(tokens: string[]): Record<string, string> {
|
|||||||
const idx = token.indexOf("=");
|
const idx = token.indexOf("=");
|
||||||
if (idx <= 0) continue;
|
if (idx <= 0) continue;
|
||||||
const key = token.slice(0, idx).trim();
|
const key = token.slice(0, idx).trim();
|
||||||
const value = token.slice(idx + 1).trim();
|
const value = decodeDnsSdEscapes(token.slice(idx + 1).trim());
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
txt[key] = value;
|
txt[key] = value;
|
||||||
}
|
}
|
||||||
@@ -134,7 +170,7 @@ function parseDnsSdBrowse(stdout: string): string[] {
|
|||||||
if (!line.includes("Add")) continue;
|
if (!line.includes("Add")) continue;
|
||||||
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
|
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
|
||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
instances.add(match[1].trim());
|
instances.add(decodeDnsSdEscapes(match[1].trim()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(instances.values());
|
return Array.from(instances.values());
|
||||||
@@ -144,7 +180,8 @@ function parseDnsSdResolve(
|
|||||||
stdout: string,
|
stdout: string,
|
||||||
instanceName: string,
|
instanceName: string,
|
||||||
): GatewayBonjourBeacon | null {
|
): GatewayBonjourBeacon | null {
|
||||||
const beacon: GatewayBonjourBeacon = { instanceName };
|
const decodedInstanceName = decodeDnsSdEscapes(instanceName);
|
||||||
|
const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName };
|
||||||
let txt: Record<string, string> = {};
|
let txt: Record<string, string> = {};
|
||||||
for (const raw of stdout.split("\n")) {
|
for (const raw of stdout.split("\n")) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
@@ -168,7 +205,7 @@ function parseDnsSdResolve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
beacon.txt = Object.keys(txt).length ? txt : undefined;
|
beacon.txt = Object.keys(txt).length ? txt : undefined;
|
||||||
if (txt.displayName) beacon.displayName = txt.displayName;
|
if (txt.displayName) beacon.displayName = decodeDnsSdEscapes(txt.displayName);
|
||||||
if (txt.lanHost) beacon.lanHost = txt.lanHost;
|
if (txt.lanHost) beacon.lanHost = txt.lanHost;
|
||||||
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
||||||
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
||||||
@@ -176,7 +213,7 @@ function parseDnsSdResolve(
|
|||||||
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||||
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
||||||
|
|
||||||
if (!beacon.displayName) beacon.displayName = instanceName;
|
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
|
||||||
return beacon;
|
return beacon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user