1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "fmt"
7 "net/http"
8 "net/http/httputil"
9 "net/url"
10 "os"
11 "strconv"
12 "strings"
13 "sync"
14
15 "github.com/bnema/zerowrap"
16
17 "gordon/internal/boundaries/in"
18 "gordon/internal/boundaries/out"
19 "gordon/internal/domain"
20)
21
22// Config holds configuration needed by the proxy service.
23type Config struct {
24 RegistryDomain string
25 RegistryPort int
26}
27
28// Service implements the ProxyService interface.
29type Service struct {
30 runtime out.ContainerRuntime
31 containerSvc in.ContainerService
32 configSvc in.ConfigService
33 config Config
34 targets map[string]*domain.ProxyTarget
35 mu sync.RWMutex
36}
37
38// NewService creates a new proxy service.
39func NewService(
40 runtime out.ContainerRuntime,
41 containerSvc in.ContainerService,
42 configSvc in.ConfigService,
43 config Config,
44) *Service {
45 return &Service{
46 runtime: runtime,
47 containerSvc: containerSvc,
48 configSvc: configSvc,
49 config: config,
50 targets: make(map[string]*domain.ProxyTarget),
51 }
52}
53
54// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
55func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56 // Enrich request context with fields for downstream logging
57 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
58 zerowrap.FieldLayer: "adapter",
59 zerowrap.FieldAdapter: "http",
60 zerowrap.FieldMethod: r.Method,
61 zerowrap.FieldPath: r.URL.Path,
62 zerowrap.FieldHost: r.Host,
63 zerowrap.FieldClientIP: r.RemoteAddr,
64 })
65 r = r.WithContext(ctx)
66 log := zerowrap.FromCtx(ctx)
67
68 // Check if this is the registry domain
69 if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {
70 log.Info().Msg("routing request to registry")
71 s.proxyToRegistry(w, r)
72 return
73 }
74
75 // Get target for this domain
76 target, err := s.GetTarget(ctx, r.Host)
77 if err != nil {
78 log.Warn().Err(err).Msg("no route found for domain")
79 http.NotFound(w, r)
80 return
81 }
82
83 log.Debug().
84 Str("host", target.Host).
85 Int("port", target.Port).
86 Str("container_id", target.ContainerID).
87 Msg("resolved proxy target")
88
89 s.proxyToTarget(w, r, target)
90}
91
92// GetTarget returns the proxy target for a given domain.
93func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
94 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95 zerowrap.FieldLayer: "usecase",
96 zerowrap.FieldUseCase: "GetTarget",
97 "domain": domainName,
98 })
99 log := zerowrap.FromCtx(ctx)
100
101 // Check cache first
102 s.mu.RLock()
103 if target, exists := s.targets[domainName]; exists {
104 s.mu.RUnlock()
105 log.Debug().
106 Str("host", target.Host).
107 Int("port", target.Port).
108 Str("container_id", target.ContainerID).
109 Msg("using cached proxy target")
110 return target, nil
111 }
112 s.mu.RUnlock()
113
114 // Get container for this domain
115 container, exists := s.containerSvc.Get(ctx, domainName)
116 if !exists {
117 log.Debug().Msg("container not found for domain")
118 return nil, domain.ErrNoTargetAvailable
119 }
120 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
121
122 // Build target based on runtime mode
123 var target *domain.ProxyTarget
124
125 if s.isRunningInContainer() {
126 // Gordon is in a container - use container network
127 containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
128 if err != nil {
129 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
130 }
131 target = &domain.ProxyTarget{
132 Host: containerIP,
133 Port: containerPort,
134 ContainerID: container.ID,
135 Scheme: "http",
136 }
137 } else {
138 // Gordon is on the host - use host port mapping
139 routes := s.configSvc.GetRoutes(ctx)
140 var route *domain.Route
141 for _, r := range routes {
142 if r.Domain == domainName {
143 route = &r
144 break
145 }
146 }
147
148 if route == nil {
149 return nil, domain.ErrRouteNotFound
150 }
151
152 // Determine target port: check for label first, then fall back to first exposed port
153 // Use container.Image (the actual running image) instead of route.Image (config shorthand)
154 targetPort, err := s.getProxyPort(ctx, container.Image)
155 if err != nil {
156 return nil, log.WrapErr(err, "failed to determine proxy port")
157 }
158
159 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
160 if err != nil {
161 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
162 }
163
164 target = &domain.ProxyTarget{
165 Host: "localhost",
166 Port: hostPort,
167 ContainerID: container.ID,
168 Scheme: "http",
169 }
170 }
171
172 // Cache the target
173 s.mu.Lock()
174 s.targets[domainName] = target
175 s.mu.Unlock()
176
177 return target, nil
178}
179
180// RegisterTarget registers a new proxy target for a domain.
181func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
182 s.mu.Lock()
183 defer s.mu.Unlock()
184
185 s.targets[domainName] = target
186 return nil
187}
188
189// UnregisterTarget removes a proxy target for a domain.
190func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
191 s.mu.Lock()
192 defer s.mu.Unlock()
193
194 delete(s.targets, domainName)
195 return nil
196}
197
198// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
199// This is used during zero-downtime deployments to switch traffic to a new container.
200func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
201 s.mu.Lock()
202 defer s.mu.Unlock()
203
204 delete(s.targets, domainName)
205}
206
207// RefreshTargets refreshes all proxy targets from container state.
208func (s *Service) RefreshTargets(ctx context.Context) error {
209 s.mu.Lock()
210 s.targets = make(map[string]*domain.ProxyTarget)
211 s.mu.Unlock()
212
213 log := zerowrap.FromCtx(ctx)
214 log.Debug().Msg("proxy targets cache cleared")
215 return nil
216}
217
218// UpdateConfig updates the service configuration.
219func (s *Service) UpdateConfig(config Config) {
220 s.config = config
221}
222
223// Helper methods
224
225func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
226 log := zerowrap.FromCtx(r.Context())
227
228 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
229 if err != nil {
230 log.WrapErr(err, "failed to parse target URL")
231 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
232 return
233 }
234
235 proxy := httputil.NewSingleHostReverseProxy(targetURL)
236
237 proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
238 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error")
239 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
240 }
241
242 proxy.ModifyResponse = func(resp *http.Response) error {
243 resp.Header.Set("X-Proxied-By", "Gordon")
244 resp.Header.Set("X-Container-ID", target.ContainerID)
245 return nil
246 }
247
248 log.Debug().
249 Str("target", targetURL.String()).
250 Str(zerowrap.FieldEntityID, target.ContainerID).
251 Msg("proxying request")
252
253 proxy.ServeHTTP(w, r)
254}
255
256func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
257 log := zerowrap.FromCtx(r.Context())
258
259 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
260 if err != nil {
261 log.WrapErr(err, "failed to parse registry target URL")
262 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
263 return
264 }
265
266 proxy := httputil.NewSingleHostReverseProxy(targetURL)
267
268 proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
269 log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
270 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
271 }
272
273 proxy.ModifyResponse = func(resp *http.Response) error {
274 resp.Header.Set("X-Proxied-By", "Gordon")
275 resp.Header.Set("X-Registry-Backend", "gordon-registry")
276 return nil
277 }
278
279 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
280
281 proxy.ServeHTTP(w, r)
282}
283
284func (s *Service) isRunningInContainer() bool {
285 // Check for /.dockerenv
286 if _, err := os.Stat("/.dockerenv"); err == nil {
287 return true
288 }
289
290 // Check cgroup for container indicators
291 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
292 content := string(data)
293 if strings.Contains(content, "docker") ||
294 strings.Contains(content, "containerd") ||
295 strings.Contains(content, "podman") {
296 return true
297 }
298 }
299
300 // Check hostname pattern
301 if hostname, err := os.Hostname(); err == nil {
302 if len(hostname) == 12 || len(hostname) == 64 {
303 return true
304 }
305 }
306
307 // Check environment variables
308 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
309 os.Getenv("DOCKER_CONTAINER") != "" ||
310 os.Getenv("container") != "" {
311 return true
312 }
313
314 return false
315}
316
317// getProxyPort determines the port to proxy to for an image.
318// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
319func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
320 log := zerowrap.FromCtx(ctx)
321
322 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
323
324 // Check for explicit port label
325 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
326 if err != nil {
327 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
328 } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
329 port, err := strconv.Atoi(portStr)
330 if err == nil && port > 0 {
331 log.Debug().Int("port", port).Msg("using proxy port from image label")
332 return port, nil
333 }
334 log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
335 }
336
337 // Fall back to first exposed port
338 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
339 if err != nil {
340 return 0, err
341 }
342 if len(exposedPorts) == 0 {
343 return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
344 }
345
346 log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
347 return exposedPorts[0], nil
348}