detached 853c006

feat(netpolicy): optional TCP accept to the gateway on one port

commit
853c0069771794cfedb641b99f065128bd73e664
author
brice · · 2026-10-13 07:19 UTC
parent
c747a0b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +162 -0
M internal/netpolicy/netns_test.go +49 -0
@@ -72,6 +72,7 @@ func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) {
7272
7373 gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2
7474 gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6
75+ gA.ForgePort = 8088 // guest A has the guest forge exception, guest B has not
7576 for _, g := range []Guest{gA, gB} {
7677 sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface)
7778 sh("ip", "link", "set", g.Iface, "up")
@@ -188,6 +189,54 @@ except Exception: pass
188189 t.Errorf("TEST-NET-2 reached: %s", got)
189190 }
190191
192+ // Guest forge access: the host listens on 8088 and 8089 on every address
193+ // (like a reverse proxy on *:8088). Guest A, which has the exception for
194+ // 8088, reaches its own gateway there and nowhere else; guest B, which has
195+ // none, reaches nothing.
196+ const tcpServer = `
197+import socket,threading
198+def serve(port):
199+ s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",port)); s.listen(8)
200+ while True:
201+ c,_=s.accept(); c.sendall(b"hello"); c.close()
202+for p in (8088,8089): threading.Thread(target=serve,args=(p,),daemon=True).start()
203+threading.Event().wait()
204+`
205+ tsrv := exec.Command("python3", "-c", tcpServer)
206+ if err := tsrv.Start(); err != nil {
207+ t.Fatal(err)
208+ }
209+ t.Cleanup(func() { _ = tsrv.Process.Kill(); _ = tsrv.Wait() })
210+ time.Sleep(500 * time.Millisecond)
211+ const tcpClient = `
212+import socket,sys
213+s=socket.socket(); s.settimeout(2)
214+try:
215+ s.connect((sys.argv[1],int(sys.argv[2]))); print(s.recv(10).decode())
216+except Exception: print("closed")
217+`
218+ tcp := func(n, dst string, port int) string {
219+ return in(n, "python3", "-c", tcpClient, dst, fmt.Sprint(port))
220+ }
221+ if got := tcp("a", gA.Gateway.String(), 8088); got != "hello" {
222+ t.Errorf("guest A cannot reach its gateway on the forge port: %s", got)
223+ }
224+ if got := tcp("a", gA.Gateway.String(), 8089); got != "closed" {
225+ t.Errorf("guest A reached its gateway on another port: %s", got)
226+ }
227+ if got := tcp("a", gB.Gateway.String(), 8088); got != "closed" {
228+ t.Errorf("guest A reached guest B's gateway on the forge port: %s", got)
229+ }
230+ if got := tcp("a", "203.0.114.1", 8088); got != "closed" {
231+ t.Errorf("guest A reached the host's LAN address on the forge port: %s", got)
232+ }
233+ if got := tcp("b", gB.Gateway.String(), 8088); got != "closed" {
234+ t.Errorf("guest B, without the exception, reached its gateway: %s", got)
235+ }
236+ if got := tcp("b", gA.Gateway.String(), 8088); got != "closed" {
237+ t.Errorf("guest B reached guest A's gateway on the forge port: %s", got)
238+ }
239+
191240 out := seen()
192241 for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} {
193242 if strings.Contains(out, bad) {
@@ -72,6 +72,7 @@ func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) {
72 72
73 gA := guest(t, "ertap0", 0) // 10.200.0.1 / .273 gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2
74 gB := guest(t, "ertap1", 1) // 10.200.0.5 / .674 gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6
75+ gA.ForgePort = 8088 // guest A has the guest forge exception, guest B has not
75 for _, g := range []Guest{gA, gB} {76 for _, g := range []Guest{gA, gB} {
76 sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface)77 sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface)
77 sh("ip", "link", "set", g.Iface, "up")78 sh("ip", "link", "set", g.Iface, "up")
@@ -188,6 +189,54 @@ except Exception: pass
188 t.Errorf("TEST-NET-2 reached: %s", got)189 t.Errorf("TEST-NET-2 reached: %s", got)
189 }190 }
190 191
192+ // Guest forge access: the host listens on 8088 and 8089 on every address
193+ // (like a reverse proxy on *:8088). Guest A, which has the exception for
194+ // 8088, reaches its own gateway there and nowhere else; guest B, which has
195+ // none, reaches nothing.
196+ const tcpServer = `
197+import socket,threading
198+def serve(port):
199+ s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",port)); s.listen(8)
200+ while True:
201+ c,_=s.accept(); c.sendall(b"hello"); c.close()
202+for p in (8088,8089): threading.Thread(target=serve,args=(p,),daemon=True).start()
203+threading.Event().wait()
204+`
205+ tsrv := exec.Command("python3", "-c", tcpServer)
206+ if err := tsrv.Start(); err != nil {
207+ t.Fatal(err)
208+ }
209+ t.Cleanup(func() { _ = tsrv.Process.Kill(); _ = tsrv.Wait() })
210+ time.Sleep(500 * time.Millisecond)
211+ const tcpClient = `
212+import socket,sys
213+s=socket.socket(); s.settimeout(2)
214+try:
215+ s.connect((sys.argv[1],int(sys.argv[2]))); print(s.recv(10).decode())
216+except Exception: print("closed")
217+`
218+ tcp := func(n, dst string, port int) string {
219+ return in(n, "python3", "-c", tcpClient, dst, fmt.Sprint(port))
220+ }
221+ if got := tcp("a", gA.Gateway.String(), 8088); got != "hello" {
222+ t.Errorf("guest A cannot reach its gateway on the forge port: %s", got)
223+ }
224+ if got := tcp("a", gA.Gateway.String(), 8089); got != "closed" {
225+ t.Errorf("guest A reached its gateway on another port: %s", got)
226+ }
227+ if got := tcp("a", gB.Gateway.String(), 8088); got != "closed" {
228+ t.Errorf("guest A reached guest B's gateway on the forge port: %s", got)
229+ }
230+ if got := tcp("a", "203.0.114.1", 8088); got != "closed" {
231+ t.Errorf("guest A reached the host's LAN address on the forge port: %s", got)
232+ }
233+ if got := tcp("b", gB.Gateway.String(), 8088); got != "closed" {
234+ t.Errorf("guest B, without the exception, reached its gateway: %s", got)
235+ }
236+ if got := tcp("b", gA.Gateway.String(), 8088); got != "closed" {
237+ t.Errorf("guest B reached guest A's gateway on the forge port: %s", got)
238+ }
239+
191 out := seen()240 out := seen()
192 for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} {241 for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} {
193 if strings.Contains(out, bad) {242 if strings.Contains(out, bad) {
M internal/netpolicy/netpolicy.go +14 -0
@@ -12,6 +12,10 @@
1212 // address of that guest, where the runner's DNS forwarder listens; every
1313 // other packet is dropped, and port 53 of a gateway is closed to every
1414 // other interface;
15+// - optionally (Guest.ForgePort, the opt-in guest forge access, staging
16+// only): TCP from the guest's source address to its own gateway address on
17+// that one port. The host's own listener on that port receives the traffic;
18+// nothing is proxied, and every other host port stays closed;
1519 // - IPv6 from the guest is dropped (the guest also disables IPv6);
1620 // - forwarded traffic must carry the guest's own source address;
1721 // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays),
@@ -73,6 +77,11 @@ type Guest struct {
7377 Iface string // host-side interface: TAP or bridge name
7478 Gateway netip.Addr // host address on Iface; the DNS forwarder listens here
7579 Addr netip.Addr // the guest's only allowed source address
80+ // ForgePort, when not 0, opens TCP from Addr to Gateway on that one port
81+ // (guest forge access). The accept is in the input chain, scoped to this
82+ // guest's interface, source and gateway, so it neither reaches other
83+ // addresses of the host nor other guests.
84+ ForgePort uint16
7685 }
7786
7887 // Validate checks the fields used in generated rules.
@@ -155,6 +164,11 @@ func Ruleset(guests []Guest) string {
155164 for _, proto := range []string{"udp", "tcp"} {
156165 fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto)
157166 }
167+ if g.ForgePort != 0 {
168+ // New connections only: the replies leave through the output hook,
169+ // which this table does not touch.
170+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.ForgePort)
171+ }
158172 fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface)
159173 }
160174 // An interface with our prefix that no live guest owns (a leftover) is
@@ -12,6 +12,10 @@
12 // address of that guest, where the runner's DNS forwarder listens; every12 // address of that guest, where the runner's DNS forwarder listens; every
13 // other packet is dropped, and port 53 of a gateway is closed to every13 // other packet is dropped, and port 53 of a gateway is closed to every
14 // other interface;14 // other interface;
15+// - optionally (Guest.ForgePort, the opt-in guest forge access, staging
16+// only): TCP from the guest's source address to its own gateway address on
17+// that one port. The host's own listener on that port receives the traffic;
18+// nothing is proxied, and every other host port stays closed;
15 // - IPv6 from the guest is dropped (the guest also disables IPv6);19 // - IPv6 from the guest is dropped (the guest also disables IPv6);
16 // - forwarded traffic must carry the guest's own source address;20 // - forwarded traffic must carry the guest's own source address;
17 // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays),21 // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays),
@@ -73,6 +77,11 @@ type Guest struct {
73 Iface string // host-side interface: TAP or bridge name77 Iface string // host-side interface: TAP or bridge name
74 Gateway netip.Addr // host address on Iface; the DNS forwarder listens here78 Gateway netip.Addr // host address on Iface; the DNS forwarder listens here
75 Addr netip.Addr // the guest's only allowed source address79 Addr netip.Addr // the guest's only allowed source address
80+ // ForgePort, when not 0, opens TCP from Addr to Gateway on that one port
81+ // (guest forge access). The accept is in the input chain, scoped to this
82+ // guest's interface, source and gateway, so it neither reaches other
83+ // addresses of the host nor other guests.
84+ ForgePort uint16
76 }85 }
77 86
78 // Validate checks the fields used in generated rules.87 // Validate checks the fields used in generated rules.
@@ -155,6 +164,11 @@ func Ruleset(guests []Guest) string {
155 for _, proto := range []string{"udp", "tcp"} {164 for _, proto := range []string{"udp", "tcp"} {
156 fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto)165 fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto)
157 }166 }
167+ if g.ForgePort != 0 {
168+ // New connections only: the replies leave through the output hook,
169+ // which this table does not touch.
170+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.ForgePort)
171+ }
158 fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface)172 fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface)
159 }173 }
160 // An interface with our prefix that no live guest owns (a leftover) is174 // An interface with our prefix that no live guest owns (a leftover) is
M internal/netpolicy/netpolicy_test.go +60 -0
@@ -232,3 +232,63 @@ func TestManagerRollsBackOnError(t *testing.T) {
232232 }
233233
234234 var _ io.Reader = strings.NewReader("")
235+
236+func forgeGuest(t *testing.T, iface string, slot int, port uint16) Guest {
237+ t.Helper()
238+ g := guest(t, iface, slot)
239+ g.ForgePort = port
240+ return g
241+}
242+
243+func TestRulesetGuestForgeGolden(t *testing.T) {
244+ golden(t, "guest_forge.nft", Ruleset([]Guest{forgeGuest(t, "ertap0", 0, 8088)}))
245+}
246+
247+// Without ForgePort the ruleset has no TCP rule beyond DNS: the exception is
248+// opt-in (and one.nft, the golden file of that case, is unchanged).
249+func TestRulesetWithoutForgePortHasNoForgeRule(t *testing.T) {
250+ rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
251+ if n, dns := strings.Count(rs, "tcp dport"), strings.Count(rs, "tcp dport 53 "); n != dns {
252+ t.Fatalf("%d tcp port rules, %d of them for DNS:\n%s", n, dns, rs)
253+ }
254+}
255+
256+// The exception is one TCP port towards the guest's own gateway, from its own
257+// address and interface, placed before the guest's catch-all drop; a second
258+// guest gets its own, never the other's gateway; nothing else opens, and the
259+// forward chain (private ranges, source checks, sandbox-to-sandbox) is the
260+// same with and without it.
261+func TestGuestForgeRuleScopeAndOrder(t *testing.T) {
262+ gs := []Guest{forgeGuest(t, "ertap0", 0, 8088), forgeGuest(t, "erbr1", 1, 8088), guest(t, "ertap2", 2)}
263+ rs := Ruleset(gs)
264+ in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")]
265+ r0 := `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept`
266+ r1 := `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8088 accept`
267+ for _, want := range []string{r0, r1} {
268+ if strings.Count(in, want) != 1 {
269+ t.Errorf("want exactly one %q in the input chain:\n%s", want, in)
270+ }
271+ }
272+ if strings.Count(rs, "8088") != 2 {
273+ t.Errorf("the port appears %d times, want 2 (one per guest with the exception):\n%s", strings.Count(rs, "8088"), rs)
274+ }
275+ if strings.Index(in, r0) > strings.Index(in, `iifname "ertap0" drop`) || strings.Index(in, r1) > strings.Index(in, `iifname "erbr1" drop`) {
276+ t.Error("the forge accept must precede the guest's catch-all drop")
277+ }
278+ // The guest without the exception has none.
279+ if strings.Contains(in, `iifname "ertap2" ip saddr 10.200.0.10 ip daddr 10.200.0.9 tcp dport 8088`) {
280+ t.Error("ertap2 got a forge rule")
281+ }
282+ // The forward and postrouting chains do not depend on the exception.
283+ plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)})
284+ tail := func(s string) string { return s[strings.Index(s, "chain forward"):] }
285+ if tail(rs) != tail(plain) {
286+ t.Error("the forward/postrouting chains changed with the exception")
287+ }
288+ // Every accept of the input chain that is not DNS is one of the two.
289+ for _, l := range strings.Split(in, "\n") {
290+ if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") {
291+ t.Errorf("unexpected accept: %s", l)
292+ }
293+ }
294+}
@@ -232,3 +232,63 @@ func TestManagerRollsBackOnError(t *testing.T) {
232 }232 }
233 233
234 var _ io.Reader = strings.NewReader("")234 var _ io.Reader = strings.NewReader("")
235+
236+func forgeGuest(t *testing.T, iface string, slot int, port uint16) Guest {
237+ t.Helper()
238+ g := guest(t, iface, slot)
239+ g.ForgePort = port
240+ return g
241+}
242+
243+func TestRulesetGuestForgeGolden(t *testing.T) {
244+ golden(t, "guest_forge.nft", Ruleset([]Guest{forgeGuest(t, "ertap0", 0, 8088)}))
245+}
246+
247+// Without ForgePort the ruleset has no TCP rule beyond DNS: the exception is
248+// opt-in (and one.nft, the golden file of that case, is unchanged).
249+func TestRulesetWithoutForgePortHasNoForgeRule(t *testing.T) {
250+ rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
251+ if n, dns := strings.Count(rs, "tcp dport"), strings.Count(rs, "tcp dport 53 "); n != dns {
252+ t.Fatalf("%d tcp port rules, %d of them for DNS:\n%s", n, dns, rs)
253+ }
254+}
255+
256+// The exception is one TCP port towards the guest's own gateway, from its own
257+// address and interface, placed before the guest's catch-all drop; a second
258+// guest gets its own, never the other's gateway; nothing else opens, and the
259+// forward chain (private ranges, source checks, sandbox-to-sandbox) is the
260+// same with and without it.
261+func TestGuestForgeRuleScopeAndOrder(t *testing.T) {
262+ gs := []Guest{forgeGuest(t, "ertap0", 0, 8088), forgeGuest(t, "erbr1", 1, 8088), guest(t, "ertap2", 2)}
263+ rs := Ruleset(gs)
264+ in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")]
265+ r0 := `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept`
266+ r1 := `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8088 accept`
267+ for _, want := range []string{r0, r1} {
268+ if strings.Count(in, want) != 1 {
269+ t.Errorf("want exactly one %q in the input chain:\n%s", want, in)
270+ }
271+ }
272+ if strings.Count(rs, "8088") != 2 {
273+ t.Errorf("the port appears %d times, want 2 (one per guest with the exception):\n%s", strings.Count(rs, "8088"), rs)
274+ }
275+ if strings.Index(in, r0) > strings.Index(in, `iifname "ertap0" drop`) || strings.Index(in, r1) > strings.Index(in, `iifname "erbr1" drop`) {
276+ t.Error("the forge accept must precede the guest's catch-all drop")
277+ }
278+ // The guest without the exception has none.
279+ if strings.Contains(in, `iifname "ertap2" ip saddr 10.200.0.10 ip daddr 10.200.0.9 tcp dport 8088`) {
280+ t.Error("ertap2 got a forge rule")
281+ }
282+ // The forward and postrouting chains do not depend on the exception.
283+ plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)})
284+ tail := func(s string) string { return s[strings.Index(s, "chain forward"):] }
285+ if tail(rs) != tail(plain) {
286+ t.Error("the forward/postrouting chains changed with the exception")
287+ }
288+ // Every accept of the input chain that is not DNS is one of the two.
289+ for _, l := range strings.Split(in, "\n") {
290+ if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") {
291+ t.Errorf("unexpected accept: %s", l)
292+ }
293+ }
294+}
A internal/netpolicy/testdata/guest_forge.nft +39 -0
@@ -0,0 +1,39 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
3+table inet ermite_runner {
4+ set blocked_v4 {
5+ type ipv4_addr
6+ flags interval
7+ elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 }
8+ }
9+ chain input {
10+ type filter hook input priority filter; policy accept;
11+ iifname "ertap*" meta nfproto ipv6 drop
12+ iifname "erbr*" meta nfproto ipv6 drop
13+ ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop
14+ ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop
15+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept
16+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept
17+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept
18+ iifname "ertap0" drop
19+ iifname "ertap*" drop
20+ iifname "erbr*" drop
21+ }
22+ chain forward {
23+ type filter hook forward priority filter; policy accept;
24+ iifname "ertap*" meta nfproto ipv6 drop
25+ iifname "erbr*" meta nfproto ipv6 drop
26+ iifname "ertap0" ip saddr != 10.200.0.2 drop
27+ iifname "ertap0" ip daddr @blocked_v4 drop
28+ iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept
29+ oifname "ertap*" drop
30+ oifname "erbr*" drop
31+ iifname "ertap0" accept
32+ iifname "ertap*" drop
33+ iifname "erbr*" drop
34+ }
35+ chain postrouting {
36+ type nat hook postrouting priority srcnat; policy accept;
37+ ip saddr 10.200.0.2 oifname != "ertap0" masquerade
38+ }
39+}
@@ -0,0 +1,39 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
3+table inet ermite_runner {
4+ set blocked_v4 {
5+ type ipv4_addr
6+ flags interval
7+ elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 }
8+ }
9+ chain input {
10+ type filter hook input priority filter; policy accept;
11+ iifname "ertap*" meta nfproto ipv6 drop
12+ iifname "erbr*" meta nfproto ipv6 drop
13+ ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop
14+ ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop
15+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept
16+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept
17+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept
18+ iifname "ertap0" drop
19+ iifname "ertap*" drop
20+ iifname "erbr*" drop
21+ }
22+ chain forward {
23+ type filter hook forward priority filter; policy accept;
24+ iifname "ertap*" meta nfproto ipv6 drop
25+ iifname "erbr*" meta nfproto ipv6 drop
26+ iifname "ertap0" ip saddr != 10.200.0.2 drop
27+ iifname "ertap0" ip daddr @blocked_v4 drop
28+ iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept
29+ oifname "ertap*" drop
30+ oifname "erbr*" drop
31+ iifname "ertap0" accept
32+ iifname "ertap*" drop
33+ iifname "erbr*" drop
34+ }
35+ chain postrouting {
36+ type nat hook postrouting priority srcnat; policy accept;
37+ ip saddr 10.200.0.2 oifname != "ertap0" masquerade
38+ }
39+}

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