Skip to content

Commit ebfdc00

Browse files
authored
fix(tui): support SOCKS5 proxies and stop ALL_PROXY from crashing startup (#465)
* fix(tui): support SOCKS5 proxies and stop ALL_PROXY from crashing startup Fixes #393. ALL_PROXY=socks5://... used to abort CLI startup because only http:// and https:// proxy URLs were accepted. - Route socks5://, socks5h://, and socks:// proxies through undici's Socks5ProxyAgent, which sends host names to the proxy for resolution. socks5h:// is rewritten to socks5:// with its credentials preserved. NO_PROXY hosts, including loopback, still connect directly. - Ignore an unusable ALL_PROXY fallback (for example socks4:// or a malformed URL) with a single stderr warning instead of failing startup. Explicit invalid HTTP_PROXY/HTTPS_PROXY values are still rejected. - Mask proxy credentials in warnings. - Suppress only undici's one-time SOCKS5 ExperimentalWarning while the agent is constructed; other warnings are unaffected. - Add unit tests and a loopback SOCKS5 integration test covering no-auth, username/password auth, domain-name CONNECT, and NO_PROXY. * fix(tui): handle IPv6 SOCKS5 proxies and malformed proxy credentials Follow-up to #393 review feedback. - Dial SOCKS5 proxies at IPv6 literals such as socks5://[::1]:1080. undici passes the bracketed URL hostname to the socket connect, which failed with ENOTFOUND; a wrapped connector now strips the brackets. - Validate the percent-encoding of proxy user names and passwords while classifying proxy settings. undici decodes them during agent construction and threw URIError, so a malformed ALL_PROXY made every command, including --help, exit with only "URI malformed". Such an ALL_PROXY is now ignored with a redacted warning; an explicit HTTP_PROXY/HTTPS_PROXY fails with an error naming the variable. - Pass decoded SOCKS5 credentials as agent options and remove them from the proxy URL so undici does not decode them again.
1 parent c460f08 commit ebfdc00

6 files changed

Lines changed: 921 additions & 10 deletions

File tree

‎packages/tui/src/cli/main.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi
101101
try {
102102
(dependencies.configureNetworkProxy ?? configureTuiNetworkProxy)({
103103
environment: processRef.env,
104+
writeWarning: (message) => processRef.stderr.write(`${message}\n`),
104105
});
105106
const createProgram = dependencies.createProgram ?? createTuiProgram;
106107
await createProgram({

‎packages/tui/src/cli/network-proxy.ts‎

Lines changed: 232 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
11
import * as undici from 'undici';
2-
import { Agent, Dispatcher, ProxyAgent } from 'undici';
2+
import { Agent, buildConnector, Dispatcher, ProxyAgent, Socks5ProxyAgent } from 'undici';
33

44
const 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

619
interface 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+
1138
export 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+
2052
export 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

3169
export 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(
5193
export 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(
69113
export 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+
78183
function 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

102283
function 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+
128350
class TuiProxyDispatcher extends Dispatcher {
129351
readonly #directDispatcher = new Agent({ allowH2: false });
130352
readonly #httpDispatcher: Dispatcher;

0 commit comments

Comments
 (0)