mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:34:32 +00:00
Browser: reuse extension relay when relay port is already occupied (#20035)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: b310666d39
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
|
||||
@@ -145,6 +146,66 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
|
||||
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
||||
const relayAuthByPort = new Map<number, string>();
|
||||
|
||||
function resolveGatewayAuthToken(): string | null {
|
||||
const envToken =
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return envToken;
|
||||
}
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const configToken = cfg.gateway?.auth?.token?.trim();
|
||||
if (configToken) {
|
||||
return configToken;
|
||||
}
|
||||
} catch {
|
||||
// ignore config read failures; caller can fallback to per-process random token
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveDeterministicRelayAuthToken(port: number): string | null {
|
||||
const gatewayToken = resolveGatewayAuthToken();
|
||||
if (!gatewayToken) {
|
||||
return null;
|
||||
}
|
||||
return createHash("sha256")
|
||||
.update(`openclaw-relay:${port}:`)
|
||||
.update(gatewayToken)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function resolveRelayAuthToken(port: number): string {
|
||||
return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function isAddrInUseError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "EADDRINUSE"
|
||||
);
|
||||
}
|
||||
|
||||
async function looksLikeOpenClawRelay(baseUrl: string): Promise<boolean> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 500);
|
||||
try {
|
||||
const statusUrl = new URL("/extension/status", `${baseUrl}/`).toString();
|
||||
const res = await fetch(statusUrl, { signal: ctrl.signal });
|
||||
if (!res.ok) {
|
||||
return false;
|
||||
}
|
||||
const body = (await res.json()) as { connected?: unknown };
|
||||
return typeof body.connected === "boolean";
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function relayAuthTokenForUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -160,7 +221,7 @@ function relayAuthTokenForUrl(url: string): string | null {
|
||||
if (!Number.isFinite(port)) {
|
||||
return null;
|
||||
}
|
||||
return relayAuthByPort.get(port) ?? null;
|
||||
return relayAuthByPort.get(port) ?? deriveDeterministicRelayAuthToken(port);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -187,6 +248,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const relayAuthToken = resolveRelayAuthToken(info.port);
|
||||
|
||||
let extensionWs: WebSocket | null = null;
|
||||
const cdpClients = new Set<WebSocket>();
|
||||
const connectedTargets = new Map<string, ConnectedTarget>();
|
||||
@@ -326,8 +389,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
}
|
||||
};
|
||||
|
||||
const relayAuthToken = randomBytes(32).toString("base64url");
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", info.baseUrl);
|
||||
const path = url.pathname;
|
||||
@@ -703,10 +764,30 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(info.port, info.host, () => resolve());
|
||||
server.once("error", reject);
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(info.port, info.host, () => resolve());
|
||||
server.once("error", reject);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAddrInUseError(err) && (await looksLikeOpenClawRelay(info.baseUrl))) {
|
||||
const existingRelay: ChromeExtensionRelayServer = {
|
||||
host: info.host,
|
||||
port: info.port,
|
||||
baseUrl: info.baseUrl,
|
||||
cdpWsUrl: `ws://${info.host}:${info.port}/cdp`,
|
||||
extensionConnected: () => false,
|
||||
stop: async () => {
|
||||
serversByPort.delete(info.port);
|
||||
relayAuthByPort.delete(info.port);
|
||||
},
|
||||
};
|
||||
relayAuthByPort.set(info.port, relayAuthToken);
|
||||
serversByPort.set(info.port, existingRelay);
|
||||
return existingRelay;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const addr = server.address() as AddressInfo | null;
|
||||
const port = addr?.port ?? info.port;
|
||||
|
||||
Reference in New Issue
Block a user