diff --git a/packages/tui/src/cli/main.ts b/packages/tui/src/cli/main.ts index 4b2fbf634..cf774863a 100644 --- a/packages/tui/src/cli/main.ts +++ b/packages/tui/src/cli/main.ts @@ -101,6 +101,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi try { (dependencies.configureNetworkProxy ?? configureTuiNetworkProxy)({ environment: processRef.env, + writeWarning: (message) => processRef.stderr.write(`${message}\n`), }); const createProgram = dependencies.createProgram ?? createTuiProgram; await createProgram({ diff --git a/packages/tui/src/cli/network-proxy.ts b/packages/tui/src/cli/network-proxy.ts index 528c914b1..3056f3928 100644 --- a/packages/tui/src/cli/network-proxy.ts +++ b/packages/tui/src/cli/network-proxy.ts @@ -1,13 +1,40 @@ import * as undici from 'undici'; -import { Agent, Dispatcher, ProxyAgent } from 'undici'; +import { Agent, buildConnector, Dispatcher, ProxyAgent, Socks5ProxyAgent } from 'undici'; const LOOPBACK_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] as const; +const HTTP_PROXY_PROTOCOLS: ReadonlySet = new Set(['http:', 'https:']); +// undici's Socks5ProxyAgent accepts `socks5:` and its alias `socks:`. It always sends +// host names to the proxy for resolution, which is what `socks5h:` requests, so that +// scheme is accepted too and rewritten before the agent is constructed. +const SOCKS5_PROXY_PROTOCOLS: ReadonlySet = new Set(['socks5:', 'socks5h:', 'socks:']); +const SUPPORTED_PROXY_SCHEMES = 'http://, https://, socks5://, or socks5h://'; +const SOCKS5_EXPERIMENTAL_WARNING_MESSAGE = + 'SOCKS5 proxy support is experimental and subject to change'; +const SOCKS5_EXPERIMENTAL_WARNING_TYPE = 'ExperimentalWarning'; +// Matches undici's default SOCKS5 connect timeout, which a custom connector replaces. +const SOCKS5_PROXY_CONNECT_TIMEOUT_MS = 5_000; +const IGNORED_ALL_PROXY_ADVICE = + "Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port, or set ALL_PROXY to a socks5:// or socks5h:// URL."; interface ProxyValue { readonly name: string; readonly value: string; } +type ProxyValueClassification = + | { readonly kind: 'http' | 'socks5' } + | { + readonly kind: 'unsupported'; + readonly reason: 'invalid-url' | 'protocol' | 'credentials'; + readonly protocol?: string; + readonly redacted?: string; + }; + +interface ProxyCredentials { + readonly username: string; + readonly password: string; +} + export type TuiProxyConfiguration = | { readonly mode: 'direct' } | { @@ -17,6 +44,11 @@ export type TuiProxyConfiguration = readonly noProxy: string; }; +export interface ResolveTuiProxyConfigurationOptions { + /** Receives one line per ignored proxy setting. Omit to discard warnings. */ + readonly writeWarning?: (message: string) => void; +} + export interface ConfigureTuiNetworkProxyOptions { readonly environment?: NodeJS.ProcessEnv; readonly createProxyDispatcher?: (options: { @@ -26,12 +58,22 @@ export interface ConfigureTuiNetworkProxyOptions { }) => Dispatcher; readonly setGlobalDispatcher?: (dispatcher: Dispatcher) => void; readonly installFetch?: () => void; + /** Defaults to writing a line to process.stderr. */ + readonly writeWarning?: (message: string) => void; +} + +interface EmitWarningTarget { + emitWarning: typeof process.emitWarning; } export function resolveTuiProxyConfiguration( environment: NodeJS.ProcessEnv = process.env, + options: ResolveTuiProxyConfigurationOptions = {}, ): TuiProxyConfiguration { - const allProxy = firstProxyValue(environment, 'ALL_PROXY', 'all_proxy'); + const allProxy = usableAllProxyValue( + firstProxyValue(environment, 'ALL_PROXY', 'all_proxy'), + options.writeWarning, + ); const httpProxy = firstProxyValue(environment, 'HTTP_PROXY', 'http_proxy') ?? allProxy; const httpsProxy = firstProxyValue(environment, 'HTTPS_PROXY', 'https_proxy') ?? allProxy ?? httpProxy; @@ -51,7 +93,9 @@ export function resolveTuiProxyConfiguration( export function configureTuiNetworkProxy( options: ConfigureTuiNetworkProxyOptions = {}, ): TuiProxyConfiguration { - const configuration = resolveTuiProxyConfiguration(options.environment); + const configuration = resolveTuiProxyConfiguration(options.environment, { + writeWarning: options.writeWarning ?? writeStderrWarning, + }); if (configuration.mode === 'direct') return configuration; const createProxyDispatcher = @@ -69,12 +113,73 @@ export function configureTuiNetworkProxy( export function createTuiNetworkDispatcher( environment: NodeJS.ProcessEnv = process.env, ): Dispatcher { + // Ignored ALL_PROXY values were already reported once at CLI startup by + // configureTuiNetworkProxy; repeating the warning here would interleave with the TUI. const configuration = resolveTuiProxyConfiguration(environment); return configuration.mode === 'proxy' ? new TuiProxyDispatcher(configuration) : new Agent({ allowH2: false }); } +/** + * Masks the user name and password of a proxy URL so it can appear in + * diagnostics. Returns undefined when the value is not a parseable URL with a host, + * because an unparseable value cannot be redacted reliably. + */ +export function redactProxyUrl(value: string): string | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if (!url.host) return undefined; + const credentials = url.username || url.password ? `${url.password ? '***:***' : '***'}@` : ''; + return `${url.protocol}//${credentials}${url.host}`; +} + +/** + * Runs `create` while dropping only undici's one-time SOCKS5 experimental + * warning. Every other warning, including other ExperimentalWarnings, is + * forwarded unchanged, and the original emitter is restored afterwards. + */ +export function withoutSocks5ExperimentalWarning( + create: () => T, + target: EmitWarningTarget = process, +): T { + const original = target.emitWarning; + const filtered = function emitWarning(this: unknown, ...args: unknown[]): void { + if (isSocks5ExperimentalWarning(args)) return; + Reflect.apply(original, this, args); + }; + target.emitWarning = filtered as typeof process.emitWarning; + try { + return create(); + } finally { + target.emitWarning = original; + } +} + +function isSocks5ExperimentalWarning(args: readonly unknown[]): boolean { + const [warning, typeOrOptions] = args; + const message = + typeof warning === 'string' ? warning : warning instanceof Error ? warning.message : undefined; + if (message !== SOCKS5_EXPERIMENTAL_WARNING_MESSAGE) return false; + const type = + typeof typeOrOptions === 'string' + ? typeOrOptions + : typeof typeOrOptions === 'object' && typeOrOptions !== null + ? (typeOrOptions as { type?: unknown }).type + : warning instanceof Error + ? warning.name + : undefined; + return type === SOCKS5_EXPERIMENTAL_WARNING_TYPE; +} + +function writeStderrWarning(message: string): void { + process.stderr.write(`${message}\n`); +} + function firstProxyValue( environment: NodeJS.ProcessEnv, ...names: readonly string[] @@ -86,17 +191,93 @@ function firstProxyValue( return undefined; } -function validateProxyValue(proxy: ProxyValue | undefined): void { - if (!proxy) return; +function classifyProxyValue(value: string): ProxyValueClassification { let url: URL; try { - url = new URL(proxy.value); + url = new URL(value); + } catch { + return { kind: 'unsupported', reason: 'invalid-url' }; + } + // Values such as `localhost:7890` parse with `localhost:` as the scheme. + if (!url.hostname) return { kind: 'unsupported', reason: 'invalid-url' }; + const kind = HTTP_PROXY_PROTOCOLS.has(url.protocol) + ? 'http' + : SOCKS5_PROXY_PROTOCOLS.has(url.protocol) + ? 'socks5' + : undefined; + if (!kind) { + return { + kind: 'unsupported', + reason: 'protocol', + protocol: url.protocol, + redacted: redactProxyUrl(value), + }; + } + // Both undici proxy agents percent-decode URL credentials while being + // constructed; a stray `%` would otherwise throw URIError at startup. + if (!decodeProxyCredentials(url)) { + return { kind: 'unsupported', reason: 'credentials', redacted: redactProxyUrl(value) }; + } + return { kind }; +} + +function decodeProxyCredentials(url: URL): ProxyCredentials | undefined { + try { + return { + username: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }; } catch { - throw new Error(`${proxy.name} must be an http:// or https:// URL.`); + return undefined; } - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) { - throw new Error(`${proxy.name} must be an http:// or https:// URL.`); +} + +/** + * ALL_PROXY is only a fallback, and SOCKS tools commonly export it next to an + * explicit HTTP(S)_PROXY. A value this client cannot use must not prevent + * startup, so it is reported and skipped instead of rejected. + */ +function usableAllProxyValue( + proxy: ProxyValue | undefined, + writeWarning: ((message: string) => void) | undefined, +): ProxyValue | undefined { + if (!proxy) return undefined; + const classification = classifyProxyValue(proxy.value); + if (classification.kind !== 'unsupported') return proxy; + writeWarning?.( + `Warning: ignoring ${describeProxySetting(proxy.name, classification)} because ${describeUnsupportedReason(classification)}; it will not be used. ${IGNORED_ALL_PROXY_ADVICE}`, + ); + return undefined; +} + +function validateProxyValue(proxy: ProxyValue | undefined): void { + if (!proxy) return; + const classification = classifyProxyValue(proxy.value); + if (classification.kind !== 'unsupported') return; + if (classification.reason === 'credentials') { + throw new Error( + `${describeProxySetting(proxy.name, classification)} has a user name or password that is not valid percent-encoding. Encode reserved characters, for example % as %25.`, + ); } + throw new Error(`${proxy.name} must be an ${SUPPORTED_PROXY_SCHEMES} URL.`); +} + +function describeProxySetting( + name: string, + classification: { readonly redacted?: string }, +): string { + return classification.redacted ? `${name}=${classification.redacted}` : name; +} + +function describeUnsupportedReason(classification: { + readonly reason: 'invalid-url' | 'protocol' | 'credentials'; + readonly protocol?: string; +}): string { + if (classification.reason === 'credentials') { + return 'its user name or password is not valid percent-encoding (encode % as %25)'; + } + if (!classification.protocol) return 'it is not a valid proxy URL'; + return `${classification.protocol}// proxies are not supported (use ${SUPPORTED_PROXY_SCHEMES})`; } function withLoopbackNoProxy(existing: string | undefined): string { @@ -115,7 +296,9 @@ function installUndiciFetch(): void { (undici as typeof undici & { install?: () => void }).install?.(); } -function createTuiProxyAgent(uri: string): ProxyAgent { +function createTuiProxyAgent(uri: string): Dispatcher { + const url = new URL(uri); + if (SOCKS5_PROXY_PROTOCOLS.has(url.protocol)) return createTuiSocks5ProxyAgent(url); // Preserve HTTP/1.1 on both TLS hops and CONNECT for plain HTTP targets. return new ProxyAgent({ uri, @@ -125,6 +308,45 @@ function createTuiProxyAgent(uri: string): ProxyAgent { }); } +function createTuiSocks5ProxyAgent(url: URL): Dispatcher { + const credentials = decodeProxyCredentials(url); + if (!credentials) throw new Error('Proxy user name or password is not valid percent-encoding.'); + // Pass decoded credentials as options and drop them from the URL so undici + // never decodes them again. An empty password stays unset, as with undici. + const proxyUrl = new URL(url.href); + proxyUrl.username = ''; + proxyUrl.password = ''; + // Socks5ProxyAgent rejects `socks5h:` although it already resolves names remotely. + if (proxyUrl.protocol === 'socks5h:') proxyUrl.protocol = 'socks5:'; + const options: Socks5ProxyAgent.Options = { + connectTimeout: SOCKS5_PROXY_CONNECT_TIMEOUT_MS, + connect: withUnbracketedProxyHost(buildConnector({ timeout: SOCKS5_PROXY_CONNECT_TIMEOUT_MS })), + ...(credentials.username ? { username: credentials.username } : {}), + ...(credentials.password ? { password: credentials.password } : {}), + }; + return withoutSocks5ExperimentalWarning(() => new Socks5ProxyAgent(proxyUrl, options)); +} + +/** + * Socks5ProxyAgent dials `URL.hostname`, which keeps the brackets of an IPv6 + * literal (`[::1]`) and makes the socket connect fail with ENOTFOUND. + */ +function withUnbracketedProxyHost(connector: buildConnector.connector): buildConnector.connector { + return (options, callback) => + connector( + { + ...options, + hostname: stripIpv6Brackets(options.hostname), + ...(options.host === undefined ? {} : { host: stripIpv6Brackets(options.host) }), + }, + callback, + ); +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + class TuiProxyDispatcher extends Dispatcher { readonly #directDispatcher = new Agent({ allowH2: false }); readonly #httpDispatcher: Dispatcher; diff --git a/packages/tui/test/unit/network-proxy-socks5.test.ts b/packages/tui/test/unit/network-proxy-socks5.test.ts new file mode 100644 index 000000000..8f1340ceb --- /dev/null +++ b/packages/tui/test/unit/network-proxy-socks5.test.ts @@ -0,0 +1,439 @@ +import { once } from 'node:events'; +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; +import { + type AddressInfo, + connect, + createServer as createNetServer, + type Server, + type Socket, +} from 'node:net'; +import { fetch, type Dispatcher } from 'undici'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + configureTuiNetworkProxy, + createTuiNetworkDispatcher, +} from '../../src/cli/network-proxy.js'; + +/** + * End-to-end coverage for SOCKS5 proxies (#393) against loopback-only fixtures. + * + * The proxied requests target `*.socks.invalid` host names. They cannot resolve + * locally, so undici must hand the name to the SOCKS5 server, which maps every + * requested domain to the local HTTP fixture. A successful response therefore + * proves that the request went through the proxy, and the server records the + * CONNECT address type to show that the name, not a local IP, was sent. Loopback hosts are always in + * the TUI's NO_PROXY list, so a request to 127.0.0.1 is the NO_PROXY case: it + * must reach the HTTP fixture directly while the SOCKS5 server sees nothing. + */ + +interface SocksConnectRecord { + /** RFC 1928 ATYP of the CONNECT request: 0x01 IPv4, 0x03 domain name, 0x04 IPv6. */ + readonly addressType: number; + readonly host: string; + readonly port: number; + readonly username?: string; +} + +interface SocksFixture { + readonly port: number; + readonly connects: SocksConnectRecord[]; + readonly connections: { count: number }; + close(): Promise; +} + +const SOCKS_VERSION = 0x05; +const NO_AUTH = 0x00; +const USERNAME_PASSWORD = 0x02; +const NO_ACCEPTABLE_METHOD = 0xff; +const ATYP_DOMAIN_NAME = 0x03; + +let target: HttpServer; +let targetPort: number; +const targetRequests: string[] = []; +const openDispatchers: Dispatcher[] = []; +const openFixtures: SocksFixture[] = []; + +/** Some CI containers disable IPv6; the IPv6 proxy case is skipped there. */ +async function canListenOnIpv6Loopback(): Promise { + const probe = createNetServer(); + try { + probe.listen(0, '::1'); + await once(probe, 'listening'); + probe.close(); + await once(probe, 'close'); + return true; + } catch { + return false; + } +} + +const HAS_IPV6_LOOPBACK = await canListenOnIpv6Loopback(); + +function createReader(socket: Socket) { + let buffered = Buffer.alloc(0); + let pending: { length: number; resolve: (value: Buffer) => void } | undefined; + const flush = () => { + if (!pending || buffered.length < pending.length) return; + const chunk = buffered.subarray(0, pending.length); + buffered = buffered.subarray(pending.length); + const { resolve } = pending; + pending = undefined; + resolve(chunk); + }; + const onData = (data: Buffer) => { + buffered = Buffer.concat([buffered, data]); + flush(); + }; + socket.on('data', onData); + return { + read: (length: number) => + new Promise((resolve) => { + pending = { length, resolve }; + flush(); + }), + /** Stops parsing and returns bytes received after the handshake. */ + detach: () => { + socket.off('data', onData); + return buffered; + }, + }; +} + +/** Minimal RFC 1928 server: CONNECT only, with optional RFC 1929 authentication. */ +async function startSocksServer( + credentials?: { username: string; password: string }, + listenHost = '127.0.0.1', +): Promise { + const connects: SocksConnectRecord[] = []; + const connections = { count: 0 }; + const sockets = new Set(); + const server: Server = createNetServer((client) => { + connections.count += 1; + sockets.add(client); + client.on('close', () => sockets.delete(client)); + client.on('error', () => undefined); + void handleClient(client).catch(() => client.destroy()); + }); + + async function handleClient(client: Socket): Promise { + const reader = createReader(client); + const { read } = reader; + const [version, methodCount] = await read(2); + if (version !== SOCKS_VERSION) throw new Error('Unexpected SOCKS version'); + const methods = [...(await read(methodCount ?? 0))]; + const method = credentials ? USERNAME_PASSWORD : NO_AUTH; + if (!methods.includes(method)) { + client.end(Buffer.from([SOCKS_VERSION, NO_ACCEPTABLE_METHOD])); + return; + } + client.write(Buffer.from([SOCKS_VERSION, method])); + + let username: string | undefined; + if (credentials) { + const [, usernameLength] = await read(2); + username = (await read(usernameLength ?? 0)).toString('utf8'); + const [passwordLength] = await read(1); + const password = (await read(passwordLength ?? 0)).toString('utf8'); + const accepted = username === credentials.username && password === credentials.password; + client.write(Buffer.from([0x01, accepted ? 0x00 : 0x01])); + if (!accepted) { + client.end(); + return; + } + } + + const [, command, , addressType = 0] = await read(4); + let host: string; + if (addressType === 0x01) host = [...(await read(4))].join('.'); + else if (addressType === 0x03) { + const [length] = await read(1); + host = (await read(length ?? 0)).toString('utf8'); + } else if (addressType === 0x04) { + host = (await read(16)).toString('hex').replace(/(.{4})(?!$)/gu, '$1:'); + } else throw new Error('Unsupported address type'); + const port = (await read(2)).readUInt16BE(0); + if (command !== 0x01) throw new Error('Only CONNECT is supported'); + connects.push({ addressType, host, port, ...(username ? { username } : {}) }); + + // Every requested destination is served by the local HTTP fixture. + const upstream = connect(targetPort, '127.0.0.1'); + sockets.add(upstream); + upstream.on('close', () => sockets.delete(upstream)); + upstream.on('error', () => client.destroy()); + await once(upstream, 'connect'); + const early = reader.detach(); + if (early.length > 0) upstream.write(early); + client.write(Buffer.from([SOCKS_VERSION, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])); + client.pipe(upstream); + upstream.pipe(client); + } + + server.listen(0, listenHost); + await once(server, 'listening'); + const fixture: SocksFixture = { + port: (server.address() as AddressInfo).port, + connects, + connections, + close: async () => { + for (const socket of sockets) socket.destroy(); + server.close(); + await once(server, 'close'); + }, + }; + openFixtures.push(fixture); + return fixture; +} + +function track(dispatcher: T): T { + openDispatchers.push(dispatcher); + return dispatcher; +} + +beforeAll(async () => { + target = createHttpServer((request, response) => { + targetRequests.push(`${request.headers.host ?? ''}${request.url ?? ''}`); + response.end(`hello from ${request.headers.host ?? 'unknown'}`); + }); + target.listen(0, '127.0.0.1'); + await once(target, 'listening'); + targetPort = (target.address() as AddressInfo).port; +}); + +afterEach(async () => { + await Promise.all(openDispatchers.splice(0).map((dispatcher) => dispatcher.destroy())); + await Promise.all(openFixtures.splice(0).map((fixture) => fixture.close())); + targetRequests.length = 0; +}); + +afterAll(async () => { + target.close(); + await once(target, 'close'); +}); + +describe('SOCKS5 proxy dispatch', () => { + it('routes requests through ALL_PROXY=socks5:// without an experimental warning', async () => { + const socks = await startSocksServer(); + const warnings: string[] = []; + const onWarning = (warning: Error) => warnings.push(warning.message); + process.on('warning', onWarning); + let dispatcher: Dispatcher; + try { + dispatcher = track( + createTuiNetworkDispatcher({ + all_proxy: ` socks5://127.0.0.1:${socks.port} `, + }), + ); + const response = await fetch('http://target.socks.invalid/through-socks', { dispatcher }); + expect(response.status).toBe(200); + expect(await response.text()).toBe('hello from target.socks.invalid'); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('warning', onWarning); + } + + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'target.socks.invalid', port: 80 }, + ]); + expect(targetRequests).toEqual(['target.socks.invalid/through-socks']); + expect(warnings.filter((message) => message.includes('SOCKS5'))).toEqual([]); + }); + + it('authenticates with credentials from the proxy URL', async () => { + const socks = await startSocksServer({ + username: 'mcode user', + password: 'p@ss:word', + }); + const dispatcher = track( + createTuiNetworkDispatcher({ + HTTPS_PROXY: `socks5://mcode%20user:p%40ss%3Aword@127.0.0.1:${socks.port}`, + HTTP_PROXY: `socks5://mcode%20user:p%40ss%3Aword@127.0.0.1:${socks.port}`, + }), + ); + + const response = await fetch('http://auth.socks.invalid/authenticated', { + dispatcher, + }); + + expect(await response.text()).toBe('hello from auth.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'auth.socks.invalid', + port: 80, + username: 'mcode user', + }, + ]); + }); + + it('fails the request when SOCKS5 authentication is rejected', async () => { + const socks = await startSocksServer({ + username: 'mcode', + password: 'expected', + }); + const dispatcher = track( + createTuiNetworkDispatcher({ + ALL_PROXY: `socks5://mcode:wrong@127.0.0.1:${socks.port}`, + }), + ); + + await expect(fetch('http://denied.socks.invalid/', { dispatcher })).rejects.toThrow(); + expect(socks.connects).toEqual([]); + expect(targetRequests).toEqual([]); + }); + + it('sends NO_PROXY hosts directly even when a SOCKS5 proxy is configured', async () => { + const socks = await startSocksServer(); + const dispatcher = track( + createTuiNetworkDispatcher({ + ALL_PROXY: `socks5://127.0.0.1:${socks.port}`, + NO_PROXY: 'internal.example.invalid', + }), + ); + + const response = await fetch(`http://127.0.0.1:${targetPort}/direct`, { + dispatcher, + }); + + expect(await response.text()).toBe(`hello from 127.0.0.1:${targetPort}`); + expect(targetRequests).toEqual([`127.0.0.1:${targetPort}/direct`]); + expect(socks.connections.count).toBe(0); + }); + + it('installs a SOCKS5 global dispatcher at startup instead of failing (#393)', async () => { + const socks = await startSocksServer(); + const warnings: string[] = []; + let installed: Dispatcher | undefined; + + const configuration = configureTuiNetworkProxy({ + environment: { ALL_PROXY: `socks5://127.0.0.1:${socks.port}` }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration.mode).toBe('proxy'); + expect(warnings).toEqual([]); + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://startup.socks.invalid/', { + dispatcher: installed, + }); + expect(await response.text()).toBe('hello from startup.socks.invalid'); + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'startup.socks.invalid', port: 80 }, + ]); + }); + + it.each(['ALL_PROXY', 'HTTPS_PROXY'])( + 'accepts %s=socks5h:// and lets the proxy resolve host names', + async (name) => { + const socks = await startSocksServer({ username: 'mcode', password: 's3cr3t' }); + const warnings: string[] = []; + let installed: Dispatcher | undefined; + + const configuration = configureTuiNetworkProxy({ + environment: { + [name]: ` socks5h://mcode:s3cr3t@127.0.0.1:${socks.port} `, + HTTP_PROXY: `socks5h://mcode:s3cr3t@127.0.0.1:${socks.port}`, + }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration.mode).toBe('proxy'); + expect(warnings).toEqual([]); + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://remote-dns.socks.invalid/h', { dispatcher: installed }); + expect(await response.text()).toBe('hello from remote-dns.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'remote-dns.socks.invalid', + port: 80, + username: 'mcode', + }, + ]); + }, + ); + + it.skipIf(!HAS_IPV6_LOOPBACK)( + 'connects to a SOCKS5 proxy at an IPv6 literal address', + async () => { + const socks = await startSocksServer({ username: 'mcode', password: 'p@ss' }, '::1'); + const dispatcher = track( + createTuiNetworkDispatcher({ ALL_PROXY: `socks5://mcode:p%40ss@[::1]:${socks.port}` }), + ); + + const response = await fetch('http://ipv6-proxy.socks.invalid/', { dispatcher }); + + expect(await response.text()).toBe('hello from ipv6-proxy.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'ipv6-proxy.socks.invalid', + port: 80, + username: 'mcode', + }, + ]); + }, + ); + + it('treats an empty password as no SOCKS5 credentials', async () => { + const socks = await startSocksServer(); + const dispatcher = track( + createTuiNetworkDispatcher({ ALL_PROXY: `socks5://mcode:@127.0.0.1:${socks.port}` }), + ); + + const response = await fetch('http://empty-password.socks.invalid/', { dispatcher }); + + expect(await response.text()).toBe('hello from empty-password.socks.invalid'); + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'empty-password.socks.invalid', port: 80 }, + ]); + }); + + it('starts without a proxy when ALL_PROXY has malformed credentials', () => { + const warnings: string[] = []; + let installed = false; + + const configuration = configureTuiNetworkProxy({ + environment: { ALL_PROXY: 'socks5://user:50%off@127.0.0.1:1080' }, + setGlobalDispatcher: () => { + installed = true; + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(installed).toBe(false); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('ALL_PROXY=socks5://***:***@127.0.0.1:1080'); + expect(warnings[0]).not.toContain('50%off'); + }); + + it('keeps explicit proxies at startup when ALL_PROXY has malformed credentials', async () => { + const socks = await startSocksServer(); + let installed: Dispatcher | undefined; + + configureTuiNetworkProxy({ + environment: { + ALL_PROXY: 'socks5://user:50%off@127.0.0.1:1080', + HTTP_PROXY: `socks5://127.0.0.1:${socks.port}`, + }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: () => undefined, + }); + + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://explicit.socks.invalid/', { dispatcher: installed }); + expect(await response.text()).toBe('hello from explicit.socks.invalid'); + }); +}); diff --git a/packages/tui/test/unit/network-proxy.test.ts b/packages/tui/test/unit/network-proxy.test.ts index 4f9ee7af2..cc0107cda 100644 --- a/packages/tui/test/unit/network-proxy.test.ts +++ b/packages/tui/test/unit/network-proxy.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + redactProxyUrl, resolveTuiProxyConfiguration, shouldBypassTuiProxy, + withoutSocks5ExperimentalWarning, } from '../../src/cli/network-proxy.js'; const PROXY_URL = 'http://proxy.example.invalid:8080'; @@ -68,3 +70,248 @@ describe('NO_PROXY environment precedence', () => { ); }); }); + +describe('proxy URL protocol classification', () => { + function resolveWithWarnings(environment: NodeJS.ProcessEnv) { + const warnings: string[] = []; + const configuration = resolveTuiProxyConfiguration(environment, { + writeWarning: (message) => warnings.push(message), + }); + return { configuration, warnings }; + } + + it.each([ + ['ALL_PROXY', 'socks5://127.0.0.1:7891'], + ['all_proxy', 'socks5://127.0.0.1:7891'], + ['ALL_PROXY', ' socks5://127.0.0.1:7891 \t'], + ['all_proxy', ' socks://127.0.0.1:7891\n'], + ['ALL_PROXY', 'socks5h://127.0.0.1:7891'], + ['all_proxy', ' socks5h://user:secret@127.0.0.1:7891 '], + ])('uses %s=%j as the fallback for both schemes', (name, value) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(warnings).toEqual([]); + expect(configuration).toEqual({ + mode: 'proxy', + httpProxy: value.trim(), + httpsProxy: value.trim(), + noProxy: 'localhost,127.0.0.1,::1', + }); + }); + + it.each([ + ['HTTPS_PROXY', 'socks5://127.0.0.1:7891'], + ['https_proxy', ' socks5://user:secret@127.0.0.1:7891 '], + ['HTTP_PROXY', 'socks://127.0.0.1:7891'], + ['http_proxy', 'socks5://127.0.0.1:7891'], + ['HTTPS_PROXY', 'socks5h://127.0.0.1:7891'], + ['https_proxy', ' socks5h://user:secret@127.0.0.1:7891 '], + ['HTTP_PROXY', 'socks5h://127.0.0.1:7891'], + ['http_proxy', 'socks5h://127.0.0.1:7891'], + ])('accepts an explicit %s=%j SOCKS5 proxy', (name, value) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(warnings).toEqual([]); + expect(configuration.mode).toBe('proxy'); + if (configuration.mode !== 'proxy') throw new Error('Expected proxy configuration'); + expect(configuration.httpsProxy).toBe(value.trim()); + }); + + it('prefers explicit HTTP(S)_PROXY values over ALL_PROXY', () => { + const { configuration } = resolveWithWarnings({ + http_proxy: ' http://127.0.0.1:7890 ', + HTTPS_PROXY: 'http://127.0.0.1:7890', + ALL_PROXY: 'socks5://127.0.0.1:7891', + }); + + expect(configuration).toMatchObject({ + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7890', + }); + }); + + it.each([ + [ + 'all_proxy', + ' socks4://127.0.0.1:7891 ', + 'socks4:// proxies are not supported (use http://, https://, socks5://, or socks5h://)', + ], + ['ALL_PROXY', 'socks4a://127.0.0.1:7891', 'socks4a:// proxies are not supported'], + ['ALL_PROXY', '127.0.0.1:7891', 'it is not a valid proxy URL'], + ['all_proxy', 'http://', 'it is not a valid proxy URL'], + ])( + 'ignores unsupported %s=%j with one warning instead of failing startup', + (name, value, reason) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`Warning: ignoring ${name}`); + expect(warnings[0]).toContain(reason); + expect(warnings[0]).toContain( + "Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port", + ); + }, + ); + + it('keeps explicit proxies when an unsupported ALL_PROXY is ignored', () => { + const { configuration, warnings } = resolveWithWarnings({ + HTTP_PROXY: 'http://127.0.0.1:7890', + ALL_PROXY: 'socks4://127.0.0.1:7891', + }); + + expect(warnings).toHaveLength(1); + expect(configuration).toMatchObject({ + mode: 'proxy', + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7890', + }); + }); + + it('discards warnings when no sink is provided', () => { + expect(resolveTuiProxyConfiguration({ ALL_PROXY: 'socks4://127.0.0.1:1080' })).toEqual({ + mode: 'direct', + }); + }); + + it.each([ + ['HTTPS_PROXY', 'socks4://127.0.0.1:1080'], + ['https_proxy', 'ftp://proxy.example.invalid'], + ['HTTP_PROXY', 'not a url'], + ['http_proxy', ' 127.0.0.1:7890 '], + ])('still rejects an explicit invalid %s=%j', (name, value) => { + expect(() => resolveTuiProxyConfiguration({ [name]: value })).toThrow( + `${name} must be an http://, https://, socks5://, or socks5h:// URL.`, + ); + }); + + it('redacts proxy credentials from warnings and errors', () => { + const { warnings } = resolveWithWarnings({ + ALL_PROXY: 'socks4://alice:s3cr3t@127.0.0.1:1080', + }); + expect(warnings[0]).toContain('ALL_PROXY=socks4://***:***@127.0.0.1:1080'); + + let message = ''; + try { + resolveTuiProxyConfiguration({ https_proxy: 'socks4://alice:s3cr3t@127.0.0.1:1080' }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toBe('https_proxy must be an http://, https://, socks5://, or socks5h:// URL.'); + + for (const text of [warnings[0], message]) { + expect(text).not.toContain('alice'); + expect(text).not.toContain('s3cr3t'); + } + }); + + it.each([ + ['ALL_PROXY', 'socks5://user:50%off@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['all_proxy', ' http://user:50%off@127.0.0.1:7890 ', 'http://***:***@127.0.0.1:7890'], + ['ALL_PROXY', 'socks5h://bad%zzname:pw@127.0.0.1:1080', 'socks5h://***:***@127.0.0.1:1080'], + ])('ignores %s=%j with malformed credentials and one warning', (name, value, redacted) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(warnings).toEqual([ + `Warning: ignoring ${name}=${redacted} because its user name or password is not valid percent-encoding (encode % as %25); it will not be used. Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port, or set ALL_PROXY to a socks5:// or socks5h:// URL.`, + ]); + }); + + it.each([ + ['HTTPS_PROXY', 'socks5://user:50%off@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['https_proxy', 'http://user:50%off@127.0.0.1:7890', 'http://***:***@127.0.0.1:7890'], + ['HTTP_PROXY', 'socks5h://bad%zzname@127.0.0.1:1080', 'socks5h://***@127.0.0.1:1080'], + ])('rejects explicit %s=%j with malformed credentials', (name, value, redacted) => { + let message = ''; + try { + resolveTuiProxyConfiguration({ [name]: value }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toBe( + `${name}=${redacted} has a user name or password that is not valid percent-encoding. Encode reserved characters, for example % as %25.`, + ); + expect(message).not.toContain('50%off'); + expect(message).not.toContain('bad%zz'); + }); + + it('accepts correctly percent-encoded credentials', () => { + expect( + resolveTuiProxyConfiguration({ ALL_PROXY: 'socks5://us%40er:50%25off@127.0.0.1:1080' }), + ).toMatchObject({ mode: 'proxy' }); + }); + + it.each([ + ['socks5://alice:s3cr3t@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['socks5://alice@127.0.0.1:1080', 'socks5://***@127.0.0.1:1080'], + ['socks5h://alice:s3cr3t@127.0.0.1:1080', 'socks5h://***:***@127.0.0.1:1080'], + ['http://127.0.0.1:7890/', 'http://127.0.0.1:7890'], + ['http://user%40corp:p%3Ass@proxy.example.invalid', 'http://***:***@proxy.example.invalid'], + ])('redactProxyUrl(%j) masks credentials', (value, expected) => { + expect(redactProxyUrl(value)).toBe(expected); + }); + + it('does not echo an unparseable proxy value', () => { + expect(redactProxyUrl('user:secret@@ bad')).toBeUndefined(); + const { warnings } = resolveWithWarnings({ ALL_PROXY: 'user:secret@@ bad' }); + expect(warnings[0]).not.toContain('secret'); + }); +}); + +describe('SOCKS5 experimental warning filter', () => { + const SOCKS5_WARNING = 'SOCKS5 proxy support is experimental and subject to change'; + + function createTarget() { + const emitted: unknown[][] = []; + const original = ((...args: unknown[]) => { + emitted.push(args); + }) as typeof process.emitWarning; + return { target: { emitWarning: original }, original, emitted }; + } + + it('drops only the SOCKS5 ExperimentalWarning and restores the emitter', () => { + const { target, original, emitted } = createTarget(); + + const result = withoutSocks5ExperimentalWarning(() => { + target.emitWarning(SOCKS5_WARNING, 'ExperimentalWarning'); + target.emitWarning(SOCKS5_WARNING, { type: 'ExperimentalWarning' }); + target.emitWarning('Another feature is experimental', 'ExperimentalWarning'); + target.emitWarning(SOCKS5_WARNING, 'DeprecationWarning'); + target.emitWarning(SOCKS5_WARNING); + target.emitWarning('Unrelated warning'); + return 'created'; + }, target); + + expect(result).toBe('created'); + expect(emitted).toEqual([ + ['Another feature is experimental', 'ExperimentalWarning'], + [SOCKS5_WARNING, 'DeprecationWarning'], + [SOCKS5_WARNING], + ['Unrelated warning'], + ]); + expect(target.emitWarning).toBe(original); + }); + + it('drops an Error-shaped SOCKS5 ExperimentalWarning', () => { + const { target, emitted } = createTarget(); + const warning = new Error(SOCKS5_WARNING); + warning.name = 'ExperimentalWarning'; + + withoutSocks5ExperimentalWarning(() => target.emitWarning(warning), target); + + expect(emitted).toEqual([]); + }); + + it('restores the emitter when creation throws', () => { + const { target, original } = createTarget(); + + expect(() => + withoutSocks5ExperimentalWarning(() => { + throw new Error('construction failed'); + }, target), + ).toThrow('construction failed'); + expect(target.emitWarning).toBe(original); + target.emitWarning('after'); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 7539f8cd2..610d58828 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3319,6 +3319,7 @@ "packages/tui/test/unit/incident-reporter-privacy.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", + "packages/tui/test/unit/network-proxy-socks5.test.ts", "packages/tui/test/unit/network-proxy.test.ts", "packages/tui/test/unit/observability.test.ts", "packages/tui/test/unit/plugin-application.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index a30ece859..aa4d15296 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -20,6 +20,7 @@ "packages/tui/test/unit/auth-session.test.ts", "packages/tui/test/unit/cli-auth-command.test.ts", "packages/tui/test/unit/network-proxy.test.ts", + "packages/tui/test/unit/network-proxy-socks5.test.ts", "packages/tui/test/unit/application-login-gate.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/provider-application.test.ts",