chore: reimplement socks to be readable (#8315)

This commit is contained in:
Pavel Feldman 2021-08-19 15:16:46 -07:00 committed by GitHub
parent 96a9a26f9f
commit 44887c237d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 680 additions and 894 deletions

View file

@ -44,8 +44,6 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServer> {
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);

View file

@ -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')

View file

@ -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

View file

@ -210,14 +210,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
ws.removeEventListener('close', closeListener);
ws.close();
});
if (params._forwardPorts) {
try {
await playwright._enablePortForwarding(params._forwardPorts);
} catch (err) {
reject(err);
return;
}
}
fulfill(browser);
});
ws.addEventListener('error', event => {

View file

@ -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);
}

View file

@ -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<channels.PlaywrightChannel, channel
readonly selectors: Selectors;
readonly errors: { TimeoutError: typeof TimeoutError };
private _selectorsOwner: SelectorsOwner;
_forwardPorts: number[] = [];
private _sockets = new Map<string, net.Socket>();
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<channels.PlaywrightChannel, channel
this._selectorsOwner = SelectorsOwner.from(initializer.selectors);
this.selectors._addChannel(this._selectorsOwner);
}
this._channel.on('incomingSocksSocket', ({socket}) => 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<void> {
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() {

View file

@ -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<channels.SocksSocketChannel, channels.SocksSocketInitializer> {
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<void> {
await this._channel.write({ data: data.toString('base64') });
}
async end(): Promise<void> {
await this._channel.end();
}
async connected(): Promise<void> {
await this._channel.connected();
}
}

View file

@ -75,13 +75,11 @@ export type LaunchPersistentContextOptions = Omit<LaunchOptionsBase & BrowserCon
export type ConnectOptions = {
wsEndpoint: string,
headers?: { [key: string]: string; };
_forwardPorts?: number[];
slowMo?: number,
timeout?: number,
logger?: Logger,
};
export type LaunchServerOptions = {
_acceptForwardedPorts?: boolean,
channel?: channels.BrowserTypeLaunchOptions['channel'],
executablePath?: string,
args?: string[],

View file

@ -14,6 +14,7 @@
* limitations under the License.
*/
import net from 'net';
import * as channels from '../protocol/channels';
import { Playwright } from '../server/playwright';
import { AndroidDispatcher } from './androidDispatcher';
@ -22,10 +23,12 @@ import { Dispatcher, DispatcherScope } from './dispatcher';
import { ElectronDispatcher } from './electronDispatcher';
import { SelectorsDispatcher } from './selectorsDispatcher';
import * as types from '../server/types';
import { SocksSocketDispatcher } from './socksSocketDispatcher';
import { SocksInterceptedSocketHandler } from '../server/socksServer';
import { SocksConnection, SocksConnectionClient } from '../utils/socksProxy';
import { createGuid } from '../utils/utils';
export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.PlaywrightInitializer> 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<Playwright, channels.Playwr
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
preLaunchedBrowser,
}, false);
this._object.on('incomingSocksSocket', (socket: SocksInterceptedSocketHandler) => {
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<void> {
this._socksProxy?.socketConnected(params);
}
async socksFailed(params: channels.PlaywrightSocksFailedParams, metadata?: channels.Metadata): Promise<void> {
this._socksProxy?.socketFailed(params);
}
async socksData(params: channels.PlaywrightSocksDataParams, metadata?: channels.Metadata): Promise<void> {
this._socksProxy?.sendSocketData(params);
}
async socksError(params: channels.PlaywrightSocksErrorParams, metadata?: channels.Metadata): Promise<void> {
this._socksProxy?.sendSocketError(params);
}
async socksEnd(params: channels.PlaywrightSocksEndParams, metadata?: channels.Metadata): Promise<void> {
this._socksProxy?.sendSocketEnd(params);
}
}
class SocksProxy implements SocksConnectionClient {
private _server: net.Server;
private _connections = new Map<string, SocksConnection>();
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<void> {
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);
}
}

View file

@ -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<SocksInterceptedSocketHandler, channels.SocksSocketInitializer> 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<void> {
this._object.connected();
}
async error(params: channels.SocksSocketErrorParams): Promise<void> {
this._object.error(params.error);
}
async write(params: channels.SocksSocketWriteParams): Promise<void> {
this._object.write(Buffer.from(params.data, 'base64'));
}
async end(): Promise<void> {
this._object.end();
}
}

View file

@ -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<PlaywrightSetForwardedPortsResult>;
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<PlaywrightSocksConnectedResult>;
socksFailed(params: PlaywrightSocksFailedParams, metadata?: Metadata): Promise<PlaywrightSocksFailedResult>;
socksData(params: PlaywrightSocksDataParams, metadata?: Metadata): Promise<PlaywrightSocksDataResult>;
socksError(params: PlaywrightSocksErrorParams, metadata?: Metadata): Promise<PlaywrightSocksErrorResult>;
socksEnd(params: PlaywrightSocksEndParams, metadata?: Metadata): Promise<PlaywrightSocksEndResult>;
}
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<SocksSocketWriteResult>;
error(params: SocksSocketErrorParams, metadata?: Metadata): Promise<SocksSocketErrorResult>;
connected(params?: SocksSocketConnectedParams, metadata?: Metadata): Promise<SocksSocketConnectedResult>;
end(params?: SocksSocketEndParams, metadata?: Metadata): Promise<SocksSocketEndResult>;
}
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',

