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 "gordon/internal/boundaries/in"
20 "gordon/internal/boundaries/out"
21 "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}
43
44// Service implements the ProxyService interface.
45type Service struct {
46 runtime out.ContainerRuntime
47 containerSvc in.ContainerService
48 configSvc in.ConfigService
49 config Config
50 targets map[string]*domain.ProxyTarget
51 mu sync.RWMutex
52}
53
54// NewService creates a new proxy service.
55func NewService(
56 runtime out.ContainerRuntime,
57 containerSvc in.ContainerService,
58 configSvc in.ConfigService,
59 config Config,
60) *Service {
61 return &Service{
62 runtime: runtime,
63 containerSvc: containerSvc,
64 configSvc: configSvc,
65 config: config,
66 targets: make(map[string]*domain.ProxyTarget),
67 }
68}
69
70// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
71func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
72 // Enrich request context with fields for downstream logging
73 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
74 zerowrap.FieldLayer: "adapter",
75 zerowrap.FieldAdapter: "http",
76 zerowrap.FieldMethod: r.Method,
77 zerowrap.FieldPath: r.URL.Path,
78 zerowrap.FieldHost: r.Host,
79 zerowrap.FieldClientIP: r.RemoteAddr,
80 })
81 r = r.WithContext(ctx)
82 log := zerowrap.FromCtx(ctx)
83
84 // Check if this is the registry domain
85 if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {
86 log.Info().Msg("routing request to registry")
87 s.proxyToRegistry(w, r)
88 return
89 }
90
91 // Get target for this domain
92 log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
93 target, err := s.GetTarget(ctx, r.Host)
94 if err != nil {
95 log.Warn().Err(err).Msg("no route found for domain")
96 http.NotFound(w, r)
97 return
98 }
99
100 log.Debug().
101 Str("host", target.Host).
102 Int("port", target.Port).
103 Str("container_id", target.ContainerID).
104 Msg("resolved proxy target")
105
106 s.proxyToTarget(w, r, target)
107}
108
109// GetTarget returns the proxy target for a given domain.
110func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
111 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
112 zerowrap.FieldLayer: "usecase",
113 zerowrap.FieldUseCase: "GetTarget",
114 "domain": domainName,
115 })
116 log := zerowrap.FromCtx(ctx)
117
118 // Check cache first
119 s.mu.RLock()
120 if target, exists := s.targets[domainName]; exists {
121 s.mu.RUnlock()
122 log.Debug().
123 Str("host", target.Host).
124 Int("port", target.Port).
125 Str("container_id", target.ContainerID).
126 Msg("using cached proxy target")
127 return target, nil
128 }
129 s.mu.RUnlock()
130
131 // Check if this is an external route
132 externalRoutes := s.configSvc.GetExternalRoutes()
133 if targetAddr, ok := externalRoutes[domainName]; ok {
134 host, portStr, err := net.SplitHostPort(targetAddr)
135 if err != nil {
136 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
137 }
138 port, err := strconv.Atoi(portStr)
139 if err != nil {
140 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
141 }
142
143 target := &domain.ProxyTarget{
144 Host: host,
145 Port: port,
146 ContainerID: "", // Not a container
147 Scheme: "http",
148 }
149
150 // Cache external route target
151 s.mu.Lock()
152 s.targets[domainName] = target
153 s.mu.Unlock()
154
155 log.Debug().
156 Str("host", host).
157 Int("port", port).
158 Msg("using external route target")
159 return target, nil
160 }
161
162 // Get container for this domain
163 container, exists := s.containerSvc.Get(ctx, domainName)
164 if !exists {
165 log.Debug().Msg("container not found for domain")
166 return nil, domain.ErrNoTargetAvailable
167 }
168 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
169
170 // Build target based on runtime mode
171 var target *domain.ProxyTarget
172
173 if s.isRunningInContainer() {
174 // Gordon is in a container - use container network
175 containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
176 if err != nil {
177 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
178 }
179 target = &domain.ProxyTarget{
180 Host: containerIP,
181 Port: containerPort,
182 ContainerID: container.ID,
183 Scheme: "http",
184 }
185 } else {
186 // Gordon is on the host - use host port mapping
187 routes := s.configSvc.GetRoutes(ctx)
188 var route *domain.Route
189 for _, r := range routes {
190 if r.Domain == domainName {
191 route = &r
192 break
193 }
194 }
195
196 if route == nil {
197 return nil, domain.ErrRouteNotFound
198 }
199
200 // Determine target port: check for label first, then fall back to first exposed port
201 // Use container.Image (the actual running image) instead of route.Image (config shorthand)
202 targetPort, err := s.getProxyPort(ctx, container.Image)
203 if err != nil {
204 return nil, log.WrapErr(err, "failed to determine proxy port")
205 }
206
207 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
208 if err != nil {
209 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
210 }
211
212 target = &domain.ProxyTarget{
213 Host: "localhost",
214 Port: hostPort,
215 ContainerID: container.ID,
216 Scheme: "http",
217 }
218 }
219
220 // Cache the target
221 s.mu.Lock()
222 s.targets[domainName] = target
223 s.mu.Unlock()
224
225 return target, nil
226}
227
228// RegisterTarget registers a new proxy target for a domain.
229func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
230 s.mu.Lock()
231 defer s.mu.Unlock()
232
233 s.targets[domainName] = target
234 return nil
235}
236
237// UnregisterTarget removes a proxy target for a domain.
238func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
239 s.mu.Lock()
240 defer s.mu.Unlock()
241
242 delete(s.targets, domainName)
243 return nil
244}
245
246// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
247// This is used during zero-downtime deployments to switch traffic to a new container.
248func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
249 s.mu.Lock()
250 defer s.mu.Unlock()
251
252 delete(s.targets, domainName)
253}
254
255// RefreshTargets refreshes all proxy targets from container state.
256func (s *Service) RefreshTargets(ctx context.Context) error {
257 s.mu.Lock()
258 s.targets = make(map[string]*domain.ProxyTarget)
259 s.mu.Unlock()
260
261 log := zerowrap.FromCtx(ctx)
262 log.Debug().Msg("proxy targets cache cleared")
263 return nil
264}
265
266// UpdateConfig updates the service configuration.
267func (s *Service) UpdateConfig(config Config) {
268 s.config = config
269}
270
271// Helper methods
272
273// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
274// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
275// to strip the Authorization header when using the default Director. Rewrite processes
276// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
277func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
278 return &httputil.ReverseProxy{
279 Rewrite: func(pr *httputil.ProxyRequest) {
280 pr.SetURL(targetURL)
281 // Preserve original X-Forwarded-Proto from upstream proxy (e.g., Cloudflare)
282 // before SetXForwarded overwrites it based on local connection
283 origProto := pr.In.Header.Get("X-Forwarded-Proto")
284 pr.SetXForwarded()
285 if origProto != "" {
286 pr.Out.Header.Set("X-Forwarded-Proto", origProto)
287 }
288 pr.Out.Host = targetURL.Host
289 },
290 Transport: proxyTransport,
291 ErrorHandler: errorHandler,
292 ModifyResponse: modifyResponse,
293 }
294}
295
296func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
297 log := zerowrap.FromCtx(r.Context())
298
299 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
300 if err != nil {
301 log.WrapErr(err, "failed to parse target URL")
302 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
303 return
304 }
305
306 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
307
308 proxy := newReverseProxy(targetURL,
309 func(w http.ResponseWriter, _ *http.Request, err error) {
310 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
312 },
313 func(resp *http.Response) error {
314 resp.Header.Set("X-Proxied-By", "Gordon")
315 resp.Header.Set("X-Container-ID", target.ContainerID)
316 return nil
317 },
318 )
319
320 log.Debug().
321 Str("target", targetURL.String()).
322 Str(zerowrap.FieldEntityID, target.ContainerID).
323 Msg("proxying request")
324
325 proxy.ServeHTTP(w, r)
326}
327
328func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
329 log := zerowrap.FromCtx(r.Context())
330
331 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
332 if err != nil {
333 log.WrapErr(err, "failed to parse registry target URL")
334 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
335 return
336 }
337
338 proxy := newReverseProxy(targetURL,
339 func(w http.ResponseWriter, _ *http.Request, err error) {
340 log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
341 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
342 },
343 func(resp *http.Response) error {
344 resp.Header.Set("X-Proxied-By", "Gordon")
345 resp.Header.Set("X-Registry-Backend", "gordon-registry")
346 return nil
347 },
348 )
349
350 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
351
352 proxy.ServeHTTP(w, r)
353}
354
355func (s *Service) isRunningInContainer() bool {
356 // Check for /.dockerenv
357 if _, err := os.Stat("/.dockerenv"); err == nil {
358 return true
359 }
360
361 // Check cgroup for container indicators
362 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
363 content := string(data)
364 if strings.Contains(content, "docker") ||
365 strings.Contains(content, "containerd") ||
366 strings.Contains(content, "podman") {
367 return true
368 }
369 }
370
371 // Check hostname pattern
372 if hostname, err := os.Hostname(); err == nil {
373 if len(hostname) == 12 || len(hostname) == 64 {
374 return true
375 }
376 }
377
378 // Check environment variables
379 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
380 os.Getenv("DOCKER_CONTAINER") != "" ||
381 os.Getenv("container") != "" {
382 return true
383 }
384
385 return false
386}
387
388// getProxyPort determines the port to proxy to for an image.
389// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
390func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
391 log := zerowrap.FromCtx(ctx)
392
393 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
394
395 // Check for explicit port label
396 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
397 if err != nil {
398 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
399 } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
400 port, err := strconv.Atoi(portStr)
401 if err == nil && port > 0 {
402 log.Debug().Int("port", port).Msg("using proxy port from image label")
403 return port, nil
404 }
405 log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
406 }
407
408 // Fall back to first exposed port
409 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
410 if err != nil {
411 return 0, err
412 }
413 if len(exposedPorts) == 0 {
414 return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
415 }
416
417 log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
418 return exposedPorts[0], nil
419}