feat: p2 TUI through Radius (npm run tui -- --radius)

The launcher signs in to Radius itself and reaches its server as a Radius
client; the brain answers client channels with loopback requests as the
relay owner. Temporary: one Radius connection per request, bundle check
every 10 s; docs/p2-notes.md describes the single-connection follow-up.
This commit is contained in:
David Brailovsky 2026-11-05 20:26:03 +01:00
parent e3c8c3b486
commit d9af3d73af
15 changed files with 1159 additions and 335 deletions

View file

@ -105,6 +105,25 @@ import {
* (`pub--<label>--<forward>`) without a principal: nobody signed in. Such a
* channel reaches only a forward the owner marked public; for any other
* forward, and for the brain's own UI, it is refused like an unknown forward.
*
* Client channels. A Pi client of the owner, the TUI launcher with `--radius`
* (src/hosts/tui-radius.ts), connects to Radius with its own bearer credential
* (`pi-server.client.v2`). Radius then opens a `ws` channel with the principal
* but without a request or public origin: there is no browser request to
* describe. The client's first message is its request instead, JSON
* `{ type: "http" | "websocket", method, path, headers }`, and the brain makes
* it a loopback request marked like the owner's browser requests, except
* without a shell origin: no page frames a client. HTTP request bodies follow
* as binary messages and a text end marker. The brain answers with one text
* message `{ status, headers, origin }` (`origin`: what the server sees as this
* client's origin), then for HTTP the response body as binary messages and a
* close with 1000; for `websocket`, status 101 turns the channel into the piped
* loopback socket. Only relays with channel sockets (v5) carry them.
*
* Temporary: one channel per request. Radius limits new connections (120 a
* minute per user, shared with this brain's reconnects), so the launcher polls
* rarely. The proper form carries all of a client's requests and its chord link
* over one channel (docs/p2-notes.md "TUI through Radius").
*/
/** Offered newest first; v3 adds credit flow control for HTTP channels, v4 port forwards, v5 channel sockets. */
@ -134,6 +153,8 @@ const HEARTBEAT_IDLE_MS = 20_000;
/** Silence from the relay after which the connection counts as lost and is replaced. */
const HEARTBEAT_TIMEOUT_MS = 60_000;
const HANDSHAKE_TIMEOUT_MS = 30_000;
/** A client channel's origin while the brain's Radius origin is unknown (nobody opened it in a browser yet). */
const CLIENT_ORIGIN = "https://radius.invalid";
/** The relay's close of a host socket another brain with this server ID replaced (relay protocol). */
const REPLACED_CODE = 1012;
const REPLACED_REASON = "Server host replaced";
@ -542,6 +563,10 @@ export class RadiusBrain {
principal?.kind === owner.kind &&
principal.id === owner.id &&
principal.organization_id === owner.organization_id;
if (isOwner && message.kind === "ws" && message.request === undefined && message.forward === undefined) {
this.#openClientChannel(id);
return;
}
// Someone a session is shared with, on the brain's UI only: sharing decides what they reach.
const member = !isOwner && principal?.kind === "user" && !!principal.email && message.forward === undefined;
// Only a public forward's origin connects without a principal; #openForward checks the forward is public.
@ -822,13 +847,7 @@ export class RadiusBrain {
void this.#currentConfig().then(
(config) => {
if (ended) return;
const url = new URL(`/v1/servers/${config.serverId}/channels/${id}`, config.gateway);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const channel = new WebSocket(url, CHANNEL_SUBPROTOCOL, {
headers: { authorization: `Bearer ${config.credential.access}` },
maxPayload: MAX_MESSAGE_BYTES,
handshakeTimeout: this.#handshakeTimeoutMs,
});
const channel = this.#relayChannel(config, id);
const upstream = this.#loopback(head, headers, target);
sockets.push(channel, upstream);
channel.on("open", () => {
@ -845,6 +864,147 @@ export class RadiusBrain {
);
}
/** A channel's own socket to Radius (relay protocol v5), authenticated like the host socket. */
#relayChannel(config: RadiusBrainConfig, id: string): WebSocket {
const url = new URL(`/v1/servers/${config.serverId}/channels/${id}`, config.gateway);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(url, CHANNEL_SUBPROTOCOL, {
headers: { authorization: `Bearer ${config.credential.access}` },
maxPayload: MAX_MESSAGE_BYTES,
handshakeTimeout: this.#handshakeTimeoutMs,
});
}
/**
* A client channel of the owner (see "Client channels"): its own socket to Radius, whose first message is the
* request. Ends like `#openChannelSocket`: refused on the host socket until the channel socket is connected.
*/
#openClientChannel(id: string): void {
if (!this.#channelSockets) {
this.#sendControl({ version: PROTOCOL_VERSION, type: "connection_close", connection_id: id, code: 1008 });
return;
}
let ended = false;
let paired = false;
let channel: WebSocket | undefined;
let stop = (): void => {};
const end = (code: number, tellRelay = !paired) => {
if (ended) return;
ended = true;
this.#channels.delete(id);
if (tellRelay)
this.#sendControl({ version: PROTOCOL_VERSION, type: "connection_close", connection_id: id, code: 1011 });
stop();
// A request body may have left it paused (a full loopback request); the close handshake must read.
channel?.resume();
channel?.close(sendableCode(code));
};
this.#channels.set(id, { data: () => {}, close: () => end(1001, false), credit: () => {}, congested: () => {} });
void this.#currentConfig().then(
(config) => {
if (ended) return;
const socket = this.#relayChannel(config, id);
channel = socket;
socket.on("open", () => {
paired = true;
});
socket.on("error", () => {});
socket.on("close", (code) => end(code));
socket.once("message", (data: Buffer, binary) => {
const request = binary ? undefined : clientRequest(data);
if (!request) return end(1008);
const origin = this.#forwards?.radiusOrigin ?? this.#config?.publicOrigin ?? CLIENT_ORIGIN;
const headers: Record<string, string> = {};
for (const [rawName, value] of Object.entries(request.headers)) {
const name = rawName.toLowerCase();
if (!DROPPED_HEADERS.has(name) && name !== "cookie" && name !== "origin") headers[name] = value;
}
// The owner by Radius alone, as on the brain's UI, at the origin it reports back to the client.
headers[RELAY_SECRET_HEADER] = this.#relay.secret;
headers[RELAY_ORIGIN_HEADER] = origin;
headers.origin = origin;
const target = { host: "127.0.0.1", port: this.#port() };
const answer = (status: number, pairs: [string, string][] = []) =>
socket.send(JSON.stringify({ status, headers: pairs, origin }));
stop =
request.type === "websocket"
? this.#clientSocket(socket, request.path, headers, target, answer, end)
: this.#clientHttp(socket, request, headers, target, answer, end);
});
},
() => end(1011),
);
}
/** A client channel's HTTP request; returns how to abandon it. */
#clientHttp(
channel: WebSocket,
request: ClientChannelRequest,
headers: Record<string, string>,
target: Target,
answer: (status: number, headers: [string, string][]) => void,
end: (code: number, tellRelay?: boolean) => void,
): () => void {
const upstream = httpRequest(
{
host: target.host,
port: target.port,
method: request.method,
path: request.path,
headers: { ...headers, host: `${target.host}:${target.port}` },
},
(response) => {
const pairs: [string, string][] = [];
for (let index = 0; index + 1 < response.rawHeaders.length; index += 2)
pairs.push([response.rawHeaders[index]!, response.rawHeaders[index + 1]!]);
answer(response.statusCode ?? 502, pairs);
response.on("data", (chunk: Buffer) => {
channel.send(chunk);
if (!response.isPaused() && channel.bufferedAmount > PIPE_HIGH_BYTES) {
response.pause();
drained(channel, () => response.resume());
}
});
response.on("end", () => end(1000, false));
response.on("error", () => end(1011, false));
},
);
upstream.on("error", () => end(1011, false));
// The request body, then the client's text end marker.
channel.on("message", (data: Buffer, binary) => {
if (!binary) upstream.end();
else if (!upstream.write(data)) {
channel.pause();
upstream.once("drain", () => channel.resume());
}
});
return () => upstream.destroy();
}
/** A client channel's WebSocket: answered 101 once the loopback socket is open, then piped both ways. */
#clientSocket(
channel: WebSocket,
path: string,
headers: Record<string, string>,
target: Target,
answer: (status: number) => void,
end: (code: number, tellRelay?: boolean) => void,
): () => void {
const upstream = this.#loopback({ method: "GET", path, headers: [] }, headers, target);
upstream.on("open", () => answer(101));
// With this listener `ws` leaves a refused upgrade open; `end` closes it.
upstream.on("unexpected-response", (_request, response) => {
response.resume();
answer(response.statusCode ?? 502);
end(1000, false);
});
upstream.on("error", () => {});
upstream.on("close", (code) => end(code, false));
pipe(channel, upstream);
pipe(upstream, channel);
return () => upstream.terminate();
}
#openWebSocket(id: string, head: RequestHead, headers: Record<string, string>, target: Target): void {
const upstream = this.#loopback(head, headers, target);
const queued: { payload: Buffer; text: boolean }[] = [];
@ -913,17 +1073,16 @@ export class RadiusBrain {
*/
function pipe(from: WebSocket, to: WebSocket): void {
const waiting: { data: Buffer; binary: boolean }[] = [];
let drain: NodeJS.Timeout | undefined;
let draining = false;
const send = (data: Buffer, binary: boolean) => {
to.send(data, { binary });
if (drain || to.bufferedAmount <= PIPE_HIGH_BYTES) return;
if (draining || to.bufferedAmount <= PIPE_HIGH_BYTES) return;
draining = true;
from.pause();
drain = setInterval(() => {
if (to.readyState === WebSocket.OPEN && to.bufferedAmount > PIPE_LOW_BYTES) return;
clearInterval(drain);
drain = undefined;
drained(to, () => {
draining = false;
if (from.readyState === WebSocket.OPEN) from.resume();
}, CONGESTION_POLL_MS);
});
};
to.on("open", () => {
for (const item of waiting.splice(0)) send(item.data, item.binary);
@ -932,9 +1091,47 @@ function pipe(from: WebSocket, to: WebSocket): void {
if (to.readyState === WebSocket.OPEN) send(data, binary);
else if (to.readyState === WebSocket.CONNECTING) waiting.push({ data, binary });
});
const stop = () => clearInterval(drain);
from.on("close", stop);
to.on("close", stop);
}
/** Calls `then` once `socket` has at most PIPE_LOW_BYTES unsent, or is no longer open. */
function drained(socket: WebSocket, then: () => void): void {
const poll = setInterval(() => {
if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount > PIPE_LOW_BYTES) return;
clearInterval(poll);
then();
}, CONGESTION_POLL_MS);
}
/** A client channel's request (see "Client channels"). */
type ClientChannelRequest = {
type: "http" | "websocket";
method: string;
path: string;
headers: Record<string, string>;
};
/** A client channel's first message, from the wire: undefined unless it is a request for a path on this server. */
function clientRequest(data: Buffer): ClientChannelRequest | undefined {
let value: Partial<ClientChannelRequest>;
try {
value = JSON.parse(data.toString("utf8")) as Partial<ClientChannelRequest>;
} catch {
return undefined;
}
const headers = value.headers;
if (
(value.type !== "http" && value.type !== "websocket") ||
(value.type === "http" && (typeof value.method !== "string" || !/^[A-Z]{1,16}$/.test(value.method))) ||
typeof value.path !== "string" ||
!value.path.startsWith("/") ||
value.path.startsWith("//") ||
!headers ||
typeof headers !== "object" ||
Array.isArray(headers) ||
!Object.values(headers).every((header) => typeof header === "string")
)
return undefined;
return { type: value.type, method: value.method ?? "GET", path: value.path, headers };
}
/** A close code one side reported, as one the other side may send (1005 and 1006 are never sent). */

View file

@ -7,12 +7,12 @@ import type { RelayUser } from "@p2/app/v1/server";
export const RELAY_SECRET_HEADER = "x-p2-relay";
/** The brain's browser-facing origin (its cortex origin), e.g. `https://<label>.rdtun.com`. */
export const RELAY_ORIGIN_HEADER = "x-p2-relay-origin";
/** The Radius shell origin that frames the brain UI. */
/** The Radius shell origin that frames the brain UI; absent for a Pi client of the owner, which no page frames. */
export const RELAY_SHELL_HEADER = "x-p2-relay-shell";
/** A Radius user other than the owner: base64url JSON `{ id, email }`. Absent for the owner. */
export const RELAY_USER_HEADER = "x-p2-relay-user";
export type RelayedRequest = { publicOrigin: string; shellOrigin: string; user?: RelayUser };
export type RelayedRequest = { publicOrigin: string; shellOrigin?: string; user?: RelayUser };
const ORIGIN = /^https?:\/\/[^/\s]+$/;
@ -31,7 +31,9 @@ const ORIGIN = /^https?:\/\/[^/\s]+$/;
* owner shared sessions with by email: Radius vouches for their email, and
* p2's sharing decides what they may reach, as for a guest cookie on a tunnel
* host. Without the secret the marker headers mean nothing, so neither
* browsers nor other local processes can claim them.
* browsers nor other local processes can claim them. Without the shell header
* it is the owner's Pi client, the TUI launcher through Radius (brain.ts,
* "Client channels").
*/
export class RelayAccess {
readonly secret = randomBytes(32).toString("hex");
@ -50,6 +52,10 @@ export class RelayAccess {
const publicOrigin = request.headers[RELAY_ORIGIN_HEADER];
const shellOrigin = request.headers[RELAY_SHELL_HEADER];
if (typeof publicOrigin !== "string" || !ORIGIN.test(publicOrigin)) return undefined;
if (shellOrigin === undefined) {
// Clients are the owner's only; sharing admits people on the brain's UI.
return request.headers[RELAY_USER_HEADER] === undefined ? { publicOrigin } : undefined;
}
if (typeof shellOrigin !== "string" || !ORIGIN.test(shellOrigin)) return undefined;
const user = request.headers[RELAY_USER_HEADER];
if (user === undefined) return { publicOrigin, shellOrigin };

View file

@ -50,7 +50,7 @@ export default defineFacet((host) => {
origin: relayed.publicOrigin,
remote: true,
port: server.info.value.port,
relayShellOrigin: relayed.shellOrigin,
...(relayed.shellOrigin === undefined ? {} : { relayShellOrigin: relayed.shellOrigin }),
};
// Anyone else is whom sharing finds by their email (the sessions shared with them), or nobody.
return relayed.user === undefined