init
This commit is contained in:
commit
80e72745f6
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
certs/rootCA.*
|
||||
certs/generated/
|
||||
captures/
|
||||
npm-debug.log*
|
||||
131
README.md
Normal file
131
README.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Local Node MITM Proxy
|
||||
|
||||
一个本地 HTTP/HTTPS 调试代理,行为类似 Fiddler 的核心代理能力:
|
||||
|
||||
- 代理普通 HTTP 请求并保存请求/响应明文。
|
||||
- 处理 HTTPS `CONNECT`。
|
||||
- 对配置匹配的域名执行 HTTPS MITM 解密,例如 `*.sunyard.com`。
|
||||
- 对未匹配域名只做 TCP 隧道透传,不解密。
|
||||
- 生成并安装自定义根证书。
|
||||
- 一键启用/关闭 Windows 当前用户系统代理。
|
||||
|
||||
> 仅在你拥有授权的设备、账号、网络和域名上使用。安装根证书后,本机信任此代理签发的站点证书;请妥善保管 `certs/rootCA.key.pem`,使用完及时关闭代理并移除证书。
|
||||
|
||||
## 环境
|
||||
|
||||
- Node.js 18+
|
||||
- Windows PowerShell
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"listenHost": "127.0.0.1",
|
||||
"listenPort": 8888,
|
||||
"interceptDomains": ["*.sunyard.com"],
|
||||
"captureBodies": true,
|
||||
"maxBodyBytes": 1048576,
|
||||
"certDir": "certs",
|
||||
"capturesDir": "captures"
|
||||
}
|
||||
```
|
||||
|
||||
`interceptDomains` 支持:
|
||||
|
||||
- 精确域名:`api.sunyard.com`
|
||||
- 通配子域名:`*.sunyard.com`
|
||||
- 全部解密:`*`,不建议日常使用
|
||||
|
||||
## 生成根证书
|
||||
|
||||
```powershell
|
||||
npm run cert:generate
|
||||
```
|
||||
|
||||
会生成:
|
||||
|
||||
- `certs/rootCA.key.pem`:根证书私钥,必须保密
|
||||
- `certs/rootCA.cert.pem`:PEM 根证书
|
||||
- `certs/rootCA.cert.cer`:Windows 可安装证书
|
||||
|
||||
如果需要重新生成,先删除 `certs/rootCA.*` 和 `certs/generated/`。
|
||||
|
||||
## 安装根证书到 Windows 当前用户
|
||||
|
||||
```powershell
|
||||
npm run cert:install:windows
|
||||
```
|
||||
|
||||
这会导入到 `Cert:\CurrentUser\Root`。安装后请重启浏览器或目标应用。
|
||||
|
||||
卸载根证书:
|
||||
|
||||
```powershell
|
||||
npm run cert:uninstall:windows
|
||||
```
|
||||
|
||||
## 启动代理
|
||||
|
||||
```powershell
|
||||
npm start
|
||||
```
|
||||
|
||||
默认监听:`127.0.0.1:8888`。
|
||||
|
||||
## 设置系统代理
|
||||
|
||||
启用 Windows 当前用户系统代理:
|
||||
|
||||
```powershell
|
||||
npm run proxy:enable:windows
|
||||
```
|
||||
|
||||
关闭系统代理:
|
||||
|
||||
```powershell
|
||||
npm run proxy:disable:windows
|
||||
```
|
||||
|
||||
脚本修改的是 Windows Internet Settings,通常 Chrome、Edge、系统组件和很多桌面应用会使用它。某些应用有自己的代理设置或证书信任库,需要单独配置。
|
||||
|
||||
## 手动代理设置
|
||||
|
||||
如果不想修改系统代理,可以在浏览器或应用里手动配置:
|
||||
|
||||
- HTTP 代理:`127.0.0.1:8888`
|
||||
- HTTPS 代理:`127.0.0.1:8888`
|
||||
|
||||
## 查看捕获数据
|
||||
|
||||
请求记录写入 `captures/`,每个请求一个 JSON 文件,包含:
|
||||
|
||||
- URL、方法、请求头
|
||||
- 请求体,文本为 UTF-8,二进制为 Base64
|
||||
- 响应状态、响应头
|
||||
- 响应体,自动尝试解 gzip/br/deflate
|
||||
- `mitm: true` 表示 HTTPS 已解密拦截
|
||||
|
||||
## 验证示例
|
||||
|
||||
未配置进 `interceptDomains` 的 HTTPS 域名会透传:
|
||||
|
||||
```powershell
|
||||
curl.exe --ssl-no-revoke -x http://127.0.0.1:8888 https://example.com/
|
||||
```
|
||||
|
||||
如果要测试 HTTPS 解密,把测试域名加入 `interceptDomains` 后,确保系统/客户端信任 `rootCA.cert.cer`,再通过代理访问该域名。
|
||||
|
||||
## 重要限制
|
||||
|
||||
- 不支持 HTTP/2 到客户端侧,MITM 后按 HTTP/1.1 转发。
|
||||
- 证书固定、公钥固定、私有信任库、移动端 App 等场景可能拒绝 MITM。
|
||||
- 系统代理不等于所有网络流量,非 HTTP/HTTPS 协议不会被该代理处理。
|
||||
- 仅支持基础抓包保存,没有 GUI、断点修改、重放等 Fiddler 高级功能。
|
||||
9
config.json
Normal file
9
config.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"listenHost": "127.0.0.1",
|
||||
"listenPort": 8888,
|
||||
"interceptDomains": ["*.sunyard.com"],
|
||||
"captureBodies": true,
|
||||
"maxBodyBytes": 1048576,
|
||||
"certDir": "certs",
|
||||
"capturesDir": "captures"
|
||||
}
|
||||
27
package-lock.json
generated
Normal file
27
package-lock.json
generated
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "local-mitm-proxy",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "local-mitm-proxy",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"node-forge": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "local-mitm-proxy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local HTTP/HTTPS debugging proxy with custom CA and domain-scoped MITM interception.",
|
||||
"main": "src/proxy.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/proxy.js",
|
||||
"cert:generate": "node scripts/generate-ca.js",
|
||||
"cert:install:windows": "powershell -ExecutionPolicy Bypass -File scripts/install-root-ca-windows.ps1",
|
||||
"cert:uninstall:windows": "powershell -ExecutionPolicy Bypass -File scripts/uninstall-root-ca-windows.ps1",
|
||||
"proxy:enable:windows": "powershell -ExecutionPolicy Bypass -File scripts/set-windows-proxy.ps1 -Enable",
|
||||
"proxy:disable:windows": "powershell -ExecutionPolicy Bypass -File scripts/set-windows-proxy.ps1 -Disable"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-forge": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
52
scripts/generate-ca.js
Normal file
52
scripts/generate-ca.js
Normal file
@ -0,0 +1,52 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const forge = require('node-forge');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const rawConfigText = fs.readFileSync(path.join(rootDir, 'config.json'), 'utf8');
|
||||
const configText = rawConfigText.charCodeAt(0) === 0xfeff ? rawConfigText.slice(1) : rawConfigText;
|
||||
const config = JSON.parse(configText);
|
||||
const certDir = path.resolve(rootDir, config.certDir || 'certs');
|
||||
const keyPath = path.join(certDir, 'rootCA.key.pem');
|
||||
const certPath = path.join(certDir, 'rootCA.cert.pem');
|
||||
const certDerPath = path.join(certDir, 'rootCA.cert.cer');
|
||||
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
|
||||
if (fs.existsSync(keyPath) || fs.existsSync(certPath) || fs.existsSync(certDerPath)) {
|
||||
console.error('Root CA already exists. Delete certs/rootCA.* first if you intentionally want to regenerate it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Generating 2048-bit RSA root CA...');
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = forge.util.bytesToHex(forge.random.getBytesSync(16)).replace(/^00/, '01');
|
||||
cert.validity.notBefore = new Date(Date.now() - 60 * 1000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
|
||||
cert.setSubject([
|
||||
{ name: 'commonName', value: 'Local Node MITM Proxy Root CA' },
|
||||
{ name: 'organizationName', value: 'Local Development' },
|
||||
{ shortName: 'OU', value: 'Debug Proxy' },
|
||||
]);
|
||||
cert.setIssuer(cert.subject.attributes);
|
||||
cert.setExtensions([
|
||||
{ name: 'basicConstraints', cA: true, critical: true },
|
||||
{ name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
|
||||
{ name: 'subjectKeyIdentifier' },
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
||||
const certPem = forge.pki.certificateToPem(cert);
|
||||
const certDer = Buffer.from(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), 'binary');
|
||||
|
||||
fs.writeFileSync(keyPath, keyPem, 'utf8');
|
||||
fs.writeFileSync(certPath, certPem, 'utf8');
|
||||
fs.writeFileSync(certDerPath, certDer);
|
||||
|
||||
console.log(`Created private key: ${keyPath}`);
|
||||
console.log(`Created PEM cert: ${certPath}`);
|
||||
console.log(`Created DER cert: ${certDerPath}`);
|
||||
console.log('Keep rootCA.key.pem private. Anyone with it can mint trusted certificates on this machine.');
|
||||
9
scripts/install-root-ca-windows.ps1
Normal file
9
scripts/install-root-ca-windows.ps1
Normal file
@ -0,0 +1,9 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$certPath = Join-Path $PSScriptRoot '..\certs\rootCA.cert.cer'
|
||||
$resolved = Resolve-Path $certPath
|
||||
|
||||
Write-Host "Installing root CA into CurrentUser\Root: $resolved"
|
||||
Import-Certificate -FilePath $resolved -CertStoreLocation Cert:\CurrentUser\Root | Out-Null
|
||||
Write-Host 'Installed. Restart browsers/apps that cache trust settings.'
|
||||
36
scripts/set-windows-proxy.ps1
Normal file
36
scripts/set-windows-proxy.ps1
Normal file
@ -0,0 +1,36 @@
|
||||
param(
|
||||
[switch]$Enable,
|
||||
[switch]$Disable,
|
||||
[string]$ProxyServer = '127.0.0.1:8888',
|
||||
[string]$Override = '<local>'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings'
|
||||
|
||||
if ($Enable -eq $Disable) {
|
||||
throw 'Pass exactly one of -Enable or -Disable.'
|
||||
}
|
||||
|
||||
if ($Enable) {
|
||||
Set-ItemProperty -Path $key -Name ProxyEnable -Type DWord -Value 1
|
||||
Set-ItemProperty -Path $key -Name ProxyServer -Type String -Value $ProxyServer
|
||||
Set-ItemProperty -Path $key -Name ProxyOverride -Type String -Value $Override
|
||||
Write-Host "Enabled Windows proxy: $ProxyServer"
|
||||
} else {
|
||||
Set-ItemProperty -Path $key -Name ProxyEnable -Type DWord -Value 0
|
||||
Write-Host 'Disabled Windows proxy.'
|
||||
}
|
||||
|
||||
$source = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class WinInetNative {
|
||||
[DllImport("wininet.dll", SetLastError = true)]
|
||||
public static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength);
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $source -ErrorAction SilentlyContinue | Out-Null
|
||||
[WinInetNative]::InternetSetOption([IntPtr]::Zero, 39, [IntPtr]::Zero, 0) | Out-Null
|
||||
[WinInetNative]::InternetSetOption([IntPtr]::Zero, 37, [IntPtr]::Zero, 0) | Out-Null
|
||||
20
scripts/uninstall-root-ca-windows.ps1
Normal file
20
scripts/uninstall-root-ca-windows.ps1
Normal file
@ -0,0 +1,20 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$subject = 'CN=Local Node MITM Proxy Root CA'
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
|
||||
$store.Open('ReadWrite')
|
||||
try {
|
||||
$matches = @($store.Certificates | Where-Object { $_.Subject -eq $subject })
|
||||
if ($matches.Count -eq 0) {
|
||||
Write-Host "No matching certificate found: $subject"
|
||||
exit 0
|
||||
}
|
||||
foreach ($cert in $matches) {
|
||||
Write-Host "Removing $($cert.Subject) thumbprint=$($cert.Thumbprint)"
|
||||
$store.Remove($cert)
|
||||
}
|
||||
Write-Host 'Removed matching root CA certificate(s).'
|
||||
} finally {
|
||||
$store.Close()
|
||||
}
|
||||
383
src/proxy.js
Normal file
383
src/proxy.js
Normal file
@ -0,0 +1,383 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const net = require('net');
|
||||
const tls = require('tls');
|
||||
const zlib = require('zlib');
|
||||
const forge = require('node-forge');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const config = loadConfig();
|
||||
const certDir = path.resolve(rootDir, config.certDir || 'certs');
|
||||
const capturesDir = path.resolve(rootDir, config.capturesDir || 'captures');
|
||||
const generatedCertDir = path.join(certDir, 'generated');
|
||||
|
||||
ensureDir(capturesDir);
|
||||
ensureDir(generatedCertDir);
|
||||
|
||||
const ca = loadCa();
|
||||
const secureContextCache = new Map();
|
||||
|
||||
const server = http.createServer(handleHttpRequest);
|
||||
server.on('connect', handleConnect);
|
||||
server.on('clientError', (err, socket) => {
|
||||
console.error('[clientError]', err.message);
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
});
|
||||
|
||||
server.listen(config.listenPort, config.listenHost, () => {
|
||||
console.log(`Proxy listening on ${config.listenHost}:${config.listenPort}`);
|
||||
console.log(`Intercept domains: ${(config.interceptDomains || []).join(', ') || '(none)'}`);
|
||||
console.log(`Captures: ${capturesDir}`);
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.resolve(rootDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) throw new Error(`Missing config file: ${configPath}`);
|
||||
return JSON.parse(stripBom(fs.readFileSync(configPath, 'utf8')));
|
||||
}
|
||||
|
||||
function loadCa() {
|
||||
const keyPath = path.join(certDir, 'rootCA.key.pem');
|
||||
const certPath = path.join(certDir, 'rootCA.cert.pem');
|
||||
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
||||
throw new Error('Root CA not found. Run: npm run cert:generate');
|
||||
}
|
||||
|
||||
const keyPem = fs.readFileSync(keyPath, 'utf8');
|
||||
const certPem = fs.readFileSync(certPath, 'utf8');
|
||||
return {
|
||||
keyPem,
|
||||
certPem,
|
||||
key: forge.pki.privateKeyFromPem(keyPem),
|
||||
cert: forge.pki.certificateFromPem(certPem),
|
||||
};
|
||||
}
|
||||
|
||||
function handleHttpRequest(clientReq, clientRes) {
|
||||
const parsed = parseClientRequestUrl(clientReq);
|
||||
if (!parsed) {
|
||||
clientRes.writeHead(400);
|
||||
clientRes.end('Bad proxy request URL');
|
||||
return;
|
||||
}
|
||||
|
||||
proxyRequest({
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.path,
|
||||
clientReq,
|
||||
clientRes,
|
||||
mitm: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect(req, clientSocket, head) {
|
||||
const { hostname, port } = parseHostPort(req.url, 443);
|
||||
if (!hostname) {
|
||||
clientSocket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldIntercept(hostname)) {
|
||||
tunnelConnect(hostname, port, clientSocket, head);
|
||||
return;
|
||||
}
|
||||
|
||||
interceptConnect(hostname, port, clientSocket, head);
|
||||
}
|
||||
|
||||
function tunnelConnect(hostname, port, clientSocket, head) {
|
||||
const upstreamSocket = net.connect(port, hostname, () => {
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head && head.length) upstreamSocket.write(head);
|
||||
upstreamSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(upstreamSocket);
|
||||
});
|
||||
|
||||
upstreamSocket.on('error', (err) => {
|
||||
console.error(`[tunnel:error] ${hostname}:${port} ${err.message}`);
|
||||
if (clientSocket.writable) clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
function interceptConnect(hostname, port, clientSocket, head) {
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
|
||||
const tlsSocket = new tls.TLSSocket(clientSocket, {
|
||||
isServer: true,
|
||||
secureContext: getSecureContext(hostname),
|
||||
});
|
||||
|
||||
if (head && head.length) tlsSocket.unshift(head);
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
console.error(`[mitm:tls:error] ${hostname}:${port} ${err.message}`);
|
||||
});
|
||||
|
||||
tlsSocket.once('secure', () => {
|
||||
const mitmServer = http.createServer((clientReq, clientRes) => {
|
||||
proxyRequest({
|
||||
protocol: 'https:',
|
||||
hostname,
|
||||
port,
|
||||
path: clientReq.url,
|
||||
clientReq,
|
||||
clientRes,
|
||||
mitm: true,
|
||||
});
|
||||
});
|
||||
|
||||
mitmServer.emit('connection', tlsSocket);
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest({ protocol, hostname, port, path: requestPath, clientReq, clientRes, mitm }) {
|
||||
const isHttps = protocol === 'https:';
|
||||
const headers = { ...clientReq.headers };
|
||||
headers.host = formatHostHeader(hostname, port, isHttps ? 443 : 80);
|
||||
delete headers['proxy-connection'];
|
||||
delete headers['proxy-authorization'];
|
||||
|
||||
const requestStarted = new Date();
|
||||
const requestChunks = [];
|
||||
let requestBytes = 0;
|
||||
|
||||
clientReq.on('data', (chunk) => {
|
||||
requestBytes += chunk.length;
|
||||
if (config.captureBodies && requestBytes <= config.maxBodyBytes) requestChunks.push(chunk);
|
||||
});
|
||||
|
||||
const upstreamReq = (isHttps ? https : http).request({
|
||||
hostname,
|
||||
port,
|
||||
method: clientReq.method,
|
||||
path: requestPath,
|
||||
headers,
|
||||
rejectUnauthorized: true,
|
||||
}, (upstreamRes) => {
|
||||
const responseHeaders = { ...upstreamRes.headers };
|
||||
delete responseHeaders['proxy-authenticate'];
|
||||
clientRes.writeHead(upstreamRes.statusCode || 502, responseHeaders);
|
||||
|
||||
const responseChunks = [];
|
||||
let responseBytes = 0;
|
||||
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
responseBytes += chunk.length;
|
||||
if (config.captureBodies && responseBytes <= config.maxBodyBytes) responseChunks.push(chunk);
|
||||
});
|
||||
|
||||
upstreamRes.on('end', () => {
|
||||
writeCapture({
|
||||
startedAt: requestStarted,
|
||||
protocol,
|
||||
hostname,
|
||||
port,
|
||||
path: requestPath,
|
||||
mitm,
|
||||
request: {
|
||||
method: clientReq.method,
|
||||
headers: clientReq.headers,
|
||||
body: Buffer.concat(requestChunks),
|
||||
bodyTruncated: requestBytes > config.maxBodyBytes,
|
||||
},
|
||||
response: {
|
||||
statusCode: upstreamRes.statusCode,
|
||||
headers: upstreamRes.headers,
|
||||
body: Buffer.concat(responseChunks),
|
||||
bodyTruncated: responseBytes > config.maxBodyBytes,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
upstreamRes.pipe(clientRes);
|
||||
});
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
console.error(`[proxy:error] ${clientReq.method} ${protocol}//${hostname}:${port}${requestPath} ${err.message}`);
|
||||
if (!clientRes.headersSent) clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
clientRes.end(`Bad Gateway: ${err.message}`);
|
||||
});
|
||||
|
||||
clientReq.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
function writeCapture(entry) {
|
||||
try {
|
||||
const safeHost = entry.hostname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
const stamp = entry.startedAt.toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `${stamp}_${safeHost}_${entry.request.method}.json`;
|
||||
const filePath = path.join(capturesDir, fileName);
|
||||
|
||||
const requestBody = formatBody(entry.request.headers, entry.request.body);
|
||||
const responseBody = formatBody(entry.response.headers, entry.response.body);
|
||||
const data = {
|
||||
startedAt: entry.startedAt.toISOString(),
|
||||
mitm: entry.mitm,
|
||||
url: `${entry.protocol}//${formatHostHeader(entry.hostname, entry.port, entry.protocol === 'https:' ? 443 : 80)}${entry.path}`,
|
||||
request: {
|
||||
method: entry.request.method,
|
||||
headers: entry.request.headers,
|
||||
bodyTruncated: entry.request.bodyTruncated,
|
||||
body: requestBody,
|
||||
},
|
||||
response: {
|
||||
statusCode: entry.response.statusCode,
|
||||
headers: entry.response.headers,
|
||||
bodyTruncated: entry.response.bodyTruncated,
|
||||
body: responseBody,
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFile(filePath, JSON.stringify(data, null, 2), (err) => {
|
||||
if (err) console.error(`[capture:error] ${err.message}`);
|
||||
else console.log(`[capture] ${data.request.method} ${data.url} -> ${filePath}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[capture:error] ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBody(headers, body) {
|
||||
if (!body || body.length === 0) return null;
|
||||
|
||||
const decoded = decodeBody(headers, body);
|
||||
const contentType = String(headers['content-type'] || headers['Content-Type'] || '');
|
||||
const looksText = /^text\//i.test(contentType)
|
||||
|| /json|xml|javascript|x-www-form-urlencoded/i.test(contentType)
|
||||
|| isMostlyText(decoded);
|
||||
|
||||
if (looksText) return { encoding: 'utf8', content: decoded.toString('utf8') };
|
||||
return { encoding: 'base64', content: decoded.toString('base64') };
|
||||
}
|
||||
|
||||
function decodeBody(headers, body) {
|
||||
const encoding = String(headers['content-encoding'] || headers['Content-Encoding'] || '').toLowerCase();
|
||||
try {
|
||||
if (encoding.includes('gzip')) return zlib.gunzipSync(body);
|
||||
if (encoding.includes('br')) return zlib.brotliDecompressSync(body);
|
||||
if (encoding.includes('deflate')) return zlib.inflateSync(body);
|
||||
} catch (err) {
|
||||
console.error(`[decodeBody:error] ${err.message}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function isMostlyText(buffer) {
|
||||
if (!buffer.length) return true;
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 512));
|
||||
let printable = 0;
|
||||
for (const byte of sample) {
|
||||
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126) || byte >= 128) printable += 1;
|
||||
}
|
||||
return printable / sample.length > 0.9;
|
||||
}
|
||||
|
||||
function getSecureContext(hostname) {
|
||||
const normalizedHost = hostname.toLowerCase();
|
||||
if (!secureContextCache.has(normalizedHost)) {
|
||||
const certPair = loadOrCreateLeafCertificate(normalizedHost);
|
||||
secureContextCache.set(normalizedHost, tls.createSecureContext({
|
||||
key: certPair.keyPem,
|
||||
cert: certPair.certPem,
|
||||
}));
|
||||
}
|
||||
return secureContextCache.get(normalizedHost);
|
||||
}
|
||||
|
||||
function loadOrCreateLeafCertificate(hostname) {
|
||||
const certPath = path.join(generatedCertDir, `${hostname}.cert.pem`);
|
||||
const keyPath = path.join(generatedCertDir, `${hostname}.key.pem`);
|
||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
return { certPem: fs.readFileSync(certPath, 'utf8'), keyPem: fs.readFileSync(keyPath, 'utf8') };
|
||||
}
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = randomSerialNumber();
|
||||
cert.validity.notBefore = new Date(Date.now() - 60 * 1000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 825 * 24 * 60 * 60 * 1000);
|
||||
cert.setSubject([{ name: 'commonName', value: hostname }]);
|
||||
cert.setIssuer(ca.cert.subject.attributes);
|
||||
cert.setExtensions([
|
||||
{ name: 'basicConstraints', cA: false },
|
||||
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
||||
{ name: 'extKeyUsage', serverAuth: true },
|
||||
{ name: 'subjectAltName', altNames: [{ type: 2, value: hostname }] },
|
||||
]);
|
||||
cert.sign(ca.key, forge.md.sha256.create());
|
||||
|
||||
const certPem = forge.pki.certificateToPem(cert);
|
||||
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
||||
fs.writeFileSync(certPath, certPem, 'utf8');
|
||||
fs.writeFileSync(keyPath, keyPem, 'utf8');
|
||||
return { certPem, keyPem };
|
||||
}
|
||||
|
||||
function shouldIntercept(hostname) {
|
||||
const lowerHost = hostname.toLowerCase();
|
||||
return (config.interceptDomains || []).some((pattern) => matchDomainPattern(lowerHost, String(pattern).toLowerCase()));
|
||||
}
|
||||
|
||||
function matchDomainPattern(hostname, pattern) {
|
||||
if (pattern === '*' || pattern === hostname) return true;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(1);
|
||||
return hostname.endsWith(suffix) && hostname.length > suffix.length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseClientRequestUrl(clientReq) {
|
||||
try {
|
||||
const parsed = new URL(clientReq.url);
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
};
|
||||
} catch {
|
||||
const hostHeader = clientReq.headers.host;
|
||||
if (!hostHeader) return null;
|
||||
const { hostname, port } = parseHostPort(hostHeader, 80);
|
||||
return { protocol: 'http:', hostname, port, path: clientReq.url };
|
||||
}
|
||||
}
|
||||
|
||||
function parseHostPort(value, defaultPort) {
|
||||
if (!value) return { hostname: null, port: defaultPort };
|
||||
const text = String(value).trim();
|
||||
if (text.startsWith('[')) {
|
||||
const closing = text.indexOf(']');
|
||||
const hostname = text.slice(1, closing);
|
||||
const portText = text.slice(closing + 1).replace(/^:/, '');
|
||||
return { hostname, port: Number(portText || defaultPort) };
|
||||
}
|
||||
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
if (lastColon > -1 && text.indexOf(':') === lastColon) {
|
||||
return { hostname: text.slice(0, lastColon), port: Number(text.slice(lastColon + 1) || defaultPort) };
|
||||
}
|
||||
return { hostname: text, port: defaultPort };
|
||||
}
|
||||
|
||||
function formatHostHeader(hostname, port, defaultPort) {
|
||||
return Number(port) === Number(defaultPort) ? hostname : `${hostname}:${port}`;
|
||||
}
|
||||
|
||||
function stripBom(text) {
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function randomSerialNumber() {
|
||||
return forge.util.bytesToHex(forge.random.getBytesSync(16)).replace(/^00/, '01');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user