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