├── web-react ├── src │ ├── types │ │ ├── traffic.js │ │ ├── socket-io-client.d.ts │ │ └── traffic.ts │ ├── lib │ │ ├── utils.js │ │ └── utils.ts │ ├── env.d.ts │ ├── providers │ │ ├── app-provider.js │ │ ├── app-provider.tsx │ │ ├── query-client.js │ │ └── query-client.tsx │ ├── App.tsx │ ├── App.js │ ├── main.js │ ├── main.tsx │ ├── components │ │ └── ui │ │ │ ├── label.js │ │ │ ├── separator.js │ │ │ ├── input.js │ │ │ ├── label.tsx │ │ │ ├── separator.tsx │ │ │ ├── input.tsx │ │ │ ├── switch.js │ │ │ ├── badge.js │ │ │ ├── switch.tsx │ │ │ ├── badge.tsx │ │ │ ├── card.js │ │ │ ├── button.js │ │ │ ├── card.tsx │ │ │ ├── button.tsx │ │ │ ├── select.js │ │ │ └── select.tsx │ ├── routes │ │ ├── index.tsx │ │ └── index.js │ ├── layouts │ │ ├── app-layout.js │ │ └── app-layout.tsx │ ├── index.css │ ├── stores │ │ ├── use-traffic-store.js │ │ └── use-traffic-store.ts │ ├── services │ │ ├── traffic-service.js │ │ ├── traffic-service.ts │ │ ├── websocket.ts │ │ └── websocket.js │ ├── hooks │ │ ├── use-traffic-stream.ts │ │ └── use-traffic-stream.js │ └── pages │ │ └── traffic │ │ ├── index.js │ │ └── index.tsx ├── vite.config.d.ts ├── postcss.config.js ├── tailwind.config.d.ts ├── tsconfig.node.json ├── index.html ├── components.json ├── tsconfig.json ├── vite.config.ts ├── tsconfig.tsbuildinfo ├── vite.config.js ├── package.json ├── README.md ├── tailwind.config.ts └── tailwind.config.js ├── .idea ├── vcs.xml ├── .gitignore ├── modules.xml └── ProxyCraft.iml ├── certs ├── README.md ├── install_windows.go ├── install_darwin.go ├── install_linux.go └── install.go ├── .vscode └── launch.json ├── .gitignore ├── TEST.md ├── LICENSE ├── .github └── workflows │ └── go.yml ├── proxycraft-ca.pem ├── cli ├── parser_test.go └── parser.go ├── proxycraft-ca-key.pem ├── TEST-PROXY.md ├── .goreleaser.yaml ├── main_test.go ├── proxy ├── server.go ├── handlers │ ├── web_handler_test.go │ └── cli_handler.go ├── http2_handler.go ├── server_test.go ├── http_handler.go ├── event_handler.go ├── roundtrip.go └── sse_handler.go ├── go.mod ├── prd.md ├── main.go └── harlogger └── har.go /web-react/src/types/traffic.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /web-react/src/types/socket-io-client.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'socket.io-client'; 2 | -------------------------------------------------------------------------------- /web-react/vite.config.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import("vite").UserConfig; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /web-react/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web-react/tailwind.config.d.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | declare const config: Config; 3 | export default config; 4 | -------------------------------------------------------------------------------- /web-react/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | export function cn(...inputs) { 4 | return twMerge(clsx(inputs)); 5 | } 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web-react/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /web-react/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare interface ImportMetaEnv { 4 | readonly VITE_PROXYCRAFT_SOCKET_URL?: string; 5 | } 6 | 7 | declare interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /web-react/src/providers/app-provider.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { QueryProvider } from './query-client'; 3 | export function AppProvider({ children }) { 4 | return _jsx(QueryProvider, { children: children }); 5 | } 6 | -------------------------------------------------------------------------------- /web-react/src/providers/app-provider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { QueryProvider } from './query-client'; 4 | 5 | export function AppProvider({ children }: PropsWithChildren) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /web-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "skipLibCheck": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts", "tailwind.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /certs/README.md: -------------------------------------------------------------------------------- 1 | # CA 2 | 3 | 本目录代码主要用于生成和安装CA证书到系统证书库,用于支持https的MITM代理模式。 4 | 5 | ## 支持平台 6 | 7 | - macOS:使用`security add-trusted-cert`写入系统钥匙串。 8 | - Linux:在支持`update-ca-certificates`或`update-ca-trust`的发行版上写入系统根证书目录,并刷新信任列表。 9 | - Windows:通过`certutil`将证书导入到本地计算机的`Root`存储。 10 | 11 | 安装/卸载系统根证书需要管理员或root权限,运行失败时请确认使用了具有足够权限的终端。 12 | -------------------------------------------------------------------------------- /web-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | import { Toaster } from 'sonner'; 3 | 4 | import { AppRoutes } from '@/routes'; 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /web-react/src/App.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { Toaster } from 'sonner'; 4 | import { AppRoutes } from '@/routes'; 5 | function App() { 6 | return (_jsxs(BrowserRouter, { children: [_jsx(AppRoutes, {}), _jsx(Toaster, {})] })); 7 | } 8 | export default App; 9 | -------------------------------------------------------------------------------- /.idea/ProxyCraft.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /web-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ProxyCraft Console 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /web-react/src/main.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import App from './App'; 5 | import '@/index.css'; 6 | import { AppProvider } from '@/providers/app-provider'; 7 | ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(AppProvider, { children: _jsx(App, {}) }) })); 8 | -------------------------------------------------------------------------------- /web-react/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import '@/index.css'; 5 | import { AppProvider } from '@/providers/app-provider'; 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "web", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/main.go", 13 | "args": ["-mode", "web", "-mitm", "-v"] 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /web-react/src/providers/query-client.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { useState } from 'react'; 3 | import { QueryClient, QueryClientProvider, } from '@tanstack/react-query'; 4 | export function QueryProvider({ children }) { 5 | const [client] = useState(() => new QueryClient({ 6 | defaultOptions: { 7 | queries: { 8 | refetchOnWindowFocus: false, 9 | retry: 1, 10 | }, 11 | }, 12 | })); 13 | return _jsx(QueryClientProvider, { client: client, children: children }); 14 | } 15 | -------------------------------------------------------------------------------- /web-react/src/providers/query-client.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from 'react'; 2 | import { 3 | QueryClient, 4 | QueryClientProvider, 5 | } from '@tanstack/react-query'; 6 | 7 | export function QueryProvider({ children }: PropsWithChildren) { 8 | const [client] = useState( 9 | () => 10 | new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | refetchOnWindowFocus: false, 14 | retry: 1, 15 | }, 16 | }, 17 | }) 18 | ); 19 | 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /web-react/src/components/ui/label.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import { cva } from "class-variance-authority"; 5 | import { cn } from "@/lib/utils"; 6 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"); 7 | const Label = React.forwardRef(({ className, ...props }, ref) => (_jsx(LabelPrimitive.Root, { ref: ref, className: cn(labelVariants(), className), ...props }))); 8 | Label.displayName = LabelPrimitive.Root.displayName; 9 | export { Label }; 10 | -------------------------------------------------------------------------------- /web-react/src/components/ui/separator.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 4 | import { cn } from "@/lib/utils"; 5 | const Separator = React.forwardRef(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (_jsx(SeparatorPrimitive.Root, { ref: ref, decorative: decorative, orientation: orientation, className: cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className), ...props }))); 6 | Separator.displayName = SeparatorPrimitive.Root.displayName; 7 | export { Separator }; 8 | -------------------------------------------------------------------------------- /web-react/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route, Navigate } from 'react-router-dom'; 2 | 3 | import { AppLayout } from '@/layouts/app-layout'; 4 | import { TrafficPage } from '@/pages/traffic'; 5 | import { SettingsPage } from '@/pages/settings'; 6 | 7 | export function AppRoutes() { 8 | return ( 9 | 10 | }> 11 | } /> 12 | } /> 13 | } /> 14 | } /> 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Added by goreleaser init: 2 | dist/ 3 | cover.out 4 | coverage.html 5 | coverage.out 6 | har.json 7 | ProxyCraft 8 | traffic.har 9 | .idea/AugmentWebviewStateStore.xml 10 | harlogger/test_output.har 11 | proxy/proxycraft-ca-key.pem 12 | .idea/CopilotSideBarWebPersist.xml 13 | proxy/proxycraft-ca.pem 14 | proxy/server.go.bak 15 | proxy/server.go.original 16 | 17 | # 前端相关 18 | node_modules/ 19 | web/node_modules/ 20 | web/dist/ 21 | api/dist/ 22 | web/.vite/ 23 | 24 | # 编译输出 25 | *.log 26 | *.har 27 | __debug_bin* 28 | api/.DS_Store 29 | web/package-lock.json 30 | certs/*.pem 31 | debug_websocket 32 | test_fix_complete 33 | .gitignore 34 | test_websocket_api 35 | test_v3 36 | .gitignore 37 | test_websocket_debug 38 | -------------------------------------------------------------------------------- /web-react/src/components/ui/input.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import { cn } from "@/lib/utils"; 4 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 5 | return (_jsx("input", { type: type, className: cn("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className), ref: ref, ...props })); 6 | }); 7 | Input.displayName = "Input"; 8 | export { Input }; 9 | -------------------------------------------------------------------------------- /web-react/src/routes/index.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { Routes, Route, Navigate } from 'react-router-dom'; 3 | import { AppLayout } from '@/layouts/app-layout'; 4 | import { TrafficPage } from '@/pages/traffic'; 5 | import { SettingsPage } from '@/pages/settings'; 6 | export function AppRoutes() { 7 | return (_jsx(Routes, { children: _jsxs(Route, { element: _jsx(AppLayout, {}), children: [_jsx(Route, { index: true, element: _jsx(Navigate, { to: "/traffic", replace: true }) }), _jsx(Route, { path: "/traffic", element: _jsx(TrafficPage, {}) }), _jsx(Route, { path: "/settings", element: _jsx(SettingsPage, {}) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/traffic", replace: true }) })] }) })); 8 | } 9 | -------------------------------------------------------------------------------- /web-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "allowJs": false, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": false, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "skipLibCheck": true, 16 | "jsx": "react-jsx", 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | }, 21 | "types": ["vite/client"] 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /web-react/src/types/traffic.ts: -------------------------------------------------------------------------------- 1 | export type TrafficEntry = { 2 | id: string; 3 | startTime?: string; 4 | endTime?: string; 5 | duration: number; 6 | host: string; 7 | method: string; 8 | url: string; 9 | path: string; 10 | statusCode: number; 11 | contentType: string; 12 | contentSize: number; 13 | isSSE: boolean; 14 | isSSECompleted: boolean; 15 | isHTTPS: boolean; 16 | error?: string; 17 | }; 18 | 19 | export type HttpMessage = { 20 | headers: Record; 21 | body?: unknown; 22 | }; 23 | 24 | export type TrafficDetail = { 25 | request?: HttpMessage; 26 | response?: HttpMessage; 27 | }; 28 | 29 | export type ConnectionState = { 30 | connected: boolean; 31 | transport: string; 32 | error: string | null; 33 | }; 34 | -------------------------------------------------------------------------------- /web-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'node:path'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, 'src'), 10 | }, 11 | }, 12 | server: { 13 | port: 5173, 14 | open: true, 15 | proxy: { 16 | '/api': { 17 | target: 'http://localhost:8081', 18 | changeOrigin: true, 19 | }, 20 | '/socket.io': { 21 | target: 'http://localhost:8081', 22 | ws: true, 23 | changeOrigin: true, 24 | }, 25 | }, 26 | }, 27 | build: { 28 | outDir: path.resolve(__dirname, '../api/dist'), 29 | emptyOutDir: true, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /web-react/tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/components/request-response-panel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/switch.tsx","./src/hooks/use-traffic-stream.ts","./src/layouts/app-layout.tsx","./src/lib/utils.ts","./src/pages/settings/index.tsx","./src/pages/traffic/index.tsx","./src/providers/app-provider.tsx","./src/providers/query-client.tsx","./src/routes/index.tsx","./src/services/traffic-service.ts","./src/services/websocket.ts","./src/stores/use-traffic-store.ts","./src/types/socket-io-client.d.ts","./src/types/traffic.ts"],"version":"5.9.2"} -------------------------------------------------------------------------------- /web-react/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | const labelVariants = cva( 7 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 8 | ); 9 | 10 | const Label = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef & 13 | VariantProps 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )); 21 | Label.displayName = LabelPrimitive.Root.displayName; 22 | 23 | export { Label }; 24 | -------------------------------------------------------------------------------- /web-react/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'node:path'; 4 | export default defineConfig({ 5 | plugins: [react()], 6 | resolve: { 7 | alias: { 8 | '@': path.resolve(__dirname, 'src'), 9 | }, 10 | }, 11 | server: { 12 | port: 5173, 13 | open: true, 14 | proxy: { 15 | '/api': { 16 | target: 'http://localhost:8081', 17 | changeOrigin: true, 18 | }, 19 | '/socket.io': { 20 | target: 'http://localhost:8081', 21 | ws: true, 22 | changeOrigin: true, 23 | }, 24 | }, 25 | }, 26 | build: { 27 | outDir: path.resolve(__dirname, '../api/dist'), 28 | emptyOutDir: true, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /TEST.md: -------------------------------------------------------------------------------- 1 | # 测试用例的构造 2 | 3 | ## 支持协议 4 | - 简单的http请求可以响应:`curl -x http://127.0.0.1:8080 -v http://ip.bmh.im` 5 | - 简单的https请求可以响应:`curl -x http://127.0.0.1:8080 -v https://ip.bmh.im` 6 | - 支持http2:能够正常返回。`curl -x http://127.0.0.1:8080 -v --http2 https://ip.bmh.im` 7 | - 支持sse:能够看到流失输出而不是一次性输出。`curl -x http://127.0.0.1:8080 http://127.0.0.1:1234/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "qwen3-4b","stream": true,"messages": [{"role": "user","content": "天空为什么是蓝色的?/no_think"}]}'` 8 | 9 | ## 支持上层代理 10 | - 上层代理才能访问的网站:`curl -v -x http://127.0.0.1:8080 https://www.google.com` 11 | 12 | ## 其他情况 13 | - https非443端口,比如8888:`curl -x http://127.0.0.1:8080 https://ip.bmh.im:8888/` 14 | - 非http协议,比如25对应的smtp协议:`ncat --proxy 127.0.0.1:8080 --proxy-type http smtp.qq.com 25` 目前burp也不支持 15 | 16 | ## ui测试 17 | - 测试json的格式化和高亮:`curl -x http://127.0.0.1:8080 -v --http2 http://ip.bmh.im/h` -------------------------------------------------------------------------------- /web-react/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Separator = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >( 9 | ( 10 | { className, orientation = "horizontal", decorative = true, ...props }, 11 | ref 12 | ) => ( 13 | 24 | ) 25 | ); 26 | Separator.displayName = SeparatorPrimitive.Root.displayName; 27 | 28 | export { Separator }; 29 | -------------------------------------------------------------------------------- /web-react/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | } 21 | ); 22 | Input.displayName = "Input"; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /web-react/src/components/ui/switch.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 4 | import { cn } from "@/lib/utils"; 5 | const Switch = React.forwardRef(({ className, ...props }, ref) => (_jsx(SwitchPrimitives.Root, { className: cn("peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", className), ...props, ref: ref, children: _jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0") }) }))); 6 | Switch.displayName = SwitchPrimitives.Root.displayName; 7 | export { Switch }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LubyRuffy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web-react/src/components/ui/badge.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { cva } from 'class-variance-authority'; 3 | import { cn } from '@/lib/utils'; 4 | const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { 5 | variants: { 6 | variant: { 7 | default: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 8 | success: 'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', 9 | warning: 'border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400', 10 | destructive: 'border-transparent bg-destructive/10 text-destructive dark:text-destructive-foreground', 11 | outline: 'text-foreground', 12 | }, 13 | }, 14 | defaultVariants: { 15 | variant: 'default', 16 | }, 17 | }); 18 | export function Badge({ className, variant, ...props }) { 19 | return _jsx("div", { className: cn(badgeVariants({ variant }), className), ...props }); 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.24.2' 23 | 24 | - name: Set up Node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: '20.x' 28 | 29 | - name: Install dependencies 30 | run: sh -c "cd web && npm install" 31 | 32 | - name: Build web 33 | run: sh -c "cd web && npm run build" 34 | 35 | - name: Build 36 | run: go build -v ./... 37 | 38 | - name: Test 39 | run: go test -v ./... 40 | 41 | - name: "Create release on GitHub" 42 | uses: goreleaser/goreleaser-action@v3 43 | with: 44 | args: "release --clean" 45 | version: latest 46 | env: 47 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 48 | -------------------------------------------------------------------------------- /web-react/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Switch = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >(({ className, ...props }, ref) => ( 9 | 17 | 22 | 23 | )); 24 | Switch.displayName = SwitchPrimitives.Root.displayName; 25 | 26 | export { Switch }; 27 | -------------------------------------------------------------------------------- /web-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxycraft-web-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-label": "^2.1.7", 13 | "@radix-ui/react-select": "^2.2.6", 14 | "@radix-ui/react-separator": "^1.1.7", 15 | "@radix-ui/react-slot": "^1.0.2", 16 | "@radix-ui/react-switch": "^1.2.6", 17 | "@tanstack/react-query": "^5.62.7", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.0", 20 | "lucide-react": "^0.473.0", 21 | "npm": "^11.6.0", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "react-router-dom": "^6.23.0", 25 | "socket.io-client": "^4.8.1", 26 | "sonner": "^2.0.7", 27 | "tailwind-merge": "^2.2.1", 28 | "zustand": "^4.5.5" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^22.7.5", 32 | "@types/react": "^18.3.5", 33 | "@types/react-dom": "^18.3.0", 34 | "@vitejs/plugin-react": "^4.3.1", 35 | "autoprefixer": "^10.4.20", 36 | "postcss": "^8.4.47", 37 | "tailwindcss": "^3.4.13", 38 | "typescript": "^5.5.4", 39 | "vite": "^5.4.8" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /proxycraft-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDWjCCAkKgAwIBAgIBATANBgkqhkiG9w0BAQsFADA/MSAwHgYDVQQKExdQcm94 3 | eUNyYWZ0IEdlbmVyYXRlZCBDQTEbMBkGA1UEAxMSUHJveHlDcmFmdCBSb290IENB 4 | MB4XDTI1MDUxNDEzMTk0OVoXDTM1MDUxNDEzMTk0OVowPzEgMB4GA1UEChMXUHJv 5 | eHlDcmFmdCBHZW5lcmF0ZWQgQ0ExGzAZBgNVBAMTElByb3h5Q3JhZnQgUm9vdCBD 6 | QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALj+9ZYnfI8dqHP1D+sN 7 | mbpE+UTyaQ+AbU2SD5q7WFfg+HbJsgLaYSmGIRw0IV5UYdCLdy0P+wbTAO+MMguD 8 | 9KR1QvHVn/opbu21LFA/Z3iE3toXcCF9frRnq2c98qYR/cx/o+6vvasd7f7IyDTL 9 | gbeYHvbRPTF/VlTriRNyZNTUoqfChy9JyWo1q6rMXrPaBDm2beE+AXqsHr7p0d/D 10 | /jYWV97svOFbcC+WEnpPONHm4qwgo4s4vz8up67MiHJ0PSXyfiHJAaxorhAHg3O6 11 | icw078pZqw8cPaCMEL7ihoNjwNrodMph0UWbhNVCudBKtiI73AjWtqA5qrKIXf1p 12 | +N8CAwEAAaNhMF8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdJQQWMBQGCCsGAQUFBwMB 13 | BggrBgEFBQcDAjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSY3KYlroeYgI5/ 14 | iaj1FfStMRwsDDANBgkqhkiG9w0BAQsFAAOCAQEALEZ32EvdhM8rgPrvvOBk8kYp 15 | cGk66T3cuXCsKt++6ygQ3p/esKG3o9nMgx9U4eT3lXEApPfBCaIADyEAXOaX1pWr 16 | 7HpJT0AXbP39/FDilwT1sCmsxiEfSCQm3zO4VECNqOx+nHfFcnxum95QfdSu4qia 17 | YzQsU6Ncz2bB83TuFRt6H4MwtVfFiMv7DeHbaWC4lqp8+kEKBp4SROg7cBSmX9De 18 | 4KpsArPlw5zylyJ4H4hFLIW5ZUhTMHQPqsIxtgmILmiulDAco0XoKvblltkSpgCZ 19 | 9BKXC2+e21ioHFz2KdlczMA5gKyh7OCQgbq3oNytmiGAzhcKT8jf8t2qodHZ9g== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /web-react/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 12 | success: 'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', 13 | warning: 'border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400', 14 | destructive: 'border-transparent bg-destructive/10 text-destructive dark:text-destructive-foreground', 15 | outline: 'text-foreground', 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: 'default', 20 | }, 21 | } 22 | ); 23 | 24 | export interface BadgeProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | export function Badge({ className, variant, ...props }: BadgeProps) { 29 | return
; 30 | } 31 | -------------------------------------------------------------------------------- /cli/parser_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "flag" // 修复缺失的导入 6 | "io" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestParseFlags(t *testing.T) { 15 | // 测试默认参数 16 | cfg := ParseFlags() 17 | assert.NotNil(t, cfg) 18 | assert.Equal(t, "127.0.0.1", cfg.ListenHost) 19 | assert.Equal(t, 8080, cfg.ListenPort) 20 | 21 | // 测试自定义参数 22 | os.Args = []string{"cmd", "-l=192.168.1.1", "-p=9090"} 23 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) // 重置flag解析 24 | cfg = ParseFlags() 25 | assert.Equal(t, "192.168.1.1", cfg.ListenHost) // 修正之前的错误断言 26 | assert.Equal(t, "192.168.1.1", cfg.ListenHost) // 修正之前的错误断言 27 | } 28 | 29 | // TestPrintHelp tests the PrintHelp function. 30 | func TestPrintHelp(t *testing.T) { 31 | // 保存原始的os.Stderr并在测试后恢复 32 | oldStderr := os.Stderr 33 | defer func() { os.Stderr = oldStderr }() 34 | 35 | // 创建一个新的管道,并将其输出端连接到os.Stderr 36 | r, w, _ := os.Pipe() 37 | os.Stderr = w 38 | 39 | // 调用PrintHelp函数 40 | PrintHelp() 41 | 42 | // 关闭写入端并恢复原始的os.Stderr 43 | w.Close() 44 | os.Stderr = oldStderr 45 | 46 | // 读取捕获的输出 47 | var buf bytes.Buffer 48 | io.Copy(&buf, r) 49 | output := buf.String() 50 | 51 | // 验证输出包含帮助信息 52 | if !strings.Contains(output, "Usage") { 53 | t.Errorf("Help output should contain 'Usage', but got:\n%s", output) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web-react/src/components/ui/card.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from 'react'; 3 | import { cn } from '@/lib/utils'; 4 | const Card = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn('rounded-xl border bg-card text-card-foreground shadow-sm', className), ...props }))); 5 | Card.displayName = 'Card'; 6 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn('flex flex-col space-y-1.5 p-6', className), ...props }))); 7 | CardHeader.displayName = 'CardHeader'; 8 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx("h3", { ref: ref, className: cn('text-lg font-semibold leading-none tracking-tight', className), ...props }))); 9 | CardTitle.displayName = 'CardTitle'; 10 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx("p", { ref: ref, className: cn('text-sm text-muted-foreground', className), ...props }))); 11 | CardDescription.displayName = 'CardDescription'; 12 | const CardContent = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn('p-6 pt-0', className), ...props }))); 13 | CardContent.displayName = 'CardContent'; 14 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn('flex items-center p-6 pt-0', className), ...props }))); 15 | CardFooter.displayName = 'CardFooter'; 16 | export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; 17 | -------------------------------------------------------------------------------- /web-react/src/layouts/app-layout.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { Outlet, NavLink, useNavigate } from 'react-router-dom'; 3 | import { Button } from '@/components/ui/button'; 4 | import { cn } from '@/lib/utils'; 5 | import { Settings } from 'lucide-react'; 6 | const navItems = [ 7 | { label: '流量列表', to: '/traffic' }, 8 | ]; 9 | export function AppLayout() { 10 | const navigate = useNavigate(); 11 | const handleSettingsClick = () => { 12 | navigate('/settings'); 13 | }; 14 | return (_jsxs("div", { className: "flex min-h-screen flex-col bg-background text-foreground", children: [_jsx("header", { className: "border-b bg-card/70 backdrop-blur", children: _jsxs("div", { className: "mx-auto flex h-14 w-full items-center justify-between px-6", children: [_jsxs("div", { className: "flex items-center gap-6", children: [_jsx("span", { className: "text-sm font-semibold tracking-tight", children: "ProxyCraft Console" }), _jsx("nav", { className: "flex items-center gap-3 text-sm text-muted-foreground", children: navItems.map((item) => (_jsx(NavLink, { to: item.to, className: ({ isActive }) => cn('rounded-md px-2 py-1 transition-colors hover:text-foreground', isActive && 'bg-secondary text-secondary-foreground'), end: true, children: item.label }, item.to))) })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: handleSettingsClick, children: [_jsx(Settings, { className: "h-4 w-4 mr-2" }), "\u8BBE\u7F6E"] })] }) }), _jsx("main", { className: "flex-1", children: _jsx(Outlet, {}) })] })); 15 | } 16 | -------------------------------------------------------------------------------- /web-react/src/components/ui/button.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import * as React from 'react'; 3 | import { Slot } from '@radix-ui/react-slot'; 4 | import { cva } from 'class-variance-authority'; 5 | import { cn } from '@/lib/utils'; 6 | const buttonVariants = cva('inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background', { 7 | variants: { 8 | variant: { 9 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 10 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 11 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 12 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 13 | ghost: 'hover:bg-accent hover:text-accent-foreground', 14 | link: 'text-primary underline-offset-4 hover:underline', 15 | }, 16 | size: { 17 | default: 'h-9 px-4 py-2', 18 | sm: 'h-8 rounded-md px-3', 19 | lg: 'h-10 rounded-md px-8', 20 | icon: 'h-9 w-9', 21 | }, 22 | }, 23 | defaultVariants: { 24 | variant: 'default', 25 | size: 'default', 26 | }, 27 | }); 28 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 29 | const Comp = asChild ? Slot : 'button'; 30 | return (_jsx(Comp, { className: cn(buttonVariants({ variant, size, className })), ref: ref, ...props })); 31 | }); 32 | Button.displayName = 'Button'; 33 | export { Button, buttonVariants }; 34 | -------------------------------------------------------------------------------- /proxycraft-ca-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4/vWWJ3yPHahz 3 | 9Q/rDZm6RPlE8mkPgG1Nkg+au1hX4Ph2ybIC2mEphiEcNCFeVGHQi3ctD/sG0wDv 4 | jDILg/SkdULx1Z/6KW7ttSxQP2d4hN7aF3AhfX60Z6tnPfKmEf3Mf6Pur72rHe3+ 5 | yMg0y4G3mB720T0xf1ZU64kTcmTU1KKnwocvSclqNauqzF6z2gQ5tm3hPgF6rB6+ 6 | 6dHfw/42Flfe7LzhW3AvlhJ6TzjR5uKsIKOLOL8/LqeuzIhydD0l8n4hyQGsaK4Q 7 | B4NzuonMNO/KWasPHD2gjBC+4oaDY8Da6HTKYdFFm4TVQrnQSrYiO9wI1ragOaqy 8 | iF39afjfAgMBAAECggEAWZBOdAjf/CX+tU0zDLjD8XONPz0hcjjaMlEBrwb+yWUI 9 | xfH2df0mR0VB6HawpQuzY5Tx+OYgwXgMnu1AGWUkXy7KX2zui93db8ZkEuHvhWDG 10 | x83LItuwKUV7rXtHa/GP757oThnVeO//lne8vhq8zZcffp1kw/8DXA/AiKK8gHEy 11 | 9qD7Vn28npdcQrjTd1kdopEmMevsmr10rU90s0YbqT15Oci2jxXCfjFesaDbpPZz 12 | aVpXIV+v6UBna7LYvFADjQO9SY7XCmOk8ymXi53j9ns6BpxX9FchR953mji5p4CA 13 | iktA/YPUhJV8DsAVWoDEMuIae1hzhbCdm8klhg/gAQKBgQDQVGd6zu0WJlU009nT 14 | oXSpApJh/O7tX7cNUzzu95TY7WjpX3SROE5hiTLm/TkYzk6ORRW6QZBC1H/u4k2k 15 | XzK1NEDKamDqHoayUkkEDhtjvbkJecZ8PuW5uhz8PbSD6NdsZfAOm1lSJW97I8NL 16 | EJ8fwTDTIMuf/RnnvIy125uI3wKBgQDjU7P6TK7PjNCN/r1fFwngs0xI/iJ4+m7x 17 | 4iZMaR4bGU6mMxhZKtf4lV1DyElv+w7XpL49+7g1eO/Lq3hHilBzUjzIC9Ujgnf2 18 | eWB2sIZHA0BX3qWrwQL0H122l358FCDoaFIiAAlgMkkQAkr6xRM4LRSkJ1DQ5Lft 19 | hrDDmE+QAQKBgQDM2vKWdHv2V+NZeyirTgylVP4UlN9tU71wwPUeFx6q4WlUcqTx 20 | V+jbSEphkYdfuR7OD2j6KhZtYQAcKzQl+eanAKblZA3AqhvRpdBeyHBud9VIWBKx 21 | gjfrlfOCpjqnq4KJ+QNBmckPWfhxog0b91BBrXsRYSJaREwA8At9mGEtCQKBgBAd 22 | kPa/lOz1yJsoYfhpw7OAQnoyOfNlnZPcOmbUVOQK6T5zBNjdZq4iuJfjjQE5RRYY 23 | gbwXKjnwNt+zajV4IrfqLpDn9dYsm6CHfkUrwnkqS9du20PPzQvNZXFaUAcvzh1r 24 | t9bdNczyA6f04afhbLlgyMPSXbJRZJCcKc/T7mABAoGBAKZsVyYh1lZx9Sjmjglh 25 | T4gubv9svdYGozHWNaIeFCgvJbetwFcBZwVntsMJQ4LCTnn0eePKqIfGBJMTGu6v 26 | m0o9tI0tMrXatKaAOXnpdIgbvTy5dZf+p91Eq8F0m4VlacYJHDcx/oEhXwh1ailU 27 | dDvF/KTDkE5zcEkmJbGCeop9 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /web-react/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: 0 0% 100%; 7 | --foreground: 222.2 84% 4.9%; 8 | --card: 0 0% 100%; 9 | --card-foreground: 222.2 84% 4.9%; 10 | --popover: 0 0% 100%; 11 | --popover-foreground: 222.2 84% 4.9%; 12 | --primary: 221.2 83.2% 53.3%; 13 | --primary-foreground: 210 40% 98%; 14 | --secondary: 210 40% 96.1%; 15 | --secondary-foreground: 222.2 47.4% 11.2%; 16 | --muted: 210 40% 96.1%; 17 | --muted-foreground: 215.4 16.3% 46.9%; 18 | --accent: 210 40% 96.1%; 19 | --accent-foreground: 222.2 47.4% 11.2%; 20 | --destructive: 0 62.8% 30.6%; 21 | --destructive-foreground: 210 40% 98%; 22 | --border: 214.3 31.8% 91.4%; 23 | --input: 214.3 31.8% 91.4%; 24 | --ring: 221.2 83.2% 53.3%; 25 | --radius: 0.75rem; 26 | } 27 | 28 | .dark { 29 | --background: 222.2 84% 4.9%; 30 | --foreground: 210 40% 98%; 31 | --card: 222.2 84% 4.9%; 32 | --card-foreground: 210 40% 98%; 33 | --popover: 222.2 84% 4.9%; 34 | --popover-foreground: 210 40% 98%; 35 | --primary: 217.2 91.2% 59.8%; 36 | --primary-foreground: 222.2 47.4% 11.2%; 37 | --secondary: 217.2 32.6% 17.5%; 38 | --secondary-foreground: 210 40% 98%; 39 | --muted: 217.2 32.6% 17.5%; 40 | --muted-foreground: 215 20.2% 65.1%; 41 | --accent: 217.2 32.6% 17.5%; 42 | --accent-foreground: 210 40% 98%; 43 | --destructive: 0 62.8% 40.6%; 44 | --destructive-foreground: 210 40% 98%; 45 | --border: 217.2 32.6% 17.5%; 46 | --input: 217.2 32.6% 17.5%; 47 | --ring: 224.3 76.3% 48%; 48 | } 49 | 50 | body { 51 | margin: 0; 52 | min-height: 100vh; 53 | background-color: hsl(var(--background)); 54 | color: hsl(var(--foreground)); 55 | font-feature-settings: 'liga' 1, 'tnum' 1, 'ss01' 1; 56 | } 57 | 58 | * { 59 | border-color: hsl(var(--border)); 60 | } 61 | -------------------------------------------------------------------------------- /web-react/src/layouts/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, NavLink, useNavigate } from 'react-router-dom'; 2 | import { Button } from '@/components/ui/button'; 3 | import { cn } from '@/lib/utils'; 4 | import { Settings } from 'lucide-react'; 5 | 6 | const navItems = [ 7 | { label: '流量列表', to: '/traffic' }, 8 | ]; 9 | 10 | export function AppLayout() { 11 | const navigate = useNavigate(); 12 | 13 | const handleSettingsClick = () => { 14 | navigate('/settings'); 15 | }; 16 | 17 | return ( 18 |
19 |
20 |
21 |
22 | ProxyCraft Console 23 | 40 |
41 | 45 |
46 |
47 |
48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /certs/install_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package certs 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | func isInstalled() (bool, error) { 15 | output, err := runCertutil("-store", "root", IssuerName) 16 | if err != nil { 17 | lower := strings.ToLower(output) 18 | if strings.Contains(lower, "no certificate matches") || strings.Contains(lower, "cannot find object") { 19 | return false, nil 20 | } 21 | return false, err 22 | } 23 | return strings.Contains(output, IssuerName), nil 24 | } 25 | 26 | func install() error { 27 | certPath := filepath.Clean(MustGetCACertPath()) 28 | output, err := runCertutil("-addstore", "-f", "root", certPath) 29 | if err != nil { 30 | lower := strings.ToLower(output) 31 | if strings.Contains(lower, "access is denied") { 32 | return fmt.Errorf("failed to install certificate: access denied. Please run ProxyCraft as Administrator") 33 | } 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | func uninstall() error { 40 | output, err := runCertutil("-delstore", "root", IssuerName) 41 | if err != nil { 42 | lower := strings.ToLower(output) 43 | if strings.Contains(lower, "cannot find object") || strings.Contains(lower, "no certificate matches") { 44 | return nil 45 | } 46 | return err 47 | } 48 | return nil 49 | } 50 | 51 | func runCertutil(args ...string) (string, error) { 52 | cmd := exec.Command("certutil", args...) 53 | cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} 54 | output, err := cmd.CombinedOutput() 55 | trimmed := strings.TrimSpace(string(output)) 56 | if err != nil { 57 | if errors.Is(err, exec.ErrNotFound) { 58 | return trimmed, fmt.Errorf("certutil is not available on this system: %w", err) 59 | } 60 | return trimmed, fmt.Errorf("certutil %s failed: %w (output: %s)", strings.Join(args, " "), err, trimmed) 61 | } 62 | return trimmed, nil 63 | } 64 | -------------------------------------------------------------------------------- /web-react/README.md: -------------------------------------------------------------------------------- 1 | # ProxyCraft React Console 2 | 3 | 基于 React + Vite + Tailwind CSS + shadcn/ui 的全新 Web 前端骨架。用于逐步取代现有的 Vue 版本。 4 | 5 | ## 特性概览 6 | 7 | - ✅ 已完成基础工程:Vite + React 18 + TypeScript 8 | - ✅ Tailwind CSS 及配套的 `postcss`、`components.json` 配置 9 | - ✅ 预置 `shadcn/ui` 常用工具(`cn` 辅助函数、Button/Badge/Card 组件) 10 | - ✅ 接入 Zustand + React Query,提供统一的 `AppProvider` 11 | - ✅ WebSocket 服务 + `useTrafficStream` Hook,整合实时推送与 HTTP 回退 12 | - ✅ `/traffic` 页面已经使用 store 数据流,可刷新 / 清空 / 选中并查看请求响应详情 13 | - ✅ `RequestResponsePanel` 复刻并排/单列视图、支持拖拽调整宽度与复制 curl 14 | - ✅ WebSocket 自动对接 + SSE 条目轮询刷新,支持断网回退 HTTP 获取详情 15 | - ✅ 采用 `@/` 路径别名,方便逐步迁移模块 16 | - ✅ 默认开启严格的 TypeScript 校验 17 | - ✅ 已搭建 React Router + 布局骨架,`/traffic` 页面提供 UI 占位 18 | 19 | ## 使用方式 20 | 21 | > 需 Node.js ≥ 18。首次使用前请在 `web-react` 目录执行安装: 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | 常用脚本: 28 | 29 | - `npm run dev`:启动开发服务器(默认 5173 端口,自动打开浏览器)。 30 | - `npm run build`:执行 TypeScript 编译并产出生产构建。 31 | - `npm run preview`:本地预览生产构建结果。 32 | 33 | ## 迁移计划建议 34 | 35 | 1. **跨项目通信**:根据部署环境配置 `VITE_PROXYCRAFT_SOCKET_URL`,替换 `traffic-service.ts` 中的 mock 数据为真实接口。 36 | 2. **状态管理**:完善 Zustand store(分页、筛选、SSE 进度等),视需要补充 React Query 缓存策略。 37 | 3. **数据绑定**:将 `/traffic` 页面对接真实流量数据,补完分页、搜索、导出等交互。 38 | 4. **组件迁移**: 39 | - 先迁移 `TrafficList` 列表(表格 + 工具栏 + 分页逻辑)。 40 | - 再迁移 `RequestResponsePanel` 及其详情组件,使用 Tabs + CodeMirror/Prism 等查看器。 41 | - 按需封装 shadcn/ui 风格的对话框、表单等常用组件。 42 | 5. **主题与样式**:Tailwind 变量已与 shadcn/ui 对齐,可按需扩展;如需暗色模式,可在 `App.tsx` 中引入主题切换逻辑。 43 | 6. **构建集成**:待主要页面迁移完成后,再调整顶层 `build_web.sh` 等脚本,确保 CI/CD 可以在新前端上运行。 44 | 7. **DevTools**:按需启用 React Query / Zustand DevTools 以辅助调试。 45 | 46 | > 建议迁移过程中保持 Vue 版本可用,待 React 端覆盖关键功能后再统一替换。 47 | 48 | ## 下一步 49 | 50 | - [x] 在 `src` 下创建 `routes` 与 `layouts` 目录,搭建基础路由框架。 51 | - [ ] 配置 `VITE_PROXYCRAFT_SOCKET_URL` 并对接真实的 Socket.IO 服务端。 52 | - [ ] 替换 mock `traffic-service.ts`,与 Go API 保持一致。 53 | - [ ] 扩展 `/traffic` 表格的筛选 / 分页 / SSE 进度展示。 54 | - [ ] 编写端到端测试(可选:Playwright)以覆盖关键交互。 55 | 56 | 欢迎继续补充迁移步骤或提出新的组件需求。 57 | -------------------------------------------------------------------------------- /TEST-PROXY.md: -------------------------------------------------------------------------------- 1 | # ProxyCraft Web模式测试指南 2 | 3 | 现在ProxyCraft已经在Web模式下成功启动,请按照以下步骤测试功能: 4 | 5 | ## 1. 访问Web界面 6 | 7 | 在浏览器中打开以下地址: 8 | ``` 9 | http://localhost:8081 10 | ``` 11 | 12 | 应该能看到ProxyCraft的Web界面,界面上会有一个空的请求列表。 13 | 14 | ## 2. 配置浏览器使用代理 15 | 16 | 将浏览器的代理设置为: 17 | - 地址:127.0.0.1 18 | - 端口:8080 19 | 20 | ### Chrome配置代理的方法 21 | - 在命令行启动Chrome: 22 | ``` 23 | chrome --proxy-server=127.0.0.1:8080 24 | ``` 25 | - 或者使用SwitchyOmega等代理插件 26 | 27 | ### Firefox配置代理的方法 28 | - 设置 -> 常规 -> 网络设置 -> 配置代理访问 29 | - 选择手动配置代理 30 | - HTTP代理:127.0.0.1,端口:8080 31 | - 勾选"对所有协议使用相同代理" 32 | 33 | ## 3. 导入CA证书(用于HTTPS解密) 34 | 35 | 如果需要查看HTTPS请求内容,请导入CA证书: 36 | 37 | ``` 38 | ./ProxyCraft -export-ca proxycraft-ca.pem 39 | ``` 40 | 41 | 然后在浏览器/操作系统中导入此证书。 42 | 43 | ## 4. 进行测试 44 | 45 | 通过配置了代理的浏览器访问几个网站,例如: 46 | - http://example.com 47 | - https://www.baidu.com 48 | 49 | 然后回到ProxyCraft的Web界面,应该能看到捕获的HTTP请求列表。 50 | 51 | 点击任意请求,在下方面板可以查看请求和响应的详细信息。 52 | 53 | ## 5. 测试功能 54 | 55 | 在Web界面上测试以下功能: 56 | - 点击"刷新"按钮,检查是否能刷新请求列表 57 | - 点击列表中的请求,检查下方是否显示详情 58 | - 切换请求/响应标签页,查看不同的详情内容 59 | - 点击"清空"按钮,检查是否能清空列表 60 | 61 | ## 问题排查 62 | 63 | 如果遇到问题: 64 | 1. 确认ProxyCraft正在运行 65 | 2. 确认浏览器代理配置正确 66 | 3. 检查是否导入了CA证书(HTTPS请求) 67 | 4. 如果Web界面空白,检查构建是否成功,可以尝试重新运行build_web.sh 68 | 69 | ### 常见错误解决方法 70 | 71 | #### 端口占用问题 72 | 如果启动时出现以下错误: 73 | ``` 74 | Failed to start proxy server: listen tcp 127.0.0.1:8080: bind: address already in use 75 | ``` 76 | 77 | 这表示端口8080已被占用,可以通过以下方法解决: 78 | 79 | 1. 找出占用端口的进程: 80 | ``` 81 | lsof -i :8080 82 | ``` 83 | 84 | 2. 终止占用端口的进程: 85 | ``` 86 | kill 87 | ``` 88 | 其中``是上一步找到的进程ID 89 | 90 | 3. 或者使用不同的端口启动ProxyCraft: 91 | ``` 92 | ./ProxyCraft -mode web -p 8081 93 | ``` 94 | 注意:此时需要在浏览器中将代理端口也设置为8081 95 | 96 | #### Web界面显示"No Data" 97 | 如果Web界面打开但显示"No Data",请确认: 98 | 99 | 1. 代理服务器是否成功启动(查看命令行输出) 100 | 2. 浏览器是否正确配置使用了代理 101 | 3. 是否通过配置了代理的浏览器访问了网页 102 | 4. 尝试点击Web界面上的"刷新"按钮 -------------------------------------------------------------------------------- /web-react/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
12 | ) 13 | ); 14 | Card.displayName = 'Card'; 15 | 16 | const CardHeader = React.forwardRef>( 17 | ({ className, ...props }, ref) => ( 18 |
19 | ) 20 | ); 21 | CardHeader.displayName = 'CardHeader'; 22 | 23 | const CardTitle = React.forwardRef>( 24 | ({ className, ...props }, ref) => ( 25 |

26 | ) 27 | ); 28 | CardTitle.displayName = 'CardTitle'; 29 | 30 | const CardDescription = React.forwardRef>( 31 | ({ className, ...props }, ref) => ( 32 |

33 | ) 34 | ); 35 | CardDescription.displayName = 'CardDescription'; 36 | 37 | const CardContent = React.forwardRef>( 38 | ({ className, ...props }, ref) => ( 39 |

40 | ) 41 | ); 42 | CardContent.displayName = 'CardContent'; 43 | 44 | const CardFooter = React.forwardRef>( 45 | ({ className, ...props }, ref) => ( 46 |
47 | ) 48 | ); 49 | CardFooter.displayName = 'CardFooter'; 50 | 51 | export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; 52 | -------------------------------------------------------------------------------- /web-react/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | }, 19 | size: { 20 | default: 'h-9 px-4 py-2', 21 | sm: 'h-8 rounded-md px-3', 22 | lg: 'h-10 rounded-md px-8', 23 | icon: 'h-9 w-9', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | } 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean; 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : 'button'; 42 | return ( 43 | 48 | ); 49 | } 50 | ); 51 | Button.displayName = 'Button'; 52 | 53 | export { Button, buttonVariants }; 54 | -------------------------------------------------------------------------------- /web-react/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './index.html', 7 | './src/**/*.{ts,tsx,js,jsx}', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | border: 'hsl(var(--border))', 13 | input: 'hsl(var(--input))', 14 | ring: 'hsl(var(--ring))', 15 | background: 'hsl(var(--background))', 16 | foreground: 'hsl(var(--foreground))', 17 | primary: { 18 | DEFAULT: 'hsl(var(--primary))', 19 | foreground: 'hsl(var(--primary-foreground))', 20 | }, 21 | secondary: { 22 | DEFAULT: 'hsl(var(--secondary))', 23 | foreground: 'hsl(var(--secondary-foreground))', 24 | }, 25 | destructive: { 26 | DEFAULT: 'hsl(var(--destructive))', 27 | foreground: 'hsl(var(--destructive-foreground))', 28 | }, 29 | muted: { 30 | DEFAULT: 'hsl(var(--muted))', 31 | foreground: 'hsl(var(--muted-foreground))', 32 | }, 33 | accent: { 34 | DEFAULT: 'hsl(var(--accent))', 35 | foreground: 'hsl(var(--accent-foreground))', 36 | }, 37 | popover: { 38 | DEFAULT: 'hsl(var(--popover))', 39 | foreground: 'hsl(var(--popover-foreground))', 40 | }, 41 | card: { 42 | DEFAULT: 'hsl(var(--card))', 43 | foreground: 'hsl(var(--card-foreground))', 44 | }, 45 | }, 46 | borderRadius: { 47 | lg: 'var(--radius)', 48 | md: 'calc(var(--radius) - 2px)', 49 | sm: 'calc(var(--radius) - 4px)', 50 | }, 51 | keyframes: { 52 | 'accordion-down': { 53 | from: { height: '0' }, 54 | to: { height: 'var(--radix-accordion-content-height)' }, 55 | }, 56 | 'accordion-up': { 57 | from: { height: 'var(--radix-accordion-content-height)' }, 58 | to: { height: '0' }, 59 | }, 60 | }, 61 | animation: { 62 | 'accordion-down': 'accordion-down 0.2s ease-out', 63 | 'accordion-up': 'accordion-up 0.2s ease-out', 64 | }, 65 | }, 66 | }, 67 | plugins: [], 68 | }; 69 | 70 | export default config; 71 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - id: proxy_craft 20 | env: 21 | - CGO_ENABLED=0 22 | flags: 23 | - -trimpath 24 | goos: 25 | - linux 26 | - windows 27 | - darwin 28 | gcflags: 29 | - all=-l -B 30 | ldflags: 31 | - -s -w 32 | - -X main.build={{.Version}} 33 | 34 | archives: 35 | - formats: [tar.gz] 36 | # this name template makes the OS and Arch compatible with the results of `uname`. 37 | name_template: >- 38 | {{ .ProjectName }}_ 39 | {{- title .Os }}_ 40 | {{- if eq .Arch "amd64" }}x86_64 41 | {{- else if eq .Arch "386" }}i386 42 | {{- else }}{{ .Arch }}{{ end }} 43 | {{- if .Arm }}v{{ .Arm }}{{ end }} 44 | # use zip for windows archives 45 | format_overrides: 46 | - goos: windows 47 | formats: [zip] 48 | 49 | changelog: 50 | sort: asc 51 | filters: 52 | exclude: 53 | - "^docs:" 54 | - "^test:" 55 | 56 | release: 57 | footer: >- 58 | 59 | --- 60 | 61 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 62 | 63 | upx: 64 | - # Whether to enable it or not. 65 | # 66 | # Templates: allowed. 67 | enabled: true 68 | 69 | # Filter by GOOS. 70 | goos: [linux, darwin] 71 | 72 | # Filter by GOARCH. 73 | goarch: [arm, amd64] 74 | 75 | # Filter by GOARM. 76 | goarm: [8] 77 | 78 | # Filter by GOAMD64. 79 | goamd64: [v1] 80 | 81 | # Compress argument. 82 | # Valid options are from '1' (faster) to '9' (better), and 'best'. 83 | compress: best 84 | 85 | # Whether to try LZMA (slower). 86 | lzma: true 87 | 88 | # Whether to try all methods and filters (slow). 89 | brute: true -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "io" 7 | "log" 8 | "os" 9 | "testing" 10 | 11 | "github.com/LubyRuffy/ProxyCraft/cli" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | // TestMainFunctionality tests the main package functionality 17 | func TestMainFunctionality(t *testing.T) { 18 | // 保存原始的os.Args并在测试后恢复 19 | origArgs := os.Args 20 | defer func() { os.Args = origArgs }() 21 | 22 | // 保存原始标准输出并在测试后恢复 23 | oldOutput := os.Stdout 24 | defer func() { os.Stdout = oldOutput }() 25 | 26 | // 保存原始的flag.CommandLine并在测试后恢复 27 | origFlagCommandLine := flag.CommandLine 28 | defer func() { flag.CommandLine = origFlagCommandLine }() 29 | 30 | // 保存原始的logger输出并在测试后恢复 31 | origLogOutput := log.Writer() 32 | defer func() { log.SetOutput(origLogOutput) }() 33 | 34 | t.Run("export_ca_flag", func(t *testing.T) { 35 | // 重置flag以便使用-export-ca标志 36 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 37 | 38 | // 创建一个临时文件来导出CA证书 39 | tmpCAFile, err := os.CreateTemp("", "test-ca-*.pem") 40 | require.NoError(t, err) 41 | tmpCAPath := tmpCAFile.Name() 42 | tmpCAFile.Close() 43 | defer os.Remove(tmpCAPath) 44 | 45 | os.Args = []string{"cmd", "-export-ca", tmpCAPath} 46 | 47 | // 重定向标准输出以捕获输出 48 | r, w, _ := os.Pipe() 49 | os.Stdout = w 50 | log.SetOutput(w) 51 | 52 | // 解析命令行参数 53 | cfg := cli.ParseFlags() 54 | assert.Equal(t, tmpCAPath, cfg.ExportCAPath) 55 | 56 | // 关闭管道写入端 57 | w.Close() 58 | 59 | // 读取捕获的输出,但不验证内容 60 | var buf bytes.Buffer 61 | io.Copy(&buf, r) 62 | 63 | // 验证flag被正确解析 64 | assert.True(t, len(cfg.ExportCAPath) > 0, "ExportCAPath应该被设置") 65 | }) 66 | 67 | t.Run("custom_listen_address", func(t *testing.T) { 68 | // 重置flag以便设置自定义监听地址 69 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 70 | os.Args = []string{"cmd", "-listen-host", "127.0.0.1", "-listen-port", "9090"} 71 | 72 | // 解析命令行参数 73 | cfg := cli.ParseFlags() 74 | 75 | // 验证监听地址被正确设置 76 | assert.Equal(t, "127.0.0.1", cfg.ListenHost) 77 | assert.Equal(t, 9090, cfg.ListenPort) 78 | }) 79 | 80 | t.Run("har_logging_options", func(t *testing.T) { 81 | // 重置flag以便测试HAR日志选项 82 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 83 | os.Args = []string{"cmd", "-o", "test.har", "-auto-save", "60"} 84 | 85 | // 解析命令行参数 86 | cfg := cli.ParseFlags() 87 | 88 | // 验证HAR日志选项被正确设置 89 | assert.Equal(t, "test.har", cfg.HarOutputFile) 90 | assert.Equal(t, 60, cfg.AutoSaveInterval) 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /proxy/server.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | // Added for reading requests from TLS connection 5 | // Added for bytes.Buffer 6 | 7 | "fmt" 8 | "net/http" 9 | "net/url" // Added for constructing target URLs 10 | 11 | "github.com/LubyRuffy/ProxyCraft/certs" 12 | "github.com/LubyRuffy/ProxyCraft/harlogger" // Added for HAR logging 13 | // Added for HTTP/2 support 14 | ) 15 | 16 | // ServerConfig 包含所有服务器配置项 17 | type ServerConfig struct { 18 | // 监听地址 19 | Addr string 20 | 21 | // 证书管理器 22 | CertManager *certs.Manager 23 | 24 | // 是否输出详细日志 25 | Verbose bool 26 | 27 | // HAR 日志记录器 28 | HarLogger *harlogger.Logger 29 | 30 | // 上游代理 URL 31 | UpstreamProxy *url.URL 32 | 33 | // 是否将抓包内容输出到控制台 34 | DumpTraffic bool 35 | 36 | // 事件处理器 37 | EventHandler EventHandler 38 | } 39 | 40 | // Server struct will hold proxy server configuration and state 41 | type Server struct { 42 | Addr string 43 | CertManager *certs.Manager 44 | Verbose bool 45 | HarLogger *harlogger.Logger // Added for HAR logging 46 | UpstreamProxy *url.URL // 上层代理服务器URL,如果为nil则直接连接 47 | DumpTraffic bool // 是否将抓包内容输出到控制台 48 | EventHandler EventHandler // 事件处理器 49 | } 50 | 51 | // NewServer creates a new proxy server instance 52 | func NewServer(addr string, certManager *certs.Manager, verbose bool, harLogger *harlogger.Logger, upstreamProxy *url.URL, dumpTraffic bool) *Server { 53 | return &Server{ 54 | Addr: addr, 55 | CertManager: certManager, 56 | Verbose: verbose, 57 | HarLogger: harLogger, 58 | UpstreamProxy: upstreamProxy, 59 | DumpTraffic: dumpTraffic, 60 | EventHandler: &NoOpEventHandler{}, // 默认使用空实现 61 | } 62 | } 63 | 64 | // NewServerWithConfig 使用配置创建新的代理服务器实例 65 | func NewServerWithConfig(config ServerConfig) *Server { 66 | server := &Server{ 67 | Addr: config.Addr, 68 | CertManager: config.CertManager, 69 | Verbose: config.Verbose, 70 | HarLogger: config.HarLogger, 71 | UpstreamProxy: config.UpstreamProxy, 72 | DumpTraffic: config.DumpTraffic, 73 | EventHandler: config.EventHandler, 74 | } 75 | 76 | // 如果没有提供事件处理器,使用默认的空实现 77 | if server.EventHandler == nil { 78 | server.EventHandler = &NoOpEventHandler{} 79 | } 80 | 81 | return server 82 | } 83 | 84 | // SetEventHandler 设置事件处理器 85 | func (s *Server) SetEventHandler(handler EventHandler) { 86 | s.EventHandler = handler 87 | } 88 | 89 | // Start begins listening for incoming proxy requests 90 | func (s *Server) Start() error { 91 | fmt.Printf("Proxy server starting on %s\n", s.Addr) 92 | return http.ListenAndServe(s.Addr, http.HandlerFunc(s.handleHTTP)) 93 | } 94 | -------------------------------------------------------------------------------- /web-react/tailwind.config.js: -------------------------------------------------------------------------------- 1 | var config = { 2 | darkMode: ['class'], 3 | content: [ 4 | './index.html', 5 | './src/**/*.{ts,tsx,js,jsx}', 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: 'hsl(var(--border))', 11 | input: 'hsl(var(--input))', 12 | ring: 'hsl(var(--ring))', 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | primary: { 16 | DEFAULT: 'hsl(var(--primary))', 17 | foreground: 'hsl(var(--primary-foreground))', 18 | }, 19 | secondary: { 20 | DEFAULT: 'hsl(var(--secondary))', 21 | foreground: 'hsl(var(--secondary-foreground))', 22 | }, 23 | destructive: { 24 | DEFAULT: 'hsl(var(--destructive))', 25 | foreground: 'hsl(var(--destructive-foreground))', 26 | }, 27 | muted: { 28 | DEFAULT: 'hsl(var(--muted))', 29 | foreground: 'hsl(var(--muted-foreground))', 30 | }, 31 | accent: { 32 | DEFAULT: 'hsl(var(--accent))', 33 | foreground: 'hsl(var(--accent-foreground))', 34 | }, 35 | popover: { 36 | DEFAULT: 'hsl(var(--popover))', 37 | foreground: 'hsl(var(--popover-foreground))', 38 | }, 39 | card: { 40 | DEFAULT: 'hsl(var(--card))', 41 | foreground: 'hsl(var(--card-foreground))', 42 | }, 43 | }, 44 | borderRadius: { 45 | lg: 'var(--radius)', 46 | md: 'calc(var(--radius) - 2px)', 47 | sm: 'calc(var(--radius) - 4px)', 48 | }, 49 | keyframes: { 50 | 'accordion-down': { 51 | from: { height: '0' }, 52 | to: { height: 'var(--radix-accordion-content-height)' }, 53 | }, 54 | 'accordion-up': { 55 | from: { height: 'var(--radix-accordion-content-height)' }, 56 | to: { height: '0' }, 57 | }, 58 | }, 59 | animation: { 60 | 'accordion-down': 'accordion-down 0.2s ease-out', 61 | 'accordion-up': 'accordion-up 0.2s ease-out', 62 | }, 63 | }, 64 | }, 65 | plugins: [], 66 | }; 67 | export default config; 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/LubyRuffy/ProxyCraft 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/stretchr/testify v1.10.0 10 | github.com/zishang520/socket.io/servers/socket/v3 v3.0.0-rc.5 11 | golang.org/x/net v0.44.0 12 | ) 13 | 14 | require ( 15 | github.com/andybalholm/brotli v1.2.0 // indirect 16 | github.com/bytedance/sonic v1.11.6 // indirect 17 | github.com/bytedance/sonic/loader v0.1.1 // indirect 18 | github.com/cloudwego/base64x v0.1.4 // indirect 19 | github.com/cloudwego/iasm v0.2.0 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 22 | github.com/gin-contrib/sse v0.1.0 // indirect 23 | github.com/go-playground/locales v0.14.1 // indirect 24 | github.com/go-playground/universal-translator v0.18.1 // indirect 25 | github.com/go-playground/validator/v10 v10.20.0 // indirect 26 | github.com/goccy/go-json v0.10.2 // indirect 27 | github.com/gookit/color v1.6.0 // indirect 28 | github.com/gorilla/websocket v1.5.3 // indirect 29 | github.com/json-iterator/go v1.1.12 // indirect 30 | github.com/klauspost/compress v1.18.0 // indirect 31 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 32 | github.com/kr/pretty v0.3.1 // indirect 33 | github.com/leodido/go-urn v1.4.0 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 36 | github.com/modern-go/reflect2 v1.0.2 // indirect 37 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/quic-go/qpack v0.5.1 // indirect 40 | github.com/quic-go/quic-go v0.54.0 // indirect 41 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 42 | github.com/ugorji/go/codec v1.2.12 // indirect 43 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 44 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 45 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 46 | github.com/zishang520/socket.io/parsers/engine/v3 v3.0.0-rc.5 // indirect 47 | github.com/zishang520/socket.io/parsers/socket/v3 v3.0.0-rc.5 // indirect 48 | github.com/zishang520/socket.io/servers/engine/v3 v3.0.0-rc.5 // indirect 49 | github.com/zishang520/socket.io/v3 v3.0.0-rc.5 // indirect 50 | github.com/zishang520/webtransport-go v0.9.1 // indirect 51 | go.uber.org/mock v0.6.0 // indirect 52 | golang.org/x/arch v0.8.0 // indirect 53 | golang.org/x/crypto v0.42.0 // indirect 54 | golang.org/x/mod v0.28.0 // indirect 55 | golang.org/x/sync v0.17.0 // indirect 56 | golang.org/x/sys v0.36.0 // indirect 57 | golang.org/x/text v0.29.0 // indirect 58 | golang.org/x/tools v0.37.0 // indirect 59 | google.golang.org/protobuf v1.34.1 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /proxy/handlers/web_handler_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/LubyRuffy/ProxyCraft/proxy" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // TestWebHandler_GetEntries_Concurrency 测试GetEntries在高并发场景下的性能和稳定性 16 | func TestWebHandler_GetEntries_Concurrency(t *testing.T) { 17 | // 创建一个WebHandler实例 18 | handler := NewWebHandler(false) 19 | 20 | // 添加大量测试数据 21 | entriesCount := 500 22 | for i := 0; i < entriesCount; i++ { 23 | req, _ := http.NewRequest("GET", "http://example.com/path", nil) 24 | reqCtx := &proxy.RequestContext{ 25 | Request: req, 26 | StartTime: time.Now(), 27 | TargetURL: "http://example.com/path", 28 | UserData: make(map[string]interface{}), 29 | } 30 | 31 | handler.OnRequest(reqCtx) 32 | 33 | // 创建一个响应 34 | resp := &http.Response{ 35 | StatusCode: 200, 36 | Header: http.Header{}, 37 | Body: io.NopCloser(bytes.NewBufferString("test response body")), 38 | } 39 | 40 | respCtx := &proxy.ResponseContext{ 41 | Response: resp, 42 | ReqCtx: reqCtx, 43 | } 44 | 45 | handler.OnResponse(respCtx) 46 | } 47 | 48 | // 确认数据已添加 49 | assert.Equal(t, entriesCount, len(handler.entries)) 50 | 51 | // 并发调用GetEntries 52 | var wg sync.WaitGroup 53 | concurrencyLevel := 10 // 10个并发goroutine 54 | callsPerGoroutine := 10 // 每个goroutine调用10次 55 | 56 | // 用于记录每个goroutine执行时间的通道 57 | timeResults := make(chan time.Duration, concurrencyLevel) 58 | 59 | t.Log("开始并发测试GetEntries") 60 | for i := 0; i < concurrencyLevel; i++ { 61 | wg.Add(1) 62 | go func(id int) { 63 | defer wg.Done() 64 | 65 | start := time.Now() 66 | for j := 0; j < callsPerGoroutine; j++ { 67 | entries := handler.GetEntries() 68 | // 验证返回的条目数量正确(应该是1000或者全部条目数) 69 | expected := entriesCount 70 | if expected > 1000 { 71 | expected = 1000 72 | } 73 | if len(entries) != expected { 74 | t.Errorf("Goroutine %d, call %d: expected %d entries, got %d", id, j, expected, len(entries)) 75 | } 76 | } 77 | elapsed := time.Since(start) 78 | timeResults <- elapsed 79 | }(i) 80 | } 81 | 82 | // 等待所有goroutine完成 83 | wg.Wait() 84 | close(timeResults) 85 | 86 | // 统计执行时间 87 | var totalTime time.Duration 88 | var maxTime time.Duration 89 | count := 0 90 | 91 | for elapsed := range timeResults { 92 | totalTime += elapsed 93 | if elapsed > maxTime { 94 | maxTime = elapsed 95 | } 96 | count++ 97 | } 98 | 99 | avgTime := totalTime / time.Duration(count) 100 | t.Logf("GetEntries并发测试结果: 平均时间 %v, 最长时间 %v", avgTime, maxTime) 101 | 102 | // 如果最长时间超过1秒,发出警告 103 | if maxTime > time.Second { 104 | t.Logf("警告: GetEntries最长执行时间(%v)超过1秒,可能存在性能问题", maxTime) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /web-react/src/stores/use-traffic-store.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { devtools } from 'zustand/middleware'; 3 | // 默认设置值 4 | const defaultSettings = { 5 | autoReconnect: true, 6 | reconnectInterval: 5, 7 | maxReconnectAttempts: 10, 8 | entriesPerPage: 50, 9 | showOnlyHttps: false, 10 | showOnlySse: false, 11 | autoSaveHar: false, 12 | harSaveInterval: 30, 13 | filterHost: '', 14 | filterMethod: 'all', 15 | theme: 'auto', 16 | }; 17 | // 创建设置store 18 | const settingsStore = create()(devtools((set) => ({ 19 | ...defaultSettings, 20 | setAutoReconnect: (autoReconnect) => set({ autoReconnect }), 21 | setReconnectInterval: (interval) => set({ reconnectInterval: Math.max(1, interval) }), 22 | setMaxReconnectAttempts: (max) => set({ maxReconnectAttempts: Math.max(1, max) }), 23 | setEntriesPerPage: (count) => set({ entriesPerPage: Math.max(10, count) }), 24 | setShowOnlyHttps: (show) => set({ showOnlyHttps: show }), 25 | setShowOnlySse: (show) => set({ showOnlySse: show }), 26 | setAutoSaveHar: (autoSave) => set({ autoSaveHar: autoSave }), 27 | setHarSaveInterval: (interval) => set({ harSaveInterval: Math.max(5, interval) }), 28 | setFilterHost: (host) => set({ filterHost: host }), 29 | setFilterMethod: (method) => set({ filterMethod: method }), 30 | setTheme: (theme) => set({ theme }), 31 | resetSettings: () => set(defaultSettings), 32 | }), { name: 'settings-store' })); 33 | const store = create()(devtools((set) => ({ 34 | entries: [], 35 | selectedId: null, 36 | detail: undefined, 37 | loading: false, 38 | error: null, 39 | connected: false, 40 | transport: 'unknown', 41 | setEntries: (entries) => set({ 42 | entries: [...entries].sort((a, b) => { 43 | const timeA = a.startTime ? new Date(a.startTime).getTime() : 0; 44 | const timeB = b.startTime ? new Date(b.startTime).getTime() : 0; 45 | return timeB - timeA; 46 | }), 47 | }), 48 | addOrUpdateEntry: (entry) => set((state) => { 49 | const existingIndex = state.entries.findIndex((item) => item.id === entry.id); 50 | if (existingIndex === -1) { 51 | return { entries: [entry, ...state.entries] }; 52 | } 53 | const updated = [...state.entries]; 54 | updated[existingIndex] = { ...updated[existingIndex], ...entry }; 55 | return { entries: updated }; 56 | }), 57 | selectEntry: (id) => set({ selectedId: id }), 58 | setDetail: (detail) => set({ detail }), 59 | mergeDetail: (partial) => set((state) => ({ detail: { ...(state.detail ?? {}), ...partial } })), 60 | clearDetail: () => set({ detail: undefined }), 61 | setLoading: (loading) => set({ loading }), 62 | setError: (error) => set({ error }), 63 | setConnected: (connected) => set({ connected }), 64 | setTransport: (transport) => set({ transport }), 65 | clearEntries: () => set({ 66 | entries: [], 67 | selectedId: null, 68 | detail: undefined, 69 | }), 70 | }), { name: 'traffic-store' })); 71 | // 导出设置store 72 | export const useSettingsStore = settingsStore; 73 | export const useTrafficStore = store; 74 | -------------------------------------------------------------------------------- /cli/parser.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // Config holds all configurable options for ProxyCraft. 10 | // These will be populated from command-line arguments. 11 | type Config struct { 12 | ListenHost string // Proxy server host 13 | ListenPort int // Proxy server port 14 | WebPort int // Web UI port 15 | Verbose bool // More verbose 16 | HarOutputFile string // Save traffic to FILE (HAR format recommended) 17 | AutoSaveInterval int // Auto-save HAR file every N seconds (0 to disable) 18 | Filter string // Filter displayed traffic (e.g., "host=example.com") 19 | ExportCAPath string // Export the root CA certificate to FILEPATH and exit 20 | UseCACertPath string // Use custom root CA certificate from CERT_PATH 21 | UseCAKeyPath string // Use custom root CA private key from KEY_PATH 22 | InstallCerts bool // Install CA certificate to system trust store 23 | ShowHelp bool // Show this help message and exit 24 | UpstreamProxy string // Upstream proxy URL (e.g., "http://proxy.example.com:8080") 25 | DumpTraffic bool // Enable dumping traffic content to console 26 | Mode string // 运行模式: "" (CLI模式) 或 "web" (Web界面模式) 27 | } 28 | 29 | // ParseFlags parses the command-line arguments and returns a Config struct. 30 | func ParseFlags() *Config { 31 | cfg := &Config{} 32 | 33 | flag.StringVar(&cfg.ListenHost, "l", "127.0.0.1", "IP address to listen on") 34 | flag.StringVar(&cfg.ListenHost, "listen-host", "127.0.0.1", "IP address to listen on") 35 | flag.IntVar(&cfg.ListenPort, "p", 38080, "Port to listen on") 36 | flag.IntVar(&cfg.ListenPort, "listen-port", 38080, "Port to listen on") 37 | flag.BoolVar(&cfg.Verbose, "v", false, "Enable verbose output") 38 | flag.BoolVar(&cfg.Verbose, "verbose", false, "Enable verbose output") 39 | flag.StringVar(&cfg.HarOutputFile, "o", "", "Save traffic to FILE (HAR format recommended)") 40 | flag.StringVar(&cfg.HarOutputFile, "output-file", "", "Save traffic to FILE (HAR format recommended)") 41 | flag.IntVar(&cfg.AutoSaveInterval, "auto-save", 10, "Auto-save HAR file every N seconds (0 to disable)") 42 | flag.StringVar(&cfg.Filter, "filter", "", "Filter displayed traffic (e.g., \"host=example.com\")") 43 | flag.StringVar(&cfg.ExportCAPath, "export-ca", "", "Export the root CA certificate to FILEPATH and exit") 44 | flag.StringVar(&cfg.UseCACertPath, "use-ca", "", "Use custom root CA certificate from CERT_PATH") 45 | flag.StringVar(&cfg.UseCAKeyPath, "use-key", "", "Use custom root CA private key from KEY_PATH") 46 | flag.BoolVar(&cfg.InstallCerts, "install-ca", false, "Install the CA certificate to system trust store and exit") 47 | flag.StringVar(&cfg.UpstreamProxy, "upstream-proxy", "", "Upstream proxy URL (e.g., \"http://proxy.example.com:8080\")") 48 | flag.BoolVar(&cfg.DumpTraffic, "dump", false, "Dump traffic content to console with headers (binary content will not be displayed)") 49 | flag.StringVar(&cfg.Mode, "mode", "", "Running mode: empty for CLI mode, 'web' for Web UI mode") 50 | 51 | // Custom help flag 52 | flag.BoolVar(&cfg.ShowHelp, "h", false, "Show this help message and exit") 53 | flag.BoolVar(&cfg.ShowHelp, "help", false, "Show this help message and exit") 54 | 55 | flag.Usage = func() { 56 | fmt.Fprintf(os.Stderr, "ProxyCraft CLI - A command-line HTTPS/HTTP2/SSE proxy tool.\n") 57 | fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0]) 58 | fmt.Fprintf(os.Stderr, "Options:\n") 59 | flag.PrintDefaults() 60 | } 61 | 62 | flag.Parse() 63 | 64 | return cfg 65 | } 66 | 67 | // PrintHelp prints the help message. 68 | func PrintHelp() { 69 | flag.Usage() 70 | } 71 | -------------------------------------------------------------------------------- /proxy/http2_handler.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | 10 | "golang.org/x/net/http2" 11 | ) 12 | 13 | // handleHTTP2 configures HTTP/2 support for client and server connections 14 | func (s *Server) handleHTTP2(transport *http.Transport) { 15 | // Configure HTTP/2 support for the transport 16 | err := http2.ConfigureTransport(transport) 17 | if err != nil { 18 | log.Printf("Error configuring HTTP/2 transport: %v", err) 19 | return 20 | } 21 | 22 | if s.Verbose { 23 | log.Printf("HTTP/2 support enabled for transport") 24 | } 25 | } 26 | 27 | // handleHTTP2MITM handles HTTP/2 connections 28 | func (s *Server) handleHTTP2MITM(tlsConn *tls.Conn, connectReq *http.Request) { 29 | if s.Verbose { 30 | log.Printf("[HTTP/2] Handling HTTP/2 connection for %s", connectReq.Host) 31 | } 32 | 33 | // 通知隧道已建立 34 | s.notifyTunnelEstablished(connectReq.Host, true) 35 | 36 | // Create an HTTP/2 server 37 | server := &http2.Server{} 38 | 39 | // Create a connection wrapper 40 | conn := &http2MITMConn{ 41 | server: server, 42 | conn: tlsConn, 43 | originalReq: connectReq, 44 | proxy: s, 45 | } 46 | 47 | // Serve the connection 48 | server.ServeConn(tlsConn, &http2.ServeConnOpts{ 49 | Handler: conn, 50 | }) 51 | } 52 | 53 | // http2MITMConn is a connection wrapper for HTTP/2 54 | type http2MITMConn struct { 55 | server *http2.Server 56 | conn *tls.Conn 57 | originalReq *http.Request 58 | proxy *Server 59 | } 60 | 61 | // ServeHTTP implements http.Handler for the HTTP/2 connection 62 | func (h *http2MITMConn) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 | if h.proxy.Verbose { 64 | log.Printf("[HTTP/2] Received request: %s %s", r.Method, r.URL.String()) 65 | } else { 66 | log.Printf("[HTTP/2] %s %s%s", r.Method, r.Host, r.URL.RequestURI()) 67 | } 68 | 69 | // 检查conn是否为nil,这在测试中可能会发生 70 | if h.conn == nil { 71 | http.Error(w, "Connection is not available", http.StatusBadGateway) 72 | return 73 | } 74 | 75 | // Create a new request to the target server 76 | targetURL := &url.URL{ 77 | Scheme: "https", 78 | Host: h.originalReq.Host, 79 | Path: r.URL.Path, 80 | RawQuery: r.URL.RawQuery, 81 | } 82 | 83 | baseTransport := h.proxy.newTransport(h.originalReq.Host, true) 84 | transport := h.proxy.wrapTransportForSSE(baseTransport) 85 | 86 | proxyReq, reqCtx, potentialSSE, startTime, err := h.proxy.prepareProxyRequest(r, targetURL.String(), true) 87 | if err != nil { 88 | log.Printf("[HTTP/2] Error creating proxy request: %v", err) 89 | http.Error(w, "Error creating proxy request", http.StatusInternalServerError) 90 | return 91 | } 92 | 93 | logPotentialSSE(h.proxy.Verbose, "[HTTP/2]", potentialSSE) 94 | 95 | resp, timeTaken, err := h.proxy.sendProxyRequest(proxyReq, transport, potentialSSE, startTime) 96 | if err != nil { 97 | log.Printf("[HTTP/2] Error sending request to target server %s: %v", targetURL.String(), err) 98 | h.proxy.recordProxyError(err, reqCtx, startTime, timeTaken) 99 | http.Error(w, fmt.Sprintf("Error proxying to %s: %v", targetURL.String(), err), http.StatusBadGateway) 100 | return 101 | } 102 | defer resp.Body.Close() 103 | 104 | respCtx, isSSE := h.proxy.processProxyResponse(reqCtx, resp, startTime, timeTaken, "[HTTP/2]", targetURL.String()) 105 | 106 | if isSSE { 107 | if err := h.proxy.handleSSE(w, respCtx); err != nil { 108 | log.Printf("[SSE] Error handling SSE response: %v", err) 109 | } 110 | return 111 | } 112 | 113 | if err := h.proxy.writeHTTPResponse(w, respCtx, "HTTP/2"); err != nil { 114 | log.Printf("[HTTP/2] Error streaming response: %v", err) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /certs/install_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package certs 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // runWithAdmin 使用 AppleScript 的 "do shell script ... with administrator privileges" 14 | // 来弹出系统认证对话框并以 root 权限执行 shellCmd(例如 security ...)。 15 | func runWithAdmin(args ...string) (string, error) { 16 | shellCmd := strings.Join(args, " ") 17 | // Quote the shell command so that special chars are safe inside the AppleScript string 18 | quotedCmd := strconv.Quote(shellCmd) // returns a double-quoted Go string literal, suitable for embedding 19 | 20 | // 构造 AppleScript:do shell script "the command" with administrator privileges 21 | // 注意:我们将 quotedCmd 的内容直接嵌入到 -e 参数中 22 | as := fmt.Sprintf("do shell script %s with administrator privileges", quotedCmd) 23 | 24 | // 调用 osascript 25 | cmd := exec.Command("osascript", "-e", as) 26 | 27 | stdout, err := cmd.CombinedOutput() 28 | 29 | if err != nil { 30 | // 如果用户取消认证,通常会在 stderr 或命令返回的错误中看到 "User canceled" 或类似信息 31 | // 返回更详细的错误信息给调用者 32 | return string(stdout), fmt.Errorf("osascript exit: %v; stderr: %s", err, string(stdout)) 33 | } 34 | return string(stdout), nil 35 | } 36 | 37 | // isInstalled checks if the CA certificate is already installed in the system trust store. 38 | func isInstalled() (bool, error) { 39 | // Use security command to find the certificate in the system keychain 40 | // We search by the certificate's common name. Reading from system keychain doesn't require sudo. 41 | cmdOutput, err := runWithAdmin("security", "find-certificate", "-c", fmt.Sprintf(`"%s"`, IssuerName), "/Library/Keychains/System.keychain") 42 | if err != nil { 43 | return false, err 44 | } 45 | // log.Println(cmd.Args) 46 | 47 | if strings.Contains(cmdOutput, "The specified item could not be found in the keychain.") { 48 | return false, nil 49 | } 50 | 51 | if strings.Contains(cmdOutput, "version:") && strings.Contains(cmdOutput, "keychain:") { 52 | return true, nil 53 | } 54 | 55 | return true, nil 56 | } 57 | 58 | // InstallCerts installs the CA certificate to the system trust store on macOS. 59 | // It requires sudo privileges. If the certificate is already installed, it will skip the installation. 60 | func install() error { 61 | // Check if the certificate is already installed 62 | installed, err := isInstalled() 63 | if err != nil { 64 | return fmt.Errorf("failed to check if certificate is installed: %w", err) 65 | } 66 | 67 | if installed { 68 | fmt.Println("CA certificate is already installed in the system keychain. Skipping installation.") 69 | return nil 70 | } 71 | 72 | certPath := MustGetCACertPath() 73 | 74 | // On macOS, we need to use the `security` command to install the cert. 75 | // This requires sudo. 76 | fmt.Println("Attempting to install CA certificate into system keychain...") 77 | fmt.Println("You might be prompted for your password.") 78 | 79 | cmdOutput, err := runWithAdmin("security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", certPath) 80 | if err != nil { 81 | return fmt.Errorf("failed to install certificate. command finished with error: %w. Make sure you have sudo privileges", err) 82 | } 83 | 84 | log.Println(cmdOutput) 85 | 86 | return nil 87 | } 88 | 89 | // uninstall uninstalls the CA certificate from the system trust store on macOS. 90 | // It requires sudo privileges. 91 | func uninstall() error { 92 | cmdOutput, err := runWithAdmin("security", "delete-certificate", "-c", IssuerName, "/Library/Keychains/System.keychain") 93 | if err != nil { 94 | return fmt.Errorf("failed to uninstall certificate. command finished with error: %w", err) 95 | } 96 | 97 | log.Println(cmdOutput) 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /certs/install_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package certs 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | type linuxTrustTarget struct { 16 | path string 17 | refresh []string 18 | } 19 | 20 | var linuxTrustTargets = []linuxTrustTarget{ 21 | {path: filepath.Join("/usr/local/share/ca-certificates", "proxycraft-root-ca.crt"), refresh: []string{"update-ca-certificates"}}, 22 | {path: filepath.Join("/etc/pki/ca-trust/source/anchors", "proxycraft-root-ca.pem"), refresh: []string{"update-ca-trust", "extract"}}, 23 | } 24 | 25 | func isInstalled() (bool, error) { 26 | for _, target := range linuxTrustTargets { 27 | if fileExists(target.path) { 28 | return true, nil 29 | } 30 | } 31 | return false, nil 32 | } 33 | 34 | func install() error { 35 | if os.Geteuid() != 0 { 36 | return fmt.Errorf("installing the root CA requires root privileges; please rerun with sudo") 37 | } 38 | 39 | certPath := MustGetCACertPath() 40 | var attempted bool 41 | var errs []error 42 | 43 | for _, target := range linuxTrustTargets { 44 | if len(target.refresh) == 0 || !commandExists(target.refresh[0]) { 45 | continue 46 | } 47 | attempted = true 48 | 49 | if err := copyFile(certPath, target.path, 0644); err != nil { 50 | errs = append(errs, fmt.Errorf("copy to %s failed: %w", target.path, err)) 51 | continue 52 | } 53 | 54 | if err := runCommand(target.refresh[0], target.refresh[1:]...); err != nil { 55 | errs = append(errs, err) 56 | continue 57 | } 58 | 59 | return nil 60 | } 61 | 62 | if !attempted { 63 | return fmt.Errorf("no supported CA trust manager found (looked for update-ca-certificates/update-ca-trust)") 64 | } 65 | 66 | return errors.Join(errs...) 67 | } 68 | 69 | func uninstall() error { 70 | if os.Geteuid() != 0 { 71 | return fmt.Errorf("removing the root CA requires root privileges; please rerun with sudo") 72 | } 73 | 74 | var attempted bool 75 | var errs []error 76 | 77 | for _, target := range linuxTrustTargets { 78 | if len(target.refresh) == 0 || !commandExists(target.refresh[0]) { 79 | continue 80 | } 81 | 82 | attempted = true 83 | 84 | if err := os.Remove(target.path); err != nil && !os.IsNotExist(err) { 85 | errs = append(errs, fmt.Errorf("remove %s failed: %w", target.path, err)) 86 | continue 87 | } 88 | 89 | if err := runCommand(target.refresh[0], target.refresh[1:]...); err != nil { 90 | errs = append(errs, err) 91 | continue 92 | } 93 | } 94 | 95 | if !attempted { 96 | return fmt.Errorf("no supported CA trust manager found (looked for update-ca-certificates/update-ca-trust)") 97 | } 98 | 99 | if len(errs) > 0 { 100 | return errors.Join(errs...) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func fileExists(path string) bool { 107 | info, err := os.Stat(path) 108 | if err != nil { 109 | return false 110 | } 111 | return !info.IsDir() 112 | } 113 | 114 | func commandExists(name string) bool { 115 | _, err := exec.LookPath(name) 116 | return err == nil 117 | } 118 | 119 | func copyFile(src, dst string, perm os.FileMode) error { 120 | in, err := os.Open(src) 121 | if err != nil { 122 | return err 123 | } 124 | defer in.Close() 125 | 126 | if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { 127 | return err 128 | } 129 | 130 | out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) 131 | if err != nil { 132 | return err 133 | } 134 | defer out.Close() 135 | 136 | if _, err := io.Copy(out, in); err != nil { 137 | return err 138 | } 139 | 140 | return out.Chmod(perm) 141 | } 142 | 143 | func runCommand(name string, args ...string) error { 144 | cmd := exec.Command(name, args...) 145 | output, err := cmd.CombinedOutput() 146 | if err == nil { 147 | return nil 148 | } 149 | trimmed := strings.TrimSpace(string(output)) 150 | joinedArgs := strings.Join(args, " ") 151 | if trimmed != "" { 152 | return fmt.Errorf("%s %s failed: %w (output: %s)", name, joinedArgs, err, trimmed) 153 | } 154 | return fmt.Errorf("%s %s failed: %w", name, joinedArgs, err) 155 | } 156 | -------------------------------------------------------------------------------- /web-react/src/services/traffic-service.js: -------------------------------------------------------------------------------- 1 | const API_BASE = '/api'; 2 | const MOCK_ENTRIES = [ 3 | { 4 | id: 'a1', 5 | startTime: new Date(Date.now() - 1000).toISOString(), 6 | endTime: new Date().toISOString(), 7 | duration: 86, 8 | host: 'api.example.com', 9 | method: 'GET', 10 | url: 'https://api.example.com/v1/profile', 11 | path: '/v1/profile', 12 | statusCode: 200, 13 | contentType: 'application/json', 14 | contentSize: 20480, 15 | isSSE: false, 16 | isSSECompleted: false, 17 | isHTTPS: true, 18 | }, 19 | { 20 | id: 'a0', 21 | startTime: new Date(Date.now() - 2000).toISOString(), 22 | endTime: new Date(Date.now() - 1500).toISOString(), 23 | duration: 142, 24 | host: 'auth.example.com', 25 | method: 'POST', 26 | url: 'https://auth.example.com/oauth/token', 27 | path: '/oauth/token', 28 | statusCode: 201, 29 | contentType: 'application/json', 30 | contentSize: 4096, 31 | isSSE: false, 32 | isSSECompleted: false, 33 | isHTTPS: true, 34 | }, 35 | { 36 | id: '9f', 37 | startTime: new Date(Date.now() - 3000).toISOString(), 38 | endTime: new Date(Date.now() - 2500).toISOString(), 39 | duration: 0, 40 | host: 'stream.example.com', 41 | method: 'GET', 42 | url: 'https://stream.example.com/events', 43 | path: '/events', 44 | statusCode: 200, 45 | contentType: 'text/event-stream', 46 | contentSize: 5120, 47 | isSSE: true, 48 | isSSECompleted: false, 49 | isHTTPS: true, 50 | }, 51 | ]; 52 | const MOCK_DETAILS = { 53 | a1: { 54 | request: { 55 | headers: { 56 | Accept: 'application/json', 57 | Authorization: 'Bearer ...', 58 | }, 59 | }, 60 | response: { 61 | headers: { 62 | 'Content-Type': 'application/json', 63 | }, 64 | body: { name: 'Jane' }, 65 | }, 66 | }, 67 | }; 68 | async function fetchJSON(path, init) { 69 | const res = await fetch(`${API_BASE}${path}`, { 70 | credentials: 'same-origin', 71 | ...init, 72 | }); 73 | if (!res.ok) { 74 | throw new Error(`Request failed with status ${res.status}`); 75 | } 76 | if (res.status === 204) { 77 | return undefined; 78 | } 79 | return (await res.json()); 80 | } 81 | export async function fetchTrafficEntries() { 82 | try { 83 | const data = await fetchJSON('/traffic'); 84 | return data.entries ?? []; 85 | } 86 | catch (error) { 87 | console.warn('fetchTrafficEntries fallback to mock data:', error); 88 | return MOCK_ENTRIES; 89 | } 90 | } 91 | export async function fetchRequestDetail(id) { 92 | try { 93 | return await fetchJSON(`/traffic/${id}/request`); 94 | } 95 | catch (error) { 96 | console.warn(`fetchRequestDetail fallback for ${id}:`, error); 97 | return MOCK_DETAILS[id]?.request; 98 | } 99 | } 100 | export async function fetchResponseDetail(id) { 101 | try { 102 | return await fetchJSON(`/traffic/${id}/response`); 103 | } 104 | catch (error) { 105 | console.warn(`fetchResponseDetail fallback for ${id}:`, error); 106 | return MOCK_DETAILS[id]?.response; 107 | } 108 | } 109 | export async function fetchTrafficDetail(id) { 110 | const [request, response] = await Promise.all([ 111 | fetchRequestDetail(id), 112 | fetchResponseDetail(id), 113 | ]); 114 | if (!request && !response) { 115 | return undefined; 116 | } 117 | return { request, response }; 118 | } 119 | export async function clearTrafficRemote() { 120 | try { 121 | const res = await fetch(`${API_BASE}/traffic`, { 122 | method: 'DELETE', 123 | credentials: 'same-origin', 124 | }); 125 | if (!res.ok) { 126 | throw new Error(`Request failed with status ${res.status}`); 127 | } 128 | return true; 129 | } 130 | catch (error) { 131 | console.warn('clearTrafficRemote fallback:', error); 132 | return false; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /web-react/src/components/ui/select.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import * as SelectPrimitive from "@radix-ui/react-select"; 4 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 5 | import { cn } from "@/lib/utils"; 6 | const Select = SelectPrimitive.Root; 7 | const SelectGroup = SelectPrimitive.Group; 8 | const SelectValue = SelectPrimitive.Value; 9 | const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(SelectPrimitive.Trigger, { ref: ref, className: cn("flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className), ...props, children: [children, _jsx(SelectPrimitive.Icon, { asChild: true, children: _jsx(ChevronDown, { className: "h-4 w-4 opacity-50" }) })] }))); 10 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 11 | const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollUpButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronUp, { className: "h-4 w-4" }) }))); 12 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 13 | const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollDownButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronDown, { className: "h-4 w-4" }) }))); 14 | SelectScrollDownButton.displayName = 15 | SelectPrimitive.ScrollDownButton.displayName; 16 | const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (_jsx(SelectPrimitive.Portal, { children: _jsxs(SelectPrimitive.Content, { ref: ref, className: cn("relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", position === "popper" && 17 | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className), position: position, ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.Viewport, { className: cn("p-1", position === "popper" && 18 | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"), children: children }), _jsx(SelectScrollDownButton, {})] }) }))); 19 | SelectContent.displayName = SelectPrimitive.Content.displayName; 20 | const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.Label, { ref: ref, className: cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className), ...props }))); 21 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 22 | const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(SelectPrimitive.Item, { ref: ref, className: cn("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Check, { className: "h-4 w-4" }) }) }), _jsx(SelectPrimitive.ItemText, { children: children })] }))); 23 | SelectItem.displayName = SelectPrimitive.Item.displayName; 24 | const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.Separator, { ref: ref, className: cn("-mx-1 my-1 h-px bg-muted", className), ...props }))); 25 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 26 | export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, }; 27 | -------------------------------------------------------------------------------- /proxy/server_test.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/LubyRuffy/ProxyCraft/certs" 10 | "github.com/LubyRuffy/ProxyCraft/harlogger" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNewServer(t *testing.T) { 15 | // 创建必要的依赖项 16 | certMgr, _ := certs.NewManager() 17 | harLog := harlogger.NewLogger("", "ProxyCraft", "0.1.0") 18 | 19 | // 测试不同配置的服务器创建 20 | testCases := []struct { 21 | name string 22 | addr string 23 | verbose bool 24 | dumpTraffic bool 25 | }{ 26 | { 27 | name: "基本模式", 28 | addr: "127.0.0.1:0", 29 | verbose: false, 30 | dumpTraffic: false, 31 | }, 32 | { 33 | name: "详细日志模式", 34 | addr: "127.0.0.1:0", 35 | verbose: true, 36 | dumpTraffic: false, 37 | }, 38 | { 39 | name: "流量转储模式", 40 | addr: "127.0.0.1:0", 41 | verbose: false, 42 | dumpTraffic: true, 43 | }, 44 | { 45 | name: "全部功能启用", 46 | addr: "127.0.0.1:0", 47 | verbose: true, 48 | dumpTraffic: true, 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | // 创建代理服务器 55 | server := NewServer( 56 | tc.addr, 57 | certMgr, 58 | tc.verbose, 59 | harLog, 60 | nil, // 不使用上游代理 61 | tc.dumpTraffic, 62 | ) 63 | 64 | // 验证服务器属性 65 | assert.NotNil(t, server) 66 | assert.Equal(t, tc.addr, server.Addr) 67 | assert.Equal(t, tc.verbose, server.Verbose) 68 | assert.Equal(t, tc.dumpTraffic, server.DumpTraffic) 69 | assert.NotNil(t, server.HarLogger) 70 | assert.NotNil(t, server.CertManager) 71 | }) 72 | } 73 | } 74 | 75 | func TestLogToHAR(t *testing.T) { 76 | // 创建必要的依赖项 77 | certMgr, _ := certs.NewManager() 78 | harLog := harlogger.NewLogger("", "ProxyCraft", "0.1.0") 79 | 80 | // 创建服务器 81 | server := NewServer( 82 | "127.0.0.1:0", 83 | certMgr, 84 | true, 85 | harLog, 86 | nil, 87 | false, 88 | ) 89 | assert.NotNil(t, server) 90 | 91 | // 创建测试请求和响应 92 | testReq, _ := http.NewRequest("GET", "http://example.com", nil) 93 | testResp := &http.Response{ 94 | StatusCode: 200, 95 | Request: testReq, 96 | Header: make(http.Header), 97 | } 98 | testResp.Header.Set("Content-Type", "text/plain") 99 | 100 | // 记录启动时间 101 | startTime := time.Now().Add(-1 * time.Second) // 假设请求发生在1秒前 102 | timeTaken := 1 * time.Second 103 | 104 | // 测试常规请求的HAR日志记录 105 | server.logToHAR(testReq, testResp, startTime, timeTaken, false) 106 | 107 | // 测试SSE请求的HAR日志记录 108 | testResp.Header.Set("Content-Type", "text/event-stream") 109 | server.logToHAR(testReq, testResp, startTime, timeTaken, true) 110 | 111 | // 测试错误情况下的HAR日志记录 112 | server.logToHAR(testReq, nil, startTime, timeTaken, false) 113 | } 114 | 115 | func TestServerStart(t *testing.T) { 116 | // 创建必要的依赖项 117 | certMgr, _ := certs.NewManager() 118 | harLog := harlogger.NewLogger("", "ProxyCraft", "0.1.0") 119 | 120 | // 创建代理服务器 - 使用随机端口避免冲突 121 | listener, err := net.Listen("tcp", "127.0.0.1:0") 122 | assert.NoError(t, err) 123 | proxyAddr := listener.Addr().String() 124 | listener.Close() // 关闭监听器,让服务器可以使用这个端口 125 | 126 | // 创建代理服务器 127 | server := NewServer( 128 | proxyAddr, 129 | certMgr, 130 | false, 131 | harLog, 132 | nil, 133 | false, 134 | ) 135 | assert.NotNil(t, server) 136 | 137 | // 在后台启动服务器 138 | go func() { 139 | err := server.Start() 140 | // 我们期望Start在服务器正常运行期间不返回 141 | // 如果返回,并且不是因为我们关闭服务器,就是错误 142 | assert.NoError(t, err) 143 | }() 144 | 145 | // 等待服务器启动 146 | time.Sleep(100 * time.Millisecond) 147 | 148 | // 尝试连接到服务器 149 | conn, err := net.Dial("tcp", proxyAddr) 150 | if err == nil { 151 | // 如果连接成功,关闭连接 152 | conn.Close() 153 | } else { 154 | // 如果连接失败,记录错误(在CI环境中可能会失败) 155 | t.Logf("无法连接到代理服务器: %v", err) 156 | } 157 | } 158 | 159 | func TestHeaderInterceptingTransportStructure(t *testing.T) { 160 | // 创建一个模拟的RoundTripper 161 | mockBaseTransport := &http.Transport{} 162 | 163 | // 创建一个headerInterceptingTransport 164 | transport := &headerInterceptingTransport{ 165 | base: mockBaseTransport, 166 | verbose: true, 167 | callback: nil, 168 | } 169 | 170 | // 检查结构是否正确初始化 171 | assert.NotNil(t, transport.base) 172 | assert.True(t, transport.verbose) 173 | assert.Nil(t, transport.callback) 174 | } 175 | -------------------------------------------------------------------------------- /web-react/src/services/traffic-service.ts: -------------------------------------------------------------------------------- 1 | import { HttpMessage, TrafficDetail, TrafficEntry } from '@/types/traffic'; 2 | 3 | const API_BASE = '/api'; 4 | 5 | const MOCK_ENTRIES: TrafficEntry[] = [ 6 | { 7 | id: 'a1', 8 | startTime: new Date(Date.now() - 1000).toISOString(), 9 | endTime: new Date().toISOString(), 10 | duration: 86, 11 | host: 'api.example.com', 12 | method: 'GET', 13 | url: 'https://api.example.com/v1/profile', 14 | path: '/v1/profile', 15 | statusCode: 200, 16 | contentType: 'application/json', 17 | contentSize: 20480, 18 | isSSE: false, 19 | isSSECompleted: false, 20 | isHTTPS: true, 21 | }, 22 | { 23 | id: 'a0', 24 | startTime: new Date(Date.now() - 2000).toISOString(), 25 | endTime: new Date(Date.now() - 1500).toISOString(), 26 | duration: 142, 27 | host: 'auth.example.com', 28 | method: 'POST', 29 | url: 'https://auth.example.com/oauth/token', 30 | path: '/oauth/token', 31 | statusCode: 201, 32 | contentType: 'application/json', 33 | contentSize: 4096, 34 | isSSE: false, 35 | isSSECompleted: false, 36 | isHTTPS: true, 37 | }, 38 | { 39 | id: '9f', 40 | startTime: new Date(Date.now() - 3000).toISOString(), 41 | endTime: new Date(Date.now() - 2500).toISOString(), 42 | duration: 0, 43 | host: 'stream.example.com', 44 | method: 'GET', 45 | url: 'https://stream.example.com/events', 46 | path: '/events', 47 | statusCode: 200, 48 | contentType: 'text/event-stream', 49 | contentSize: 5120, 50 | isSSE: true, 51 | isSSECompleted: false, 52 | isHTTPS: true, 53 | }, 54 | ]; 55 | 56 | const MOCK_DETAILS: Record = { 57 | a1: { 58 | request: { 59 | headers: { 60 | Accept: 'application/json', 61 | Authorization: 'Bearer ...', 62 | }, 63 | }, 64 | response: { 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | body: { name: 'Jane' }, 69 | }, 70 | }, 71 | }; 72 | 73 | async function fetchJSON(path: string, init?: RequestInit): Promise { 74 | const res = await fetch(`${API_BASE}${path}`, { 75 | credentials: 'same-origin', 76 | ...init, 77 | }); 78 | 79 | if (!res.ok) { 80 | throw new Error(`Request failed with status ${res.status}`); 81 | } 82 | 83 | if (res.status === 204) { 84 | return undefined as T; 85 | } 86 | 87 | return (await res.json()) as T; 88 | } 89 | 90 | export async function fetchTrafficEntries(): Promise { 91 | try { 92 | const data = await fetchJSON<{ entries?: TrafficEntry[] }>('/traffic'); 93 | return data.entries ?? []; 94 | } catch (error) { 95 | console.warn('fetchTrafficEntries fallback to mock data:', error); 96 | return MOCK_ENTRIES; 97 | } 98 | } 99 | 100 | export async function fetchRequestDetail(id: string): Promise { 101 | try { 102 | return await fetchJSON(`/traffic/${id}/request`); 103 | } catch (error) { 104 | console.warn(`fetchRequestDetail fallback for ${id}:`, error); 105 | return MOCK_DETAILS[id]?.request; 106 | } 107 | } 108 | 109 | export async function fetchResponseDetail(id: string): Promise { 110 | try { 111 | return await fetchJSON(`/traffic/${id}/response`); 112 | } catch (error) { 113 | console.warn(`fetchResponseDetail fallback for ${id}:`, error); 114 | return MOCK_DETAILS[id]?.response; 115 | } 116 | } 117 | 118 | export async function fetchTrafficDetail(id: string): Promise { 119 | const [request, response] = await Promise.all([ 120 | fetchRequestDetail(id), 121 | fetchResponseDetail(id), 122 | ]); 123 | 124 | if (!request && !response) { 125 | return undefined; 126 | } 127 | 128 | return { request, response }; 129 | } 130 | 131 | export async function clearTrafficRemote(): Promise { 132 | try { 133 | const res = await fetch(`${API_BASE}/traffic`, { 134 | method: 'DELETE', 135 | credentials: 'same-origin', 136 | }); 137 | if (!res.ok) { 138 | throw new Error(`Request failed with status ${res.status}`); 139 | } 140 | return true; 141 | } catch (error) { 142 | console.warn('clearTrafficRemote fallback:', error); 143 | return false; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /proxy/http_handler.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // handleHTTP is the handler for all incoming HTTP requests 10 | func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { 11 | log.Printf("[HTTP] Received request: %s %s %s %s", r.Method, r.Host, r.URL.String(), r.Proto) 12 | 13 | if r.Method == http.MethodConnect { 14 | s.handleHTTPS(w, r) 15 | return 16 | } 17 | 18 | targetURL := s.resolveTargetURL(r) 19 | 20 | baseTransport := s.newTransport(r.Host, false) 21 | transport := s.wrapTransportForSSE(baseTransport) 22 | 23 | proxyReq, reqCtx, potentialSSE, startTime, err := s.prepareProxyRequest(r, targetURL, false) 24 | if err != nil { 25 | log.Printf("[Proxy] Error creating proxy request for %s: %v", targetURL, err) 26 | http.Error(w, "Error creating proxy request", http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | logPotentialSSE(s.Verbose, "[Proxy]", potentialSSE) 31 | 32 | resp, timeTaken, err := s.sendProxyRequest(proxyReq, transport, potentialSSE, startTime) 33 | if err != nil { 34 | log.Printf("[Proxy] Error sending request to target server %s: %v", targetURL, err) 35 | s.recordProxyError(err, reqCtx, startTime, timeTaken) 36 | http.Error(w, "Error proxying to "+targetURL+": "+err.Error(), http.StatusBadGateway) 37 | return 38 | } 39 | defer resp.Body.Close() 40 | 41 | respCtx, isSSE := s.processProxyResponse(reqCtx, resp, startTime, timeTaken, "[Proxy]", targetURL) 42 | 43 | if isSSE { 44 | if err := s.handleSSE(w, respCtx); err != nil { 45 | log.Printf("[SSE] Error handling SSE response: %v", err) 46 | s.notifyError(err, reqCtx) 47 | } 48 | return 49 | } 50 | 51 | if err := s.writeHTTPResponse(w, respCtx, r.Proto); err != nil { 52 | log.Printf("[Proxy] Error streaming response: %v", err) 53 | } 54 | } 55 | 56 | // resolveTargetURL builds the absolute target URL for the incoming request. 57 | func (s *Server) resolveTargetURL(r *http.Request) string { 58 | if r.URL.IsAbs() { 59 | return r.URL.String() 60 | } 61 | 62 | targetURL := "http://" + r.Host + r.URL.Path 63 | if r.URL.RawQuery != "" { 64 | targetURL += "?" + r.URL.RawQuery 65 | } 66 | return targetURL 67 | } 68 | 69 | // isTextContentType 判断Content-Type是否为文本类型 70 | func isTextContentType(contentType string) bool { 71 | if contentType == "" { 72 | return false 73 | } 74 | 75 | contentType = strings.ToLower(contentType) 76 | 77 | // 移除可能的字符集和其他参数 78 | if idx := strings.Index(contentType, ";"); idx >= 0 { 79 | contentType = contentType[:idx] 80 | } 81 | contentType = strings.TrimSpace(contentType) 82 | 83 | // 直接匹配的类型 84 | knownTextTypes := []string{ 85 | "text/", // 所有text/类型 86 | "application/json", // JSON 87 | "application/xml", // XML 88 | "application/javascript", // JavaScript 89 | "application/x-javascript", // 旧式JavaScript 90 | "application/ecmascript", // ECMAScript 91 | "application/x-www-form-urlencoded", // 表单数据 92 | "application/xhtml+xml", // XHTML 93 | "application/atom+xml", // Atom 94 | "application/rss+xml", // RSS 95 | "application/soap+xml", // SOAP 96 | "application/x-yaml", // YAML 97 | "application/yaml", // YAML 98 | "application/graphql", // GraphQL 99 | "message/rfc822", // 邮件格式 100 | } 101 | 102 | for _, textType := range knownTextTypes { 103 | if strings.HasPrefix(contentType, textType) { 104 | return true 105 | } 106 | } 107 | 108 | // 包含特定后缀的类型 109 | knownTextSuffixes := []string{ 110 | "+json", // JSON类型的变体如application/ld+json 111 | "+xml", // XML类型的变体如application/rdf+xml 112 | "+text", // 任何带text后缀的类型 113 | } 114 | 115 | for _, suffix := range knownTextSuffixes { 116 | if strings.HasSuffix(contentType, suffix) { 117 | return true 118 | } 119 | } 120 | 121 | // 特定的不常见但仍是文本的MIME类型 122 | otherTextTypes := map[string]bool{ 123 | "application/json-patch+json": true, 124 | "application/merge-patch+json": true, 125 | "application/schema+json": true, 126 | "application/vnd.api+json": true, 127 | "application/vnd.github+json": true, 128 | "application/problem+json": true, 129 | "application/x-httpd-php": true, 130 | "application/x-sh": true, 131 | "application/x-csh": true, 132 | "application/typescript": true, 133 | "application/sql": true, 134 | "application/csv": true, 135 | "application/x-csv": true, 136 | "text/csv": true, 137 | "application/ld+json": true, 138 | } 139 | return otherTextTypes[contentType] 140 | } 141 | -------------------------------------------------------------------------------- /proxy/event_handler.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // EventHandler 定义了代理处理不同事件的接口 11 | type EventHandler interface { 12 | // OnRequest 在收到请求时调用 13 | OnRequest(ctx *RequestContext) *http.Request 14 | 15 | // OnResponse 在收到响应时调用 16 | OnResponse(ctx *ResponseContext) *http.Response 17 | 18 | // OnError 在处理过程中发生错误时调用 19 | OnError(err error, reqCtx *RequestContext) 20 | 21 | // OnTunnelEstablished 在HTTPS隧道建立时调用 22 | OnTunnelEstablished(host string, isIntercepted bool) 23 | 24 | // OnSSE 在处理服务器发送事件流时调用 25 | OnSSE(event string, ctx *ResponseContext) 26 | } 27 | 28 | // RequestContext 包含请求的上下文信息 29 | type RequestContext struct { 30 | // 原始请求 31 | Request *http.Request 32 | 33 | // 请求的开始时间 34 | StartTime time.Time 35 | 36 | // IsSSE 表示这是否可能是一个SSE请求 37 | IsSSE bool 38 | 39 | // IsHTTPS 表示这是否是HTTPS请求 40 | IsHTTPS bool 41 | 42 | // TargetURL 表示请求的目标URL 43 | TargetURL string 44 | 45 | // 用于保存上下文的自定义数据 46 | UserData map[string]interface{} 47 | } 48 | 49 | // GetRequestBody 获取请求体的内容,同时保持请求体可以再次被读取 50 | func (ctx *RequestContext) GetRequestBody() ([]byte, error) { 51 | if ctx.Request == nil || ctx.Request.Body == nil { 52 | return nil, nil 53 | } 54 | 55 | body, err := io.ReadAll(ctx.Request.Body) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | // 重置请求体,使其可以再次被读取 61 | ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) 62 | 63 | return body, nil 64 | } 65 | 66 | // ResponseContext 包含响应的上下文信息 67 | type ResponseContext struct { 68 | // 原始请求上下文 69 | ReqCtx *RequestContext 70 | 71 | // 原始响应 72 | Response *http.Response 73 | 74 | // 响应耗时 75 | TimeTaken time.Duration 76 | 77 | // IsSSE 表示这是否是一个SSE响应 78 | IsSSE bool 79 | 80 | // 用于保存上下文的自定义数据 81 | UserData map[string]interface{} 82 | } 83 | 84 | // GetResponseBody 获取响应体的内容,同时保持响应体可以再次被读取 85 | func (ctx *ResponseContext) GetResponseBody() ([]byte, error) { 86 | if ctx.Response == nil || ctx.Response.Body == nil { 87 | return nil, nil 88 | } 89 | 90 | body, err := io.ReadAll(ctx.Response.Body) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | // 重置响应体,使其可以再次被读取 96 | ctx.Response.Body = io.NopCloser(bytes.NewBuffer(body)) 97 | 98 | return body, nil 99 | } 100 | 101 | // NoOpEventHandler 提供一个默认的空实现,方便只重写部分方法 102 | type NoOpEventHandler struct{} 103 | 104 | // OnRequest 实现 EventHandler 接口 105 | func (h *NoOpEventHandler) OnRequest(ctx *RequestContext) *http.Request { 106 | return ctx.Request 107 | } 108 | 109 | // OnResponse 实现 EventHandler 接口 110 | func (h *NoOpEventHandler) OnResponse(ctx *ResponseContext) *http.Response { 111 | return ctx.Response 112 | } 113 | 114 | // OnError 实现 EventHandler 接口 115 | func (h *NoOpEventHandler) OnError(err error, reqCtx *RequestContext) {} 116 | 117 | // OnTunnelEstablished 实现 EventHandler 接口 118 | func (h *NoOpEventHandler) OnTunnelEstablished(host string, isIntercepted bool) {} 119 | 120 | // OnSSE 实现 EventHandler 接口 121 | func (h *NoOpEventHandler) OnSSE(event string, ctx *ResponseContext) {} 122 | 123 | // MultiEventHandler 允许注册多个事件处理器 124 | type MultiEventHandler struct { 125 | handlers []EventHandler 126 | } 127 | 128 | // NewMultiEventHandler 创建一个新的多事件处理器 129 | func NewMultiEventHandler(handlers ...EventHandler) *MultiEventHandler { 130 | return &MultiEventHandler{ 131 | handlers: handlers, 132 | } 133 | } 134 | 135 | // AddHandler 添加一个事件处理器 136 | func (m *MultiEventHandler) AddHandler(handler EventHandler) { 137 | m.handlers = append(m.handlers, handler) 138 | } 139 | 140 | // OnRequest 实现 EventHandler 接口,调用所有处理器 141 | func (m *MultiEventHandler) OnRequest(ctx *RequestContext) *http.Request { 142 | req := ctx.Request 143 | for _, handler := range m.handlers { 144 | if modifiedReq := handler.OnRequest(ctx); modifiedReq != nil { 145 | req = modifiedReq 146 | ctx.Request = req 147 | } 148 | } 149 | return req 150 | } 151 | 152 | // OnResponse 实现 EventHandler 接口,调用所有处理器 153 | func (m *MultiEventHandler) OnResponse(ctx *ResponseContext) *http.Response { 154 | resp := ctx.Response 155 | for _, handler := range m.handlers { 156 | if modifiedResp := handler.OnResponse(ctx); modifiedResp != nil { 157 | resp = modifiedResp 158 | ctx.Response = resp 159 | } 160 | } 161 | return resp 162 | } 163 | 164 | // OnError 实现 EventHandler 接口,调用所有处理器 165 | func (m *MultiEventHandler) OnError(err error, reqCtx *RequestContext) { 166 | for _, handler := range m.handlers { 167 | handler.OnError(err, reqCtx) 168 | } 169 | } 170 | 171 | // OnTunnelEstablished 实现 EventHandler 接口,调用所有处理器 172 | func (m *MultiEventHandler) OnTunnelEstablished(host string, isIntercepted bool) { 173 | for _, handler := range m.handlers { 174 | handler.OnTunnelEstablished(host, isIntercepted) 175 | } 176 | } 177 | 178 | // OnSSE 实现 EventHandler 接口,调用所有处理器 179 | func (m *MultiEventHandler) OnSSE(event string, ctx *ResponseContext) { 180 | for _, handler := range m.handlers { 181 | handler.OnSSE(event, ctx) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /web-react/src/stores/use-traffic-store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { devtools } from 'zustand/middleware'; 3 | 4 | import { TrafficDetail, TrafficEntry } from '@/types/traffic'; 5 | 6 | export type SettingsState = { 7 | // WebSocket连接设置 8 | autoReconnect: boolean; 9 | reconnectInterval: number; 10 | maxReconnectAttempts: number; 11 | 12 | // 显示设置 13 | entriesPerPage: number; 14 | showOnlyHttps: boolean; 15 | showOnlySse: boolean; 16 | 17 | // 数据保存设置 18 | autoSaveHar: boolean; 19 | harSaveInterval: number; 20 | 21 | // 过滤设置 22 | filterHost: string; 23 | filterMethod: 'all' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 24 | 25 | // 主题设置 26 | theme: 'light' | 'dark' | 'auto'; 27 | 28 | setAutoReconnect: (autoReconnect: boolean) => void; 29 | setReconnectInterval: (interval: number) => void; 30 | setMaxReconnectAttempts: (max: number) => void; 31 | setEntriesPerPage: (count: number) => void; 32 | setShowOnlyHttps: (show: boolean) => void; 33 | setShowOnlySse: (show: boolean) => void; 34 | setAutoSaveHar: (autoSave: boolean) => void; 35 | setHarSaveInterval: (interval: number) => void; 36 | setFilterHost: (host: string) => void; 37 | setFilterMethod: (method: 'all' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH') => void; 38 | setTheme: (theme: 'light' | 'dark' | 'auto') => void; 39 | resetSettings: () => void; 40 | }; 41 | 42 | export type TrafficState = { 43 | entries: TrafficEntry[]; 44 | selectedId: string | null; 45 | detail?: TrafficDetail; 46 | loading: boolean; 47 | error: string | null; 48 | connected: boolean; 49 | transport: string; 50 | setEntries: (entries: TrafficEntry[]) => void; 51 | addOrUpdateEntry: (entry: TrafficEntry) => void; 52 | selectEntry: (id: string | null) => void; 53 | setDetail: (detail?: TrafficDetail) => void; 54 | mergeDetail: (partial: Partial) => void; 55 | clearDetail: () => void; 56 | setLoading: (loading: boolean) => void; 57 | setError: (error: string | null) => void; 58 | setConnected: (connected: boolean) => void; 59 | setTransport: (transport: string) => void; 60 | clearEntries: () => void; 61 | }; 62 | 63 | // 默认设置值 64 | const defaultSettings: Omit = { 65 | autoReconnect: true, 66 | reconnectInterval: 5, 67 | maxReconnectAttempts: 10, 68 | entriesPerPage: 50, 69 | showOnlyHttps: false, 70 | showOnlySse: false, 71 | autoSaveHar: false, 72 | harSaveInterval: 30, 73 | filterHost: '', 74 | filterMethod: 'all', 75 | theme: 'auto' as const, 76 | }; 77 | 78 | // 创建设置store 79 | const settingsStore = create()( 80 | devtools( 81 | (set) => ({ 82 | ...defaultSettings, 83 | setAutoReconnect: (autoReconnect) => set({ autoReconnect }), 84 | setReconnectInterval: (interval) => set({ reconnectInterval: Math.max(1, interval) }), 85 | setMaxReconnectAttempts: (max) => set({ maxReconnectAttempts: Math.max(1, max) }), 86 | setEntriesPerPage: (count) => set({ entriesPerPage: Math.max(10, count) }), 87 | setShowOnlyHttps: (show) => set({ showOnlyHttps: show }), 88 | setShowOnlySse: (show) => set({ showOnlySse: show }), 89 | setAutoSaveHar: (autoSave) => set({ autoSaveHar: autoSave }), 90 | setHarSaveInterval: (interval) => set({ harSaveInterval: Math.max(5, interval) }), 91 | setFilterHost: (host) => set({ filterHost: host }), 92 | setFilterMethod: (method: 'all' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH') => set({ filterMethod: method }), 93 | setTheme: (theme) => set({ theme }), 94 | resetSettings: () => set(defaultSettings), 95 | }), 96 | { name: 'settings-store' } 97 | ) 98 | ); 99 | 100 | const store = create()( 101 | devtools( 102 | (set) => ({ 103 | entries: [], 104 | selectedId: null, 105 | detail: undefined, 106 | loading: false, 107 | error: null, 108 | connected: false, 109 | transport: 'unknown', 110 | setEntries: (entries) => 111 | set({ 112 | entries: [...entries].sort((a, b) => { 113 | const timeA = a.startTime ? new Date(a.startTime).getTime() : 0; 114 | const timeB = b.startTime ? new Date(b.startTime).getTime() : 0; 115 | return timeB - timeA; 116 | }), 117 | }), 118 | addOrUpdateEntry: (entry) => 119 | set((state) => { 120 | const existingIndex = state.entries.findIndex((item) => item.id === entry.id); 121 | if (existingIndex === -1) { 122 | return { entries: [entry, ...state.entries] }; 123 | } 124 | const updated = [...state.entries]; 125 | updated[existingIndex] = { ...updated[existingIndex], ...entry }; 126 | return { entries: updated }; 127 | }), 128 | selectEntry: (id) => set({ selectedId: id }), 129 | setDetail: (detail) => set({ detail }), 130 | mergeDetail: (partial) => 131 | set((state) => ({ detail: { ...(state.detail ?? {}), ...partial } })), 132 | clearDetail: () => set({ detail: undefined }), 133 | setLoading: (loading) => set({ loading }), 134 | setError: (error) => set({ error }), 135 | setConnected: (connected) => set({ connected }), 136 | setTransport: (transport) => set({ transport }), 137 | clearEntries: () => 138 | set({ 139 | entries: [], 140 | selectedId: null, 141 | detail: undefined, 142 | }), 143 | }), 144 | { name: 'traffic-store' } 145 | ) 146 | ); 147 | 148 | // 导出设置store 149 | export const useSettingsStore = settingsStore; 150 | 151 | export const useTrafficStore = store; 152 | -------------------------------------------------------------------------------- /proxy/handlers/cli_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/LubyRuffy/ProxyCraft/proxy" 10 | ) 11 | 12 | // CLIHandler 是一个命令行界面的事件处理器 13 | type CLIHandler struct { 14 | // Verbose 是否输出详细信息 15 | Verbose bool 16 | 17 | // DumpBody 是否输出请求和响应的主体 18 | DumpBody bool 19 | 20 | // 计数器 21 | RequestCount int 22 | ResponseCount int 23 | ErrorCount int 24 | TunnelCount int 25 | SSECount int 26 | } 27 | 28 | // NewCLIHandler 创建一个新的命令行事件处理器 29 | func NewCLIHandler(verbose, dumpBody bool) *CLIHandler { 30 | return &CLIHandler{ 31 | Verbose: verbose, 32 | DumpBody: dumpBody, 33 | } 34 | } 35 | 36 | // OnRequest 实现 EventHandler 接口 37 | func (h *CLIHandler) OnRequest(ctx *proxy.RequestContext) *http.Request { 38 | h.RequestCount++ 39 | 40 | if h.Verbose { 41 | fmt.Printf("[REQ] #%d %s %s\n", h.RequestCount, ctx.Request.Method, ctx.TargetURL) 42 | 43 | // 输出请求头 44 | fmt.Println("[REQ] Headers:") 45 | for key, values := range ctx.Request.Header { 46 | for _, value := range values { 47 | fmt.Printf("[REQ] %s: %s\n", key, value) 48 | } 49 | } 50 | 51 | // 如果需要输出主体 52 | if h.DumpBody { 53 | body, err := ctx.GetRequestBody() 54 | if err != nil { 55 | fmt.Printf("[REQ] Error reading body: %v\n", err) 56 | } else if len(body) > 0 { 57 | if len(body) > 1024 { 58 | fmt.Printf("[REQ] Body (%d bytes, showing first 1024):\n%s...\n", len(body), body[:1024]) 59 | } else { 60 | fmt.Printf("[REQ] Body (%d bytes):\n%s\n", len(body), body) 61 | } 62 | } 63 | } 64 | } else { 65 | fmt.Printf("[REQ] #%d %s %s\n", h.RequestCount, ctx.Request.Method, ctx.TargetURL) 66 | } 67 | 68 | return ctx.Request 69 | } 70 | 71 | // OnResponse 实现 EventHandler 接口 72 | func (h *CLIHandler) OnResponse(ctx *proxy.ResponseContext) *http.Response { 73 | h.ResponseCount++ 74 | 75 | if h.Verbose { 76 | fmt.Printf("[RES] #%d %s %s -> %d %s (took %dms)\n", 77 | h.ResponseCount, 78 | ctx.ReqCtx.Request.Method, 79 | ctx.ReqCtx.TargetURL, 80 | ctx.Response.StatusCode, 81 | ctx.Response.Header.Get("Content-Type"), 82 | ctx.TimeTaken.Milliseconds()) 83 | 84 | // 输出响应头 85 | fmt.Println("[RES] Headers:") 86 | for key, values := range ctx.Response.Header { 87 | for _, value := range values { 88 | fmt.Printf("[RES] %s: %s\n", key, value) 89 | } 90 | } 91 | 92 | // 如果需要输出主体,且不是SSE 93 | if h.DumpBody && !ctx.IsSSE { 94 | body, err := ctx.GetResponseBody() 95 | if err != nil { 96 | fmt.Printf("[RES] Error reading body: %v\n", err) 97 | } else if len(body) > 0 { 98 | if len(body) > 1024 { 99 | fmt.Printf("[RES] Body (%d bytes, showing first 1024):\n%s...\n", len(body), body[:1024]) 100 | } else { 101 | fmt.Printf("[RES] Body (%d bytes):\n%s\n", len(body), body) 102 | } 103 | } 104 | } 105 | } else { 106 | fmt.Printf("[RES] #%d %s %s -> %d %s (took %dms)\n", 107 | h.ResponseCount, 108 | ctx.ReqCtx.Request.Method, 109 | ctx.ReqCtx.TargetURL, 110 | ctx.Response.StatusCode, 111 | ctx.Response.Header.Get("Content-Type"), 112 | ctx.TimeTaken.Milliseconds()) 113 | } 114 | 115 | return ctx.Response 116 | } 117 | 118 | // OnError 实现 EventHandler 接口 119 | func (h *CLIHandler) OnError(err error, reqCtx *proxy.RequestContext) { 120 | h.ErrorCount++ 121 | 122 | if reqCtx != nil { 123 | log.Printf("[ERR] #%d %s %s: %v", h.ErrorCount, reqCtx.Request.Method, reqCtx.TargetURL, err) 124 | } else { 125 | log.Printf("[ERR] #%d: %v", h.ErrorCount, err) 126 | } 127 | } 128 | 129 | // OnTunnelEstablished 实现 EventHandler 接口 130 | func (h *CLIHandler) OnTunnelEstablished(host string, isIntercepted bool) { 131 | h.TunnelCount++ 132 | log.Printf("[TUN] #%d 与 %s 的隧道已建立", h.TunnelCount, host) 133 | } 134 | 135 | // OnSSE 实现 EventHandler 接口 136 | func (h *CLIHandler) OnSSE(event string, ctx *proxy.ResponseContext) { 137 | h.SSECount++ 138 | 139 | if h.Verbose { 140 | fmt.Printf("[SSE] #%d %s\n", h.SSECount, event) 141 | } 142 | } 143 | 144 | // GetStats 获取处理器的统计信息 145 | func (h *CLIHandler) GetStats() string { 146 | return fmt.Sprintf( 147 | "请求: %d, 响应: %d, 错误: %d, 隧道: %d, SSE事件: %d", 148 | h.RequestCount, 149 | h.ResponseCount, 150 | h.ErrorCount, 151 | h.TunnelCount, 152 | h.SSECount, 153 | ) 154 | } 155 | 156 | // 统计报告器,定期输出统计信息 157 | type StatsReporter struct { 158 | Handler *CLIHandler 159 | Running bool 160 | Interval time.Duration 161 | StopChan chan struct{} 162 | StartTime time.Time 163 | } 164 | 165 | // NewStatsReporter 创建一个新的统计报告器 166 | func NewStatsReporter(handler *CLIHandler, interval time.Duration) *StatsReporter { 167 | return &StatsReporter{ 168 | Handler: handler, 169 | Interval: interval, 170 | StopChan: make(chan struct{}), 171 | StartTime: time.Now(), 172 | } 173 | } 174 | 175 | // Start 开始定期报告 176 | func (r *StatsReporter) Start() { 177 | if r.Running { 178 | return 179 | } 180 | 181 | r.Running = true 182 | r.StartTime = time.Now() 183 | 184 | go func() { 185 | ticker := time.NewTicker(r.Interval) 186 | defer ticker.Stop() 187 | 188 | for { 189 | select { 190 | case <-ticker.C: 191 | r.Report() 192 | case <-r.StopChan: 193 | return 194 | } 195 | } 196 | }() 197 | } 198 | 199 | // Stop 停止报告 200 | func (r *StatsReporter) Stop() { 201 | if !r.Running { 202 | return 203 | } 204 | 205 | r.StopChan <- struct{}{} 206 | r.Running = false 207 | r.Report() // 输出最终报告 208 | } 209 | 210 | // Report 输出一次报告 211 | func (r *StatsReporter) Report() { 212 | runningTime := time.Since(r.StartTime) 213 | fmt.Printf("\n--- 统计报告(运行时间: %s)---\n", runningTime.Round(time.Second)) 214 | fmt.Println(r.Handler.GetStats()) 215 | fmt.Printf("QPS: %.2f, 平均响应时间: %.2fms\n", 216 | float64(r.Handler.RequestCount)/runningTime.Seconds(), 217 | float64(r.Handler.ResponseCount)/float64(r.Handler.RequestCount)*1000) 218 | fmt.Print("--------------------------------\n") 219 | } 220 | -------------------------------------------------------------------------------- /certs/install.go: -------------------------------------------------------------------------------- 1 | package certs 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/pem" 9 | "fmt" 10 | "log" 11 | "math/big" 12 | "os" 13 | "path/filepath" 14 | "time" 15 | ) 16 | 17 | var ( 18 | IssuerName = "ProxyCraft Root CA" // Issuer Name 19 | OrgName = "ProxyCraft Inc." // Organization Name 20 | NotAfter = time.Now().AddDate(10, 0, 0) // 10 years 21 | ) 22 | 23 | const ( 24 | caCertFile = "proxycraft-ca.pem" // CA certificate file name 25 | caKeyFile = "proxycraft-ca-key.pem" // CA private key file name 26 | rsaBits = 2048 // RSA bits 27 | ) 28 | 29 | // mustGetCertDir returns the directory where certificates are stored (~/.proxycraft). 30 | // It creates the directory if it doesn't exist. 31 | func mustGetCertDir() string { 32 | home, err := os.UserHomeDir() 33 | if err != nil { 34 | log.Fatalf("could not get user home directory: %v", err) 35 | } 36 | certDir := filepath.Join(home, ".proxycraft") 37 | if err := os.MkdirAll(certDir, 0755); err != nil { 38 | log.Fatalf("could not create cert directory %s: %v", certDir, err) 39 | } 40 | return certDir 41 | } 42 | 43 | // generateCA generates a CA certificate and private key. 44 | func generateCA(issuerName string, orgName string, notAfter time.Time) (*x509.Certificate, *rsa.PrivateKey, error) { 45 | privKey, err := rsa.GenerateKey(rand.Reader, rsaBits) 46 | if err != nil { 47 | return nil, nil, fmt.Errorf("failed to generate RSA private key: %w", err) 48 | } 49 | 50 | template := x509.Certificate{ 51 | SerialNumber: big.NewInt(time.Now().Unix()), 52 | Subject: pkix.Name{ 53 | Organization: []string{orgName}, 54 | CommonName: issuerName, 55 | }, 56 | NotBefore: time.Now().AddDate(0, 0, -1), // Start 1 day ago for clock skew 57 | NotAfter: notAfter, 58 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 59 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, 60 | BasicConstraintsValid: true, 61 | IsCA: true, 62 | } 63 | 64 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) 65 | if err != nil { 66 | return nil, nil, fmt.Errorf("failed to create certificate: %w", err) 67 | } 68 | 69 | cert, err := x509.ParseCertificate(derBytes) 70 | if err != nil { 71 | return nil, nil, fmt.Errorf("failed to parse created certificate: %w", err) 72 | } 73 | 74 | return cert, privKey, nil 75 | } 76 | 77 | // GenerateToFile generates a CA certificate and private key to the specified file path. 78 | func generateToFile(certPath, keyPath string, issuerName string, orgName string, notAfter time.Time) error { 79 | cert, key, err := generateCA(issuerName, orgName, notAfter) 80 | if err != nil { 81 | return fmt.Errorf("failed to generate CA: %w", err) 82 | } 83 | 84 | // Save CA certificate to file 85 | certOut, err := os.Create(certPath) 86 | if err != nil { 87 | return fmt.Errorf("failed to open %s for writing: %w", certPath, err) 88 | } 89 | defer certOut.Close() 90 | if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { 91 | return fmt.Errorf("failed to write CA certificate to %s: %w", certPath, err) 92 | } 93 | 94 | // Save CA private key to file 95 | keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 96 | if err != nil { 97 | return fmt.Errorf("failed to open %s for writing: %w", keyPath, err) 98 | } 99 | defer keyOut.Close() 100 | privBytes, err := x509.MarshalPKCS8PrivateKey(key) 101 | if err != nil { 102 | return fmt.Errorf("failed to marshal private key: %w", err) 103 | } 104 | if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { 105 | return fmt.Errorf("failed to write CA key to %s: %w", keyPath, err) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func IsInstalled() bool { 112 | installed, err := isInstalled() 113 | if err != nil { 114 | log.Fatalf("failed to check if certificate is installed: %v", err) 115 | } 116 | return installed 117 | } 118 | 119 | // Install installs the CA certificate to the system keychain. 120 | func Install() error { 121 | // 先检查证书是否已经安装 122 | installed := IsInstalled() 123 | if installed { 124 | fmt.Println("CA certificate is already installed in the system keychain. Skipping installation.") 125 | return nil 126 | } 127 | 128 | // 生成证书 129 | certDir := mustGetCertDir() 130 | certPath := filepath.Join(certDir, caCertFile) 131 | keyPath := filepath.Join(certDir, caKeyFile) 132 | needToGenerate := false 133 | if _, err := os.Stat(certPath); os.IsNotExist(err) { 134 | needToGenerate = true 135 | } else if _, err := os.Stat(keyPath); os.IsNotExist(err) { 136 | needToGenerate = true 137 | } 138 | if needToGenerate { 139 | err := generateToFile(certPath, keyPath, IssuerName, OrgName, NotAfter) 140 | if err != nil { 141 | return fmt.Errorf("failed to generate CA: %w", err) 142 | } 143 | } 144 | 145 | // 安装证书 146 | err := install() 147 | if err != nil { 148 | return fmt.Errorf("failed to install certificate: %w", err) 149 | } 150 | 151 | fmt.Println("Certificate installed successfully.") 152 | return nil 153 | } 154 | 155 | // Uninstall uninstalls the CA certificate from the system keychain. 156 | func Uninstall() error { 157 | // 先检查证书是否已经安装 158 | installed, err := isInstalled() 159 | if err != nil { 160 | return fmt.Errorf("failed to check if certificate is installed: %w", err) 161 | } 162 | if !installed { 163 | fmt.Println("CA certificate is not installed in the system keychain. Skipping uninstallation.") 164 | return nil 165 | } 166 | 167 | // 卸载证书 168 | err = uninstall() 169 | if err != nil { 170 | return fmt.Errorf("failed to uninstall certificate: %w", err) 171 | } 172 | 173 | fmt.Println("Certificate uninstalled successfully.") 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /prd.md: -------------------------------------------------------------------------------- 1 | # PRD: 命令行 HTTPS/HTTP2/SSE 代理工具 (代号: "ProxyCraft CLI") 2 | 3 | ## 1. 引言 (Introduction) 4 | 5 | ProxyCraft CLI 是一款轻量级、高性能的命令行代理工具,旨在为开发人员、安全测试人员和网络管理员提供便捷的流量观察、分析和调试能力。它专注于核心代理功能,支持最新的 Web 协议如 HTTPS, HTTP/2, 以及 Server-Sent Events (SSE),并且完全通过命令行界面进行操作,无需图形用户界面。其设计灵感来源于 Burp Suite 的代理功能,但更侧重于命令行环境下的易用性和可集成性。 6 | 7 | ## 2. 目标 (Goals) 8 | 9 | * **核心代理功能:** 提供稳定可靠的 HTTP/HTTPS 代理服务。 10 | * **现代协议支持:** 无缝支持 HTTP/1.1, HTTP/2, 和 HTTPS (TLS/SSL)。 11 | * **SSE 协议支持:** 能够正确代理并展示 Server-Sent Events 流量。 12 | * **命令行友好:** 所有功能通过命令行参数和输出进行交互,易于脚本化和集成到自动化流程中。 13 | * **流量可视性:** 清晰展示请求和响应的头部、正文等关键信息。 14 | * **HTTPS 解密:** 支持中间人 (MITM) 攻击以解密和检查 HTTPS 流量。 15 | * **轻量高效:** 资源占用低,启动速度快,对系统性能影响小。 16 | 17 | ## 3. 目标用户 (Target Audience) 18 | 19 | * **Web 开发人员:** 调试客户端与服务器之间的通信,理解 API 调用,分析 SSE 流。 20 | * **API 开发人员:** 测试和验证 API 端点的行为和性能。 21 | * **安全研究员/渗透测试员:** 初步分析应用流量,识别潜在的通信模式 (非主动攻击工具)。 22 | * **网络管理员/DevOps 工程师:** 诊断网络连接问题,监控特定应用流量。 23 | 24 | ## 4. 功能需求 (Functional Requirements) 25 | 26 | ### 4.1 代理核心 (Proxy Core) 27 | 28 | * **FR1.1:** 工具能够作为 HTTP/HTTPS 代理服务器启动,监听在用户指定的 IP 地址和端口上。 29 | * **FR1.1.1:** 默认监听 `127.0.0.1:8080`,用户可通过命令行参数修改。 30 | * **FR1.2:** 支持 HTTP/1.1 协议的代理。 31 | * **FR1.3:** 能够将客户端请求转发到目标服务器,并将服务器响应返回给客户端。 32 | * **FR1.4:** 正确处理各种 HTTP 方法 (GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH 等)。 33 | * **FR1.5:** 正确处理常见的 HTTP 状态码。 34 | * **FR1.6:** 支持 Keep-Alive 连接。 35 | 36 | ### 4.2 HTTPS 支持 (MITM) 37 | 38 | * **FR2.1:** 能够对 HTTPS 流量进行中间人解密。 39 | * **FR2.2:** 首次运行时自动生成自签名根 CA 证书。 40 | * **FR2.2.1:** 提供命令行选项导出根 CA 证书 (如 `proxycraft-ca.pem`),方便用户导入到浏览器或操作系统信任存储中。 41 | * **FR2.2.2:** 如果 CA 证书已存在,则复用。 42 | * **FR2.3:** 为客户端请求的每个域名动态生成并使用由自签名根 CA 签发的服务器证书。 43 | * **FR2.4:** 支持 TLS 1.2 和 TLS 1.3。 44 | * **FR2.5:** 用户可以通过命令行参数指定自定义的根 CA 证书和私钥。 45 | 46 | ### 4.3 HTTP/2 支持 47 | 48 | * **FR3.1:** 能够代理 HTTP/2 流量。 49 | * **FR3.2:** 支持通过 ALPN (Application-Layer Protocol Negotiation) 进行 HTTP/2 协议协商。 50 | * **FR3.3:** 当客户端和服务器均支持 HTTP/2 时,优先使用 HTTP/2 进行通信。 51 | * **FR3.4:** 即使客户端仅支持 HTTP/1.1,而服务器支持 HTTP/2(反之亦然),代理也应能正确处理和转换(如果可行且必要,或至少能透明代理)。 52 | * **FR3.5:** 能够展示 HTTP/2 的帧信息(如 HEADERS, DATA 帧)的概要(详细帧分析可选)。 53 | 54 | ### 4.4 Server-Sent Events (SSE) 支持 55 | 56 | * **FR4.1:** 能够正确代理使用 SSE 协议的连接 (`Content-Type: text/event-stream`)。 57 | * **FR4.2:** 保持 SSE 连接的持久性,不因代理的内部机制而意外中断。 58 | * **FR4.3:** 实时或近实时地在命令行输出中展示接收到的 SSE 事件数据。 59 | * **FR4.3.1:** 清晰区分不同的 SSE 事件 (event, data, id, retry 字段)。 60 | 61 | ### 4.5 流量日志与展示 (Logging & Display) 62 | 63 | * **FR5.1:** 在命令行实时输出捕获到的 HTTP/HTTPS/HTTP2 请求和响应的概要信息。 64 | * **FR5.1.1:** 概要信息至少包括:请求方法、URL、协议版本、响应状态码、响应内容类型、响应体大小。 65 | * **FR5.2:** 提供详细模式,展示完整的请求/响应头部和正文。 66 | * **FR5.2.1:** 正文展示时,对常见的 Content-Type (如 JSON, XML, HTML, text) 进行美化输出。 67 | * **FR5.2.2:** 对于二进制内容,显示 "[Binary data]" 或十六进制摘要。 68 | * **FR5.3:** 为每个请求/响应分配一个唯一的序号,方便跟踪。 69 | * **FR5.4:** 支持将所有捕获的流量(包括头部和正文)保存到指定文件中。 70 | * **FR5.4.1:** 支持以 HAR (HTTP Archive) 格式保存(推荐)。 71 | * **FR5.4.2:** 支持以自定义的纯文本格式保存。 72 | * **FR5.5:** 提供过滤功能,允许用户根据域名、URL 路径、请求方法、响应状态码等条件过滤显示的流量。 73 | * **FR5.5.1:** 支持简单的字符串匹配和正则表达式匹配。 74 | * **FR5.6:** 清晰标记通过 HTTPS 解密的流量。 75 | * **FR5.7:** 对于 SSE 流量,每个事件都应有清晰的时间戳和来源信息。 76 | 77 | ### 4.6 命令行界面 (CLI) 78 | 79 | * **FR6.1:** 提供清晰的帮助信息 (`-h`, `--help`),列出所有可用参数及其说明。 80 | * **FR6.2:** 允许用户通过参数配置监听地址 (`-l HOST` 或 `--listen-host HOST`) 和端口 (`-p PORT` 或 `--listen-port PORT`)。 81 | * **FR6.3:** 允许用户通过参数启用/禁用详细输出模式 (`-v` 或 `--verbose`)。 82 | * **FR6.4:** 允许用户通过参数指定日志输出文件 (`-o FILE` 或 `--output-file FILE`) 及格式。 83 | * **FR6.5:** 允许用户通过参数指定过滤规则 (`--filter "expression"`). 84 | * **FR6.6:** 提供参数用于管理 CA 证书 (如 `--export-ca FILEPATH`, `--use-ca CERT_PATH --use-key KEY_PATH`)。 85 | * **FR6.7:** 工具应能通过 `Ctrl+C` 优雅地关闭,并完成必要的清理工作 (如关闭打开的文件)。 86 | 87 | ## 5. 非功能需求 (Non-Functional Requirements) 88 | 89 | * **NFR1. Performance:** 90 | * **NFR1.1:** 代理引入的延迟应尽可能小,对用户体验影响降到最低。 91 | * **NFR1.2:** 能够处理中等数量的并发连接(例如,至少 50-100 个并发连接)而不会显著降低性能。 92 | * **NFR2. Stability & Reliability:** 93 | * **NFR2.1:** 工具应能长时间稳定运行,不易崩溃。 94 | * **NFR2.2:** 能够优雅处理网络错误、无效请求/响应等异常情况,并给出提示。 95 | * **NFR3. Security (of the tool itself):** 96 | * **NFR3.1:** 生成的 CA 私钥应有合适的权限保护(如果存储在文件系统)。 97 | * **NFR3.2:** 避免引入常见的安全漏洞(如命令注入、路径遍历等)。 98 | * **NFR4. Usability (CLI):** 99 | * **NFR4.1:** 命令行参数设计直观易懂。 100 | * **NFR4.2:** 输出信息清晰、格式良好,易于阅读和解析。 101 | * **NFR5. Portability:** 102 | * **NFR5.1:** 优先考虑跨平台支持 (Linux, macOS, Windows)。如果选择的语言/库有限制,需明确指出。 103 | * **NFR6. Maintainability:** 104 | * 代码结构清晰,易于维护和扩展。 105 | 106 | ## 6. 技术栈与架构建议 (Technical Considerations - Optional) 107 | 108 | * **语言选择:** 109 | * **Go:** 优秀的网络编程能力、并发处理、编译成单个二进制文件、跨平台。 110 | * **核心库:** 111 | * 需要 HTTP/1.1, HTTP/2, TLS, TCP Sockets 的库。 112 | * 证书生成和管理的库 (如 OpenSSL bindings, Go crypto/tls)。 113 | * **架构:** 114 | * 事件驱动/异步 I/O 模型以实现高并发。 115 | * 模块化设计:代理核心、协议处理器 (HTTP/1, HTTP/2, SSE)、MITM 模块、日志模块、CLI 解析模块。 116 | 117 | ## 7. 里程碑/发布计划 (Milestones - Simplified) 118 | 119 | * **MVP (Minimum Viable Product):** 120 | * HTTP/1.1 和 HTTPS (MITM) 代理功能。 121 | * 基本的命令行启动和流量概要输出。 122 | * CA 证书生成和导出。 123 | * **V1.0:** 124 | * 完整支持 HTTP/2 代理。 125 | * 完整支持 SSE 代理和展示。 126 | * 详细流量日志输出 (头部和正文)。 127 | * 流量保存到文件 (HAR 格式)。 128 | * 基本的流量过滤功能。 129 | * 完善的 CLI 参数和帮助信息。 130 | * **V1.x (Future):** 131 | * 更高级的过滤选项。 132 | * 请求/响应修改能力 (通过脚本或规则)。 133 | * 交互式命令行界面 (可选)。 134 | 135 | ## 8. 未来展望 (Future Enhancements - Out of Scope for V1.0) 136 | 137 | * **请求/响应修改:** 允许用户通过规则或脚本修改请求/响应内容。 138 | * **请求重放:** 能够重放捕获到的请求。 139 | * **WebSocket 支持:** 代理和展示 WebSocket 流量。 140 | * **插件系统:** 允许用户通过插件扩展功能。 141 | * **配置文件支持:** 除了命令行参数,还支持通过配置文件进行设置。 142 | * **交互式控制台:** 提供类似 `mitmproxy` 的交互式控制台界面,用于更细致地查看和操作流量。 143 | 144 | ## 9. 成功指标 (Success Metrics) 145 | 146 | * 用户能够成功使用工具调试 HTTPS, HTTP/2 和 SSE 应用。 147 | * 工具稳定运行,崩溃率低。 148 | * 社区反馈积极,有用户贡献 issue 或 feature request。 149 | * 在开发者社区(如 GitHub, 技术论坛)获得一定的关注和使用量。 150 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/LubyRuffy/ProxyCraft/api" 13 | "github.com/LubyRuffy/ProxyCraft/certs" 14 | "github.com/LubyRuffy/ProxyCraft/cli" 15 | "github.com/LubyRuffy/ProxyCraft/harlogger" // Added for HAR logging 16 | "github.com/LubyRuffy/ProxyCraft/proxy" 17 | "github.com/LubyRuffy/ProxyCraft/proxy/handlers" 18 | ) 19 | 20 | const appName = "ProxyCraft CLI" 21 | const appVersion = "0.1.0" // TODO: This should ideally come from a build flag or version file 22 | 23 | func main() { 24 | cfg := cli.ParseFlags() 25 | 26 | if cfg.ShowHelp { 27 | cli.PrintHelp() 28 | return 29 | } 30 | 31 | fmt.Println("ProxyCraft CLI starting...") 32 | 33 | certManager, err := certs.NewManager() 34 | if err != nil { 35 | log.Fatalf("Error initializing certificate manager: %v", err) 36 | } 37 | 38 | if cfg.ExportCAPath != "" { 39 | err = certManager.ExportCACert(cfg.ExportCAPath) 40 | if err != nil { 41 | log.Fatalf("Error exporting CA certificate: %v", err) 42 | } 43 | fmt.Printf("CA certificate exported to %s. Exiting.\n", cfg.ExportCAPath) 44 | return 45 | } 46 | 47 | if cfg.InstallCerts { 48 | err = certManager.InstallCerts() 49 | if err != nil { 50 | log.Fatalf("Error installing CA certificate: %v", err) 51 | } 52 | fmt.Println("CA certificate installed successfully. Exiting.") 53 | return 54 | } 55 | 56 | // Use custom CA certificate and key if provided 57 | if cfg.UseCACertPath != "" && cfg.UseCAKeyPath != "" { 58 | err = certManager.LoadCustomCA(cfg.UseCACertPath, cfg.UseCAKeyPath) 59 | if err != nil { 60 | log.Fatalf("Error loading custom CA certificate and key: %v", err) 61 | } 62 | log.Printf("Successfully loaded custom CA certificate and key") 63 | } 64 | 65 | listenAddr := fmt.Sprintf("%s:%d", cfg.ListenHost, cfg.ListenPort) 66 | fmt.Printf("Proxy server attempting to listen on %s\n", listenAddr) 67 | if cfg.Verbose { 68 | fmt.Println("Verbose mode enabled.") 69 | } 70 | 71 | // Initialize HAR Logger 72 | harLogger := harlogger.NewLogger(cfg.HarOutputFile, appName, appVersion) 73 | if harLogger.IsEnabled() { 74 | log.Printf("HAR logging enabled, will save to: %s", cfg.HarOutputFile) 75 | 76 | // Enable auto-save if interval > 0 77 | if cfg.AutoSaveInterval > 0 { 78 | log.Printf("Auto-save enabled, HAR log will be saved every %d seconds", cfg.AutoSaveInterval) 79 | harLogger.EnableAutoSave(time.Duration(cfg.AutoSaveInterval) * time.Second) 80 | } else { 81 | log.Printf("Auto-save disabled, HAR log will only be saved on exit") 82 | } 83 | 84 | // Also save on exit 85 | defer func() { 86 | if cfg.AutoSaveInterval > 0 { 87 | harLogger.DisableAutoSave() // Stop auto-save before final save 88 | } 89 | if err := harLogger.Save(); err != nil { 90 | log.Printf("Error saving HAR log on exit: %v", err) 91 | } 92 | }() 93 | } 94 | 95 | // 解析上层代理URL 96 | var upstreamProxyURL *url.URL 97 | if cfg.UpstreamProxy != "" { 98 | var err error 99 | upstreamProxyURL, err = url.Parse(cfg.UpstreamProxy) 100 | if err != nil { 101 | log.Fatalf("Error parsing upstream proxy URL: %v", err) 102 | } 103 | log.Printf("Using upstream proxy: %s", upstreamProxyURL.String()) 104 | } 105 | 106 | // 根据模式选择事件处理器 107 | var eventHandler proxy.EventHandler 108 | 109 | // Web模式使用WebHandler 110 | if cfg.Mode == "web" { 111 | log.Printf("启动Web模式...") 112 | 113 | // 创建Web事件处理器 114 | webHandler := handlers.NewWebHandler(cfg.Verbose) 115 | 116 | // 创建API服务器,默认使用8081端口 117 | apiServer := api.NewServer(webHandler, 8081) 118 | 119 | // 启动API服务器 120 | go func() { 121 | log.Printf("启动API服务器在端口8081...") 122 | if err := apiServer.Start(); err != nil { 123 | log.Fatalf("启动API服务器失败: %v", err) 124 | } 125 | }() 126 | 127 | // 设置Web处理器为事件处理器 128 | eventHandler = webHandler 129 | 130 | log.Printf("Web模式已启用,界面地址: %s", apiServer.UIAddr) 131 | log.Printf("如果Web界面无法显示,请先运行: ./build_web.sh") 132 | } else { 133 | // CLI模式使用CLIHandler 134 | log.Printf("启动CLI模式...") 135 | 136 | cliHandler := handlers.NewCLIHandler(cfg.Verbose, cfg.DumpTraffic) 137 | statsReporter := handlers.NewStatsReporter(cliHandler, 10*time.Second) 138 | 139 | // 启动统计报告 140 | statsReporter.Start() 141 | 142 | // 设置CLI处理器为事件处理器 143 | eventHandler = cliHandler 144 | 145 | // 在函数返回时停止统计报告 146 | defer statsReporter.Stop() 147 | } 148 | 149 | // 创建服务器配置 150 | serverConfig := proxy.ServerConfig{ 151 | Addr: listenAddr, 152 | CertManager: certManager, 153 | Verbose: cfg.Verbose, 154 | HarLogger: harLogger, 155 | UpstreamProxy: upstreamProxyURL, 156 | DumpTraffic: cfg.DumpTraffic, 157 | EventHandler: eventHandler, 158 | } 159 | 160 | // 初始化并启动代理服务器 161 | proxyServer := proxy.NewServerWithConfig(serverConfig) 162 | 163 | // 如果启用了流量输出 164 | if cfg.DumpTraffic { 165 | fmt.Println("Traffic dump enabled - HTTP request and response content will be displayed in console") 166 | } 167 | 168 | // Log MITM mode status 169 | log.Printf("MITM mode enabled - HTTPS traffic will be decrypted and inspected") 170 | log.Printf("Make sure to add the CA certificate to your browser/system trust store") 171 | log.Printf("You can export the CA certificate using the -export-ca flag") 172 | caCertPath := certs.MustGetCACertPath() 173 | log.Printf("CA certificate is located at: %s", caCertPath) 174 | log.Printf("For curl, you can use: curl --cacert %s --proxy http://%s https://example.com", caCertPath, listenAddr) 175 | 176 | // Set up signal handling for graceful shutdown 177 | sigChan := make(chan os.Signal, 1) 178 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 179 | 180 | // Start the proxy server in a goroutine 181 | go func() { 182 | log.Printf("Starting proxy server on %s", listenAddr) 183 | if err := proxyServer.Start(); err != nil { 184 | log.Fatalf("Failed to start proxy server: %v", err) 185 | } 186 | }() 187 | 188 | // Wait for termination signal 189 | sig := <-sigChan 190 | log.Printf("Received signal %v, shutting down...", sig) 191 | 192 | // The deferred harLogger.Save() will be called when main() exits 193 | } 194 | -------------------------------------------------------------------------------- /web-react/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SelectPrimitive from "@radix-ui/react-select"; 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Select = SelectPrimitive.Root; 7 | 8 | const SelectGroup = SelectPrimitive.Group; 9 | 10 | const SelectValue = SelectPrimitive.Value; 11 | 12 | const SelectTrigger = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, children, ...props }, ref) => ( 16 | span]:line-clamp-1", 20 | className 21 | )} 22 | {...props} 23 | > 24 | {children} 25 | 26 | 27 | 28 | 29 | )); 30 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 31 | 32 | const SelectScrollUpButton = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, ...props }, ref) => ( 36 | 44 | 45 | 46 | )); 47 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 48 | 49 | const SelectScrollDownButton = React.forwardRef< 50 | React.ElementRef, 51 | React.ComponentPropsWithoutRef 52 | >(({ className, ...props }, ref) => ( 53 | 61 | 62 | 63 | )); 64 | SelectScrollDownButton.displayName = 65 | SelectPrimitive.ScrollDownButton.displayName; 66 | 67 | const SelectContent = React.forwardRef< 68 | React.ElementRef, 69 | React.ComponentPropsWithoutRef 70 | >(({ className, children, position = "popper", ...props }, ref) => ( 71 | 72 | 83 | 84 | 91 | {children} 92 | 93 | 94 | 95 | 96 | )); 97 | SelectContent.displayName = SelectPrimitive.Content.displayName; 98 | 99 | const SelectLabel = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 110 | 111 | const SelectItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, children, ...props }, ref) => ( 115 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | {children} 130 | 131 | )); 132 | SelectItem.displayName = SelectPrimitive.Item.displayName; 133 | 134 | const SelectSeparator = React.forwardRef< 135 | React.ElementRef, 136 | React.ComponentPropsWithoutRef 137 | >(({ className, ...props }, ref) => ( 138 | 143 | )); 144 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 145 | 146 | export { 147 | Select, 148 | SelectGroup, 149 | SelectValue, 150 | SelectTrigger, 151 | SelectContent, 152 | SelectLabel, 153 | SelectItem, 154 | SelectSeparator, 155 | SelectScrollUpButton, 156 | SelectScrollDownButton, 157 | }; 158 | -------------------------------------------------------------------------------- /web-react/src/services/websocket.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | 3 | import { HttpMessage, TrafficEntry } from '@/types/traffic'; 4 | 5 | export enum TrafficSocketEvent { 6 | CONNECT = 'connect', 7 | DISCONNECT = 'disconnect', 8 | CONNECT_ERROR = 'connect_error', 9 | TRAFFIC_ENTRIES = 'traffic_entries', 10 | TRAFFIC_NEW_ENTRY = 'traffic_new_entry', 11 | TRAFFIC_CLEAR = 'traffic_clear', 12 | REQUEST_DETAILS = 'request_details', 13 | RESPONSE_DETAILS = 'response_details', 14 | } 15 | 16 | const DEFAULT_WS_URL = import.meta.env.VITE_PROXYCRAFT_SOCKET_URL ?? 'http://localhost:8081'; 17 | 18 | type Unsubscribe = () => void; 19 | 20 | class TrafficWebSocketService { 21 | private socket?: ReturnType; 22 | private url: string = DEFAULT_WS_URL; 23 | private reconnectTimer: ReturnType | null = null; 24 | private hasRequestedEntries = false; 25 | 26 | init(url: string = DEFAULT_WS_URL) { 27 | this.url = url; 28 | if (this.socket) { 29 | return; 30 | } 31 | 32 | if (typeof window === 'undefined') { 33 | return; 34 | } 35 | 36 | this.socket = io(this.url, { 37 | transports: ['websocket'], 38 | reconnection: true, 39 | reconnectionAttempts: Infinity, 40 | reconnectionDelay: 1000, 41 | reconnectionDelayMax: 5000, 42 | timeout: 20000, 43 | autoConnect: false, 44 | }); 45 | } 46 | 47 | private getSocket(): ReturnType | undefined { 48 | if (!this.socket) { 49 | this.init(this.url); 50 | } 51 | return this.socket; 52 | } 53 | 54 | connect(url?: string) { 55 | if (url && url !== this.url) { 56 | this.dispose(); 57 | this.url = url; 58 | } 59 | const socket = this.getSocket(); 60 | if (!socket) { 61 | return; 62 | } 63 | 64 | if (!socket.connected) { 65 | socket.connect(); 66 | } 67 | } 68 | 69 | disconnect() { 70 | const socket = this.getSocket(); 71 | socket?.disconnect(); 72 | this.clearReconnectTimer(); 73 | this.hasRequestedEntries = false; 74 | } 75 | 76 | reconnect() { 77 | const socket = this.getSocket(); 78 | if (!socket) { 79 | return; 80 | } 81 | this.disconnect(); 82 | this.clearReconnectTimer(); 83 | this.reconnectTimer = setTimeout(() => { 84 | this.hasRequestedEntries = false; 85 | this.connect(); 86 | this.reconnectTimer = null; 87 | }, 1000); 88 | } 89 | 90 | dispose() { 91 | this.clearReconnectTimer(); 92 | if (this.socket) { 93 | this.socket.removeAllListeners(); 94 | this.socket.disconnect(); 95 | this.socket = undefined; 96 | } 97 | this.hasRequestedEntries = false; 98 | } 99 | 100 | requestTrafficEntries(force = false) { 101 | const socket = this.getSocket(); 102 | if (!socket) { 103 | return; 104 | } 105 | if (this.hasRequestedEntries && !force) { 106 | return; 107 | } 108 | if (socket.connected) { 109 | socket.emit(TrafficSocketEvent.TRAFFIC_ENTRIES); 110 | this.hasRequestedEntries = true; 111 | } else { 112 | this.connect(); 113 | } 114 | } 115 | 116 | requestRequestDetails(id: string) { 117 | const socket = this.getSocket(); 118 | if (socket?.connected) { 119 | socket.emit(TrafficSocketEvent.REQUEST_DETAILS, id); 120 | } 121 | } 122 | 123 | requestResponseDetails(id: string) { 124 | const socket = this.getSocket(); 125 | if (socket?.connected) { 126 | socket.emit(TrafficSocketEvent.RESPONSE_DETAILS, id); 127 | } 128 | } 129 | 130 | requestClearTraffic() { 131 | const socket = this.getSocket(); 132 | if (socket?.connected) { 133 | socket.emit(TrafficSocketEvent.TRAFFIC_CLEAR); 134 | } 135 | } 136 | 137 | onConnect(callback: () => void): Unsubscribe { 138 | const socket = this.getSocket(); 139 | socket?.on(TrafficSocketEvent.CONNECT, callback); 140 | return () => socket?.off(TrafficSocketEvent.CONNECT, callback); 141 | } 142 | 143 | onDisconnect(callback: (reason: string) => void): Unsubscribe { 144 | const socket = this.getSocket(); 145 | socket?.on(TrafficSocketEvent.DISCONNECT, callback); 146 | return () => socket?.off(TrafficSocketEvent.DISCONNECT, callback); 147 | } 148 | 149 | onConnectError(callback: (error: Error) => void): Unsubscribe { 150 | const socket = this.getSocket(); 151 | socket?.on(TrafficSocketEvent.CONNECT_ERROR, callback); 152 | return () => socket?.off(TrafficSocketEvent.CONNECT_ERROR, callback); 153 | } 154 | 155 | onTrafficEntries(callback: (entries: TrafficEntry[]) => void): Unsubscribe { 156 | const socket = this.getSocket(); 157 | socket?.on(TrafficSocketEvent.TRAFFIC_ENTRIES, callback); 158 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_ENTRIES, callback); 159 | } 160 | 161 | onNewTrafficEntry(callback: (entry: TrafficEntry) => void): Unsubscribe { 162 | const socket = this.getSocket(); 163 | socket?.on(TrafficSocketEvent.TRAFFIC_NEW_ENTRY, callback); 164 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_NEW_ENTRY, callback); 165 | } 166 | 167 | onTrafficClear(callback: () => void): Unsubscribe { 168 | const socket = this.getSocket(); 169 | socket?.on(TrafficSocketEvent.TRAFFIC_CLEAR, callback); 170 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_CLEAR, callback); 171 | } 172 | 173 | onRequestDetails(callback: (details: HttpMessage) => void): Unsubscribe { 174 | const socket = this.getSocket(); 175 | socket?.on(TrafficSocketEvent.REQUEST_DETAILS, callback); 176 | return () => socket?.off(TrafficSocketEvent.REQUEST_DETAILS, callback); 177 | } 178 | 179 | onResponseDetails(callback: (details: HttpMessage) => void): Unsubscribe { 180 | const socket = this.getSocket(); 181 | socket?.on(TrafficSocketEvent.RESPONSE_DETAILS, callback); 182 | return () => socket?.off(TrafficSocketEvent.RESPONSE_DETAILS, callback); 183 | } 184 | 185 | isConnected(): boolean { 186 | const socket = this.getSocket(); 187 | return Boolean(socket?.connected); 188 | } 189 | 190 | getTransport(): string { 191 | const socket = this.getSocket(); 192 | if (!socket || !socket.connected) { 193 | return 'disconnected'; 194 | } 195 | return socket.io.engine.transport.name || 'unknown'; 196 | } 197 | 198 | private clearReconnectTimer() { 199 | if (this.reconnectTimer) { 200 | clearTimeout(this.reconnectTimer); 201 | this.reconnectTimer = null; 202 | } 203 | } 204 | } 205 | 206 | export const trafficSocket = new TrafficWebSocketService(); 207 | -------------------------------------------------------------------------------- /proxy/roundtrip.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "crypto/tls" 5 | "log" 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // newTransport creates a transport configured for HTTP or HTTPS requests. 12 | func (s *Server) newTransport(targetHost string, secure bool) *http.Transport { 13 | transport := &http.Transport{ 14 | DialContext: (&net.Dialer{ 15 | Timeout: 30 * time.Second, 16 | KeepAlive: 30 * time.Second, 17 | }).DialContext, 18 | MaxIdleConns: 100, 19 | IdleConnTimeout: 90 * time.Second, 20 | TLSHandshakeTimeout: 10 * time.Second, 21 | ExpectContinueTimeout: 1 * time.Second, 22 | DisableCompression: true, 23 | ResponseHeaderTimeout: 5 * time.Second, 24 | } 25 | 26 | if secure { 27 | hostForSNI := targetHost 28 | if host, _, err := net.SplitHostPort(targetHost); err == nil { 29 | hostForSNI = host 30 | } 31 | transport.TLSClientConfig = &tls.Config{ 32 | InsecureSkipVerify: true, 33 | ServerName: hostForSNI, 34 | } 35 | } 36 | 37 | if s.UpstreamProxy != nil { 38 | if s.Verbose { 39 | log.Printf("[Proxy] Using upstream proxy: %s", s.UpstreamProxy.String()) 40 | } 41 | transport.Proxy = http.ProxyURL(s.UpstreamProxy) 42 | } 43 | 44 | s.handleHTTP2(transport) 45 | 46 | return transport 47 | } 48 | 49 | // wrapTransportForSSE wraps the base transport so that SSE responses can be detected early. 50 | func (s *Server) wrapTransportForSSE(base *http.Transport) http.RoundTripper { 51 | if base == nil { 52 | return nil 53 | } 54 | return &earlySSEDetector{ 55 | base: base, 56 | server: s, 57 | verbose: s.Verbose, 58 | } 59 | } 60 | 61 | // prepareProxyRequest builds the outgoing request and related context for proxying. 62 | func (s *Server) prepareProxyRequest(r *http.Request, targetURL string, isHTTPS bool) (*http.Request, *RequestContext, bool, time.Time, error) { 63 | startTime := time.Now() 64 | 65 | reqCtx := s.createRequestContext(r, targetURL, startTime, isHTTPS) 66 | if modified := s.notifyRequest(reqCtx); modified != nil && modified != r { 67 | r = modified 68 | reqCtx.Request = modified 69 | } 70 | 71 | proxyReq, err := cloneRequestWithURL(r, targetURL) 72 | if err != nil { 73 | s.notifyError(err, reqCtx) 74 | return nil, reqCtx, false, startTime, err 75 | } 76 | 77 | potentialSSE := isSSERequest(proxyReq) 78 | 79 | return proxyReq, reqCtx, potentialSSE, startTime, nil 80 | } 81 | 82 | // sendProxyRequest executes the outbound request using the provided transport. 83 | func (s *Server) sendProxyRequest(proxyReq *http.Request, transport http.RoundTripper, potentialSSE bool, startTime time.Time) (*http.Response, time.Duration, error) { 84 | client := &http.Client{ 85 | Transport: transport, 86 | Timeout: 30 * time.Second, 87 | } 88 | 89 | if potentialSSE { 90 | client.Timeout = 0 91 | proxyReq.Header.Set("Accept", "text/event-stream") 92 | proxyReq.Header.Set("Cache-Control", "no-cache") 93 | proxyReq.Header.Set("Connection", "keep-alive") 94 | } 95 | 96 | resp, err := client.Do(proxyReq) 97 | timeTaken := time.Since(startTime) 98 | if err != nil { 99 | return nil, timeTaken, err 100 | } 101 | 102 | if resp.Request == nil { 103 | resp.Request = proxyReq 104 | } 105 | 106 | return resp, timeTaken, nil 107 | } 108 | 109 | // processProxyResponse handles response post-processing, logging, and notifications. 110 | func (s *Server) processProxyResponse(reqCtx *RequestContext, resp *http.Response, startTime time.Time, timeTaken time.Duration, logPrefix, targetURL string) (*ResponseContext, bool) { 111 | if resp == nil { 112 | return nil, false 113 | } 114 | 115 | s.processCompressedResponse(resp, reqCtx, s.Verbose) 116 | 117 | respCtx := s.createResponseContext(reqCtx, resp, timeTaken) 118 | if modified := s.notifyResponse(respCtx); modified != nil && modified != resp { 119 | resp = modified 120 | respCtx.Response = resp 121 | } 122 | 123 | isSSE := isServerSentEvent(respCtx.Response) 124 | 125 | if s.DumpTraffic { 126 | s.dumpRequestBody(reqCtx.Request) 127 | if !isSSE { 128 | s.dumpResponseBody(respCtx.Response) 129 | } 130 | } 131 | 132 | if !isSSE { 133 | s.logToHAR(reqCtx.Request, respCtx.Response, startTime, timeTaken, false) 134 | } 135 | 136 | if s.Verbose { 137 | log.Printf("%s Received response from %s: %d %s", logPrefix, targetURL, respCtx.Response.StatusCode, respCtx.Response.Status) 138 | } else { 139 | reqURL := reqCtx.Request.URL 140 | host := reqCtx.Request.Host 141 | path := "" 142 | if reqURL != nil { 143 | path = reqURL.RequestURI() 144 | } 145 | log.Printf("%s %s %s%s -> %d %s", logPrefix, reqCtx.Request.Method, host, path, respCtx.Response.StatusCode, respCtx.Response.Header.Get("Content-Type")) 146 | } 147 | 148 | return respCtx, isSSE 149 | } 150 | 151 | // recordProxyError captures error details for logging and event notification. 152 | func (s *Server) recordProxyError(err error, reqCtx *RequestContext, startTime time.Time, timeTaken time.Duration) { 153 | if reqCtx == nil { 154 | return 155 | } 156 | s.logToHAR(reqCtx.Request, nil, startTime, timeTaken, false) 157 | s.notifyError(err, reqCtx) 158 | } 159 | 160 | // cloneRequestWithURL duplicates the request and rewrites its URL to the target. 161 | func cloneRequestWithURL(r *http.Request, targetURL string) (*http.Request, error) { 162 | proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | proxyReq.Header = r.Header.Clone() 168 | proxyReq.Host = r.Host 169 | proxyReq.ContentLength = r.ContentLength 170 | if len(r.TransferEncoding) > 0 { 171 | proxyReq.TransferEncoding = append([]string(nil), r.TransferEncoding...) 172 | } 173 | proxyReq.Close = r.Close 174 | 175 | return proxyReq, nil 176 | } 177 | 178 | // writeHTTPResponse writes the proxied response back to an HTTP client. 179 | func (s *Server) writeHTTPResponse(w http.ResponseWriter, respCtx *ResponseContext, protocol string) error { 180 | if respCtx == nil || respCtx.Response == nil { 181 | return nil 182 | } 183 | 184 | for k, vv := range respCtx.Response.Header { 185 | for _, v := range vv { 186 | w.Header().Add(k, v) 187 | } 188 | } 189 | 190 | w.Header().Add("X-Protocol", protocol) 191 | w.WriteHeader(respCtx.Response.StatusCode) 192 | 193 | contentType := respCtx.Response.Header.Get("Content-Type") 194 | _, err := s.streamResponse(respCtx.Response.Body, w, contentType, s.Verbose) 195 | return err 196 | } 197 | 198 | func logPotentialSSE(verbose bool, prefix string, potential bool) { 199 | if !verbose || !potential { 200 | return 201 | } 202 | log.Printf("%s Potential SSE request detected based on URL path or Accept header", prefix) 203 | } 204 | -------------------------------------------------------------------------------- /web-react/src/hooks/use-traffic-stream.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | import { 4 | clearTrafficRemote, 5 | fetchTrafficDetail, 6 | fetchTrafficEntries, 7 | } from '@/services/traffic-service'; 8 | import { trafficSocket } from '@/services/websocket'; 9 | import { useTrafficStore } from '@/stores/use-traffic-store'; 10 | 11 | export function useTrafficStream() { 12 | const setEntries = useTrafficStore((state) => state.setEntries); 13 | const addOrUpdateEntry = useTrafficStore((state) => state.addOrUpdateEntry); 14 | const clearEntries = useTrafficStore((state) => state.clearEntries); 15 | const setConnected = useTrafficStore((state) => state.setConnected); 16 | const setError = useTrafficStore((state) => state.setError); 17 | const setTransport = useTrafficStore((state) => state.setTransport); 18 | const mergeDetail = useTrafficStore((state) => state.mergeDetail); 19 | const clearDetail = useTrafficStore((state) => state.clearDetail); 20 | const selectedId = useTrafficStore((state) => state.selectedId); 21 | const entries = useTrafficStore((state) => state.entries); 22 | const selectEntry = useTrafficStore((state) => state.selectEntry); 23 | const setLoading = useTrafficStore((state) => state.setLoading); 24 | 25 | useEffect(() => { 26 | trafficSocket.connect(); 27 | 28 | const cleanups = [ 29 | trafficSocket.onConnect(() => { 30 | setConnected(true); 31 | setError(null); 32 | setTransport(trafficSocket.getTransport()); 33 | trafficSocket.requestTrafficEntries(true); 34 | }), 35 | trafficSocket.onDisconnect((reason) => { 36 | setConnected(false); 37 | setTransport('disconnected'); 38 | setError(`WebSocket已断开: ${reason}`); 39 | }), 40 | trafficSocket.onConnectError((error) => { 41 | setConnected(false); 42 | setTransport('disconnected'); 43 | setError(`WebSocket连接错误: ${error.message}`); 44 | }), 45 | trafficSocket.onTrafficEntries((incoming) => { 46 | setEntries(incoming); 47 | setError(null); 48 | setLoading(false); 49 | }), 50 | trafficSocket.onNewTrafficEntry((entry) => { 51 | addOrUpdateEntry(entry); 52 | }), 53 | trafficSocket.onTrafficClear(() => { 54 | clearEntries(); 55 | clearDetail(); 56 | selectEntry(null); 57 | setError(null); 58 | setLoading(false); 59 | }), 60 | trafficSocket.onRequestDetails((request) => { 61 | mergeDetail({ request }); 62 | setError(null); 63 | setLoading(false); 64 | }), 65 | trafficSocket.onResponseDetails((response) => { 66 | mergeDetail({ response }); 67 | setError(null); 68 | setLoading(false); 69 | }), 70 | ]; 71 | 72 | return () => { 73 | cleanups.forEach((cleanup) => cleanup()); 74 | trafficSocket.disconnect(); 75 | }; 76 | }, [ 77 | addOrUpdateEntry, 78 | clearDetail, 79 | clearEntries, 80 | mergeDetail, 81 | selectEntry, 82 | setConnected, 83 | setEntries, 84 | setError, 85 | setLoading, 86 | setTransport, 87 | ]); 88 | 89 | useEffect(() => { 90 | const timer = window.setInterval(() => { 91 | setTransport(trafficSocket.getTransport()); 92 | const connected = trafficSocket.isConnected(); 93 | setConnected(connected); 94 | if (connected) { 95 | setError(null); 96 | } 97 | }, 2000); 98 | 99 | return () => { 100 | window.clearInterval(timer); 101 | }; 102 | }, [setConnected, setError, setTransport]); 103 | 104 | useEffect(() => { 105 | if (!selectedId) { 106 | clearDetail(); 107 | return; 108 | } 109 | 110 | if (trafficSocket.isConnected()) { 111 | setLoading(true); 112 | trafficSocket.requestRequestDetails(selectedId); 113 | trafficSocket.requestResponseDetails(selectedId); 114 | return; 115 | } 116 | 117 | setLoading(true); 118 | fetchTrafficDetail(selectedId) 119 | .then((detail) => { 120 | if (!detail) { 121 | clearDetail(); 122 | return; 123 | } 124 | mergeDetail(detail); 125 | }) 126 | .catch((error) => { 127 | setError(`加载详情失败: ${error instanceof Error ? error.message : '未知错误'}`); 128 | clearDetail(); 129 | }) 130 | .finally(() => setLoading(false)); 131 | }, [clearDetail, mergeDetail, selectedId, setError, setLoading]); 132 | 133 | useEffect(() => { 134 | const entry = entries.find((item) => item.id === selectedId); 135 | if (!selectedId || !entry?.isSSE || entry.isSSECompleted) { 136 | return undefined; 137 | } 138 | 139 | const timer = window.setInterval(() => { 140 | if (trafficSocket.isConnected()) { 141 | trafficSocket.requestRequestDetails(selectedId); 142 | trafficSocket.requestResponseDetails(selectedId); 143 | } else { 144 | fetchTrafficDetail(selectedId) 145 | .then((detail) => { 146 | if (!detail) { 147 | return; 148 | } 149 | mergeDetail(detail); 150 | }) 151 | .catch((error) => { 152 | setError(`加载详情失败: ${error instanceof Error ? error.message : '未知错误'}`); 153 | }); 154 | } 155 | }, 1000); 156 | 157 | return () => window.clearInterval(timer); 158 | }, [entries, mergeDetail, selectedId, setError]); 159 | 160 | const refresh = useCallback(() => { 161 | setLoading(true); 162 | if (trafficSocket.isConnected()) { 163 | trafficSocket.requestTrafficEntries(true); 164 | return; 165 | } 166 | fetchTrafficEntries() 167 | .then((list) => { 168 | setEntries(list); 169 | setError(null); 170 | }) 171 | .catch((error) => { 172 | setError(`获取流量数据失败: ${error instanceof Error ? error.message : '未知错误'}`); 173 | }) 174 | .finally(() => { 175 | setLoading(false); 176 | }); 177 | }, [setEntries, setError, setLoading]); 178 | 179 | const reconnect = useCallback(() => { 180 | setLoading(true); 181 | trafficSocket.reconnect(); 182 | }, [setLoading]); 183 | 184 | const clearRemoteTraffic = useCallback(async () => { 185 | setLoading(true); 186 | if (trafficSocket.isConnected()) { 187 | trafficSocket.requestClearTraffic(); 188 | selectEntry(null); 189 | return true; 190 | } 191 | const cleared = await clearTrafficRemote(); 192 | if (cleared) { 193 | clearEntries(); 194 | clearDetail(); 195 | selectEntry(null); 196 | setError(null); 197 | } 198 | setLoading(false); 199 | return cleared; 200 | }, [clearDetail, clearEntries, selectEntry, setError, setLoading]); 201 | 202 | return { 203 | refresh, 204 | reconnect, 205 | clearRemoteTraffic, 206 | }; 207 | } 208 | -------------------------------------------------------------------------------- /web-react/src/services/websocket.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | export var TrafficSocketEvent; 3 | (function (TrafficSocketEvent) { 4 | TrafficSocketEvent["CONNECT"] = "connect"; 5 | TrafficSocketEvent["DISCONNECT"] = "disconnect"; 6 | TrafficSocketEvent["CONNECT_ERROR"] = "connect_error"; 7 | TrafficSocketEvent["TRAFFIC_ENTRIES"] = "traffic_entries"; 8 | TrafficSocketEvent["TRAFFIC_NEW_ENTRY"] = "traffic_new_entry"; 9 | TrafficSocketEvent["TRAFFIC_CLEAR"] = "traffic_clear"; 10 | TrafficSocketEvent["REQUEST_DETAILS"] = "request_details"; 11 | TrafficSocketEvent["RESPONSE_DETAILS"] = "response_details"; 12 | })(TrafficSocketEvent || (TrafficSocketEvent = {})); 13 | const DEFAULT_WS_URL = import.meta.env.VITE_PROXYCRAFT_SOCKET_URL ?? 'http://localhost:8081'; 14 | class TrafficWebSocketService { 15 | constructor() { 16 | Object.defineProperty(this, "socket", { 17 | enumerable: true, 18 | configurable: true, 19 | writable: true, 20 | value: void 0 21 | }); 22 | Object.defineProperty(this, "url", { 23 | enumerable: true, 24 | configurable: true, 25 | writable: true, 26 | value: DEFAULT_WS_URL 27 | }); 28 | Object.defineProperty(this, "reconnectTimer", { 29 | enumerable: true, 30 | configurable: true, 31 | writable: true, 32 | value: null 33 | }); 34 | Object.defineProperty(this, "hasRequestedEntries", { 35 | enumerable: true, 36 | configurable: true, 37 | writable: true, 38 | value: false 39 | }); 40 | } 41 | init(url = DEFAULT_WS_URL) { 42 | this.url = url; 43 | if (this.socket) { 44 | return; 45 | } 46 | if (typeof window === 'undefined') { 47 | return; 48 | } 49 | this.socket = io(this.url, { 50 | transports: ['websocket'], 51 | reconnection: true, 52 | reconnectionAttempts: Infinity, 53 | reconnectionDelay: 1000, 54 | reconnectionDelayMax: 5000, 55 | timeout: 20000, 56 | autoConnect: false, 57 | }); 58 | } 59 | getSocket() { 60 | if (!this.socket) { 61 | this.init(this.url); 62 | } 63 | return this.socket; 64 | } 65 | connect(url) { 66 | if (url && url !== this.url) { 67 | this.dispose(); 68 | this.url = url; 69 | } 70 | const socket = this.getSocket(); 71 | if (!socket) { 72 | return; 73 | } 74 | if (!socket.connected) { 75 | socket.connect(); 76 | } 77 | } 78 | disconnect() { 79 | const socket = this.getSocket(); 80 | socket?.disconnect(); 81 | this.clearReconnectTimer(); 82 | this.hasRequestedEntries = false; 83 | } 84 | reconnect() { 85 | const socket = this.getSocket(); 86 | if (!socket) { 87 | return; 88 | } 89 | this.disconnect(); 90 | this.clearReconnectTimer(); 91 | this.reconnectTimer = setTimeout(() => { 92 | this.hasRequestedEntries = false; 93 | this.connect(); 94 | this.reconnectTimer = null; 95 | }, 1000); 96 | } 97 | dispose() { 98 | this.clearReconnectTimer(); 99 | if (this.socket) { 100 | this.socket.removeAllListeners(); 101 | this.socket.disconnect(); 102 | this.socket = undefined; 103 | } 104 | this.hasRequestedEntries = false; 105 | } 106 | requestTrafficEntries(force = false) { 107 | const socket = this.getSocket(); 108 | if (!socket) { 109 | return; 110 | } 111 | if (this.hasRequestedEntries && !force) { 112 | return; 113 | } 114 | if (socket.connected) { 115 | socket.emit(TrafficSocketEvent.TRAFFIC_ENTRIES); 116 | this.hasRequestedEntries = true; 117 | } 118 | else { 119 | this.connect(); 120 | } 121 | } 122 | requestRequestDetails(id) { 123 | const socket = this.getSocket(); 124 | if (socket?.connected) { 125 | socket.emit(TrafficSocketEvent.REQUEST_DETAILS, id); 126 | } 127 | } 128 | requestResponseDetails(id) { 129 | const socket = this.getSocket(); 130 | if (socket?.connected) { 131 | socket.emit(TrafficSocketEvent.RESPONSE_DETAILS, id); 132 | } 133 | } 134 | requestClearTraffic() { 135 | const socket = this.getSocket(); 136 | if (socket?.connected) { 137 | socket.emit(TrafficSocketEvent.TRAFFIC_CLEAR); 138 | } 139 | } 140 | onConnect(callback) { 141 | const socket = this.getSocket(); 142 | socket?.on(TrafficSocketEvent.CONNECT, callback); 143 | return () => socket?.off(TrafficSocketEvent.CONNECT, callback); 144 | } 145 | onDisconnect(callback) { 146 | const socket = this.getSocket(); 147 | socket?.on(TrafficSocketEvent.DISCONNECT, callback); 148 | return () => socket?.off(TrafficSocketEvent.DISCONNECT, callback); 149 | } 150 | onConnectError(callback) { 151 | const socket = this.getSocket(); 152 | socket?.on(TrafficSocketEvent.CONNECT_ERROR, callback); 153 | return () => socket?.off(TrafficSocketEvent.CONNECT_ERROR, callback); 154 | } 155 | onTrafficEntries(callback) { 156 | const socket = this.getSocket(); 157 | socket?.on(TrafficSocketEvent.TRAFFIC_ENTRIES, callback); 158 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_ENTRIES, callback); 159 | } 160 | onNewTrafficEntry(callback) { 161 | const socket = this.getSocket(); 162 | socket?.on(TrafficSocketEvent.TRAFFIC_NEW_ENTRY, callback); 163 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_NEW_ENTRY, callback); 164 | } 165 | onTrafficClear(callback) { 166 | const socket = this.getSocket(); 167 | socket?.on(TrafficSocketEvent.TRAFFIC_CLEAR, callback); 168 | return () => socket?.off(TrafficSocketEvent.TRAFFIC_CLEAR, callback); 169 | } 170 | onRequestDetails(callback) { 171 | const socket = this.getSocket(); 172 | socket?.on(TrafficSocketEvent.REQUEST_DETAILS, callback); 173 | return () => socket?.off(TrafficSocketEvent.REQUEST_DETAILS, callback); 174 | } 175 | onResponseDetails(callback) { 176 | const socket = this.getSocket(); 177 | socket?.on(TrafficSocketEvent.RESPONSE_DETAILS, callback); 178 | return () => socket?.off(TrafficSocketEvent.RESPONSE_DETAILS, callback); 179 | } 180 | isConnected() { 181 | const socket = this.getSocket(); 182 | return Boolean(socket?.connected); 183 | } 184 | getTransport() { 185 | const socket = this.getSocket(); 186 | if (!socket || !socket.connected) { 187 | return 'disconnected'; 188 | } 189 | return socket.io.engine.transport.name || 'unknown'; 190 | } 191 | clearReconnectTimer() { 192 | if (this.reconnectTimer) { 193 | clearTimeout(this.reconnectTimer); 194 | this.reconnectTimer = null; 195 | } 196 | } 197 | } 198 | export const trafficSocket = new TrafficWebSocketService(); 199 | -------------------------------------------------------------------------------- /web-react/src/pages/traffic/index.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { useCallback, useEffect, useMemo } from 'react'; 3 | import { RequestResponsePanel } from '@/components/request-response-panel'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { Button } from '@/components/ui/button'; 6 | import { useTrafficStream } from '@/hooks/use-traffic-stream'; 7 | import { cn } from '@/lib/utils'; 8 | import { useTrafficStore } from '@/stores/use-traffic-store'; 9 | const formatBytes = (bytes) => { 10 | if (bytes === 0) 11 | return '0 B'; 12 | const units = ['B', 'KB', 'MB', 'GB']; 13 | const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); 14 | return `${(bytes / 1024 ** power).toFixed(power === 0 ? 0 : 1)} ${units[power]}`; 15 | }; 16 | const statusTint = (status) => { 17 | if (status >= 500) 18 | return 'text-red-500'; 19 | if (status >= 400) 20 | return 'text-amber-500'; 21 | if (status >= 200) 22 | return 'text-emerald-500'; 23 | return 'text-muted-foreground'; 24 | }; 25 | export function TrafficPage() { 26 | const entries = useTrafficStore((state) => state.entries); 27 | const selectedId = useTrafficStore((state) => state.selectedId); 28 | const selectEntry = useTrafficStore((state) => state.selectEntry); 29 | const detail = useTrafficStore((state) => state.detail); 30 | const loading = useTrafficStore((state) => state.loading); 31 | const error = useTrafficStore((state) => state.error); 32 | const connected = useTrafficStore((state) => state.connected); 33 | const transport = useTrafficStore((state) => state.transport); 34 | const { refresh, reconnect, clearRemoteTraffic } = useTrafficStream(); 35 | useEffect(() => { 36 | if (entries.length === 0) { 37 | refresh(); 38 | } 39 | }, [entries.length, refresh]); 40 | const list = useMemo(() => entries ?? [], [entries]); 41 | const selectedEntry = useMemo(() => list.find((item) => item.id === selectedId) ?? null, [list, selectedId]); 42 | const lastUpdated = useMemo(() => { 43 | if (!list.length) 44 | return '未同步'; 45 | const timestamps = list 46 | .map((entry) => entry.endTime || entry.startTime) 47 | .filter(Boolean) 48 | .map((value) => new Date(value).getTime()); 49 | if (!timestamps.length) 50 | return '未知'; 51 | return new Date(Math.max(...timestamps)).toLocaleTimeString(); 52 | }, [list]); 53 | const handleClear = useCallback(async () => { 54 | const confirmed = window.confirm('确定要清空所有流量数据吗?此操作不可恢复。'); 55 | if (!confirmed) { 56 | return; 57 | } 58 | await clearRemoteTraffic(); 59 | }, [clearRemoteTraffic]); 60 | const handleRefresh = useCallback(() => { 61 | refresh(); 62 | }, [refresh]); 63 | const handleReconnect = useCallback(() => { 64 | reconnect(); 65 | }, [reconnect]); 66 | return (_jsxs("section", { className: "flex h-full flex-1 flex-col gap-4 px-6 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4 border-b pb-3", children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx("h1", { className: "text-lg font-semibold", children: "\u6D41\u91CF\u5217\u8868" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", onClick: handleRefresh, disabled: loading, children: loading ? '处理中…' : '刷新' }), _jsx(Button, { size: "sm", variant: "destructive", onClick: handleClear, disabled: loading, children: "\u6E05\u7A7A" }), _jsx(Button, { size: "sm", variant: "secondary", onClick: handleReconnect, disabled: loading, children: "\u91CD\u65B0\u8FDE\u63A5" })] })] }), _jsxs("div", { className: "flex items-center gap-3 text-sm text-muted-foreground", children: [_jsx(Badge, { variant: connected ? 'success' : 'warning', children: connected ? 'WebSocket 已连接' : 'WebSocket 未连接' }), _jsxs("span", { children: ["\u6700\u540E\u66F4\u65B0\uFF1A", lastUpdated] })] })] }), error ? (_jsx("div", { className: "rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-sm text-destructive-foreground", children: error })) : null, _jsx("div", { className: "flex-1 overflow-hidden", children: _jsxs("div", { className: "h-full overflow-auto rounded-lg border", children: [_jsxs("table", { className: "min-w-full text-sm", children: [_jsx("thead", { className: "bg-muted/50 text-xs uppercase text-muted-foreground sticky top-0", children: _jsxs("tr", { children: [_jsx("th", { className: "px-4 py-2 text-left font-medium w-16", children: "\u65B9\u6CD5" }), _jsx("th", { className: "px-4 py-2 text-left font-medium min-w-32", children: "Host" }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: "Path" }), _jsx("th", { className: "px-4 py-2 text-left font-medium w-16", children: "Code" }), _jsx("th", { className: "px-4 py-2 text-left font-medium w-24", children: "MIME" }), _jsx("th", { className: "px-4 py-2 text-left font-medium w-20", children: "Size" }), _jsx("th", { className: "px-4 py-2 text-left font-medium w-20", children: "Cost" }), _jsx("th", { className: "px-4 py-2 text-left font-medium w-24", children: "Tags" })] }) }), _jsx("tbody", { children: list.map((entry) => (_jsxs("tr", { onClick: () => selectEntry(entry.id), className: cn('cursor-pointer transition-colors hover:bg-muted/70', selectedId === entry.id && 'bg-secondary/40'), children: [_jsx("td", { className: "px-4 py-2 font-medium", children: entry.method }), _jsx("td", { className: "px-4 py-2 truncate text-muted-foreground", children: entry.host }), _jsx("td", { className: "px-4 py-2 truncate", children: entry.path }), _jsx("td", { className: cn('px-4 py-2 font-mono', statusTint(entry.statusCode)), children: entry.statusCode }), _jsx("td", { className: "px-4 py-2 truncate text-muted-foreground", children: entry.contentType }), _jsx("td", { className: "px-4 py-2 text-muted-foreground", children: formatBytes(entry.contentSize) }), _jsxs("td", { className: "px-4 py-2 text-muted-foreground", children: [entry.duration, " ms"] }), _jsx("td", { className: "px-4 py-2", children: _jsxs("div", { className: "flex gap-1", children: [entry.isHTTPS ? _jsx(Badge, { variant: "success", children: "HTTPS" }) : null, entry.isSSE ? _jsx(Badge, { variant: "warning", children: "SSE" }) : null] }) })] }, entry.id))) })] }), list.length === 0 ? (_jsx("div", { className: "flex items-center justify-center p-20 text-sm text-muted-foreground", children: loading ? '加载中…' : '暂无流量记录' })) : null] }) }), _jsx("div", { className: "h-80 border rounded-lg overflow-hidden", children: _jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b px-4 py-2", children: [_jsx("h2", { className: "text-base font-semibold", children: "\u8BF7\u6C42 / \u54CD\u5E94\u8BE6\u60C5" }), selectedId && (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsxs(Badge, { variant: "outline", children: ["ID: ", selectedId] }), loading ? _jsx(Badge, { variant: "warning", children: "\u52A0\u8F7D\u4E2D\u2026" }) : null] }))] }), _jsx("div", { className: "flex-1 p-4", children: selectedId ? (_jsx(RequestResponsePanel, { entry: selectedEntry ?? undefined, detail: detail, loading: loading })) : (_jsx("div", { className: "flex h-full items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground", children: "\u8BF7\u9009\u62E9\u5217\u8868\u4E2D\u7684\u4E00\u6761\u6D41\u91CF\u8BB0\u5F55\u4EE5\u67E5\u770B\u8BE6\u60C5\u3002" })) })] }) })] })); 67 | } 68 | -------------------------------------------------------------------------------- /web-react/src/hooks/use-traffic-stream.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { clearTrafficRemote, fetchTrafficDetail, fetchTrafficEntries, } from '@/services/traffic-service'; 3 | import { trafficSocket } from '@/services/websocket'; 4 | import { useTrafficStore } from '@/stores/use-traffic-store'; 5 | export function useTrafficStream() { 6 | const setEntries = useTrafficStore((state) => state.setEntries); 7 | const addOrUpdateEntry = useTrafficStore((state) => state.addOrUpdateEntry); 8 | const clearEntries = useTrafficStore((state) => state.clearEntries); 9 | const setConnected = useTrafficStore((state) => state.setConnected); 10 | const setError = useTrafficStore((state) => state.setError); 11 | const setTransport = useTrafficStore((state) => state.setTransport); 12 | const mergeDetail = useTrafficStore((state) => state.mergeDetail); 13 | const clearDetail = useTrafficStore((state) => state.clearDetail); 14 | const selectedId = useTrafficStore((state) => state.selectedId); 15 | const entries = useTrafficStore((state) => state.entries); 16 | const selectEntry = useTrafficStore((state) => state.selectEntry); 17 | const setLoading = useTrafficStore((state) => state.setLoading); 18 | useEffect(() => { 19 | trafficSocket.connect(); 20 | const cleanups = [ 21 | trafficSocket.onConnect(() => { 22 | setConnected(true); 23 | setError(null); 24 | setTransport(trafficSocket.getTransport()); 25 | trafficSocket.requestTrafficEntries(true); 26 | }), 27 | trafficSocket.onDisconnect((reason) => { 28 | setConnected(false); 29 | setTransport('disconnected'); 30 | setError(`WebSocket已断开: ${reason}`); 31 | }), 32 | trafficSocket.onConnectError((error) => { 33 | setConnected(false); 34 | setTransport('disconnected'); 35 | setError(`WebSocket连接错误: ${error.message}`); 36 | }), 37 | trafficSocket.onTrafficEntries((incoming) => { 38 | setEntries(incoming); 39 | setError(null); 40 | setLoading(false); 41 | }), 42 | trafficSocket.onNewTrafficEntry((entry) => { 43 | addOrUpdateEntry(entry); 44 | }), 45 | trafficSocket.onTrafficClear(() => { 46 | clearEntries(); 47 | clearDetail(); 48 | selectEntry(null); 49 | setError(null); 50 | setLoading(false); 51 | }), 52 | trafficSocket.onRequestDetails((request) => { 53 | mergeDetail({ request }); 54 | setError(null); 55 | setLoading(false); 56 | }), 57 | trafficSocket.onResponseDetails((response) => { 58 | mergeDetail({ response }); 59 | setError(null); 60 | setLoading(false); 61 | }), 62 | ]; 63 | return () => { 64 | cleanups.forEach((cleanup) => cleanup()); 65 | trafficSocket.disconnect(); 66 | }; 67 | }, [ 68 | addOrUpdateEntry, 69 | clearDetail, 70 | clearEntries, 71 | mergeDetail, 72 | selectEntry, 73 | setConnected, 74 | setEntries, 75 | setError, 76 | setLoading, 77 | setTransport, 78 | ]); 79 | useEffect(() => { 80 | const timer = window.setInterval(() => { 81 | setTransport(trafficSocket.getTransport()); 82 | const connected = trafficSocket.isConnected(); 83 | setConnected(connected); 84 | if (connected) { 85 | setError(null); 86 | } 87 | }, 2000); 88 | return () => { 89 | window.clearInterval(timer); 90 | }; 91 | }, [setConnected, setError, setTransport]); 92 | useEffect(() => { 93 | if (!selectedId) { 94 | clearDetail(); 95 | return; 96 | } 97 | if (trafficSocket.isConnected()) { 98 | setLoading(true); 99 | trafficSocket.requestRequestDetails(selectedId); 100 | trafficSocket.requestResponseDetails(selectedId); 101 | return; 102 | } 103 | setLoading(true); 104 | fetchTrafficDetail(selectedId) 105 | .then((detail) => { 106 | if (!detail) { 107 | clearDetail(); 108 | return; 109 | } 110 | mergeDetail(detail); 111 | }) 112 | .catch((error) => { 113 | setError(`加载详情失败: ${error instanceof Error ? error.message : '未知错误'}`); 114 | clearDetail(); 115 | }) 116 | .finally(() => setLoading(false)); 117 | }, [clearDetail, mergeDetail, selectedId, setError, setLoading]); 118 | useEffect(() => { 119 | const entry = entries.find((item) => item.id === selectedId); 120 | if (!selectedId || !entry?.isSSE || entry.isSSECompleted) { 121 | return undefined; 122 | } 123 | const timer = window.setInterval(() => { 124 | if (trafficSocket.isConnected()) { 125 | trafficSocket.requestRequestDetails(selectedId); 126 | trafficSocket.requestResponseDetails(selectedId); 127 | } 128 | else { 129 | fetchTrafficDetail(selectedId) 130 | .then((detail) => { 131 | if (!detail) { 132 | return; 133 | } 134 | mergeDetail(detail); 135 | }) 136 | .catch((error) => { 137 | setError(`加载详情失败: ${error instanceof Error ? error.message : '未知错误'}`); 138 | }); 139 | } 140 | }, 1000); 141 | return () => window.clearInterval(timer); 142 | }, [entries, mergeDetail, selectedId, setError]); 143 | const refresh = useCallback(() => { 144 | setLoading(true); 145 | if (trafficSocket.isConnected()) { 146 | trafficSocket.requestTrafficEntries(true); 147 | return; 148 | } 149 | fetchTrafficEntries() 150 | .then((list) => { 151 | setEntries(list); 152 | setError(null); 153 | }) 154 | .catch((error) => { 155 | setError(`获取流量数据失败: ${error instanceof Error ? error.message : '未知错误'}`); 156 | }) 157 | .finally(() => { 158 | setLoading(false); 159 | }); 160 | }, [setEntries, setError, setLoading]); 161 | const reconnect = useCallback(() => { 162 | setLoading(true); 163 | trafficSocket.reconnect(); 164 | }, [setLoading]); 165 | const clearRemoteTraffic = useCallback(async () => { 166 | setLoading(true); 167 | if (trafficSocket.isConnected()) { 168 | trafficSocket.requestClearTraffic(); 169 | selectEntry(null); 170 | return true; 171 | } 172 | const cleared = await clearTrafficRemote(); 173 | if (cleared) { 174 | clearEntries(); 175 | clearDetail(); 176 | selectEntry(null); 177 | setError(null); 178 | } 179 | setLoading(false); 180 | return cleared; 181 | }, [clearDetail, clearEntries, selectEntry, setError, setLoading]); 182 | return { 183 | refresh, 184 | reconnect, 185 | clearRemoteTraffic, 186 | }; 187 | } 188 | -------------------------------------------------------------------------------- /web-react/src/pages/traffic/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'react'; 2 | 3 | import { RequestResponsePanel } from '@/components/request-response-panel'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { Button } from '@/components/ui/button'; 6 | import { useTrafficStream } from '@/hooks/use-traffic-stream'; 7 | import { cn } from '@/lib/utils'; 8 | import { useTrafficStore } from '@/stores/use-traffic-store'; 9 | import { TrafficEntry } from '@/types/traffic'; 10 | 11 | const formatBytes = (bytes: number) => { 12 | if (bytes === 0) return '0 B'; 13 | const units = ['B', 'KB', 'MB', 'GB']; 14 | const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); 15 | return `${(bytes / 1024 ** power).toFixed(power === 0 ? 0 : 1)} ${units[power]}`; 16 | }; 17 | 18 | const statusTint = (status: number) => { 19 | if (status >= 500) return 'text-red-500'; 20 | if (status >= 400) return 'text-amber-500'; 21 | if (status >= 200) return 'text-emerald-500'; 22 | return 'text-muted-foreground'; 23 | }; 24 | 25 | export function TrafficPage() { 26 | const entries = useTrafficStore((state) => state.entries); 27 | const selectedId = useTrafficStore((state) => state.selectedId); 28 | const selectEntry = useTrafficStore((state) => state.selectEntry); 29 | const detail = useTrafficStore((state) => state.detail); 30 | const loading = useTrafficStore((state) => state.loading); 31 | const error = useTrafficStore((state) => state.error); 32 | const connected = useTrafficStore((state) => state.connected); 33 | const transport = useTrafficStore((state) => state.transport); 34 | 35 | const { refresh, reconnect, clearRemoteTraffic } = useTrafficStream(); 36 | 37 | useEffect(() => { 38 | if (entries.length === 0) { 39 | refresh(); 40 | } 41 | }, [entries.length, refresh]); 42 | 43 | const list: TrafficEntry[] = useMemo(() => entries ?? [], [entries]); 44 | const selectedEntry = useMemo( 45 | () => list.find((item) => item.id === selectedId) ?? null, 46 | [list, selectedId] 47 | ); 48 | const lastUpdated = useMemo(() => { 49 | if (!list.length) return '未同步'; 50 | const timestamps = list 51 | .map((entry) => entry.endTime || entry.startTime) 52 | .filter(Boolean) 53 | .map((value) => new Date(value as string).getTime()); 54 | if (!timestamps.length) return '未知'; 55 | return new Date(Math.max(...timestamps)).toLocaleTimeString(); 56 | }, [list]); 57 | 58 | const handleClear = useCallback(async () => { 59 | const confirmed = window.confirm('确定要清空所有流量数据吗?此操作不可恢复。'); 60 | if (!confirmed) { 61 | return; 62 | } 63 | await clearRemoteTraffic(); 64 | }, [clearRemoteTraffic]); 65 | 66 | const handleRefresh = useCallback(() => { 67 | refresh(); 68 | }, [refresh]); 69 | 70 | const handleReconnect = useCallback(() => { 71 | reconnect(); 72 | }, [reconnect]); 73 | 74 | return ( 75 |
76 | {/* 工具栏区域 */} 77 |
78 |
79 |

