feat(browserServer): forward local ports (#6375)
This commit is contained in:
parent
c9f35fb811
commit
3f43db5cc4
|
|
@ -35,6 +35,9 @@ import { BrowserContext } from './server/browserContext';
|
||||||
import { CRBrowser } from './server/chromium/crBrowser';
|
import { CRBrowser } from './server/chromium/crBrowser';
|
||||||
import { CDPSessionDispatcher } from './dispatchers/cdpSessionDispatcher';
|
import { CDPSessionDispatcher } from './dispatchers/cdpSessionDispatcher';
|
||||||
import { PageDispatcher } from './dispatchers/pageDispatcher';
|
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 {
|
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
||||||
private _playwright: Playwright;
|
private _playwright: Playwright;
|
||||||
|
|
@ -46,20 +49,24 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
||||||
}
|
}
|
||||||
|
|
||||||
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServer> {
|
async launchServer(options: LaunchServerOptions = {}): Promise<BrowserServer> {
|
||||||
|
const portForwardingServer = new BrowserServerPortForwardingServer(this._playwright, !!options._acceptForwardedPorts);
|
||||||
// 1. Pre-launch the browser
|
// 1. Pre-launch the browser
|
||||||
const browser = await this._browserType.launch(internalCallMetadata(), {
|
const browser = await this._browserType.launch(internalCallMetadata(), {
|
||||||
...options,
|
...options,
|
||||||
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
|
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
|
||||||
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
|
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
|
||||||
env: options.env ? envObjectToArray(options.env) : undefined,
|
env: options.env ? envObjectToArray(options.env) : undefined,
|
||||||
|
...portForwardingServer.browserLaunchOptions(),
|
||||||
}, toProtocolLogger(options.logger));
|
}, toProtocolLogger(options.logger));
|
||||||
|
|
||||||
// 2. Start the server
|
// 2. Start the server
|
||||||
const delegate: PlaywrightServerDelegate = {
|
const delegate: PlaywrightServerDelegate = {
|
||||||
path: '/' + createGuid(),
|
path: '/' + createGuid(),
|
||||||
allowMultipleClients: true,
|
allowMultipleClients: options._acceptForwardedPorts ? false : true,
|
||||||
onClose: () => {},
|
onClose: () => {
|
||||||
onConnect: this._onConnect.bind(this, browser),
|
portForwardingServer.stop();
|
||||||
|
},
|
||||||
|
onConnect: this._onConnect.bind(this, browser, portForwardingServer),
|
||||||
};
|
};
|
||||||
const server = new PlaywrightServer(delegate);
|
const server = new PlaywrightServer(delegate);
|
||||||
const wsEndpoint = await server.listen(options.port);
|
const wsEndpoint = await server.listen(options.port);
|
||||||
|
|
@ -78,7 +85,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
||||||
return browserServer;
|
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 selectors = new Selectors();
|
||||||
const selectorsDispatcher = new SelectorsDispatcher(scope, selectors);
|
const selectorsDispatcher = new SelectorsDispatcher(scope, selectors);
|
||||||
const browserDispatcher = new ConnectedBrowserDispatcher(scope, browser, 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.
|
// Underlying browser did close for some reason - force disconnect the client.
|
||||||
forceDisconnect();
|
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 () => {
|
return () => {
|
||||||
|
portForwardingServer.off('incomingSocksSocket', incomingSocksSocketHandler);
|
||||||
// Cleanup contexts upon disconnect.
|
// Cleanup contexts upon disconnect.
|
||||||
browserDispatcher.cleanupContexts().catch(e => {});
|
browserDispatcher.cleanupContexts().catch(e => {});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
|
||||||
if (!connection.isDisconnected())
|
if (!connection.isDisconnected())
|
||||||
connection.dispatch(JSON.parse(event.data));
|
connection.dispatch(JSON.parse(event.data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`Playwright: Connection dispatch error`);
|
||||||
|
console.error(e);
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -150,8 +152,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.addEventListener('open', async () => {
|
ws.addEventListener('open', async () => {
|
||||||
const prematureCloseListener = (event: { reason: string }) => {
|
const prematureCloseListener = (event: { code: number, reason: string }) => {
|
||||||
reject(new Error('Server disconnected: ' + event.reason));
|
reject(new Error(`WebSocket server disconnected (${event.code}) ${event.reason}`));
|
||||||
};
|
};
|
||||||
ws.addEventListener('close', prematureCloseListener);
|
ws.addEventListener('close', prematureCloseListener);
|
||||||
const playwright = await connection.waitForObjectWithKnownName('Playwright') as Playwright;
|
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.removeEventListener('close', closeListener);
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
if (params._forwardPorts) {
|
||||||
|
try {
|
||||||
|
await playwright._channel.enablePortForwarding({
|
||||||
|
ports: params._forwardPorts,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
fulfill(browser);
|
fulfill(browser);
|
||||||
});
|
});
|
||||||
ws.addEventListener('error', event => {
|
ws.addEventListener('error', event => {
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,10 @@ import { debugLogger } from '../utils/debugLogger';
|
||||||
import { SelectorsOwner } from './selectors';
|
import { SelectorsOwner } from './selectors';
|
||||||
import { isUnderTest } from '../utils/utils';
|
import { isUnderTest } from '../utils/utils';
|
||||||
import { Android, AndroidSocket, AndroidDevice } from './android';
|
import { Android, AndroidSocket, AndroidDevice } from './android';
|
||||||
|
import { SocksSocket } from './socksSocket';
|
||||||
import { captureStackTrace } from '../utils/stackTrace';
|
import { captureStackTrace } from '../utils/stackTrace';
|
||||||
import { Artifact } from './artifact';
|
import { Artifact } from './artifact';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
class Root extends ChannelOwner<channels.Channel, {}> {
|
class Root extends ChannelOwner<channels.Channel, {}> {
|
||||||
constructor(connection: Connection) {
|
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>();
|
readonly _objects = new Map<string, ChannelOwner>();
|
||||||
private _waitingForObject = new Map<string, any>();
|
private _waitingForObject = new Map<string, any>();
|
||||||
onmessage = (message: object): void => {};
|
onmessage = (message: object): void => {};
|
||||||
|
|
@ -56,6 +58,7 @@ export class Connection {
|
||||||
private _onClose?: () => void;
|
private _onClose?: () => void;
|
||||||
|
|
||||||
constructor(onClose?: () => void) {
|
constructor(onClose?: () => void) {
|
||||||
|
super();
|
||||||
this._rootObject = new Root(this);
|
this._rootObject = new Root(this);
|
||||||
this._onClose = onClose;
|
this._onClose = onClose;
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +138,7 @@ export class Connection {
|
||||||
for (const callback of this._callbacks.values())
|
for (const callback of this._callbacks.values())
|
||||||
callback.reject(new Error(errorMessage));
|
callback.reject(new Error(errorMessage));
|
||||||
this._callbacks.clear();
|
this._callbacks.clear();
|
||||||
|
this.emit('disconnect');
|
||||||
}
|
}
|
||||||
|
|
||||||
isDisconnected() {
|
isDisconnected() {
|
||||||
|
|
@ -239,6 +243,9 @@ export class Connection {
|
||||||
case 'Worker':
|
case 'Worker':
|
||||||
result = new Worker(parent, type, guid, initializer);
|
result = new Worker(parent, type, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
case 'SocksSocket':
|
||||||
|
result = new SocksSocket(parent, type, guid, initializer);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error('Missing type ' + type);
|
throw new Error('Missing type ' + type);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { Electron } from './electron';
|
||||||
import { TimeoutError } from '../utils/errors';
|
import { TimeoutError } from '../utils/errors';
|
||||||
import { Size } from './types';
|
import { Size } from './types';
|
||||||
import { Android } from './android';
|
import { Android } from './android';
|
||||||
|
import { SocksSocket } from './socksSocket';
|
||||||
|
|
||||||
type DeviceDescriptor = {
|
type DeviceDescriptor = {
|
||||||
userAgent: string,
|
userAgent: string,
|
||||||
|
|
@ -59,6 +60,8 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel, channel
|
||||||
|
|
||||||
this._selectorsOwner = SelectorsOwner.from(initializer.selectors);
|
this._selectorsOwner = SelectorsOwner.from(initializer.selectors);
|
||||||
this.selectors._addChannel(this._selectorsOwner);
|
this.selectors._addChannel(this._selectorsOwner);
|
||||||
|
|
||||||
|
this._channel.on('incomingSocksSocket', ({socket}) => SocksSocket.from(socket));
|
||||||
}
|
}
|
||||||
|
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
|
|
|
||||||
66
src/client/socksSocket.ts
Normal file
66
src/client/socksSocket.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,11 +71,13 @@ export type LaunchPersistentContextOptions = Omit<LaunchOptionsBase & BrowserCon
|
||||||
export type ConnectOptions = {
|
export type ConnectOptions = {
|
||||||
wsEndpoint: string,
|
wsEndpoint: string,
|
||||||
headers?: { [key: string]: string; };
|
headers?: { [key: string]: string; };
|
||||||
|
_forwardPorts?: number[];
|
||||||
slowMo?: number,
|
slowMo?: number,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
logger?: Logger,
|
logger?: Logger,
|
||||||
};
|
};
|
||||||
export type LaunchServerOptions = {
|
export type LaunchServerOptions = {
|
||||||
|
_acceptForwardedPorts?: boolean,
|
||||||
channel?: channels.BrowserTypeLaunchOptions['channel'],
|
channel?: channels.BrowserTypeLaunchOptions['channel'],
|
||||||
executablePath?: string,
|
executablePath?: string,
|
||||||
args?: string[],
|
args?: string[],
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,11 @@ import { Dispatcher, DispatcherScope } from './dispatcher';
|
||||||
import { ElectronDispatcher } from './electronDispatcher';
|
import { ElectronDispatcher } from './electronDispatcher';
|
||||||
import { SelectorsDispatcher } from './selectorsDispatcher';
|
import { SelectorsDispatcher } from './selectorsDispatcher';
|
||||||
import * as types from '../server/types';
|
import * as types from '../server/types';
|
||||||
|
import { assert } from '../utils/utils';
|
||||||
|
|
||||||
export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.PlaywrightInitializer> implements channels.PlaywrightChannel {
|
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 descriptors = require('../server/deviceDescriptors') as types.Devices;
|
||||||
const deviceDescriptors = Object.entries(descriptors)
|
const deviceDescriptors = Object.entries(descriptors)
|
||||||
.map(([name, descriptor]) => ({ name, descriptor }));
|
.map(([name, descriptor]) => ({ name, descriptor }));
|
||||||
|
|
@ -38,5 +40,11 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
|
||||||
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
|
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
|
||||||
preLaunchedBrowser,
|
preLaunchedBrowser,
|
||||||
}, false);
|
}, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
src/dispatchers/socksSocketDispatcher.ts
Normal file
47
src/dispatchers/socksSocketDispatcher.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -179,7 +179,19 @@ export type PlaywrightInitializer = {
|
||||||
preLaunchedBrowser?: BrowserChannel,
|
preLaunchedBrowser?: BrowserChannel,
|
||||||
};
|
};
|
||||||
export interface PlaywrightChannel extends Channel {
|
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 -----------
|
// ----------- Selectors -----------
|
||||||
export type SelectorsInitializer = {};
|
export type SelectorsInitializer = {};
|
||||||
|
|
@ -3113,3 +3125,41 @@ export type AndroidElementInfo = {
|
||||||
scrollable: boolean,
|
scrollable: boolean,
|
||||||
selected: 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;
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,20 @@ Playwright:
|
||||||
# Only present when connecting remotely via BrowserType.connect() method.
|
# Only present when connecting remotely via BrowserType.connect() method.
|
||||||
preLaunchedBrowser: Browser?
|
preLaunchedBrowser: Browser?
|
||||||
|
|
||||||
|
commands:
|
||||||
|
|
||||||
|
enablePortForwarding:
|
||||||
|
parameters:
|
||||||
|
ports:
|
||||||
|
type: array
|
||||||
|
items: number
|
||||||
|
|
||||||
|
events:
|
||||||
|
|
||||||
|
incomingSocksSocket:
|
||||||
|
parameters:
|
||||||
|
socket: SocksSocket
|
||||||
|
|
||||||
|
|
||||||
Selectors:
|
Selectors:
|
||||||
type: interface
|
type: interface
|
||||||
|
|
@ -2523,3 +2537,29 @@ AndroidElementInfo:
|
||||||
longClickable: boolean
|
longClickable: boolean
|
||||||
scrollable: boolean
|
scrollable: boolean
|
||||||
selected: 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:
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
||||||
})),
|
})),
|
||||||
value: tOptional(tType('SerializedValue')),
|
value: tOptional(tType('SerializedValue')),
|
||||||
});
|
});
|
||||||
|
scheme.PlaywrightEnablePortForwardingParams = tObject({
|
||||||
|
ports: tArray(tNumber),
|
||||||
|
});
|
||||||
scheme.SelectorsRegisterParams = tObject({
|
scheme.SelectorsRegisterParams = tObject({
|
||||||
name: tString,
|
name: tString,
|
||||||
source: tString,
|
source: tString,
|
||||||
|
|
@ -1221,6 +1224,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
||||||
scrollable: tBoolean,
|
scrollable: tBoolean,
|
||||||
selected: tBoolean,
|
selected: tBoolean,
|
||||||
});
|
});
|
||||||
|
scheme.SocksSocketWriteParams = tObject({
|
||||||
|
data: tBinary,
|
||||||
|
});
|
||||||
|
scheme.SocksSocketErrorParams = tObject({
|
||||||
|
error: tString,
|
||||||
|
});
|
||||||
|
scheme.SocksSocketConnectedParams = tOptional(tObject({}));
|
||||||
|
scheme.SocksSocketEndParams = tOptional(tObject({}));
|
||||||
|
|
||||||
return scheme;
|
return scheme;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ import { CallMetadata } from '../instrumentation';
|
||||||
import { findChromiumChannel } from './findChromiumChannel';
|
import { findChromiumChannel } from './findChromiumChannel';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
|
type LaunchServerOptions = {
|
||||||
|
_acceptForwardedPorts?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
export class Chromium extends BrowserType {
|
export class Chromium extends BrowserType {
|
||||||
private _devtools: CRDevTools | undefined;
|
private _devtools: CRDevTools | undefined;
|
||||||
|
|
||||||
|
|
@ -119,7 +123,7 @@ export class Chromium extends BrowserType {
|
||||||
transport.send(message);
|
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 { args = [], proxy } = options;
|
||||||
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
|
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
|
||||||
if (userDataDirArg)
|
if (userDataDirArg)
|
||||||
|
|
@ -155,10 +159,14 @@ export class Chromium extends BrowserType {
|
||||||
chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`);
|
chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`);
|
||||||
}
|
}
|
||||||
chromeArguments.push(`--proxy-server=${proxy.server}`);
|
chromeArguments.push(`--proxy-server=${proxy.server}`);
|
||||||
if (proxy.bypass) {
|
const proxyBypassRules = [];
|
||||||
const patterns = proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t);
|
// https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578
|
||||||
chromeArguments.push(`--proxy-bypass-list=${patterns.join(';')}`);
|
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);
|
chromeArguments.push(...args);
|
||||||
if (isPersistent)
|
if (isPersistent)
|
||||||
|
|
|
||||||
300
src/server/socksServer.ts
Normal file
300
src/server/socksServer.ts
Normal 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
83
src/server/socksSocket.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ const debugLoggerColorMap = {
|
||||||
'protocol': 34, // green
|
'protocol': 34, // green
|
||||||
'install': 34, // green
|
'install': 34, // green
|
||||||
'browser': 0, // reset
|
'browser': 0, // reset
|
||||||
|
'proxy': 92, // purple
|
||||||
'error': 160, // red,
|
'error': 160, // red,
|
||||||
'channel:command': 33, // blue
|
'channel:command': 33, // blue
|
||||||
'channel:response': 202, // orange
|
'channel:response': 202, // orange
|
||||||
|
|
|
||||||
|
|
@ -177,3 +177,7 @@ export function canAccessFile(file: string) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLocalIpAddress(ipAdress: string): boolean {
|
||||||
|
return ['localhost', '127.0.0.1', '::ffff:127.0.0.1', '::1'].includes(ipAdress);
|
||||||
|
}
|
||||||
|
|
|
||||||
162
tests/portForwardingServer.spec.ts
Normal file
162
tests/portForwardingServer.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue