v0.4.2 257b319

dnsfwd_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package dnsfwd
4
5import (
6	"context"
7	"encoding/binary"
8	"errors"
9	"io"
10	"net"
11	"net/netip"
12	"os"
13	"path/filepath"
14	"reflect"
15	"strings"
16	"sync/atomic"
17	"syscall"
18	"testing"
19	"time"
20)
21
22// query builds a minimal query for name with the given ID.
23func query(id uint16, name string) []byte {
24	b := make([]byte, hdrLen)
25	binary.BigEndian.PutUint16(b, id)
26	b[2] = 0x01 // RD
27	b[5] = 1    // QDCOUNT
28	for _, l := range strings.Split(name, ".") {
29		b = append(b, byte(len(l)))
30		b = append(b, l...)
31	}
32	return append(b, 0, 0, 1, 0, 1) // root, A, IN
33}
34
35// answer turns a query into a response carrying a recognisable payload.
36func answer(q []byte) []byte {
37	r := append([]byte(nil), q...)
38	r[2] |= 0x80
39	return append(r, "ANSWER"...)
40}
41
42// fakeUpstream serves UDP and TCP on one port of 127.0.0.1 and returns it.
43func fakeUpstream(t *testing.T, handle func(q []byte) []byte) string {
44	t.Helper()
45	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
46	if err != nil {
47		t.Skip("no loopback:", err)
48	}
49	addr := pc.LocalAddr().String()
50	tl, err := net.Listen("tcp", addr)
51	if err != nil {
52		t.Fatal(err)
53	}
54	t.Cleanup(func() { _ = pc.Close(); _ = tl.Close() })
55	go func() {
56		buf := make([]byte, 4096)
57		for {
58			n, from, err := pc.ReadFrom(buf)
59			if err != nil {
60				return
61			}
62			if r := handle(append([]byte(nil), buf[:n]...)); r != nil {
63				_, _ = pc.WriteTo(r, from)
64			}
65		}
66	}()
67	go func() {
68		for {
69			c, err := tl.Accept()
70			if err != nil {
71				return
72			}
73			go func() {
74				defer func() { _ = c.Close() }()
75				var l [2]byte
76				if _, err := io.ReadFull(c, l[:]); err != nil {
77					return
78				}
79				q := make([]byte, binary.BigEndian.Uint16(l[:]))
80				if _, err := io.ReadFull(c, q); err != nil {
81					return
82				}
83				r := handle(q)
84				out := make([]byte, 2+len(r))
85				binary.BigEndian.PutUint16(out, uint16(len(r)))
86				copy(out[2:], r)
87				_, _ = c.Write(out)
88			}()
89		}
90	}()
91	return addr
92}
93
94func start(t *testing.T, ups ...string) (*Forwarder, string) {
95	t.Helper()
96	f := New(func() []string { return ups }, 0)
97	ip := netip.MustParseAddr("127.0.0.1")
98	if err := f.Listen(context.Background(), ip); err != nil {
99		t.Fatal(err)
100	}
101	t.Cleanup(f.CloseAll)
102	a, _ := f.Addr(ip)
103	return f, a.String()
104}
105
106func TestUDPAndTCPForwarding(t *testing.T) {
107	up := fakeUpstream(t, answer)
108	_, addr := start(t, up)
109
110	c, err := net.Dial("udp", addr)
111	if err != nil {
112		t.Fatal(err)
113	}
114	defer func() { _ = c.Close() }()
115	_ = c.SetDeadline(time.Now().Add(5 * time.Second))
116	q := query(0x1234, "example.test")
117	_, _ = c.Write(q)
118	buf := make([]byte, 512)
119	n, err := c.Read(buf)
120	if err != nil {
121		t.Fatal(err)
122	}
123	if string(buf[n-6:n]) != "ANSWER" || binary.BigEndian.Uint16(buf) != 0x1234 {
124		t.Fatalf("udp answer: %x", buf[:n])
125	}
126
127	tc, err := net.Dial("tcp", addr)
128	if err != nil {
129		t.Fatal(err)
130	}
131	defer func() { _ = tc.Close() }()
132	_ = tc.SetDeadline(time.Now().Add(5 * time.Second))
133	// Two queries on one connection.
134	for _, id := range []uint16{7, 8} {
135		q := query(id, "tcp.test")
136		framed := append([]byte{byte(len(q) >> 8), byte(len(q))}, q...)
137		_, _ = tc.Write(framed)
138		var l [2]byte
139		if _, err := io.ReadFull(tc, l[:]); err != nil {
140			t.Fatal(err)
141		}
142		r := make([]byte, binary.BigEndian.Uint16(l[:]))
143		if _, err := io.ReadFull(tc, r); err != nil {
144			t.Fatal(err)
145		}
146		if binary.BigEndian.Uint16(r) != id || string(r[len(r)-6:]) != "ANSWER" {
147			t.Fatalf("tcp answer for %d: %x", id, r)
148		}
149	}
150}
151
152func TestFailoverToSecondUpstream(t *testing.T) {
153	dead := "127.0.0.1:1" // nothing listens: UDP read fails with ICMP refused
154	live := fakeUpstream(t, answer)
155	_, addr := start(t, dead, live)
156	c, _ := net.Dial("udp", addr)
157	defer func() { _ = c.Close() }()
158	_ = c.SetDeadline(time.Now().Add(8 * time.Second))
159	_, _ = c.Write(query(1, "a.test"))
160	buf := make([]byte, 512)
161	n, err := c.Read(buf)
162	if err != nil || string(buf[n-6:n]) != "ANSWER" {
163		t.Fatalf("n=%d err=%v", n, err)
164	}
165}
166
167func TestServfailWhenNoUpstreamAnswers(t *testing.T) {
168	_, addr := start(t) // no upstream at all
169	c, _ := net.Dial("udp", addr)
170	defer func() { _ = c.Close() }()
171	_ = c.SetDeadline(time.Now().Add(5 * time.Second))
172	_, _ = c.Write(query(0xbeef, "a.test"))
173	buf := make([]byte, 512)
174	n, err := c.Read(buf)
175	if err != nil {
176		t.Fatal(err)
177	}
178	if n != hdrLen || binary.BigEndian.Uint16(buf) != 0xbeef || buf[3]&0x0f != 2 || buf[2]&0x80 == 0 {
179		t.Fatalf("not a SERVFAIL: %x", buf[:n])
180	}
181}
182
183func TestMismatchedUpstreamIDIgnored(t *testing.T) {
184	// Upstream answers with a wrong ID: treated as no answer, client gets SERVFAIL.
185	up := fakeUpstream(t, func(q []byte) []byte {
186		r := answer(q)
187		r[0] ^= 0xff
188		return r
189	})
190	f, addr := start(t, up)
191	_ = f
192	c, _ := net.Dial("udp", addr)
193	defer func() { _ = c.Close() }()
194	_ = c.SetDeadline(time.Now().Add(2*UpstreamTimeout + 2*time.Second))
195	_, _ = c.Write(query(5, "a.test"))
196	buf := make([]byte, 512)
197	n, err := c.Read(buf)
198	if err != nil {
199		t.Fatal(err)
200	}
201	if buf[3]&0x0f != 2 {
202		t.Fatalf("want SERVFAIL, got %x", buf[:n])
203	}
204}
205
206func TestShortPacketsIgnored(t *testing.T) {
207	up := fakeUpstream(t, answer)
208	_, addr := start(t, up)
209	c, _ := net.Dial("udp", addr)
210	defer func() { _ = c.Close() }()
211	_, _ = c.Write([]byte{1, 2, 3}) // dropped
212	_, _ = c.Write(query(9, "a.test"))
213	_ = c.SetDeadline(time.Now().Add(5 * time.Second))
214	buf := make([]byte, 512)
215	n, err := c.Read(buf)
216	if err != nil || binary.BigEndian.Uint16(buf) != 9 {
217		t.Fatalf("n=%d err=%v", n, err)
218	}
219}
220
221func TestListenTwiceAndClose(t *testing.T) {
222	f := New(func() []string { return nil }, 0)
223	ip := netip.MustParseAddr("127.0.0.1")
224	if err := f.Listen(context.Background(), ip); err != nil {
225		t.Skip(err)
226	}
227	if err := f.Listen(context.Background(), ip); err == nil {
228		t.Fatal("second Listen on the same address must fail")
229	}
230	a, _ := f.Addr(ip)
231	f.Close(ip)
232	f.Close(ip) // idempotent
233	if _, ok := f.Addr(ip); ok {
234		t.Fatal("still listening after Close")
235	}
236	if pc, err := net.ListenPacket("udp", a.String()); err != nil {
237		t.Fatalf("port not released: %v", err)
238	} else {
239		_ = pc.Close()
240	}
241}
242
243func TestServfailShape(t *testing.T) {
244	if servfail([]byte{1}) != nil {
245		t.Fatal("short query must give nil")
246	}
247	q := query(0xabcd, "x.test")
248	q[2] = 0x79 // opcode bits + RD + AA
249	r := servfail(q)
250	if r[0] != 0xab || r[1] != 0xcd || r[2]&0x80 == 0 || r[3] != 0x82 {
251		t.Fatalf("bad servfail %x", r)
252	}
253}
254
255func TestParseResolvConf(t *testing.T) {
256	in := `# comment
257nameserver 127.0.0.53
258nameserver 127.0.0.53
259nameserver 192.0.2.1 # trailing
260nameserver 2001:db8::1
261nameserver fe80::1%eth0
262nameserver 0.0.0.0
263nameserver not-an-ip
264search example.test
265options edns0
266nameserver
267`
268	got := ParseResolvConf(strings.NewReader(in))
269	want := []string{"127.0.0.53:53", "192.0.2.1:53", "[2001:db8::1]:53", "[fe80::1%eth0]:53"}
270	if !reflect.DeepEqual(got, want) {
271		t.Fatalf("got %v want %v", got, want)
272	}
273}
274
275func TestResolversReload(t *testing.T) {
276	p := filepath.Join(t.TempDir(), "resolv.conf")
277	if err := os.WriteFile(p, []byte("nameserver 192.0.2.1\n"), 0o644); err != nil {
278		t.Fatal(err)
279	}
280	r := NewResolvers(p)
281	if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.1:53"}) {
282		t.Fatal(got)
283	}
284	if err := os.WriteFile(p, []byte("nameserver 192.0.2.99\nnameserver 192.0.2.1\n"), 0o644); err != nil {
285		t.Fatal(err)
286	}
287	later := time.Now().Add(2 * time.Second)
288	_ = os.Chtimes(p, later, later)
289	if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.99:53", "192.0.2.1:53"}) {
290		t.Fatal(got)
291	}
292	// Unreadable file keeps the last known list.
293	_ = os.Remove(p)
294	if got := r.Addrs(); len(got) != 2 {
295		t.Fatalf("lost the last known list: %v", got)
296	}
297	if got := NewResolvers(filepath.Join(t.TempDir(), "missing")).Addrs(); len(got) != 0 {
298		t.Fatal(got)
299	}
300}
301
302// S2: a listener holds at most MaxTCPConns connections; extra ones are closed
303// at once, and a free slot is usable again after a close.
304func TestTCPConnectionCapPerListener(t *testing.T) {
305	up := fakeUpstream(t, answer)
306	_, addr := start(t, up)
307	var held []net.Conn
308	for range MaxTCPConns {
309		c, err := net.Dial("tcp", addr)
310		if err != nil {
311			t.Fatal(err)
312		}
313		held = append(held, c)
314		defer func() { _ = c.Close() }()
315	}
316	// Let the accept loop register all of them.
317	time.Sleep(200 * time.Millisecond)
318	extra, err := net.Dial("tcp", addr)
319	if err != nil {
320		t.Fatal(err)
321	}
322	defer func() { _ = extra.Close() }()
323	_ = extra.SetReadDeadline(time.Now().Add(3 * time.Second))
324	if _, err := extra.Read(make([]byte, 1)); err != io.EOF && !errors.Is(err, syscall.ECONNRESET) {
325		t.Fatalf("the connection over the cap must be closed by the server, got %v", err)
326	}
327	// Free one slot: a new connection is served.
328	_ = held[0].Close()
329	time.Sleep(200 * time.Millisecond)
330	c, err := net.Dial("tcp", addr)
331	if err != nil {
332		t.Fatal(err)
333	}
334	defer func() { _ = c.Close() }()
335	_ = c.SetDeadline(time.Now().Add(5 * time.Second))
336	q := query(3, "slot.test")
337	_, _ = c.Write(append([]byte{byte(len(q) >> 8), byte(len(q))}, q...))
338	var l [2]byte
339	if _, err := io.ReadFull(c, l[:]); err != nil {
340		t.Fatalf("a freed slot must serve again: %v", err)
341	}
342}
343
344// S2: the lifetime is absolute from accept: a client that keeps the connection
345// alive by trickling bytes still loses it.
346func TestTCPAbsoluteDeadline(t *testing.T) {
347	up := fakeUpstream(t, answer)
348	f := New(func() []string { return []string{up} }, 0)
349	f.Lifetime = 600 * time.Millisecond
350	ip := netip.MustParseAddr("127.0.0.1")
351	if err := f.Listen(context.Background(), ip); err != nil {
352		t.Skip(err)
353	}
354	t.Cleanup(f.CloseAll)
355	la, _ := f.Addr(ip)
356	addr := la.String()
357	c, err := net.Dial("tcp", addr)
358	if err != nil {
359		t.Fatal(err)
360	}
361	defer func() { _ = c.Close() }()
362	start := time.Now()
363	closed := make(chan struct{})
364	go func() {
365		_, _ = io.Copy(io.Discard, c) // returns when the server closes
366		close(closed)
367	}()
368	// Trickle one byte every 100 ms, never finishing a frame: only an idle
369	// timeout (10 s) would let this live; the absolute deadline must not.
370	go func() {
371		for i := 0; i < 40; i++ {
372			if _, err := c.Write([]byte{0}); err != nil {
373				return
374			}
375			time.Sleep(100 * time.Millisecond)
376		}
377	}()
378	select {
379	case <-closed:
380		if d := time.Since(start); d > 3*time.Second {
381			t.Fatalf("closed after %v, want about %v", d, f.Lifetime)
382		}
383	case <-time.After(5 * time.Second):
384		t.Fatal("a trickling client kept its connection past the absolute deadline")
385	}
386}
387
388// S2/S9: a saturated listener drops UDP queries instead of blocking its read
389// loop, and another listener (another guest) keeps answering.
390func TestUDPSaturationDropsAndIsolatesListeners(t *testing.T) {
391	release := make(chan struct{})
392	slow := fakeUpstream(t, func(q []byte) []byte { <-release; return answer(q) })
393	fast := fakeUpstream(t, answer)
394	var useSlow atomic.Bool
395	useSlow.Store(true)
396	f := New(func() []string {
397		if useSlow.Load() {
398			return []string{slow}
399		}
400		return []string{fast}
401	}, 0)
402	a1, a2 := netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("127.0.0.2")
403	if err := f.Listen(context.Background(), a1); err != nil {
404		t.Skip(err)
405	}
406	if err := f.Listen(context.Background(), a2); err != nil {
407		t.Skip(err)
408	}
409	t.Cleanup(f.CloseAll)
410	t.Cleanup(func() { close(release) })
411	l1, _ := f.Addr(a1)
412	flood, err := net.Dial("udp", l1.String())
413	if err != nil {
414		t.Fatal(err)
415	}
416	defer func() { _ = flood.Close() }()
417	for i := 0; i < MaxInflight*3; i++ {
418		_, _ = flood.Write(query(uint16(i), "flood.test"))
419	}
420	deadline := time.Now().Add(3 * time.Second)
421	for f.Dropped(a1) == 0 && time.Now().Before(deadline) {
422		time.Sleep(20 * time.Millisecond)
423	}
424	if f.Dropped(a1) == 0 {
425		t.Fatal("a saturated listener must drop queries")
426	}
427	// The other listener is unaffected: point the resolver at the fast upstream.
428	useSlow.Store(false)
429	l2, _ := f.Addr(a2)
430	c, err := net.Dial("udp", l2.String())
431	if err != nil {
432		t.Fatal(err)
433	}
434	defer func() { _ = c.Close() }()
435	_ = c.SetDeadline(time.Now().Add(4 * time.Second))
436	_, _ = c.Write(query(77, "other-guest.test"))
437	buf := make([]byte, 512)
438	n, err := c.Read(buf)
439	if err != nil || string(buf[n-6:n]) != "ANSWER" {
440		t.Fatalf("another listener must not be starved by the flood: n=%d err=%v", n, err)
441	}
442}
443
444// By default Listen fails fast on an address the host does not own (Docker and
445// the VM driver have configured it by then: a missing one is an error).
446func TestListenOnAMissingAddressFailsByDefault(t *testing.T) {
447	f := New(func() []string { return nil }, 0)
448	t.Cleanup(f.CloseAll)
449	ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here
450	if err := f.Listen(context.Background(), ip); err == nil {
451		t.Fatal("listening on an address the host does not own must fail without FreeBind")
452	}
453}
454
455// With FreeBind (podman's netavark creates the bridge address only when the
456// first container starts) Listen binds an address the host does not own yet.
457func TestFreeBindListensOnAnAddressTheHostDoesNotOwnYet(t *testing.T) {
458	f := New(func() []string { return nil }, 0)
459	f.FreeBind = true
460	t.Cleanup(f.CloseAll)
461	ip := netip.MustParseAddr("203.0.113.77")
462	if err := f.Listen(context.Background(), ip); err != nil {
463		t.Fatalf("FreeBind: %v", err)
464	}
465	if _, ok := f.Addr(ip); !ok {
466		t.Fatal("no listener")
467	}
468}
469
470// queryType is query with a chosen type (and optionally EDNS0).
471func queryType(id uint16, name string, qtype uint16, edns bool) []byte {
472	q := query(id, name)
473	binary.BigEndian.PutUint16(q[len(q)-4:], qtype)
474	if edns {
475		q[11] = 1
476		q = append(q, 0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0)
477	}
478	return q
479}
480
481func udpExchange(t *testing.T, addr string, q []byte) []byte {
482	t.Helper()
483	c, err := net.Dial("udp", addr)
484	if err != nil {
485		t.Fatal(err)
486	}
487	defer func() { _ = c.Close() }()
488	_ = c.SetDeadline(time.Now().Add(5 * time.Second))
489	_, _ = c.Write(q)
490	buf := make([]byte, 1024)
491	n, err := c.Read(buf)
492	if err != nil {
493		t.Fatal(err)
494	}
495	return buf[:n]
496}
497
498func startOverride(t *testing.T, name string, ups ...string) (*Forwarder, string) {
499	t.Helper()
500	f := New(func() []string { return ups }, 0)
501	f.Override = name
502	ip := netip.MustParseAddr("127.0.0.1")
503	if err := f.Listen(context.Background(), ip); err != nil {
504		t.Fatal(err)
505	}
506	t.Cleanup(f.CloseAll)
507	a, _ := f.Addr(ip)
508	return f, a.String()
509}
510
511func TestOverrideAnswersAWithTheListenerAddress(t *testing.T) {
512	var asked atomic.Int64
513	up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) })
514	// A trailing dot in the setting is ignored; on the wire a name has none.
515	_, addr := startOverride(t, "Ermite.Example.org.", up)
516	for _, name := range []string{"ermite.example.org", "ERMITE.example.ORG", "Ermite.Example.Org"} {
517		r := udpExchange(t, addr, queryType(0x4242, name, 1, false))
518		if binary.BigEndian.Uint16(r) != 0x4242 || r[2]&0x80 == 0 || r[2]&0x01 == 0 || r[3]&0x0F != 0 {
519			t.Fatalf("%s: header %x", name, r[:hdrLen])
520		}
521		if an := binary.BigEndian.Uint16(r[6:]); an != 1 {
522			t.Fatalf("%s: %d answers", name, an)
523		}
524		if !strings.HasSuffix(string(r), "\x00\x04\x7f\x00\x00\x01") {
525			t.Fatalf("%s: rdata is not 127.0.0.1: %x", name, r)
526		}
527	}
528	if asked.Load() != 0 {
529		t.Fatalf("the override went upstream %d times", asked.Load())
530	}
531}
532
533func TestOverrideAAAAIsEmptyNoError(t *testing.T) {
534	up := fakeUpstream(t, answer)
535	_, addr := startOverride(t, "ermite.example.org", up)
536	for _, edns := range []bool{false, true} {
537		r := udpExchange(t, addr, queryType(7, "ermite.EXAMPLE.org", 28, edns))
538		if r[3]&0x0F != 0 || binary.BigEndian.Uint16(r[6:]) != 0 || r[2]&0x80 == 0 {
539			t.Fatalf("AAAA (edns %v): %x", edns, r)
540		}
541		if edns != (binary.BigEndian.Uint16(r[10:]) == 1) {
542			t.Fatalf("AAAA (edns %v): arcount %d", edns, binary.BigEndian.Uint16(r[10:]))
543		}
544	}
545}
546
547func TestOverrideForwardsEveryOtherName(t *testing.T) {
548	var asked atomic.Int64
549	up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) })
550	_, addr := startOverride(t, "ermite.example.org", up)
551	for _, name := range []string{"example.org", "other.example.org", "x.ermite.example.org", "ermite.example.org.evil.test", "ermite.example.or"} {
552		r := udpExchange(t, addr, query(9, name))
553		if string(r[len(r)-6:]) != "ANSWER" {
554			t.Fatalf("%s was not forwarded: %x", name, r)
555		}
556	}
557	if asked.Load() != 5 {
558		t.Fatalf("%d upstream queries, want 5", asked.Load())
559	}
560}
561
562func TestOverrideOverTCPAndDisabledByDefault(t *testing.T) {
563	up := fakeUpstream(t, answer)
564	_, addr := startOverride(t, "ermite.example.org", up)
565	tc, err := net.Dial("tcp", addr)
566	if err != nil {
567		t.Fatal(err)
568	}
569	defer func() { _ = tc.Close() }()
570	_ = tc.SetDeadline(time.Now().Add(5 * time.Second))
571	q := queryType(3, "ermite.example.org", 1, false)
572	_, _ = tc.Write(append([]byte{byte(len(q) >> 8), byte(len(q))}, q...))
573	var l [2]byte
574	if _, err := io.ReadFull(tc, l[:]); err != nil {
575		t.Fatal(err)
576	}
577	r := make([]byte, binary.BigEndian.Uint16(l[:]))
578	if _, err := io.ReadFull(tc, r); err != nil {
579		t.Fatal(err)
580	}
581	if binary.BigEndian.Uint16(r[6:]) != 1 || !strings.HasSuffix(string(r), "\x7f\x00\x00\x01") {
582		t.Fatalf("tcp: %x", r)
583	}
584
585	// Off by default: the name is forwarded like any other.
586	_, addr2 := start(t, up)
587	r2 := udpExchange(t, addr2, query(4, "ermite.example.org"))
588	if string(r2[len(r2)-6:]) != "ANSWER" {
589		t.Fatalf("not forwarded without Override: %x", r2)
590	}
591}
592
593func TestOverrideIgnoresMalformedAndNonQueries(t *testing.T) {
594	f := New(func() []string { return nil }, 0)
595	f.Override = "ermite.example.org"
596	gw := netip.MustParseAddr("10.200.0.1")
597	good := queryType(1, "ermite.example.org", 1, false)
598	if f.overrideAnswer(gw, good) == nil {
599		t.Fatal("control query not answered")
600	}
601	cut := good[:len(good)-3]
602	chaos := queryType(1, "ermite.example.org", 1, false)
603	binary.BigEndian.PutUint16(chaos[len(chaos)-2:], 3) // class CH
604	resp := append([]byte(nil), good...)
605	resp[2] |= 0x80 // QR set: not a query
606	notify := append([]byte(nil), good...)
607	notify[2] |= 4 << 3 // opcode 4
608	ptr := append(append([]byte(nil), good[:hdrLen]...), 0xC0, 0x0C, 0, 1, 0, 1)
609	for name, q := range map[string][]byte{"truncated": cut, "class CH": chaos, "response": resp, "opcode": notify, "pointer": ptr, "short": good[:5]} {
610		if f.overrideAnswer(gw, q) != nil {
611			t.Errorf("%s was answered locally", name)
612		}
613	}
614}

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 4

Tags 11

↑↓ moveenter openesc close