feat(tether): support hostname pattern in socks proxy (#19302)

For now, only '*' and 'localhost' are supported.

References #19287.
This commit is contained in:
Dmitry Gozman 2022-12-07 08:46:35 -08:00 committed by GitHub
parent cd4ccdfa29
commit fd22d8bde1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 92 additions and 9 deletions

View file

@ -297,6 +297,8 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
private _sockets = new Set<net.Socket>();
private _closed = false;
private _port: number | undefined;
private _pattern: string | undefined;
private _directSockets = new Map<string, net.Socket>();
constructor() {
super();
@ -315,6 +317,37 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
});
}
setProxyPattern(pattern: string | undefined) {
this._pattern = pattern;
}
private _matchesPattern(request: SocksSocketRequestedPayload) {
return this._pattern === '*' || (this._pattern === 'localhost' && request.host === 'localhost');
}
private async _handleDirect(request: SocksSocketRequestedPayload) {
try {
// TODO: Node.js 17 does resolve localhost to ipv6
const { address } = await dnsLookupAsync(request.host === 'localhost' ? '127.0.0.1' : request.host);
const socket = await createSocket(address, request.port);
socket.on('data', data => this._connections.get(request.uid)?.sendData(data));
socket.on('error', error => {
this._connections.get(request.uid)?.error(error.message);
this._directSockets.delete(request.uid);
});
socket.on('end', () => {
this._connections.get(request.uid)?.end();
this._directSockets.delete(request.uid);
});
const localAddress = socket.localAddress;
const localPort = socket.localPort;
this._directSockets.set(request.uid, socket);
this._connections.get(request.uid)?.socketConnected(localAddress, localPort);
} catch (error) {
this._connections.get(request.uid)?.socketFailed(error.code);
}
}
port() {
return this._port;
}
@ -339,14 +372,29 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
}
onSocketRequested(payload: SocksSocketRequestedPayload) {
if (!this._matchesPattern(payload)) {
this._handleDirect(payload);
return;
}
this.emit(SocksProxy.Events.SocksRequested, payload);
}
onSocketData(payload: SocksSocketDataPayload): void {
const direct = this._directSockets.get(payload.uid);
if (direct) {
direct.write(payload.data);
return;
}
this.emit(SocksProxy.Events.SocksData, payload);
}
onSocketClosed(payload: SocksSocketClosedPayload): void {
const direct = this._directSockets.get(payload.uid);
if (direct) {
direct.destroy();
this._directSockets.delete(payload.uid);
return;
}
this.emit(SocksProxy.Events.SocksClosed, payload);
}

View file

