detached 93ddb1b

Preserve route host for managed proxy targets

commit
93ddb1bfeba874a244518caf43d7937dacf01776
author
brice · · 2026-04-28 18:32 UTC
parent
bf863eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +92 -25
M internal/adapters/in/http/proxy/forwarder.go +42 -19
@@ -90,14 +90,19 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
9090 return
9191 }
9292
93- originalHost := target.OriginalHost
9493 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
9594 defer releaseInFlight()
9695
9796 transport := h.transportForTarget(target)
9897
99- proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets,
100- func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
98+ proxy := newReverseProxy(reverseProxyOptions{
99+ targetURL: targetURL,
100+ hostHeader: targetHostHeader(target, targetURL),
101+ forwardedHost: target.RouteHost,
102+ transport: transport,
103+ incomingReq: r,
104+ trustedNets: h.trustedNets,
105+ errorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
101106 var maxBytesErr *http.MaxBytesError
102107 if errors.As(proxyErr, &maxBytesErr) {
103108 log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
@@ -107,8 +112,8 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
107112 log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
108113 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
109114 },
110- modifyResponse(maxResponseSize),
111- )
115+ modifyResponse: modifyResponse(maxResponseSize),
116+ })
112117
113118 if target.Protocol == "h2c" {
114119 log.Debug().
@@ -176,39 +181,57 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
176181 proxy.ServeHTTP(w, r)
177182 }
178183
184+type reverseProxyOptions struct {
185+ targetURL *url.URL
186+ hostHeader string
187+ forwardedHost string
188+ transport http.RoundTripper
189+ incomingReq *http.Request
190+ trustedNets []*net.IPNet
191+ errorHandler func(http.ResponseWriter, *http.Request, error)
192+ modifyResponse func(*http.Response) error
193+}
194+
195+func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string {
196+ if target.RouteHost != "" {
197+ return target.RouteHost
198+ }
199+ if target.OriginalHost != "" {
200+ return net.JoinHostPort(target.OriginalHost, targetURL.Port())
201+ }
202+ return targetURL.Host
203+}
204+
179205 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
180206 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
181207 // to strip the Authorization header when using the default Director. Rewrite processes
182208 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
183209 //
184-// originalHost, when non-empty, overrides the Host header sent to the backend.
185-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
186-// send the original hostname for virtual-hosted upstreams.
187-//
188210 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
189211 // request originates from a trusted upstream proxy.
190-func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
212+func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
191213 return &httputil.ReverseProxy{
192214 Rewrite: func(pr *httputil.ProxyRequest) {
193215 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
194- pr.SetURL(targetURL)
216+ pr.SetURL(opts.targetURL)
195217 pr.SetXForwarded()
196- if originalHost != "" {
197- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
198- } else {
199- pr.Out.Host = targetURL.Host
218+ if opts.hostHeader != "" {
219+ pr.Out.Host = opts.hostHeader
220+ }
221+ if opts.forwardedHost != "" {
222+ pr.Out.Header.Set("X-Forwarded-Host", opts.forwardedHost)
200223 }
201224 // Preserve X-Forwarded-Proto from trusted upstream proxies.
202225 // SetXForwarded() unconditionally sets it based on the incoming scheme
203226 // (HTTP between proxies), but when a trusted proxy already sent the
204227 // real client proto, we must honor it.
205- if existingProto != "" && gordonhttp.IsTrustedSource(incomingReq, trustedNets) {
228+ if existingProto != "" && gordonhttp.IsTrustedSource(opts.incomingReq, opts.trustedNets) {
206229 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207230 }
208231 },
209- Transport: transport,
210- ErrorHandler: errorHandler,
211- ModifyResponse: modifyResp,
232+ Transport: opts.transport,
233+ ErrorHandler: opts.errorHandler,
234+ ModifyResponse: opts.modifyResponse,
212235 }
213236 }
214237
@@ -90,14 +90,19 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
90 return90 return
91 }91 }
92 92
93- originalHost := target.OriginalHost
94 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)93 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
95 defer releaseInFlight()94 defer releaseInFlight()
96 95
97 transport := h.transportForTarget(target)96 transport := h.transportForTarget(target)
98 97
99- proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets,98+ proxy := newReverseProxy(reverseProxyOptions{
100- func(w http.ResponseWriter, _ *http.Request, proxyErr error) {99+ targetURL: targetURL,
100+ hostHeader: targetHostHeader(target, targetURL),
101+ forwardedHost: target.RouteHost,
102+ transport: transport,
103+ incomingReq: r,
104+ trustedNets: h.trustedNets,
105+ errorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
101 var maxBytesErr *http.MaxBytesError106 var maxBytesErr *http.MaxBytesError
102 if errors.As(proxyErr, &maxBytesErr) {107 if errors.As(proxyErr, &maxBytesErr) {
103 log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")108 log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
@@ -107,8 +112,8 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
107 log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")112 log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
108 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)113 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
109 },114 },
110- modifyResponse(maxResponseSize),115+ modifyResponse: modifyResponse(maxResponseSize),
111- )116+ })
112 117
113 if target.Protocol == "h2c" {118 if target.Protocol == "h2c" {
114 log.Debug().119 log.Debug().
@@ -176,39 +181,57 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
176 proxy.ServeHTTP(w, r)181 proxy.ServeHTTP(w, r)
177 }182 }
178 183
184+type reverseProxyOptions struct {
185+ targetURL *url.URL
186+ hostHeader string
187+ forwardedHost string
188+ transport http.RoundTripper
189+ incomingReq *http.Request
190+ trustedNets []*net.IPNet
191+ errorHandler func(http.ResponseWriter, *http.Request, error)
192+ modifyResponse func(*http.Response) error
193+}
194+
195+func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string {
196+ if target.RouteHost != "" {
197+ return target.RouteHost
198+ }
199+ if target.OriginalHost != "" {
200+ return net.JoinHostPort(target.OriginalHost, targetURL.Port())
201+ }
202+ return targetURL.Host
203+}
204+
179 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent205 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
180 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"206 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
181 // to strip the Authorization header when using the default Director. Rewrite processes207 // to strip the Authorization header when using the default Director. Rewrite processes
182 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.208 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
183 //209 //
184-// originalHost, when non-empty, overrides the Host header sent to the backend.
185-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
186-// send the original hostname for virtual-hosted upstreams.
187-//
188 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the210 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
189 // request originates from a trusted upstream proxy.211 // request originates from a trusted upstream proxy.
190-func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {212+func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
191 return &httputil.ReverseProxy{213 return &httputil.ReverseProxy{
192 Rewrite: func(pr *httputil.ProxyRequest) {214 Rewrite: func(pr *httputil.ProxyRequest) {
193 existingProto := pr.In.Header.Get("X-Forwarded-Proto")215 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
194- pr.SetURL(targetURL)216+ pr.SetURL(opts.targetURL)
195 pr.SetXForwarded()217 pr.SetXForwarded()
196- if originalHost != "" {218+ if opts.hostHeader != "" {
197- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())219+ pr.Out.Host = opts.hostHeader
198- } else {220+ }
199- pr.Out.Host = targetURL.Host221+ if opts.forwardedHost != "" {
222+ pr.Out.Header.Set("X-Forwarded-Host", opts.forwardedHost)
200 }223 }
201 // Preserve X-Forwarded-Proto from trusted upstream proxies.224 // Preserve X-Forwarded-Proto from trusted upstream proxies.
202 // SetXForwarded() unconditionally sets it based on the incoming scheme225 // SetXForwarded() unconditionally sets it based on the incoming scheme
203 // (HTTP between proxies), but when a trusted proxy already sent the226 // (HTTP between proxies), but when a trusted proxy already sent the
204 // real client proto, we must honor it.227 // real client proto, we must honor it.
205- if existingProto != "" && gordonhttp.IsTrustedSource(incomingReq, trustedNets) {228+ if existingProto != "" && gordonhttp.IsTrustedSource(opts.incomingReq, opts.trustedNets) {
206 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)229 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207 }230 }
208 },231 },
209- Transport: transport,232+ Transport: opts.transport,
210- ErrorHandler: errorHandler,233+ ErrorHandler: opts.errorHandler,
211- ModifyResponse: modifyResp,234+ ModifyResponse: opts.modifyResponse,
212 }235 }
213 }236 }
214 237
M internal/adapters/in/http/proxy/forwarder_test.go +45 -5
@@ -145,7 +145,44 @@ func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
145145 handler.ServeHTTP(w, req)
146146
147147 assert.Equal(t, http.StatusOK, w.Code)
148- assert.Contains(t, receivedHost, "upstream.example.com")
148+ assert.Equal(t, net.JoinHostPort("upstream.example.com", strconv.Itoa(backendPort)), receivedHost)
149+}
150+
151+func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
152+ var receivedHost string
153+ var receivedForwardedHost string
154+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ receivedHost = r.Host
156+ receivedForwardedHost = r.Header.Get("X-Forwarded-Host")
157+ w.WriteHeader(http.StatusOK)
158+ }))
159+ defer backend.Close()
160+
161+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
162+
163+ proxySvc := inmocks.NewMockProxyService(t)
164+
165+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
166+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
167+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
168+ Host: "127.0.0.1",
169+ Port: backendPort,
170+ ContainerID: "c-1",
171+ Scheme: "http",
172+ RouteHost: "app.example.com",
173+ }, nil)
174+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
175+
176+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
177+
178+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
179+ req.Host = "App.Example.com:443"
180+ w := httptest.NewRecorder()
181+ handler.ServeHTTP(w, req)
182+
183+ assert.Equal(t, http.StatusOK, w.Code)
184+ assert.Equal(t, "app.example.com", receivedHost)
185+ assert.Equal(t, "app.example.com", receivedForwardedHost)
149186 }
150187
151188 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
@@ -280,12 +317,15 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
280317 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
281318 }
282319
283- proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets,
284- func(w http.ResponseWriter, _ *http.Request, err error) {
320+ proxy := newReverseProxy(reverseProxyOptions{
321+ targetURL: backendURL,
322+ transport: http.DefaultTransport,
323+ incomingReq: incomingReq,
324+ trustedNets: tt.nets,
325+ errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
285326 http.Error(w, err.Error(), http.StatusBadGateway)
286327 },
287- nil,
288- )
328+ })
289329
290330 rec := httptest.NewRecorder()
291331 proxy.ServeHTTP(rec, incomingReq)
@@ -145,7 +145,44 @@ func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
145 handler.ServeHTTP(w, req)145 handler.ServeHTTP(w, req)
146 146
147 assert.Equal(t, http.StatusOK, w.Code)147 assert.Equal(t, http.StatusOK, w.Code)
148- assert.Contains(t, receivedHost, "upstream.example.com")148+ assert.Equal(t, net.JoinHostPort("upstream.example.com", strconv.Itoa(backendPort)), receivedHost)
149+}
150+
151+func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
152+ var receivedHost string
153+ var receivedForwardedHost string
154+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ receivedHost = r.Host
156+ receivedForwardedHost = r.Header.Get("X-Forwarded-Host")
157+ w.WriteHeader(http.StatusOK)
158+ }))
159+ defer backend.Close()
160+
161+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
162+
163+ proxySvc := inmocks.NewMockProxyService(t)
164+
165+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
166+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
167+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
168+ Host: "127.0.0.1",
169+ Port: backendPort,
170+ ContainerID: "c-1",
171+ Scheme: "http",
172+ RouteHost: "app.example.com",
173+ }, nil)
174+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
175+
176+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
177+
178+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
179+ req.Host = "App.Example.com:443"
180+ w := httptest.NewRecorder()
181+ handler.ServeHTTP(w, req)
182+
183+ assert.Equal(t, http.StatusOK, w.Code)
184+ assert.Equal(t, "app.example.com", receivedHost)
185+ assert.Equal(t, "app.example.com", receivedForwardedHost)
149 }186 }
150 187
151 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {188 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
@@ -280,12 +317,15 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
280 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)317 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
281 }318 }
282 319
283- proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets,320+ proxy := newReverseProxy(reverseProxyOptions{
284- func(w http.ResponseWriter, _ *http.Request, err error) {321+ targetURL: backendURL,
322+ transport: http.DefaultTransport,
323+ incomingReq: incomingReq,
324+ trustedNets: tt.nets,
325+ errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
285 http.Error(w, err.Error(), http.StatusBadGateway)326 http.Error(w, err.Error(), http.StatusBadGateway)
286 },327 },
287- nil,328+ })
288- )
289 329
290 rec := httptest.NewRecorder()330 rec := httptest.NewRecorder()
291 proxy.ServeHTTP(rec, incomingReq)331 proxy.ServeHTTP(rec, incomingReq)
M internal/domain/route.go +2 -1
@@ -15,7 +15,8 @@ type ProxyTarget struct {
1515 ContainerID string
1616 Scheme string // "http" or "https"
1717 Protocol string // "" (default HTTP/1.1) or "h2c" (cleartext HTTP/2)
18- OriginalHost string // Original hostname before DNS resolution (for Host header)
18+ OriginalHost string // Original hostname before DNS resolution (for external-route Host header)
19+ RouteHost string // Canonical matched route domain for managed-route Host header
1920 }
2021
2122 // RouteMatch represents the result of matching a request to a route.
@@ -15,7 +15,8 @@ type ProxyTarget struct {
15 ContainerID string15 ContainerID string
16 Scheme string // "http" or "https"16 Scheme string // "http" or "https"
17 Protocol string // "" (default HTTP/1.1) or "h2c" (cleartext HTTP/2)17 Protocol string // "" (default HTTP/1.1) or "h2c" (cleartext HTTP/2)
18- OriginalHost string // Original hostname before DNS resolution (for Host header)18+ OriginalHost string // Original hostname before DNS resolution (for external-route Host header)
19+ RouteHost string // Canonical matched route domain for managed-route Host header
19 }20 }
20 21
21 // RouteMatch represents the result of matching a request to a route.22 // RouteMatch represents the result of matching a request to a route.
M internal/usecase/proxy/service.go +2 -0
@@ -134,6 +134,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
134134 ContainerID: container.ID,
135135 Scheme: "http",
136136 Protocol: meta.Protocol,
137+ RouteHost: domainName,
137138 }
138139 } else {
139140 // Gordon is on the host - use host port mapping
@@ -166,6 +167,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
166167 ContainerID: container.ID,
167168 Scheme: "http",
168169 Protocol: meta.Protocol,
170+ RouteHost: domainName,
169171 }
170172 }
171173
@@ -134,6 +134,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
134 ContainerID: container.ID,134 ContainerID: container.ID,
135 Scheme: "http",135 Scheme: "http",
136 Protocol: meta.Protocol,136 Protocol: meta.Protocol,
137+ RouteHost: domainName,
137 }138 }
138 } else {139 } else {
139 // Gordon is on the host - use host port mapping140 // Gordon is on the host - use host port mapping
@@ -166,6 +167,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
166 ContainerID: container.ID,167 ContainerID: container.ID,
167 Scheme: "http",168 Scheme: "http",
168 Protocol: meta.Protocol,169 Protocol: meta.Protocol,
170+ RouteHost: domainName,
169 }171 }
170 }172 }
171 173
M internal/usecase/proxy/service_test.go +1 -0
@@ -644,6 +644,7 @@ func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
644644 assert.Equal(t, "localhost", result.Host)
645645 assert.Equal(t, 32000, result.Port)
646646 assert.Equal(t, "c-gitea", result.ContainerID)
647+ assert.Equal(t, "git.example.com", result.RouteHost)
647648 }
648649
649650 func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {
@@ -644,6 +644,7 @@ func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
644 assert.Equal(t, "localhost", result.Host)644 assert.Equal(t, "localhost", result.Host)
645 assert.Equal(t, 32000, result.Port)645 assert.Equal(t, 32000, result.Port)
646 assert.Equal(t, "c-gitea", result.ContainerID)646 assert.Equal(t, "c-gitea", result.ContainerID)
647+ assert.Equal(t, "git.example.com", result.RouteHost)
647 }648 }
648 649
649 func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {650 func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close