Add an option to preserve server path

This commit is contained in:
Michael 2024-10-17 09:12:04 +02:00 committed by GitHub
parent 6e1f5dc071
commit 83871f27dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 251 additions and 44 deletions

View file

@ -59,10 +59,12 @@
[[http.services.Service02.loadBalancer.servers]]
url = "foobar"
weight = 42
preservePath = true
[[http.services.Service02.loadBalancer.servers]]
url = "foobar"
weight = 42
preservePath = true
[http.services.Service02.loadBalancer.healthCheck]
scheme = "foobar"
mode = "foobar"

View file

@ -66,8 +66,10 @@ http:
servers:
- url: foobar
weight: 42
preservePath: true
- url: foobar
weight: 42
preservePath: true
healthCheck:
scheme: foobar
mode: foobar

View file

@ -256,8 +256,10 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/http/services/Service02/loadBalancer/healthCheck/timeout` | `42s` |
| `traefik/http/services/Service02/loadBalancer/passHostHeader` | `true` |
| `traefik/http/services/Service02/loadBalancer/responseForwarding/flushInterval` | `42s` |
| `traefik/http/services/Service02/loadBalancer/servers/0/preservePath` | `true` |
| `traefik/http/services/Service02/loadBalancer/servers/0/url` | `foobar` |
| `traefik/http/services/Service02/loadBalancer/servers/0/weight` | `42` |
| `traefik/http/services/Service02/loadBalancer/servers/1/preservePath` | `true` |
| `traefik/http/services/Service02/loadBalancer/servers/1/url` | `foobar` |
| `traefik/http/services/Service02/loadBalancer/servers/1/weight` | `42` |
| `traefik/http/services/Service02/loadBalancer/serversTransport` | `foobar` |

View file

@ -116,12 +116,8 @@ Each service has a load-balancer, even if there is only one server to forward tr
#### Servers
Servers declare a single instance of your program.
The `url` option point to a specific instance.
!!! info ""
Paths in the servers' `url` have no effect.
If you want the requests to be sent to a specific path on your servers,
configure your [`routers`](../routers/index.md) to use a corresponding [middleware](../../middlewares/overview.md) (e.g. the [AddPrefix](../../middlewares/http/addprefix.md) or [ReplacePath](../../middlewares/http/replacepath.md)) middlewares.
The `url` option point to a specific instance.
??? example "A Service with One Server -- Using the [File Provider](../../providers/file.md)"
@ -173,6 +169,34 @@ The `weight` option allows for weighted load balancing on the servers.
weight = 1
```
The `preservePath` option allows to preserve the URL path.
!!! info "Health Check"
When a [health check](#health-check) is configured for the server, the path is not preserved.
??? example "A Service with One Server and PreservePath -- Using the [File Provider](../../providers/file.md)"
```yaml tab="YAML"
## Dynamic configuration
http:
services:
my-service:
loadBalancer:
servers:
- url: "http://private-ip-server-1/base"
preservePath: true
```
```toml tab="TOML"
## Dynamic configuration
[http.services]
[http.services.my-service.loadBalancer]
[[http.services.my-service.loadBalancer.servers]]
url = "http://private-ip-server-1/base"
preservePath = true
```
#### Load-balancing
For now, only round robin load balancing is supported:

View file

@ -245,7 +245,8 @@ func (r *ResponseForwarding) SetDefaults() {
// Server holds the server configuration.
type Server struct {
URL string `json:"url,omitempty" toml:"url,omitempty" yaml:"url,omitempty" label:"-"`
Weight *int `json:"weight,omitempty" toml:"weight,omitempty" yaml:"weight,omitempty" label:"weight"`
Weight *int `json:"weight,omitempty" toml:"weight,omitempty" yaml:"weight,omitempty" label:"weight" export:"true"`
PreservePath bool `json:"preservePath,omitempty" toml:"preservePath,omitempty" yaml:"preservePath,omitempty" label:"-" export:"true"`
Scheme string `json:"-" toml:"-" yaml:"-" file:"-"`
Port string `json:"-" toml:"-" yaml:"-" file:"-"`
}

View file

@ -68,7 +68,7 @@ func (r *ProxyBuilder) Update(newConfigs map[string]*dynamic.ServersTransport) {
}
// Build builds a new ReverseProxy with the given configuration.
func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader bool) (http.Handler, error) {
func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader, preservePath bool) (http.Handler, error) {
proxyURL, err := r.proxy(&http.Request{URL: targetURL})
if err != nil {
return nil, fmt.Errorf("getting proxy: %w", err)
@ -90,7 +90,7 @@ func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader
}
pool := r.getPool(cfgName, cfg, tlsConfig, targetURL, proxyURL)
return NewReverseProxy(targetURL, proxyURL, r.debug, passHostHeader, responseHeaderTimeout, pool)
return NewReverseProxy(targetURL, proxyURL, r.debug, passHostHeader, preservePath, responseHeaderTimeout, pool)
}
func (r *ProxyBuilder) getPool(cfgName string, config *dynamic.ServersTransport, tlsConfig *tls.Config, targetURL *url.URL, proxyURL *url.URL) *connPool {

View file

@ -121,11 +121,12 @@ type ReverseProxy struct {
targetURL *url.URL
passHostHeader bool
preservePath bool
responseHeaderTimeout time.Duration
}
// NewReverseProxy creates a new ReverseProxy.
func NewReverseProxy(targetURL *url.URL, proxyURL *url.URL, debug, passHostHeader bool, responseHeaderTimeout time.Duration, connPool *connPool) (*ReverseProxy, error) {
func NewReverseProxy(targetURL, proxyURL *url.URL, debug, passHostHeader, preservePath bool, responseHeaderTimeout time.Duration, connPool *connPool) (*ReverseProxy, error) {
var proxyAuth string
if proxyURL != nil && proxyURL.User != nil && targetURL.Scheme == "http" {
username := proxyURL.User.Username()
@ -136,6 +137,7 @@ func NewReverseProxy(targetURL *url.URL, proxyURL *url.URL, debug, passHostHeade
return &ReverseProxy{
debug: debug,
passHostHeader: passHostHeader,
preservePath: preservePath,
targetURL: targetURL,
proxyAuth: proxyAuth,
connPool: connPool,
@ -207,6 +209,11 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
u2.Path = u.Path
u2.RawPath = u.RawPath
if p.preservePath {
u2.Path, u2.RawPath = proxyhttputil.JoinURLPath(p.targetURL, u)
}
u2.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&")
outReq.SetHost(u2.Host)

View file

@ -230,7 +230,7 @@ func TestProxyFromEnvironment(t *testing.T) {
return u, nil
}
reverseProxy, err := builder.Build("foo", testhelpers.MustParseURL(backendURL), false)
reverseProxy, err := builder.Build("foo", testhelpers.MustParseURL(backendURL), false, false)
require.NoError(t, err)
reverseProxyServer := httptest.NewServer(reverseProxy)
@ -252,6 +252,32 @@ func TestProxyFromEnvironment(t *testing.T) {
}
}
func TestPreservePath(t *testing.T) {
var callCount int
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
callCount++
assert.Equal(t, "/base/foo/bar", req.URL.Path)
assert.Equal(t, "/base/foo%2Fbar", req.URL.RawPath)
}))
t.Cleanup(server.Close)
builder := NewProxyBuilder(&transportManagerMock{}, static.FastProxyConfig{})
serverURL, err := url.JoinPath(server.URL, "base")
require.NoError(t, err)
proxyHandler, err := builder.Build("", testhelpers.MustParseURL(serverURL), true, true)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/foo%2Fbar", http.NoBody)
res := httptest.NewRecorder()
proxyHandler.ServeHTTP(res, req)
assert.Equal(t, 1, callCount)
assert.Equal(t, http.StatusOK, res.Code)
}
func newCertificate(t *testing.T, domain string) *tls.Certificate {
t.Helper()

View file

@ -362,7 +362,7 @@ func TestWebSocketRequestWithHeadersInResponseWriter(t *testing.T) {
u := parseURI(t, srv.URL)
f, err := NewReverseProxy(u, nil, true, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
f, err := NewReverseProxy(u, nil, true, false, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
return net.Dial("tcp", u.Host)
}))
require.NoError(t, err)
@ -434,7 +434,7 @@ func TestWebSocketUpgradeFailed(t *testing.T) {
defer srv.Close()
u := parseURI(t, srv.URL)
f, err := NewReverseProxy(u, nil, true, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
f, err := NewReverseProxy(u, nil, true, false, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
return net.Dial("tcp", u.Host)
}))
require.NoError(t, err)
@ -676,7 +676,7 @@ func createProxyWithForwarder(t *testing.T, uri string, pool *connPool) *httptes
t.Helper()
u := parseURI(t, uri)
proxy, err := NewReverseProxy(u, nil, false, true, 0, pool)
proxy, err := NewReverseProxy(u, nil, false, true, false, 0, pool)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {

View file

@ -38,7 +38,7 @@ func NewProxyBuilder(transportManager TransportManager, semConvMetricsRegistry *
func (r *ProxyBuilder) Update(_ map[string]*dynamic.ServersTransport) {}
// Build builds a new httputil.ReverseProxy with the given configuration.
func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error) {
func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error) {
roundTripper, err := r.transportManager.GetRoundTripper(cfgName)
if err != nil {
return nil, fmt.Errorf("getting RoundTripper: %w", err)
@ -50,5 +50,5 @@ func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve,
roundTripper = newObservabilityRoundTripper(r.semConvMetricsRegistry, roundTripper)
}
return buildSingleHostProxy(targetURL, passHostHeader, flushInterval, roundTripper, r.bufferPool), nil
return buildSingleHostProxy(targetURL, passHostHeader, preservePath, flushInterval, roundTripper, r.bufferPool), nil
}

View file

@ -23,7 +23,7 @@ func TestEscapedPath(t *testing.T) {
roundTrippers: map[string]http.RoundTripper{"default": &http.Transport{}},
}
p, err := NewProxyBuilder(transportManager, nil).Build("default", testhelpers.MustParseURL(srv.URL), false, true, 0)
p, err := NewProxyBuilder(transportManager, nil).Build("default", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
require.NoError(t, err)
proxy := httptest.NewServer(http.HandlerFunc(p.ServeHTTP))

View file

@ -15,15 +15,17 @@ import (
"golang.org/x/net/http/httpguts"
)
const (
// StatusClientClosedRequest non-standard HTTP status code for client disconnection.
const StatusClientClosedRequest = 499
StatusClientClosedRequest = 499
// StatusClientClosedRequestText non-standard HTTP status for client disconnection.
const StatusClientClosedRequestText = "Client Closed Request"
StatusClientClosedRequestText = "Client Closed Request"
)
func buildSingleHostProxy(target *url.URL, passHostHeader bool, flushInterval time.Duration, roundTripper http.RoundTripper, bufferPool httputil.BufferPool) http.Handler {
func buildSingleHostProxy(target *url.URL, passHostHeader bool, preservePath bool, flushInterval time.Duration, roundTripper http.RoundTripper, bufferPool httputil.BufferPool) http.Handler {
return &httputil.ReverseProxy{
Director: directorBuilder(target, passHostHeader),
Director: directorBuilder(target, passHostHeader, preservePath),
Transport: roundTripper,
FlushInterval: flushInterval,
BufferPool: bufferPool,
@ -31,7 +33,7 @@ func buildSingleHostProxy(target *url.URL, passHostHeader bool, flushInterval ti
}
}
func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Request) {
func directorBuilder(target *url.URL, passHostHeader bool, preservePath bool) func(req *http.Request) {
return func(outReq *http.Request) {
outReq.URL.Scheme = target.Scheme
outReq.URL.Host = target.Host
@ -46,6 +48,11 @@ func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Reques
outReq.URL.Path = u.Path
outReq.URL.RawPath = u.RawPath
if preservePath {
outReq.URL.Path, outReq.URL.RawPath = JoinURLPath(target, u)
}
// If a plugin/middleware adds semicolons in query params, they should be urlEncoded.
outReq.URL.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&")
outReq.RequestURI = "" // Outgoing request should not have RequestURI
@ -54,7 +61,7 @@ func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Reques
outReq.ProtoMajor = 1
outReq.ProtoMinor = 1
// Do not pass client Host header unless optsetter PassHostHeader is set.
// Do not pass client Host header unless option PassHostHeader is set.
if !passHostHeader {
outReq.Host = outReq.URL.Host
}
@ -106,6 +113,13 @@ func ErrorHandler(w http.ResponseWriter, req *http.Request, err error) {
}
}
func statusText(statusCode int) string {
if statusCode == StatusClientClosedRequest {
return StatusClientClosedRequestText
}
return http.StatusText(statusCode)
}
// ComputeStatusCode computes the HTTP status code according to the given error.
func ComputeStatusCode(err error) int {
switch {
@ -127,9 +141,38 @@ func ComputeStatusCode(err error) int {
return http.StatusInternalServerError
}
func statusText(statusCode int) string {
if statusCode == StatusClientClosedRequest {
return StatusClientClosedRequestText
// JoinURLPath computes the joined path and raw path of the given URLs.
// From https://github.com/golang/go/blob/b521ebb55a9b26c8824b219376c7f91f7cda6ec2/src/net/http/httputil/reverseproxy.go#L221
func JoinURLPath(a, b *url.URL) (path, rawpath string) {
if a.RawPath == "" && b.RawPath == "" {
return singleJoiningSlash(a.Path, b.Path), ""
}
return http.StatusText(statusCode)
// Same as singleJoiningSlash, but uses EscapedPath to determine
// whether a slash should be added
apath := a.EscapedPath()
bpath := b.EscapedPath()
aslash := strings.HasSuffix(apath, "/")
bslash := strings.HasPrefix(bpath, "/")
switch {
case aslash && bslash:
return a.Path + b.Path[1:], apath + bpath[1:]
case !aslash && !bslash:
return a.Path + "/" + b.Path, apath + "/" + bpath
}
return a.Path + b.Path, apath + bpath
}
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}

View file

@ -0,0 +1,102 @@
package httputil
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/traefik/traefik/v3/pkg/testhelpers"
)
func Test_directorBuilder(t *testing.T) {
tests := []struct {
name string
target *url.URL
passHostHeader bool
preservePath bool
incomingURL string
expectedScheme string
expectedHost string
expectedPath string
expectedRawPath string
expectedQuery string
}{
{
name: "Basic proxy",
target: testhelpers.MustParseURL("http://example.com"),
passHostHeader: false,
preservePath: false,
incomingURL: "http://localhost/test?param=value",
expectedScheme: "http",
expectedHost: "example.com",
expectedPath: "/test",
expectedQuery: "param=value",
},
{
name: "HTTPS target",
target: testhelpers.MustParseURL("https://secure.example.com"),
passHostHeader: false,
preservePath: false,
incomingURL: "http://localhost/secure",
expectedScheme: "https",
expectedHost: "secure.example.com",
expectedPath: "/secure",
},
{
name: "PassHostHeader",
target: testhelpers.MustParseURL("http://example.com"),
passHostHeader: true,
preservePath: false,
incomingURL: "http://original.host/test",
expectedScheme: "http",
expectedHost: "original.host",
expectedPath: "/test",
},
{
name: "Preserve path",
target: testhelpers.MustParseURL("http://example.com/base"),
passHostHeader: false,
preservePath: true,
incomingURL: "http://localhost/foo%2Fbar",
expectedScheme: "http",
expectedHost: "example.com",
expectedPath: "/base/foo/bar",
expectedRawPath: "/base/foo%2Fbar",
},
{
name: "Handle semicolons in query",
target: testhelpers.MustParseURL("http://example.com"),
passHostHeader: false,
preservePath: false,
incomingURL: "http://localhost/test?param1=value1;param2=value2",
expectedScheme: "http",
expectedHost: "example.com",
expectedPath: "/test",
expectedQuery: "param1=value1&param2=value2",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
director := directorBuilder(test.target, test.passHostHeader, test.preservePath)
req := httptest.NewRequest(http.MethodGet, test.incomingURL, http.NoBody)
director(req)
assert.Equal(t, test.expectedScheme, req.URL.Scheme)
assert.Equal(t, test.expectedHost, req.Host)
assert.Equal(t, test.expectedPath, req.URL.Path)
assert.Equal(t, test.expectedRawPath, req.URL.RawPath)
assert.Equal(t, test.expectedQuery, req.URL.RawQuery)
assert.Empty(t, req.RequestURI)
assert.Equal(t, "HTTP/1.1", req.Proto)
assert.Equal(t, 1, req.ProtoMajor)
assert.Equal(t, 1, req.ProtoMinor)
assert.False(t, !test.passHostHeader && req.Host != req.URL.Host)
})
}
}

View file

@ -298,9 +298,8 @@ func TestWebSocketRequestWithHeadersInResponseWriter(t *testing.T) {
},
}
p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, 0)
p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
require.NoError(t, err)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
req.URL = testhelpers.MustParseURL(srv.URL)
w.Header().Set("HEADER-KEY", "HEADER-VALUE")
@ -355,9 +354,8 @@ func TestWebSocketUpgradeFailed(t *testing.T) {
},
}
p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, 0)
p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
require.NoError(t, err)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path // keep the original path
@ -588,7 +586,7 @@ func createProxyWithForwarder(t *testing.T, uri string, transport http.RoundTrip
roundTrippers: map[string]http.RoundTripper{"fwd": transport},
}
p, err := NewProxyBuilder(transportManager, nil).Build("fwd", u, false, true, 0)
p, err := NewProxyBuilder(transportManager, nil).Build("fwd", u, false, true, false, 0)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {

View file

@ -45,7 +45,7 @@ func (b *SmartBuilder) Update(newConfigs map[string]*dynamic.ServersTransport) {
}
// Build builds an HTTP proxy for the given URL using the ServersTransport with the given name.
func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error) {
func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error) {
serversTransport, err := b.transportManager.Get(configName)
if err != nil {
return nil, fmt.Errorf("getting ServersTransport: %w", err)
@ -55,7 +55,7 @@ func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserv
// For the https scheme we cannot guess if the backend communication will use HTTP2,
// thus we check if HTTP/2 is disabled to use the fast proxy implementation when this is possible.
if targetURL.Scheme == "h2c" || (targetURL.Scheme == "https" && !serversTransport.DisableHTTP2) {
return b.proxyBuilder.Build(configName, targetURL, shouldObserve, passHostHeader, flushInterval)
return b.proxyBuilder.Build(configName, targetURL, shouldObserve, passHostHeader, preservePath, flushInterval)
}
return b.fastProxyBuilder.Build(configName, targetURL, passHostHeader)
return b.fastProxyBuilder.Build(configName, targetURL, passHostHeader, preservePath)
}

View file

@ -101,7 +101,7 @@ func TestSmartBuilder_Build(t *testing.T) {
httpProxyBuilder := httputil.NewProxyBuilder(transportManager, nil)
proxyBuilder := NewSmartBuilder(transportManager, httpProxyBuilder, test.fastProxyConfig)
proxyHandler, err := proxyBuilder.Build("test", targetURL, false, false, time.Second)
proxyHandler, err := proxyBuilder.Build("test", targetURL, false, false, false, time.Second)
require.NoError(t, err)
rw := httptest.NewRecorder()

View file

@ -897,7 +897,7 @@ func BenchmarkService(b *testing.B) {
type proxyBuilderMock struct{}
func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) {
func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _, _ bool, _ time.Duration) (http.Handler, error) {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {}), nil
}

View file

@ -254,7 +254,7 @@ func TestInternalServices(t *testing.T) {
type proxyBuilderMock struct{}
func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) {
func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _, _ bool, _ time.Duration) (http.Handler, error) {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {}), nil
}

View file

@ -42,7 +42,7 @@ const (
// ProxyBuilder builds reverse proxy handlers.
type ProxyBuilder interface {
Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error)
Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error)
Update(configs map[string]*dynamic.ServersTransport)
}
@ -338,7 +338,7 @@ func (m *Manager) getLoadBalancerServiceHandler(ctx context.Context, serviceName
qualifiedSvcName := provider.GetQualifiedName(ctx, serviceName)
shouldObserve := m.observabilityMgr.ShouldAddTracing(qualifiedSvcName) || m.observabilityMgr.ShouldAddMetrics(qualifiedSvcName)
proxy, err := m.proxyBuilder.Build(service.ServersTransport, target, shouldObserve, passHostHeader, flushInterval)
proxy, err := m.proxyBuilder.Build(service.ServersTransport, target, shouldObserve, passHostHeader, server.PreservePath, flushInterval)
if err != nil {
return nil, fmt.Errorf("error building proxy for server URL %s: %w", server.URL, err)
}