electron、pcap实现抓包工具
前置准备
首先要搭建起来electron的开发环境,笔者采用rollup+ts的框架搭建electron开发环境,之后使用npm安装pcap模块包。 最后的package.json长这样
{
"name": "node-fiddler",
"version": "1.0.0",
"main": "main.ts",
"license": "MIT",
"scripts": {
"pack:dev": "rollup --config rollup.config.ts --environment NODE_ENV:development --configPlugin typescript --bundleConfigAsCjs",
"pack:pro": "rollup --config rollup.config.ts --environment NODE_ENV:production --configPlugin typescript --bundleConfigAsCjs",
"watch": "rollup --config rollup.config.ts --environment NODE_ENV:development --configPlugin typescript --watch --bundleConfigAsCjs",
"build": "npm run pack:pro",
"electron": "sudo electron ./dist/client.js",
"start": "npm run watch",
"rebuild": "electron-rebuild",
"test": "sudo electron proxy.js"
},
"author": "Methy42",
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.3",
"@rollup/plugin-html": "^1.0.2",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-typescript": "^11.1.2",
"@rollup/plugin-url": "^8.0.1",
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/node": "^20.4.5",
"@types/react-dom": "^18.2.7",
"@types/rollup-plugin-css-only": "^3.1.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"electron": "file:./electron",
"electron-rebuild": "^3.2.9",
"eslint": "^7.32.0",
"react-markdown": "^8.0.7",
"rollup": "^3.27.0",
"rollup-plugin-css-only": "^4.3.0",
"tslib": "^2.6.1",
"typescript": "^4.8.3"
},
"dependencies": {
"@ant-design/icons": "^5.2.5",
"antd": "^5.8.1",
"pcap": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3"
}
}
这里需要注意的是直接装完的pcap模块包不要用本地的nodejs来rebuild,会和electron中的nodejs版本对不上,要使用electron-rebuild来进行rebuild。 运行启动的时候需要以管理员身份运行,因为pcap是需要管理员权限才可以抓取网络硬件上的包的。
然后还需要一个rollup.config.ts
import { defineConfig } from "rollup";
import typescript from "@rollup/plugin-typescript";
import replace from '@rollup/plugin-replace';
import commonjs from '@rollup/plugin-commonjs';
import url from '@rollup/plugin-url';
import html from '@rollup/plugin-html';
console.log("[ start ] - env: ", process.env.NODE_ENV);
const initHTMLOption = ({
input,
output
}) => ({
input,
plugins: [
typescript(),
commonjs({
include: 'node_modules/**', // Default: undefined
extensions: ['.js', '.coffee'], // Default: [ '.js' ]
ignoreGlobal: false, // Default: false
sourceMap: false, // Default: true
ignore: ['conditional-runtime-dependency']
}),
replace({
preventAssignment: true,
'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`
}),
require("./plugins/plugin-css")(),
url(),
html()
],
output
});
export default defineConfig([
initHTMLOption({
input: "ui/main.tsx",
output: {
name: "main",
dir: "dist/ui/main",
format: 'cjs'
}
}),
{
input: "client/main.ts",
plugins: [
typescript(),
commonjs({
include: 'node_modules/**', // Default: undefined
extensions: ['.js', '.coffee'], // Default: [ '.js' ]
ignoreGlobal: false, // Default: false
sourceMap: false, // Default: true
ignore: ['conditional-runtime-dependency']
}),
replace({
preventAssignment: true,
'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`
}),
(() => {
if (process.argv.includes('--watch')) {
console.log("start to run electron plugin");
return require("./plugins/plugin-electron")();
} else {
return undefined;
}
})()
],
output: [{
file: "dist/client.js",
format: "cjs",
sourcemap: true
}]
},
]);
plugin-css.js
const { createFilter } = require("@rollup/pluginutils");
const path = require("path");
const plugin = (options = {}) => {
if (!options.transform) options.transform = (code) => code;
const styles = {};
const alwaysOutput = options.alwaysOutput ?? false;
const filter = createFilter(options.include ?? ["**/*.css"], options.exclude ?? []);
/* function to sort the css imports in order - credit to rollup-plugin-postcss */
const getRecursiveImportOrder = (id, getModuleInfo, seen = new Set()) => {
if (seen.has(id)) return [];
seen.add(id);
const result = [id];
getModuleInfo(id).importedIds.forEach((importFile) => {
result.push(...getRecursiveImportOrder(importFile, getModuleInfo, seen));
});
return result;
};
return {
name: "import-css",
/* convert the css file to a module and save the code for a file output */
transform(code, id) {
if (!filter(id)) return;
const transformedCode = (options.minify) ? minifyCSS(options.transform(code)) : options.transform(code);
/* cache the result */
if (!styles[id] || styles[id] != transformedCode) {
styles[id] = transformedCode;
}
const moduleInfo = this.getModuleInfo(id);
if (options.modules || moduleInfo.assertions?.type == "css") {
return {
code: `const sheet = new CSSStyleSheet();sheet.replaceSync(${JSON.stringify(transformedCode)});export default sheet;`,
map: { mappings: "" }
};
}
return {
code: `export default ${JSON.stringify(transformedCode)};`,
map: { mappings: "" }
};
},
/* output a css file with all css that was imported without being assigned a variable */
generateBundle(opts, bundle) {
/* collect all the imported modules for each entry file */
let modules = {};
let entryChunk = null;
for (let file in bundle) {
modules = Object.assign(modules, bundle[file].modules);
if (!entryChunk) entryChunk = bundle[file].facadeModuleId;
}
/* get the list of modules in order */
const moduleIds = getRecursiveImportOrder(entryChunk, this.getModuleInfo);
/* remove css that was imported as a string */
const css = Object.entries(styles)
.sort((a, b) => moduleIds.indexOf(a[0]) - moduleIds.indexOf(b[0]))
.map(([id, code]) => {
if (!modules[id]) return code;
})
.join("\n");
if (css.trim().length <= 0 && !alwaysOutput) return;
/* return the asset name by going through a set of possible options */
const getAssetName = () => {
const fileName = options.output ?? (opts.file ?? "bundle.js");
return `${path.basename(fileName, path.extname(fileName))}.css`;
};
/* return the asset fileName by going through a set of possible options */
const getAssetFileName = () => {
if (options.output) return options.output;
if (opts.assetFileNames) return undefined;
return `${getAssetName()}.css`;
};
this.emitFile({
type: "asset",
name: getAssetName(),
fileName: getAssetFileName(),
source: css
});
}
};
};
/* minify css */
function minifyCSS(content) {
content = content.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, "");
content = content.replace(/ {2,}/g, " ");
content = content.replace(/ ([{:}]) /g, "$1");
content = content.replace(/([{:}]) /g, "$1");
content = content.replace(/([;,]) /g, "$1");
content = content.replace(/ !/g, "!");
return content;
}
module.exports = plugin;
plugin-electron.js
const child_process = require("child_process");
function electron() {
let electronProcess = null;
return {
name: "electron",
buildStart() {
if (electronProcess) {
console.log("Killing electron process");
child_process.execSync(`pkill -P ${electronProcess.pid}`);
electronProcess = null;
}
},
closeBundle() {
if (!electronProcess) {
console.log("Starting electron process");
electronProcess = child_process.spawn("npm", ["run", "electron"], {
stdio: "inherit",
shell: true
});
}
}
}
}
module.exports = electron;
主进程代码
主进程中创建如下三个文件:
- main.ts 入口文件
- MainWindow.ts 主窗口类文件
- Capture.ts 基于pcap的网络包捕获类文件
main.ts 就是按官方的案例直接拷贝的
import { app, BrowserWindow, ipcMain } from "electron";
import MainWindow from "./MainWindow";
const mainWindow = new MainWindow();
let singleLock = app.requestSingleInstanceLock();
if (!singleLock) {
app.quit();
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
mainWindow.open();
app.on("activate", function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow.open();
}
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
app.on("second-instance", () => {
mainWindow.open();
});
MainWindow.ts 也是一个普通窗口,然后有一些ipc事件,来和主窗口中的ui进程进行通信
import path from "path";
import { BrowserWindow, app, ipcMain } from "electron";
import { getValidNetworkInterfaces } from './NetworkInterface';
import Capture from "./Capture";
export default class MainWindow {
window: BrowserWindow | null = null;
capture?: Capture;
captureCount = 0;
constructor() {
ipcMain.handle("get.network.interfaces", async () => {
return getValidNetworkInterfaces();
});
ipcMain.handle("set.device.name", async (_, { deviceName }) => {
this.capture = new Capture(deviceName);
});
ipcMain.handle("start.capture", async (_, { filter }) => {
this.capture?.start(filter);
this.capture?.onPacket((result) => {
this.window?.webContents.send("capture.on.packet", result);
})
});
}
private async create() {
if (this.window) {
return;
}
await app.whenReady();
this.window = new BrowserWindow({
width: 1000,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
nodeIntegrationInWorker: true,
webSecurity: false
}
});
this.window.loadFile(path.join(__dirname, "./ui/main/index.html"));
this.window.webContents.openDevTools({ mode: "detach" });
this.window.webContents.once("did-finish-load", () => {
if (this.window) {
// new Socket();
}
});
this.window.on("closed", () => {
this.window = null;
});
}
async open() {
if (this.window) {
this.window.restore();
this.window.show();
this.window.focus();
return;
} else {
await this.create();
}
}
close() {
if (this.window) {
this.window.close();
}
}
}
这里注册了三个ipc事件:
- get.network.interfaces 返回正常可以使用的网卡列表给ui
- set.device.name ui选定要捕获网络包的网卡名称
- start.capture ui发起开始捕获指令,之后会调用捕获类的开始方法,监听返回的网络包数据并返回给ui
getValidNetworkInterfaces方法如下
import os from 'os';
export function getValidNetworkInterfaces() {
const networkInterfaces = os.networkInterfaces();
const validInterfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = {};
for (const interfaceName in networkInterfaces) {
const interfaces = networkInterfaces[interfaceName] || [];
const validInterfacesForName = interfaces.filter(isValidInterface);
if (validInterfacesForName.length > 0) {
validInterfaces[interfaceName] = validInterfacesForName;
}
}
return validInterfaces;
}
function isValidInterface(iface: os.NetworkInterfaceInfo) {
return iface.family === 'IPv4' && !iface.internal;
}
Capture.ts 中就是基于pcap捕获网络包的主要逻辑
import pcap, { PacketWithHeader } from 'pcap';
function toIpAddr(addr: number[]) {
if (!addr || !addr.length) return '';
if (addr.length === 4) {
return addr.join('.');
}
if (addr.length === 16) {
// 将数组的每两个元素转换为两位十六进制字符串,并连接起来
const hexString = addr.map(byte => byte.toString(16).padStart(2, '0')).join('');
// 使用正则表达式按照 IPv6 地址的格式添加冒号分隔符
const ipv6String = hexString.replace(/(.{4})(?=.)/g, '$1:');
return ipv6String;
}
return '';
}
export default class Capture {
constructor (deviceName: string) {
this.deviceName = deviceName;
}
private session?: pcap.PcapSession;
public deviceName = '';
public filter = '';
private captureCount = 0;
public start(filter = '') {
this.session?.removeAllListeners();
this.session && this.session.close();
this.filter = filter;
this.session = pcap.createSession(this.deviceName, { filter: this.filter });
}
public onPacket(callback: (captureResult: CaptureResult) => void) {
this.session?.on("packet", (packetWithHeader: PacketWithHeader) => {
const packet = pcap.decode.packet(packetWithHeader);
this.captureCount++;
const result: CaptureResult = {
index: this.captureCount,
time: packet?.pcap_header?.tv_sec * 1_000,
source: toIpAddr(packet?.payload?.payload?.saddr?.addr) + (packet?.payload?.payload?.payload?.sport ? (":" + packet?.payload?.payload?.payload?.sport) : ''),
target: toIpAddr(packet?.payload?.payload?.daddr?.addr) + (packet?.payload?.payload?.payload?.dport ? (":" + packet?.payload?.payload?.payload?.dport) : ''),
protocol: packet?.payload?.payload?.payload?.constructor?.name
};
callback(result);
});
}
}
主窗口ui进程代码
基于React实现,有两个页面,一个网卡设备选择的页面和一个捕获结果的页面。 网卡设备选择页面中通过调用主进程get.network.interfaces接口来获取可以使用的网卡设备列表,选择之后再调用set.device.name接口来告诉主进程选择的网卡名称,之后会跳转到展示捕获结果的页面 捕获结果页面会监听主进程的capture.on.packet事件,来获取pcap在选择的网卡设备上捕获到的包 代码如下: main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createHashRouter } from "react-router-dom";
import NetworkInterfaceSelector from './NetworkInterfaceSelector';
import Capture from './Capture';
import './Main.css';
const router = createHashRouter([
{
path: "/",
element: <NetworkInterfaceSelector />,
},
{
path: "/capture/:deviceName",
element: <Capture />,
},
]);
const App = () => {
return (
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
};
document.body.innerHTML = '<div id="app"></div>';
const root = createRoot(document.getElementById('app') as Element);
root.render(<App />);
NetworkInterfaceSelector.tsx
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import os from 'os';
import electron from 'electron';
import { Avatar, Button, List, Result } from 'antd';
import { LinkOutlined, SmileOutlined } from '@ant-design/icons';
import SelectMenuItemPng from '../assets/select-menu-item.png'
export const NetworkInterfaceSelector: React.FC = () => {
const [networkInterfaceList, setNetworkInterfaceList] = useState<Array<{
name: string;
description: string;
checked: boolean;
}>>([]);
const navigate = useNavigate();
useEffect(() => {
electron.ipcRenderer.invoke('get.network.interfaces').then((networkInterfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>) => {
console.log(networkInterfaces);
setNetworkInterfaceList(Object.keys(networkInterfaces).map(name => {
const description = `${networkInterfaces[name]?.map(info => `
${info.family}: ${info.address}, ${info.mac}\n
`)
}`;
return {
name,
description,
checked: false
};
}));
// setNetworkInterfaceList()
});
}, [])
return (
<>
<Result
icon={<img src={SelectMenuItemPng} />}
title="请先选择一张网卡"
extra={
<List
bordered
itemLayout="horizontal"
dataSource={networkInterfaceList}
style={{ textAlign: 'left' }}
renderItem={(item, index) => (
<List.Item
onClick={() => {
console.log("click");
electron.ipcRenderer.invoke('set.device.name', { deviceName: item.name });
navigate(`/capture/${ item.name }`);
// networkInterfaceList.forEach((_, _index) => (networkInterfaceList[_index].checked = false));
// networkInterfaceList[index].checked = true;
// console.log(networkInterfaceList);
// setNetworkInterfaceList(networkInterfaceList);
// props.h
}}
style={{
cursor: 'pointer',
border: item.checked ? '1px solid #1677ff' : '',
borderRadius: item.checked ? '8px' : ''
}}
>
<List.Item.Meta
avatar={<Avatar icon={<LinkOutlined />} />}
title={<b>{item.name}</b>}
description={item.description}
/>
</List.Item>
)}
/>
}
/>
</>
);
}
export default NetworkInterfaceSelector;
Capture.tsx
import { Input, Table } from "antd";
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import electron from 'electron';
let _captureResult: CaptureResult[] = [];
export const Capture = () => {
const [filter, setFilter] = useState('');
useEffect(() => {
electron.ipcRenderer.invoke('start.capture', { filter });
electron.ipcRenderer.on('capture.on.packet', (_, result: CaptureResult) => {
_captureResult = [ ..._captureResult, result ]
setCaptureResult(_captureResult);
});
return () => {
electron.ipcRenderer.removeAllListeners('capture.on.packet');
}
}, []);
let { deviceName } = useParams();
const onSearch = (value: string) => {
setFilter(value);
electron.ipcRenderer.invoke('start.capture', { filter });
}
const [captureResult, setCaptureResult] = useState<CaptureResult[]>(_captureResult);
const columns = [
{
title: '时间',
dataIndex: 'time',
key: 'time',
width: 150,
render: (text: number) => {
return (<>{new Date(text).toLocaleString()}</>)
}
},
{
title: '来源',
dataIndex: 'source',
key: 'source',
},
{
title: '目标',
dataIndex: 'target',
key: 'target',
},
{
title: '协议',
dataIndex: 'protocol',
key: 'protocol',
}
];
return (<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<div style={{ flexShrink: '0' }}>
<h3>网卡{deviceName}中的捕获</h3>
<Input.Search
addonBefore="筛选条件"
allowClear
onSearch={onSearch}
style={{ width: 304 }}
value={filter}
/>
</div>
<Table
bordered
pagination={false}
size={'small'}
dataSource={captureResult}
rowKey={'index'}
columns={columns}
sticky={{ offsetHeader: 0 }}
style={{ marginTop: '15px', flexGrow: 1, overflow: 'scroll' }}
/>
</div>)
}
export default Capture;