v2.16.0 ebd2ab3

service.go RawHistoryFolder

1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"net"
10	"net/http"
11	"net/http/httputil"
12	"net/url"
13	"os"
14	"strconv"
15	"strings"
16	"sync"
17	"sync/atomic"
18	"time"
19
20	"github.com/bnema/zerowrap"
21	"go.opentelemetry.io/otel"
22	"go.opentelemetry.io/otel/attribute"
23	"go.opentelemetry.io/otel/codes"
24	"go.opentelemetry.io/otel/trace"
25
26	"github.com/bnema/gordon/internal/boundaries/in"
27	"github.com/bnema/gordon/internal/boundaries/out"
28	"github.com/bnema/gordon/internal/domain"
29)
30
31var proxyTracer = otel.Tracer("gordon.proxy")
32
33// proxyTransport is a shared HTTP transport for proxying to application containers.
34// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
35var proxyTransport = &http.Transport{
36	DialContext: (&net.Dialer{
37		Timeout:   10 * time.Second,
38		KeepAlive: 30 * time.Second,
39	}).DialContext,
40	TLSHandshakeTimeout:   10 * time.Second,
41	ResponseHeaderTimeout: 30 * time.Second,
42	MaxIdleConns:          100,
43	MaxIdleConnsPerHost:   10,
44	IdleConnTimeout:       90 * time.Second,
45}
46
47// registryTransport is used for the registry reverse proxy (localhost loopback).
48// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
49// perform blocking operations (image pull, container start, readiness checks) that
50// can take well over 30 seconds.
51var registryTransport = &http.Transport{
52	DialContext: (&net.Dialer{
53		Timeout:   10 * time.Second,
54		KeepAlive: 30 * time.Second,
55	}).DialContext,
56	ResponseHeaderTimeout: 3 * time.Minute,
57	MaxIdleConns:          20,
58	MaxIdleConnsPerHost:   10,
59	IdleConnTimeout:       90 * time.Second,
60}
61
62// Config holds configuration needed by the proxy service.
63type Config struct {
64	RegistryDomain     string
65	RegistryPort       int
66	MaxBodySize        int64 // Maximum request body size in bytes (0 = no limit)
67	MaxResponseSize    int64 // Maximum response body size in bytes (0 = no limit)
68	MaxConcurrentConns int   // Maximum concurrent proxy connections (0 = no limit)
69}
70
71// Service implements the ProxyService interface.
72type Service struct {
73	runtime      out.ContainerRuntime
74	containerSvc in.ContainerService
75	configSvc    in.ConfigService
76	config       Config
77	targets      map[string]*domain.ProxyTarget
78	mu           sync.RWMutex
79	activeConns  atomic.Int64 // Atomic counter for concurrent connection limiting
80	inFlight     map[string]int
81	inFlightMu   sync.Mutex
82}
83
84// NewService creates a new proxy service.
85func NewService(
86	runtime out.ContainerRuntime,
87	containerSvc in.ContainerService,
88	configSvc in.ConfigService,
89	config Config,
90) *Service {
91	return &Service{
92		runtime:      runtime,
93		containerSvc: containerSvc,
94		configSvc:    configSvc,
95		config:       config,
96		targets:      make(map[string]*domain.ProxyTarget),
97		inFlight:     make(map[string]int),
98	}
99}
100
101// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
102func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
103	// Copy config under RLock to avoid data race with UpdateConfig
104	s.mu.RLock()
105	cfg := s.config
106	s.mu.RUnlock()
107
108	// SECURITY: Limit concurrent connections to prevent resource exhaustion.
109	// Uses an atomic counter so config reloads don't create a race between
110	// an old and new semaphore — the counter is shared across reloads.
111	if cfg.MaxConcurrentConns > 0 {
112		current := s.activeConns.Add(1)
113		if current > int64(cfg.MaxConcurrentConns) {
114			s.activeConns.Add(-1)
115			proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
116			return
117		}
118		defer s.activeConns.Add(-1)
119	}
120
121	// SECURITY: Limit request body size to prevent resource exhaustion.
122	if cfg.MaxBodySize > 0 {
123		r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
124	}
125
126	// Enrich request context with fields for downstream logging
127	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
128		zerowrap.FieldLayer:    "adapter",
129		zerowrap.FieldAdapter:  "http",
130		zerowrap.FieldMethod:   r.Method,
131		zerowrap.FieldPath:     r.URL.Path,
132		zerowrap.FieldHost:     r.Host,
133		zerowrap.FieldClientIP: r.RemoteAddr,
134	})
135	r = r.WithContext(ctx)
136	log := zerowrap.FromCtx(ctx)
137
138	// Check if this is the registry domain
139	if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
140		log.Info().Msg("routing request to registry")
141		s.proxyToRegistry(w, r)
142		return
143	}
144
145	// Get target for this domain
146	log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
147	target, err := s.GetTarget(ctx, r.Host)
148	if err != nil {
149		log.Warn().Err(err).Msg("no route found for domain")
150		proxyError(w, "404 page not found", http.StatusNotFound)
151		return
152	}
153
154	log.Debug().
155		Str("host", target.Host).
156		Int("port", target.Port).
157		Str("container_id", target.ContainerID).
158		Msg("resolved proxy target")
159
160	s.proxyToTarget(w, r, target)
161}
162
163// GetTarget returns the proxy target for a given domain.
164func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
165	ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
166		trace.WithAttributes(attribute.String("domain", domainName)))
167	defer func() {
168		if retErr != nil {
169			span.RecordError(retErr)
170			span.SetStatus(codes.Error, retErr.Error())
171		}
172		span.End()
173	}()
174
175	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
176		zerowrap.FieldLayer:   "usecase",
177		zerowrap.FieldUseCase: "GetTarget",
178		"domain":              domainName,
179	})
180	log := zerowrap.FromCtx(ctx)
181
182	// Check cache first
183	s.mu.RLock()
184	if target, exists := s.targets[domainName]; exists {
185		s.mu.RUnlock()
186		log.Debug().
187			Str("host", target.Host).
188			Int("port", target.Port).
189			Str("container_id", target.ContainerID).
190			Msg("using cached proxy target")
191		return target, nil
192	}
193	s.mu.RUnlock()
194
195	// Check if this is an external route
196	externalRoutes := s.configSvc.GetExternalRoutes()
197	if targetAddr, ok := externalRoutes[domainName]; ok {
198		return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
199	}
200
201	// Get container for this domain
202	container, exists := s.containerSvc.Get(ctx, domainName)
203	if !exists {
204		log.Debug().Msg("container not found for domain")
205		return nil, domain.ErrNoTargetAvailable
206	}
207	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
208
209	// Build target based on runtime mode
210
211	if s.isRunningInContainer() {
212		// Gordon is in a container - use container network
213		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
214		if err != nil {
215			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
216		}
217		target = &domain.ProxyTarget{
218			Host:        containerIP,
219			Port:        containerPort,
220			ContainerID: container.ID,
221			Scheme:      "http",
222		}
223	} else {
224		// Gordon is on the host - use host port mapping
225		routes := s.configSvc.GetRoutes(ctx)
226		var route *domain.Route
227		for _, r := range routes {
228			if r.Domain == domainName {
229				route = &r
230				break
231			}
232		}
233
234		if route == nil {
235			return nil, domain.ErrRouteNotFound
236		}
237
238		// Determine target port: check for label first, then fall back to first exposed port
239		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
240		targetPort, err := s.getProxyPort(ctx, container.Image)
241		if err != nil {
242			return nil, log.WrapErr(err, "failed to determine proxy port")
243		}
244
245		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
246		if err != nil {
247			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
248		}
249
250		target = &domain.ProxyTarget{
251			Host:        "localhost",
252			Port:        hostPort,
253			ContainerID: container.ID,
254			Scheme:      "http",
255		}
256	}
257
258	// Cache the target
259	s.mu.Lock()
260	s.targets[domainName] = target
261	s.mu.Unlock()
262
263	return target, nil
264}
265
266// resolveExternalRoute resolves an external route target address into a ProxyTarget,
267// performing DNS validation and SSRF protection.
268func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
269	host, portStr, err := net.SplitHostPort(targetAddr)
270	if err != nil {
271		return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
272	}
273	port, err := strconv.Atoi(portStr)
274	if err != nil {
275		return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
276	}
277
278	// SECURITY: Resolve DNS and validate that the target is not an internal/blocked
279	// network. We use the resolved IP as the proxy target to prevent DNS rebinding
280	// (TOCTOU) attacks where a hostname resolves to a public IP during validation
281	// but to a private IP when the proxy connects.
282	resolvedIP, err := ResolveAndValidateHost(host)
283	if err != nil {
284		log.Warn().
285			Err(err).
286			Str("host", host).
287			Str("domain", domainName).
288			Msg("SSRF protection: blocked external route to internal network")
289		return nil, err
290	}
291
292	// Preserve the original hostname for the Host header so virtual-hosted
293	// upstreams work correctly. The resolved IP is used for dialing only.
294	var originalHost string
295	if resolvedIP != host {
296		originalHost = host
297	}
298
299	t := &domain.ProxyTarget{
300		Host:         resolvedIP,
301		Port:         port,
302		ContainerID:  "", // Not a container
303		Scheme:       "http",
304		OriginalHost: originalHost,
305	}
306
307	// Cache external route target
308	s.mu.Lock()
309	s.targets[domainName] = t
310	s.mu.Unlock()
311
312	log.Debug().
313		Str("host", host).
314		Int("port", port).
315		Msg("using external route target")
316	return t, nil
317}
318
319// RegisterTarget registers a new proxy target for a domain.
320func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
321	s.mu.Lock()
322	defer s.mu.Unlock()
323
324	s.targets[domainName] = target
325	return nil
326}
327
328// UnregisterTarget removes a proxy target for a domain.
329func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
330	s.mu.Lock()
331	defer s.mu.Unlock()
332
333	delete(s.targets, domainName)
334	return nil
335}
336
337// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
338// This is used during zero-downtime deployments to switch traffic to a new container.
339func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
340	s.mu.Lock()
341	defer s.mu.Unlock()
342
343	delete(s.targets, domainName)
344}
345
346// WaitForNoInFlight waits until no requests are currently proxied to the
347// given container, or until timeout/context cancellation.
348func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
349	if containerID == "" {
350		return true
351	}
352	if timeout <= 0 {
353		timeout = 30 * time.Second
354	}
355
356	deadline := time.Now().Add(timeout)
357	for {
358		s.inFlightMu.Lock()
359		count := s.inFlight[containerID]
360		s.inFlightMu.Unlock()
361		if count <= 0 {
362			return true
363		}
364
365		if time.Now().After(deadline) {
366			return false
367		}
368
369		select {
370		case <-time.After(100 * time.Millisecond):
371		case <-ctx.Done():
372			return false
373		}
374	}
375}
376
377// RefreshTargets refreshes all proxy targets from container state.
378func (s *Service) RefreshTargets(ctx context.Context) error {
379	s.mu.Lock()
380	s.targets = make(map[string]*domain.ProxyTarget)
381	s.mu.Unlock()
382
383	log := zerowrap.FromCtx(ctx)
384	log.Debug().Msg("proxy targets cache cleared")
385	return nil
386}
387
388// UpdateConfig updates the service configuration.
389// Connection limiting uses an atomic counter checked against config, so
390// changing MaxConcurrentConns takes effect immediately without any race.
391func (s *Service) UpdateConfig(config Config) {
392	s.mu.Lock()
393	s.config = config
394	s.mu.Unlock()
395}
396
397// modifyResponse returns a function that modifies backend responses.
398// It adds proxy headers and enforces the response size limit if configured.
399func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
400	return func(resp *http.Response) error {
401		resp.Header.Set("X-Proxied-By", "Gordon")
402
403		// SECURITY: Enforce response size limit to prevent memory exhaustion
404		// from malicious or buggy backends streaming unbounded data.
405		if cfg.MaxResponseSize > 0 {
406			if resp.ContentLength > cfg.MaxResponseSize {
407				resp.Body.Close()
408				const msg = "Response Too Large"
409				resp.StatusCode = http.StatusBadGateway
410				resp.Body = io.NopCloser(strings.NewReader(msg))
411				resp.ContentLength = int64(len(msg))
412				resp.Header.Set("Content-Type", "text/plain")
413				resp.Header.Set("Cache-Control", "no-store")
414				resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
415				// Ensure framing/encoding headers match the substituted body
416				resp.Header.Del("Transfer-Encoding")
417				resp.Header.Del("Content-Encoding")
418				resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
419				return nil
420			}
421			// For chunked/streaming responses where Content-Length is unknown,
422			// wrap with LimitReader to enforce the cap.
423			if resp.ContentLength < 0 {
424				resp.Body = &limitedReadCloser{
425					ReadCloser: resp.Body,
426					remaining:  cfg.MaxResponseSize,
427				}
428			}
429		}
430
431		return nil
432	}
433}
434
435// limitedReadCloser wraps an io.ReadCloser with a byte limit.
436// When the limit is exceeded, subsequent reads return an error and the
437// underlying body is closed exactly once to release upstream resources.
438type limitedReadCloser struct {
439	io.ReadCloser
440	remaining int64
441	closed    bool
442}
443
444func (l *limitedReadCloser) Read(p []byte) (int, error) {
445	if l.remaining <= 0 {
446		// Close the underlying body to release upstream connection/resources
447		if !l.closed {
448			l.closed = true
449			l.ReadCloser.Close()
450		}
451		return 0, fmt.Errorf("response body exceeded maximum size limit")
452	}
453	if int64(len(p)) > l.remaining {
454		p = p[:l.remaining]
455	}
456	n, err := l.ReadCloser.Read(p)
457	l.remaining -= int64(n)
458	return n, err
459}
460
461func (l *limitedReadCloser) Close() error {
462	if l.closed {
463		return nil
464	}
465	l.closed = true
466	return l.ReadCloser.Close()
467}
468
469// proxyError writes an error response with security headers appropriate for
470// proxy-generated error pages. CSP is set here (not in global middleware)
471// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
472func proxyError(w http.ResponseWriter, msg string, code int) {
473	w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
474	w.Header().Set("Cache-Control", "no-store")
475	http.Error(w, msg, code)
476}
477
478// Helper methods
479
480// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
481// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
482// to strip the Authorization header when using the default Director. Rewrite processes
483// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
484//
485// originalHost, when non-empty, overrides the Host header sent to the backend.
486// This is used for DNS-pinned targets where the proxy dials an IP but needs to
487// send the original hostname for virtual-hosted upstreams.
488func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
489	return &httputil.ReverseProxy{
490		Rewrite: func(pr *httputil.ProxyRequest) {
491			pr.SetURL(targetURL)
492			// SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
493			// connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
494			// from the incoming request because we cannot verify it came from a trusted
495			// proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
496			// to trick backends into thinking the connection is secure.
497			pr.SetXForwarded()
498			if originalHost != "" {
499				// DNS pinning: dial the resolved IP but send the original hostname
500				// so virtual-hosted upstreams route the request correctly.
501				pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
502			} else {
503				pr.Out.Host = targetURL.Host
504			}
505		},
506		Transport:      proxyTransport,
507		ErrorHandler:   errorHandler,
508		ModifyResponse: modifyResponse,
509	}
510}
511
512func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
513	s.mu.RLock()
514	cfg := s.config
515	s.mu.RUnlock()
516
517	log := zerowrap.FromCtx(r.Context())
518
519	// Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
520	targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
521	if err != nil {
522		log.WrapErr(err, "failed to parse target URL")
523		proxyError(w, "Internal Server Error", http.StatusInternalServerError)
524		return
525	}
526
527	log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
528
529	// If DNS pinning resolved hostname to IP, preserve the original hostname
530	// for the Host header so virtual-hosted upstreams work correctly.
531	originalHost := target.OriginalHost
532	releaseInFlight := s.trackInFlight(target.ContainerID)
533	defer releaseInFlight()
534
535	proxy := newReverseProxy(targetURL, originalHost,
536		func(w http.ResponseWriter, _ *http.Request, err error) {
537			var maxBytesErr *http.MaxBytesError
538			if errors.As(err, &maxBytesErr) {
539				log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
540				proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
541				return
542			}
543			log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
544			proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
545		},
546		s.modifyResponse(cfg),
547	)
548
549	log.Debug().
550		Str("target", targetURL.String()).
551		Str(zerowrap.FieldEntityID, target.ContainerID).
552		Msg("proxying request")
553
554	proxy.ServeHTTP(w, r)
555}
556
557func (s *Service) trackInFlight(containerID string) func() {
558	if containerID == "" {
559		return func() {}
560	}
561
562	s.inFlightMu.Lock()
563	s.inFlight[containerID]++
564	s.inFlightMu.Unlock()
565
566	return func() {
567		s.inFlightMu.Lock()
568		if s.inFlight[containerID] > 1 {
569			s.inFlight[containerID]--
570		} else {
571			delete(s.inFlight, containerID)
572		}
573		s.inFlightMu.Unlock()
574	}
575}
576
577// proxyToRegistry forwards requests to the local registry HTTP server.
578// SECURITY: This uses http://localhost:{port} which is safe because the registry
579// runs on the same host and traffic never leaves the loopback interface. If Gordon
580// ever supports running the registry on a separate host, this must use TLS with
581// certificate verification.
582func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
583	// Copy config under RLock to avoid data race with UpdateConfig
584	s.mu.RLock()
585	cfg := s.config
586	s.mu.RUnlock()
587
588	log := zerowrap.FromCtx(r.Context())
589
590	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
591	if err != nil {
592		log.WrapErr(err, "failed to parse registry target URL")
593		proxyError(w, "Internal Server Error", http.StatusInternalServerError)
594		return
595	}
596
597	proxy := &httputil.ReverseProxy{
598		Rewrite: func(pr *httputil.ProxyRequest) {
599			pr.SetURL(targetURL)
600			pr.SetXForwarded()
601			pr.Out.Host = targetURL.Host
602		},
603		Transport: registryTransport,
604		ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
605			var maxBytesErr *http.MaxBytesError
606			if errors.As(err, &maxBytesErr) {
607				log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
608				proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
609				return
610			}
611			log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
612			proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
613		},
614		ModifyResponse: func(resp *http.Response) error {
615			resp.Header.Set("X-Proxied-By", "Gordon")
616			resp.Header.Set("X-Registry-Backend", "gordon-registry")
617
618			// Remove security headers from registry response to prevent duplicates.
619			resp.Header.Del("X-Content-Type-Options")
620			resp.Header.Del("X-Frame-Options")
621			resp.Header.Del("X-XSS-Protection")
622			resp.Header.Del("Referrer-Policy")
623			resp.Header.Del("Permissions-Policy")
624			resp.Header.Del("Content-Security-Policy")
625			resp.Header.Del("Strict-Transport-Security")
626
627			return nil
628		},
629	}
630
631	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
632
633	proxy.ServeHTTP(w, r)
634}
635
636func (s *Service) isRunningInContainer() bool {
637	// Check for /.dockerenv
638	if _, err := os.Stat("/.dockerenv"); err == nil {
639		return true
640	}
641
642	// Check cgroup for container indicators
643	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
644		content := string(data)
645		if strings.Contains(content, "docker") ||
646			strings.Contains(content, "containerd") ||
647			strings.Contains(content, "podman") {
648			return true
649		}
650	}
651
652	// NOTE: Hostname length check (12 or 64 chars) was removed because it produced
653	// false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
654	// which would cause the proxy to use container network IPs instead of host port mappings.
655
656	// Check environment variables
657	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
658		os.Getenv("DOCKER_CONTAINER") != "" ||
659		os.Getenv("container") != "" {
660		return true
661	}
662
663	return false
664}
665
666// getProxyPort determines the port to proxy to for an image.
667// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
668func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
669	log := zerowrap.FromCtx(ctx)
670
671	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
672
673	// Check for explicit port label
674	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
675	if err != nil {
676		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
677	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
678		port, err := strconv.Atoi(portStr)
679		if err == nil && port > 0 {
680			log.Debug().Int("port", port).Msg("using proxy port from image label")
681			return port, nil
682		}
683		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
684	}
685
686	// Fall back to first exposed port
687	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
688	if err != nil {
689		return 0, err
690	}
691	if len(exposedPorts) == 0 {
692		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
693	}
694
695	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
696	return exposedPorts[0], nil
697}

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