feat(browserServer): forward local ports (#6375)

This commit is contained in:
Max Schmitt 2021-05-25 17:11:32 +02:00 committed by GitHub
parent c9f35fb811
commit 3f43db5cc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 833 additions and 14 deletions

View file

@ -35,6 +35,9 @@ import { BrowserContext } from './server/browserContext';
import { CRBrowser } from './server/chromium/crBrowser';
import { CDPSessionDispatcher } from './dispatchers/cdpSessionDispatcher';
import { PageDispatcher } from './dispatchers/pageDispatcher';
import { BrowserServerPortForwardingServer } from './server/socksSocket';
import { SocksSocketDispatcher } from './dispatchers/socksSocketDispatcher';
import { SocksInterceptedSocketHandler } from './server/socksServer';
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
private _playwright: Playwright;
@ -46,20 +49,24 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
}
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServer> {
const portForwardingServer = new BrowserServerPortForwardingServer(this._playwright, !!options._acceptForwardedPorts);
// 1. Pre-launch the browser
const browser = await this._browserType.launch(internalCallMetadata(), {
...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? envObjectToArray(options.env) : undefined,
...portForwardingServer.browserLaunchOptions(),
}, toProtocolLogger(options.logger));
// 2. Start the server
const delegate: PlaywrightServerDelegate = {
path: '/' + createGuid(),
allowMultipleClients: true,
onClose: () => {},
onConnect: this._onConnect.bind(this, browser),
allowMultipleClients: options._acceptForwardedPorts ? false : true,
onClose: () => {
portForwardingServer.stop();
},
onConnect: this._onConnect.bind(this, browser, portForwardingServer),
};
const server = new PlaywrightServer(delegate);
const wsEndpoint = await server.listen(options.port);
@ -78,7 +85,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
return browserServer;
}
private _onConnect(browser: Browser, scope: DispatcherScope, forceDisconnect: () => void) {
private _onConnect(browser: Browser, portForwardingServer: BrowserServerPortForwardingServer, scope: DispatcherScope, forceDisconnect: () => void) {
const selectors = new Selectors();
const selectorsDispatcher = new SelectorsDispatcher(scope, selectors);
const browserDispatcher = new ConnectedBrowserDispatcher(scope, browser, selectors);
@ -86,8 +93,16 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
// Underlying browser did close for some reason - force disconnect the client.
forceDisconnect();
});
new PlaywrightDispatcher(scope, this._playwright, selectorsDispatcher, browserDispatcher);
const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, selectorsDispatcher, browserDispatcher, (ports: number[]) => {
portForwardingServer.enablePortForwarding(ports);
});
const incomingSocksSocketHandler = (socket: SocksInterceptedSocketHandler) => {
playwrightDispatcher._dispatchEvent('incomingSocksSocket', { socket: new SocksSocketDispatcher(playwrightDispatcher._scope, socket) });
};
portForwardingServer.on('incomingSocksSocket', incomingSocksSocketHandler);
return () => {
portForwardingServer.off('incomingSocksSocket', incomingSocksSocketHandler);
// Cleanup contexts upon disconnect.
browserDispatcher.cleanupContexts().catch(e => {});
};

View file

