From 80e72745f640c671d4f4587edbd0f701418c017d Mon Sep 17 00:00:00 2001 From: cheney Date: Tue, 30 Jun 2026 23:00:35 +0800 Subject: [PATCH] init --- .gitignore | 5 + README.md | 131 +++++++++ config.json | 9 + package-lock.json | 27 ++ package.json | 22 ++ scripts/generate-ca.js | 52 ++++ scripts/install-root-ca-windows.ps1 | 9 + scripts/set-windows-proxy.ps1 | 36 +++ scripts/uninstall-root-ca-windows.ps1 | 20 ++ src/proxy.js | 383 ++++++++++++++++++++++++++ 10 files changed, 694 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/generate-ca.js create mode 100644 scripts/install-root-ca-windows.ps1 create mode 100644 scripts/set-windows-proxy.ps1 create mode 100644 scripts/uninstall-root-ca-windows.ps1 create mode 100644 src/proxy.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..586ab96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +certs/rootCA.* +certs/generated/ +captures/ +npm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cca24f --- /dev/null +++ b/README.md @@ -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 高级功能。 diff --git a/config.json b/config.json new file mode 100644 index 0000000..f964eac --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "listenHost": "127.0.0.1", + "listenPort": 8888, + "interceptDomains": ["*.sunyard.com"], + "captureBodies": true, + "maxBodyBytes": 1048576, + "certDir": "certs", + "capturesDir": "captures" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..57c76f4 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1528f43 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/generate-ca.js b/scripts/generate-ca.js new file mode 100644 index 0000000..fb54fb2 --- /dev/null +++ b/scripts/generate-ca.js @@ -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.'); diff --git a/scripts/install-root-ca-windows.ps1 b/scripts/install-root-ca-windows.ps1 new file mode 100644 index 0000000..862e3cc --- /dev/null +++ b/scripts/install-root-ca-windows.ps1 @@ -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.' diff --git a/scripts/set-windows-proxy.ps1 b/scripts/set-windows-proxy.ps1 new file mode 100644 index 0000000..1c4d96a --- /dev/null +++ b/scripts/set-windows-proxy.ps1 @@ -0,0 +1,36 @@ +param( + [switch]$Enable, + [switch]$Disable, + [string]$ProxyServer = '127.0.0.1:8888', + [string]$Override = '' +) + +$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 diff --git a/scripts/uninstall-root-ca-windows.ps1 b/scripts/uninstall-root-ca-windows.ps1 new file mode 100644 index 0000000..23787ee --- /dev/null +++ b/scripts/uninstall-root-ca-windows.ps1 @@ -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() +} diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 0000000..e6dc833 --- /dev/null +++ b/src/proxy.js @@ -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'); +}