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