From 739ce5e5a70a8d295726ebc8f5ecb8560e91b642 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 13:47:56 +0800 Subject: [PATCH 1/5] feat(proxy): add owned opaque tunnels with fenced route refresh --- scripts/test-layout/layout.json | 7 + .../opaque-proxy-lifecycle/connect-request.ts | 36 +++ .../opaque-proxy-lifecycle/managed-ingress.ts | 174 +++++++++++ .../opaque-proxy-lifecycle/managed-session.ts | 40 +++ .../opaque-proxy-lifecycle/owner-manager.ts | 112 +++++++ src/lib/opaque-proxy-tunnel.ts | 280 ++++++++++++++++++ structure/transports/inventory.md | 71 +++++ tests/fixtures/test-layout-expected.json | 7 + tests/helpers/opaque-proxy-tunnel-protocol.ts | 209 +++++++++++++ tests/helpers/opaque-proxy-tunnel.ts | 153 ++++++++++ .../lib/opaque-proxy-lifecycle-parser.test.ts | 31 ++ .../opaque-proxy-lifecycle-transport.test.ts | 44 +++ tests/lib/opaque-proxy-lifecycle.test.ts | 190 ++++++++++++ tests/lib/opaque-proxy-route-refresh.test.ts | 113 +++++++ .../lib/opaque-proxy-session-protocol.test.ts | 155 ++++++++++ .../lib/opaque-proxy-tunnel-protocol.test.ts | 223 ++++++++++++++ tests/lib/opaque-proxy-tunnel.test.ts | 186 ++++++++++++ 17 files changed, 2031 insertions(+) create mode 100644 src/lib/opaque-proxy-lifecycle/connect-request.ts create mode 100644 src/lib/opaque-proxy-lifecycle/managed-ingress.ts create mode 100644 src/lib/opaque-proxy-lifecycle/managed-session.ts create mode 100644 src/lib/opaque-proxy-lifecycle/owner-manager.ts create mode 100644 src/lib/opaque-proxy-tunnel.ts create mode 100644 tests/helpers/opaque-proxy-tunnel-protocol.ts create mode 100644 tests/helpers/opaque-proxy-tunnel.ts create mode 100644 tests/lib/opaque-proxy-lifecycle-parser.test.ts create mode 100644 tests/lib/opaque-proxy-lifecycle-transport.test.ts create mode 100644 tests/lib/opaque-proxy-lifecycle.test.ts create mode 100644 tests/lib/opaque-proxy-route-refresh.test.ts create mode 100644 tests/lib/opaque-proxy-session-protocol.test.ts create mode 100644 tests/lib/opaque-proxy-tunnel-protocol.test.ts create mode 100644 tests/lib/opaque-proxy-tunnel.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..4c0b8e7d67d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -835,6 +835,13 @@ "deepseek-responses-item-id-repair.test.ts": "providers", "management-desktop-compatibility-routes.test.ts": "server", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "opaque-proxy-tunnel.test.ts": "lib", + "opaque-proxy-tunnel-protocol.test.ts": "lib", + "opaque-proxy-lifecycle.test.ts": "lib", + "opaque-proxy-lifecycle-transport.test.ts": "lib", + "opaque-proxy-lifecycle-parser.test.ts": "lib", + "opaque-proxy-session-protocol.test.ts": "lib", + "opaque-proxy-route-refresh.test.ts": "lib", "destination-policy-resolved.test.ts": "routing", "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", diff --git a/src/lib/opaque-proxy-lifecycle/connect-request.ts b/src/lib/opaque-proxy-lifecycle/connect-request.ts new file mode 100644 index 00000000000..ceea6e1ce44 --- /dev/null +++ b/src/lib/opaque-proxy-lifecycle/connect-request.ts @@ -0,0 +1,36 @@ +import {validateAuthority} from '../opaque-proxy-tunnel'; + +export class ConnectRequestError extends Error { + constructor(readonly status: string) {super('Rejected local proxy request');} +} +export interface ConnectRequest {authority: string; authorization: string | undefined;} +/** Strict local CONNECT header only. Never parses target TLS/application bytes. */ +export function parseConnectRequest(header: Buffer): ConnectRequest { + if (header.length > 8192) throw new ConnectRequestError('431 Request Header Fields Too Large'); + const text = header.toString('latin1'); + if (/[^\x20-\x7e\r\n]/.test(text) || /(? 64 || lines[0].length > 2048 || lines.some(line => line.length > 4096)) throw new ConnectRequestError('431 Request Header Fields Too Large'); + const request = /^([A-Z]+) ([^ ]+) HTTP\/1\.[01]$/.exec(lines[0]); + if (!request) throw new ConnectRequestError('400 Bad Request'); + if (request[1] !== 'CONNECT') throw new ConnectRequestError('405 Method Not Allowed'); + const authority = request[2]; + try {validateAuthority(authority);} catch {throw new ConnectRequestError('400 Bad Request');} + let host: string | undefined, authorization: string | undefined; + for (const line of lines.slice(1)) { + const field = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+): *([\x20-\x7e]*?) *$/.exec(line); + if (!field) throw new ConnectRequestError('400 Bad Request'); + const name = field[1].toLowerCase(), value = field[2]; + if (name === 'content-length' || name === 'transfer-encoding') throw new ConnectRequestError('400 Bad Request'); + if (name === 'host') { + if (host !== undefined || value !== authority) throw new ConnectRequestError('400 Bad Request'); + host = value; + } + if (name === 'proxy-authorization') { + if (authorization !== undefined) throw new ConnectRequestError('407 Proxy Authentication Required'); + authorization = value; + } + } + if (host === undefined) throw new ConnectRequestError('400 Bad Request'); + return {authority, authorization}; +} diff --git a/src/lib/opaque-proxy-lifecycle/managed-ingress.ts b/src/lib/opaque-proxy-lifecycle/managed-ingress.ts new file mode 100644 index 00000000000..7fb8eddd664 --- /dev/null +++ b/src/lib/opaque-proxy-lifecycle/managed-ingress.ts @@ -0,0 +1,174 @@ +import {createServer} from 'node:net'; +import {parseConnectRequest, ConnectRequestError} from './connect-request'; +import { timingSafeEqual } from 'node:crypto'; +import {Socket, type AddressInfo} from 'node:net'; +import {dialTunnel, acceptsTargetPolicy, snapshotProxyRoutes, type OpaqueProxyRoute} from '../opaque-proxy-tunnel'; + +export interface ManagedIngressOptions { + authorization: string; + candidates: readonly OpaqueProxyRoute[]; + allowTarget(authority: string): boolean; + maxActive?: number; maxQueued?: number; maxClients?: number; + idleMs?: number; totalMs?: number; sessionMs?: number; + queueMs?: number; establishMs?: number; + dial?: typeof dialTunnel; +} +interface ClientRecord { + client: Socket; controller: AbortController; done: boolean; active: boolean; + authority?: string; head?: Buffer; upstream?: Socket; + routes?: readonly OpaqueProxyRoute[]; + totalTimer?: ReturnType; + queueTimer?: ReturnType; + establishTimer?: ReturnType; +} + +const integer = (x: number, name: string, min = 1) => { + if (!Number.isSafeInteger(x) || x < min || x > 2 ** 31 - 1) throw new Error(`Invalid ${name}`); +}; + +/** All limits include unauthenticated clients. The dialer owns establishment only. */ +export function createManagedIngress({ authorization, candidates, allowTarget, + maxActive = 16, maxQueued = 16, maxClients = 64, idleMs = 30000, + totalMs = 300000, sessionMs = 300000, queueMs = 3000, establishMs = 5000, dial = dialTunnel }: ManagedIngressOptions) { + if (typeof authorization !== 'string' || authorization.length < 40 || /[\r\n]/.test(authorization)) throw new Error('Private capability required'); + if (typeof allowTarget !== 'function' || !Array.isArray(candidates) || !candidates.length) throw new Error('Explicit routes and policy required'); + for (const [key, val] of Object.entries({maxActive, maxClients, idleMs, totalMs, queueMs, establishMs, sessionMs})) integer(val, key); + integer(maxQueued, 'maxQueued', 0); + if (establishMs > 30000) throw new Error('Establishment deadline exceeds transport limit'); + let routes = snapshotProxyRoutes(candidates); + const secret = Buffer.from(authorization); + const clients = new Map(), queue: ClientRecord[] = []; + let active = 0, closing = false; + let listenPromise: Promise | undefined; + let closePromise: Promise | undefined; + let sessionTimer: ReturnType | undefined; + // Bun node:http emits connection only after request bytes arrive. A raw net + // listener is required to own unauthenticated/slowloris sockets from accept. + const server = createServer({allowHalfOpen: true}); + server.on('error', () => {void api.close();}); + function finish(record: ClientRecord) { + if (record.done) return; + record.done = true; + clearTimeout(record.totalTimer); clearTimeout(record.queueTimer); clearTimeout(record.establishTimer); + record.controller.abort(); record.upstream?.destroy(); + clients.delete(record.client); + const i = queue.indexOf(record); if (i !== -1) queue.splice(i, 1); + if (record.active) { active--; record.active = false; } + pump(); + } + function reject(record: ClientRecord, code: string) { + if (record.done || record.client.destroyed) return; + const queuedIndex = queue.indexOf(record); if (queuedIndex !== -1) queue.splice(queuedIndex, 1); + const challenge = code === '407 Proxy Authentication Required' ? 'Proxy-Authenticate: Basic realm="opencodex-session"\r\n' : ''; + record.client.end(`HTTP/1.1 ${code}\r\n${challenge}Connection: close\r\nContent-Length: 0\r\n\r\n`, () => record.client.destroy()); + } + server.on('connection', client => { + client.on('error', () => {}); + if (closing || clients.size >= maxClients || client.remoteAddress !== '127.0.0.1') { client.destroy(); return; } + const record: ClientRecord = {client, controller: new AbortController(), done:false, active:false}; + clients.set(client, record); + record.totalTimer = setTimeout(() => {client.destroy(); record.upstream?.destroy();}, totalMs); + client.setTimeout(idleMs, () => client.destroy()); + client.once('close', () => { + if (!record.upstream || record.upstream.destroyed || !client.readableEnded) finish(record); + // A graceful downstream EOF may still have bytes queued for upstream. + // Keep this slot and its absolute deadline until that writable drains. + }); + readConnect(record); + }); + function pump() { + while (!closing && active < maxActive && queue.length) { + const record = queue.shift(); + if (record) begin(record); + } + } + function begin(record: ClientRecord) { + if (record.done) return; + clearTimeout(record.queueTimer); record.active = true; active++; + record.establishTimer = setTimeout(() => {record.controller.abort(); record.client.destroy();}, establishMs); + // One dial per accepted connection. No replay after commitment. + Promise.resolve().then(() => { + if (record.done || closing || record.controller.signal.aborted) throw new Error('Closed'); + const authority = record.authority; + if (!authority) throw new Error('Missing accepted target'); + return dial(authority, record.routes ?? [], {signal:record.controller.signal, timeoutMs:establishMs, + totalTimeoutMs:establishMs, allowTarget: target => target === authority && acceptsTargetPolicy(allowTarget, target)}); + }).then(({socket: upstream}) => { + clearTimeout(record.establishTimer); + if (closing || record.done || record.controller.signal.aborted || record.client.destroyed) {upstream.destroy(); return;} + record.upstream = upstream; + const client = record.client; + const destroy = () => {client.destroy(); upstream.destroy();}; + upstream.on('error', destroy); client.once('error', destroy); + upstream.setTimeout(idleMs, destroy); + upstream.once('close', () => { + if (!upstream.readableEnded) client.destroy(); + if (client.destroyed) finish(record); + }); + client.once('close', () => {if (!client.readableEnded) upstream.destroy();}); + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (record.head?.length) client.unshift(record.head); + client.pipe(upstream); upstream.pipe(client); + }).catch(() => {clearTimeout(record.establishTimer); reject(record, '502 Bad Gateway');}); + } + function readConnect(record: ClientRecord) { + const client = record.client; + let buffered = Buffer.alloc(0); + const receive = (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + const end = buffered.indexOf('\r\n\r\n'); + if (end < 0 && buffered.length <= 8192) return; + client.pause(); client.removeListener('data', receive); + if (record.done || closing) {client.destroy(); return;} + if (end < 0 || end > 8192) {reject(record, '431 Request Header Fields Too Large'); return;} + let request; + try {request = parseConnectRequest(buffered.subarray(0, end));} + catch (error) {reject(record, error instanceof ConnectRequestError ? error.status : '400 Bad Request'); return;} + const supplied = Buffer.from(request.authorization ?? ''); + if (supplied.length !== secret.length || !timingSafeEqual(supplied, secret)) {reject(record, '407 Proxy Authentication Required'); return;} + const allowed = acceptsTargetPolicy(allowTarget, request.authority); + if (!allowed) {reject(record, '403 Forbidden'); return;} + record.authority = request.authority; record.head = buffered.subarray(end + 4); + record.routes = routes; + if (active < maxActive) begin(record); + else if (queue.length < maxQueued) { + queue.push(record); record.queueTimer = setTimeout(() => {reject(record, '503 Service Unavailable');}, queueMs); + } else reject(record, '503 Service Unavailable'); + }; + client.once('end', () => {if (!record.authority) client.destroy();}); + client.on('data', receive); + } + const api = { + server, + replaceCandidates(candidates: readonly OpaqueProxyRoute[]): void { + if (closing) throw new Error('Closed'); + routes = snapshotProxyRoutes(candidates); + }, + stats: () => ({clients:clients.size, active, queued:queue.length}), + listen(): Promise { + if (closing) return Promise.reject(new Error('Closed')); + return listenPromise ??= new Promise((resolve, reject) => { + const fail = (error: Error) => {server.removeListener('listening', ready); reject(error);}; + const ready = () => { + server.removeListener('error', fail); + const address = server.address(); + if (!address || typeof address === 'string') {reject(new Error('Invalid listening address')); return;} + resolve(address); + }; + server.once('error', fail); server.once('listening', ready); sessionTimer = setTimeout(() => {void api.close();}, sessionMs); + server.listen(0, '127.0.0.1'); + }); + }, + close(): Promise { + if (closePromise) return closePromise; + closing = true; clearTimeout(sessionTimer); + for (const record of clients.values()) {record.controller.abort(); record.client.destroy(); record.upstream?.destroy();} + closePromise = (async () => { + if (listenPromise) await listenPromise.catch(() => {}); + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + })(); + return closePromise; + }, + }; + return api; +} diff --git a/src/lib/opaque-proxy-lifecycle/managed-session.ts b/src/lib/opaque-proxy-lifecycle/managed-session.ts new file mode 100644 index 00000000000..0156626198b --- /dev/null +++ b/src/lib/opaque-proxy-lifecycle/managed-session.ts @@ -0,0 +1,40 @@ +import {createOwnerManager, type OwnerManagerOptions} from './owner-manager'; +import {createManagedIngress, type ManagedIngressOptions} from './managed-ingress'; +import {snapshotProxyRoutes, type OpaqueProxyRoute} from '../opaque-proxy-tunnel'; + +export interface OwnedIngressManagerOptions { + owner: object; + candidates: readonly OpaqueProxyRoute[]; + allowTarget(authority: string): boolean; + ingress?: Omit; + manager?: Omit; +} +/** Executable composition seam. No activation, registration, or listener at import. */ +export function createOwnedIngressManager({owner, candidates, allowTarget, ingress = {}, manager = {}}: OwnedIngressManagerOptions) { + if (!Array.isArray(candidates) || !candidates.length || typeof allowTarget !== 'function') throw new Error('Explicit routes and target policy required'); + let routes = snapshotProxyRoutes(candidates), revision = 0, epoch = 0; + let active: ReturnType | undefined; + const lifecycle = createOwnerManager({...manager, owner, createWorker:({authorization, onExit}) => { + epoch++; + const worker = createManagedIngress({...ingress, authorization, candidates:routes, allowTarget}); + active = worker; + worker.server.once('close', () => { if (active === worker) active = undefined; onExit(); }); + worker.server.once('error', onExit); + return worker; + }}); + return { + ...lifecycle, + routeVersion: () => ({revision, epoch, generation:lifecycle.status().generation}), + /** Only future accepted connections change routes. Existing/queued streams never replay. */ + updateRoutes(caller: object, expected: {revision: number; epoch: number; generation: number | null}, candidates: readonly OpaqueProxyRoute[]): number { + if (caller !== owner) throw new Error('Wrong owner'); + const status = lifecycle.status(); + if (status.draining || status.poisoned || status.state === 'stopping' || status.state === 'failed') throw new Error('Manager unavailable'); + if (!expected || expected.revision !== revision || expected.epoch !== epoch || expected.generation !== status.generation) throw new Error('Stale route version'); + const next = snapshotProxyRoutes(candidates); + active?.replaceCandidates(next); + routes = next; + return ++revision; + }, + }; +} diff --git a/src/lib/opaque-proxy-lifecycle/owner-manager.ts b/src/lib/opaque-proxy-lifecycle/owner-manager.ts new file mode 100644 index 00000000000..1ce535c6ac4 --- /dev/null +++ b/src/lib/opaque-proxy-lifecycle/owner-manager.ts @@ -0,0 +1,112 @@ +/** Explicit in-process ownership. No PID lookup, discovery, daemon, or auto-restart. */ +export interface OwnedWorker { + listen(): Promise<{ address: string; port: number }> | { address: string; port: number }; + close(): Promise | void; +} +export interface WorkerContext { + generation: number; + signal: AbortSignal; + authorization: string; + onExit(): void; +} +export interface OwnerManagerOptions { + owner: object; + createWorker(context: WorkerContext): OwnedWorker; + startupMs?: number; + shutdownMs?: number; + sessionMs?: number; +} +export interface Activation { + authorized?: boolean; + authorization?: string; +} +export interface GenerationLease { + readonly generation: number; + readonly host: string; + readonly port: number; + stop(): Promise; +} +type Phase = 'starting' | 'ready' | 'stopping' | 'stopped' | 'failed'; +interface Generation { + generation: number; + authorization: string; + state: Phase; + abort: AbortController; + worker?: OwnedWorker; + ready?: Promise; + stopPromise?: Promise; + sessionTimer?: ReturnType; +} +function bounded(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + return Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + })]).finally(() => clearTimeout(timer)); +} +export function createOwnerManager({owner, createWorker, startupMs = 5000, + shutdownMs = 5000, sessionMs = 300000}: OwnerManagerOptions) { + if (!owner || typeof owner !== 'object' || typeof createWorker !== 'function') throw new Error('Owner and worker factory required'); + for (const value of [startupMs, shutdownMs, sessionMs]) { + if (!Number.isSafeInteger(value) || value < 1 || value > 2 ** 31 - 1) throw new Error('Invalid deadline'); + } + let current: Generation | undefined; + let nextGeneration = 0, draining = false, poisoned = false; + const checkOwner = (caller: object) => { if (caller !== owner) throw new Error('Wrong owner'); }; + function dispose(record: Generation): Promise { + if (record.stopPromise) return record.stopPromise; + record.state = 'stopping'; + clearTimeout(record.sessionTimer); + record.abort.abort(); + record.stopPromise = bounded(Promise.resolve().then(() => record.worker?.close()), shutdownMs, 'Shutdown deadline exceeded') + .then(() => { record.state = 'stopped'; if (current === record) current = undefined; }) + .catch((error: unknown) => { record.state = 'failed'; poisoned = true; throw error; }); + return record.stopPromise; + } + return { + status() { return {generation: current?.generation ?? null, state: current?.state ?? 'stopped', draining, poisoned}; }, + start(caller: object, {authorized = false, authorization}: Activation = {}): Promise { + checkOwner(caller); + if (authorized !== true || typeof authorization !== 'string' || authorization.length < 40 || /[\r\n]/.test(authorization)) { + throw new Error('Explicit authorization and ephemeral capability required'); + } + if (draining || poisoned) throw new Error('Manager unavailable'); + if (current) { + if (current.authorization !== authorization) throw new Error('Active generation configuration differs'); + if (current.state === 'starting' || current.state === 'ready') { + if (!current.ready) throw new Error('Reentrant startup'); + return current.ready; + } + throw new Error('Previous generation has not stopped'); + } + const record: Generation = {generation: ++nextGeneration, authorization, state: 'starting', abort: new AbortController()}; + current = record; + const onExit = () => { + if (current !== record || record.state === 'stopping' || record.state === 'stopped') return; + record.state = 'failed'; record.abort.abort(); + void dispose(record).catch(() => {}); + }; + try { record.worker = createWorker({generation: record.generation, signal: record.abort.signal, authorization, onExit}); } + catch (error) { current = undefined; throw error; } + const worker = record.worker; + // A factory may synchronously report exit. Never schedule a post-disposal timer. + if (current === record && record.state === 'starting' && !record.abort.signal.aborted) { + record.sessionTimer = setTimeout(() => { void dispose(record).catch(() => {}); }, sessionMs); + } + record.ready = bounded(Promise.resolve().then(() => { + if (record.abort.signal.aborted) throw new Error('Stale readiness'); + return worker.listen(); + }), startupMs, 'Startup deadline exceeded').then(address => { + if (current !== record || record.state !== 'starting' || record.abort.signal.aborted) throw new Error('Stale readiness'); + if (!address || address.address !== '127.0.0.1' || !Number.isInteger(address.port) || address.port < 1 || address.port > 65535) { + throw new Error('Invalid loopback readiness'); + } + record.state = 'ready'; + return Object.freeze({generation: record.generation, host: address.address, port: address.port, stop: () => dispose(record)}); + }).catch(async (error: unknown) => { await dispose(record); throw error; }); + return record.ready; + }, + stop(caller: object): Promise { checkOwner(caller); return current ? dispose(current) : Promise.resolve(); }, + shutdown(caller: object): Promise { checkOwner(caller); draining = true; return current ? dispose(current) : Promise.resolve(); }, + }; +} +export type OwnerManager = ReturnType; diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts new file mode 100644 index 00000000000..e1dbc0e1227 --- /dev/null +++ b/src/lib/opaque-proxy-tunnel.ts @@ -0,0 +1,280 @@ +/** + * Explicit authorized opaque proxy tunnel. No production caller is registered. + * Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment + * cancellation and socket cleanup. Never replay or implicitly retry directly. + */ +import net, { type Socket } from 'node:net'; +import tls from 'node:tls'; +import { socks5Handshake, socks5Credentials, Socks5HandshakeError } from './socks5-handshake'; + +interface RouteEndpoint { id: string; host: string; port: number } +type RouteCredentials = { username?: undefined; password?: undefined } | { username: string; password: string }; +type PlainProxy = { kind?: 'http'; proxyCa?: never; proxyServername?: never }; +type SecureProxy = { kind: 'https'; proxyCa?: string | Buffer; proxyServername?: string }; +type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never; proxyServername?: never }; +export type OpaqueProxyRoute = RouteEndpoint & RouteCredentials & (PlainProxy | SecureProxy | SocksProxy); +export interface OpaqueTunnelOptions { + /** Rechecked before each physical attempt and each handshake write. No implicit target permission. */ + allowTarget: (authority: string) => boolean; + signal?: AbortSignal; + timeoutMs?: number; + totalTimeoutMs?: number; + maxHeaderBytes?: number; +} +interface AttemptOptions { + signal: AbortSignal; + timeoutMs: number; + maxHeaderBytes: number; + authorize: () => void; +} +export class TunnelError extends Error { + override readonly name = 'TunnelError'; + readonly code: string; + readonly retryable: boolean; + readonly status?: number; + constructor(code: string, message: string, { retryable = false, status }: { retryable?: boolean; status?: number } = {}) { + super(message); this.code = code; this.retryable = retryable; this.status = status; + } +} +const aborted = () => new TunnelError('ABORTED', 'Tunnel establishment cancelled'); + +/** Synchronous permission only. Invalid async policies remain denied and observed. */ +export function acceptsTargetPolicy(policy: ((authority: string) => unknown) | undefined, authority: string): boolean { + try { + const decision: unknown = typeof policy === 'function' ? policy(authority) : false; + // Never await an async policy to grant access. Observe rejected misuse so + // private policy context cannot escape through an unhandled rejection. + if (decision && (typeof decision === 'object' || typeof decision === 'function')) { + void Promise.resolve(decision).catch(() => {}); + } + return decision === true; + } catch { return false; } +} + +export function validateAuthority(authority: string): string { + if (typeof authority !== 'string' || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]+$/.test(authority)) { + throw new TunnelError('TARGET', 'An explicit host:port authority is required'); + } + const port = Number(authority.slice(authority.lastIndexOf(':') + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TunnelError('TARGET', 'Invalid target port'); + return authority; +} + +function validateOptions(candidates: readonly OpaqueProxyRoute[], timeoutMs: number, maxHeaderBytes: number, totalTimeoutMs = 10000): void { + if (!Array.isArray(candidates) || !candidates.length) throw new TunnelError('CONFIG', 'Explicit proxy candidates are required'); + const ids = new Set(); + for (const p of candidates) { + if (!p || typeof p.id !== 'string' || !p.id || ids.has(p.id) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host) + || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535 + || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy candidate'); + if ((p.username === undefined) !== (p.password === undefined) + || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string' + || p.username.includes(':') || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); + if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined + && (!p.username || !p.password || Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each contain 1–255 UTF-8 bytes'); + if ((p.proxyCa !== undefined || p.proxyServername !== undefined) && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy'); + if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string' && !Buffer.isBuffer(p.proxyCa)) throw new TunnelError('CONFIG', 'Invalid proxy CA'); + if (p.proxyServername !== undefined && (typeof p.proxyServername !== 'string' || !p.proxyServername || /[\s/@\x00]/.test(p.proxyServername))) throw new TunnelError('CONFIG', 'Invalid proxy TLS server name'); + ids.add(p.id); + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 + || !Number.isSafeInteger(totalTimeoutMs) || totalTimeoutMs < 1 || totalTimeoutMs > 120000 + || !Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes < 64 || maxHeaderBytes > 65536) { + throw new TunnelError('CONFIG', 'Invalid deadline or header limit'); + } +} + +/** Validated owned snapshot; refreshing a caller's routes cannot mutate an active dial. */ +export function snapshotProxyRoutes(candidates: readonly OpaqueProxyRoute[]): readonly OpaqueProxyRoute[] { + validateOptions(candidates, 1000, 8192); + return Object.freeze(candidates.map(p => Object.freeze(p.kind === 'https' + ? {...p, proxyCa: Buffer.isBuffer(p.proxyCa) ? Buffer.from(p.proxyCa) : p.proxyCa} + : {...p}))); +} + +function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes, authorize }: AttemptOptions): Promise { + if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes, authorize }); + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(aborted()); + const secure = proxy.kind === 'https'; + const identity = proxy.proxyServername ?? proxy.host; + const tlsOptions: tls.ConnectionOptions & { allowHalfOpen: boolean } = { + host: proxy.host, port: proxy.port, allowHalfOpen: true, + servername: net.isIP(identity) ? undefined : identity, ca: proxy.proxyCa, + checkServerIdentity: (_name, cert) => tls.checkServerIdentity(identity, cert), + rejectUnauthorized: true, ALPNProtocols: ['http/1.1'], + }; + const socket = secure + ? tls.connect(tlsOptions) + : net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true }); + // Keep a defensive observer across the promise-to-owner handoff. Errors still + // settle the establishment promise or are handled by the relay after handoff. + socket.on('error', () => {}); + let pending: Buffer = Buffer.alloc(0); let settled = false, tlsReady = !secure; + const connectedEvent = secure ? 'secureConnect' : 'connect'; + const cleanup = () => { + clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + socket.removeListener(connectedEvent, onConnect); socket.removeListener('data', onData); + socket.removeListener('error', onError); socket.removeListener('end', onEnd); socket.removeListener('close', onClose); + }; + const fail = (error: Error) => { + if (settled) return; settled = true; cleanup(); socket.destroy(); reject(error); + }; + const onAbort = () => fail(aborted()); + // Partial responses can contain a refusal that has not finished arriving. + // Fail closed once any proxy response bytes exist, even on transport failure. + const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message, { retryable: pending.length === 0 && tlsReady })); + const onError = (error: NodeJS.ErrnoException) => { + if (secure && !tlsReady) { + const retryable = ['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN'].includes(error.code ?? ''); + fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed', { retryable })); + } else establishmentFailure('TRANSPORT', 'HTTP proxy connection failed'); + }; + const onEnd = () => establishmentFailure('EARLY_CLOSE', 'HTTP proxy closed before establishing the tunnel'); + const onClose = onEnd; + const onConnect = () => { + try { authorize(); } catch (error) { fail(error instanceof Error ? error : new TunnelError('TARGET', 'Target authorization failed')); return; } + tlsReady = true; + const auth = proxy.username === undefined ? '' : `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n`; + socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`); + }; + const onData = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const lineEnd = pending.indexOf('\r\n'); + if (lineEnd >= 0) { + const firstLine = pending.subarray(0, lineEnd).toString('latin1'); + const firstStatus = /^HTTP\/1\.[01] ([0-9]{3})(?: [\t\x20-\x7e\x80-\xff]*)?$/.exec(firstLine); + if (!firstStatus) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); + const status = Number(firstStatus[1]); + if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); + } + const end = pending.indexOf('\r\n\r\n'); + if (end < 0) { + if (pending.length > maxHeaderBytes) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); + return; + } + if (end + 4 > maxHeaderBytes) return fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); + const lines = pending.subarray(0, end).toString('latin1').split('\r\n'); + const statusLine = lines[0]; + const match = /^HTTP\/1\.[01] ([0-9]{3})(?: [^\r\n]*)?$/.exec(statusLine); + if (!match) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); + const status = Number(match[1]); + if (lines.slice(1).some(line => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e\x80-\xff]*$/.test(line))) { + return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy response header')); + } + // Every HTTP refusal, including 407 and 403, is terminal: trying another + // candidate could otherwise silently sidestep authentication or policy. + if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); + settled = true; socket.pause(); cleanup(); + const head = pending.subarray(end + 4); + if (head.length) socket.unshift(head); + resolve(socket); + }; + const timer = setTimeout(() => establishmentFailure('TIMEOUT', 'HTTP proxy establishment deadline exceeded'), timeoutMs); + socket.once(connectedEvent, onConnect); socket.on('data', onData); + socket.once('error', onError); socket.once('end', onEnd); socket.once('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +} + +// Reader contract follows desktop-upstream-tunnel.ts. The framing is the existing +// MIT socks5-handshake module; neither shared framing nor its DNS policy changes. +async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, authorize }: AttemptOptions): Promise { + if (signal?.aborted) throw aborted(); + const socket = net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true }); + socket.on('error', () => {}); + let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined, bytesSeen = 0; + let waiting: { length: number; resolve: () => void; reject: (error: Error) => void } | undefined; + const fail = (error: Error) => { if (failure) return; failure = error; const current = waiting; waiting = undefined; current?.reject(error); socket.destroy(); }; + const transportError = (code: string, message: string) => fail(new TunnelError(code, message, { retryable: bytesSeen === 0 })); + const onData = (chunk: Buffer) => { + // Consume at most the bytes needed by a bounded handshake read, then pause. + // A valid CONNECT reply can share a packet with arbitrary early target data; + // that remainder is not a malformed/oversized handshake and must survive. + socket.pause(); + bytesSeen += chunk.length; + buffer = Buffer.concat([buffer, chunk]); + if (waiting && buffer.length >= waiting.length) { const current = waiting; waiting = undefined; current.resolve(); } + else if (waiting) socket.resume(); + }; + const onError = () => transportError('TRANSPORT', 'SOCKS proxy connection failed'); + const onClose = () => transportError('EARLY_CLOSE', 'SOCKS proxy closed before tunnel establishment'); + const onAbort = () => fail(aborted()); + const timer = setTimeout(() => transportError('TIMEOUT', 'SOCKS establishment deadline exceeded'), timeoutMs); + socket.on('data', onData); socket.once('error', onError); socket.once('end', onClose); socket.once('close', onClose); + socket.pause(); + signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort(); + const reader = { + write(bytes: Uint8Array): void { if (failure) throw failure; authorize(); socket.write(bytes); }, + async readExact(length: number): Promise { + if (failure) throw failure; + if (!Number.isSafeInteger(length) || length < 0 || length > 1024) throw new TunnelError('PROTOCOL', 'Invalid handshake read size'); + if (buffer.length < length) await new Promise((resolve, reject) => { + if (waiting) { reject(new TunnelError('PROTOCOL', 'Concurrent handshake read')); return; } + waiting = { length, resolve, reject }; + socket.resume(); + }); + if (failure) throw failure; + const result = buffer.subarray(0, length); buffer = buffer.subarray(length); return result; + }, + }; + try { + const credentialsUrl = new URL('socks5://fixture.invalid'); + if (proxy.username !== undefined) { credentialsUrl.username = encodeURIComponent(proxy.username); credentialsUrl.password = encodeURIComponent(proxy.password); } + const colon = authority.lastIndexOf(':'); + const target = { host: authority.slice(0, colon).replace(/^\[|\]$/g, ''), port: Number(authority.slice(colon + 1)) }; + await socks5Handshake(reader, target, socks5Credentials(credentialsUrl), signal); + if (failure) throw failure; + if (signal?.aborted || socket.destroyed) throw aborted(); + socket.pause(); if (buffer.length) socket.unshift(buffer); buffer = Buffer.alloc(0); + return socket; + } catch (error) { + socket.destroy(); + if (error instanceof Socks5HandshakeError) throw new TunnelError('SOCKS_REFUSED', 'SOCKS authentication, policy, or protocol refusal'); + throw error; + } finally { + clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + socket.removeListener('data', onData); socket.removeListener('error', onError); + socket.removeListener('end', onClose); socket.removeListener('close', onClose); + } +} + +/** Establish only. There is no application data, implicit DIRECT, or replay here. + * HTTP/HTTPS CONNECT and SOCKS5/SOCKS5h yield an opaque raw target tunnel. + */ +export async function dialTunnel(authority: string, candidates: readonly OpaqueProxyRoute[], options: OpaqueTunnelOptions): Promise<{ socket: Socket; routeId: string }> { + const { signal, timeoutMs = 1000, totalTimeoutMs = 10000, maxHeaderBytes = 8192, allowTarget } = options ?? {}; + const authorize = (): void => { + if (!acceptsTargetPolicy(allowTarget, authority)) throw new TunnelError('TARGET', 'Target is not authorized'); + }; + validateAuthority(authority); validateOptions(candidates, timeoutMs, maxHeaderBytes, totalTimeoutMs); authorize(); + // Freeze the route decision for this attempt; caller edits affect future dials. + const selected: OpaqueProxyRoute[] = candidates.map(p => p.kind === 'https' ? { ...p, proxyCa: Buffer.isBuffer(p.proxyCa) ? Buffer.from(p.proxyCa) : p.proxyCa } : { ...p }); + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), totalTimeoutMs); + const combined = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal; + let last: TunnelError | undefined; + try { + for (const proxy of selected) { + authorize(); + if (combined.aborted) throw aborted(); + try { + const socket = await connectOne(authority, proxy, { signal: combined, timeoutMs, maxHeaderBytes, authorize }); + // Permission can change while a proxy reply is in flight. A completed + // handshake alone never authorizes handing a target socket to the caller. + try { authorize(); if (combined.aborted) throw aborted(); } + catch (error) { socket.destroy(); throw error; } + return { socket, routeId: proxy.id }; + } catch (error) { + if (combined.aborted) throw aborted(); + if (!(error instanceof TunnelError) || !error.retryable) throw error; + last = error; + } + } + throw new TunnelError('ALL_FAILED', `All ${selected.length} configured proxies failed (${last?.code ?? 'UNKNOWN'})`); + } catch (error) { + if (deadline.signal.aborted && !signal?.aborted) throw new TunnelError('TOTAL_TIMEOUT', 'Overall tunnel establishment deadline exceeded'); + throw error; + } finally { clearTimeout(timer); } +} diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 896442feb4e..3da66fbd29b 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -417,3 +417,74 @@ Only the proxy handshake carries proxy credentials. The raw upgraded socket rema the relay does not decode WebSocket payloads. After CONNECT the paused outer stream resumes before TLS-over-TLS negotiation. Test fixture CA trust is confined to child processes or injected test socket options, never the user's OS trust store. + +## Explicit opaque proxy candidates + +`src/lib/opaque-proxy-tunnel.ts` exposes an authorized, paused target socket through +an explicit ordered HTTP, HTTPS CONNECT, SOCKS5 or SOCKS5h candidate list. It has no +registered production caller and changes neither desktop proxy discovery nor the +fixed-destination TLS contract above. Each call requires a synchronous target +predicate; it is rechecked before every physical attempt and handshake write. +A completed proxy handshake is rechecked before the socket is handed to the caller. +A caller owns target/network authorization, including whether a proxy may resolve +that target. No implicit DIRECT path, environment inheritance or provider reroute +exists. Candidate credentials are request-local, isolated to that proxy's handshake, +and absent from error messages; the module stores no credentials and emits no logs. + +The existing `src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS +scheme spellings retain its ATYP DOMAIN remote-DNS behavior; they are not distinct +local/remote DNS modes. Credentials offer both NO-AUTH and USER-PASS as that shared +contract specifies. HTTPS proxy identity and trust are always verified. Target TLS +belongs to the client and target, not the opaque tunnel. + +Per-candidate and total deadlines bound establishment. A transport failure before +any proxy response may advance only through the caller's explicit candidate list. +Authentication, policy and certificate refusals, malformed/partial responses, +cancellation and the total deadline are terminal. After return, the caller owns +all cancellation, backpressure, half-close and socket destruction. The returned +socket is paused; no established connection is migrated and no target bytes are +replayed. Bounded SOCKS frame reads preserve early opaque bytes after CONNECT. +`tests/lib/opaque-proxy-tunnel.test.ts` and +`tests/lib/opaque-proxy-tunnel-protocol.test.ts` cover these local transport contracts. + +## Owned opaque proxy sessions + +`src/lib/opaque-proxy-lifecycle/managed-ingress.ts` provides a loopback-only CONNECT +ingress using raw TCP accept so Bun counts even clients that send no HTTP bytes, +with an ephemeral capability, an authority predicate, connection limits, +header/establishment/idle/session deadlines and owned socket cleanup. It consumes +client proxy authorization locally rather than forwarding it. Its diagnostic state +contains bounded counts and lifecycle labels, not target bodies or credentials. +`src/lib/opaque-proxy-lifecycle/connect-request.ts` requires an exact Host/authority +match and rejects ambiguous authentication, request bodies, folded headers and +control bytes before dispatch. `tests/lib/opaque-proxy-lifecycle-parser.test.ts` +covers this narrow strict CONNECT-only parser. +`src/lib/opaque-proxy-lifecycle/owner-manager.ts` serializes start ownership by +generation, reuses healthy workers, retires stale starts and gives intentional stop +precedence over restart. `src/lib/opaque-proxy-lifecycle/managed-session.ts` composes +the manager, ingress and explicitly authorized dial without installing a service. +`tests/lib/opaque-proxy-lifecycle.test.ts` and +`tests/lib/opaque-proxy-lifecycle-transport.test.ts` bind these lifecycle and real +loopback composition contracts. These modules have no production registration, +Desktop launch/configuration writes, OS proxy changes, CA installation, or account +integration; successful local composition is not original-Desktop compatibility. + +`tests/lib/opaque-proxy-session-protocol.test.ts` exercises the complete managed +session through all four proxy schemes with independently verified target TLS, +including a coalesced CONNECT/ClientHello, large half-closed streams, capability +rejection before dial, stop/session expiry and certificate refusals. These are +loopback composition tests; they do not establish original-Desktop integration. + +The owned manager's explicit `updateRoutes` operation uses owner identity and a +compare-and-swap revision, monotonic start epoch and observed session generation. It validates and snapshots routes before changing them, +including copies of mutable HTTPS trust bytes. The existing listener and capability +remain fixed; a successfully authenticated CONNECT captures its route list before +queueing. Established and queued connections never migrate or replay during refresh. +Stopped managers can stage a next route, but shutdown, failed disposal and stale +revisions refuse updates. This is a process-local seam, not OS proxy discovery or +a Desktop attachment API. `tests/lib/opaque-proxy-route-refresh.test.ts` verifies +real CONNECT connections across a refresh, queue pinning and rejected updates. +Unauthenticated CONNECT receives a standard Basic proxy challenge with a fixed +realm; it never reveals the session capability. A challenge-aware client must +still obtain that capability through a separately authorized integration. This +does not establish that the original Desktop implements proxy authentication. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..c12fb0fd75a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1263,6 +1263,13 @@ "management-desktop-compatibility-runtime-routes.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "opaque-proxy-tunnel.test.ts": "lib", + "opaque-proxy-tunnel-protocol.test.ts": "lib", + "opaque-proxy-lifecycle.test.ts": "lib", + "opaque-proxy-lifecycle-transport.test.ts": "lib", + "opaque-proxy-lifecycle-parser.test.ts": "lib", + "opaque-proxy-session-protocol.test.ts": "lib", + "opaque-proxy-route-refresh.test.ts": "lib", "server-desktop-compatibility-startup.test.ts": "server", "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor" diff --git a/tests/helpers/opaque-proxy-tunnel-protocol.ts b/tests/helpers/opaque-proxy-tunnel-protocol.ts new file mode 100644 index 00000000000..441810ef173 --- /dev/null +++ b/tests/helpers/opaque-proxy-tunnel-protocol.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import net, { type Socket } from "node:net"; +import tls from "node:tls"; +import { once } from "node:events"; +import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; +import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; +import { listen, TunnelFixtureScope } from "./opaque-proxy-tunnel"; + +// Runtime-only keys, never persisted or added to an OS trust store. Proxy and target +// use separate leaf identities; clients explicitly trust only this fixture CA. +const commonName = "opaque tunnel fixture CA"; +const authority = createCertificateAuthority({ commonName, validityDays: 1 }); +export const ca = authority.certPem; +const originIdentity = issueServerLeaf(authority, commonName, ["origin.fixture.invalid"]); +const proxyIdentity = issueServerLeaf(authority, commonName, ["proxy.fixture.invalid"]); +export const credentials = { username: "fixture-user", password: "fixture-páss" }; +const basic = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64"); +export type ProxyKind = "http" | "https" | "socks5" | "socks5h"; +export const proxyKinds: readonly ProxyKind[] = ["http", "https", "socks5", "socks5h"]; + +export async function origin(scope: TunnelFixtureScope, { slow = false, drop = false } = {}) { + const sockets = new Set(), received: Buffer[] = []; + let calls = 0; + const server = tls.createServer({ key: originIdentity.keyPem, cert: originIdentity.certPem, allowHalfOpen: true }, socket => { + calls++; + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + let writes = 0, ended = false; + socket.on("data", (chunk: Buffer) => { + received.push(chunk); + if (drop) { socket.destroy(); return; } + if (slow) { + writes++; + socket.pause(); + scope.later(() => { + writes--; + if (socket.destroyed) return; + socket.write(chunk); + if (ended && !writes) socket.end(); else socket.resume(); + }, 2); + } else socket.write(chunk); + }); + socket.once("end", () => { ended = true; if (!writes) socket.end(); }); + }); + server.on("tlsClientError", (_error, socket) => socket.destroy()); + const port = await listen(scope, server, sockets); + return { port, authority: `origin.fixture.invalid:${port}`, received, calls: () => calls }; +} + +type ProxyOptions = { + mode?: "ok" | "hang" | "partial" | "refuse" | "policy" | "early-large"; + destination?: Awaited>; + requireAuth?: boolean; + fragmented?: boolean; + onAccepted?: () => void; + beforeEstablishedReply?: () => void; +}; + +export async function protocolProxy(scope: TunnelFixtureScope, kind: ProxyKind, { + mode = "ok", destination, requireAuth = true, fragmented = false, onAccepted, beforeEstablishedReply, +}: ProxyOptions = {}) { + const sockets = new Set(); + const seen = { connections: 0, handshakes: 0, auth: 0, targets: [] as Array<{ host: string; port: number }>, opaque: [] as Buffer[], applicationBytes: 0, clientEnds: 0 }; + const own = (socket: Socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + return socket; + }; + const handle = (socket: Socket) => { + socket.allowHalfOpen = true; + own(socket); + seen.handshakes++; + let buffer = Buffer.alloc(0), stage = 0; + const send = (bytes: Buffer | number[]) => { + const data = Buffer.from(bytes); + if (!fragmented) socket.write(data); + else for (let i = 0; i < data.length; i++) { + scope.later(() => { if (!socket.destroyed) socket.write(data.subarray(i, i + 1)); }, i); + } + }; + socket.once("end", () => { seen.clientEnds++; }); + const onEnd = () => socket.end(); + socket.once("end", onEnd); + const tunnel = (host: string, port: number, head = Buffer.alloc(0)) => { + seen.targets.push({ host, port }); + socket.removeListener("data", onData); + socket.pause(); + if (mode === "early-large") { + socket.write(Buffer.concat([Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]), Buffer.alloc(192 * 1024, 0xa5)])); + return; + } + assert.ok(destination, "test destination required"); + assert.equal(host, "origin.fixture.invalid"); + assert.equal(port, destination.port); + // All target routing stays on loopback. The .invalid name is forwarded verbatim. + const upstream = own(net.connect({ port: destination.port, host: "127.0.0.1", allowHalfOpen: true })); + upstream.once("connect", () => { + const connected = kind.startsWith("socks") + ? Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) + : Buffer.from("HTTP/1.1 200 Established\r\n\r\n"); + beforeEstablishedReply?.(); + send(connected); + scope.later(() => { + if (socket.destroyed || upstream.destroyed) return; + socket.removeListener("end", onEnd); + socket.on("data", (chunk: Buffer) => seen.opaque.push(chunk)); + socket.once("close", () => { if (!socket.readableEnded) upstream.destroy(); }); + upstream.once("close", () => { if (!upstream.readableEnded) socket.destroy(); }); + socket.once("error", () => upstream.destroy()); + upstream.once("error", () => socket.destroy()); + if (head.length) socket.unshift(head); + socket.pipe(upstream); + upstream.pipe(socket); + }, fragmented ? connected.length + 3 : 0); + }); + }; + const onData = (data: Buffer) => { + seen.applicationBytes += data.length; + buffer = Buffer.concat([buffer, data]); + if (mode === "hang") return; + if (mode === "partial") { + socket.removeListener("data", onData); + socket.write(kind.startsWith("socks") ? Buffer.from([5]) : "HTTP/1.1 4"); + return; + } + if (!kind.startsWith("socks")) { + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) return; + const header = buffer.subarray(0, end).toString("latin1"); + if (mode === "refuse" || (requireAuth && !header.includes(`Proxy-Authorization: ${basic}`))) { + socket.end("HTTP/1.1 407 Authentication Required\r\n\r\n"); + return; + } + if (requireAuth) seen.auth++; + const requested = /^CONNECT ([^ ]+) HTTP\/1\.1/.exec(header)?.[1]; + assert.ok(requested); + const colon = requested.lastIndexOf(":"); + tunnel(requested.slice(0, colon), Number(requested.slice(colon + 1)), buffer.subarray(end + 4)); + return; + } + for (;;) { + if (stage === 0) { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + assert.equal(buffer[0], 5); + if (requireAuth) assert.ok(buffer.subarray(2, 2 + buffer[1]).includes(2)); + buffer = buffer.subarray(2 + buffer[1]); + send([5, requireAuth ? 2 : 0]); + stage = requireAuth ? 1 : 2; + } else if (stage === 1) { + if (buffer.length < 2) return; + const userLength = buffer[1]; + if (buffer.length < 3 + userLength) return; + const passwordLength = buffer[2 + userLength]; + if (buffer.length < 3 + userLength + passwordLength) return; + const username = buffer.subarray(2, 2 + userLength).toString(); + const password = buffer.subarray(3 + userLength, 3 + userLength + passwordLength).toString(); + assert.equal(buffer[0], 1); + buffer = buffer.subarray(3 + userLength + passwordLength); + if (mode === "refuse" || username !== credentials.username || password !== credentials.password) { + socket.end(Buffer.from([1, 1])); + return; + } + seen.auth++; + send([1, 0]); + stage = 2; + } else if (stage === 2) { + if (buffer.length < 5 || buffer.length < 7 + buffer[4]) return; + assert.deepEqual([...buffer.subarray(0, 4)], [5, 1, 0, 3]); + const length = buffer[4]; + const host = buffer.subarray(5, 5 + length).toString(); + const port = buffer.readUInt16BE(5 + length); + if (mode === "policy") { socket.end(Buffer.from([5, 2, 0, 1, 0, 0, 0, 0, 0, 0])); return; } + tunnel(host, port, buffer.subarray(7 + length)); + stage = 3; + return; + } else return; + } + }; + socket.on("data", onData); + }; + // Pre-handshake TLS peers auto-close; half-close is enabled only after secureConnect. + const server = kind === "https" + ? tls.createServer({ key: proxyIdentity.keyPem, cert: proxyIdentity.certPem }, handle) + : net.createServer({ allowHalfOpen: true }, handle); + server.on("connection", () => { seen.connections++; onAccepted?.(); }); + server.on("tlsClientError", (_error, socket) => socket.destroy()); + const port = await listen(scope, server, sockets); + const base = { id: `${kind}-${port}`, host: "127.0.0.1", port }; + let route: OpaqueProxyRoute; + switch (kind) { + case "https": route = { ...base, kind, proxyCa: ca, proxyServername: "proxy.fixture.invalid" }; break; + case "http": route = { ...base, kind }; break; + default: route = { ...base, kind }; break; + } + const candidate: OpaqueProxyRoute = requireAuth ? { ...route, ...credentials } : route; + return { candidate, seen, sockets }; +} + +export async function securedClient(scope: TunnelFixtureScope, raw: Socket, servername = "origin.fixture.invalid", trust = ca) { + scope.own(raw); + const options = { socket: raw, servername, ca: trust, rejectUnauthorized: true, allowHalfOpen: true }; + const socket = scope.own(tls.connect(options)); + raw.resume(); + await once(socket, "secureConnect"); + assert.equal(socket.authorized, true); + return socket; +} diff --git a/tests/helpers/opaque-proxy-tunnel.ts b/tests/helpers/opaque-proxy-tunnel.ts new file mode 100644 index 00000000000..e338667f5f9 --- /dev/null +++ b/tests/helpers/opaque-proxy-tunnel.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import net, { type AddressInfo, type Server, type Socket } from "node:net"; +import { once } from "node:events"; +import { createHash } from "node:crypto"; +import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; + +/** Each case owns its listeners, peers, client sockets, and fixture timers. */ +export class TunnelFixtureScope { + private cleanups: Array<() => void | Promise> = []; + after(cleanup: () => void | Promise): void { this.cleanups.push(cleanup); } + own(socket: T): T { + socket.on("error", () => {}); + this.after(() => { socket.destroy(); }); + return socket; + } + later(callback: () => void, ms: number): void { + const timer = setTimeout(callback, ms); + this.after(() => clearTimeout(timer)); + } + async close(): Promise { + const failures: unknown[] = []; + for (const cleanup of this.cleanups.splice(0).reverse()) { + try { await cleanup(); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "tunnel fixture cleanup failed"); + } +} + +export const target = "fixture.invalid:443"; +export const allowTarget = (authority: string) => authority === target; +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +export const digest = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); + +export async function until(predicate: () => boolean): Promise { + const deadline = performance.now() + 2_000; + while (!predicate() && performance.now() < deadline) await wait(5); + assert.ok(predicate(), "fixture condition did not settle"); +} + +export async function listen(scope: TunnelFixtureScope, server: Server, sockets = new Set()): Promise { + server.on("connection", socket => { + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + }); + scope.after(async () => { + for (const socket of sockets) socket.destroy(); + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +export async function deadProxy(kind: OpaqueProxyRoute["kind"] = "http"): Promise { + // An OS-assigned local port, closed before the candidate is used. No destination DNS lookup. + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + await new Promise(resolve => server.close(() => resolve())); + return { id: `dead-${port}`, kind, host: "127.0.0.1", port } as OpaqueProxyRoute; +} + +export async function readBytes(socket: Socket, count: number): Promise { + return new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + const done = () => { + socket.pause(); + socket.removeListener("data", onData); + socket.removeListener("end", onEnd); + socket.removeListener("error", onError); + }; + const onData = (chunk: Buffer) => { + data = Buffer.concat([data, chunk]); + if (data.length < count) return; + done(); + if (data.length > count) socket.unshift(data.subarray(count)); + resolve(data.subarray(0, count)); + }; + const onEnd = () => { done(); reject(new Error(`early end: ${data.length}/${count}`)); }; + const onError = (error: Error) => { done(); reject(error); }; + socket.on("data", onData); + socket.once("end", onEnd); + socket.once("error", onError); + socket.resume(); + }); +} + +export async function echo(socket: Socket, payload: Buffer, slowRead = false): Promise { + const chunks: Buffer[] = []; + const ended = once(socket, "end"); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + if (slowRead) socket.pause(); + socket.end(payload); + if (slowRead) { await wait(35); socket.resume(); } + await ended; + const received = Buffer.concat(chunks); + assert.equal(received.length, payload.length); + assert.equal(digest(received), digest(payload)); +} + +export type HttpFixtureMode = "echo" | "hang" | "407" | "403" | "malformed" | "oversize" + | "bad-header" | "partial-refusal" | "partial-timeout" | "banner" | "drop" | "slow"; + +export async function httpProxy(scope: TunnelFixtureScope, mode: HttpFixtureMode = "echo") { + const sockets = new Set(); + const seen = { calls: 0, bytes: 0, payloads: [] as Buffer[], authorities: [] as string[] }; + const server = net.createServer({ allowHalfOpen: true }, socket => { + let pending = Buffer.alloc(0), writes = 0, ended = false; + socket.once("end", () => { ended = true; if (!writes) socket.end(); }); + const handshake = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const end = pending.indexOf("\r\n\r\n"); + if (end < 0) return; + socket.removeListener("data", handshake); + seen.calls++; + const headers = pending.subarray(0, end).toString(); + assert.match(headers, /^CONNECT fixture\.invalid:443 HTTP\/1\.1\r\nHost: fixture\.invalid:443$/); + seen.authorities.push(headers.split("\r\n")[0]); + if (mode === "hang") return; + if (mode === "407" || mode === "403") { socket.end(`HTTP/1.1 ${mode} Refused\r\nContent-Length: 0\r\n\r\n`); return; } + if (mode === "malformed") { socket.end("NOT HTTP\r\n\r\n"); return; } + if (mode === "oversize") { socket.write("HTTP/1.1 200 OK\r\nX: " + "x".repeat(9000)); return; } + if (mode === "bad-header") { socket.end("HTTP/1.1 200 OK\r\nInvalidHeader\r\n\r\n"); return; } + if (mode === "partial-refusal") { socket.end("HTTP/1.1 407 Refused\r\nIncomplete"); return; } + if (mode === "partial-timeout") { socket.write("HTTP/1.1 4"); return; } + // Coalesce early opaque bytes with the CONNECT response to exercise unshift ownership. + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n" + (mode === "banner" ? "opaque-banner" : "")); + const payload = (data: Buffer) => { + seen.bytes += data.length; + seen.payloads.push(data); + if (mode === "drop") { socket.destroy(); return; } + if (mode === "slow") { + writes++; + socket.pause(); + scope.later(() => { + writes--; + if (socket.destroyed) return; + socket.write(data); + if (ended && !writes) socket.end(); else socket.resume(); + }, 3); + } else socket.write(data); + }; + socket.on("data", payload); + const head = pending.subarray(end + 4); + if (head.length) payload(head); + }; + socket.on("data", handshake); + }); + const port = await listen(scope, server, sockets); + return { candidate: { id: `${mode}-${port}`, host: "127.0.0.1", port } satisfies OpaqueProxyRoute, seen, sockets }; +} diff --git a/tests/lib/opaque-proxy-lifecycle-parser.test.ts b/tests/lib/opaque-proxy-lifecycle-parser.test.ts new file mode 100644 index 00000000000..1d5b8980d59 --- /dev/null +++ b/tests/lib/opaque-proxy-lifecycle-parser.test.ts @@ -0,0 +1,31 @@ +import {test, expect} from 'bun:test'; +import {parseConnectRequest, ConnectRequestError} from '../../src/lib/opaque-proxy-lifecycle/connect-request'; +const authority='target.invalid:443'; +const auth='Basic synthetic-local-session-fixture'; +const valid=`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${auth}`; +test('strict local parser accepts explicit CONNECT with matching single Host and capability',()=>{ + expect(parseConnectRequest(Buffer.from(valid))).toEqual({authority,authorization:auth}); +}); +const invalid: Array<[string,string]> = [ + ['missing Host', `CONNECT ${authority} HTTP/1.1\r\nProxy-Authorization: ${auth}`], + ['duplicate Host', valid+`\r\nHost: ${authority}`], + ['ambiguous Host', valid.replace(`Host: ${authority}`, 'Host: other.invalid:443')], + ['duplicate authorization', valid+`\r\nProxy-Authorization: ${auth}`], + ['folded line', valid+'\r\n extra: value'], + ['NUL value', valid+'\r\nX: abc\0def'], + ['bare LF', valid.replace('\r\n','\n')], + ['bare CR', valid+'\rX: bad'], + ['tab header value', valid+'\r\nX: abc\tdef'], + ['content length zero', valid+'\r\nContent-Length: 0'], + ['content length body', valid+'\r\nContent-Length: 5'], + ['transfer encoding', valid+'\r\nTransfer-Encoding: chunked'], + ['unsupported method', valid.replace('CONNECT','GET')], + ['authority path', valid.replace(authority,authority+'/path')], + ['request line over limit', `CONNECT ${'a'.repeat(2050)}:443 HTTP/1.1`], + ['line over limit', valid+'\r\nX: '+'a'.repeat(4096)], + ['header byte limit', valid+'\r\nX: '+'a'.repeat(8192)], + ['header line limit', valid+'\r\nX: a'.repeat(63)], +]; +for(const [name,header] of invalid) test(`strict local parser rejects ${name}`,()=>{ + expect(()=>parseConnectRequest(Buffer.from(header))).toThrow(ConnectRequestError); +}); diff --git a/tests/lib/opaque-proxy-lifecycle-transport.test.ts b/tests/lib/opaque-proxy-lifecycle-transport.test.ts new file mode 100644 index 00000000000..4103d7f8f6c --- /dev/null +++ b/tests/lib/opaque-proxy-lifecycle-transport.test.ts @@ -0,0 +1,44 @@ +import {test as bunTest} from 'bun:test'; +interface CleanupContext {after(cleanup: () => void | Promise): void;} +function test(name: string, run: (context: CleanupContext) => void | Promise) { + bunTest(name, async () => { + const cleanups: Array<() => void | Promise> = []; + try {await run({after: cleanup => {cleanups.push(cleanup);}});} + finally {for (const cleanup of cleanups.reverse()) await cleanup();} + }); +} +import assert from 'node:assert/strict'; +import net, {type AddressInfo, type Server, type Socket} from 'node:net'; +import {once} from 'node:events'; +import {createOwnedIngressManager} from '../../src/lib/opaque-proxy-lifecycle/managed-session'; +const authorization='Basic '+Buffer.from('session:synthetic-integration-only-capability').toString('base64'); +const pause=(ms: number)=>new Promise(r=>setTimeout(r,ms)); +async function until(fn: () => boolean){for(let n=0;n<200;n++){if(fn())return;await pause(5);}assert.fail('Did not settle');} +async function fixture(t: CleanupContext,{sessionMs=2000}={}){ + const sockets=new Set(),listeners: Server[]=[];let failedAttempts=0,proxyAttempts=0,targetConnections=0,targetBytes=0;const proxyHeaders: string[]=[]; + async function listen(handler: (socket: Socket) => void){const s=net.createServer({allowHalfOpen:true},socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));handler(socket);});listeners.push(s);s.listen(0,'127.0.0.1');await once(s,'listening');return (s.address() as AddressInfo).port;} + const targetPort=await listen(s=>{targetConnections++;s.on('data',b=>targetBytes+=b.length);s.pipe(s);}); + const firstPort=await listen(s=>{failedAttempts++;s.destroy();}); + const goodPort=await listen(client=>{ + proxyAttempts++;let bytes=Buffer.alloc(0); + function receive(chunk: Buffer){bytes=Buffer.concat([bytes,chunk]);const end=bytes.indexOf('\r\n\r\n');if(end<0)return;client.removeListener('data',receive);client.pause(); + proxyHeaders.push(bytes.subarray(0,end).toString()); + const upstream=net.connect(targetPort,'127.0.0.1');sockets.add(upstream);upstream.on('error',()=>client.destroy());upstream.on('close',()=>{sockets.delete(upstream);client.destroy();});client.on('close',()=>upstream.destroy()); + upstream.once('connect',()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');const head=bytes.subarray(end+4);if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client);}); + }client.on('data',receive); + }); + const owner={},authority=`127.0.0.1:${targetPort}`; + const manager=createOwnedIngressManager({owner,candidates:[{id:'closed-first',kind:'http',host:'127.0.0.1',port:firstPort},{id:'working-second',kind:'http',host:'127.0.0.1',port:goodPort}],allowTarget:a=>a===authority,ingress:{sessionMs,idleMs:1000,totalMs:2000},manager:{sessionMs:3000}}); + const lease=await manager.start(owner,{authorized:true,authorization});const clients: Socket[]=[]; + async function connect(auth: string){const client=Object.assign(net.connect(lease.port,lease.host),{output:''});clients.push(client);client.on('error',()=>{});client.on('data',b=>client.output+=b.toString());await once(client,'connect');client.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${auth}\r\n\r\n`);return client;} + t.after(async()=>{for(const s of clients)s.destroy();await manager.shutdown(owner);for(const s of sockets)s.destroy();await Promise.all(listeners.map(s=>new Promise(r=>s.close(r))));}); + return{manager,owner,lease,connect,proxyHeaders,counts:()=>({failedAttempts,proxyAttempts,targetConnections,targetBytes,sockets:sockets.size})}; +} +test('real composition: wrong capability has zero network dials; approved fallback carries opaque bytes once; stop reclaims generation',async t=>{ + const x=await fixture(t);const denied=await x.connect('Basic invalid');await until(()=>denied.destroyed);assert.match(denied.output,/407/);assert.deepEqual(x.counts(),{failedAttempts:0,proxyAttempts:0,targetConnections:0,targetBytes:0,sockets:0}); + const accepted=await x.connect(authorization);await until(()=>accepted.output.includes('200'));const body='opaque-TLS-like-synthetic-application-bytes';accepted.write(body);await until(()=>accepted.output.endsWith(body));assert.equal(x.counts().failedAttempts,1);assert.equal(x.counts().proxyAttempts,1);assert.equal(x.counts().targetConnections,1);assert.equal(x.counts().targetBytes,body.length);assert.doesNotMatch(x.proxyHeaders[0],/Proxy-Authorization/i); + await x.manager.stop(x.owner);await until(()=>accepted.destroyed&&x.counts().sockets===0);assert.equal(x.manager.status().state,'stopped');assert.equal(x.counts().failedAttempts,1);assert.equal(x.counts().proxyAttempts,1); +}); +test('real composition: ingress session expiry invalidates ready lease and closes active target tunnel without renewal',async t=>{ + const x=await fixture(t,{sessionMs:80});const accepted=await x.connect(authorization);await until(()=>accepted.output.includes('200'));accepted.write('one');await until(()=>accepted.output.endsWith('one'));await until(()=>accepted.destroyed&&x.counts().sockets===0&&x.manager.status().state==='stopped');await x.lease.stop();assert.equal(x.counts().proxyAttempts,1);assert.equal(x.counts().targetBytes,3); +}); diff --git a/tests/lib/opaque-proxy-lifecycle.test.ts b/tests/lib/opaque-proxy-lifecycle.test.ts new file mode 100644 index 00000000000..747228ed65b --- /dev/null +++ b/tests/lib/opaque-proxy-lifecycle.test.ts @@ -0,0 +1,190 @@ +import {test as bunTest} from 'bun:test'; +interface CleanupContext {after(cleanup: () => void | Promise): void;} +function test(name: string, run: (context: CleanupContext) => void | Promise) { + bunTest(name, async () => { + const cleanups: Array<() => void | Promise> = []; + try {await run({after: cleanup => {cleanups.push(cleanup);}});} + finally {for (const cleanup of cleanups.reverse()) await cleanup();} + }); +} +import assert from 'node:assert/strict'; +import net, {type AddressInfo, type Socket} from 'node:net'; +import {once} from 'node:events'; +import {createManagedIngress, type ManagedIngressOptions} from '../../src/lib/opaque-proxy-lifecycle/managed-ingress'; +import {createOwnerManager, type WorkerContext} from '../../src/lib/opaque-proxy-lifecycle/owner-manager'; +const authorization = 'Basic ' + Buffer.from('session:synthetic-test-only-capability-000000000000').toString('base64'); +const pause = (ms: number) => new Promise(r => setTimeout(r, ms)); +function deferred() {let resolve!: (value: T | PromiseLike) => void, reject!: (reason?: unknown) => void; const promise = new Promise((a,b) => {resolve=a; reject=b;}); return {promise, resolve, reject};} +async function until(fn: () => boolean) {for(let n=0; n<100;n++){if(fn()) return; await pause(5);} assert.fail('Condition did not settle');} +async function setup(t: CleanupContext, options: Partial={}) { + const peers = new Set(); let proxyCalls = 0; + const proxy = net.createServer({allowHalfOpen:true}, socket => { + peers.add(socket); socket.on('error',()=>{}); socket.on('close',()=>peers.delete(socket)); + socket.once('data', () => {proxyCalls++; socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); socket.pipe(socket);}); + }); + proxy.listen(0,'127.0.0.1'); await once(proxy,'listening'); + const ingress = createManagedIngress({authorization, candidates:[{id:'test',host:'127.0.0.1',port:(proxy.address() as AddressInfo).port}], allowTarget:a=>a==='test.invalid:443', idleMs:1000,totalMs:3000,...options}); + const address = await ingress.listen(); const clients: Socket[]=[]; + t.after(async()=>{for(const s of clients)s.destroy();await ingress.close(); for(const s of peers)s.destroy(); await new Promise(r=>proxy.close(r));}); + async function client(header: string | null=authorization, extra='') { + const s = Object.assign(net.connect(address.port, '127.0.0.1'), {output:''}); clients.push(s); s.on('error',()=>{}); s.on('data',b=>s.output+=b.toString()); + await once(s,'connect'); s.write(`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\n${header===null?'':`Proxy-Authorization: ${header}\r\n`}${extra}\r\n`); return s; + } + return {ingress,address,client,clients,calls:()=>proxyCalls}; +} +test('explicit owner, authorization, and loopback readiness are required', async()=>{ + const owner={}; const m=createOwnerManager({owner,createWorker:()=>({listen:()=>({address:'0.0.0.0',port:1}),close:()=>{}})}); + assert.throws(()=>m.start({},{authorized:true,authorization}),/owner/); + assert.throws(()=>m.start(owner,{authorization}),/authorization/); + // Deliberately invalid runtime values exercise the boundary's strict true check. + for (const authorized of [false, 'false', 1, Promise.resolve(true)]) assert.throws(()=>m.start(owner,{authorized:authorized as boolean,authorization}),/authorization/); + await assert.rejects(m.start(owner,{authorized:true,authorization}),/loopback/); + assert.equal(m.status().state,'stopped'); +}); +test('duplicate starts share readiness; old lease cannot stop new generation', async()=>{ + const owner={}, workers: Array<{opts: WorkerContext; closed: number}>=[]; + const m=createOwnerManager({owner,createWorker:opts=>{const w={opts,closed:0,listen:()=>({address:'127.0.0.1',port:1234}),close(){this.closed++;}};workers.push(w); return w;}}); + const a=m.start(owner,{authorized:true,authorization}), b=m.start(owner,{authorized:true,authorization}); assert.equal(a,b); + const first=await a; await first.stop(); const second=await m.start(owner,{authorized:true,authorization}); + await first.stop(); workers[0].opts.onExit(); assert.equal(m.status().generation,second.generation); assert.equal(workers[1].closed,0); + await m.shutdown(owner); assert.throws(()=>m.start(owner,{authorized:true,authorization}),/unavailable/); +}); +test('stop during startup and late readiness cannot publish over a new generation', async()=>{ + const owner={}, ready=deferred<{address: string; port: number}>(); let n=0; + const m=createOwnerManager({owner,createWorker:()=>({listen:()=>++n===1?ready.promise:{address:'127.0.0.1',port:2},close:()=>{}})}); + const old=m.start(owner,{authorized:true,authorization}); const oldResult=assert.rejects(old,/Stale/); + await pause(0); await m.stop(owner); const next=await m.start(owner,{authorized:true,authorization}); ready.resolve({address:'127.0.0.1',port:1}); await oldResult; + assert.equal(m.status().generation,next.generation); await m.stop(owner); +}); +test('startup timeout closes own worker; crashed generation has no automatic restart',async()=>{ + const owner={}; let calls=0,closed=0;let onExit!: WorkerContext['onExit']; + const m=createOwnerManager({owner,startupMs:20,createWorker:o=>{calls++;onExit=o.onExit;return{listen:()=>new Promise(()=>{}),close:()=>{closed++;}};}}); + await assert.rejects(m.start(owner,{authorized:true,authorization}),/Startup/); assert.equal(closed,1);onExit();assert.equal(calls,1); +}); +test('failed shutdown is bounded and blocks new starts instead of orphaning ownership',async()=>{ + const owner={};const m=createOwnerManager({owner,shutdownMs:20,createWorker:()=>({listen:()=>({address:'127.0.0.1',port:2}),close:()=>new Promise(()=>{})})}); + await m.start(owner,{authorized:true,authorization});await assert.rejects(m.stop(owner),/Shutdown/);assert.throws(()=>m.start(owner,{authorized:true,authorization}),/unavailable/); +}); +test('missing, wrong, duplicate capability denied before dialing; authorized bytes echoed',async t=>{ + const x=await setup(t); assert.equal(x.address.address,'127.0.0.1'); + for(const [header,extra] of [[null,''],['wrong',''],[authorization,`Proxy-Authorization: ${authorization}\r\n`]] as const){ + const s=await x.client(header,extra);await until(()=>s.destroyed);assert.match(s.output,/407/); + } + assert.equal(x.calls(),0);const good=await x.client();await until(()=>good.output.includes('200'));good.write('opaque-data');await until(()=>good.output.includes('opaque-data'));assert.equal(x.calls(),1); +}); +test('concurrency and queue are bounded; disconnect promotes only queued accepted client',async t=>{ + const x=await setup(t,{maxActive:1,maxQueued:1});const a=await x.client();await until(()=>a.output.includes('200')); + const b=await x.client();await until(()=>x.ingress.stats().queued===1);const c=await x.client();await until(()=>c.destroyed);assert.match(c.output,/503/);assert.equal(x.calls(),1); + a.destroy();await until(()=>b.output.includes('200'));assert.equal(x.calls(),2);assert.equal(x.ingress.stats().active,1); +}); +test('queue deadline removes waiting client without dialing',async t=>{ + const x=await setup(t,{maxActive:1,maxQueued:1,queueMs:25});const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>b.destroyed);assert.match(b.output,/503/);a.destroy();await pause(25);assert.equal(x.calls(),1); +}); +test('idle deadline and total session deadline close owned sockets',async t=>{ + const x=await setup(t,{idleMs:40,totalMs:120}); const a=await x.client();await until(()=>a.output.includes('200'));await until(()=>a.destroyed); + const b=await x.client();await until(()=>b.output.includes('200'));const timer=setInterval(()=>b.write('x'),10);await until(()=>b.destroyed);clearInterval(timer);await until(()=>x.ingress.stats().clients===0); +}); +test('raw clients also bounded and expired before authentication',async t=>{ + const x=await setup(t,{maxClients:1,idleMs:40}); const a=net.connect(x.address.port,'127.0.0.1');x.clients.push(a);a.on('error',()=>{});a.resume();await once(a,'connect'); + const b=net.connect(x.address.port,'127.0.0.1');x.clients.push(b);b.on('error',()=>{});b.resume();await until(()=>b.destroyed);await until(()=>a.destroyed);assert.equal(x.calls(),0); +}); +test('late successful dial after close is destroyed, without response or replay',async t=>{ + const late=deferred<{socket: {destroy(): void}}>();let calls=0,destroyed=0; + // This late-resolution branch only calls destroy; keep the fake narrow at the dialer boundary. + const x=await setup(t,{dial:()=>{calls++;return late.promise as unknown as ReturnType>;}});const a=await x.client();await until(()=>calls===1);await x.ingress.close(); + late.resolve({socket:{destroy(){destroyed++;}}});await until(()=>destroyed===1);assert.equal(calls,1);assert.equal(a.output,''); +}); +test('manager integrates real ingress; shutdown releases listener and active tunnel',async t=>{ + const x=await setup(t);await x.ingress.close(); const owner={}; + const m=createOwnerManager({owner,createWorker:({authorization})=>createManagedIngress({authorization,candidates:[{id:'unused',host:'127.0.0.1',port:1}],allowTarget:()=>true})}); + const lease=await m.start(owner,{authorized:true,authorization});const s=net.connect(lease.port,lease.host);s.on('error',()=>{});s.resume();await once(s,'connect');await m.shutdown(owner);await until(()=>s.destroyed);assert.equal(m.status().state,'stopped'); +}); +test('policy rejection and thrown policy never dial',async t=>{ + for(const allowTarget of [()=>false,()=>{throw Error('deny');}]){ + const x=await setup(t,{allowTarget});const a=await x.client();await until(()=>a.destroyed);assert.match(a.output,/403/);assert.equal(x.calls(),0); + } +}); +test('half-close flushes opaque payload before session disposal',async t=>{ + const x=await setup(t);const a=await x.client();await until(()=>a.output.includes('200'));const payload='z'.repeat(1024*1024);a.end(payload);await until(()=>a.destroyed);assert.equal(a.output.split('\r\n\r\n')[1],payload);assert.equal(x.calls(),1); +}); +test('close aborts establishing and queued clients without promoting or replaying',async t=>{ + let calls=0;let signal: AbortSignal | undefined; + const x=await setup(t,{maxActive:1,maxQueued:2,dial:(_a,_c,opts)=>{calls++;signal=opts.signal;return new Promise(()=>{});}}); + const a=await x.client();await until(()=>calls===1);const b=await x.client();await until(()=>x.ingress.stats().queued===1);await x.ingress.close();await until(()=>a.destroyed&&b.destroyed);assert.ok(signal);assert.equal(signal.aborted,true);assert.equal(calls,1);assert.equal(x.ingress.stats().clients,0); +}); +test('establishment has absolute deadline even for a non-cooperating dialer',async t=>{ + let signal: AbortSignal | undefined; + const x=await setup(t,{establishMs:30,dial:(_a,_c,opts)=>{signal=opts.signal;return new Promise(()=>{});}});const a=await x.client();await until(()=>a.destroyed);assert.ok(signal);assert.equal(signal.aborted,true);assert.equal(x.ingress.stats().active,0); +}); +test('crash after ready closes only owned generation and never restarts',async()=>{ + const owner={};let callback!: WorkerContext['onExit'];let closed=0,created=0; + const m=createOwnerManager({owner,createWorker:({onExit})=>{created++;callback=onExit;return {listen:()=>({address:'127.0.0.1',port:3}),close:()=>{closed++;}};}}); + await m.start(owner,{authorized:true,authorization});callback();await until(()=>m.status().state==='stopped');assert.equal(closed,1);assert.equal(created,1); +}); +test('session capability expires with listener and active clients, zero automatic renewal',async t=>{ + const x=await setup(t,{sessionMs:45});const a=await x.client();await until(()=>a.output.includes('200'));await until(()=>a.destroyed);assert.equal(x.ingress.server.listening,false);assert.equal(x.calls(),1); + const owner={};let created=0; + const m=createOwnerManager({owner,sessionMs:20,createWorker:()=>{created++;return {listen:()=>({address:'127.0.0.1',port:2}),close:()=>{}};}}); + await m.start(owner,{authorized:true,authorization});await until(()=>m.status().state==='stopped');assert.equal(created,1); +}); +test('reverse half-close preserves slow upstream upload after server FIN',async t=>{ + const payload=Buffer.alloc(4*1024*1024,0x78);let received=0;let resolveDone!: () => void;const done=new Promise(r=>resolveDone=r);const peers=new Set(); + const echo=net.createServer({allowHalfOpen:true},s=>{peers.add(s);s.on('error',()=>{});s.once('data',()=>{ + s.end('HTTP/1.1 200 Connection Established\r\n\r\n');s.on('data',b=>{received+=b.length;s.pause();setTimeout(()=>s.resume(),2);});s.once('end',resolveDone); + });});echo.listen(0,'127.0.0.1');await once(echo,'listening'); + const ingress=createManagedIngress({authorization,candidates:[{id:'reverse',host:'127.0.0.1',port:(echo.address() as AddressInfo).port}],allowTarget:()=>true,totalMs:5000});const address=await ingress.listen(); + const client=net.connect({port:address.port,host:'127.0.0.1',allowHalfOpen:true});client.on('error',()=>{}); + t.after(async()=>{client.destroy();await ingress.close();for(const s of peers)s.destroy();await new Promise(r=>echo.close(r));}); + await once(client,'connect');client.write(`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\nProxy-Authorization: ${authorization}\r\n\r\n`);await once(client,'data');await once(client,'end');client.end(payload);await done;assert.equal(received,payload.length); +}); +test('synchronous factory exit leaves no timer keeping process alive',async()=>{ + const {execFile}=await import('node:child_process'); + const moduleURL=new URL('../../src/lib/opaque-proxy-lifecycle/owner-manager.ts',import.meta.url).href; + await new Promise((resolve,reject)=>execFile(process.execPath,['--eval',` + import {createOwnerManager} from ${JSON.stringify(moduleURL)}; + const owner={};const manager=createOwnerManager({owner,createWorker:({onExit})=>{onExit();return {listen(){throw Error('must not start')},close(){}};}}); + try {await manager.start(owner,{authorized:true,authorization:${JSON.stringify(authorization)}});process.exitCode=2;}catch{} + `],{timeout:1500},error=>error?reject(error):resolve())); +}); +test('composition forwards real ingress termination to matching owner generation',async()=>{ + const {createOwnedIngressManager}=await import('../../src/lib/opaque-proxy-lifecycle/managed-session');const owner={}; + const m=createOwnedIngressManager({owner,candidates:[{id:'unused',host:'127.0.0.1',port:1}],allowTarget:()=>true,ingress:{sessionMs:30},manager:{sessionMs:1000}}); + const lease=await m.start(owner,{authorized:true,authorization});assert.equal(m.status().state,'ready');await until(()=>m.status().state==='stopped');await lease.stop(); +}); +test('fragmented CONNECT authenticates only at header end and preserves early bytes',async t=>{ + const x=await setup(t);const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});let output='';s.on('data',b=>output+=b.toString());await once(s,'connect'); + s.write('CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\n');await pause(20);assert.equal(x.calls(),0); + s.write(`Proxy-Authorization: ${authorization}\r\n\r\nearly-payload`);await until(()=>output.endsWith('early-payload'));assert.equal(x.calls(),1); +}); +test('absolute raw deadline defeats trickle activity; incomplete EOF frees slot',async t=>{ + const x=await setup(t,{idleMs:40,totalMs:80});const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});s.resume();await once(s,'connect');const trickle=setInterval(()=>s.write('x'),5); + try {await until(()=>s.destroyed);}finally{clearInterval(trickle);}assert.equal(x.calls(),0);assert.equal(x.ingress.stats().clients,0); + const eof=net.connect(x.address.port,'127.0.0.1');x.clients.push(eof);eof.on('error',()=>{});eof.resume();await once(eof,'connect');eof.end('CONNECT partial');await until(()=>eof.destroyed);assert.equal(x.ingress.stats().clients,0); +}); +test('ambiguous and oversized raw headers are rejected before any actual proxy dial',async t=>{ + const x=await setup(t);const base=`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\nProxy-Authorization: ${authorization}`; + for(const request of [base+'\r\nHost: test.invalid:443\r\n\r\n',base+'\r\nContent-Length: 0\r\n\r\n',base+'\r\nTransfer-Encoding: chunked\r\n\r\n',base+'\r\n Folded: a\r\n\r\n',base+'\r\nX: a\0b\r\n\r\n',base.replace('\r\n','\n')+'\r\n\r\n',base+'\r\nX: '+'x'.repeat(8500)]){ + const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});let output='';s.on('data',b=>output+=b.toString());await once(s,'connect');s.end(request);await until(()=>s.destroyed);assert.match(output,/HTTP\/1.1 (400|431)/); + } + assert.equal(x.calls(),0); +}); +test('reentrant synchronous factory startup rejects rather than returning missing readiness',async()=>{ + const owner={};let manager: ReturnType; + manager=createOwnerManager({owner,createWorker:()=>{assert.throws(()=>manager.start(owner,{authorized:true,authorization}),/Reentrant/);return {listen:()=>({address:'127.0.0.1',port:2}),close:()=>{}};}}); + await manager.start(owner,{authorized:true,authorization});await manager.stop(owner); +}); +test('queued authorization is rechecked before physical dial after policy withdrawal',async t=>{ + let allowed=true;const x=await setup(t,{maxActive:1,maxQueued:1,allowTarget:()=>allowed});const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>x.ingress.stats().queued===1);allowed=false;a.destroy();await until(()=>b.destroyed);assert.match(b.output,/502/);assert.equal(x.calls(),1); +}); +test('invalid async policy rejection is observed and denied without dialing',async t=>{ + // Invalid async policies intentionally cross the synchronous policy API boundary. + const allowTarget=(async()=>{throw new Error('synthetic denied policy');}) as unknown as ManagedIngressOptions['allowTarget']; + const x=await setup(t,{allowTarget});const a=await x.client();await until(()=>a.destroyed);assert.match(a.output,/403/);await pause(10);assert.equal(x.calls(),0); +}); +test('queued policy changing to rejected promise cannot leak rejection or dial',async t=>{ + let withdrawn=false; + // The policy becomes invalid at runtime after this connection enters the queue. + const allowTarget=(()=>withdrawn?Promise.reject(new Error('synthetic policy withdrawn')):true) as ManagedIngressOptions['allowTarget']; + const x=await setup(t,{maxActive:1,maxQueued:1,allowTarget}); + const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>x.ingress.stats().queued===1);withdrawn=true;a.destroy();await until(()=>b.destroyed);assert.match(b.output,/502/);await pause(10);assert.equal(x.calls(),1); +}); diff --git a/tests/lib/opaque-proxy-route-refresh.test.ts b/tests/lib/opaque-proxy-route-refresh.test.ts new file mode 100644 index 00000000000..16d8be87fca --- /dev/null +++ b/tests/lib/opaque-proxy-route-refresh.test.ts @@ -0,0 +1,113 @@ +import {afterEach, test} from 'bun:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import {once} from 'node:events'; +import {createOwnedIngressManager} from '../../src/lib/opaque-proxy-lifecycle/managed-session'; +import {snapshotProxyRoutes} from '../../src/lib/opaque-proxy-tunnel'; +import {TunnelFixtureScope, httpProxy, target, allowTarget, until, wait} from '../helpers/opaque-proxy-tunnel'; +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); +const authorization = 'Basic ' + Buffer.from('session:synthetic-route-refresh-capability').toString('base64'); +async function fixture(maxActive = 16) { + const first = await httpProxy(scope), second = await httpProxy(scope), owner = {}; + const manager = createOwnedIngressManager({owner, candidates:[first.candidate], allowTarget, + ingress:{maxActive, sessionMs:10000, totalMs:10000, idleMs:5000}, manager:{sessionMs:15000}}); + scope.after(() => manager.shutdown(owner)); + const lease = await manager.start(owner, {authorized:true, authorization}); + async function connect(auth = authorization) { + const socket = scope.own(net.connect(lease.port, lease.host)); + let output = ''; + socket.on('data', bytes => output += bytes.toString()); + await once(socket, 'connect'); + socket.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: ${auth}\r\n\r\n`); + return {socket, output:() => output}; + } + return {first, second, owner, manager, lease, connect}; +} +test('refresh keeps endpoint and established stream, routes only the next connection', async () => { + const x = await fixture(), old = await x.connect(); + await until(() => old.output().includes('200')); + old.socket.write('before'); await until(() => old.output().endsWith('before')); + assert.equal(x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]), 1); + assert.equal(await x.manager.start(x.owner, {authorized:true, authorization}), x.lease); + old.socket.write('after'); await until(() => old.output().endsWith('beforeafter')); + const next = await x.connect(); await until(() => next.output().includes('200')); + next.socket.write('new'); await until(() => next.output().endsWith('new')); + assert.equal(x.first.seen.calls, 1); assert.equal(x.first.seen.bytes, 11); + assert.equal(x.second.seen.calls, 1); assert.equal(x.second.seen.bytes, 3); +}); +test('queued CONNECT retains its accepted route across refresh', async () => { + const x = await fixture(1), old = await x.connect(); await until(() => old.output().includes('200')); + const queued = await x.connect(); await wait(30); + assert.equal(queued.output(), ''); + x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]); + old.socket.destroy(); + await until(() => queued.output().includes('200')); + assert.equal(x.first.seen.calls, 2); assert.equal(x.second.seen.calls, 0); + queued.socket.destroy(); + const fresh = await x.connect(); await until(() => fresh.output().includes('200')); + assert.equal(x.second.seen.calls, 1); +}); +test('wrong owner, stale revision and invalid replacement cannot change the accepted route', async () => { + const x = await fixture(); + assert.throws(() => x.manager.updateRoutes({}, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]), /Wrong owner/); + assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:1}, [x.second.candidate]), /Stale/); + assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, []), /candidates/); + assert.equal(x.manager.routeVersion().revision, 0); + const client = await x.connect(); await until(() => client.output().includes('200')); + assert.equal(x.first.seen.calls, 1); assert.equal(x.second.seen.calls, 0); +}); +test('caller route edits cannot alter a committed refresh, and shutdown cannot be renewed', async () => { + const x = await fixture(); + const routes = [{...x.second.candidate}]; + x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, routes); routes[0].port = x.first.candidate.port; + const client = await x.connect(); await until(() => client.output().includes('200')); + assert.equal(x.second.seen.calls, 1); assert.equal(x.first.seen.calls, 0); + await x.manager.shutdown(x.owner); + assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:1}, [x.first.candidate]), /unavailable/); + assert.equal(x.manager.routeVersion().revision, 1); +}); +test('TLS trust bytes are owned by a route snapshot', () => { + const certificate = Buffer.from('synthetic-trust-bytes'); + const routes = snapshotProxyRoutes([{id:'tls', kind:'https', host:'proxy.invalid', port:443, proxyCa:certificate}]); + certificate.fill(0); + assert.equal(routes[0].proxyCa?.toString(), 'synthetic-trust-bytes'); + assert.ok(Object.isFrozen(routes)); assert.ok(Object.isFrozen(routes[0])); +}); +test('route refresh is allowed before activation without opening a listener', async () => { + const first = await httpProxy(scope), second = await httpProxy(scope), owner = {}; + const manager = createOwnedIngressManager({owner, candidates:[first.candidate], allowTarget}); + scope.after(() => manager.shutdown(owner)); + assert.equal(manager.updateRoutes(owner, manager.routeVersion(), [second.candidate]), 1); + assert.equal(manager.status().state, 'stopped'); + assert.equal(first.seen.calls + second.seen.calls, 0); +}); + +test('standard proxy challenge discloses no capability and a subsequent authorized connection succeeds', async () => { + const x = await fixture(), denied = await x.connect('Basic invalid'); + await until(() => denied.output().includes('407')); + assert.match(denied.output(), /Proxy-Authenticate: Basic realm="opencodex-session"/); + assert.ok(!denied.output().includes(authorization)); + assert.equal(x.first.seen.calls, 0); + const accepted = await x.connect(); await until(() => accepted.output().includes('200')); + assert.equal(x.first.seen.calls, 1); +}); + +test('an update observed before restart cannot mutate the new capability generation', async () => { + const x = await fixture(), observed = x.manager.routeVersion(); + await x.manager.stop(x.owner); + const next = await x.manager.start(x.owner, {authorized:true, authorization:authorization + 'next'}); + assert.notEqual(next.generation, observed.generation); + assert.throws(() => x.manager.updateRoutes(x.owner, observed, [x.second.candidate]), /Stale/); + assert.equal(x.manager.routeVersion().revision, 0); +}); + +test('an update observed while stopped cannot survive an intervening start and stop', async () => { + const proxy = await httpProxy(scope), owner = {}; + const manager = createOwnedIngressManager({owner, candidates:[proxy.candidate], allowTarget}); + scope.after(() => manager.shutdown(owner)); + const observed = manager.routeVersion(); + await manager.start(owner, {authorized:true, authorization}); await manager.stop(owner); + assert.equal(manager.status().generation, null); + assert.throws(() => manager.updateRoutes(owner, observed, [proxy.candidate]), /Stale/); +}); diff --git a/tests/lib/opaque-proxy-session-protocol.test.ts b/tests/lib/opaque-proxy-session-protocol.test.ts new file mode 100644 index 00000000000..d754f42ee25 --- /dev/null +++ b/tests/lib/opaque-proxy-session-protocol.test.ts @@ -0,0 +1,155 @@ +import { afterEach, test } from "bun:test"; +import assert from "node:assert/strict"; +import net, { type Socket } from "node:net"; +import tls from "node:tls"; +import { once } from "node:events"; +import { createOwnedIngressManager } from "../../src/lib/opaque-proxy-lifecycle/managed-session"; +import type { GenerationLease } from "../../src/lib/opaque-proxy-lifecycle/owner-manager"; +import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; +import { deadProxy, echo, listen, readBytes, TunnelFixtureScope, until } from "../helpers/opaque-proxy-tunnel"; +import { ca, origin, protocolProxy, proxyKinds, securedClient } from "../helpers/opaque-proxy-tunnel-protocol"; + +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); +const capability = "Basic " + Buffer.from("session:synthetic-full-tls-integration-capability").toString("base64"); + +async function session(authority: string, candidates: readonly OpaqueProxyRoute[], sessionMs = 5000) { + const owner = {}; + const manager = createOwnedIngressManager({ owner, candidates, allowTarget: value => value === authority, + ingress: { sessionMs, totalMs: 5000, idleMs: 3000, establishMs: 2000 }, + manager: { sessionMs: 10000, startupMs: 2000, shutdownMs: 2000 } }); + scope.after(() => manager.shutdown(owner)); + const lease = await manager.start(owner, { authorized: true, authorization: capability }); + return { owner, manager, lease }; +} + +function connectHead(authority: string, authorization = capability): Buffer { + return Buffer.from(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${authorization}\r\n\r\n`); +} + +async function responseHead(socket: Socket): Promise { + return new Promise((resolve, reject) => { + let buffer: Buffer = Buffer.alloc(0); + const cleanup = () => { socket.pause(); socket.off("data", data); socket.off("error", fail); socket.off("end", ended); }; + const fail = (error: Error) => { cleanup(); reject(error); }; + const ended = () => fail(new Error("CONNECT response ended before headers")); + const data = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) { if (buffer.length > 8192) fail(new Error("oversized fixture response")); return; } + cleanup(); + if (buffer.length > end + 4) socket.unshift(buffer.subarray(end + 4)); + resolve(buffer.subarray(0, end).toString("latin1")); + }; + socket.on("data", data); socket.once("error", fail); socket.once("end", ended); socket.resume(); + }); +} + +async function connectClient(lease: GenerationLease, authority: string, authorization = capability) { + const socket = scope.own(net.connect({ host: lease.host, port: lease.port, allowHalfOpen: true })); + await once(socket, "connect"); + const head = responseHead(socket); socket.write(connectHead(authority, authorization)); + return { socket, header: await head }; +} + +/** A test-only CONNECT client bridge. It strips only CONNECT's 200 response; + * all following bytes, including ClientHello, remain encrypted and untouched. + */ +async function eagerTlsClient(lease: GenerationLease, authority: string) { + let earlyBytes = 0; + const server = net.createServer({ allowHalfOpen: true }, client => { + client.on("error", () => {}); + client.once("data", (first: Buffer) => { + client.pause(); earlyBytes = first.length; + void (async () => { + assert.equal(first[0], 0x16, "first coalesced bytes are a genuine TLS handshake record"); + const upstream = scope.own(net.connect({ host: lease.host, port: lease.port, allowHalfOpen: true })); + client.once("error", () => upstream.destroy()); + upstream.once("error", () => client.destroy()); + client.once("close", () => { if (!client.readableEnded) upstream.destroy(); }); + upstream.once("close", () => { if (!upstream.readableEnded) client.destroy(); }); + await once(upstream, "connect"); + const head = responseHead(upstream); + // One write deliberately coalesces the local CONNECT head and ClientHello. + upstream.write(Buffer.concat([connectHead(authority), first])); + assert.match(await head, /^HTTP\/1\.1 200 /); + client.pipe(upstream); upstream.pipe(client); + })().catch(error => client.destroy(error instanceof Error ? error : undefined)); + }); + }); + const port = await listen(scope, server); + const options = { host: "127.0.0.1", port, servername: "origin.fixture.invalid", ca, rejectUnauthorized: true, allowHalfOpen: true }; + const socket = scope.own(tls.connect(options)); + await once(socket, "secureConnect"); assert.equal(socket.authorized, true); assert.ok(earlyBytes > 0); + return { socket, earlyBytes }; +} + +for (const kind of proxyKinds) { + test(`managed ${kind}: approved fallback plus coalesced ClientHello preserves verified TLS and half-close`, async () => { + const end = await origin(scope, { slow: true }); + const proxy = await protocolProxy(scope, kind, { destination: end, fragmented: true }); + const x = await session(end.authority, [await deadProxy(kind), proxy.candidate]); + // A second start must reuse the same generation/port without duplicating listeners. + assert.equal(await x.manager.start(x.owner, { authorized: true, authorization: capability }), x.lease); + const { socket } = await eagerTlsClient(x.lease, end.authority); + const payload = Buffer.alloc(1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + await echo(socket, payload, true); + assert.deepEqual(Buffer.concat(end.received), payload); + assert.equal(proxy.seen.auth, 1); assert.equal(proxy.seen.connections, 1); assert.equal(end.calls(), 1); + assert.equal(Buffer.concat(proxy.seen.opaque).includes(payload.subarray(0, 256)), false); + assert.equal(Buffer.concat(end.received).includes(Buffer.from(capability)), false); + await x.manager.stop(x.owner); assert.equal(x.manager.status().state, "stopped"); + await until(() => proxy.sockets.size === 0); + }, 10000); + + test(`managed ${kind}: wrong capability sends no handshake; stop closes an active target TLS session`, async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); + const x = await session(end.authority, [proxy.candidate]); + const denied = await connectClient(x.lease, end.authority, "Basic invalid-fixture"); + assert.match(denied.header, /^HTTP\/1\.1 407 /); denied.socket.destroy(); + assert.equal(proxy.seen.connections, 0); assert.equal(end.calls(), 0); + const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); + const secure = await securedClient(scope, accepted.socket); + const payload = Buffer.from("one-unreplayed-operation"); const answer = readBytes(secure, payload.length); secure.write(payload); + assert.deepEqual(await answer, payload); + const closed = new Promise(resolve => { secure.once("close", resolve); secure.once("end", resolve); }); secure.resume(); + await x.manager.stop(x.owner); await closed; + assert.equal(x.manager.status().state, "stopped"); assert.equal(proxy.seen.connections, 1); + assert.equal(end.calls(), 1); assert.deepEqual(Buffer.concat(end.received), payload); + await until(() => proxy.seen.clientEnds === 1); + await until(() => proxy.sockets.size === 0); + }, 7000); +} + +for (const kind of ["https", "socks5h"] as const) { + test(`managed ${kind}: session expiry closes target TLS without reconnect or replay`, async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); + const x = await session(end.authority, [proxy.candidate], 600); + const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); + const secure = await securedClient(scope, accepted.socket); + const payload = Buffer.from("expiry-operation"); const answer = readBytes(secure, payload.length); secure.write(payload); assert.deepEqual(await answer, payload); + const closed = new Promise(resolve => { secure.once("close", resolve); secure.once("end", resolve); }); secure.resume(); + await closed; await until(() => x.manager.status().state === "stopped"); + await until(() => proxy.sockets.size === 0); + assert.equal(proxy.seen.connections, 1); assert.equal(end.calls(), 1); assert.deepEqual(Buffer.concat(end.received), payload); + }, 5000); +} + +test("managed HTTPS: untrusted proxy certificate is rejected before origin or fallback", async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, "https", { destination: end }); + const backup = await protocolProxy(scope, "http", { destination: end }); + assert.equal(proxy.candidate.kind, "https"); + const bad = { ...proxy.candidate, proxyCa: undefined } as OpaqueProxyRoute; + const x = await session(end.authority, [bad, backup.candidate]); + const accepted = await connectClient(x.lease, end.authority); + assert.match(accepted.header, /^HTTP\/1\.1 502 /); accepted.socket.destroy(); + assert.equal(backup.seen.connections, 0); assert.equal(end.calls(), 0); +}, 5000); + +test("managed HTTPS: target hostname verification remains mandatory through the full session", async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, "https", { destination: end }); + const x = await session(end.authority, [proxy.candidate]); + const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); + await assert.rejects(securedClient(scope, accepted.socket, "wrong.fixture.invalid"), /certificate|hostname|altnames/i); + assert.equal(proxy.seen.connections, 1); assert.equal(end.received.length, 0); +}, 5000); diff --git a/tests/lib/opaque-proxy-tunnel-protocol.test.ts b/tests/lib/opaque-proxy-tunnel-protocol.test.ts new file mode 100644 index 00000000000..a54006d7222 --- /dev/null +++ b/tests/lib/opaque-proxy-tunnel-protocol.test.ts @@ -0,0 +1,223 @@ +import { afterEach, test } from "bun:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { dialTunnel, type OpaqueProxyRoute, type OpaqueTunnelOptions } from "../../src/lib/opaque-proxy-tunnel"; +import { deadProxy, digest, echo, listen, readBytes, TunnelFixtureScope, until, wait } from "../helpers/opaque-proxy-tunnel"; +import { ca, credentials, origin, protocolProxy, proxyKinds, securedClient } from "../helpers/opaque-proxy-tunnel-protocol"; + +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); +const dialAllowed = (authority: string, routes: readonly OpaqueProxyRoute[], options: Omit = {}) => + dialTunnel(authority, routes, { ...options, allowTarget: value => value === authority }); + +for (const kind of proxyKinds) { + test(`${kind}: real authenticated tunnel carries independently verified opaque target TLS`, async () => { + const end = await origin(scope); const p = await protocolProxy(scope, kind, { destination: end, fragmented: true }); + const { socket: raw, routeId } = await dialAllowed(end.authority, [p.candidate]); assert.equal(routeId, p.candidate.id); + const secured = await securedClient(scope, raw); const payload = Buffer.from('origin-only plaintext marker\0\xff'); await echo(secured, payload); + assert.equal(p.seen.auth, 1); assert.deepEqual(p.seen.targets, [{ host: 'origin.fixture.invalid', port: end.port }]); + assert.deepEqual(Buffer.concat(end.received), payload); + assert.equal(Buffer.concat(p.seen.opaque).includes(payload), false, 'proxy must see encrypted target data'); + assert.equal(Buffer.concat(end.received).includes(Buffer.from('Proxy-Authorization')), false); + }, 7000); + test(`${kind}: explicit dead route falls back before establishment`, async () => { + const end = await origin(scope), p = await protocolProxy(scope, kind, { destination: end }); + const { socket, routeId } = await dialAllowed(end.authority, [await deadProxy(kind), p.candidate]); scope.own(socket); + assert.equal(routeId, p.candidate.id); assert.equal(p.seen.handshakes, 1); + }, 5000); + test(`${kind}: authentication refusal cannot switch to a backup`, async () => { + const denied = await protocolProxy(scope, kind, { mode: 'refuse' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [denied.candidate, backup.candidate]), error => { + assert.equal((error as { retryable: boolean }).retryable, false); + const diagnostic = String(error) + JSON.stringify(error); + assert.equal(diagnostic.includes(credentials.username), false); + assert.equal(diagnostic.includes(credentials.password), false); + assert.equal(diagnostic.includes(Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")), false); + return true; + }); + assert.equal(backup.seen.connections, 0); await until(() => denied.sockets.size === 0); + }, 5000); + test(`${kind}: no-response per-attempt timeout may try explicit backup`, async () => { + const end = await origin(scope), hung = await protocolProxy(scope, kind, { mode: 'hang' }), backup = await protocolProxy(scope, kind, { destination: end }); + const result = await dialAllowed(end.authority, [hung.candidate, backup.candidate], { timeoutMs: 200 }); scope.own(result.socket); + assert.equal(result.routeId, backup.candidate.id); await until(() => hung.sockets.size === 0); + }, 5000); + test(`${kind}: partial response timeout is terminal`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'partial' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate], { timeoutMs: 150 }), error => !(error as { retryable: boolean }).retryable); + assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + }, 5000); + test(`${kind}: overall deadline stops the route chain and closes sockets`, async () => { + const one = await protocolProxy(scope, kind, { mode: 'hang' }), two = await protocolProxy(scope, kind, { mode: 'hang' }), three = await protocolProxy(scope, kind, { mode: 'hang' }); + const start = performance.now(); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [one.candidate, two.candidate, three.candidate], { timeoutMs: 300, totalTimeoutMs: 500 }), { code: 'TOTAL_TIMEOUT' }); + assert.ok(performance.now() - start < 2_000); assert.equal(one.seen.connections, 1); assert.equal(two.seen.connections, 1); assert.equal(three.seen.connections, 0); + await until(() => one.sockets.size === 0 && two.sockets.size === 0); + }, 5000); + test(`${kind}: caller cancellation cannot switch routes`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'hang' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }), controller = new AbortController(); + const result = dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate], { signal: controller.signal }); + const assertion = assert.rejects(result, { code: 'ABORTED' }); await until(() => p.seen.handshakes === 1); controller.abort(); await assertion; + assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + }, 5000); + test(`${kind}: target TLS 2 MiB backpressure and half-close preserve bytes`, async () => { + const end = await origin(scope, { slow: true }), p = await protocolProxy(scope, kind, { destination: end }); + const { socket } = await dialAllowed(end.authority, [p.candidate]); const secured = await securedClient(scope, socket); + const payload = Buffer.alloc(2 * 1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + await echo(secured, payload, true); assert.equal(digest(Buffer.concat(end.received)), digest(payload)); + }, 12000); + test(`${kind}: failure after opaque target TLS and application bytes never replays`, async () => { + const end = await origin(scope, { drop: true }), p = await protocolProxy(scope, kind, { destination: end }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); + const { socket } = await dialAllowed(end.authority, [p.candidate, backup.candidate]); const secured = await securedClient(scope, socket); + const closed = new Promise(resolve => { secured.once('end', resolve); secured.once('close', resolve); secured.once('error', () => resolve()); }); + const payload = Buffer.from('single-committed-operation'); secured.write(payload); secured.resume(); await closed; secured.destroy(); + assert.deepEqual(Buffer.concat(end.received), payload); assert.equal(end.calls(), 1); assert.equal(backup.seen.connections, 0); + }, 5000); +} + +for (const mode of ['untrusted', 'wrong-name']) { + test(`HTTPS proxy ${mode} certificate is terminal and cannot fall back`, async () => { + const p = await protocolProxy(scope, 'https', { mode: 'hang' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); + assert.equal(p.candidate.kind, 'https'); + if (p.candidate.kind !== 'https') throw new Error('HTTPS fixture required'); + const bad = { ...p.candidate, ...(mode === 'untrusted' ? { proxyCa: undefined } : { proxyServername: 'wrong.fixture.invalid' }) }; + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [bad, backup.candidate]), error => (error as { code: string }).code === 'PROXY_TLS' && !(error as { retryable: boolean }).retryable); + assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + }, 5000); +} +test('target TLS certificate still rejects a wrong hostname through a verified HTTPS proxy', async () => { + const end = await origin(scope), p = await protocolProxy(scope, 'https', { destination: end }); + const { socket } = await dialAllowed(end.authority, [p.candidate]); + await assert.rejects(securedClient(scope, socket, 'wrong.fixture.invalid'), /certificate|hostname|altnames/i); +}, 5000); +for (const kind of ['socks5', 'socks5h'] as const) { + test(`${kind}: early opaque bytes beyond 64 KiB survive CONNECT response coalescing`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'early-large' }); + const { socket } = await dialAllowed('origin.fixture.invalid:443', [p.candidate]); scope.own(socket); + assert.deepEqual(await readBytes(socket, 192 * 1024), Buffer.alloc(192 * 1024, 0xa5)); + }, 5000); + test(`${kind}: explicit SOCKS policy refusal never falls back`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'policy' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate]), { code: 'SOCKS_REFUSED', retryable: false }); + assert.equal(backup.seen.connections, 0); + }, 5000); +} +test('stalled HTTPS proxy TLS handshake is bounded, terminal, and cancellable', async () => { + const plain = await protocolProxy(scope, 'http', { mode: 'hang' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); + const candidate = { id: plain.candidate.id, kind: 'https' as const, host: '127.0.0.1', port: plain.candidate.port, proxyCa: ca, proxyServername: 'proxy.fixture.invalid' }; + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [candidate, backup.candidate], { timeoutMs: 60 }), error => (error as { code: string }).code === 'TIMEOUT' && !(error as { retryable: boolean }).retryable); + assert.equal(backup.seen.connections, 0); await until(() => plain.sockets.size === 0); + const abort = new AbortController(), pending = dialAllowed('origin.fixture.invalid:443', [candidate], { signal: abort.signal }); + const assertion = assert.rejects(pending, { code: 'ABORTED' }); await until(() => plain.seen.connections === 2); abort.abort(); await assertion; await until(() => plain.sockets.size === 0); +}, 5000); +test('configuration rejects unsupported protocols and invalid credential sizes before dialing', async () => { + const route = { id: 'x', host: '127.0.0.1', port: 1 }; + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'direct' } as unknown as OpaqueProxyRoute]), { code: 'CONFIG' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5', username: 'x'.repeat(256), password: 'p' }]), { code: 'CONFIG' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5h', username: 'u', password: '' }]), { code: 'CONFIG' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [route], { totalTimeoutMs: 0 }), { code: 'CONFIG' }); +}); + + +for (const kind of ["http", "https"] as const) { + test(`${kind}: revoked target authorization during connection sends no CONNECT or credentials`, async () => { + let authorized = true; + const proxy = await protocolProxy(scope, kind, { mode: "hang", onAccepted: () => { authorized = false; } }); + const backup = await protocolProxy(scope, "http", { mode: "hang" }); + const authority = "origin.fixture.invalid:443"; + await assert.rejects(dialTunnel(authority, [proxy.candidate, backup.candidate], { + allowTarget: value => value === authority && authorized, + }), { code: "TARGET", retryable: false }); + assert.equal(proxy.seen.connections, 1); + assert.equal(proxy.seen.applicationBytes, 0); + assert.equal(proxy.seen.auth, 0); + assert.equal(proxy.seen.targets.length, 0); + assert.equal(backup.seen.connections, 0); + await until(() => proxy.sockets.size === 0); + }, 5_000); +} + +for (const kind of ["socks5", "socks5h"] as const) { + for (const phase of ["credentials", "target"] as const) { + test(`${kind}: revocation before ${phase} write fails closed`, async () => { + let authorized = true, stage = 0; + const received: Buffer[] = []; + const server = net.createServer(socket => { + let pending = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + received.push(chunk); + pending = Buffer.concat([pending, chunk]); + if (stage === 0 && pending.length >= 4) { + assert.deepEqual([...pending], [5, 2, 0, 2]); + pending = Buffer.alloc(0); + stage++; + if (phase === "credentials") authorized = false; + socket.write(Buffer.from([5, 2])); + } else if (stage === 1 && pending.length >= 3 + Buffer.byteLength(credentials.username + credentials.password)) { + assert.equal(pending[0], 1); + assert.equal(pending.subarray(2, 2 + pending[1]).toString(), credentials.username); + stage++; + authorized = false; + socket.write(Buffer.from([1, 0])); + } + }); + }); + const sockets = new Set(); + const port = await listen(scope, server, sockets); + const backup = await protocolProxy(scope, "http", { mode: "hang" }); + const authority = "origin.fixture.invalid:443"; + await assert.rejects(dialTunnel(authority, [ + { id: "revoked", kind, host: "127.0.0.1", port, ...credentials }, backup.candidate, + ], { allowTarget: value => value === authority && authorized }), { code: "TARGET", retryable: false }); + await until(() => sockets.size === 0); + const all = Buffer.concat(received); + assert.equal(stage, phase === "credentials" ? 1 : 2); + assert.equal(all.includes(Buffer.from("origin.fixture.invalid")), false); + if (phase === "credentials") { + assert.equal(all.includes(Buffer.from(credentials.username)), false); + assert.equal(all.includes(Buffer.from(credentials.password)), false); + } + assert.equal(backup.seen.connections, 0); + }, 5_000); + } +} + +for (const kind of proxyKinds) { + test(`${kind}: after establishment the caller owns cancellation and the socket`, async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); + const controller = new AbortController(); + const { socket } = await dialAllowed(end.authority, [proxy.candidate], { + signal: controller.signal, totalTimeoutMs: 200, + }); + scope.own(socket); + assert.equal(socket.isPaused(), true); + controller.abort(); + // The already-cleared establishment deadline must not destroy caller-owned I/O. + await wait(250); + assert.equal(socket.destroyed, false); + const secured = await securedClient(scope, socket); + const payload = Buffer.from("caller-owned-after-establishment"); + await echo(secured, payload); + assert.deepEqual(Buffer.concat(end.received), payload); + }, 5_000); +} + +for (const kind of proxyKinds) { + test(`${kind}: revocation while awaiting CONNECT success prevents socket handoff`, async () => { + let authorized = true; + const end = await origin(scope); + const proxy = await protocolProxy(scope, kind, { + destination: end, + beforeEstablishedReply: () => { authorized = false; }, + }); + const backup = await protocolProxy(scope, "http", { mode: "hang" }); + await assert.rejects(dialTunnel(end.authority, [proxy.candidate, backup.candidate], { + allowTarget: value => value === end.authority && authorized, + }), { code: "TARGET", retryable: false }); + assert.equal(proxy.seen.auth, 1); + assert.deepEqual(proxy.seen.targets, [{ host: "origin.fixture.invalid", port: end.port }]); + assert.equal(backup.seen.connections, 0); + assert.equal(end.received.length, 0); + await until(() => proxy.seen.clientEnds === 1); + }, 5_000); +} diff --git a/tests/lib/opaque-proxy-tunnel.test.ts b/tests/lib/opaque-proxy-tunnel.test.ts new file mode 100644 index 00000000000..bb6c4b8f387 --- /dev/null +++ b/tests/lib/opaque-proxy-tunnel.test.ts @@ -0,0 +1,186 @@ +import { afterEach, test } from "bun:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { once } from "node:events"; +import { dialTunnel, type OpaqueProxyRoute, type OpaqueTunnelOptions } from "../../src/lib/opaque-proxy-tunnel"; +import { + allowTarget, deadProxy, digest, echo, httpProxy, listen, readBytes, + target, TunnelFixtureScope, until, wait, +} from "../helpers/opaque-proxy-tunnel"; + +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); + +test("refused first proxy falls back to an explicit second and opaque bytes round-trip", async () => { + const dead = await deadProxy(), live = await httpProxy(scope); + const { socket, routeId } = await dialTunnel(target, [dead, live.candidate], { allowTarget }); + scope.own(socket); + assert.equal(routeId, live.candidate.id); + const expected = Buffer.from([0, 255, 1, 127, 13, 10]); + const reply = readBytes(socket, expected.length); + socket.write(expected); + assert.deepEqual(await reply, expected); + assert.equal(live.seen.calls, 1); +}, 5_000); + +test("all failed candidates surface ALL_FAILED without a direct destination connection", async () => { + const dead = await deadProxy(), dead2 = await deadProxy(); + let directCalls = 0; + const destination = net.createServer(socket => { directCalls++; socket.destroy(); }); + const port = await listen(scope, destination); + const authority = `127.0.0.1:${port}`; + await assert.rejects(dialTunnel(authority, [dead, dead2], { allowTarget: value => value === authority }), { code: "ALL_FAILED" }); + assert.equal(directCalls, 0); +}, 5_000); + +for (const mode of ["407", "403", "malformed", "oversize"] as const) { + test(`${mode} is terminal and never contacts a backup`, async () => { + const refused = await httpProxy(scope, mode), backup = await httpProxy(scope); + await assert.rejects(dialTunnel(target, [refused.candidate, backup.candidate], { allowTarget }), error => { + const failure = error as { code: string; status?: number }; + assert.equal(failure.code, mode === "407" || mode === "403" ? "REFUSED" : "PROTOCOL"); + if (mode === "407" || mode === "403") assert.equal(failure.status, Number(mode)); + return true; + }); + assert.equal(backup.seen.calls, 0); + await until(() => refused.sockets.size === 0); + }, 5_000); +} + +for (const mode of ["bad-header", "partial-refusal", "partial-timeout"] as const) { + test(`${mode} fails closed without contacting backup`, async () => { + const proxy = await httpProxy(scope, mode), backup = await httpProxy(scope); + await assert.rejects(dialTunnel(target, [proxy.candidate, backup.candidate], { allowTarget, timeoutMs: 150 }), { retryable: false }); + assert.equal(backup.seen.calls, 0); + await until(() => proxy.sockets.size === 0); + }, 5_000); +} + +test("establishment timeout falls back and destroys the hanging first socket", async () => { + const hang = await httpProxy(scope, "hang"), backup = await httpProxy(scope); + const result = await dialTunnel(target, [hang.candidate, backup.candidate], { allowTarget, timeoutMs: 150 }); + scope.own(result.socket); + assert.equal(result.routeId, backup.candidate.id); + assert.equal(backup.seen.calls, 1); + await until(() => hang.sockets.size === 0); +}, 5_000); + +test("caller cancellation closes establishment and never tries backup", async () => { + const hang = await httpProxy(scope, "hang"), backup = await httpProxy(scope), controller = new AbortController(); + const pending = dialTunnel(target, [hang.candidate, backup.candidate], { allowTarget, signal: controller.signal, timeoutMs: 2_000 }); + const check = assert.rejects(pending, { code: "ABORTED" }); + await until(() => hang.seen.calls === 1); + controller.abort(); + await check; + assert.equal(backup.seen.calls, 0); + await until(() => hang.sockets.size === 0); +}, 5_000); + +test("already-cancelled request opens no proxy connection", async () => { + const proxy = await httpProxy(scope), controller = new AbortController(); + controller.abort(); + await assert.rejects(dialTunnel(target, [proxy.candidate], { allowTarget, signal: controller.signal }), { code: "ABORTED" }); + assert.equal(proxy.seen.calls, 0); + assert.equal(proxy.sockets.size, 0); +}, 5_000); + +test("early opaque bytes coalesced with CONNECT headers are preserved", async () => { + const proxy = await httpProxy(scope, "banner"); + const { socket } = await dialTunnel(target, [proxy.candidate], { allowTarget }); + scope.own(socket); + const payload = Buffer.from("early-client-bytes"); + const received = readBytes(socket, 13 + payload.length); + socket.write(payload); + assert.deepEqual(await received, Buffer.concat([Buffer.from("opaque-banner"), payload])); + assert.deepEqual(Buffer.concat(proxy.seen.payloads), payload); +}, 5_000); + +test("caller half-close preserves a 4 MiB echoed response under backpressure", async () => { + const proxy = await httpProxy(scope, "slow"); + const { socket } = await dialTunnel(target, [proxy.candidate], { allowTarget }); + scope.own(socket); + const payload = Buffer.alloc(4 * 1024 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + await echo(socket, payload, true); + assert.equal(proxy.seen.bytes, payload.length); + assert.equal(digest(Buffer.concat(proxy.seen.payloads)), digest(payload)); +}, 12_000); + +test("failure after establishment and application bytes never reconnects or replays", async () => { + const drop = await httpProxy(scope, "drop"), backup = await httpProxy(scope); + const { socket } = await dialTunnel(target, [drop.candidate, backup.candidate], { allowTarget }); + scope.own(socket); + const ended = once(socket, "end"); + socket.write("unique-turn-payload"); + socket.resume(); + await ended; + socket.end(); + assert.equal(drop.seen.bytes, 19); + assert.equal(drop.seen.calls, 1); + assert.equal(backup.seen.calls, 0); +}, 5_000); + +for (const policy of ["denies", "throws", "async"] as const) { + test(`target policy ${policy} fails closed before any proxy connection`, async () => { + const proxy = await httpProxy(scope); + const allow = policy === "denies" ? () => false + : policy === "throws" ? () => { throw new Error("private policy context"); } + : async () => true; + await assert.rejects(dialTunnel(target, [proxy.candidate], { + allowTarget: allow as OpaqueTunnelOptions["allowTarget"], + }), error => { + assert.equal((error as { code: string }).code, "TARGET"); + assert.equal(String(error).includes("private policy context"), false); + return true; + }); + assert.equal(proxy.seen.calls, 0); + assert.equal(proxy.sockets.size, 0); + }, 5_000); +} + +test("configuration requires explicit candidates and a synchronous target policy", async () => { + const route: OpaqueProxyRoute = { id: "proxy", host: "127.0.0.1", port: 1 }; + await assert.rejects(dialTunnel(target, [], { allowTarget }), { code: "CONFIG" }); + await assert.rejects(dialTunnel(target, [route], {} as OpaqueTunnelOptions), { code: "TARGET" }); + await assert.rejects(dialTunnel(target, [route], undefined as unknown as OpaqueTunnelOptions), { code: "TARGET" }); +}); + +test("authorization is rechecked before opening the next physical candidate", async () => { + const first = await httpProxy(scope, "hang"), backup = await httpProxy(scope); + let authorized = true; + const result = dialTunnel(target, [first.candidate, backup.candidate], { + allowTarget: authority => authority === target && authorized, + timeoutMs: 200, + }); + const check = assert.rejects(result, { code: "TARGET", retryable: false }); + await until(() => first.seen.calls === 1); + authorized = false; + await check; + assert.equal(backup.seen.calls, 0); + assert.equal(backup.sockets.size, 0); + await until(() => first.sockets.size === 0); +}, 5_000); + +test("rejecting asynchronous target policy is denied without an unhandled rejection", async () => { + const proxy = await httpProxy(scope); + const unhandled: unknown[] = []; + const observe = (reason: unknown) => { unhandled.push(reason); }; + process.on("unhandledRejection", observe); + try { + const policy = async () => { throw new Error("private fixture context"); }; + await assert.rejects(dialTunnel(target, [proxy.candidate], { + allowTarget: policy as unknown as OpaqueTunnelOptions["allowTarget"], + }), error => { + assert.equal((error as { code: string }).code, "TARGET"); + assert.equal((String(error) + JSON.stringify(error)).includes("private fixture context"), false); + return true; + }); + // Allow the rejected policy promise to cross a complete event-loop turn. + await wait(10); + assert.deepEqual(unhandled, []); + assert.equal(proxy.seen.calls, 0); + assert.equal(proxy.sockets.size, 0); + } finally { + process.removeListener("unhandledRejection", observe); + } +}, 5_000); From add9705158c5599ba3b9e6e533063f92c57ffc58 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 13:49:39 +0800 Subject: [PATCH 2/5] test(proxy): assert synchronous startup rejection explicitly --- tests/lib/opaque-proxy-lifecycle.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/lib/opaque-proxy-lifecycle.test.ts b/tests/lib/opaque-proxy-lifecycle.test.ts index 747228ed65b..fb920772417 100644 --- a/tests/lib/opaque-proxy-lifecycle.test.ts +++ b/tests/lib/opaque-proxy-lifecycle.test.ts @@ -141,9 +141,10 @@ test('synchronous factory exit leaves no timer keeping process alive',async()=>{ const {execFile}=await import('node:child_process'); const moduleURL=new URL('../../src/lib/opaque-proxy-lifecycle/owner-manager.ts',import.meta.url).href; await new Promise((resolve,reject)=>execFile(process.execPath,['--eval',` + import assert from "node:assert/strict"; import {createOwnerManager} from ${JSON.stringify(moduleURL)}; const owner={};const manager=createOwnerManager({owner,createWorker:({onExit})=>{onExit();return {listen(){throw Error('must not start')},close(){}};}}); - try {await manager.start(owner,{authorized:true,authorization:${JSON.stringify(authorization)}});process.exitCode=2;}catch{} + await assert.rejects(manager.start(owner,{authorized:true,authorization:${JSON.stringify(authorization)}}), /Stale readiness/); `],{timeout:1500},error=>error?reject(error):resolve())); }); test('composition forwards real ingress termination to matching owner generation',async()=>{ From 2ec9b1b59e5a52d919db98d5d18d83cd93059f16 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 14:14:49 +0800 Subject: [PATCH 3/5] feat(proxy): connect opaque dialer to desktop upstream transport --- .../content/docs/guides/codex-integration.md | 19 +++ src/lib/desktop-upstream-tunnel.ts | 75 +++------ src/lib/opaque-proxy-tunnel.ts | 10 +- structure/transports/inventory.md | 15 +- tests/helpers/desktop-egress-fixture.ts | 11 +- .../lib/opaque-proxy-tunnel-protocol.test.ts | 2 +- tests/lib/optional-desktop-upstream.test.ts | 146 +++++++++++++++++- 7 files changed, 207 insertions(+), 71 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index acf3da827d7..409a261b88a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1614,3 +1614,22 @@ The shared SSE decoder accepts LF, CRLF and standalone CR line endings, even whe Choosing an account-qualified model does not enable credit spending. Stored accounts obey **Use credits after limit** during authentication, when credentials are prepared, and before Responses HTTP or WebSocket dispatch after pacing or retry waits. A held request reports the credit policy, not an authentication failure; wait for the reset, choose another account, or explicitly enable that account’s credit spending. Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior. + +## Native Desktop relay proxy connections + +When the existing native Desktop compatibility relay is enabled, its upgraded +connections to `chatgpt.com:443` use the selected HTTP/HTTPS CONNECT or +SOCKS5/SOCKS5h proxy. This is the relay's outbound transport, not a new proxy setting +or a claim that the original Desktop accepts local proxy credentials. Existing +`NO_PROXY` and explicit direct-route decisions remain unchanged; a selected proxy +failure never retries directly. + +Proxy and target TLS identities remain verified. HTTP Basic usernames containing +colons, credential pairs exceeding 4096 UTF-8 bytes, and malformed CONNECT response +headers are refused. SOCKS credentials retain the shared handshake's 255-byte +per-field limit and existing empty-field behavior; both SOCKS schemes resolve the +target through the proxy. Credentials are used only for the chosen proxy handshake. + +The owned CONNECT-session library remains an internal integration seam. This change +adds no session-launch command, persistent credential injection, OS proxy change, +certificate installation, or original-Desktop authentication flow. diff --git a/src/lib/desktop-upstream-tunnel.ts b/src/lib/desktop-upstream-tunnel.ts index 936ccb0b2f3..3494377770e 100644 --- a/src/lib/desktop-upstream-tunnel.ts +++ b/src/lib/desktop-upstream-tunnel.ts @@ -1,10 +1,10 @@ -import { connect as tcpConnect, isIP, type Socket } from "node:net"; -import { checkServerIdentity, connect as tlsConnect, type TLSSocket } from "node:tls"; +import { connect as tcpConnect, type Socket } from "node:net"; +import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { desktopProxyFor } from "./desktop-proxy-route"; -import { socks5Credentials, socks5Handshake } from "./socks5-handshake"; +import { dialTunnel, type OpaqueProxyRoute } from "./opaque-proxy-tunnel"; -// Shares the SOCKS exchange extracted by lcxhh521 in PR #5947 (efdccdbfac3f). -// Lifecycle here additionally bounds the entire dial and handles close/abort/header limits. +// The shared opaque dialer owns proxy framing; this caller owns fixed-target +// TLS and bounds the entire connection, including the target TLS handshake. export interface DesktopTunnelOptions { proxy?: string | false; signal?: AbortSignal; @@ -15,37 +15,6 @@ export interface DesktopTunnelOptions { proxyCa?: string; } const fail = () => new Error("desktop_upstream_connection_failed"); -class HandshakeReader { - private buffer: Buffer = Buffer.alloc(0); - private failure: Error | null = null; - private waiting: { ready: () => boolean; resolve: () => void; reject: (error: Error) => void } | null = null; - private onData = (chunk: Buffer) => { - if (this.buffer.length + chunk.length > 65536) { this.onFailure(); return; } - this.buffer = Buffer.concat([this.buffer, chunk]); - if (this.waiting?.ready()) { const current = this.waiting; this.waiting = null; current.resolve(); } - }; - private onFailure = () => { this.failure = fail(); const current = this.waiting; this.waiting = null; current?.reject(this.failure); }; - constructor(private socket: Socket, private signal: AbortSignal) { - socket.on("data", this.onData); socket.once("error", this.onFailure); socket.once("end", this.onFailure); socket.once("close", this.onFailure); - signal.addEventListener("abort", this.onFailure, { once: true }); if (signal.aborted) this.onFailure(); socket.resume(); - } - write(bytes: Uint8Array | string): void { if (this.failure) throw this.failure; this.socket.write(bytes); } - private async until(ready: () => boolean): Promise { - if (this.failure) throw this.failure; - if (ready()) return; - if (this.waiting) throw fail(); - await new Promise((resolve, reject) => { this.waiting = { ready, resolve, reject }; }); - } - private take(length: number): Buffer { const out = this.buffer.subarray(0, length); this.buffer = this.buffer.subarray(length); return out; } - async readExact(length: number): Promise { await this.until(() => this.buffer.length >= length); return this.take(length); } - async readHead(): Promise { await this.until(() => this.buffer.indexOf("\r\n\r\n") >= 0); return this.take(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"); } - dispose(): void { - this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onFailure); - this.socket.removeListener("end", this.onFailure); this.socket.removeListener("close", this.onFailure); - this.signal.removeEventListener("abort", this.onFailure); - if (this.buffer.length) this.socket.unshift(this.buffer); this.buffer = Buffer.alloc(0); this.onFailure(); - } -} function waitConnected(socket: Socket, event: "connect" | "secureConnect", signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const dispose = () => { socket.off(event, done); socket.off("error", failure); socket.off("close", failure); signal.removeEventListener("abort", failure); }; @@ -76,25 +45,25 @@ export async function dialDesktopUpstream(options: DesktopTunnelOptions = {}): P const url = new URL(proxy), protocol = url.protocol; if (!["http:", "https:", "socks5:", "socks5h:"].includes(protocol) || !url.hostname || url.search || url.hash || url.pathname !== "" && url.pathname !== "/") throw fail(); const host = url.hostname.replace(/^\[|\]$/g, ""), port = Number(url.port) || (protocol === "https:" ? 443 : protocol.startsWith("socks") ? 1080 : 80); - raw = own(tcpConnect({ host, port })); await waitConnected(raw, "connect", signal); - if (protocol === "https:") { - raw = own(tlsConnect({ socket: raw, servername: isIP(host) ? undefined : host, ca: options.proxyCa, - rejectUnauthorized: true, checkServerIdentity: (_name, certificate) => checkServerIdentity(host, certificate), ALPNProtocols: ["http/1.1"] })); - await waitConnected(raw, "secureConnect", signal); - } - const reader = new HandshakeReader(raw, signal); - try { - if (protocol.startsWith("socks")) await socks5Handshake(reader, target, socks5Credentials(url)); - else { - const authority = `${target.host}:${target.port}`; - const lines = [`CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`]; - if (url.username || url.password) lines.push(`Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString("base64")}`); - reader.write(lines.join("\r\n") + "\r\n\r\n"); - if (!/^HTTP\/1\.[01] 2\d\d(?: |\r)/.test(await reader.readHead())) throw fail(); - } - } finally { reader.dispose(); } + const credentials = url.username || url.password + ? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) } + : {}; + const endpoint = { id: "desktop-explicit", host, port, ...credentials }; + const route: OpaqueProxyRoute = protocol === "https:" + ? { ...endpoint, kind: "https", proxyCa: options.proxyCa } + : { ...endpoint, kind: protocol.slice(0, -1) as "http" | "socks5" | "socks5h" }; + const authority = `${target.host.includes(":") ? `[${target.host}]` : target.host}:${target.port}`; + // The route resolver remains authoritative. One explicit candidate cannot + // introduce failover or turn a proxy refusal into a direct connection. + raw = own((await dialTunnel(authority, [route], { + allowTarget: value => value === authority, signal, + timeoutMs: timeout, totalTimeoutMs: timeout, maxHeaderBytes: 65536, + })).socket); } if (signal.aborted || raw.destroyed) throw fail(); + // This consumer historically closes its writable side after peer EOF. The + // shared opaque transport preserves half-closes for callers that need them. + raw.allowHalfOpen = false; const secured = own(tlsConnect({ socket: raw, servername: "chatgpt.com", ca: options.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"] })); // The CONNECT reader paused its stream before handing buffered bytes back. Bun's // TLS-over-TLS path needs the outer TLS stream explicitly resumed for the new handshake. diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts index e1dbc0e1227..3b969631ad0 100644 --- a/src/lib/opaque-proxy-tunnel.ts +++ b/src/lib/opaque-proxy-tunnel.ts @@ -1,5 +1,6 @@ /** - * Explicit authorized opaque proxy tunnel. No production caller is registered. + * Explicit authorized opaque proxy tunnel. The desktop upstream dialer consumes + * a single selected route while retaining its own fixed-target TLS contract. * Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment * cancellation and socket cleanup. Never replay or implicitly retry directly. */ @@ -69,9 +70,9 @@ function validateOptions(candidates: readonly OpaqueProxyRoute[], timeoutMs: num || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy candidate'); if ((p.username === undefined) !== (p.password === undefined) || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string' - || p.username.includes(':') || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); + || (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined - && (!p.username || !p.password || Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each contain 1–255 UTF-8 bytes'); + && (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes'); if ((p.proxyCa !== undefined || p.proxyServername !== undefined) && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy'); if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string' && !Buffer.isBuffer(p.proxyCa)) throw new TunnelError('CONFIG', 'Invalid proxy CA'); if (p.proxyServername !== undefined && (typeof p.proxyServername !== 'string' || !p.proxyServername || /[\s/@\x00]/.test(p.proxyServername))) throw new TunnelError('CONFIG', 'Invalid proxy TLS server name'); @@ -178,7 +179,8 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou }); } -// Reader contract follows desktop-upstream-tunnel.ts. The framing is the existing +// The bounded socket reader owns setup cancellation and preserves queued bytes. +// Framing uses the existing // MIT socks5-handshake module; neither shared framing nor its DNS policy changes. async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, authorize }: AttemptOptions): Promise { if (signal?.aborted) throw aborted(); diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 3da66fbd29b..6431f0b383a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -411,18 +411,25 @@ refuses. A failed selected route never retries directly. Identity verification u HTTP path, while provider inference retains its separate existing provider-egress policy. `src/lib/desktop-upstream-tunnel.ts` dials fixed chatgpt.com TLS through HTTP/HTTPS CONNECT -or authenticated SOCKS5. It bounds the whole connection setup, rejects proxy EOF, abort and +or authenticated SOCKS5. Its selected-proxy branch delegates handshake framing to +`src/lib/opaque-proxy-tunnel.ts` with one explicit candidate and exact target policy; +its direct branch and final target TLS handshake remain locally owned. It bounds +the whole connection setup, rejects proxy EOF, abort and oversized handshake heads, and verifies both proxy and upstream certificate identities. Only the proxy handshake carries proxy credentials. The raw upgraded socket remains opaque; the relay does not decode WebSocket payloads. After CONNECT the paused outer stream resumes before TLS-over-TLS negotiation. Test fixture CA trust is confined to child processes or injected test socket options, never the user's OS trust store. +`tests/lib/optional-desktop-upstream.test.ts` exercises the production dialer and +owned-session composition; `tests/clients/desktop-compatibility-relay.test.ts` +preserves the real relay’s fixed-target and opaque upgrade contracts. ## Explicit opaque proxy candidates `src/lib/opaque-proxy-tunnel.ts` exposes an authorized, paused target socket through an explicit ordered HTTP, HTTPS CONNECT, SOCKS5 or SOCKS5h candidate list. It has no -registered production caller and changes neither desktop proxy discovery nor the +implicit activation. The desktop upstream dialer is a production consumer of a +single explicit candidate; this changes neither desktop proxy discovery nor the fixed-destination TLS contract above. Each call requires a synchronous target predicate; it is rechecked before every physical attempt and handshake write. A completed proxy handshake is rechecked before the socket is handed to the caller. @@ -434,7 +441,9 @@ and absent from error messages; the module stores no credentials and emits no lo The existing `src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS scheme spellings retain its ATYP DOMAIN remote-DNS behavior; they are not distinct local/remote DNS modes. Credentials offer both NO-AUTH and USER-PASS as that shared -contract specifies. HTTPS proxy identity and trust are always verified. Target TLS +contract specifies; SOCKS usernames may contain colons and decoded fields retain +the existing helper’s zero-to-255-byte compatibility bounds. HTTP Basic usernames +with colons are rejected rather than emitting an ambiguous identity. HTTPS proxy identity and trust are always verified. Target TLS belongs to the client and target, not the opaque tunnel. Per-candidate and total deadlines bound establishment. A transport failure before diff --git a/tests/helpers/desktop-egress-fixture.ts b/tests/helpers/desktop-egress-fixture.ts index fd2e60d083c..7be3eadecdd 100644 --- a/tests/helpers/desktop-egress-fixture.ts +++ b/tests/helpers/desktop-egress-fixture.ts @@ -9,7 +9,7 @@ export async function listenFixture(server: Server, host = "127.0.0.1") { return { port: (server.address() as { port: number }).port, async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); } }; } -export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort: number, leaf: PemKeyPair) { +export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort: number, leaf: PemKeyPair, credentials = {username: "fixture", password: "secret"}) { const sockets = new Set(), seen: { authority: string; auth: string }[] = []; const pipe = (client: Socket, head = Buffer.alloc(0)) => { const target = connect({ host: "127.0.0.1", port: targetPort }); sockets.add(target); @@ -29,8 +29,9 @@ export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort } else if (stage === 1) { if (held.length < 2 || held.length < 3 + held[1]!) return; const n = held[1]!, m = held[2 + n]!; if (held.length < 3 + n + m) return; - auth = held.subarray(2, 2 + n).toString() + ":" + held.subarray(3 + n, 3 + n + m).toString(); - held = held.subarray(3 + n + m); client.write(Buffer.from([1, auth === "fixture:secret" ? 0 : 1])); stage = 2; + const username = held.subarray(2, 2 + n).toString(), password = held.subarray(3 + n, 3 + n + m).toString(); + auth = `${username}:${password}`; + held = held.subarray(3 + n + m); client.write(Buffer.from([1, username === credentials.username && password === credentials.password ? 0 : 1])); stage = 2; } else { if (held.length < 5 || held.length < 7 + held[4]!) return; const n = held[4]!, authority = held.subarray(5, 5 + n).toString() + ":" + held.readUInt16BE(5 + n); @@ -43,10 +44,10 @@ export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort }) : kind === "https" ? createHttps({ cert: leaf.certPem, key: leaf.keyPem }) : createHttp(); if (kind !== "socks5") server.on("connect", (req, client, head) => { const auth = String(req.headers["proxy-authorization"] ?? ""); seen.push({ authority: req.url ?? "", auth }); - if (auth !== "Basic " + Buffer.from("fixture:secret").toString("base64")) { client.end("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); return; } + if (auth !== "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")) { client.end("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); return; } client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); pipe(client as Socket, head); }); const listening = await listenFixture(server, kind === "https" ? "localhost" : "127.0.0.1"); - return { ...listening, seen, url: `${kind}://fixture:secret@${kind === "https" ? "localhost" : "127.0.0.1"}:${listening.port}`, + return { ...listening, seen, url: `${kind}://${encodeURIComponent(credentials.username)}:${encodeURIComponent(credentials.password)}@${kind === "https" ? "localhost" : "127.0.0.1"}:${listening.port}`, async close() { for (const socket of sockets) socket.destroy(); await listening.close(); } }; } diff --git a/tests/lib/opaque-proxy-tunnel-protocol.test.ts b/tests/lib/opaque-proxy-tunnel-protocol.test.ts index a54006d7222..ab9c73d7fbb 100644 --- a/tests/lib/opaque-proxy-tunnel-protocol.test.ts +++ b/tests/lib/opaque-proxy-tunnel-protocol.test.ts @@ -114,7 +114,7 @@ test('configuration rejects unsupported protocols and invalid credential sizes b const route = { id: 'x', host: '127.0.0.1', port: 1 }; await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'direct' } as unknown as OpaqueProxyRoute]), { code: 'CONFIG' }); await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5', username: 'x'.repeat(256), password: 'p' }]), { code: 'CONFIG' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5h', username: 'u', password: '' }]), { code: 'CONFIG' }); + await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5h', username: 'u', password: 'x'.repeat(256) }]), { code: 'CONFIG' }); await assert.rejects(dialAllowed('origin.fixture.invalid:443', [route], { totalTimeoutMs: 0 }), { code: 'CONFIG' }); }); diff --git a/tests/lib/optional-desktop-upstream.test.ts b/tests/lib/optional-desktop-upstream.test.ts index 820ceea9c67..9e6669df7ff 100644 --- a/tests/lib/optional-desktop-upstream.test.ts +++ b/tests/lib/optional-desktop-upstream.test.ts @@ -1,8 +1,10 @@ import { expect, test } from "bun:test"; +import assert from "node:assert/strict"; import { createServer as createHttps } from "node:https"; -import { createServer as createTcp } from "node:net"; +import { createServer as createTcp, type Socket } from "node:net"; import { dialDesktopUpstream } from "../../src/lib/desktop-upstream-tunnel"; import { desktopProxyFor } from "../../src/lib/desktop-proxy-route"; +import { createOwnedIngressManager } from "../../src/lib/opaque-proxy-lifecycle/managed-session"; import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; import { forwardProxy, listenFixture } from "../helpers/desktop-egress-fixture"; import { mkdtempSync, writeFileSync } from "node:fs"; @@ -25,7 +27,7 @@ test("desktop HTTP and WS choose explicit NO_PROXY, supported proxies, or a clos expect(() => desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444/path" })).toThrow("proxy_invalid"); }); -for (const kind of ["http", "https", "socks5"] as const) test(`verified TLS through authenticated ${kind} proxy preserves raw request and response`, async () => { +for (const kind of ["http", "https", "socks5", "socks5h"] as const) test(`verified TLS through authenticated ${kind} proxy preserves raw request and response`, async () => { const ca = createCertificateAuthority({ commonName: "egress-fixture", validityDays: 1 }); const leaf = issueServerLeaf(ca, "egress-fixture", ["chatgpt.com"]), proxyLeaf = issueServerLeaf(ca, "egress-fixture", ["localhost"]); let cookie: string | undefined, leaked: string | undefined; @@ -33,17 +35,19 @@ for (const kind of ["http", "https", "socks5"] as const) test(`verified TLS thro cookie = req.headers.cookie; leaked = req.headers["proxy-authorization"] as string | undefined; res.end("fixture-response"); }); - const origin = await listenFixture(upstream), proxy = await forwardProxy(kind, origin.port, proxyLeaf); + const transport = kind === "socks5h" ? "socks5" : kind; + const origin = await listenFixture(upstream), proxy = await forwardProxy(transport, origin.port, proxyLeaf); let socket: Awaited> | undefined; try { - socket = await dialDesktopUpstream({ proxy: proxy.url, ca: ca.certPem, proxyCa: ca.certPem, target: { host: "chatgpt.com", port: 443 } }); + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem, proxyCa: ca.certPem, target: { host: "chatgpt.com", port: 443 } }); + assert.equal(socket.authorized, true); const response = new Promise((resolve, reject) => { let text = ""; socket!.on("data", bytes => { text += bytes.toString(); }); socket!.once("end", () => resolve(text)); socket!.once("error", reject); }); socket.write("GET /fixture HTTP/1.1\r\nHost: chatgpt.com\r\nCookie: fixture=session\r\nConnection: close\r\n\r\n"); expect(await response).toContain("fixture-response"); expect(cookie).toBe("fixture=session"); expect(leaked).toBeUndefined(); expect(proxy.seen).toHaveLength(1); expect(proxy.seen[0]!.authority).toBe("chatgpt.com:443"); - expect(proxy.seen[0]!.auth).toBe(kind === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64")); + expect(proxy.seen[0]!.auth).toBe(transport === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64")); } finally { socket?.destroy(); await proxy.close(); await origin.close(); } }, 15000); @@ -122,3 +126,135 @@ for (const failure of ["eof", "oversized", "timeout", "abort"] as const) test(`p await closedEvent; } finally { await proxy.close(); } }, 3000); + +/** Read exactly one fixture HTTP response while keeping its verified TLS connection alive. */ +function fixtureRequest(socket: Socket, path: string): Promise { + return new Promise((resolve, reject) => { + let bytes: Buffer = Buffer.alloc(0); + const cleanup = () => { + clearTimeout(timer); socket.off("data", receive); socket.off("error", fail); + socket.off("end", ended); socket.off("close", ended); + }; + const fail = (error: Error) => { cleanup(); reject(error); }; + const ended = () => fail(new Error("Fixture response closed before Content-Length bytes arrived")); + const receive = (chunk: Buffer) => { + try { + bytes = Buffer.concat([bytes, chunk]); + assert.ok(bytes.length <= 65536, "fixture response is bounded"); + const end = bytes.indexOf("\r\n\r\n"); + if (end < 0) return; + const head = bytes.subarray(0, end).toString("latin1"); + assert.match(head, /^HTTP\/1\.1 200 /); + const length = /\r\ncontent-length: ([0-9]+)(?:\r\n|$)/i.exec(head); + assert.ok(length, "fixture response declares Content-Length"); + const expected = end + 4 + Number(length[1]); + assert.ok(bytes.length <= expected, "one request receives exactly one response"); + if (bytes.length === expected) { cleanup(); resolve(bytes.subarray(end + 4).toString("utf8")); } + } catch (error) { fail(error as Error); } + }; + const timer = setTimeout(() => fail(new Error("Fixture response deadline exceeded")), 2000); + socket.on("data", receive); socket.once("error", fail); socket.once("end", ended); socket.once("close", ended); + socket.write(`GET ${path} HTTP/1.1\r\nHost: chatgpt.com\r\nCookie: fixture=session\r\nConnection: keep-alive\r\n\r\n`); + }); +} + +function socketClosed(socket: Socket): Promise { + return new Promise((resolve, reject) => { + const closed = () => { clearTimeout(timer); resolve(); }; + const timer = setTimeout(() => { + socket.off("close", closed); + reject(new Error(`Shutdown did not close the consumer socket (readableEnded=${socket.readableEnded}, writableEnded=${socket.writableEnded}, destroyed=${socket.destroyed}, allowHalfOpen=${socket.allowHalfOpen})`)); + }, 2000); + socket.once("close", closed); + }); +} + +for (const kind of ["socks5", "socks5h"] as const) for (const credentials of [ + { username: "fixture:colon", password: "secret" }, + { username: "", password: "secret" }, + { username: "fixture", password: "" }, +]) test(`desktop ${kind} preserves SOCKS credentials ${JSON.stringify(credentials)}`, async () => { + const ca = createCertificateAuthority({ commonName: "socks-credentials-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "socks-credentials-fixture", ["chatgpt.com"]); + const origin = await listenFixture(createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (_req, res) => { + const body = "authenticated-fixture"; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); + })); + const proxy = await forwardProxy("socks5", origin.port, leaf, credentials); + let socket: Awaited> | undefined; + try { + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem }); + assert.equal(socket.authorized, true); + assert.equal(await fixtureRequest(socket, "/credentials"), "authenticated-fixture"); + assert.deepEqual(proxy.seen, [{ authority: "chatgpt.com:443", auth: `${credentials.username}:${credentials.password}` }]); + } finally { socket?.destroy(); await proxy.close(); await origin.close(); } +}, 10000); + +test("desktop consumer authenticates to an owned ingress, refreshes only future connections, and closes on shutdown", async () => { + const ca = createCertificateAuthority({ commonName: "desktop-session-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "desktop-session-fixture", ["chatgpt.com"]); + const requests: { origin: string; path: string | undefined; socket: Socket; cookie: string | undefined; proxyAuth: unknown }[] = []; + let handshakes = 0; + const createOrigin = (name: string) => { + const server = createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { + requests.push({ origin: name, path: req.url, socket: req.socket, cookie: req.headers.cookie, proxyAuth: req.headers["proxy-authorization"] }); + const body = `${name}:${req.url}`; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); + }); + server.on("secureConnection", () => { handshakes++; }); + return listenFixture(server); + }; + const firstOrigin = await createOrigin("first"), secondOrigin = await createOrigin("second"); + const firstProxy = await forwardProxy("http", firstOrigin.port, leaf), secondProxy = await forwardProxy("http", secondOrigin.port, leaf); + const route = (id: string, port: number) => ({ id, kind: "http" as const, host: "127.0.0.1", port, username: "fixture", password: "secret" }); + const owner = {}, username = "session", password = "synthetic-desktop-consumer-capability"; + const authorization = "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); + const manager = createOwnedIngressManager({ owner, candidates: [route("first", firstProxy.port)], + allowTarget: authority => authority === "chatgpt.com:443", + ingress: { sessionMs: 30000, totalMs: 30000, idleMs: 10000, establishMs: 2000 }, + manager: { sessionMs: 30000, startupMs: 2000, shutdownMs: 2000 } }); + const sockets: Awaited>[] = []; + try { + const lease = await manager.start(owner, { authorized: true, authorization }); + assert.equal(lease.host, "127.0.0.1"); + const proxy = `http://${username}:${password}@${lease.host}:${lease.port}`; + const wrongCapability = new URL(proxy); wrongCapability.password = "wrong-fixture-capability"; + await assert.rejects(dialDesktopUpstream({ proxy: wrongCapability.href, ca: ca.certPem, timeoutMs: 1000 }), + { message: "desktop_upstream_connection_failed" }); + assert.equal(firstProxy.seen.length, 0); assert.equal(secondProxy.seen.length, 0); + assert.equal(handshakes, 0); assert.equal(requests.length, 0); + + // Do not override the production target: only the upstream fixture proxy redirects it to loopback. + const first = await dialDesktopUpstream({ proxy, ca: ca.certPem }); sockets.push(first); + assert.equal(first.authorized, true); + assert.equal(await fixtureRequest(first, "/before-refresh"), "first:/before-refresh"); + assert.equal(manager.updateRoutes(owner, manager.routeVersion(), [route("second", secondProxy.port)]), 1); + assert.equal(await manager.start(owner, { authorized: true, authorization }), lease); + assert.equal(await fixtureRequest(first, "/after-refresh"), "first:/after-refresh"); + + const second = await dialDesktopUpstream({ proxy, ca: ca.certPem }); sockets.push(second); + assert.equal(second.authorized, true); + assert.equal(await fixtureRequest(second, "/next-connection"), "second:/next-connection"); + assert.deepEqual(requests.map(({ origin, path }) => ({ origin, path })), [ + { origin: "first", path: "/before-refresh" }, { origin: "first", path: "/after-refresh" }, + { origin: "second", path: "/next-connection" }, + ]); + assert.equal(requests[0]!.socket, requests[1]!.socket); + assert.notEqual(requests[0]!.socket, requests[2]!.socket); + for (const request of requests) { assert.equal(request.cookie, "fixture=session"); assert.equal(request.proxyAuth, undefined); } + const upstreamConnect = [{ authority: "chatgpt.com:443", auth: "Basic " + Buffer.from("fixture:secret").toString("base64") }]; + assert.deepEqual(firstProxy.seen, upstreamConnect); assert.deepEqual(secondProxy.seen, upstreamConnect); + assert.equal(handshakes, 2); assert.equal(first.destroyed, false); assert.equal(second.destroyed, false); + + await Promise.all([socketClosed(first), socketClosed(second), manager.shutdown(owner)]); + assert.equal(first.destroyed, true); assert.equal(second.destroyed, true); + assert.deepEqual(manager.status(), { generation: null, state: "stopped", draining: true, poisoned: false }); + await assert.rejects(dialDesktopUpstream({ proxy, ca: ca.certPem, timeoutMs: 1000 }), + { message: "desktop_upstream_connection_failed" }); + assert.throws(() => manager.start(owner, { authorized: true, authorization }), /Manager unavailable/); + assert.equal(requests.length, 3); assert.equal(handshakes, 2); + assert.deepEqual(firstProxy.seen, upstreamConnect); assert.deepEqual(secondProxy.seen, upstreamConnect); + } finally { + for (const socket of sockets) socket.destroy(); + await manager.shutdown(owner); await firstProxy.close(); await secondProxy.close(); + await firstOrigin.close(); await secondOrigin.close(); + } +}, 15000); From 3dba53cf3ffa44723c82e30b9905db75fb8aeb9e Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 15:16:00 +0800 Subject: [PATCH 4/5] refactor(proxy): retain desktop transport and drop unused owned ingress --- .../content/docs/guides/codex-integration.md | 6 +- scripts/test-layout/layout.json | 5 - .../opaque-proxy-lifecycle/connect-request.ts | 36 ---- .../opaque-proxy-lifecycle/managed-ingress.ts | 174 ---------------- .../opaque-proxy-lifecycle/managed-session.ts | 40 ---- .../opaque-proxy-lifecycle/owner-manager.ts | 112 ---------- src/lib/opaque-proxy-tunnel.ts | 8 - structure/transports/inventory.md | 48 +---- tests/fixtures/test-layout-expected.json | 5 - .../lib/opaque-proxy-lifecycle-parser.test.ts | 31 --- .../opaque-proxy-lifecycle-transport.test.ts | 44 ---- tests/lib/opaque-proxy-lifecycle.test.ts | 191 ------------------ tests/lib/opaque-proxy-route-refresh.test.ts | 113 ----------- .../lib/opaque-proxy-session-protocol.test.ts | 155 -------------- tests/lib/optional-desktop-upstream.test.ts | 116 ++++------- 15 files changed, 45 insertions(+), 1039 deletions(-) delete mode 100644 src/lib/opaque-proxy-lifecycle/connect-request.ts delete mode 100644 src/lib/opaque-proxy-lifecycle/managed-ingress.ts delete mode 100644 src/lib/opaque-proxy-lifecycle/managed-session.ts delete mode 100644 src/lib/opaque-proxy-lifecycle/owner-manager.ts delete mode 100644 tests/lib/opaque-proxy-lifecycle-parser.test.ts delete mode 100644 tests/lib/opaque-proxy-lifecycle-transport.test.ts delete mode 100644 tests/lib/opaque-proxy-lifecycle.test.ts delete mode 100644 tests/lib/opaque-proxy-route-refresh.test.ts delete mode 100644 tests/lib/opaque-proxy-session-protocol.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 409a261b88a..45e908ebcdc 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1630,6 +1630,6 @@ headers are refused. SOCKS credentials retain the shared handshake's 255-byte per-field limit and existing empty-field behavior; both SOCKS schemes resolve the target through the proxy. Credentials are used only for the chosen proxy handshake. -The owned CONNECT-session library remains an internal integration seam. This change -adds no session-launch command, persistent credential injection, OS proxy change, -certificate installation, or original-Desktop authentication flow. +The existing relay owns connection cancellation and shutdown. This change adds +no listener, route-version API, configuration hot-reload mechanism, persistent +credential injection, OS proxy change, or certificate installation. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4c0b8e7d67d..2006c9e0ee4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -837,11 +837,6 @@ "optional-desktop-upstream.test.ts": "lib", "opaque-proxy-tunnel.test.ts": "lib", "opaque-proxy-tunnel-protocol.test.ts": "lib", - "opaque-proxy-lifecycle.test.ts": "lib", - "opaque-proxy-lifecycle-transport.test.ts": "lib", - "opaque-proxy-lifecycle-parser.test.ts": "lib", - "opaque-proxy-session-protocol.test.ts": "lib", - "opaque-proxy-route-refresh.test.ts": "lib", "destination-policy-resolved.test.ts": "routing", "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", diff --git a/src/lib/opaque-proxy-lifecycle/connect-request.ts b/src/lib/opaque-proxy-lifecycle/connect-request.ts deleted file mode 100644 index ceea6e1ce44..00000000000 --- a/src/lib/opaque-proxy-lifecycle/connect-request.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {validateAuthority} from '../opaque-proxy-tunnel'; - -export class ConnectRequestError extends Error { - constructor(readonly status: string) {super('Rejected local proxy request');} -} -export interface ConnectRequest {authority: string; authorization: string | undefined;} -/** Strict local CONNECT header only. Never parses target TLS/application bytes. */ -export function parseConnectRequest(header: Buffer): ConnectRequest { - if (header.length > 8192) throw new ConnectRequestError('431 Request Header Fields Too Large'); - const text = header.toString('latin1'); - if (/[^\x20-\x7e\r\n]/.test(text) || /(? 64 || lines[0].length > 2048 || lines.some(line => line.length > 4096)) throw new ConnectRequestError('431 Request Header Fields Too Large'); - const request = /^([A-Z]+) ([^ ]+) HTTP\/1\.[01]$/.exec(lines[0]); - if (!request) throw new ConnectRequestError('400 Bad Request'); - if (request[1] !== 'CONNECT') throw new ConnectRequestError('405 Method Not Allowed'); - const authority = request[2]; - try {validateAuthority(authority);} catch {throw new ConnectRequestError('400 Bad Request');} - let host: string | undefined, authorization: string | undefined; - for (const line of lines.slice(1)) { - const field = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+): *([\x20-\x7e]*?) *$/.exec(line); - if (!field) throw new ConnectRequestError('400 Bad Request'); - const name = field[1].toLowerCase(), value = field[2]; - if (name === 'content-length' || name === 'transfer-encoding') throw new ConnectRequestError('400 Bad Request'); - if (name === 'host') { - if (host !== undefined || value !== authority) throw new ConnectRequestError('400 Bad Request'); - host = value; - } - if (name === 'proxy-authorization') { - if (authorization !== undefined) throw new ConnectRequestError('407 Proxy Authentication Required'); - authorization = value; - } - } - if (host === undefined) throw new ConnectRequestError('400 Bad Request'); - return {authority, authorization}; -} diff --git a/src/lib/opaque-proxy-lifecycle/managed-ingress.ts b/src/lib/opaque-proxy-lifecycle/managed-ingress.ts deleted file mode 100644 index 7fb8eddd664..00000000000 --- a/src/lib/opaque-proxy-lifecycle/managed-ingress.ts +++ /dev/null @@ -1,174 +0,0 @@ -import {createServer} from 'node:net'; -import {parseConnectRequest, ConnectRequestError} from './connect-request'; -import { timingSafeEqual } from 'node:crypto'; -import {Socket, type AddressInfo} from 'node:net'; -import {dialTunnel, acceptsTargetPolicy, snapshotProxyRoutes, type OpaqueProxyRoute} from '../opaque-proxy-tunnel'; - -export interface ManagedIngressOptions { - authorization: string; - candidates: readonly OpaqueProxyRoute[]; - allowTarget(authority: string): boolean; - maxActive?: number; maxQueued?: number; maxClients?: number; - idleMs?: number; totalMs?: number; sessionMs?: number; - queueMs?: number; establishMs?: number; - dial?: typeof dialTunnel; -} -interface ClientRecord { - client: Socket; controller: AbortController; done: boolean; active: boolean; - authority?: string; head?: Buffer; upstream?: Socket; - routes?: readonly OpaqueProxyRoute[]; - totalTimer?: ReturnType; - queueTimer?: ReturnType; - establishTimer?: ReturnType; -} - -const integer = (x: number, name: string, min = 1) => { - if (!Number.isSafeInteger(x) || x < min || x > 2 ** 31 - 1) throw new Error(`Invalid ${name}`); -}; - -/** All limits include unauthenticated clients. The dialer owns establishment only. */ -export function createManagedIngress({ authorization, candidates, allowTarget, - maxActive = 16, maxQueued = 16, maxClients = 64, idleMs = 30000, - totalMs = 300000, sessionMs = 300000, queueMs = 3000, establishMs = 5000, dial = dialTunnel }: ManagedIngressOptions) { - if (typeof authorization !== 'string' || authorization.length < 40 || /[\r\n]/.test(authorization)) throw new Error('Private capability required'); - if (typeof allowTarget !== 'function' || !Array.isArray(candidates) || !candidates.length) throw new Error('Explicit routes and policy required'); - for (const [key, val] of Object.entries({maxActive, maxClients, idleMs, totalMs, queueMs, establishMs, sessionMs})) integer(val, key); - integer(maxQueued, 'maxQueued', 0); - if (establishMs > 30000) throw new Error('Establishment deadline exceeds transport limit'); - let routes = snapshotProxyRoutes(candidates); - const secret = Buffer.from(authorization); - const clients = new Map(), queue: ClientRecord[] = []; - let active = 0, closing = false; - let listenPromise: Promise | undefined; - let closePromise: Promise | undefined; - let sessionTimer: ReturnType | undefined; - // Bun node:http emits connection only after request bytes arrive. A raw net - // listener is required to own unauthenticated/slowloris sockets from accept. - const server = createServer({allowHalfOpen: true}); - server.on('error', () => {void api.close();}); - function finish(record: ClientRecord) { - if (record.done) return; - record.done = true; - clearTimeout(record.totalTimer); clearTimeout(record.queueTimer); clearTimeout(record.establishTimer); - record.controller.abort(); record.upstream?.destroy(); - clients.delete(record.client); - const i = queue.indexOf(record); if (i !== -1) queue.splice(i, 1); - if (record.active) { active--; record.active = false; } - pump(); - } - function reject(record: ClientRecord, code: string) { - if (record.done || record.client.destroyed) return; - const queuedIndex = queue.indexOf(record); if (queuedIndex !== -1) queue.splice(queuedIndex, 1); - const challenge = code === '407 Proxy Authentication Required' ? 'Proxy-Authenticate: Basic realm="opencodex-session"\r\n' : ''; - record.client.end(`HTTP/1.1 ${code}\r\n${challenge}Connection: close\r\nContent-Length: 0\r\n\r\n`, () => record.client.destroy()); - } - server.on('connection', client => { - client.on('error', () => {}); - if (closing || clients.size >= maxClients || client.remoteAddress !== '127.0.0.1') { client.destroy(); return; } - const record: ClientRecord = {client, controller: new AbortController(), done:false, active:false}; - clients.set(client, record); - record.totalTimer = setTimeout(() => {client.destroy(); record.upstream?.destroy();}, totalMs); - client.setTimeout(idleMs, () => client.destroy()); - client.once('close', () => { - if (!record.upstream || record.upstream.destroyed || !client.readableEnded) finish(record); - // A graceful downstream EOF may still have bytes queued for upstream. - // Keep this slot and its absolute deadline until that writable drains. - }); - readConnect(record); - }); - function pump() { - while (!closing && active < maxActive && queue.length) { - const record = queue.shift(); - if (record) begin(record); - } - } - function begin(record: ClientRecord) { - if (record.done) return; - clearTimeout(record.queueTimer); record.active = true; active++; - record.establishTimer = setTimeout(() => {record.controller.abort(); record.client.destroy();}, establishMs); - // One dial per accepted connection. No replay after commitment. - Promise.resolve().then(() => { - if (record.done || closing || record.controller.signal.aborted) throw new Error('Closed'); - const authority = record.authority; - if (!authority) throw new Error('Missing accepted target'); - return dial(authority, record.routes ?? [], {signal:record.controller.signal, timeoutMs:establishMs, - totalTimeoutMs:establishMs, allowTarget: target => target === authority && acceptsTargetPolicy(allowTarget, target)}); - }).then(({socket: upstream}) => { - clearTimeout(record.establishTimer); - if (closing || record.done || record.controller.signal.aborted || record.client.destroyed) {upstream.destroy(); return;} - record.upstream = upstream; - const client = record.client; - const destroy = () => {client.destroy(); upstream.destroy();}; - upstream.on('error', destroy); client.once('error', destroy); - upstream.setTimeout(idleMs, destroy); - upstream.once('close', () => { - if (!upstream.readableEnded) client.destroy(); - if (client.destroyed) finish(record); - }); - client.once('close', () => {if (!client.readableEnded) upstream.destroy();}); - client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); - if (record.head?.length) client.unshift(record.head); - client.pipe(upstream); upstream.pipe(client); - }).catch(() => {clearTimeout(record.establishTimer); reject(record, '502 Bad Gateway');}); - } - function readConnect(record: ClientRecord) { - const client = record.client; - let buffered = Buffer.alloc(0); - const receive = (chunk: Buffer) => { - buffered = Buffer.concat([buffered, chunk]); - const end = buffered.indexOf('\r\n\r\n'); - if (end < 0 && buffered.length <= 8192) return; - client.pause(); client.removeListener('data', receive); - if (record.done || closing) {client.destroy(); return;} - if (end < 0 || end > 8192) {reject(record, '431 Request Header Fields Too Large'); return;} - let request; - try {request = parseConnectRequest(buffered.subarray(0, end));} - catch (error) {reject(record, error instanceof ConnectRequestError ? error.status : '400 Bad Request'); return;} - const supplied = Buffer.from(request.authorization ?? ''); - if (supplied.length !== secret.length || !timingSafeEqual(supplied, secret)) {reject(record, '407 Proxy Authentication Required'); return;} - const allowed = acceptsTargetPolicy(allowTarget, request.authority); - if (!allowed) {reject(record, '403 Forbidden'); return;} - record.authority = request.authority; record.head = buffered.subarray(end + 4); - record.routes = routes; - if (active < maxActive) begin(record); - else if (queue.length < maxQueued) { - queue.push(record); record.queueTimer = setTimeout(() => {reject(record, '503 Service Unavailable');}, queueMs); - } else reject(record, '503 Service Unavailable'); - }; - client.once('end', () => {if (!record.authority) client.destroy();}); - client.on('data', receive); - } - const api = { - server, - replaceCandidates(candidates: readonly OpaqueProxyRoute[]): void { - if (closing) throw new Error('Closed'); - routes = snapshotProxyRoutes(candidates); - }, - stats: () => ({clients:clients.size, active, queued:queue.length}), - listen(): Promise { - if (closing) return Promise.reject(new Error('Closed')); - return listenPromise ??= new Promise((resolve, reject) => { - const fail = (error: Error) => {server.removeListener('listening', ready); reject(error);}; - const ready = () => { - server.removeListener('error', fail); - const address = server.address(); - if (!address || typeof address === 'string') {reject(new Error('Invalid listening address')); return;} - resolve(address); - }; - server.once('error', fail); server.once('listening', ready); sessionTimer = setTimeout(() => {void api.close();}, sessionMs); - server.listen(0, '127.0.0.1'); - }); - }, - close(): Promise { - if (closePromise) return closePromise; - closing = true; clearTimeout(sessionTimer); - for (const record of clients.values()) {record.controller.abort(); record.client.destroy(); record.upstream?.destroy();} - closePromise = (async () => { - if (listenPromise) await listenPromise.catch(() => {}); - if (server.listening) await new Promise(resolve => server.close(() => resolve())); - })(); - return closePromise; - }, - }; - return api; -} diff --git a/src/lib/opaque-proxy-lifecycle/managed-session.ts b/src/lib/opaque-proxy-lifecycle/managed-session.ts deleted file mode 100644 index 0156626198b..00000000000 --- a/src/lib/opaque-proxy-lifecycle/managed-session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {createOwnerManager, type OwnerManagerOptions} from './owner-manager'; -import {createManagedIngress, type ManagedIngressOptions} from './managed-ingress'; -import {snapshotProxyRoutes, type OpaqueProxyRoute} from '../opaque-proxy-tunnel'; - -export interface OwnedIngressManagerOptions { - owner: object; - candidates: readonly OpaqueProxyRoute[]; - allowTarget(authority: string): boolean; - ingress?: Omit; - manager?: Omit; -} -/** Executable composition seam. No activation, registration, or listener at import. */ -export function createOwnedIngressManager({owner, candidates, allowTarget, ingress = {}, manager = {}}: OwnedIngressManagerOptions) { - if (!Array.isArray(candidates) || !candidates.length || typeof allowTarget !== 'function') throw new Error('Explicit routes and target policy required'); - let routes = snapshotProxyRoutes(candidates), revision = 0, epoch = 0; - let active: ReturnType | undefined; - const lifecycle = createOwnerManager({...manager, owner, createWorker:({authorization, onExit}) => { - epoch++; - const worker = createManagedIngress({...ingress, authorization, candidates:routes, allowTarget}); - active = worker; - worker.server.once('close', () => { if (active === worker) active = undefined; onExit(); }); - worker.server.once('error', onExit); - return worker; - }}); - return { - ...lifecycle, - routeVersion: () => ({revision, epoch, generation:lifecycle.status().generation}), - /** Only future accepted connections change routes. Existing/queued streams never replay. */ - updateRoutes(caller: object, expected: {revision: number; epoch: number; generation: number | null}, candidates: readonly OpaqueProxyRoute[]): number { - if (caller !== owner) throw new Error('Wrong owner'); - const status = lifecycle.status(); - if (status.draining || status.poisoned || status.state === 'stopping' || status.state === 'failed') throw new Error('Manager unavailable'); - if (!expected || expected.revision !== revision || expected.epoch !== epoch || expected.generation !== status.generation) throw new Error('Stale route version'); - const next = snapshotProxyRoutes(candidates); - active?.replaceCandidates(next); - routes = next; - return ++revision; - }, - }; -} diff --git a/src/lib/opaque-proxy-lifecycle/owner-manager.ts b/src/lib/opaque-proxy-lifecycle/owner-manager.ts deleted file mode 100644 index 1ce535c6ac4..00000000000 --- a/src/lib/opaque-proxy-lifecycle/owner-manager.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Explicit in-process ownership. No PID lookup, discovery, daemon, or auto-restart. */ -export interface OwnedWorker { - listen(): Promise<{ address: string; port: number }> | { address: string; port: number }; - close(): Promise | void; -} -export interface WorkerContext { - generation: number; - signal: AbortSignal; - authorization: string; - onExit(): void; -} -export interface OwnerManagerOptions { - owner: object; - createWorker(context: WorkerContext): OwnedWorker; - startupMs?: number; - shutdownMs?: number; - sessionMs?: number; -} -export interface Activation { - authorized?: boolean; - authorization?: string; -} -export interface GenerationLease { - readonly generation: number; - readonly host: string; - readonly port: number; - stop(): Promise; -} -type Phase = 'starting' | 'ready' | 'stopping' | 'stopped' | 'failed'; -interface Generation { - generation: number; - authorization: string; - state: Phase; - abort: AbortController; - worker?: OwnedWorker; - ready?: Promise; - stopPromise?: Promise; - sessionTimer?: ReturnType; -} -function bounded(promise: Promise, ms: number, message: string): Promise { - let timer: ReturnType | undefined; - return Promise.race([promise, new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms); - })]).finally(() => clearTimeout(timer)); -} -export function createOwnerManager({owner, createWorker, startupMs = 5000, - shutdownMs = 5000, sessionMs = 300000}: OwnerManagerOptions) { - if (!owner || typeof owner !== 'object' || typeof createWorker !== 'function') throw new Error('Owner and worker factory required'); - for (const value of [startupMs, shutdownMs, sessionMs]) { - if (!Number.isSafeInteger(value) || value < 1 || value > 2 ** 31 - 1) throw new Error('Invalid deadline'); - } - let current: Generation | undefined; - let nextGeneration = 0, draining = false, poisoned = false; - const checkOwner = (caller: object) => { if (caller !== owner) throw new Error('Wrong owner'); }; - function dispose(record: Generation): Promise { - if (record.stopPromise) return record.stopPromise; - record.state = 'stopping'; - clearTimeout(record.sessionTimer); - record.abort.abort(); - record.stopPromise = bounded(Promise.resolve().then(() => record.worker?.close()), shutdownMs, 'Shutdown deadline exceeded') - .then(() => { record.state = 'stopped'; if (current === record) current = undefined; }) - .catch((error: unknown) => { record.state = 'failed'; poisoned = true; throw error; }); - return record.stopPromise; - } - return { - status() { return {generation: current?.generation ?? null, state: current?.state ?? 'stopped', draining, poisoned}; }, - start(caller: object, {authorized = false, authorization}: Activation = {}): Promise { - checkOwner(caller); - if (authorized !== true || typeof authorization !== 'string' || authorization.length < 40 || /[\r\n]/.test(authorization)) { - throw new Error('Explicit authorization and ephemeral capability required'); - } - if (draining || poisoned) throw new Error('Manager unavailable'); - if (current) { - if (current.authorization !== authorization) throw new Error('Active generation configuration differs'); - if (current.state === 'starting' || current.state === 'ready') { - if (!current.ready) throw new Error('Reentrant startup'); - return current.ready; - } - throw new Error('Previous generation has not stopped'); - } - const record: Generation = {generation: ++nextGeneration, authorization, state: 'starting', abort: new AbortController()}; - current = record; - const onExit = () => { - if (current !== record || record.state === 'stopping' || record.state === 'stopped') return; - record.state = 'failed'; record.abort.abort(); - void dispose(record).catch(() => {}); - }; - try { record.worker = createWorker({generation: record.generation, signal: record.abort.signal, authorization, onExit}); } - catch (error) { current = undefined; throw error; } - const worker = record.worker; - // A factory may synchronously report exit. Never schedule a post-disposal timer. - if (current === record && record.state === 'starting' && !record.abort.signal.aborted) { - record.sessionTimer = setTimeout(() => { void dispose(record).catch(() => {}); }, sessionMs); - } - record.ready = bounded(Promise.resolve().then(() => { - if (record.abort.signal.aborted) throw new Error('Stale readiness'); - return worker.listen(); - }), startupMs, 'Startup deadline exceeded').then(address => { - if (current !== record || record.state !== 'starting' || record.abort.signal.aborted) throw new Error('Stale readiness'); - if (!address || address.address !== '127.0.0.1' || !Number.isInteger(address.port) || address.port < 1 || address.port > 65535) { - throw new Error('Invalid loopback readiness'); - } - record.state = 'ready'; - return Object.freeze({generation: record.generation, host: address.address, port: address.port, stop: () => dispose(record)}); - }).catch(async (error: unknown) => { await dispose(record); throw error; }); - return record.ready; - }, - stop(caller: object): Promise { checkOwner(caller); return current ? dispose(current) : Promise.resolve(); }, - shutdown(caller: object): Promise { checkOwner(caller); draining = true; return current ? dispose(current) : Promise.resolve(); }, - }; -} -export type OwnerManager = ReturnType; diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts index 3b969631ad0..e32073f4b3d 100644 --- a/src/lib/opaque-proxy-tunnel.ts +++ b/src/lib/opaque-proxy-tunnel.ts @@ -85,14 +85,6 @@ function validateOptions(candidates: readonly OpaqueProxyRoute[], timeoutMs: num } } -/** Validated owned snapshot; refreshing a caller's routes cannot mutate an active dial. */ -export function snapshotProxyRoutes(candidates: readonly OpaqueProxyRoute[]): readonly OpaqueProxyRoute[] { - validateOptions(candidates, 1000, 8192); - return Object.freeze(candidates.map(p => Object.freeze(p.kind === 'https' - ? {...p, proxyCa: Buffer.isBuffer(p.proxyCa) ? Buffer.from(p.proxyCa) : p.proxyCa} - : {...p}))); -} - function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes, authorize }: AttemptOptions): Promise { if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes, authorize }); return new Promise((resolve, reject) => { diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6431f0b383a..bb5bbc708a5 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -420,8 +420,7 @@ Only the proxy handshake carries proxy credentials. The raw upgraded socket rema the relay does not decode WebSocket payloads. After CONNECT the paused outer stream resumes before TLS-over-TLS negotiation. Test fixture CA trust is confined to child processes or injected test socket options, never the user's OS trust store. -`tests/lib/optional-desktop-upstream.test.ts` exercises the production dialer and -owned-session composition; `tests/clients/desktop-compatibility-relay.test.ts` +`tests/lib/optional-desktop-upstream.test.ts` exercises the production dialer; `tests/clients/desktop-compatibility-relay.test.ts` preserves the real relay’s fixed-target and opaque upgrade contracts. ## Explicit opaque proxy candidates @@ -456,44 +455,7 @@ replayed. Bounded SOCKS frame reads preserve early opaque bytes after CONNECT. `tests/lib/opaque-proxy-tunnel.test.ts` and `tests/lib/opaque-proxy-tunnel-protocol.test.ts` cover these local transport contracts. -## Owned opaque proxy sessions - -`src/lib/opaque-proxy-lifecycle/managed-ingress.ts` provides a loopback-only CONNECT -ingress using raw TCP accept so Bun counts even clients that send no HTTP bytes, -with an ephemeral capability, an authority predicate, connection limits, -header/establishment/idle/session deadlines and owned socket cleanup. It consumes -client proxy authorization locally rather than forwarding it. Its diagnostic state -contains bounded counts and lifecycle labels, not target bodies or credentials. -`src/lib/opaque-proxy-lifecycle/connect-request.ts` requires an exact Host/authority -match and rejects ambiguous authentication, request bodies, folded headers and -control bytes before dispatch. `tests/lib/opaque-proxy-lifecycle-parser.test.ts` -covers this narrow strict CONNECT-only parser. -`src/lib/opaque-proxy-lifecycle/owner-manager.ts` serializes start ownership by -generation, reuses healthy workers, retires stale starts and gives intentional stop -precedence over restart. `src/lib/opaque-proxy-lifecycle/managed-session.ts` composes -the manager, ingress and explicitly authorized dial without installing a service. -`tests/lib/opaque-proxy-lifecycle.test.ts` and -`tests/lib/opaque-proxy-lifecycle-transport.test.ts` bind these lifecycle and real -loopback composition contracts. These modules have no production registration, -Desktop launch/configuration writes, OS proxy changes, CA installation, or account -integration; successful local composition is not original-Desktop compatibility. - -`tests/lib/opaque-proxy-session-protocol.test.ts` exercises the complete managed -session through all four proxy schemes with independently verified target TLS, -including a coalesced CONNECT/ClientHello, large half-closed streams, capability -rejection before dial, stop/session expiry and certificate refusals. These are -loopback composition tests; they do not establish original-Desktop integration. - -The owned manager's explicit `updateRoutes` operation uses owner identity and a -compare-and-swap revision, monotonic start epoch and observed session generation. It validates and snapshots routes before changing them, -including copies of mutable HTTPS trust bytes. The existing listener and capability -remain fixed; a successfully authenticated CONNECT captures its route list before -queueing. Established and queued connections never migrate or replay during refresh. -Stopped managers can stage a next route, but shutdown, failed disposal and stale -revisions refuse updates. This is a process-local seam, not OS proxy discovery or -a Desktop attachment API. `tests/lib/opaque-proxy-route-refresh.test.ts` verifies -real CONNECT connections across a refresh, queue pinning and rejected updates. -Unauthenticated CONNECT receives a standard Basic proxy challenge with a fixed -realm; it never reveals the session capability. A challenge-aware client must -still obtain that capability through a separately authorized integration. This -does not establish that the original Desktop implements proxy authentication. +The existing Desktop relay owns connection cancellation and shutdown. Each new +dial selects its configured proxy; established connections keep their original +transport. This module adds no listener, session manager, route revision API or +configuration hot-reload mechanism. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c12fb0fd75a..5b4530b1330 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1265,11 +1265,6 @@ "optional-desktop-upstream.test.ts": "lib", "opaque-proxy-tunnel.test.ts": "lib", "opaque-proxy-tunnel-protocol.test.ts": "lib", - "opaque-proxy-lifecycle.test.ts": "lib", - "opaque-proxy-lifecycle-transport.test.ts": "lib", - "opaque-proxy-lifecycle-parser.test.ts": "lib", - "opaque-proxy-session-protocol.test.ts": "lib", - "opaque-proxy-route-refresh.test.ts": "lib", "server-desktop-compatibility-startup.test.ts": "server", "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor" diff --git a/tests/lib/opaque-proxy-lifecycle-parser.test.ts b/tests/lib/opaque-proxy-lifecycle-parser.test.ts deleted file mode 100644 index 1d5b8980d59..00000000000 --- a/tests/lib/opaque-proxy-lifecycle-parser.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {test, expect} from 'bun:test'; -import {parseConnectRequest, ConnectRequestError} from '../../src/lib/opaque-proxy-lifecycle/connect-request'; -const authority='target.invalid:443'; -const auth='Basic synthetic-local-session-fixture'; -const valid=`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${auth}`; -test('strict local parser accepts explicit CONNECT with matching single Host and capability',()=>{ - expect(parseConnectRequest(Buffer.from(valid))).toEqual({authority,authorization:auth}); -}); -const invalid: Array<[string,string]> = [ - ['missing Host', `CONNECT ${authority} HTTP/1.1\r\nProxy-Authorization: ${auth}`], - ['duplicate Host', valid+`\r\nHost: ${authority}`], - ['ambiguous Host', valid.replace(`Host: ${authority}`, 'Host: other.invalid:443')], - ['duplicate authorization', valid+`\r\nProxy-Authorization: ${auth}`], - ['folded line', valid+'\r\n extra: value'], - ['NUL value', valid+'\r\nX: abc\0def'], - ['bare LF', valid.replace('\r\n','\n')], - ['bare CR', valid+'\rX: bad'], - ['tab header value', valid+'\r\nX: abc\tdef'], - ['content length zero', valid+'\r\nContent-Length: 0'], - ['content length body', valid+'\r\nContent-Length: 5'], - ['transfer encoding', valid+'\r\nTransfer-Encoding: chunked'], - ['unsupported method', valid.replace('CONNECT','GET')], - ['authority path', valid.replace(authority,authority+'/path')], - ['request line over limit', `CONNECT ${'a'.repeat(2050)}:443 HTTP/1.1`], - ['line over limit', valid+'\r\nX: '+'a'.repeat(4096)], - ['header byte limit', valid+'\r\nX: '+'a'.repeat(8192)], - ['header line limit', valid+'\r\nX: a'.repeat(63)], -]; -for(const [name,header] of invalid) test(`strict local parser rejects ${name}`,()=>{ - expect(()=>parseConnectRequest(Buffer.from(header))).toThrow(ConnectRequestError); -}); diff --git a/tests/lib/opaque-proxy-lifecycle-transport.test.ts b/tests/lib/opaque-proxy-lifecycle-transport.test.ts deleted file mode 100644 index 4103d7f8f6c..00000000000 --- a/tests/lib/opaque-proxy-lifecycle-transport.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {test as bunTest} from 'bun:test'; -interface CleanupContext {after(cleanup: () => void | Promise): void;} -function test(name: string, run: (context: CleanupContext) => void | Promise) { - bunTest(name, async () => { - const cleanups: Array<() => void | Promise> = []; - try {await run({after: cleanup => {cleanups.push(cleanup);}});} - finally {for (const cleanup of cleanups.reverse()) await cleanup();} - }); -} -import assert from 'node:assert/strict'; -import net, {type AddressInfo, type Server, type Socket} from 'node:net'; -import {once} from 'node:events'; -import {createOwnedIngressManager} from '../../src/lib/opaque-proxy-lifecycle/managed-session'; -const authorization='Basic '+Buffer.from('session:synthetic-integration-only-capability').toString('base64'); -const pause=(ms: number)=>new Promise(r=>setTimeout(r,ms)); -async function until(fn: () => boolean){for(let n=0;n<200;n++){if(fn())return;await pause(5);}assert.fail('Did not settle');} -async function fixture(t: CleanupContext,{sessionMs=2000}={}){ - const sockets=new Set(),listeners: Server[]=[];let failedAttempts=0,proxyAttempts=0,targetConnections=0,targetBytes=0;const proxyHeaders: string[]=[]; - async function listen(handler: (socket: Socket) => void){const s=net.createServer({allowHalfOpen:true},socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));handler(socket);});listeners.push(s);s.listen(0,'127.0.0.1');await once(s,'listening');return (s.address() as AddressInfo).port;} - const targetPort=await listen(s=>{targetConnections++;s.on('data',b=>targetBytes+=b.length);s.pipe(s);}); - const firstPort=await listen(s=>{failedAttempts++;s.destroy();}); - const goodPort=await listen(client=>{ - proxyAttempts++;let bytes=Buffer.alloc(0); - function receive(chunk: Buffer){bytes=Buffer.concat([bytes,chunk]);const end=bytes.indexOf('\r\n\r\n');if(end<0)return;client.removeListener('data',receive);client.pause(); - proxyHeaders.push(bytes.subarray(0,end).toString()); - const upstream=net.connect(targetPort,'127.0.0.1');sockets.add(upstream);upstream.on('error',()=>client.destroy());upstream.on('close',()=>{sockets.delete(upstream);client.destroy();});client.on('close',()=>upstream.destroy()); - upstream.once('connect',()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');const head=bytes.subarray(end+4);if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client);}); - }client.on('data',receive); - }); - const owner={},authority=`127.0.0.1:${targetPort}`; - const manager=createOwnedIngressManager({owner,candidates:[{id:'closed-first',kind:'http',host:'127.0.0.1',port:firstPort},{id:'working-second',kind:'http',host:'127.0.0.1',port:goodPort}],allowTarget:a=>a===authority,ingress:{sessionMs,idleMs:1000,totalMs:2000},manager:{sessionMs:3000}}); - const lease=await manager.start(owner,{authorized:true,authorization});const clients: Socket[]=[]; - async function connect(auth: string){const client=Object.assign(net.connect(lease.port,lease.host),{output:''});clients.push(client);client.on('error',()=>{});client.on('data',b=>client.output+=b.toString());await once(client,'connect');client.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${auth}\r\n\r\n`);return client;} - t.after(async()=>{for(const s of clients)s.destroy();await manager.shutdown(owner);for(const s of sockets)s.destroy();await Promise.all(listeners.map(s=>new Promise(r=>s.close(r))));}); - return{manager,owner,lease,connect,proxyHeaders,counts:()=>({failedAttempts,proxyAttempts,targetConnections,targetBytes,sockets:sockets.size})}; -} -test('real composition: wrong capability has zero network dials; approved fallback carries opaque bytes once; stop reclaims generation',async t=>{ - const x=await fixture(t);const denied=await x.connect('Basic invalid');await until(()=>denied.destroyed);assert.match(denied.output,/407/);assert.deepEqual(x.counts(),{failedAttempts:0,proxyAttempts:0,targetConnections:0,targetBytes:0,sockets:0}); - const accepted=await x.connect(authorization);await until(()=>accepted.output.includes('200'));const body='opaque-TLS-like-synthetic-application-bytes';accepted.write(body);await until(()=>accepted.output.endsWith(body));assert.equal(x.counts().failedAttempts,1);assert.equal(x.counts().proxyAttempts,1);assert.equal(x.counts().targetConnections,1);assert.equal(x.counts().targetBytes,body.length);assert.doesNotMatch(x.proxyHeaders[0],/Proxy-Authorization/i); - await x.manager.stop(x.owner);await until(()=>accepted.destroyed&&x.counts().sockets===0);assert.equal(x.manager.status().state,'stopped');assert.equal(x.counts().failedAttempts,1);assert.equal(x.counts().proxyAttempts,1); -}); -test('real composition: ingress session expiry invalidates ready lease and closes active target tunnel without renewal',async t=>{ - const x=await fixture(t,{sessionMs:80});const accepted=await x.connect(authorization);await until(()=>accepted.output.includes('200'));accepted.write('one');await until(()=>accepted.output.endsWith('one'));await until(()=>accepted.destroyed&&x.counts().sockets===0&&x.manager.status().state==='stopped');await x.lease.stop();assert.equal(x.counts().proxyAttempts,1);assert.equal(x.counts().targetBytes,3); -}); diff --git a/tests/lib/opaque-proxy-lifecycle.test.ts b/tests/lib/opaque-proxy-lifecycle.test.ts deleted file mode 100644 index fb920772417..00000000000 --- a/tests/lib/opaque-proxy-lifecycle.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {test as bunTest} from 'bun:test'; -interface CleanupContext {after(cleanup: () => void | Promise): void;} -function test(name: string, run: (context: CleanupContext) => void | Promise) { - bunTest(name, async () => { - const cleanups: Array<() => void | Promise> = []; - try {await run({after: cleanup => {cleanups.push(cleanup);}});} - finally {for (const cleanup of cleanups.reverse()) await cleanup();} - }); -} -import assert from 'node:assert/strict'; -import net, {type AddressInfo, type Socket} from 'node:net'; -import {once} from 'node:events'; -import {createManagedIngress, type ManagedIngressOptions} from '../../src/lib/opaque-proxy-lifecycle/managed-ingress'; -import {createOwnerManager, type WorkerContext} from '../../src/lib/opaque-proxy-lifecycle/owner-manager'; -const authorization = 'Basic ' + Buffer.from('session:synthetic-test-only-capability-000000000000').toString('base64'); -const pause = (ms: number) => new Promise(r => setTimeout(r, ms)); -function deferred() {let resolve!: (value: T | PromiseLike) => void, reject!: (reason?: unknown) => void; const promise = new Promise((a,b) => {resolve=a; reject=b;}); return {promise, resolve, reject};} -async function until(fn: () => boolean) {for(let n=0; n<100;n++){if(fn()) return; await pause(5);} assert.fail('Condition did not settle');} -async function setup(t: CleanupContext, options: Partial={}) { - const peers = new Set(); let proxyCalls = 0; - const proxy = net.createServer({allowHalfOpen:true}, socket => { - peers.add(socket); socket.on('error',()=>{}); socket.on('close',()=>peers.delete(socket)); - socket.once('data', () => {proxyCalls++; socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); socket.pipe(socket);}); - }); - proxy.listen(0,'127.0.0.1'); await once(proxy,'listening'); - const ingress = createManagedIngress({authorization, candidates:[{id:'test',host:'127.0.0.1',port:(proxy.address() as AddressInfo).port}], allowTarget:a=>a==='test.invalid:443', idleMs:1000,totalMs:3000,...options}); - const address = await ingress.listen(); const clients: Socket[]=[]; - t.after(async()=>{for(const s of clients)s.destroy();await ingress.close(); for(const s of peers)s.destroy(); await new Promise(r=>proxy.close(r));}); - async function client(header: string | null=authorization, extra='') { - const s = Object.assign(net.connect(address.port, '127.0.0.1'), {output:''}); clients.push(s); s.on('error',()=>{}); s.on('data',b=>s.output+=b.toString()); - await once(s,'connect'); s.write(`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\n${header===null?'':`Proxy-Authorization: ${header}\r\n`}${extra}\r\n`); return s; - } - return {ingress,address,client,clients,calls:()=>proxyCalls}; -} -test('explicit owner, authorization, and loopback readiness are required', async()=>{ - const owner={}; const m=createOwnerManager({owner,createWorker:()=>({listen:()=>({address:'0.0.0.0',port:1}),close:()=>{}})}); - assert.throws(()=>m.start({},{authorized:true,authorization}),/owner/); - assert.throws(()=>m.start(owner,{authorization}),/authorization/); - // Deliberately invalid runtime values exercise the boundary's strict true check. - for (const authorized of [false, 'false', 1, Promise.resolve(true)]) assert.throws(()=>m.start(owner,{authorized:authorized as boolean,authorization}),/authorization/); - await assert.rejects(m.start(owner,{authorized:true,authorization}),/loopback/); - assert.equal(m.status().state,'stopped'); -}); -test('duplicate starts share readiness; old lease cannot stop new generation', async()=>{ - const owner={}, workers: Array<{opts: WorkerContext; closed: number}>=[]; - const m=createOwnerManager({owner,createWorker:opts=>{const w={opts,closed:0,listen:()=>({address:'127.0.0.1',port:1234}),close(){this.closed++;}};workers.push(w); return w;}}); - const a=m.start(owner,{authorized:true,authorization}), b=m.start(owner,{authorized:true,authorization}); assert.equal(a,b); - const first=await a; await first.stop(); const second=await m.start(owner,{authorized:true,authorization}); - await first.stop(); workers[0].opts.onExit(); assert.equal(m.status().generation,second.generation); assert.equal(workers[1].closed,0); - await m.shutdown(owner); assert.throws(()=>m.start(owner,{authorized:true,authorization}),/unavailable/); -}); -test('stop during startup and late readiness cannot publish over a new generation', async()=>{ - const owner={}, ready=deferred<{address: string; port: number}>(); let n=0; - const m=createOwnerManager({owner,createWorker:()=>({listen:()=>++n===1?ready.promise:{address:'127.0.0.1',port:2},close:()=>{}})}); - const old=m.start(owner,{authorized:true,authorization}); const oldResult=assert.rejects(old,/Stale/); - await pause(0); await m.stop(owner); const next=await m.start(owner,{authorized:true,authorization}); ready.resolve({address:'127.0.0.1',port:1}); await oldResult; - assert.equal(m.status().generation,next.generation); await m.stop(owner); -}); -test('startup timeout closes own worker; crashed generation has no automatic restart',async()=>{ - const owner={}; let calls=0,closed=0;let onExit!: WorkerContext['onExit']; - const m=createOwnerManager({owner,startupMs:20,createWorker:o=>{calls++;onExit=o.onExit;return{listen:()=>new Promise(()=>{}),close:()=>{closed++;}};}}); - await assert.rejects(m.start(owner,{authorized:true,authorization}),/Startup/); assert.equal(closed,1);onExit();assert.equal(calls,1); -}); -test('failed shutdown is bounded and blocks new starts instead of orphaning ownership',async()=>{ - const owner={};const m=createOwnerManager({owner,shutdownMs:20,createWorker:()=>({listen:()=>({address:'127.0.0.1',port:2}),close:()=>new Promise(()=>{})})}); - await m.start(owner,{authorized:true,authorization});await assert.rejects(m.stop(owner),/Shutdown/);assert.throws(()=>m.start(owner,{authorized:true,authorization}),/unavailable/); -}); -test('missing, wrong, duplicate capability denied before dialing; authorized bytes echoed',async t=>{ - const x=await setup(t); assert.equal(x.address.address,'127.0.0.1'); - for(const [header,extra] of [[null,''],['wrong',''],[authorization,`Proxy-Authorization: ${authorization}\r\n`]] as const){ - const s=await x.client(header,extra);await until(()=>s.destroyed);assert.match(s.output,/407/); - } - assert.equal(x.calls(),0);const good=await x.client();await until(()=>good.output.includes('200'));good.write('opaque-data');await until(()=>good.output.includes('opaque-data'));assert.equal(x.calls(),1); -}); -test('concurrency and queue are bounded; disconnect promotes only queued accepted client',async t=>{ - const x=await setup(t,{maxActive:1,maxQueued:1});const a=await x.client();await until(()=>a.output.includes('200')); - const b=await x.client();await until(()=>x.ingress.stats().queued===1);const c=await x.client();await until(()=>c.destroyed);assert.match(c.output,/503/);assert.equal(x.calls(),1); - a.destroy();await until(()=>b.output.includes('200'));assert.equal(x.calls(),2);assert.equal(x.ingress.stats().active,1); -}); -test('queue deadline removes waiting client without dialing',async t=>{ - const x=await setup(t,{maxActive:1,maxQueued:1,queueMs:25});const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>b.destroyed);assert.match(b.output,/503/);a.destroy();await pause(25);assert.equal(x.calls(),1); -}); -test('idle deadline and total session deadline close owned sockets',async t=>{ - const x=await setup(t,{idleMs:40,totalMs:120}); const a=await x.client();await until(()=>a.output.includes('200'));await until(()=>a.destroyed); - const b=await x.client();await until(()=>b.output.includes('200'));const timer=setInterval(()=>b.write('x'),10);await until(()=>b.destroyed);clearInterval(timer);await until(()=>x.ingress.stats().clients===0); -}); -test('raw clients also bounded and expired before authentication',async t=>{ - const x=await setup(t,{maxClients:1,idleMs:40}); const a=net.connect(x.address.port,'127.0.0.1');x.clients.push(a);a.on('error',()=>{});a.resume();await once(a,'connect'); - const b=net.connect(x.address.port,'127.0.0.1');x.clients.push(b);b.on('error',()=>{});b.resume();await until(()=>b.destroyed);await until(()=>a.destroyed);assert.equal(x.calls(),0); -}); -test('late successful dial after close is destroyed, without response or replay',async t=>{ - const late=deferred<{socket: {destroy(): void}}>();let calls=0,destroyed=0; - // This late-resolution branch only calls destroy; keep the fake narrow at the dialer boundary. - const x=await setup(t,{dial:()=>{calls++;return late.promise as unknown as ReturnType>;}});const a=await x.client();await until(()=>calls===1);await x.ingress.close(); - late.resolve({socket:{destroy(){destroyed++;}}});await until(()=>destroyed===1);assert.equal(calls,1);assert.equal(a.output,''); -}); -test('manager integrates real ingress; shutdown releases listener and active tunnel',async t=>{ - const x=await setup(t);await x.ingress.close(); const owner={}; - const m=createOwnerManager({owner,createWorker:({authorization})=>createManagedIngress({authorization,candidates:[{id:'unused',host:'127.0.0.1',port:1}],allowTarget:()=>true})}); - const lease=await m.start(owner,{authorized:true,authorization});const s=net.connect(lease.port,lease.host);s.on('error',()=>{});s.resume();await once(s,'connect');await m.shutdown(owner);await until(()=>s.destroyed);assert.equal(m.status().state,'stopped'); -}); -test('policy rejection and thrown policy never dial',async t=>{ - for(const allowTarget of [()=>false,()=>{throw Error('deny');}]){ - const x=await setup(t,{allowTarget});const a=await x.client();await until(()=>a.destroyed);assert.match(a.output,/403/);assert.equal(x.calls(),0); - } -}); -test('half-close flushes opaque payload before session disposal',async t=>{ - const x=await setup(t);const a=await x.client();await until(()=>a.output.includes('200'));const payload='z'.repeat(1024*1024);a.end(payload);await until(()=>a.destroyed);assert.equal(a.output.split('\r\n\r\n')[1],payload);assert.equal(x.calls(),1); -}); -test('close aborts establishing and queued clients without promoting or replaying',async t=>{ - let calls=0;let signal: AbortSignal | undefined; - const x=await setup(t,{maxActive:1,maxQueued:2,dial:(_a,_c,opts)=>{calls++;signal=opts.signal;return new Promise(()=>{});}}); - const a=await x.client();await until(()=>calls===1);const b=await x.client();await until(()=>x.ingress.stats().queued===1);await x.ingress.close();await until(()=>a.destroyed&&b.destroyed);assert.ok(signal);assert.equal(signal.aborted,true);assert.equal(calls,1);assert.equal(x.ingress.stats().clients,0); -}); -test('establishment has absolute deadline even for a non-cooperating dialer',async t=>{ - let signal: AbortSignal | undefined; - const x=await setup(t,{establishMs:30,dial:(_a,_c,opts)=>{signal=opts.signal;return new Promise(()=>{});}});const a=await x.client();await until(()=>a.destroyed);assert.ok(signal);assert.equal(signal.aborted,true);assert.equal(x.ingress.stats().active,0); -}); -test('crash after ready closes only owned generation and never restarts',async()=>{ - const owner={};let callback!: WorkerContext['onExit'];let closed=0,created=0; - const m=createOwnerManager({owner,createWorker:({onExit})=>{created++;callback=onExit;return {listen:()=>({address:'127.0.0.1',port:3}),close:()=>{closed++;}};}}); - await m.start(owner,{authorized:true,authorization});callback();await until(()=>m.status().state==='stopped');assert.equal(closed,1);assert.equal(created,1); -}); -test('session capability expires with listener and active clients, zero automatic renewal',async t=>{ - const x=await setup(t,{sessionMs:45});const a=await x.client();await until(()=>a.output.includes('200'));await until(()=>a.destroyed);assert.equal(x.ingress.server.listening,false);assert.equal(x.calls(),1); - const owner={};let created=0; - const m=createOwnerManager({owner,sessionMs:20,createWorker:()=>{created++;return {listen:()=>({address:'127.0.0.1',port:2}),close:()=>{}};}}); - await m.start(owner,{authorized:true,authorization});await until(()=>m.status().state==='stopped');assert.equal(created,1); -}); -test('reverse half-close preserves slow upstream upload after server FIN',async t=>{ - const payload=Buffer.alloc(4*1024*1024,0x78);let received=0;let resolveDone!: () => void;const done=new Promise(r=>resolveDone=r);const peers=new Set(); - const echo=net.createServer({allowHalfOpen:true},s=>{peers.add(s);s.on('error',()=>{});s.once('data',()=>{ - s.end('HTTP/1.1 200 Connection Established\r\n\r\n');s.on('data',b=>{received+=b.length;s.pause();setTimeout(()=>s.resume(),2);});s.once('end',resolveDone); - });});echo.listen(0,'127.0.0.1');await once(echo,'listening'); - const ingress=createManagedIngress({authorization,candidates:[{id:'reverse',host:'127.0.0.1',port:(echo.address() as AddressInfo).port}],allowTarget:()=>true,totalMs:5000});const address=await ingress.listen(); - const client=net.connect({port:address.port,host:'127.0.0.1',allowHalfOpen:true});client.on('error',()=>{}); - t.after(async()=>{client.destroy();await ingress.close();for(const s of peers)s.destroy();await new Promise(r=>echo.close(r));}); - await once(client,'connect');client.write(`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\nProxy-Authorization: ${authorization}\r\n\r\n`);await once(client,'data');await once(client,'end');client.end(payload);await done;assert.equal(received,payload.length); -}); -test('synchronous factory exit leaves no timer keeping process alive',async()=>{ - const {execFile}=await import('node:child_process'); - const moduleURL=new URL('../../src/lib/opaque-proxy-lifecycle/owner-manager.ts',import.meta.url).href; - await new Promise((resolve,reject)=>execFile(process.execPath,['--eval',` - import assert from "node:assert/strict"; - import {createOwnerManager} from ${JSON.stringify(moduleURL)}; - const owner={};const manager=createOwnerManager({owner,createWorker:({onExit})=>{onExit();return {listen(){throw Error('must not start')},close(){}};}}); - await assert.rejects(manager.start(owner,{authorized:true,authorization:${JSON.stringify(authorization)}}), /Stale readiness/); - `],{timeout:1500},error=>error?reject(error):resolve())); -}); -test('composition forwards real ingress termination to matching owner generation',async()=>{ - const {createOwnedIngressManager}=await import('../../src/lib/opaque-proxy-lifecycle/managed-session');const owner={}; - const m=createOwnedIngressManager({owner,candidates:[{id:'unused',host:'127.0.0.1',port:1}],allowTarget:()=>true,ingress:{sessionMs:30},manager:{sessionMs:1000}}); - const lease=await m.start(owner,{authorized:true,authorization});assert.equal(m.status().state,'ready');await until(()=>m.status().state==='stopped');await lease.stop(); -}); -test('fragmented CONNECT authenticates only at header end and preserves early bytes',async t=>{ - const x=await setup(t);const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});let output='';s.on('data',b=>output+=b.toString());await once(s,'connect'); - s.write('CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\n');await pause(20);assert.equal(x.calls(),0); - s.write(`Proxy-Authorization: ${authorization}\r\n\r\nearly-payload`);await until(()=>output.endsWith('early-payload'));assert.equal(x.calls(),1); -}); -test('absolute raw deadline defeats trickle activity; incomplete EOF frees slot',async t=>{ - const x=await setup(t,{idleMs:40,totalMs:80});const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});s.resume();await once(s,'connect');const trickle=setInterval(()=>s.write('x'),5); - try {await until(()=>s.destroyed);}finally{clearInterval(trickle);}assert.equal(x.calls(),0);assert.equal(x.ingress.stats().clients,0); - const eof=net.connect(x.address.port,'127.0.0.1');x.clients.push(eof);eof.on('error',()=>{});eof.resume();await once(eof,'connect');eof.end('CONNECT partial');await until(()=>eof.destroyed);assert.equal(x.ingress.stats().clients,0); -}); -test('ambiguous and oversized raw headers are rejected before any actual proxy dial',async t=>{ - const x=await setup(t);const base=`CONNECT test.invalid:443 HTTP/1.1\r\nHost: test.invalid:443\r\nProxy-Authorization: ${authorization}`; - for(const request of [base+'\r\nHost: test.invalid:443\r\n\r\n',base+'\r\nContent-Length: 0\r\n\r\n',base+'\r\nTransfer-Encoding: chunked\r\n\r\n',base+'\r\n Folded: a\r\n\r\n',base+'\r\nX: a\0b\r\n\r\n',base.replace('\r\n','\n')+'\r\n\r\n',base+'\r\nX: '+'x'.repeat(8500)]){ - const s=net.connect(x.address.port,'127.0.0.1');x.clients.push(s);s.on('error',()=>{});let output='';s.on('data',b=>output+=b.toString());await once(s,'connect');s.end(request);await until(()=>s.destroyed);assert.match(output,/HTTP\/1.1 (400|431)/); - } - assert.equal(x.calls(),0); -}); -test('reentrant synchronous factory startup rejects rather than returning missing readiness',async()=>{ - const owner={};let manager: ReturnType; - manager=createOwnerManager({owner,createWorker:()=>{assert.throws(()=>manager.start(owner,{authorized:true,authorization}),/Reentrant/);return {listen:()=>({address:'127.0.0.1',port:2}),close:()=>{}};}}); - await manager.start(owner,{authorized:true,authorization});await manager.stop(owner); -}); -test('queued authorization is rechecked before physical dial after policy withdrawal',async t=>{ - let allowed=true;const x=await setup(t,{maxActive:1,maxQueued:1,allowTarget:()=>allowed});const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>x.ingress.stats().queued===1);allowed=false;a.destroy();await until(()=>b.destroyed);assert.match(b.output,/502/);assert.equal(x.calls(),1); -}); -test('invalid async policy rejection is observed and denied without dialing',async t=>{ - // Invalid async policies intentionally cross the synchronous policy API boundary. - const allowTarget=(async()=>{throw new Error('synthetic denied policy');}) as unknown as ManagedIngressOptions['allowTarget']; - const x=await setup(t,{allowTarget});const a=await x.client();await until(()=>a.destroyed);assert.match(a.output,/403/);await pause(10);assert.equal(x.calls(),0); -}); -test('queued policy changing to rejected promise cannot leak rejection or dial',async t=>{ - let withdrawn=false; - // The policy becomes invalid at runtime after this connection enters the queue. - const allowTarget=(()=>withdrawn?Promise.reject(new Error('synthetic policy withdrawn')):true) as ManagedIngressOptions['allowTarget']; - const x=await setup(t,{maxActive:1,maxQueued:1,allowTarget}); - const a=await x.client();await until(()=>a.output.includes('200'));const b=await x.client();await until(()=>x.ingress.stats().queued===1);withdrawn=true;a.destroy();await until(()=>b.destroyed);assert.match(b.output,/502/);await pause(10);assert.equal(x.calls(),1); -}); diff --git a/tests/lib/opaque-proxy-route-refresh.test.ts b/tests/lib/opaque-proxy-route-refresh.test.ts deleted file mode 100644 index 16d8be87fca..00000000000 --- a/tests/lib/opaque-proxy-route-refresh.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {afterEach, test} from 'bun:test'; -import assert from 'node:assert/strict'; -import net from 'node:net'; -import {once} from 'node:events'; -import {createOwnedIngressManager} from '../../src/lib/opaque-proxy-lifecycle/managed-session'; -import {snapshotProxyRoutes} from '../../src/lib/opaque-proxy-tunnel'; -import {TunnelFixtureScope, httpProxy, target, allowTarget, until, wait} from '../helpers/opaque-proxy-tunnel'; -const scope = new TunnelFixtureScope(); -afterEach(() => scope.close()); -const authorization = 'Basic ' + Buffer.from('session:synthetic-route-refresh-capability').toString('base64'); -async function fixture(maxActive = 16) { - const first = await httpProxy(scope), second = await httpProxy(scope), owner = {}; - const manager = createOwnedIngressManager({owner, candidates:[first.candidate], allowTarget, - ingress:{maxActive, sessionMs:10000, totalMs:10000, idleMs:5000}, manager:{sessionMs:15000}}); - scope.after(() => manager.shutdown(owner)); - const lease = await manager.start(owner, {authorized:true, authorization}); - async function connect(auth = authorization) { - const socket = scope.own(net.connect(lease.port, lease.host)); - let output = ''; - socket.on('data', bytes => output += bytes.toString()); - await once(socket, 'connect'); - socket.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: ${auth}\r\n\r\n`); - return {socket, output:() => output}; - } - return {first, second, owner, manager, lease, connect}; -} -test('refresh keeps endpoint and established stream, routes only the next connection', async () => { - const x = await fixture(), old = await x.connect(); - await until(() => old.output().includes('200')); - old.socket.write('before'); await until(() => old.output().endsWith('before')); - assert.equal(x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]), 1); - assert.equal(await x.manager.start(x.owner, {authorized:true, authorization}), x.lease); - old.socket.write('after'); await until(() => old.output().endsWith('beforeafter')); - const next = await x.connect(); await until(() => next.output().includes('200')); - next.socket.write('new'); await until(() => next.output().endsWith('new')); - assert.equal(x.first.seen.calls, 1); assert.equal(x.first.seen.bytes, 11); - assert.equal(x.second.seen.calls, 1); assert.equal(x.second.seen.bytes, 3); -}); -test('queued CONNECT retains its accepted route across refresh', async () => { - const x = await fixture(1), old = await x.connect(); await until(() => old.output().includes('200')); - const queued = await x.connect(); await wait(30); - assert.equal(queued.output(), ''); - x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]); - old.socket.destroy(); - await until(() => queued.output().includes('200')); - assert.equal(x.first.seen.calls, 2); assert.equal(x.second.seen.calls, 0); - queued.socket.destroy(); - const fresh = await x.connect(); await until(() => fresh.output().includes('200')); - assert.equal(x.second.seen.calls, 1); -}); -test('wrong owner, stale revision and invalid replacement cannot change the accepted route', async () => { - const x = await fixture(); - assert.throws(() => x.manager.updateRoutes({}, {...x.manager.routeVersion(), revision:0}, [x.second.candidate]), /Wrong owner/); - assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:1}, [x.second.candidate]), /Stale/); - assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, []), /candidates/); - assert.equal(x.manager.routeVersion().revision, 0); - const client = await x.connect(); await until(() => client.output().includes('200')); - assert.equal(x.first.seen.calls, 1); assert.equal(x.second.seen.calls, 0); -}); -test('caller route edits cannot alter a committed refresh, and shutdown cannot be renewed', async () => { - const x = await fixture(); - const routes = [{...x.second.candidate}]; - x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:0}, routes); routes[0].port = x.first.candidate.port; - const client = await x.connect(); await until(() => client.output().includes('200')); - assert.equal(x.second.seen.calls, 1); assert.equal(x.first.seen.calls, 0); - await x.manager.shutdown(x.owner); - assert.throws(() => x.manager.updateRoutes(x.owner, {...x.manager.routeVersion(), revision:1}, [x.first.candidate]), /unavailable/); - assert.equal(x.manager.routeVersion().revision, 1); -}); -test('TLS trust bytes are owned by a route snapshot', () => { - const certificate = Buffer.from('synthetic-trust-bytes'); - const routes = snapshotProxyRoutes([{id:'tls', kind:'https', host:'proxy.invalid', port:443, proxyCa:certificate}]); - certificate.fill(0); - assert.equal(routes[0].proxyCa?.toString(), 'synthetic-trust-bytes'); - assert.ok(Object.isFrozen(routes)); assert.ok(Object.isFrozen(routes[0])); -}); -test('route refresh is allowed before activation without opening a listener', async () => { - const first = await httpProxy(scope), second = await httpProxy(scope), owner = {}; - const manager = createOwnedIngressManager({owner, candidates:[first.candidate], allowTarget}); - scope.after(() => manager.shutdown(owner)); - assert.equal(manager.updateRoutes(owner, manager.routeVersion(), [second.candidate]), 1); - assert.equal(manager.status().state, 'stopped'); - assert.equal(first.seen.calls + second.seen.calls, 0); -}); - -test('standard proxy challenge discloses no capability and a subsequent authorized connection succeeds', async () => { - const x = await fixture(), denied = await x.connect('Basic invalid'); - await until(() => denied.output().includes('407')); - assert.match(denied.output(), /Proxy-Authenticate: Basic realm="opencodex-session"/); - assert.ok(!denied.output().includes(authorization)); - assert.equal(x.first.seen.calls, 0); - const accepted = await x.connect(); await until(() => accepted.output().includes('200')); - assert.equal(x.first.seen.calls, 1); -}); - -test('an update observed before restart cannot mutate the new capability generation', async () => { - const x = await fixture(), observed = x.manager.routeVersion(); - await x.manager.stop(x.owner); - const next = await x.manager.start(x.owner, {authorized:true, authorization:authorization + 'next'}); - assert.notEqual(next.generation, observed.generation); - assert.throws(() => x.manager.updateRoutes(x.owner, observed, [x.second.candidate]), /Stale/); - assert.equal(x.manager.routeVersion().revision, 0); -}); - -test('an update observed while stopped cannot survive an intervening start and stop', async () => { - const proxy = await httpProxy(scope), owner = {}; - const manager = createOwnedIngressManager({owner, candidates:[proxy.candidate], allowTarget}); - scope.after(() => manager.shutdown(owner)); - const observed = manager.routeVersion(); - await manager.start(owner, {authorized:true, authorization}); await manager.stop(owner); - assert.equal(manager.status().generation, null); - assert.throws(() => manager.updateRoutes(owner, observed, [proxy.candidate]), /Stale/); -}); diff --git a/tests/lib/opaque-proxy-session-protocol.test.ts b/tests/lib/opaque-proxy-session-protocol.test.ts deleted file mode 100644 index d754f42ee25..00000000000 --- a/tests/lib/opaque-proxy-session-protocol.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { afterEach, test } from "bun:test"; -import assert from "node:assert/strict"; -import net, { type Socket } from "node:net"; -import tls from "node:tls"; -import { once } from "node:events"; -import { createOwnedIngressManager } from "../../src/lib/opaque-proxy-lifecycle/managed-session"; -import type { GenerationLease } from "../../src/lib/opaque-proxy-lifecycle/owner-manager"; -import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; -import { deadProxy, echo, listen, readBytes, TunnelFixtureScope, until } from "../helpers/opaque-proxy-tunnel"; -import { ca, origin, protocolProxy, proxyKinds, securedClient } from "../helpers/opaque-proxy-tunnel-protocol"; - -const scope = new TunnelFixtureScope(); -afterEach(() => scope.close()); -const capability = "Basic " + Buffer.from("session:synthetic-full-tls-integration-capability").toString("base64"); - -async function session(authority: string, candidates: readonly OpaqueProxyRoute[], sessionMs = 5000) { - const owner = {}; - const manager = createOwnedIngressManager({ owner, candidates, allowTarget: value => value === authority, - ingress: { sessionMs, totalMs: 5000, idleMs: 3000, establishMs: 2000 }, - manager: { sessionMs: 10000, startupMs: 2000, shutdownMs: 2000 } }); - scope.after(() => manager.shutdown(owner)); - const lease = await manager.start(owner, { authorized: true, authorization: capability }); - return { owner, manager, lease }; -} - -function connectHead(authority: string, authorization = capability): Buffer { - return Buffer.from(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: ${authorization}\r\n\r\n`); -} - -async function responseHead(socket: Socket): Promise { - return new Promise((resolve, reject) => { - let buffer: Buffer = Buffer.alloc(0); - const cleanup = () => { socket.pause(); socket.off("data", data); socket.off("error", fail); socket.off("end", ended); }; - const fail = (error: Error) => { cleanup(); reject(error); }; - const ended = () => fail(new Error("CONNECT response ended before headers")); - const data = (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - const end = buffer.indexOf("\r\n\r\n"); - if (end < 0) { if (buffer.length > 8192) fail(new Error("oversized fixture response")); return; } - cleanup(); - if (buffer.length > end + 4) socket.unshift(buffer.subarray(end + 4)); - resolve(buffer.subarray(0, end).toString("latin1")); - }; - socket.on("data", data); socket.once("error", fail); socket.once("end", ended); socket.resume(); - }); -} - -async function connectClient(lease: GenerationLease, authority: string, authorization = capability) { - const socket = scope.own(net.connect({ host: lease.host, port: lease.port, allowHalfOpen: true })); - await once(socket, "connect"); - const head = responseHead(socket); socket.write(connectHead(authority, authorization)); - return { socket, header: await head }; -} - -/** A test-only CONNECT client bridge. It strips only CONNECT's 200 response; - * all following bytes, including ClientHello, remain encrypted and untouched. - */ -async function eagerTlsClient(lease: GenerationLease, authority: string) { - let earlyBytes = 0; - const server = net.createServer({ allowHalfOpen: true }, client => { - client.on("error", () => {}); - client.once("data", (first: Buffer) => { - client.pause(); earlyBytes = first.length; - void (async () => { - assert.equal(first[0], 0x16, "first coalesced bytes are a genuine TLS handshake record"); - const upstream = scope.own(net.connect({ host: lease.host, port: lease.port, allowHalfOpen: true })); - client.once("error", () => upstream.destroy()); - upstream.once("error", () => client.destroy()); - client.once("close", () => { if (!client.readableEnded) upstream.destroy(); }); - upstream.once("close", () => { if (!upstream.readableEnded) client.destroy(); }); - await once(upstream, "connect"); - const head = responseHead(upstream); - // One write deliberately coalesces the local CONNECT head and ClientHello. - upstream.write(Buffer.concat([connectHead(authority), first])); - assert.match(await head, /^HTTP\/1\.1 200 /); - client.pipe(upstream); upstream.pipe(client); - })().catch(error => client.destroy(error instanceof Error ? error : undefined)); - }); - }); - const port = await listen(scope, server); - const options = { host: "127.0.0.1", port, servername: "origin.fixture.invalid", ca, rejectUnauthorized: true, allowHalfOpen: true }; - const socket = scope.own(tls.connect(options)); - await once(socket, "secureConnect"); assert.equal(socket.authorized, true); assert.ok(earlyBytes > 0); - return { socket, earlyBytes }; -} - -for (const kind of proxyKinds) { - test(`managed ${kind}: approved fallback plus coalesced ClientHello preserves verified TLS and half-close`, async () => { - const end = await origin(scope, { slow: true }); - const proxy = await protocolProxy(scope, kind, { destination: end, fragmented: true }); - const x = await session(end.authority, [await deadProxy(kind), proxy.candidate]); - // A second start must reuse the same generation/port without duplicating listeners. - assert.equal(await x.manager.start(x.owner, { authorized: true, authorization: capability }), x.lease); - const { socket } = await eagerTlsClient(x.lease, end.authority); - const payload = Buffer.alloc(1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; - await echo(socket, payload, true); - assert.deepEqual(Buffer.concat(end.received), payload); - assert.equal(proxy.seen.auth, 1); assert.equal(proxy.seen.connections, 1); assert.equal(end.calls(), 1); - assert.equal(Buffer.concat(proxy.seen.opaque).includes(payload.subarray(0, 256)), false); - assert.equal(Buffer.concat(end.received).includes(Buffer.from(capability)), false); - await x.manager.stop(x.owner); assert.equal(x.manager.status().state, "stopped"); - await until(() => proxy.sockets.size === 0); - }, 10000); - - test(`managed ${kind}: wrong capability sends no handshake; stop closes an active target TLS session`, async () => { - const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); - const x = await session(end.authority, [proxy.candidate]); - const denied = await connectClient(x.lease, end.authority, "Basic invalid-fixture"); - assert.match(denied.header, /^HTTP\/1\.1 407 /); denied.socket.destroy(); - assert.equal(proxy.seen.connections, 0); assert.equal(end.calls(), 0); - const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); - const secure = await securedClient(scope, accepted.socket); - const payload = Buffer.from("one-unreplayed-operation"); const answer = readBytes(secure, payload.length); secure.write(payload); - assert.deepEqual(await answer, payload); - const closed = new Promise(resolve => { secure.once("close", resolve); secure.once("end", resolve); }); secure.resume(); - await x.manager.stop(x.owner); await closed; - assert.equal(x.manager.status().state, "stopped"); assert.equal(proxy.seen.connections, 1); - assert.equal(end.calls(), 1); assert.deepEqual(Buffer.concat(end.received), payload); - await until(() => proxy.seen.clientEnds === 1); - await until(() => proxy.sockets.size === 0); - }, 7000); -} - -for (const kind of ["https", "socks5h"] as const) { - test(`managed ${kind}: session expiry closes target TLS without reconnect or replay`, async () => { - const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); - const x = await session(end.authority, [proxy.candidate], 600); - const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); - const secure = await securedClient(scope, accepted.socket); - const payload = Buffer.from("expiry-operation"); const answer = readBytes(secure, payload.length); secure.write(payload); assert.deepEqual(await answer, payload); - const closed = new Promise(resolve => { secure.once("close", resolve); secure.once("end", resolve); }); secure.resume(); - await closed; await until(() => x.manager.status().state === "stopped"); - await until(() => proxy.sockets.size === 0); - assert.equal(proxy.seen.connections, 1); assert.equal(end.calls(), 1); assert.deepEqual(Buffer.concat(end.received), payload); - }, 5000); -} - -test("managed HTTPS: untrusted proxy certificate is rejected before origin or fallback", async () => { - const end = await origin(scope), proxy = await protocolProxy(scope, "https", { destination: end }); - const backup = await protocolProxy(scope, "http", { destination: end }); - assert.equal(proxy.candidate.kind, "https"); - const bad = { ...proxy.candidate, proxyCa: undefined } as OpaqueProxyRoute; - const x = await session(end.authority, [bad, backup.candidate]); - const accepted = await connectClient(x.lease, end.authority); - assert.match(accepted.header, /^HTTP\/1\.1 502 /); accepted.socket.destroy(); - assert.equal(backup.seen.connections, 0); assert.equal(end.calls(), 0); -}, 5000); - -test("managed HTTPS: target hostname verification remains mandatory through the full session", async () => { - const end = await origin(scope), proxy = await protocolProxy(scope, "https", { destination: end }); - const x = await session(end.authority, [proxy.candidate]); - const accepted = await connectClient(x.lease, end.authority); assert.match(accepted.header, /^HTTP\/1\.1 200 /); - await assert.rejects(securedClient(scope, accepted.socket, "wrong.fixture.invalid"), /certificate|hostname|altnames/i); - assert.equal(proxy.seen.connections, 1); assert.equal(end.received.length, 0); -}, 5000); diff --git a/tests/lib/optional-desktop-upstream.test.ts b/tests/lib/optional-desktop-upstream.test.ts index 9e6669df7ff..a4fa7c321f5 100644 --- a/tests/lib/optional-desktop-upstream.test.ts +++ b/tests/lib/optional-desktop-upstream.test.ts @@ -4,7 +4,6 @@ import { createServer as createHttps } from "node:https"; import { createServer as createTcp, type Socket } from "node:net"; import { dialDesktopUpstream } from "../../src/lib/desktop-upstream-tunnel"; import { desktopProxyFor } from "../../src/lib/desktop-proxy-route"; -import { createOwnedIngressManager } from "../../src/lib/opaque-proxy-lifecycle/managed-session"; import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; import { forwardProxy, listenFixture } from "../helpers/desktop-egress-fixture"; import { mkdtempSync, writeFileSync } from "node:fs"; @@ -158,17 +157,6 @@ function fixtureRequest(socket: Socket, path: string): Promise { }); } -function socketClosed(socket: Socket): Promise { - return new Promise((resolve, reject) => { - const closed = () => { clearTimeout(timer); resolve(); }; - const timer = setTimeout(() => { - socket.off("close", closed); - reject(new Error(`Shutdown did not close the consumer socket (readableEnded=${socket.readableEnded}, writableEnded=${socket.writableEnded}, destroyed=${socket.destroyed}, allowHalfOpen=${socket.allowHalfOpen})`)); - }, 2000); - socket.once("close", closed); - }); -} - for (const kind of ["socks5", "socks5h"] as const) for (const credentials of [ { username: "fixture:colon", password: "secret" }, { username: "", password: "secret" }, @@ -189,72 +177,42 @@ for (const kind of ["socks5", "socks5h"] as const) for (const credentials of [ } finally { socket?.destroy(); await proxy.close(); await origin.close(); } }, 10000); -test("desktop consumer authenticates to an owned ingress, refreshes only future connections, and closes on shutdown", async () => { - const ca = createCertificateAuthority({ commonName: "desktop-session-fixture", validityDays: 1 }); - const leaf = issueServerLeaf(ca, "desktop-session-fixture", ["chatgpt.com"]); - const requests: { origin: string; path: string | undefined; socket: Socket; cookie: string | undefined; proxyAuth: unknown }[] = []; - let handshakes = 0; - const createOrigin = (name: string) => { - const server = createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { - requests.push({ origin: name, path: req.url, socket: req.socket, cookie: req.headers.cookie, proxyAuth: req.headers["proxy-authorization"] }); - const body = `${name}:${req.url}`; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); - }); - server.on("secureConnection", () => { handshakes++; }); - return listenFixture(server); - }; - const firstOrigin = await createOrigin("first"), secondOrigin = await createOrigin("second"); - const firstProxy = await forwardProxy("http", firstOrigin.port, leaf), secondProxy = await forwardProxy("http", secondOrigin.port, leaf); - const route = (id: string, port: number) => ({ id, kind: "http" as const, host: "127.0.0.1", port, username: "fixture", password: "secret" }); - const owner = {}, username = "session", password = "synthetic-desktop-consumer-capability"; - const authorization = "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); - const manager = createOwnedIngressManager({ owner, candidates: [route("first", firstProxy.port)], - allowTarget: authority => authority === "chatgpt.com:443", - ingress: { sessionMs: 30000, totalMs: 30000, idleMs: 10000, establishMs: 2000 }, - manager: { sessionMs: 30000, startupMs: 2000, shutdownMs: 2000 } }); - const sockets: Awaited>[] = []; +for (const kind of ["http", "https", "socks5", "socks5h"] as const) test(`desktop ${kind} consumer fully closes after upstream EOF without caller cleanup`, async () => { + const ca = createCertificateAuthority({ commonName: "desktop-eof-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "desktop-eof-fixture", ["chatgpt.com"]); + const proxyLeaf = issueServerLeaf(ca, "desktop-eof-fixture", ["localhost"]); + let peer: Socket | undefined, cookie: string | undefined, proxyAuth: string | string[] | undefined; + const origin = await listenFixture(createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { + peer = req.socket; cookie = req.headers.cookie; proxyAuth = req.headers["proxy-authorization"]; + const body = "fixture-before-eof"; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); + })); + const transport = kind === "socks5h" ? "socks5" : kind; + const proxy = await forwardProxy(transport, origin.port, proxyLeaf); + let socket: Awaited> | undefined; + let timer: ReturnType | undefined; try { - const lease = await manager.start(owner, { authorized: true, authorization }); - assert.equal(lease.host, "127.0.0.1"); - const proxy = `http://${username}:${password}@${lease.host}:${lease.port}`; - const wrongCapability = new URL(proxy); wrongCapability.password = "wrong-fixture-capability"; - await assert.rejects(dialDesktopUpstream({ proxy: wrongCapability.href, ca: ca.certPem, timeoutMs: 1000 }), - { message: "desktop_upstream_connection_failed" }); - assert.equal(firstProxy.seen.length, 0); assert.equal(secondProxy.seen.length, 0); - assert.equal(handshakes, 0); assert.equal(requests.length, 0); - - // Do not override the production target: only the upstream fixture proxy redirects it to loopback. - const first = await dialDesktopUpstream({ proxy, ca: ca.certPem }); sockets.push(first); - assert.equal(first.authorized, true); - assert.equal(await fixtureRequest(first, "/before-refresh"), "first:/before-refresh"); - assert.equal(manager.updateRoutes(owner, manager.routeVersion(), [route("second", secondProxy.port)]), 1); - assert.equal(await manager.start(owner, { authorized: true, authorization }), lease); - assert.equal(await fixtureRequest(first, "/after-refresh"), "first:/after-refresh"); - - const second = await dialDesktopUpstream({ proxy, ca: ca.certPem }); sockets.push(second); - assert.equal(second.authorized, true); - assert.equal(await fixtureRequest(second, "/next-connection"), "second:/next-connection"); - assert.deepEqual(requests.map(({ origin, path }) => ({ origin, path })), [ - { origin: "first", path: "/before-refresh" }, { origin: "first", path: "/after-refresh" }, - { origin: "second", path: "/next-connection" }, - ]); - assert.equal(requests[0]!.socket, requests[1]!.socket); - assert.notEqual(requests[0]!.socket, requests[2]!.socket); - for (const request of requests) { assert.equal(request.cookie, "fixture=session"); assert.equal(request.proxyAuth, undefined); } - const upstreamConnect = [{ authority: "chatgpt.com:443", auth: "Basic " + Buffer.from("fixture:secret").toString("base64") }]; - assert.deepEqual(firstProxy.seen, upstreamConnect); assert.deepEqual(secondProxy.seen, upstreamConnect); - assert.equal(handshakes, 2); assert.equal(first.destroyed, false); assert.equal(second.destroyed, false); + // Keep the real destination and certificate identity; only the fixture proxy redirects to loopback. + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem, proxyCa: ca.certPem }); + assert.equal(socket.authorized, true); + assert.equal(socket.allowHalfOpen, false, "desktop consumer retains automatic writable-side closure"); + assert.equal(await fixtureRequest(socket, "/peer-eof"), "fixture-before-eof"); + assert.equal(cookie, "fixture=session"); assert.equal(proxyAuth, undefined); + assert.deepEqual(proxy.seen, [{ authority: "chatgpt.com:443", + auth: transport === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64") }]); + assert.ok(peer); assert.equal(peer.destroyed, false); + assert.equal(socket.readableEnded, false); assert.equal(socket.writableEnded, false); assert.equal(socket.destroyed, false); - await Promise.all([socketClosed(first), socketClosed(second), manager.shutdown(owner)]); - assert.equal(first.destroyed, true); assert.equal(second.destroyed, true); - assert.deepEqual(manager.status(), { generation: null, state: "stopped", draining: true, poisoned: false }); - await assert.rejects(dialDesktopUpstream({ proxy, ca: ca.certPem, timeoutMs: 1000 }), - { message: "desktop_upstream_connection_failed" }); - assert.throws(() => manager.start(owner, { authorized: true, authorization }), /Manager unavailable/); - assert.equal(requests.length, 3); assert.equal(handshakes, 2); - assert.deepEqual(firstProxy.seen, upstreamConnect); assert.deepEqual(secondProxy.seen, upstreamConnect); - } finally { - for (const socket of sockets) socket.destroy(); - await manager.shutdown(owner); await firstProxy.close(); await secondProxy.close(); - await firstOrigin.close(); await secondOrigin.close(); - } -}, 15000); + let consumerError: Error | undefined; + socket.once("error", error => { consumerError = error; }); + const closed = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("Consumer did not fully close after upstream EOF")), 2000); + socket!.once("close", hadError => { clearTimeout(timer); resolve(hadError); }); + }); + // Only the upstream initiates termination. No consumer end/destroy or fixture cleanup occurs before close. + peer.end(); + assert.equal(await closed, false); + assert.equal(consumerError, undefined); + assert.equal(socket.readableEnded, true); assert.equal(socket.writableEnded, true); assert.equal(socket.destroyed, true); + assert.equal(proxy.seen.length, 1, "EOF never redials or replays the request"); + } finally { clearTimeout(timer); socket?.destroy(); await proxy.close(); await origin.close(); } +}, 10000); From ef6d74993343fd27e0398e0007256588235a988e Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 17:34:57 +0800 Subject: [PATCH 5/5] refactor(proxy): limit tunnel helper to desktop single-route needs --- .../content/docs/guides/codex-integration.md | 28 +-- src/lib/desktop-upstream-tunnel.ts | 10 +- src/lib/opaque-proxy-tunnel.ts | 137 ++++--------- structure/transports/inventory.md | 60 +++--- tests/helpers/opaque-proxy-tunnel-protocol.ts | 17 +- tests/helpers/opaque-proxy-tunnel.ts | 5 +- .../lib/opaque-proxy-tunnel-protocol.test.ts | 191 +++++------------- tests/lib/opaque-proxy-tunnel.test.ts | 168 ++++----------- 8 files changed, 177 insertions(+), 439 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 45e908ebcdc..ff1ac1239cc 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1615,21 +1615,13 @@ Choosing an account-qualified model does not enable credit spending. Stored acco Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior. -## Native Desktop relay proxy connections - -When the existing native Desktop compatibility relay is enabled, its upgraded -connections to `chatgpt.com:443` use the selected HTTP/HTTPS CONNECT or -SOCKS5/SOCKS5h proxy. This is the relay's outbound transport, not a new proxy setting -or a claim that the original Desktop accepts local proxy credentials. Existing -`NO_PROXY` and explicit direct-route decisions remain unchanged; a selected proxy -failure never retries directly. - -Proxy and target TLS identities remain verified. HTTP Basic usernames containing -colons, credential pairs exceeding 4096 UTF-8 bytes, and malformed CONNECT response -headers are refused. SOCKS credentials retain the shared handshake's 255-byte -per-field limit and existing empty-field behavior; both SOCKS schemes resolve the -target through the proxy. Credentials are used only for the chosen proxy handshake. - -The existing relay owns connection cancellation and shutdown. This change adds -no listener, route-version API, configuration hot-reload mechanism, persistent -credential injection, OS proxy change, or certificate installation. +## Native Desktop relay CONNECT validation + +The Desktop relay's existing HTTP/HTTPS CONNECT transport now rejects malformed +proxy response headers and ambiguous HTTP Basic usernames containing colons. +Decoded credential pairs are limited to 4096 UTF-8 bytes. If your selected proxy +is rejected, correct its configuration or response; the relay does not silently +switch to a direct connection. + +Proxy selection, SOCKS5/SOCKS5h support, certificate verification, and connection +lifecycle are unchanged. Both SOCKS spellings retain remote target DNS resolution. diff --git a/src/lib/desktop-upstream-tunnel.ts b/src/lib/desktop-upstream-tunnel.ts index 3494377770e..a9b03db7e7e 100644 --- a/src/lib/desktop-upstream-tunnel.ts +++ b/src/lib/desktop-upstream-tunnel.ts @@ -48,17 +48,13 @@ export async function dialDesktopUpstream(options: DesktopTunnelOptions = {}): P const credentials = url.username || url.password ? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) } : {}; - const endpoint = { id: "desktop-explicit", host, port, ...credentials }; + const endpoint = { host, port, ...credentials }; const route: OpaqueProxyRoute = protocol === "https:" ? { ...endpoint, kind: "https", proxyCa: options.proxyCa } : { ...endpoint, kind: protocol.slice(0, -1) as "http" | "socks5" | "socks5h" }; const authority = `${target.host.includes(":") ? `[${target.host}]` : target.host}:${target.port}`; - // The route resolver remains authoritative. One explicit candidate cannot - // introduce failover or turn a proxy refusal into a direct connection. - raw = own((await dialTunnel(authority, [route], { - allowTarget: value => value === authority, signal, - timeoutMs: timeout, totalTimeoutMs: timeout, maxHeaderBytes: 65536, - })).socket); + // The route resolver remains authoritative; a failed proxy never becomes DIRECT. + raw = own(await dialTunnel(authority, route, { signal, timeoutMs: timeout, maxHeaderBytes: 65536 })); } if (signal.aborted || raw.destroyed) throw fail(); // This consumer historically closes its writable side after peer EOF. The diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts index e32073f4b3d..abdeb49ab53 100644 --- a/src/lib/opaque-proxy-tunnel.ts +++ b/src/lib/opaque-proxy-tunnel.ts @@ -1,6 +1,6 @@ /** - * Explicit authorized opaque proxy tunnel. The desktop upstream dialer consumes - * a single selected route while retaining its own fixed-target TLS contract. + * Single-route opaque proxy tunnel. The desktop upstream dialer retains its + * own fixed-target TLS contract. * Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment * cancellation and socket cleanup. Never replay or implicitly retry directly. */ @@ -8,51 +8,33 @@ import net, { type Socket } from 'node:net'; import tls from 'node:tls'; import { socks5Handshake, socks5Credentials, Socks5HandshakeError } from './socks5-handshake'; -interface RouteEndpoint { id: string; host: string; port: number } +interface RouteEndpoint { host: string; port: number } type RouteCredentials = { username?: undefined; password?: undefined } | { username: string; password: string }; -type PlainProxy = { kind?: 'http'; proxyCa?: never; proxyServername?: never }; -type SecureProxy = { kind: 'https'; proxyCa?: string | Buffer; proxyServername?: string }; -type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never; proxyServername?: never }; +type PlainProxy = { kind?: 'http'; proxyCa?: never }; +type SecureProxy = { kind: 'https'; proxyCa?: string }; +type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never }; export type OpaqueProxyRoute = RouteEndpoint & RouteCredentials & (PlainProxy | SecureProxy | SocksProxy); export interface OpaqueTunnelOptions { - /** Rechecked before each physical attempt and each handshake write. No implicit target permission. */ - allowTarget: (authority: string) => boolean; signal?: AbortSignal; timeoutMs?: number; - totalTimeoutMs?: number; maxHeaderBytes?: number; } interface AttemptOptions { - signal: AbortSignal; + signal?: AbortSignal; timeoutMs: number; maxHeaderBytes: number; - authorize: () => void; } export class TunnelError extends Error { override readonly name = 'TunnelError'; readonly code: string; - readonly retryable: boolean; readonly status?: number; - constructor(code: string, message: string, { retryable = false, status }: { retryable?: boolean; status?: number } = {}) { - super(message); this.code = code; this.retryable = retryable; this.status = status; + constructor(code: string, message: string, { status }: { status?: number } = {}) { + super(message); this.code = code; this.status = status; } } const aborted = () => new TunnelError('ABORTED', 'Tunnel establishment cancelled'); -/** Synchronous permission only. Invalid async policies remain denied and observed. */ -export function acceptsTargetPolicy(policy: ((authority: string) => unknown) | undefined, authority: string): boolean { - try { - const decision: unknown = typeof policy === 'function' ? policy(authority) : false; - // Never await an async policy to grant access. Observe rejected misuse so - // private policy context cannot escape through an unhandled rejection. - if (decision && (typeof decision === 'object' || typeof decision === 'function')) { - void Promise.resolve(decision).catch(() => {}); - } - return decision === true; - } catch { return false; } -} - -export function validateAuthority(authority: string): string { +function validateAuthority(authority: string): string { if (typeof authority !== 'string' || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]+$/.test(authority)) { throw new TunnelError('TARGET', 'An explicit host:port authority is required'); } @@ -61,36 +43,29 @@ export function validateAuthority(authority: string): string { return authority; } -function validateOptions(candidates: readonly OpaqueProxyRoute[], timeoutMs: number, maxHeaderBytes: number, totalTimeoutMs = 10000): void { - if (!Array.isArray(candidates) || !candidates.length) throw new TunnelError('CONFIG', 'Explicit proxy candidates are required'); - const ids = new Set(); - for (const p of candidates) { - if (!p || typeof p.id !== 'string' || !p.id || ids.has(p.id) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host) - || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535 - || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy candidate'); - if ((p.username === undefined) !== (p.password === undefined) - || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string' - || (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); - if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined - && (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes'); - if ((p.proxyCa !== undefined || p.proxyServername !== undefined) && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy'); - if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string' && !Buffer.isBuffer(p.proxyCa)) throw new TunnelError('CONFIG', 'Invalid proxy CA'); - if (p.proxyServername !== undefined && (typeof p.proxyServername !== 'string' || !p.proxyServername || /[\s/@\x00]/.test(p.proxyServername))) throw new TunnelError('CONFIG', 'Invalid proxy TLS server name'); - ids.add(p.id); - } +function validateOptions(p: OpaqueProxyRoute, timeoutMs: number, maxHeaderBytes: number): void { + if (!p || Array.isArray(p) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host) + || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535 + || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy route'); + if ((p.username === undefined) !== (p.password === undefined) + || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string' + || (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); + if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined + && (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes'); + if (p.proxyCa !== undefined && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy'); + if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string') throw new TunnelError('CONFIG', 'Invalid proxy CA'); if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 - || !Number.isSafeInteger(totalTimeoutMs) || totalTimeoutMs < 1 || totalTimeoutMs > 120000 || !Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes < 64 || maxHeaderBytes > 65536) { throw new TunnelError('CONFIG', 'Invalid deadline or header limit'); } } -function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes, authorize }: AttemptOptions): Promise { - if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes, authorize }); +function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes }: AttemptOptions): Promise { + if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes }); return new Promise((resolve, reject) => { if (signal?.aborted) return reject(aborted()); const secure = proxy.kind === 'https'; - const identity = proxy.proxyServername ?? proxy.host; + const identity = proxy.host; const tlsOptions: tls.ConnectionOptions & { allowHalfOpen: boolean } = { host: proxy.host, port: proxy.port, allowHalfOpen: true, servername: net.isIP(identity) ? undefined : identity, ca: proxy.proxyCa, @@ -114,19 +89,15 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou if (settled) return; settled = true; cleanup(); socket.destroy(); reject(error); }; const onAbort = () => fail(aborted()); - // Partial responses can contain a refusal that has not finished arriving. - // Fail closed once any proxy response bytes exist, even on transport failure. - const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message, { retryable: pending.length === 0 && tlsReady })); - const onError = (error: NodeJS.ErrnoException) => { + const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message)); + const onError = () => { if (secure && !tlsReady) { - const retryable = ['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN'].includes(error.code ?? ''); - fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed', { retryable })); + fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed')); } else establishmentFailure('TRANSPORT', 'HTTP proxy connection failed'); }; const onEnd = () => establishmentFailure('EARLY_CLOSE', 'HTTP proxy closed before establishing the tunnel'); const onClose = onEnd; const onConnect = () => { - try { authorize(); } catch (error) { fail(error instanceof Error ? error : new TunnelError('TARGET', 'Target authorization failed')); return; } tlsReady = true; const auth = proxy.username === undefined ? '' : `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n`; socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`); @@ -155,8 +126,6 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou if (lines.slice(1).some(line => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e\x80-\xff]*$/.test(line))) { return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy response header')); } - // Every HTTP refusal, including 407 and 403, is terminal: trying another - // candidate could otherwise silently sidestep authentication or policy. if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); settled = true; socket.pause(); cleanup(); const head = pending.subarray(end + 4); @@ -174,20 +143,19 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou // The bounded socket reader owns setup cancellation and preserves queued bytes. // Framing uses the existing // MIT socks5-handshake module; neither shared framing nor its DNS policy changes. -async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, authorize }: AttemptOptions): Promise { +async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs }: AttemptOptions): Promise { if (signal?.aborted) throw aborted(); const socket = net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true }); socket.on('error', () => {}); - let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined, bytesSeen = 0; + let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined; let waiting: { length: number; resolve: () => void; reject: (error: Error) => void } | undefined; const fail = (error: Error) => { if (failure) return; failure = error; const current = waiting; waiting = undefined; current?.reject(error); socket.destroy(); }; - const transportError = (code: string, message: string) => fail(new TunnelError(code, message, { retryable: bytesSeen === 0 })); + const transportError = (code: string, message: string) => fail(new TunnelError(code, message)); const onData = (chunk: Buffer) => { // Consume at most the bytes needed by a bounded handshake read, then pause. // A valid CONNECT reply can share a packet with arbitrary early target data; // that remainder is not a malformed/oversized handshake and must survive. socket.pause(); - bytesSeen += chunk.length; buffer = Buffer.concat([buffer, chunk]); if (waiting && buffer.length >= waiting.length) { const current = waiting; waiting = undefined; current.resolve(); } else if (waiting) socket.resume(); @@ -200,7 +168,7 @@ async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal socket.pause(); signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort(); const reader = { - write(bytes: Uint8Array): void { if (failure) throw failure; authorize(); socket.write(bytes); }, + write(bytes: Uint8Array): void { if (failure) throw failure; socket.write(bytes); }, async readExact(length: number): Promise { if (failure) throw failure; if (!Number.isSafeInteger(length) || length < 0 || length > 1024) throw new TunnelError('PROTOCOL', 'Invalid handshake read size'); @@ -237,38 +205,13 @@ async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal /** Establish only. There is no application data, implicit DIRECT, or replay here. * HTTP/HTTPS CONNECT and SOCKS5/SOCKS5h yield an opaque raw target tunnel. */ -export async function dialTunnel(authority: string, candidates: readonly OpaqueProxyRoute[], options: OpaqueTunnelOptions): Promise<{ socket: Socket; routeId: string }> { - const { signal, timeoutMs = 1000, totalTimeoutMs = 10000, maxHeaderBytes = 8192, allowTarget } = options ?? {}; - const authorize = (): void => { - if (!acceptsTargetPolicy(allowTarget, authority)) throw new TunnelError('TARGET', 'Target is not authorized'); - }; - validateAuthority(authority); validateOptions(candidates, timeoutMs, maxHeaderBytes, totalTimeoutMs); authorize(); - // Freeze the route decision for this attempt; caller edits affect future dials. - const selected: OpaqueProxyRoute[] = candidates.map(p => p.kind === 'https' ? { ...p, proxyCa: Buffer.isBuffer(p.proxyCa) ? Buffer.from(p.proxyCa) : p.proxyCa } : { ...p }); - const deadline = new AbortController(); - const timer = setTimeout(() => deadline.abort(), totalTimeoutMs); - const combined = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal; - let last: TunnelError | undefined; - try { - for (const proxy of selected) { - authorize(); - if (combined.aborted) throw aborted(); - try { - const socket = await connectOne(authority, proxy, { signal: combined, timeoutMs, maxHeaderBytes, authorize }); - // Permission can change while a proxy reply is in flight. A completed - // handshake alone never authorizes handing a target socket to the caller. - try { authorize(); if (combined.aborted) throw aborted(); } - catch (error) { socket.destroy(); throw error; } - return { socket, routeId: proxy.id }; - } catch (error) { - if (combined.aborted) throw aborted(); - if (!(error instanceof TunnelError) || !error.retryable) throw error; - last = error; - } - } - throw new TunnelError('ALL_FAILED', `All ${selected.length} configured proxies failed (${last?.code ?? 'UNKNOWN'})`); - } catch (error) { - if (deadline.signal.aborted && !signal?.aborted) throw new TunnelError('TOTAL_TIMEOUT', 'Overall tunnel establishment deadline exceeded'); - throw error; - } finally { clearTimeout(timer); } +export async function dialTunnel(authority: string, proxy: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}): Promise { + const { signal, timeoutMs = 10000, maxHeaderBytes = 65536 } = options; + validateAuthority(authority); + validateOptions(proxy, timeoutMs, maxHeaderBytes); + if (signal?.aborted) throw aborted(); + // Snapshot the selected route. No alternate proxy or direct fallback exists. + const socket = await connectOne(authority, { ...proxy }, { signal, timeoutMs, maxHeaderBytes }); + if (signal?.aborted || socket.destroyed) { socket.destroy(); throw aborted(); } + return socket; } diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index bb5bbc708a5..dfbdf4e252e 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -412,7 +412,7 @@ HTTP path, while provider inference retains its separate existing provider-egres `src/lib/desktop-upstream-tunnel.ts` dials fixed chatgpt.com TLS through HTTP/HTTPS CONNECT or authenticated SOCKS5. Its selected-proxy branch delegates handshake framing to -`src/lib/opaque-proxy-tunnel.ts` with one explicit candidate and exact target policy; +`src/lib/opaque-proxy-tunnel.ts` with one explicit route; its direct branch and final target TLS handshake remain locally owned. It bounds the whole connection setup, rejects proxy EOF, abort and oversized handshake heads, and verifies both proxy and upstream certificate identities. @@ -423,39 +423,27 @@ injected test socket options, never the user's OS trust store. `tests/lib/optional-desktop-upstream.test.ts` exercises the production dialer; `tests/clients/desktop-compatibility-relay.test.ts` preserves the real relay’s fixed-target and opaque upgrade contracts. -## Explicit opaque proxy candidates - -`src/lib/opaque-proxy-tunnel.ts` exposes an authorized, paused target socket through -an explicit ordered HTTP, HTTPS CONNECT, SOCKS5 or SOCKS5h candidate list. It has no -implicit activation. The desktop upstream dialer is a production consumer of a -single explicit candidate; this changes neither desktop proxy discovery nor the -fixed-destination TLS contract above. Each call requires a synchronous target -predicate; it is rechecked before every physical attempt and handshake write. -A completed proxy handshake is rechecked before the socket is handed to the caller. -A caller owns target/network authorization, including whether a proxy may resolve -that target. No implicit DIRECT path, environment inheritance or provider reroute -exists. Candidate credentials are request-local, isolated to that proxy's handshake, -and absent from error messages; the module stores no credentials and emits no logs. - -The existing `src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS -scheme spellings retain its ATYP DOMAIN remote-DNS behavior; they are not distinct -local/remote DNS modes. Credentials offer both NO-AUTH and USER-PASS as that shared -contract specifies; SOCKS usernames may contain colons and decoded fields retain -the existing helper’s zero-to-255-byte compatibility bounds. HTTP Basic usernames -with colons are rejected rather than emitting an ambiguous identity. HTTPS proxy identity and trust are always verified. Target TLS -belongs to the client and target, not the opaque tunnel. - -Per-candidate and total deadlines bound establishment. A transport failure before -any proxy response may advance only through the caller's explicit candidate list. -Authentication, policy and certificate refusals, malformed/partial responses, -cancellation and the total deadline are terminal. After return, the caller owns -all cancellation, backpressure, half-close and socket destruction. The returned -socket is paused; no established connection is migrated and no target bytes are -replayed. Bounded SOCKS frame reads preserve early opaque bytes after CONNECT. +## Single-route opaque proxy transport + +`src/lib/opaque-proxy-tunnel.ts` establishes one HTTP/HTTPS CONNECT or SOCKS5/SOCKS5h +route selected by `src/lib/desktop-upstream-tunnel.ts`. It returns a paused socket; +the caller owns target selection and TLS, cancellation after establishment, +backpressure, half-close and final destruction. The dialer has no proxy discovery, +alternate-route list, direct fallback, listener, route version or session manager. +The existing relay retains connection lifecycle ownership. + +The handshake is bounded by a timeout and optional caller signal. Proxy credentials +are request-local and excluded from diagnostics. HTTPS proxy identity is verified +against its configured host. HTTP Basic usernames with colons and malformed CONNECT +response headers are rejected. Header size remains bounded even when a delimiter +and early target bytes arrive together; remaining bytes are handed to the caller. + +`src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS spellings use its +remote-DNS behavior and existing zero-to-255-byte credential-field limits, including +colon usernames. Bounded reads preserve coalesced opaque bytes after CONNECT. +There is no retry or replay before or after establishment. The caller retains the +fixed target, explicit DIRECT selection and whole-connection deadline described above. `tests/lib/opaque-proxy-tunnel.test.ts` and -`tests/lib/opaque-proxy-tunnel-protocol.test.ts` cover these local transport contracts. - -The existing Desktop relay owns connection cancellation and shutdown. Each new -dial selects its configured proxy; established connections keep their original -transport. This module adds no listener, session manager, route revision API or -configuration hot-reload mechanism. +`tests/lib/opaque-proxy-tunnel-protocol.test.ts` cover framing, TLS, authentication, +timeout, cancellation and half-close; `tests/lib/optional-desktop-upstream.test.ts` +checks all four proxy schemes close cleanly when the production consumer sees EOF. diff --git a/tests/helpers/opaque-proxy-tunnel-protocol.ts b/tests/helpers/opaque-proxy-tunnel-protocol.ts index 441810ef173..4f7a7d28c1e 100644 --- a/tests/helpers/opaque-proxy-tunnel-protocol.ts +++ b/tests/helpers/opaque-proxy-tunnel-protocol.ts @@ -12,7 +12,8 @@ const commonName = "opaque tunnel fixture CA"; const authority = createCertificateAuthority({ commonName, validityDays: 1 }); export const ca = authority.certPem; const originIdentity = issueServerLeaf(authority, commonName, ["origin.fixture.invalid"]); -const proxyIdentity = issueServerLeaf(authority, commonName, ["proxy.fixture.invalid"]); +const wrongProxyIdentity = issueServerLeaf(authority, commonName, ["wrong.fixture.invalid"]); +const proxyIdentity = issueServerLeaf(authority, commonName, ["127.0.0.1"]); export const credentials = { username: "fixture-user", password: "fixture-páss" }; const basic = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64"); export type ProxyKind = "http" | "https" | "socks5" | "socks5h"; @@ -53,12 +54,11 @@ type ProxyOptions = { destination?: Awaited>; requireAuth?: boolean; fragmented?: boolean; - onAccepted?: () => void; - beforeEstablishedReply?: () => void; + wrongIdentity?: boolean; }; export async function protocolProxy(scope: TunnelFixtureScope, kind: ProxyKind, { - mode = "ok", destination, requireAuth = true, fragmented = false, onAccepted, beforeEstablishedReply, + mode = "ok", destination, requireAuth = true, fragmented = false, wrongIdentity = false, }: ProxyOptions = {}) { const sockets = new Set(); const seen = { connections: 0, handshakes: 0, auth: 0, targets: [] as Array<{ host: string; port: number }>, opaque: [] as Buffer[], applicationBytes: 0, clientEnds: 0 }; @@ -100,7 +100,6 @@ export async function protocolProxy(scope: TunnelFixtureScope, kind: ProxyKind, const connected = kind.startsWith("socks") ? Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) : Buffer.from("HTTP/1.1 200 Established\r\n\r\n"); - beforeEstablishedReply?.(); send(connected); scope.later(() => { if (socket.destroyed || upstream.destroyed) return; @@ -182,15 +181,15 @@ export async function protocolProxy(scope: TunnelFixtureScope, kind: ProxyKind, }; // Pre-handshake TLS peers auto-close; half-close is enabled only after secureConnect. const server = kind === "https" - ? tls.createServer({ key: proxyIdentity.keyPem, cert: proxyIdentity.certPem }, handle) + ? tls.createServer({ key: (wrongIdentity ? wrongProxyIdentity : proxyIdentity).keyPem, cert: (wrongIdentity ? wrongProxyIdentity : proxyIdentity).certPem }, handle) : net.createServer({ allowHalfOpen: true }, handle); - server.on("connection", () => { seen.connections++; onAccepted?.(); }); + server.on("connection", () => { seen.connections++; }); server.on("tlsClientError", (_error, socket) => socket.destroy()); const port = await listen(scope, server, sockets); - const base = { id: `${kind}-${port}`, host: "127.0.0.1", port }; + const base = { host: "127.0.0.1", port }; let route: OpaqueProxyRoute; switch (kind) { - case "https": route = { ...base, kind, proxyCa: ca, proxyServername: "proxy.fixture.invalid" }; break; + case "https": route = { ...base, kind, proxyCa: ca }; break; case "http": route = { ...base, kind }; break; default: route = { ...base, kind }; break; } diff --git a/tests/helpers/opaque-proxy-tunnel.ts b/tests/helpers/opaque-proxy-tunnel.ts index e338667f5f9..aa9c0eed1b4 100644 --- a/tests/helpers/opaque-proxy-tunnel.ts +++ b/tests/helpers/opaque-proxy-tunnel.ts @@ -27,7 +27,6 @@ export class TunnelFixtureScope { } export const target = "fixture.invalid:443"; -export const allowTarget = (authority: string) => authority === target; export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export const digest = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); @@ -59,7 +58,7 @@ export async function deadProxy(kind: OpaqueProxyRoute["kind"] = "http"): Promis await once(server, "listening"); const port = (server.address() as AddressInfo).port; await new Promise(resolve => server.close(() => resolve())); - return { id: `dead-${port}`, kind, host: "127.0.0.1", port } as OpaqueProxyRoute; + return { kind, host: "127.0.0.1", port } as OpaqueProxyRoute; } export async function readBytes(socket: Socket, count: number): Promise { @@ -149,5 +148,5 @@ export async function httpProxy(scope: TunnelFixtureScope, mode: HttpFixtureMode socket.on("data", handshake); }); const port = await listen(scope, server, sockets); - return { candidate: { id: `${mode}-${port}`, host: "127.0.0.1", port } satisfies OpaqueProxyRoute, seen, sockets }; + return { candidate: { host: "127.0.0.1", port } satisfies OpaqueProxyRoute, seen, sockets }; } diff --git a/tests/lib/opaque-proxy-tunnel-protocol.test.ts b/tests/lib/opaque-proxy-tunnel-protocol.test.ts index ab9c73d7fbb..e73eb834646 100644 --- a/tests/lib/opaque-proxy-tunnel-protocol.test.ts +++ b/tests/lib/opaque-proxy-tunnel-protocol.test.ts @@ -1,193 +1,118 @@ import { afterEach, test } from "bun:test"; import assert from "node:assert/strict"; -import net from "node:net"; import { dialTunnel, type OpaqueProxyRoute, type OpaqueTunnelOptions } from "../../src/lib/opaque-proxy-tunnel"; -import { deadProxy, digest, echo, listen, readBytes, TunnelFixtureScope, until, wait } from "../helpers/opaque-proxy-tunnel"; +import { deadProxy, digest, echo, readBytes, TunnelFixtureScope, until, wait } from "../helpers/opaque-proxy-tunnel"; import { ca, credentials, origin, protocolProxy, proxyKinds, securedClient } from "../helpers/opaque-proxy-tunnel-protocol"; const scope = new TunnelFixtureScope(); afterEach(() => scope.close()); -const dialAllowed = (authority: string, routes: readonly OpaqueProxyRoute[], options: Omit = {}) => - dialTunnel(authority, routes, { ...options, allowTarget: value => value === authority }); +const dial = (authority: string, route: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}) => dialTunnel(authority, route, options); for (const kind of proxyKinds) { test(`${kind}: real authenticated tunnel carries independently verified opaque target TLS`, async () => { const end = await origin(scope); const p = await protocolProxy(scope, kind, { destination: end, fragmented: true }); - const { socket: raw, routeId } = await dialAllowed(end.authority, [p.candidate]); assert.equal(routeId, p.candidate.id); + const raw = await dial(end.authority, p.candidate); const secured = await securedClient(scope, raw); const payload = Buffer.from('origin-only plaintext marker\0\xff'); await echo(secured, payload); assert.equal(p.seen.auth, 1); assert.deepEqual(p.seen.targets, [{ host: 'origin.fixture.invalid', port: end.port }]); assert.deepEqual(Buffer.concat(end.received), payload); assert.equal(Buffer.concat(p.seen.opaque).includes(payload), false, 'proxy must see encrypted target data'); assert.equal(Buffer.concat(end.received).includes(Buffer.from('Proxy-Authorization')), false); }, 7000); - test(`${kind}: explicit dead route falls back before establishment`, async () => { - const end = await origin(scope), p = await protocolProxy(scope, kind, { destination: end }); - const { socket, routeId } = await dialAllowed(end.authority, [await deadProxy(kind), p.candidate]); scope.own(socket); - assert.equal(routeId, p.candidate.id); assert.equal(p.seen.handshakes, 1); + test(`${kind}: explicit dead route reports failure`, async () => { + await assert.rejects(dial('origin.fixture.invalid:443', await deadProxy(kind)), error => ['TRANSPORT', 'PROXY_TLS'].includes((error as { code: string }).code)); }, 5000); - test(`${kind}: authentication refusal cannot switch to a backup`, async () => { - const denied = await protocolProxy(scope, kind, { mode: 'refuse' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [denied.candidate, backup.candidate]), error => { - assert.equal((error as { retryable: boolean }).retryable, false); + test(`${kind}: authentication refusal is terminal and credential-safe`, async () => { + const denied = await protocolProxy(scope, kind, { mode: 'refuse' }); + await assert.rejects(dial('origin.fixture.invalid:443', denied.candidate), error => { + assert.ok(["REFUSED", "SOCKS_REFUSED"].includes((error as { code: string }).code)); const diagnostic = String(error) + JSON.stringify(error); assert.equal(diagnostic.includes(credentials.username), false); assert.equal(diagnostic.includes(credentials.password), false); assert.equal(diagnostic.includes(Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")), false); return true; }); - assert.equal(backup.seen.connections, 0); await until(() => denied.sockets.size === 0); + await until(() => denied.sockets.size === 0); }, 5000); - test(`${kind}: no-response per-attempt timeout may try explicit backup`, async () => { - const end = await origin(scope), hung = await protocolProxy(scope, kind, { mode: 'hang' }), backup = await protocolProxy(scope, kind, { destination: end }); - const result = await dialAllowed(end.authority, [hung.candidate, backup.candidate], { timeoutMs: 200 }); scope.own(result.socket); - assert.equal(result.routeId, backup.candidate.id); await until(() => hung.sockets.size === 0); + test(`${kind}: no-response timeout closes the only route`, async () => { + const hung = await protocolProxy(scope, kind, { mode: 'hang' }); + await assert.rejects(dial('origin.fixture.invalid:443', hung.candidate, { timeoutMs: 200 }), { code: 'TIMEOUT' }); + assert.equal(hung.seen.connections, 1); await until(() => hung.sockets.size === 0); }, 5000); test(`${kind}: partial response timeout is terminal`, async () => { - const p = await protocolProxy(scope, kind, { mode: 'partial' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate], { timeoutMs: 150 }), error => !(error as { retryable: boolean }).retryable); - assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + const p = await protocolProxy(scope, kind, { mode: 'partial' }); + await assert.rejects(dial('origin.fixture.invalid:443', p.candidate, { timeoutMs: 150 }), { code: 'TIMEOUT' }); + await until(() => p.sockets.size === 0); }, 5000); - test(`${kind}: overall deadline stops the route chain and closes sockets`, async () => { - const one = await protocolProxy(scope, kind, { mode: 'hang' }), two = await protocolProxy(scope, kind, { mode: 'hang' }), three = await protocolProxy(scope, kind, { mode: 'hang' }); - const start = performance.now(); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [one.candidate, two.candidate, three.candidate], { timeoutMs: 300, totalTimeoutMs: 500 }), { code: 'TOTAL_TIMEOUT' }); - assert.ok(performance.now() - start < 2_000); assert.equal(one.seen.connections, 1); assert.equal(two.seen.connections, 1); assert.equal(three.seen.connections, 0); - await until(() => one.sockets.size === 0 && two.sockets.size === 0); - }, 5000); - test(`${kind}: caller cancellation cannot switch routes`, async () => { - const p = await protocolProxy(scope, kind, { mode: 'hang' }), backup = await protocolProxy(scope, kind, { mode: 'hang' }), controller = new AbortController(); - const result = dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate], { signal: controller.signal }); + test(`${kind}: caller cancellation closes the selected route`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'hang' }), controller = new AbortController(); + const result = dial('origin.fixture.invalid:443', p.candidate, { signal: controller.signal }); const assertion = assert.rejects(result, { code: 'ABORTED' }); await until(() => p.seen.handshakes === 1); controller.abort(); await assertion; - assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + await until(() => p.sockets.size === 0); }, 5000); test(`${kind}: target TLS 2 MiB backpressure and half-close preserve bytes`, async () => { const end = await origin(scope, { slow: true }), p = await protocolProxy(scope, kind, { destination: end }); - const { socket } = await dialAllowed(end.authority, [p.candidate]); const secured = await securedClient(scope, socket); + const socket = await dial(end.authority, p.candidate); const secured = await securedClient(scope, socket); const payload = Buffer.alloc(2 * 1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; await echo(secured, payload, true); assert.equal(digest(Buffer.concat(end.received)), digest(payload)); }, 12000); test(`${kind}: failure after opaque target TLS and application bytes never replays`, async () => { - const end = await origin(scope, { drop: true }), p = await protocolProxy(scope, kind, { destination: end }), backup = await protocolProxy(scope, kind, { mode: 'hang' }); - const { socket } = await dialAllowed(end.authority, [p.candidate, backup.candidate]); const secured = await securedClient(scope, socket); + const end = await origin(scope, { drop: true }), p = await protocolProxy(scope, kind, { destination: end }); + const socket = await dial(end.authority, p.candidate); const secured = await securedClient(scope, socket); const closed = new Promise(resolve => { secured.once('end', resolve); secured.once('close', resolve); secured.once('error', () => resolve()); }); const payload = Buffer.from('single-committed-operation'); secured.write(payload); secured.resume(); await closed; secured.destroy(); - assert.deepEqual(Buffer.concat(end.received), payload); assert.equal(end.calls(), 1); assert.equal(backup.seen.connections, 0); + assert.deepEqual(Buffer.concat(end.received), payload); assert.equal(end.calls(), 1); }, 5000); } for (const mode of ['untrusted', 'wrong-name']) { - test(`HTTPS proxy ${mode} certificate is terminal and cannot fall back`, async () => { - const p = await protocolProxy(scope, 'https', { mode: 'hang' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); + test(`HTTPS proxy ${mode} certificate is rejected`, async () => { + const p = await protocolProxy(scope, 'https', { mode: 'hang', wrongIdentity: mode === 'wrong-name' }); assert.equal(p.candidate.kind, 'https'); if (p.candidate.kind !== 'https') throw new Error('HTTPS fixture required'); - const bad = { ...p.candidate, ...(mode === 'untrusted' ? { proxyCa: undefined } : { proxyServername: 'wrong.fixture.invalid' }) }; - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [bad, backup.candidate]), error => (error as { code: string }).code === 'PROXY_TLS' && !(error as { retryable: boolean }).retryable); - assert.equal(backup.seen.connections, 0); await until(() => p.sockets.size === 0); + const bad = { ...p.candidate, ...(mode === 'untrusted' ? { proxyCa: undefined } : {}) }; + await assert.rejects(dial('origin.fixture.invalid:443', bad), error => (error as { code: string }).code === 'PROXY_TLS'); + await until(() => p.sockets.size === 0); }, 5000); } test('target TLS certificate still rejects a wrong hostname through a verified HTTPS proxy', async () => { const end = await origin(scope), p = await protocolProxy(scope, 'https', { destination: end }); - const { socket } = await dialAllowed(end.authority, [p.candidate]); + const socket = await dial(end.authority, p.candidate); await assert.rejects(securedClient(scope, socket, 'wrong.fixture.invalid'), /certificate|hostname|altnames/i); }, 5000); for (const kind of ['socks5', 'socks5h'] as const) { test(`${kind}: early opaque bytes beyond 64 KiB survive CONNECT response coalescing`, async () => { const p = await protocolProxy(scope, kind, { mode: 'early-large' }); - const { socket } = await dialAllowed('origin.fixture.invalid:443', [p.candidate]); scope.own(socket); + const socket = await dial('origin.fixture.invalid:443', p.candidate); scope.own(socket); assert.deepEqual(await readBytes(socket, 192 * 1024), Buffer.alloc(192 * 1024, 0xa5)); }, 5000); - test(`${kind}: explicit SOCKS policy refusal never falls back`, async () => { - const p = await protocolProxy(scope, kind, { mode: 'policy' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [p.candidate, backup.candidate]), { code: 'SOCKS_REFUSED', retryable: false }); - assert.equal(backup.seen.connections, 0); + test(`${kind}: explicit SOCKS policy refusal is reported`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'policy' }); + await assert.rejects(dial('origin.fixture.invalid:443', p.candidate), { code: 'SOCKS_REFUSED' }); + }, 5000); } test('stalled HTTPS proxy TLS handshake is bounded, terminal, and cancellable', async () => { - const plain = await protocolProxy(scope, 'http', { mode: 'hang' }), backup = await protocolProxy(scope, 'http', { mode: 'hang' }); - const candidate = { id: plain.candidate.id, kind: 'https' as const, host: '127.0.0.1', port: plain.candidate.port, proxyCa: ca, proxyServername: 'proxy.fixture.invalid' }; - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [candidate, backup.candidate], { timeoutMs: 60 }), error => (error as { code: string }).code === 'TIMEOUT' && !(error as { retryable: boolean }).retryable); - assert.equal(backup.seen.connections, 0); await until(() => plain.sockets.size === 0); - const abort = new AbortController(), pending = dialAllowed('origin.fixture.invalid:443', [candidate], { signal: abort.signal }); + const plain = await protocolProxy(scope, 'http', { mode: 'hang' }); + const candidate = { kind: 'https' as const, host: '127.0.0.1', port: plain.candidate.port, proxyCa: ca }; + await assert.rejects(dial('origin.fixture.invalid:443', candidate, { timeoutMs: 60 }), error => (error as { code: string }).code === 'TIMEOUT'); + await until(() => plain.sockets.size === 0); + const abort = new AbortController(), pending = dial('origin.fixture.invalid:443', candidate, { signal: abort.signal }); const assertion = assert.rejects(pending, { code: 'ABORTED' }); await until(() => plain.seen.connections === 2); abort.abort(); await assertion; await until(() => plain.sockets.size === 0); }, 5000); test('configuration rejects unsupported protocols and invalid credential sizes before dialing', async () => { - const route = { id: 'x', host: '127.0.0.1', port: 1 }; - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'direct' } as unknown as OpaqueProxyRoute]), { code: 'CONFIG' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5', username: 'x'.repeat(256), password: 'p' }]), { code: 'CONFIG' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [{ ...route, kind: 'socks5h', username: 'u', password: 'x'.repeat(256) }]), { code: 'CONFIG' }); - await assert.rejects(dialAllowed('origin.fixture.invalid:443', [route], { totalTimeoutMs: 0 }), { code: 'CONFIG' }); + const route = { host: '127.0.0.1', port: 1 }; + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'direct' } as unknown as OpaqueProxyRoute), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'socks5', username: 'x'.repeat(256), password: 'p' }), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'socks5h', username: 'u', password: 'x'.repeat(256) }), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', route, { timeoutMs: 0 }), { code: 'CONFIG' }); }); - -for (const kind of ["http", "https"] as const) { - test(`${kind}: revoked target authorization during connection sends no CONNECT or credentials`, async () => { - let authorized = true; - const proxy = await protocolProxy(scope, kind, { mode: "hang", onAccepted: () => { authorized = false; } }); - const backup = await protocolProxy(scope, "http", { mode: "hang" }); - const authority = "origin.fixture.invalid:443"; - await assert.rejects(dialTunnel(authority, [proxy.candidate, backup.candidate], { - allowTarget: value => value === authority && authorized, - }), { code: "TARGET", retryable: false }); - assert.equal(proxy.seen.connections, 1); - assert.equal(proxy.seen.applicationBytes, 0); - assert.equal(proxy.seen.auth, 0); - assert.equal(proxy.seen.targets.length, 0); - assert.equal(backup.seen.connections, 0); - await until(() => proxy.sockets.size === 0); - }, 5_000); -} - -for (const kind of ["socks5", "socks5h"] as const) { - for (const phase of ["credentials", "target"] as const) { - test(`${kind}: revocation before ${phase} write fails closed`, async () => { - let authorized = true, stage = 0; - const received: Buffer[] = []; - const server = net.createServer(socket => { - let pending = Buffer.alloc(0); - socket.on("data", (chunk: Buffer) => { - received.push(chunk); - pending = Buffer.concat([pending, chunk]); - if (stage === 0 && pending.length >= 4) { - assert.deepEqual([...pending], [5, 2, 0, 2]); - pending = Buffer.alloc(0); - stage++; - if (phase === "credentials") authorized = false; - socket.write(Buffer.from([5, 2])); - } else if (stage === 1 && pending.length >= 3 + Buffer.byteLength(credentials.username + credentials.password)) { - assert.equal(pending[0], 1); - assert.equal(pending.subarray(2, 2 + pending[1]).toString(), credentials.username); - stage++; - authorized = false; - socket.write(Buffer.from([1, 0])); - } - }); - }); - const sockets = new Set(); - const port = await listen(scope, server, sockets); - const backup = await protocolProxy(scope, "http", { mode: "hang" }); - const authority = "origin.fixture.invalid:443"; - await assert.rejects(dialTunnel(authority, [ - { id: "revoked", kind, host: "127.0.0.1", port, ...credentials }, backup.candidate, - ], { allowTarget: value => value === authority && authorized }), { code: "TARGET", retryable: false }); - await until(() => sockets.size === 0); - const all = Buffer.concat(received); - assert.equal(stage, phase === "credentials" ? 1 : 2); - assert.equal(all.includes(Buffer.from("origin.fixture.invalid")), false); - if (phase === "credentials") { - assert.equal(all.includes(Buffer.from(credentials.username)), false); - assert.equal(all.includes(Buffer.from(credentials.password)), false); - } - assert.equal(backup.seen.connections, 0); - }, 5_000); - } -} - for (const kind of proxyKinds) { test(`${kind}: after establishment the caller owns cancellation and the socket`, async () => { const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); const controller = new AbortController(); - const { socket } = await dialAllowed(end.authority, [proxy.candidate], { - signal: controller.signal, totalTimeoutMs: 200, + const socket = await dial(end.authority, proxy.candidate, { + signal: controller.signal, timeoutMs: 200, }); scope.own(socket); assert.equal(socket.isPaused(), true); @@ -201,23 +126,3 @@ for (const kind of proxyKinds) { assert.deepEqual(Buffer.concat(end.received), payload); }, 5_000); } - -for (const kind of proxyKinds) { - test(`${kind}: revocation while awaiting CONNECT success prevents socket handoff`, async () => { - let authorized = true; - const end = await origin(scope); - const proxy = await protocolProxy(scope, kind, { - destination: end, - beforeEstablishedReply: () => { authorized = false; }, - }); - const backup = await protocolProxy(scope, "http", { mode: "hang" }); - await assert.rejects(dialTunnel(end.authority, [proxy.candidate, backup.candidate], { - allowTarget: value => value === end.authority && authorized, - }), { code: "TARGET", retryable: false }); - assert.equal(proxy.seen.auth, 1); - assert.deepEqual(proxy.seen.targets, [{ host: "origin.fixture.invalid", port: end.port }]); - assert.equal(backup.seen.connections, 0); - assert.equal(end.received.length, 0); - await until(() => proxy.seen.clientEnds === 1); - }, 5_000); -} diff --git a/tests/lib/opaque-proxy-tunnel.test.ts b/tests/lib/opaque-proxy-tunnel.test.ts index bb6c4b8f387..ed8dd10684a 100644 --- a/tests/lib/opaque-proxy-tunnel.test.ts +++ b/tests/lib/opaque-proxy-tunnel.test.ts @@ -2,185 +2,101 @@ import { afterEach, test } from "bun:test"; import assert from "node:assert/strict"; import net from "node:net"; import { once } from "node:events"; -import { dialTunnel, type OpaqueProxyRoute, type OpaqueTunnelOptions } from "../../src/lib/opaque-proxy-tunnel"; -import { - allowTarget, deadProxy, digest, echo, httpProxy, listen, readBytes, - target, TunnelFixtureScope, until, wait, -} from "../helpers/opaque-proxy-tunnel"; +import { dialTunnel, type OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; +import { deadProxy, digest, echo, httpProxy, listen, readBytes, target, TunnelFixtureScope, until } from "../helpers/opaque-proxy-tunnel"; const scope = new TunnelFixtureScope(); afterEach(() => scope.close()); -test("refused first proxy falls back to an explicit second and opaque bytes round-trip", async () => { - const dead = await deadProxy(), live = await httpProxy(scope); - const { socket, routeId } = await dialTunnel(target, [dead, live.candidate], { allowTarget }); - scope.own(socket); - assert.equal(routeId, live.candidate.id); +test("single explicit proxy carries opaque bytes", async () => { + const live = await httpProxy(scope), socket = scope.own(await dialTunnel(target, live.candidate)); const expected = Buffer.from([0, 255, 1, 127, 13, 10]); const reply = readBytes(socket, expected.length); socket.write(expected); assert.deepEqual(await reply, expected); assert.equal(live.seen.calls, 1); -}, 5_000); +}); -test("all failed candidates surface ALL_FAILED without a direct destination connection", async () => { - const dead = await deadProxy(), dead2 = await deadProxy(); +test("failed proxy never opens a direct destination connection", async () => { + const dead = await deadProxy(); let directCalls = 0; const destination = net.createServer(socket => { directCalls++; socket.destroy(); }); const port = await listen(scope, destination); - const authority = `127.0.0.1:${port}`; - await assert.rejects(dialTunnel(authority, [dead, dead2], { allowTarget: value => value === authority }), { code: "ALL_FAILED" }); + await assert.rejects(dialTunnel(`127.0.0.1:${port}`, dead), { code: "TRANSPORT" }); assert.equal(directCalls, 0); -}, 5_000); +}); -for (const mode of ["407", "403", "malformed", "oversize"] as const) { - test(`${mode} is terminal and never contacts a backup`, async () => { - const refused = await httpProxy(scope, mode), backup = await httpProxy(scope); - await assert.rejects(dialTunnel(target, [refused.candidate, backup.candidate], { allowTarget }), error => { +for (const mode of ["407", "403", "malformed", "oversize", "bad-header", "partial-refusal", "partial-timeout"] as const) { + test(`${mode} closes the selected proxy and reports failure`, async () => { + const proxy = await httpProxy(scope, mode); + await assert.rejects(dialTunnel(target, proxy.candidate, { timeoutMs: 150, maxHeaderBytes: 8192 }), error => { const failure = error as { code: string; status?: number }; - assert.equal(failure.code, mode === "407" || mode === "403" ? "REFUSED" : "PROTOCOL"); + const expected = mode === "407" || mode === "403" || mode === "partial-refusal" ? "REFUSED" : mode === "partial-timeout" ? "TIMEOUT" : "PROTOCOL"; + assert.equal(failure.code, expected); if (mode === "407" || mode === "403") assert.equal(failure.status, Number(mode)); return true; }); - assert.equal(backup.seen.calls, 0); - await until(() => refused.sockets.size === 0); - }, 5_000); -} - -for (const mode of ["bad-header", "partial-refusal", "partial-timeout"] as const) { - test(`${mode} fails closed without contacting backup`, async () => { - const proxy = await httpProxy(scope, mode), backup = await httpProxy(scope); - await assert.rejects(dialTunnel(target, [proxy.candidate, backup.candidate], { allowTarget, timeoutMs: 150 }), { retryable: false }); - assert.equal(backup.seen.calls, 0); + assert.equal(proxy.seen.calls, 1); await until(() => proxy.sockets.size === 0); - }, 5_000); + }); } -test("establishment timeout falls back and destroys the hanging first socket", async () => { - const hang = await httpProxy(scope, "hang"), backup = await httpProxy(scope); - const result = await dialTunnel(target, [hang.candidate, backup.candidate], { allowTarget, timeoutMs: 150 }); - scope.own(result.socket); - assert.equal(result.routeId, backup.candidate.id); - assert.equal(backup.seen.calls, 1); +test("establishment timeout destroys the hanging socket", async () => { + const hang = await httpProxy(scope, "hang"); + await assert.rejects(dialTunnel(target, hang.candidate, { timeoutMs: 150 }), { code: "TIMEOUT" }); + assert.equal(hang.seen.calls, 1); await until(() => hang.sockets.size === 0); -}, 5_000); +}); -test("caller cancellation closes establishment and never tries backup", async () => { - const hang = await httpProxy(scope, "hang"), backup = await httpProxy(scope), controller = new AbortController(); - const pending = dialTunnel(target, [hang.candidate, backup.candidate], { allowTarget, signal: controller.signal, timeoutMs: 2_000 }); +test("caller cancellation closes establishment", async () => { + const hang = await httpProxy(scope, "hang"), controller = new AbortController(); + const pending = dialTunnel(target, hang.candidate, { signal: controller.signal, timeoutMs: 2000 }); const check = assert.rejects(pending, { code: "ABORTED" }); await until(() => hang.seen.calls === 1); - controller.abort(); - await check; - assert.equal(backup.seen.calls, 0); + controller.abort(); await check; await until(() => hang.sockets.size === 0); -}, 5_000); +}); test("already-cancelled request opens no proxy connection", async () => { const proxy = await httpProxy(scope), controller = new AbortController(); controller.abort(); - await assert.rejects(dialTunnel(target, [proxy.candidate], { allowTarget, signal: controller.signal }), { code: "ABORTED" }); + await assert.rejects(dialTunnel(target, proxy.candidate, { signal: controller.signal }), { code: "ABORTED" }); assert.equal(proxy.seen.calls, 0); assert.equal(proxy.sockets.size, 0); -}, 5_000); +}); test("early opaque bytes coalesced with CONNECT headers are preserved", async () => { - const proxy = await httpProxy(scope, "banner"); - const { socket } = await dialTunnel(target, [proxy.candidate], { allowTarget }); - scope.own(socket); + const proxy = await httpProxy(scope, "banner"), socket = scope.own(await dialTunnel(target, proxy.candidate)); const payload = Buffer.from("early-client-bytes"); const received = readBytes(socket, 13 + payload.length); socket.write(payload); assert.deepEqual(await received, Buffer.concat([Buffer.from("opaque-banner"), payload])); assert.deepEqual(Buffer.concat(proxy.seen.payloads), payload); -}, 5_000); +}); test("caller half-close preserves a 4 MiB echoed response under backpressure", async () => { - const proxy = await httpProxy(scope, "slow"); - const { socket } = await dialTunnel(target, [proxy.candidate], { allowTarget }); - scope.own(socket); + const proxy = await httpProxy(scope, "slow"), socket = scope.own(await dialTunnel(target, proxy.candidate)); const payload = Buffer.alloc(4 * 1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; await echo(socket, payload, true); assert.equal(proxy.seen.bytes, payload.length); assert.equal(digest(Buffer.concat(proxy.seen.payloads)), digest(payload)); -}, 12_000); +}, 12000); test("failure after establishment and application bytes never reconnects or replays", async () => { - const drop = await httpProxy(scope, "drop"), backup = await httpProxy(scope); - const { socket } = await dialTunnel(target, [drop.candidate, backup.candidate], { allowTarget }); - scope.own(socket); + const drop = await httpProxy(scope, "drop"), socket = scope.own(await dialTunnel(target, drop.candidate)); const ended = once(socket, "end"); - socket.write("unique-turn-payload"); - socket.resume(); - await ended; - socket.end(); + socket.write("unique-turn-payload"); socket.resume(); await ended; socket.end(); assert.equal(drop.seen.bytes, 19); assert.equal(drop.seen.calls, 1); - assert.equal(backup.seen.calls, 0); -}, 5_000); - -for (const policy of ["denies", "throws", "async"] as const) { - test(`target policy ${policy} fails closed before any proxy connection`, async () => { - const proxy = await httpProxy(scope); - const allow = policy === "denies" ? () => false - : policy === "throws" ? () => { throw new Error("private policy context"); } - : async () => true; - await assert.rejects(dialTunnel(target, [proxy.candidate], { - allowTarget: allow as OpaqueTunnelOptions["allowTarget"], - }), error => { - assert.equal((error as { code: string }).code, "TARGET"); - assert.equal(String(error).includes("private policy context"), false); - return true; - }); - assert.equal(proxy.seen.calls, 0); - assert.equal(proxy.sockets.size, 0); - }, 5_000); -} - -test("configuration requires explicit candidates and a synchronous target policy", async () => { - const route: OpaqueProxyRoute = { id: "proxy", host: "127.0.0.1", port: 1 }; - await assert.rejects(dialTunnel(target, [], { allowTarget }), { code: "CONFIG" }); - await assert.rejects(dialTunnel(target, [route], {} as OpaqueTunnelOptions), { code: "TARGET" }); - await assert.rejects(dialTunnel(target, [route], undefined as unknown as OpaqueTunnelOptions), { code: "TARGET" }); }); -test("authorization is rechecked before opening the next physical candidate", async () => { - const first = await httpProxy(scope, "hang"), backup = await httpProxy(scope); - let authorized = true; - const result = dialTunnel(target, [first.candidate, backup.candidate], { - allowTarget: authority => authority === target && authorized, - timeoutMs: 200, - }); - const check = assert.rejects(result, { code: "TARGET", retryable: false }); - await until(() => first.seen.calls === 1); - authorized = false; - await check; - assert.equal(backup.seen.calls, 0); - assert.equal(backup.sockets.size, 0); - await until(() => first.sockets.size === 0); -}, 5_000); - -test("rejecting asynchronous target policy is denied without an unhandled rejection", async () => { +test("configuration requires one route and a well-formed authority", async () => { const proxy = await httpProxy(scope); - const unhandled: unknown[] = []; - const observe = (reason: unknown) => { unhandled.push(reason); }; - process.on("unhandledRejection", observe); - try { - const policy = async () => { throw new Error("private fixture context"); }; - await assert.rejects(dialTunnel(target, [proxy.candidate], { - allowTarget: policy as unknown as OpaqueTunnelOptions["allowTarget"], - }), error => { - assert.equal((error as { code: string }).code, "TARGET"); - assert.equal((String(error) + JSON.stringify(error)).includes("private fixture context"), false); - return true; - }); - // Allow the rejected policy promise to cross a complete event-loop turn. - await wait(10); - assert.deepEqual(unhandled, []); - assert.equal(proxy.seen.calls, 0); - assert.equal(proxy.sockets.size, 0); - } finally { - process.removeListener("unhandledRejection", observe); + for (const authority of ["target", "target:0", "target:65536", "target:443\r\nInjected: yes"]) { + await assert.rejects(dialTunnel(authority, proxy.candidate), { code: "TARGET" }); } -}, 5_000); + await assert.rejects(dialTunnel(target, [] as unknown as OpaqueProxyRoute), { code: "CONFIG" }); + await assert.rejects(dialTunnel(target, undefined as unknown as OpaqueProxyRoute), { code: "CONFIG" }); + assert.equal(proxy.seen.calls, 0); +});