@ -299,6 +299,7 @@ async function tetherHostNetwork(endpoint: string) {
const headers: any = {
'User-Agent': getUserAgent(),
'x-playwright-network-tethering': '1',
'x-playwright-proxy': '*',
};
const transport = await WebSocketTransport.connect(undefined /* progress */, wsEndpoint, headers, true /* followRedirects */);
const socksInterceptor = new SocksInterceptor(transport, undefined);

View file

@ -23,7 +23,7 @@ function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: str
const log = debug(`pw:grid:worker:${workerId}`);
log('created');
const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`);
new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { enableSocksProxy: true, browserName, launchOptions: {} }, { }, log, async () => {
new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { socksProxy: '*', browserName, launchOptions: {} }, { }, log, async () => {
log('exiting process');
setTimeout(() => process.exit(0), 30000);
// Meanwhile, try to gracefully close all browsers.

View file

@ -28,7 +28,7 @@ import { DebugControllerDispatcher } from '../server/dispatchers/debugController
export type ClientType = 'controller' | 'playwright' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser' | 'network-tethering';
type Options = {
enableSocksProxy: boolean,
socksProxy: string | undefined,
browserName: string | null,
launchOptions: LaunchOptions,
};
@ -99,6 +99,7 @@ export class PlaywrightConnection {
private async _initPlaywrightTetheringMode(scope: RootDispatcher) {
this._debugLog(`engaged playwright.tethering mode`);
const playwright = createPlaywright('javascript');
this._preLaunched.networkTetheringSocksProxy?.setProxyPattern(this._options.socksProxy);
return new PlaywrightDispatcher(scope, playwright, this._preLaunched.networkTetheringSocksProxy);
}
@ -221,7 +222,7 @@ export class PlaywrightConnection {
}
private async _configureSocksProxy(playwright: Playwright): Promise<undefined|SocksProxy> {
if (!this._options.enableSocksProxy)
if (!this._options.socksProxy)
return undefined;
if (this._preLaunched.networkTetheringSocksProxy) {
playwright.options.socksProxyPort = this._preLaunched.networkTetheringSocksProxy.port();
@ -229,6 +230,7 @@ export class PlaywrightConnection {
return undefined;
}
const socksProxy = new SocksProxy();
socksProxy.setProxyPattern(this._options.socksProxy);
playwright.options.socksProxyPort = await socksProxy.listen(0);
this._debugLog(`started socks proxy on port ${playwright.options.socksProxyPort}`);
this._cleanups.push(() => socksProxy.close());

View file

@ -115,7 +115,7 @@ export class PlaywrightServer {
const browserName = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null;
const proxyHeader = request.headers['x-playwright-proxy'];
const proxyValue = url.searchParams.get('proxy') || (Array.isArray(proxyHeader) ? proxyHeader[0] : proxyHeader);
const enableSocksProxy = this._options.browserProxyMode !== 'disabled' && proxyValue === '*';
const socksProxy = this._options.browserProxyMode !== 'disabled' ? proxyValue : undefined;
const launchOptionsHeader = request.headers['x-playwright-launch-options'] || '';
let launchOptions: LaunchOptions = {};
@ -160,7 +160,7 @@ export class PlaywrightServer {
const connection = new PlaywrightConnection(
semaphore.aquire(),
clientType, ws,
{ enableSocksProxy, browserName, launchOptions },
{ socksProxy, browserName, launchOptions },
{
playwright: this._preLaunchedPlaywright,
browser: this._options.preLaunchedBrowser,

View file

@ -58,15 +58,15 @@ class OutOfProcessPlaywrightServer {
}
}
const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number) => Promise<Page> }>({
const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number, pattern?: string) => Promise<Page> }>({
pageFactory: async ({ browserType, browserName, channel }, run, testInfo) => {
const playwrightServers: OutOfProcessPlaywrightServer[] = [];
const browsers: Browser[] = [];
await run(async (redirectPortForTest?: number): Promise<Page> => {
await run(async (redirectPortForTest?: number, pattern = '*'): Promise<Page> => {
const server = new OutOfProcessPlaywrightServer(0, 3200 + testInfo.workerIndex);
playwrightServers.push(server);
const browser = await browserType.connect({
wsEndpoint: await server.wsEndpoint() + '?proxy=*&browser=' + browserName,
wsEndpoint: await server.wsEndpoint() + `?proxy=${pattern}&browser=` + browserName,
headers: { 'x-playwright-launch-options': JSON.stringify({ channel }) },
__testHookRedirectPortForwarding: redirectPortForTest,
} as any);
@ -166,5 +166,37 @@ it('should lead to the error page for forwarded requests when the connection is
else if (browserName === 'webkit')
expect(error.message).toBeTruthy();
else if (browserName === 'firefox')
expect(error.message.includes('NS_ERROR_NET_RESET') || error.message.includes('NS_ERROR_CONNECTION_REFUSED')).toBe(true);
expect(error.message.includes('NS_ERROR_NET_RESET') || error.message.includes('NS_ERROR_CONNECTION_REFUSED')).toBe(true);
});
it('should proxy based on the pattern', async ({ pageFactory, server, browserName, platform }, workerInfo) => {
it.skip(browserName === 'webkit' && platform === 'darwin');
const { testServerPort, stopTestServer } = await startTestServer();
let reachedOriginalTarget = false;
server.setRoute('/foo.html', async (req, res) => {
reachedOriginalTarget = true;
res.end('<html><body>from-original-server</body></html>');
});
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const page = await pageFactory(testServerPort, 'localhost');
// localhost should be proxied.
await page.goto(`http://localhost:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-retargeted-server');
expect(reachedOriginalTarget).toBe(false);
// 127.0.0.1 should be served directly.
await page.goto(`http://127.0.0.1:${server.PORT}/foo.html`);
expect(await page.content()).toContain('from-original-server');
expect(reachedOriginalTarget).toBe(true);
// Random domain should be served directly and fail.
let failed = false;
await page.goto(`http://does-not-exist-bad-domain.oh-no-should-not-work`).catch(e => {
failed = true;
});
expect(failed).toBe(true);
stopTestServer();
});