git-remote-entire: stamp HTTP User-Agent on outbound requests · Entire
git-remote-entire: stamp HTTP User-Agent on outbound requests
The helper already advertises itself via the git protocol pkt-line agent= capability (commit-stamped), but every HTTP request it makes — info/refs, upload-pack/receive-pack POSTs, well-known discovery, STS exchanges, refresh-token rotations — went out with Go's default "Go-http-client/1.1", making the helper invisible in upstream access logs and indistinguishable from any other Go client hitting the data plane.
Add httpclient.UserAgentTransport, a small RoundTripper wrapper that
stamps User-Agent on each outbound request, and wire it into both the
auth-side *http.Client and the transport.Proxy so every credentialled hop
from the helper carries "git-remote-entire/
Format is version-stamped (binary/version) to match the conventional form the rest of the entire HTTP clients use; the git protocol agent stays commit-stamped where it is, since the two serve different correlation needs and changing it risks breaking server-side log analysis.
Assisted-by: Claude Opus 4.7 noreply@anthropic.com Signed-off-by: Paulo Gomes paulo@entire.io
Sessions
Changes
5
- cmd/git-remote-entire
- Mmain.go+14/-3
- internal
- entireclient/httpclient
- Auseragent.go+28
- Auseragent_test.go+100
- remotehelper/transport
- Mproxy.go+16/-2
- entireclient/httpclient
package httpclient
import "net/http"
// UserAgentTransport wraps another http.RoundTripper and stamps the
// User-Agent header on every outgoing request so the server can
// attribute traffic to the calling binary. Callers that already set
// User-Agent are overwritten: the wrapper exists to give the binary a
// single identity in upstream access logs, not to be overridden per
// request.
// The wrapper clones the request before mutating headers so the
// caller's original *http.Request is left untouched — important for
// retries and for callers that hold a reference after Do returns.
// Concurrent use is safe iff Next is safe for concurrent use.
type UserAgentTransport struct {
Next http.RoundTripper
UA string
}
// RoundTrip implements http.RoundTripper.
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
r := req.Clone(req.Context())
r.Header.Set("User-Agent", t.UA)
return t.Next.RoundTrip(r)
}
func TestUserAgentTransport_SetsHeader(t *testing.T) {
t.Parallel()
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
client := &http.Client{
Transport: &UserAgentTransport{
Next: http.DefaultTransport,
UA: "test-binary/1.2.3",
},
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
_ = resp.Body.Close()
if want := "test-binary/1.2.3"; got != want {
t.Errorf("User-Agent = %q, want %q", got, want)
}
}
func TestUserAgentTransport_OverwritesCallerHeader(t *testing.T) {
t.Parallel()
client := &http.Client{
Transport: &UserAgentTransport{
Next: http.DefaultTransport,
UA: "wrapper-set",
},
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("User-Agent", "caller-set")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
_ = resp.Body.Close()
if want := "wrapper-set"; got != want {
t.Errorf("User-Agent = %q, want %q", got, want)
}
}
func TestUserAgentTransport_DoesNotMutateCallerRequest(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if got := req.Header.Get("User-Agent"); got != "caller-set" {
t.Errorf("caller request mutated: User-Agent = %q, want %q", got, "caller-set")
}
}
func TestProxy_SetsUserAgentHeader(t *testing.T) {
t.Parallel()
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
p := New(Config{
Nodes: replicas.NodeConfig{
InitialNodes: []string{server.URL},
EntryURL: server.URL,
ClusterHost: mustHost(t, server.URL),
RepoPath: "owner/repo",
},
Path: "/et/owner/repo",
UserAgent: "git-remote-entire/9.9.9",
})
resp, err := p.InfoRefs(t.Context(), serviceParam)
if err != nil {
t.Fatalf("InfoRefs: %v", err)
}
_ = resp.Close()
if want := "git-remote-entire/9.9.9"; got != want {
t.Errorf("User-Agent = %q, want %q", got, want)
}
}
func TestProxy_OmitsUserAgentWrapperWhenEmpty(t *testing.T) {
t.Parallel()
p := New(Config{
Nodes: replicas.NodeConfig{
InitialNodes: []string{server.URL},
EntryURL: server.URL,
ClusterHost: mustHost(t, server.URL),
RepoPath: "owner/repo",
},
Path: "/et/owner/repo",
})
resp, err := p.InfoRefs(t.Context(), serviceParam)
if err != nil {
t.Fatalf("InfoRefs: %v", err)
}
_ = resp.Close()
if !strings.HasPrefix(got, "Go-http-client/") {
t.Errorf("User-Agent = %q, want Go's default when wrapper omitted", got)
}
}