@ -137,6 +137,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
if (!connection.isDisconnected())
connection.dispatch(JSON.parse(event.data));
} catch (e) {
console.error(`Playwright: Connection dispatch error`);
console.error(e);
ws.close();
}
});
@ -150,8 +152,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
}
}
ws.addEventListener('open', async () => {
const prematureCloseListener = (event: { reason: string }) => {
reject(new Error('Server disconnected: ' + event.reason));
const prematureCloseListener = (event: { code: number, reason: string }) => {
reject(new Error(`WebSocket server disconnected (${event.code}) ${event.reason}`));
};
ws.addEventListener('close', prematureCloseListener);
const playwright = await connection.waitForObjectWithKnownName('Playwright') as Playwright;
@ -182,6 +184,16 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
ws.removeEventListener('close', closeListener);
ws.close();
});
if (params._forwardPorts) {
try {
await playwright._channel.enablePortForwarding({
ports: params._forwardPorts,
});
} catch (err) {
reject(err);
return;
}
}
fulfill(browser);
});
ws.addEventListener('error', event => {

View file

@ -36,8 +36,10 @@ import { debugLogger } from '../utils/debugLogger';
import { SelectorsOwner } from './selectors';
import { isUnderTest } from '../utils/utils';
import { Android, AndroidSocket, AndroidDevice } from './android';
import { SocksSocket } from './socksSocket';
import { captureStackTrace } from '../utils/stackTrace';
import { Artifact } from './artifact';
import { EventEmitter } from 'events';
class Root extends ChannelOwner<channels.Channel, {}> {
constructor(connection: Connection) {
@ -45,7 +47,7 @@ class Root extends ChannelOwner<channels.Channel, {}> {
}
}
export class Connection {
export class Connection extends EventEmitter {
readonly _objects = new Map<string, ChannelOwner>();
private _waitingForObject = new Map<string, any>();
onmessage = (message: object): void => {};
@ -56,6 +58,7 @@ export class Connection {
private _onClose?: () => void;
constructor(onClose?: () => void) {
super();
this._rootObject = new Root(this);
this._onClose = onClose;
}
@ -135,6 +138,7 @@ export class Connection {
for (const callback of this._callbacks.values())
callback.reject(new Error(errorMessage));
this._callbacks.clear();
this.emit('disconnect');
}
isDisconnected() {
@ -239,6 +243,9 @@ export class Connection {
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

@ -22,6 +22,7 @@ import { Electron } from './electron';
import { TimeoutError } from '../utils/errors';
import { Size } from './types';
import { Android } from './android';
import { SocksSocket } from './socksSocket';
type DeviceDescriptor = {
userAgent: string,
@ -59,6 +60,8 @@ 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));
}
_cleanup() {

66
src/client/socksSocket.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* 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 { 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);
this._connection.on('disconnect', () => this._socket.end());
if (isUnderTest() && process.env.PW_TEST_PROXY_TARGET)
this._initializer.dstPort = Number(process.env.PW_TEST_PROXY_TARGET);
assert(isLocalIpAddress(this._initializer.dstAddr));
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());
}
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

@ -71,11 +71,13 @@ 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

@ -22,9 +22,11 @@ import { Dispatcher, DispatcherScope } from './dispatcher';
import { ElectronDispatcher } from './electronDispatcher';
import { SelectorsDispatcher } from './selectorsDispatcher';
import * as types from '../server/types';
import { assert } from '../utils/utils';
export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.PlaywrightInitializer> implements channels.PlaywrightChannel {
constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel) {
private _portForwardingCallback: ((ports: number[]) => void) | undefined;
constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel, portForwardingCallback?: (ports: number[]) => void) {
const descriptors = require('../server/deviceDescriptors') as types.Devices;
const deviceDescriptors = Object.entries(descriptors)
.map(([name, descriptor]) => ({ name, descriptor }));
@ -38,5 +40,11 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
preLaunchedBrowser,
}, false);
this._portForwardingCallback = portForwardingCallback;
}
async enablePortForwarding(params: channels.PlaywrightEnablePortForwardingParams): Promise<void> {
assert(this._portForwardingCallback, 'Port forwarding is only supported when using connect()');
this._portForwardingCallback(params.ports);
}
}

View file

@ -0,0 +1,47 @@
/**
* 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

@ -179,7 +179,19 @@ export type PlaywrightInitializer = {
preLaunchedBrowser?: BrowserChannel,
};
export interface PlaywrightChannel extends Channel {
on(event: 'incomingSocksSocket', callback: (params: PlaywrightIncomingSocksSocketEvent) => void): this;
enablePortForwarding(params: PlaywrightEnablePortForwardingParams, metadata?: Metadata): Promise<PlaywrightEnablePortForwardingResult>;
}
export type PlaywrightIncomingSocksSocketEvent = {
socket: SocksSocketChannel,
};
export type PlaywrightEnablePortForwardingParams = {
ports: number[],
};
export type PlaywrightEnablePortForwardingOptions = {
};
export type PlaywrightEnablePortForwardingResult = void;
// ----------- Selectors -----------
export type SelectorsInitializer = {};
@ -3113,3 +3125,41 @@ export type AndroidElementInfo = {
scrollable: boolean,
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;

View file

@ -377,6 +377,20 @@ Playwright:
# Only present when connecting remotely via BrowserType.connect() method.
preLaunchedBrowser: Browser?
commands:
enablePortForwarding:
parameters:
ports:
type: array
items: number
events:
incomingSocksSocket:
parameters:
socket: SocksSocket
Selectors:
type: interface
@ -2523,3 +2537,29 @@ 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

@ -147,6 +147,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
})),
value: tOptional(tType('SerializedValue')),
});
scheme.PlaywrightEnablePortForwardingParams = tObject({
ports: tArray(tNumber),
});
scheme.SelectorsRegisterParams = tObject({
name: tString,
source: tString,
@ -1221,6 +1224,14 @@ 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

@ -34,6 +34,10 @@ import { CallMetadata } from '../instrumentation';
import { findChromiumChannel } from './findChromiumChannel';
import http from 'http';
type LaunchServerOptions = {
_acceptForwardedPorts?: boolean,
};
export class Chromium extends BrowserType {
private _devtools: CRDevTools | undefined;
@ -119,7 +123,7 @@ export class Chromium extends BrowserType {
transport.send(message);
}
_defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
_defaultArgs(options: types.LaunchOptions & LaunchServerOptions, isPersistent: boolean, userDataDir: string): string[] {
const { args = [], proxy } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg)
@ -155,10 +159,14 @@ export class Chromium extends BrowserType {
chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`);
}
chromeArguments.push(`--proxy-server=${proxy.server}`);
if (proxy.bypass) {
const patterns = proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t);
chromeArguments.push(`--proxy-bypass-list=${patterns.join(';')}`);
}
const proxyBypassRules = [];
// https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578
if (options._acceptForwardedPorts)
proxyBypassRules.push('<-loopback>');
if (proxy.bypass)
proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t));
if (proxyBypassRules.length > 0)
chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`);
}
chromeArguments.push(...args);
if (isPersistent)

300
src/server/socksServer.ts Normal file
View file

@ -0,0 +1,300 @@
/**
* 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/utils';
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 _readyResolve!: (value?: unknown) => void;
private _ready: 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._ready = new Promise(resolve => this._readyResolve = resolve);
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++];
while (i < chunk.length && this._phase !== ConnectionPhases.DONE) {
switch (this._phase) {
case ConnectionPhases.VERSION:
assert(readByte() === SOCKS_VERSION);
this._phase = ConnectionPhases.NMETHODS;
break;
case ConnectionPhases.NMETHODS:
this._authMethods = Buffer.alloc(readByte());
this._phase = ConnectionPhases.METHODS;
break;
case ConnectionPhases.METHODS: {
assert(this._authMethods);
chunk.copy(this._authMethods, 0, i, i + chunk.length);
assert(this._authMethods.includes(SOCKS_AUTH_METHOD.NO_AUTH));
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;
assert(this._methodsp === this._authMethods.length);
if (i < chunk.length)
this._socket.unshift(chunk.slice(i));
this._authWithoutPassword(socket);
this._phase = ConnectionPhases.REQ_CMD;
break;
}
case ConnectionPhases.REQ_CMD:
assert(readByte() === SOCKS_VERSION);
const cmd: SOCKS_CMD = readByte();
assert(cmd === SOCKS_CMD.CONNECT);
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();
assert(this._addressType in SOCKS_ATYP);
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: {
assert(this._dstAddr);
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:
assert(this._dstAddr);
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._readyResolve();
return;
default:
assert(false);
}
}
}
private _authWithoutPassword(socket: net.Socket) {
socket.write(Buffer.from([0x05, 0x00]));
}
async ready(): Promise<{ info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler }> {
await this._ready;
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 listen(port: number, host?: string) {
this.server.listen(port, host);
}
async _handleConnection(incomingMessageHandler: IncomingProxyRequestHandler, socket: net.Socket) {
const parser = new SocksV5ServerParser(socket);
const { info, forward, intercept } = await parser.ready();
incomingMessageHandler(info, forward, intercept);
}
public close() {
this.server.close();
}
}

83
src/server/socksSocket.ts Normal file
View file

@ -0,0 +1,83 @@
/**
* 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';
import { LaunchOptions } from './types';
export class BrowserServerPortForwardingServer extends EventEmitter {
enabled: boolean;
private _forwardPorts: number[] = [];
private _parent: SdkObject;
private _server: SocksProxyServer;
constructor(parent: SdkObject, enabled: boolean) {
super();
this.setMaxListeners(0);
this.enabled = enabled;
this._parent = parent;
this._server = new SocksProxyServer(this._handler.bind(this));
if (enabled) {
this._server.listen(0);
debugLogger.log('proxy', `initialized server on port ${this._port()})`);
}
}
private _port(): number {
if (!this.enabled)
return 0;
return (this._server.server.address() as net.AddressInfo).port;
}
public browserLaunchOptions(): LaunchOptions | undefined {
if (!this.enabled)
return;
return {
proxy: {
server: `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 enablePortForwarding(ports: number[]): void {
if (!this.enabled)
throw new Error(`Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.`);
debugLogger.log('proxy', `enable port forwarding on ports: ${ports}`);
this._forwardPorts = ports;
}
public stop(): void {
if (!this.enabled)
return;
debugLogger.log('proxy', 'stopping server');
this._server.close();
}
}

View file

@ -22,6 +22,7 @@ const debugLoggerColorMap = {
'protocol': 34, // green
'install': 34, // green
'browser': 0, // reset
'proxy': 92, // purple
'error': 160, // red,
'channel:command': 33, // blue
'channel:response': 202, // orange

View file

@ -177,3 +177,7 @@ export function canAccessFile(file: string) {
return false;
}
}
export function isLocalIpAddress(ipAdress: string): boolean {
return ['localhost', '127.0.0.1', '::ffff:127.0.0.1', '::1'].includes(ipAdress);
}

View file

@ -0,0 +1,162 @@
/**
* 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 http from 'http';
import { contextTest as it, expect } from './config/browserTest';
import type { LaunchOptions, ConnectOptions } from '../index';
it.skip(({ mode }) => mode !== 'default');
let targetTestServer: http.Server;
let port!: number;
it.beforeAll(async ({}, test) => {
port = 30_000 + test.workerIndex * 4;
targetTestServer = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
res.end('<html><body>from-retargeted-server</body></html>');
}).listen(port);
});
it.beforeEach(() => {
delete process.env.PW_TEST_PROXY_TARGET;
});
it.afterAll(() => {
targetTestServer.close();
});
it('should forward non-forwarded requests', async ({ browserType, browserOptions, server }, workerInfo) => {
process.env.PW_TEST_PROXY_TARGET = port.toString();
let reachedOriginalTarget = false;
server.setRoute('/foo.html', async (req, res) => {
reachedOriginalTarget = true;
res.end('<html><body>original-target</body></html>');
});
const browserServer = await browserType.launchServer({
...browserOptions,
_acceptForwardedPorts: true
} as LaunchOptions);
const browser = await browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: []
} as ConnectOptions);
const page = await browser.newPage();
await page.goto(server.PREFIX + '/foo.html');
expect(await page.content()).toContain('original-target');
expect(reachedOriginalTarget).toBe(true);
await browserServer.close();
});
it('should proxy local requests', async ({ browserType, browserOptions, server }, workerInfo) => {
process.env.PW_TEST_PROXY_TARGET = port.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 browserServer = await browserType.launchServer({
...browserOptions,
_acceptForwardedPorts: true
} as LaunchOptions);
const browser = await browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: [examplePort]
} as ConnectOptions);
const page = await browser.newPage();
await page.goto(`http://localhost:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-retargeted-server');
expect(reachedOriginalTarget).toBe(false);
await browserServer.close();
});
it('should lead to the error page for forwarded requests when the connection is refused', async ({ browserType, browserOptions, browserName, isWindows}, workerInfo) => {
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const browserServer = await browserType.launchServer({
...browserOptions,
_acceptForwardedPorts: true
} as LaunchOptions);
const browser = await browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: [examplePort]
} as ConnectOptions);
const page = await browser.newPage();
const response = await page.goto(`http://localhost:${examplePort}`);
expect(response.status()).toBe(502);
await page.waitForSelector('text=Connection error');
await browserServer.close();
});
it('should lead to the error page for non-forwarded requests when the connection is refused', async ({ browserName, browserType, browserOptions, isWindows}, workerInfo) => {
process.env.PW_TEST_PROXY_TARGET = '50001';
const browserServer = await browserType.launchServer({
...browserOptions,
_acceptForwardedPorts: true
} as LaunchOptions);
const browser = await browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: []
} as ConnectOptions);
const page = await browser.newPage();
const response = await page.goto(`http://localhost:44123/non-existing-url`);
expect(response.status()).toBe(502);
await page.waitForSelector('text=Connection error');
await browserServer.close();
});
it('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();
});
it('should should not allow to connect when the server does not allow port-forwarding', async ({ browserType, browserOptions }, workerInfo) => {
const browserServer = await browserType.launchServer({
...browserOptions,
_acceptForwardedPorts: false
} as LaunchOptions);
await expect(browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: []
} as ConnectOptions)).rejects.toThrowError('browserType.connect: Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.');
await expect(browserType.connect({
wsEndpoint: browserServer.wsEndpoint(),
_forwardPorts: [1234]
} as ConnectOptions)).rejects.toThrowError('browserType.connect: Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.');
await browserServer.close();
});