import { runCommandWithTimeout } from "../process/exec.js" ;
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js" ;
import { isTailnetIPv4 } from "./tailnet.js" ;
import { resolveWideAreaDiscoveryDomain } from "./widearea-dns.js" ;
export type GatewayBonjourBeacon = {
instanceName: string;
domain?: string;
displayName?: string;
host?: string;
port?: number;
lanHost?: string;
tailnetDns?: string;
gatewayPort?: number;
sshPort?: number;
gatewayTls?: boolean ;
gatewayTlsFingerprintSha256?: string;
cliPath?: string;
role?: string;
transport?: string;
txt?: Record<string, string>;
};
export type GatewayDiscoveryResolvedEndpoint = {
host: string;
port: number;
gatewayTls: boolean ;
gatewayTlsFingerprintSha256?: string;
scheme: "ws" | "wss" ;
wsUrl: string;
};
export function resolveGatewayDiscoveryEndpoint(
beacon: GatewayBonjourBeacon,
): GatewayDiscoveryResolvedEndpoint | null {
const host = beacon.host?.trim();
const port = beacon.port;
if (!host || typeof port !== "number" || !Number.isFinite(port) || port <= 0 ) {
return null ;
}
const gatewayTls = beacon.gatewayTls === true ;
const scheme = gatewayTls ? "wss" : "ws" ;
return {
host,
port,
gatewayTls,
gatewayTlsFingerprintSha256: beacon.gatewayTlsFingerprintSha256,
scheme,
wsUrl: `${scheme}://${host}:${port}`,
};
}
export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null {
return resolveGatewayDiscoveryEndpoint(beacon)?.host ?? null ;
}
export function pickResolvedGatewayPort(beacon: GatewayBonjourBeacon): number | null {
return resolveGatewayDiscoveryEndpoint(beacon)?.port ?? null ;
}
export type GatewayBonjourDiscoverOpts = {
timeoutMs?: number;
domains?: string[];
wideAreaDomain?: string | null ;
platform?: NodeJS.Platform;
run?: typeof runCommandWithTimeout;
};
const DEFAULT_TIMEOUT_MS = 2000 ;
const GATEWAY_SERVICE_TYPE = "_openclaw-gw._tcp" ;
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 parseDigShortLines(stdout: string): string[] {
return stdout
.split("\n" )
.map((l) => l.trim())
.filter(Boolean );
}
function parseDigTxt(stdout: string): string[] {
// dig +short TXT prints one or more lines of quoted strings:
// "k=v" "k2=v2"
const tokens: string[] = [];
for (const raw of stdout.split("\n" )) {
const line = raw.trim();
if (!line) {
continue ;
}
const matches = Array.from(line.matchAll(/"([^" ]*)"/g), (m) => m[1] ?? " ");
for (const m of matches) {
const unescaped = m.replaceAll("\\\\" , "\\" ).replaceAll('\\"' , '"' ).replaceAll("\\n" , "\n" );
tokens.push(unescaped);
}
}
return tokens;
}
function parseDigSrv(stdout: string): { host: string; port: number } | null {
// dig +short SRV: "0 0 18790 host.domain."
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 = Number.parseInt(parts[2 ] ?? "" , 10 );
const hostRaw = parts[3 ] ?? "" ;
if (!Number.isFinite(port) || port <= 0 ) {
return null ;
}
const host = hostRaw.replace(/\.$/, "" );
if (!host) {
return null ;
}
return { host, port };
}
function parseTailscaleStatusIPv4s(stdout: string): string[] {
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
const out: string[] = [];
const addIps = (value: unknown) => {
if (!value || typeof value !== "object" ) {
return ;
}
const ips = (value as { TailscaleIPs?: unknown }).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 as { Self?: unknown }).Self);
const peerObj = (parsed as { Peer?: unknown }).Peer;
if (peerObj && typeof peerObj === "object" ) {
for (const peer of Object.values(peerObj as Record<string, unknown>)) {
addIps(peer);
}
}
return [...new Set(out)];
}
function parseIntOrNull(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10 );
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseTxtTokens(tokens: string[]): Record<string, string> {
const txt: Record<string, string> = {};
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 parseDnsSdBrowse(stdout: string): string[] {
const instances = new Set<string>();
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: string, instanceName: string): GatewayBonjourBeacon | null {
const decodedInstanceName = decodeDnsSdEscapes(instanceName);
const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName };
let txt: Record<string, string> = {};
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:]+):(\d+)/i);
if (match?.[1 ]) {
beacon.host = match[1 ].replace(/\.$/, "" );
}
if (match?.[2 ]) {
beacon.port = parseIntOrNull(match[2 ]);
}
continue ;
}
if (line.startsWith("txt" ) || line.includes("txtvers=" )) {
const tokens = line.split(/\s+/).filter(Boolean );
txt = parseTxtTokens(tokens);
}
}
beacon.txt = Object.keys(txt).length ? txt : undefined;
if (txt.displayName) {
beacon.displayName = decodeDnsSdEscapes(txt.displayName);
}
if (txt.lanHost) {
beacon.lanHost = txt.lanHost;
}
if (txt.tailnetDns) {
beacon.tailnetDns = txt.tailnetDns;
}
if (txt.cliPath) {
beacon.cliPath = txt.cliPath;
}
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
beacon.sshPort = parseIntOrNull(txt.sshPort);
if (txt.gatewayTls) {
const raw = normalizeOptionalLowercaseString(txt.gatewayTls);
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes" ;
}
if (txt.gatewayTlsSha256) {
beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
}
if (txt.role) {
beacon.role = txt.role;
}
if (txt.transport) {
beacon.transport = txt.transport;
}
if (!beacon.displayName) {
beacon.displayName = decodedInstanceName;
}
return beacon;
}
async function discoverViaDnsSd(
domain: string,
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const browse = await run(["dns-sd" , "-B" , GATEWAY_SERVICE_TYPE, domain], {
timeoutMs,
});
const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) {
const resolved = await run(["dns-sd" , "-L" , instance, GATEWAY_SERVICE_TYPE, domain], {
timeoutMs,
});
const parsed = parseDnsSdResolve(resolved.stdout, instance);
if (parsed) {
results.push({ ...parsed, domain });
}
}
return results;
}
async function discoverWideAreaViaTailnetDns(
domain: string,
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
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: string[] = [];
for (const candidate of tailscaleCandidates) {
try {
const res = await run([candidate, "status" , "--json" ], {
timeoutMs: Math.max(1 , Math.min(700 , remainingMs())),
});
ips = parseTailscaleStatusIPv4s(res.stdout);
if (ips.length > 0 ) {
break ;
}
} catch {
// ignore
}
}
if (ips.length === 0 ) {
return [];
}
if (remainingMs() <= 0 ) {
return [];
}
// Keep scans bounded: this is a fallback and should not block long.
ips = ips.slice(0 , 40 );
const probeName = `${GATEWAY_SERVICE_TYPE}.${domain.replace(/\.$/, "" )}`;
const concurrency = 6 ;
let nextIndex = 0 ;
let nameserver: string | null = null ;
let ptrs: string[] = [];
const worker = async () => {
while (nameserver === null ) {
const budget = remainingMs();
if (budget <= 0 ) {
return ;
}
const i = nextIndex;
nextIndex += 1 ;
if (i >= ips.length) {
return ;
}
const ip = ips[i] ?? "" ;
if (!ip) {
continue ;
}
try {
const probe = await run(
["dig" , "+short" , "+time=1" , "+tries=1" , `@${ip}`, probeName, "PTR" ],
{ timeoutMs: Math.max(1 , Math.min(250 , budget)) },
);
const lines = parseDigShortLines(probe.stdout);
if (lines.length === 0 ) {
continue ;
}
nameserver = ip;
ptrs = lines;
return ;
} catch {
// ignore
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()));
if (!nameserver || ptrs.length === 0 ) {
return [];
}
if (remainingMs() <= 0 ) {
return [];
}
const nameserverArg = `@${String(nameserver)}`;
const results: GatewayBonjourBeacon[] = [];
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 txtBudget = remainingMs();
if (txtBudget <= 0 ) {
results.push({
instanceName: instanceName || ptrName,
displayName: instanceName || ptrName,
domain,
host: srvParsed.host,
port: srvParsed.port,
});
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) : {};
const beacon: GatewayBonjourBeacon = {
instanceName: instanceName || ptrName,
displayName: txtMap.displayName || instanceName || ptrName,
domain,
host: srvParsed.host,
port: srvParsed.port,
txt: Object.keys(txtMap).length ? txtMap : undefined,
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
sshPort: parseIntOrNull(txtMap.sshPort),
tailnetDns: txtMap.tailnetDns || undefined,
cliPath: txtMap.cliPath || undefined,
};
if (txtMap.gatewayTls) {
const raw = normalizeOptionalLowercaseString(txtMap.gatewayTls);
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes" ;
}
if (txtMap.gatewayTlsSha256) {
beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256;
}
if (txtMap.role) {
beacon.role = txtMap.role;
}
if (txtMap.transport) {
beacon.transport = txtMap.transport;
}
results.push(beacon);
}
return results;
}
function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
const results: GatewayBonjourBeacon[] = [];
let current: GatewayBonjourBeacon | null = 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 = parseIntOrNull(match[1 ]);
}
continue ;
}
if (trimmed.startsWith("txt =" )) {
const tokens = Array.from(trimmed.matchAll(/"([^" ]*)"/g), (m) => m[1]);
const txt = parseTxtTokens(tokens);
current.txt = Object.keys(txt).length ? txt : undefined;
if (txt.displayName) {
current.displayName = txt.displayName;
}
if (txt.lanHost) {
current.lanHost = txt.lanHost;
}
if (txt.tailnetDns) {
current.tailnetDns = txt.tailnetDns;
}
if (txt.cliPath) {
current.cliPath = txt.cliPath;
}
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
current.sshPort = parseIntOrNull(txt.sshPort);
if (txt.gatewayTls) {
const raw = normalizeOptionalLowercaseString(txt.gatewayTls);
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes" ;
}
if (txt.gatewayTlsSha256) {
current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
}
if (txt.role) {
current.role = txt.role;
}
if (txt.transport) {
current.transport = txt.transport;
}
}
}
if (current) {
results.push(current);
}
return results;
}
async function discoverViaAvahi(
domain: string,
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const args = ["avahi-browse" , "-rt" , GATEWAY_SERVICE_TYPE];
if (domain && domain !== "local." ) {
// avahi-browse wants a plain domain (no trailing dot)
args.push("-d" , domain.replace(/\.$/, "" ));
}
const browse = await run(args, { timeoutMs });
return parseAvahiBrowse(browse.stdout).map((beacon) => Object.assign({}, beacon, { domain }));
}
export async function discoverGatewayBeacons(
opts: GatewayBonjourDiscoverOpts = {},
): Promise<GatewayBonjourBeacon[]> {
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 = (domainsRaw.length > 0 ? domainsRaw : defaultDomains)
.map((d) => d.trim())
.filter(Boolean )
.map((d) => (d.endsWith("." ) ? d : `${d}.`));
try {
if (platform === "darwin" ) {
const perDomain = await Promise.allSettled(
domains.map(async (domain) => await discoverViaDnsSd(domain, timeoutMs, run)),
);
const discovered = perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
const wantsWideArea = wideAreaDomain ? domains.includes(wideAreaDomain) : false ;
const hasWideArea = wideAreaDomain
? discovered.some((b) => b.domain === wideAreaDomain)
: false ;
if (wantsWideArea && !hasWideArea && wideAreaDomain) {
const fallback = await discoverWideAreaViaTailnetDns(wideAreaDomain, timeoutMs, run).catch (
() => [],
);
return [...discovered, ...fallback];
}
return discovered;
}
if (platform === "linux" ) {
const perDomain = await Promise.allSettled(
domains.map(async (domain) => await discoverViaAvahi(domain, timeoutMs, run)),
);
return perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
}
} catch {
return [];
}
return [];
}
Messung V0.5 in Prozent C=99 H=97 G=97
¤ Dauer der Verarbeitung: 0.7 Sekunden
¤
*© Formatika GbR, Deutschland