From 40f847b9441cb770dde7fa4b0cbc7d2d5f9ad9ff Mon Sep 17 00:00:00 2001 From: LUKIEYF <103362370+LUKIEYF@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:41:48 +0800 Subject: [PATCH] Add Header User-Agent for request client (#5664) add Header User-Agent for request client for more precise in recognized the http request from. close #3778 --- .cspell.json | 1 + cli/internal/util.go | 9 +- pipeline/backend/docker/docker.go | 5 +- server/forge/bitbucket/bitbucket.go | 5 +- .../bitbucketdatacenter.go | 2 + server/forge/forgejo/forgejo.go | 6 +- server/forge/gitea/gitea.go | 6 +- server/forge/github/github.go | 20 ++- server/forge/gitlab/helper.go | 12 +- server/services/utils/http.go | 13 +- shared/httputil/useragent.go | 72 ++++++++ shared/httputil/useragent_test.go | 169 ++++++++++++++++++ woodpecker-go/woodpecker/client.go | 8 +- .../woodpecker/httputil/useragent.go | 72 ++++++++ .../woodpecker/httputil/useragent_test.go | 169 ++++++++++++++++++ 15 files changed, 546 insertions(+), 23 deletions(-) create mode 100644 shared/httputil/useragent.go create mode 100644 shared/httputil/useragent_test.go create mode 100644 woodpecker-go/woodpecker/httputil/useragent.go create mode 100644 woodpecker-go/woodpecker/httputil/useragent_test.go diff --git a/.cspell.json b/.cspell.json index 2903aef49..b13fa591f 100644 --- a/.cspell.json +++ b/.cspell.json @@ -208,6 +208,7 @@ "unsanitize", "Upsert", "urfave", + "useragent", "usecase", "varchar", "varz", diff --git a/cli/internal/util.go b/cli/internal/util.go index e1367dfd5..bcf61a29f 100644 --- a/cli/internal/util.go +++ b/cli/internal/util.go @@ -30,6 +30,7 @@ import ( "golang.org/x/net/proxy" "golang.org/x/oauth2" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) @@ -72,23 +73,27 @@ func NewClient(ctx context.Context, c *cli.Command) (woodpecker.Client, error) { trans, _ := client.Transport.(*oauth2.Transport) + var baseTransport http.RoundTripper if len(socks) != 0 && !socksOff { dialer, err := proxy.SOCKS5("tcp", socks, nil, proxy.Direct) if err != nil { return nil, err } - trans.Base = &http.Transport{ + baseTransport = &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, Dial: dialer.Dial, } } else { - trans.Base = &http.Transport{ + baseTransport = &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, } } + // Wrap the base transport with User-Agent support + trans.Base = httputil.NewUserAgentRoundTripper(baseTransport, "cli") + return woodpecker.NewClient(server, client), nil } diff --git a/pipeline/backend/docker/docker.go b/pipeline/backend/docker/docker.go index e3b4cad85..8a48fdd72 100644 --- a/pipeline/backend/docker/docker.go +++ b/pipeline/backend/docker/docker.go @@ -37,6 +37,7 @@ import ( "github.com/urfave/cli/v3" backend "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) @@ -92,7 +93,9 @@ func httpClientOfOpts(dockerCertPath string, verifyTLS bool) *http.Client { } return &http.Client{ - Transport: &http.Transport{TLSClientConfig: tlsConf}, + Transport: httputil.NewUserAgentRoundTripper( + &http.Transport{TLSClientConfig: tlsConf}, + "backend-docker"), CheckRedirect: client.CheckRedirect, } } diff --git a/server/forge/bitbucket/bitbucket.go b/server/forge/bitbucket/bitbucket.go index 4a61520d4..51be3385b 100644 --- a/server/forge/bitbucket/bitbucket.go +++ b/server/forge/bitbucket/bitbucket.go @@ -32,6 +32,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) @@ -449,7 +450,7 @@ func (c *config) newClient(ctx context.Context, u *model.User) *internal.Client // helper function to return the bitbucket oauth2 client. func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken string) *internal.Client { - return internal.NewClientToken( + client := internal.NewClientToken( ctx, c.api, accessToken, @@ -459,6 +460,8 @@ func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken s RefreshToken: refreshToken, }, ) + client.Client = httputil.WrapClient(client.Client, "forge-bitbucket") + return client } // helper function to return the bitbucket oauth2 config. diff --git a/server/forge/bitbucketdatacenter/bitbucketdatacenter.go b/server/forge/bitbucketdatacenter/bitbucketdatacenter.go index 3b18a7412..063534e1a 100644 --- a/server/forge/bitbucketdatacenter/bitbucketdatacenter.go +++ b/server/forge/bitbucketdatacenter/bitbucketdatacenter.go @@ -34,6 +34,7 @@ import ( forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) const ( @@ -768,5 +769,6 @@ func (c *client) newClient(ctx context.Context, u *model.User) (*bb.Client, erro AccessToken: u.AccessToken, } client := config.Client(ctx, t) + client = httputil.WrapClient(client, "forge-bitbucketdatacenter") return bb.NewClient(c.urlAPI, client) } diff --git a/server/forge/forgejo/forgejo.go b/server/forge/forgejo/forgejo.go index ad8a64477..68b227741 100644 --- a/server/forge/forgejo/forgejo.go +++ b/server/forge/forgejo/forgejo.go @@ -34,6 +34,7 @@ import ( forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) @@ -586,12 +587,13 @@ func (c *Forgejo) newClientToken(ctx context.Context, token string) (*forgejo.Cl TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } - client, err := forgejo.NewClient(c.url, forgejo.SetToken(token), forgejo.SetHTTPClient(httpClient), forgejo.SetContext(ctx)) + wrappedClient := httputil.WrapClient(httpClient, "forge-forgejo") + client, err := forgejo.NewClient(c.url, forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx)) if err != nil && (errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { // we guess it's a dev forgejo version log.Error().Err(err).Msgf("could not detect forgejo version, assume dev version %s", forgejoDevVersion) - client, err = forgejo.NewClient(c.url, forgejo.SetForgejoVersion(forgejoDevVersion), forgejo.SetToken(token), forgejo.SetHTTPClient(httpClient), forgejo.SetContext(ctx)) + client, err = forgejo.NewClient(c.url, forgejo.SetForgejoVersion(forgejoDevVersion), forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx)) } return client, err } diff --git a/server/forge/gitea/gitea.go b/server/forge/gitea/gitea.go index 5d89d2d3b..d8076c9b5 100644 --- a/server/forge/gitea/gitea.go +++ b/server/forge/gitea/gitea.go @@ -36,6 +36,7 @@ import ( forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) @@ -593,12 +594,13 @@ func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } - client, err := gitea.NewClient(c.url, gitea.SetToken(token), gitea.SetHTTPClient(httpClient), gitea.SetContext(ctx)) + wrappedClient := httputil.WrapClient(httpClient, "forge-gitea") + client, err := gitea.NewClient(c.url, gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx)) if err != nil && (errors.Is(err, &gitea.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { // we guess it's a dev gitea version log.Error().Err(err).Msgf("could not detect gitea version, assume dev version %s", giteaDevVersion) - client, err = gitea.NewClient(c.url, gitea.SetGiteaVersion(giteaDevVersion), gitea.SetToken(token), gitea.SetHTTPClient(httpClient), gitea.SetContext(ctx)) + client, err = gitea.NewClient(c.url, gitea.SetGiteaVersion(giteaDevVersion), gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx)) } return client, err } diff --git a/server/forge/github/github.go b/server/forge/github/github.go index 9ae9846e2..354f3f2f9 100644 --- a/server/forge/github/github.go +++ b/server/forge/github/github.go @@ -37,6 +37,7 @@ import ( forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) @@ -469,15 +470,22 @@ func (c *client) newClientToken(ctx context.Context, token string) *github.Clien &oauth2.Token{AccessToken: token}, ) tc := oauth2.NewClient(ctx, ts) + + // Get the oauth2 transport to set custom base + tp, _ := tc.Transport.(*oauth2.Transport) + + baseTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + } if c.SkipVerify { - tp, _ := tc.Transport.(*oauth2.Transport) - tp.Base = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, + baseTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, } } + + // Wrap the base transport with User-Agent support + tp.Base = httputil.NewUserAgentRoundTripper(baseTransport, "forge-github") + client := github.NewClient(tc) client.BaseURL, _ = url.Parse(c.API) return client diff --git a/server/forge/gitlab/helper.go b/server/forge/gitlab/helper.go index f6b669bc6..26d6c0846 100644 --- a/server/forge/gitlab/helper.go +++ b/server/forge/gitlab/helper.go @@ -21,6 +21,8 @@ import ( gitlab "gitlab.com/gitlab-org/api/client-go" "golang.org/x/oauth2" + + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) const ( @@ -33,10 +35,12 @@ func newClient(url, accessToken string, skipVerify bool) (*gitlab.Client, error) return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{ TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), }, gitlab.WithBaseURL(url), gitlab.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, - Proxy: http.ProxyFromEnvironment, - }, + Transport: httputil.NewUserAgentRoundTripper( + &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, + Proxy: http.ProxyFromEnvironment, + }, + "forge-gitlab"), })) } diff --git a/server/services/utils/http.go b/server/services/utils/http.go index 60eb158d4..711b665b6 100644 --- a/server/services/utils/http.go +++ b/server/services/utils/http.go @@ -30,6 +30,7 @@ import ( "github.com/yaronf/httpsign" host_matcher "go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher" + "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) type Client struct { @@ -58,12 +59,18 @@ func getHTTPClient(privateKey crypto.PrivateKey, allowedHostListValue string) (* return nil, err } - client := http.Client{ - Timeout: timeout, - Transport: &http.Transport{ + // Create base transport with custom User-Agent + baseTransport := httputil.NewUserAgentRoundTripper( + &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, DialContext: host_matcher.NewDialContext("extensions", allowedHostMatcher), }, + "server-extensions", + ) + + client := http.Client{ + Timeout: timeout, + Transport: baseTransport, } config := httpsign.NewClientConfig().SetSignatureName(pubKeyID).SetSigner(signer) diff --git a/shared/httputil/useragent.go b/shared/httputil/useragent.go new file mode 100644 index 000000000..63ad2bc20 --- /dev/null +++ b/shared/httputil/useragent.go @@ -0,0 +1,72 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httputil + +import ( + "fmt" + "net/http" + + "go.woodpecker-ci.org/woodpecker/v3/version" +) + +// UserAgentRoundTripper is an http.RoundTripper that sets a custom User-Agent header +// on all outgoing requests. +type UserAgentRoundTripper struct { + base http.RoundTripper + userAgent string +} + +// NewUserAgentRoundTripper creates a new RoundTripper that adds the Woodpecker User-Agent +// to all requests. If base is nil, http.DefaultTransport is used. +func NewUserAgentRoundTripper(base http.RoundTripper, component string) *UserAgentRoundTripper { + if base == nil { + base = http.DefaultTransport + } + + userAgent := fmt.Sprintf("Woodpecker/%s", version.String()) + if component != "" { + userAgent = fmt.Sprintf("%s (%s)", userAgent, component) + } + + return &UserAgentRoundTripper{ + base: base, + userAgent: userAgent, + } +} + +// RoundTrip implements the http.RoundTripper interface. +func (rt *UserAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + reqClone := req.Clone(req.Context()) + + // Set the User-Agent header if not already set + if reqClone.Header.Get("User-Agent") == "" { + reqClone.Header.Set("User-Agent", rt.userAgent) + } + + // Execute the request using the base transport + return rt.base.RoundTrip(reqClone) +} + +// WrapClient wraps an existing http.Client with the UserAgentRoundTripper. +// If client is nil, a new client with default settings is created. +func WrapClient(client *http.Client, component string) *http.Client { + if client == nil { + client = &http.Client{} + } + + client.Transport = NewUserAgentRoundTripper(client.Transport, component) + return client +} diff --git a/shared/httputil/useragent_test.go b/shared/httputil/useragent_test.go new file mode 100644 index 000000000..2df18b171 --- /dev/null +++ b/shared/httputil/useragent_test.go @@ -0,0 +1,169 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httputil + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.woodpecker-ci.org/woodpecker/v3/version" +) + +func TestNewUserAgentRoundTripper(t *testing.T) { + t.Run("with custom component", func(t *testing.T) { + rt := NewUserAgentRoundTripper(nil, "test-component") + assert.NotNil(t, rt) + assert.NotNil(t, rt.base) + expectedUA := fmt.Sprintf("Woodpecker/%s (test-component)", version.String()) + assert.Equal(t, expectedUA, rt.userAgent) + }) + + t.Run("without component", func(t *testing.T) { + rt := NewUserAgentRoundTripper(nil, "") + assert.NotNil(t, rt) + expectedUA := fmt.Sprintf("Woodpecker/%s", version.String()) + assert.Equal(t, expectedUA, rt.userAgent) + }) + + t.Run("with custom base transport", func(t *testing.T) { + customTransport := &http.Transport{} + rt := NewUserAgentRoundTripper(customTransport, "custom") + assert.Equal(t, customTransport, rt.base) + }) +} + +func TestUserAgentRoundTripper_RoundTrip(t *testing.T) { + // Create a test server to capture requests + var capturedUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + })) + defer server.Close() + + t.Run("sets user-agent when not present", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "agent"), + } + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + expectedUA := fmt.Sprintf("Woodpecker/%s (agent)", version.String()) + assert.Equal(t, expectedUA, capturedUserAgent) + }) + + t.Run("preserves existing user-agent", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "agent"), + } + + customUA := "CustomUserAgent/1.0" + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + req.Header.Set("User-Agent", customUA) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, customUA, capturedUserAgent) + }) + + t.Run("does not modify original request", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "test"), + } + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + originalUserAgent := req.Header.Get("User-Agent") + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + // Original request should remain unchanged + assert.Equal(t, originalUserAgent, req.Header.Get("User-Agent")) + }) +} + +func TestWrapClient(t *testing.T) { + t.Run("wraps existing client", func(t *testing.T) { + originalClient := &http.Client{} + wrappedClient := WrapClient(originalClient, "cli") + + assert.Equal(t, originalClient, wrappedClient) + assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) + }) + + t.Run("creates new client when nil", func(t *testing.T) { + wrappedClient := WrapClient(nil, "server") + + assert.NotNil(t, wrappedClient) + assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) + }) + + t.Run("preserves existing transport", func(t *testing.T) { + customTransport := &http.Transport{} + originalClient := &http.Client{ + Transport: customTransport, + } + + wrappedClient := WrapClient(originalClient, "test") + + rt, ok := wrappedClient.Transport.(*UserAgentRoundTripper) + assert.True(t, ok) + assert.Equal(t, customTransport, rt.base) + }) +} + +func TestIntegration_UserAgentInRealRequest(t *testing.T) { + // Test with a real HTTP server + var receivedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := WrapClient(nil, "integration-test") + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + userAgent := receivedHeaders.Get("User-Agent") + assert.NotEmpty(t, userAgent) + assert.Contains(t, userAgent, "Woodpecker/") + assert.Contains(t, userAgent, "(integration-test)") +} diff --git a/woodpecker-go/woodpecker/client.go b/woodpecker-go/woodpecker/client.go index 8871f654a..e8e15e8ae 100644 --- a/woodpecker-go/woodpecker/client.go +++ b/woodpecker-go/woodpecker/client.go @@ -23,6 +23,8 @@ import ( "net/url" "strconv" "strings" + + "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/httputil" ) const ( @@ -50,12 +52,14 @@ type client struct { // New returns a client at the specified url. func New(uri string) Client { - return &client{http.DefaultClient, strings.TrimSuffix(uri, "/")} + wrappedClient := httputil.WrapClient(http.DefaultClient, "go-client") + return &client{wrappedClient, strings.TrimSuffix(uri, "/")} } // NewClient returns a client at the specified url. func NewClient(uri string, cli *http.Client) Client { - return &client{cli, strings.TrimSuffix(uri, "/")} + wrappedClient := httputil.WrapClient(cli, "go-client") + return &client{wrappedClient, strings.TrimSuffix(uri, "/")} } // SetClient sets the http.Client. diff --git a/woodpecker-go/woodpecker/httputil/useragent.go b/woodpecker-go/woodpecker/httputil/useragent.go new file mode 100644 index 000000000..63ad2bc20 --- /dev/null +++ b/woodpecker-go/woodpecker/httputil/useragent.go @@ -0,0 +1,72 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httputil + +import ( + "fmt" + "net/http" + + "go.woodpecker-ci.org/woodpecker/v3/version" +) + +// UserAgentRoundTripper is an http.RoundTripper that sets a custom User-Agent header +// on all outgoing requests. +type UserAgentRoundTripper struct { + base http.RoundTripper + userAgent string +} + +// NewUserAgentRoundTripper creates a new RoundTripper that adds the Woodpecker User-Agent +// to all requests. If base is nil, http.DefaultTransport is used. +func NewUserAgentRoundTripper(base http.RoundTripper, component string) *UserAgentRoundTripper { + if base == nil { + base = http.DefaultTransport + } + + userAgent := fmt.Sprintf("Woodpecker/%s", version.String()) + if component != "" { + userAgent = fmt.Sprintf("%s (%s)", userAgent, component) + } + + return &UserAgentRoundTripper{ + base: base, + userAgent: userAgent, + } +} + +// RoundTrip implements the http.RoundTripper interface. +func (rt *UserAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + reqClone := req.Clone(req.Context()) + + // Set the User-Agent header if not already set + if reqClone.Header.Get("User-Agent") == "" { + reqClone.Header.Set("User-Agent", rt.userAgent) + } + + // Execute the request using the base transport + return rt.base.RoundTrip(reqClone) +} + +// WrapClient wraps an existing http.Client with the UserAgentRoundTripper. +// If client is nil, a new client with default settings is created. +func WrapClient(client *http.Client, component string) *http.Client { + if client == nil { + client = &http.Client{} + } + + client.Transport = NewUserAgentRoundTripper(client.Transport, component) + return client +} diff --git a/woodpecker-go/woodpecker/httputil/useragent_test.go b/woodpecker-go/woodpecker/httputil/useragent_test.go new file mode 100644 index 000000000..2df18b171 --- /dev/null +++ b/woodpecker-go/woodpecker/httputil/useragent_test.go @@ -0,0 +1,169 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httputil + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.woodpecker-ci.org/woodpecker/v3/version" +) + +func TestNewUserAgentRoundTripper(t *testing.T) { + t.Run("with custom component", func(t *testing.T) { + rt := NewUserAgentRoundTripper(nil, "test-component") + assert.NotNil(t, rt) + assert.NotNil(t, rt.base) + expectedUA := fmt.Sprintf("Woodpecker/%s (test-component)", version.String()) + assert.Equal(t, expectedUA, rt.userAgent) + }) + + t.Run("without component", func(t *testing.T) { + rt := NewUserAgentRoundTripper(nil, "") + assert.NotNil(t, rt) + expectedUA := fmt.Sprintf("Woodpecker/%s", version.String()) + assert.Equal(t, expectedUA, rt.userAgent) + }) + + t.Run("with custom base transport", func(t *testing.T) { + customTransport := &http.Transport{} + rt := NewUserAgentRoundTripper(customTransport, "custom") + assert.Equal(t, customTransport, rt.base) + }) +} + +func TestUserAgentRoundTripper_RoundTrip(t *testing.T) { + // Create a test server to capture requests + var capturedUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + })) + defer server.Close() + + t.Run("sets user-agent when not present", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "agent"), + } + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + expectedUA := fmt.Sprintf("Woodpecker/%s (agent)", version.String()) + assert.Equal(t, expectedUA, capturedUserAgent) + }) + + t.Run("preserves existing user-agent", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "agent"), + } + + customUA := "CustomUserAgent/1.0" + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + req.Header.Set("User-Agent", customUA) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, customUA, capturedUserAgent) + }) + + t.Run("does not modify original request", func(t *testing.T) { + client := &http.Client{ + Transport: NewUserAgentRoundTripper(nil, "test"), + } + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + originalUserAgent := req.Header.Get("User-Agent") + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + // Original request should remain unchanged + assert.Equal(t, originalUserAgent, req.Header.Get("User-Agent")) + }) +} + +func TestWrapClient(t *testing.T) { + t.Run("wraps existing client", func(t *testing.T) { + originalClient := &http.Client{} + wrappedClient := WrapClient(originalClient, "cli") + + assert.Equal(t, originalClient, wrappedClient) + assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) + }) + + t.Run("creates new client when nil", func(t *testing.T) { + wrappedClient := WrapClient(nil, "server") + + assert.NotNil(t, wrappedClient) + assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) + }) + + t.Run("preserves existing transport", func(t *testing.T) { + customTransport := &http.Transport{} + originalClient := &http.Client{ + Transport: customTransport, + } + + wrappedClient := WrapClient(originalClient, "test") + + rt, ok := wrappedClient.Transport.(*UserAgentRoundTripper) + assert.True(t, ok) + assert.Equal(t, customTransport, rt.base) + }) +} + +func TestIntegration_UserAgentInRealRequest(t *testing.T) { + // Test with a real HTTP server + var receivedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := WrapClient(nil, "integration-test") + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + userAgent := receivedHeaders.Get("User-Agent") + assert.NotEmpty(t, userAgent) + assert.Contains(t, userAgent, "Woodpecker/") + assert.Contains(t, userAgent, "(integration-test)") +}