From 9f7761b03dbb28c2c5a7e629f3aa3fe4f899db70 Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Sat, 27 Jun 2026 21:25:53 +0530 Subject: [PATCH] fix(network): match each ip-range to its subnet for dual-stack network create applied a single --ip-range to every subnet, so on a dual-stack network the IPv4 range was checked against the IPv6 subnet and creation failed with "no matching subnet". Accept --ip-range more than once and match each range to the subnet that contains it, the same way --gateway is handled. Signed-off-by: Mayur Das --- cmd/nerdctl/network/network_create.go | 6 +- .../network/network_create_linux_test.go | 35 +++++++++ pkg/api/types/network_types.go | 2 +- pkg/cmd/network/create.go | 2 +- pkg/netutil/netutil_unix.go | 72 ++++++++++++++----- pkg/netutil/netutil_unix_test.go | 54 ++++++++++++++ pkg/netutil/netutil_windows.go | 8 ++- 7 files changed, 156 insertions(+), 23 deletions(-) diff --git a/cmd/nerdctl/network/network_create.go b/cmd/nerdctl/network/network_create.go index 7d6c02cb123..f8b5dc85822 100644 --- a/cmd/nerdctl/network/network_create.go +++ b/cmd/nerdctl/network/network_create.go @@ -48,7 +48,7 @@ func createCommand() *cobra.Command { cmd.Flags().StringArray("ipam-opt", nil, "Set IPAM driver specific options") cmd.Flags().StringArray("subnet", nil, `Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"`) cmd.Flags().StringArray("gateway", nil, "IPv4 or IPv6 Gateway for the master subnet") - cmd.Flags().String("ip-range", "", `Allocate container ip from a sub-range`) + cmd.Flags().StringArray("ip-range", nil, `Allocate container ip from a sub-range`) cmd.Flags().StringArray("label", nil, "Set metadata for a network") cmd.Flags().Bool("ipv6", false, "Enable IPv6 networking") cmd.Flags().Bool("internal", false, "Restrict external access to the network") @@ -88,7 +88,7 @@ func createAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - ipRangeStr, err := cmd.Flags().GetString("ip-range") + ipRanges, err := cmd.Flags().GetStringArray("ip-range") if err != nil { return err } @@ -115,7 +115,7 @@ func createAction(cmd *cobra.Command, args []string) error { IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts), Subnets: subnets, Gateway: gateways, - IPRange: ipRangeStr, + IPRange: ipRanges, Labels: labels, IPv6: ipv6, Internal: internal, diff --git a/cmd/nerdctl/network/network_create_linux_test.go b/cmd/nerdctl/network/network_create_linux_test.go index e43bbddf983..76992c85c47 100644 --- a/cmd/nerdctl/network/network_create_linux_test.go +++ b/cmd/nerdctl/network/network_create_linux_test.go @@ -144,6 +144,41 @@ func TestNetworkCreate(t *testing.T) { } }, }, + { + Description: "dual-stack with explicit ip-ranges", + Require: nerdtest.OnlyIPv6, + Setup: func(data test.Data, helpers test.Helpers) { + // Before the fix the IPv4 ip-range was checked against the IPv6 + // subnet and creation failed. + helpers.Ensure("network", "create", data.Identifier(), + "--ipv6", + "--subnet", "10.6.0.0/16", + "--subnet", "2001:db8:6::/64", + "--ip-range", "10.6.1.0/24", + "--ip-range", "2001:db8:6::/80", + ) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: func(stdout string, t tig.T) { + netw := nerdtest.InspectNetwork(helpers, data.Identifier()) + ranges := map[string]string{} + for _, c := range netw.IPAM.Config { + ranges[c.Subnet] = c.IPRange + } + assert.Equal(t, ranges["10.6.0.0/16"], "10.6.1.0/24") + assert.Equal(t, ranges["2001:db8:6::/64"], "2001:db8:6::/80") + }, + } + }, + }, { Description: "internal enabled", Setup: func(data test.Data, helpers test.Helpers) { diff --git a/pkg/api/types/network_types.go b/pkg/api/types/network_types.go index 17ca78a4cf6..a2df90ecf84 100644 --- a/pkg/api/types/network_types.go +++ b/pkg/api/types/network_types.go @@ -32,7 +32,7 @@ type NetworkCreateOptions struct { IPAMOptions map[string]string Subnets []string Gateway []string - IPRange string + IPRange []string Labels []string IPv6 bool Internal bool diff --git a/pkg/cmd/network/create.go b/pkg/cmd/network/create.go index 69f4b80a2c8..89c48cc0db9 100644 --- a/pkg/cmd/network/create.go +++ b/pkg/cmd/network/create.go @@ -28,7 +28,7 @@ import ( func Create(options types.NetworkCreateOptions, stdout io.Writer) error { if len(options.Subnets) == 0 { - if len(options.Gateway) > 0 || options.IPRange != "" { + if len(options.Gateway) > 0 || len(options.IPRange) > 0 { return fmt.Errorf("cannot set gateway or ip-range without subnet, specify --subnet manually") } options.Subnets = []string{""} diff --git a/pkg/netutil/netutil_unix.go b/pkg/netutil/netutil_unix.go index 4a5d1c0c50f..a3cd38c6f93 100644 --- a/pkg/netutil/netutil_unix.go +++ b/pkg/netutil/netutil_unix.go @@ -215,7 +215,7 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRanges []string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { var ipamConfig interface{} switch driver { case "default", "host-local": @@ -225,14 +225,15 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string {Dst: "0.0.0.0/0"}, } } - ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRangeStr, ipv6) + ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRanges, ipv6) if err != nil { return nil, err } ipamConf.Ranges = append(ipamConf.Ranges, ranges...) if !findIPv4 { - // The default IPv4 range uses a computed gateway; pass none. - ranges, _, _ = e.parseIPAMRanges([]string{""}, nil, ipRangeStr, ipv6) + // The default IPv4 range uses a computed gateway and no ip-range; + // any user-supplied gateway or ip-range belongs to an explicit subnet. + ranges, _, _ = e.parseIPAMRanges([]string{""}, nil, nil, ipv6) ipamConf.Ranges = append(ipamConf.Ranges, ranges...) } ipamConfig = ipamConf @@ -289,7 +290,25 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string return ipam, nil } -func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange string, ipv6 bool) ([][]IPAMRange, bool, error) { +func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRanges []string, ipv6 bool) ([][]IPAMRange, bool, error) { + // Resolve every requested subnet first; parseSubnet also rejects overlaps + // with existing networks. The pairing below then works purely on the parsed + // CIDRs, so it can be unit-tested without probing the host's networks. + parsedSubnets := make([]*net.IPNet, len(subnets)) + for i := range subnets { + subnet, err := e.parseSubnet(subnets[i]) + if err != nil { + return nil, false, err + } + parsedSubnets[i] = subnet + } + return pairIPAMRanges(parsedSubnets, gateways, ipRanges, ipv6) +} + +// pairIPAMRanges matches each gateway and ip-range to the subnet that contains +// it and builds the per-subnet IPAM ranges. It is split out from subnet +// resolution so the matching can be tested without touching live networks. +func pairIPAMRanges(subnets []*net.IPNet, gateways []string, ipRanges []string, ipv6 bool) ([][]IPAMRange, bool, error) { // Parse the gateways once up front; matching them to subnets below is then // just a containment check, with no parse error mixed into the loop. parsedGateways := make([]net.IP, len(gateways)) @@ -300,15 +319,22 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st } parsedGateways[i] = gw } + // Parse the ip-ranges the same way, keyed by network so each can be matched + // to the subnet that contains it. + parsedRanges := make([]*net.IPNet, len(ipRanges)) + for i, r := range ipRanges { + _, ipNet, err := net.ParseCIDR(r) + if err != nil { + return nil, false, fmt.Errorf("failed to parse ip-range %q", r) + } + parsedRanges[i] = ipNet + } findIPv4 := false ranges := make([][]IPAMRange, 0, len(subnets)) - used := make([]bool, len(gateways)) - for i := range subnets { - subnet, err := e.parseSubnet(subnets[i]) - if err != nil { - return nil, findIPv4, err - } + usedGateways := make([]bool, len(gateways)) + usedRanges := make([]bool, len(ipRanges)) + for _, subnet := range subnets { // if ipv6 flag is not set, subnets of ipv6 should be excluded if !ipv6 && subnet.IP.To4() == nil { continue @@ -320,8 +346,17 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st // the v4 gateway to the v4 subnet and v6 to v6. gateway := "" for j, gw := range parsedGateways { - if !used[j] && subnet.Contains(gw) { - gateway, used[j] = gateways[j], true + if !usedGateways[j] && subnet.Contains(gw) { + gateway, usedGateways[j] = gateways[j], true + break + } + } + // Pair the subnet with the ip-range it contains, the same way, so a + // dual-stack network does not check the v4 range against the v6 subnet. + ipRange := "" + for j, r := range parsedRanges { + if !usedRanges[j] && subnet.Contains(r.IP) { + ipRange, usedRanges[j] = ipRanges[j], true break } } @@ -331,13 +366,18 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st } ranges = append(ranges, []IPAMRange{*ipamRange}) } - // Only known after every subnet is seen: a gateway that matched none is a - // user error, same as Docker. - for j, ok := range used { + // Only known after every subnet is seen: a gateway or ip-range that matched + // none is a user error, same as Docker. + for j, ok := range usedGateways { if !ok { return nil, findIPv4, fmt.Errorf("no matching subnet for gateway %q", gateways[j]) } } + for j, ok := range usedRanges { + if !ok { + return nil, findIPv4, fmt.Errorf("no matching subnet for ip-range %q", ipRanges[j]) + } + } return ranges, findIPv4, nil } diff --git a/pkg/netutil/netutil_unix_test.go b/pkg/netutil/netutil_unix_test.go index 5a2d66d4451..142a3e36b71 100644 --- a/pkg/netutil/netutil_unix_test.go +++ b/pkg/netutil/netutil_unix_test.go @@ -19,6 +19,7 @@ package netutil import ( + "net" "testing" "github.com/Masterminds/semver/v3" @@ -69,3 +70,56 @@ func TestGuessFirewallPluginVersion(t *testing.T) { } } } + +// TestPairIPAMRangesIPRange covers matching repeatable --ip-range values to the +// subnet that contains each, and the errors for an unmatched or malformed range. +func TestPairIPAMRangesIPRange(t *testing.T) { + t.Parallel() + // parse turns the CIDR strings into the already-resolved subnets that + // pairIPAMRanges takes, keeping each subtest readable. + parse := func(t *testing.T, cidrs ...string) []*net.IPNet { + t.Helper() + subnets := make([]*net.IPNet, len(cidrs)) + for i, c := range cidrs { + _, n, err := net.ParseCIDR(c) + assert.NilError(t, err) + subnets[i] = n + } + return subnets + } + + t.Run("each ip-range pairs with its subnet regardless of order", func(t *testing.T) { + subnets := parse(t, "10.6.0.0/16", "2001:db8:6::/64") + // Given v6-first to prove the pairing is by containment, not by index. + ipRanges := []string{"2001:db8:6::/80", "10.6.1.0/24"} + ranges, findIPv4, err := pairIPAMRanges(subnets, nil, ipRanges, true) + assert.NilError(t, err) + assert.Equal(t, true, findIPv4) + got := map[string]string{} + for _, r := range ranges { + got[r[0].Subnet] = r[0].IPRange + } + assert.Equal(t, "10.6.1.0/24", got["10.6.0.0/16"]) + assert.Equal(t, "2001:db8:6::/80", got["2001:db8:6::/64"]) + }) + + t.Run("an ip-range matching no subnet errors", func(t *testing.T) { + _, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"192.168.1.0/24"}, false) + assert.ErrorContains(t, err, `no matching subnet for ip-range "192.168.1.0/24"`) + }) + + t.Run("an IPv4 ip-range with only an IPv6 subnet errors", func(t *testing.T) { + _, _, err := pairIPAMRanges(parse(t, "2001:db8:6::/64"), nil, []string{"10.6.1.0/24"}, true) + assert.ErrorContains(t, err, `no matching subnet for ip-range "10.6.1.0/24"`) + }) + + t.Run("a second ip-range claiming the same subnet errors", func(t *testing.T) { + _, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"10.6.1.0/24", "10.6.2.0/24"}, false) + assert.ErrorContains(t, err, `no matching subnet for ip-range "10.6.2.0/24"`) + }) + + t.Run("a malformed ip-range errors", func(t *testing.T) { + _, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"bogus"}, false) + assert.ErrorContains(t, err, `failed to parse ip-range "bogus"`) + }) +} diff --git a/pkg/netutil/netutil_windows.go b/pkg/netutil/netutil_windows.go index 71d9a76ce6c..5d204c4161d 100644 --- a/pkg/netutil/netutil_windows.go +++ b/pkg/netutil/netutil_windows.go @@ -72,18 +72,22 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRanges []string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { switch driver { case "default": default: return nil, fmt.Errorf("unsupported ipam driver %q", driver) } - // Windows is single-subnet, so use at most one gateway. + // Windows is single-subnet, so use at most one gateway and one ip-range. gatewayStr := "" if len(gateways) > 0 { gatewayStr = gateways[0] } + ipRangeStr := "" + if len(ipRanges) > 0 { + ipRangeStr = ipRanges[0] + } ipamConfig := newWindowsIPAMConfig() subnet, err := e.parseSubnet(subnets[0])