Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: proxy & browser capabilities #2

Merged
merged 16 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ typings/

# Electron-Forge
out/

# proxy certificates
resources/certificates
5 changes: 5 additions & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import { getPlatform, getArch } from './src/utils';

const config: ForgeConfig = {
packagerConfig: {
asar: true,
extraResource: [
'./resources/json_output.py',
'./resources/' + getPlatform() + '/' + getArch(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will pick up only the specific os/architecture combination when packaging for a specific system

],
},
rebuildConfig: {},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
Expand Down
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
<body>
<h1>💖 Welcome to k6 studio!</h1>
<p>🎉🎉🎉🎉🎉🎉</p>

<button id='launch_proxy'>Launch Proxy</button>
<button id='stop_proxy'>Stop Proxy</button>
<br>
<button id='launch_browser'>Launch Browser</button>
<button id='stop_browser'>Stop Browser</button>
<br>
<h2>Requests</h2>
<ul id="requests_list"></ul>
<script type="module" src="/src/renderer.ts"></script>
</body>
</html>
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@electron-forge/plugin-fuses": "^7.4.0",
"@electron-forge/plugin-vite": "^7.4.0",
"@electron/fuses": "^1.8.0",
"@types/node-forge": "^1.3.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"electron": "30.0.8",
Expand All @@ -37,6 +38,8 @@
"license": "UNLICENSED",
"private": true,
"dependencies": {
"electron-squirrel-startup": "^1.0.1"
"@puppeteer/browsers": "^2.2.3",
"electron-squirrel-startup": "^1.0.1",
"node-forge": "^1.3.1"
}
}
167 changes: 167 additions & 0 deletions resources/json_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import base64
import hashlib
import json
import mitmproxy
from mitmproxy import http
from mitmproxy.tools.web.app import cert_to_json
from mitmproxy.utils.strutils import always_str
from mitmproxy.utils.emoji import emoji
from mitmproxy.dns import DNSFlow
from mitmproxy.http import HTTPFlow
from mitmproxy.tcp import TCPFlow
from mitmproxy.udp import UDPFlow


def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"""
`flow_to_json` method taken from `mitmproxy/tools/web/app.py` modified to also include content.
"""
f = {
"id": flow.id,
"intercepted": flow.intercepted,
"isReplay": flow.is_replay,
"type": flow.type,
"modified": flow.modified(),
"marked": emoji.get(flow.marked, "🔴") if flow.marked else "",
"comment": flow.comment,
"timestampCreated": flow.timestamp_created,
}

if flow.client_conn:
f["clientConn"] = {
"id": flow.client_conn.id,
"peername": flow.client_conn.peername,
"sockname": flow.client_conn.sockname,
"tlsEstablished": flow.client_conn.tls_established,
"cert": cert_to_json(flow.client_conn.certificate_list),
"sni": flow.client_conn.sni,
"cipher": flow.client_conn.cipher,
"alpn": always_str(flow.client_conn.alpn, "ascii", "backslashreplace"),
"tlsVersion": flow.client_conn.tls_version,
"timestampStart": flow.client_conn.timestamp_start,
"timestampTlsSetup": flow.client_conn.timestamp_tls_setup,
"timestampEnd": flow.client_conn.timestamp_end,
}

if flow.server_conn:
f["serverConn"] = {
"id": flow.server_conn.id,
"peername": flow.server_conn.peername,
"sockname": flow.server_conn.sockname,
"address": flow.server_conn.address,
"tlsEstablished": flow.server_conn.tls_established,
"cert": cert_to_json(flow.server_conn.certificate_list),
"sni": flow.server_conn.sni,
"cipher": flow.server_conn.cipher,
"alpn": always_str(flow.server_conn.alpn, "ascii", "backslashreplace"),
"tlsVersion": flow.server_conn.tls_version,
"timestampStart": flow.server_conn.timestamp_start,
"timestampTcpSetup": flow.server_conn.timestamp_tcp_setup,
"timestampTlsSetup": flow.server_conn.timestamp_tls_setup,
"timestampEnd": flow.server_conn.timestamp_end,
}
if flow.error:
f["error"] = flow.error.get_state()

if isinstance(flow, HTTPFlow):
content_length: int | None
content_hash: str | None

if flow.request.raw_content is not None:
content_length = len(flow.request.raw_content)
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
else:
content_length = None
content_hash = None

# we base64 encode content and let the client deal with it depending on mimetype
content = base64.b64encode(flow.request.content).decode() if flow.request.content else None

f["request"] = {
"method": flow.request.method,
"scheme": flow.request.scheme,
"host": flow.request.host,
"port": flow.request.port,
"path": flow.request.path,
"httpVersion": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)),
"contentLength": content_length,
"contentHash": content_hash,
"timestampStart": flow.request.timestamp_start,
"timestampEnd": flow.request.timestamp_end,
"prettyHost": flow.request.pretty_host,
"content": content,
}
if flow.response:
if flow.response.raw_content is not None:
content_length = len(flow.response.raw_content)
content_hash = hashlib.sha256(flow.response.raw_content).hexdigest()
else:
content_length = None
content_hash = None

