feat(connect): allow exposing local network to the remote browser (experimental) (#19372)

`connectOptions: { _exposeNetwork: '*' | 'localhost' }`

References #19287.
This commit is contained in:
Dmitry Gozman 2022-12-09 11:16:29 -08:00 committed by GitHub
parent 8ea48752db
commit 256e9fd443
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 85 additions and 47 deletions

View File

@ -69,7 +69,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
async launch(options: LaunchOptions = {}): Promise<Browser> {
if (this._defaultConnectOptions)
return await this._connectInsteadOfLaunching();
return await this._connectInsteadOfLaunching(this._defaultConnectOptions);
const logger = options.logger || this._defaultLaunchOptions?.logger;
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
@ -89,14 +89,15 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
});
}
private async _connectInsteadOfLaunching(): Promise<Browser> {
const connectOptions = this._defaultConnectOptions!;
return this._connect(connectOptions.wsEndpoint, {
private async _connectInsteadOfLaunching(connectOptions: ConnectOptions): Promise<Browser> {
return this._connect({
wsEndpoint: connectOptions.wsEndpoint,
headers: {
'x-playwright-browser': this.name(),
'x-playwright-launch-options': JSON.stringify(this._defaultLaunchOptions || {}),
...connectOptions.headers,
},
_exposeNetwork: connectOptions._exposeNetwork,
slowMo: connectOptions.slowMo,
timeout: connectOptions.timeout ?? 3 * 60 * 1000, // 3 minutes
});
}
@ -132,22 +133,28 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
});
}
connect(options: api.ConnectOptions & { wsEndpoint?: string }): Promise<api.Browser>;
connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise<api.Browser>;
connect(wsEndpoint: string, options?: api.ConnectOptions): Promise<api.Browser>;
async connect(optionsOrWsEndpoint: string|(api.ConnectOptions & { wsEndpoint?: string }), options?: api.ConnectOptions): Promise<Browser>{
async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise<Browser>{
if (typeof optionsOrWsEndpoint === 'string')
return this._connect(optionsOrWsEndpoint, options);
return this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint });
assert(optionsOrWsEndpoint.wsEndpoint, 'options.wsEndpoint is required');
return this._connect(optionsOrWsEndpoint.wsEndpoint, optionsOrWsEndpoint);
return this._connect(optionsOrWsEndpoint);
}
async _connect(wsEndpoint: string, params: Partial<ConnectOptions> = {}): Promise<Browser> {
async _connect(params: ConnectOptions): Promise<Browser> {
const logger = params.logger;
return await this._wrapApiCall(async () => {
const deadline = params.timeout ? monotonicTime() + params.timeout : 0;
const headers = { 'x-playwright-browser': this.name(), ...params.headers };
const localUtils = this._connection.localUtils();
const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: params.slowMo, timeout: params.timeout };
const connectParams: channels.LocalUtilsConnectParams = {
wsEndpoint: params.wsEndpoint,
headers,
exposeNetwork: params._exposeNetwork,
slowMo: params.slowMo,
timeout: params.timeout,
};
if ((params as any).__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
const { pipe } = await localUtils._channel.connect(connectParams);

View File

@ -87,6 +87,7 @@ export type LaunchPersistentContextOptions = Omit<LaunchOptionsBase & BrowserCon
export type ConnectOptions = {
wsEndpoint: string,
headers?: { [key: string]: string; };
_exposeNetwork?: string,
slowMo?: number,
timeout?: number,
logger?: Logger,

View File

@ -431,19 +431,31 @@ export class SocksProxyHandler extends EventEmitter {
};
private _sockets = new Map<string, net.Socket>();
private _pattern: string | undefined;
private _redirectPortForTest: number | undefined;
constructor(redirectPortForTest?: number) {
constructor(pattern: string | undefined, redirectPortForTest?: number) {
super();
this._pattern = pattern;
this._redirectPortForTest = redirectPortForTest;
}
private _matchesPattern(host: string, port: number) {
return this._pattern === '*' || (this._pattern === 'localhost' && host === 'localhost');
}
cleanup() {
for (const uid of this._sockets.keys())
this.socketClosed({ uid });
}
async socketRequested({ uid, host, port }: SocksSocketRequestedPayload): Promise<void> {
if (!this._matchesPattern(host, port)) {
const payload: SocksSocketFailedPayload = { uid, errorCode: 'ECONNREFUSED' };
this.emit(SocksProxyHandler.Events.SocksFailed, payload);
return;
}
if (host === 'local.playwright')
host = '127.0.0.1';
// Node.js 17 does resolve localhost to ipv6

View File

@ -288,7 +288,7 @@ async function tetherHostNetwork(endpoint: string) {
'x-playwright-proxy': '*',
};
const transport = await WebSocketTransport.connect(undefined /* progress */, wsEndpoint, headers, true /* followRedirects */);
const socksInterceptor = new SocksInterceptor(transport, undefined);
const socksInterceptor = new SocksInterceptor(transport, '*', undefined);
transport.onmessage = json => socksInterceptor.interceptMessage(json);
transport.onclose = () => {
socksInterceptor.cleanup();
@ -387,7 +387,7 @@ export function addDockerCLI(program: Command) {
.option('--browser <name>', 'browser to launch')
.option('--endpoint <url>', 'server endpoint')
.action(async function(options: { browser: string, endpoint: string }) {
let browserType: any;
let browserType: playwright.BrowserType | undefined;
if (options.browser === 'chromium')
browserType = playwright.chromium;
else if (options.browser === 'firefox')
@ -404,9 +404,9 @@ export function addDockerCLI(program: Command) {
headless: false,
viewport: null,
}),
'x-playwright-proxy': '*',
},
});
_exposeNetwork: '*',
} as any);
const context = await browser.newContext();
context.on('page', (page: playwright.Page) => {
page.on('dialog', () => {}); // Prevent dialogs from being automatically dismissed.

View File

@ -248,6 +248,7 @@ scheme.LocalUtilsHarUnzipResult = tOptional(tObject({}));
scheme.LocalUtilsConnectParams = tObject({
wsEndpoint: tString,
headers: tOptional(tAny),
exposeNetwork: tOptional(tString),
slowMo: tOptional(tNumber),
timeout: tOptional(tNumber),
socksProxyRedirectPortForTest: tOptional(tNumber),

View File

@ -156,11 +156,15 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
const controller = new ProgressController(metadata, this._object as SdkObject);
controller.setLogName('browser');
return await controller.run(async progress => {
const paramsHeaders = Object.assign({ 'User-Agent': getUserAgent() }, params.headers || {});
const wsHeaders = {
'User-Agent': getUserAgent(),
'x-playwright-proxy': params.exposeNetwork ?? '',
...params.headers,
};
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
const transport = await WebSocketTransport.connect(progress, wsEndpoint, paramsHeaders, true);
const socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest);
const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true);
const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest);
const pipe = new JsonPipeDispatcher(this);
transport.onmessage = json => {
if (socksInterceptor.interceptMessage(json))

View File

@ -27,8 +27,8 @@ export class SocksInterceptor {
private _socksSupportObjectGuid?: string;
private _ids = new Set<number>();
constructor(transport: WebSocketTransport, redirectPortForTest: number | undefined) {
this._handler = new socks.SocksProxyHandler(redirectPortForTest);
constructor(transport: WebSocketTransport, pattern: string | undefined, redirectPortForTest: number | undefined) {
this._handler = new socks.SocksProxyHandler(pattern, redirectPortForTest);
let lastId = -1;
this._channel = new Proxy(new EventEmitter(), {

View File

@ -87,8 +87,9 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}
return use({
wsEndpoint,
headers
});
headers,
_exposeNetwork: process.env.PW_TEST_CONNECT_EXPOSE_NETWORK,
} as any);
}, { scope: 'worker', option: true }],
screenshot: ['off', { scope: 'worker', option: true }],
video: ['off', { scope: 'worker', option: true }],

View File

@ -35,9 +35,7 @@ export const dockerPlugin: TestRunnerPlugin = {
throw new Error('ERROR: please launch docker container separately!');
println('');
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.httpEndpoint;
process.env.PW_TEST_CONNECT_HEADERS = JSON.stringify({
'x-playwright-proxy': '*',
});
process.env.PW_TEST_CONNECT_EXPOSE_NETWORK = '*';
},
};

View File

@ -446,12 +446,14 @@ export type LocalUtilsHarUnzipResult = void;
export type LocalUtilsConnectParams = {
wsEndpoint: string,
headers?: any,
exposeNetwork?: string,
slowMo?: number,
timeout?: number,
socksProxyRedirectPortForTest?: number,
};
export type LocalUtilsConnectOptions = {
headers?: any,
exposeNetwork?: string,
slowMo?: number,
timeout?: number,
socksProxyRedirectPortForTest?: number,

View File

@ -535,6 +535,7 @@ LocalUtils:
parameters:
wsEndpoint: string
headers: json?
exposeNetwork: string?
slowMo: number?
timeout: number?
socksProxyRedirectPortForTest: number?

View File

@ -15,7 +15,7 @@
*/
import path from 'path';
import type { BrowserType, Browser, LaunchOptions } from 'playwright-core';
import type { BrowserType, Browser } from 'playwright-core';
import type { CommonFixtures, TestChildProcess } from './commonFixtures';
export interface PlaywrightServer {
@ -79,7 +79,7 @@ export class RemoteServer implements PlaywrightServer {
const browserOptions = (browserType as any)._defaultLaunchOptions;
// Copy options to prevent a large JSON string when launching subprocess.
// Otherwise, we get `Error: spawn ENAMETOOLONG` on Windows.
const launchOptions: LaunchOptions = {
const launchOptions: Parameters<BrowserType['launchServer']>[0] = {
args: browserOptions.args,
headless: browserOptions.headless,
channel: browserOptions.channel,

View File

@ -679,7 +679,6 @@ for (const kind of ['launchServer', 'run-server'] as const) {
test.describe('socks proxy', () => {
test.fixme(({ platform, browserName }) => browserName === 'webkit' && platform === 'win32');
test.skip(({ mode }) => mode !== 'default');
test.skip(kind === 'launchServer', 'This feature is not yet supported in launchServer');
test('should forward non-forwarded requests', async ({ server, startRemoteServer, connect }) => {
let reachedOriginalTarget = false;
@ -688,9 +687,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
res.end('<html><body>original-target</body></html>');
});
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': '*' }
});
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any);
const page = await browser.newPage();
await page.goto(server.PREFIX + '/foo.html');
expect(await page.content()).toContain('original-target');
@ -707,9 +704,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
});
const examplePort = 20_000 + testInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': '*' }
}, dummyServerPort);
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort);
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-dummy-server');
@ -726,9 +721,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
});
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': '*' }
}, dummyServerPort);
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort);
const page = await browser.newPage();
const response = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`);
expect(response.status()).toBe(200);
@ -744,9 +737,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
});
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': '*' }
}, dummyServerPort);
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort);
const page = await browser.newPage();
await page.goto(`http://local.playwright:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-dummy-server');
@ -756,9 +747,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
test('should lead to the error page for forwarded requests when the connection is refused', async ({ connect, startRemoteServer, browserName }, workerInfo) => {
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': '*' }
});
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any);
const page = await browser.newPage();
const error = await page.goto(`http://127.0.0.1:${examplePort}`).catch(e => e);
if (browserName === 'chromium')
@ -779,9 +768,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
});
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
headers: { 'x-playwright-proxy': 'localhost' }
}, dummyServerPort);
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: 'localhost' } as any, dummyServerPort);
const page = await browser.newPage();
// localhost should be proxied.
@ -801,6 +788,30 @@ for (const kind of ['launchServer', 'run-server'] as const) {
});
expect(failed).toBe(true);
});
test('should check proxy pattern on the client', async ({ connect, startRemoteServer, server, browserName, platform, dummyServerPort }, workerInfo) => {
let reachedOriginalTarget = false;
server.setRoute('/foo.html', async (req, res) => {
reachedOriginalTarget = true;
res.end('<html><body>from-original-server</body></html>');
});
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), {
_exposeNetwork: 'localhost',
headers: {
'x-playwright-proxy': '*',
},
} as any, dummyServerPort);
const page = await browser.newPage();
// 127.0.0.1 should fail on the client side.
let failed = false;
await page.goto(`http://127.0.0.1:${server.PORT}/foo.html`).catch(e => {
failed = true;
});
expect(failed).toBe(true);
expect(reachedOriginalTarget).toBe(false);
});
});
});
}