detached 1bf1886

Merge pull request #124 from bnema/feat/http2-h2c-proxy

feat: add HTTP/2 (h2c) reverse proxy support
commit
1bf18866c5e90b83ac06ed63237585646ac29e3c
author
bnema · · 2026-03-16 05:22 UTC
committer
GitHub · · 2026-03-16 05:22 UTC
merge of
defb232 + de49ebd · diff against the first parent
signature
unsigned
14 files · +1435 -561
M go.mod +1 -1
@@ -1,6 +1,6 @@
11 module github.com/bnema/gordon
22
3-go 1.25.6
3+go 1.26.1
44
55 require (
66 github.com/bnema/zerowrap v1.3.0
@@ -1,6 +1,6 @@
1 module github.com/bnema/gordon1 module github.com/bnema/gordon
2 2
3-go 1.25.63+go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/zerowrap v1.3.06 github.com/bnema/zerowrap v1.3.0
A internal/adapters/in/http/proxy/forwarder.go +291 -0
@@ -0,0 +1,291 @@
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.24+ 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 adjusts registry responses for Docker client consumption.
160+ // Registry API responses are consumed by Docker CLI/daemon, not browsers.
161+ // Upstream registry servers may inject browser-oriented security headers
162+ // (e.g., HSTS, CSP, X-Frame-Options) that are irrelevant for API clients
163+ // and could conflict with Gordon's own security headers on browser-facing
164+ // routes. We strip them here to ensure clean, predictable registry responses.
165+ ModifyResponse: func(resp *http.Response) error {
166+ resp.Header.Set("X-Proxied-By", "Gordon")
167+ resp.Header.Set("X-Registry-Backend", "gordon-registry")
168+
169+ // Remove browser-oriented security headers injected by the upstream
170+ // registry. These are unnecessary for Docker client API traffic and
171+ // would conflict with headers set on browser-facing proxy routes.
172+ resp.Header.Del("X-Content-Type-Options")
173+ resp.Header.Del("X-Frame-Options")
174+ resp.Header.Del("X-XSS-Protection")
175+ resp.Header.Del("Referrer-Policy")
176+ resp.Header.Del("Permissions-Policy")
177+ resp.Header.Del("Content-Security-Policy")
178+ resp.Header.Del("Strict-Transport-Security")
179+
180+ return nil
181+ },
182+ }
183+
184+ log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
185+ proxy.ServeHTTP(w, r)
186+}
187+
188+// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
189+// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
190+// to strip the Authorization header when using the default Director. Rewrite processes
191+// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
192+//
193+// originalHost, when non-empty, overrides the Host header sent to the backend.
194+// This is used for DNS-pinned targets where the proxy dials an IP but needs to
195+// send the original hostname for virtual-hosted upstreams.
196+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
197+ return &httputil.ReverseProxy{
198+ Rewrite: func(pr *httputil.ProxyRequest) {
199+ pr.SetURL(targetURL)
200+ pr.SetXForwarded()
201+ if originalHost != "" {
202+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
203+ } else {
204+ pr.Out.Host = targetURL.Host
205+ }
206+ },
207+ Transport: transport,
208+ ErrorHandler: errorHandler,
209+ ModifyResponse: modifyResp,
210+ }
211+}
212+
213+// modifyResponse returns a function that adds proxy headers and enforces response size limits.
214+func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215+ return func(resp *http.Response) error {
216+ resp.Header.Set("X-Proxied-By", "Gordon")
217+
218+ if maxResponseSize > 0 {
219+ if resp.ContentLength > maxResponseSize {
220+ resp.Body.Close()
221+ const msg = "Response Too Large"
222+ resp.StatusCode = http.StatusBadGateway
223+ resp.Body = io.NopCloser(strings.NewReader(msg))
224+ resp.ContentLength = int64(len(msg))
225+ resp.Header.Set("Content-Type", "text/plain")
226+ resp.Header.Set("Cache-Control", "no-store")
227+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
228+ resp.Header.Del("Transfer-Encoding")
229+ resp.Header.Del("Content-Encoding")
230+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
231+ return nil
232+ }
233+ if resp.ContentLength < 0 {
234+ resp.Body = &limitedReadCloser{
235+ ReadCloser: resp.Body,
236+ remaining: maxResponseSize,
237+ }
238+ }
239+ }
240+
241+ return nil
242+ }
243+}
244+
245+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
246+type limitedReadCloser struct {
247+ io.ReadCloser
248+ remaining int64
249+ closed bool
250+}
251+
252+func (l *limitedReadCloser) Read(p []byte) (int, error) {
253+ if l.remaining <= 0 {
254+ if !l.closed {
255+ l.closed = true
256+ l.ReadCloser.Close()
257+ }
258+ return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
259+ }
260+ toRead := len(p)
261+ if int64(toRead) > l.remaining {
262+ toRead = int(l.remaining)
263+ }
264+ n, err := l.ReadCloser.Read(p[:toRead])
265+ // Cap n to the requested slice to guard against misbehaving readers
266+ // that return more bytes than the buffer length.
267+ if n > toRead {
268+ n = toRead
269+ }
270+ l.remaining -= int64(n)
271+ if l.remaining < 0 {
272+ l.remaining = 0
273+ }
274+ return n, err
275+}
276+
277+func (l *limitedReadCloser) Close() error {
278+ if l.closed {
279+ return nil
280+ }
281+ l.closed = true
282+ return l.ReadCloser.Close()
283+}
284+
285+// proxyError writes an error response with security headers appropriate for
286+// proxy-generated error pages.
287+func proxyError(w http.ResponseWriter, msg string, code int) {
288+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
289+ w.Header().Set("Cache-Control", "no-store")
290+ http.Error(w, msg, code)
291+}
@@ -0,0 +1,291 @@
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.24+ 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 adjusts registry responses for Docker client consumption.
160+ // Registry API responses are consumed by Docker CLI/daemon, not browsers.
161+ // Upstream registry servers may inject browser-oriented security headers
162+ // (e.g., HSTS, CSP, X-Frame-Options) that are irrelevant for API clients
163+ // and could conflict with Gordon's own security headers on browser-facing
164+ // routes. We strip them here to ensure clean, predictable registry responses.
165+ ModifyResponse: func(resp *http.Response) error {
166+ resp.Header.Set("X-Proxied-By", "Gordon")
167+ resp.Header.Set("X-Registry-Backend", "gordon-registry")
168+
169+ // Remove browser-oriented security headers injected by the upstream
170+ // registry. These are unnecessary for Docker client API traffic and
171+ // would conflict with headers set on browser-facing proxy routes.
172+ resp.Header.Del("X-Content-Type-Options")
173+ resp.Header.Del("X-Frame-Options")
174+ resp.Header.Del("X-XSS-Protection")
175+ resp.Header.Del("Referrer-Policy")
176+ resp.Header.Del("Permissions-Policy")
177+ resp.Header.Del("Content-Security-Policy")
178+ resp.Header.Del("Strict-Transport-Security")
179+
180+ return nil
181+ },
182+ }
183+
184+ log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
185+ proxy.ServeHTTP(w, r)
186+}
187+
188+// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
189+// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
190+// to strip the Authorization header when using the default Director. Rewrite processes
191+// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
192+//
193+// originalHost, when non-empty, overrides the Host header sent to the backend.
194+// This is used for DNS-pinned targets where the proxy dials an IP but needs to
195+// send the original hostname for virtual-hosted upstreams.
196+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
197+ return &httputil.ReverseProxy{
198+ Rewrite: func(pr *httputil.ProxyRequest) {
199+ pr.SetURL(targetURL)
200+ pr.SetXForwarded()
201+ if originalHost != "" {
202+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
203+ } else {
204+ pr.Out.Host = targetURL.Host
205+ }
206+ },
207+ Transport: transport,
208+ ErrorHandler: errorHandler,
209+ ModifyResponse: modifyResp,
210+ }
211+}
212+
213+// modifyResponse returns a function that adds proxy headers and enforces response size limits.
214+func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215+ return func(resp *http.Response) error {
216+ resp.Header.Set("X-Proxied-By", "Gordon")
217+
218+ if maxResponseSize > 0 {
219+ if resp.ContentLength > maxResponseSize {
220+ resp.Body.Close()
221+ const msg = "Response Too Large"
222+ resp.StatusCode = http.StatusBadGateway
223+ resp.Body = io.NopCloser(strings.NewReader(msg))
224+ resp.ContentLength = int64(len(msg))
225+ resp.Header.Set("Content-Type", "text/plain")
226+ resp.Header.Set("Cache-Control", "no-store")
227+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
228+ resp.Header.Del("Transfer-Encoding")
229+ resp.Header.Del("Content-Encoding")
230+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
231+ return nil
232+ }
233+ if resp.ContentLength < 0 {
234+ resp.Body = &limitedReadCloser{
235+ ReadCloser: resp.Body,
236+ remaining: maxResponseSize,
237+ }
238+ }
239+ }
240+
241+ return nil
242+ }
243+}
244+
245+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
246+type limitedReadCloser struct {
247+ io.ReadCloser
248+ remaining int64
249+ closed bool
250+}
251+
252+func (l *limitedReadCloser) Read(p []byte) (int, error) {
253+ if l.remaining <= 0 {
254+ if !l.closed {
255+ l.closed = true
256+ l.ReadCloser.Close()
257+ }
258+ return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
259+ }
260+ toRead := len(p)
261+ if int64(toRead) > l.remaining {
262+ toRead = int(l.remaining)
263+ }
264+ n, err := l.ReadCloser.Read(p[:toRead])
265+ // Cap n to the requested slice to guard against misbehaving readers
266+ // that return more bytes than the buffer length.
267+ if n > toRead {
268+ n = toRead
269+ }
270+ l.remaining -= int64(n)
271+ if l.remaining < 0 {
272+ l.remaining = 0
273+ }
274+ return n, err
275+}
276+
277+func (l *limitedReadCloser) Close() error {
278+ if l.closed {
279+ return nil
280+ }
281+ l.closed = true
282+ return l.ReadCloser.Close()
283+}
284+
285+// proxyError writes an error response with security headers appropriate for
286+// proxy-generated error pages.
287+func proxyError(w http.ResponseWriter, msg string, code int) {
288+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
289+ w.Header().Set("Cache-Control", "no-store")
290+ http.Error(w, msg, code)
291+}
A internal/adapters/in/http/proxy/forwarder_test.go +201 -0
@@ -0,0 +1,201 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/in"
17+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
22+ var h2cProtos http.Protocols
23+ h2cProtos.SetUnencryptedHTTP2(true)
24+
25+ h2cBackend := &http.Server{
26+ Protocols: &h2cProtos,
27+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ w.Header().Set("X-Proto", r.Proto)
29+ w.WriteHeader(http.StatusOK)
30+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
31+ }),
32+ }
33+
34+ ln, err := net.Listen("tcp", "127.0.0.1:0")
35+ require.NoError(t, err)
36+ defer ln.Close()
37+
38+ go func() {
39+ _ = h2cBackend.Serve(ln)
40+ }()
41+ defer h2cBackend.Close()
42+
43+ _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
44+ require.NoError(t, splitErr)
45+ port, atoiErr := strconv.Atoi(portStr)
46+ require.NoError(t, atoiErr)
47+
48+ proxySvc := inmocks.NewMockProxyService(t)
49+
50+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
51+ proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
52+ proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
53+ Host: "127.0.0.1",
54+ Port: port,
55+ ContainerID: "grpc-1",
56+ Scheme: "http",
57+ Protocol: "h2c",
58+ }, nil)
59+ proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
60+
61+ handler := NewHandler(proxySvc, zerowrap.Default())
62+
63+ req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
64+ req.Host = "grpc.example.com"
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusOK, rec.Code)
70+ assert.Contains(t, rec.Body.String(), "HTTP/2")
71+}
72+
73+func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
74+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
76+ }))
77+ defer backend.Close()
78+
79+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
80+
81+ proxySvc := inmocks.NewMockProxyService(t)
82+
83+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
84+ proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
85+ proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
86+ Host: "127.0.0.1",
87+ Port: backendPort,
88+ ContainerID: "web-1",
89+ Scheme: "http",
90+ }, nil)
91+ proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
92+
93+ handler := NewHandler(proxySvc, zerowrap.Default())
94+
95+ req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
96+ req.Host = "web.example.com"
97+ rec := httptest.NewRecorder()
98+
99+ handler.ServeHTTP(rec, req)
100+
101+ assert.Equal(t, http.StatusOK, rec.Code)
102+ assert.Contains(t, rec.Body.String(), "HTTP/1.1")
103+}
104+
105+func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
106+ handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default())
107+
108+ h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
109+ httpTarget := &domain.ProxyTarget{Protocol: ""}
110+
111+ assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
112+ assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
113+}
114+
115+func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
116+ var receivedHost string
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ receivedHost = r.Host
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
124+
125+ proxySvc := inmocks.NewMockProxyService(t)
126+
127+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
128+ proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
129+ proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
130+ Host: "127.0.0.1",
131+ Port: backendPort,
132+ ContainerID: "",
133+ Scheme: "http",
134+ OriginalHost: "upstream.example.com",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, zerowrap.Default())
139+
140+ req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
141+ req.Host = "external.example.com"
142+ w := httptest.NewRecorder()
143+ handler.ServeHTTP(w, req)
144+
145+ assert.Equal(t, http.StatusOK, w.Code)
146+ assert.Contains(t, receivedHost, "upstream.example.com")
147+}
148+
149+func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
150+ proxySvc := inmocks.NewMockProxyService(t)
151+
152+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
153+ proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
154+ proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
155+ Host: "127.0.0.1",
156+ Port: 1,
157+ ContainerID: "c-down",
158+ Scheme: "http",
159+ }, nil)
160+ proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
161+
162+ handler := NewHandler(proxySvc, zerowrap.Default())
163+
164+ req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
165+ req.Host = "down.example.com"
166+ w := httptest.NewRecorder()
167+ handler.ServeHTTP(w, req)
168+
169+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
170+}
171+
172+func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
173+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
174+ w.WriteHeader(http.StatusOK)
175+ }))
176+ defer backend.Close()
177+
178+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
179+
180+ proxySvc := inmocks.NewMockProxyService(t)
181+
182+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
183+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
184+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
185+ Host: "127.0.0.1",
186+ Port: backendPort,
187+ ContainerID: "c-1",
188+ Scheme: "http",
189+ }, nil)
190+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
191+
192+ handler := NewHandler(proxySvc, zerowrap.Default())
193+
194+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
195+ req.Host = "app.example.com"
196+ w := httptest.NewRecorder()
197+ handler.ServeHTTP(w, req)
198+
199+ assert.Equal(t, http.StatusOK, w.Code)
200+ assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201+}
@@ -0,0 +1,201 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/in"
17+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
22+ var h2cProtos http.Protocols
23+ h2cProtos.SetUnencryptedHTTP2(true)
24+
25+ h2cBackend := &http.Server{
26+ Protocols: &h2cProtos,
27+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ w.Header().Set("X-Proto", r.Proto)
29+ w.WriteHeader(http.StatusOK)
30+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
31+ }),
32+ }
33+
34+ ln, err := net.Listen("tcp", "127.0.0.1:0")
35+ require.NoError(t, err)
36+ defer ln.Close()
37+
38+ go func() {
39+ _ = h2cBackend.Serve(ln)
40+ }()
41+ defer h2cBackend.Close()
42+
43+ _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
44+ require.NoError(t, splitErr)
45+ port, atoiErr := strconv.Atoi(portStr)
46+ require.NoError(t, atoiErr)
47+
48+ proxySvc := inmocks.NewMockProxyService(t)
49+
50+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
51+ proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
52+ proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
53+ Host: "127.0.0.1",
54+ Port: port,
55+ ContainerID: "grpc-1",
56+ Scheme: "http",
57+ Protocol: "h2c",
58+ }, nil)
59+ proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
60+
61+ handler := NewHandler(proxySvc, zerowrap.Default())
62+
63+ req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
64+ req.Host = "grpc.example.com"
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusOK, rec.Code)
70+ assert.Contains(t, rec.Body.String(), "HTTP/2")
71+}
72+
73+func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
74+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
76+ }))
77+ defer backend.Close()
78+
79+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
80+
81+ proxySvc := inmocks.NewMockProxyService(t)
82+
83+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
84+ proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
85+ proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
86+ Host: "127.0.0.1",
87+ Port: backendPort,
88+ ContainerID: "web-1",
89+ Scheme: "http",
90+ }, nil)
91+ proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
92+
93+ handler := NewHandler(proxySvc, zerowrap.Default())
94+
95+ req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
96+ req.Host = "web.example.com"
97+ rec := httptest.NewRecorder()
98+
99+ handler.ServeHTTP(rec, req)
100+
101+ assert.Equal(t, http.StatusOK, rec.Code)
102+ assert.Contains(t, rec.Body.String(), "HTTP/1.1")
103+}
104+
105+func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
106+ handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default())
107+
108+ h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
109+ httpTarget := &domain.ProxyTarget{Protocol: ""}
110+
111+ assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
112+ assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
113+}
114+
115+func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
116+ var receivedHost string
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ receivedHost = r.Host
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
124+
125+ proxySvc := inmocks.NewMockProxyService(t)
126+
127+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
128+ proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
129+ proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
130+ Host: "127.0.0.1",
131+ Port: backendPort,
132+ ContainerID: "",
133+ Scheme: "http",
134+ OriginalHost: "upstream.example.com",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, zerowrap.Default())
139+
140+ req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
141+ req.Host = "external.example.com"
142+ w := httptest.NewRecorder()
143+ handler.ServeHTTP(w, req)
144+
145+ assert.Equal(t, http.StatusOK, w.Code)
146+ assert.Contains(t, receivedHost, "upstream.example.com")
147+}
148+
149+func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
150+ proxySvc := inmocks.NewMockProxyService(t)
151+
152+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
153+ proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
154+ proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
155+ Host: "127.0.0.1",
156+ Port: 1,
157+ ContainerID: "c-down",
158+ Scheme: "http",
159+ }, nil)
160+ proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
161+
162+ handler := NewHandler(proxySvc, zerowrap.Default())
163+
164+ req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
165+ req.Host = "down.example.com"
166+ w := httptest.NewRecorder()
167+ handler.ServeHTTP(w, req)
168+
169+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
170+}
171+
172+func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
173+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
174+ w.WriteHeader(http.StatusOK)
175+ }))
176+ defer backend.Close()
177+
178+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
179+
180+ proxySvc := inmocks.NewMockProxyService(t)
181+
182+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
183+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
184+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
185+ Host: "127.0.0.1",
186+ Port: backendPort,
187+ ContainerID: "c-1",
188+ Scheme: "http",
189+ }, nil)
190+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
191+
192+ handler := NewHandler(proxySvc, zerowrap.Default())
193+
194+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
195+ req.Host = "app.example.com"
196+ w := httptest.NewRecorder()
197+ handler.ServeHTTP(w, req)
198+
199+ assert.Equal(t, http.StatusOK, w.Code)
200+ assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201+}
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.Debug().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.Debug().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 }
A internal/adapters/in/http/proxy/handler_test.go +210 -0
@@ -0,0 +1,210 @@
1+package proxy
2+
3+import (
4+ "bytes"
5+ "io"
6+ "net"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+
15+ "github.com/bnema/gordon/internal/boundaries/in"
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func testLogger() zerowrap.Logger {
21+ return zerowrap.Default()
22+}
23+
24+func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
25+ proxySvc := inmocks.NewMockProxyService(t)
26+
27+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
28+ MaxConcurrentConns: 1,
29+ })
30+
31+ handler := NewHandler(proxySvc, testLogger())
32+
33+ // Simulate an in-flight connection.
34+ handler.activeConns.Add(1)
35+ defer handler.activeConns.Add(-1)
36+
37+ req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
38+ w := httptest.NewRecorder()
39+ handler.ServeHTTP(w, req)
40+
41+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
42+}
43+
44+func TestHandler_RoutesToRegistry(t *testing.T) {
45+ proxySvc := inmocks.NewMockProxyService(t)
46+
47+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
48+ RegistryPort: 5000,
49+ })
50+ proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
51+ proxySvc.EXPECT().TrackRegistryRequest().Return()
52+ proxySvc.EXPECT().ReleaseRegistryRequest().Return()
53+
54+ handler := NewHandler(proxySvc, testLogger())
55+
56+ req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
57+ req.Host = "registry.example.com"
58+ w := httptest.NewRecorder()
59+ handler.ServeHTTP(w, req)
60+
61+ assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
62+}
63+
64+func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65+ proxySvc := inmocks.NewMockProxyService(t)
66+
67+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
68+ proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
69+ proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
70+
71+ handler := NewHandler(proxySvc, testLogger())
72+
73+ req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
74+ req.Host = "unknown.example.com"
75+ w := httptest.NewRecorder()
76+ handler.ServeHTTP(w, req)
77+
78+ assert.Equal(t, http.StatusNotFound, w.Code)
79+}
80+
81+func TestHandler_ProxiesToTarget(t *testing.T) {
82+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
83+ w.WriteHeader(http.StatusOK)
84+ _, _ = w.Write([]byte("ok"))
85+ }))
86+ defer backend.Close()
87+
88+ proxySvc := inmocks.NewMockProxyService(t)
89+
90+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
91+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
92+
93+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
94+
95+ target := &domain.ProxyTarget{
96+ Host: "127.0.0.1",
97+ Port: backendPort,
98+ ContainerID: "c-1",
99+ Scheme: "http",
100+ }
101+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
102+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
103+
104+ handler := NewHandler(proxySvc, testLogger())
105+
106+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
107+ req.Host = "app.example.com"
108+ w := httptest.NewRecorder()
109+ handler.ServeHTTP(w, req)
110+
111+ assert.Equal(t, http.StatusOK, w.Code)
112+ assert.Equal(t, "ok", w.Body.String())
113+}
114+
115+func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) {
116+ // Backend that reads the full request body, triggering MaxBytesReader.
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ _, _ = io.ReadAll(r.Body)
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ proxySvc := inmocks.NewMockProxyService(t)
124+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
125+
126+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
127+ MaxBodySize: 1024,
128+ })
129+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
130+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
131+ Host: "127.0.0.1",
132+ Port: backendPort,
133+ ContainerID: "c-1",
134+ Scheme: "http",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, testLogger())
139+
140+ // Send a body larger than the 1024-byte limit.
141+ largeBody := bytes.NewReader(make([]byte, 2048))
142+ req := httptest.NewRequest(http.MethodPost, "http://app.example.com/test", largeBody)
143+ req.Host = "app.example.com"
144+ w := httptest.NewRecorder()
145+ handler.ServeHTTP(w, req)
146+
147+ assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
148+}
149+
150+func TestHandler_UpdatedConfigReflected(t *testing.T) {
151+ proxySvc := inmocks.NewMockProxyService(t)
152+
153+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
154+ MaxConcurrentConns: 0,
155+ }).Once()
156+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
157+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
158+
159+ handler := NewHandler(proxySvc, testLogger())
160+
161+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
162+ req.Host = "app.example.com"
163+ rec := httptest.NewRecorder()
164+ handler.ServeHTTP(rec, req)
165+ assert.Equal(t, http.StatusNotFound, rec.Code)
166+
167+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
168+ MaxConcurrentConns: 1,
169+ }).Once()
170+
171+ handler.activeConns.Add(1)
172+ defer handler.activeConns.Add(-1)
173+
174+ req2 := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
175+ req2.Host = "app.example.com"
176+ rec2 := httptest.NewRecorder()
177+ handler.ServeHTTP(rec2, req2)
178+ assert.Equal(t, http.StatusServiceUnavailable, rec2.Code)
179+}
180+
181+func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
182+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
183+ w.WriteHeader(http.StatusOK)
184+ }))
185+ defer backend.Close()
186+
187+ proxySvc := inmocks.NewMockProxyService(t)
188+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
189+
190+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
191+ MaxConcurrentConns: 0,
192+ })
193+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
194+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
195+ Host: "127.0.0.1",
196+ Port: backendPort,
197+ ContainerID: "c-1",
198+ Scheme: "http",
199+ }, nil)
200+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
201+
202+ handler := NewHandler(proxySvc, testLogger())
203+
204+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
205+ req.Host = "app.example.com"
206+ w := httptest.NewRecorder()
207+ handler.ServeHTTP(w, req)
208+
209+ assert.Equal(t, http.StatusOK, w.Code)
210+}
@@ -0,0 +1,210 @@
1+package proxy
2+
3+import (
4+ "bytes"
5+ "io"
6+ "net"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+
15+ "github.com/bnema/gordon/internal/boundaries/in"
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func testLogger() zerowrap.Logger {
21+ return zerowrap.Default()
22+}
23+
24+func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
25+ proxySvc := inmocks.NewMockProxyService(t)
26+
27+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
28+ MaxConcurrentConns: 1,
29+ })
30+
31+ handler := NewHandler(proxySvc, testLogger())
32+
33+ // Simulate an in-flight connection.
34+ handler.activeConns.Add(1)
35+ defer handler.activeConns.Add(-1)
36+
37+ req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
38+ w := httptest.NewRecorder()
39+ handler.ServeHTTP(w, req)
40+
41+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
42+}
43+
44+func TestHandler_RoutesToRegistry(t *testing.T) {
45+ proxySvc := inmocks.NewMockProxyService(t)
46+
47+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
48+ RegistryPort: 5000,
49+ })
50+ proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
51+ proxySvc.EXPECT().TrackRegistryRequest().Return()
52+ proxySvc.EXPECT().ReleaseRegistryRequest().Return()
53+
54+ handler := NewHandler(proxySvc, testLogger())
55+
56+ req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
57+ req.Host = "registry.example.com"
58+ w := httptest.NewRecorder()
59+ handler.ServeHTTP(w, req)
60+
61+ assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
62+}
63+
64+func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65+ proxySvc := inmocks.NewMockProxyService(t)
66+
67+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
68+ proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
69+ proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
70+
71+ handler := NewHandler(proxySvc, testLogger())
72+
73+ req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
74+ req.Host = "unknown.example.com"
75+ w := httptest.NewRecorder()
76+ handler.ServeHTTP(w, req)
77+
78+ assert.Equal(t, http.StatusNotFound, w.Code)
79+}
80+
81+func TestHandler_ProxiesToTarget(t *testing.T) {
82+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
83+ w.WriteHeader(http.StatusOK)
84+ _, _ = w.Write([]byte("ok"))
85+ }))
86+ defer backend.Close()
87+
88+ proxySvc := inmocks.NewMockProxyService(t)
89+
90+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
91+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
92+
93+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
94+
95+ target := &domain.ProxyTarget{
96+ Host: "127.0.0.1",
97+ Port: backendPort,
98+ ContainerID: "c-1",
99+ Scheme: "http",
100+ }
101+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
102+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
103+
104+ handler := NewHandler(proxySvc, testLogger())
105+
106+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
107+ req.Host = "app.example.com"
108+ w := httptest.NewRecorder()
109+ handler.ServeHTTP(w, req)
110+
111+ assert.Equal(t, http.StatusOK, w.Code)
112+ assert.Equal(t, "ok", w.Body.String())
113+}
114+
115+func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) {
116+ // Backend that reads the full request body, triggering MaxBytesReader.
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ _, _ = io.ReadAll(r.Body)
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ proxySvc := inmocks.NewMockProxyService(t)
124+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
125+
126+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
127+ MaxBodySize: 1024,
128+ })
129+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
130+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
131+ Host: "127.0.0.1",
132+ Port: backendPort,
133+ ContainerID: "c-1",
134+ Scheme: "http",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, testLogger())
139+
140+ // Send a body larger than the 1024-byte limit.
141+ largeBody := bytes.NewReader(make([]byte, 2048))
142+ req := httptest.NewRequest(http.MethodPost, "http://app.example.com/test", largeBody)
143+ req.Host = "app.example.com"
144+ w := httptest.NewRecorder()
145+ handler.ServeHTTP(w, req)
146+
147+ assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
148+}
149+
150+func TestHandler_UpdatedConfigReflected(t *testing.T) {
151+ proxySvc := inmocks.NewMockProxyService(t)
152+
153+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
154+ MaxConcurrentConns: 0,
155+ }).Once()
156+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
157+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
158+
159+ handler := NewHandler(proxySvc, testLogger())
160+
161+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
162+ req.Host = "app.example.com"
163+ rec := httptest.NewRecorder()
164+ handler.ServeHTTP(rec, req)
165+ assert.Equal(t, http.StatusNotFound, rec.Code)
166+
167+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
168+ MaxConcurrentConns: 1,
169+ }).Once()
170+
171+ handler.activeConns.Add(1)
172+ defer handler.activeConns.Add(-1)
173+
174+ req2 := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
175+ req2.Host = "app.example.com"
176+ rec2 := httptest.NewRecorder()
177+ handler.ServeHTTP(rec2, req2)
178+ assert.Equal(t, http.StatusServiceUnavailable, rec2.Code)
179+}
180+
181+func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
182+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
183+ w.WriteHeader(http.StatusOK)
184+ }))
185+ defer backend.Close()
186+
187+ proxySvc := inmocks.NewMockProxyService(t)
188+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
189+
190+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
191+ MaxConcurrentConns: 0,
192+ })
193+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
194+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
195+ Host: "127.0.0.1",
196+ Port: backendPort,
197+ ContainerID: "c-1",
198+ Scheme: "http",
199+ }, nil)
200+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
201+
202+ handler := NewHandler(proxySvc, testLogger())
203+
204+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
205+ req.Host = "app.example.com"
206+ w := httptest.NewRecorder()
207+ handler.ServeHTTP(w, req)
208+
209+ assert.Equal(t, http.StatusOK, w.Code)
210+}
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/domain/labels.go +4 -0
@@ -18,6 +18,10 @@ const (
1818 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
1919 LabelProxyPort = "gordon.proxy.port"
2020
21+ // LabelProxyProtocol specifies the backend protocol the container speaks.
22+ // Supported values: "h2c" (cleartext HTTP/2). When unset, HTTP/1.1 is assumed.
23+ LabelProxyProtocol = "gordon.proxy.protocol"
24+
2125 // Auto-route image labels (for automatic route discovery)
2226 // LabelDomains specifies multiple route domains (comma-separated).
2327 LabelDomains = "gordon.domains"
@@ -18,6 +18,10 @@ const (
18 // LabelProxyPort specifies the container port to proxy HTTP traffic to.18 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
19 LabelProxyPort = "gordon.proxy.port"19 LabelProxyPort = "gordon.proxy.port"
20 20
21+ // LabelProxyProtocol specifies the backend protocol the container speaks.
22+ // Supported values: "h2c" (cleartext HTTP/2). When unset, HTTP/1.1 is assumed.
23+ LabelProxyProtocol = "gordon.proxy.protocol"
24+
21 // Auto-route image labels (for automatic route discovery)25 // Auto-route image labels (for automatic route discovery)
22 // LabelDomains specifies multiple route domains (comma-separated).26 // LabelDomains specifies multiple route domains (comma-separated).
23 LabelDomains = "gordon.domains"27 LabelDomains = "gordon.domains"
M internal/domain/route.go +1 -0
@@ -13,6 +13,7 @@ type ProxyTarget struct {
1313 Port int
1414 ContainerID string
1515 Scheme string // "http" or "https"
16+ Protocol string // "" (default HTTP/1.1) or "h2c" (cleartext HTTP/2)
1617 OriginalHost string // Original hostname before DNS resolution (for Host header)
1718 }
1819
@@ -13,6 +13,7 @@ type ProxyTarget struct {
13 Port int13 Port int
14 ContainerID string14 ContainerID string
15 Scheme string // "http" or "https"15 Scheme string // "http" or "https"
16+ Protocol string // "" (default HTTP/1.1) or "h2c" (cleartext HTTP/2)
16 OriginalHost string // Original hostname before DNS resolution (for Host header)17 OriginalHost string // Original hostname before DNS resolution (for Host header)
17 }18 }
18 19
M internal/usecase/proxy/service.go +39 -365
@@ -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",
@@ -211,10 +113,9 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
211113
212114 if s.isRunningInContainer() {
213115 // Gordon is in a container - use container network
214- // Determine target port: check for explicit label first, then fall back to network detection
215- targetPort, err := s.getProxyPort(ctx, container.Image)
116+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
216117 if err != nil {
217- return nil, log.WrapErr(err, "failed to determine proxy port")
118+ return nil, log.WrapErr(err, "failed to resolve target metadata")
218119 }
219120 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
220121 if err != nil {
@@ -222,9 +123,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
222123 }
223124 target = &domain.ProxyTarget{
224125 Host: containerIP,
225- Port: targetPort,
126+ Port: meta.Port,
226127 ContainerID: container.ID,
227128 Scheme: "http",
129+ Protocol: meta.Protocol,
228130 }
229131 } else {
230132 // Gordon is on the host - use host port mapping
@@ -241,16 +143,14 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
241143 return nil, domain.ErrRouteNotFound
242144 }
243145
244- // Determine target port: check for label first, then fall back to first exposed port
245- // Use container.Image (the actual running image) instead of route.Image (config shorthand)
246- targetPort, err := s.getProxyPort(ctx, container.Image)
146+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
247147 if err != nil {
248- return nil, log.WrapErr(err, "failed to determine proxy port")
148+ return nil, log.WrapErr(err, "failed to resolve target metadata")
249149 }
250150
251- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
151+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
252152 if err != nil {
253- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
153+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
254154 }
255155
256156 target = &domain.ProxyTarget{
@@ -258,6 +158,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
258158 Port: hostPort,
259159 ContainerID: container.ID,
260160 Scheme: "http",
161+ Protocol: meta.Protocol,
261162 }
262163 }
263164
@@ -400,167 +301,16 @@ func (s *Service) UpdateConfig(config Config) {
400301 s.mu.Unlock()
401302 }
402303
403-// modifyResponse returns a function that modifies backend responses.
404-// It adds proxy headers and enforces the response size limit if configured.
405-func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
406- return func(resp *http.Response) error {
407- resp.Header.Set("X-Proxied-By", "Gordon")
408-
409- // SECURITY: Enforce response size limit to prevent memory exhaustion
410- // from malicious or buggy backends streaming unbounded data.
411- if cfg.MaxResponseSize > 0 {
412- if resp.ContentLength > cfg.MaxResponseSize {
413- resp.Body.Close()
414- const msg = "Response Too Large"
415- resp.StatusCode = http.StatusBadGateway
416- resp.Body = io.NopCloser(strings.NewReader(msg))
417- resp.ContentLength = int64(len(msg))
418- resp.Header.Set("Content-Type", "text/plain")
419- resp.Header.Set("Cache-Control", "no-store")
420- resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
421- // Ensure framing/encoding headers match the substituted body
422- resp.Header.Del("Transfer-Encoding")
423- resp.Header.Del("Content-Encoding")
424- resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
425- return nil
426- }
427- // For chunked/streaming responses where Content-Length is unknown,
428- // wrap with LimitReader to enforce the cap.
429- if resp.ContentLength < 0 {
430- resp.Body = &limitedReadCloser{
431- ReadCloser: resp.Body,
432- remaining: cfg.MaxResponseSize,
433- }
434- }
435- }
436-
437- return nil
438- }
439-}
440-
441-// limitedReadCloser wraps an io.ReadCloser with a byte limit.
442-// When the limit is exceeded, subsequent reads return an error and the
443-// underlying body is closed exactly once to release upstream resources.
444-type limitedReadCloser struct {
445- io.ReadCloser
446- remaining int64
447- closed bool
448-}
449-
450-func (l *limitedReadCloser) Read(p []byte) (int, error) {
451- if l.remaining <= 0 {
452- // Close the underlying body to release upstream connection/resources
453- if !l.closed {
454- l.closed = true
455- l.ReadCloser.Close()
456- }
457- return 0, fmt.Errorf("response body exceeded maximum size limit")
458- }
459- if int64(len(p)) > l.remaining {
460- p = p[:l.remaining]
461- }
462- n, err := l.ReadCloser.Read(p)
463- l.remaining -= int64(n)
464- return n, err
465-}
466-
467-func (l *limitedReadCloser) Close() error {
468- if l.closed {
469- return nil
470- }
471- l.closed = true
472- return l.ReadCloser.Close()
473-}
474-
475-// proxyError writes an error response with security headers appropriate for
476-// proxy-generated error pages. CSP is set here (not in global middleware)
477-// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
478-func proxyError(w http.ResponseWriter, msg string, code int) {
479- w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
480- w.Header().Set("Cache-Control", "no-store")
481- http.Error(w, msg, code)
482-}
483-
484-// Helper methods
485-
486-// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
487-// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
488-// to strip the Authorization header when using the default Director. Rewrite processes
489-// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
490-//
491-// originalHost, when non-empty, overrides the Host header sent to the backend.
492-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
493-// send the original hostname for virtual-hosted upstreams.
494-func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
495- return &httputil.ReverseProxy{
496- Rewrite: func(pr *httputil.ProxyRequest) {
497- pr.SetURL(targetURL)
498- // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
499- // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
500- // from the incoming request because we cannot verify it came from a trusted
501- // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
502- // to trick backends into thinking the connection is secure.
503- pr.SetXForwarded()
504- if originalHost != "" {
505- // DNS pinning: dial the resolved IP but send the original hostname
506- // so virtual-hosted upstreams route the request correctly.
507- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
508- } else {
509- pr.Out.Host = targetURL.Host
510- }
511- },
512- Transport: proxyTransport,
513- ErrorHandler: errorHandler,
514- ModifyResponse: modifyResponse,
515- }
516-}
517-
518-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 {
519306 s.mu.RLock()
520- cfg := s.config
521- s.mu.RUnlock()
522-
523- log := zerowrap.FromCtx(r.Context())
524-
525- // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
526- targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
527- if err != nil {
528- log.WrapErr(err, "failed to parse target URL")
529- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
530- return
531- }
532-
533- log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
534-
535- // If DNS pinning resolved hostname to IP, preserve the original hostname
536- // for the Host header so virtual-hosted upstreams work correctly.
537- originalHost := target.OriginalHost
538- releaseInFlight := s.trackInFlight(target.ContainerID)
539- defer releaseInFlight()
540-
541- proxy := newReverseProxy(targetURL, originalHost,
542- func(w http.ResponseWriter, _ *http.Request, err error) {
543- var maxBytesErr *http.MaxBytesError
544- if errors.As(err, &maxBytesErr) {
545- log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
546- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
547- return
548- }
549- log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
550- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
551- },
552- s.modifyResponse(cfg),
553- )
554-
555- log.Debug().
556- Str("target", targetURL.String()).
557- Str(zerowrap.FieldEntityID, target.ContainerID).
558- Msg("proxying request")
559-
560- proxy.ServeHTTP(w, r)
307+ defer s.mu.RUnlock()
308+ return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
561309 }
562310
563-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() {
564314 if containerID == "" {
565315 return func() {}
566316 }
@@ -580,6 +330,29 @@ func (s *Service) trackInFlight(containerID string) func() {
580330 }
581331 }
582332
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+
583356 // RegistryInFlight returns the current count of active registry proxy requests.
584357 func (s *Service) RegistryInFlight() int64 {
585358 return s.registryInFlight.Load()
@@ -600,68 +373,6 @@ func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
600373 return false
601374 }
602375
603-// proxyToRegistry forwards requests to the local registry HTTP server.
604-// SECURITY: This uses http://localhost:{port} which is safe because the registry
605-// runs on the same host and traffic never leaves the loopback interface. If Gordon
606-// ever supports running the registry on a separate host, this must use TLS with
607-// certificate verification.
608-func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
609- // Copy config under RLock to avoid data race with UpdateConfig
610- s.mu.RLock()
611- cfg := s.config
612- s.mu.RUnlock()
613-
614- s.registryInFlight.Add(1)
615- defer s.registryInFlight.Add(-1)
616-
617- log := zerowrap.FromCtx(r.Context())
618-
619- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
620- if err != nil {
621- log.WrapErr(err, "failed to parse registry target URL")
622- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
623- return
624- }
625-
626- proxy := &httputil.ReverseProxy{
627- Rewrite: func(pr *httputil.ProxyRequest) {
628- pr.SetURL(targetURL)
629- pr.SetXForwarded()
630- pr.Out.Host = targetURL.Host
631- },
632- Transport: registryTransport,
633- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
634- var maxBytesErr *http.MaxBytesError
635- if errors.As(err, &maxBytesErr) {
636- log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
637- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
638- return
639- }
640- log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
641- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
642- },
643- ModifyResponse: func(resp *http.Response) error {
644- resp.Header.Set("X-Proxied-By", "Gordon")
645- resp.Header.Set("X-Registry-Backend", "gordon-registry")
646-
647- // Remove security headers from registry response to prevent duplicates.
648- resp.Header.Del("X-Content-Type-Options")
649- resp.Header.Del("X-Frame-Options")
650- resp.Header.Del("X-XSS-Protection")
651- resp.Header.Del("Referrer-Policy")
652- resp.Header.Del("Permissions-Policy")
653- resp.Header.Del("Content-Security-Policy")
654- resp.Header.Del("Strict-Transport-Security")
655-
656- return nil
657- },
658- }
659-
660- log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
661-
662- proxy.ServeHTTP(w, r)
663-}
664-
665376 func (s *Service) isRunningInContainer() bool {
666377 // Check for /.dockerenv
667378 if _, err := os.Stat("/.dockerenv"); err == nil {
@@ -691,40 +402,3 @@ func (s *Service) isRunningInContainer() bool {
691402
692403 return false
693404 }
694-
695-// getProxyPort determines the port to proxy to for an image.
696-// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
697-func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
698- log := zerowrap.FromCtx(ctx)
699-
700- log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
701-
702- // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
703- labels, err := s.runtime.GetImageLabels(ctx, imageRef)
704- if err != nil {
705- log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
706- } else {
707- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
708- if portStr, ok := labels[key]; ok && portStr != "" {
709- port, err := strconv.Atoi(portStr)
710- if err == nil && port > 0 && port <= 65535 {
711- log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712- return port, nil
713- }
714- log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715- }
716- }
717- }
718-
719- // Fall back to first exposed port
720- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
721- if err != nil {
722- return 0, err
723- }
724- if len(exposedPorts) == 0 {
725- return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
726- }
727-
728- log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
729- return exposedPorts[0], nil
730-}
@@ -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",
@@ -211,10 +113,9 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
211 113
212 if s.isRunningInContainer() {114 if s.isRunningInContainer() {
213 // Gordon is in a container - use container network115 // Gordon is in a container - use container network
214- // Determine target port: check for explicit label first, then fall back to network detection116+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
215- targetPort, err := s.getProxyPort(ctx, container.Image)
216 if err != nil {117 if err != nil {
217- return nil, log.WrapErr(err, "failed to determine proxy port")118+ return nil, log.WrapErr(err, "failed to resolve target metadata")
218 }119 }
219 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)120 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
220 if err != nil {121 if err != nil {
@@ -222,9 +123,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
222 }123 }
223 target = &domain.ProxyTarget{124 target = &domain.ProxyTarget{
224 Host: containerIP,125 Host: containerIP,
225- Port: targetPort,126+ Port: meta.Port,
226 ContainerID: container.ID,127 ContainerID: container.ID,
227 Scheme: "http",128 Scheme: "http",
129+ Protocol: meta.Protocol,
228 }130 }
229 } else {131 } else {
230 // Gordon is on the host - use host port mapping132 // Gordon is on the host - use host port mapping
@@ -241,16 +143,14 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
241 return nil, domain.ErrRouteNotFound143 return nil, domain.ErrRouteNotFound
242 }144 }
243 145
244- // Determine target port: check for label first, then fall back to first exposed port146+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
245- // Use container.Image (the actual running image) instead of route.Image (config shorthand)
246- targetPort, err := s.getProxyPort(ctx, container.Image)
247 if err != nil {147 if err != nil {
248- return nil, log.WrapErr(err, "failed to determine proxy port")148+ return nil, log.WrapErr(err, "failed to resolve target metadata")
249 }149 }
250 150
251- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)151+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
252 if err != nil {152 if err != nil {
253- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})153+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
254 }154 }
255 155
256 target = &domain.ProxyTarget{156 target = &domain.ProxyTarget{
@@ -258,6 +158,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
258 Port: hostPort,158 Port: hostPort,
259 ContainerID: container.ID,159 ContainerID: container.ID,
260 Scheme: "http",160 Scheme: "http",
161+ Protocol: meta.Protocol,
261 }162 }
262 }163 }
263 164
@@ -400,167 +301,16 @@ func (s *Service) UpdateConfig(config Config) {
400 s.mu.Unlock()301 s.mu.Unlock()
401 }302 }
402 303
403-// modifyResponse returns a function that modifies backend responses.304+// IsRegistryDomain returns true if the host matches the configured registry domain.
404-// It adds proxy headers and enforces the response size limit if configured.305+func (s *Service) IsRegistryDomain(host string) bool {
405-func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
406- return func(resp *http.Response) error {
407- resp.Header.Set("X-Proxied-By", "Gordon")
408-
409- // SECURITY: Enforce response size limit to prevent memory exhaustion
410- // from malicious or buggy backends streaming unbounded data.
411- if cfg.MaxResponseSize > 0 {
412- if resp.ContentLength > cfg.MaxResponseSize {
413- resp.Body.Close()
414- const msg = "Response Too Large"
415- resp.StatusCode = http.StatusBadGateway
416- resp.Body = io.NopCloser(strings.NewReader(msg))
417- resp.ContentLength = int64(len(msg))
418- resp.Header.Set("Content-Type", "text/plain")
419- resp.Header.Set("Cache-Control", "no-store")
420- resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
421- // Ensure framing/encoding headers match the substituted body
422- resp.Header.Del("Transfer-Encoding")
423- resp.Header.Del("Content-Encoding")
424- resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
425- return nil
426- }
427- // For chunked/streaming responses where Content-Length is unknown,
428- // wrap with LimitReader to enforce the cap.
429- if resp.ContentLength < 0 {
430- resp.Body = &limitedReadCloser{
431- ReadCloser: resp.Body,
432- remaining: cfg.MaxResponseSize,
433- }
434- }
435- }
436-
437- return nil
438- }
439-}
440-
441-// limitedReadCloser wraps an io.ReadCloser with a byte limit.
442-// When the limit is exceeded, subsequent reads return an error and the
443-// underlying body is closed exactly once to release upstream resources.
444-type limitedReadCloser struct {
445- io.ReadCloser
446- remaining int64
447- closed bool
448-}
449-
450-func (l *limitedReadCloser) Read(p []byte) (int, error) {
451- if l.remaining <= 0 {
452- // Close the underlying body to release upstream connection/resources
453- if !l.closed {
454- l.closed = true
455- l.ReadCloser.Close()
456- }
457- return 0, fmt.Errorf("response body exceeded maximum size limit")
458- }
459- if int64(len(p)) > l.remaining {
460- p = p[:l.remaining]
461- }
462- n, err := l.ReadCloser.Read(p)
463- l.remaining -= int64(n)
464- return n, err
465-}
466-
467-func (l *limitedReadCloser) Close() error {
468- if l.closed {
469- return nil
470- }
471- l.closed = true
472- return l.ReadCloser.Close()
473-}
474-
475-// proxyError writes an error response with security headers appropriate for
476-// proxy-generated error pages. CSP is set here (not in global middleware)
477-// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
478-func proxyError(w http.ResponseWriter, msg string, code int) {
479- w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
480- w.Header().Set("Cache-Control", "no-store")
481- http.Error(w, msg, code)
482-}
483-
484-// Helper methods
485-
486-// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
487-// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
488-// to strip the Authorization header when using the default Director. Rewrite processes
489-// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
490-//
491-// originalHost, when non-empty, overrides the Host header sent to the backend.
492-// This is used for DNS-pinned targets where the proxy dials an IP but needs to
493-// send the original hostname for virtual-hosted upstreams.
494-func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
495- return &httputil.ReverseProxy{
496- Rewrite: func(pr *httputil.ProxyRequest) {
497- pr.SetURL(targetURL)
498- // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
499- // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
500- // from the incoming request because we cannot verify it came from a trusted
501- // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
502- // to trick backends into thinking the connection is secure.
503- pr.SetXForwarded()
504- if originalHost != "" {
505- // DNS pinning: dial the resolved IP but send the original hostname
506- // so virtual-hosted upstreams route the request correctly.
507- pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
508- } else {
509- pr.Out.Host = targetURL.Host
510- }
511- },
512- Transport: proxyTransport,
513- ErrorHandler: errorHandler,
514- ModifyResponse: modifyResponse,
515- }
516-}
517-
518-func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
519 s.mu.RLock()306 s.mu.RLock()
520- cfg := s.config307+ defer s.mu.RUnlock()
521- s.mu.RUnlock()308+ return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
522-
523- log := zerowrap.FromCtx(r.Context())
524-
525- // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
526- targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
527- if err != nil {
528- log.WrapErr(err, "failed to parse target URL")
529- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
530- return
531- }
532-
533- log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
534-
535- // If DNS pinning resolved hostname to IP, preserve the original hostname
536- // for the Host header so virtual-hosted upstreams work correctly.
537- originalHost := target.OriginalHost
538- releaseInFlight := s.trackInFlight(target.ContainerID)
539- defer releaseInFlight()
540-
541- proxy := newReverseProxy(targetURL, originalHost,
542- func(w http.ResponseWriter, _ *http.Request, err error) {
543- var maxBytesErr *http.MaxBytesError
544- if errors.As(err, &maxBytesErr) {
545- log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
546- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
547- return
548- }
549- log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
550- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
551- },
552- s.modifyResponse(cfg),
553- )
554-
555- log.Debug().
556- Str("target", targetURL.String()).
557- Str(zerowrap.FieldEntityID, target.ContainerID).
558- Msg("proxying request")
559-
560- proxy.ServeHTTP(w, r)
561 }309 }
562 310
563-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() {
564 if containerID == "" {314 if containerID == "" {
565 return func() {}315 return func() {}
566 }316 }
@@ -580,6 +330,29 @@ func (s *Service) trackInFlight(containerID string) func() {
580 }330 }
581 }331 }
582 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+
583 // RegistryInFlight returns the current count of active registry proxy requests.356 // RegistryInFlight returns the current count of active registry proxy requests.
584 func (s *Service) RegistryInFlight() int64 {357 func (s *Service) RegistryInFlight() int64 {
585 return s.registryInFlight.Load()358 return s.registryInFlight.Load()
@@ -600,68 +373,6 @@ func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
600 return false373 return false
601 }374 }
602 375
603-// proxyToRegistry forwards requests to the local registry HTTP server.
604-// SECURITY: This uses http://localhost:{port} which is safe because the registry
605-// runs on the same host and traffic never leaves the loopback interface. If Gordon
606-// ever supports running the registry on a separate host, this must use TLS with
607-// certificate verification.
608-func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
609- // Copy config under RLock to avoid data race with UpdateConfig
610- s.mu.RLock()
611- cfg := s.config
612- s.mu.RUnlock()
613-
614- s.registryInFlight.Add(1)
615- defer s.registryInFlight.Add(-1)
616-
617- log := zerowrap.FromCtx(r.Context())
618-
619- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
620- if err != nil {
621- log.WrapErr(err, "failed to parse registry target URL")
622- proxyError(w, "Internal Server Error", http.StatusInternalServerError)
623- return
624- }
625-
626- proxy := &httputil.ReverseProxy{
627- Rewrite: func(pr *httputil.ProxyRequest) {
628- pr.SetURL(targetURL)
629- pr.SetXForwarded()
630- pr.Out.Host = targetURL.Host
631- },
632- Transport: registryTransport,
633- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
634- var maxBytesErr *http.MaxBytesError
635- if errors.As(err, &maxBytesErr) {
636- log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
637- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
638- return
639- }
640- log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
641- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
642- },
643- ModifyResponse: func(resp *http.Response) error {
644- resp.Header.Set("X-Proxied-By", "Gordon")
645- resp.Header.Set("X-Registry-Backend", "gordon-registry")
646-
647- // Remove security headers from registry response to prevent duplicates.
648- resp.Header.Del("X-Content-Type-Options")
649- resp.Header.Del("X-Frame-Options")
650- resp.Header.Del("X-XSS-Protection")
651- resp.Header.Del("Referrer-Policy")
652- resp.Header.Del("Permissions-Policy")
653- resp.Header.Del("Content-Security-Policy")
654- resp.Header.Del("Strict-Transport-Security")
655-
656- return nil
657- },
658- }
659-
660- log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
661-
662- proxy.ServeHTTP(w, r)
663-}
664-
665 func (s *Service) isRunningInContainer() bool {376 func (s *Service) isRunningInContainer() bool {
666 // Check for /.dockerenv377 // Check for /.dockerenv
667 if _, err := os.Stat("/.dockerenv"); err == nil {378 if _, err := os.Stat("/.dockerenv"); err == nil {
@@ -691,40 +402,3 @@ func (s *Service) isRunningInContainer() bool {
691 402
692 return false403 return false
693 }404 }
694-
695-// getProxyPort determines the port to proxy to for an image.
696-// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
697-func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
698- log := zerowrap.FromCtx(ctx)
699-
700- log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
701-
702- // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
703- labels, err := s.runtime.GetImageLabels(ctx, imageRef)
704- if err != nil {
705- log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
706- } else {
707- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
708- if portStr, ok := labels[key]; ok && portStr != "" {
709- port, err := strconv.Atoi(portStr)
710- if err == nil && port > 0 && port <= 65535 {
711- log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712- return port, nil
713- }
714- log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715- }
716- }
717- }
718-
719- // Fall back to first exposed port
720- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
721- if err != nil {
722- return 0, err
723- }
724- if len(exposedPorts) == 0 {
725- return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
726- }
727-
728- log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
729- return exposedPorts[0], nil
730-}
M internal/usecase/proxy/service_test.go +182 -115
@@ -2,7 +2,6 @@ package proxy
22
33 import (
44 "context"
5- "net/http/httptest"
65 "testing"
76 "time"
87
@@ -398,120 +397,6 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
398397 assert.NoError(t, err)
399398 }
400399
401-func TestService_ConcurrentConnectionLimit(t *testing.T) {
402- runtime := outmocks.NewMockContainerRuntime(t)
403- containerSvc := inmocks.NewMockContainerService(t)
404- configSvc := inmocks.NewMockConfigService(t)
405-
406- // Allow only 1 concurrent connection
407- svc := NewService(runtime, containerSvc, configSvc, Config{
408- MaxConcurrentConns: 1,
409- })
410-
411- assert.Equal(t, 1, svc.config.MaxConcurrentConns)
412- assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
413-}
414-
415-func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
416- runtime := outmocks.NewMockContainerRuntime(t)
417- containerSvc := inmocks.NewMockContainerService(t)
418- configSvc := inmocks.NewMockConfigService(t)
419-
420- svc := NewService(runtime, containerSvc, configSvc, Config{
421- MaxConcurrentConns: 0,
422- })
423-
424- assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
425-}
426-
427-func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
428- runtime := outmocks.NewMockContainerRuntime(t)
429- containerSvc := inmocks.NewMockContainerService(t)
430- configSvc := inmocks.NewMockConfigService(t)
431-
432- svc := NewService(runtime, containerSvc, configSvc, Config{
433- MaxConcurrentConns: 1,
434- })
435-
436- // Simulate an in-flight connection by incrementing the atomic counter
437- svc.activeConns.Add(1)
438- defer svc.activeConns.Add(-1)
439-
440- // Next request should get 503
441- req := httptest.NewRequest("GET", "http://example.com/test", nil)
442- w := httptest.NewRecorder()
443- svc.ServeHTTP(w, req)
444-
445- assert.Equal(t, 503, w.Code)
446-}
447-
448-func TestServeHTTP_MaxBodySize(t *testing.T) {
449- tests := []struct {
450- name string
451- maxBodySize int64
452- bodySize int
453- expect413 bool
454- }{
455- {
456- name: "body within limit",
457- maxBodySize: 1024,
458- bodySize: 512,
459- expect413: false,
460- },
461- {
462- name: "body exceeds limit returns 413",
463- maxBodySize: 1024,
464- bodySize: 2048,
465- expect413: true,
466- },
467- {
468- name: "no limit configured allows any size",
469- maxBodySize: 0,
470- bodySize: 10240,
471- expect413: false,
472- },
473- }
474-
475- for _, tt := range tests {
476- t.Run(tt.name, func(t *testing.T) {
477- // Create fresh mocks for each test
478- runtime := outmocks.NewMockContainerRuntime(t)
479- containerSvc := inmocks.NewMockContainerService(t)
480- configSvc := inmocks.NewMockConfigService(t)
481-
482- svc := NewService(runtime, containerSvc, configSvc, Config{
483- MaxBodySize: tt.maxBodySize,
484- })
485-
486- // Mock GetExternalRoutes to return empty map (no external routes)
487- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
488-
489- // Mock container service Get to return not found (404)
490- containerSvc.EXPECT().Get(mock.Anything, "example.com").Return(nil, false)
491-
492- // Create a test request with a body of specified size
493- body := make([]byte, tt.bodySize)
494- for i := range body {
495- body[i] = 'a'
496- }
497-
498- req := httptest.NewRequest("POST", "http://example.com/test", nil)
499- req.Header.Set("Host", "example.com")
500- w := httptest.NewRecorder()
501-
502- // Note: We're testing ServeHTTP which applies the MaxBytesReader wrapper
503- // The actual limit enforcement happens during request body reading by httputil.ReverseProxy
504- // So we verify the MaxBytesReader is applied correctly
505- svc.ServeHTTP(w, req)
506-
507- // The MaxBytesReader is applied, but the actual error happens when ReverseProxy reads the body
508- // We can't easily test the full flow without a real backend, so we verify the wrapper is applied
509- // by checking that MaxBodySize is properly stored in the service config
510- assert.Equal(t, tt.maxBodySize, svc.config.MaxBodySize)
511- })
512- }
513-}
514-
515400 func TestRegistryInFlightTracking(t *testing.T) {
516401 svc := &Service{
517402 inFlight: make(map[string]int),
@@ -572,6 +457,122 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
572457 }
573458 }
574459
460+func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
461+ runtime := outmocks.NewMockContainerRuntime(t)
462+ containerSvc := inmocks.NewMockContainerService(t)
463+ configSvc := inmocks.NewMockConfigService(t)
464+
465+ svc := NewService(runtime, containerSvc, configSvc, Config{
466+ RegistryDomain: "old.registry.com",
467+ RegistryPort: 5000,
468+ MaxBodySize: 1024,
469+ MaxResponseSize: 2048,
470+ MaxConcurrentConns: 10,
471+ })
472+
473+ cfg := svc.ProxyConfig()
474+ assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
475+ assert.Equal(t, 5000, cfg.RegistryPort)
476+ assert.Equal(t, int64(1024), cfg.MaxBodySize)
477+ assert.Equal(t, int64(2048), cfg.MaxResponseSize)
478+ assert.Equal(t, 10, cfg.MaxConcurrentConns)
479+
480+ svc.UpdateConfig(Config{
481+ RegistryDomain: "new.registry.com",
482+ RegistryPort: 5001,
483+ MaxBodySize: 4096,
484+ MaxResponseSize: 8192,
485+ MaxConcurrentConns: 50,
486+ })
487+
488+ cfg = svc.ProxyConfig()
489+ assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
490+ assert.Equal(t, 5001, cfg.RegistryPort)
491+ assert.Equal(t, int64(4096), cfg.MaxBodySize)
492+ assert.Equal(t, int64(8192), cfg.MaxResponseSize)
493+ assert.Equal(t, 50, cfg.MaxConcurrentConns)
494+}
495+
496+func TestService_IsRegistryDomain(t *testing.T) {
497+ runtime := outmocks.NewMockContainerRuntime(t)
498+ containerSvc := inmocks.NewMockContainerService(t)
499+ configSvc := inmocks.NewMockConfigService(t)
500+
501+ svc := NewService(runtime, containerSvc, configSvc, Config{
502+ RegistryDomain: "registry.example.com",
503+ })
504+
505+ assert.True(t, svc.IsRegistryDomain("registry.example.com"))
506+ assert.False(t, svc.IsRegistryDomain("other.example.com"))
507+ assert.False(t, svc.IsRegistryDomain(""))
508+}
509+
510+func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
511+ runtime := outmocks.NewMockContainerRuntime(t)
512+ containerSvc := inmocks.NewMockContainerService(t)
513+ configSvc := inmocks.NewMockConfigService(t)
514+
515+ svc := NewService(runtime, containerSvc, configSvc, Config{})
516+
517+ assert.False(t, svc.IsRegistryDomain("registry.example.com"))
518+ assert.False(t, svc.IsRegistryDomain(""))
519+}
520+
521+func TestService_TrackInFlight(t *testing.T) {
522+ svc := &Service{
523+ inFlight: make(map[string]int),
524+ }
525+
526+ // Empty container ID returns noop
527+ release := svc.TrackInFlight("")
528+ release() // should not panic
529+
530+ // Track a container
531+ release1 := svc.TrackInFlight("c-1")
532+ svc.inFlightMu.Lock()
533+ assert.Equal(t, 1, svc.inFlight["c-1"])
534+ svc.inFlightMu.Unlock()
535+
536+ // Track same container again
537+ release2 := svc.TrackInFlight("c-1")
538+ svc.inFlightMu.Lock()
539+ assert.Equal(t, 2, svc.inFlight["c-1"])
540+ svc.inFlightMu.Unlock()
541+
542+ // Release one
543+ release2()
544+ svc.inFlightMu.Lock()
545+ assert.Equal(t, 1, svc.inFlight["c-1"])
546+ svc.inFlightMu.Unlock()
547+
548+ // Release last — should delete key
549+ release1()
550+ svc.inFlightMu.Lock()
551+ _, exists := svc.inFlight["c-1"]
552+ assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
553+ svc.inFlightMu.Unlock()
554+}
555+
556+func TestService_TrackRegistryRequest(t *testing.T) {
557+ svc := &Service{
558+ inFlight: make(map[string]int),
559+ }
560+
561+ assert.Equal(t, int64(0), svc.RegistryInFlight())
562+
563+ svc.TrackRegistryRequest()
564+ assert.Equal(t, int64(1), svc.RegistryInFlight())
565+
566+ svc.TrackRegistryRequest()
567+ assert.Equal(t, int64(2), svc.RegistryInFlight())
568+
569+ svc.ReleaseRegistryRequest()
570+ assert.Equal(t, int64(1), svc.RegistryInFlight())
571+
572+ svc.ReleaseRegistryRequest()
573+ assert.Equal(t, int64(0), svc.RegistryInFlight())
574+}
575+
575576 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576577 runtime := outmocks.NewMockContainerRuntime(t)
577578 containerSvc := inmocks.NewMockContainerService(t)
@@ -710,3 +711,69 @@ func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
710711 assert.NoError(t, err)
711712 assert.Equal(t, 35000, result.Port)
712713 }
714+
715+func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
716+ runtime := outmocks.NewMockContainerRuntime(t)
717+ containerSvc := inmocks.NewMockContainerService(t)
718+ configSvc := inmocks.NewMockConfigService(t)
719+
720+ svc := NewService(runtime, containerSvc, configSvc, Config{})
721+ ctx := testContext()
722+
723+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
724+
725+ container := &domain.Container{
726+ ID: "c-grpc",
727+ Image: "grpc-app:latest",
728+ }
729+ containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
730+
731+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
732+ {Domain: "grpc.example.com", Image: "grpc-app:latest"},
733+ })
734+
735+ runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
736+ domain.LabelProxyPort: "50051",
737+ domain.LabelProxyProtocol: "h2c",
738+ }, nil)
739+
740+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
741+
742+ result, err := svc.GetTarget(ctx, "grpc.example.com")
743+
744+ assert.NoError(t, err)
745+ assert.Equal(t, "h2c", result.Protocol)
746+ assert.Equal(t, 50051, result.Port)
747+}
748+
749+func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
750+ runtime := outmocks.NewMockContainerRuntime(t)
751+ containerSvc := inmocks.NewMockContainerService(t)
752+ configSvc := inmocks.NewMockConfigService(t)
753+
754+ svc := NewService(runtime, containerSvc, configSvc, Config{})
755+ ctx := testContext()
756+
757+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
758+
759+ container := &domain.Container{
760+ ID: "c-web",
761+ Image: "web:latest",
762+ }
763+ containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
764+
765+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
766+ {Domain: "web.example.com", Image: "web:latest"},
767+ })
768+
769+ runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
770+ domain.LabelProxyPort: "8080",
771+ }, nil)
772+
773+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
774+
775+ result, err := svc.GetTarget(ctx, "web.example.com")
776+
777+ assert.NoError(t, err)
778+ assert.Equal(t, "", result.Protocol)
779+}
@@ -2,7 +2,6 @@ package proxy
2 2
3 import (3 import (
4 "context"4 "context"
5- "net/http/httptest"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -398,120 +397,6 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
398 assert.NoError(t, err)397 assert.NoError(t, err)
399 }398 }
400 399
401-func TestService_ConcurrentConnectionLimit(t *testing.T) {
402- runtime := outmocks.NewMockContainerRuntime(t)
403- containerSvc := inmocks.NewMockContainerService(t)
404- configSvc := inmocks.NewMockConfigService(t)
405-
406- // Allow only 1 concurrent connection
407- svc := NewService(runtime, containerSvc, configSvc, Config{
408- MaxConcurrentConns: 1,
409- })
410-
411- assert.Equal(t, 1, svc.config.MaxConcurrentConns)
412- assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
413-}
414-
415-func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
416- runtime := outmocks.NewMockContainerRuntime(t)
417- containerSvc := inmocks.NewMockContainerService(t)
418- configSvc := inmocks.NewMockConfigService(t)
419-
420- svc := NewService(runtime, containerSvc, configSvc, Config{
421- MaxConcurrentConns: 0,
422- })
423-
424- assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
425-}
426-
427-func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
428- runtime := outmocks.NewMockContainerRuntime(t)
429- containerSvc := inmocks.NewMockContainerService(t)
430- configSvc := inmocks.NewMockConfigService(t)
431-
432- svc := NewService(runtime, containerSvc, configSvc, Config{
433- MaxConcurrentConns: 1,
434- })
435-
436- // Simulate an in-flight connection by incrementing the atomic counter
437- svc.activeConns.Add(1)
438- defer svc.activeConns.Add(-1)
439-
440- // Next request should get 503
441- req := httptest.NewRequest("GET", "http://example.com/test", nil)
442- w := httptest.NewRecorder()
443- svc.ServeHTTP(w, req)
444-
445- assert.Equal(t, 503, w.Code)
446-}
447-
448-func TestServeHTTP_MaxBodySize(t *testing.T) {
449- tests := []struct {
450- name string
451- maxBodySize int64
452- bodySize int
453- expect413 bool
454- }{
455- {
456- name: "body within limit",
457- maxBodySize: 1024,
458- bodySize: 512,
459- expect413: false,
460- },
461- {
462- name: "body exceeds limit returns 413",
463- maxBodySize: 1024,
464- bodySize: 2048,
465- expect413: true,
466- },
467- {
468- name: "no limit configured allows any size",
469- maxBodySize: 0,
470- bodySize: 10240,
471- expect413: false,
472- },
473- }
474-
475- for _, tt := range tests {
476- t.Run(tt.name, func(t *testing.T) {
477- // Create fresh mocks for each test
478- runtime := outmocks.NewMockContainerRuntime(t)
479- containerSvc := inmocks.NewMockContainerService(t)
480- configSvc := inmocks.NewMockConfigService(t)
481-
482- svc := NewService(runtime, containerSvc, configSvc, Config{
483- MaxBodySize: tt.maxBodySize,
484- })
485-
486- // Mock GetExternalRoutes to return empty map (no external routes)
487- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
488-
489- // Mock container service Get to return not found (404)
490- containerSvc.EXPECT().Get(mock.Anything, "example.com").Return(nil, false)
491-
492- // Create a test request with a body of specified size
493- body := make([]byte, tt.bodySize)
494- for i := range body {
495- body[i] = 'a'
496- }
497-
498- req := httptest.NewRequest("POST", "http://example.com/test", nil)
499- req.Header.Set("Host", "example.com")
500- w := httptest.NewRecorder()
501-
502- // Note: We're testing ServeHTTP which applies the MaxBytesReader wrapper
503- // The actual limit enforcement happens during request body reading by httputil.ReverseProxy
504- // So we verify the MaxBytesReader is applied correctly
505- svc.ServeHTTP(w, req)
506-
507- // The MaxBytesReader is applied, but the actual error happens when ReverseProxy reads the body
508- // We can't easily test the full flow without a real backend, so we verify the wrapper is applied
509- // by checking that MaxBodySize is properly stored in the service config
510- assert.Equal(t, tt.maxBodySize, svc.config.MaxBodySize)
511- })
512- }
513-}
514-
515 func TestRegistryInFlightTracking(t *testing.T) {400 func TestRegistryInFlightTracking(t *testing.T) {
516 svc := &Service{401 svc := &Service{
517 inFlight: make(map[string]int),402 inFlight: make(map[string]int),
@@ -572,6 +457,122 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
572 }457 }
573 }458 }
574 459
460+func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
461+ runtime := outmocks.NewMockContainerRuntime(t)
462+ containerSvc := inmocks.NewMockContainerService(t)
463+ configSvc := inmocks.NewMockConfigService(t)
464+
465+ svc := NewService(runtime, containerSvc, configSvc, Config{
466+ RegistryDomain: "old.registry.com",
467+ RegistryPort: 5000,
468+ MaxBodySize: 1024,
469+ MaxResponseSize: 2048,
470+ MaxConcurrentConns: 10,
471+ })
472+
473+ cfg := svc.ProxyConfig()
474+ assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
475+ assert.Equal(t, 5000, cfg.RegistryPort)
476+ assert.Equal(t, int64(1024), cfg.MaxBodySize)
477+ assert.Equal(t, int64(2048), cfg.MaxResponseSize)
478+ assert.Equal(t, 10, cfg.MaxConcurrentConns)
479+
480+ svc.UpdateConfig(Config{
481+ RegistryDomain: "new.registry.com",
482+ RegistryPort: 5001,
483+ MaxBodySize: 4096,
484+ MaxResponseSize: 8192,
485+ MaxConcurrentConns: 50,
486+ })
487+
488+ cfg = svc.ProxyConfig()
489+ assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
490+ assert.Equal(t, 5001, cfg.RegistryPort)
491+ assert.Equal(t, int64(4096), cfg.MaxBodySize)
492+ assert.Equal(t, int64(8192), cfg.MaxResponseSize)
493+ assert.Equal(t, 50, cfg.MaxConcurrentConns)
494+}
495+
496+func TestService_IsRegistryDomain(t *testing.T) {
497+ runtime := outmocks.NewMockContainerRuntime(t)
498+ containerSvc := inmocks.NewMockContainerService(t)
499+ configSvc := inmocks.NewMockConfigService(t)
500+
501+ svc := NewService(runtime, containerSvc, configSvc, Config{
502+ RegistryDomain: "registry.example.com",
503+ })
504+
505+ assert.True(t, svc.IsRegistryDomain("registry.example.com"))
506+ assert.False(t, svc.IsRegistryDomain("other.example.com"))
507+ assert.False(t, svc.IsRegistryDomain(""))
508+}
509+
510+func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
511+ runtime := outmocks.NewMockContainerRuntime(t)
512+ containerSvc := inmocks.NewMockContainerService(t)
513+ configSvc := inmocks.NewMockConfigService(t)
514+
515+ svc := NewService(runtime, containerSvc, configSvc, Config{})
516+
517+ assert.False(t, svc.IsRegistryDomain("registry.example.com"))
518+ assert.False(t, svc.IsRegistryDomain(""))
519+}
520+
521+func TestService_TrackInFlight(t *testing.T) {
522+ svc := &Service{
523+ inFlight: make(map[string]int),
524+ }
525+
526+ // Empty container ID returns noop
527+ release := svc.TrackInFlight("")
528+ release() // should not panic
529+
530+ // Track a container
531+ release1 := svc.TrackInFlight("c-1")
532+ svc.inFlightMu.Lock()
533+ assert.Equal(t, 1, svc.inFlight["c-1"])
534+ svc.inFlightMu.Unlock()
535+
536+ // Track same container again
537+ release2 := svc.TrackInFlight("c-1")
538+ svc.inFlightMu.Lock()
539+ assert.Equal(t, 2, svc.inFlight["c-1"])
540+ svc.inFlightMu.Unlock()
541+
542+ // Release one
543+ release2()
544+ svc.inFlightMu.Lock()
545+ assert.Equal(t, 1, svc.inFlight["c-1"])
546+ svc.inFlightMu.Unlock()
547+
548+ // Release last — should delete key
549+ release1()
550+ svc.inFlightMu.Lock()
551+ _, exists := svc.inFlight["c-1"]
552+ assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
553+ svc.inFlightMu.Unlock()
554+}
555+
556+func TestService_TrackRegistryRequest(t *testing.T) {
557+ svc := &Service{
558+ inFlight: make(map[string]int),
559+ }
560+
561+ assert.Equal(t, int64(0), svc.RegistryInFlight())
562+
563+ svc.TrackRegistryRequest()
564+ assert.Equal(t, int64(1), svc.RegistryInFlight())
565+
566+ svc.TrackRegistryRequest()
567+ assert.Equal(t, int64(2), svc.RegistryInFlight())
568+
569+ svc.ReleaseRegistryRequest()
570+ assert.Equal(t, int64(1), svc.RegistryInFlight())
571+
572+ svc.ReleaseRegistryRequest()
573+ assert.Equal(t, int64(0), svc.RegistryInFlight())
574+}
575+
575 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {576 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576 runtime := outmocks.NewMockContainerRuntime(t)577 runtime := outmocks.NewMockContainerRuntime(t)
577 containerSvc := inmocks.NewMockContainerService(t)578 containerSvc := inmocks.NewMockContainerService(t)
@@ -710,3 +711,69 @@ func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
710 assert.NoError(t, err)711 assert.NoError(t, err)
711 assert.Equal(t, 35000, result.Port)712 assert.Equal(t, 35000, result.Port)
712 }713 }
714+
715+func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
716+ runtime := outmocks.NewMockContainerRuntime(t)
717+ containerSvc := inmocks.NewMockContainerService(t)
718+ configSvc := inmocks.NewMockConfigService(t)
719+
720+ svc := NewService(runtime, containerSvc, configSvc, Config{})
721+ ctx := testContext()
722+
723+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
724+
725+ container := &domain.Container{
726+ ID: "c-grpc",
727+ Image: "grpc-app:latest",
728+ }
729+ containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
730+
731+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
732+ {Domain: "grpc.example.com", Image: "grpc-app:latest"},
733+ })
734+
735+ runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
736+ domain.LabelProxyPort: "50051",
737+ domain.LabelProxyProtocol: "h2c",
738+ }, nil)
739+
740+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
741+
742+ result, err := svc.GetTarget(ctx, "grpc.example.com")
743+
744+ assert.NoError(t, err)
745+ assert.Equal(t, "h2c", result.Protocol)
746+ assert.Equal(t, 50051, result.Port)
747+}
748+
749+func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
750+ runtime := outmocks.NewMockContainerRuntime(t)
751+ containerSvc := inmocks.NewMockContainerService(t)
752+ configSvc := inmocks.NewMockConfigService(t)
753+
754+ svc := NewService(runtime, containerSvc, configSvc, Config{})
755+ ctx := testContext()
756+
757+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
758+
759+ container := &domain.Container{
760+ ID: "c-web",
761+ Image: "web:latest",
762+ }
763+ containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
764+
765+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
766+ {Domain: "web.example.com", Image: "web:latest"},
767+ })
768+
769+ runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
770+ domain.LabelProxyPort: "8080",
771+ }, nil)
772+
773+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
774+
775+ result, err := svc.GetTarget(ctx, "web.example.com")
776+
777+ assert.NoError(t, err)
778+ assert.Equal(t, "", result.Protocol)
779+}
A internal/usecase/proxy/target_metadata.go +74 -0
@@ -0,0 +1,74 @@
1+package proxy
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TargetMetadata holds resolved backend connection metadata for a container image.
14+type TargetMetadata struct {
15+ Port int // The port to proxy traffic to
16+ Protocol string // "" for HTTP/1.1, "h2c" for cleartext HTTP/2
17+}
18+
19+// resolveTargetMetadata determines the port and protocol for an image by reading
20+// image labels. It checks gordon.proxy.port first, then the deprecated gordon.port
21+// alias, then falls back to the first exposed port. Protocol is read from
22+// gordon.proxy.protocol (only "h2c" is recognized; unknown values are ignored).
23+func (s *Service) resolveTargetMetadata(ctx context.Context, imageRef string) (TargetMetadata, error) {
24+ log := zerowrap.FromCtx(ctx)
25+ log.Debug().Str("image_ref", imageRef).Msg("resolving target metadata for image")
26+
27+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
28+ if err != nil {
29+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
30+ labels = nil
31+ }
32+
33+ port := 0
34+ if labels != nil {
35+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
36+ if portStr, ok := labels[key]; ok && portStr != "" {
37+ p, convErr := strconv.Atoi(portStr)
38+ if convErr == nil && p > 0 && p <= 65535 {
39+ log.Debug().Int("port", p).Str("label", key).Msg("using proxy port from image label")
40+ port = p
41+ break
42+ }
43+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
44+ }
45+ }
46+ }
47+
48+ if port == 0 {
49+ exposedPorts, portsErr := s.runtime.GetImageExposedPorts(ctx, imageRef)
50+ if portsErr != nil {
51+ return TargetMetadata{}, fmt.Errorf("failed to get exposed ports for image %s: %w", imageRef, portsErr)
52+ }
53+ if len(exposedPorts) == 0 {
54+ return TargetMetadata{}, fmt.Errorf("no exposed ports found for image %s", imageRef)
55+ }
56+ port = exposedPorts[0]
57+ log.Debug().Int("port", port).Msg("using first exposed port")
58+ }
59+
60+ protocol := ""
61+ if labels != nil {
62+ proto := labels[domain.LabelProxyProtocol]
63+ switch proto {
64+ case "h2c":
65+ protocol = "h2c"
66+ log.Debug().Str("protocol", proto).Msg("container opts into h2c")
67+ case "":
68+ default:
69+ log.Warn().Str("protocol", proto).Msg("unknown proxy protocol label value, ignoring")
70+ }
71+ }
72+
73+ return TargetMetadata{Port: port, Protocol: protocol}, nil
74+}
@@ -0,0 +1,74 @@
1+package proxy
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TargetMetadata holds resolved backend connection metadata for a container image.
14+type TargetMetadata struct {
15+ Port int // The port to proxy traffic to
16+ Protocol string // "" for HTTP/1.1, "h2c" for cleartext HTTP/2
17+}
18+
19+// resolveTargetMetadata determines the port and protocol for an image by reading
20+// image labels. It checks gordon.proxy.port first, then the deprecated gordon.port
21+// alias, then falls back to the first exposed port. Protocol is read from
22+// gordon.proxy.protocol (only "h2c" is recognized; unknown values are ignored).
23+func (s *Service) resolveTargetMetadata(ctx context.Context, imageRef string) (TargetMetadata, error) {
24+ log := zerowrap.FromCtx(ctx)
25+ log.Debug().Str("image_ref", imageRef).Msg("resolving target metadata for image")
26+
27+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
28+ if err != nil {
29+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
30+ labels = nil
31+ }
32+
33+ port := 0
34+ if labels != nil {
35+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
36+ if portStr, ok := labels[key]; ok && portStr != "" {
37+ p, convErr := strconv.Atoi(portStr)
38+ if convErr == nil && p > 0 && p <= 65535 {
39+ log.Debug().Int("port", p).Str("label", key).Msg("using proxy port from image label")
40+ port = p
41+ break
42+ }
43+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
44+ }
45+ }
46+ }
47+
48+ if port == 0 {
49+ exposedPorts, portsErr := s.runtime.GetImageExposedPorts(ctx, imageRef)
50+ if portsErr != nil {
51+ return TargetMetadata{}, fmt.Errorf("failed to get exposed ports for image %s: %w", imageRef, portsErr)
52+ }
53+ if len(exposedPorts) == 0 {
54+ return TargetMetadata{}, fmt.Errorf("no exposed ports found for image %s", imageRef)
55+ }
56+ port = exposedPorts[0]
57+ log.Debug().Int("port", port).Msg("using first exposed port")
58+ }
59+
60+ protocol := ""
61+ if labels != nil {
62+ proto := labels[domain.LabelProxyProtocol]
63+ switch proto {
64+ case "h2c":
65+ protocol = "h2c"
66+ log.Debug().Str("protocol", proto).Msg("container opts into h2c")
67+ case "":
68+ default:
69+ log.Warn().Str("protocol", proto).Msg("unknown proxy protocol label value, ignoring")
70+ }
71+ }
72+
73+ return TargetMetadata{Port: port, Protocol: protocol}, nil
74+}
A internal/usecase/proxy/target_metadata_test.go +137 -0
@@ -0,0 +1,137 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestResolveTargetMetadata_PortAndProtocolFromLabels(t *testing.T) {
14+ runtime := outmocks.NewMockContainerRuntime(t)
15+ svc := &Service{runtime: runtime}
16+ ctx := testContext()
17+
18+ runtime.EXPECT().GetImageLabels(ctx, "grpc-app:latest").Return(map[string]string{
19+ domain.LabelProxyPort: "50051",
20+ domain.LabelProxyProtocol: "h2c",
21+ }, nil)
22+
23+ meta, err := svc.resolveTargetMetadata(ctx, "grpc-app:latest")
24+
25+ assert.NoError(t, err)
26+ assert.Equal(t, 50051, meta.Port)
27+ assert.Equal(t, "h2c", meta.Protocol)
28+}
29+
30+func TestResolveTargetMetadata_DefaultProtocolWhenAbsent(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := &Service{runtime: runtime}
33+ ctx := testContext()
34+
35+ runtime.EXPECT().GetImageLabels(ctx, "web:latest").Return(map[string]string{
36+ domain.LabelProxyPort: "8080",
37+ }, nil)
38+
39+ meta, err := svc.resolveTargetMetadata(ctx, "web:latest")
40+
41+ assert.NoError(t, err)
42+ assert.Equal(t, 8080, meta.Port)
43+ assert.Equal(t, "", meta.Protocol)
44+}
45+
46+func TestResolveTargetMetadata_DeprecatedPortLabel(t *testing.T) {
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ svc := &Service{runtime: runtime}
49+ ctx := testContext()
50+
51+ runtime.EXPECT().GetImageLabels(ctx, "old:latest").Return(map[string]string{
52+ domain.LabelPort: "3000",
53+ }, nil)
54+
55+ meta, err := svc.resolveTargetMetadata(ctx, "old:latest")
56+
57+ assert.NoError(t, err)
58+ assert.Equal(t, 3000, meta.Port)
59+ assert.Equal(t, "", meta.Protocol)
60+}
61+
62+func TestResolveTargetMetadata_ProxyPortWinsOverDeprecated(t *testing.T) {
63+ runtime := outmocks.NewMockContainerRuntime(t)
64+ svc := &Service{runtime: runtime}
65+ ctx := testContext()
66+
67+ runtime.EXPECT().GetImageLabels(ctx, "dual:latest").Return(map[string]string{
68+ domain.LabelProxyPort: "9000",
69+ domain.LabelPort: "3000",
70+ }, nil)
71+
72+ meta, err := svc.resolveTargetMetadata(ctx, "dual:latest")
73+
74+ assert.NoError(t, err)
75+ assert.Equal(t, 9000, meta.Port)
76+}
77+
78+func TestResolveTargetMetadata_FallsBackToExposedPort(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := &Service{runtime: runtime}
81+ ctx := testContext()
82+
83+ runtime.EXPECT().GetImageLabels(ctx, "plain:latest").Return(map[string]string{}, nil)
84+ runtime.EXPECT().GetImageExposedPorts(ctx, "plain:latest").Return([]int{8080}, nil)
85+
86+ meta, err := svc.resolveTargetMetadata(ctx, "plain:latest")
87+
88+ assert.NoError(t, err)
89+ assert.Equal(t, 8080, meta.Port)
90+ assert.Equal(t, "", meta.Protocol)
91+}
92+
93+func TestResolveTargetMetadata_UnknownProtocolIgnored(t *testing.T) {
94+ runtime := outmocks.NewMockContainerRuntime(t)
95+ svc := &Service{runtime: runtime}
96+ ctx := testContext()
97+
98+ runtime.EXPECT().GetImageLabels(ctx, "exotic:latest").Return(map[string]string{
99+ domain.LabelProxyPort: "8080",
100+ domain.LabelProxyProtocol: "quic",
101+ }, nil)
102+
103+ meta, err := svc.resolveTargetMetadata(ctx, "exotic:latest")
104+
105+ assert.NoError(t, err)
106+ assert.Equal(t, 8080, meta.Port)
107+ assert.Equal(t, "", meta.Protocol)
108+}
109+
110+func TestResolveTargetMetadata_NoPortsAvailable(t *testing.T) {
111+ runtime := outmocks.NewMockContainerRuntime(t)
112+ svc := &Service{runtime: runtime}
113+ ctx := testContext()
114+
115+ runtime.EXPECT().GetImageLabels(ctx, "empty:latest").Return(map[string]string{}, nil)
116+ runtime.EXPECT().GetImageExposedPorts(ctx, "empty:latest").Return([]int{}, nil)
117+
118+ _, err := svc.resolveTargetMetadata(ctx, "empty:latest")
119+
120+ assert.Error(t, err)
121+ assert.Contains(t, err.Error(), "no exposed ports")
122+}
123+
124+func TestResolveTargetMetadata_LabelErrorFallsBackToExposedPort(t *testing.T) {
125+ runtime := outmocks.NewMockContainerRuntime(t)
126+ svc := &Service{runtime: runtime}
127+ ctx := testContext()
128+
129+ runtime.EXPECT().GetImageLabels(ctx, "broken:latest").Return(nil, fmt.Errorf("image inspect failed"))
130+ runtime.EXPECT().GetImageExposedPorts(ctx, "broken:latest").Return([]int{3000}, nil)
131+
132+ meta, err := svc.resolveTargetMetadata(ctx, "broken:latest")
133+
134+ assert.NoError(t, err)
135+ assert.Equal(t, 3000, meta.Port)
136+ assert.Equal(t, "", meta.Protocol)
137+}
@@ -0,0 +1,137 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestResolveTargetMetadata_PortAndProtocolFromLabels(t *testing.T) {
14+ runtime := outmocks.NewMockContainerRuntime(t)
15+ svc := &Service{runtime: runtime}
16+ ctx := testContext()
17+
18+ runtime.EXPECT().GetImageLabels(ctx, "grpc-app:latest").Return(map[string]string{
19+ domain.LabelProxyPort: "50051",
20+ domain.LabelProxyProtocol: "h2c",
21+ }, nil)
22+
23+ meta, err := svc.resolveTargetMetadata(ctx, "grpc-app:latest")
24+
25+ assert.NoError(t, err)
26+ assert.Equal(t, 50051, meta.Port)
27+ assert.Equal(t, "h2c", meta.Protocol)
28+}
29+
30+func TestResolveTargetMetadata_DefaultProtocolWhenAbsent(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := &Service{runtime: runtime}
33+ ctx := testContext()
34+
35+ runtime.EXPECT().GetImageLabels(ctx, "web:latest").Return(map[string]string{
36+ domain.LabelProxyPort: "8080",
37+ }, nil)
38+
39+ meta, err := svc.resolveTargetMetadata(ctx, "web:latest")
40+
41+ assert.NoError(t, err)
42+ assert.Equal(t, 8080, meta.Port)
43+ assert.Equal(t, "", meta.Protocol)
44+}
45+
46+func TestResolveTargetMetadata_DeprecatedPortLabel(t *testing.T) {
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ svc := &Service{runtime: runtime}
49+ ctx := testContext()
50+
51+ runtime.EXPECT().GetImageLabels(ctx, "old:latest").Return(map[string]string{
52+ domain.LabelPort: "3000",
53+ }, nil)
54+
55+ meta, err := svc.resolveTargetMetadata(ctx, "old:latest")
56+
57+ assert.NoError(t, err)
58+ assert.Equal(t, 3000, meta.Port)
59+ assert.Equal(t, "", meta.Protocol)
60+}
61+
62+func TestResolveTargetMetadata_ProxyPortWinsOverDeprecated(t *testing.T) {
63+ runtime := outmocks.NewMockContainerRuntime(t)
64+ svc := &Service{runtime: runtime}
65+ ctx := testContext()
66+
67+ runtime.EXPECT().GetImageLabels(ctx, "dual:latest").Return(map[string]string{
68+ domain.LabelProxyPort: "9000",
69+ domain.LabelPort: "3000",
70+ }, nil)
71+
72+ meta, err := svc.resolveTargetMetadata(ctx, "dual:latest")
73+
74+ assert.NoError(t, err)
75+ assert.Equal(t, 9000, meta.Port)
76+}
77+
78+func TestResolveTargetMetadata_FallsBackToExposedPort(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := &Service{runtime: runtime}
81+ ctx := testContext()
82+
83+ runtime.EXPECT().GetImageLabels(ctx, "plain:latest").Return(map[string]string{}, nil)
84+ runtime.EXPECT().GetImageExposedPorts(ctx, "plain:latest").Return([]int{8080}, nil)
85+
86+ meta, err := svc.resolveTargetMetadata(ctx, "plain:latest")
87+
88+ assert.NoError(t, err)
89+ assert.Equal(t, 8080, meta.Port)
90+ assert.Equal(t, "", meta.Protocol)
91+}
92+
93+func TestResolveTargetMetadata_UnknownProtocolIgnored(t *testing.T) {
94+ runtime := outmocks.NewMockContainerRuntime(t)
95+ svc := &Service{runtime: runtime}
96+ ctx := testContext()
97+
98+ runtime.EXPECT().GetImageLabels(ctx, "exotic:latest").Return(map[string]string{
99+ domain.LabelProxyPort: "8080",
100+ domain.LabelProxyProtocol: "quic",
101+ }, nil)
102+
103+ meta, err := svc.resolveTargetMetadata(ctx, "exotic:latest")
104+
105+ assert.NoError(t, err)
106+ assert.Equal(t, 8080, meta.Port)
107+ assert.Equal(t, "", meta.Protocol)
108+}
109+
110+func TestResolveTargetMetadata_NoPortsAvailable(t *testing.T) {
111+ runtime := outmocks.NewMockContainerRuntime(t)
112+ svc := &Service{runtime: runtime}
113+ ctx := testContext()
114+
115+ runtime.EXPECT().GetImageLabels(ctx, "empty:latest").Return(map[string]string{}, nil)
116+ runtime.EXPECT().GetImageExposedPorts(ctx, "empty:latest").Return([]int{}, nil)
117+
118+ _, err := svc.resolveTargetMetadata(ctx, "empty:latest")
119+
120+ assert.Error(t, err)
121+ assert.Contains(t, err.Error(), "no exposed ports")
122+}
123+
124+func TestResolveTargetMetadata_LabelErrorFallsBackToExposedPort(t *testing.T) {
125+ runtime := outmocks.NewMockContainerRuntime(t)
126+ svc := &Service{runtime: runtime}
127+ ctx := testContext()
128+
129+ runtime.EXPECT().GetImageLabels(ctx, "broken:latest").Return(nil, fmt.Errorf("image inspect failed"))
130+ runtime.EXPECT().GetImageExposedPorts(ctx, "broken:latest").Return([]int{3000}, nil)
131+
132+ meta, err := svc.resolveTargetMetadata(ctx, "broken:latest")
133+
134+ assert.NoError(t, err)
135+ assert.Equal(t, 3000, meta.Port)
136+ assert.Equal(t, "", meta.Protocol)
137+}

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