v2.15.0 cf76057

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

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