11import * as undici from 'undici' ;
2- import { Agent , Dispatcher , ProxyAgent } from 'undici' ;
2+ import { Agent , buildConnector , Dispatcher , ProxyAgent , Socks5ProxyAgent } from 'undici' ;
33
44const LOOPBACK_NO_PROXY_ENTRIES = [ 'localhost' , '127.0.0.1' , '::1' ] as const ;
5+ const HTTP_PROXY_PROTOCOLS : ReadonlySet < string > = new Set ( [ 'http:' , 'https:' ] ) ;
6+ // undici's Socks5ProxyAgent accepts `socks5:` and its alias `socks:`. It always sends
7+ // host names to the proxy for resolution, which is what `socks5h:` requests, so that
8+ // scheme is accepted too and rewritten before the agent is constructed.
9+ const SOCKS5_PROXY_PROTOCOLS : ReadonlySet < string > = new Set ( [ 'socks5:' , 'socks5h:' , 'socks:' ] ) ;
10+ const SUPPORTED_PROXY_SCHEMES = 'http://, https://, socks5://, or socks5h://' ;
11+ const SOCKS5_EXPERIMENTAL_WARNING_MESSAGE =
12+ 'SOCKS5 proxy support is experimental and subject to change' ;
13+ const SOCKS5_EXPERIMENTAL_WARNING_TYPE = 'ExperimentalWarning' ;
14+ // Matches undici's default SOCKS5 connect timeout, which a custom connector replaces.
15+ const SOCKS5_PROXY_CONNECT_TIMEOUT_MS = 5_000 ;
16+ const IGNORED_ALL_PROXY_ADVICE =
17+ "Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port, or set ALL_PROXY to a socks5:// or socks5h:// URL." ;
518
619interface ProxyValue {
720 readonly name : string ;
821 readonly value : string ;
922}
1023
24+ type ProxyValueClassification =
25+ | { readonly kind : 'http' | 'socks5' }
26+ | {
27+ readonly kind : 'unsupported' ;
28+ readonly reason : 'invalid-url' | 'protocol' | 'credentials' ;
29+ readonly protocol ?: string ;
30+ readonly redacted ?: string ;
31+ } ;
32+
33+ interface ProxyCredentials {
34+ readonly username : string ;
35+ readonly password : string ;
36+ }
37+
1138export type TuiProxyConfiguration =
1239 | { readonly mode : 'direct' }
1340 | {
@@ -17,6 +44,11 @@ export type TuiProxyConfiguration =
1744 readonly noProxy : string ;
1845 } ;
1946
47+ export interface ResolveTuiProxyConfigurationOptions {
48+ /** Receives one line per ignored proxy setting. Omit to discard warnings. */
49+ readonly writeWarning ?: ( message : string ) => void ;
50+ }
51+
2052export interface ConfigureTuiNetworkProxyOptions {
2153 readonly environment ?: NodeJS . ProcessEnv ;
2254 readonly createProxyDispatcher ?: ( options : {
@@ -26,12 +58,22 @@ export interface ConfigureTuiNetworkProxyOptions {
2658 } ) => Dispatcher ;
2759 readonly setGlobalDispatcher ?: ( dispatcher : Dispatcher ) => void ;
2860 readonly installFetch ?: ( ) => void ;
61+ /** Defaults to writing a line to process.stderr. */
62+ readonly writeWarning ?: ( message : string ) => void ;
63+ }
64+
65+ interface EmitWarningTarget {
66+ emitWarning : typeof process . emitWarning ;
2967}
3068
3169export function resolveTuiProxyConfiguration (
3270 environment : NodeJS . ProcessEnv = process . env ,
71+ options : ResolveTuiProxyConfigurationOptions = { } ,
3372) : TuiProxyConfiguration {
34- const allProxy = firstProxyValue ( environment , 'ALL_PROXY' , 'all_proxy' ) ;
73+ const allProxy = usableAllProxyValue (
74+ firstProxyValue ( environment , 'ALL_PROXY' , 'all_proxy' ) ,
75+ options . writeWarning ,
76+ ) ;
3577 const httpProxy = firstProxyValue ( environment , 'HTTP_PROXY' , 'http_proxy' ) ?? allProxy ;
3678 const httpsProxy =
3779 firstProxyValue ( environment , 'HTTPS_PROXY' , 'https_proxy' ) ?? allProxy ?? httpProxy ;
@@ -51,7 +93,9 @@ export function resolveTuiProxyConfiguration(
5193export function configureTuiNetworkProxy (
5294 options : ConfigureTuiNetworkProxyOptions = { } ,
5395) : TuiProxyConfiguration {
54- const configuration = resolveTuiProxyConfiguration ( options . environment ) ;
96+ const configuration = resolveTuiProxyConfiguration ( options . environment , {
97+ writeWarning : options . writeWarning ?? writeStderrWarning ,
98+ } ) ;
5599 if ( configuration . mode === 'direct' ) return configuration ;
56100
57101 const createProxyDispatcher =
@@ -69,12 +113,73 @@ export function configureTuiNetworkProxy(
69113export function createTuiNetworkDispatcher (
70114 environment : NodeJS . ProcessEnv = process . env ,
71115) : Dispatcher {
116+ // Ignored ALL_PROXY values were already reported once at CLI startup by
117+ // configureTuiNetworkProxy; repeating the warning here would interleave with the TUI.
72118 const configuration = resolveTuiProxyConfiguration ( environment ) ;
73119 return configuration . mode === 'proxy'
74120 ? new TuiProxyDispatcher ( configuration )
75121 : new Agent ( { allowH2 : false } ) ;
76122}
77123
124+ /**
125+ * Masks the user name and password of a proxy URL so it can appear in
126+ * diagnostics. Returns undefined when the value is not a parseable URL with a host,
127+ * because an unparseable value cannot be redacted reliably.
128+ */
129+ export function redactProxyUrl ( value : string ) : string | undefined {
130+ let url : URL ;
131+ try {
132+ url = new URL ( value ) ;
133+ } catch {
134+ return undefined ;
135+ }
136+ if ( ! url . host ) return undefined ;
137+ const credentials = url . username || url . password ? `${ url . password ? '***:***' : '***' } @` : '' ;
138+ return `${ url . protocol } //${ credentials } ${ url . host } ` ;
139+ }
140+
141+ /**
142+ * Runs `create` while dropping only undici's one-time SOCKS5 experimental
143+ * warning. Every other warning, including other ExperimentalWarnings, is
144+ * forwarded unchanged, and the original emitter is restored afterwards.
145+ */
146+ export function withoutSocks5ExperimentalWarning < T > (
147+ create : ( ) => T ,
148+ target : EmitWarningTarget = process ,
149+ ) : T {
150+ const original = target . emitWarning ;
151+ const filtered = function emitWarning ( this : unknown , ...args : unknown [ ] ) : void {
152+ if ( isSocks5ExperimentalWarning ( args ) ) return ;
153+ Reflect . apply ( original , this , args ) ;
154+ } ;
155+ target . emitWarning = filtered as typeof process . emitWarning ;
156+ try {
157+ return create ( ) ;
158+ } finally {
159+ target . emitWarning = original ;
160+ }
161+ }
162+
163+ function isSocks5ExperimentalWarning ( args : readonly unknown [ ] ) : boolean {
164+ const [ warning , typeOrOptions ] = args ;
165+ const message =
166+ typeof warning === 'string' ? warning : warning instanceof Error ? warning . message : undefined ;
167+ if ( message !== SOCKS5_EXPERIMENTAL_WARNING_MESSAGE ) return false ;
168+ const type =
169+ typeof typeOrOptions === 'string'
170+ ? typeOrOptions
171+ : typeof typeOrOptions === 'object' && typeOrOptions !== null
172+ ? ( typeOrOptions as { type ?: unknown } ) . type
173+ : warning instanceof Error
174+ ? warning . name
175+ : undefined ;
176+ return type === SOCKS5_EXPERIMENTAL_WARNING_TYPE ;
177+ }
178+
179+ function writeStderrWarning ( message : string ) : void {
180+ process . stderr . write ( `${ message } \n` ) ;
181+ }
182+
78183function firstProxyValue (
79184 environment : NodeJS . ProcessEnv ,
80185 ...names : readonly string [ ]
@@ -86,17 +191,93 @@ function firstProxyValue(
86191 return undefined ;
87192}
88193
89- function validateProxyValue ( proxy : ProxyValue | undefined ) : void {
90- if ( ! proxy ) return ;
194+ function classifyProxyValue ( value : string ) : ProxyValueClassification {
91195 let url : URL ;
92196 try {
93- url = new URL ( proxy . value ) ;
197+ url = new URL ( value ) ;
198+ } catch {
199+ return { kind : 'unsupported' , reason : 'invalid-url' } ;
200+ }
201+ // Values such as `localhost:7890` parse with `localhost:` as the scheme.
202+ if ( ! url . hostname ) return { kind : 'unsupported' , reason : 'invalid-url' } ;
203+ const kind = HTTP_PROXY_PROTOCOLS . has ( url . protocol )
204+ ? 'http'
205+ : SOCKS5_PROXY_PROTOCOLS . has ( url . protocol )
206+ ? 'socks5'
207+ : undefined ;
208+ if ( ! kind ) {
209+ return {
210+ kind : 'unsupported' ,
211+ reason : 'protocol' ,
212+ protocol : url . protocol ,
213+ redacted : redactProxyUrl ( value ) ,
214+ } ;
215+ }
216+ // Both undici proxy agents percent-decode URL credentials while being
217+ // constructed; a stray `%` would otherwise throw URIError at startup.
218+ if ( ! decodeProxyCredentials ( url ) ) {
219+ return { kind : 'unsupported' , reason : 'credentials' , redacted : redactProxyUrl ( value ) } ;
220+ }
221+ return { kind } ;
222+ }
223+
224+ function decodeProxyCredentials ( url : URL ) : ProxyCredentials | undefined {
225+ try {
226+ return {
227+ username : decodeURIComponent ( url . username ) ,
228+ password : decodeURIComponent ( url . password ) ,
229+ } ;
94230 } catch {
95- throw new Error ( ` ${ proxy . name } must be an http:// or https:// URL.` ) ;
231+ return undefined ;
96232 }
97- if ( ( url . protocol !== 'http:' && url . protocol !== 'https:' ) || ! url . hostname ) {
98- throw new Error ( `${ proxy . name } must be an http:// or https:// URL.` ) ;
233+ }
234+
235+ /**
236+ * ALL_PROXY is only a fallback, and SOCKS tools commonly export it next to an
237+ * explicit HTTP(S)_PROXY. A value this client cannot use must not prevent
238+ * startup, so it is reported and skipped instead of rejected.
239+ */
240+ function usableAllProxyValue (
241+ proxy : ProxyValue | undefined ,
242+ writeWarning : ( ( message : string ) => void ) | undefined ,
243+ ) : ProxyValue | undefined {
244+ if ( ! proxy ) return undefined ;
245+ const classification = classifyProxyValue ( proxy . value ) ;
246+ if ( classification . kind !== 'unsupported' ) return proxy ;
247+ writeWarning ?.(
248+ `Warning: ignoring ${ describeProxySetting ( proxy . name , classification ) } because ${ describeUnsupportedReason ( classification ) } ; it will not be used. ${ IGNORED_ALL_PROXY_ADVICE } ` ,
249+ ) ;
250+ return undefined ;
251+ }
252+
253+ function validateProxyValue ( proxy : ProxyValue | undefined ) : void {
254+ if ( ! proxy ) return ;
255+ const classification = classifyProxyValue ( proxy . value ) ;
256+ if ( classification . kind !== 'unsupported' ) return ;
257+ if ( classification . reason === 'credentials' ) {
258+ throw new Error (
259+ `${ describeProxySetting ( proxy . name , classification ) } has a user name or password that is not valid percent-encoding. Encode reserved characters, for example % as %25.` ,
260+ ) ;
99261 }
262+ throw new Error ( `${ proxy . name } must be an ${ SUPPORTED_PROXY_SCHEMES } URL.` ) ;
263+ }
264+
265+ function describeProxySetting (
266+ name : string ,
267+ classification : { readonly redacted ?: string } ,
268+ ) : string {
269+ return classification . redacted ? `${ name } =${ classification . redacted } ` : name ;
270+ }
271+
272+ function describeUnsupportedReason ( classification : {
273+ readonly reason : 'invalid-url' | 'protocol' | 'credentials' ;
274+ readonly protocol ?: string ;
275+ } ) : string {
276+ if ( classification . reason === 'credentials' ) {
277+ return 'its user name or password is not valid percent-encoding (encode % as %25)' ;
278+ }
279+ if ( ! classification . protocol ) return 'it is not a valid proxy URL' ;
280+ return `${ classification . protocol } // proxies are not supported (use ${ SUPPORTED_PROXY_SCHEMES } )` ;
100281}
101282
102283function withLoopbackNoProxy ( existing : string | undefined ) : string {
@@ -115,7 +296,9 @@ function installUndiciFetch(): void {
115296 ( undici as typeof undici & { install ?: ( ) => void } ) . install ?.( ) ;
116297}
117298
118- function createTuiProxyAgent ( uri : string ) : ProxyAgent {
299+ function createTuiProxyAgent ( uri : string ) : Dispatcher {
300+ const url = new URL ( uri ) ;
301+ if ( SOCKS5_PROXY_PROTOCOLS . has ( url . protocol ) ) return createTuiSocks5ProxyAgent ( url ) ;
119302 // Preserve HTTP/1.1 on both TLS hops and CONNECT for plain HTTP targets.
120303 return new ProxyAgent ( {
121304 uri,
@@ -125,6 +308,45 @@ function createTuiProxyAgent(uri: string): ProxyAgent {
125308 } ) ;
126309}
127310
311+ function createTuiSocks5ProxyAgent ( url : URL ) : Dispatcher {
312+ const credentials = decodeProxyCredentials ( url ) ;
313+ if ( ! credentials ) throw new Error ( 'Proxy user name or password is not valid percent-encoding.' ) ;
314+ // Pass decoded credentials as options and drop them from the URL so undici
315+ // never decodes them again. An empty password stays unset, as with undici.
316+ const proxyUrl = new URL ( url . href ) ;
317+ proxyUrl . username = '' ;
318+ proxyUrl . password = '' ;
319+ // Socks5ProxyAgent rejects `socks5h:` although it already resolves names remotely.
320+ if ( proxyUrl . protocol === 'socks5h:' ) proxyUrl . protocol = 'socks5:' ;
321+ const options : Socks5ProxyAgent . Options = {
322+ connectTimeout : SOCKS5_PROXY_CONNECT_TIMEOUT_MS ,
323+ connect : withUnbracketedProxyHost ( buildConnector ( { timeout : SOCKS5_PROXY_CONNECT_TIMEOUT_MS } ) ) ,
324+ ...( credentials . username ? { username : credentials . username } : { } ) ,
325+ ...( credentials . password ? { password : credentials . password } : { } ) ,
326+ } ;
327+ return withoutSocks5ExperimentalWarning ( ( ) => new Socks5ProxyAgent ( proxyUrl , options ) ) ;
328+ }
329+
330+ /**
331+ * Socks5ProxyAgent dials `URL.hostname`, which keeps the brackets of an IPv6
332+ * literal (`[::1]`) and makes the socket connect fail with ENOTFOUND.
333+ */
334+ function withUnbracketedProxyHost ( connector : buildConnector . connector ) : buildConnector . connector {
335+ return ( options , callback ) =>
336+ connector (
337+ {
338+ ...options ,
339+ hostname : stripIpv6Brackets ( options . hostname ) ,
340+ ...( options . host === undefined ? { } : { host : stripIpv6Brackets ( options . host ) } ) ,
341+ } ,
342+ callback ,
343+ ) ;
344+ }
345+
346+ function stripIpv6Brackets ( hostname : string ) : string {
347+ return hostname . startsWith ( '[' ) && hostname . endsWith ( ']' ) ? hostname . slice ( 1 , - 1 ) : hostname ;
348+ }
349+
128350class TuiProxyDispatcher extends Dispatcher {
129351 readonly #directDispatcher = new Agent ( { allowH2 : false } ) ;
130352 readonly #httpDispatcher: Dispatcher ;
0 commit comments