1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "fmt"
7 "net"
8 "net/http"
9 "net/http/httputil"
10 "net/url"
11 "os"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/bnema/zerowrap"
18
19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"
22)
23
24// proxyTransport is a shared HTTP transport with proper timeouts.
25// This prevents resource exhaustion from slow backends or network issues.
26var proxyTransport = &http.Transport{
27 DialContext: (&net.Dialer{
28 Timeout: 10 * time.Second,
29 KeepAlive: 30 * time.Second,
30 }).DialContext,
31 TLSHandshakeTimeout: 10 * time.Second,
32 ResponseHeaderTimeout: 30 * time.Second,
33 MaxIdleConns: 100,
34 MaxIdleConnsPerHost: 10,
35 IdleConnTimeout: 90 * time.Second,
36}
37
38// Config holds configuration needed by the proxy service.
39type Config struct {
40 RegistryDomain string
41 RegistryPort int
42 MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
43}
44
45// Service implements the ProxyService interface.
46type Service struct {
47 runtime out.ContainerRuntime
48 containerSvc in.ContainerService
49 configSvc in.ConfigService
50 config Config
51 targets map[string]*domain.ProxyTarget
52 mu sync.RWMutex
53}
54
55// NewService creates a new proxy service.
56func NewService(
57 runtime out.ContainerRuntime,
58 containerSvc in.ContainerService,
59 configSvc in.ConfigService,
60 config Config,
61) *Service {
62 return &Service{
63 runtime: runtime,
64 containerSvc: containerSvc,
65 configSvc: configSvc,
66 config: config,
67 targets: make(map[string]*domain.ProxyTarget),
68 }
69}
70
71// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
72func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
73 // Copy config under RLock to avoid data race with UpdateConfig
74 s.mu.RLock()
75 cfg := s.config
76 s.mu.RUnlock()
77
78 // SECURITY: Limit request body size to prevent resource exhaustion.
79 if cfg.MaxBodySize > 0 {
80 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
81 }
82
83 // Enrich request context with fields for downstream logging
84 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85 zerowrap.FieldLayer: "adapter",
86 zerowrap.FieldAdapter: "http",
87 zerowrap.FieldMethod: r.Method,
88 zerowrap.FieldPath: r.URL.Path,
89 zerowrap.FieldHost: r.Host,
90 zerowrap.FieldClientIP: r.RemoteAddr,
91 })
92 r = r.WithContext(ctx)
93 log := zerowrap.FromCtx(ctx)
94
95 // Check if this is the registry domain
96 if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
97 log.Info().Msg("routing request to registry")
98 s.proxyToRegistry(w, r)
99 return
100 }
101
102 // Get target for this domain
103 log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
104 target, err := s.GetTarget(ctx, r.Host)
105 if err != nil {
106 log.Warn().Err(err).Msg("no route found for domain")
107 http.NotFound(w, r)
108 return
109 }
110
111 log.Debug().
112 Str("host", target.Host).
113 Int("port", target.Port).
114 Str("container_id", target.ContainerID).
115 Msg("resolved proxy target")
116
117 s.proxyToTarget(w, r, target)
118}
119
120// GetTarget returns the proxy target for a given domain.
121func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
122 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
123 zerowrap.FieldLayer: "usecase",
124 zerowrap.FieldUseCase: "GetTarget",
125 "domain": domainName,
126 })
127 log := zerowrap.FromCtx(ctx)
128
129 // Check cache first
130 s.mu.RLock()
131 if target, exists := s.targets[domainName]; exists {
132 s.mu.RUnlock()
133 log.Debug().
134 Str("host", target.Host).
135 Int("port", target.Port).
136 Str("container_id", target.ContainerID).
137 Msg("using cached proxy target")
138 return target, nil
139 }
140 s.mu.RUnlock()
141
142 // Check if this is an external route
143 externalRoutes := s.configSvc.GetExternalRoutes()
144 if targetAddr, ok := externalRoutes[domainName]; ok {
145 host, portStr, err := net.SplitHostPort(targetAddr)
146 if err != nil {
147 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
148 }
149 port, err := strconv.Atoi(portStr)
150 if err != nil {
151 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152 }
153
154 // SECURITY: Validate target is not an internal/blocked network (SSRF protection)
155 if err := ValidateExternalRouteTarget(host); err != nil {
156 log.Warn().
157 Err(err).
158 Str("host", host).
159 Str("domain", domainName).
160 Msg("SSRF protection: blocked external route to internal network")
161 return nil, err
162 }
163
164 target := &domain.ProxyTarget{
165 Host: host,
166 Port: port,
167 ContainerID: "", // Not a container
168 Scheme: "http",
169 }
170
171 // Cache external route target
172 s.mu.Lock()
173 s.targets[domainName] = target
174 s.mu.Unlock()
175
176 log.Debug().
177 Str("host", host).
178 Int("port", port).
179 Msg("using external route target")
180 return target, nil
181 }
182
183 // Get container for this domain
184 container, exists := s.containerSvc.Get(ctx, domainName)
185 if !exists {
186 log.Debug().Msg("container not found for domain")
187 return nil, domain.ErrNoTargetAvailable
188 }
189 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
190
191 // Build target based on runtime mode
192 var target *domain.ProxyTarget
193
194 if s.isRunningInContainer() {
195 // Gordon is in a container - use container network
196 containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
197 if err != nil {
198 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
199 }
200 target = &domain.ProxyTarget{
201 Host: containerIP,
202 Port: containerPort,
203 ContainerID: container.ID,
204 Scheme: "http",
205 }
206 } else {
207 // Gordon is on the host - use host port mapping
208 routes := s.configSvc.GetRoutes(ctx)
209 var route *domain.Route
210 for _, r := range routes {
211 if r.Domain == domainName {
212 route = &r
213 break
214 }
215 }
216
217 if route == nil {
218 return nil, domain.ErrRouteNotFound
219 }
220
221 // Determine target port: check for label first, then fall back to first exposed port
222 // Use container.Image (the actual running image) instead of route.Image (config shorthand)
223 targetPort, err := s.getProxyPort(ctx, container.Image)
224 if err != nil {
225 return nil, log.WrapErr(err, "failed to determine proxy port")
226 }
227
228 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
229 if err != nil {
230 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
231 }
232
233 target = &domain.ProxyTarget{
234 Host: "localhost",
235 Port: hostPort,
236 ContainerID: container.ID,
237 Scheme: "http",
238 }
239 }
240
241 // Cache the target
242 s.mu.Lock()
243 s.targets[domainName] = target
244 s.mu.Unlock()
245
246 return target, nil
247}
248
249// RegisterTarget registers a new proxy target for a domain.
250func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
251 s.mu.Lock()
252 defer s.mu.Unlock()
253
254 s.targets[domainName] = target
255 return nil
256}
257
258// UnregisterTarget removes a proxy target for a domain.
259func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
260 s.mu.Lock()
261 defer s.mu.Unlock()
262
263 delete(s.targets, domainName)
264 return nil
265}
266
267// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
268// This is used during zero-downtime deployments to switch traffic to a new container.
269func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
270 s.mu.Lock()
271 defer s.mu.Unlock()
272
273 delete(s.targets, domainName)
274}
275
276// RefreshTargets refreshes all proxy targets from container state.
277func (s *Service) RefreshTargets(ctx context.Context) error {
278 s.mu.Lock()
279 s.targets = make(map[string]*domain.ProxyTarget)
280 s.mu.Unlock()
281
282 log := zerowrap.FromCtx(ctx)
283 log.Debug().Msg("proxy targets cache cleared")
284 return nil
285}
286
287// UpdateConfig updates the service configuration.
288func (s *Service) UpdateConfig(config Config) {
289 s.mu.Lock()
290 s.config = config
291 s.mu.Unlock()
292}
293
294// Helper methods
295
296// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
297// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
298// to strip the Authorization header when using the default Director. Rewrite processes
299// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
300func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
301 return &httputil.ReverseProxy{
302 Rewrite: func(pr *httputil.ProxyRequest) {
303 pr.SetURL(targetURL)
304 // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
305 // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
306 // from the incoming request because we cannot verify it came from a trusted
307 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
308 // to trick backends into thinking the connection is secure.
309 pr.SetXForwarded()
310 pr.Out.Host = targetURL.Host
311 },
312 Transport: proxyTransport,
313 ErrorHandler: errorHandler,
314 ModifyResponse: modifyResponse,
315 }
316}
317
318func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
319 log := zerowrap.FromCtx(r.Context())
320
321 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
322 if err != nil {
323 log.WrapErr(err, "failed to parse target URL")
324 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
325 return
326 }
327
328 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
329
330 proxy := newReverseProxy(targetURL,
331 func(w http.ResponseWriter, _ *http.Request, err error) {
332 // Check if this is a MaxBytesError (request body too large)
333 if err.Error() == "http: request body too large" {
334 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
335 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336 return
337 }
338 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
339 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
340 },
341 func(resp *http.Response) error {
342 resp.Header.Set("X-Proxied-By", "Gordon")
343 return nil
344 },
345 )
346
347 log.Debug().
348 Str("target", targetURL.String()).
349 Str(zerowrap.FieldEntityID, target.ContainerID).
350 Msg("proxying request")
351
352 proxy.ServeHTTP(w, r)
353}
354
355func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356 // Copy config under RLock to avoid data race with UpdateConfig
357 s.mu.RLock()
358 cfg := s.config
359 s.mu.RUnlock()
360
361 log := zerowrap.FromCtx(r.Context())
362
363 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
364 if err != nil {
365 log.WrapErr(err, "failed to parse registry target URL")
366 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
367 return
368 }
369
370 proxy := newReverseProxy(targetURL,
371 func(w http.ResponseWriter, _ *http.Request, err error) {
372 // Check if this is a MaxBytesError (request body too large)
373 if err.Error() == "http: request body too large" {
374 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
375 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376 return
377 }
378 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
379 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
380 },
381 func(resp *http.Response) error {
382 resp.Header.Set("X-Proxied-By", "Gordon")
383 resp.Header.Set("X-Registry-Backend", "gordon-registry")
384
385 // Remove security headers from registry response to prevent duplicates.
386 // The proxy middleware already adds these headers, so the registry's
387 // copies would create duplicates in the final response.
388 resp.Header.Del("X-Content-Type-Options")
389 resp.Header.Del("X-Frame-Options")
390 resp.Header.Del("X-XSS-Protection")
391 resp.Header.Del("Referrer-Policy")
392 resp.Header.Del("Permissions-Policy")
393
394 return nil
395 },
396 )
397
398 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
399
400 proxy.ServeHTTP(w, r)
401}
402
403func (s *Service) isRunningInContainer() bool {
404 // Check for /.dockerenv
405 if _, err := os.Stat("/.dockerenv"); err == nil {
406 return true
407 }
408
409 // Check cgroup for container indicators
410 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
411 content := string(data)
412 if strings.Contains(content, "docker") ||
413 strings.Contains(content, "containerd") ||
414 strings.Contains(content, "podman") {
415 return true
416 }
417 }
418
419 // NOTE: Hostname length check (12 or 64 chars) was removed because it produced
420 // false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
421 // which would cause the proxy to use container network IPs instead of host port mappings.
422
423 // Check environment variables
424 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
425 os.Getenv("DOCKER_CONTAINER") != "" ||
426 os.Getenv("container") != "" {
427 return true
428 }
429
430 return false
431}
432
433// getProxyPort determines the port to proxy to for an image.
434// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
435func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
436 log := zerowrap.FromCtx(ctx)
437
438 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
439
440 // Check for explicit port label
441 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
442 if err != nil {
443 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
444 } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
445 port, err := strconv.Atoi(portStr)
446 if err == nil && port > 0 {
447 log.Debug().Int("port", port).Msg("using proxy port from image label")
448 return port, nil
449 }
450 log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
451 }
452
453 // Fall back to first exposed port
454 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
455 if err != nil {
456 return 0, err
457 }
458 if len(exposedPorts) == 0 {
459 return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
460 }
461
462 log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
463 return exposedPorts[0], nil
464}