|
| 1 | +//go:build linux |
| 2 | + |
| 3 | +package nftabler |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "fmt" |
| 8 | + "net" |
| 9 | + "net/netip" |
| 10 | + "testing" |
| 11 | + |
| 12 | + "github.com/docker/docker/internal/testutils/netnsutils" |
| 13 | + "github.com/docker/docker/libnetwork/drivers/bridge/internal/firewaller" |
| 14 | + "github.com/docker/docker/libnetwork/internal/nftables" |
| 15 | + "github.com/docker/docker/libnetwork/types" |
| 16 | + "gotest.tools/v3/assert" |
| 17 | + is "gotest.tools/v3/assert/cmp" |
| 18 | + "gotest.tools/v3/golden" |
| 19 | + "gotest.tools/v3/icmd" |
| 20 | +) |
| 21 | + |
| 22 | +func TestNftabler(t *testing.T) { |
| 23 | + const ( |
| 24 | + ipv4 uint64 = 1 << iota |
| 25 | + ipv6 |
| 26 | + hairpin |
| 27 | + internal |
| 28 | + icc |
| 29 | + masq |
| 30 | + snat |
| 31 | + bindLocalhost |
| 32 | + wsl2Mirrored |
| 33 | + numBoolParams = iota |
| 34 | + ) |
| 35 | + nftables.Enable() |
| 36 | + t.Cleanup(func() { nftables.Disable() }) // Cleanup instead of defer, this func returns before the parallel subtests finish. |
| 37 | + for i := range uint64(1) << numBoolParams { |
| 38 | + p := func(n uint64) bool { return (i & n) == n } |
| 39 | + for _, gwmode := range []string{"nat", "nat-unprotected", "routed"} { |
| 40 | + config := firewaller.Config{ |
| 41 | + IPv4: p(ipv4), |
| 42 | + IPv6: p(ipv6), |
| 43 | + Hairpin: p(hairpin), |
| 44 | + WSL2Mirrored: p(wsl2Mirrored), |
| 45 | + } |
| 46 | + netConfig := firewaller.NetworkConfig{ |
| 47 | + IfName: "br-dummy", |
| 48 | + Internal: p(internal), |
| 49 | + ICC: p(icc), |
| 50 | + Masquerade: p(masq), |
| 51 | + Config4: firewaller.NetworkConfigFam{ |
| 52 | + HostIP: netip.Addr{}, |
| 53 | + Prefix: netip.MustParsePrefix("192.168.0.0/24"), |
| 54 | + Routed: gwmode == "routed", |
| 55 | + Unprotected: gwmode == "nat-unprotected", |
| 56 | + }, |
| 57 | + Config6: firewaller.NetworkConfigFam{ |
| 58 | + HostIP: netip.Addr{}, |
| 59 | + Prefix: netip.MustParsePrefix("fd49:efd7:54aa::/64"), |
| 60 | + Routed: gwmode == "routed", |
| 61 | + Unprotected: gwmode == "nat-unprotected", |
| 62 | + }, |
| 63 | + } |
| 64 | + if p(snat) { |
| 65 | + netConfig.Config4.HostIP = netip.MustParseAddr("192.168.123.0") |
| 66 | + netConfig.Config6.HostIP = netip.MustParseAddr("fd34:d0d4:672f::123") |
| 67 | + } |
| 68 | + tn := t.Name() |
| 69 | + t.Run(fmt.Sprintf("ipv4=%v/ipv6=%v/hairpin=%v/internal=%v/icc=%v/masq=%v/snat=%v/gwm=%v/bindlh=%v/wsl2mirrored=%v", |
| 70 | + p(ipv4), p(ipv6), p(hairpin), p(internal), p(icc), p(masq), p(snat), gwmode, p(bindLocalhost), p(wsl2Mirrored)), func(t *testing.T) { |
| 71 | + // If updating results, don't run in parallel because some of the results files are shared. |
| 72 | + if !golden.FlagUpdate() { |
| 73 | + t.Parallel() |
| 74 | + } |
| 75 | + // Combine results (golden output files) where possible to: |
| 76 | + // - check params that should have no effect when made irrelevant by other params, and |
| 77 | + // - minimise the number of results files. |
| 78 | + var resName string |
| 79 | + if p(internal) { |
| 80 | + // Port binding params should have no effect on an internal network. |
| 81 | + resName = fmt.Sprintf("hairpin=%v,internal=true,icc=%v", p(hairpin), p(icc)) |
| 82 | + } else { |
| 83 | + resName = fmt.Sprintf("hairpin=%v,internal=%v,icc=%v,masq=%v,snat=%v,gwm=%v,bindlh=%v", |
| 84 | + p(hairpin), p(internal), p(icc), p(masq), p(snat), gwmode, p(bindLocalhost)) |
| 85 | + } |
| 86 | + testNftabler(t, tn, config, netConfig, p(bindLocalhost), tn+"_"+resName) |
| 87 | + }) |
| 88 | + } |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +func testNftabler(t *testing.T, tn string, config firewaller.Config, netConfig firewaller.NetworkConfig, bindLocalhost bool, resName string) { |
| 93 | + defer netnsutils.SetupTestOSContext(t)() |
| 94 | + |
| 95 | + checkResults := func(family, name string, en bool) { |
| 96 | + t.Helper() |
| 97 | + res := icmd.RunCommand("nft", "list", "table", family, dockerTable) |
| 98 | + if !en { |
| 99 | + assert.Assert(t, is.Contains(res.Combined(), "No such file or directory")) |
| 100 | + return |
| 101 | + } |
| 102 | + assert.Assert(t, res.Error) |
| 103 | + golden.Assert(t, res.Combined(), name+"__"+family+".golden") |
| 104 | + } |
| 105 | + |
| 106 | + makePB := func(hip string, cip netip.Addr) types.PortBinding { |
| 107 | + return types.PortBinding{ |
| 108 | + Proto: types.TCP, |
| 109 | + IP: cip.AsSlice(), |
| 110 | + Port: 80, |
| 111 | + HostIP: net.ParseIP(hip), |
| 112 | + HostPort: 8080, |
| 113 | + HostPortEnd: 8080, |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // WSL2Mirrored should only affect IPv4 results, and only if there's a port binding |
| 118 | + // to a loopback address or docker-proxy is disabled. Share other results files. |
| 119 | + rnWSL2Mirrored := func(resName string) string { |
| 120 | + if config.IPv4 && config.WSL2Mirrored && (bindLocalhost || !config.Hairpin) { |
| 121 | + return resName + ",wsl2mirrored=true" |
| 122 | + } |
| 123 | + return resName |
| 124 | + } |
| 125 | + |
| 126 | + // Initialise iptables, check the iptables config looks like it should look at the |
| 127 | + // end of the test (after deleting per-network and per-port rules). |
| 128 | + fw, err := NewNftabler(context.Background(), config) |
| 129 | + assert.NilError(t, err) |
| 130 | + checkResults("ip", rnWSL2Mirrored(fmt.Sprintf("%s_cleaned,hairpin=%v", tn, config.Hairpin)), config.IPv4) |
| 131 | + checkResults("ip6", fmt.Sprintf("%s_cleaned,hairpin=%v", tn, config.Hairpin), config.IPv6) |
| 132 | + |
| 133 | + // Add the network. |
| 134 | + nw, err := fw.NewNetwork(context.Background(), netConfig) |
| 135 | + assert.NilError(t, err) |
| 136 | + |
| 137 | + // Add an endpoint. |
| 138 | + epAddr4 := netip.MustParseAddr("192.168.0.2") |
| 139 | + epAddr6 := netip.MustParseAddr("fd49:efd7:54aa::1") |
| 140 | + err = nw.AddEndpoint(context.Background(), epAddr4, epAddr6) |
| 141 | + assert.NilError(t, err) |
| 142 | + |
| 143 | + // Add IPv4 and IPv6 port mappings. |
| 144 | + var pb4, pb6 types.PortBinding |
| 145 | + if bindLocalhost { |
| 146 | + pb4 = makePB("127.0.0.1", epAddr4) |
| 147 | + pb6 = makePB("::1", epAddr6) |
| 148 | + } else { |
| 149 | + pb4 = makePB("0.0.0.0", epAddr4) |
| 150 | + pb6 = makePB("::", epAddr6) |
| 151 | + } |
| 152 | + err = nw.AddPorts(context.Background(), []types.PortBinding{pb4, pb6}) |
| 153 | + assert.NilError(t, err) |
| 154 | + |
| 155 | + // Check the resulting iptables config. |
| 156 | + checkResults("ip", rnWSL2Mirrored(resName), config.IPv4) |
| 157 | + checkResults("ip6", resName, config.IPv6) |
| 158 | + |
| 159 | + // Remove the port mappings and the network, and check the result (should be the same |
| 160 | + // for all tests with the same "hairpin" setting). |
| 161 | + err = nw.DelPorts(context.Background(), []types.PortBinding{pb4, pb6}) |
| 162 | + assert.NilError(t, err) |
| 163 | + err = nw.DelEndpoint(context.Background(), epAddr4, epAddr6) |
| 164 | + assert.NilError(t, err) |
| 165 | + err = nw.DelNetworkLevelRules(context.Background()) |
| 166 | + assert.NilError(t, err) |
| 167 | + checkResults("ip", rnWSL2Mirrored(fmt.Sprintf("%s_cleaned,hairpin=%v", tn, config.Hairpin)), config.IPv4) |
| 168 | + checkResults("ip6", fmt.Sprintf("%s_cleaned,hairpin=%v", tn, config.Hairpin), config.IPv6) |
| 169 | +} |
0 commit comments