From ba3dcf7624ef2416f34bcbdad66462ad43170da3 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:22:05 +0800 Subject: [PATCH 1/2] perf(proxy): optimize hot request paths - Cache route handlers, simplify NoRoute path normalization, and reduce matcher/header allocations - Honor configured transport pool limits in auto mode and add hotpath regression benchmarks/tests --- main.go | 24 +++--- proxy/handler.go | 28 ++++--- proxy/hotpath_test.go | 167 +++++++++++++++++++++++++++++++++++++ proxy/httpc.go | 20 +++-- proxy/httpc_test.go | 64 ++++++++++++++ proxy/match.go | 52 +++++++++--- proxy/matcher_test.go | 24 ++++++ proxy/perf_compare_test.go | 88 +++++++++++++++++++ proxy/reqheader.go | 21 +++-- 9 files changed, 437 insertions(+), 51 deletions(-) create mode 100644 proxy/hotpath_test.go create mode 100644 proxy/httpc_test.go create mode 100644 proxy/perf_compare_test.go diff --git a/main.go b/main.go index e978552..870ff5f 100644 --- a/main.go +++ b/main.go @@ -404,6 +404,8 @@ func main() { setupApi(cfg, r, version) setupPages(cfg, r) r.SetRedirectTrailingSlash(false) + routingHandler := proxy.RoutingHandler(cfg) + noRouteHandler := proxy.NoRouteHandler(cfg) r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) { // 规范化路径: 移除前导斜杠, 简化后续处理 @@ -433,7 +435,7 @@ func main() { // 根据匹配结果执行最终操作 if isValidDownload { c.Set("matcher", "releases") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) } else { // 任何不符合下载链接格式的 'releases' 路径都被视为浏览页面并拒绝 proxy.ErrorPage(c, proxy.NewErrorWithStatusLookup(400, "unsupported releases page, only download links are allowed")) @@ -443,45 +445,45 @@ func main() { r.GET("/github.com/:user/:repo/archive/*filepath", func(c *touka.Context) { c.Set("matcher", "releases") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/github.com/:user/:repo/blob/*filepath", func(c *touka.Context) { c.Set("matcher", "blob") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/github.com/:user/:repo/raw/*filepath", func(c *touka.Context) { c.Set("matcher", "raw") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/github.com/:user/:repo/info/*filepath", func(c *touka.Context) { c.Set("matcher", "clone") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) { c.Set("matcher", "clone") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.POST("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) { c.Set("matcher", "clone") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/raw.githubusercontent.com/:user/:repo/*filepath", func(c *touka.Context) { c.Set("matcher", "raw") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.GET("/gist.githubusercontent.com/:user/*filepath", func(c *touka.Context) { c.Set("matcher", "gist") - proxy.NoRouteHandler(cfg)(c) + noRouteHandler(c) }) r.ANY("/api.github.com/repos/:user/:repo/*filepath", func(c *touka.Context) { c.Set("matcher", "api") - proxy.RoutingHandler(cfg)(c) + routingHandler(c) }) r.ANY("/v2/*path", @@ -497,7 +499,7 @@ func main() { }) r.NoRoute(func(c *touka.Context) { - proxy.NoRouteHandler(cfg)(c) + noRouteHandler(c) }) fmt.Printf("GHProxy Version: %s\n", version) diff --git a/proxy/handler.go b/proxy/handler.go index 55a3e1e..35032ef 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -3,7 +3,6 @@ package proxy import ( "fmt" "ghproxy/config" - "regexp" "strings" "github.com/infinite-iroha/touka" @@ -31,29 +30,34 @@ func buildProxyPath(path, matcher string) string { return sb.String() } -var re = regexp.MustCompile(`^(http:|https:)?/?/?(.*)`) // 匹配http://或https://开头的路径 +func normalizeProxyPath(rawPath string) (string, bool) { + path := strings.TrimLeft(rawPath, "/") + + switch { + case strings.HasPrefix(path, "https:"): + path = path[len("https:"):] + case strings.HasPrefix(path, "http:"): + path = path[len("http:"):] + } + + path = strings.TrimLeft(path, "/") + return path, path != "" +} func NoRouteHandler(cfg *config.Config) touka.HandlerFunc { return func(c *touka.Context) { var ctx = c.Request.Context() var shoudBreak bool - var ( - rawPath string - matches []string - ) - - rawPath = strings.TrimPrefix(c.GetRequestURI(), "/") // 去掉前缀/ - matches = re.FindStringSubmatch(rawPath) // 匹配路径 + path, ok := normalizeProxyPath(c.GetRequestURI()) // 匹配路径错误处理 - if len(matches) < 3 { + if !ok { c.Warnf("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, c.Request.URL.Path, c.UserAgent(), c.Request.Proto) ErrorPage(c, NewErrorWithStatusLookup(400, fmt.Sprintf("Invalid URL Format: %s", c.GetRequestURI()))) return } - path := matches[2] var matcherErr *GHProxyErrors user, repo, matcher, matcherErr := Matcher("https://"+path, cfg) if matcherErr != nil { @@ -61,7 +65,7 @@ func NoRouteHandler(cfg *config.Config) touka.HandlerFunc { return } - rawPath = buildProxyPath(path, matcher) + rawPath := buildProxyPath(path, matcher) shoudBreak = listCheck(cfg, c, user, repo, rawPath) if shoudBreak { diff --git a/proxy/hotpath_test.go b/proxy/hotpath_test.go new file mode 100644 index 0000000..d20ac06 --- /dev/null +++ b/proxy/hotpath_test.go @@ -0,0 +1,167 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "ghproxy/config" + + "github.com/infinite-iroha/touka" +) + +func TestNormalizeProxyPath(t *testing.T) { + testCases := []struct { + name string + rawPath string + expected string + expectValid bool + }{ + {name: "Plain host path", rawPath: "/github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true}, + {name: "HTTPS URL", rawPath: "/https://github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true}, + {name: "HTTP URL", rawPath: "http://github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true}, + {name: "Scheme with single slash", rawPath: "https:/github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true}, + {name: "Extra leading slashes", rawPath: "////github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true}, + {name: "Empty path", rawPath: "", expectValid: false}, + {name: "Slash only", rawPath: "////", expectValid: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, ok := normalizeProxyPath(tc.rawPath) + if ok != tc.expectValid { + t.Fatalf("valid = %v, want %v", ok, tc.expectValid) + } + if got != tc.expected { + t.Fatalf("path = %q, want %q", got, tc.expected) + } + }) + } +} + +func TestCopyHeaderFiltered(t *testing.T) { + src := http.Header{ + "Accept": {"text/plain"}, + "Connection": {"keep-alive"}, + "X-Test": {"one", "two"}, + "Accept-Encoding": {"gzip"}, + } + dst := make(http.Header) + + copyHeaderFiltered(dst, src, reqHeadersToRemove) + + if got := dst.Values("Accept"); !reflect.DeepEqual(got, []string{"text/plain"}) { + t.Fatalf("Accept = %v, want [text/plain]", got) + } + if got := dst.Values("X-Test"); !reflect.DeepEqual(got, []string{"one", "two"}) { + t.Fatalf("X-Test = %v, want [one two]", got) + } + if got := dst.Values("Connection"); len(got) != 0 { + t.Fatalf("Connection should be filtered, got %v", got) + } + if got := dst.Values("Accept-Encoding"); len(got) != 0 { + t.Fatalf("Accept-Encoding should be filtered, got %v", got) + } +} + +func TestCopyHeaderFiltered_AllowsAllWhenDenylistEmpty(t *testing.T) { + src := http.Header{ + "X-Test": {"one", "two"}, + } + dst := make(http.Header) + + copyHeaderFiltered(dst, src, nil) + + if got := dst.Values("X-Test"); !reflect.DeepEqual(got, []string{"one", "two"}) { + t.Fatalf("X-Test = %v, want [one two]", got) + } +} + +func TestBuildProxyPath(t *testing.T) { + testCases := []struct { + name string + path string + matcher string + expected string + }{ + { + name: "Blob path rewrites to raw host", + path: "github.com/owner/repo/blob/main/file.go", + matcher: "blob", + expected: "https://raw.githubusercontent.com/owner/repo/main/file.go", + }, + { + name: "Non blob path keeps host", + path: "raw.githubusercontent.com/owner/repo/main/file.go", + matcher: "raw", + expected: "https://raw.githubusercontent.com/owner/repo/main/file.go", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := buildProxyPath(tc.path, tc.matcher); got != tc.expected { + t.Fatalf("buildProxyPath() = %q, want %q", got, tc.expected) + } + }) + } +} + +func TestNoRouteHandler_InvalidURI_ReturnsBadRequest(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://client.example/", nil) + req.RequestURI = "/" + + ctx, _ := touka.CreateTestContextWithRequest(recorder, req) + NoRouteHandler(&config.Config{})(ctx) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + if body := recorder.Body.String(); body == "" { + t.Fatal("expected error response body to be written") + } +} + +func TestNoRouteHandler_NormalizesAbsoluteRequestURIForAPI(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://client.example/placeholder", nil) + req.RequestURI = "/https://api.github.com/repos/WJQSERVER-STUDIO/ghproxy/releases?per_page=1" + + ctx, _ := touka.CreateTestContextWithRequest(recorder, req) + cfg := &config.Config{} + NoRouteHandler(cfg)(ctx) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } + if body := recorder.Body.String(); body == "" { + t.Fatal("expected error response body to be written") + } +} + +func BenchmarkNormalizeProxyPath(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = normalizeProxyPath("/https://github.com/WJQSERVER-STUDIO/ghproxy/releases/download/v1.0.0/asset.tar.gz") + } +} + +func BenchmarkCopyHeaderFiltered(b *testing.B) { + src := http.Header{ + "Accept": {"text/plain"}, + "Accept-Encoding": {"gzip"}, + "Connection": {"keep-alive"}, + "User-Agent": {"curl/8.0.1"}, + "X-Test": {"one", "two"}, + "CF-Connecting-IP": {"127.0.0.1"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Transfer-Encoding": {"chunked"}, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + dst := make(http.Header) + copyHeaderFiltered(dst, src, reqHeadersToRemove) + } +} diff --git a/proxy/httpc.go b/proxy/httpc.go index 857f3f0..3abbdfb 100644 --- a/proxy/httpc.go +++ b/proxy/httpc.go @@ -39,10 +39,13 @@ func initHTTPClient(cfg *config.Config) *httpc.Client { switch cfg.Httpc.Mode { case "auto", "": tr = &http.Transport{ - IdleConnTimeout: 30 * time.Second, - WriteBufferSize: 32 * 1024, // 32KB - ReadBufferSize: 32 * 1024, // 32KB - Protocols: proTolcols, + MaxIdleConns: cfg.Httpc.MaxIdleConns, + MaxConnsPerHost: cfg.Httpc.MaxConnsPerHost, + MaxIdleConnsPerHost: cfg.Httpc.MaxIdleConnsPerHost, + IdleConnTimeout: 30 * time.Second, + WriteBufferSize: 32 * 1024, // 32KB + ReadBufferSize: 32 * 1024, // 32KB + Protocols: proTolcols, } case "advanced": tr = &http.Transport{ @@ -77,9 +80,12 @@ func initGitHTTPClient(cfg *config.Config) { switch cfg.Httpc.Mode { case "auto", "": gittr = &http.Transport{ - IdleConnTimeout: 30 * time.Second, - WriteBufferSize: 32 * 1024, // 32KB - ReadBufferSize: 32 * 1024, // 32KB + MaxIdleConns: cfg.Httpc.MaxIdleConns, + MaxConnsPerHost: cfg.Httpc.MaxConnsPerHost, + MaxIdleConnsPerHost: cfg.Httpc.MaxIdleConnsPerHost, + IdleConnTimeout: 30 * time.Second, + WriteBufferSize: 32 * 1024, // 32KB + ReadBufferSize: 32 * 1024, // 32KB } case "advanced": gittr = &http.Transport{ diff --git a/proxy/httpc_test.go b/proxy/httpc_test.go new file mode 100644 index 0000000..31898fa --- /dev/null +++ b/proxy/httpc_test.go @@ -0,0 +1,64 @@ +package proxy + +import ( + "ghproxy/config" + "testing" +) + +func TestInitHTTPClient_AutoModeUsesConfiguredPoolSizes(t *testing.T) { + oldTr, oldClient := tr, client + t.Cleanup(func() { + tr = oldTr + client = oldClient + }) + + cfg := &config.Config{} + cfg.Httpc.Mode = "auto" + cfg.Httpc.MaxIdleConns = 123 + cfg.Httpc.MaxIdleConnsPerHost = 45 + cfg.Httpc.MaxConnsPerHost = 67 + + initHTTPClient(cfg) + + if tr == nil { + t.Fatal("transport was not initialized") + } + if tr.MaxIdleConns != 123 { + t.Fatalf("MaxIdleConns = %d, want 123", tr.MaxIdleConns) + } + if tr.MaxIdleConnsPerHost != 45 { + t.Fatalf("MaxIdleConnsPerHost = %d, want 45", tr.MaxIdleConnsPerHost) + } + if tr.MaxConnsPerHost != 67 { + t.Fatalf("MaxConnsPerHost = %d, want 67", tr.MaxConnsPerHost) + } +} + +func TestInitGitHTTPClient_AutoModeUsesConfiguredPoolSizes(t *testing.T) { + oldGitTr, oldGitClient := gittr, gitclient + t.Cleanup(func() { + gittr = oldGitTr + gitclient = oldGitClient + }) + + cfg := &config.Config{} + cfg.Httpc.Mode = "auto" + cfg.Httpc.MaxIdleConns = 98 + cfg.Httpc.MaxIdleConnsPerHost = 76 + cfg.Httpc.MaxConnsPerHost = 54 + + initGitHTTPClient(cfg) + + if gittr == nil { + t.Fatal("git transport was not initialized") + } + if gittr.MaxIdleConns != 98 { + t.Fatalf("MaxIdleConns = %d, want 98", gittr.MaxIdleConns) + } + if gittr.MaxIdleConnsPerHost != 76 { + t.Fatalf("MaxIdleConnsPerHost = %d, want 76", gittr.MaxIdleConnsPerHost) + } + if gittr.MaxConnsPerHost != 54 { + t.Fatalf("MaxConnsPerHost = %d, want 54", gittr.MaxConnsPerHost) + } +} diff --git a/proxy/match.go b/proxy/match.go index 9353c8b..5988f58 100644 --- a/proxy/match.go +++ b/proxy/match.go @@ -116,11 +116,19 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro // 匹配 "https://raw.githubusercontent.com/" if strings.HasPrefix(rawPath, rawPrefix) { remaining := rawPath[rawPrefixLen:] - parts := strings.SplitN(remaining, "/", 3) - if len(parts) < 3 { + i := strings.IndexByte(remaining, '/') + if i <= 0 { return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: path too short") } - return parts[0], parts[1], "raw", nil + user := remaining[:i] + remaining = remaining[i+1:] + + i = strings.IndexByte(remaining, '/') + if i <= 0 || i == len(remaining)-1 { + return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: path too short") + } + + return user, remaining[:i], "raw", nil } // 匹配 "https://gist.github.com/" 或 "https://gist.githubusercontent.com/" @@ -132,11 +140,16 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro } else { remaining = rawPath[gistContentPrefixLen:] } - parts := strings.SplitN(remaining, "/", 2) - if len(parts) == 0 || parts[0] == "" { + if remaining == "" { return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user") } - return parts[0], "", "gist", nil + if i := strings.IndexByte(remaining, '/'); i != -1 { + if i == 0 { + return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user") + } + remaining = remaining[:i] + } + return remaining, "", "gist", nil } // 匹配 "https://api.github.com/" @@ -147,15 +160,28 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro remaining := rawPath[apiPrefixLen:] var user, repo string if strings.HasPrefix(remaining, "repos/") { - parts := strings.SplitN(remaining[6:], "/", 3) - if len(parts) >= 2 { - user = parts[0] - repo = parts[1] + remaining = remaining[6:] + i := strings.IndexByte(remaining, '/') + if i > 0 { + userCandidate := remaining[:i] + rest := remaining[i+1:] + if rest != "" { + if j := strings.IndexByte(rest, '/'); j != -1 { + repo = rest[:j] + } else { + repo = rest + } + user = userCandidate + } } } else if strings.HasPrefix(remaining, "users/") { - parts := strings.SplitN(remaining[6:], "/", 2) - if len(parts) >= 1 { - user = parts[0] + remaining = remaining[6:] + if remaining != "" { + if i := strings.IndexByte(remaining, '/'); i != -1 { + user = remaining[:i] + } else { + user = remaining + } } } return user, repo, "api", nil diff --git a/proxy/matcher_test.go b/proxy/matcher_test.go index 4f82374..dec623e 100644 --- a/proxy/matcher_test.go +++ b/proxy/matcher_test.go @@ -99,12 +99,24 @@ func TestMatcher_Compatibility(t *testing.T) { config: cfgWithAuth, expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "raw", }, + { + name: "Malformed Raw Path (missing branch)", + rawPath: "https://raw.githubusercontent.com/owner/repo", + config: cfgWithAuth, + expectError: true, expectedErrCode: 400, + }, { name: "Gist Path", rawPath: "https://gist.github.com/user/abcdef1234567890", config: cfgWithAuth, expectedUser: "user", expectedRepo: "", expectedMatcher: "gist", }, + { + name: "Gist Path (user only)", + rawPath: "https://gist.github.com/user", + config: cfgWithAuth, + expectedUser: "user", expectedRepo: "", expectedMatcher: "gist", + }, { name: "Gist UserContent Path", rawPath: "https://gist.githubusercontent.com/user/abcdef1234567890", @@ -135,6 +147,18 @@ func TestMatcher_Compatibility(t *testing.T) { config: cfgApiForceAllowed, // Auth disabled, but force allowed expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "api", }, + { + name: "API Repos Path (missing repo)", + rawPath: "https://api.github.com/repos/owner", + config: cfgWithAuth, + expectedUser: "", expectedRepo: "", expectedMatcher: "api", + }, + { + name: "API Users Path (exact user)", + rawPath: "https://api.github.com/users/someuser", + config: cfgWithAuth, + expectedUser: "someuser", expectedRepo: "", expectedMatcher: "api", + }, { name: "Malformed GH Path (no repo)", rawPath: "https://github.com/owner/", diff --git a/proxy/perf_compare_test.go b/proxy/perf_compare_test.go new file mode 100644 index 0000000..d5ebe86 --- /dev/null +++ b/proxy/perf_compare_test.go @@ -0,0 +1,88 @@ +package proxy + +import ( + "net/http" + "testing" + + "ghproxy/config" + + "github.com/infinite-iroha/touka" +) + +var benchmarkHeaderSource = http.Header{ + "Accept": {"text/plain"}, + "Accept-Encoding": {"gzip"}, + "Connection": {"keep-alive"}, + "User-Agent": {"curl/8.0.1"}, + "X-Test": {"one", "two"}, + "CF-Connecting-IP": {"127.0.0.1"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Transfer-Encoding": {"chunked"}, +} + +func BenchmarkMatcherGithubRelease(b *testing.B) { + cfg := &config.Config{ + Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false}, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _, _, _ = Matcher("https://github.com/WJQSERVER-STUDIO/ghproxy/releases/download/v1.0.0/asset.tar.gz", cfg) + } +} + +func BenchmarkMatcherRaw(b *testing.B) { + cfg := &config.Config{ + Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false}, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _, _, _ = Matcher("https://raw.githubusercontent.com/WJQSERVER-STUDIO/ghproxy/main/README.md", cfg) + } +} + +func BenchmarkMatcherGist(b *testing.B) { + cfg := &config.Config{ + Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false}, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _, _, _ = Matcher("https://gist.githubusercontent.com/user/abcdef1234567890/raw/file.txt", cfg) + } +} + +func BenchmarkMatcherAPI(b *testing.B) { + cfg := &config.Config{ + Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false}, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _, _, _ = Matcher("https://api.github.com/repos/WJQSERVER-STUDIO/ghproxy/releases", cfg) + } +} + +func BenchmarkSetRequestHeadersClone(b *testing.B) { + ctx := &touka.Context{Request: &http.Request{Header: benchmarkHeaderSource}} + cfg := &config.Config{} + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + req := &http.Request{Header: make(http.Header)} + setRequestHeaders(ctx, req, cfg, "clone") + } +} + +func BenchmarkSetRequestHeadersRawCustom(b *testing.B) { + ctx := &touka.Context{Request: &http.Request{Header: benchmarkHeaderSource}} + cfg := &config.Config{} + cfg.Httpc.UseCustomRawHeaders = true + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + req := &http.Request{Header: make(http.Header)} + setRequestHeaders(ctx, req, cfg, "raw") + } +} diff --git a/proxy/reqheader.go b/proxy/reqheader.go index 57d8542..bb20cc9 100644 --- a/proxy/reqheader.go +++ b/proxy/reqheader.go @@ -60,6 +60,17 @@ func copyHeader(dst, src http.Header) { } } +func copyHeaderFiltered(dst, src http.Header, denylist map[string]struct{}) { + for k, vv := range src { + if _, denied := denylist[k]; denied { + continue + } + for _, v := range vv { + dst.Add(k, v) + } + } +} + func setRequestHeaders(c *touka.Context, req *http.Request, cfg *config.Config, matcher string) { if matcher == "raw" && cfg.Httpc.UseCustomRawHeaders { // 使用预定义Header @@ -67,14 +78,8 @@ func setRequestHeaders(c *touka.Context, req *http.Request, cfg *config.Config, req.Header.Set(key, value) } } else if matcher == "clone" { - copyHeader(req.Header, c.Request.Header) - for key := range cloneHeadersToRemove { - req.Header.Del(key) - } + copyHeaderFiltered(req.Header, c.Request.Header, cloneHeadersToRemove) } else { - copyHeader(req.Header, c.Request.Header) - for key := range reqHeadersToRemove { - req.Header.Del(key) - } + copyHeaderFiltered(req.Header, c.Request.Header, reqHeadersToRemove) } } From e9e48fcefdaa875ea342a9c555e21596c7533f07 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 12 Apr 2026 07:17:59 +0800 Subject: [PATCH 2/2] fix(proxy): restore header filtering and API matcher consistency - Canonicalize filtered header deny-lists so Cloudflare and CDN headers are still removed - Normalize incomplete API repo paths to stable owner-level matcher output regardless of trailing slash or query - Add regression tests covering header canonicalization and incomplete API repo path parsing --- proxy/hotpath_test.go | 25 +++++++++++++++++++++++++ proxy/match.go | 23 +++++++++++++++-------- proxy/matcher_test.go | 14 +++++++++++++- proxy/reqheader.go | 20 ++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/proxy/hotpath_test.go b/proxy/hotpath_test.go index d20ac06..8d9b4e9 100644 --- a/proxy/hotpath_test.go +++ b/proxy/hotpath_test.go @@ -65,6 +65,31 @@ func TestCopyHeaderFiltered(t *testing.T) { } } +func TestCopyHeaderFiltered_CanonicalizesDenylist(t *testing.T) { + src := http.Header{ + "Cf-Ipcountry": {"CN"}, + "Cf-Ray": {"abc123"}, + "Cf-Ew-Via": {"edge"}, + "X-Forwarded-For": {"127.0.0.1"}, + } + dst := make(http.Header) + + copyHeaderFiltered(dst, src, reqHeadersToRemove) + + if got := dst.Values("Cf-Ipcountry"); len(got) != 0 { + t.Fatalf("Cf-Ipcountry should be filtered, got %v", got) + } + if got := dst.Values("Cf-Ray"); len(got) != 0 { + t.Fatalf("Cf-Ray should be filtered, got %v", got) + } + if got := dst.Values("Cf-Ew-Via"); len(got) != 0 { + t.Fatalf("Cf-Ew-Via should be filtered, got %v", got) + } + if got := dst.Values("X-Forwarded-For"); !reflect.DeepEqual(got, []string{"127.0.0.1"}) { + t.Fatalf("X-Forwarded-For = %v, want [127.0.0.1]", got) + } +} + func TestCopyHeaderFiltered_AllowsAllWhenDenylistEmpty(t *testing.T) { src := http.Header{ "X-Test": {"one", "two"}, diff --git a/proxy/match.go b/proxy/match.go index 5988f58..29a6cdf 100644 --- a/proxy/match.go +++ b/proxy/match.go @@ -161,21 +161,28 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro var user, repo string if strings.HasPrefix(remaining, "repos/") { remaining = remaining[6:] + if q := strings.IndexByte(remaining, '?'); q != -1 { + remaining = remaining[:q] + } + if remaining != "" && !strings.ContainsRune(remaining, '/') { + user = remaining + return user, "", "api", nil + } i := strings.IndexByte(remaining, '/') if i > 0 { - userCandidate := remaining[:i] + user = remaining[:i] rest := remaining[i+1:] - if rest != "" { - if j := strings.IndexByte(rest, '/'); j != -1 { - repo = rest[:j] - } else { - repo = rest - } - user = userCandidate + if j := strings.IndexByte(rest, '/'); j != -1 { + repo = rest[:j] + } else { + repo = rest } } } else if strings.HasPrefix(remaining, "users/") { remaining = remaining[6:] + if q := strings.IndexByte(remaining, '?'); q != -1 { + remaining = remaining[:q] + } if remaining != "" { if i := strings.IndexByte(remaining, '/'); i != -1 { user = remaining[:i] diff --git a/proxy/matcher_test.go b/proxy/matcher_test.go index dec623e..9a58a7e 100644 --- a/proxy/matcher_test.go +++ b/proxy/matcher_test.go @@ -151,7 +151,19 @@ func TestMatcher_Compatibility(t *testing.T) { name: "API Repos Path (missing repo)", rawPath: "https://api.github.com/repos/owner", config: cfgWithAuth, - expectedUser: "", expectedRepo: "", expectedMatcher: "api", + expectedUser: "owner", expectedRepo: "", expectedMatcher: "api", + }, + { + name: "API Repos Path (trailing slash)", + rawPath: "https://api.github.com/repos/owner/", + config: cfgWithAuth, + expectedUser: "owner", expectedRepo: "", expectedMatcher: "api", + }, + { + name: "API Repos Path (missing repo with query)", + rawPath: "https://api.github.com/repos/owner?per_page=1", + config: cfgWithAuth, + expectedUser: "owner", expectedRepo: "", expectedMatcher: "api", }, { name: "API Users Path (exact user)", diff --git a/proxy/reqheader.go b/proxy/reqheader.go index bb20cc9..dce4e2f 100644 --- a/proxy/reqheader.go +++ b/proxy/reqheader.go @@ -60,6 +60,26 @@ func copyHeader(dst, src http.Header) { } } +func canonicalizeHeaderSet(headers map[string]struct{}) map[string]struct{} { + canonicalized := make(map[string]struct{}, len(headers)) + for key := range headers { + canonicalized[http.CanonicalHeaderKey(key)] = struct{}{} + } + return canonicalized +} + +func init() { + reqHeadersToRemove = canonicalizeHeaderSet(reqHeadersToRemove) + cloneHeadersToRemove = canonicalizeHeaderSet(cloneHeadersToRemove) + respHeadersToRemove = canonicalizeHeaderSet(respHeadersToRemove) + defaultHeaders = map[string]string{ + "Accept": "*/*", + "Accept-Encoding": "", + "Transfer-Encoding": "chunked", + "User-Agent": "GHProxy/1.0", + } +} + func copyHeaderFiltered(dst, src http.Header, denylist map[string]struct{}) { for k, vv := range src { if _, denied := denylist[k]; denied {