mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
e3c8c3b486
commit
d9af3d73af
15 changed files with 1159 additions and 335 deletions
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue