Skip to content

Commit 6c45bc9

Browse files
committed
Add custom user agent for every http request
JIRA:LMCROSSITXSADEPLOY-2854 Increase user agent suffix length to 128 characters
1 parent 1773fa0 commit 6c45bc9

13 files changed

+677
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ The configuration of the MultiApps CF plugin is done via env variables. The foll
7171
For example, with a 100MB MTAR the minimum value for this environment variable would be 2, and for a 400MB MTAR it would be 8. Finally, the minimum value cannot grow over 50, so with a 4GB MTAR, the minimum value would be 50 and not 80.
7272
* `MULTIAPPS_UPLOAD_CHUNKS_SEQUENTIALLY=<BOOLEAN>` - By default, MTAR chunks are uploaded in parallel for better performance. In case of a bad internet connection, the option to upload them sequentially will lessen network load.
7373
* `MULTIAPPS_DISABLE_UPLOAD_PROGRESS_BAR=<BOOLEAN>` - By default, the file upload shows a progress bar. In case of CI/CD systems where console text escaping isn't supported, the bar can be disabled to reduce unnecessary logs.
74+
* `MULTIAPPS_USER_AGENT_SUFFIX=<STRING>` - Allows customization of the User-Agent header sent with all HTTP requests. The value will be appended to the standard User-Agent string format: "Multiapps-CF-plugin/{version} ({operating system version}) {golang builder version} {custom_value}". Only alphanumeric characters, spaces, hyphens, dots, and underscores are allowed. Maximum length is 128 characters; longer values will be truncated. Dangerous characters (control characters, colons, semicolons) are automatically removed for security. This can be useful for tracking requests from specific environments or CI/CD systems.
7475

7576
# How to contribute
7677
* [Did you find a bug?](CONTRIBUTING.md#did-you-find-a-bug)

clients/baseclient/base_client.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ func NewHTTPTransport(host, url string, rt http.RoundTripper) *client.Runtime {
3030
// TODO: apply the changes made by Boyan here, as after the update of the dependencies the changes are not available
3131
transport := client.New(host, url, schemes)
3232
transport.Consumers["text/html"] = runtime.TextConsumer()
33-
transport.Transport = rt
33+
34+
// Wrap the RoundTripper with User-Agent support
35+
userAgentTransport := NewUserAgentTransport(rt)
36+
transport.Transport = userAgentTransport
37+
3438
jar, _ := cookiejar.New(nil)
3539
transport.Jar = jar
3640
return transport
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package baseclient
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
7+
. "github.com/onsi/ginkgo"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
var _ = Describe("BaseClient", func() {
12+
13+
Describe("NewHTTPTransport", func() {
14+
Context("when creating transport with User-Agent functionality", func() {
15+
var server *httptest.Server
16+
var capturedHeaders http.Header
17+
18+
BeforeEach(func() {
19+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
capturedHeaders = r.Header
21+
w.WriteHeader(http.StatusOK)
22+
}))
23+
})
24+
25+
AfterEach(func() {
26+
if server != nil {
27+
server.Close()
28+
}
29+
})
30+
31+
It("should include User-Agent header in requests", func() {
32+
transport := NewHTTPTransport(server.URL, "/", nil)
33+
34+
req, err := http.NewRequest("GET", server.URL+"/test", nil)
35+
Expect(err).ToNot(HaveOccurred())
36+
37+
_, err = transport.Transport.RoundTrip(req)
38+
Expect(err).ToNot(HaveOccurred())
39+
40+
userAgent := capturedHeaders.Get("User-Agent")
41+
Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set")
42+
Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'")
43+
})
44+
})
45+
46+
Context("when custom round tripper is provided", func() {
47+
It("should preserve the custom round tripper as base transport", func() {
48+
customTransport := &mockRoundTripper{}
49+
50+
transport := NewHTTPTransport("example.com", "/", customTransport)
51+
52+
userAgentTransport, ok := transport.Transport.(*UserAgentTransport)
53+
Expect(ok).To(BeTrue(), "Expected transport to be wrapped with UserAgentTransport")
54+
Expect(userAgentTransport.Base).To(Equal(customTransport), "Expected custom round tripper to be preserved as base transport")
55+
})
56+
})
57+
})
58+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package baseclient
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/util"
7+
)
8+
9+
// UserAgentTransport wraps an existing RoundTripper and adds User-Agent header
10+
type UserAgentTransport struct {
11+
Base http.RoundTripper
12+
UserAgent string
13+
}
14+
15+
// NewUserAgentTransport creates a new transport with User-Agent header support
16+
func NewUserAgentTransport(base http.RoundTripper) *UserAgentTransport {
17+
if base == nil {
18+
base = http.DefaultTransport
19+
}
20+
21+
return &UserAgentTransport{
22+
Base: base,
23+
UserAgent: util.BuildUserAgent(),
24+
}
25+
}
26+
27+
// RoundTrip implements the RoundTripper interface
28+
func (uat *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
29+
// Clone the request to avoid modifying the original
30+
reqCopy := req.Clone(req.Context())
31+
32+
// Add or override the User-Agent header
33+
reqCopy.Header.Set("User-Agent", uat.UserAgent)
34+
35+
// Execute the request with the base transport
36+
return uat.Base.RoundTrip(reqCopy)
37+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package baseclient
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
7+
. "github.com/onsi/ginkgo"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
// mockRoundTripper for testing
12+
type mockRoundTripper struct {
13+
lastRequest *http.Request
14+
}
15+
16+
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
17+
m.lastRequest = req
18+
return &http.Response{
19+
StatusCode: 200,
20+
Header: make(http.Header),
21+
Body: http.NoBody,
22+
}, nil
23+
}
24+
25+
var _ = Describe("UserAgentTransport", func() {
26+
27+
Describe("NewUserAgentTransport", func() {
28+
Context("when base transport is nil", func() {
29+
It("should use http.DefaultTransport as base", func() {
30+
transport := NewUserAgentTransport(nil)
31+
32+
Expect(transport.Base).To(Equal(http.DefaultTransport))
33+
})
34+
35+
It("should set User-Agent with correct prefix", func() {
36+
transport := NewUserAgentTransport(nil)
37+
38+
Expect(transport.UserAgent).ToNot(BeEmpty())
39+
Expect(transport.UserAgent).To(HavePrefix("Multiapps-CF-plugin/"))
40+
})
41+
})
42+
43+
Context("when custom base transport is provided", func() {
44+
It("should use the provided transport as base", func() {
45+
mockTransport := &mockRoundTripper{}
46+
transport := NewUserAgentTransport(mockTransport)
47+
48+
Expect(transport.Base).To(Equal(mockTransport))
49+
})
50+
})
51+
})
52+
53+
Describe("RoundTrip", func() {
54+
var mockTransport *mockRoundTripper
55+
var userAgentTransport *UserAgentTransport
56+
57+
BeforeEach(func() {
58+
mockTransport = &mockRoundTripper{}
59+
userAgentTransport = NewUserAgentTransport(mockTransport)
60+
})
61+
62+
Context("when making a request", func() {
63+
var req *http.Request
64+
65+
BeforeEach(func() {
66+
req = httptest.NewRequest("GET", "http://example.com", nil)
67+
req.Header.Set("Existing-Header", "value")
68+
})
69+
70+
It("should pass the request to base transport", func() {
71+
_, err := userAgentTransport.RoundTrip(req)
72+
73+
Expect(err).ToNot(HaveOccurred())
74+
Expect(mockTransport.lastRequest).ToNot(BeNil())
75+
})
76+
77+
It("should add User-Agent header to the request", func() {
78+
_, err := userAgentTransport.RoundTrip(req)
79+
80+
Expect(err).ToNot(HaveOccurred())
81+
userAgent := mockTransport.lastRequest.Header.Get("User-Agent")
82+
Expect(userAgent).ToNot(BeEmpty())
83+
Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"))
84+
})
85+
86+
It("should preserve existing headers", func() {
87+
_, err := userAgentTransport.RoundTrip(req)
88+
89+
Expect(err).ToNot(HaveOccurred())
90+
existingHeader := mockTransport.lastRequest.Header.Get("Existing-Header")
91+
Expect(existingHeader).To(Equal("value"))
92+
})
93+
94+
It("should not modify the original request", func() {
95+
_, err := userAgentTransport.RoundTrip(req)
96+
97+
Expect(err).ToNot(HaveOccurred())
98+
Expect(req.Header.Get("User-Agent")).To(BeEmpty())
99+
})
100+
})
101+
102+
Context("when request has existing User-Agent header", func() {
103+
var req *http.Request
104+
105+
BeforeEach(func() {
106+
req = httptest.NewRequest("GET", "http://example.com", nil)
107+
req.Header.Set("User-Agent", "existing-user-agent")
108+
})
109+
110+
It("should override the existing User-Agent header", func() {
111+
_, err := userAgentTransport.RoundTrip(req)
112+
113+
Expect(err).ToNot(HaveOccurred())
114+
userAgent := mockTransport.lastRequest.Header.Get("User-Agent")
115+
Expect(userAgent).ToNot(Equal("existing-user-agent"))
116+
Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"))
117+
})
118+
119+
It("should not modify the original request", func() {
120+
_, err := userAgentTransport.RoundTrip(req)
121+
122+
Expect(err).ToNot(HaveOccurred())
123+
Expect(req.Header.Get("User-Agent")).To(Equal("existing-user-agent"))
124+
})
125+
})
126+
})
127+
})

