detached ec22770

refactor(proxy): move HTTP execution from usecase to adapter layer

Restores hexagonal architecture: the proxy usecase handles routing,
target resolution, and state management. The HTTP adapter owns
transports, reverse proxy creation, concurrency limits, and request
handling. The boundary interface no longer depends on net/http.

Also adds h2c transport using Go 1.26 native Protocols support for
containers that opt in via gordon.proxy.protocol=h2c label.
commit
ec22770b8bdc04751a4b17661275714eecde1c2d
author
brice · · 2026-03-15 22:43 UTC
parent
9d2bfe7
signature
unsigned
6 files · +598 -398
A internal/adapters/in/http/proxy/forwarder.go +273 -0
@@ -0,0 +1,273 @@
1+// Package proxy implements the HTTP adapter for the reverse proxy.
2+package proxy
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/http/httputil"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+// newAppTransport creates the shared HTTP/1.1 transport for proxying to application containers.
22+func newAppTransport() *http.Transport {
23+ return &http.Transport{
24+ DialContext: (&net.Dialer{
25+ Timeout: 10 * time.Second,
26+ KeepAlive: 30 * time.Second,
27+ }).DialContext,
28+ TLSHandshakeTimeout: 10 * time.Second,
29+ ResponseHeaderTimeout: 30 * time.Second,
30+ MaxIdleConns: 100,
31+ MaxIdleConnsPerHost: 10,
32+ IdleConnTimeout: 90 * time.Second,
33+ }
34+}
35+
36+// newH2CTransport creates a dedicated cleartext HTTP/2 transport for containers
37+// that opt in via the gordon.proxy.protocol=h2c label.
38+// Uses Go 1.26 native UnencryptedHTTP2 protocol support.
39+// HTTP/1 is disabled so http:// URLs use HTTP/2 prior-knowledge.
40+func newH2CTransport() *http.Transport {
41+ var protos http.Protocols
42+ protos.SetUnencryptedHTTP2(true)
43+ // Do NOT set HTTP1 — this forces HTTP/2 for http:// URLs.
44+ return &http.Transport{
45+ Protocols: &protos,
46+ DialContext: (&net.Dialer{
47+ Timeout: 10 * time.Second,
48+ KeepAlive: 30 * time.Second,
49+ }).DialContext,
50+ ResponseHeaderTimeout: 30 * time.Second,
51+ MaxIdleConns: 100,
52+ MaxIdleConnsPerHost: 10,
53+ IdleConnTimeout: 90 * time.Second,
54+ }
55+}
56+
57+// newRegistryTransport creates the transport for the registry reverse proxy (localhost loopback).
58+// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
59+// perform blocking operations (image pull, container start, readiness checks).
60+func newRegistryTransport() *http.Transport {
61+ return &http.Transport{
62+ DialContext: (&net.Dialer{
63+ Timeout: 10 * time.Second,
64+ KeepAlive: 30 * time.Second,
65+ }).DialContext,
66+ ResponseHeaderTimeout: 3 * time.Minute,
67+ MaxIdleConns: 20,
68+ MaxIdleConnsPerHost: 10,
69+ IdleConnTimeout: 90 * time.Second,
70+ }
71+}
72+
73+// transportForTarget selects the appropriate HTTP transport based on the target's protocol.
74+func (h *Handler) transportForTarget(target *domain.ProxyTarget) http.RoundTripper {
75+ if target.Protocol == "h2c" {
76+ return h.h2cTransport
77+ }
78+ return h.appTransport
79+}
80+
81+// forwardToTarget proxies a request to the resolved target.
82+func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget, maxResponseSize int64) {
83+ log := zerowrap.FromCtx(r.Context())
84+
85+ targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
86+ if err != nil {
87+ log.WrapErr(err, "failed to parse target URL")
88+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
89+ return
90+ }
91+
92+ originalHost := target.OriginalHost
93+ releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
94+ defer releaseInFlight()
95+
96+ transport := h.transportForTarget(target)
97+
98+ proxy := newReverseProxy(targetURL, originalHost, transport,
99+ func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
100+ var maxBytesErr *http.MaxBytesError
101+ if errors.As(proxyErr, &maxBytesErr) {
102+ log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
103+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
104+ return
105+ }
106+ log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
107+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
108+ },
109+ modifyResponse(maxResponseSize),
110+ )
111+
112+ if target.Protocol == "h2c" {
113+ log.Debug().
114+ Str("target", targetURL.String()).
115+ Str("protocol", "h2c").
116+ Str(zerowrap.FieldEntityID, target.ContainerID).
117+ Msg("proxying request via h2c")
118+ } else {
119+ log.Debug().
120+ Str("target", targetURL.String()).
121+ Str(zerowrap.FieldEntityID, target.ContainerID).
122+ Msg("proxying request")
123+ }
124+
125+ proxy.ServeHTTP(w, r)
126+}
127+
128+// forwardToRegistry proxies a request to the local registry server.
129+func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) {
130+ h.proxySvc.TrackRegistryRequest()
131+ defer h.proxySvc.ReleaseRegistryRequest()
132+
133+ log := zerowrap.FromCtx(r.Context())
134+
135+ targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort))
136+ if err != nil {
137+ log.WrapErr(err, "failed to parse registry target URL")
138+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
139+ return
140+ }
141+
142+ proxy := &httputil.ReverseProxy{
143+ Rewrite: func(pr *httputil.ProxyRequest) {
144+ pr.SetURL(targetURL)
145+ pr.SetXForwarded()
146+ pr.Out.Host = targetURL.Host
147+ },
148+ Transport: h.registryTransport,
149+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
150+ var maxBytesErr *http.MaxBytesError
151+ if errors.As(proxyErr, &maxBytesErr) {
152+ log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")
153+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
154+ return
155+ }
156+ log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
157+ proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
158+ },
159+ ModifyResponse: func(resp *http.Response) error {
160+ resp.Header.Set("X-Proxied-By", "Gordon")
161+ resp.Header.Set("X-Registry-Backend", "gordon-registry")
162+
163+ resp.Header.Del("X-Content-Type-Options")
164+ resp.Header.Del("X-Frame-Options")
165+ resp.Header.Del("X-XSS-Protection")
166+ resp.Header.Del("Referrer-Policy")
167+ resp.Header.Del("Permissions-Policy")
168+ resp.Header.Del("Content-Security-Policy")
169+ resp.Header.Del("Strict-Transport-Security")
170+
171+ return nil
172+ },
173+ }
174+
175+ log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
176+ proxy.ServeHTTP(w, r)
177+}
178+
179+// 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"
181+// 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.
183+//
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+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
188+ return &httputil.ReverseProxy{
189+ Rewrite: func(pr *httputil.ProxyRequest) {
190+ pr.SetURL(targetURL)
191+ pr.SetXForwarded()
192+ if originalHost != "" {
193+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
194+ } else {
195+ pr.Out.Host = targetURL.Host
196+ }
197+ },
198+ Transport: transport,
199+ ErrorHandler: errorHandler,
200+ ModifyResponse: modifyResp,
201+ }
202+}
203+
204+// modifyResponse returns a function that adds proxy headers and enforces response size limits.
205+func modifyResponse(maxResponseSize int64) func(*http.Response) error {
206+ return func(resp *http.Response) error {
207+ resp.Header.Set("X-Proxied-By", "Gordon")
208+
209+ if maxResponseSize > 0 {
210+ if resp.ContentLength > maxResponseSize {
211+ resp.Body.Close()
212+ const msg = "Response Too Large"
213+ resp.StatusCode = http.StatusBadGateway
214+ resp.Body = io.NopCloser(strings.NewReader(msg))
215+ resp.ContentLength = int64(len(msg))
216+ resp.Header.Set("Content-Type", "text/plain")
217+ resp.Header.Set("Cache-Control", "no-store")
218+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
219+ resp.Header.Del("Transfer-Encoding")
220+ resp.Header.Del("Content-Encoding")
221+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
222+ return nil
223+ }
224+ if resp.ContentLength < 0 {
225+ resp.Body = &limitedReadCloser{
226+ ReadCloser: resp.Body,
227+ remaining: maxResponseSize,
228+ }
229+ }
230+ }
231+
232+ return nil
233+ }
234+}
235+
236+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
237+type limitedReadCloser struct {
238+ io.ReadCloser
239+ remaining int64
240+ closed bool
241+}
242+
243+func (l *limitedReadCloser) Read(p []byte) (int, error) {
244+ if l.remaining <= 0 {
245+ if !l.closed {
246+ l.closed = true
247+ l.ReadCloser.Close()
248+ }
249+ return 0, fmt.Errorf("response body exceeded maximum size limit")
250+ }
251+ if int64(len(p)) > l.remaining {
252+ p = p[:l.remaining]
253+ }
254+ n, err := l.ReadCloser.Read(p)
255+ l.remaining -= int64(n)
256+ return n, err
257+}
258+
259+func (l *limitedReadCloser) Close() error {
260+ if l.closed {
261+ return nil
262+ }
263+ l.closed = true
264+ return l.ReadCloser.Close()
265+}
266+
267+// proxyError writes an error response with security headers appropriate for
268+// proxy-generated error pages.
269+func proxyError(w http.ResponseWriter, msg string, code int) {
270+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
271+ w.Header().Set("Cache-Control", "no-store")
272+ http.Error(w, msg, code)
273+}
@@ -0,0 +1,273 @@
1+// Package proxy implements the HTTP adapter for the reverse proxy.
2+package proxy
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/http/httputil"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+// newAppTransport creates the shared HTTP/1.1 transport for proxying to application containers.
22+func newAppTransport() *http.Transport {
23+ return &http.Transport{
24+ DialContext: (&net.Dialer{
25+ Timeout: 10 * time.Second,
26+ KeepAlive: 30 * time.Second,
27+ }).DialContext,
28+ TLSHandshakeTimeout: 10 * time.Second,
29+ ResponseHeaderTimeout: 30 * time.Second,
30+ MaxIdleConns: 100,
31+ MaxIdleConnsPerHost: 10,
32+ IdleConnTimeout: 90 * time.Second,
33+ }
34+}
35+
36+// newH2CTransport creates a dedicated cleartext HTTP/2 transport for containers
37+// that opt in via the gordon.proxy.protocol=h2c label.
38+// Uses Go 1.26 native UnencryptedHTTP2 protocol support.
39+// HTTP/1 is disabled so http:// URLs use HTTP/2 prior-knowledge.
40+func newH2CTransport() *http.Transport {
41+ var protos http.Protocols
42+ protos.SetUnencryptedHTTP2(true)
43+ // Do NOT set HTTP1 — this forces HTTP/2 for http:// URLs.
44+ return &http.Transport{
45+ Protocols: &protos,
46+ DialContext: (&net.Dialer{
47+ Timeout: 10 * time.Second,
48+ KeepAlive: 30 * time.Second,
49+ }).DialContext,
50+ ResponseHeaderTimeout: 30 * time.Second,
51+ MaxIdleConns: 100,
52+ MaxIdleConnsPerHost: 10,
53+ IdleConnTimeout: 90 * time.Second,
54+ }
55+}
56+
57+// newRegistryTransport creates the transport for the registry reverse proxy (localhost loopback).
58+// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
59+// perform blocking operations (image pull, container start, readiness checks).
60+func newRegistryTransport() *http.Transport {
61+ return &http.Transport{
62+ DialContext: (&net.Dialer{
63+ Timeout: 10 * time.Second,
64+ KeepAlive: 30 * time.Second,
65+ }).DialContext,
66+ ResponseHeaderTimeout: 3 * time.Minute,
67+ MaxIdleConns: 20,
68+ MaxIdleConnsPerHost: 10,
69+ IdleConnTimeout: 90 * time.Second,
70+ }
71+}
72+
73+// transportForTarget selects the appropriate HTTP transport based on the target's protocol.
74+func (h *Handler) transportForTarget(target *domain.ProxyTarget) http.RoundTripper {
75+ if target.Protocol == "h2c" {
76+ return h.h2cTransport
77+ }
78+ return h.appTransport
79+}
80+
81+// forwardToTarget proxies a request to the resolved target.
82+func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget, maxResponseSize int64) {
83+ log := zerowrap.FromCtx(r.Context())
84+
85+ targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
86+ if err != nil {
87+ log.WrapErr(err, "failed to parse target URL")
88+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
89+ return
90+ }
91+
92+ originalHost := target.OriginalHost
93+ releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
94+ defer releaseInFlight()
95+
96+ transport := h.transportForTarget(target)
97+
98+ proxy := newReverseProxy(targetURL, originalHost, transport,
99+ func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
100+ var maxBytesErr *http.MaxBytesError
101+ if errors.As(proxyErr, &maxBytesErr) {
102+ log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
103+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
104+ return
105+ }
106+ log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
107+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
108+ },
109+ modifyResponse(maxResponseSize),
110+ )
111+
112+ if target.Protocol == "h2c" {
113+ log.Debug().
114+ Str("target", targetURL.String()).
115+ Str("protocol", "h2c").
116+ Str(zerowrap.FieldEntityID, target.ContainerID).
117+ Msg("proxying request via h2c")
118+ } else {
119+ log.Debug().
120+ Str("target", targetURL.String()).
121+ Str(zerowrap.FieldEntityID, target.ContainerID).
122+ Msg("proxying request")
123+ }
124+
125+ proxy.ServeHTTP(w, r)
126+}
127+
128+// forwardToRegistry proxies a request to the local registry server.
129+func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) {
130+ h.proxySvc.TrackRegistryRequest()
131+ defer h.proxySvc.ReleaseRegistryRequest()
132+
133+ log := zerowrap.FromCtx(r.Context())
134+
135+ targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort))
136+ if err != nil {
137+ log.WrapErr(err, "failed to parse registry target URL")
138+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
139+ return
140+ }
141+
142+ proxy := &httputil.ReverseProxy{
143+ Rewrite: func(pr *httputil.ProxyRequest) {
144+ pr.SetURL(targetURL)
145+ pr.SetXForwarded()
146+ pr.Out.Host = targetURL.Host
147+ },
148+ Transport: h.registryTransport,
149+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
150+ var maxBytesErr *http.MaxBytesError
151+ if errors.As(proxyErr, &maxBytesErr) {
152+ log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")
153+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
154+ return
155+ }
156+ log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
157+ proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
158+ },
159+ ModifyResponse: func(resp *http.Response) error {
160+ resp.Header.Set("X-Proxied-By", "Gordon")
161+ resp.Header.Set("X-Registry-Backend", "gordon-registry")
162+
163+ resp.Header.Del("X-Content-Type-Options")
164+ resp.Header.Del("X-Frame-Options")
165+ resp.Header.Del("X-XSS-Protection")
166+ resp.Header.Del("Referrer-Policy")
167+ resp.Header.Del("Permissions-Policy")
168+ resp.Header.Del("Content-Security-Policy")
169+ resp.Header.Del("Strict-Transport-Security")
170+
171+ return nil
172+ },
173+ }
174+
175+ log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
176+ proxy.ServeHTTP(w, r)
177+}
178+
179+// 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"
181+// 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.
183+//
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+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
188+ return &httputil.ReverseProxy{
189+ Rewrite: func(pr *httputil.ProxyRequest) {
190+ pr.SetURL(targetURL)
191+ pr.SetXForwarded()
192+ if originalHost != "" {
193+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
194+ } else {
195+ pr.Out.Host = targetURL.Host
196+ }
197+ },
198+ Transport: transport,
199+ ErrorHandler: errorHandler,
200+ ModifyResponse: modifyResp,
201+ }
202+}
203+
204+// modifyResponse returns a function that adds proxy headers and enforces response size limits.
205+func modifyResponse(maxResponseSize int64) func(*http.Response) error {
206+ return func(resp *http.Response) error {
207+ resp.Header.Set("X-Proxied-By", "Gordon")
208+
209+ if maxResponseSize > 0 {
210+ if resp.ContentLength > maxResponseSize {
211+ resp.Body.Close()
212+ const msg = "Response Too Large"
213+ resp.StatusCode = http.StatusBadGateway
214+ resp.Body = io.NopCloser(strings.NewReader(msg))
215+ resp.ContentLength = int64(len(msg))
216+ resp.Header.Set("Content-Type", "text/plain")
217+ resp.Header.Set("Cache-Control", "no-store")
218+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
219+ resp.Header.Del("Transfer-Encoding")
220+ resp.Header.Del("Content-Encoding")
221+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
222+ return nil
223+ }
224+ if resp.ContentLength < 0 {
225+ resp.Body = &limitedReadCloser{
226+ ReadCloser: resp.Body,
227+ remaining: maxResponseSize,
228+ }
229+ }
230+ }
231+
232+ return nil
233+ }
234+}
235+
236+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
237+type limitedReadCloser struct {
238+ io.ReadCloser
239+ remaining int64
240+ closed bool
241+}
242+
243+func (l *limitedReadCloser) Read(p []byte) (int, error) {
244+ if l.remaining <= 0 {
245+ if !l.closed {
246+ l.closed = true
247+ l.ReadCloser.Close()
248+ }
249+ return 0, fmt.Errorf("response body exceeded maximum size limit")
250+ }
251+ if int64(len(p)) > l.remaining {
252+ p = p[:l.remaining]
253+ }
254+ n, err := l.ReadCloser.Read(p)
255+ l.remaining -= int64(n)
256+ return n, err
257+}
258+
259+func (l *limitedReadCloser) Close() error {
260+ if l.closed {
261+ return nil
262+ }
263+ l.closed = true
264+ return l.ReadCloser.Close()
265+}
266+
267+// proxyError writes an error response with security headers appropriate for
268+// proxy-generated error pages.
269+func proxyError(w http.ResponseWriter, msg string, code int) {
270+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
271+ w.Header().Set("Cache-Control", "no-store")
272+ http.Error(w, msg, code)
273+}
M internal/adapters/in/http/proxy/handler.go +64 -50
@@ -2,77 +2,91 @@
22 package proxy
33
44 import (
5- "context"
65 "net/http"
7- "strconv"
8- "time"
6+ "sync/atomic"
97
108 "github.com/bnema/zerowrap"
119
1210 "github.com/bnema/gordon/internal/boundaries/in"
1311 )
1412
15-// Handler wraps the proxy service for HTTP.
16-// The actual proxying logic is in the usecase layer (ProxyService implements http.Handler).
13+// Handler implements http.Handler for the reverse proxy.
14+// It owns all HTTP-level concerns: concurrency limiting, body size enforcement,
15+// transport selection, and reverse proxy execution.
16+// Routing decisions are delegated to the ProxyService usecase.
1717 type Handler struct {
18- proxySvc in.ProxyService
19- log zerowrap.Logger
20- port int
18+ proxySvc in.ProxyService
19+ log zerowrap.Logger
20+ appTransport http.RoundTripper
21+ h2cTransport http.RoundTripper
22+ registryTransport http.RoundTripper
23+ activeConns atomic.Int64
2124 }
2225
2326 // NewHandler creates a new proxy HTTP handler.
24-func NewHandler(proxySvc in.ProxyService, port int, log zerowrap.Logger) *Handler {
27+func NewHandler(proxySvc in.ProxyService, log zerowrap.Logger) *Handler {
2528 return &Handler{
26- proxySvc: proxySvc,
27- port: port,
28- log: log,
29+ proxySvc: proxySvc,
30+ log: log,
31+ appTransport: newAppTransport(),
32+ h2cTransport: newH2CTransport(),
33+ registryTransport: newRegistryTransport(),
2934 }
3035 }
3136
32-// ServeHTTP implements http.Handler by delegating to the proxy service.
37+// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
3338 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
34- h.proxySvc.ServeHTTP(w, r)
35-}
39+ cfg := h.proxySvc.ProxyConfig()
3640
37-// Start starts the proxy HTTP server.
38-func (h *Handler) Start(ctx context.Context, handler http.Handler) error {
39- addr := ":" + strconv.Itoa(h.port)
41+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
42+ if cfg.MaxConcurrentConns > 0 {
43+ current := h.activeConns.Add(1)
44+ if current > int64(cfg.MaxConcurrentConns) {
45+ h.activeConns.Add(-1)
46+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
47+ return
48+ }
49+ defer h.activeConns.Add(-1)
50+ }
4051
41- server := &http.Server{
42- Addr: addr,
43- Handler: handler,
44- ReadHeaderTimeout: 10 * time.Second,
45- ReadTimeout: 5 * time.Minute, // Timeout for reading entire request
46- WriteTimeout: 5 * time.Minute, // Timeout for writing response
47- IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
48- MaxHeaderBytes: 1 << 20, // 1MB max header size
52+ // SECURITY: Limit request body size to prevent resource exhaustion.
53+ if cfg.MaxBodySize > 0 {
54+ r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
4955 }
5056
51- h.log.Info().
52- Str(zerowrap.FieldLayer, "adapter").
53- Str(zerowrap.FieldAdapter, "http").
54- Str("address", addr).
55- Msg("proxy server starting")
57+ // Enrich request context with fields for downstream logging
58+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
59+ zerowrap.FieldLayer: "adapter",
60+ zerowrap.FieldAdapter: "http",
61+ zerowrap.FieldMethod: r.Method,
62+ zerowrap.FieldPath: r.URL.Path,
63+ zerowrap.FieldHost: r.Host,
64+ zerowrap.FieldClientIP: r.RemoteAddr,
65+ })
66+ r = r.WithContext(ctx)
67+ log := zerowrap.FromCtx(ctx)
5668
57- // Start server in a goroutine
58- errChan := make(chan error, 1)
59- go func() {
60- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
61- errChan <- err
62- }
63- }()
69+ // Check if this is the registry domain
70+ if h.proxySvc.IsRegistryDomain(r.Host) {
71+ log.Info().Msg("routing request to registry")
72+ h.forwardToRegistry(w, r, cfg.RegistryPort)
73+ return
74+ }
6475
65- // Wait for context cancellation or server error
66- select {
67- case err := <-errChan:
68- return err
69- case <-ctx.Done():
70- h.log.Info().
71- Str(zerowrap.FieldLayer, "adapter").
72- Str(zerowrap.FieldAdapter, "http").
73- Msg("proxy server shutting down")
74- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
75- defer shutdownCancel()
76- return server.Shutdown(shutdownCtx)
76+ // Get target for this domain
77+ log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
78+ target, err := h.proxySvc.GetTarget(ctx, r.Host)
79+ if err != nil {
80+ log.Warn().Err(err).Msg("no route found for domain")
81+ proxyError(w, "404 page not found", http.StatusNotFound)
82+ return
7783 }
84+
85+ log.Debug().
86+ Str("host", target.Host).
87+ Int("port", target.Port).
88+ Str("container_id", target.ContainerID).
89+ Msg("resolved proxy target")
90+
91+ h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
7892 }
@@ -2,77 +2,91 @@
2 package proxy2 package proxy
3 3
4 import (4 import (
5- "context"
6 "net/http"5 "net/http"
7- "strconv"6+ "sync/atomic"
8- "time"
9 7
10 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
11 9
12 "github.com/bnema/gordon/internal/boundaries/in"10 "github.com/bnema/gordon/internal/boundaries/in"
13 )11 )
14 12
15-// Handler wraps the proxy service for HTTP.13+// Handler implements http.Handler for the reverse proxy.
16-// The actual proxying logic is in the usecase layer (ProxyService implements http.Handler).14+// It owns all HTTP-level concerns: concurrency limiting, body size enforcement,
15+// transport selection, and reverse proxy execution.
16+// Routing decisions are delegated to the ProxyService usecase.
17 type Handler struct {17 type Handler struct {
18- proxySvc in.ProxyService18+ proxySvc in.ProxyService
19- log zerowrap.Logger19+ log zerowrap.Logger
20- port int20+ appTransport http.RoundTripper
21+ h2cTransport http.RoundTripper
22+ registryTransport http.RoundTripper
23+ activeConns atomic.Int64
21 }24 }
22 25
23 // NewHandler creates a new proxy HTTP handler.26 // NewHandler creates a new proxy HTTP handler.
24-func NewHandler(proxySvc in.ProxyService, port int, log zerowrap.Logger) *Handler {27+func NewHandler(proxySvc in.ProxyService, log zerowrap.Logger) *Handler {
25 return &Handler{28 return &Handler{
26- proxySvc: proxySvc,29+ proxySvc: proxySvc,
27- port: port,30+ log: log,
28- log: log,31+ appTransport: newAppTransport(),
32+ h2cTransport: newH2CTransport(),
33+ registryTransport: newRegistryTransport(),
29 }34 }
30 }35 }
31 36
32-// ServeHTTP implements http.Handler by delegating to the proxy service.37+// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
33 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {38 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
34- h.proxySvc.ServeHTTP(w, r)39+ cfg := h.proxySvc.ProxyConfig()
35-}
36 40
37-// Start starts the proxy HTTP server.41+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
38-func (h *Handler) Start(ctx context.Context, handler http.Handler) error {42+ if cfg.MaxConcurrentConns > 0 {
39- addr := ":" + strconv.Itoa(h.port)43+ current := h.activeConns.Add(1)
44+ if current > int64(cfg.MaxConcurrentConns) {
45+ h.activeConns.Add(-1)
46+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
47+ return
48+ }
49+ defer h.activeConns.Add(-1)
50+ }
40 51
41- server := &http.Server{52+ // SECURITY: Limit request body size to prevent resource exhaustion.
42- Addr: addr,53+ if cfg.MaxBodySize > 0 {
43- Handler: handler,54+ r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
44- ReadHeaderTimeout: 10 * time.Second,
45- ReadTimeout: 5 * time.Minute, // Timeout for reading entire request
46- WriteTimeout: 5 * time.Minute, // Timeout for writing response
47- IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
48- MaxHeaderBytes: 1 << 20, // 1MB max header size
49 }55 }
50 56
51- h.log.Info().57+ // Enrich request context with fields for downstream logging
52- Str(zerowrap.FieldLayer, "adapter").58+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
53- Str(zerowrap.FieldAdapter, "http").59+ zerowrap.FieldLayer: "adapter",
54- Str("address", addr).60+ zerowrap.FieldAdapter: "http",
55- Msg("proxy server starting")61+ zerowrap.FieldMethod: r.Method,
62+ zerowrap.FieldPath: r.URL.Path,
63+ zerowrap.FieldHost: r.Host,
64+ zerowrap.FieldClientIP: r.RemoteAddr,
65+ })
66+ r = r.WithContext(ctx)
67+ log := zerowrap.FromCtx(ctx)
56 68
57- // Start server in a goroutine69+ // Check if this is the registry domain
58- errChan := make(chan error, 1)70+ if h.proxySvc.IsRegistryDomain(r.Host) {
59- go func() {71+ log.Info().Msg("routing request to registry")
60- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {72+ h.forwardToRegistry(w, r, cfg.RegistryPort)
61- errChan <- err73+ return
62- }74+ }
63- }()
64 75
65- // Wait for context cancellation or server error76+ // Get target for this domain
66- select {77+ log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
67- case err := <-errChan:78+ target, err := h.proxySvc.GetTarget(ctx, r.Host)
68- return err79+ if err != nil {
69- case <-ctx.Done():80+ log.Warn().Err(err).Msg("no route found for domain")
70- h.log.Info().81+ proxyError(w, "404 page not found", http.StatusNotFound)
71- Str(zerowrap.FieldLayer, "adapter").82+ return
72- Str(zerowrap.FieldAdapter, "http").
73- Msg("proxy server shutting down")
74- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
75- defer shutdownCancel()
76- return server.Shutdown(shutdownCtx)
77 }83 }
84+
85+ log.Debug().
86+ Str("host", target.Host).
87+ Int("port", target.Port).
88+ Str("container_id", target.ContainerID).
89+ Msg("resolved proxy target")
90+
91+ h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
78 }92 }
M internal/app/run.go +13 -4
@@ -50,6 +50,7 @@ import (
5050 "github.com/bnema/gordon/internal/adapters/in/http/admin"
5151 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
5252 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
53+ proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
5354 "github.com/bnema/gordon/internal/adapters/in/http/registry"
5455
5556 // Boundaries
@@ -1455,8 +1456,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
14551456 middleware.SecurityHeaders,
14561457 }
14571458
1459+ proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
14581460 proxyWithMiddleware := otelhttp.NewHandler(
1459- middleware.Chain(proxyMiddlewares...)(svc.proxySvc),
1461+ middleware.Chain(proxyMiddlewares...)(proxyHandler),
14601462 "gordon.proxy",
14611463 )
14621464 proxyMux := http.NewServeMux()
@@ -1719,8 +1721,14 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17191721
17201722 errChan := make(chan error, 3)
17211723
1722- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1723- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1724+ registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
1725+
1726+ // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1727+ // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1728+ var proxyProtos http.Protocols
1729+ proxyProtos.SetHTTP1(true)
1730+ proxyProtos.SetUnencryptedHTTP2(true)
1731+ proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
17241732 var tlsSrv *http.Server
17251733 var tlsReady <-chan struct{}
17261734 if cfg.Server.TLSEnabled {
@@ -2019,12 +2027,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
20192027 // port to be ready before taking actions that depend on it (e.g. auto-start
20202028 // pulling from the local registry). The returned *http.Server can be used for
20212029 // graceful shutdown.
2022-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
2030+func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
20232031 ready := make(chan struct{})
20242032
20252033 server := &http.Server{
20262034 Addr: addr,
20272035 Handler: handler,
2036+ Protocols: protocols,
20282037 ReadHeaderTimeout: 10 * time.Second,
20292038 ReadTimeout: 5 * time.Minute,
20302039 WriteTimeout: 5 * time.Minute,
@@ -50,6 +50,7 @@ import (
50 "github.com/bnema/gordon/internal/adapters/in/http/admin"50 "github.com/bnema/gordon/internal/adapters/in/http/admin"
51 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"51 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
52 "github.com/bnema/gordon/internal/adapters/in/http/middleware"52 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
53+ proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
53 "github.com/bnema/gordon/internal/adapters/in/http/registry"54 "github.com/bnema/gordon/internal/adapters/in/http/registry"
54 55
55 // Boundaries56 // Boundaries
@@ -1455,8 +1456,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1455 middleware.SecurityHeaders,1456 middleware.SecurityHeaders,
1456 }1457 }
1457 1458
1459+ proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
1458 proxyWithMiddleware := otelhttp.NewHandler(1460 proxyWithMiddleware := otelhttp.NewHandler(
1459- middleware.Chain(proxyMiddlewares...)(svc.proxySvc),1461+ middleware.Chain(proxyMiddlewares...)(proxyHandler),
1460 "gordon.proxy",1462 "gordon.proxy",
1461 )1463 )
1462 proxyMux := http.NewServeMux()1464 proxyMux := http.NewServeMux()
@@ -1719,8 +1721,14 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1719 1721
1720 errChan := make(chan error, 3)1722 errChan := make(chan error, 3)
1721 1723
1722- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)1724+ registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
1723- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)1725+
1726+ // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1727+ // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1728+ var proxyProtos http.Protocols
1729+ proxyProtos.SetHTTP1(true)
1730+ proxyProtos.SetUnencryptedHTTP2(true)
1731+ proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1724 var tlsSrv *http.Server1732 var tlsSrv *http.Server
1725 var tlsReady <-chan struct{}1733 var tlsReady <-chan struct{}
1726 if cfg.Server.TLSEnabled {1734 if cfg.Server.TLSEnabled {
@@ -2019,12 +2027,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
2019 // port to be ready before taking actions that depend on it (e.g. auto-start2027 // port to be ready before taking actions that depend on it (e.g. auto-start
2020 // pulling from the local registry). The returned *http.Server can be used for2028 // pulling from the local registry). The returned *http.Server can be used for
2021 // graceful shutdown.2029 // graceful shutdown.
2022-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {2030+func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
2023 ready := make(chan struct{})2031 ready := make(chan struct{})
2024 2032
2025 server := &http.Server{2033 server := &http.Server{
2026 Addr: addr,2034 Addr: addr,
2027 Handler: handler,2035 Handler: handler,
2036+ Protocols: protocols,
2028 ReadHeaderTimeout: 10 * time.Second,2037 ReadHeaderTimeout: 10 * time.Second,
2029 ReadTimeout: 5 * time.Minute,2038 ReadTimeout: 5 * time.Minute,
2030 WriteTimeout: 5 * time.Minute,2039 WriteTimeout: 5 * time.Minute,
M internal/boundaries/in/mocks/mock_proxy_service.go +189 -21
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "net/http"
109
10+ "github.com/bnema/gordon/internal/boundaries/in"
1111 "github.com/bnema/gordon/internal/domain"
1212 mock "github.com/stretchr/testify/mock"
1313 )
@@ -107,6 +107,101 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con
107107 return _c
108108 }
109109
110+// IsRegistryDomain provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112+ ret := _mock.Called(host)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for IsRegistryDomain")
116+ }
117+
118+ var r0 bool
119+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
120+ r0 = returnFunc(host)
121+ } else {
122+ r0 = ret.Get(0).(bool)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_IsRegistryDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRegistryDomain'
128+type MockProxyService_IsRegistryDomain_Call struct {
129+ *mock.Call
130+}
131+
132+// IsRegistryDomain is a helper method to define mock.On call
133+// - host string
134+func (_e *MockProxyService_Expecter) IsRegistryDomain(host interface{}) *MockProxyService_IsRegistryDomain_Call {
135+ return &MockProxyService_IsRegistryDomain_Call{Call: _e.mock.On("IsRegistryDomain", host)}
136+}
137+
138+func (_c *MockProxyService_IsRegistryDomain_Call) Run(run func(host string)) *MockProxyService_IsRegistryDomain_Call {
139+ _c.Call.Run(func(args mock.Arguments) {
140+ var arg0 string
141+ if args[0] != nil {
142+ arg0 = args[0].(string)
143+ }
144+ run(
145+ arg0,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *MockProxyService_IsRegistryDomain_Call) Return(b bool) *MockProxyService_IsRegistryDomain_Call {
152+ _c.Call.Return(b)
153+ return _c
154+}
155+
156+func (_c *MockProxyService_IsRegistryDomain_Call) RunAndReturn(run func(host string) bool) *MockProxyService_IsRegistryDomain_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
161+// ProxyConfig provides a mock function for the type MockProxyService
162+func (_mock *MockProxyService) ProxyConfig() in.ProxyServiceConfig {
163+ ret := _mock.Called()
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for ProxyConfig")
167+ }
168+
169+ var r0 in.ProxyServiceConfig
170+ if returnFunc, ok := ret.Get(0).(func() in.ProxyServiceConfig); ok {
171+ r0 = returnFunc()
172+ } else {
173+ r0 = ret.Get(0).(in.ProxyServiceConfig)
174+ }
175+ return r0
176+}
177+
178+// MockProxyService_ProxyConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProxyConfig'
179+type MockProxyService_ProxyConfig_Call struct {
180+ *mock.Call
181+}
182+
183+// ProxyConfig is a helper method to define mock.On call
184+func (_e *MockProxyService_Expecter) ProxyConfig() *MockProxyService_ProxyConfig_Call {
185+ return &MockProxyService_ProxyConfig_Call{Call: _e.mock.On("ProxyConfig")}
186+}
187+
188+func (_c *MockProxyService_ProxyConfig_Call) Run(run func()) *MockProxyService_ProxyConfig_Call {
189+ _c.Call.Run(func(args mock.Arguments) {
190+ run()
191+ })
192+ return _c
193+}
194+
195+func (_c *MockProxyService_ProxyConfig_Call) Return(proxyServiceConfig in.ProxyServiceConfig) *MockProxyService_ProxyConfig_Call {
196+ _c.Call.Return(proxyServiceConfig)
197+ return _c
198+}
199+
200+func (_c *MockProxyService_ProxyConfig_Call) RunAndReturn(run func() in.ProxyServiceConfig) *MockProxyService_ProxyConfig_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
204+
110205 // RefreshTargets provides a mock function for the type MockProxyService
111206 func (_mock *MockProxyService) RefreshTargets(ctx context.Context) error {
112207 ret := _mock.Called(ctx)
@@ -221,48 +316,121 @@ func (_c *MockProxyService_RegisterTarget_Call) RunAndReturn(run func(ctx contex
221316 return _c
222317 }
223318
224-// ServeHTTP provides a mock function for the type MockProxyService
225-func (_mock *MockProxyService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
226- _mock.Called(w, r)
319+// ReleaseRegistryRequest provides a mock function for the type MockProxyService
320+func (_mock *MockProxyService) ReleaseRegistryRequest() {
321+ _mock.Called()
227322 return
228323 }
229324
230-// MockProxyService_ServeHTTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServeHTTP'
231-type MockProxyService_ServeHTTP_Call struct {
325+// MockProxyService_ReleaseRegistryRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseRegistryRequest'
326+type MockProxyService_ReleaseRegistryRequest_Call struct {
232327 *mock.Call
233328 }
234329
235-// ServeHTTP is a helper method to define mock.On call
236-// - w http.ResponseWriter
237-// - r *http.Request
238-func (_e *MockProxyService_Expecter) ServeHTTP(w interface{}, r interface{}) *MockProxyService_ServeHTTP_Call {
239- return &MockProxyService_ServeHTTP_Call{Call: _e.mock.On("ServeHTTP", w, r)}
330+// ReleaseRegistryRequest is a helper method to define mock.On call
331+func (_e *MockProxyService_Expecter) ReleaseRegistryRequest() *MockProxyService_ReleaseRegistryRequest_Call {
332+ return &MockProxyService_ReleaseRegistryRequest_Call{Call: _e.mock.On("ReleaseRegistryRequest")}
240333 }
241334
242-func (_c *MockProxyService_ServeHTTP_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {
335+func (_c *MockProxyService_ReleaseRegistryRequest_Call) Run(run func()) *MockProxyService_ReleaseRegistryRequest_Call {
243336 _c.Call.Run(func(args mock.Arguments) {
244- var arg0 http.ResponseWriter
245- if args[0] != nil {
246- arg0 = args[0].(http.ResponseWriter)
337+ run()
338+ })
339+ return _c
340+}
341+
342+func (_c *MockProxyService_ReleaseRegistryRequest_Call) Return() *MockProxyService_ReleaseRegistryRequest_Call {
343+ _c.Call.Return()
344+ return _c
345+}
346+
347+func (_c *MockProxyService_ReleaseRegistryRequest_Call) RunAndReturn(run func()) *MockProxyService_ReleaseRegistryRequest_Call {
348+ _c.Run(run)
349+ return _c
350+}
351+
352+// TrackInFlight provides a mock function for the type MockProxyService
353+func (_mock *MockProxyService) TrackInFlight(containerID string) func() {
354+ ret := _mock.Called(containerID)
355+
356+ if len(ret) == 0 {
357+ panic("no return value specified for TrackInFlight")
358+ }
359+
360+ var r0 func()
361+ if returnFunc, ok := ret.Get(0).(func(string) func()); ok {
362+ r0 = returnFunc(containerID)
363+ } else {
364+ if ret.Get(0) != nil {
365+ r0 = ret.Get(0).(func())
247366 }
248- var arg1 *http.Request
249- if args[1] != nil {
250- arg1 = args[1].(*http.Request)
367+ }
368+ return r0
369+}
370+
371+// MockProxyService_TrackInFlight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrackInFlight'
372+type MockProxyService_TrackInFlight_Call struct {
373+ *mock.Call
374+}
375+
376+// TrackInFlight is a helper method to define mock.On call
377+// - containerID string
378+func (_e *MockProxyService_Expecter) TrackInFlight(containerID interface{}) *MockProxyService_TrackInFlight_Call {
379+ return &MockProxyService_TrackInFlight_Call{Call: _e.mock.On("TrackInFlight", containerID)}
380+}
381+
382+func (_c *MockProxyService_TrackInFlight_Call) Run(run func(containerID string)) *MockProxyService_TrackInFlight_Call {
383+ _c.Call.Run(func(args mock.Arguments) {
384+ var arg0 string
385+ if args[0] != nil {
386+ arg0 = args[0].(string)
251387 }
252388 run(
253389 arg0,
254- arg1,
255390 )
256391 })
257392 return _c
258393 }
259394
260-func (_c *MockProxyService_ServeHTTP_Call) Return() *MockProxyService_ServeHTTP_Call {
395+func (_c *MockProxyService_TrackInFlight_Call) Return(fn func()) *MockProxyService_TrackInFlight_Call {
396+ _c.Call.Return(fn)
397+ return _c
398+}
399+
400+func (_c *MockProxyService_TrackInFlight_Call) RunAndReturn(run func(containerID string) func()) *MockProxyService_TrackInFlight_Call {
401+ _c.Call.Return(run)
402+ return _c
403+}
404+
405+// TrackRegistryRequest provides a mock function for the type MockProxyService
406+func (_mock *MockProxyService) TrackRegistryRequest() {
407+ _mock.Called()
408+ return
409+}
410+
411+// MockProxyService_TrackRegistryRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrackRegistryRequest'
412+type MockProxyService_TrackRegistryRequest_Call struct {
413+ *mock.Call
414+}
415+
416+// TrackRegistryRequest is a helper method to define mock.On call
417+func (_e *MockProxyService_Expecter) TrackRegistryRequest() *MockProxyService_TrackRegistryRequest_Call {
418+ return &MockProxyService_TrackRegistryRequest_Call{Call: _e.mock.On("TrackRegistryRequest")}
419+}
420+
421+func (_c *MockProxyService_TrackRegistryRequest_Call) Run(run func()) *MockProxyService_TrackRegistryRequest_Call {
422+ _c.Call.Run(func(args mock.Arguments) {
423+ run()
424+ })
425+ return _c
426+}
427+
428+func (_c *MockProxyService_TrackRegistryRequest_Call) Return() *MockProxyService_TrackRegistryRequest_Call {
261429 _c.Call.Return()
262430 return _c
263431 }
264432
265-func (_c *MockProxyService_ServeHTTP_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {
433+func (_c *MockProxyService_TrackRegistryRequest_Call) RunAndReturn(run func()) *MockProxyService_TrackRegistryRequest_Call {
266434 _c.Run(run)
267435 return _c
268436 }
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "net/http"
10 9
10+ "github.com/bnema/gordon/internal/boundaries/in"
11 "github.com/bnema/gordon/internal/domain"11 "github.com/bnema/gordon/internal/domain"
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
@@ -107,6 +107,101 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con
107 return _c107 return _c
108 }108 }
109 109
110+// IsRegistryDomain provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112+ ret := _mock.Called(host)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for IsRegistryDomain")
116+ }
117+
118+ var r0 bool
119+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
120+ r0 = returnFunc(host)
121+ } else {
122+ r0 = ret.Get(0).(bool)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_IsRegistryDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRegistryDomain'
128+type MockProxyService_IsRegistryDomain_Call struct {
129+ *mock.Call
130+}
131+
132+// IsRegistryDomain is a helper method to define mock.On call
133+// - host string
134+func (_e *MockProxyService_Expecter) IsRegistryDomain(host interface{}) *MockProxyService_IsRegistryDomain_Call {
135+ return &MockProxyService_IsRegistryDomain_Call{Call: _e.mock.On("IsRegistryDomain", host)}
136+}
137+
138+func (_c *MockProxyService_IsRegistryDomain_Call) Run(run func(host string)) *MockProxyService_IsRegistryDomain_Call {
139+ _c.Call.Run(func(args mock.Arguments) {
140+ var arg0 string
141+ if args[0] != nil {
142+ arg0 = args[0].(string)
143+ }
144+ run(
145+ arg0,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *MockProxyService_IsRegistryDomain_Call) Return(b bool) *MockProxyService_IsRegistryDomain_Call {
152+ _c.Call.Return(b)
153+ return _c
154+}
155+
156+func (_c *MockProxyService_IsRegistryDomain_Call) RunAndReturn(run func(host string) bool) *MockProxyService_IsRegistryDomain_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
161+// ProxyConfig provides a mock function for the type MockProxyService
162+func (_mock *MockProxyService) ProxyConfig() in.ProxyServiceConfig {
163+ ret := _mock.Called()
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for ProxyConfig")
167+ }
168+
169+ var r0 in.ProxyServiceConfig
170+ if returnFunc, ok := ret.Get(0).(func() in.ProxyServiceConfig); ok {
171+ r0 = returnFunc()
172+ } else {
173+ r0 = ret.Get(0).(in.ProxyServiceConfig)
174+ }
175+ return r0
176+}
177+
178+// MockProxyService_ProxyConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProxyConfig'
179+type MockProxyService_ProxyConfig_Call struct {
180+ *mock.Call
181+}
182+
183+// ProxyConfig is a helper method to define mock.On call
184+func (_e *MockProxyService_Expecter) ProxyConfig() *MockProxyService_ProxyConfig_Call {
185+ return &MockProxyService_ProxyConfig_Call{Call: _e.mock.On("ProxyConfig")}
186+}
187+
188+func (_c *MockProxyService_ProxyConfig_Call) Run(run func()) *MockProxyService_ProxyConfig_Call {
189+ _c.Call.Run(func(args mock.Arguments) {
190+ run()
191+ })
192+ return _c
193+}
194+
195+func (_c *MockProxyService_ProxyConfig_Call) Return(proxyServiceConfig in.ProxyServiceConfig) *MockProxyService_ProxyConfig_Call {
196+ _c.Call.Return(proxyServiceConfig)
197+ return _c
198+}
199+
200+func (_c *MockProxyService_ProxyConfig_Call) RunAndReturn(run func() in.ProxyServiceConfig) *MockProxyService_ProxyConfig_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
204+
110 // RefreshTargets provides a mock function for the type MockProxyService205 // RefreshTargets provides a mock function for the type MockProxyService
111 func (_mock *MockProxyService) RefreshTargets(ctx context.Context) error {206 func (_mock *MockProxyService) RefreshTargets(ctx context.Context) error {
112 ret := _mock.Called(ctx)207 ret := _mock.Called(ctx)
@@ -221,48 +316,121 @@ func (_c *MockProxyService_RegisterTarget_Call) RunAndReturn(run func(ctx contex
221 return _c316 return _c
222 }317 }
223 318
224-// ServeHTTP provides a mock function for the type MockProxyService319+// ReleaseRegistryRequest provides a mock function for the type MockProxyService
225-func (_mock *MockProxyService) ServeHTTP(w http.ResponseWriter, r *http.Request) {320+func (_mock *MockProxyService) ReleaseRegistryRequest() {
226- _mock.Called(w, r)321+ _mock.Called()
227 return322 return
228 }323 }
229 324
230-// MockProxyService_ServeHTTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServeHTTP'325+// MockProxyService_ReleaseRegistryRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseRegistryRequest'
231-type MockProxyService_ServeHTTP_Call struct {326+type MockProxyService_ReleaseRegistryRequest_Call struct {
232 *mock.Call327 *mock.Call
233 }328 }
234 329
235-// ServeHTTP is a helper method to define mock.On call330+// ReleaseRegistryRequest is a helper method to define mock.On call
236-// - w http.ResponseWriter331+func (_e *MockProxyService_Expecter) ReleaseRegistryRequest() *MockProxyService_ReleaseRegistryRequest_Call {
237-// - r *http.Request332+ return &MockProxyService_ReleaseRegistryRequest_Call{Call: _e.mock.On("ReleaseRegistryRequest")}
238-func (_e *MockProxyService_Expecter) ServeHTTP(w interface{}, r interface{}) *MockProxyService_ServeHTTP_Call {
239- return &MockProxyService_ServeHTTP_Call{Call: _e.mock.On("ServeHTTP", w, r)}
240 }333 }
241 334
242-func (_c *MockProxyService_ServeHTTP_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {335+func (_c *MockProxyService_ReleaseRegistryRequest_Call) Run(run func()) *MockProxyService_ReleaseRegistryRequest_Call {
243 _c.Call.Run(func(args mock.Arguments) {336 _c.Call.Run(func(args mock.Arguments) {
244- var arg0 http.ResponseWriter337+ run()
245- if args[0] != nil {338+ })
246- arg0 = args[0].(http.ResponseWriter)339+ return _c
340+}
341+
342+func (_c *MockProxyService_ReleaseRegistryRequest_Call) Return() *MockProxyService_ReleaseRegistryRequest_Call {
343+ _c.Call.Return()
344+ return _c
345+}
346+
347+func (_c *MockProxyService_ReleaseRegistryRequest_Call) RunAndReturn(run func()) *MockProxyService_ReleaseRegistryRequest_Call {
348+ _c.Run(run)
349+ return _c
350+}
351+
352+// TrackInFlight provides a mock function for the type MockProxyService
353+func (_mock *MockProxyService) TrackInFlight(containerID string) func() {
354+ ret := _mock.Called(containerID)
355+
356+ if len(ret) == 0 {
357+ panic("no return value specified for TrackInFlight")
358+ }
359+
360+ var r0 func()
361+ if returnFunc, ok := ret.Get(0).(func(string) func()); ok {
362+ r0 = returnFunc(containerID)
363+ } else {
364+ if ret.Get(0) != nil {
365+ r0 = ret.Get(0).(func())
247 }366 }
248- var arg1 *http.Request367+ }
249- if args[1] != nil {368+ return r0
250- arg1 = args[1].(*http.Request)369+}
370+
371+// MockProxyService_TrackInFlight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrackInFlight'
372+type MockProxyService_TrackInFlight_Call struct {
373+ *mock.Call
374+}
375+
376+// TrackInFlight is a helper method to define mock.On call
377+// - containerID string
378+func (_e *MockProxyService_Expecter) TrackInFlight(containerID interface{}) *MockProxyService_TrackInFlight_Call {
379+ return &MockProxyService_TrackInFlight_Call{Call: _e.mock.On("TrackInFlight", containerID)}
380+}
381+
382+func (_c *MockProxyService_TrackInFlight_Call) Run(run func(containerID string)) *MockProxyService_TrackInFlight_Call {
383+ _c.Call.Run(func(args mock.Arguments) {
384+ var arg0 string
385+ if args[0] != nil {
386+ arg0 = args[0].(string)
251 }387 }
252 run(388 run(
253 arg0,389 arg0,
254- arg1,
255 )390 )
256 })391 })
257 return _c392 return _c
258 }393 }
259 394
260-func (_c *MockProxyService_ServeHTTP_Call) Return() *MockProxyService_ServeHTTP_Call {395+func (_c *MockProxyService_TrackInFlight_Call) Return(fn func()) *MockProxyService_TrackInFlight_Call {
396+ _c.Call.Return(fn)
397+ return _c
398+}
399+
400+func (_c *MockProxyService_TrackInFlight_Call) RunAndReturn(run func(containerID string) func()) *MockProxyService_TrackInFlight_Call {
401+ _c.Call.Return(run)
402+ return _c
403+}
404+
405+// TrackRegistryRequest provides a mock function for the type MockProxyService
406+func (_mock *MockProxyService) TrackRegistryRequest() {
407+ _mock.Called()
408+ return
409+}
410+
411+// MockProxyService_TrackRegistryRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrackRegistryRequest'
412+type MockProxyService_TrackRegistryRequest_Call struct {
413+ *mock.Call
414+}
415+
416+// TrackRegistryRequest is a helper method to define mock.On call
417+func (_e *MockProxyService_Expecter) TrackRegistryRequest() *MockProxyService_TrackRegistryRequest_Call {
418+ return &MockProxyService_TrackRegistryRequest_Call{Call: _e.mock.On("TrackRegistryRequest")}
419+}
420+
421+func (_c *MockProxyService_TrackRegistryRequest_Call) Run(run func()) *MockProxyService_TrackRegistryRequest_Call {
422+ _c.Call.Run(func(args mock.Arguments) {
423+ run()
424+ })
425+ return _c
426+}
427+
428+func (_c *MockProxyService_TrackRegistryRequest_Call) Return() *MockProxyService_TrackRegistryRequest_Call {
261 _c.Call.Return()429 _c.Call.Return()
262 return _c430 return _c
263 }431 }
264 432
265-func (_c *MockProxyService_ServeHTTP_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {433+func (_c *MockProxyService_TrackRegistryRequest_Call) RunAndReturn(run func()) *MockProxyService_TrackRegistryRequest_Call {
266 _c.Run(run)434 _c.Run(run)
267 return _c435 return _c
268 }436 }
M internal/boundaries/in/proxy.go +29 -5
@@ -2,16 +2,13 @@ package in
22
33 import (
44 "context"
5- "net/http"
65
76 "github.com/bnema/gordon/internal/domain"
87 )
98
10-// ProxyService defines the contract for reverse proxy operations.
9+// ProxyService defines the contract for proxy routing and state management.
10+// HTTP request handling is the adapter's responsibility (adapters/in/http/proxy).
1111 type ProxyService interface {
12- // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
13- ServeHTTP(w http.ResponseWriter, r *http.Request)
14-
1512 // GetTarget returns the proxy target for a given domain.
1613 GetTarget(ctx context.Context, domain string) (*domain.ProxyTarget, error)
1714
@@ -23,4 +20,31 @@ type ProxyService interface {
2320
2421 // RefreshTargets refreshes all proxy targets from container state.
2522 RefreshTargets(ctx context.Context) error
23+
24+ // IsRegistryDomain returns true if the host matches the configured registry domain.
25+ IsRegistryDomain(host string) bool
26+
27+ // TrackInFlight records an in-flight request for a container.
28+ // Returns a release function that must be called when the request completes.
29+ TrackInFlight(containerID string) func()
30+
31+ // TrackRegistryRequest increments the registry in-flight counter.
32+ TrackRegistryRequest()
33+
34+ // ReleaseRegistryRequest decrements the registry in-flight counter.
35+ ReleaseRegistryRequest()
36+
37+ // ProxyConfig returns the current proxy configuration.
38+ // The adapter uses this for HTTP-level enforcement (body size, response size, concurrency).
39+ ProxyConfig() ProxyServiceConfig
40+}
41+
42+// ProxyServiceConfig holds configuration that the proxy adapter needs
43+// from the usecase layer for HTTP-level enforcement.
44+type ProxyServiceConfig struct {
45+ RegistryDomain string
46+ RegistryPort int
47+ MaxBodySize int64
48+ MaxResponseSize int64
49+ MaxConcurrentConns int
2650 }
@@ -2,16 +2,13 @@ package in
2 2
3 import (3 import (
4 "context"4 "context"
5- "net/http"
6 5
7 "github.com/bnema/gordon/internal/domain"6 "github.com/bnema/gordon/internal/domain"
8 )7 )
9 8
10-// ProxyService defines the contract for reverse proxy operations.9+// ProxyService defines the contract for proxy routing and state management.
10+// HTTP request handling is the adapter's responsibility (adapters/in/http/proxy).
11 type ProxyService interface {11 type ProxyService interface {
12- // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
13- ServeHTTP(w http.ResponseWriter, r *http.Request)
14-
15 // GetTarget returns the proxy target for a given domain.12 // GetTarget returns the proxy target for a given domain.
16 GetTarget(ctx context.Context, domain string) (*domain.ProxyTarget, error)13 GetTarget(ctx context.Context, domain string) (*domain.ProxyTarget, error)
17 14
@@ -23,4 +20,31 @@ type ProxyService interface {
23 20
24 // RefreshTargets refreshes all proxy targets from container state.21 // RefreshTargets refreshes all proxy targets from container state.
25 RefreshTargets(ctx context.Context) error22 RefreshTargets(ctx context.Context) error
23+
24+ // IsRegistryDomain returns true if the host matches the configured registry domain.
25+ IsRegistryDomain(host string) bool
26+
27+ // TrackInFlight records an in-flight request for a container.
28+ // Returns a release function that must be called when the request completes.
29+ TrackInFlight(containerID string) func()
30+
31+ // TrackRegistryRequest increments the registry in-flight counter.
32+ TrackRegistryRequest()
33+
34+ // ReleaseRegistryRequest decrements the registry in-flight counter.
35+ ReleaseRegistryRequest()
36+
37+ // ProxyConfig returns the current proxy configuration.
38+ // The adapter uses this for HTTP-level enforcement (body size, response size, concurrency).
39+ ProxyConfig() ProxyServiceConfig
40+}
41+
42+// ProxyServiceConfig holds configuration that the proxy adapter needs
43+// from the usecase layer for HTTP-level enforcement.
44+type ProxyServiceConfig struct {
45+ RegistryDomain string
46+ RegistryPort int
47+ MaxBodySize int64
48+ MaxResponseSize int64
49+ MaxConcurrentConns int
26 }50 }
M internal/usecase/proxy/service.go +30 -318
@@ -3,13 +3,7 @@ package proxy
33
44 import (
55 "context"
6- "errors"
7- "fmt"
8- "io"
96 "net"
10- "net/http"
11- "net/http/httputil"
12- "net/url"
137 "os"
148 "strconv"
159 "strings"
@@ -30,35 +24,6 @@ import (
3024
3125 var proxyTracer = otel.Tracer("gordon.proxy")
3226
33-// proxyTransport is a shared HTTP transport for proxying to application containers.
34-// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
35-var proxyTransport = &http.Transport{
36- DialContext: (&net.Dialer{
37- Timeout: 10 * time.Second,
38- KeepAlive: 30 * time.Second,
39- }).DialContext,
40- TLSHandshakeTimeout: 10 * time.Second,
41- ResponseHeaderTimeout: 30 * time.Second,
42- MaxIdleConns: 100,
43- MaxIdleConnsPerHost: 10,
44- IdleConnTimeout: 90 * time.Second,
45-}
46-
47-// registryTransport is used for the registry reverse proxy (localhost loopback).
48-// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
49-// perform blocking operations (image pull, container start, readiness checks) that
50-// can take well over 30 seconds.
51-var registryTransport = &http.Transport{
52- DialContext: (&net.Dialer{
53- Timeout: 10 * time.Second,
54- KeepAlive: 30 * time.Second,
55- }).DialContext,
56- ResponseHeaderTimeout: 3 * time.Minute,
57- MaxIdleConns: 20,
58- MaxIdleConnsPerHost: 10,
59- IdleConnTimeout: 90 * time.Second,
60-}
61-
6227 // Config holds configuration needed by the proxy service.
6328 type Config struct {
6429 RegistryDomain string
@@ -76,7 +41,6 @@ type Service struct {
7641 config Config
7742 targets map[string]*domain.ProxyTarget
7843 mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
8044 inFlight map[string]int
8145 inFlightMu sync.Mutex
8246 registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
@@ -99,68 +63,6 @@ func NewService(
9963 }
10064 }
10165
102-// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
103-func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104- // Copy config under RLock to avoid data race with UpdateConfig
105- s.mu.RLock()
106- cfg := s.config
107- s.mu.RUnlock()
108-
109- // SECURITY: Limit concurrent connections to prevent resource exhaustion.
110- // Uses an atomic counter so config reloads don't create a race between
111- // an old and new semaphore — the counter is shared across reloads.
112- if cfg.MaxConcurrentConns > 0 {
113- current := s.activeConns.Add(1)
114- if current > int64(cfg.MaxConcurrentConns) {
115- s.activeConns.Add(-1)
116- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
117- return
118- }
119- defer s.activeConns.Add(-1)
120- }
121-
122- // SECURITY: Limit request body size to prevent resource exhaustion.
123- if cfg.MaxBodySize > 0 {
124- r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
125- }
126-
127- // Enrich request context with fields for downstream logging
128- ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
129- zerowrap.FieldLayer: "adapter",
130- zerowrap.FieldAdapter: "http",
131- zerowrap.FieldMethod: r.Method,
132- zerowrap.FieldPath: r.URL.Path,
133- zerowrap.FieldHost: r.Host,
134- zerowrap.FieldClientIP: r.RemoteAddr,
135- })
136- r = r.WithContext(ctx)
137- log := zerowrap.FromCtx(ctx)
138-
139- // Check if this is the registry domain
140- if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
141- log.Info().Msg("routing request to registry")
142- s.proxyToRegistry(w, r)
143- return
144- }
145-
146- // Get target for this domain
147- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
148- target, err := s.GetTarget(ctx, r.Host)
149- if err != nil {
150- log.Warn().Err(err).Msg("no route found for domain")
151- proxyError(w, "404 page not found", http.StatusNotFound)
152- return
153- }
154-
155- log.Debug().
156- Str("host", target.Host).
157- Int("port", target.Port).
158- Str("container_id", target.ContainerID).
159- Msg("resolved proxy target")
160-
161- s.proxyToTarget(w, r, target)
162-}
163-
16466 // GetTarget returns the proxy target for a given domain.
16567 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
16668 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
@@ -399,167 +301,16 @@ func (s *Service) UpdateConfig(config Config) {
399301 s.mu.Unlock()
400302 }
401303
402-// modifyResponse returns a function that modifies backend responses.
403-// It adds proxy headers and enforces the response size limit if configured.
404-func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
405- return func(resp *http.Response) error {
406- resp.Header.Set("X-Proxied-By", "Gordon")
407-
408- // SECURITY: Enforce response size limit to prevent memory exhaustion
409- // from malicious or buggy backends streaming unbounded data.
410- if cfg.MaxResponseSize > 0 {
411- if resp.ContentLength > cfg.MaxResponseSize {
412- resp.Body.Close()
413- const msg = "Response Too Large"
414- resp.StatusCode = http.StatusBadGateway
415- resp.Body = io.NopCloser(strings.NewReader(msg))
416- resp.ContentLength = int64(len(msg))
417- resp.Header.Set("Content-Type", "text/plain")
418- resp.Header.Set("Cache-Control", "no-store")
419- resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
420- // Ensure framing/encoding headers match the substituted body
421- resp.Header.Del("Transfer-Encoding")
422- resp.Header.Del("Content-Encoding")
423- resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
424- return nil
425- }
426- // For chunked/streaming responses where Content-Length is unknown,
427- // wrap with LimitReader to enforce the cap.
428- if resp.ContentLength < 0 {
429- resp.Body = &limitedReadCloser{
430- ReadCloser: resp.Body,
431- remaining: cfg.MaxResponseSize,
432- }
433- }
434- }
435-
436- return nil
437- }
438-}
439-
440-// limitedReadCloser wraps an io.ReadCloser with a byte limit.
441-// When the limit is exceeded, subsequent reads return an error and the
442-// underlying body is closed exactly once to release upstream resources.
443-type limitedReadCloser struct {
444- io.ReadCloser
445- remaining int64
446- closed bool
447-}
448-
449-func (l *limitedReadCloser) Read(p []byte) (int, error) {
450- if l.remaining <= 0 {
451- // Close the underlying body to release upstream connection/resources
452- if !l.closed {
453- l.closed = true
454- l.ReadCloser.Close()
455- }
456- return 0, fmt.Errorf("response body exceeded maximum size limit")
457- }
458- if int64(len(p)) > l.remaining {
459- p = p[:l.remaining]
460- }
461- n, err := l.ReadCloser.Read(p)
462- l.remaining -= int64(n)
463- return n, err
464-}
465-
466-func (l *limitedReadCloser) Close() error {
467- if l.closed {
468- return nil
469- }
470- l.closed = true
471- return l.ReadCloser.Close()
472-}
473-
474-// proxyError writes an error response with security headers appropriate for
475-// proxy-generated error pages. CSP is set here (not in global middleware)
476-// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
477-func proxyError(w http.ResponseWriter, msg string, code int) {
478- w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
479- w.Header().Set("Cache-Control", "no-store")
480- http.Error(w, msg, code)
481-}
482-
483-// Helper methods
484-
485-// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
486-// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
487-// to strip the Authorization header when using the default Director. Rewrite processes
488-// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
489-//
490-// originalHost, when non-empty, overrides the Host header sent to the backend.
491-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
492-// send the original hostname for virtual-hosted upstreams.
493-func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
494- return &httputil.ReverseProxy{
495- Rewrite: func(pr *httputil.ProxyRequest) {
496- pr.SetURL(targetURL)
497- // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
498- // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
499- // from the incoming request because we cannot verify it came from a trusted
500- // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
501- // to trick backends into thinking the connection is secure.
502- pr.SetXForwarded()
503- if originalHost != "" {
504- // DNS pinning: dial the resolved IP but send the original hostname
505- // so virtual-hosted upstreams route the request correctly.
506- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
507- } else {
508- pr.Out.Host = targetURL.Host
509- }
510- },
511- Transport: proxyTransport,
512- ErrorHandler: errorHandler,
513- ModifyResponse: modifyResponse,
514- }
515-}
516-
517-func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
304+// IsRegistryDomain returns true if the host matches the configured registry domain.
305+func (s *Service) IsRegistryDomain(host string) bool {
518306 s.mu.RLock()
519- cfg := s.config
520- s.mu.RUnlock()
521-
522- log := zerowrap.FromCtx(r.Context())
523-
524- // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
525- targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
526- if err != nil {
527- log.WrapErr(err, "failed to parse target URL")
528- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
529- return
530- }
531-
532- log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
533-
534- // If DNS pinning resolved hostname to IP, preserve the original hostname
535- // for the Host header so virtual-hosted upstreams work correctly.
536- originalHost := target.OriginalHost
537- releaseInFlight := s.trackInFlight(target.ContainerID)
538- defer releaseInFlight()
539-
540- proxy := newReverseProxy(targetURL, originalHost,
541- func(w http.ResponseWriter, _ *http.Request, err error) {
542- var maxBytesErr *http.MaxBytesError
543- if errors.As(err, &maxBytesErr) {
544- log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
545- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
546- return
547- }
548- log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
549- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
550- },
551- s.modifyResponse(cfg),
552- )
553-
554- log.Debug().
555- Str("target", targetURL.String()).
556- Str(zerowrap.FieldEntityID, target.ContainerID).
557- Msg("proxying request")
558-
559- proxy.ServeHTTP(w, r)
307+ defer s.mu.RUnlock()
308+ return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
560309 }
561310
562-func (s *Service) trackInFlight(containerID string) func() {
311+// TrackInFlight records an in-flight request for a container.
312+// Returns a release function that must be called when the request completes.
313+func (s *Service) TrackInFlight(containerID string) func() {
563314 if containerID == "" {
564315 return func() {}
565316 }
@@ -579,6 +330,29 @@ func (s *Service) trackInFlight(containerID string) func() {
579330 }
580331 }
581332
333+// TrackRegistryRequest increments the registry in-flight counter.
334+func (s *Service) TrackRegistryRequest() {
335+ s.registryInFlight.Add(1)
336+}
337+
338+// ReleaseRegistryRequest decrements the registry in-flight counter.
339+func (s *Service) ReleaseRegistryRequest() {
340+ s.registryInFlight.Add(-1)
341+}
342+
343+// ProxyConfig returns the current proxy configuration for adapter use.
344+func (s *Service) ProxyConfig() in.ProxyServiceConfig {
345+ s.mu.RLock()
346+ defer s.mu.RUnlock()
347+ return in.ProxyServiceConfig{
348+ RegistryDomain: s.config.RegistryDomain,
349+ RegistryPort: s.config.RegistryPort,
350+ MaxBodySize: s.config.MaxBodySize,
351+ MaxResponseSize: s.config.MaxResponseSize,
352+ MaxConcurrentConns: s.config.MaxConcurrentConns,
353+ }
354+}
355+
582356 // RegistryInFlight returns the current count of active registry proxy requests.
583357 func (s *Service) RegistryInFlight() int64 {
584358 return s.registryInFlight.Load()
@@ -599,68 +373,6 @@ func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
599373 return false
600374 }
601375
602-// proxyToRegistry forwards requests to the local registry HTTP server.
603-// SECURITY: This uses http://localhost:{port} which is safe because the registry
604-// runs on the same host and traffic never leaves the loopback interface. If Gordon
605-// ever supports running the registry on a separate host, this must use TLS with
606-// certificate verification.
607-func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
608- // Copy config under RLock to avoid data race with UpdateConfig
609- s.mu.RLock()
610- cfg := s.config
611- s.mu.RUnlock()
612-
613- s.registryInFlight.Add(1)
614- defer s.registryInFlight.Add(-1)
615-
616- log := zerowrap.FromCtx(r.Context())
617-
618- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
619- if err != nil {
620- log.WrapErr(err, "failed to parse registry target URL")
621- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
622- return
623- }
624-
625- proxy := &httputil.ReverseProxy{
626- Rewrite: func(pr *httputil.ProxyRequest) {
627- pr.SetURL(targetURL)
628- pr.SetXForwarded()
629- pr.Out.Host = targetURL.Host
630- },
631- Transport: registryTransport,
632- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
633- var maxBytesErr *http.MaxBytesError
634- if errors.As(err, &maxBytesErr) {
635- log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
636- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
637- return
638- }
639- log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
640- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
641- },
642- ModifyResponse: func(resp *http.Response) error {
643- resp.Header.Set("X-Proxied-By", "Gordon")
644- resp.Header.Set("X-Registry-Backend", "gordon-registry")
645-
646- // Remove security headers from registry response to prevent duplicates.
647- resp.Header.Del("X-Content-Type-Options")
648- resp.Header.Del("X-Frame-Options")
649- resp.Header.Del("X-XSS-Protection")
650- resp.Header.Del("Referrer-Policy")
651- resp.Header.Del("Permissions-Policy")
652- resp.Header.Del("Content-Security-Policy")
653- resp.Header.Del("Strict-Transport-Security")
654-
655- return nil
656- },
657- }
658-
659- log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
660-
661- proxy.ServeHTTP(w, r)
662-}
663-
664376 func (s *Service) isRunningInContainer() bool {
665377 // Check for /.dockerenv
666378 if _, err := os.Stat("/.dockerenv"); err == nil {
@@ -3,13 +3,7 @@ package proxy
3 3
4 import (4 import (
5 "context"5 "context"
6- "errors"
7- "fmt"
8- "io"
9 "net"6 "net"
10- "net/http"
11- "net/http/httputil"
12- "net/url"
13 "os"7 "os"
14 "strconv"8 "strconv"
15 "strings"9 "strings"
@@ -30,35 +24,6 @@ import (
30 24
31 var proxyTracer = otel.Tracer("gordon.proxy")25 var proxyTracer = otel.Tracer("gordon.proxy")
32 26
33-// proxyTransport is a shared HTTP transport for proxying to application containers.
34-// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
35-var proxyTransport = &http.Transport{
36- DialContext: (&net.Dialer{
37- Timeout: 10 * time.Second,
38- KeepAlive: 30 * time.Second,
39- }).DialContext,
40- TLSHandshakeTimeout: 10 * time.Second,
41- ResponseHeaderTimeout: 30 * time.Second,
42- MaxIdleConns: 100,
43- MaxIdleConnsPerHost: 10,
44- IdleConnTimeout: 90 * time.Second,
45-}
46-
47-// registryTransport is used for the registry reverse proxy (localhost loopback).
48-// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
49-// perform blocking operations (image pull, container start, readiness checks) that
50-// can take well over 30 seconds.
51-var registryTransport = &http.Transport{
52- DialContext: (&net.Dialer{
53- Timeout: 10 * time.Second,
54- KeepAlive: 30 * time.Second,
55- }).DialContext,
56- ResponseHeaderTimeout: 3 * time.Minute,
57- MaxIdleConns: 20,
58- MaxIdleConnsPerHost: 10,
59- IdleConnTimeout: 90 * time.Second,
60-}
61-
62 // Config holds configuration needed by the proxy service.27 // Config holds configuration needed by the proxy service.
63 type Config struct {28 type Config struct {
64 RegistryDomain string29 RegistryDomain string
@@ -76,7 +41,6 @@ type Service struct {
76 config Config41 config Config
77 targets map[string]*domain.ProxyTarget42 targets map[string]*domain.ProxyTarget
78 mu sync.RWMutex43 mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80 inFlight map[string]int44 inFlight map[string]int
81 inFlightMu sync.Mutex45 inFlightMu sync.Mutex
82 registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain46 registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
@@ -99,68 +63,6 @@ func NewService(
99 }63 }
100 }64 }
101 65
102-// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
103-func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104- // Copy config under RLock to avoid data race with UpdateConfig
105- s.mu.RLock()
106- cfg := s.config
107- s.mu.RUnlock()
108-
109- // SECURITY: Limit concurrent connections to prevent resource exhaustion.
110- // Uses an atomic counter so config reloads don't create a race between
111- // an old and new semaphore — the counter is shared across reloads.
112- if cfg.MaxConcurrentConns > 0 {
113- current := s.activeConns.Add(1)
114- if current > int64(cfg.MaxConcurrentConns) {
115- s.activeConns.Add(-1)
116- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
117- return
118- }
119- defer s.activeConns.Add(-1)
120- }
121-
122- // SECURITY: Limit request body size to prevent resource exhaustion.
123- if cfg.MaxBodySize > 0 {
124- r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
125- }
126-
127- // Enrich request context with fields for downstream logging
128- ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
129- zerowrap.FieldLayer: "adapter",
130- zerowrap.FieldAdapter: "http",
131- zerowrap.FieldMethod: r.Method,
132- zerowrap.FieldPath: r.URL.Path,
133- zerowrap.FieldHost: r.Host,
134- zerowrap.FieldClientIP: r.RemoteAddr,
135- })
136- r = r.WithContext(ctx)
137- log := zerowrap.FromCtx(ctx)
138-
139- // Check if this is the registry domain
140- if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
141- log.Info().Msg("routing request to registry")
142- s.proxyToRegistry(w, r)
143- return
144- }
145-
146- // Get target for this domain
147- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
148- target, err := s.GetTarget(ctx, r.Host)
149- if err != nil {
150- log.Warn().Err(err).Msg("no route found for domain")
151- proxyError(w, "404 page not found", http.StatusNotFound)
152- return
153- }
154-
155- log.Debug().
156- Str("host", target.Host).
157- Int("port", target.Port).
158- Str("container_id", target.ContainerID).
159- Msg("resolved proxy target")
160-
161- s.proxyToTarget(w, r, target)
162-}
163-
164 // GetTarget returns the proxy target for a given domain.66 // GetTarget returns the proxy target for a given domain.
165 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {67 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
166 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",68 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
@@ -399,167 +301,16 @@ func (s *Service) UpdateConfig(config Config) {
399 s.mu.Unlock()301 s.mu.Unlock()
400 }302 }
401 303
402-// modifyResponse returns a function that modifies backend responses.304+// IsRegistryDomain returns true if the host matches the configured registry domain.
403-// It adds proxy headers and enforces the response size limit if configured.305+func (s *Service) IsRegistryDomain(host string) bool {
404-func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
405- return func(resp *http.Response) error {
406- resp.Header.Set("X-Proxied-By", "Gordon")
407-
408- // SECURITY: Enforce response size limit to prevent memory exhaustion
409- // from malicious or buggy backends streaming unbounded data.
410- if cfg.MaxResponseSize > 0 {
411- if resp.ContentLength > cfg.MaxResponseSize {
412- resp.Body.Close()
413- const msg = "Response Too Large"
414- resp.StatusCode = http.StatusBadGateway
415- resp.Body = io.NopCloser(strings.NewReader(msg))
416- resp.ContentLength = int64(len(msg))
417- resp.Header.Set("Content-Type", "text/plain")
418- resp.Header.Set("Cache-Control", "no-store")
419- resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
420- // Ensure framing/encoding headers match the substituted body
421- resp.Header.Del("Transfer-Encoding")
422- resp.Header.Del("Content-Encoding")
423- resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
424- return nil
425- }
426- // For chunked/streaming responses where Content-Length is unknown,
427- // wrap with LimitReader to enforce the cap.
428- if resp.ContentLength < 0 {
429- resp.Body = &limitedReadCloser{
430- ReadCloser: resp.Body,
431- remaining: cfg.MaxResponseSize,
432- }
433- }
434- }
435-
436- return nil
437- }
438-}
439-
440-// limitedReadCloser wraps an io.ReadCloser with a byte limit.
441-// When the limit is exceeded, subsequent reads return an error and the
442-// underlying body is closed exactly once to release upstream resources.
443-type limitedReadCloser struct {
444- io.ReadCloser
445- remaining int64
446- closed bool
447-}
448-
449-func (l *limitedReadCloser) Read(p []byte) (int, error) {
450- if l.remaining <= 0 {
451- // Close the underlying body to release upstream connection/resources
452- if !l.closed {
453- l.closed = true
454- l.ReadCloser.Close()
455- }
456- return 0, fmt.Errorf("response body exceeded maximum size limit")
457- }
458- if int64(len(p)) > l.remaining {
459- p = p[:l.remaining]
460- }
461- n, err := l.ReadCloser.Read(p)
462- l.remaining -= int64(n)
463- return n, err
464-}
465-
466-func (l *limitedReadCloser) Close() error {
467- if l.closed {
468- return nil
469- }
470- l.closed = true
471- return l.ReadCloser.Close()
472-}
473-
474-// proxyError writes an error response with security headers appropriate for
475-// proxy-generated error pages. CSP is set here (not in global middleware)
476-// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
477-func proxyError(w http.ResponseWriter, msg string, code int) {
478- w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
479- w.Header().Set("Cache-Control", "no-store")
480- http.Error(w, msg, code)
481-}
482-
483-// Helper methods
484-
485-// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
486-// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
487-// to strip the Authorization header when using the default Director. Rewrite processes
488-// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
489-//
490-// originalHost, when non-empty, overrides the Host header sent to the backend.
491-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
492-// send the original hostname for virtual-hosted upstreams.
493-func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
494- return &httputil.ReverseProxy{
495- Rewrite: func(pr *httputil.ProxyRequest) {
496- pr.SetURL(targetURL)
497- // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
498- // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
499- // from the incoming request because we cannot verify it came from a trusted
500- // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
501- // to trick backends into thinking the connection is secure.
502- pr.SetXForwarded()
503- if originalHost != "" {
504- // DNS pinning: dial the resolved IP but send the original hostname
505- // so virtual-hosted upstreams route the request correctly.
506- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
507- } else {
508- pr.Out.Host = targetURL.Host
509- }
510- },
511- Transport: proxyTransport,
512- ErrorHandler: errorHandler,
513- ModifyResponse: modifyResponse,
514- }
515-}
516-
517-func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
518 s.mu.RLock()306 s.mu.RLock()
519- cfg := s.config307+ defer s.mu.RUnlock()
520- s.mu.RUnlock()308+ return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
521-
522- log := zerowrap.FromCtx(r.Context())
523-
524- // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
525- targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
526- if err != nil {
527- log.WrapErr(err, "failed to parse target URL")
528- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
529- return
530- }
531-
532- log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
533-
534- // If DNS pinning resolved hostname to IP, preserve the original hostname
535- // for the Host header so virtual-hosted upstreams work correctly.
536- originalHost := target.OriginalHost
537- releaseInFlight := s.trackInFlight(target.ContainerID)
538- defer releaseInFlight()
539-
540- proxy := newReverseProxy(targetURL, originalHost,
541- func(w http.ResponseWriter, _ *http.Request, err error) {
542- var maxBytesErr *http.MaxBytesError
543- if errors.As(err, &maxBytesErr) {
544- log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
545- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
546- return
547- }
548- log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
549- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
550- },
551- s.modifyResponse(cfg),
552- )
553-
554- log.Debug().
555- Str("target", targetURL.String()).
556- Str(zerowrap.FieldEntityID, target.ContainerID).
557- Msg("proxying request")
558-
559- proxy.ServeHTTP(w, r)
560 }309 }
561 310
562-func (s *Service) trackInFlight(containerID string) func() {311+// TrackInFlight records an in-flight request for a container.
312+// Returns a release function that must be called when the request completes.
313+func (s *Service) TrackInFlight(containerID string) func() {
563 if containerID == "" {314 if containerID == "" {
564 return func() {}315 return func() {}
565 }316 }
@@ -579,6 +330,29 @@ func (s *Service) trackInFlight(containerID string) func() {
579 }330 }
580 }331 }
581 332
333+// TrackRegistryRequest increments the registry in-flight counter.
334+func (s *Service) TrackRegistryRequest() {
335+ s.registryInFlight.Add(1)
336+}
337+
338+// ReleaseRegistryRequest decrements the registry in-flight counter.
339+func (s *Service) ReleaseRegistryRequest() {
340+ s.registryInFlight.Add(-1)
341+}
342+
343+// ProxyConfig returns the current proxy configuration for adapter use.
344+func (s *Service) ProxyConfig() in.ProxyServiceConfig {
345+ s.mu.RLock()
346+ defer s.mu.RUnlock()
347+ return in.ProxyServiceConfig{
348+ RegistryDomain: s.config.RegistryDomain,
349+ RegistryPort: s.config.RegistryPort,
350+ MaxBodySize: s.config.MaxBodySize,
351+ MaxResponseSize: s.config.MaxResponseSize,
352+ MaxConcurrentConns: s.config.MaxConcurrentConns,
353+ }
354+}
355+
582 // RegistryInFlight returns the current count of active registry proxy requests.356 // RegistryInFlight returns the current count of active registry proxy requests.
583 func (s *Service) RegistryInFlight() int64 {357 func (s *Service) RegistryInFlight() int64 {
584 return s.registryInFlight.Load()358 return s.registryInFlight.Load()
@@ -599,68 +373,6 @@ func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
599 return false373 return false
600 }374 }
601 375
602-// proxyToRegistry forwards requests to the local registry HTTP server.
603-// SECURITY: This uses http://localhost:{port} which is safe because the registry
604-// runs on the same host and traffic never leaves the loopback interface. If Gordon
605-// ever supports running the registry on a separate host, this must use TLS with
606-// certificate verification.
607-func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
608- // Copy config under RLock to avoid data race with UpdateConfig
609- s.mu.RLock()
610- cfg := s.config
611- s.mu.RUnlock()
612-
613- s.registryInFlight.Add(1)
614- defer s.registryInFlight.Add(-1)
615-
616- log := zerowrap.FromCtx(r.Context())
617-
618- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
619- if err != nil {
620- log.WrapErr(err, "failed to parse registry target URL")
621- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
622- return
623- }
624-
625- proxy := &httputil.ReverseProxy{
626- Rewrite: func(pr *httputil.ProxyRequest) {
627- pr.SetURL(targetURL)
628- pr.SetXForwarded()
629- pr.Out.Host = targetURL.Host
630- },
631- Transport: registryTransport,
632- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
633- var maxBytesErr *http.MaxBytesError
634- if errors.As(err, &maxBytesErr) {
635- log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
636- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
637- return
638- }
639- log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
640- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
641- },
642- ModifyResponse: func(resp *http.Response) error {
643- resp.Header.Set("X-Proxied-By", "Gordon")
644- resp.Header.Set("X-Registry-Backend", "gordon-registry")
645-
646- // Remove security headers from registry response to prevent duplicates.
647- resp.Header.Del("X-Content-Type-Options")
648- resp.Header.Del("X-Frame-Options")
649- resp.Header.Del("X-XSS-Protection")
650- resp.Header.Del("Referrer-Policy")
651- resp.Header.Del("Permissions-Policy")
652- resp.Header.Del("Content-Security-Policy")
653- resp.Header.Del("Strict-Transport-Security")
654-
655- return nil
656- },
657- }
658-
659- log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
660-
661- proxy.ServeHTTP(w, r)
662-}
663-
664 func (s *Service) isRunningInContainer() bool {376 func (s *Service) isRunningInContainer() bool {
665 // Check for /.dockerenv377 // Check for /.dockerenv
666 if _, err := os.Stat("/.dockerenv"); err == nil {378 if _, err := os.Stat("/.dockerenv"); err == nil {

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