From 57c9f4830b4f90bfdedaacac65370d9c35eec7cb 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/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 ++++++++- 5 files changed, 131 insertions(+), 10 deletions(-) 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 9c998dde09..3f2d311d3c 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.