main bac21c6

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close