1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "net"
7 "os"
8 "strconv"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 "github.com/bnema/zerowrap"
15 "go.opentelemetry.io/otel"
16 "go.opentelemetry.io/otel/attribute"
17 "go.opentelemetry.io/otel/codes"
18 "go.opentelemetry.io/otel/trace"
19
20 "github.com/bnema/gordon/internal/boundaries/in"
21 "github.com/bnema/gordon/internal/boundaries/out"
22 "github.com/bnema/gordon/internal/domain"
23)
24
25var proxyTracer = otel.Tracer("gordon.proxy")
26
27// Config holds configuration needed by the proxy service.
28type Config struct {
29 RegistryDomain string
30 RegistryPort int
31 MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
32 MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
33 MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
34}
35
36// Service implements the ProxyService interface.
37type Service struct {
38 runtime out.ContainerRuntime
39 containerSvc in.ContainerService
40 configSvc in.ConfigService
41 config Config
42 targets map[string]*domain.ProxyTarget
43 mu sync.RWMutex
44 inFlight map[string]int
45 inFlightMu sync.Mutex
46 registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
47}
48
49// NewService creates a new proxy service.
50func NewService(
51 runtime out.ContainerRuntime,
52 containerSvc in.ContainerService,
53 configSvc in.ConfigService,
54 config Config,
55) *Service {
56 return &Service{
57 runtime: runtime,
58 containerSvc: containerSvc,
59 configSvc: configSvc,
60 config: config,
61 targets: make(map[string]*domain.ProxyTarget),
62 inFlight: make(map[string]int),
63 }
64}
65
66// GetTarget returns the proxy target for a given domain.
67func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
68 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
69 trace.WithAttributes(attribute.String("domain", domainName)))
70 defer func() {
71 if retErr != nil {
72 span.RecordError(retErr)
73 span.SetStatus(codes.Error, retErr.Error())
74 }
75 span.End()
76 }()
77
78 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
79 zerowrap.FieldLayer: "usecase",
80 zerowrap.FieldUseCase: "GetTarget",
81 "domain": domainName,
82 })
83 log := zerowrap.FromCtx(ctx)
84
85 // Check cache first
86 s.mu.RLock()
87 if target, exists := s.targets[domainName]; exists {
88 s.mu.RUnlock()
89 log.Debug().
90 Str("host", target.Host).
91 Int("port", target.Port).
92 Str("container_id", target.ContainerID).
93 Msg("using cached proxy target")
94 return target, nil
95 }
96 s.mu.RUnlock()
97
98 // Check if this is an external route
99 externalRoutes := s.configSvc.GetExternalRoutes()
100 if targetAddr, ok := externalRoutes[domainName]; ok {
101 return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
102 }
103
104 // Get container for this domain
105 container, exists := s.containerSvc.Get(ctx, domainName)
106 if !exists {
107 log.Debug().Msg("container not found for domain")
108 return nil, domain.ErrNoTargetAvailable
109 }
110 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
111
112 // Build target based on runtime mode
113
114 if s.isRunningInContainer() {
115 // Gordon is in a container - use container network
116 meta, err := s.resolveTargetMetadata(ctx, container.Image)
117 if err != nil {
118 return nil, log.WrapErr(err, "failed to resolve target metadata")
119 }
120 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
121 if err != nil {
122 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
123 }
124 target = &domain.ProxyTarget{
125 Host: containerIP,
126 Port: meta.Port,
127 ContainerID: container.ID,
128 Scheme: "http",
129 Protocol: meta.Protocol,
130 }
131 } else {
132 // Gordon is on the host - use host port mapping
133 routes := s.configSvc.GetRoutes(ctx)
134 var route *domain.Route
135 for _, r := range routes {
136 if r.Domain == domainName {
137 route = &r
138 break
139 }
140 }
141
142 if route == nil {
143 return nil, domain.ErrRouteNotFound
144 }
145
146 meta, err := s.resolveTargetMetadata(ctx, container.Image)
147 if err != nil {
148 return nil, log.WrapErr(err, "failed to resolve target metadata")
149 }
150
151 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
152 if err != nil {
153 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
154 }
155
156 target = &domain.ProxyTarget{
157 Host: "localhost",
158 Port: hostPort,
159 ContainerID: container.ID,
160 Scheme: "http",
161 Protocol: meta.Protocol,
162 }
163 }
164
165 // Cache the target
166 s.mu.Lock()
167 s.targets[domainName] = target
168 s.mu.Unlock()
169
170 return target, nil
171}
172
173// resolveExternalRoute resolves an external route target address into a ProxyTarget,
174// performing DNS validation and SSRF protection.
175func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
176 host, portStr, err := net.SplitHostPort(targetAddr)
177 if err != nil {
178 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
179 }
180 port, err := strconv.Atoi(portStr)
181 if err != nil {
182 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
183 }
184
185 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
186 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
187 // (TOCTOU) attacks where a hostname resolves to a public IP during validation
188 // but to a private IP when the proxy connects.
189 resolvedIP, err := ResolveAndValidateHost(host)
190 if err != nil {
191 log.Warn().
192 Err(err).
193 Str("host", host).
194 Str("domain", domainName).
195 Msg("SSRF protection: blocked external route to internal network")
196 return nil, err
197 }
198
199 // Preserve the original hostname for the Host header so virtual-hosted
200 // upstreams work correctly. The resolved IP is used for dialing only.
201 var originalHost string
202 if resolvedIP != host {
203 originalHost = host
204 }
205
206 t := &domain.ProxyTarget{
207 Host: resolvedIP,
208 Port: port,
209 ContainerID: "", // Not a container
210 Scheme: "http",
211 OriginalHost: originalHost,
212 }
213
214 // Cache external route target
215 s.mu.Lock()
216 s.targets[domainName] = t
217 s.mu.Unlock()
218
219 log.Debug().
220 Str("host", host).
221 Int("port", port).
222 Msg("using external route target")
223 return t, nil
224}
225
226// RegisterTarget registers a new proxy target for a domain.
227func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
228 s.mu.Lock()
229 defer s.mu.Unlock()
230
231 s.targets[domainName] = target
232 return nil
233}
234
235// UnregisterTarget removes a proxy target for a domain.
236func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
237 s.mu.Lock()
238 defer s.mu.Unlock()
239
240 delete(s.targets, domainName)
241 return nil
242}
243
244// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
245// This is used during zero-downtime deployments to switch traffic to a new container.
246func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
247 s.mu.Lock()
248 defer s.mu.Unlock()
249
250 delete(s.targets, domainName)
251}
252
253// WaitForNoInFlight waits until no requests are currently proxied to the
254// given container, or until timeout/context cancellation.
255func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
256 if containerID == "" {
257 return true
258 }
259 if timeout <= 0 {
260 timeout = 30 * time.Second
261 }
262
263 deadline := time.Now().Add(timeout)
264 for {
265 s.inFlightMu.Lock()
266 count := s.inFlight[containerID]
267 s.inFlightMu.Unlock()
268 if count <= 0 {
269 return true
270 }
271
272 if time.Now().After(deadline) {
273 return false
274 }
275
276 select {
277 case <-time.After(100 * time.Millisecond):
278 case <-ctx.Done():
279 return false
280 }
281 }
282}
283
284// RefreshTargets refreshes all proxy targets from container state.
285func (s *Service) RefreshTargets(ctx context.Context) error {
286 s.mu.Lock()
287 s.targets = make(map[string]*domain.ProxyTarget)
288 s.mu.Unlock()
289
290 log := zerowrap.FromCtx(ctx)
291 log.Debug().Msg("proxy targets cache cleared")
292 return nil
293}
294
295// UpdateConfig updates the service configuration.
296// Connection limiting uses an atomic counter checked against config, so
297// changing MaxConcurrentConns takes effect immediately without any race.
298func (s *Service) UpdateConfig(config Config) {
299 s.mu.Lock()
300 s.config = config
301 s.mu.Unlock()
302}
303
304// IsRegistryDomain returns true if the host matches the configured registry domain.
305func (s *Service) IsRegistryDomain(host string) bool {
306 s.mu.RLock()
307 defer s.mu.RUnlock()
308 return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
309}
310
311// TrackInFlight records an in-flight request for a container.
312// Returns a release function that must be called when the request completes.
313func (s *Service) TrackInFlight(containerID string) func() {
314 if containerID == "" {
315 return func() {}
316 }
317
318 s.inFlightMu.Lock()
319 s.inFlight[containerID]++
320 s.inFlightMu.Unlock()
321
322 return func() {
323 s.inFlightMu.Lock()
324 if s.inFlight[containerID] > 1 {
325 s.inFlight[containerID]--
326 } else {
327 delete(s.inFlight, containerID)
328 }
329 s.inFlightMu.Unlock()
330 }
331}
332
333// TrackRegistryRequest increments the registry in-flight counter.
334func (s *Service) TrackRegistryRequest() {
335 s.registryInFlight.Add(1)
336}
337
338// ReleaseRegistryRequest decrements the registry in-flight counter.
339func (s *Service) ReleaseRegistryRequest() {
340 s.registryInFlight.Add(-1)
341}
342
343// ProxyConfig returns the current proxy configuration for adapter use.
344func (s *Service) ProxyConfig() in.ProxyServiceConfig {
345 s.mu.RLock()
346 defer s.mu.RUnlock()
347 return in.ProxyServiceConfig{
348 RegistryDomain: s.config.RegistryDomain,
349 RegistryPort: s.config.RegistryPort,
350 MaxBodySize: s.config.MaxBodySize,
351 MaxResponseSize: s.config.MaxResponseSize,
352 MaxConcurrentConns: s.config.MaxConcurrentConns,
353 }
354}
355
356// RegistryInFlight returns the current count of active registry proxy requests.
357func (s *Service) RegistryInFlight() int64 {
358 return s.registryInFlight.Load()
359}
360
361// DrainRegistryInFlight blocks until all in-flight registry proxy requests
362// complete or the timeout elapses. Returns true if drained cleanly, false if
363// timed out with requests still in flight. Call this before shutting down the
364// registry server.
365func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
366 deadline := time.Now().Add(timeout)
367 for time.Now().Before(deadline) {
368 if s.registryInFlight.Load() == 0 {
369 return true
370 }
371 time.Sleep(10 * time.Millisecond)
372 }
373 return false
374}
375
376func (s *Service) isRunningInContainer() bool {
377 // Check for /.dockerenv
378 if _, err := os.Stat("/.dockerenv"); err == nil {
379 return true
380 }
381
382 // Check cgroup for container indicators
383 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
384 content := string(data)
385 if strings.Contains(content, "docker") ||
386 strings.Contains(content, "containerd") ||
387 strings.Contains(content, "podman") {
388 return true
389 }
390 }
391
392 // NOTE: Hostname length check (12 or 64 chars) was removed because it produced
393 // false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
394 // which would cause the proxy to use container network IPs instead of host port mappings.
395
396 // Check environment variables
397 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
398 os.Getenv("DOCKER_CONTAINER") != "" ||
399 os.Getenv("container") != "" {
400 return true
401 }
402
403 return false
404}