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