1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2025-11-23 21:44:44 +02:00

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
This commit is contained in:
LUKIEYF
2025-11-05 18:41:48 +08:00
committed by GitHub
parent 2cabcc5fe5
commit 40f847b944
15 changed files with 546 additions and 23 deletions

View File

@@ -208,6 +208,7 @@
"unsanitize", "unsanitize",
"Upsert", "Upsert",
"urfave", "urfave",
"useragent",
"usecase", "usecase",
"varchar", "varchar",
"varz", "varz",

View File

@@ -30,6 +30,7 @@ import (
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" "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) trans, _ := client.Transport.(*oauth2.Transport)
var baseTransport http.RoundTripper
if len(socks) != 0 && !socksOff { if len(socks) != 0 && !socksOff {
dialer, err := proxy.SOCKS5("tcp", socks, nil, proxy.Direct) dialer, err := proxy.SOCKS5("tcp", socks, nil, proxy.Direct)
if err != nil { if err != nil {
return nil, err return nil, err
} }
trans.Base = &http.Transport{ baseTransport = &http.Transport{
TLSClientConfig: tlsConfig, TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
Dial: dialer.Dial, Dial: dialer.Dial,
} }
} else { } else {
trans.Base = &http.Transport{ baseTransport = &http.Transport{
TLSClientConfig: tlsConfig, TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
} }
} }
// Wrap the base transport with User-Agent support
trans.Base = httputil.NewUserAgentRoundTripper(baseTransport, "cli")
return woodpecker.NewClient(server, client), nil return woodpecker.NewClient(server, client), nil
} }

View File

@@ -37,6 +37,7 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
backend "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" 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" "go.woodpecker-ci.org/woodpecker/v3/shared/utils"
) )
@@ -92,7 +93,9 @@ func httpClientOfOpts(dockerCertPath string, verifyTLS bool) *http.Client {
} }
return &http.Client{ return &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsConf}, Transport: httputil.NewUserAgentRoundTripper(
&http.Transport{TLSClientConfig: tlsConf},
"backend-docker"),
CheckRedirect: client.CheckRedirect, CheckRedirect: client.CheckRedirect,
} }
} }

View File

@@ -32,6 +32,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common"
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" 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/model"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" 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. // helper function to return the bitbucket oauth2 client.
func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken string) *internal.Client { func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken string) *internal.Client {
return internal.NewClientToken( client := internal.NewClientToken(
ctx, ctx,
c.api, c.api,
accessToken, accessToken,
@@ -459,6 +460,8 @@ func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken s
RefreshToken: refreshToken, RefreshToken: refreshToken,
}, },
) )
client.Client = httputil.WrapClient(client.Client, "forge-bitbucket")
return client
} }
// helper function to return the bitbucket oauth2 config. // helper function to return the bitbucket oauth2 config.

View File

@@ -34,6 +34,7 @@ import (
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" 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/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
) )
const ( const (
@@ -768,5 +769,6 @@ func (c *client) newClient(ctx context.Context, u *model.User) (*bb.Client, erro
AccessToken: u.AccessToken, AccessToken: u.AccessToken,
} }
client := config.Client(ctx, t) client := config.Client(ctx, t)
client = httputil.WrapClient(client, "forge-bitbucketdatacenter")
return bb.NewClient(c.urlAPI, client) return bb.NewClient(c.urlAPI, client)
} }

View File

@@ -34,6 +34,7 @@ import (
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" 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/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store" "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" 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}, 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 && if err != nil &&
(errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { (errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) {
// we guess it's a dev forgejo version // we guess it's a dev forgejo version
log.Error().Err(err).Msgf("could not detect forgejo version, assume dev version %s", forgejoDevVersion) 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 return client, err
} }

View File

@@ -36,6 +36,7 @@ import (
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" 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/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store" "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" 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}, 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 && if err != nil &&
(errors.Is(err, &gitea.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { (errors.Is(err, &gitea.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) {
// we guess it's a dev gitea version // we guess it's a dev gitea version
log.Error().Err(err).Msgf("could not detect gitea version, assume dev version %s", giteaDevVersion) 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 return client, err
} }

View File

@@ -37,6 +37,7 @@ import (
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" 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/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
"go.woodpecker-ci.org/woodpecker/v3/shared/utils" "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}, &oauth2.Token{AccessToken: token},
) )
tc := oauth2.NewClient(ctx, ts) 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 { if c.SkipVerify {
tp, _ := tc.Transport.(*oauth2.Transport) baseTransport.TLSClientConfig = &tls.Config{
tp.Base = &http.Transport{ InsecureSkipVerify: true,
Proxy: http.ProxyFromEnvironment,
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 := github.NewClient(tc)
client.BaseURL, _ = url.Parse(c.API) client.BaseURL, _ = url.Parse(c.API)
return client return client

View File

@@ -21,6 +21,8 @@ import (
gitlab "gitlab.com/gitlab-org/api/client-go" gitlab "gitlab.com/gitlab-org/api/client-go"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
) )
const ( const (
@@ -33,10 +35,12 @@ func newClient(url, accessToken string, skipVerify bool) (*gitlab.Client, error)
return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{ return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{
TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
}, gitlab.WithBaseURL(url), gitlab.WithHTTPClient(&http.Client{ }, gitlab.WithBaseURL(url), gitlab.WithHTTPClient(&http.Client{
Transport: &http.Transport{ Transport: httputil.NewUserAgentRoundTripper(
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, &http.Transport{
Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify},
}, Proxy: http.ProxyFromEnvironment,
},
"forge-gitlab"),
})) }))
} }

View File

@@ -30,6 +30,7 @@ import (
"github.com/yaronf/httpsign" "github.com/yaronf/httpsign"
host_matcher "go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher" host_matcher "go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher"
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
) )
type Client struct { type Client struct {
@@ -58,12 +59,18 @@ func getHTTPClient(privateKey crypto.PrivateKey, allowedHostListValue string) (*
return nil, err return nil, err
} }
client := http.Client{ // Create base transport with custom User-Agent
Timeout: timeout, baseTransport := httputil.NewUserAgentRoundTripper(
Transport: &http.Transport{ &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: host_matcher.NewDialContext("extensions", allowedHostMatcher), DialContext: host_matcher.NewDialContext("extensions", allowedHostMatcher),
}, },
"server-extensions",
)
client := http.Client{
Timeout: timeout,
Transport: baseTransport,
} }
config := httpsign.NewClientConfig().SetSignatureName(pubKeyID).SetSigner(signer) config := httpsign.NewClientConfig().SetSignatureName(pubKeyID).SetSigner(signer)

View File

@@ -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
}

View File

@@ -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)")
}

View File

@@ -23,6 +23,8 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/httputil"
) )
const ( const (
@@ -50,12 +52,14 @@ type client struct {
// New returns a client at the specified url. // New returns a client at the specified url.
func New(uri string) Client { 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. // NewClient returns a client at the specified url.
func NewClient(uri string, cli *http.Client) Client { 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. // SetClient sets the http.Client.

View File

@@ -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
}

View File

@@ -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)")
}