From 44887c237d40cd68e754a36851949dd3d7a1e7c1 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 19 Aug 2021 15:16:46 -0700 Subject: [PATCH] chore: reimplement socks to be readable (#8315) --- src/browserServerImpl.ts | 8 +- src/cli/cli.ts | 2 +- src/cli/driver.ts | 9 +- src/client/browserType.ts | 8 - src/client/connection.ts | 4 - src/client/playwright.ts | 56 +++- src/client/socksSocket.ts | 71 ----- src/client/types.ts | 2 - src/dispatchers/playwrightDispatcher.ts | 84 +++++- src/dispatchers/socksSocketDispatcher.ts | 47 ---- src/protocol/channels.ts | 102 +++---- src/protocol/protocol.yml | 69 ++--- src/protocol/validator.ts | 29 +- src/remote/playwrightClient.ts | 5 +- src/remote/playwrightServer.ts | 16 +- src/server/browser.ts | 1 - src/server/browserType.ts | 10 +- src/server/chromium/chromium.ts | 4 +- src/server/playwright.ts | 25 -- src/server/socksServer.ts | 323 ----------------------- src/server/socksSocket.ts | 71 ----- src/utils/netUtils.ts | 25 ++ src/utils/socksProxy.ts | 270 +++++++++++++++++++ tests/port-forwarding-server.spec.ts | 130 +++++++++ tests/portForwardingServer.spec.ts | 203 -------------- 25 files changed, 680 insertions(+), 894 deletions(-) delete mode 100644 src/client/socksSocket.ts delete mode 100644 src/dispatchers/socksSocketDispatcher.ts delete mode 100644 src/server/socksServer.ts delete mode 100644 src/server/socksSocket.ts create mode 100644 src/utils/netUtils.ts create mode 100644 src/utils/socksProxy.ts create mode 100644 tests/port-forwarding-server.spec.ts delete mode 100644 tests/portForwardingServer.spec.ts diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index 54aa1b30f1..5741357207 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -44,8 +44,6 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { async launchServer(options: LaunchServerOptions = {}): Promise { const playwright = createPlaywright(); - if (options._acceptForwardedPorts) - await playwright._enablePortForwarding(); // 1. Pre-launch the browser const browser = await playwright[this._browserName].launch(internalCallMetadata(), { ...options, @@ -57,10 +55,8 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { // 2. Start the server const delegate: PlaywrightServerDelegate = { path: '/' + createGuid(), - allowMultipleClients: options._acceptForwardedPorts ? false : true, - onClose: () => { - playwright._disablePortForwarding(); - }, + allowMultipleClients: true, + onClose: () => {}, onConnect: this._onConnect.bind(this, playwright, browser), }; const server = new PlaywrightServer(delegate); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 82b44d3cea..b159e2f33d 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -250,7 +250,7 @@ if (!process.env.PW_CLI_TARGET_LANG) { if (process.argv[2] === 'run-driver') runDriver(); else if (process.argv[2] === 'run-server') - runServer(process.argv[3] ? +process.argv[3] : undefined, process.argv[4]).catch(logErrorAndExit); + runServer(process.argv[3] ? +process.argv[3] : undefined).catch(logErrorAndExit); else if (process.argv[2] === 'print-api-json') printApiJson(); else if (process.argv[2] === 'launch-server') diff --git a/src/cli/driver.ts b/src/cli/driver.ts index 0b8eec62ee..71145dfd8b 100644 --- a/src/cli/driver.ts +++ b/src/cli/driver.ts @@ -23,7 +23,7 @@ import { LaunchServerOptions } from '../client/types'; import { DispatcherConnection, Root } from '../dispatchers/dispatcher'; import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher'; import { Transport } from '../protocol/transport'; -import { PlaywrightServer, PlaywrightServerOptions } from '../remote/playwrightServer'; +import { PlaywrightServer } from '../remote/playwrightServer'; import { createPlaywright } from '../server/playwright'; import { gracefullyCloseAll } from '../utils/processLauncher'; @@ -52,11 +52,8 @@ export function runDriver() { }; } -export async function runServer(port: number | undefined, configFile?: string) { - let options: PlaywrightServerOptions = {}; - if (configFile) - options = JSON.parse(fs.readFileSync(configFile).toString()); - const server = await PlaywrightServer.startDefault(options); +export async function runServer(port: number | undefined) { + const server = await PlaywrightServer.startDefault(); const wsEndpoint = await server.listen(port); process.on('exit', () => server.close().catch(console.error)); console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 17282dfc66..f062263ea0 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -210,14 +210,6 @@ export class BrowserType extends ChannelOwner { diff --git a/src/client/connection.ts b/src/client/connection.ts index 0311c0c3fd..455c537cfa 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -35,7 +35,6 @@ import { Stream } from './stream'; import { debugLogger } from '../utils/debugLogger'; import { SelectorsOwner } from './selectors'; import { Android, AndroidSocket, AndroidDevice } from './android'; -import { SocksSocket } from './socksSocket'; import { ParsedStackTrace } from '../utils/stackTrace'; import { Artifact } from './artifact'; import { EventEmitter } from 'events'; @@ -248,9 +247,6 @@ export class Connection extends EventEmitter { case 'Worker': result = new Worker(parent, type, guid, initializer); break; - case 'SocksSocket': - result = new SocksSocket(parent, type, guid, initializer); - break; default: throw new Error('Missing type ' + type); } diff --git a/src/client/playwright.ts b/src/client/playwright.ts index 0e2025ea8d..1c60943bf7 100644 --- a/src/client/playwright.ts +++ b/src/client/playwright.ts @@ -14,15 +14,19 @@ * limitations under the License. */ +import dns from 'dns'; +import net from 'net'; +import util from 'util'; import * as channels from '../protocol/channels'; +import { TimeoutError } from '../utils/errors'; +import { createSocket } from '../utils/netUtils'; +import { Android } from './android'; import { BrowserType } from './browserType'; import { ChannelOwner } from './channelOwner'; -import { Selectors, SelectorsOwner, sharedSelectors } from './selectors'; import { Electron } from './electron'; -import { TimeoutError } from '../utils/errors'; +import { Selectors, SelectorsOwner, sharedSelectors } from './selectors'; import { Size } from './types'; -import { Android } from './android'; -import { SocksSocket } from './socksSocket'; +const dnsLookupAsync = util.promisify(dns.lookup); type DeviceDescriptor = { userAgent: string, @@ -44,7 +48,8 @@ export class Playwright extends ChannelOwner(); + private _redirectPortForTest: number | undefined; constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) { super(parent, type, guid, initializer); @@ -61,17 +66,50 @@ export class Playwright extends ChannelOwner SocksSocket.from(socket)); + _enablePortForwarding(redirectPortForTest?: number) { + this._redirectPortForTest = redirectPortForTest; + this._channel.on('socksRequested', ({ uid, host, port }) => this._onSocksRequested(uid, host, port)); + this._channel.on('socksData', ({ uid, data }) => this._onSocksData(uid, Buffer.from(data, 'base64'))); + this._channel.on('socksClosed', ({ uid }) => this._onSocksClosed(uid)); + } + + private async _onSocksRequested(uid: string, host: string, port: number): Promise { + try { + if (this._redirectPortForTest) + port = this._redirectPortForTest; + const { address } = await dnsLookupAsync(host); + const socket = await createSocket(address, port); + socket.on('data', data => this._channel.socksData({ uid, data: data.toString('base64') }).catch(() => {})); + socket.on('error', error => { + this._channel.socksError({ uid, error: error.message }).catch(() => { }); + this._sockets.delete(uid); + }); + socket.on('end', () => { + this._channel.socksEnd({ uid }).catch(() => {}); + this._sockets.delete(uid); + }); + const localAddress = socket.localAddress; + const localPort = socket.localPort; + this._sockets.set(uid, socket); + this._channel.socksConnected({ uid, host: localAddress, port: localPort }).catch(() => {}); + } catch (error) { + this._channel.socksFailed({ uid, errorCode: error.code }).catch(() => {}); + } + } + + private _onSocksData(uid: string, data: Buffer): void { + this._sockets.get(uid)?.write(data); } static from(channel: channels.PlaywrightChannel): Playwright { return (channel as any)._object; } - async _enablePortForwarding(ports: number[]) { - this._forwardPorts = ports; - await this._channel.setForwardedPorts({ports}); + private _onSocksClosed(uid: string): void { + this._sockets.get(uid)?.destroy(); + this._sockets.delete(uid); } _cleanup() { diff --git a/src/client/socksSocket.ts b/src/client/socksSocket.ts deleted file mode 100644 index 8af0ce499e..0000000000 --- a/src/client/socksSocket.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import net from 'net'; - -import * as channels from '../protocol/channels'; -import { Playwright } from './playwright'; -import { assert, isLocalIpAddress, isUnderTest } from '../utils/utils'; -import { ChannelOwner } from './channelOwner'; - -export class SocksSocket extends ChannelOwner { - private _socket: net.Socket; - static from(socket: channels.SocksSocketChannel): SocksSocket { - return (socket as any)._object; - } - - constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.SocksSocketInitializer) { - super(parent, type, guid, initializer); - assert(parent instanceof Playwright); - - assert(parent._forwardPorts.includes(this._initializer.dstPort)); - assert(isLocalIpAddress(this._initializer.dstAddr)); - - if (isUnderTest() && process.env.PW_TEST_PROXY_TARGET) - this._initializer.dstPort = Number(process.env.PW_TEST_PROXY_TARGET); - - this._socket = net.createConnection(this._initializer.dstPort, this._initializer.dstAddr); - this._socket.on('error', (err: Error) => this._channel.error({error: String(err)})); - this._socket.on('connect', () => { - this.connected().catch(() => {}); - this._socket.on('data', data => this.write(data).catch(() => {})); - }); - this._socket.on('close', () => { - this.end().catch(() => {}); - }); - - this._channel.on('data', ({ data }) => { - if (!this._socket.writable) - return; - this._socket.write(Buffer.from(data, 'base64')); - }); - this._channel.on('close', () => this._socket.end()); - - this._connection.on('disconnect', () => this._socket.end()); - } - - async write(data: Buffer): Promise { - await this._channel.write({ data: data.toString('base64') }); - } - - async end(): Promise { - await this._channel.end(); - } - - async connected(): Promise { - await this._channel.connected(); - } -} diff --git a/src/client/types.ts b/src/client/types.ts index dc4020f373..fe7fde4c86 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -75,13 +75,11 @@ export type LaunchPersistentContextOptions = Omit implements channels.PlaywrightChannel { + private _socksProxy: SocksProxy | undefined; + constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel) { const descriptors = require('../server/deviceDescriptors') as types.Devices; const deviceDescriptors = Object.entries(descriptors) @@ -40,12 +43,81 @@ export class PlaywrightDispatcher extends Dispatcher { - this._dispatchEvent('incomingSocksSocket', { socket: new SocksSocketDispatcher(this, socket) }); + } + + enableSocksProxy(port: number) { + this._socksProxy = new SocksProxy(this); + this._socksProxy.listen(port); + } + + async socksConnected(params: channels.PlaywrightSocksConnectedParams, metadata?: channels.Metadata): Promise { + this._socksProxy?.socketConnected(params); + } + + async socksFailed(params: channels.PlaywrightSocksFailedParams, metadata?: channels.Metadata): Promise { + this._socksProxy?.socketFailed(params); + } + + async socksData(params: channels.PlaywrightSocksDataParams, metadata?: channels.Metadata): Promise { + this._socksProxy?.sendSocketData(params); + } + + async socksError(params: channels.PlaywrightSocksErrorParams, metadata?: channels.Metadata): Promise { + this._socksProxy?.sendSocketError(params); + } + + async socksEnd(params: channels.PlaywrightSocksEndParams, metadata?: channels.Metadata): Promise { + this._socksProxy?.sendSocketEnd(params); + } +} + +class SocksProxy implements SocksConnectionClient { + private _server: net.Server; + private _connections = new Map(); + private _dispatcher: PlaywrightDispatcher; + + constructor(dispatcher: PlaywrightDispatcher) { + this._dispatcher = dispatcher; + this._server = new net.Server((socket: net.Socket) => { + const uid = createGuid(); + const connection = new SocksConnection(uid, socket, this); + this._connections.set(uid, connection); }); } - async setForwardedPorts(params: channels.PlaywrightSetForwardedPortsParams): Promise { - this._object._setForwardedPorts(params.ports); + listen(port: number) { + this._server.listen(port); + } + + onSocketRequested(uid: string, host: string, port: number): void { + this._dispatcher._dispatchEvent('socksRequested', { uid, host, port }); + } + + onSocketData(uid: string, data: Buffer): void { + this._dispatcher._dispatchEvent('socksData', { uid, data: data.toString('base64') }); + } + + onSocketClosed(uid: string): void { + this._dispatcher._dispatchEvent('socksClosed', { uid }); + } + + socketConnected(params: channels.PlaywrightSocksConnectedParams) { + this._connections.get(params.uid)?.socketConnected(params.host, params.port); + } + + socketFailed(params: channels.PlaywrightSocksFailedParams) { + this._connections.get(params.uid)?.socketFailed(params.errorCode); + } + + sendSocketData(params: channels.PlaywrightSocksDataParams) { + this._connections.get(params.uid)?.sendData(Buffer.from(params.data, 'base64')); + } + + sendSocketEnd(params: channels.PlaywrightSocksEndParams) { + this._connections.get(params.uid)?.end(); + } + + sendSocketError(params: channels.PlaywrightSocksErrorParams) { + this._connections.get(params.uid)?.error(params.error); } } diff --git a/src/dispatchers/socksSocketDispatcher.ts b/src/dispatchers/socksSocketDispatcher.ts deleted file mode 100644 index 5997c3acd5..0000000000 --- a/src/dispatchers/socksSocketDispatcher.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the 'License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Dispatcher, DispatcherScope } from './dispatcher'; -import * as channels from '../protocol/channels'; -import { SocksInterceptedSocketHandler } from '../server/socksServer'; - -export class SocksSocketDispatcher extends Dispatcher implements channels.SocksSocketChannel { - constructor(scope: DispatcherScope, socket: SocksInterceptedSocketHandler) { - super(scope, socket, 'SocksSocket', { - dstAddr: socket.dstAddr, - dstPort: socket.dstPort - }, true); - socket.on('data', (data: Buffer) => this._dispatchEvent('data', { data: data.toString('base64') })); - socket.on('close', () => { - this._dispatchEvent('close'); - this._dispose(); - }); - } - async connected(): Promise { - this._object.connected(); - } - async error(params: channels.SocksSocketErrorParams): Promise { - this._object.error(params.error); - } - - async write(params: channels.SocksSocketWriteParams): Promise { - this._object.write(Buffer.from(params.data, 'base64')); - } - - async end(): Promise { - this._object.end(); - } -} diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 1d087454e1..edf9112da4 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -196,19 +196,67 @@ export type PlaywrightInitializer = { preLaunchedBrowser?: BrowserChannel, }; export interface PlaywrightChannel extends Channel { - on(event: 'incomingSocksSocket', callback: (params: PlaywrightIncomingSocksSocketEvent) => void): this; - setForwardedPorts(params: PlaywrightSetForwardedPortsParams, metadata?: Metadata): Promise; + on(event: 'socksRequested', callback: (params: PlaywrightSocksRequestedEvent) => void): this; + on(event: 'socksData', callback: (params: PlaywrightSocksDataEvent) => void): this; + on(event: 'socksClosed', callback: (params: PlaywrightSocksClosedEvent) => void): this; + socksConnected(params: PlaywrightSocksConnectedParams, metadata?: Metadata): Promise; + socksFailed(params: PlaywrightSocksFailedParams, metadata?: Metadata): Promise; + socksData(params: PlaywrightSocksDataParams, metadata?: Metadata): Promise; + socksError(params: PlaywrightSocksErrorParams, metadata?: Metadata): Promise; + socksEnd(params: PlaywrightSocksEndParams, metadata?: Metadata): Promise; } -export type PlaywrightIncomingSocksSocketEvent = { - socket: SocksSocketChannel, +export type PlaywrightSocksRequestedEvent = { + uid: string, + host: string, + port: number, }; -export type PlaywrightSetForwardedPortsParams = { - ports: number[], +export type PlaywrightSocksDataEvent = { + uid: string, + data: Binary, }; -export type PlaywrightSetForwardedPortsOptions = { +export type PlaywrightSocksClosedEvent = { + uid: string, +}; +export type PlaywrightSocksConnectedParams = { + uid: string, + host: string, + port: number, +}; +export type PlaywrightSocksConnectedOptions = { }; -export type PlaywrightSetForwardedPortsResult = void; +export type PlaywrightSocksConnectedResult = void; +export type PlaywrightSocksFailedParams = { + uid: string, + errorCode: string, +}; +export type PlaywrightSocksFailedOptions = { + +}; +export type PlaywrightSocksFailedResult = void; +export type PlaywrightSocksDataParams = { + uid: string, + data: Binary, +}; +export type PlaywrightSocksDataOptions = { + +}; +export type PlaywrightSocksDataResult = void; +export type PlaywrightSocksErrorParams = { + uid: string, + error: string, +}; +export type PlaywrightSocksErrorOptions = { + +}; +export type PlaywrightSocksErrorResult = void; +export type PlaywrightSocksEndParams = { + uid: string, +}; +export type PlaywrightSocksEndOptions = { + +}; +export type PlaywrightSocksEndResult = void; // ----------- Selectors ----------- export type SelectorsInitializer = {}; @@ -3317,44 +3365,6 @@ export type AndroidElementInfo = { selected: boolean, }; -// ----------- SocksSocket ----------- -export type SocksSocketInitializer = { - dstAddr: string, - dstPort: number, -}; -export interface SocksSocketChannel extends Channel { - on(event: 'data', callback: (params: SocksSocketDataEvent) => void): this; - on(event: 'close', callback: (params: SocksSocketCloseEvent) => void): this; - write(params: SocksSocketWriteParams, metadata?: Metadata): Promise; - error(params: SocksSocketErrorParams, metadata?: Metadata): Promise; - connected(params?: SocksSocketConnectedParams, metadata?: Metadata): Promise; - end(params?: SocksSocketEndParams, metadata?: Metadata): Promise; -} -export type SocksSocketDataEvent = { - data: Binary, -}; -export type SocksSocketCloseEvent = {}; -export type SocksSocketWriteParams = { - data: Binary, -}; -export type SocksSocketWriteOptions = { - -}; -export type SocksSocketWriteResult = void; -export type SocksSocketErrorParams = { - error: string, -}; -export type SocksSocketErrorOptions = { - -}; -export type SocksSocketErrorResult = void; -export type SocksSocketConnectedParams = {}; -export type SocksSocketConnectedOptions = {}; -export type SocksSocketConnectedResult = void; -export type SocksSocketEndParams = {}; -export type SocksSocketEndOptions = {}; -export type SocksSocketEndResult = void; - export const commandsWithTracingSnapshots = new Set([ 'EventTarget.waitForEventInfo', 'BrowserContext.waitForEventInfo', diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index e7b30818b4..82555e8814 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -380,18 +380,47 @@ Playwright: commands: - setForwardedPorts: + socksConnected: parameters: - ports: - type: array - items: number + uid: string + host: string + port: number + + socksFailed: + parameters: + uid: string + errorCode: string + + socksData: + parameters: + uid: string + data: binary + + socksError: + parameters: + uid: string + error: string + + socksEnd: + parameters: + uid: string events: - - incomingSocksSocket: + socksRequested: parameters: - socket: SocksSocket + uid: string + host: string + port: number + socksData: + parameters: + uid: string + data: binary + + socksClosed: + parameters: + uid: string + Selectors: type: interface @@ -2788,29 +2817,3 @@ AndroidElementInfo: longClickable: boolean scrollable: boolean selected: boolean - -SocksSocket: - type: interface - - initializer: - dstAddr: string - dstPort: number - - commands: - write: - parameters: - data: binary - - error: - parameters: - error: string - - connected: - - end: - - events: - data: - parameters: - data: binary - close: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 517031a073..9180434466 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -152,8 +152,25 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.RootInitializeParams = tObject({ language: tString, }); - scheme.PlaywrightSetForwardedPortsParams = tObject({ - ports: tArray(tNumber), + scheme.PlaywrightSocksConnectedParams = tObject({ + uid: tString, + host: tString, + port: tNumber, + }); + scheme.PlaywrightSocksFailedParams = tObject({ + uid: tString, + errorCode: tString, + }); + scheme.PlaywrightSocksDataParams = tObject({ + uid: tString, + data: tBinary, + }); + scheme.PlaywrightSocksErrorParams = tObject({ + uid: tString, + error: tString, + }); + scheme.PlaywrightSocksEndParams = tObject({ + uid: tString, }); scheme.SelectorsRegisterParams = tObject({ name: tString, @@ -1315,14 +1332,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scrollable: tBoolean, selected: tBoolean, }); - scheme.SocksSocketWriteParams = tObject({ - data: tBinary, - }); - scheme.SocksSocketErrorParams = tObject({ - error: tString, - }); - scheme.SocksSocketConnectedParams = tOptional(tObject({})); - scheme.SocksSocketEndParams = tOptional(tObject({})); return scheme; } diff --git a/src/remote/playwrightClient.ts b/src/remote/playwrightClient.ts index 3f18bce230..5b30fe1d5f 100644 --- a/src/remote/playwrightClient.ts +++ b/src/remote/playwrightClient.ts @@ -20,7 +20,6 @@ import { Playwright } from '../client/playwright'; export type PlaywrightClientConnectOptions = { wsEndpoint: string; - forwardPorts?: number[]; timeout?: number }; @@ -30,7 +29,7 @@ export class PlaywrightClient { private _closePromise: Promise; static async connect(options: PlaywrightClientConnectOptions): Promise { - const {wsEndpoint, forwardPorts, timeout = 30000} = options; + const { wsEndpoint, timeout = 30000 } = options; const connection = new Connection(); const ws = new WebSocket(wsEndpoint); connection.onmessage = message => ws.send(JSON.stringify(message)); @@ -40,8 +39,6 @@ export class PlaywrightClient { const playwrightClientPromise = new Promise((resolve, reject) => { ws.on('open', async () => { const playwright = await connection.initializePlaywright(); - if (forwardPorts) - await playwright._enablePortForwarding(forwardPorts).catch(reject); resolve(new PlaywrightClient(playwright, ws)); }); }); diff --git a/src/remote/playwrightServer.ts b/src/remote/playwrightServer.ts index 0e254d8ce1..f32a866e92 100644 --- a/src/remote/playwrightServer.ts +++ b/src/remote/playwrightServer.ts @@ -31,17 +31,12 @@ export interface PlaywrightServerDelegate { onClose: () => any; } -export type PlaywrightServerOptions = { - acceptForwardedPorts?: boolean - onDisconnect?: () => void; -}; - export class PlaywrightServer { private _wsServer: ws.Server | undefined; private _clientsCount = 0; private _delegate: PlaywrightServerDelegate; - static async startDefault({ acceptForwardedPorts, onDisconnect }: PlaywrightServerOptions = {}): Promise { + static async startDefault(): Promise { const cleanup = async () => { await gracefullyCloseAll().catch(e => {}); }; @@ -53,15 +48,14 @@ export class PlaywrightServer { let playwright: Playwright | undefined; new Root(connection, async (rootScope): Promise => { playwright = createPlaywright(); - if (acceptForwardedPorts) - await playwright._enablePortForwarding(); - return new PlaywrightDispatcher(rootScope, playwright); + const dispatcher = new PlaywrightDispatcher(rootScope, playwright); + if (process.env.PW_SOCKS_PROXY_PORT) + dispatcher.enableSocksProxy(+process.env.PW_SOCKS_PROXY_PORT); + return dispatcher; }); return () => { cleanup(); - playwright?._disablePortForwarding(); playwright?.selectors.unregisterAll(); - onDisconnect?.(); }; }, }; diff --git a/src/server/browser.ts b/src/server/browser.ts index 3472a796cb..0e3d3fc77d 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -35,7 +35,6 @@ export interface BrowserProcess { export type PlaywrightOptions = { rootSdkObject: SdkObject, selectors: Selectors, - loopbackProxyOverride?: () => string, }; export type BrowserOptions = PlaywrightOptions & { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index b515658fff..9ed5ca5f77 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -53,7 +53,7 @@ export abstract class BrowserType extends SdkObject { } async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { - options = validateLaunchOptions(options, this._playwrightOptions.loopbackProxyOverride?.()); + options = validateLaunchOptions(options); const controller = new ProgressController(metadata, this); controller.setLogName('browser'); const browser = await controller.run(progress => { @@ -63,7 +63,7 @@ export abstract class BrowserType extends SdkObject { } async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: types.LaunchPersistentOptions): Promise { - options = validateLaunchOptions(options, this._playwrightOptions.loopbackProxyOverride?.()); + options = validateLaunchOptions(options); const controller = new ProgressController(metadata, this); const persistent: types.BrowserContextOptions = options; controller.setLogName('browser'); @@ -257,14 +257,14 @@ function copyTestHooks(from: object, to: object) { } } -function validateLaunchOptions(options: Options, proxyOverride?: string): Options { +function validateLaunchOptions(options: Options): Options { const { devtools = false } = options; let { headless = !devtools, downloadsPath, proxy } = options; if (debugMode()) headless = false; if (downloadsPath && !path.isAbsolute(downloadsPath)) downloadsPath = path.join(process.cwd(), downloadsPath); - if (proxyOverride) - proxy = { server: proxyOverride }; + if (process.env.PW_SOCKS_PROXY_PORT) + proxy = { server: `socks5://127.0.0.1:${process.env.PW_SOCKS_PROXY_PORT}` }; return { ...options, devtools, headless, downloadsPath, proxy }; } diff --git a/src/server/chromium/chromium.ts b/src/server/chromium/chromium.ts index d348764d44..52dc11b77e 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -163,14 +163,14 @@ export class Chromium extends BrowserType { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; // https://www.chromium.org/developers/design-documents/network-settings - if (isSocks) { + if (isSocks && !process.env.PW_SOCKS_PROXY_PORT) { // https://www.chromium.org/developers/design-documents/network-stack/socks-proxy chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`); } chromeArguments.push(`--proxy-server=${proxy.server}`); const proxyBypassRules = []; // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 - if (this._playwrightOptions.loopbackProxyOverride) + if (process.env.PW_SOCKS_PROXY_PORT) proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); diff --git a/src/server/playwright.ts b/src/server/playwright.ts index d2d6e42a94..6b6a519470 100644 --- a/src/server/playwright.ts +++ b/src/server/playwright.ts @@ -24,9 +24,6 @@ import { Selectors } from './selectors'; import { WebKit } from './webkit/webkit'; import { CallMetadata, createInstrumentation, SdkObject } from './instrumentation'; import { debugLogger } from '../utils/debugLogger'; -import { PortForwardingServer } from './socksSocket'; -import { SocksInterceptedSocketHandler } from './socksServer'; -import { assert } from '../utils/utils'; export class Playwright extends SdkObject { readonly selectors: Selectors; @@ -36,7 +33,6 @@ export class Playwright extends SdkObject { readonly firefox: Firefox; readonly webkit: WebKit; readonly options: PlaywrightOptions; - private _portForwardingServer: PortForwardingServer | undefined; constructor(isInternal: boolean) { super({ attribution: { isInternal }, instrumentation: createInstrumentation() } as any, undefined, 'Playwright'); @@ -56,27 +52,6 @@ export class Playwright extends SdkObject { this.android = new Android(new AdbBackend(), this.options); this.selectors = this.options.selectors; } - - async _enablePortForwarding() { - assert(!this._portForwardingServer); - this._portForwardingServer = await PortForwardingServer.create(this); - this.options.loopbackProxyOverride = () => this._portForwardingServer!.proxyServer(); - this._portForwardingServer.on('incomingSocksSocket', (socket: SocksInterceptedSocketHandler) => { - this.emit('incomingSocksSocket', socket); - }); - } - - _disablePortForwarding() { - if (!this._portForwardingServer) - return; - this._portForwardingServer.stop(); - } - - _setForwardedPorts(ports: number[]) { - if (!this._portForwardingServer) - throw new Error(`Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.`); - this._portForwardingServer.setForwardedPorts(ports); - } } export function createPlaywright(isInternal = false) { diff --git a/src/server/socksServer.ts b/src/server/socksServer.ts deleted file mode 100644 index d4f424b727..0000000000 --- a/src/server/socksServer.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import net from 'net'; - -import { debugLogger } from '../utils/debugLogger'; -import { SdkObject } from './instrumentation'; - -export type SocksConnectionInfo = { - srcAddr: string, - srcPort: number, - dstAddr: string; - dstPort: number; -}; - -enum ConnectionPhases { - VERSION = 0, - NMETHODS, - METHODS, - REQ_CMD, - REQ_RSV, - REQ_ATYP, - REQ_DSTADDR, - REQ_DSTADDR_VARLEN, - REQ_DSTPORT, - DONE, -} - -enum SOCKS_AUTH_METHOD { - NO_AUTH = 0 -} - -enum SOCKS_CMD { - CONNECT = 0x01, - BIND = 0x02, - UDP = 0x03 -} - -enum SOCKS_ATYP { - IPv4 = 0x01, - NAME = 0x03, - IPv6 = 0x04 -} - -enum SOCKS_REPLY { - SUCCESS = 0x00, -} - -const SOCKS_VERSION = 0x5; - -const BUF_REP_INTR_SUCCESS = Buffer.from([ - 0x05, - SOCKS_REPLY.SUCCESS, - 0x00, - 0x01, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00 -]); - - -/** - * https://tools.ietf.org/html/rfc1928 - */ -class SocksV5ServerParser { - private _dstAddrp: number = 0; - private _dstPort?: number; - private _socket: net.Socket; - private _parsingFinishedResolve!: (value?: unknown) => void; - private _parsingFinishedReject!: (value: Error) => void; - private _parsingFinished: Promise; - private _info: SocksConnectionInfo; - private _phase: ConnectionPhases = ConnectionPhases.VERSION; - private _authMethods?: Buffer; - private _dstAddr?: Buffer; - private _addressType: any; - private _methodsp: number = 0; - constructor(socket: net.Socket) { - this._socket = socket; - this._info = { srcAddr: socket.remoteAddress!, srcPort: socket.remotePort!, dstAddr: '', dstPort: 0 }; - this._parsingFinished = new Promise((resolve, reject) => { - this._parsingFinishedResolve = resolve; - this._parsingFinishedReject = reject; - }); - socket.on('data', this._onData.bind(this)); - socket.on('error', () => {}); - } - private _onData(chunk: Buffer) { - const socket = this._socket; - let i = 0; - const readByte = () => chunk[i++]; - const closeSocketOnError = () => { - socket.end(); - this._parsingFinishedReject(new Error('Parsing aborted')); - }; - while (i < chunk.length && this._phase !== ConnectionPhases.DONE) { - switch (this._phase) { - case ConnectionPhases.VERSION: - if (readByte() !== SOCKS_VERSION) - return closeSocketOnError(); - this._phase = ConnectionPhases.NMETHODS; - break; - - case ConnectionPhases.NMETHODS: - this._authMethods = Buffer.alloc(readByte()); - this._phase = ConnectionPhases.METHODS; - break; - - case ConnectionPhases.METHODS: { - if (!this._authMethods) - return closeSocketOnError(); - chunk.copy(this._authMethods, 0, i, i + chunk.length); - if (!this._authMethods.includes(SOCKS_AUTH_METHOD.NO_AUTH)) - return closeSocketOnError(); - const left = this._authMethods.length - this._methodsp; - const chunkLeft = chunk.length - i; - const minLen = (left < chunkLeft ? left : chunkLeft); - chunk.copy(this._authMethods, this._methodsp, i, i + minLen); - this._methodsp += minLen; - i += minLen; - if (this._methodsp !== this._authMethods.length) - return closeSocketOnError(); - if (i < chunk.length) - this._socket.unshift(chunk.slice(i)); - this._authWithoutPassword(socket); - this._phase = ConnectionPhases.REQ_CMD; - break; - } - - case ConnectionPhases.REQ_CMD: - if (readByte() !== SOCKS_VERSION) - return closeSocketOnError(); - const cmd: SOCKS_CMD = readByte(); - if (cmd !== SOCKS_CMD.CONNECT) - return closeSocketOnError(); - this._phase = ConnectionPhases.REQ_RSV; - break; - - case ConnectionPhases.REQ_RSV: - readByte(); - this._phase = ConnectionPhases.REQ_ATYP; - break; - - case ConnectionPhases.REQ_ATYP: - this._phase = ConnectionPhases.REQ_DSTADDR; - this._addressType = readByte(); - if (!(this._addressType in SOCKS_ATYP)) - return closeSocketOnError(); - if (this._addressType === SOCKS_ATYP.IPv4) - this._dstAddr = Buffer.alloc(4); - else if (this._addressType === SOCKS_ATYP.IPv6) - this._dstAddr = Buffer.alloc(16); - else if (this._addressType === SOCKS_ATYP.NAME) - this._phase = ConnectionPhases.REQ_DSTADDR_VARLEN; - break; - - case ConnectionPhases.REQ_DSTADDR: { - if (!this._dstAddr) - return closeSocketOnError(); - const left = this._dstAddr.length - this._dstAddrp; - const chunkLeft = chunk.length - i; - const minLen = (left < chunkLeft ? left : chunkLeft); - chunk.copy(this._dstAddr, this._dstAddrp, i, i + minLen); - this._dstAddrp += minLen; - i += minLen; - if (this._dstAddrp === this._dstAddr.length) - this._phase = ConnectionPhases.REQ_DSTPORT; - break; - } - - case ConnectionPhases.REQ_DSTADDR_VARLEN: - this._dstAddr = Buffer.alloc(readByte()); - this._phase = ConnectionPhases.REQ_DSTADDR; - break; - - case ConnectionPhases.REQ_DSTPORT: - if (!this._dstAddr) - return closeSocketOnError(); - if (this._dstPort === undefined) { - this._dstPort = readByte(); - break; - } - this._dstPort <<= 8; - this._dstPort += readByte(); - - this._socket.removeListener('data', this._onData); - if (i < chunk.length) - this._socket.unshift(chunk.slice(i)); - - if (this._addressType === SOCKS_ATYP.IPv4) { - this._info.dstAddr = [...this._dstAddr].join('.'); - } else if (this._addressType === SOCKS_ATYP.IPv6) { - let ipv6str = ''; - const addr = this._dstAddr; - for (let b = 0; b < 16; ++b) { - if (b % 2 === 0 && b > 0) - ipv6str += ':'; - ipv6str += (addr[b] < 16 ? '0' : '') + addr[b].toString(16); - } - this._info.dstAddr = ipv6str; - } else { - this._info.dstAddr = this._dstAddr.toString(); - } - this._info.dstPort = this._dstPort; - this._phase = ConnectionPhases.DONE; - this._parsingFinishedResolve(); - return; - default: - return closeSocketOnError(); - } - } - } - - private _authWithoutPassword(socket: net.Socket) { - socket.write(Buffer.from([0x05, 0x00])); - } - - async ready(): Promise<{ info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler }> { - await this._parsingFinished; - return { - info: this._info, - forward: () => { - const dstSocket = new net.Socket(); - this._socket.on('close', () => dstSocket.end()); - this._socket.on('end', () => dstSocket.end()); - dstSocket.setKeepAlive(false); - dstSocket.on('error', (err: NodeJS.ErrnoException) => writeSocksSocketError(this._socket, String(err))); - dstSocket.on('connect', () => { - this._socket.write(BUF_REP_INTR_SUCCESS); - this._socket.pipe(dstSocket).pipe(this._socket); - this._socket.resume(); - }).connect(this._info.dstPort, this._info.dstAddr); - }, - intercept: (parent: SdkObject): SocksInterceptedSocketHandler => { - return new SocksInterceptedSocketHandler(parent, this._socket, this._info.dstAddr, this._info.dstPort); - }, - }; - } -} - -export class SocksInterceptedSocketHandler extends SdkObject { - socket: net.Socket; - dstAddr: string; - dstPort: number; - constructor(parent: SdkObject, socket: net.Socket, dstAddr: string, dstPort: number) { - super(parent, 'SocksSocket'); - this.socket = socket; - this.dstAddr = dstAddr; - this.dstPort = dstPort; - socket.on('data', data => this.emit('data', data)); - socket.on('close', data => this.emit('close', data)); - } - connected() { - this.socket.write(BUF_REP_INTR_SUCCESS); - this.socket.resume(); - } - error(error: string) { - this.socket.resume(); - writeSocksSocketError(this.socket, error); - } - write(data: Buffer) { - this.socket.write(data); - } - end() { - this.socket.end(); - } -} - -function writeSocksSocketError(socket: net.Socket, error: string) { - if (!socket.writable) - return; - socket.write(BUF_REP_INTR_SUCCESS); - - const body = `Connection error: ${error}`; - socket.end([ - 'HTTP/1.1 502 OK', - 'Connection: close', - 'Content-Type: text/plain', - 'Content-Length: ' + Buffer.byteLength(body), - '', - body - ].join('\r\n')); -} - -type IncomingProxyRequestHandler = (info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler) => void; - -export class SocksProxyServer { - public server: net.Server; - constructor(incomingMessageHandler: IncomingProxyRequestHandler) { - this.server = net.createServer(this._handleConnection.bind(this, incomingMessageHandler)); - } - - public async listen(port: number, host?: string) { - await new Promise(resolve => this.server.listen(port, host, resolve)); - } - - async _handleConnection(incomingMessageHandler: IncomingProxyRequestHandler, socket: net.Socket) { - const parser = new SocksV5ServerParser(socket); - let parsedSocket; - try { - parsedSocket = await parser.ready(); - } catch (error) { - debugLogger.log('proxy', `Could not parse: ${error} ${error?.stack}`); - return; - } - incomingMessageHandler(parsedSocket.info, parsedSocket.forward, parsedSocket.intercept); - } - - public close() { - this.server.close(); - } -} diff --git a/src/server/socksSocket.ts b/src/server/socksSocket.ts deleted file mode 100644 index 15d0a5da13..0000000000 --- a/src/server/socksSocket.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import net from 'net'; -import { EventEmitter } from 'events'; - -import { SdkObject } from './instrumentation'; -import { debugLogger } from '../utils/debugLogger'; -import { isLocalIpAddress } from '../utils/utils'; -import { SocksProxyServer, SocksConnectionInfo, SocksInterceptedSocketHandler } from './socksServer'; - -export class PortForwardingServer extends EventEmitter { - private _forwardPorts: number[] = []; - private _parent: SdkObject; - private _server: SocksProxyServer; - constructor(parent: SdkObject) { - super(); - this.setMaxListeners(0); - this._parent = parent; - this._server = new SocksProxyServer(this._handler.bind(this)); - } - - static async create(parent: SdkObject) { - const server = new PortForwardingServer(parent); - await server._server.listen(0); - debugLogger.log('proxy', `starting server on port ${server._port()})`); - return server; - } - - private _port(): number { - return (this._server.server.address() as net.AddressInfo).port; - } - - public proxyServer() { - return `socks5://127.0.0.1:${this._port()}`; - } - - private _handler(info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler): void { - const shouldProxyRequestToClient = isLocalIpAddress(info.dstAddr) && this._forwardPorts.includes(info.dstPort); - debugLogger.log('proxy', `incoming connection from ${info.srcAddr}:${info.srcPort} to ${info.dstAddr}:${info.dstPort} shouldProxyRequestToClient=${shouldProxyRequestToClient}`); - if (!shouldProxyRequestToClient) { - forward(); - return; - } - const socket = intercept(this._parent); - this.emit('incomingSocksSocket', socket); - } - - public setForwardedPorts(ports: number[]): void { - debugLogger.log('proxy', `enable port forwarding on ports: ${ports}`); - this._forwardPorts = ports; - } - - public stop(): void { - debugLogger.log('proxy', 'stopping server'); - this._server.close(); - } -} diff --git a/src/utils/netUtils.ts b/src/utils/netUtils.ts new file mode 100644 index 0000000000..3bb9fc324b --- /dev/null +++ b/src/utils/netUtils.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import net from 'net'; + +export async function createSocket(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + socket.on('connect', () => resolve(socket)); + socket.on('error', error => reject(error)); + }); +} diff --git a/src/utils/socksProxy.ts b/src/utils/socksProxy.ts new file mode 100644 index 0000000000..24fdca4312 --- /dev/null +++ b/src/utils/socksProxy.ts @@ -0,0 +1,270 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import net from 'net'; +import { assert } from './utils'; + +// https://tools.ietf.org/html/rfc1928 + +enum SocksAuth { + NO_AUTHENTICATION_REQUIRED = 0x00, + GSSAPI = 0x01, + USERNAME_PASSWORD = 0x02, + NO_ACCEPTABLE_METHODS = 0xFF +} + +enum SocksAddressType { + IPv4 = 0x01, + FqName = 0x03, + IPv6 = 0x04 +} + +enum SocksCommand { + CONNECT = 0x01, + BIND = 0x02, + UDP_ASSOCIATE = 0x03 +} + +enum SocksReply { + Succeeded = 0x00, + GeneralServerFailure = 0x01, + NotAllowedByRuleSet = 0x02, + NetworkUnreachable = 0x03, + HostUnreachable = 0x04, + ConnectionRefused = 0x05, + TtlExpired = 0x06, + CommandNotSupported = 0x07, + AddressTypeNotSupported = 0x08 +} + +export interface SocksConnectionClient { + onSocketRequested(uid: string, host: string, port: number): void; + onSocketData(uid: string, data: Buffer): void; + onSocketClosed(uid: string): void; +} + +export class SocksConnection { + private _buffer = Buffer.from([]); + private _offset = 0; + private _fence = 0; + private _fenceCallback: (() => void) | undefined; + private _socket: net.Socket; + private _boundOnData: (buffer: Buffer) => void; + private _uid: string; + private _client: SocksConnectionClient; + + constructor(uid: string, socket: net.Socket, client: SocksConnectionClient) { + this._uid = uid; + this._socket = socket; + this._client = client; + this._boundOnData = this._onData.bind(this); + socket.on('data', this._boundOnData); + socket.on('close', () => this._onClose()); + socket.on('end', () => this._onClose()); + socket.on('error', () => this._onClose()); + this._run().catch(() => this._socket.end()); + } + + async _run() { + assert(await this._authenticate()); + const { command, host, port } = await this._parseRequest(); + if (command !== SocksCommand.CONNECT) { + this._writeBytes(Buffer.from([ + 0x05, + SocksReply.CommandNotSupported, + 0x00, // RSV + 0x01, // IPv4 + 0x00, 0x00, 0x00, 0x00, // Address + 0x00, 0x00 // Port + ])); + return; + } + + this._socket.off('data', this._boundOnData); + this._client.onSocketRequested(this._uid, host, port); + } + + async _authenticate(): Promise { + // Request: + // +----+----------+----------+ + // |VER | NMETHODS | METHODS | + // +----+----------+----------+ + // | 1 | 1 | 1 to 255 | + // +----+----------+----------+ + + // Response: + // +----+--------+ + // |VER | METHOD | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + + const version = await this._readByte(); + assert(version === 0x05, 'The VER field must be set to x05 for this version of the protocol, was ' + version); + + const nMethods = await this._readByte(); + assert(nMethods, 'No authentication methods specified'); + + const methods = await this._readBytes(nMethods); + for (const method of methods) { + if (method === 0) { + this._writeBytes(Buffer.from([version, method])); + return true; + } + } + this._writeBytes(Buffer.from([version, SocksAuth.NO_ACCEPTABLE_METHODS])); + return false; + } + + async _parseRequest(): Promise<{ host: string, port: number, command: SocksCommand }> { + // Request. + // +----+-----+-------+------+----------+----------+ + // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + + // Response. + // +----+-----+-------+------+----------+----------+ + // |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + + const version = await this._readByte(); + assert(version === 0x05, 'The VER field must be set to x05 for this version of the protocol, was ' + version); + + const command = await this._readByte(); + await this._readByte(); // skip reserved. + const addressType = await this._readByte(); + let host = ''; + switch (addressType) { + case SocksAddressType.IPv4: + host = (await this._readBytes(4)).join('.'); + break; + case SocksAddressType.FqName: + const length = await this._readByte(); + host = (await this._readBytes(length)).toString(); + break; + case SocksAddressType.IPv6: + const bytes = await this._readBytes(16); + const tokens = []; + for (let i = 0; i < 8; ++i) + tokens.push(bytes.readUInt16BE(i * 2)); + host = tokens.join(':'); + break; + } + const port = (await this._readBytes(2)).readUInt16BE(0); + + this._buffer = Buffer.from([]); + this._offset = 0; + this._fence = 0; + + return { + command, + host, + port + }; + } + + private async _readByte(): Promise { + const buffer = await this._readBytes(1); + return buffer[0]; + } + + private async _readBytes(length: number): Promise { + this._fence = this._offset + length; + if (!this._buffer || this._buffer.length < this._fence) + await new Promise(f => this._fenceCallback = f); + this._offset += length; + return this._buffer.slice(this._offset - length, this._offset); + } + + private _writeBytes(buffer: Buffer) { + if (this._socket.writable) + this._socket.write(buffer); + } + + private _onClose() { + this._client.onSocketClosed(this._uid); + } + + private _onData(buffer: Buffer) { + this._buffer = Buffer.concat([this._buffer, buffer]); + if (this._fenceCallback && this._buffer.length >= this._fence) { + const callback = this._fenceCallback; + this._fenceCallback = undefined; + callback(); + } + } + + socketConnected(host: string, port: number) { + this._writeBytes(Buffer.from([ + 0x05, + SocksReply.Succeeded, + 0x00, // RSV + 0x01, // IPv4 + ...parseIP(host), // Address + port << 8, port & 0xFF // Port + ])); + this._socket.on('data', data => this._client.onSocketData(this._uid, data)); + } + + socketFailed(errorCode: string) { + const buffer = Buffer.from([ + 0x05, + 0, + 0x00, // RSV + 0x01, // IPv4 + ...parseIP('0.0.0.0'), // Address + 0, 0 // Port + ]); + switch (errorCode) { + case 'ENOENT': + case 'ENOTFOUND': + case 'ETIMEDOUT': + case 'EHOSTUNREACH': + buffer[1] = SocksReply.HostUnreachable; + break; + case 'ENETUNREACH': + buffer[1] = SocksReply.NetworkUnreachable; + break; + case 'ECONNREFUSED': + buffer[1] = SocksReply.ConnectionRefused; + break; + } + this._writeBytes(buffer); + this._socket.end(); + } + + sendData(data: Buffer) { + this._socket.write(data); + } + + end() { + this._socket.end(); + } + + error(error: string) { + this._socket.destroy(new Error(error)); + } +} + +function parseIP(address: string): number[] { + if (!net.isIPv4(address)) + throw new Error('IPv6 is not supported'); + return address.split('.', 4).map(t => +t); +} diff --git a/tests/port-forwarding-server.spec.ts b/tests/port-forwarding-server.spec.ts new file mode 100644 index 0000000000..92af987c6e --- /dev/null +++ b/tests/port-forwarding-server.spec.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import childProcess from 'child_process'; +import http from 'http'; +import path from 'path'; +import net from 'net'; + +import { contextTest, expect } from './config/browserTest'; +import { PlaywrightClient } from '../lib/remote/playwrightClient'; +import type { Page } from '..'; + +class OutOfProcessPlaywrightServer { + private _driverProcess: childProcess.ChildProcess; + private _receivedPortPromise: Promise; + + constructor(port: number, proxyPort: number) { + this._driverProcess = childProcess.fork(path.join(__dirname, '..', 'lib', 'cli', 'cli.js'), ['run-server', port.toString()], { + stdio: 'pipe', + detached: true, + env: { + ...process.env, + PW_SOCKS_PROXY_PORT: String(proxyPort) + } + }); + this._driverProcess.unref(); + this._receivedPortPromise = new Promise((resolve, reject) => { + this._driverProcess.stdout.on('data', (data: Buffer) => { + const prefix = 'Listening on '; + const line = data.toString(); + if (line.startsWith(prefix)) + resolve(line.substr(prefix.length)); + }); + this._driverProcess.on('exit', () => reject()); + }); + } + async kill() { + const waitForExit = new Promise(resolve => this._driverProcess.on('exit', () => resolve())); + this._driverProcess.kill('SIGKILL'); + await waitForExit; + } + public async wsEndpoint(): Promise { + return await this._receivedPortPromise; + } +} + +const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number) => Promise }>({ + pageFactory: async ({ browserName, browserOptions }, run, testInfo) => { + const playwrightServers: OutOfProcessPlaywrightServer[] = []; + await run(async (redirectPortForTest?: number): Promise => { + const server = new OutOfProcessPlaywrightServer(0, 3200 + testInfo.workerIndex); + playwrightServers.push(server); + const service = await PlaywrightClient.connect({ + wsEndpoint: await server.wsEndpoint(), + }); + const playwright = service.playwright(); + playwright._enablePortForwarding(redirectPortForTest); + const browser = await playwright[browserName].launch(browserOptions); + return await browser.newPage(); + }); + for (const playwrightServer of playwrightServers) + await playwrightServer.kill(); + }, +}); + +it.fixme(({ platform, browserName }) => browserName === 'webkit' && platform !== 'linux'); +it.skip(({ mode }) => mode !== 'default'); + +async function startTestServer() { + const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { + res.end('from-retargeted-server'); + }); + await new Promise(resolve => server.listen(0, resolve)); + return { + testServerPort: (server.address() as net.AddressInfo).port, + stopTestServer: () => server.close() + }; +} + +it('should forward non-forwarded requests', async ({ pageFactory, server }) => { + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end('original-target'); + }); + const page = await pageFactory(); + await page.goto(server.PREFIX + '/foo.html'); + expect(await page.content()).toContain('original-target'); + expect(reachedOriginalTarget).toBe(true); +}); + +it('should proxy local requests', async ({ pageFactory, server }, workerInfo) => { + const { testServerPort, stopTestServer } = await startTestServer(); + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end(''); + }); + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const page = await pageFactory(testServerPort); + await page.goto(`http://localhost:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-retargeted-server'); + expect(reachedOriginalTarget).toBe(false); + stopTestServer(); +}); + +it('should lead to the error page for forwarded requests when the connection is refused', async ({ pageFactory, browserName }, workerInfo) => { + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const page = await pageFactory(); + const error = await page.goto(`http://localhost:${examplePort}`).catch(e => e); + if (browserName === 'chromium') + expect(error.message).toContain('net::ERR_SOCKS_CONNECTION_FAILED at http://localhost:20'); + else if (browserName === 'webkit') + expect(error.message).toBeTruthy(); + else if (browserName === 'firefox') + expect(error.message).toContain('NS_ERROR_CONNECTION_REFUSED'); +}); diff --git a/tests/portForwardingServer.spec.ts b/tests/portForwardingServer.spec.ts deleted file mode 100644 index 77f9db8105..0000000000 --- a/tests/portForwardingServer.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import childProcess from 'child_process'; -import http from 'http'; -import path from 'path'; -import os from 'os'; -import fs from 'fs'; -import net from 'net'; - -import { contextTest, expect } from './config/browserTest'; -import { PlaywrightClient } from '../lib/remote/playwrightClient'; -import { createGuid } from '../src/utils/utils'; -import type { PlaywrightServerOptions } from '../src/remote/playwrightServer'; -import type { LaunchOptions, ConnectOptions } from '../index'; -import type { Page, BrowserServer } from '..'; - -class OutOfProcessPlaywrightServer { - private _driverProcess: childProcess.ChildProcess; - private _receivedPortPromise: Promise; - constructor(port: number, config: PlaywrightServerOptions) { - const configFile = path.join(os.tmpdir(), `playwright-server-config-${createGuid()}.json`); - fs.writeFileSync(configFile, JSON.stringify(config)); - this._driverProcess = childProcess.fork(path.join(__dirname, '..', 'lib', 'cli', 'cli.js'), ['run-server', port.toString(), configFile], { - stdio: 'pipe', - detached: true, - }); - this._driverProcess.unref(); - this._receivedPortPromise = new Promise((resolve, reject) => { - this._driverProcess.stdout.on('data', (data: Buffer) => { - const prefix = 'Listening on '; - const line = data.toString(); - if (line.startsWith(prefix)) - resolve(line.substr(prefix.length)); - }); - this._driverProcess.on('exit', () => reject()); - }); - } - async kill() { - const waitForExit = new Promise(resolve => this._driverProcess.on('exit', () => resolve())); - this._driverProcess.kill('SIGKILL'); - await waitForExit; - } - public async wsEndpoint(): Promise { - return await this._receivedPortPromise; - } -} - -type PageFactoryOptions = { - acceptForwardedPorts: boolean - forwardPorts: number[] -}; - -type LaunchMode = 'playwrightclient' | 'launchServer'; - -const it = contextTest.extend<{ pageFactory: (options?: PageFactoryOptions) => Promise, launchMode: LaunchMode }>({ - launchMode: [ 'launchServer', { scope: 'test' }], - pageFactory: async ({ launchMode, browserType, browserName, browserOptions }, run) => { - const browserServers: BrowserServer[] = []; - const playwrightServers: OutOfProcessPlaywrightServer[] = []; - await run(async (options?: PageFactoryOptions): Promise => { - const { acceptForwardedPorts, forwardPorts } = options; - if (launchMode === 'playwrightclient') { - const server = new OutOfProcessPlaywrightServer(0, { - acceptForwardedPorts, - }); - playwrightServers.push(server); - const service = await PlaywrightClient.connect({ - wsEndpoint: await server.wsEndpoint(), - forwardPorts, - }); - const playwright = service.playwright(); - const browser = await playwright[browserName].launch(browserOptions); - return await browser.newPage(); - } - const browserServer = await browserType.launchServer({ - ...browserOptions, - _acceptForwardedPorts: acceptForwardedPorts - } as LaunchOptions); - browserServers.push(browserServer); - const browser = await browserType.connect({ - wsEndpoint: browserServer.wsEndpoint(), - _forwardPorts: forwardPorts - } as ConnectOptions); - return await browser.newPage(); - }); - for (const browserServer of browserServers) - await browserServer.close(); - for (const playwrightServer of playwrightServers) - await playwrightServer.kill(); - }, -}); - -it.fixme(({ platform, browserName }) => platform === 'darwin' && browserName === 'webkit'); -it.skip(({ mode }) => mode !== 'default'); - -it.beforeEach(() => { - delete process.env.PW_TEST_PROXY_TARGET; -}); - -async function startTestServer() { - const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { - res.end('from-retargeted-server'); - }); - await new Promise(resolve => server.listen(0, resolve)); - return { - testServerPort: (server.address() as net.AddressInfo).port, - stopTestServer: () => server.close() - }; -} - -for (const launchMode of ['playwrightclient', 'launchServer'] as LaunchMode[]) { - it.describe(`${launchMode}:`, () => { - it.use({ launchMode }); - - it('should forward non-forwarded requests', async ({ pageFactory, server }) => { - let reachedOriginalTarget = false; - server.setRoute('/foo.html', async (req, res) => { - reachedOriginalTarget = true; - res.end('original-target'); - }); - const page = await pageFactory({ acceptForwardedPorts: true, forwardPorts: [] }); - await page.goto(server.PREFIX + '/foo.html'); - expect(await page.content()).toContain('original-target'); - expect(reachedOriginalTarget).toBe(true); - }); - - it('should proxy local requests', async ({ pageFactory, server }, workerInfo) => { - const { testServerPort, stopTestServer } = await startTestServer(); - process.env.PW_TEST_PROXY_TARGET = testServerPort.toString(); - let reachedOriginalTarget = false; - server.setRoute('/foo.html', async (req, res) => { - reachedOriginalTarget = true; - res.end(''); - }); - const examplePort = 20_000 + workerInfo.workerIndex * 3; - const page = await pageFactory({ acceptForwardedPorts: true, forwardPorts: [examplePort] }); - await page.goto(`http://localhost:${examplePort}/foo.html`); - expect(await page.content()).toContain('from-retargeted-server'); - expect(reachedOriginalTarget).toBe(false); - stopTestServer(); - }); - - it('should lead to the error page for forwarded requests when the connection is refused', async ({ pageFactory }, workerInfo) => { - const examplePort = 20_000 + workerInfo.workerIndex * 3; - const page = await pageFactory({ acceptForwardedPorts: true, forwardPorts: [examplePort] }); - const response = await page.goto(`http://localhost:${examplePort}`); - expect(response.status()).toBe(502); - await page.waitForSelector('text=Connection error'); - }); - - it('should lead to the error page for non-forwarded requests when the connection is refused', async ({ pageFactory }) => { - process.env.PW_TEST_PROXY_TARGET = '50001'; - const page = await pageFactory({ acceptForwardedPorts: true, forwardPorts: [] }); - const response = await page.goto(`http://localhost:44123/non-existing-url`); - expect(response.status()).toBe(502); - await page.waitForSelector('text=Connection error'); - }); - - it('should should not allow to connect when the server does not allow port-forwarding', async ({ pageFactory }) => { - await expect(pageFactory({ acceptForwardedPorts: false, forwardPorts: [] })).rejects.toThrowError('Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.'); - await expect(pageFactory({ acceptForwardedPorts: false, forwardPorts: [1234] })).rejects.toThrowError('Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.'); - }); - }); -} - -it('launchServer: should not allow connecting a second client when _acceptForwardedPorts is used', async ({ browserType, browserOptions }, workerInfo) => { - const browserServer = await browserType.launchServer({ - ...browserOptions, - _acceptForwardedPorts: true - } as LaunchOptions); - const examplePort = 20_000 + workerInfo.workerIndex * 3; - - const browser1 = await browserType.connect({ - wsEndpoint: browserServer.wsEndpoint(), - _forwardPorts: [examplePort] - } as ConnectOptions); - await expect(browserType.connect({ - wsEndpoint: browserServer.wsEndpoint(), - _forwardPorts: [examplePort] - } as ConnectOptions)).rejects.toThrowError('browserType.connect: WebSocket server disconnected (1005)'); - await browser1.close(); - const browser2 = await browserType.connect({ - wsEndpoint: browserServer.wsEndpoint(), - _forwardPorts: [examplePort] - } as ConnectOptions); - await browser2.close(); - - await browserServer.close(); -});