From 58a1564f067a6792b3b12720481170df846f4462 Mon Sep 17 00:00:00 2001 From: Nicholas Liverett Date: Sat, 14 Mar 2026 14:44:06 -0500 Subject: [PATCH 1/4] Add WebSocket relay tunnel bridge and config Add a relay tunnel WebSocket bridge to proxy binary frames to the Gerbil TCP relay and wire it into the WS upgrade flow. Introduce tcp_relay_port (default 4430) in the config schema. Add relayEndpointWss and relayPort to OLM token responses and relay messages (helpers added to build the WSS relay endpoint). Implement relayTunnelBridge.ts to validate OLM tokens, resolve Gerbil targets, frame/parse length-prefixed packets, connect to Gerbil over TCP, and forward framed binary data between WebSocket and TCP. Integrate the upgrade handler into the existing ws upgrade path (ws.ts). Files changed: server/lib/readConfigFile.ts, server/routers/olm/getOlmToken.ts, server/routers/olm/handleOlmRelayMessage.ts, server/routers/ws/ws.ts and new server/routers/ws/relayTunnelBridge.ts. --- server/lib/readConfigFile.ts | 5 + server/routers/olm/getOlmToken.ts | 16 +- server/routers/olm/handleOlmRelayMessage.ts | 10 +- server/routers/ws/relayTunnelBridge.ts | 312 ++++++++++++++++++++ server/routers/ws/ws.ts | 5 + 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 server/routers/ws/relayTunnelBridge.ts diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index c3e796fc1e..0c4f33f819 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -274,6 +274,11 @@ export const configSchema = z .default(21820) .transform(stoi) .pipe(portSchema), + tcp_relay_port: portSchema + .optional() + .default(4430) + .transform(stoi) + .pipe(portSchema), base_endpoint: z .string() .optional() diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index 5b8411eb71..6612c31467 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -29,6 +29,13 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { APP_VERSION } from "@server/lib/consts"; +function buildRelayEndpointWss() { + const dashboardUrl = ( + config.getRawConfig().app.dashboard_url || "http://localhost:3000" + ).replace(/\/$/, ""); + return `${dashboardUrl.replace(/^http/i, "ws")}/api/v1/relay-tunnel`; +} + export const olmGetTokenBodySchema = z.object({ olmId: z.string(), secret: z.string().optional(), @@ -253,6 +260,7 @@ export async function getOlmToken( publicKey: exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: exitNode.endpoint, + relayEndpointWss: buildRelayEndpointWss(), siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); @@ -261,7 +269,13 @@ export async function getOlmToken( return response<{ token: string; - exitNodes: { publicKey: string; endpoint: string }[]; + exitNodes: { + publicKey: string; + endpoint: string; + relayPort: number; + relayEndpointWss: string; + siteIds: number[]; + }[]; serverVersion: string; }>(res, { data: { diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 7196824d23..1de003575d 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -6,6 +6,13 @@ import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; +function buildRelayEndpointWss() { + const dashboardUrl = ( + config.getRawConfig().app.dashboard_url || "http://localhost:3000" + ).replace(/\/$/, ""); + return `${dashboardUrl.replace(/^http/i, "ws")}/api/v1/relay-tunnel`; +} + export const handleOlmRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; @@ -91,7 +98,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { siteId: siteId, relayEndpoint: exitNode.endpoint, relayPort: config.getRawConfig().gerbil.clients_start_port, - chainId + chainId, + relayEndpointWss: buildRelayEndpointWss() } }, broadcast: false, diff --git a/server/routers/ws/relayTunnelBridge.ts b/server/routers/ws/relayTunnelBridge.ts new file mode 100644 index 0000000000..243f6a6160 --- /dev/null +++ b/server/routers/ws/relayTunnelBridge.ts @@ -0,0 +1,312 @@ +import net, { Socket as NetSocket } from "net"; +import { Socket } from "net"; +import { URL } from "url"; +import { WebSocket, WebSocketServer } from "ws"; +import logger from "@server/logger"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; +import { validateSessionToken } from "@server/auth/sessions/app"; +import { clients, clientSitesAssociationsCache, db, exitNodes, olms, sites } from "@server/db"; +import { eq, inArray } from "drizzle-orm"; +import { WebSocketRequest } from "./types"; +import config from "@server/lib/config"; + +const relayTunnelWss = new WebSocketServer({ noServer: true }); + +function resolveGerbilHost(reachableAt: string): string | null { + try { + if (reachableAt.startsWith("http://") || reachableAt.startsWith("https://")) { + const url = new URL(reachableAt); + return url.hostname; + } + + const [host] = reachableAt.split(":"); + return host || null; + } catch (error) { + logger.error("Failed to resolve gerbil host from reachableAt %s: %o", reachableAt, error); + return null; + } +} + +function writeFramedPacket(socket: NetSocket, payload: Buffer): void { + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(payload.length, 0); + socket.write(header); + socket.write(payload); +} + +type FrameReaderState = { + buffer: Buffer; +}; + +type RelayRegistrationPayload = { + type: "relay-register"; + olmId: string; + token: string; + publicKey: string; + reachableAt: string; + exitNodePublicKey: string; +}; + +function consumeFramedPackets( + state: FrameReaderState, + chunk: Buffer, + onPacket: (packet: Buffer) => void +) { + state.buffer = Buffer.concat([state.buffer, chunk]); + while (state.buffer.length >= 4) { + const frameLen = state.buffer.readUInt32BE(0); + if (frameLen <= 0 || frameLen > 64 * 1024) { + throw new Error(`invalid frame length ${frameLen}`); + } + if (state.buffer.length < frameLen + 4) { + return; + } + const packet = state.buffer.subarray(4, 4 + frameLen); + state.buffer = state.buffer.subarray(4 + frameLen); + onPacket(packet); + } +} + +async function resolveGerbilConnectionTarget(olmId: string): Promise<{ + host: string; + port: number; + clientPublicKey: string; + reachableAt: string; + exitNodePublicKey: string; +} | null> { + const [olm] = await db.select().from(olms).where(eq(olms.olmId, olmId)).limit(1); + if (!olm?.clientId) { + logger.warn("Relay tunnel: olm %s has no client association", olmId); + return null; + } + + const [client] = await db.select().from(clients).where(eq(clients.clientId, olm.clientId)).limit(1); + if (!client) { + logger.warn("Relay tunnel: client not found for olm %s", olmId); + return null; + } + + const siteRows = await db + .select({ + exitNodeId: sites.exitNodeId + }) + .from(clientSitesAssociationsCache) + .innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId)) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + const exitNodeIds = Array.from( + new Set(siteRows.map((row) => row.exitNodeId).filter((id): id is number => id !== null)) + ); + + if (exitNodeIds.length === 0) { + logger.warn("Relay tunnel: no exit nodes for olm %s", olmId); + return null; + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(inArray(exitNodes.exitNodeId, exitNodeIds)) + .limit(1); + + if (!exitNode?.reachableAt) { + logger.warn("Relay tunnel: exit node reachableAt missing for olm %s", olmId); + return null; + } + + const host = resolveGerbilHost(exitNode.reachableAt); + if (!host) { + return null; + } + + return { + host, + port: config.getRawConfig().gerbil.tcp_relay_port, + clientPublicKey: client.pubKey || "", + reachableAt: exitNode.reachableAt, + exitNodePublicKey: exitNode.publicKey || "" + }; +} + +function sendRelayRegistration(socket: NetSocket, payload: RelayRegistrationPayload): void { + const registrationData = Buffer.from(JSON.stringify(payload)); + writeFramedPacket(socket, registrationData); +} + +async function setupRelayTunnel( + ws: WebSocket, + olmId: string, + token: string +): Promise { + const target = await resolveGerbilConnectionTarget(olmId); + if (!target) { + ws.close(1011, "No relay target"); + return; + } + + const gerbilSocket = net.createConnection({ + host: target.host, + port: target.port + }); + const pendingFrames: Buffer[] = []; + let relayReady = false; + const maxPendingFrames = 256; + + gerbilSocket.on("connect", () => { + logger.info("Relay tunnel WebSocket connected for OLM %s", olmId); + try { + sendRelayRegistration(gerbilSocket, { + type: "relay-register", + olmId, + token, + publicKey: target.clientPublicKey, + reachableAt: target.reachableAt, + exitNodePublicKey: target.exitNodePublicKey + }); + relayReady = true; + logger.debug("Relay tunnel registration sent for OLM %s", olmId); + while (pendingFrames.length > 0) { + const queuedPayload = pendingFrames.shift(); + if (!queuedPayload) { + break; + } + writeFramedPacket(gerbilSocket, queuedPayload); + } + } catch (error) { + logger.warn("Relay tunnel registration failed for OLM %s: %o", olmId, error); + if (ws.readyState === WebSocket.OPEN) { + ws.close(1011, "Relay registration failed"); + } + gerbilSocket.destroy(); + } + }); + + const readerState: FrameReaderState = { buffer: Buffer.alloc(0) }; + + gerbilSocket.on("data", (chunk: Buffer) => { + try { + consumeFramedPackets(readerState, chunk, (packet) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(packet, { binary: true }); + } + }); + } catch (error) { + logger.warn("Relay tunnel decode error for OLM %s: %o", olmId, error); + ws.close(1011, "Relay decode error"); + gerbilSocket.destroy(); + } + }); + + gerbilSocket.on("error", (error) => { + logger.warn("Relay tunnel gerbil socket error for OLM %s: %o", olmId, error); + if (ws.readyState === WebSocket.OPEN) { + ws.close(1011, "Relay socket error"); + } + }); + + gerbilSocket.on("close", () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, "Relay closed"); + } + }); + + ws.on("message", (data, isBinary) => { + if (!isBinary) { + logger.debug("Relay tunnel dropped non-binary message for OLM %s", olmId); + return; + } + + const payload = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); + if (!relayReady) { + if (pendingFrames.length >= maxPendingFrames) { + logger.warn( + "Relay tunnel pending frame buffer full for OLM %s, closing tunnel", + olmId + ); + ws.close(1013, "Relay not ready"); + gerbilSocket.destroy(); + return; + } + pendingFrames.push(payload); + logger.debug( + "Relay tunnel queued %d-byte frame for OLM %s (pending=%d)", + payload.length, + olmId, + pendingFrames.length + ); + return; + } + + logger.debug("Relay tunnel: forwarding %d bytes to Gerbil for OLM %s", payload.length, olmId); + writeFramedPacket(gerbilSocket, payload); + }); + + ws.on("close", () => { + pendingFrames.length = 0; + gerbilSocket.destroy(); + logger.info("Relay tunnel WebSocket closed for OLM %s", olmId); + }); + + ws.on("error", (error) => { + logger.warn("Relay tunnel WebSocket error for OLM %s: %o", olmId, error); + gerbilSocket.destroy(); + }); +} + +async function verifyRelayToken(token: string, userToken: string): Promise { + const { session, olm } = await validateOlmSessionToken(token); + if (!session || !olm) { + return null; + } + + if (olm.userId) { + const { session: userSession, user } = await validateSessionToken(userToken); + if (!userSession || !user || user.userId !== olm.userId) { + return null; + } + } + + return olm.olmId; +} + +export const handleRelayTunnelUpgrade = async ( + request: WebSocketRequest, + socket: Socket, + head: Buffer +): Promise => { + const requestUrl = new URL(request.url || "", `http://${request.headers.host}`); + const pathname = requestUrl.pathname; + + if (!pathname.endsWith("/relay-tunnel")) { + return false; + } + + logger.debug("Relay tunnel upgrade requested: %s", request.url); + + const token = + requestUrl.searchParams.get("token") || + (request.headers["sec-websocket-protocol"] as string) || + ""; + const userToken = requestUrl.searchParams.get("userToken") || ""; + + if (!token) { + logger.warn("Relay tunnel upgrade rejected: missing token"); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return true; + } + + const olmId = await verifyRelayToken(token, userToken); + if (!olmId) { + logger.warn("Relay tunnel upgrade rejected: invalid olm session token"); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return true; + } + + relayTunnelWss.handleUpgrade(request, socket, head, (ws) => { + void setupRelayTunnel(ws, olmId, token); + }); + + return true; +}; diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 6e63127150..f6bcd3a55f 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -21,6 +21,7 @@ import { SendMessageOptions } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; +import { handleRelayTunnelUpgrade } from "./relayTunnelBridge"; // Subset of TokenPayload for public ws.ts (newt and olm only) interface PublicTokenPayload { @@ -421,6 +422,10 @@ const handleWSUpgrade = (server: HttpServer): void => { "upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { try { + if (await handleRelayTunnelUpgrade(request, socket, head)) { + return; + } + const url = new URL( request.url || "", `http://${request.headers.host}` From 5d1a234e25c491c9b117e8dbc08a75bd7b94f581 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:54:08 -0400 Subject: [PATCH 2/4] dont show wildcard in domain picker --- messages/en-US.json | 1 + src/components/DomainPicker.tsx | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6a..9fa4e730d3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2116,6 +2116,7 @@ "domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", + "domainPickerManual": "Manual", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 44446763bc..afb273b5c2 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -509,9 +509,11 @@ export default function DomainPicker({ {selectedBaseDomain.domain} - {selectedBaseDomain.verified && ( - - )} + {selectedBaseDomain.verified && + selectedBaseDomain.domainType !== + "wildcard" && ( + + )} ) : ( t("domainPickerSelectBaseDomain") @@ -574,14 +576,23 @@ export default function DomainPicker({ } - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified + {orgDomain.type === + "wildcard" ? t( - "domainPickerVerified" + "domainPickerManual" ) - : t( - "domainPickerUnverified" + : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + )} From 1f57ff7d840eef0e580551669afac267c6f29168 Mon Sep 17 00:00:00 2001 From: Nicholas Liverett Date: Fri, 10 Apr 2026 18:46:34 -0500 Subject: [PATCH 3/4] Update WebSocket relay configuration and fix port assignment Refactor the configuration schema to include an optional wss_relay_port with a default value of 4430. Update the relayTunnelBridge to use wss_relay_port for connection targeting, ensuring proper fallback handling. This change enhances the WebSocket relay functionality and improves configuration flexibility. --- server/lib/readConfigFile.ts | 10 +++++++++- server/routers/ws/relayTunnelBridge.ts | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 0c4f33f819..dee0e5f458 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -274,7 +274,11 @@ export const configSchema = z .default(21820) .transform(stoi) .pipe(portSchema), - tcp_relay_port: portSchema + wss_relay_port: portSchema + .optional() + .transform(stoi) + .pipe(portSchema.optional()), + wss_relay_port: portSchema .optional() .default(4430) .transform(stoi) @@ -294,6 +298,10 @@ export const configSchema = z .optional() .default(30) }) + .transform((cfg) => ({ + ...cfg, + wss_relay_port: cfg.wss_relay_port ?? cfg.wss_relay_port ?? 4430 + })) .optional() .prefault({}), orgs: z diff --git a/server/routers/ws/relayTunnelBridge.ts b/server/routers/ws/relayTunnelBridge.ts index 243f6a6160..19f80715a8 100644 --- a/server/routers/ws/relayTunnelBridge.ts +++ b/server/routers/ws/relayTunnelBridge.ts @@ -121,7 +121,9 @@ async function resolveGerbilConnectionTarget(olmId: string): Promise<{ return { host, - port: config.getRawConfig().gerbil.tcp_relay_port, + port: + config.getRawConfig().gerbil.wss_relay_port || + config.getRawConfig().gerbil.wss_relay_port, clientPublicKey: client.pubKey || "", reachableAt: exitNode.reachableAt, exitNodePublicKey: exitNode.publicKey || "" From 09e134617c80bcacd56017781d8a030030ddb5f7 Mon Sep 17 00:00:00 2001 From: Nicholas Liverett Date: Fri, 10 Apr 2026 18:48:54 -0500 Subject: [PATCH 4/4] fix: wss_relay_port --- server/lib/readConfigFile.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index dee0e5f458..2c86f2ba35 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -274,10 +274,6 @@ export const configSchema = z .default(21820) .transform(stoi) .pipe(portSchema), - wss_relay_port: portSchema - .optional() - .transform(stoi) - .pipe(portSchema.optional()), wss_relay_port: portSchema .optional() .default(4430)