流量列表

80 |
81 | 84 | 87 | 90 |
91 |
92 |
93 | 94 | {connected ? 'WebSocket 已连接' : 'WebSocket 未连接'} 95 | 96 | 最后更新:{lastUpdated} 97 |
98 |
99 | 100 | {/* 错误提示 */} 101 | {error ? ( 102 |
103 | {error} 104 |
105 | ) : null} 106 | 107 | {/* 主内容区域 - 表格 */} 108 |
109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | {list.map((entry) => ( 125 | selectEntry(entry.id)} 128 | className={cn( 129 | 'cursor-pointer transition-colors hover:bg-muted/70', 130 | selectedId === entry.id && 'bg-secondary/40' 131 | )} 132 | > 133 | 134 | 135 | 136 | 139 | 140 | 141 | 142 | 148 | 149 | ))} 150 | 151 |
方法HostPathCodeMIMESizeCostTags
{entry.method}{entry.host}{entry.path} 137 | {entry.statusCode} 138 | {entry.contentType}{formatBytes(entry.contentSize)}{entry.duration} ms 143 |
144 | {entry.isHTTPS ? HTTPS : null} 145 | {entry.isSSE ? SSE : null} 146 |
147 |
152 | {list.length === 0 ? ( 153 |
154 | {loading ? '加载中…' : '暂无流量记录'} 155 |
156 | ) : null} 157 |
158 |
159 | 160 | {/* 详情区域 */} 161 |
162 |
163 |
164 |

