Skip to content

Commit 3feeba5

Browse files
committed
Add support for upstream proxies with websockets too
1 parent f521a4b commit 3feeba5

File tree

7 files changed

+292
-196
lines changed

7 files changed

+292
-196
lines changed

src/rules/requests/request-handlers.ts

Lines changed: 15 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import tls = require('tls');
99
import http = require('http');
1010
import http2 = require('http2');
1111
import https = require('https');
12-
import ProxyAgent = require('proxy-agent');
1312
import * as h2Client from 'http2-wrapper';
1413
import CacheableLookup from 'cacheable-lookup';
1514
import { encode as encodeBase64, decode as decodeBase64 } from 'base64-arraybuffer';
@@ -18,6 +17,18 @@ import { stripIndent, oneLine } from 'common-tags';
1817
import { TypedError } from 'typed-error';
1918
import { encodeBuffer, SUPPORTED_ENCODING } from 'http-encoding';
2019

20+
import {
21+
Headers,
22+
OngoingRequest,
23+
CompletedRequest,
24+
OngoingResponse,
25+
CompletedBody,
26+
Explainable
27+
} from "../../types";
28+
29+
import { byteLength } from '../../util/util';
30+
import { MaybePromise, Replace } from '../../util/type-utils';
31+
import { readFile } from '../../util/fs';
2132
import {
2233
waitForCompletedRequest,
2334
setHeaders,
@@ -29,8 +40,7 @@ import {
2940
h2HeadersToH1,
3041
isAbsoluteUrl,
3142
cleanUpHeaders,
32-
isMockttpBody,
33-
matchesNoProxy
43+
isMockttpBody
3444
} from '../../util/request-utils';
3545
import { streamToBuffer, asBuffer } from '../../util/buffer-utils';
3646
import { isLocalPortActive, isSocketLoop } from '../../util/socket-util';
@@ -45,18 +55,7 @@ import {
4555
serializeBuffer,
4656
deserializeBuffer
4757
} from "../../util/serialization";
48-
import { MaybePromise, Replace } from '../../util/type-utils';
49-
import { readFile } from '../../util/fs';
50-
51-
import {
52-
Headers,
53-
OngoingRequest,
54-
CompletedRequest,
55-
OngoingResponse,
56-
CompletedBody,
57-
Explainable
58-
} from "../../types";
59-
import { byteLength, isNode } from '../../util/util';
58+
import { getAgent, ProxyConfig } from '../../util/http-agents';
6059

6160
// An error that indicates that the handler is aborting the request.
6261
// This could be intentional, or an upstream server aborting the request.
@@ -416,36 +415,6 @@ export interface ForwardingOptions {
416415
updateHostHeader?: true | false | string // Change automatically/ignore/change to custom value
417416
}
418417

419-
export interface ProxyConfig {
420-
/**
421-
* The URL for the proxy to pass through.
422-
*
423-
* This can be any URL supported by https://www.npmjs.com/package/proxy-agent.
424-
* For example: http://..., socks5://..., pac+http://...
425-
*/
426-
proxyUrl: string;
427-
428-
/**
429-
* A list of no-proxy values, matching URLs which should not be proxied.
430-
*
431-
* This is a common proxy feature, but unfortunately isn't standardized. See
432-
* https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ for some
433-
* background. This implementation is intended to match Curl's behaviour, and
434-
* any differences are a bug.
435-
*
436-
* The currently supported formats are:
437-
* - example.com (matches domain and all subdomains)
438-
* - example.com:443 (matches domain and all subdomains, but only on that port)
439-
* - 10.0.0.1 (matches IP, but only when used directly - does not resolve domains)
440-
*
441-
* Some other formats (e.g. leading dots or *.) will work, but the leading
442-
* characters are ignored. More formats may be added in future, e.g. CIDR ranges.
443-
* To maximize compatibility with values used elsewhere, unrecognized formats
444-
* will generally be ignored, but may match in unexpected ways.
445-
*/
446-
noProxy?: string[];
447-
}
448-
449418
export interface PassThroughLookupOptions {
450419
/**
451420
* The maximum time to cache a DNS response. Up to this limit,
@@ -883,50 +852,6 @@ function validateCustomHeaders(
883852
const OMIT_SYMBOL = Symbol('omit-value');
884853
const SERIALIZED_OMIT = "__mockttp__transform__omit__";
885854

886-
const KeepAliveAgents = isNode
887-
? { // These are only used (and only available) on the node server side
888-
'http:': new http.Agent({
889-
keepAlive: true
890-
}),
891-
'https:': new https.Agent({
892-
keepAlive: true
893-
})
894-
} : {};
895-
896-
function getAgent({
897-
protocol, hostname, port, tryHttp2, keepAlive, proxyConfig
898-
}: {
899-
protocol: 'http:' | 'https:' | undefined,
900-
hostname: string,
901-
port: number,
902-
tryHttp2: boolean,
903-
keepAlive: boolean
904-
proxyConfig: ProxyConfig | undefined,
905-
}): {} | undefined {
906-
if (proxyConfig && proxyConfig.proxyUrl) {
907-
// If there's a (non-empty) proxy configured, use it. We require non-empty because empty strings
908-
// will fall back to detecting from the environment, which is likely to behave unexpectedly.
909-
910-
if (!matchesNoProxy(hostname, port, proxyConfig.noProxy)) {
911-
// We notably ignore HTTP/2 upstream in this case: it's complicated to mix that up with proxying
912-
// so for now we ignore it entirely.
913-
return new ProxyAgent(proxyConfig.proxyUrl);
914-
}
915-
}
916-
917-
if (tryHttp2 && protocol === 'https:') {
918-
// H2 wrapper takes multiple agents, uses the appropriate one for the detected protocol.
919-
// We notably never use H2 upstream for plaintext, it's rare and we can't use ALPN to detect it.
920-
return { https: KeepAliveAgents['https:'], http2: undefined };
921-
} else if (keepAlive) {
922-
// HTTP/1.1 or HTTP/1 with explicit keep-alive
923-
return KeepAliveAgents[protocol || 'http:']
924-
} else {
925-
// HTTP/1 without KA - just send the request with no agent
926-
return undefined;
927-
}
928-
}
929-
930855
export class PassThroughHandler extends Serializable implements RequestHandler {
931856
readonly type = 'passthrough';
932857

@@ -1265,7 +1190,7 @@ export class PassThroughHandler extends Serializable implements RequestHandler {
12651190
tryHttp2: shouldTryH2Upstream,
12661191
keepAlive: shouldKeepAlive(clientReq),
12671192
proxyConfig: this.proxyConfig
1268-
}) as http.Agent;
1193+
});
12691194

12701195
if (isH2Downstream && shouldTryH2Upstream) {
12711196
// We drop all incoming pseudoheaders, and regenerate them (except legally modified ones)

src/rules/websockets/websocket-handlers.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../requests/request-handlers';
2828
import { isHttp2 } from '../../util/request-utils';
2929
import { streamToBuffer } from '../../util/buffer-utils';
30+
import { getAgent, ProxyConfig } from '../../util/http-agents';
3031

3132
export interface WebSocketHandler extends Explainable, Serializable {
3233
type: keyof typeof WsHandlerLookup;
@@ -162,6 +163,11 @@ export interface PassThroughWebSocketHandlerOptions {
162163
*/
163164
ignoreHostCertificateErrors?: string[];
164165

166+
/**
167+
* Upstream proxy configuration: pass through requests via this proxy
168+
*/
169+
proxyConfig?: ProxyConfig;
170+
165171
/**
166172
* Custom DNS options, to allow configuration of the resolver used
167173
* when forwarding requests upstream. Passing any option switches
@@ -189,6 +195,8 @@ export class PassThroughWebSocketHandler extends Serializable implements WebSock
189195
// Same lookup configuration as normal request PassThroughHandler:
190196
public readonly lookupOptions: PassThroughLookupOptions | undefined;
191197

198+
public readonly proxyConfig?: ProxyConfig;
199+
192200
private _cacheableLookupInstance: CacheableLookup | undefined;
193201
private lookup() {
194202
if (!this.lookupOptions) return undefined;
@@ -235,6 +243,7 @@ export class PassThroughWebSocketHandler extends Serializable implements WebSock
235243
this.forwarding = options.forwarding;
236244

237245
this.lookupOptions = options.lookupOptions;
246+
this.proxyConfig = options.proxyConfig;
238247
}
239248

240249
explain() {
@@ -330,9 +339,23 @@ export class PassThroughWebSocketHandler extends Serializable implements WebSock
330339
const checkServerCertificate = !_.includes(this.ignoreHostHttpsErrors, parsedUrl.hostname) &&
331340
!_.includes(this.ignoreHostHttpsErrors, parsedUrl.host);
332341

342+
const effectivePort = !!parsedUrl.port
343+
? parseInt(parsedUrl.port, 10)
344+
: parsedUrl.protocol == 'wss:' ? 443 : 80;
345+
346+
const agent = getAgent({
347+
protocol: parsedUrl.protocol as 'ws:' | 'wss:',
348+
hostname: parsedUrl.hostname!,
349+
port: effectivePort,
350+
proxyConfig: this.proxyConfig,
351+
tryHttp2: false, // We don't support websockets over H2 yet
352+
keepAlive: false // Not a thing for websockets: they take over the whole connection
353+
});
354+
333355
const upstreamWebSocket = new WebSocket(wsUrl, {
334356
rejectUnauthorized: checkServerCertificate,
335357
maxPayload: 0,
358+
agent,
336359
lookup: this.lookup(),
337360
headers: _.omitBy(headers, (_v, headerName) =>
338361
headerName.toLowerCase().startsWith('sec-websocket') ||

src/util/http-agents.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as http from 'http';
2+
import * as https from 'https';
3+
import ProxyAgent = require('proxy-agent');
4+
5+
import { isNode } from "./util";
6+
7+
const KeepAliveAgents = isNode
8+
? { // These are only used (and only available) on the node server side
9+
'http:': new http.Agent({
10+
keepAlive: true
11+
}),
12+
'https:': new https.Agent({
13+
keepAlive: true
14+
})
15+
} : {};
16+
17+
export interface ProxyConfig {
18+
/**
19+
* The URL for the proxy to forward traffic through.
20+
*
21+
* This can be any URL supported by https://www.npmjs.com/package/proxy-agent.
22+
* For example: http://..., socks5://..., pac+http://...
23+
*/
24+
proxyUrl: string;
25+
26+
/**
27+
* A list of no-proxy values, matching hosts' traffic should *not* be proxied.
28+
*
29+
* This is a common proxy feature, but unfortunately isn't standardized. See
30+
* https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ for some
31+
* background. This implementation is intended to match Curl's behaviour, and
32+
* any differences are a bug.
33+
*
34+
* The currently supported formats are:
35+
* - example.com (matches domain and all subdomains)
36+
* - example.com:443 (matches domain and all subdomains, but only on that port)
37+
* - 10.0.0.1 (matches IP, but only when used directly - does not resolve domains)
38+
*
39+
* Some other formats (e.g. leading dots or *.) will work, but the leading
40+
* characters are ignored. More formats may be added in future, e.g. CIDR ranges.
41+
* To maximize compatibility with values used elsewhere, unrecognized formats
42+
* will generally be ignored, but may match in unexpected ways.
43+
*/
44+
noProxy?: string[];
45+
}
46+
47+
export function getAgent({
48+
protocol, hostname, port, tryHttp2, keepAlive, proxyConfig
49+
}: {
50+
protocol: 'http:' | 'https:' | 'ws:' | 'wss:' | undefined,
51+
hostname: string,
52+
port: number,
53+
tryHttp2: boolean,
54+
keepAlive: boolean
55+
proxyConfig: ProxyConfig | undefined,
56+
}): http.Agent | undefined { // <-- We force this cast for convenience in various different uses later
57+
if (proxyConfig && proxyConfig.proxyUrl) {
58+
// If there's a (non-empty) proxy configured, use it. We require non-empty because empty strings
59+
// will fall back to detecting from the environment, which is likely to behave unexpectedly.
60+
61+
if (!matchesNoProxy(hostname, port, proxyConfig.noProxy)) {
62+
// We notably ignore HTTP/2 upstream in this case: it's complicated to mix that up with proxying
63+
// so for now we ignore it entirely.
64+
return new ProxyAgent(proxyConfig.proxyUrl) as http.Agent;
65+
}
66+
}
67+
68+
if (tryHttp2 && (protocol === 'https:' || protocol === 'wss:')) {
69+
// H2 wrapper takes multiple agents, uses the appropriate one for the detected protocol.
70+
// We notably never use H2 upstream for plaintext, it's rare and we can't use ALPN to detect it.
71+
return { https: KeepAliveAgents['https:'], http2: undefined } as any as http.Agent;
72+
} else if (keepAlive && protocol !== 'wss:' && protocol !== 'ws:') {
73+
// HTTP/1.1 or HTTP/1 with explicit keep-alive
74+
return KeepAliveAgents[protocol || 'http:']
75+
} else {
76+
// HTTP/1 without KA - just send the request with no agent
77+
return undefined;
78+
}
79+
}
80+
81+
export const matchesNoProxy = (hostname: string, portNum: number, noProxyValues: string[] | undefined) => {
82+
if (!noProxyValues || noProxyValues.length === 0) return false; // Skip everything in the common case.
83+
84+
const port = portNum.toString();
85+
const hostParts = hostname.split('.').reverse();
86+
87+
return noProxyValues.some((noProxy) => {
88+
const [noProxyHost, noProxyPort] = noProxy.split(':') as [string, string | undefined];
89+
90+
let noProxyParts = noProxyHost.split('.').reverse();
91+
const lastPart = noProxyParts[noProxyParts.length - 1];
92+
if (lastPart === '' || lastPart === '*') {
93+
noProxyParts = noProxyParts.slice(0, -1);
94+
}
95+
96+
if (noProxyPort && port !== noProxyPort) return false;
97+
98+
for (let i = 0; i < noProxyParts.length; i++) {
99+
let noProxyPart = noProxyParts[i];
100+
let hostPart = hostParts[i];
101+
102+
if (hostPart === undefined) return false; // No-proxy is longer than hostname
103+
if (noProxyPart !== hostPart) return false; // Mismatch
104+
}
105+
106+
// If we run out of no-proxy parts with no mismatch then we've matched
107+
return true;
108+
});
109+
}

src/util/request-utils.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -65,36 +65,6 @@ export const shouldKeepAlive = (req: OngoingRequest): boolean =>
6565
req.headers['connection'] !== 'close' &&
6666
req.headers['proxy-connection'] !== 'close';
6767

68-
export const matchesNoProxy = (hostname: string, portNum: number, noProxyValues: string[] | undefined) => {
69-
if (!noProxyValues || noProxyValues.length === 0) return false; // Skip everything in the common case.
70-
71-
const port = portNum.toString();
72-
const hostParts = hostname.split('.').reverse();
73-
74-
return noProxyValues.some((noProxy) => {
75-
const [noProxyHost, noProxyPort] = noProxy.split(':') as [string, string | undefined];
76-
77-
let noProxyParts = noProxyHost.split('.').reverse();
78-
const lastPart = noProxyParts[noProxyParts.length - 1];
79-
if (lastPart === '' || lastPart === '*') {
80-
noProxyParts = noProxyParts.slice(0, -1);
81-
}
82-
83-
if (noProxyPort && port !== noProxyPort) return false;
84-
85-
for (let i = 0; i < noProxyParts.length; i++) {
86-
let noProxyPart = noProxyParts[i];
87-
let hostPart = hostParts[i];
88-
89-
if (hostPart === undefined) return false; // No-proxy is longer than hostname
90-
if (noProxyPart !== hostPart) return false; // Mismatch
91-
}
92-
93-
// If we run out of no-proxy parts with no mismatch then we've matched
94-
return true;
95-
});
96-
}
97-
9868
export const setHeaders = (response: http.ServerResponse, headers: Headers) => {
9969
Object.keys(headers).forEach((header) => {
10070
let value = headers[header];

0 commit comments

Comments
 (0)