# we base64 encode content and let the client deal with it depending on mimetype
content = base64.b64encode(flow.response.content).decode() if flow.response.content else None
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using base64 encoding for "stringifying" binary data since it doesn't play well with the json format. This should allow us to handle all mime-types and eventually showcase them in a better way 😃


f["response"] = {
"httpVersion": flow.response.http_version,
"statusCode": flow.response.status_code,
"reason": flow.response.reason,
"headers": tuple(flow.response.headers.items(True)),
"contentLength": content_length,
"contentHash": content_hash,
"timestampStart": flow.response.timestamp_start,
"timestampEnd": flow.response.timestamp_end,
"content": content,
}
if flow.response.data.trailers:
f["response"]["trailers"] = tuple(
flow.response.data.trailers.items(True)
)

if flow.websocket:
f["websocket"] = {
"messagesMeta": {
"contentLength": sum(
len(x.content) for x in flow.websocket.messages
),
"count": len(flow.websocket.messages),
"timestampLast": flow.websocket.messages[-1].timestamp
if flow.websocket.messages
else None,
},
"closedByClient": flow.websocket.closed_by_client,
"closeCode": flow.websocket.close_code,
"closeReason": flow.websocket.close_reason,
"timestampEnd": flow.websocket.timestamp_end,
}
elif isinstance(flow, (TCPFlow, UDPFlow)):
f["messagesMeta"] = {
"contentLength": sum(len(x.content) for x in flow.messages),
"count": len(flow.messages),
"timestampLast": flow.messages[-1].timestamp if flow.messages else None,
}
elif isinstance(flow, DNSFlow):
f["request"] = flow.request.to_json()
if flow.response:
f["response"] = flow.response.to_json()

return f


def request(flow: http.HTTPFlow) -> None:

data = flow_to_json(flow)
data = json.dumps(data)
print(data, flush=True)


def response(flow: http.HTTPFlow) -> None:

data = flow_to_json(flow)
data = json.dumps(data)
print(data, flush=True)


# we flush it as we want to catch it as soon as it's emitted
print("Proxy Started~", flush=True)
Binary file added resources/linux/x86_64/mitmdump
Binary file not shown.
Binary file added resources/mac/arm64/mitmdump
Binary file not shown.
Binary file added resources/mac/x86_64/mitmdump
Binary file not shown.
Binary file added resources/win/x86_64/mitmdump.exe
Binary file not shown.
44 changes: 44 additions & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { computeSystemExecutablePath, Browser, ChromeReleaseChannel, launch } from '@puppeteer/browsers';
import { getCertificateSPKI } from './proxy';
import { mkdtemp } from 'fs/promises';
import path from 'path';
import os from 'os';

const createUserDataDir = async () => {
return mkdtemp(path.join(os.tmpdir(), 'k6-studio-'));
};