请求 / 响应详情

165 | {selectedId && ( 166 |
167 | ID: {selectedId} 168 | {loading ? 加载中… : null} 169 |
170 | )} 171 |
172 |
173 | {selectedId ? ( 174 | 175 | ) : ( 176 |
177 | 请选择列表中的一条流量记录以查看详情。 178 |
179 | )} 180 |
181 |
182 |
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /harlogger/har.go: -------------------------------------------------------------------------------- 1 | package harlogger 2 | 3 | import "time" 4 | 5 | // HAR is the root object of a HAR file. 6 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#har 7 | type HAR struct { 8 | Log Log `json:"log"` 9 | } 10 | 11 | // Log is the main log object. 12 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#log 13 | type Log struct { 14 | Version string `json:"version"` 15 | Creator Creator `json:"creator"` 16 | Browser *Browser `json:"browser,omitempty"` // Optional 17 | Pages []Page `json:"pages,omitempty"` // Optional 18 | Entries []Entry `json:"entries"` 19 | Comment string `json:"comment,omitempty"` // Optional 20 | } 21 | 22 | // Creator is information about the HAR creator application. 23 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#creator 24 | type Creator struct { 25 | Name string `json:"name"` 26 | Version string `json:"version"` 27 | Comment string `json:"comment,omitempty"` // Optional 28 | } 29 | 30 | // Browser is information about the browser that created the HAR. 31 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#browser 32 | type Browser struct { 33 | Name string `json:"name"` 34 | Version string `json:"version"` 35 | Comment string `json:"comment,omitempty"` // Optional 36 | } 37 | 38 | // Page contains information about a single page. 39 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#pages 40 | type Page struct { 41 | StartedDateTime time.Time `json:"startedDateTime"` 42 | ID string `json:"id"` 43 | Title string `json:"title"` 44 | PageTimings PageTimings `json:"pageTimings"` 45 | Comment string `json:"comment,omitempty"` // Optional 46 | } 47 | 48 | // PageTimings describes page loading timings. 49 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#pageTimings 50 | type PageTimings struct { 51 | OnContentLoad float64 `json:"onContentLoad,omitempty"` // Optional, in ms 52 | OnLoad float64 `json:"onLoad,omitempty"` // Optional, in ms 53 | Comment string `json:"comment,omitempty"` // Optional 54 | } 55 | 56 | // Entry represents an HTTP request/response pair. 57 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#entries 58 | type Entry struct { 59 | Pageref string `json:"pageref,omitempty"` // Optional 60 | StartedDateTime time.Time `json:"startedDateTime"` 61 | Time float64 `json:"time"` // Total time in ms 62 | Request Request `json:"request"` 63 | Response Response `json:"response"` 64 | Cache Cache `json:"cache"` 65 | Timings Timings `json:"timings"` 66 | ServerIPAddress string `json:"serverIPAddress,omitempty"` // Optional 67 | Connection string `json:"connection,omitempty"` // Optional 68 | Comment string `json:"comment,omitempty"` // Optional 69 | } 70 | 71 | // Request contains detailed information about the HTTP request. 72 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#request 73 | type Request struct { 74 | Method string `json:"method"` 75 | URL string `json:"url"` 76 | HTTPVersion string `json:"httpVersion"` 77 | Cookies []Cookie `json:"cookies"` 78 | Headers []NameValuePair `json:"headers"` 79 | QueryString []NameValuePair `json:"queryString"` 80 | PostData *PostData `json:"postData,omitempty"` // Optional 81 | HeadersSize int64 `json:"headersSize"` // -1 if unknown 82 | BodySize int64 `json:"bodySize"` // -1 if unknown 83 | Comment string `json:"comment,omitempty"` // Optional 84 | } 85 | 86 | // Response contains detailed information about the HTTP response. 87 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#response 88 | type Response struct { 89 | Status int `json:"status"` 90 | StatusText string `json:"statusText"` 91 | HTTPVersion string `json:"httpVersion"` 92 | Cookies []Cookie `json:"cookies"` 93 | Headers []NameValuePair `json:"headers"` 94 | Content Content `json:"content"` 95 | RedirectURL string `json:"redirectURL"` 96 | HeadersSize int64 `json:"headersSize"` // -1 if unknown 97 | BodySize int64 `json:"bodySize"` // -1 if unknown 98 | Comment string `json:"comment,omitempty"` // Optional 99 | } 100 | 101 | // Cookie contains information about a single cookie. 102 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#cookies 103 | type Cookie struct { 104 | Name string `json:"name"` 105 | Value string `json:"value"` 106 | Path string `json:"path,omitempty"` // Optional 107 | Domain string `json:"domain,omitempty"` // Optional 108 | Expires *time.Time `json:"expires,omitempty"` // Optional 109 | HTTPOnly bool `json:"httpOnly,omitempty"` // Optional 110 | Secure bool `json:"secure,omitempty"` // Optional 111 | Comment string `json:"comment,omitempty"` // Optional 112 | } 113 | 114 | // NameValuePair is a generic name/value pair structure used for headers, query strings etc. 115 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#nameValuePair 116 | type NameValuePair struct { 117 | Name string `json:"name"` 118 | Value string `json:"value"` 119 | Comment string `json:"comment,omitempty"` // Optional 120 | } 121 | 122 | // PostData describes posted data. 123 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#postData 124 | type PostData struct { 125 | MimeType string `json:"mimeType"` 126 | Params []PostParam `json:"params,omitempty"` 127 | Text string `json:"text,omitempty"` 128 | Encoding string `json:"encoding,omitempty"` // Added for base64 encoded content 129 | // Comment string `json:"comment,omitempty"` // Optional according to spec, not commonly used by browsers 130 | } 131 | 132 | // PostParam describes a posted parameter. 133 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#params 134 | type PostParam struct { 135 | Name string `json:"name"` 136 | Value string `json:"value,omitempty"` // Optional 137 | FileName string `json:"fileName,omitempty"` // Optional 138 | ContentType string `json:"contentType,omitempty"` // Optional 139 | Comment string `json:"comment,omitempty"` // Optional 140 | } 141 | 142 | // Content describes the response content. 143 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#content 144 | type Content struct { 145 | Size int64 `json:"size"` 146 | Compression int64 `json:"compression,omitempty"` // Optional 147 | MimeType string `json:"mimeType"` 148 | Text string `json:"text,omitempty"` // Optional, decoded if possible 149 | Encoding string `json:"encoding,omitempty"` // Optional (e.g., "base64") 150 | Comment string `json:"comment,omitempty"` // Optional 151 | } 152 | 153 | // Cache contains information about the cache entry. 154 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#cache 155 | type Cache struct { 156 | BeforeRequest *CacheEntry `json:"beforeRequest,omitempty"` // Optional 157 | AfterRequest *CacheEntry `json:"afterRequest,omitempty"` // Optional 158 | Comment string `json:"comment,omitempty"` // Optional 159 | } 160 | 161 | // CacheEntry describes a cache entry. 162 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#cacheEntry 163 | type CacheEntry struct { 164 | Expires *time.Time `json:"expires,omitempty"` // Optional 165 | LastAccess time.Time `json:"lastAccess"` 166 | ETag string `json:"eTag"` 167 | HitCount int `json:"hitCount"` 168 | Comment string `json:"comment,omitempty"` // Optional 169 | } 170 | 171 | // Timings describes various timings for the request-response cycle. 172 | // Spec: http://www.softwareishard.com/blog/har-12-spec/#timings 173 | type Timings struct { 174 | Blocked float64 `json:"blocked,omitempty"` // Optional, in ms, -1 if not applicable 175 | DNS float64 `json:"dns,omitempty"` // Optional, in ms, -1 if not applicable 176 | Connect float64 `json:"connect,omitempty"` // Optional, in ms, -1 if not applicable 177 | Send float64 `json:"send"` // in ms 178 | Wait float64 `json:"wait"` // in ms 179 | Receive float64 `json:"receive"` // in ms 180 | SSL float64 `json:"ssl,omitempty"` // Optional, in ms, -1 if not applicable 181 | Comment string `json:"comment,omitempty"` // Optional 182 | } 183 | -------------------------------------------------------------------------------- /proxy/sse_handler.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // ResponseBodyTee 是一个结构,用于同时将数据写入两个目的地 15 | type ResponseBodyTee struct { 16 | buffer *bytes.Buffer // 用于收集完整的响应体 17 | writer io.Writer // 原始的响应写入器 18 | flusher http.Flusher // 用于刷新数据 19 | } 20 | 21 | // Write 实现 io.Writer 接口 22 | func (t *ResponseBodyTee) Write(p []byte) (n int, err error) { 23 | // 写入原始响应写入器 24 | n, err = t.writer.Write(p) 25 | if err != nil { 26 | return n, err 27 | } 28 | 29 | // 同时写入缓冲区 30 | _, bufErr := t.buffer.Write(p) 31 | if bufErr != nil { 32 | // 如果缓冲区写入失败,仅记录日志,不影响原始写入 33 | log.Printf("[SSE] Error writing to buffer: %v", bufErr) 34 | } 35 | 36 | // 刷新数据 37 | if t.flusher != nil { 38 | t.flusher.Flush() 39 | } 40 | 41 | return n, nil 42 | } 43 | 44 | // GetBuffer 返回收集到的完整数据 45 | func (t *ResponseBodyTee) GetBuffer() *bytes.Buffer { 46 | return t.buffer 47 | } 48 | 49 | // handleSSE handles Server-Sent Events responses 50 | func (s *Server) handleSSE(w http.ResponseWriter, respCtx *ResponseContext) error { 51 | // 记录开始时间,用于后续的 HAR 记录 52 | startTime := time.Now() 53 | 54 | // Set appropriate headers for SSE 55 | for k, vv := range respCtx.Response.Header { 56 | for _, v := range vv { 57 | w.Header().Add(k, v) 58 | } 59 | } 60 | 61 | // Ensure critical headers are set for SSE streaming 62 | w.Header().Set("Content-Type", "text/event-stream") 63 | w.Header().Set("Cache-Control", "no-cache") 64 | w.Header().Set("Connection", "keep-alive") 65 | w.Header().Del("Content-Length") // Remove Content-Length to ensure chunked encoding 66 | 67 | // 添加协议版本头以便前端识别 68 | w.Header().Add("X-Protocol", respCtx.Response.Proto) 69 | 70 | // Set the status code 71 | w.WriteHeader(respCtx.Response.StatusCode) 72 | 73 | // Create a flusher if the ResponseWriter supports it 74 | flusher, ok := w.(http.Flusher) 75 | if !ok { 76 | return fmt.Errorf("streaming not supported") 77 | } 78 | 79 | // Flush headers immediately 80 | flusher.Flush() 81 | 82 | // Log SSE handling 83 | if s.Verbose { 84 | log.Printf("[SSE] Handling Server-Sent Events stream") 85 | } 86 | 87 | // 创建一个 ResponseBodyTee 来同时处理流和记录数据 88 | tee := &ResponseBodyTee{ 89 | buffer: &bytes.Buffer{}, 90 | writer: w, 91 | flusher: flusher, 92 | } 93 | 94 | // 创建请求上下文,如果请求有效 95 | respCtx.ReqCtx.IsSSE = true 96 | respCtx.IsSSE = true 97 | 98 | // Read and forward SSE events 99 | reader := bufio.NewReader(respCtx.Response.Body) 100 | 101 | // 如果启用了流量输出,初始化前缀并输出头部 102 | var dumpPrefix string 103 | if s.DumpTraffic { 104 | dumpPrefix = fmt.Sprintf("[DUMP] %s %s%s -> SSE Stream", respCtx.Response.Request.Method, respCtx.Response.Request.Host, respCtx.Response.Request.URL.RequestURI()) 105 | 106 | // 输出响应状态行 107 | fmt.Printf("%s %s\n", dumpPrefix, respCtx.Response.Status) 108 | 109 | // 输出响应头部 110 | fmt.Printf("%s Response Headers:\n", dumpPrefix) 111 | for name, values := range respCtx.Response.Header { 112 | for _, value := range values { 113 | fmt.Printf("%s %s: %s\n", dumpPrefix, name, value) 114 | } 115 | } 116 | 117 | fmt.Printf("%s Starting SSE stream\n", dumpPrefix) 118 | } 119 | 120 | for { 121 | line, err := reader.ReadBytes('\n') 122 | if err != nil { 123 | if err == io.EOF { 124 | break 125 | } 126 | // 通知错误 127 | if respCtx.ReqCtx != nil { 128 | s.notifyError(fmt.Errorf("error reading SSE stream: %v", err), respCtx.ReqCtx) 129 | } 130 | return fmt.Errorf("error reading SSE stream: %v", err) 131 | } 132 | 133 | // 写入 tee,它会同时写入客户端和缓冲区 134 | _, err = tee.Write(line) 135 | if err != nil { 136 | // 通知错误 137 | if respCtx.ReqCtx != nil { 138 | s.notifyError(fmt.Errorf("error writing SSE data: %v", err), respCtx.ReqCtx) 139 | } 140 | return fmt.Errorf("error writing SSE data: %v", err) 141 | } 142 | 143 | // Log the event if verbose 144 | lineStr := strings.TrimSpace(string(line)) 145 | logSSEEvent(lineStr, s.Verbose) 146 | 147 | // 如果启用了流量输出,输出 SSE 事件 148 | if s.DumpTraffic && lineStr != "" { 149 | fmt.Printf("%s %s\n", dumpPrefix, lineStr) 150 | } 151 | 152 | // 通知 SSE 事件处理 153 | if respCtx != nil && lineStr != "" { 154 | s.notifySSE(lineStr, respCtx) 155 | } 156 | } 157 | 158 | // 流结束后,记录 HAR 条目并标记SSE已完成 159 | // 通知SSE已完成 160 | if respCtx != nil && respCtx.ReqCtx != nil { 161 | // 发送一个特殊的SSE完成事件 162 | s.notifySSE("__SSE_COMPLETED__", respCtx) 163 | 164 | if s.Verbose { 165 | log.Printf("[SSE] Stream completed, notified handlers") 166 | } 167 | } 168 | 169 | if s.HarLogger.IsEnabled() { 170 | // 计算流处理时间 171 | timeTaken := time.Since(startTime) 172 | if respCtx != nil { 173 | respCtx.TimeTaken = timeTaken 174 | } 175 | 176 | // 创建一个新的响应,包含收集到的完整数据 177 | newResp := &http.Response{ 178 | Status: respCtx.Response.Status, 179 | StatusCode: respCtx.Response.StatusCode, 180 | Header: respCtx.Response.Header.Clone(), 181 | Body: io.NopCloser(bytes.NewReader(tee.GetBuffer().Bytes())), 182 | Proto: respCtx.Response.Proto, 183 | ProtoMajor: respCtx.Response.ProtoMajor, 184 | ProtoMinor: respCtx.Response.ProtoMinor, 185 | } 186 | 187 | // 使用原始请求记录 HAR 条目 188 | s.logToHAR(respCtx.Response.Request, newResp, startTime, timeTaken, false) // 这里使用 false 因为我们已经有了完整的数据 189 | 190 | if s.Verbose { 191 | log.Printf("[SSE] Recorded complete SSE response in HAR log (%d bytes)", tee.GetBuffer().Len()) 192 | } 193 | } 194 | 195 | return nil 196 | } 197 | 198 | // logSSEEvent 记录 SSE 事件的日志 199 | // 这个函数集中了所有 SSE 事件日志记录逻辑,避免代码重复 200 | func logSSEEvent(lineStr string, verbose bool) { 201 | if !verbose || len(lineStr) <= 1 { // Skip empty lines or when verbose is disabled 202 | return 203 | } 204 | 205 | if strings.HasPrefix(lineStr, "data:") { 206 | log.Printf("[SSE] Event data: %s", lineStr) 207 | } else if strings.HasPrefix(lineStr, "event:") { 208 | log.Printf("[SSE] Event type: %s", lineStr) 209 | } else if strings.HasPrefix(lineStr, "id:") { 210 | log.Printf("[SSE] Event ID: %s", lineStr) 211 | } else if strings.HasPrefix(lineStr, "retry:") { 212 | log.Printf("[SSE] Event retry: %s", lineStr) 213 | } else if lineStr != "" { 214 | log.Printf("[SSE] Event line: %s", lineStr) 215 | } 216 | } 217 | 218 | // isServerSentEvent checks if the response is a Server-Sent Event stream 219 | func isServerSentEvent(resp *http.Response) bool { 220 | // Check Content-Type header for SSE 221 | contentType := resp.Header.Get("Content-Type") 222 | 223 | // 检查是否是标准的 SSE Content-Type 224 | if strings.Contains(contentType, "text/event-stream") { 225 | return true 226 | } 227 | 228 | // 确保 Request 不为 nil 229 | if resp.Request == nil || resp.Request.URL == nil { 230 | return false 231 | } 232 | 233 | // 检查是否是 JSON 流 234 | // 注意:httpbin.org/stream 返回的是 JSON 流,而不是真正的 SSE 235 | // 但我们仍然可以将其作为流式处理 236 | if strings.Contains(contentType, "application/json") && strings.Contains(resp.Request.URL.Path, "/stream") { 237 | return true 238 | } 239 | 240 | // 检查是否是 OpenAI 的流式 API 241 | if strings.Contains(contentType, "application/json") && (strings.Contains(resp.Request.URL.Path, "/completions") || 242 | strings.Contains(resp.Request.URL.Path, "/chat/completions")) { 243 | // 检查是否有 stream=true 参数 244 | if resp.Request.URL.Query().Get("stream") == "true" { 245 | return true 246 | } 247 | } 248 | 249 | return false 250 | } 251 | 252 | // isSSERequest checks if the request might be for a Server-Sent Event stream 253 | func isSSERequest(req *http.Request) bool { 254 | // Check Accept header for SSE 255 | acceptHeader := req.Header.Get("Accept") 256 | 257 | // Check if the URL path contains common SSE endpoints 258 | path := strings.ToLower(req.URL.Path) 259 | 260 | // Common SSE endpoint patterns 261 | ssePatterns := []string{ 262 | "/events", 263 | "/stream", 264 | "/sse", 265 | "/notifications", 266 | "/messages", 267 | "/updates", 268 | "/push", 269 | "/chat", 270 | "/completions", // OpenAI API 271 | "/v1/chat/completions", // OpenAI API 272 | } 273 | 274 | // Check if the path contains any of the SSE patterns 275 | for _, pattern := range ssePatterns { 276 | if strings.Contains(path, pattern) { 277 | return true 278 | } 279 | } 280 | 281 | return strings.Contains(acceptHeader, "text/event-stream") 282 | } 283 | 284 | // mayBeServerSentEvent checks if the request might be for a Server-Sent Event stream 285 | // This is used to set up the request properly before sending it 286 | func mayBeServerSentEvent(req *http.Request) bool { 287 | // Check Accept header for SSE 288 | acceptHeader := req.Header.Get("Accept") 289 | return strings.Contains(acceptHeader, "text/event-stream") 290 | } 291 | --------------------------------------------------------------------------------