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
|
|
@ -113,8 +113,14 @@ The TUI launcher requires **Node >=26.5.1**; the server still supports Node 22.1
|
|||
npm run tui # device owner on http://127.0.0.1:$PORT (8788 when unset)
|
||||
npm run tui -- https://your-domain # prompts for an owner link if no valid cookie is saved
|
||||
npm run tui -- https://your-domain --owner-link 'https://your-domain/owner#<token>'
|
||||
npm run tui -- --radius # through Radius: your only online server, or --radius <server-id>
|
||||
```
|
||||
|
||||
With `--radius` the TUI reaches a server signed in with `npm run radius:login`, from anywhere and without a tunnel or
|
||||
owner link. The first run signs the TUI in to Radius by device code (saved in `~/.pi/p2-tui/radius.json`); Radius
|
||||
admits only the server's owner. For now each request is its own Radius connection, so a new server bundle shows up
|
||||
within 10 seconds rather than one (see [p2 notes](docs/p2-notes.md#tui-through-radius)).
|
||||
|
||||
Direct loopback auto-signs in as the device owner without a link prompt; a proxy marker cannot use that path.
|
||||
For remote URLs (HTTPS required), get an owner link on the host, then pass `--owner-link` or paste it at the prompt.
|
||||
The TUI stores only its cookie per origin in `~/.pi/p2-tui/config.json` (0600, directory 0700;
|
||||
|
|
|
|||
|
|
@ -432,6 +432,25 @@ npm start
|
|||
admission and provider access, HTTP credit in both directions, congestion, forwards, redirects, WebSockets,
|
||||
unavailable ports and anonymous public/private gates. All listeners use port 0 and private temporary data.
|
||||
|
||||
### TUI through Radius
|
||||
|
||||
`npm run tui -- --radius [<server-id>]` reaches the server through Radius, without a tunnel or owner link. The
|
||||
launcher signs in to Radius itself (device code, saved in `~/.pi/p2-tui/radius.json`, separate from any server's
|
||||
`radius.json`), picks the given server or the user's only online one (`GET /v1/servers`), and connects to
|
||||
`/v1/servers/<id>/connect` with its bearer token (`pi-server.client.v2`). Radius admits only the server's owner and
|
||||
opens a `ws` channel without a request; the brain ("Client channels" in `brain.ts`) reads the request from the
|
||||
client's first message and makes it a loopback request marked like the owner's browser requests, without a shell
|
||||
origin (`access: "relay"`: no owner links). The launcher's requests go through a transport (`tui-transport.ts`,
|
||||
`tui-radius.ts`); the direct cookie transport is unchanged. Only v5 relays (channel sockets) carry client channels.
|
||||
|
||||
**Temporary: one Radius connection per request.** Radius allows 120 new connections a minute per user, shared with
|
||||
the server's own reconnects, so through Radius the launcher checks for a new bundle every 10 s instead of every
|
||||
second, and every request (owner check, manifest, bundle, downloads, uploads) and every chord link reconnect costs a
|
||||
connection setup. The proper form keeps one connection per launcher and carries all of its requests and its chord
|
||||
link over it: every message labelled with the request it belongs to, both sides sorting them, and large transfers
|
||||
taking turns with the chord link so a download cannot hold up live output. Then the 1 s bundle check returns.
|
||||
`test/p2/radius.test.ts` and `test/p2/tui-radius.test.ts` cover client channels with the same fake relay.
|
||||
|
||||
## Browser navigation
|
||||
|
||||
`src/web/navigation.ts` is an independent, minimized copy of Pi Mobile's stack. `src/runtime/web.tsx` exposes
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ import {
|
|||
readClipboardImage,
|
||||
readClipboardText,
|
||||
} from "./tui-clipboard.ts";
|
||||
import { configDirectory, connectionHeaders, type TuiCredentials } from "./tui-config.ts";
|
||||
import { configDirectory, type TuiCredentials } from "./tui-config.ts";
|
||||
import type { LauncherAction, LauncherActionContext } from "./tui-grants.ts";
|
||||
import { applyPrefsPatch, prefsDiff, readPrefsFile, validatePrefsPatch, writePrefsFile } from "./tui-prefs-save.ts";
|
||||
import { cookieTransport, type TuiRequestInit, type TuiTransport } from "./tui-transport.ts";
|
||||
import type { ConsentRequest } from "./tui-trusted-ui.ts";
|
||||
|
||||
export const MAX_COPY = 1024 * 1024;
|
||||
|
|
@ -40,6 +41,8 @@ const ARTIFACT = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|||
export type LauncherActionOptions = {
|
||||
url: URL;
|
||||
credentials: TuiCredentials;
|
||||
/** How requests reach the server; default: directly to `url` with `credentials`. */
|
||||
transport?: TuiTransport;
|
||||
role: "owner" | "guest";
|
||||
/** Download folder (launcher-only preference; default ~/Downloads/p2). */
|
||||
downloads?: string;
|
||||
|
|
@ -277,13 +280,12 @@ function suspendThisProcess(): Promise<void> {
|
|||
export function launcherActions(options: LauncherActionOptions): Record<string, LauncherAction> {
|
||||
const { url } = options;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const headers = connectionHeaders(url, options.credentials);
|
||||
const transport = options.transport ?? cookieTransport(url, options.credentials);
|
||||
const clipboard = options.clipboard ?? ((text, write) => copyToClipboard(text, write));
|
||||
const open = options.openBrowser ?? ((target) => openBrowser(target));
|
||||
const folder = options.downloads ?? join(homedir(), "Downloads", "p2");
|
||||
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.protocol === "http:";
|
||||
const request = (path: string, init: RequestInit & { signal: AbortSignal }) =>
|
||||
fetch(new URL(path, url), { ...init, headers: { ...headers, ...init.headers }, redirect: "error" });
|
||||
const request = (path: string, init: TuiRequestInit) => transport.request(path, init);
|
||||
const copyText = (value: unknown): string | undefined => {
|
||||
const text = record(value)?.text;
|
||||
return typeof text === "string" && text.length > 0 && Buffer.byteLength(text) <= MAX_COPY ? text : undefined;
|
||||
|
|
@ -321,17 +323,12 @@ export function launcherActions(options: LauncherActionOptions): Record<string,
|
|||
}
|
||||
})();
|
||||
if (body instanceof Uint8Array && body.byteLength > MAX_UPLOAD) throw new Error("Larger than 25 MiB");
|
||||
const response = await fetch(new URL(`/upload?${query}`, url), {
|
||||
const response = await request(`/upload?${query}`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/octet-stream" },
|
||||
body:
|
||||
body instanceof Uint8Array
|
||||
? Buffer.from(body)
|
||||
: (stream as AsyncIterable<Uint8Array> as unknown as BodyInit),
|
||||
redirect: "error",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: body instanceof Uint8Array ? Buffer.from(body) : (stream as AsyncIterable<Uint8Array>),
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]),
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
} as RequestInit);
|
||||
});
|
||||
const text = (await response.text()).slice(0, 8192);
|
||||
let value: Record<string, unknown> | undefined;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// Trusted launcher transport. Downloaded code sees opaque chord frames, not the socket, URL or sign-in cookie.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui";
|
||||
import { WebSocket } from "ws";
|
||||
import type { TuiLocalPreferences } from "../tui/preferences.ts";
|
||||
import { connectionHeaders, type TuiCredentials } from "./tui-config.ts";
|
||||
import type { TuiCredentials } from "./tui-config.ts";
|
||||
import { MAX_TERMINAL_FRAME } from "./tui-filter.ts";
|
||||
import {
|
||||
type Grant,
|
||||
|
|
@ -19,9 +18,9 @@ import { type MousePolicy, TerminalModes } from "./tui-modes.ts";
|
|||
import { SelectionGesture } from "./tui-mouse.ts";
|
||||
import { MAX_TUI_BUNDLE, requireTuiNode, TuiSandbox } from "./tui-sandbox.ts";
|
||||
import { colorFgBgScheme, TerminalColorProbe } from "./tui-terminal-colors.ts";
|
||||
import { cookieTransport, MAX_LINK_FRAME, type TuiSocket, type TuiTransport } from "./tui-transport.ts";
|
||||
import { TrustedUi, visibleText } from "./tui-trusted-ui.ts";
|
||||
|
||||
const MAX_LINK_FRAME = 4 * 1024 * 1024;
|
||||
/** How long after the launcher saw a selection gesture pi-tui's copy request is accepted. */
|
||||
const GESTURE_WINDOW_MS = 2000;
|
||||
/** The server no longer admits this sign-in (401/403): a revoked or reset share, an archived session, a logout. */
|
||||
|
|
@ -78,6 +77,8 @@ export async function startTuiClient(options: {
|
|||
capabilities?: ReturnType<typeof launcherCapabilities>;
|
||||
colorQueryMs?: number;
|
||||
credentials?: TuiCredentials;
|
||||
/** How requests reach the server; default: directly to `url` with `credentials`. */
|
||||
transport?: TuiTransport;
|
||||
reconnectMs?: number;
|
||||
pollMs?: number;
|
||||
onExit?: () => void;
|
||||
|
|
@ -86,11 +87,11 @@ export async function startTuiClient(options: {
|
|||
onAccessEnded?: () => void;
|
||||
}): Promise<TuiClient> {
|
||||
requireTuiNode();
|
||||
const headers = connectionHeaders(options.url, options.credentials ?? {});
|
||||
const transport = options.transport ?? cookieTransport(options.url, options.credentials ?? {});
|
||||
let sandbox: TuiSandbox | undefined;
|
||||
let preparing: TuiSandbox | undefined;
|
||||
let closing: Promise<void> | undefined;
|
||||
let socket: WebSocket | undefined;
|
||||
let socket: TuiSocket | undefined;
|
||||
let version = "";
|
||||
let connected = false;
|
||||
let closed = false;
|
||||
|
|
@ -302,12 +303,8 @@ export async function startTuiClient(options: {
|
|||
};
|
||||
/** Confirms a refusal with the launcher's own fixed-origin request; a lying server can only end its own session. */
|
||||
const verifyAccess = (): void => {
|
||||
void fetch(new URL("/__p2_tui", options.url), {
|
||||
method: "HEAD",
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
void transport
|
||||
.request("/__p2_tui", { method: "HEAD", signal: AbortSignal.timeout(10_000) })
|
||||
.then((response) => {
|
||||
if (response.status === 401 || response.status === 403) accessEnded();
|
||||
})
|
||||
|
|
@ -318,7 +315,7 @@ export async function startTuiClient(options: {
|
|||
fail("TUI link frame exceeds 4 MiB");
|
||||
return;
|
||||
}
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(data);
|
||||
if (socket?.open) socket.send(data);
|
||||
else {
|
||||
pendingBytes += data.byteLength;
|
||||
if (pendingBytes > MAX_LINK_FRAME) {
|
||||
|
|
@ -330,63 +327,51 @@ export async function startTuiClient(options: {
|
|||
};
|
||||
const connect = (attempt = 0): void => {
|
||||
if (closed) return;
|
||||
const url = new URL("/chord", options.url);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const current = new WebSocket(url, {
|
||||
headers,
|
||||
maxPayload: MAX_LINK_FRAME,
|
||||
handshakeTimeout: 10_000,
|
||||
followRedirects: false,
|
||||
let opened = false;
|
||||
const current: TuiSocket = transport.socket("/chord", {
|
||||
open: () => {
|
||||
if (closed || socket !== current) {
|
||||
current.terminate();
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
connected = true;
|
||||
for (const data of pending.splice(0)) current.send(data);
|
||||
pendingBytes = 0;
|
||||
sandbox?.post({ kind: "connection", open: true });
|
||||
},
|
||||
message: (data, binary) => {
|
||||
if (socket !== current || closed) return;
|
||||
// Chord's links carry binary messages only.
|
||||
if (!binary) {
|
||||
current.terminate();
|
||||
return;
|
||||
}
|
||||
sandbox?.post({ kind: "link", data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) });
|
||||
},
|
||||
refused: (status) => {
|
||||
if (status === 401 || status === 403) verifyAccess();
|
||||
},
|
||||
close: () => {
|
||||
if (closed || socket !== current) return;
|
||||
connected = false;
|
||||
sandbox?.post({ kind: "connection", open: false });
|
||||
// The replacement link emits fresh hello/holds. Never replay stale connection frames.
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
retry = setTimeout(
|
||||
() => connect(opened ? 0 : attempt + 1),
|
||||
Math.min(5000, (options.reconnectMs ?? 250) * 2 ** Math.min(attempt, 5)),
|
||||
);
|
||||
},
|
||||
});
|
||||
socket = current;
|
||||
let opened = false;
|
||||
current.on("open", () => {
|
||||
if (closed || socket !== current) {
|
||||
current.terminate();
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
connected = true;
|
||||
for (const data of pending.splice(0)) current.send(data);
|
||||
pendingBytes = 0;
|
||||
sandbox?.post({ kind: "connection", open: true });
|
||||
});
|
||||
current.on("message", (data, binary) => {
|
||||
if (socket !== current || closed) return;
|
||||
// Chord's links carry binary messages only.
|
||||
if (!binary) {
|
||||
current.terminate();
|
||||
return;
|
||||
}
|
||||
const bytes = Buffer.isBuffer(data) ? data : Buffer.concat(data as Buffer[]);
|
||||
sandbox?.post({ kind: "link", data: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
|
||||
});
|
||||
current.on("error", () => {});
|
||||
current.on("unexpected-response", (_request, response) => {
|
||||
const status = response.statusCode;
|
||||
response.resume();
|
||||
current.terminate();
|
||||
if (status === 401 || status === 403) verifyAccess();
|
||||
});
|
||||
current.on("close", () => {
|
||||
if (closed || socket !== current) return;
|
||||
connected = false;
|
||||
sandbox?.post({ kind: "connection", open: false });
|
||||
// The replacement link emits fresh hello/holds. Never replay stale connection frames.
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
retry = setTimeout(
|
||||
() => connect(opened ? 0 : attempt + 1),
|
||||
Math.min(5000, (options.reconnectMs ?? 250) * 2 ** Math.min(attempt, 5)),
|
||||
);
|
||||
});
|
||||
};
|
||||
const load = (force: boolean): Promise<void> => {
|
||||
if (closed) return Promise.resolve();
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
const request = (path: string) =>
|
||||
fetch(new URL(path, options.url), { headers, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
||||
const request = (path: string) => transport.request(path, { signal: AbortSignal.timeout(10_000) });
|
||||
const manifest: unknown = JSON.parse(await readBounded(await request("/__p2_tui"), 8192));
|
||||
if (
|
||||
!manifest ||
|
||||
|
|
|
|||
|
|
@ -293,9 +293,14 @@ export function parseTuiArgs(args: readonly string[], env: NodeJS.ProcessEnv = p
|
|||
let joinLink: string | undefined;
|
||||
let theme: string | undefined;
|
||||
let noThemes = false;
|
||||
let radius: { server?: string } | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg === "--owner-link") {
|
||||
if (arg === "--radius") {
|
||||
// Optionally followed by the server's ID; without it, the user's only online server.
|
||||
const next = args[i + 1];
|
||||
radius = next !== undefined && SERVER_ID.test(next) ? { server: args[++i] } : {};
|
||||
} else if (arg === "--owner-link") {
|
||||
ownerLink = args[++i];
|
||||
if (!ownerLink) throw new Error("--owner-link needs a value");
|
||||
} else if (arg === "--join") {
|
||||
|
|
@ -310,7 +315,9 @@ export function parseTuiArgs(args: readonly string[], env: NodeJS.ProcessEnv = p
|
|||
else urlText = arg;
|
||||
}
|
||||
if (joinLink && ownerLink && args.includes("--owner-link")) throw new Error("Use either --join or --owner-link");
|
||||
if (joinLink) ownerLink = undefined;
|
||||
if (radius && (urlText !== undefined || joinLink || args.includes("--owner-link")))
|
||||
throw new Error("--radius signs in through Radius: no URL, owner link or share link");
|
||||
if (joinLink || radius) ownerLink = undefined;
|
||||
// Without a URL argument, --join takes the origin from its link; otherwise the link must match the URL.
|
||||
const url =
|
||||
urlText === undefined && joinLink
|
||||
|
|
@ -318,5 +325,7 @@ export function parseTuiArgs(args: readonly string[], env: NodeJS.ProcessEnv = p
|
|||
: ownerOrigin(urlText ?? `http://127.0.0.1:${env.PORT ?? 8788}`);
|
||||
if (ownerLink) ownerLinkToken(ownerLink, url);
|
||||
if (joinLink) joinLinkToken(joinLink, url);
|
||||
return { url, ownerLink, joinLink, theme, noThemes };
|
||||
return { url, ownerLink, joinLink, theme, noThemes, radius };
|
||||
}
|
||||
|
||||
const SERVER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
|
|
|||
327
apps/p2/src/hosts/tui-radius.ts
Normal file
327
apps/p2/src/hosts/tui-radius.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// The TUI launcher through Radius (`npm run tui -- --radius`): the launcher's own Radius sign-in, finding the server,
|
||||
// and its requests as Radius client connections, which the server's Radius plugin answers (plugins/radius/brain.ts,
|
||||
// "Client channels").
|
||||
//
|
||||
// Temporary: one Radius connection per request. Every connection counts against Radius's limit on new connections
|
||||
// (120 a minute per user, shared with the server's own reconnects), so the launcher checks for a new bundle only every
|
||||
// RADIUS_POLL_MS. The proper form carries all of a launcher's requests and its chord link over one connection; see
|
||||
// docs/p2-notes.md "TUI through Radius".
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
||||
import { radiusProvider } from "@earendil-works/pi-ai/providers/radius";
|
||||
import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
|
||||
import { WebSocket } from "ws";
|
||||
import { configDirectory } from "./tui-config.ts";
|
||||
import type { TuiRequestInit, TuiSocket, TuiSocketEvents, TuiTransport } from "./tui-transport.ts";
|
||||
|
||||
/** A signed-in Radius user: the gateway and a current access token. */
|
||||
export type RadiusClient = {
|
||||
readonly gateway: string;
|
||||
access(): Promise<string>;
|
||||
};
|
||||
|
||||
/** A transport through Radius, and the server's origin as its last answer named it (see `TunnelHead.origin`). */
|
||||
export type RadiusTransport = TuiTransport & { readonly origin: string | undefined };
|
||||
|
||||
/** How often a launcher through Radius checks for a new bundle (see the file comment). */
|
||||
export const RADIUS_POLL_MS = 10_000;
|
||||
|
||||
type RadiusLogin = { gateway: string; credential: OAuthCredential };
|
||||
|
||||
/**
|
||||
* The launcher's Radius sign-in, kept in `<config>/radius.json` (0600): read, or made once by device code (`print`
|
||||
* shows the code), and refreshed before the token expires. Separate from a p2 server's own `radius.json`.
|
||||
*/
|
||||
export async function radiusClient(
|
||||
print: (text: string) => void,
|
||||
signal: AbortSignal,
|
||||
directory = configDirectory(),
|
||||
): Promise<RadiusClient> {
|
||||
const path = join(directory, "radius.json");
|
||||
let login = readLogin(path);
|
||||
if (!login) {
|
||||
const gateway = normalizeRadiusGatewayUrl(process.env.PI_RADIUS_GATEWAY ?? DEFAULT_RADIUS_GATEWAY);
|
||||
print(`Signing in to Radius at ${gateway}`);
|
||||
const credential = await radiusOAuth(gateway).login({
|
||||
signal,
|
||||
prompt: async () => "device-code",
|
||||
notify: (event) => {
|
||||
if (event.type === "device_code")
|
||||
print(`Open ${event.verificationUri} and enter the code ${event.userCode}`);
|
||||
},
|
||||
});
|
||||
login = { gateway, credential: { ...credential, type: "oauth" } };
|
||||
saveLogin(directory, path, login);
|
||||
}
|
||||
let current = login;
|
||||
let refreshing: Promise<RadiusLogin> | undefined;
|
||||
return {
|
||||
gateway: current.gateway,
|
||||
access: async () => {
|
||||
if (current.credential.expires - Date.now() > REFRESH_MARGIN_MS) return current.credential.access;
|
||||
refreshing ??= radiusOAuth(current.gateway)
|
||||
.refresh(current.credential, AbortSignal.timeout(30_000))
|
||||
.then((credential) => {
|
||||
const next: RadiusLogin = { gateway: current.gateway, credential: { ...credential, type: "oauth" } };
|
||||
saveLogin(directory, path, next);
|
||||
return next;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = undefined;
|
||||
});
|
||||
current = await refreshing;
|
||||
return current.credential.access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const REFRESH_MARGIN_MS = 5 * 60_000;
|
||||
|
||||
function readLogin(path: string): RadiusLogin | undefined {
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
const value = JSON.parse(text) as Partial<RadiusLogin>;
|
||||
// A file on disk: checked before use.
|
||||
if (typeof value.gateway !== "string" || value.credential?.type !== "oauth")
|
||||
throw new Error(`Invalid ${path}; delete it to sign in to Radius again`);
|
||||
return value as RadiusLogin;
|
||||
}
|
||||
|
||||
function saveLogin(directory: string, path: string, login: RadiusLogin): void {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, `${JSON.stringify(login, null, "\t")}\n`, { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
function radiusOAuth(gateway: string) {
|
||||
const oauth = radiusProvider({ gateway }).auth.oauth;
|
||||
if (!oauth) throw new Error("The Radius provider has no OAuth support");
|
||||
return oauth;
|
||||
}
|
||||
|
||||
/** The server to connect to: `serverId`, else this user's only server Radius reports online. */
|
||||
export async function radiusServer(client: RadiusClient, serverId: string | undefined): Promise<string> {
|
||||
if (serverId) return serverId;
|
||||
const response = await fetch(new URL("/v1/servers", client.gateway), {
|
||||
headers: { authorization: `Bearer ${await client.access()}`, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Radius did not list your servers (${response.status})`);
|
||||
const { servers } = (await response.json()) as { servers: { server_id: string; status: string }[] };
|
||||
const online = servers.filter((server) => server.status === "online").map((server) => server.server_id);
|
||||
if (online.length === 1) return online[0]!;
|
||||
throw new Error(
|
||||
online.length === 0
|
||||
? "None of your p2 servers is connected to Radius"
|
||||
: `Several of your servers are online; choose one with --radius <server-id>: ${online.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests to `serverId` through Radius, one client connection each. A request is one text message (`TunnelRequest`),
|
||||
* the body as binary messages and a text end marker. The server answers with one text message (`TunnelHead`): for
|
||||
* HTTP the response body follows as binary messages and a close with 1000 ends it; for the chord WebSocket, status 101
|
||||
* makes the connection the link itself.
|
||||
*/
|
||||
export function radiusTransport(client: RadiusClient, serverId: string): RadiusTransport {
|
||||
let origin: string | undefined;
|
||||
const connect = async (): Promise<WebSocket> => {
|
||||
const url = new URL(`/v1/servers/${encodeURIComponent(serverId)}/connect`, client.gateway);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return new WebSocket(url, CLIENT_SUBPROTOCOL, {
|
||||
headers: { authorization: `Bearer ${await client.access()}` },
|
||||
maxPayload: MAX_MESSAGE_BYTES,
|
||||
handshakeTimeout: 10_000,
|
||||
followRedirects: false,
|
||||
});
|
||||
};
|
||||
const head = (data: Buffer): TunnelHead => {
|
||||
const value = parseHead(data);
|
||||
origin = value.origin;
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
get origin() {
|
||||
return origin;
|
||||
},
|
||||
request: async (path, init) => tunnelRequest(await connect(), path, init, head),
|
||||
socket: (path, events) => tunnelSocket(connect(), path, events, head),
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENT_SUBPROTOCOL = "pi-server.client.v2";
|
||||
/** Radius's limit for one message. */
|
||||
const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
const BODY_CHUNK_BYTES = 256 * 1024;
|
||||
/** A streamed request body waits while more than this is unsent. */
|
||||
const SEND_HIGH_BYTES = 1024 * 1024;
|
||||
/** Statuses whose Response may not have a body. */
|
||||
const NULL_BODY = new Set([204, 205, 304]);
|
||||
|
||||
/** The server's answer to a request (brain.ts writes it). */
|
||||
type TunnelHead = { status: number; headers: [string, string][]; origin: string };
|
||||
|
||||
/** The server's answer, from the wire: checked before use. */
|
||||
function parseHead(data: Buffer): TunnelHead {
|
||||
const value = JSON.parse(data.toString("utf8")) as Partial<TunnelHead>;
|
||||
if (
|
||||
typeof value.status !== "number" ||
|
||||
typeof value.origin !== "string" ||
|
||||
!Array.isArray(value.headers) ||
|
||||
!value.headers.every(
|
||||
(pair) => Array.isArray(pair) && pair.length === 2 && pair.every((part) => typeof part === "string"),
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid answer through Radius");
|
||||
return value as TunnelHead;
|
||||
}
|
||||
|
||||
/** One HTTP request over its own connection; the body streams with the socket paused while the reader is behind. */
|
||||
function tunnelRequest(
|
||||
socket: WebSocket,
|
||||
path: string,
|
||||
init: TuiRequestInit,
|
||||
head: (data: Buffer) => TunnelHead,
|
||||
): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let body: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let done = false;
|
||||
/** Ends the request once; `settle` tells the reader why, unless the reader itself cancelled the body. */
|
||||
const finish = (error?: Error, settle = true) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
init.signal.removeEventListener("abort", abort);
|
||||
if (!body) reject(error ?? new Error("The server closed the connection without an answer"));
|
||||
else if (settle && error) body.error(error);
|
||||
else if (settle) body.close();
|
||||
socket.terminate();
|
||||
};
|
||||
const abort = () => finish(new Error("Request aborted"));
|
||||
init.signal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("error", () => {});
|
||||
socket.on("unexpected-response", (_request, response) => {
|
||||
response.resume();
|
||||
const status = response.statusCode ?? 502;
|
||||
// Radius answers 403 when the server refused the connection and 401 for an invalid sign-in: like any server's.
|
||||
if (status === 401 || status === 403) resolve(new Response(null, { status }));
|
||||
finish(new Error(`Radius refused the connection (${status})`));
|
||||
});
|
||||
socket.on("open", () => {
|
||||
void sendRequest(socket, path, init).catch((error: unknown) =>
|
||||
finish(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
});
|
||||
socket.on("message", (data: Buffer, binary) => {
|
||||
if (done) return;
|
||||
if (body) {
|
||||
if (!binary) return finish(new Error("Invalid answer through Radius"));
|
||||
body.enqueue(data);
|
||||
if ((body.desiredSize ?? 1) <= 0) socket.pause();
|
||||
return;
|
||||
}
|
||||
let answer: TunnelHead;
|
||||
try {
|
||||
answer = head(data);
|
||||
} catch (error) {
|
||||
return finish(error as Error);
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start: (controller) => {
|
||||
body = controller;
|
||||
},
|
||||
pull: () => {
|
||||
socket.resume();
|
||||
},
|
||||
cancel: () => finish(undefined, false),
|
||||
},
|
||||
{ highWaterMark: 4 },
|
||||
);
|
||||
resolve(
|
||||
new Response(NULL_BODY.has(answer.status) ? null : stream, {
|
||||
status: answer.status,
|
||||
headers: answer.headers,
|
||||
}),
|
||||
);
|
||||
});
|
||||
socket.on("close", (code) =>
|
||||
finish(code === 1000 ? undefined : new Error(`The connection through Radius was lost (${code})`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(socket: WebSocket, path: string, init: TuiRequestInit): Promise<void> {
|
||||
socket.send(JSON.stringify({ type: "http", method: init.method ?? "GET", path, headers: init.headers ?? {} }));
|
||||
const body = typeof init.body === "string" ? Buffer.from(init.body) : init.body;
|
||||
if (body instanceof Uint8Array)
|
||||
for (let offset = 0; offset < body.byteLength; offset += BODY_CHUNK_BYTES)
|
||||
socket.send(body.subarray(offset, offset + BODY_CHUNK_BYTES));
|
||||
else if (body)
|
||||
for await (const chunk of body) {
|
||||
socket.send(chunk);
|
||||
while (socket.bufferedAmount > SEND_HIGH_BYTES && socket.readyState === WebSocket.OPEN)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "end" }));
|
||||
}
|
||||
|
||||
/** The chord WebSocket over its own connection: open once the server answers 101, then the link's frames unchanged. */
|
||||
function tunnelSocket(
|
||||
connecting: Promise<WebSocket>,
|
||||
path: string,
|
||||
events: TuiSocketEvents,
|
||||
head: (data: Buffer) => TunnelHead,
|
||||
): TuiSocket {
|
||||
let socket: WebSocket | undefined;
|
||||
let open = false;
|
||||
let terminated = false;
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
open = false;
|
||||
events.close();
|
||||
};
|
||||
void connecting.then((connected) => {
|
||||
socket = connected;
|
||||
connected.on("error", () => {});
|
||||
connected.on("unexpected-response", (_request, response) => {
|
||||
response.resume();
|
||||
connected.terminate();
|
||||
events.refused(response.statusCode ?? 502);
|
||||
});
|
||||
connected.on("open", () => connected.send(JSON.stringify({ type: "websocket", path, headers: {} })));
|
||||
connected.on("message", (data: Buffer, binary) => {
|
||||
if (open) return events.message(data, binary);
|
||||
let status: number | undefined;
|
||||
try {
|
||||
status = binary ? undefined : head(data).status;
|
||||
} catch {}
|
||||
if (status === 101) {
|
||||
open = true;
|
||||
events.open();
|
||||
return;
|
||||
}
|
||||
connected.terminate();
|
||||
if (status !== undefined) events.refused(status);
|
||||
});
|
||||
connected.on("close", close);
|
||||
if (terminated) connected.terminate();
|
||||
}, close);
|
||||
return {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
send: (data) => socket?.send(data),
|
||||
terminate: () => {
|
||||
terminated = true;
|
||||
socket?.terminate();
|
||||
},
|
||||
};
|
||||
}
|
||||
77
apps/p2/src/hosts/tui-transport.ts
Normal file
77
apps/p2/src/hosts/tui-transport.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// How the launcher reaches its server: HTTP requests and the chord WebSocket, directly with a cookie or through Radius
|
||||
// (tui-radius.ts). Credentials stay inside a transport; callers name only paths on the server.
|
||||
import { WebSocket } from "ws";
|
||||
import { connectionHeaders, type TuiCredentials } from "./tui-config.ts";
|
||||
|
||||
/** Chord link frames and every queued message are bounded by this. */
|
||||
export const MAX_LINK_FRAME = 4 * 1024 * 1024;
|
||||
|
||||
export type TuiRequestInit = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | Uint8Array | AsyncIterable<Uint8Array>;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type TuiSocketEvents = {
|
||||
open(): void;
|
||||
message(data: Buffer, binary: boolean): void;
|
||||
/** The server refused the WebSocket with this HTTP status; `close` follows. */
|
||||
refused(status: number): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type TuiSocket = {
|
||||
readonly open: boolean;
|
||||
send(data: Uint8Array): void;
|
||||
terminate(): void;
|
||||
};
|
||||
|
||||
/** The launcher's link to one server. Redirects are refused. */
|
||||
export type TuiTransport = {
|
||||
request(path: string, init: TuiRequestInit): Promise<Response>;
|
||||
socket(path: string, events: TuiSocketEvents): TuiSocket;
|
||||
};
|
||||
|
||||
/** Direct requests to `url`'s origin with the launcher's exact Origin and its cookie, if any. */
|
||||
export function cookieTransport(url: URL, credentials: TuiCredentials): TuiTransport {
|
||||
const headers = connectionHeaders(url, credentials);
|
||||
return {
|
||||
request: (path, init) =>
|
||||
fetch(new URL(path, url), {
|
||||
method: init.method,
|
||||
headers: { ...headers, ...init.headers },
|
||||
body: init.body as BodyInit | undefined,
|
||||
redirect: "error",
|
||||
signal: init.signal,
|
||||
// Node's fetch needs this for a streamed request body.
|
||||
...(init.body && Symbol.asyncIterator in Object(init.body) ? { duplex: "half" } : {}),
|
||||
} as RequestInit),
|
||||
socket: (path, events) => {
|
||||
const target = new URL(path, url);
|
||||
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(target, {
|
||||
headers,
|
||||
maxPayload: MAX_LINK_FRAME,
|
||||
handshakeTimeout: 10_000,
|
||||
followRedirects: false,
|
||||
});
|
||||
socket.on("open", () => events.open());
|
||||
socket.on("message", (data: Buffer, binary) => events.message(data, binary));
|
||||
socket.on("error", () => {});
|
||||
socket.on("unexpected-response", (_request, response) => {
|
||||
response.resume();
|
||||
socket.terminate();
|
||||
events.refused(response.statusCode ?? 502);
|
||||
});
|
||||
socket.on("close", () => events.close());
|
||||
return {
|
||||
get open() {
|
||||
return socket.readyState === WebSocket.OPEN;
|
||||
},
|
||||
send: (data) => socket.send(data),
|
||||
terminate: () => socket.terminate(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -19,7 +19,9 @@ import {
|
|||
localPreferenceSources,
|
||||
watchLocalPreferences,
|
||||
} from "./tui-local-preferences.ts";
|
||||
import { RADIUS_POLL_MS, type RadiusTransport, radiusClient, radiusServer, radiusTransport } from "./tui-radius.ts";
|
||||
import { requireTuiNode } from "./tui-sandbox.ts";
|
||||
import { cookieTransport } from "./tui-transport.ts";
|
||||
|
||||
const ask = async (question: string): Promise<string> => {
|
||||
const input = createInterface({ input: stdin, output: stdout });
|
||||
|
|
@ -30,13 +32,32 @@ const ask = async (question: string): Promise<string> => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Through Radius (`--radius`): sign in to Radius, find the server, and check that it admits this user as its owner.
|
||||
* The server's answer names its origin, which the launcher shows and builds browser links from.
|
||||
*/
|
||||
async function connectRadius(
|
||||
server: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ url: URL; transport: RadiusTransport }> {
|
||||
const client = await radiusClient((text) => console.log(text), signal);
|
||||
const transport = radiusTransport(client, await radiusServer(client, server));
|
||||
const response = await transport.request("/owner/check", { signal: AbortSignal.timeout(30_000) });
|
||||
const value: unknown = response.ok ? await response.json() : undefined;
|
||||
if (!value || typeof value !== "object" || !("owner" in value) || value.owner !== true || !transport.origin)
|
||||
throw new Error(`This server does not admit you as its owner through Radius (${response.status})`);
|
||||
return { url: new URL(transport.origin), transport };
|
||||
}
|
||||
|
||||
let client: TuiClient | undefined;
|
||||
let stopping = false;
|
||||
const stopped = new AbortController();
|
||||
/** Ends the current run's wait; a signal closes the client without its exit callback. */
|
||||
let finish: () => void = () => {};
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const)
|
||||
process.once(signal, () => {
|
||||
stopping = true;
|
||||
stopped.abort();
|
||||
void (client?.close() ?? Promise.resolve()).finally(() => finish());
|
||||
});
|
||||
try {
|
||||
|
|
@ -46,15 +67,21 @@ try {
|
|||
// The visitor's own pi keybindings/settings/themes and p2 prefs, read here and sent to the worker as data.
|
||||
const sources = localPreferenceSources({ theme: options.theme, noThemes: options.noThemes });
|
||||
const current = loadLocalPreferences(sources);
|
||||
const radius = options.radius && (await connectRadius(options.radius.server, stopped.signal));
|
||||
while (!stopping) {
|
||||
const admission: TuiAdmission = await authenticateTui(options, config, (kind) =>
|
||||
ask(
|
||||
kind === "name"
|
||||
? "Your name (shown to the owner and other guests; not verified): "
|
||||
: "Owner link (npm run owner-link on the host) or share link: ",
|
||||
),
|
||||
);
|
||||
saveConfig(config);
|
||||
const admission: TuiAdmission = radius
|
||||
? { role: "owner" }
|
||||
: await authenticateTui(options, config, (kind) =>
|
||||
ask(
|
||||
kind === "name"
|
||||
? "Your name (shown to the owner and other guests; not verified): "
|
||||
: "Owner link (npm run owner-link on the host) or share link: ",
|
||||
),
|
||||
);
|
||||
if (!radius) saveConfig(config);
|
||||
const url = radius ? radius.url : options.url;
|
||||
const credentials = { cookie: admission.cookie };
|
||||
const transport = radius ? radius.transport : cookieTransport(url, credentials);
|
||||
const local = launcherPreferences(sources);
|
||||
const failures: string[] = [];
|
||||
let ended = false;
|
||||
|
|
@ -63,13 +90,15 @@ try {
|
|||
finish = resolve;
|
||||
void startTuiClient({
|
||||
terminal: new ProcessTerminal(),
|
||||
url: options.url,
|
||||
credentials: { cookie: admission.cookie },
|
||||
url,
|
||||
transport,
|
||||
...(radius ? { pollMs: RADIUS_POLL_MS } : {}),
|
||||
mouse: local.mouse,
|
||||
role: admission.role,
|
||||
actions: launcherActions({
|
||||
url: options.url,
|
||||
credentials: { cookie: admission.cookie },
|
||||
url,
|
||||
credentials,
|
||||
transport,
|
||||
role: admission.role,
|
||||
downloads: local.downloads,
|
||||
editor: editorArgv(local.editor),
|
||||
|
|
@ -97,6 +126,10 @@ try {
|
|||
client = undefined;
|
||||
for (const message of failures) console.error(message);
|
||||
if (!ended || stopping) break;
|
||||
if (radius) {
|
||||
console.error("The server no longer admits you through Radius.");
|
||||
break;
|
||||
}
|
||||
// Launcher-drawn, on the restored terminal: the same words as the web page, then a way back in.
|
||||
console.error(endedNotice(admission.role));
|
||||
const next = await resumeAfterEnded(admission.role, options.url, config, ask);
|
||||
|
|
|
|||
274
apps/p2/test/p2/radius-fixture.ts
Normal file
274
apps/p2/test/p2/radius-fixture.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// A local stand-in for Radius and a p2 server connected to it as a brain, for the Radius tests. Never contacts Radius.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { TestContext } from "node:test";
|
||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { ForwardsNative } from "../../plugins/ports/contract.ts";
|
||||
import { writeRadiusConfig } from "../../plugins/radius/brain.ts";
|
||||
import { RadiusConnections } from "../../plugins/radius/contract.ts";
|
||||
import { startServer } from "../../src/hosts/server.ts";
|
||||
import { defineFacet } from "../../src/runtime/chord.ts";
|
||||
import { isOwner } from "../../src/runtime/principals.ts";
|
||||
import { Routes } from "../../src/runtime/server.ts";
|
||||
import { poll } from "./helpers.ts";
|
||||
|
||||
export const OWNER = { kind: "user", id: "usr_1", organization_id: "org_1" };
|
||||
const CLIENT_PROTOCOL = "pi-server.client.v2";
|
||||
export const PUBLIC_ORIGIN = "https://0123456789abcdef0123456789abcdef.rdtun.com";
|
||||
type Frame = { id: string; type: number; payload: Buffer };
|
||||
|
||||
/**
|
||||
* Local stand-in for the Radius context endpoint and relay v4, or v5 with `channels`: then the brain's channel sockets
|
||||
* (`/v1/servers/<id>/channels/<connection>`) are accepted unless `rejectChannels`, and `channel(id)` resolves with the
|
||||
* relay's end of one. Pi clients (`pi-server.client.v2`) get a `ws` channel without a request, as the owner for
|
||||
* `Bearer fixture-token` and as another user otherwise; their upgrade fails with 403 when the brain refuses the channel.
|
||||
* Never contacts Radius.
|
||||
*/
|
||||
export async function fakeRadius(options: { channels?: boolean; rejectChannels?: boolean } = {}) {
|
||||
const version = options.channels ? "pi-server.host.v5" : "pi-server.host.v4";
|
||||
const sockets = new WebSocketServer({
|
||||
noServer: true,
|
||||
handleProtocols: (protocols) =>
|
||||
[version, "pi-server.channel.v1", CLIENT_PROTOCOL].find((protocol) => protocols.has(protocol)) ?? false,
|
||||
});
|
||||
const channels = new Map<string, WebSocket>();
|
||||
const channelWaiters = new Map<string, (socket: WebSocket) => void>();
|
||||
const closeWaiters = new Map<string, () => void>();
|
||||
/** A Pi client: a channel the brain opens its own socket for, then piped to the client as Radius does. */
|
||||
const connectClient = async (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
const id = randomUUID();
|
||||
const principal = request.headers.authorization === "Bearer fixture-token" ? OWNER : { ...OWNER, id: "usr_2" };
|
||||
const refused = new Promise<undefined>((resolve) => closeWaiters.set(id, () => resolve(undefined)));
|
||||
host?.send(JSON.stringify({ version: 2, type: "connection_open", connection_id: id, kind: "ws", principal }));
|
||||
const channel = await Promise.race([
|
||||
channels.get(id) ?? new Promise<WebSocket>((resolve) => channelWaiters.set(id, resolve)),
|
||||
refused,
|
||||
]);
|
||||
if (!channel) {
|
||||
socket.end("HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (client) => {
|
||||
const sendable = (code: number) => (code === 1005 ? 1000 : code === 1006 ? 1011 : code);
|
||||
client.on("message", (data: Buffer, binary) => channel.send(data, { binary }));
|
||||
channel.on("message", (data: Buffer, binary) => client.send(data, { binary }));
|
||||
client.on("close", (code) => channel.close(sendable(code)));
|
||||
channel.on("close", (code) => client.close(sendable(code)));
|
||||
});
|
||||
};
|
||||
const shares: string[][] = [];
|
||||
const server = createServer((request, response) => {
|
||||
const shared = /^\/v1\/servers\/[^/]+\/shares$/.test(request.url ?? "") && request.method === "PUT";
|
||||
if (shared) {
|
||||
let body = "";
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString("utf8");
|
||||
});
|
||||
request.on("end", () => {
|
||||
shares.push((JSON.parse(body) as { emails: string[] }).emails);
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.url !== "/v1/context") {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
actor: { principal_id: OWNER.id, principal_kind: OWNER.kind },
|
||||
organization: { id: OWNER.organization_id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
let host: WebSocket | undefined;
|
||||
const controls: Array<Record<string, unknown>> = [];
|
||||
const frames: Frame[] = [];
|
||||
const hostReady = new Promise<WebSocket>((resolve) =>
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (String(request.headers["sec-websocket-protocol"]).includes(CLIENT_PROTOCOL)) {
|
||||
void connectClient(request, socket, head);
|
||||
return;
|
||||
}
|
||||
const channelId = /^\/v1\/servers\/[^/]+\/channels\/([^/]+)$/.exec(request.url ?? "")?.[1];
|
||||
if (channelId !== undefined) {
|
||||
assert.equal(request.headers.authorization, "Bearer fixture-token");
|
||||
if (options.rejectChannels) {
|
||||
socket.end("HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (connection) => {
|
||||
channels.set(channelId, connection);
|
||||
channelWaiters.get(channelId)?.(connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (connection) => {
|
||||
host = connection;
|
||||
connection.on("message", (data: Buffer, binary) => {
|
||||
if (!binary) {
|
||||
const control = JSON.parse(data.toString("utf8")) as Record<string, unknown>;
|
||||
controls.push(control);
|
||||
if (control.type === "connection_close") closeWaiters.get(String(control.connection_id))?.();
|
||||
} else {
|
||||
const hex = data.subarray(2, 18).toString("hex");
|
||||
frames.push({
|
||||
id: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
|
||||
type: data[1] ?? 0,
|
||||
payload: data.subarray(18),
|
||||
});
|
||||
}
|
||||
});
|
||||
resolve(connection);
|
||||
});
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert(address && typeof address !== "string");
|
||||
const sendData = (id: string, payload: Buffer, type = 1) => {
|
||||
const frame = Buffer.alloc(18 + payload.length);
|
||||
frame[0] = 2;
|
||||
frame[1] = type;
|
||||
Buffer.from(id.replaceAll("-", ""), "hex").copy(frame, 2);
|
||||
payload.copy(frame, 18);
|
||||
host?.send(frame);
|
||||
};
|
||||
return {
|
||||
gateway: `http://127.0.0.1:${address.port}`,
|
||||
/** Every set of emails the brain reported, in order. */
|
||||
shares,
|
||||
hostReady,
|
||||
controls,
|
||||
frames,
|
||||
sendData,
|
||||
send: (message: object) => host?.send(JSON.stringify(message)),
|
||||
channel: (id: string) => channels.get(id) ?? new Promise<WebSocket>((resolve) => channelWaiters.set(id, resolve)),
|
||||
reading: (on: boolean) => {
|
||||
if (on) host?.resume();
|
||||
else host?.pause();
|
||||
},
|
||||
response: async (id: string) => {
|
||||
await poll(() => controls.some((message) => message.type === "response" && message.connection_id === id));
|
||||
return controls.find((message) => message.type === "response" && message.connection_id === id)!;
|
||||
},
|
||||
body: async (id: string) => {
|
||||
await poll(() => frames.some((frame) => frame.id === id && frame.type === 3));
|
||||
return Buffer.concat(frames.filter((frame) => frame.id === id).map((frame) => frame.payload));
|
||||
},
|
||||
close: async () => {
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function radiusFixture(
|
||||
t: TestContext,
|
||||
content = Buffer.from("body"),
|
||||
relay: Parameters<typeof fakeRadius>[0] = {},
|
||||
) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "p2-radius-"));
|
||||
const radius = await fakeRadius(relay);
|
||||
t.after(async () => {
|
||||
await radius.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
await writeRadiusConfig(directory, {
|
||||
gateway: radius.gateway,
|
||||
serverId: randomUUID(),
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fixture-token",
|
||||
refresh: "fixture-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
// A probe plugin's routes: who the server sees, a large body, an echo.
|
||||
const probe = defineFacet((_host, self) =>
|
||||
self.contribute(Routes, {
|
||||
"GET /probe": (_request, response, access) => {
|
||||
response.writeHead(isOwner(access.principal) ? 200 : 403);
|
||||
response.end(JSON.stringify(access));
|
||||
},
|
||||
"GET /body": (_request, response) => {
|
||||
response.writeHead(200);
|
||||
response.end(content);
|
||||
},
|
||||
"POST /echo": async (request, response, access) => {
|
||||
if (!isOwner(access.principal)) {
|
||||
response.writeHead(403);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
response.writeHead(200);
|
||||
response.end(Buffer.concat(chunks));
|
||||
},
|
||||
}),
|
||||
);
|
||||
const server = await startServer({
|
||||
dataDir: directory,
|
||||
port: 0,
|
||||
fake: true,
|
||||
web: false,
|
||||
facets: [{ name: "radius-probe", facet: probe }],
|
||||
});
|
||||
t.after(() => server.close());
|
||||
await radius.hostReady;
|
||||
// The natives the radius and ports plugins contribute once open (their objects live from the load).
|
||||
const natives = await Promise.all([
|
||||
server.host.registry(RadiusConnections, BACKGROUND_CONTEXT),
|
||||
server.host.registry(ForwardsNative, BACKGROUND_CONTEXT),
|
||||
]);
|
||||
const native = { relay: natives[0].value!.connection.relay, forwards: natives[1].value!.forwards };
|
||||
const open = (
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
kind?: "http" | "ws" | "stream";
|
||||
forward?: string;
|
||||
anonymous?: boolean;
|
||||
principal?: typeof OWNER & { email?: string };
|
||||
window?: number;
|
||||
headers?: [string, string][];
|
||||
} = {},
|
||||
) => {
|
||||
const id = randomUUID();
|
||||
const origin = options.forward
|
||||
? `https://fwd--0123456789abcdef0123456789abcdef--${options.forward}.rdtun.com`
|
||||
: PUBLIC_ORIGIN;
|
||||
radius.send({
|
||||
version: 2,
|
||||
type: "connection_open",
|
||||
connection_id: id,
|
||||
kind: options.kind ?? "http",
|
||||
...(options.anonymous ? {} : { principal: options.principal ?? OWNER }),
|
||||
public_origin: origin,
|
||||
request: {
|
||||
method: options.method ?? "GET",
|
||||
path,
|
||||
headers: options.headers ?? [
|
||||
["origin", origin],
|
||||
["x-radius-shell-origin", "https://radius.earendil.com"],
|
||||
],
|
||||
},
|
||||
...(options.forward ? { forward: options.forward } : {}),
|
||||
window: options.window ?? 64 * 1024,
|
||||
});
|
||||
if ((!options.kind || options.kind === "http") && options.method !== "POST")
|
||||
radius.sendData(id, Buffer.alloc(0), 3);
|
||||
return id;
|
||||
};
|
||||
return { directory, server, radius, open, ...native };
|
||||
}
|
||||
|
|
@ -10,9 +10,7 @@ import { BACKGROUND_CONTEXT, statusOf, whenLive } from "@p2/app/v1";
|
|||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { Devices } from "../../plugins/devices/contract.ts";
|
||||
import { ProviderLogin } from "../../plugins/login/contract.ts";
|
||||
import { ForwardsNative } from "../../plugins/ports/contract.ts";
|
||||
import { RadiusBrain, type RadiusStatus, readRadiusConfig, writeRadiusConfig } from "../../plugins/radius/brain.ts";
|
||||
import { RadiusConnections } from "../../plugins/radius/contract.ts";
|
||||
import {
|
||||
encodeRelayUser,
|
||||
RELAY_ORIGIN_HEADER,
|
||||
|
|
@ -24,230 +22,10 @@ import {
|
|||
import { radiusStatusText } from "../../plugins/radius/server.ts";
|
||||
import { Sessions } from "../../plugins/sessions/contract.ts";
|
||||
import { Sharing } from "../../plugins/sharing/contract.ts";
|
||||
import { startServer } from "../../src/hosts/server.ts";
|
||||
import { radiusTransport } from "../../src/hosts/tui-radius.ts";
|
||||
import { Access } from "../../src/runtime/access.ts";
|
||||
import { defineFacet } from "../../src/runtime/chord.ts";
|
||||
import { isOwner } from "../../src/runtime/principals.ts";
|
||||
import { Routes } from "../../src/runtime/server.ts";
|
||||
import { clientHost, expectChord, holdIn, http, poll, until } from "./helpers.ts";
|
||||
|
||||
const OWNER = { kind: "user", id: "usr_1", organization_id: "org_1" };
|
||||
const PUBLIC_ORIGIN = "https://0123456789abcdef0123456789abcdef.rdtun.com";
|
||||
type Frame = { id: string; type: number; payload: Buffer };
|
||||
|
||||
/**
|
||||
* Local stand-in for the Radius context endpoint and relay v4, or v5 with `channels`: then the brain's channel sockets
|
||||
* (`/v1/servers/<id>/channels/<connection>`) are accepted unless `rejectChannels`, and `channel(id)` resolves with the
|
||||
* relay's end of one. Never contacts Radius.
|
||||
*/
|
||||
async function fakeRadius(options: { channels?: boolean; rejectChannels?: boolean } = {}) {
|
||||
const version = options.channels ? "pi-server.host.v5" : "pi-server.host.v4";
|
||||
const sockets = new WebSocketServer({
|
||||
noServer: true,
|
||||
handleProtocols: (protocols) =>
|
||||
protocols.has(version) ? version : protocols.has("pi-server.channel.v1") ? "pi-server.channel.v1" : false,
|
||||
});
|
||||
const channels = new Map<string, WebSocket>();
|
||||
const channelWaiters = new Map<string, (socket: WebSocket) => void>();
|
||||
const shares: string[][] = [];
|
||||
const server = createServer((request, response) => {
|
||||
const shared = /^\/v1\/servers\/[^/]+\/shares$/.test(request.url ?? "") && request.method === "PUT";
|
||||
if (shared) {
|
||||
let body = "";
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString("utf8");
|
||||
});
|
||||
request.on("end", () => {
|
||||
shares.push((JSON.parse(body) as { emails: string[] }).emails);
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.url !== "/v1/context") {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
actor: { principal_id: OWNER.id, principal_kind: OWNER.kind },
|
||||
organization: { id: OWNER.organization_id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
let host: WebSocket | undefined;
|
||||
const controls: Array<Record<string, unknown>> = [];
|
||||
const frames: Frame[] = [];
|
||||
const hostReady = new Promise<WebSocket>((resolve) =>
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const channelId = /^\/v1\/servers\/[^/]+\/channels\/([^/]+)$/.exec(request.url ?? "")?.[1];
|
||||
if (channelId !== undefined) {
|
||||
assert.equal(request.headers.authorization, "Bearer fixture-token");
|
||||
if (options.rejectChannels) {
|
||||
socket.end("HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (connection) => {
|
||||
channels.set(channelId, connection);
|
||||
channelWaiters.get(channelId)?.(connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (connection) => {
|
||||
host = connection;
|
||||
connection.on("message", (data: Buffer, binary) => {
|
||||
if (!binary) controls.push(JSON.parse(data.toString("utf8")) as Record<string, unknown>);
|
||||
else {
|
||||
const hex = data.subarray(2, 18).toString("hex");
|
||||
frames.push({
|
||||
id: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
|
||||
type: data[1] ?? 0,
|
||||
payload: data.subarray(18),
|
||||
});
|
||||
}
|
||||
});
|
||||
resolve(connection);
|
||||
});
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert(address && typeof address !== "string");
|
||||
const sendData = (id: string, payload: Buffer, type = 1) => {
|
||||
const frame = Buffer.alloc(18 + payload.length);
|
||||
frame[0] = 2;
|
||||
frame[1] = type;
|
||||
Buffer.from(id.replaceAll("-", ""), "hex").copy(frame, 2);
|
||||
payload.copy(frame, 18);
|
||||
host?.send(frame);
|
||||
};
|
||||
return {
|
||||
gateway: `http://127.0.0.1:${address.port}`,
|
||||
/** Every set of emails the brain reported, in order. */
|
||||
shares,
|
||||
hostReady,
|
||||
controls,
|
||||
frames,
|
||||
sendData,
|
||||
send: (message: object) => host?.send(JSON.stringify(message)),
|
||||
channel: (id: string) => channels.get(id) ?? new Promise<WebSocket>((resolve) => channelWaiters.set(id, resolve)),
|
||||
reading: (on: boolean) => {
|
||||
if (on) host?.resume();
|
||||
else host?.pause();
|
||||
},
|
||||
response: async (id: string) => {
|
||||
await poll(() => controls.some((message) => message.type === "response" && message.connection_id === id));
|
||||
return controls.find((message) => message.type === "response" && message.connection_id === id)!;
|
||||
},
|
||||
body: async (id: string) => {
|
||||
await poll(() => frames.some((frame) => frame.id === id && frame.type === 3));
|
||||
return Buffer.concat(frames.filter((frame) => frame.id === id).map((frame) => frame.payload));
|
||||
},
|
||||
close: async () => {
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t: TestContext, content = Buffer.from("body"), relay: Parameters<typeof fakeRadius>[0] = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "p2-radius-"));
|
||||
const radius = await fakeRadius(relay);
|
||||
t.after(async () => {
|
||||
await radius.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
await writeRadiusConfig(directory, {
|
||||
gateway: radius.gateway,
|
||||
serverId: randomUUID(),
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fixture-token",
|
||||
refresh: "fixture-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
// A probe plugin's routes: who the server sees, a large body, an echo.
|
||||
const probe = defineFacet((_host, self) =>
|
||||
self.contribute(Routes, {
|
||||
"GET /probe": (_request, response, access) => {
|
||||
response.writeHead(isOwner(access.principal) ? 200 : 403);
|
||||
response.end(JSON.stringify(access));
|
||||
},
|
||||
"GET /body": (_request, response) => {
|
||||
response.writeHead(200);
|
||||
response.end(content);
|
||||
},
|
||||
"POST /echo": async (request, response, access) => {
|
||||
if (!isOwner(access.principal)) {
|
||||
response.writeHead(403);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
response.writeHead(200);
|
||||
response.end(Buffer.concat(chunks));
|
||||
},
|
||||
}),
|
||||
);
|
||||
const server = await startServer({
|
||||
dataDir: directory,
|
||||
port: 0,
|
||||
fake: true,
|
||||
web: false,
|
||||
facets: [{ name: "radius-probe", facet: probe }],
|
||||
});
|
||||
t.after(() => server.close());
|
||||
await radius.hostReady;
|
||||
// The natives the radius and ports plugins contribute once open (their objects live from the load).
|
||||
const natives = await Promise.all([
|
||||
server.host.registry(RadiusConnections, BACKGROUND_CONTEXT),
|
||||
server.host.registry(ForwardsNative, BACKGROUND_CONTEXT),
|
||||
]);
|
||||
const native = { relay: natives[0].value!.connection.relay, forwards: natives[1].value!.forwards };
|
||||
const open = (
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
kind?: "http" | "ws" | "stream";
|
||||
forward?: string;
|
||||
anonymous?: boolean;
|
||||
principal?: typeof OWNER & { email?: string };
|
||||
window?: number;
|
||||
headers?: [string, string][];
|
||||
} = {},
|
||||
) => {
|
||||
const id = randomUUID();
|
||||
const origin = options.forward
|
||||
? `https://fwd--0123456789abcdef0123456789abcdef--${options.forward}.rdtun.com`
|
||||
: PUBLIC_ORIGIN;
|
||||
radius.send({
|
||||
version: 2,
|
||||
type: "connection_open",
|
||||
connection_id: id,
|
||||
kind: options.kind ?? "http",
|
||||
...(options.anonymous ? {} : { principal: options.principal ?? OWNER }),
|
||||
public_origin: origin,
|
||||
request: {
|
||||
method: options.method ?? "GET",
|
||||
path,
|
||||
headers: options.headers ?? [
|
||||
["origin", origin],
|
||||
["x-radius-shell-origin", "https://radius.earendil.com"],
|
||||
],
|
||||
},
|
||||
...(options.forward ? { forward: options.forward } : {}),
|
||||
window: options.window ?? 64 * 1024,
|
||||
});
|
||||
if ((!options.kind || options.kind === "http") && options.method !== "POST")
|
||||
radius.sendData(id, Buffer.alloc(0), 3);
|
||||
return id;
|
||||
};
|
||||
return { directory, server, radius, open, ...native };
|
||||
}
|
||||
import { radiusFixture as fixture, OWNER, PUBLIC_ORIGIN } from "./radius-fixture.ts";
|
||||
|
||||
test(
|
||||
"Radius plugin admits its authenticated remote owner including provider login, without local cookies",
|
||||
|
|
@ -605,6 +383,79 @@ test(
|
|||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"Radius client channels: the owner's TUI launcher reaches the server, one Radius connection per request",
|
||||
{ timeout: 30_000 },
|
||||
async (t) => {
|
||||
const content = randomBytes(3 * 1024 * 1024);
|
||||
const f = await fixture(t, content, { channels: true });
|
||||
const transport = radiusTransport(
|
||||
{ gateway: f.radius.gateway, access: async () => "fixture-token" },
|
||||
randomUUID(),
|
||||
);
|
||||
const signal = AbortSignal.timeout(20_000);
|
||||
const probe = await transport.request("/probe", { signal });
|
||||
assert.equal(probe.status, 200);
|
||||
// The owner by Radius alone, framed by no shell; until a browser opened the brain, its origin is a placeholder.
|
||||
const access = (await probe.json()) as { principal: unknown; origin: unknown };
|
||||
assert.deepEqual(access.principal, { kind: "owner", access: "relay" });
|
||||
assert.deepEqual(access.origin, { origin: "https://radius.invalid", remote: true, port: f.server.port });
|
||||
assert.equal(transport.origin, "https://radius.invalid");
|
||||
// Bodies larger than one message, both ways; a request body may stream.
|
||||
assert(Buffer.from(await (await transport.request("/body", { signal })).arrayBuffer()).equals(content));
|
||||
const upload = randomBytes(1024 * 1024);
|
||||
const echo = await transport.request("/echo", {
|
||||
method: "POST",
|
||||
body: (async function* () {
|
||||
yield upload.subarray(0, 1000);
|
||||
yield upload.subarray(1000);
|
||||
})(),
|
||||
signal,
|
||||
});
|
||||
assert(Buffer.from(await echo.arrayBuffer()).equals(upload));
|
||||
// Only the device itself mints owner links.
|
||||
assert.equal((await transport.request("/owner-token", { signal })).status, 403);
|
||||
// Once the owner opened the brain in a browser, clients get its Radius origin.
|
||||
assert.equal((await f.radius.response(f.open("/probe"))).status, 200);
|
||||
await (await transport.request("/probe", { signal })).arrayBuffer();
|
||||
assert.equal(transport.origin, PUBLIC_ORIGIN);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"Radius client channels admit only the owner, and only through channel sockets",
|
||||
{ timeout: 30_000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t, undefined, { channels: true });
|
||||
const stranger = radiusTransport(
|
||||
{ gateway: f.radius.gateway, access: async () => "stranger-token" },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal((await stranger.request("/probe", { signal: AbortSignal.timeout(10_000) })).status, 403);
|
||||
const refused = await new Promise<number>((resolve) =>
|
||||
stranger.socket("/chord", { open: () => {}, message: () => {}, refused: resolve, close: () => {} }),
|
||||
);
|
||||
assert.equal(refused, 403);
|
||||
// A request that is not one is refused on the channel.
|
||||
const id = randomUUID();
|
||||
f.radius.send({ version: 2, type: "connection_open", connection_id: id, kind: "ws", principal: OWNER });
|
||||
const channel = await f.radius.channel(id);
|
||||
const closed = new Promise<number>((resolve) => channel.once("close", resolve));
|
||||
channel.send(JSON.stringify({ type: "http", method: "GET", path: "//elsewhere.example/", headers: {} }));
|
||||
assert.equal(await closed, 1008);
|
||||
// Relays before channel sockets do not carry them.
|
||||
const old = await fixture(t);
|
||||
const oldId = randomUUID();
|
||||
old.radius.send({ version: 2, type: "connection_open", connection_id: oldId, kind: "ws", principal: OWNER });
|
||||
await poll(() =>
|
||||
old.radius.controls.some(
|
||||
(message) =>
|
||||
message.type === "connection_close" && message.connection_id === oldId && message.code === 1008,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A relay whose network can fail: `silent` drops everything in both directions without closing (as a lost network
|
||||
* does), `stallHandshakes` leaves upgrade requests unanswered, `drop()` closes the host socket. `occupied` answers
|
||||
|
|
|
|||
36
apps/p2/test/p2/tui-radius.test.ts
Normal file
36
apps/p2/test/p2/tui-radius.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// The TUI launcher through Radius (src/hosts/tui-radius.ts). Its own file: the launcher's memory watchdog measures the
|
||||
// whole process, which in radius.test.ts also holds every earlier test's servers.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { VirtualTerminal } from "../../../../packages/tui/test/virtual-terminal.ts";
|
||||
import { startTuiClient } from "../../src/hosts/tui-client.ts";
|
||||
import { radiusTransport } from "../../src/hosts/tui-radius.ts";
|
||||
import { poll } from "./helpers.ts";
|
||||
import { radiusFixture as fixture } from "./radius-fixture.ts";
|
||||
|
||||
test(
|
||||
"the TUI through Radius loads the server's bundle and opens its chord link",
|
||||
{ skip: Number(process.versions.node.split(".")[0]) < 26, timeout: 60_000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t, undefined, { channels: true });
|
||||
const transport = radiusTransport(
|
||||
{ gateway: f.radius.gateway, access: async () => "fixture-token" },
|
||||
randomUUID(),
|
||||
);
|
||||
const terminal = new VirtualTerminal(100, 30);
|
||||
const failures: string[] = [];
|
||||
const client = await startTuiClient({
|
||||
terminal,
|
||||
url: new URL("https://radius.invalid"),
|
||||
transport,
|
||||
colorQueryMs: 10,
|
||||
onFailure: (message) => failures.push(message),
|
||||
});
|
||||
t.after(() => client.close());
|
||||
const screen = async () => (await terminal.flushAndGetViewport()).join("\n");
|
||||
await poll(async () => (await screen()).includes("New session"), 20_000, "sessions over the chord link");
|
||||
assert.equal(client.connected, true);
|
||||
assert.deepEqual(failures, []);
|
||||
},
|
||||
);
|
||||
|
|
@ -48,6 +48,13 @@ test("TUI auth options, private config and owner-claim cookie round trip", async
|
|||
);
|
||||
assert.throws(() => parseTuiArgs(["http://remote.test"], {}), /HTTPS/);
|
||||
assert.throws(() => parseTuiArgs(["https://fixture:secret@example.test"], {}), /without credentials/);
|
||||
// Through Radius: optionally the server's ID; Radius admits the owner, so no URL or link, and no owner link from env.
|
||||
const radiusServer = "0b5a3e8e-6f1d-4c55-9d3a-2a7b1c9e4f10";
|
||||
assert.deepEqual(parseTuiArgs(["--radius"], {}).radius, {});
|
||||
assert.deepEqual(parseTuiArgs(["--radius", radiusServer, "--theme", "dark"], {}).radius, { server: radiusServer });
|
||||
assert.equal(parseTuiArgs(["--radius"], { P2_TUI_OWNER_LINK: link }).ownerLink, undefined);
|
||||
assert.throws(() => parseTuiArgs(["--radius", "https://example.test"], {}), /no URL/);
|
||||
assert.throws(() => parseTuiArgs(["https://example.test", "--radius", "--owner-link", link], {}), /no URL/);
|
||||
saveConfig({ connections: { "https://example.test": { cookie: "owner=fixture" } } }, directory);
|
||||
assert.equal(statSync(join(directory, "config.json")).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(directory).mode & 0o777, 0o700);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue