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