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