From 3a84ba8553baeae2f20b4deee2d4bdbe56de04d9 Mon Sep 17 00:00:00 2001 From: Alexander Dreweke Date: Mon, 20 Jul 2026 16:36:03 +0200 Subject: [PATCH] common: add IPv6 subnet configuration options Currently, IPv6 subnets are always randomly generated from fd00::/8 (RFC 4193), with no way for operators to configure a stable, predictable ULA prefix. This makes it difficult to set up DNS exclude lists, firewall rules, and network documentation for containerized services. Add default_subnet_v6 and default_subnet_pools_v6 configuration options mirroring the existing IPv4 default_subnet and default_subnet_pools. When default_subnet_v6 is set, the default "podman" network is created as a dual-stack network with the configured IPv6 subnet. When default_subnet_pools_v6 is set, podman network create allocates IPv6 subnets sequentially from the configured pools instead of randomly. When neither option is set, existing random generation behavior is preserved for full backward compatibility. Fixes: #87 Signed-off-by: Alexander Dreweke --- common/libnetwork/internal/util/bridge.go | 4 +- common/libnetwork/internal/util/ip_test.go | 70 ++++++++++++++++++++++ common/libnetwork/internal/util/util.go | 35 ++++++++++- common/libnetwork/netavark/config.go | 2 + common/libnetwork/netavark/network.go | 39 ++++++++++-- common/pkg/config/config.go | 26 +++++++- common/pkg/config/config_local_test.go | 55 +++++++++++++++++ common/pkg/config/containers.conf | 19 +++++- 8 files changed, 237 insertions(+), 13 deletions(-) diff --git a/common/libnetwork/internal/util/bridge.go b/common/libnetwork/internal/util/bridge.go index f245fb016c..1e485c518b 100644 --- a/common/libnetwork/internal/util/bridge.go +++ b/common/libnetwork/internal/util/bridge.go @@ -10,7 +10,7 @@ import ( "go.podman.io/common/pkg/config" ) -func CreateBridge(n NetUtil, network *types.Network, usedNetworks []*net.IPNet, subnetPools []config.SubnetPool, checkBridgeConflict bool) error { +func CreateBridge(n NetUtil, network *types.Network, usedNetworks []*net.IPNet, subnetPools []config.SubnetPool, subnetPoolsV6 []config.SubnetPool, checkBridgeConflict bool) error { if network.NetworkInterface != "" { if checkBridgeConflict { bridges := GetBridgeInterfaceNames(n) @@ -60,7 +60,7 @@ func CreateBridge(n NetUtil, network *types.Network, usedNetworks []*net.IPNet, network.Subnets = append(network.Subnets, *freeSubnet) } if !ipv6 { - freeSubnet, err := GetFreeIPv6NetworkSubnet(usedNetworks) + freeSubnet, err := GetFreeIPv6NetworkSubnet(usedNetworks, subnetPoolsV6) if err != nil { return err } diff --git a/common/libnetwork/internal/util/ip_test.go b/common/libnetwork/internal/util/ip_test.go index 527463f76c..6d046997db 100644 --- a/common/libnetwork/internal/util/ip_test.go +++ b/common/libnetwork/internal/util/ip_test.go @@ -5,6 +5,9 @@ import ( "net" "reflect" "testing" + + "go.podman.io/common/libnetwork/types" + "go.podman.io/common/pkg/config" ) func parseCIDR(n string) *net.IPNet { @@ -52,6 +55,73 @@ func TestNextSubnet(t *testing.T) { } } +func parseV6Pool(base string, size int) config.SubnetPool { + _, parsedNet, _ := net.ParseCIDR(base) + return config.SubnetPool{ + Base: &types.IPNet{IPNet: *parsedNet}, + Size: size, + } +} + +func TestGetFreeIPv6SubnetFromPools(t *testing.T) { + t.Run("allocate from single pool", func(t *testing.T) { + pools := []config.SubnetPool{parseV6Pool("fd00::/48", 64)} + subnet, err := GetFreeIPv6NetworkSubnet(nil, pools) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ones, bits := subnet.Subnet.Mask.Size() + if ones != 64 || bits != 128 { + t.Errorf("expected /64, got /%d (bits=%d)", ones, bits) + } + expected := parseCIDR("fd00::/64") + if subnet.Subnet.String() != expected.String() { + t.Errorf("expected %s, got %s", expected, subnet.Subnet) + } + }) + + t.Run("skip used subnets", func(t *testing.T) { + pools := []config.SubnetPool{parseV6Pool("fd00::/48", 64)} + used := []*net.IPNet{parseCIDR("fd00::/64")} + subnet, err := GetFreeIPv6NetworkSubnet(used, pools) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := parseCIDR("fd00:0:0:1::/64") + if subnet.Subnet.String() != expected.String() { + t.Errorf("expected %s, got %s", expected, subnet.Subnet) + } + }) + + t.Run("fall through to second pool", func(t *testing.T) { + pool1 := parseV6Pool("fd00:1::/112", 112) + pool2 := parseV6Pool("fd00:2::/48", 64) + used := []*net.IPNet{parseCIDR("fd00:1::/112")} + subnet, err := GetFreeIPv6NetworkSubnet(used, []config.SubnetPool{pool1, pool2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := parseCIDR("fd00:2::/64") + if subnet.Subnet.String() != expected.String() { + t.Errorf("expected %s, got %s", expected, subnet.Subnet) + } + }) + + t.Run("fallback to random when no pools", func(t *testing.T) { + subnet, err := GetFreeIPv6NetworkSubnet(nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if subnet.Subnet.IP.To4() != nil { + t.Errorf("expected ipv6 address, got %s", subnet.Subnet.IP) + } + ones, _ := subnet.Subnet.Mask.Size() + if ones != 64 { + t.Errorf("expected /64, got /%d", ones) + } + }) +} + func TestGetRandomIPv6Subnet(t *testing.T) { for i := range 1000 { t.Run(fmt.Sprintf("GetRandomIPv6Subnet %d", i), func(t *testing.T) { diff --git a/common/libnetwork/internal/util/util.go b/common/libnetwork/internal/util/util.go index 8c64f1863a..ec459e77bc 100644 --- a/common/libnetwork/internal/util/util.go +++ b/common/libnetwork/internal/util/util.go @@ -110,7 +110,12 @@ func GetFreeIPv4NetworkSubnet(usedNetworks []*net.IPNet, subnetPools []config.Su } // GetFreeIPv6NetworkSubnet returns a unused ipv6 subnet. -func GetFreeIPv6NetworkSubnet(usedNetworks []*net.IPNet) (*types.Subnet, error) { +// When subnetPools is non-empty, subnets are allocated sequentially from the pools. +// Otherwise, subnets are generated randomly per RFC 4193. +func GetFreeIPv6NetworkSubnet(usedNetworks []*net.IPNet, subnetPools []config.SubnetPool) (*types.Subnet, error) { + if len(subnetPools) > 0 { + return getFreeIPv6SubnetFromPools(usedNetworks, subnetPools) + } // FIXME: Is 10000 fine as limit? We should prevent an endless loop. for range 10000 { // RFC4193: Choose the ipv6 subnet random and NOT sequentially. @@ -128,6 +133,34 @@ func GetFreeIPv6NetworkSubnet(usedNetworks []*net.IPNet) (*types.Subnet, error) return nil, errors.New("failed to get random ipv6 subnet") } +func getFreeIPv6SubnetFromPools(usedNetworks []*net.IPNet, subnetPools []config.SubnetPool) (*types.Subnet, error) { + var err error + for _, pool := range subnetPools { + netIP := make(net.IP, net.IPv6len) + copy(netIP, pool.Base.IP) + network := &net.IPNet{ + IP: netIP, + Mask: net.CIDRMask(pool.Size, 128), + } + for pool.Base.Contains(network.IP) { + if !NetworkIntersectsWithNetworks(network, usedNetworks) { + logrus.Debugf("found free ipv6 network subnet %s", network.String()) + return &types.Subnet{ + Subnet: types.IPNet{IPNet: *network}, + }, nil + } + network, err = NextSubnet(network) + if err != nil { + break + } + } + } + if err != nil { + return nil, err + } + return nil, errors.New("could not find free ipv6 subnet from subnet pools") +} + // MapDockerBridgeDriverOptions docker driver network options to podman network options. func MapDockerBridgeDriverOptions(n *types.Network) { // validate the given options diff --git a/common/libnetwork/netavark/config.go b/common/libnetwork/netavark/config.go index 3b78544352..6321356865 100644 --- a/common/libnetwork/netavark/config.go +++ b/common/libnetwork/netavark/config.go @@ -166,6 +166,7 @@ func (n *netavarkNetwork) networkCreate(newNetwork *types.Network, defaultNet bo type ConfigOpts struct { SubnetPools []config.SubnetPool `json:"subnet_pools"` + SubnetPoolsV6 []config.SubnetPool `json:"subnet_pools_v6,omitempty"` DefaultInterfaceName string `json:"default_interface_name"` CheckUsedSubnets bool `json:"check_used_subnets"` } @@ -190,6 +191,7 @@ func (n *netavarkNetwork) networkCreate(newNetwork *types.Network, defaultNet bo }, Options: ConfigOpts{ SubnetPools: n.defaultsubnetPools, + SubnetPoolsV6: n.defaultSubnetPoolsV6, DefaultInterfaceName: n.DefaultInterfaceName(), CheckUsedSubnets: !defaultNet, }, diff --git a/common/libnetwork/netavark/network.go b/common/libnetwork/netavark/network.go index c06d0486f2..cf765f3e83 100644 --- a/common/libnetwork/netavark/network.go +++ b/common/libnetwork/netavark/network.go @@ -47,6 +47,12 @@ type netavarkNetwork struct { // defaultsubnetPools contains the subnets which must be used to allocate a free subnet by network create defaultsubnetPools []config.SubnetPool + // defaultSubnetV6 is the default ipv6 subnet for the default network (may be zero value). + defaultSubnetV6 types.IPNet + + // defaultSubnetPoolsV6 contains ipv6 subnets for allocating free subnets by network create + defaultSubnetPoolsV6 []config.SubnetPool + // dnsBindPort is set the port to pass to netavark for aardvark dnsBindPort uint16 @@ -150,6 +156,16 @@ func NewNetworkInterface(conf *InitConfig) (types.ContainerNetwork, error) { defaultSubnetPools = config.DefaultSubnetPools } + var defaultSubnetV6 types.IPNet + if v6 := conf.Config.Network.DefaultSubnetV6; v6 != "" { + defaultSubnetV6, err = types.ParseCIDR(v6) + if err != nil { + return nil, fmt.Errorf("failed to parse default ipv6 subnet: %w", err) + } + } + + defaultSubnetPoolsV6 := conf.Config.Network.DefaultSubnetPoolsV6 + n := &netavarkNetwork{ networkConfigDir: conf.NetworkConfigDir, networkRunDir: conf.NetworkRunDir, @@ -161,6 +177,8 @@ func NewNetworkInterface(conf *InitConfig) (types.ContainerNetwork, error) { defaultNetwork: defaultNetworkName, defaultSubnet: defaultNet, defaultsubnetPools: defaultSubnetPools, + defaultSubnetV6: defaultSubnetV6, + defaultSubnetPoolsV6: defaultSubnetPoolsV6, dnsBindPort: conf.Config.Network.DNSBindPort, pluginDirs: conf.Config.Network.NetavarkPluginDirs.Get(), lock: lock, @@ -303,16 +321,25 @@ func parseNetwork(network *types.Network) error { } func (n *netavarkNetwork) createDefaultNetwork() (*types.Network, error) { + subnets := []types.Subnet{ + {Subnet: n.defaultSubnet}, + } + + ipv6Enabled := false + if n.defaultSubnetV6.IP != nil { + subnets = append(subnets, types.Subnet{Subnet: n.defaultSubnetV6}) + ipv6Enabled = true + } + network := &types.Network{ Name: n.defaultNetwork, NetworkInterface: defaultBridgeName + "0", // Important do not change this ID - ID: "2f259bab93aaaaa2542ba43ef33eb990d0999ee1b9924b557b7be53c0b7a1bb9", - Driver: types.BridgeNetworkDriver, - Created: time.Now(), - Subnets: []types.Subnet{ - {Subnet: n.defaultSubnet}, - }, + ID: "2f259bab93aaaaa2542ba43ef33eb990d0999ee1b9924b557b7be53c0b7a1bb9", + Driver: types.BridgeNetworkDriver, + Created: time.Now(), + Subnets: subnets, + IPv6Enabled: ipv6Enabled, IPAMOptions: map[string]string{ "driver": types.HostLocalIPAMDriver, }, diff --git a/common/pkg/config/config.go b/common/pkg/config/config.go index 22d161e27f..2038f7618f 100644 --- a/common/pkg/config/config.go +++ b/common/pkg/config/config.go @@ -604,10 +604,19 @@ type NetworkConfig struct { // DefaultSubnetPools is a list of subnets and size which are used to // allocate subnets automatically for podman network create. // It will iterate through the list and will pick the first free subnet - // with the given size. This is only used for ipv4 subnets, ipv6 subnets - // are always assigned randomly. + // with the given size. This is only used for ipv4 subnets. DefaultSubnetPools []SubnetPool `toml:"default_subnet_pools,omitempty"` + // DefaultSubnetV6 is the IPv6 subnet for the default network. + // If empty, no IPv6 subnet is added to the default network. + // Must be a valid IPv6 CIDR block. + DefaultSubnetV6 string `toml:"default_subnet_v6,omitempty"` + + // DefaultSubnetPoolsV6 is a list of IPv6 subnets and prefix lengths used to + // allocate subnets automatically for podman network create with --ipv6. + // If empty, IPv6 subnets are assigned randomly from fd00::/8 (RFC 4193). + DefaultSubnetPoolsV6 []SubnetPool `toml:"default_subnet_pools_v6,omitempty"` + // DefaultRootlessNetworkCmd is used to set the default rootless network // program, either "slirp4nents" (default) or "pasta". DefaultRootlessNetworkCmd string `toml:"default_rootless_network_cmd,omitempty"` @@ -948,6 +957,19 @@ func (c *NetworkConfig) Validate() error { } } + for _, pool := range c.DefaultSubnetPoolsV6 { + if pool.Base.IP.To4() != nil { + return fmt.Errorf("invalid ipv6 subnet pool ip %q, must be ipv6", pool.Base.IP) + } + ones, _ := pool.Base.IPNet.Mask.Size() + if ones > pool.Size { + return fmt.Errorf("invalid ipv6 subnet pool, size is bigger than subnet %q", &pool.Base.IPNet) + } + if pool.Size > 128 { + return errors.New("invalid ipv6 subnet pool size, must be between 0-128") + } + } + return nil } diff --git a/common/pkg/config/config_local_test.go b/common/pkg/config/config_local_test.go index 833a1b60de..1b70cb6470 100644 --- a/common/pkg/config/config_local_test.go +++ b/common/pkg/config/config_local_test.go @@ -85,6 +85,61 @@ var _ = Describe("Config Local", func() { )) }) + It("should fail on invalid ipv6 subnet pool with ipv4 address", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + net, _ := types.ParseCIDR("10.0.0.0/8") + defConf.Network.DefaultSubnetPoolsV6 = []SubnetPool{ + {Base: &net, Size: 24}, + } + + err = defConf.Network.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("must be ipv6")) + }) + + It("should fail on invalid ipv6 subnet pool size bigger than subnet", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + net, _ := types.ParseCIDR("fd00::/48") + defConf.Network.DefaultSubnetPoolsV6 = []SubnetPool{ + {Base: &net, Size: 32}, + } + + err = defConf.Network.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("size is bigger than subnet")) + }) + + It("should fail on invalid ipv6 subnet pool size exceeding 128", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + net, _ := types.ParseCIDR("fd00::/8") + defConf.Network.DefaultSubnetPoolsV6 = []SubnetPool{ + {Base: &net, Size: 129}, + } + + err = defConf.Network.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("must be between 0-128")) + }) + + It("should pass on valid ipv6 subnet pool", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + net, _ := types.ParseCIDR("fd00::/8") + defConf.Network.DefaultSubnetPoolsV6 = []SubnetPool{ + {Base: &net, Size: 64}, + } + + err = defConf.Network.Validate() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + It("parse dns port", func() { // Given config, err := newLocked(&Options{}, testConfigPath("")) diff --git a/common/pkg/config/containers.conf b/common/pkg/config/containers.conf index 7f52238178..189d95b498 100644 --- a/common/pkg/config/containers.conf +++ b/common/pkg/config/containers.conf @@ -392,8 +392,7 @@ default_sysctls = [ # DefaultSubnetPools is a list of subnets and size which are used to # allocate subnets automatically for podman network create. # It will iterate through the list and will pick the first free subnet -# with the given size. This is only used for ipv4 subnets, ipv6 subnets -# are always assigned randomly. +# with the given size. This is only used for ipv4 subnets. # #default_subnet_pools = [ # {"base" = "10.89.0.0/16", "size" = 24}, @@ -403,6 +402,22 @@ default_sysctls = [ # {"base" = "10.128.0.0/9", "size" = 24}, #] +# The IPv6 subnet for the default network. +# If empty, no IPv6 subnet is added to the default network. +# Must be a valid IPv6 CIDR block. +# +#default_subnet_v6 = "" + +# DefaultSubnetPoolsV6 is a list of IPv6 subnets and prefix lengths used to +# allocate subnets automatically for podman network create with --ipv6. +# It will iterate through the list and will pick the first free subnet +# with the given size. If empty, IPv6 subnets are assigned randomly +# from fd00::/8 (RFC 4193). +# +#default_subnet_pools_v6 = [ +# {"base" = "fd00::/48", "size" = 64}, +#] + # Configure which rootless network program to use by default.