1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "fmt"
7 "net"
8 "strconv"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "github.com/bnema/zerowrap"
14 "go.opentelemetry.io/otel"
15 "go.opentelemetry.io/otel/attribute"
16 "go.opentelemetry.io/otel/codes"
17 "go.opentelemetry.io/otel/trace"
18
19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/domain"
21)
22
23var proxyTracer = otel.Tracer("gordon.proxy")
24
25// Config holds configuration needed by the proxy service.
26type Config struct {
27 RegistryDomain string
28 RegistryPort int
29 MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
30 MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
31 MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
32}
33
34// TargetProvider resolves a canonical HTTP host to its app backend.
35// Implemented by the apptraffic host index (derived from ACTIVE state);
36// the proxy never interprets app state itself.
37type TargetProvider interface {
38 // LookupHost returns the projected backend for a canonical host.
39 LookupHost(host string) (domain.AppBackend, bool)
40}
41
42// Service implements the ProxyService interface.
43// App backends resolve through appTargets (ACTIVE-derived projection);
44// the pre-v3 container-service + image-label resolution is removed.
45type Service struct {
46 configSvc in.ConfigService
47 config Config
48 // appTargets is the ACTIVE-derived host index, wired after the app
49 // store opens (WithAppTargets). Nil means no app serves this host.
50 // App resolutions bypass the target cache entirely (see
51 // resolveAppTarget); only external routes and explicit RegisterTarget
52 // entries live in targets.
53 appTargets TargetProvider
54 targets map[string]cachedTarget
55 // gen is the LOCAL cache invalidation epoch for external/explicit
56 // targets only. It is unrelated to the apptraffic HostIndex version:
57 // app resolutions never consult it.
58 gen uint64
59 mu sync.RWMutex
60 inFlight map[string]int
61 inFlightMu sync.Mutex
62 registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
63}
64
65// cachedTarget carries the local cache epoch its resolution was built from.
66// Applies to external/explicit targets only; app targets are never cached.
67type cachedTarget struct {
68 target *domain.ProxyTarget
69 gen uint64
70}
71
72// WithAppTargets wires the ACTIVE-derived host index. The proxy is
73// built before the app store opens, so wiring is deferred like the
74// backup WithAppSources hook.
75func (s *Service) WithAppTargets(provider TargetProvider) *Service {
76 s.mu.Lock()
77 defer s.mu.Unlock()
78 s.appTargets = provider
79 s.gen++
80 s.targets = make(map[string]cachedTarget)
81 return s
82}
83
84// NewService creates a new proxy service. The app target provider is
85// wired later via WithAppTargets once the app store opens.
86func NewService(
87 configSvc in.ConfigService,
88 config Config,
89) *Service {
90 return &Service{
91 configSvc: configSvc,
92 config: config,
93 targets: make(map[string]cachedTarget),
94 inFlight: make(map[string]int),
95 }
96}
97
98// GetTarget returns the proxy target for a given domain.
99func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
100 canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
101 if !ok {
102 return nil, domain.ErrNoTargetAvailable
103 }
104 domainName = canonicalDomain
105
106 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
107 trace.WithAttributes(attribute.String("domain", domainName)))
108 defer func() {
109 if retErr != nil {
110 span.RecordError(retErr)
111 span.SetStatus(codes.Error, retErr.Error())
112 }
113 span.End()
114 }()
115
116 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
117 zerowrap.FieldLayer: "usecase",
118 zerowrap.FieldUseCase: "GetTarget",
119 "domain": domainName,
120 })
121 log := zerowrap.FromCtx(ctx)
122
123 // Check cache first (generation-guarded: a resolution built before
124 // the latest invalidation is dropped, never served).
125 s.mu.RLock()
126 if cached, exists := s.targets[domainName]; exists {
127 s.mu.RUnlock()
128 log.Debug().
129 Str("host", cached.target.Host).
130 Int("port", cached.target.Port).
131 Str("container_id", cached.target.ContainerID).
132 Msg("using cached proxy target")
133 return cached.target, nil
134 }
135 s.mu.RUnlock()
136
137 // Check if this is an external route
138 externalRoutes := s.configSvc.GetExternalRoutes()
139 if targetAddr, ok := externalRoutes[domainName]; ok {
140 return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
141 }
142
143 // App backends resolve from the ACTIVE-derived host index.
144 return s.resolveAppTarget(domainName, log)
145}
146
147// resolveAppTarget maps a canonical host to its recorded loopback
148// backend. App targets are NEVER cached: every resolution reads the
149// ACTIVE-derived host index (an in-memory RWMutex lookup), so a freshly
150// rebuilt index is observed on the very next request with no invalidation
151// wiring. Unbound or unknown hosts fail closed (no container-IP
152// fallback, no image-label inference, no stale-target fallback).
153func (s *Service) resolveAppTarget(domainName string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
154 s.mu.RLock()
155 provider := s.appTargets
156 s.mu.RUnlock()
157 if provider == nil {
158 log.Debug().Msg("no app target provider wired")
159 return nil, domain.ErrNoTargetAvailable
160 }
161 backend, ok := provider.LookupHost(domainName)
162 if !ok || !backend.Resolved() {
163 log.Debug().Msg("no resolved app backend for host")
164 return nil, domain.ErrNoTargetAvailable
165 }
166 return &domain.ProxyTarget{
167 Host: backend.Host,
168 Port: backend.Port,
169 ContainerID: backend.ContainerID,
170 Scheme: "http",
171 RouteHost: domainName,
172 }, nil
173}
174
175// resolveExternalRoute resolves an external route target address into a ProxyTarget,
176// performing DNS validation and SSRF protection.
177func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
178 host, portStr, err := net.SplitHostPort(targetAddr)
179 if err != nil {
180 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
181 }
182 port, err := strconv.Atoi(portStr)
183 if err != nil {
184 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
185 }
186 if port < 1 || port > 65535 {
187 return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
188 }
189
190 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
191 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
192 // (TOCTOU) attacks where a hostname resolves to a public IP during validation
193 // but to a private IP when the proxy connects.
194 resolvedIP, err := ResolveAndValidateHost(host)
195 if err != nil {
196 log.Warn().
197 Err(err).
198 Str("host", host).
199 Str("domain", domainName).
200 Msg("SSRF protection: blocked external route to internal network")
201 return nil, err
202 }
203
204 // Preserve the original hostname for the Host header so virtual-hosted
205 // upstreams work correctly. The resolved IP is used for dialing only.
206 var originalHost string
207 if resolvedIP != host {
208 originalHost = host
209 }
210
211 t := &domain.ProxyTarget{
212 Host: resolvedIP,
213 Port: port,
214 ContainerID: "", // Not a container
215 Scheme: "http",
216 OriginalHost: originalHost,
217 }
218
219 // Cache external route target
220 s.mu.Lock()
221 s.targets[domainName] = cachedTarget{target: t, gen: s.gen}
222 s.mu.Unlock()
223
224 log.Debug().
225 Str("host", host).
226 Int("port", port).
227 Msg("using external route target")
228 return t, nil
229}
230
231// RegisterTarget registers a new proxy target for a domain.
232func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
233 canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
234 if !ok {
235 return domain.ErrRouteDomainInvalid
236 }
237
238 s.mu.Lock()
239 defer s.mu.Unlock()
240
241 s.targets[canonicalDomain] = cachedTarget{target: target, gen: s.gen}
242 return nil
243}
244
245// UnregisterTarget removes a proxy target for a domain.
246func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
247 canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
248 if !ok {
249 return domain.ErrRouteDomainInvalid
250 }
251
252 s.mu.Lock()
253 defer s.mu.Unlock()
254
255 delete(s.targets, canonicalDomain)
256 return nil
257}
258
259// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
260// This is used during zero-downtime deployments to switch traffic to a new container.
261// Every invalidation bumps the cache generation so concurrent stale
262// resolutions are dropped instead of cached.
263func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
264 canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
265 if !ok {
266 return
267 }
268
269 s.mu.Lock()
270 defer s.mu.Unlock()
271
272 s.gen++
273 delete(s.targets, canonicalDomain)
274}
275
276// WaitForNoInFlight waits until no requests are currently proxied to the
277// given container, or until timeout/context cancellation.
278func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
279 if containerID == "" {
280 return true
281 }
282 if timeout <= 0 {
283 timeout = 30 * time.Second
284 }
285
286 deadline := time.Now().Add(timeout)
287 for {
288 s.inFlightMu.Lock()
289 count := s.inFlight[containerID]
290 s.inFlightMu.Unlock()
291 if count <= 0 {
292 return true
293 }
294
295 if time.Now().After(deadline) {
296 return false
297 }
298
299 select {
300 case <-time.After(100 * time.Millisecond):
301 case <-ctx.Done():
302 return false
303 }
304 }
305}
306
307// RefreshTargets drops all cached proxy targets, forcing re-lookup.
308func (s *Service) RefreshTargets(ctx context.Context) error {
309 s.mu.Lock()
310 s.gen++
311 s.targets = make(map[string]cachedTarget)
312 s.mu.Unlock()
313
314 log := zerowrap.FromCtx(ctx)
315 log.Debug().Msg("proxy targets cache cleared")
316 return nil
317}
318
319// UpdateConfig updates the service configuration.
320// Connection limiting uses an atomic counter checked against config, so
321// changing MaxConcurrentConns takes effect immediately without any race.
322func (s *Service) UpdateConfig(config Config) {
323 s.mu.Lock()
324 s.config = config
325 s.mu.Unlock()
326}
327
328// IsRegistryDomain returns true if the host matches the configured registry domain.
329func (s *Service) IsRegistryDomain(host string) bool {
330 canonicalHost, ok := domain.CanonicalRouteDomain(host)
331 if !ok {
332 return false
333 }
334
335 s.mu.RLock()
336 registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
337 s.mu.RUnlock()
338 return ok && canonicalHost == registryDomain
339}
340
341// IsKnownHost returns true for the registry domain, external routes,
342// and app-served hosts from the ACTIVE-derived index.
343func (s *Service) IsKnownHost(_ context.Context, host string) bool {
344 canonicalHost, ok := domain.CanonicalRouteDomain(host)
345 if !ok {
346 return false
347 }
348 if s.IsRegistryDomain(canonicalHost) {
349 return true
350 }
351 if _, ok := s.configSvc.GetExternalRoutes()[canonicalHost]; ok {
352 return true
353 }
354 s.mu.RLock()
355 provider := s.appTargets
356 s.mu.RUnlock()
357 if provider == nil {
358 return false
359 }
360 _, ok = provider.LookupHost(canonicalHost)
361 return ok
362}
363
364// TrackInFlight records an in-flight request for a container.
365// Returns a release function that must be called when the request completes.
366func (s *Service) TrackInFlight(containerID string) func() {
367 if containerID == "" {
368 return func() {}
369 }
370
371 s.inFlightMu.Lock()
372 s.inFlight[containerID]++
373 s.inFlightMu.Unlock()
374
375 return func() {
376 s.inFlightMu.Lock()
377 if s.inFlight[containerID] > 1 {
378 s.inFlight[containerID]--
379 } else {
380 delete(s.inFlight, containerID)
381 }
382 s.inFlightMu.Unlock()
383 }
384}
385
386// TrackRegistryRequest increments the registry in-flight counter.
387func (s *Service) TrackRegistryRequest() {
388 s.registryInFlight.Add(1)
389}
390
391// ReleaseRegistryRequest decrements the registry in-flight counter.
392func (s *Service) ReleaseRegistryRequest() {
393 s.registryInFlight.Add(-1)
394}
395
396// ProxyConfig returns the current proxy configuration for adapter use.
397func (s *Service) ProxyConfig() in.ProxyServiceConfig {
398 s.mu.RLock()
399 defer s.mu.RUnlock()
400 return in.ProxyServiceConfig{
401 RegistryDomain: s.config.RegistryDomain,
402 RegistryPort: s.config.RegistryPort,
403 MaxBodySize: s.config.MaxBodySize,
404 MaxResponseSize: s.config.MaxResponseSize,
405 MaxConcurrentConns: s.config.MaxConcurrentConns,
406 }
407}
408
409// RegistryInFlight returns the current count of active registry proxy requests.
410func (s *Service) RegistryInFlight() int64 {
411 return s.registryInFlight.Load()
412}
413
414// DrainRegistryInFlight blocks until all in-flight registry proxy requests
415// complete or the timeout elapses. Returns true if drained cleanly, false if
416// timed out with requests still in flight. Call this before shutting down the
417// registry server.
418func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
419 deadline := time.Now().Add(timeout)
420 for time.Now().Before(deadline) {
421 if s.registryInFlight.Load() == 0 {
422 return true
423 }
424 time.Sleep(10 * time.Millisecond)
425 }
426 return false
427}