View file

@ -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:

View file

@ -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;
}

View file

@ -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<void>;
static async connect(options: PlaywrightClientConnectOptions): Promise<PlaywrightClient> {
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<PlaywrightClient>((resolve, reject) => {
ws.on('open', async () => {
const playwright = await connection.initializePlaywright();
if (forwardPorts)
await playwright._enablePortForwarding(forwardPorts).catch(reject);
resolve(new PlaywrightClient(playwright, ws));
});
});

View file

@ -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<PlaywrightServer> {
static async startDefault(): Promise<PlaywrightServer> {
const cleanup = async () => {
await gracefullyCloseAll().catch(e => {});
};
@ -53,15 +48,14 @@ export class PlaywrightServer {
let playwright: Playwright | undefined;
new Root(connection, async (rootScope): Promise<PlaywrightDispatcher> => {
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?.();
};
},
};

View file

@ -35,7 +35,6 @@ export interface BrowserProcess {
export type PlaywrightOptions = {
rootSdkObject: SdkObject,
selectors: Selectors,
loopbackProxyOverride?: () => string,
};
export type BrowserOptions = PlaywrightOptions & {

View file

@ -53,7 +53,7 @@ export abstract class BrowserType extends SdkObject {
}
async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise<Browser> {
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<BrowserContext> {
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 extends types.LaunchOptions>(options: Options, proxyOverride?: string): Options {
function validateLaunchOptions<Options extends types.LaunchOptions>(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 };
}

View file

@ -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));

View file

@ -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) {

View file

@ -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<unknown>;
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();
}
}

View file

@ -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();
}
}

25
src/utils/netUtils.ts Normal file
View file

@ -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<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
socket.on('connect', () => resolve(socket));
socket.on('error', error => reject(error));
});
}

270
src/utils/socksProxy.ts Normal file
View file

@ -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<boolean> {
// 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<number> {
const buffer = await this._readBytes(1);
return buffer[0];
}
private async _readBytes(length: number): Promise<Buffer> {
this._fence = this._offset + length;
if (!this._buffer || this._buffer.length < this._fence)
await new Promise<void>(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);
}

View file

@ -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<string>;
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<string>((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<void>(resolve => this._driverProcess.on('exit', () => resolve()));
this._driverProcess.kill('SIGKILL');
await waitForExit;
}
public async wsEndpoint(): Promise<string> {
return await this._receivedPortPromise;
}
}
const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number) => Promise<Page> }>({
pageFactory: async ({ browserName, browserOptions }, run, testInfo) => {
const playwrightServers: OutOfProcessPlaywrightServer[] = [];
await run(async (redirectPortForTest?: number): Promise<Page> => {
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('<html><body>from-retargeted-server</body></html>');
});
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('<html><body>original-target</body></html>');
});
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('<html><body></body></html>');
});
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');
});

View file

@ -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<string>;
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<string>((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<void>(resolve => this._driverProcess.on('exit', () => resolve()));
this._driverProcess.kill('SIGKILL');
await waitForExit;
}
public async wsEndpoint(): Promise<string> {
return await this._receivedPortPromise;
}
}
type PageFactoryOptions = {
acceptForwardedPorts: boolean
forwardPorts: number[]
};
type LaunchMode = 'playwrightclient' | 'launchServer';
const it = contextTest.extend<{ pageFactory: (options?: PageFactoryOptions) => Promise<Page>, 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<Page> => {
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('<html><body>from-retargeted-server</body></html>');
});
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('<html><body>original-target</body></html>');
});
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('<html><body></body></html>');
});
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();
});