b91b29d516
- Add UI resources and mappings for groups, windows, sessions, storage, performance, extension, and more tab/DOM/page actions. - Remove the synthetic Gateway Health operation so exposed operations match real browser-cli commands. - Document the expanded command/policy matrix and cover the new request mappings with tests. - Cap the node SVG icon at 60x60, bump the n8n package to 0.3.0, and advertise client protocol version 0.16.0.
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
/**
|
|
* Socket client for a raw `browser-cli serve` endpoint.
|
|
*
|
|
* One command per connection: connect, read the challenge frame, send the
|
|
* authenticated (and PQ-encrypted) request frame, read the response frame,
|
|
* close. The crypto and payload shapes live in `protocol.ts`.
|
|
*/
|
|
import { connect as netConnect, isIP } from 'node:net';
|
|
import { connect as tlsConnect } from 'node:tls';
|
|
import type { Socket } from 'node:net';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
|
|
|
|
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
|
|
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
|
* client implements. */
|
|
const CLIENT_VERSION = '0.16.0';
|
|
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
|
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
|
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
|
const MAX_MSG_BYTES = 32 * 1024 * 1024;
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
|
export interface ServeConnectOptions {
|
|
host: string;
|
|
port: number;
|
|
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
|
|
tls?: boolean;
|
|
/** Reject self-signed / invalid certs when `tls` is on. */
|
|
rejectUnauthorized?: boolean;
|
|
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
|
|
privateKeyPem?: string | null;
|
|
/** Optional `_route` target for a multi-browser serve. */
|
|
route?: string | null;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
|
|
class FrameReader {
|
|
private buffer = Buffer.alloc(0);
|
|
private waiters: Array<(frame: Buffer) => void> = [];
|
|
private failed: Error | null = null;
|
|
|
|
constructor(socket: Socket) {
|
|
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
|
}
|
|
|
|
private onData(chunk: Buffer): void {
|
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
while (this.buffer.length >= 4) {
|
|
const length = this.buffer.readUInt32LE(0);
|
|
if (length > MAX_MSG_BYTES) {
|
|
this.fail(new Error(`serve frame too large (${length} bytes)`));
|
|
return;
|
|
}
|
|
if (this.buffer.length < 4 + length) break;
|
|
const payload = this.buffer.subarray(4, 4 + length);
|
|
this.buffer = this.buffer.subarray(4 + length);
|
|
const waiter = this.waiters.shift();
|
|
if (waiter) waiter(Buffer.from(payload));
|
|
}
|
|
}
|
|
|
|
fail(error: Error): void {
|
|
this.failed = error;
|
|
}
|
|
|
|
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
|
|
next(): Promise<Buffer> {
|
|
if (this.failed) return Promise.reject(this.failed);
|
|
return new Promise((resolve) => this.waiters.push(resolve));
|
|
}
|
|
}
|
|
|
|
function openSocket(opts: ServeConnectOptions): Socket {
|
|
if (opts.tls) {
|
|
return tlsConnect({
|
|
host: opts.host,
|
|
port: opts.port,
|
|
rejectUnauthorized: opts.rejectUnauthorized !== false,
|
|
// SNI must be a hostname; Node rejects an IP literal as servername.
|
|
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
|
});
|
|
}
|
|
return netConnect({ host: opts.host, port: opts.port });
|
|
}
|
|
|
|
/**
|
|
* Run a single browser-cli command against a `serve` endpoint and return the
|
|
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
|
|
*/
|
|
export async function sendServeCommand(
|
|
opts: ServeConnectOptions,
|
|
command: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<any> {
|
|
const socket = openSocket(opts);
|
|
socket.setNoDelay(true);
|
|
const reader = new FrameReader(socket);
|
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
|
|
return new Promise<any>((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
fn();
|
|
};
|
|
|
|
const timer = setTimeout(
|
|
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
|
|
timeoutMs,
|
|
);
|
|
|
|
socket.on('error', (err) => finish(() => reject(err)));
|
|
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
|
|
|
|
const run = async () => {
|
|
const challengeRaw = await reader.next();
|
|
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
|
|
|
const baseMsg: Record<string, unknown> = {
|
|
id: randomUUID(),
|
|
command,
|
|
args: args ?? {},
|
|
user_agent: USER_AGENT,
|
|
accept_encoding: ACCEPT_ENCODING,
|
|
};
|
|
if (opts.route) baseMsg._route = opts.route;
|
|
|
|
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
|
|
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
|
|
|
|
const responseRaw = await reader.next();
|
|
const response = decodeResponse(responseRaw, pqSecret);
|
|
finish(() => resolve(response));
|
|
};
|
|
|
|
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
|
|
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
|
|
run().catch((err) => finish(() => reject(err)));
|
|
});
|
|
});
|
|
}
|