You've already forked woodpecker
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:
@@ -208,6 +208,7 @@
|
||||
"unsanitize",
|
||||
"Upsert",
|
||||
"urfave",
|
||||
"useragent",
|
||||
"usecase",
|
||||
"varchar",
|
||||
"varz",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
72
shared/httputil/useragent.go
Normal file
72
shared/httputil/useragent.go
Normal 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
|
||||
}
|
||||
169
shared/httputil/useragent_test.go
Normal file
169
shared/httputil/useragent_test.go
Normal 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)")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
72
woodpecker-go/woodpecker/httputil/useragent.go
Normal file
72
woodpecker-go/woodpecker/httputil/useragent.go
Normal 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
|
||||
}
|
||||
169
woodpecker-go/woodpecker/httputil/useragent_test.go
Normal file
169
woodpecker-go/woodpecker/httputil/useragent_test.go
Normal 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)")
|
||||
}
|
||||
Reference in New Issue
Block a user