diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7f4143b..7ac7280 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -83,4 +83,17 @@ jobs: tags: | woodchen/proxy-go:latest woodchen/proxy-go:${{ steps.date.outputs.date }} + - name: Execute deployment commands + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: root + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + docker pull woodchen/proxy-go:latest + + docker stop proxy-go || true + docker rm proxy-go || true + + docker compose -f /opt/1panel/docker/compose/proxy-go/docker-compose.yml up -d diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go new file mode 100644 index 0000000..5cf1649 --- /dev/null +++ b/cmd/proxy/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "log" + "net/http" + "os" + "os/signal" + "proxy-go/internal/compression" + "proxy-go/internal/config" + "proxy-go/internal/handler" + "proxy-go/internal/middleware" + "syscall" +) + +func main() { + // 加载配置 + cfg, err := config.Load("data/config.json") + if err != nil { + log.Fatal("Error loading config:", err) + } + + // 创建压缩管理器,直接使用配置文件中的压缩配置 + compManager := compression.NewManager(compression.Config{ + Gzip: compression.CompressorConfig(cfg.Compression.Gzip), + Brotli: compression.CompressorConfig(cfg.Compression.Brotli), + }) + + // 创建代理处理器 + proxyHandler := handler.NewProxyHandler(cfg.MAP) + + // 添加中间件 + var handler http.Handler = proxyHandler + if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled { + handler = middleware.CompressionMiddleware(compManager)(handler) + } + + // 创建服务器 + server := &http.Server{ + Addr: ":80", + Handler: handler, + } + + // 优雅关闭 + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + log.Println("Shutting down server...") + if err := server.Close(); err != nil { + log.Printf("Error during server shutdown: %v\n", err) + } + }() + + // 启动服务器 + log.Println("Starting proxy server on :80") + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal("Error starting server:", err) + } +} diff --git a/data/config.json b/data/config.json index eafbc7e..b4a50d7 100644 --- a/data/config.json +++ b/data/config.json @@ -1,6 +1,16 @@ { - "MAP":{ - "/path1": "https://path1.com/path/path/path", - "/path2": "https://path2.com" + "MAP": { + "/path1": "https://path1.com/path/path/path", + "/path2": "https://path2.com" + }, + "Compression": { + "Gzip": { + "Enabled": true, + "Level": 6 + }, + "Brotli": { + "Enabled": true, + "Level": 4 } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 8548eea..41429da 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module proxy-go go 1.23.1 + +require github.com/andybalholm/brotli v1.1.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5064fa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= diff --git a/internal/compression/brotli.go b/internal/compression/brotli.go new file mode 100644 index 0000000..1af8d8b --- /dev/null +++ b/internal/compression/brotli.go @@ -0,0 +1,23 @@ +package compression + +import ( + "io" + + "github.com/andybalholm/brotli" +) + +type BrotliCompressor struct { + level int +} + +func NewBrotliCompressor(level int) *BrotliCompressor { + // 确保level在有效范围内 (0-11) + if level < 0 || level > 11 { + level = brotli.DefaultCompression + } + return &BrotliCompressor{level: level} +} + +func (b *BrotliCompressor) Compress(w io.Writer) (io.WriteCloser, error) { + return brotli.NewWriterLevel(w, b.level), nil +} diff --git a/internal/compression/gzip.go b/internal/compression/gzip.go new file mode 100644 index 0000000..2e38270 --- /dev/null +++ b/internal/compression/gzip.go @@ -0,0 +1,22 @@ +package compression + +import ( + "compress/gzip" + "io" +) + +type GzipCompressor struct { + level int +} + +func NewGzipCompressor(level int) *GzipCompressor { + // 确保level在有效范围内 + if level < gzip.DefaultCompression || level > gzip.BestCompression { + level = gzip.DefaultCompression + } + return &GzipCompressor{level: level} +} + +func (g *GzipCompressor) Compress(w io.Writer) (io.WriteCloser, error) { + return gzip.NewWriterLevel(w, g.level) +} diff --git a/internal/compression/manager.go b/internal/compression/manager.go new file mode 100644 index 0000000..acf7327 --- /dev/null +++ b/internal/compression/manager.go @@ -0,0 +1,41 @@ +package compression + +import "strings" + +type compressionManager struct { + gzip Compressor + brotli Compressor + config Config +} + +// NewManager 创建新的压缩管理器 +func NewManager(config Config) Manager { + m := &compressionManager{ + config: config, + } + + if config.Gzip.Enabled { + m.gzip = NewGzipCompressor(config.Gzip.Level) + } + + if config.Brotli.Enabled { + m.brotli = NewBrotliCompressor(config.Brotli.Level) + } + + return m +} + +// SelectCompressor 实现 Manager 接口 +func (m *compressionManager) SelectCompressor(acceptEncoding string) (Compressor, CompressionType) { + // 优先选择 brotli + if m.brotli != nil && strings.Contains(acceptEncoding, string(CompressionBrotli)) { + return m.brotli, CompressionBrotli + } + + // 其次选择 gzip + if m.gzip != nil && strings.Contains(acceptEncoding, string(CompressionGzip)) { + return m.gzip, CompressionGzip + } + + return nil, "" +} diff --git a/internal/compression/types.go b/internal/compression/types.go new file mode 100644 index 0000000..e2be720 --- /dev/null +++ b/internal/compression/types.go @@ -0,0 +1,35 @@ +package compression + +import "io" + +// Compressor 定义压缩器接口 +type Compressor interface { + Compress(w io.Writer) (io.WriteCloser, error) +} + +// CompressionType 表示压缩类型 +type CompressionType string + +const ( + CompressionGzip CompressionType = "gzip" + CompressionBrotli CompressionType = "br" +) + +// Config 压缩配置结构体 +type Config struct { + Gzip CompressorConfig `json:"Gzip"` + Brotli CompressorConfig `json:"Brotli"` +} + +// CompressorConfig 单个压缩器的配置 +type CompressorConfig struct { + Enabled bool `json:"Enabled"` + Level int `json:"Level"` +} + +// Manager 压缩管理器接口 +type Manager interface { + // SelectCompressor 根据 Accept-Encoding 头选择合适的压缩器 + // 返回选中的压缩器和对应的压缩类型 + SelectCompressor(acceptEncoding string) (Compressor, CompressionType) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a46b739 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,20 @@ +package config + +import ( + "encoding/json" + "os" +) + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..e8ad6f4 --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,16 @@ +package config + +type Config struct { + MAP map[string]string `json:"MAP"` + Compression CompressionConfig `json:"Compression"` +} + +type CompressionConfig struct { + Gzip CompressorConfig `json:"Gzip"` + Brotli CompressorConfig `json:"Brotli"` +} + +type CompressorConfig struct { + Enabled bool `json:"Enabled"` + Level int `json:"Level"` +} diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go new file mode 100644 index 0000000..2bfb19a --- /dev/null +++ b/internal/handler/proxy.go @@ -0,0 +1,107 @@ +package handler + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" +) + +type ProxyHandler struct { + pathMap map[string]string +} + +func NewProxyHandler(pathMap map[string]string) *ProxyHandler { + return &ProxyHandler{ + pathMap: pathMap, + } +} + +func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // 处理根路径请求 + if r.URL.Path == "/" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "Welcome to CZL proxy.") + return + } + + // 查找匹配的代理路径 + var matchedPrefix string + var targetBase string + for prefix, target := range h.pathMap { + if strings.HasPrefix(r.URL.Path, prefix) { + matchedPrefix = prefix + targetBase = target + break + } + } + + // 如果没有匹配的路径,返回 404 + if matchedPrefix == "" { + http.NotFound(w, r) + return + } + + // 构建目标 URL + targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix) + targetURL := targetBase + targetPath + + // 创建新的请求 + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) + if err != nil { + http.Error(w, "Error creating proxy request", http.StatusInternalServerError) + return + } + + // 复制原始请求的 header + copyHeader(proxyReq.Header, r.Header) + + // 设置一些必要的头部 + proxyReq.Header.Set("X-Forwarded-Host", r.Host) + proxyReq.Header.Set("X-Real-IP", getClientIP(r)) + + // 发送代理请求 + client := &http.Client{} + resp, err := client.Do(proxyReq) + if err != nil { + http.Error(w, "Error forwarding request", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // 复制响应 header + copyHeader(w.Header(), resp.Header) + + // 设置响应状态码 + w.WriteHeader(resp.StatusCode) + + // 复制响应体 + if _, err := io.Copy(w, resp.Body); err != nil { + // 这里只记录错误,不返回给客户端,因为响应头已经发送 + fmt.Printf("Error copying response: %v\n", err) + } +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func getClientIP(r *http.Request) string { + // 检查各种可能的请求头 + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if ip := r.Header.Get("X-Forwarded-For"); ip != "" { + return strings.Split(ip, ",")[0] + } + // 从RemoteAddr获取 + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return ip + } + return r.RemoteAddr +} diff --git a/internal/middleware/compression.go b/internal/middleware/compression.go new file mode 100644 index 0000000..ff0f6fb --- /dev/null +++ b/internal/middleware/compression.go @@ -0,0 +1,174 @@ +package middleware + +import ( + "bufio" + "io" + "mime" + "net" + "net/http" + "proxy-go/internal/compression" + "strings" +) + +const ( + defaultBufferSize = 32 * 1024 // 32KB +) + +type CompressResponseWriter struct { + http.ResponseWriter + compressor compression.Compressor + writer io.WriteCloser + bufferedWriter *bufio.Writer + statusCode int + written bool + compressed bool +} + +func CompressionMiddleware(manager compression.Manager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 检查源站是否已经压缩 + if r.Header.Get("Content-Encoding") != "" { + next.ServeHTTP(w, r) + return + } + + // 选择压缩器 + compressor, encoding := manager.SelectCompressor(r.Header.Get("Accept-Encoding")) + if compressor == nil { + next.ServeHTTP(w, r) + return + } + + cw := &CompressResponseWriter{ + ResponseWriter: w, + compressor: compressor, + statusCode: 0, + written: false, + compressed: false, + } + + // 设置Content-Encoding header + cw.Header().Set("Content-Encoding", string(encoding)) + cw.Header().Add("Vary", "Accept-Encoding") + + defer func() { + if cw.writer != nil { + if cw.bufferedWriter != nil { + cw.bufferedWriter.Flush() + } + cw.writer.Close() + } + }() + + next.ServeHTTP(cw, r) + }) + } +} + +func (cw *CompressResponseWriter) WriteHeader(statusCode int) { + if cw.written { + return + } + + cw.statusCode = statusCode + cw.written = true + + // 某些状态码不应该压缩 + if !shouldCompressForStatus(statusCode) { + cw.compressed = false + cw.Header().Del("Content-Encoding") + cw.ResponseWriter.WriteHeader(statusCode) + return + } + + // 检查内容类型是否应该压缩 + if !shouldCompressType(cw.Header().Get("Content-Type")) { + cw.compressed = false + cw.Header().Del("Content-Encoding") + cw.ResponseWriter.WriteHeader(statusCode) + return + } + + cw.compressed = true + cw.Header().Del("Content-Length") // 因为内容将被压缩,原长度不再有效 + cw.ResponseWriter.WriteHeader(statusCode) +} + +func (cw *CompressResponseWriter) Write(b []byte) (int, error) { + if !cw.written { + cw.WriteHeader(http.StatusOK) + } + + if !cw.compressed { + return cw.ResponseWriter.Write(b) + } + + // 延迟初始化压缩写入器 + if cw.writer == nil { + var err error + cw.writer, err = cw.compressor.Compress(cw.ResponseWriter) + if err != nil { + return 0, err + } + cw.bufferedWriter = bufio.NewWriterSize(cw.writer, defaultBufferSize) + } + + return cw.bufferedWriter.Write(b) +} + +// 实现 http.Hijacker 接口 +func (cw *CompressResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := cw.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// 实现 http.Flusher 接口 +func (cw *CompressResponseWriter) Flush() { + if cw.bufferedWriter != nil { + cw.bufferedWriter.Flush() + } + if f, ok := cw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// 判断是否应该对该状态码的响应进行压缩 +func shouldCompressForStatus(status int) bool { + // 只压缩成功的响应 + return status == http.StatusOK || + status == http.StatusCreated || + status == http.StatusAccepted || + status == http.StatusNonAuthoritativeInfo || + status == http.StatusNoContent || + status == http.StatusPartialContent +} + +// 判断是否应该对该内容类型进行压缩 +func shouldCompressType(contentType string) bool { + // 解析内容类型 + mimeType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + compressibleTypes := map[string]bool{ + "text/": true, + "application/javascript": true, + "application/json": true, + "application/xml": true, + "application/x-yaml": true, + "image/svg+xml": true, + } + + // 检查是否是可压缩类型 + for prefix := range compressibleTypes { + if strings.HasPrefix(mimeType, prefix) { + return true + } + } + + return false +} diff --git a/main.go b/main.go deleted file mode 100644 index 4b32da9..0000000 --- a/main.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" -) - -// Config 结构体用于解析配置文件 -type Config struct { - MAP map[string]string `json:"MAP"` -} - -func main() { - // 读取配置文件 - configFile, err := os.ReadFile("data/config.json") - if err != nil { - log.Fatal("Error reading config file:", err) - } - - // 解析配置文件 - var config Config - if err := json.Unmarshal(configFile, &config); err != nil { - log.Fatal("Error parsing config file:", err) - } - - // 创建 HTTP 处理函数 - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // 处理根路径请求 - if r.URL.Path == "/" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "Welcome to CZL proxy.") - return - } - - // 查找匹配的代理路径 - var matchedPrefix string - var targetBase string - for prefix, target := range config.MAP { - if strings.HasPrefix(r.URL.Path, prefix) { - matchedPrefix = prefix - targetBase = target - break - } - } - - // 如果没有匹配的路径,返回 404 - if matchedPrefix == "" { - http.NotFound(w, r) - return - } - - // 构建目标 URL - targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix) - targetURL := targetBase + targetPath - - // 创建新的请求 - proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) - if err != nil { - http.Error(w, "Error creating proxy request", http.StatusInternalServerError) - return - } - - // 复制原始请求的 header - for header, values := range r.Header { - for _, value := range values { - proxyReq.Header.Add(header, value) - } - } - - // 发送代理请求 - client := &http.Client{} - resp, err := client.Do(proxyReq) - if err != nil { - http.Error(w, "Error forwarding request", http.StatusBadGateway) - return - } - defer resp.Body.Close() - - // 复制响应 header - for header, values := range resp.Header { - for _, value := range values { - w.Header().Add(header, value) - } - } - - // 设置响应状态码 - w.WriteHeader(resp.StatusCode) - - // 复制响应体 - if _, err := io.Copy(w, resp.Body); err != nil { - log.Printf("Error copying response: %v", err) - } - }) - - // 启动服务器 - log.Println("Starting proxy server on :80") - if err := http.ListenAndServe(":80", nil); err != nil { - log.Fatal("Error starting server:", err) - } -}