clients/cfrestclient/rest_cloud_foundry_client_extended.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"code.cloudfoundry.org/cli/plugin"
1313
"code.cloudfoundry.org/jsonry"
14+
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/baseclient"
1415
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/models"
1516
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/log"
1617
)
@@ -156,10 +157,15 @@ func getPaginatedResourcesWithIncluded[T any, Auxiliary any](url, token string,
156157
func executeRequest(url, token string, isSslDisabled bool) ([]byte, error) {
157158
req, _ := http.NewRequest(http.MethodGet, url, nil)
158159
req.Header.Add("Authorization", token)
160+
161+
// Create transport with TLS configuration
159162
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
160163
httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: isSslDisabled}
161-
client := http.DefaultClient
162-
client.Transport = httpTransport
164+
165+
// Wrap with User-Agent transport
166+
userAgentTransport := baseclient.NewUserAgentTransport(httpTransport)
167+
168+
client := &http.Client{Transport: userAgentTransport}
163169
resp, err := client.Do(req)
164170
if err != nil {
165171
return nil, err

commands/base_command.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,17 @@ func newTransport(isSslDisabled bool) http.RoundTripper {
276276
// Increase tls handshake timeout to cope with slow internet connections. 3 x default value =30s.
277277
httpTransport.TLSHandshakeTimeout = 30 * time.Second
278278
httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: isSslDisabled}
279-
return &csrf.Transport{Delegate: httpTransport, Csrf: &csrfx}
279+
280+
// Wrap with User-Agent transport first
281+
userAgentTransport := baseclient.NewUserAgentTransport(httpTransport)
282+
283+
// Then wrap with CSRF transport
284+
return &csrf.Transport{Delegate: userAgentTransport, Csrf: &csrfx}
285+
}
286+
287+
// NewTransportForTesting creates a transport for testing purposes
288+
func NewTransportForTesting(isSslDisabled bool) http.RoundTripper {
289+
return newTransport(isSslDisabled)
280290
}
281291

282292
func getNonProtectedMethods() map[string]struct{} {

commands/csrf_transport_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package commands_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
7+
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands"
8+
. "github.com/onsi/ginkgo"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
var _ = Describe("CSRFTransport", func() {
13+
14+
Describe("newTransport", func() {
15+
var server *httptest.Server
16+
var capturedHeaders http.Header
17+
var transport http.RoundTripper
18+
19+
BeforeEach(func() {
20+
transport = commands.NewTransportForTesting(false)
21+
})
22+
23+
AfterEach(func() {
24+
if server != nil {
25+
server.Close()
26+
}
27+
})
28+
29+
Context("when making regular requests", func() {
30+
BeforeEach(func() {
31+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
capturedHeaders = r.Header
33+
w.WriteHeader(http.StatusOK)
34+
}))
35+
})
36+
37+
It("should include User-Agent header", func() {
38+
req, err := http.NewRequest("GET", server.URL+"/test", nil)
39+
Expect(err).ToNot(HaveOccurred())
40+
41+
_, err = transport.RoundTrip(req)
42+
Expect(err).ToNot(HaveOccurred())
43+
44+
userAgent := capturedHeaders.Get("User-Agent")
45+
Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set in CSRF transport")
46+
Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'")
47+
})
48+
})
49+
50+
Context("when CSRF token fetch is required", func() {
51+
var requestCount int
52+
53+
BeforeEach(func() {
54+
requestCount = 0
55+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+
requestCount++
57+
capturedHeaders = r.Header
58+
59+
if requestCount == 1 {
60+
// First request should be CSRF token fetch - return 403 to trigger token fetch
61+
w.Header().Set("X-Csrf-Token", "required")
62+
w.WriteHeader(http.StatusForbidden)
63+
} else {
64+
// Second request should have the token
65+
w.WriteHeader(http.StatusOK)
66+
}
67+
}))
68+
})
69+
70+
It("should include User-Agent header in CSRF token fetch request", func() {
71+
req, err := http.NewRequest("POST", server.URL+"/test", nil)
72+
Expect(err).ToNot(HaveOccurred())
73+
74+
// Execute the request through the transport
75+
// This should trigger a CSRF token fetch first
76+
// We expect this to potentially error since our mock server doesn't properly implement CSRF
77+
// But we can still verify the User-Agent was set in the token fetch request
78+
transport.RoundTrip(req)
79+
80+
userAgent := capturedHeaders.Get("User-Agent")
81+
Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set in CSRF token fetch request")
82+
Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'")
83+
})
84+
})
85+
})
86+
})

multiapps_plugin.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"code.cloudfoundry.org/cli/plugin"
1212
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands"
1313
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/log"
14+
"github.com/cloudfoundry-incubator/multiapps-cli-plugin/util"
1415
)
1516

1617
// Version is the version of the CLI plugin. It is injected on linking time.
@@ -42,6 +43,7 @@ func (p *MultiappsPlugin) Run(cliConnection plugin.CliConnection, args []string)
4243
if err != nil {
4344
log.Fatalln(err)
4445
}
46+
util.SetPluginVersion(Version)
4547
command.Initialize(command.GetPluginCommand().Name, cliConnection)
4648
status := command.Execute(args[1:])
4749
if status == commands.Failure {

0 commit comments

Comments
 (0)