├── 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 |
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 | Host |
115 | Path |
116 | Code |
117 | MIME |
118 | Size |
119 | Cost |
120 | Tags |
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 | | {entry.method} |
134 | {entry.host} |
135 | {entry.path} |
136 |
137 | {entry.statusCode}
138 | |
139 | {entry.contentType} |
140 | {formatBytes(entry.contentSize)} |
141 | {entry.duration} ms |
142 |
143 |
144 | {entry.isHTTPS ? HTTPS : null}
145 | {entry.isSSE ? SSE : null}
146 |
147 | |
148 |
149 | ))}
150 |
151 |
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 |
--------------------------------------------------------------------------------