v2.15.1 c808592

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

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