export const launchBrowser = async () => {
const path = computeSystemExecutablePath({browser: Browser.CHROME, channel: ChromeReleaseChannel.STABLE})
console.info(`browser path: ${path}`);

const userDataDir = await createUserDataDir();
console.log(userDataDir);
const certificateSPKI = await getCertificateSPKI();

const optimizationsToDisable = [
"OptimizationGuideModelDownloading",
"OptimizationHintsFetching",
"OptimizationTargetPrediction",
"OptimizationHints",
]
const disableChromeOptimizations = `--disable-features=${optimizationsToDisable.join(',')}`

return launch({
executablePath: path,
args: [
'--new',
'--args',
`--user-data-dir=${userDataDir}`,
'--hide-crash-restore-bubble',
'--test-type',
'--no-default-browser-check',
'--no-first-run',
'--disable-background-networking',
'--disable-component-update',
'--proxy-server=http://localhost:8080', // TODO: consider passing in the proxy port
`--ignore-certificate-errors-spki-list=${certificateSPKI}`,
disableChromeOptimizations,
],
});
};
3 changes: 3 additions & 0 deletions src/electron.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare interface Window {
studio: import('../src/preload').Studio;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the trick for not having to specify manually every type

27 changes: 27 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// TODO: modify json_output.py to use CamelCase instead of snake_case
export interface Request {
headers: Array<Array<string>>;
scheme: string,
host: string,
method: string,
path: string,
content: string,
timestampStart: string,
id?: string,
response?: Response
}

export interface Response {
headers: Array<Array<string>>;
reason: string,
statusCode: number,
content: string,
path: string,
timestampStart: string,
}

export interface ProxyData {
id: string,
request: Request,
response?: Response,
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has more data but like this is enough to get us started

40 changes: 39 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import { launchProxy, type ProxyProcess } from './proxy';
import { launchBrowser } from './browser';
import { Process } from '@puppeteer/browsers';

let currentProxyProcess: ProxyProcess;
let currentBrowserProcess: Process;

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
Expand Down Expand Up @@ -58,3 +64,35 @@ app.on('activate', () => {
createWindow();
}
});

// Proxy
ipcMain.on('proxy:start', async (event) => {
console.info('proxy:start event received');
const browserWindow = BrowserWindow.fromWebContents(event.sender);
currentProxyProcess = launchProxy(browserWindow);
});

ipcMain.on('proxy:stop', async () => {
console.info('proxy:stop event received');
if (currentProxyProcess) {
currentProxyProcess.kill();
currentProxyProcess = null;
}
});

// Browser
ipcMain.on('browser:start', async (event) => {
console.info('browser:start event received');
const browserWindow = BrowserWindow.fromWebContents(event.sender);
currentBrowserProcess = await launchBrowser(browserWindow);
browserWindow.webContents.send('browser:started')
console.info('browser:started event sent');
});

ipcMain.on('browser:stop', async () => {
console.info('browser:stop event received');
if (currentBrowserProcess) {
currentBrowserProcess.close();
currentBrowserProcess = null;
}
});
45 changes: 45 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,47 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts

import { ipcRenderer, contextBridge } from "electron";
import { ProxyData } from "./lib/types";

const proxy = {
launchProxy: () => {
ipcRenderer.send('proxy:start');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is an actually confusing bit about electron. As of today per my understanding you have both the .send()/.on() and .invoke()/.handle() methods combination.

For single direction communication is totally fine to use .send() and it's actually more explicit because you are saying that you are not expecting a response.

For bi-directional communication, although you can still do the old pattern with .send() it is recommended to use the .invoke() method when you expect a response back.

So .send() it's not a legacy approach on every scenario but just in the bi-directional communication part (where you find the warning).

},
onProxyStarted: (callback: () => void) => {
ipcRenderer.on('proxy:started', () => {
callback();
});
},
stopProxy: () => {
ipcRenderer.send('proxy:stop');
},
onProxyData: (callback: (data: ProxyData) => void) => {
ipcRenderer.on('proxy:data', (_, data) => {
callback(data);
});
},
} as const;

const browser = {
launchBrowser: () => {
ipcRenderer.send('browser:start');
},
onBrowserStarted: (callback: () => void) => {
ipcRenderer.on('browser:started', () => {
callback();
});
},
stopBrowser: () => {
ipcRenderer.send('browser:stop');
},
} as const;

const studio = {
proxy: proxy,
browser: browser,
} as const;

contextBridge.exposeInMainWorld('studio', studio);

export type Studio = typeof studio;
Loading