feat: support proxy in connect/connectOverCDP (#35389)
This commit is contained in:
parent
e3bb687cfc
commit
471a28e0d5
|
@ -144,6 +144,16 @@ Some common examples:
|
|||
1. `"<loopback>"` to expose localhost network.
|
||||
1. `"*.test.internal-domain,*.staging.internal-domain,<loopback>"` to expose test/staging deployments and localhost.
|
||||
|
||||
### option: BrowserType.connect.proxy
|
||||
* since: v1.52
|
||||
- `proxy` <[Object]>
|
||||
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
|
||||
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
|
||||
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.
|
||||
|
||||
Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.
|
||||
|
||||
## async method: BrowserType.connectOverCDP
|
||||
* since: v1.9
|
||||
- returns: <[Browser]>
|
||||
|
@ -232,6 +242,16 @@ Logger sink for Playwright logging. Optional.
|
|||
Maximum time in milliseconds to wait for the connection to be established. Defaults to
|
||||
`30000` (30 seconds). Pass `0` to disable timeout.
|
||||
|
||||
### option: BrowserType.connectOverCDP.proxy
|
||||
* since: v1.52
|
||||
- `proxy` <[Object]>
|
||||
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
|
||||
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
|
||||
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.
|
||||
|
||||
Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.
|
||||
|
||||
## method: BrowserType.executablePath
|
||||
* since: v1.8
|
||||
- returns: <[string]>
|
||||
|
|
|
@ -226,10 +226,8 @@ Dangerous option; use with care. Defaults to `false`.
|
|||
## browser-option-proxy
|
||||
- `proxy` <[Object]>
|
||||
- `server` <[string]> Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example
|
||||
`http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
|
||||
proxy.
|
||||
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org,
|
||||
.domain.com"`.
|
||||
`http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
|
||||
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
|
||||
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.
|
||||
|
||||
|
|
|
@ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions {
|
|||
*/
|
||||
logger?: Logger;
|
||||
|
||||
/**
|
||||
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
|
||||
* by the browser to load web pages.
|
||||
*/
|
||||
proxy?: {
|
||||
/**
|
||||
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
|
||||
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
|
||||
* proxy.
|
||||
*/
|
||||
server: string;
|
||||
|
||||
/**
|
||||
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
*/
|
||||
bypass?: string;
|
||||
|
||||
/**
|
||||
* Optional username to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Optional password to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
password?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
|
||||
* on. Defaults to 0.
|
||||
|
@ -21834,6 +21862,34 @@ export interface ConnectOptions {
|
|||
*/
|
||||
logger?: Logger;
|
||||
|
||||
/**
|
||||
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
|
||||
* by the browser to load web pages.
|
||||
*/
|
||||
proxy?: {
|
||||
/**
|
||||
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
|
||||
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
|
||||
* proxy.
|
||||
*/
|
||||
server: string;
|
||||
|
||||
/**
|
||||
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
*/
|
||||
bypass?: string;
|
||||
|
||||
/**
|
||||
* Optional username to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Optional password to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
password?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
|
||||
* on. Defaults to 0.
|
||||
|
|
|
@ -129,6 +129,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
|
|||
exposeNetwork: params.exposeNetwork ?? params._exposeNetwork,
|
||||
slowMo: params.slowMo,
|
||||
timeout: params.timeout,
|
||||
proxy: params.proxy,
|
||||
};
|
||||
if ((params as any).__testHookRedirectPortForwarding)
|
||||
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
|
||||
|
@ -188,7 +189,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
|
|||
endpointURL,
|
||||
headers,
|
||||
slowMo: params.slowMo,
|
||||
timeout: params.timeout
|
||||
timeout: params.timeout,
|
||||
proxy: params.proxy,
|
||||
});
|
||||
const browser = Browser.from(result.browser);
|
||||
this._didLaunchBrowser(browser, {}, params.logger);
|
||||
|
|
|
@ -103,6 +103,12 @@ export type ConnectOptions = {
|
|||
slowMo?: number,
|
||||
timeout?: number,
|
||||
logger?: Logger,
|
||||
proxy?: {
|
||||
server: string,
|
||||
bypass?: string,
|
||||
username?: string,
|
||||
password?: string
|
||||
},
|
||||
};
|
||||
export type LaunchServerOptions = {
|
||||
channel?: channels.BrowserTypeLaunchOptions['channel'],
|
||||
|
|
|
@ -327,6 +327,12 @@ scheme.LocalUtilsConnectParams = tObject({
|
|||
exposeNetwork: tOptional(tString),
|
||||
slowMo: tOptional(tNumber),
|
||||
timeout: tOptional(tNumber),
|
||||
proxy: tOptional(tObject({
|
||||
server: tString,
|
||||
bypass: tOptional(tString),
|
||||
username: tOptional(tString),
|
||||
password: tOptional(tString),
|
||||
})),
|
||||
socksProxyRedirectPortForTest: tOptional(tNumber),
|
||||
});
|
||||
scheme.LocalUtilsConnectResult = tObject({
|
||||
|
@ -650,6 +656,12 @@ scheme.BrowserTypeConnectOverCDPParams = tObject({
|
|||
headers: tOptional(tArray(tType('NameValue'))),
|
||||
slowMo: tOptional(tNumber),
|
||||
timeout: tOptional(tNumber),
|
||||
proxy: tOptional(tObject({
|
||||
server: tString,
|
||||
bypass: tOptional(tString),
|
||||
username: tOptional(tString),
|
||||
password: tOptional(tString),
|
||||
})),
|
||||
});
|
||||
scheme.BrowserTypeConnectOverCDPResult = tObject({
|
||||
browser: tChannel(['Browser']),
|
||||
|
|
|
@ -292,7 +292,7 @@ export abstract class BrowserType extends SdkObject {
|
|||
await fs.promises.mkdir(options.tracesDir, { recursive: true });
|
||||
}
|
||||
|
||||
async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number }, timeout?: number): Promise<Browser> {
|
||||
async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }): Promise<Browser> {
|
||||
throw new Error('CDP connections are only supported by Chromium');
|
||||
}
|
||||
|
||||
|
|
|
@ -64,15 +64,15 @@ export class Chromium extends BrowserType {
|
|||
this._devtools = this._createDevTools();
|
||||
}
|
||||
|
||||
override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }, timeout?: number) {
|
||||
override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }) {
|
||||
const controller = new ProgressController(metadata, this);
|
||||
controller.setLogName('browser');
|
||||
return controller.run(async progress => {
|
||||
return await this._connectOverCDPInternal(progress, endpointURL, options);
|
||||
}, TimeoutSettings.timeout({ timeout }));
|
||||
}, TimeoutSettings.timeout(options));
|
||||
}
|
||||
|
||||
async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise<void>) {
|
||||
async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray, proxy?: types.ProxySettings }, onClose?: () => Promise<void>) {
|
||||
let headersMap: { [key: string]: string; } | undefined;
|
||||
if (options.headers)
|
||||
headersMap = headersArrayToObject(options.headers, false);
|
||||
|
@ -84,10 +84,10 @@ export class Chromium extends BrowserType {
|
|||
|
||||
const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);
|
||||
|
||||
const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap);
|
||||
const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap, options.proxy);
|
||||
progress.throwIfAborted();
|
||||
|
||||
const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, headersMap);
|
||||
const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap, proxy: options.proxy });
|
||||
const cleanedUp = new ManualPromise<void>();
|
||||
const doCleanup = async () => {
|
||||
await removeFolders([artifactsDir]);
|
||||
|
@ -365,18 +365,35 @@ class ChromiumReadyState extends BrowserReadyState {
|
|||
}
|
||||
}
|
||||
|
||||
async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) {
|
||||
async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }, proxy?: types.ProxySettings) {
|
||||
if (endpointURL.startsWith('ws'))
|
||||
return endpointURL;
|
||||
progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
|
||||
const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`;
|
||||
// Chromium insists on localhost "Host" header for security reasons, and in the case of proxy
|
||||
// we end up with the remote host instead of localhost.
|
||||
const extraHeaders = proxy ? { Host: `localhost:9222` } : {};
|
||||
const json = await fetchData({
|
||||
url: httpURL,
|
||||
headers,
|
||||
headers: { ...headers, ...extraHeaders },
|
||||
proxy,
|
||||
}, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` +
|
||||
`This does not look like a DevTools server, try connecting via ws://.`)
|
||||
);
|
||||
return JSON.parse(json).webSocketDebuggerUrl;
|
||||
const wsUrl = JSON.parse(json).webSocketDebuggerUrl;
|
||||
if (proxy) {
|
||||
// webSocketDebuggerUrl will be a localhost URL, accessible from the browser's computer.
|
||||
// When using a proxy, assume we need to connect through the original endpointURL.
|
||||
// Example:
|
||||
// connecting to http://example.com/
|
||||
// making request to http://example.com/json/version/
|
||||
// webSocketDebuggerUrl ends up as ws://localhost:9222/devtools/page/<guid>
|
||||
// we construct ws://example.com/devtools/page/<guid> by appending the pathname to the original URL
|
||||
const url = new URL(endpointURL);
|
||||
url.pathname += (url.pathname.endsWith('/') ? '' : '/') + new URL(wsUrl).pathname.substring(1);
|
||||
return url.toString();
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) {
|
||||
|
|
|
@ -43,7 +43,7 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
|
|||
}
|
||||
|
||||
async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> {
|
||||
const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params, params.timeout);
|
||||
const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params);
|
||||
const browserDispatcher = new BrowserDispatcher(this, browser);
|
||||
return {
|
||||
browser: browserDispatcher,
|
||||
|
|
|
@ -33,6 +33,7 @@ import type { RootDispatcher } from './dispatcher';
|
|||
import type * as channels from '@protocol/channels';
|
||||
import type * as http from 'http';
|
||||
import type { HTTPRequestParams } from '../utils/network';
|
||||
import type { ProxySettings } from '../types';
|
||||
|
||||
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
|
||||
_type_LocalUtils: boolean;
|
||||
|
@ -90,9 +91,9 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
|
|||
'x-playwright-proxy': params.exposeNetwork ?? '',
|
||||
...params.headers,
|
||||
};
|
||||
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
|
||||
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint, params.proxy);
|
||||
|
||||
const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true, 'x-playwright-debug-log');
|
||||
const transport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: wsHeaders, followRedirects: true, debugLogHeader: 'x-playwright-debug-log', proxy: params.proxy });
|
||||
const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest);
|
||||
const pipe = new JsonPipeDispatcher(this);
|
||||
transport.onmessage = json => {
|
||||
|
@ -128,7 +129,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
|
|||
}
|
||||
}
|
||||
|
||||
async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string): Promise<string> {
|
||||
async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string, proxy?: ProxySettings): Promise<string> {
|
||||
if (endpointURL.startsWith('ws'))
|
||||
return endpointURL;
|
||||
|
||||
|
@ -142,6 +143,7 @@ async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: stri
|
|||
method: 'GET',
|
||||
timeout: progress?.timeUntilDeadline() ?? 30_000,
|
||||
headers: { 'User-Agent': getUserAgent() },
|
||||
proxy,
|
||||
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
|
||||
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
|
||||
`This does not look like a Playwright server, try connecting via ws://.`);
|
||||
|
|
|
@ -18,14 +18,12 @@ import http from 'http';
|
|||
import https from 'https';
|
||||
import { Transform, pipeline } from 'stream';
|
||||
import { TLSSocket } from 'tls';
|
||||
import url from 'url';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
import { TimeoutSettings } from './timeoutSettings';
|
||||
import { assert, constructURLBasedOnBaseURL, eventsHelper, monotonicTime } from '../utils';
|
||||
import { assert, constructURLBasedOnBaseURL, createProxyAgent, eventsHelper, monotonicTime } from '../utils';
|
||||
import { createGuid } from './utils/crypto';
|
||||
import { getUserAgent } from './utils/userAgent';
|
||||
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
|
||||
import { BrowserContext, verifyClientCertificates } from './browserContext';
|
||||
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
|
||||
import { MultipartFormData } from './formData';
|
||||
|
@ -183,8 +181,8 @@ export abstract class APIRequestContext extends SdkObject {
|
|||
let agent;
|
||||
// We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
|
||||
// workaround an upstream Chromium bug. Can be removed in the future.
|
||||
if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass))
|
||||
agent = createProxyAgent(proxy);
|
||||
if (proxy?.server !== 'per-context')
|
||||
agent = createProxyAgent(proxy, requestUrl);
|
||||
|
||||
let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20);
|
||||
maxRedirects = maxRedirects === 0 ? -1 : maxRedirects;
|
||||
|
@ -647,13 +645,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
|
|||
const timeoutSettings = new TimeoutSettings();
|
||||
if (options.timeout !== undefined)
|
||||
timeoutSettings.setDefaultTimeout(options.timeout);
|
||||
const proxy = options.proxy;
|
||||
if (proxy?.server) {
|
||||
let url = proxy?.server.trim();
|
||||
if (!/^\w+:\/\//.test(url))
|
||||
url = 'http://' + url;
|
||||
proxy.server = url;
|
||||
}
|
||||
if (options.storageState) {
|
||||
this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin }));
|
||||
this._cookieStore.addCookies(options.storageState.cookies || []);
|
||||
|
@ -668,7 +659,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
|
|||
maxRedirects: options.maxRedirects,
|
||||
httpCredentials: options.httpCredentials,
|
||||
clientCertificates: options.clientCertificates,
|
||||
proxy,
|
||||
proxy: options.proxy,
|
||||
timeoutSettings,
|
||||
};
|
||||
this._tracing = new Tracing(this, options.tracesDir);
|
||||
|
@ -705,20 +696,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
|
|||
}
|
||||
}
|
||||
|
||||
export function createProxyAgent(proxy: types.ProxySettings) {
|
||||
const proxyOpts = url.parse(proxy.server);
|
||||
if (proxyOpts.protocol?.startsWith('socks')) {
|
||||
return new SocksProxyAgent({
|
||||
host: proxyOpts.hostname,
|
||||
port: proxyOpts.port || undefined,
|
||||
});
|
||||
}
|
||||
if (proxy.username)
|
||||
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
|
||||
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
|
||||
return new HttpsProxyAgent(proxyOpts);
|
||||
}
|
||||
|
||||
function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
|
||||
const result: types.HeadersArray = [];
|
||||
for (let i = 0; i < rawHeaders.length; i += 2)
|
||||
|
@ -791,19 +768,6 @@ function removeHeader(headers: { [name: string]: string }, name: string) {
|
|||
delete headers[name];
|
||||
}
|
||||
|
||||
function shouldBypassProxy(url: URL, bypass?: string): boolean {
|
||||
if (!bypass)
|
||||
return false;
|
||||
const domains = bypass.split(',').map(s => {
|
||||
s = s.trim();
|
||||
if (!s.startsWith('.'))
|
||||
s = '.' + s;
|
||||
return s;
|
||||
});
|
||||
const domain = '.' + url.hostname;
|
||||
return domains.some(d => domain.endsWith(d));
|
||||
}
|
||||
|
||||
function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) {
|
||||
const { username, password } = credentials;
|
||||
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
|
||||
|
|
|
@ -23,7 +23,7 @@ import tls from 'tls';
|
|||
import { SocksProxy } from './utils/socksProxy';
|
||||
import { ManualPromise, escapeHTML, generateSelfSignedCertificate, rewriteErrorMessage } from '../utils';
|
||||
import { verifyClientCertificates } from './browserContext';
|
||||
import { createProxyAgent } from './fetch';
|
||||
import { createProxyAgent } from './utils/network';
|
||||
import { debugLogger } from './utils/debugLogger';
|
||||
import { createSocket, createTLSSocket } from './utils/happyEyeballs';
|
||||
|
||||
|
@ -242,7 +242,7 @@ export class ClientCertificatesProxy {
|
|||
ignoreHTTPSErrors: boolean | undefined;
|
||||
secureContextMap: Map<string, tls.SecureContext> = new Map();
|
||||
alpnCache: ALPNCache;
|
||||
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
|
||||
proxyAgentFromOptions: ReturnType<typeof createProxyAgent>;
|
||||
|
||||
constructor(
|
||||
contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'>
|
||||
|
@ -250,7 +250,7 @@ export class ClientCertificatesProxy {
|
|||
verifyClientCertificates(contextOptions.clientCertificates);
|
||||
this.alpnCache = new ALPNCache();
|
||||
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
|
||||
this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined;
|
||||
this.proxyAgentFromOptions = createProxyAgent(contextOptions.proxy);
|
||||
this._initSecureContexts(contextOptions.clientCertificates);
|
||||
this._socksProxy = new SocksProxy();
|
||||
this._socksProxy.setPattern('*');
|
||||
|
|
|
@ -15,13 +15,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { makeWaitForNextTask } from '../utils';
|
||||
import { createProxyAgent, makeWaitForNextTask } from '../utils';
|
||||
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs';
|
||||
import { ws } from '../utilsBundle';
|
||||
|
||||
import type { WebSocket } from '../utilsBundle';
|
||||
import type { Progress } from './progress';
|
||||
import type { HeadersArray } from './types';
|
||||
import type { HeadersArray, ProxySettings } from './types';
|
||||
import type { ClientRequest, IncomingMessage } from 'http';
|
||||
|
||||
export const perMessageDeflate = {
|
||||
|
@ -60,6 +60,13 @@ export interface ConnectionTransport {
|
|||
onclose?: (reason?: string) => void,
|
||||
}
|
||||
|
||||
type WebSocketTransportOptions = {
|
||||
headers?: { [key: string]: string; };
|
||||
followRedirects?: boolean;
|
||||
debugLogHeader?: string;
|
||||
proxy?: ProxySettings;
|
||||
};
|
||||
|
||||
export class WebSocketTransport implements ConnectionTransport {
|
||||
private _ws: WebSocket;
|
||||
private _progress?: Progress;
|
||||
|
@ -70,14 +77,14 @@ export class WebSocketTransport implements ConnectionTransport {
|
|||
readonly wsEndpoint: string;
|
||||
readonly headers: HeadersArray = [];
|
||||
|
||||
static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string): Promise<WebSocketTransport> {
|
||||
return await WebSocketTransport._connect(progress, url, headers || {}, { follow: !!followRedirects, hadRedirects: false }, debugLogHeader);
|
||||
static async connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions = {}): Promise<WebSocketTransport> {
|
||||
return await WebSocketTransport._connect(progress, url, options, false /* hadRedirects */);
|
||||
}
|
||||
|
||||
static async _connect(progress: (Progress|undefined), url: string, headers: { [key: string]: string; }, redirect: { follow: boolean, hadRedirects: boolean }, debugLogHeader?: string): Promise<WebSocketTransport> {
|
||||
static async _connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions, hadRedirects: boolean): Promise<WebSocketTransport> {
|
||||
const logUrl = stripQueryParams(url);
|
||||
progress?.log(`<ws connecting> ${logUrl}`);
|
||||
const transport = new WebSocketTransport(progress, url, logUrl, headers, redirect.follow && redirect.hadRedirects, debugLogHeader);
|
||||
const transport = new WebSocketTransport(progress, url, logUrl, { ...options, followRedirects: !!options.followRedirects && hadRedirects });
|
||||
let success = false;
|
||||
progress?.cleanupWhenAborted(async () => {
|
||||
if (!success)
|
||||
|
@ -94,13 +101,13 @@ export class WebSocketTransport implements ConnectionTransport {
|
|||
transport._ws.close();
|
||||
});
|
||||
transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => {
|
||||
if (redirect.follow && !redirect.hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) {
|
||||
if (options.followRedirects && !hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) {
|
||||
fulfill({ redirect: response });
|
||||
transport._ws.close();
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < response.rawHeaders.length; i += 2) {
|
||||
if (debugLogHeader && response.rawHeaders[i] === debugLogHeader)
|
||||
if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader)
|
||||
progress?.log(response.rawHeaders[i + 1]);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
|
@ -117,32 +124,34 @@ export class WebSocketTransport implements ConnectionTransport {
|
|||
|
||||
if (result.redirect) {
|
||||
// Strip authorization headers from the redirected request.
|
||||
const newHeaders = Object.fromEntries(Object.entries(headers || {}).filter(([name]) => {
|
||||
const newHeaders = Object.fromEntries(Object.entries(options.headers || {}).filter(([name]) => {
|
||||
return !name.includes('access-key') && name.toLowerCase() !== 'authorization';
|
||||
}));
|
||||
return WebSocketTransport._connect(progress, result.redirect.headers.location!, newHeaders, { follow: true, hadRedirects: true }, debugLogHeader);
|
||||
return WebSocketTransport._connect(progress, result.redirect.headers.location!, { ...options, headers: newHeaders }, true /* hadRedirects */);
|
||||
}
|
||||
|
||||
success = true;
|
||||
return transport;
|
||||
}
|
||||
|
||||
constructor(progress: Progress|undefined, url: string, logUrl: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string) {
|
||||
constructor(progress: Progress|undefined, url: string, logUrl: string, options: WebSocketTransportOptions) {
|
||||
this.wsEndpoint = url;
|
||||
this._logUrl = logUrl;
|
||||
const proxyAgent = createProxyAgent(options.proxy, new URL(url));
|
||||
const happyEyeballsAgent = (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent;
|
||||
this._ws = new ws(url, [], {
|
||||
maxPayload: 256 * 1024 * 1024, // 256Mb,
|
||||
// Prevent internal http client error when passing negative timeout.
|
||||
handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1),
|
||||
headers,
|
||||
followRedirects,
|
||||
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
|
||||
headers: options.headers,
|
||||
followRedirects: options.followRedirects,
|
||||
agent: proxyAgent || happyEyeballsAgent,
|
||||
perMessageDeflate,
|
||||
});
|
||||
this._ws.on('upgrade', response => {
|
||||
for (let i = 0; i < response.rawHeaders.length; i += 2) {
|
||||
this.headers.push({ name: response.rawHeaders[i], value: response.rawHeaders[i + 1] });
|
||||
if (debugLogHeader && response.rawHeaders[i] === debugLogHeader)
|
||||
if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader)
|
||||
progress?.log(response.rawHeaders[i + 1]);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -20,10 +20,11 @@ import http2 from 'http2';
|
|||
import https from 'https';
|
||||
import url from 'url';
|
||||
|
||||
import { HttpsProxyAgent, getProxyForUrl } from '../../utilsBundle';
|
||||
import { HttpsProxyAgent, SocksProxyAgent, getProxyForUrl } from '../../utilsBundle';
|
||||
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs';
|
||||
|
||||
import type net from 'net';
|
||||
import type { ProxySettings } from '../types';
|
||||
|
||||
export type HTTPRequestParams = {
|
||||
url: string,
|
||||
|
@ -32,6 +33,7 @@ export type HTTPRequestParams = {
|
|||
data?: string | Buffer,
|
||||
timeout?: number,
|
||||
rejectUnauthorized?: boolean,
|
||||
proxy?: ProxySettings,
|
||||
};
|
||||
|
||||
export const NET_DEFAULT_TIMEOUT = 30_000;
|
||||
|
@ -49,22 +51,26 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco
|
|||
|
||||
const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT;
|
||||
|
||||
const proxyURL = getProxyForUrl(params.url);
|
||||
if (proxyURL) {
|
||||
const parsedProxyURL = url.parse(proxyURL);
|
||||
if (params.url.startsWith('http:')) {
|
||||
options = {
|
||||
path: parsedUrl.href,
|
||||
host: parsedProxyURL.hostname,
|
||||
port: parsedProxyURL.port,
|
||||
headers: options.headers,
|
||||
method: options.method
|
||||
};
|
||||
} else {
|
||||
(parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:';
|
||||
if (params.proxy) {
|
||||
options.agent = createProxyAgent(params.proxy, new URL(params.url));
|
||||
} else {
|
||||
const proxyURL = getProxyForUrl(params.url);
|
||||
if (proxyURL) {
|
||||
const parsedProxyURL = url.parse(proxyURL);
|
||||
if (params.url.startsWith('http:')) {
|
||||
options = {
|
||||
path: parsedUrl.href,
|
||||
host: parsedProxyURL.hostname,
|
||||
port: parsedProxyURL.port,
|
||||
headers: options.headers,
|
||||
method: options.method
|
||||
};
|
||||
} else {
|
||||
(parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:';
|
||||
|
||||
options.agent = new HttpsProxyAgent(parsedProxyURL);
|
||||
options.rejectUnauthorized = false;
|
||||
options.agent = new HttpsProxyAgent(parsedProxyURL);
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,6 +115,47 @@ export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequ
|
|||
});
|
||||
}
|
||||
|
||||
function shouldBypassProxy(url: URL, bypass?: string): boolean {
|
||||
if (!bypass)
|
||||
return false;
|
||||
const domains = bypass.split(',').map(s => {
|
||||
s = s.trim();
|
||||
if (!s.startsWith('.'))
|
||||
s = '.' + s;
|
||||
return s;
|
||||
});
|
||||
const domain = '.' + url.hostname;
|
||||
return domains.some(d => domain.endsWith(d));
|
||||
}
|
||||
|
||||
export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) {
|
||||
if (!proxy)
|
||||
return;
|
||||
if (forUrl && proxy.bypass && shouldBypassProxy(forUrl, proxy.bypass))
|
||||
return;
|
||||
|
||||
// Browsers allow to specify proxy without a protocol, defaulting to http.
|
||||
let proxyServer = proxy.server.trim();
|
||||
if (!/^\w+:\/\//.test(proxyServer))
|
||||
proxyServer = 'http://' + proxyServer;
|
||||
|
||||
const proxyOpts = url.parse(proxyServer);
|
||||
if (proxyOpts.protocol?.startsWith('socks')) {
|
||||
return new SocksProxyAgent({
|
||||
host: proxyOpts.hostname,
|
||||
port: proxyOpts.port || undefined,
|
||||
});
|
||||
}
|
||||
if (proxy.username)
|
||||
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
|
||||
if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) {
|
||||
// Force CONNECT method for WebSockets.
|
||||
return new HttpsProxyAgent(proxyOpts);
|
||||
}
|
||||
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
|
||||
return new HttpsProxyAgent(proxyOpts);
|
||||
}
|
||||
|
||||
export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server;
|
||||
export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server;
|
||||
export function createHttpServer(...args: any[]): http.Server {
|
||||
|
|
|
@ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions {
|
|||
*/
|
||||
logger?: Logger;
|
||||
|
||||
/**
|
||||
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
|
||||
* by the browser to load web pages.
|
||||
*/
|
||||
proxy?: {
|
||||
/**
|
||||
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
|
||||
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
|
||||
* proxy.
|
||||
*/
|
||||
server: string;
|
||||
|
||||
/**
|
||||
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
*/
|
||||
bypass?: string;
|
||||
|
||||
/**
|
||||
* Optional username to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Optional password to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
password?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
|
||||
* on. Defaults to 0.
|
||||
|
@ -21834,6 +21862,34 @@ export interface ConnectOptions {
|
|||
*/
|
||||
logger?: Logger;
|
||||
|
||||
/**
|
||||
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
|
||||
* by the browser to load web pages.
|
||||
*/
|
||||
proxy?: {
|
||||
/**
|
||||
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
|
||||
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
|
||||
* proxy.
|
||||
*/
|
||||
server: string;
|
||||
|
||||
/**
|
||||
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
|
||||
*/
|
||||
bypass?: string;
|
||||
|
||||
/**
|
||||
* Optional username to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Optional password to use if HTTP proxy requires authentication.
|
||||
*/
|
||||
password?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
|
||||
* on. Defaults to 0.
|
||||
|
|
|
@ -540,6 +540,12 @@ export type LocalUtilsConnectParams = {
|
|||
exposeNetwork?: string,
|
||||
slowMo?: number,
|
||||
timeout?: number,
|
||||
proxy?: {
|
||||
server: string,
|
||||
bypass?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
},
|
||||
socksProxyRedirectPortForTest?: number,
|
||||
};
|
||||
export type LocalUtilsConnectOptions = {
|
||||
|
@ -547,6 +553,12 @@ export type LocalUtilsConnectOptions = {
|
|||
exposeNetwork?: string,
|
||||
slowMo?: number,
|
||||
timeout?: number,
|
||||
proxy?: {
|
||||
server: string,
|
||||
bypass?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
},
|
||||
socksProxyRedirectPortForTest?: number,
|
||||
};
|
||||
export type LocalUtilsConnectResult = {
|
||||
|
@ -1165,11 +1177,23 @@ export type BrowserTypeConnectOverCDPParams = {
|
|||
headers?: NameValue[],
|
||||
slowMo?: number,
|
||||
timeout?: number,
|
||||
proxy?: {
|
||||
server: string,
|
||||
bypass?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
},
|
||||
};
|
||||
export type BrowserTypeConnectOverCDPOptions = {
|
||||
headers?: NameValue[],
|
||||
slowMo?: number,
|
||||
timeout?: number,
|
||||
proxy?: {
|
||||
server: string,
|
||||
bypass?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
},
|
||||
};
|
||||
export type BrowserTypeConnectOverCDPResult = {
|
||||
browser: BrowserChannel,
|
||||
|
|
|
@ -702,6 +702,13 @@ LocalUtils:
|
|||
exposeNetwork: string?
|
||||
slowMo: number?
|
||||
timeout: number?
|
||||
proxy:
|
||||
type: object?
|
||||
properties:
|
||||
server: string
|
||||
bypass: string?
|
||||
username: string?
|
||||
password: string?
|
||||
socksProxyRedirectPortForTest: number?
|
||||
returns:
|
||||
pipe: JsonPipe
|
||||
|
@ -1010,6 +1017,13 @@ BrowserType:
|
|||
items: NameValue
|
||||
slowMo: number?
|
||||
timeout: number?
|
||||
proxy:
|
||||
type: object?
|
||||
properties:
|
||||
server: string
|
||||
bypass: string?
|
||||
username: string?
|
||||
password: string?
|
||||
returns:
|
||||
browser: Browser
|
||||
defaultContext: BrowserContext?
|
||||
|
|
|
@ -765,6 +765,24 @@ for (const kind of ['launchServer', 'run-server'] as const) {
|
|||
await browser.close();
|
||||
});
|
||||
|
||||
test('should connect over http proxy', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33894' },
|
||||
}, async ({ connect, startRemoteServer, proxyServer }) => {
|
||||
const remoteServer = await startRemoteServer(kind);
|
||||
const url = new URL(remoteServer.wsEndpoint());
|
||||
proxyServer.forwardTo(+url.port, { allowConnectRequests: true });
|
||||
const browser = await connect(`http://some.random.host.does.not.exist:1337`, {
|
||||
proxy: { server: `localhost:${proxyServer.PORT}` },
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
expect(await page.evaluate('11 * 11')).toBe(121);
|
||||
await browser.close();
|
||||
// We should "CONNECT" twice:
|
||||
// - to convert http url into ws url
|
||||
// - actually connect to the ws endpoint
|
||||
expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']);
|
||||
});
|
||||
|
||||
test.describe('socks proxy', () => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
test.skip(kind === 'launchServer', 'not supported yet');
|
||||
|
|
|
@ -446,6 +446,32 @@ test('should be able to connect via localhost', async ({ browserType }, testInfo
|
|||
}
|
||||
});
|
||||
|
||||
test('should be able to connect over http proxy', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35206' },
|
||||
}, async ({ browserType, proxyServer }, testInfo) => {
|
||||
const port = 9339 + testInfo.workerIndex;
|
||||
const browserServer = await browserType.launch({
|
||||
args: ['--remote-debugging-port=' + port]
|
||||
});
|
||||
proxyServer.forwardTo(port, { allowConnectRequests: true });
|
||||
try {
|
||||
const cdpBrowser = await browserType.connectOverCDP(`http://some.random.host.does.not.exist:1337`, {
|
||||
proxy: { server: `localhost:${proxyServer.PORT}` },
|
||||
});
|
||||
const contexts = cdpBrowser.contexts();
|
||||
expect(contexts.length).toBe(1);
|
||||
const page = await contexts[0].newPage();
|
||||
expect(await page.evaluate('11 * 11')).toBe(121);
|
||||
await cdpBrowser.close();
|
||||
// We should "CONNECT" twice:
|
||||
// - to convert http url into ws url
|
||||
// - actually connect to the ws endpoint
|
||||
expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']);
|
||||
} finally {
|
||||
await browserServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('emulate media should not be affected by second connectOverCDP', async ({ browserType }, testInfo) => {
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24109' });
|
||||
test.fixme();
|
||||
|
|
|
@ -323,7 +323,7 @@ it('should use SOCKS proxy for websocket requests', async ({ browserType, server
|
|||
await closeProxyServer();
|
||||
});
|
||||
|
||||
it('should use http proxy for websocket requests', async ({ browserName, browserType, server, proxyServer }) => {
|
||||
it('should use http proxy for websocket requests', async ({ isWindows, browserName, browserType, server, proxyServer }) => {
|
||||
proxyServer.forwardTo(server.PORT, { allowConnectRequests: true });
|
||||
const browser = await browserType.launch({
|
||||
proxy: { server: `localhost:${proxyServer.PORT}` }
|
||||
|
@ -350,7 +350,7 @@ it('should use http proxy for websocket requests', async ({ browserName, browser
|
|||
|
||||
// WebKit does not use CONNECT for websockets, but other browsers do.
|
||||
if (browserName === 'webkit')
|
||||
expect(proxyServer.wsUrls).toContain('ws://fake-localhost-127-0-0-1.nip.io:1337/ws');
|
||||
expect(proxyServer.wsUrls).toContain(isWindows ? '/ws' : 'ws://fake-localhost-127-0-0-1.nip.io:1337/ws');
|
||||
else
|
||||
expect(proxyServer.connectHosts).toContain('fake-localhost-127-0-0-1.nip.io:1337');
|
||||
|
||||
|
|
Loading…
Reference in New Issue