main bac21c6

builder.go RawHistoryFolder

1package traffic
2
3import (
4	"fmt"
5	"net"
6	"net/netip"
7	"sort"
8	"strconv"
9	"strings"
10	"time"
11
12	"github.com/bnema/gordon/internal/domain"
13)
14
15const DefaultEdgeEntryPointName = "edge"
16
17// Input is the complete, usecase-level input used to build a validated traffic graph.
18type Input struct {
19	EntryPoints     map[string]EntryPointConfig
20	Traffic         Config
21	Routes          []domain.Route
22	ExternalRoutes  map[string]string
23	NetworkServices []NetworkServiceConfig
24	Services        []domain.StandaloneService
25}
26
27type EntryPointConfig struct {
28	Address                 string                    `mapstructure:"address"`
29	Protocol                domain.EntryPointProtocol `mapstructure:"protocol"`
30	TrustedCIDRs            []string                  `mapstructure:"trusted_cidrs"`
31	RawFallback             string                    `mapstructure:"raw_fallback"`
32	RawFallbackTrustedCIDRs []string                  `mapstructure:"raw_fallback_trusted_cidrs"`
33	AllowPublicRawFallback  bool                      `mapstructure:"allow_public_raw_fallback"`
34}
35
36// Config holds traffic router and option configuration.
37type Config struct {
38	TCP TCPConfig `mapstructure:"tcp"`
39	UDP UDPConfig `mapstructure:"udp"`
40	TLS TLSConfig `mapstructure:"tls"`
41	// HTTP configures the HTTP servers behind traffic entrypoints.
42	HTTP HTTPConfig `mapstructure:"http"`
43}
44
45// HTTPConfig holds traffic.http duration strings (Go duration syntax).
46type HTTPConfig struct {
47	ReadHeaderTimeout   string `mapstructure:"read_header_timeout"`
48	IdleTimeout         string `mapstructure:"idle_timeout"`
49	TransferIdleTimeout string `mapstructure:"transfer_idle_timeout"`
50}
51
52type TCPConfig struct {
53	Routers        []RouterConfig `mapstructure:"routers"`
54	DialTimeout    string         `mapstructure:"dial_timeout"`
55	IdleTimeout    string         `mapstructure:"idle_timeout"`
56	DrainTimeout   string         `mapstructure:"drain_timeout"`
57	MaxConnections int            `mapstructure:"max_connections"`
58}
59
60type UDPConfig struct {
61	Routers      []RouterConfig `mapstructure:"routers"`
62	IdleTimeout  string         `mapstructure:"idle_timeout"`
63	DrainTimeout string         `mapstructure:"drain_timeout"`
64	MaxSessions  int            `mapstructure:"max_sessions"`
65}
66
67type TLSConfig struct {
68	Routers []RouterConfig `mapstructure:"routers"`
69}
70
71type RouterConfig struct {
72	Name       string `mapstructure:"name"`
73	EntryPoint string `mapstructure:"entrypoint"`
74	Host       string `mapstructure:"host"`
75	SNI        string `mapstructure:"sni"`
76	Service    string `mapstructure:"service"`
77}
78
79type NetworkServiceConfig struct {
80	Name  string       `mapstructure:"name"`
81	Ports []PortConfig `mapstructure:"ports"`
82}
83
84type PortConfig struct {
85	Name      string                 `mapstructure:"name"`
86	Container int                    `mapstructure:"container"`
87	Protocol  domain.NetworkProtocol `mapstructure:"protocol"`
88}
89
90// Build builds and validates a TrafficGraph snapshot. Managed route services are
91// intentionally backend-less because their concrete container endpoints are resolved at runtime.
92func Build(input Input) (domain.TrafficGraph, error) {
93	b := builder{input: input, services: map[string]domain.TrafficService{}}
94	graph := domain.TrafficGraph{}
95
96	options, err := buildOptions(input.Traffic)
97	if err != nil {
98		return domain.TrafficGraph{}, fmt.Errorf("build traffic options: %w", err)
99	}
100	if err := validateNetworkServices(input.NetworkServices); err != nil {
101		return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
102	}
103	if err := validateStandaloneServices(input.Services); err != nil {
104		return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err)
105	}
106	graph.Options = options
107	graph.EntryPoints = b.buildEntryPoints()
108
109	if err := b.addHTTPRoutes(&graph); err != nil {
110		return domain.TrafficGraph{}, fmt.Errorf("add HTTP routes: %w", err)
111	}
112	if err := b.addExternalRoutes(&graph); err != nil {
113		return domain.TrafficGraph{}, fmt.Errorf("add external routes: %w", err)
114	}
115	if err := b.addExplicitRouters(&graph); err != nil {
116		return domain.TrafficGraph{}, fmt.Errorf("add explicit traffic routers: %w", err)
117	}
118
119	graph.Services = make([]domain.TrafficService, 0, len(b.services))
120	for _, svc := range b.services {
121		graph.Services = append(graph.Services, svc)
122	}
123	sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name })
124	if err := graph.Validate(); err != nil {
125		return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err)
126	}
127	return graph, nil
128}
129
130type builder struct {
131	input    Input
132	services map[string]domain.TrafficService
133}
134
135func (b *builder) buildEntryPoints() []domain.EntryPoint {
136	entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints))
137	for _, name := range sortedKeys(b.input.EntryPoints) {
138		cfg := b.input.EntryPoints[name]
139		entries = append(entries, domain.EntryPoint{
140			Name:                    name,
141			Address:                 cfg.Address,
142			Protocol:                cfg.Protocol,
143			TrustedCIDRs:            append([]string(nil), cfg.TrustedCIDRs...),
144			RawFallback:             cfg.RawFallback,
145			RawFallbackTrustedCIDRs: append([]string(nil), cfg.RawFallbackTrustedCIDRs...),
146			AllowPublicRawFallback:  cfg.AllowPublicRawFallback,
147		})
148	}
149	return entries
150}
151
152func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
153	routes := append([]domain.Route{}, b.input.Routes...)
154	if len(routes) == 0 {
155		return nil
156	}
157	entryPoint, err := b.routeEntryPoint()
158	if err != nil {
159		return err
160	}
161	sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
162	for _, route := range routes {
163		serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
164		b.addService(domain.TrafficService{Name: serviceName})
165		graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
166	}
167	return nil
168}
169
170func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
171	hostNames := sortedKeys(b.input.ExternalRoutes)
172	if len(hostNames) == 0 {
173		return nil
174	}
175	entryPoint, err := b.routeEntryPoint()
176	if err != nil {
177		return err
178	}
179	for _, hostName := range hostNames {
180		target := b.input.ExternalRoutes[hostName]
181		host, port, err := parseBackendAddress(target)
182		if err != nil {
183			return fmt.Errorf("external route %q target %q: %w", hostName, target, err)
184		}
185		serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
186		b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
187		graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
188	}
189	return nil
190}
191
192func (b *builder) routeEntryPoint() (string, error) {
193	if cfg, ok := b.input.EntryPoints[DefaultEdgeEntryPointName]; ok && routeCapableEntryPoint(cfg.Protocol) {
194		return DefaultEdgeEntryPointName, nil
195	}
196
197	candidates := make([]string, 0)
198	for _, name := range sortedKeys(b.input.EntryPoints) {
199		if routeCapableEntryPoint(b.input.EntryPoints[name].Protocol) {
200			candidates = append(candidates, name)
201		}
202	}
203	switch len(candidates) {
204	case 0:
205		return DefaultEdgeEntryPointName, nil
206	case 1:
207		return candidates[0], nil
208	default:
209		return "", fmt.Errorf("multiple route-capable entrypoints configured without %q: %s", DefaultEdgeEntryPointName, strings.Join(candidates, ", "))
210	}
211}
212
213func routeCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
214	switch protocol {
215	case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
216		return true
217	default:
218		return false
219	}
220}
221
222func (b *builder) addExplicitRouters(graph *domain.TrafficGraph) error {
223	if err := b.addL4Routers(graph, b.input.Traffic.TCP.Routers, domain.RouterProtocolTCP, domain.NetworkProtocolTCP); err != nil {
224		return err
225	}
226	if err := b.addL4Routers(graph, b.input.Traffic.UDP.Routers, domain.RouterProtocolUDP, domain.NetworkProtocolUDP); err != nil {
227		return err
228	}
229	return b.addL4Routers(graph, b.input.Traffic.TLS.Routers, domain.RouterProtocolTLSPassthrough, domain.NetworkProtocolTCP)
230}
231
232func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfig, protocol domain.RouterProtocol, backendProtocol domain.NetworkProtocol) error {
233	ordered := append([]RouterConfig{}, routers...)
234	sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
235	for _, cfg := range ordered {
236		service, err := b.resolveService(cfg, backendProtocol)
237		if err != nil {
238			return fmt.Errorf("router %q: %w", cfg.Name, err)
239		}
240		b.addService(service)
241		graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: cfg.Name, EntryPoint: cfg.EntryPoint, Protocol: protocol, Rule: domain.TrafficRule{Host: cfg.Host, SNI: cfg.SNI}, Service: cfg.Service})
242	}
243	return nil
244}
245
246func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
247	ref, err := domain.ParseTrafficServiceRef(router.Service)
248	if err != nil {
249		return domain.TrafficService{}, err
250	}
251	switch ref.Kind {
252	case domain.TrafficServiceRefNetworkService:
253		return b.resolveNetworkService(router.Service, ref, protocol)
254	case domain.TrafficServiceRefService:
255		return b.resolveStandaloneService(router, ref, protocol)
256	case domain.TrafficServiceRefStatic:
257		return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service)
258	default:
259		return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service)
260	}
261}
262
263func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
264	ns, ok := b.networkService(ref.Name)
265	if !ok {
266		return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
267	}
268	port, ok := networkServicePort(ns, ref.PortName)
269	if !ok {
270		return domain.TrafficService{}, fmt.Errorf("unknown port %q on network service %q", ref.PortName, ref.Name)
271	}
272	if port.Container < 1 || port.Container > 65535 {
273		return domain.TrafficService{}, fmt.Errorf("invalid port %d on network service %q port %q", port.Container, ref.Name, ref.PortName)
274	}
275	if port.Protocol != protocol {
276		return domain.TrafficService{}, fmt.Errorf("network service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
277	}
278	return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
279}
280
281func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
282	svc, ok := b.standaloneService(ref.Name)
283	if !ok || !svc.Enabled {
284		return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
285	}
286	port, ok := standaloneServicePort(svc, ref.PortName)
287	if !ok {
288		return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name)
289	}
290	if port.Protocol != protocol {
291		return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
292	}
293	if isPrivateStandalonePort(port) {
294		if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil {
295			return domain.TrafficService{}, err
296		}
297	}
298	host, backendPort, err := parseBackendAddress(port.Publish)
299	if err != nil {
300		return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err)
301	}
302	return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil
303}
304
305func validateStandaloneServices(services []domain.StandaloneService) error {
306	seen := map[string]struct{}{}
307	for _, service := range services {
308		if service.Name == "" {
309			return fmt.Errorf("standalone service name is required")
310		}
311		if _, exists := seen[service.Name]; exists {
312			return fmt.Errorf("duplicate standalone service %q", service.Name)
313		}
314		seen[service.Name] = struct{}{}
315		if err := service.Validate(); err != nil {
316			return fmt.Errorf("standalone service %q: %w", service.Name, err)
317		}
318	}
319	return nil
320}
321
322func validateNetworkServices(services []NetworkServiceConfig) error {
323	seen := map[string]struct{}{}
324	for _, service := range services {
325		if service.Name == "" {
326			return fmt.Errorf("network service name is required")
327		}
328		if _, exists := seen[service.Name]; exists {
329			return fmt.Errorf("duplicate network service %q", service.Name)
330		}
331		seen[service.Name] = struct{}{}
332		if err := validateNetworkServicePorts(service); err != nil {
333			return err
334		}
335	}
336	return nil
337}
338
339func validateNetworkServicePorts(service NetworkServiceConfig) error {
340	seen := map[string]struct{}{}
341	for _, port := range service.Ports {
342		if port.Name == "" {
343			return fmt.Errorf("network service %q port name is required", service.Name)
344		}
345		if _, exists := seen[port.Name]; exists {
346			return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name)
347		}
348		if port.Container < 1 || port.Container > 65535 {
349			return fmt.Errorf("invalid container port %d on network service %q port %q", port.Container, service.Name, port.Name)
350		}
351		if err := validateNetworkServicePortProtocol(port.Protocol); err != nil {
352			return fmt.Errorf("invalid protocol for network service %q port %q: %w", service.Name, port.Name, err)
353		}
354		seen[port.Name] = struct{}{}
355	}
356	return nil
357}
358
359func validateNetworkServicePortProtocol(protocol domain.NetworkProtocol) error {
360	switch protocol {
361	case domain.NetworkProtocolTCP, domain.NetworkProtocolUDP:
362		return nil
363	default:
364		return fmt.Errorf("%q", protocol)
365	}
366}
367
368func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
369	for _, service := range b.input.NetworkServices {
370		if service.Name == name {
371			return service, true
372		}
373	}
374	return NetworkServiceConfig{}, false
375}
376
377func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) {
378	for _, service := range b.input.Services {
379		if service.Name == name {
380			return service, true
381		}
382	}
383	return domain.StandaloneService{}, false
384}
385
386func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
387	for _, port := range service.Ports {
388		if port.Name == name {
389			return port, true
390		}
391	}
392	return PortConfig{}, false
393}
394
395func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) {
396	for _, port := range service.Ports {
397		if port.Name == name {
398			return port, true
399		}
400	}
401	return domain.StandaloneServicePort{}, false
402}
403
404func isPrivateStandalonePort(port domain.StandaloneServicePort) bool {
405	if port.Public {
406		return false
407	}
408	return port.Private || strings.EqualFold(port.Name, "rcon")
409}
410
411func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error {
412	entryPoint, ok := b.input.EntryPoints[router.EntryPoint]
413	if !ok {
414		return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint)
415	}
416	if len(port.TrustedCIDRs) == 0 {
417		return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name)
418	}
419	if len(entryPoint.TrustedCIDRs) == 0 {
420		return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
421	}
422	if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) {
423		return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
424	}
425	return nil
426}
427
428func sameCIDRSet(a, b []string) bool {
429	if len(a) != len(b) {
430		return false
431	}
432	left, ok := canonicalCIDRs(a)
433	if !ok {
434		return false
435	}
436	right, ok := canonicalCIDRs(b)
437	if !ok {
438		return false
439	}
440	for i := range left {
441		if left[i] != right[i] {
442			return false
443		}
444	}
445	return true
446}
447
448func canonicalCIDRs(values []string) ([]string, bool) {
449	canonical := make([]string, 0, len(values))
450	for _, value := range values {
451		prefix, err := netip.ParsePrefix(value)
452		if err != nil {
453			return nil, false
454		}
455		canonical = append(canonical, prefix.Masked().String())
456	}
457	sort.Strings(canonical)
458	return canonical, true
459}
460
461func (b *builder) addService(service domain.TrafficService) {
462	if _, exists := b.services[service.Name]; !exists {
463		b.services[service.Name] = service
464	}
465}
466
467func buildOptions(cfg Config) (domain.TrafficOptions, error) {
468	tcpDial, err := parsePositiveDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout")
469	if err != nil {
470		return domain.TrafficOptions{}, err
471	}
472	tcpIdle, err := parsePositiveDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout")
473	if err != nil {
474		return domain.TrafficOptions{}, err
475	}
476	tcpDrain, err := parsePositiveDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout")
477	if err != nil {
478		return domain.TrafficOptions{}, err
479	}
480	udpIdle, err := parsePositiveDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout")
481	if err != nil {
482		return domain.TrafficOptions{}, err
483	}
484	udpDrain, err := parsePositiveDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout")
485	if err != nil {
486		return domain.TrafficOptions{}, err
487	}
488	httpOptions, err := ParseHTTPOptions(cfg.HTTP)
489	if err != nil {
490		return domain.TrafficOptions{}, err
491	}
492	return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}, HTTP: httpOptions}, nil
493}
494
495// ParseHTTPOptions converts traffic.http duration strings into validated
496// options. Empty values select the defaults; non-positive or malformed values
497// are rejected.
498func ParseHTTPOptions(cfg HTTPConfig) (domain.HTTPOptions, error) {
499	defaults := domain.DefaultHTTPOptions()
500	readHeader, err := parsePositiveDurationDefault(cfg.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "traffic.http.read_header_timeout")
501	if err != nil {
502		return domain.HTTPOptions{}, err
503	}
504	idle, err := parsePositiveDurationDefault(cfg.IdleTimeout, defaults.IdleTimeout, "traffic.http.idle_timeout")
505	if err != nil {
506		return domain.HTTPOptions{}, err
507	}
508	transferIdle, err := parsePositiveDurationDefault(cfg.TransferIdleTimeout, defaults.TransferIdleTimeout, "traffic.http.transfer_idle_timeout")
509	if err != nil {
510		return domain.HTTPOptions{}, err
511	}
512	return domain.HTTPOptions{ReadHeaderTimeout: readHeader, IdleTimeout: idle, TransferIdleTimeout: transferIdle}, nil
513}
514
515func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
516	d, err := parseDurationDefault(value, fallback, field)
517	if err != nil {
518		return 0, err
519	}
520	if d <= 0 {
521		return 0, fmt.Errorf("invalid %s: must be positive", field)
522	}
523	return d, nil
524}
525
526func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
527	if strings.TrimSpace(value) == "" {
528		return fallback, nil
529	}
530	d, err := time.ParseDuration(value)
531	if err != nil {
532		return 0, fmt.Errorf("invalid %s: %w", field, err)
533	}
534	return d, nil
535}
536
537func parseBackendAddress(value string) (string, int, error) {
538	host, portText, err := net.SplitHostPort(value)
539	if err != nil {
540		lastColon := strings.LastIndex(value, ":")
541		if lastColon <= 0 || lastColon == len(value)-1 || strings.Count(value, ":") > 1 {
542			return "", 0, err
543		}
544		host, portText = value[:lastColon], value[lastColon+1:]
545	}
546	port, err := strconv.Atoi(portText)
547	if err != nil {
548		return "", 0, fmt.Errorf("invalid port: %w", err)
549	}
550	if host == "" || port < 1 || port > 65535 {
551		return "", 0, fmt.Errorf("host and valid port are required")
552	}
553	return host, port, nil
554}
555
556func sortedKeys[V any](m map[string]V) []string {
557	keys := make([]string, 0, len(m))
558	for k := range m {
559		keys = append(keys, k)
560	}
561	sort.Strings(keys)
562	return keys
563}

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