1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httputil"
12 "net/url"
13 "os"
14 "strconv"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19
20 "github.com/bnema/zerowrap"
21
22 "github.com/bnema/gordon/internal/boundaries/in"
23 "github.com/bnema/gordon/internal/boundaries/out"
24 "github.com/bnema/gordon/internal/domain"
25)
26
27// proxyTransport is a shared HTTP transport with proper timeouts.
28// This prevents resource exhaustion from slow backends or network issues.
29var proxyTransport = &http.Transport{
30 DialContext: (&net.Dialer{
31 Timeout: 10 * time.Second,
32 KeepAlive: 30 * time.Second,
33 }).DialContext,
34 TLSHandshakeTimeout: 10 * time.Second,
35 ResponseHeaderTimeout: 30 * time.Second,
36 MaxIdleConns: 100,
37 MaxIdleConnsPerHost: 10,
38 IdleConnTimeout: 90 * time.Second,
39}
40
41// Config holds configuration needed by the proxy service.
42type Config struct {
43 RegistryDomain string
44 RegistryPort int
45 MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
46 MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
47 MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
48}
49
50// Service implements the ProxyService interface.
51type Service struct {
52 runtime out.ContainerRuntime
53 containerSvc in.ContainerService
54 configSvc in.ConfigService
55 config Config
56 targets map[string]*domain.ProxyTarget
57 mu sync.RWMutex
58 activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
59}
60
61// NewService creates a new proxy service.
62func NewService(
63 runtime out.ContainerRuntime,
64 containerSvc in.ContainerService,
65 configSvc in.ConfigService,
66 config Config,
67) *Service {
68 return &Service{
69 runtime: runtime,
70 containerSvc: containerSvc,
71 configSvc: configSvc,
72 config: config,
73 targets: make(map[string]*domain.ProxyTarget),
74 }
75}
76
77// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
78func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79 // Copy config under RLock to avoid data race with UpdateConfig
80 s.mu.RLock()
81 cfg := s.config
82 s.mu.RUnlock()
83
84 // SECURITY: Limit concurrent connections to prevent resource exhaustion.
85 // Uses an atomic counter so config reloads don't create a race between
86 // an old and new semaphore — the counter is shared across reloads.
87 if cfg.MaxConcurrentConns > 0 {
88 current := s.activeConns.Add(1)
89 if current > int64(cfg.MaxConcurrentConns) {
90 s.activeConns.Add(-1)
91 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
92 return
93 }
94 defer s.activeConns.Add(-1)
95 }
96
97 // SECURITY: Limit request body size to prevent resource exhaustion.
98 if cfg.MaxBodySize > 0 {
99 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
100 }
101
102 // Enrich request context with fields for downstream logging
103 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
104 zerowrap.FieldLayer: "adapter",
105 zerowrap.FieldAdapter: "http",
106 zerowrap.FieldMethod: r.Method,
107 zerowrap.FieldPath: r.URL.Path,
108 zerowrap.FieldHost: r.Host,
109 zerowrap.FieldClientIP: r.RemoteAddr,
110 })
111 r = r.WithContext(ctx)
112 log := zerowrap.FromCtx(ctx)
113
114 // Check if this is the registry domain
115 if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
116 log.Info().Msg("routing request to registry")
117 s.proxyToRegistry(w, r)
118 return
119 }
120
121 // Get target for this domain
122 log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
123 target, err := s.GetTarget(ctx, r.Host)
124 if err != nil {
125 log.Warn().Err(err).Msg("no route found for domain")
126 proxyError(w, "404 page not found", http.StatusNotFound)
127 return
128 }
129
130 log.Debug().
131 Str("host", target.Host).
132 Int("port", target.Port).
133 Str("container_id", target.ContainerID).
134 Msg("resolved proxy target")
135
136 s.proxyToTarget(w, r, target)
137}
138
139// GetTarget returns the proxy target for a given domain.
140func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
141 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
142 zerowrap.FieldLayer: "usecase",
143 zerowrap.FieldUseCase: "GetTarget",
144 "domain": domainName,
145 })
146 log := zerowrap.FromCtx(ctx)
147
148 // Check cache first
149 s.mu.RLock()
150 if target, exists := s.targets[domainName]; exists {
151 s.mu.RUnlock()
152 log.Debug().
153 Str("host", target.Host).
154 Int("port", target.Port).
155 Str("container_id", target.ContainerID).
156 Msg("using cached proxy target")
157 return target, nil
158 }
159 s.mu.RUnlock()
160
161 // Check if this is an external route
162 externalRoutes := s.configSvc.GetExternalRoutes()
163 if targetAddr, ok := externalRoutes[domainName]; ok {
164 host, portStr, err := net.SplitHostPort(targetAddr)
165 if err != nil {
166 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
167 }
168 port, err := strconv.Atoi(portStr)
169 if err != nil {
170 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
171 }
172
173 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
174 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
175 // (TOCTOU) attacks where a hostname resolves to a public IP during validation
176 // but to a private IP when the proxy connects.
177 resolvedIP, err := ResolveAndValidateHost(host)
178 if err != nil {
179 log.Warn().
180 Err(err).
181 Str("host", host).
182 Str("domain", domainName).
183 Msg("SSRF protection: blocked external route to internal network")
184 return nil, err
185 }
186
187 // Preserve the original hostname for the Host header so virtual-hosted
188 // upstreams work correctly. The resolved IP is used for dialing only.
189 var originalHost string
190 if resolvedIP != host {
191 originalHost = host
192 }
193
194 target := &domain.ProxyTarget{
195 Host: resolvedIP,
196 Port: port,
197 ContainerID: "", // Not a container
198 Scheme: "http",
199 OriginalHost: originalHost,
200 }
201
202 // Cache external route target
203 s.mu.Lock()
204 s.targets[domainName] = target
205 s.mu.Unlock()
206
207 log.Debug().
208 Str("host", host).
209 Int("port", port).
210 Msg("using external route target")
211 return target, nil
212 }
213
214 // Get container for this domain
215 container, exists := s.containerSvc.Get(ctx, domainName)
216 if !exists {
217 log.Debug().Msg("container not found for domain")
218 return nil, domain.ErrNoTargetAvailable
219 }
220 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
221
222 // Build target based on runtime mode
223 var target *domain.ProxyTarget
224
225 if s.isRunningInContainer() {
226 // Gordon is in a container - use container network
227 containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
228 if err != nil {
229 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
230 }
231 target = &domain.ProxyTarget{
232 Host: containerIP,
233 Port: containerPort,
234 ContainerID: container.ID,
235 Scheme: "http",
236 }
237 } else {
238 // Gordon is on the host - use host port mapping
239 routes := s.configSvc.GetRoutes(ctx)
240 var route *domain.Route
241 for _, r := range routes {
242 if r.Domain == domainName {
243 route = &r
244 break
245 }
246 }
247
248 if route == nil {
249 return nil, domain.ErrRouteNotFound
250 }
251
252 // Determine target port: check for label first, then fall back to first exposed port
253 // Use container.Image (the actual running image) instead of route.Image (config shorthand)
254 targetPort, err := s.getProxyPort(ctx, container.Image)
255 if err != nil {
256 return nil, log.WrapErr(err, "failed to determine proxy port")
257 }
258
259 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
260 if err != nil {
261 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
262 }
263
264 target = &domain.ProxyTarget{
265 Host: "localhost",
266 Port: hostPort,
267 ContainerID: container.ID,
268 Scheme: "http",
269 }
270 }
271
272 // Cache the target
273 s.mu.Lock()
274 s.targets[domainName] = target
275 s.mu.Unlock()
276
277 return target, nil
278}
279
280// RegisterTarget registers a new proxy target for a domain.
281func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
282 s.mu.Lock()
283 defer s.mu.Unlock()
284
285 s.targets[domainName] = target
286 return nil
287}
288
289// UnregisterTarget removes a proxy target for a domain.
290func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
291 s.mu.Lock()
292 defer s.mu.Unlock()
293
294 delete(s.targets, domainName)
295 return nil
296}
297
298// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
299// This is used during zero-downtime deployments to switch traffic to a new container.
300func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
301 s.mu.Lock()
302 defer s.mu.Unlock()
303
304 delete(s.targets, domainName)
305}
306
307// RefreshTargets refreshes all proxy targets from container state.
308func (s *Service) RefreshTargets(ctx context.Context) error {
309 s.mu.Lock()
310 s.targets = make(map[string]*domain.ProxyTarget)
311 s.mu.Unlock()
312
313 log := zerowrap.FromCtx(ctx)
314 log.Debug().Msg("proxy targets cache cleared")
315 return nil
316}
317
318// UpdateConfig updates the service configuration.
319// Connection limiting uses an atomic counter checked against config, so
320// changing MaxConcurrentConns takes effect immediately without any race.
321func (s *Service) UpdateConfig(config Config) {
322 s.mu.Lock()
323 s.config = config
324 s.mu.Unlock()
325}
326
327// modifyResponse returns a function that modifies backend responses.
328// It adds proxy headers and enforces the response size limit if configured.
329func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
330 return func(resp *http.Response) error {
331 resp.Header.Set("X-Proxied-By", "Gordon")
332
333 // SECURITY: Enforce response size limit to prevent memory exhaustion
334 // from malicious or buggy backends streaming unbounded data.
335 if cfg.MaxResponseSize > 0 {
336 if resp.ContentLength > cfg.MaxResponseSize {
337 resp.Body.Close()
338 const msg = "Response Too Large"
339 resp.StatusCode = http.StatusBadGateway
340 resp.Body = io.NopCloser(strings.NewReader(msg))
341 resp.ContentLength = int64(len(msg))
342 resp.Header.Set("Content-Type", "text/plain")
343 resp.Header.Set("Cache-Control", "no-store")
344 resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
345 // Ensure framing/encoding headers match the substituted body
346 resp.Header.Del("Transfer-Encoding")
347 resp.Header.Del("Content-Encoding")
348 resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
349 return nil
350 }
351 // For chunked/streaming responses where Content-Length is unknown,
352 // wrap with LimitReader to enforce the cap.
353 if resp.ContentLength < 0 {
354 resp.Body = &limitedReadCloser{
355 ReadCloser: resp.Body,
356 remaining: cfg.MaxResponseSize,
357 }
358 }
359 }
360
361 return nil
362 }
363}
364
365// limitedReadCloser wraps an io.ReadCloser with a byte limit.
366// When the limit is exceeded, subsequent reads return an error and the
367// underlying body is closed exactly once to release upstream resources.
368type limitedReadCloser struct {
369 io.ReadCloser
370 remaining int64
371 closed bool
372}
373
374func (l *limitedReadCloser) Read(p []byte) (int, error) {
375 if l.remaining <= 0 {
376 // Close the underlying body to release upstream connection/resources
377 if !l.closed {
378 l.closed = true
379 l.ReadCloser.Close()
380 }
381 return 0, fmt.Errorf("response body exceeded maximum size limit")
382 }
383 if int64(len(p)) > l.remaining {
384 p = p[:l.remaining]
385 }
386 n, err := l.ReadCloser.Read(p)
387 l.remaining -= int64(n)
388 return n, err
389}
390
391func (l *limitedReadCloser) Close() error {
392 if l.closed {
393 return nil
394 }
395 l.closed = true
396 return l.ReadCloser.Close()
397}
398
399// proxyError writes an error response with security headers appropriate for
400// proxy-generated error pages. CSP is set here (not in global middleware)
401// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
402func proxyError(w http.ResponseWriter, msg string, code int) {
403 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
404 w.Header().Set("Cache-Control", "no-store")
405 http.Error(w, msg, code)
406}
407
408// Helper methods
409
410// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
411// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
412// to strip the Authorization header when using the default Director. Rewrite processes
413// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
414//
415// originalHost, when non-empty, overrides the Host header sent to the backend.
416// This is used for DNS-pinned targets where the proxy dials an IP but needs to
417// send the original hostname for virtual-hosted upstreams.
418func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
419 return &httputil.ReverseProxy{
420 Rewrite: func(pr *httputil.ProxyRequest) {
421 pr.SetURL(targetURL)
422 // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
423 // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
424 // from the incoming request because we cannot verify it came from a trusted
425 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
426 // to trick backends into thinking the connection is secure.
427 pr.SetXForwarded()
428 if originalHost != "" {
429 // DNS pinning: dial the resolved IP but send the original hostname
430 // so virtual-hosted upstreams route the request correctly.
431 pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
432 } else {
433 pr.Out.Host = targetURL.Host
434 }
435 },
436 Transport: proxyTransport,
437 ErrorHandler: errorHandler,
438 ModifyResponse: modifyResponse,
439 }
440}
441
442func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
443 s.mu.RLock()
444 cfg := s.config
445 s.mu.RUnlock()
446
447 log := zerowrap.FromCtx(r.Context())
448
449 // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
450 targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
451 if err != nil {
452 log.WrapErr(err, "failed to parse target URL")
453 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
454 return
455 }
456
457 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
458
459 // If DNS pinning resolved hostname to IP, preserve the original hostname
460 // for the Host header so virtual-hosted upstreams work correctly.
461 originalHost := target.OriginalHost
462
463 proxy := newReverseProxy(targetURL, originalHost,
464 func(w http.ResponseWriter, _ *http.Request, err error) {
465 var maxBytesErr *http.MaxBytesError
466 if errors.As(err, &maxBytesErr) {
467 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
468 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
469 return
470 }
471 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
472 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
473 },
474 s.modifyResponse(cfg),
475 )
476
477 log.Debug().
478 Str("target", targetURL.String()).
479 Str(zerowrap.FieldEntityID, target.ContainerID).
480 Msg("proxying request")
481
482 proxy.ServeHTTP(w, r)
483}
484
485// proxyToRegistry forwards requests to the local registry HTTP server.
486// SECURITY: This uses http://localhost:{port} which is safe because the registry
487// runs on the same host and traffic never leaves the loopback interface. If Gordon
488// ever supports running the registry on a separate host, this must use TLS with
489// certificate verification.
490func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
491 // Copy config under RLock to avoid data race with UpdateConfig
492 s.mu.RLock()
493 cfg := s.config
494 s.mu.RUnlock()
495
496 log := zerowrap.FromCtx(r.Context())
497
498 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
499 if err != nil {
500 log.WrapErr(err, "failed to parse registry target URL")
501 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
502 return
503 }
504
505 proxy := newReverseProxy(targetURL, "",
506 func(w http.ResponseWriter, _ *http.Request, err error) {
507 var maxBytesErr *http.MaxBytesError
508 if errors.As(err, &maxBytesErr) {
509 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
510 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
511 return
512 }
513 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
514 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
515 },
516 func(resp *http.Response) error {
517 resp.Header.Set("X-Proxied-By", "Gordon")
518 resp.Header.Set("X-Registry-Backend", "gordon-registry")
519
520 // Remove security headers from registry response to prevent duplicates.
521 // The proxy middleware already adds these headers, so the registry's
522 // copies would create duplicates in the final response.
523 resp.Header.Del("X-Content-Type-Options")
524 resp.Header.Del("X-Frame-Options")
525 resp.Header.Del("X-XSS-Protection")
526 resp.Header.Del("Referrer-Policy")
527 resp.Header.Del("Permissions-Policy")
528 resp.Header.Del("Content-Security-Policy")
529 resp.Header.Del("Strict-Transport-Security")
530
531 return nil
532 },
533 )
534
535 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
536
537 proxy.ServeHTTP(w, r)
538}
539
540func (s *Service) isRunningInContainer() bool {
541 // Check for /.dockerenv
542 if _, err := os.Stat("/.dockerenv"); err == nil {
543 return true
544 }
545
546 // Check cgroup for container indicators
547 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
548 content := string(data)
549 if strings.Contains(content, "docker") ||
550 strings.Contains(content, "containerd") ||
551 strings.Contains(content, "podman") {
552 return true
553 }
554 }
555
556 // NOTE: Hostname length check (12 or 64 chars) was removed because it produced
557 // false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
558 // which would cause the proxy to use container network IPs instead of host port mappings.
559
560 // Check environment variables
561 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
562 os.Getenv("DOCKER_CONTAINER") != "" ||
563 os.Getenv("container") != "" {
564 return true
565 }
566
567 return false
568}
569
570// getProxyPort determines the port to proxy to for an image.
571// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
572func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
573 log := zerowrap.FromCtx(ctx)
574
575 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
576
577 // Check for explicit port label
578 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
579 if err != nil {
580 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
581 } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
582 port, err := strconv.Atoi(portStr)
583 if err == nil && port > 0 {
584 log.Debug().Int("port", port).Msg("using proxy port from image label")
585 return port, nil
586 }
587 log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
588 }
589
590 // Fall back to first exposed port
591 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
592 if err != nil {
593 return 0, err
594 }
595 if len(exposedPorts) == 0 {
596 return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
597 }
598
599 log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
600 return exposedPorts[0], nil
601}