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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 72 | 72 | ||
| 73 | 73 | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | |
| 74 | 74 | 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 | 76 | for _, g := range []Guest{gA, gB} { | |
| 76 | 77 | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | |
| 77 | 78 | sh("ip", "link", "set", g.Iface, "up") | |
| @@ -188,6 +189,54 @@ except Exception: pass | |||
| 188 | 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 | 240 | out := seen() | |
| 192 | 241 | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | |
| 193 | 242 | 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 / .2 | 73 | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | ||
| 74 | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | 74 | 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 @@ | |||
| 12 | 12 | // address of that guest, where the runner's DNS forwarder listens; every | |
| 13 | 13 | // other packet is dropped, and port 53 of a gateway is closed to every | |
| 14 | 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 | 19 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | |
| 16 | 20 | // - forwarded traffic must carry the guest's own source address; | |
| 17 | 21 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | |
| @@ -73,6 +77,11 @@ type Guest struct { | |||
| 73 | 77 | Iface string // host-side interface: TAP or bridge name | |
| 74 | 78 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | |
| 75 | 79 | 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 | 87 | // Validate checks the fields used in generated rules. | |
| @@ -155,6 +164,11 @@ func Ruleset(guests []Guest) string { | |||
| 155 | 164 | for _, proto := range []string{"udp", "tcp"} { | |
| 156 | 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 | 172 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | |
| 159 | 173 | } | |
| 160 | 174 | // 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; every | 12 | // 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 every | 13 | // 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 name | 77 | Iface string // host-side interface: TAP or bridge name | ||
| 74 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | 78 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | ||
| 75 | Addr netip.Addr // the guest's only allowed source address | 79 | 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) is | 174 | // 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) { | |||
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 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 | + | } | |
| @@ -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 | + | } | |||