Repository: metallb/metallb
Pull request #1032 (2021-11-09 12:15:31)

Add E2E configuration changes tests
Create two address pools and request a specific address pool for each Loadbalancer service. Validate functionality.

Create two peers and validate each FRR container is paired with the cluster nodes.

fedepaol: @sabinaaledort looks good but needs rebase.
The other tests support dual stack too now, but I think it's not really necessary here since here we are testing how different config changes are handled.

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 212593f8..6d96e6ff 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -40,7 +40,7 @@ jobs:
           SKIP="none"
           if [ "<< parameters.bgp-type >>" = "native" ]; then SKIP="$SKIP\|FRR\|BFD\|DUALSTACK"; fi
           if [ "<< parameters.ip-family >>" = "ipv4" ]; then SKIP="$SKIP\|IPV6\|DUALSTACK"; fi
-          if [ "<< parameters.ip-family >>" = "dual" ]; then SKIP="$SKIP\|IPV6\|DUALSTACK"; fi # TODO: until DUALSTACK stack is supported, we can't run the ipv6 / dual stack tests against the cluster
+          if [ "<< parameters.ip-family >>" = "dual" ]; then SKIP="$SKIP\|IPV6"; fi
           if [ "<< parameters.ip-family >>" = "ipv6" ]; then SKIP="$SKIP\|IPV4\|DUALSTACK"; fi
           if [ "<< parameters.ip-family >>" = "ipv6" ] && [ "<< parameters.bgp-type >>" = "native" ]; then SKIP="$SKIP\|BGP"; fi # we are skipping BGP for ipv6 for native bgp stack
           echo "Skipping $SKIP"
diff --git a/controller/service.go b/controller/service.go
index eb01d6d1..6f95e765 100644
--- a/controller/service.go
+++ b/controller/service.go
@@ -23,11 +23,16 @@ import (
 	v1 "k8s.io/api/core/v1"
 
 	"go.universe.tf/metallb/internal/allocator/k8salloc"
+	"go.universe.tf/metallb/internal/ipfamily"
 )
 
-func (c *controller) convergeBalancer(l log.Logger, key string, svc *v1.Service) bool {
-	var lbIP net.IP
+const (
+	annotationAddressPool = "metallb.universe.tf/address-pool"
+)
 
+func (c *controller) convergeBalancer(l log.Logger, key string, svc *v1.Service) bool {
+	lbIPs := []net.IP{}
+	var err error
 	// Not a LoadBalancer, early exit. It might have been a balancer
 	// in the past, so we still need to clear LB state.
 	if svc.Spec.Type != "LoadBalancer" {
@@ -38,85 +43,106 @@ func (c *controller) convergeBalancer(l log.Logger, key string, svc *v1.Service)
 		return true
 	}
 
-	// If the ClusterIP is malformed or not set we can't determine the
+	// If the ClusterIPs is malformed or not set we can't determine the
 	// ipFamily to use.
-	clusterIP := net.ParseIP(svc.Spec.ClusterIP)
-	if clusterIP == nil {
-		level.Info(l).Log("event", "clearAssignment", "reason", "noClusterIP", "msg", "No ClusterIP")
+	if len(svc.Spec.ClusterIPs) == 0 {
+		level.Info(l).Log("event", "clearAssignment", "reason", "noClusterIPs", "msg", "No ClusterIPs")
 		c.clearServiceState(key, svc)
 		return true
 	}
 
-	// The assigned LB IP is the end state of convergence. If there's
+	// The assigned LB IP(s) is the end state of convergence. If there's
 	// none or a malformed one, nuke all controlled state so that we
 	// start converging from a clean slate.
-	if len(svc.Status.LoadBalancer.Ingress) == 1 {
-		lbIP = net.ParseIP(svc.Status.LoadBalancer.Ingress[0].IP)
-	}
-	if lbIP == nil {
-		c.clearServiceState(key, svc)
+	for i := range svc.Status.LoadBalancer.Ingress {
+		ip := svc.Status.LoadBalancer.Ingress[i].IP
+		if len(ip) != 0 {
+			lbIPs = append(lbIPs, net.ParseIP(ip))
+		}
 	}
 
-	// Clear the lbIP if it has a different ipFamily compared to the clusterIP.
-	// (this should not happen since the "ipFamily" of a service is immutable)
-	if (clusterIP.To4() == nil) != (lbIP.To4() == nil) {
+	lbIPsIPFamily := ipfamily.Unknown
+	if len(lbIPs) == 0 {
 		c.clearServiceState(key, svc)
-		lbIP = nil
+	} else {
+		lbIPsIPFamily, err = ipfamily.ForAddressesIPs(lbIPs)
+		if err != nil {
+			level.Error(l).Log("event", "clearAssignment", "reason", "nolbIPsIPFamily", "msg", "Failed to retrieve lbIPs family")
+			c.client.Errorf(svc, "nolbIPsIPFamily", "Failed to retrieve LBIPs IPFamily for %q: %s", lbIPs, err)
+			return true
+		}
+		clusterIPsIPFamily, err := ipfamily.ForAddresses(svc.Spec.ClusterIPs)
+		if err != nil {
+			level.Error(l).Log("event", "clearAssignment", "reason", "noclusterIPsIPFamily", "msg", "Failed to retrieve clusterIPs family")
+			c.client.Errorf(svc, "noclusterIPsIPFamily", "Failed to retrieve ClusterIPs IPFamily for %q: %s", svc.Spec.ClusterIPs, err)
+			return true
+		}
+		// Clear the lbIP if it has a different ipFamily compared to the clusterIP.
+		// (this should not happen since the "ipFamily" of a service is immutable)
+		if lbIPsIPFamily != clusterIPsIPFamily {
+			c.clearServiceState(key, svc)
+			lbIPs = []net.IP{}
+		}
 	}
 
 	// It's possible the config mutated and the IP we have no longer
 	// makes sense. If so, clear it out and give the rest of the logic
 	// a chance to allocate again.
-	if lbIP != nil {
+	if len(lbIPs) != 0 {
 		// This assign is idempotent if the config is consistent,
 		// otherwise it'll fail and tell us why.
-		if err := c.ips.Assign(key, lbIP, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc)); err != nil {
-			level.Info(l).Log("event", "clearAssignment", "reason", "notAllowedByConfig", "msg", "current IP not allowed by config, clearing")
+		if err = c.ips.Assign(key, lbIPs, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc)); err != nil {
+			level.Info(l).Log("event", "clearAssignment", "error", err, "msg", "current IP not allowed by config, clearing")
 			c.clearServiceState(key, svc)
-			lbIP = nil
+			lbIPs = []net.IP{}
 		}
 
 		// The user might also have changed the pool annotation, and
 		// requested a different pool than the one that is currently
 		// allocated.
-		desiredPool := svc.Annotations["metallb.universe.tf/address-pool"]
-		if lbIP != nil && desiredPool != "" && c.ips.Pool(key) != desiredPool {
+		desiredPool := svc.Annotations[annotationAddressPool]
+		if len(lbIPs) != 0 && desiredPool != "" && c.ips.Pool(key) != desiredPool {
 			level.Info(l).Log("event", "clearAssignment", "reason", "differentPoolRequested", "msg", "user requested a different pool than the one currently assigned")
 			c.clearServiceState(key, svc)
-			lbIP = nil
+			lbIPs = []net.IP{}
+		}
+		// User set or changed the desired LB IP, nuke the
+		// state. allocateIP will pay attention to LoadBalancerIP and try
+		// to meet the user's demands.
+		if svc.Spec.LoadBalancerIP != "" {
+			if lbIPsIPFamily == ipfamily.DualStack {
+				level.Error(l).Log("event", "loadbalancerIP", "error", "DualStack", "msg", "user requested loadbalancer IP for dualstack address family")
+				c.client.Errorf(svc, "LoadBalancerFailed", "Failed loadbalancer IP for dual-stack")
+				return true
+			}
+			if svc.Spec.LoadBalancerIP != lbIPs[0].String() {
+				level.Info(l).Log("event", "clearAssignment", "reason", "differentIPRequested", "msg", "user requested a different IP than the one currently assigned")
+				c.clearServiceState(key, svc)
+				lbIPs = []net.IP{}
+			}
 		}
-	}
-
-	// User set or changed the desired LB IP, nuke the
-	// state. allocateIP will pay attention to LoadBalancerIP and try
-	// to meet the user's demands.
-	if svc.Spec.LoadBalancerIP != "" && svc.Spec.LoadBalancerIP != lbIP.String() {
-		level.Info(l).Log("event", "clearAssignment", "reason", "differentIPRequested", "msg", "user requested a different IP than the one currently assigned")
-		c.clearServiceState(key, svc)
-		lbIP = nil
 	}
 
 	// If lbIP is still nil at this point, try to allocate.
-	if lbIP == nil {
+	if len(lbIPs) == 0 {
 		if !c.synced {
-			level.Error(l).Log("op", "allocateIP", "error", "controller not synced", "msg", "controller not synced yet, cannot allocate IP; will retry after sync")
+			level.Error(l).Log("op", "allocateIPs", "error", "controller not synced", "msg", "controller not synced yet, cannot allocate IP; will retry after sync")
 			return false
 		}
-		ip, err := c.allocateIP(key, svc)
+		lbIPs, err = c.allocateIPs(key, svc)
 		if err != nil {
-			level.Error(l).Log("op", "allocateIP", "error", err, "msg", "IP allocation failed")
+			level.Error(l).Log("op", "allocateIPs", "error", err, "msg", "IP allocation failed")
 			c.client.Errorf(svc, "AllocationFailed", "Failed to allocate IP for %q: %s", key, err)
 			// The outer controller loop will retry converging this
 			// service when another service gets deleted, so there's
 			// nothing to do here but wait to get called again later.
 			return true
 		}
-		lbIP = ip
-		level.Info(l).Log("event", "ipAllocated", "ip", lbIP, "msg", "IP address assigned by controller")
-		c.client.Infof(svc, "IPAllocated", "Assigned IP %q", lbIP)
+		level.Info(l).Log("event", "ipAllocated", "ip", lbIPs, "msg", "IP address assigned by controller")
+		c.client.Infof(svc, "IPAllocated", "Assigned IP %q", lbIPs)
 	}
 
-	if lbIP == nil {
+	if len(lbIPs) == 0 {
 		level.Error(l).Log("bug", "true", "msg", "internal error: failed to allocate an IP, but did not exit convergeService early!")
 		c.client.Errorf(svc, "InternalError", "didn't allocate an IP but also did not fail")
 		c.clearServiceState(key, svc)
@@ -125,7 +151,7 @@ func (c *controller) convergeBalancer(l log.Logger, key string, svc *v1.Service)
 
 	pool := c.ips.Pool(key)
 	if pool == "" || c.config.Pools[pool] == nil {
-		level.Error(l).Log("bug", "true", "ip", lbIP, "msg", "internal error: allocated IP has no matching address pool")
+		level.Error(l).Log("bug", "true", "ip", lbIPs, "msg", "internal error: allocated IP has no matching address pool")
 		c.client.Errorf(svc, "InternalError", "allocated an IP that has no pool")
 		c.clearServiceState(key, svc)
 		return true
@@ -133,7 +159,11 @@ func (c *controller) convergeBalancer(l log.Logger, key string, svc *v1.Service)
 
 	// At this point, we have an IP selected somehow, all that remains
 	// is to program the data plane.
-	svc.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{IP: lbIP.String()}}
+	lbIngressIPs := []v1.LoadBalancerIngress{}
+	for _, lbIP := range lbIPs {
+		lbIngressIPs = append(lbIngressIPs, v1.LoadBalancerIngress{IP: lbIP.String()})
+	}
+	svc.Status.LoadBalancer.Ingress = lbIngressIPs
 	return true
 }
 
@@ -144,13 +174,16 @@ func (c *controller) clearServiceState(key string, svc *v1.Service) {
 	svc.Status.LoadBalancer = v1.LoadBalancerStatus{}
 }
 
-func (c *controller) allocateIP(key string, svc *v1.Service) (net.IP, error) {
-	clusterIP := net.ParseIP(svc.Spec.ClusterIP)
-	if clusterIP == nil {
+func (c *controller) allocateIPs(key string, svc *v1.Service) ([]net.IP, error) {
+	if len(svc.Spec.ClusterIPs) == 0 {
 		// (we should never get here because the caller ensured that Spec.ClusterIP != nil)
 		return nil, fmt.Errorf("invalid ClusterIP [%s], can't determine family", svc.Spec.ClusterIP)
 	}
-	isIPv6 := clusterIP.To4() == nil
+
+	serviceIPFamily, err := ipfamily.ForAddresses(svc.Spec.ClusterIPs)
+	if err != nil {
+		return nil, err
+	}
 
 	// If the user asked for a specific IP, try that.
 	if svc.Spec.LoadBalancerIP != "" {
@@ -158,25 +191,26 @@ func (c *controller) allocateIP(key string, svc *v1.Service) (net.IP, error) {
 		if ip == nil {
 			return nil, fmt.Errorf("invalid spec.loadBalancerIP %q", svc.Spec.LoadBalancerIP)
 		}
-		if (ip.To4() == nil) != isIPv6 {
+		lbipFamily := ipfamily.ForAddress(ip)
+		if serviceIPFamily != lbipFamily {
 			return nil, fmt.Errorf("requested spec.loadBalancerIP %q does not match the ipFamily of the service", svc.Spec.LoadBalancerIP)
 		}
-		if err := c.ips.Assign(key, ip, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc)); err != nil {
+		lbIPs := []net.IP{ip}
+		if err := c.ips.Assign(key, lbIPs, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc)); err != nil {
 			return nil, err
 		}
-		return ip, nil
+		return lbIPs, nil
 	}
-
 	// Otherwise, did the user ask for a specific pool?
-	desiredPool := svc.Annotations["metallb.universe.tf/address-pool"]
+	desiredPool := svc.Annotations[annotationAddressPool]
 	if desiredPool != "" {
-		ip, err := c.ips.AllocateFromPool(key, isIPv6, desiredPool, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc))
+		ips, err := c.ips.AllocateFromPool(key, serviceIPFamily, desiredPool, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc))
 		if err != nil {
 			return nil, err
 		}
-		return ip, nil
+		return ips, nil
 	}
 
 	// Okay, in that case just bruteforce across all pools.
-	return c.ips.Allocate(key, isIPv6, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc))
+	return c.ips.Allocate(key, serviceIPFamily, k8salloc.Ports(svc), k8salloc.SharingKey(svc), k8salloc.BackendKey(svc))
 }
diff --git a/internal/allocator/allocator.go b/internal/allocator/allocator.go
index c47169d7..637af5b2 100644
--- a/internal/allocator/allocator.go
+++ b/internal/allocator/allocator.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 
 	"go.universe.tf/metallb/internal/config"
+	"go.universe.tf/metallb/internal/ipfamily"
 
 	"github.com/mikioh/ipaddr"
 )
@@ -43,7 +44,7 @@ type key struct {
 
 type alloc struct {
 	pool  string
-	ip    net.IP
+	ips   []net.IP
 	ports []Port
 	key
 }
@@ -68,8 +69,8 @@ func (a *Allocator) SetPools(pools map[string]*config.Pool) error {
 	// only question we have to answer is: can we fit all allocated
 	// IPs into address pools under the new configuration?
 	for svc, alloc := range a.allocated {
-		if poolFor(pools, alloc.ip) == "" {
-			return fmt.Errorf("new config not compatible with assigned IPs: service %q cannot own %q under new config", svc, alloc.ip)
+		if poolFor(pools, alloc.ips) == "" {
+			return fmt.Errorf("new config not compatible with assigned IPs: service %q cannot own %q under new config", svc, alloc.ips)
 		}
 	}
 
@@ -85,7 +86,7 @@ func (a *Allocator) SetPools(pools map[string]*config.Pool) error {
 
 	// Need to rearrange existing pool mappings and counts
 	for svc, alloc := range a.allocated {
-		pool := poolFor(a.pools, alloc.ip)
+		pool := poolFor(a.pools, alloc.ips)
 		if pool != alloc.pool {
 			a.Unassign(svc)
 			alloc.pool = pool
@@ -109,61 +110,54 @@ func (a *Allocator) SetPools(pools map[string]*config.Pool) error {
 func (a *Allocator) assign(svc string, alloc *alloc) {
 	a.Unassign(svc)
 	a.allocated[svc] = alloc
-	a.sharingKeyForIP[alloc.ip.String()] = &alloc.key
-	if a.portsInUse[alloc.ip.String()] == nil {
-		a.portsInUse[alloc.ip.String()] = map[Port]string{}
-	}
-	for _, port := range alloc.ports {
-		a.portsInUse[alloc.ip.String()][port] = svc
-	}
-	if a.servicesOnIP[alloc.ip.String()] == nil {
-		a.servicesOnIP[alloc.ip.String()] = map[string]bool{}
-	}
-	a.servicesOnIP[alloc.ip.String()][svc] = true
-	if a.poolIPsInUse[alloc.pool] == nil {
-		a.poolIPsInUse[alloc.pool] = map[string]int{}
+	for _, ip := range alloc.ips {
+		a.sharingKeyForIP[ip.String()] = &alloc.key
+		if a.portsInUse[ip.String()] == nil {
+			a.portsInUse[ip.String()] = map[Port]string{}
+		}
+		for _, port := range alloc.ports {
+			a.portsInUse[ip.String()][port] = svc
+		}
+		if a.servicesOnIP[ip.String()] == nil {
+			a.servicesOnIP[ip.String()] = map[string]bool{}
+		}
+		a.servicesOnIP[ip.String()][svc] = true
+		if a.poolIPsInUse[alloc.pool] == nil {
+			a.poolIPsInUse[alloc.pool] = map[string]int{}
+		}
+		a.poolIPsInUse[alloc.pool][ip.String()]++
 	}
-	a.poolIPsInUse[alloc.pool][alloc.ip.String()]++
-
 	stats.poolCapacity.WithLabelValues(alloc.pool).Set(float64(poolCount(a.pools[alloc.pool])))
 	stats.poolActive.WithLabelValues(alloc.pool).Set(float64(len(a.poolIPsInUse[alloc.pool])))
 }
 
 // Assign assigns the requested ip to svc, if the assignment is
 // permissible by sharingKey and backendKey.
-func (a *Allocator) Assign(svc string, ip net.IP, ports []Port, sharingKey, backendKey string) error {
-	pool := poolFor(a.pools, ip)
+func (a *Allocator) Assign(svc string, ips []net.IP, ports []Port, sharingKey, backendKey string) error {
+	pool := poolFor(a.pools, ips)
 	if pool == "" {
-		return fmt.Errorf("%q is not allowed in config", ip)
+		return fmt.Errorf("%q is not allowed in config", ips)
 	}
 	sk := &key{
 		sharing: sharingKey,
 		backend: backendKey,
 	}
+	// Check the dual-stack constraints:
+	// - Two addresses
+	// - Different families, ipv4 and ipv6
+	if len(ips) > 2 {
+		return fmt.Errorf("More than two addresses %q", ips)
+	}
+	if len(ips) == 2 && (ipfamily.ForAddress(ips[0]) == ipfamily.ForAddress(ips[1])) {
+		return fmt.Errorf("%q %q has the same family", ips[0], ips[1])
+	}
 
-	// Does the IP already have allocs? If so, needs to be the same
-	// sharing key, and have non-overlapping ports. If not, the
-	// proposed IP needs to be allowed by configuration.
-	if existingSK := a.sharingKeyForIP[ip.String()]; existingSK != nil {
-		if err := sharingOK(existingSK, sk); err != nil {
-			// Sharing key is incompatible. However, if the owner is
-			// the same service, and is the only user of the IP, we
-			// can just update its sharing key in place.
-			var otherSvcs []string
-			for otherSvc := range a.servicesOnIP[ip.String()] {
-				if otherSvc != svc {
-					otherSvcs = append(otherSvcs, otherSvc)
-				}
-			}
-			if len(otherSvcs) > 0 {
-				return fmt.Errorf("can't change sharing key for %q, address also in use by %s: %w", svc, strings.Join(otherSvcs, ","), err)
-			}
-		}
-
-		for _, port := range ports {
-			if curSvc, ok := a.portsInUse[ip.String()][port]; ok && curSvc != svc {
-				return fmt.Errorf("port %s is already in use on %q", port, ip)
-			}
+	for _, ip := range ips {
+		// Does the IP already have allocs? If so, needs to be the same
+		// sharing key, and have non-overlapping ports. If not, the
+		// proposed IP needs to be allowed by configuration.
+		if err := a.checkSharing(svc, ip.String(), ports, sk); err != nil {
+			return err
 		}
 	}
 
@@ -174,12 +168,11 @@ func (a *Allocator) Assign(svc string, ip net.IP, ports []Port, sharingKey, back
 	// unconditionally safe to do.
 	alloc := &alloc{
 		pool:  pool,
-		ip:    ip,
+		ips:   ips,
 		ports: make([]Port, len(ports)),
 		key:   *sk,
 	}
 	for i, port := range ports {
-		port := port
 		alloc.ports[i] = port
 	}
 	a.assign(svc, alloc)
@@ -194,46 +187,46 @@ func (a *Allocator) Unassign(svc string) bool {
 
 	al := a.allocated[svc]
 	delete(a.allocated, svc)
-	for _, port := range al.ports {
-		if curSvc := a.portsInUse[al.ip.String()][port]; curSvc != svc {
-			panic(fmt.Sprintf("incoherent state, I thought port %q belonged to service %q, but it seems to belong to %q", port, svc, curSvc))
+	for _, ip := range al.ips {
+		for _, port := range al.ports {
+			if curSvc := a.portsInUse[ip.String()][port]; curSvc != svc {
+				panic(fmt.Sprintf("incoherent state, I thought port %q belonged to service %q, but it seems to belong to %q", port, svc, curSvc))
+			}
+			delete(a.portsInUse[ip.String()], port)
+		}
+
+		delete(a.servicesOnIP[ip.String()], svc)
+		if len(a.portsInUse[ip.String()]) == 0 {
+			delete(a.portsInUse, ip.String())
+			delete(a.sharingKeyForIP, ip.String())
+		}
+		a.poolIPsInUse[al.pool][ip.String()]--
+		if a.poolIPsInUse[al.pool][ip.String()] == 0 {
+			// Explicitly delete unused IPs from the pool, so that len()
+			// is an accurate count of IPs in use.
+			delete(a.poolIPsInUse[al.pool], ip.String())
 		}
-		delete(a.portsInUse[al.ip.String()], port)
-	}
-	delete(a.servicesOnIP[al.ip.String()], svc)
-	if len(a.portsInUse[al.ip.String()]) == 0 {
-		delete(a.portsInUse, al.ip.String())
-		delete(a.sharingKeyForIP, al.ip.String())
-	}
-	a.poolIPsInUse[al.pool][al.ip.String()]--
-	if a.poolIPsInUse[al.pool][al.ip.String()] == 0 {
-		// Explicitly delete unused IPs from the pool, so that len()
-		// is an accurate count of IPs in use.
-		delete(a.poolIPsInUse[al.pool], al.ip.String())
 	}
 	stats.poolActive.WithLabelValues(al.pool).Set(float64(len(a.poolIPsInUse[al.pool])))
 	return true
 }
 
-func cidrIsIPv6(cidr *net.IPNet) bool {
-	return cidr.IP.To4() == nil
-}
-func ipIsIPv6(ip net.IP) bool {
-	return ip.To4() == nil
-}
-
 // AllocateFromPool assigns an available IP from pool to service.
-func (a *Allocator) AllocateFromPool(svc string, isIPv6 bool, poolName string, ports []Port, sharingKey, backendKey string) (net.IP, error) {
+func (a *Allocator) AllocateFromPool(svc string, serviceIPFamily ipfamily.Family, poolName string, ports []Port, sharingKey, backendKey string) ([]net.IP, error) {
 	if alloc := a.allocated[svc]; alloc != nil {
 		// Handle the case where the svc has already been assigned an IP but from the wrong family.
-		// This "should-not-happen" since the "ipFamily" is an immutable field in services.
-		if isIPv6 != ipIsIPv6(alloc.ip) {
-			return nil, fmt.Errorf("IP for wrong family assigned %s", alloc.ip.String())
+		// This "should-not-happen" since the "serviceIPFamily" is an immutable field in services.
+		allocIPsFamily, err := ipfamily.ForAddressesIPs(alloc.ips)
+		if err != nil {
+			return nil, err
+		}
+		if allocIPsFamily != serviceIPFamily {
+			return nil, fmt.Errorf("IP for wrong family assigned alloc %s service family %s", allocIPsFamily, serviceIPFamily)
 		}
-		if err := a.Assign(svc, alloc.ip, ports, sharingKey, backendKey); err != nil {
+		if err := a.Assign(svc, alloc.ips, ports, sharingKey, backendKey); err != nil {
 			return nil, err
 		}
-		return alloc.ip, nil
+		return alloc.ips, nil
 	}
 
 	pool := a.pools[poolName]
@@ -241,66 +234,68 @@ func (a *Allocator) AllocateFromPool(svc string, isIPv6 bool, poolName string, p
 		return nil, fmt.Errorf("unknown pool %q", poolName)
 	}
 
+	ips := []net.IP{}
+	ipfamilySel := make(map[ipfamily.Family]bool)
+
+	switch serviceIPFamily {
+	case ipfamily.DualStack:
+		ipfamilySel[ipfamily.IPv4], ipfamilySel[ipfamily.IPv6] = true, true
+	default:
+		ipfamilySel[serviceIPFamily] = true
+	}
+
 	for _, cidr := range pool.CIDR {
-		if cidrIsIPv6(cidr) != isIPv6 {
+		cidrIPFamily := ipfamily.ForCIDR(cidr)
+		if _, ok := ipfamilySel[cidrIPFamily]; !ok {
 			// Not the right ip-family
 			continue
 		}
-		c := ipaddr.NewCursor([]ipaddr.Prefix{*ipaddr.NewPrefix(cidr)})
-		for pos := c.First(); pos != nil; pos = c.Next() {
-			ip := pos.IP
-			if pool.AvoidBuggyIPs && ipConfusesBuggyFirmwares(ip) {
-				continue
-			}
-			// Somewhat inefficiently brute-force by invoking the
-			// IP-specific allocator.
-			if err := a.Assign(svc, ip, ports, sharingKey, backendKey); err == nil {
-				return ip, nil
-			}
+		ip := a.getIPFromCIDR(cidr, pool.AvoidBuggyIPs, svc, ports, sharingKey, backendKey)
+		if ip != nil {
+			ips = append(ips, ip)
+			delete(ipfamilySel, cidrIPFamily)
 		}
 	}
 
-	// Woops, run out of IPs :( Fail.
-	return nil, fmt.Errorf("no available IPs in pool %q", poolName)
+	if len(ipfamilySel) > 0 {
+		// Woops, run out of IPs :( Fail.
+		return nil, fmt.Errorf("no available IPs in pool %q for %s IPFamily", poolName, serviceIPFamily)
+	}
+	err := a.Assign(svc, ips, ports, sharingKey, backendKey)
+	if err != nil {
+		return nil, err
+	}
+	return ips, nil
 }
 
 // Allocate assigns any available and assignable IP to service.
-func (a *Allocator) Allocate(svc string, isIPv6 bool, ports []Port, sharingKey, backendKey string) (net.IP, error) {
+func (a *Allocator) Allocate(svc string, serviceIPFamily ipfamily.Family, ports []Port, sharingKey, backendKey string) ([]net.IP, error) {
 	if alloc := a.allocated[svc]; alloc != nil {
-		if err := a.Assign(svc, alloc.ip, ports, sharingKey, backendKey); err != nil {
+		if err := a.Assign(svc, alloc.ips, ports, sharingKey, backendKey); err != nil {
 			return nil, err
 		}
-		return alloc.ip, nil
+		return alloc.ips, nil
 	}
 
 	for poolName := range a.pools {
 		if !a.pools[poolName].AutoAssign {
 			continue
 		}
-		if ip, err := a.AllocateFromPool(svc, isIPv6, poolName, ports, sharingKey, backendKey); err == nil {
-			return ip, nil
+		if ips, err := a.AllocateFromPool(svc, serviceIPFamily, poolName, ports, sharingKey, backendKey); err == nil {
+			return ips, nil
 		}
 	}
 
 	return nil, errors.New("no available IPs")
 }
 
-// IP returns the IP address allocated to service, or nil if none are allocated.
-func (a *Allocator) IP(svc string) net.IP {
-	if alloc := a.allocated[svc]; alloc != nil {
-		return alloc.ip
-	}
-	return nil
-}
-
 // Pool returns the pool from which service's IP was allocated. If
 // service has no IP allocated, "" is returned.
 func (a *Allocator) Pool(svc string) string {
-	ip := a.IP(svc)
-	if ip == nil {
-		return ""
+	if alloc := a.allocated[svc]; alloc != nil {
+		return poolFor(a.pools, alloc.ips)
 	}
-	return poolFor(a.pools, ip)
+	return ""
 }
 
 func sharingOK(existing, new *key) error {
@@ -357,17 +352,24 @@ func poolCount(p *config.Pool) int64 {
 	return total
 }
 
-// poolFor returns the pool that owns the requested IP, or "" if none.
-func poolFor(pools map[string]*config.Pool, ip net.IP) string {
+// poolFor returns the pool that owns the requested IPs, or "" if none.
+func poolFor(pools map[string]*config.Pool, ips []net.IP) string {
 	for pname, p := range pools {
-		if p.AvoidBuggyIPs && ipConfusesBuggyFirmwares(ip) {
-			continue
-		}
-		for _, cidr := range p.CIDR {
-			if cidr.Contains(ip) {
-				return pname
+		cnt := 0
+		for _, ip := range ips {
+			if p.AvoidBuggyIPs && ipConfusesBuggyFirmwares(ip) {
+				continue
+			}
+			for _, cidr := range p.CIDR {
+				if cidr.Contains(ip) {
+					cnt++
+					break
+				}
 			}
 		}
+		if cnt == len(ips) {
+			return pname
+		}
 	}
 	return ""
 }
@@ -383,3 +385,47 @@ func ipConfusesBuggyFirmwares(ip net.IP) bool {
 	}
 	return ip[3] == 0 || ip[3] == 255
 }
+
+func (a *Allocator) getIPFromCIDR(cidr *net.IPNet, avoidBuggyIPs bool, svc string, ports []Port, sharingKey, backendKey string) net.IP {
+	sk := &key{
+		sharing: sharingKey,
+		backend: backendKey,
+	}
+	c := ipaddr.NewCursor([]ipaddr.Prefix{*ipaddr.NewPrefix(cidr)})
+	for pos := c.First(); pos != nil; pos = c.Next() {
+		if avoidBuggyIPs && ipConfusesBuggyFirmwares(pos.IP) {
+			continue
+		}
+		if a.checkSharing(svc, pos.IP.String(), ports, sk) != nil {
+			continue
+		}
+		return pos.IP
+	}
+	return nil
+}
+
+func (a *Allocator) checkSharing(svc string, ip string, ports []Port, sk *key) error {
+	if existingSK := a.sharingKeyForIP[ip]; existingSK != nil {
+		if err := sharingOK(existingSK, sk); err != nil {
+			// Sharing key is incompatible. However, if the owner is
+			// the same service, and is the only user of the IP, we
+			// can just update its sharing key in place.
+			var otherSvcs []string
+			for otherSvc := range a.servicesOnIP[ip] {
+				if otherSvc != svc {
+					otherSvcs = append(otherSvcs, otherSvc)
+				}
+			}
+			if len(otherSvcs) > 0 {
+				return fmt.Errorf("can't change sharing key for %q, address also in use by %s", svc, strings.Join(otherSvcs, ","))
+			}
+		}
+
+		for _, port := range ports {
+			if curSvc, ok := a.portsInUse[ip][port]; ok && curSvc != svc {
+				return fmt.Errorf("port %s is already in use on %q", port, ip)
+			}
+		}
+	}
+	return nil
+}
diff --git a/internal/ipfamily/ipfamily.go b/internal/ipfamily/ipfamily.go
new file mode 100644
index 00000000..44f2110e
--- /dev/null
+++ b/internal/ipfamily/ipfamily.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package ipfamily // import "go.universe.tf/metallb/internal/ipfamily"
+
+import (
+	"fmt"
+	"net"
+)
+
+// IP family helps identifying single stack IPv4/IPv6 vs Dual-stack ["IPv4", "IPv6"] or ["IPv6", "Ipv4"].
+type Family string
+
+const (
+	IPv4      Family = "ipv4"
+	IPv6      Family = "ipv6"
+	DualStack Family = "dual"
+	Unknown   Family = "unknown"
+)
+
+// ForAddresses returns the address family given list of addresses strings.
+func ForAddresses(ips []string) (Family, error) {
+	switch len(ips) {
+	case 1:
+		ip := net.ParseIP(ips[0])
+		if ip.To4() != nil {
+			return IPv4, nil
+		} else {
+			return IPv6, nil
+		}
+	case 2:
+		ip1 := net.ParseIP(ips[0])
+		ip2 := net.ParseIP(ips[1])
+		if ip1 == nil || ip2 == nil {
+			return Unknown, fmt.Errorf("IPFamilyForAddresses: Invalid address %q", ips)
+		}
+		if (ip1.To4() == nil) == (ip2.To4() == nil) {
+			return Unknown, fmt.Errorf("IPFamilyForAddresses: same address family %q", ips)
+		}
+		return DualStack, nil
+	default:
+		return Unknown, fmt.Errorf("IPFamilyForAddresses: invalid ips length %d %q", len(ips), ips)
+	}
+}
+
+// ForAddressesIPs returns the address family from a given list of addresses IPs.
+func ForAddressesIPs(ips []net.IP) (Family, error) {
+	ipsStrings := []string{}
+
+	for _, ip := range ips {
+		ipsStrings = append(ipsStrings, ip.String())
+	}
+	return ForAddresses(ipsStrings)
+}
+
+// ForCIDR returns the address family from a given CIDR.
+func ForCIDR(cidr *net.IPNet) Family {
+	if cidr.IP.To4() == nil {
+		return IPv6
+	}
+	return IPv4
+}
+
+// ForAddress returns the address family for a given address.
+func ForAddress(ip net.IP) Family {
+	if ip.To4() == nil {
+		return IPv6
+	}
+	return IPv4
+}
diff --git a/internal/layer2/announcer.go b/internal/layer2/announcer.go
index 027ef4b1..a61c4a4d 100644
--- a/internal/layer2/announcer.go
+++ b/internal/layer2/announcer.go
@@ -21,8 +21,8 @@ type Announce struct {
 	sync.RWMutex
 	arps     map[int]*arpResponder
 	ndps     map[int]*ndpResponder
-	ips      map[string]net.IP // svcName -> IP
-	ipRefcnt map[string]int    // ip.String() -> number of uses
+	ips      map[string][]net.IP // svcName -> IPs
+	ipRefcnt map[string]int      // ip.String() -> number of uses
 
 	// This channel can block - do not write to it while holding the mutex
 	// to avoid deadlocking.
@@ -35,7 +35,7 @@ func New(l log.Logger) (*Announce, error) {
 		logger:   l,
 		arps:     map[int]*arpResponder{},
 		ndps:     map[int]*ndpResponder{},
-		ips:      map[string]net.IP{},
+		ips:      map[string][]net.IP{},
 		ipRefcnt: map[string]int{},
 		spamCh:   make(chan net.IP, 1024),
 	}
@@ -208,9 +208,11 @@ func (a *Announce) gratuitous(ip net.IP) {
 func (a *Announce) shouldAnnounce(ip net.IP) dropReason {
 	a.RLock()
 	defer a.RUnlock()
-	for _, i := range a.ips {
-		if i.Equal(ip) {
-			return dropReasonNone
+	for _, ips := range a.ips {
+		for _, i := range ips {
+			if i.Equal(ip) {
+				return dropReasonNone
+			}
 		}
 	}
 	return dropReasonAnnounceIP
@@ -225,10 +227,15 @@ func (a *Announce) SetBalancer(name string, ip net.IP) {
 
 	// Kubernetes may inform us that we should advertise this address multiple
 	// times, so just no-op any subsequent requests.
-	if _, ok := a.ips[name]; ok {
-		return
+	if ips, ok := a.ips[name]; ok {
+		for i, ip := range ips {
+			if ip.Equal(a.ips[name][i]) {
+				continue
+			}
+		}
 	}
-	a.ips[name] = ip
+
+	a.ips[name] = append(a.ips[name], ip)
 
 	a.ipRefcnt[ip.String()]++
 	if a.ipRefcnt[ip.String()] > 1 {
@@ -249,22 +256,23 @@ func (a *Announce) DeleteBalancer(name string) {
 	a.Lock()
 	defer a.Unlock()
 
-	ip, ok := a.ips[name]
+	ips, ok := a.ips[name]
 	if !ok {
 		return
 	}
 	delete(a.ips, name)
+	for _, ip := range ips {
+		a.ipRefcnt[ip.String()]--
+		if a.ipRefcnt[ip.String()] > 0 {
+			// Another service is still using this IP, don't touch any
+			// more things.
+			return
+		}
 
-	a.ipRefcnt[ip.String()]--
-	if a.ipRefcnt[ip.String()] > 0 {
-		// Another service is still using this IP, don't touch any
-		// more things.
-		return
-	}
-
-	for _, client := range a.ndps {
-		if err := client.Unwatch(ip); err != nil {
-			level.Error(a.logger).Log("op", "unwatchMulticastGroup", "error", err, "ip", ip, "msg", "failed to unwatch NDP multicast group for IP")
+		for _, client := range a.ndps {
+			if err := client.Unwatch(ip); err != nil {
+				level.Error(a.logger).Log("op", "unwatchMulticastGroup", "error", err, "ip", ip, "msg", "failed to unwatch NDP multicast group for IP")
+			}
 		}
 	}
 
diff --git a/speaker/bgp_controller.go b/speaker/bgp_controller.go
index 114528cc..7129c69f 100644
--- a/speaker/bgp_controller.go
+++ b/speaker/bgp_controller.go
@@ -151,7 +151,7 @@ func hasHealthyEndpoint(eps k8s.EpsOrSlices, filterNode func(*string) bool) bool
 	return false
 }
 
-func (c *bgpController) ShouldAnnounce(l log.Logger, name string, _ net.IP, svc *v1.Service, eps k8s.EpsOrSlices) string {
+func (c *bgpController) ShouldAnnounce(l log.Logger, name string, _ []net.IP, svc *v1.Service, eps k8s.EpsOrSlices) string {
 	// Should we advertise?
 	// Yes, if externalTrafficPolicy is
 	//  Cluster && any healthy endpoint exists
@@ -237,25 +237,27 @@ func (c *bgpController) syncBFDProfiles(profiles map[string]*config.BFDProfile)
 	return c.sessionManager.SyncBFDProfiles(profiles)
 }
 
-func (c *bgpController) SetBalancer(l log.Logger, name string, lbIP net.IP, pool *config.Pool) error {
+func (c *bgpController) SetBalancer(l log.Logger, name string, lbIPs []net.IP, pool *config.Pool) error {
 	c.svcAds[name] = nil
-	for _, adCfg := range pool.BGPAdvertisements {
-		m := net.CIDRMask(adCfg.AggregationLength, 32)
-		if lbIP.To4() == nil {
-			m = net.CIDRMask(adCfg.AggregationLengthV6, 128)
-		}
-		ad := &bgp.Advertisement{
-			Prefix: &net.IPNet{
-				IP:   lbIP.Mask(m),
-				Mask: m,
-			},
-			LocalPref: adCfg.LocalPref,
-		}
-		for comm := range adCfg.Communities {
-			ad.Communities = append(ad.Communities, comm)
+	for _, lbIP := range lbIPs {
+		for _, adCfg := range pool.BGPAdvertisements {
+			m := net.CIDRMask(adCfg.AggregationLength, 32)
+			if lbIP.To4() == nil {
+				m = net.CIDRMask(adCfg.AggregationLengthV6, 128)
+			}
+			ad := &bgp.Advertisement{
+				Prefix: &net.IPNet{
+					IP:   lbIP.Mask(m),
+					Mask: m,
+				},
+				LocalPref: adCfg.LocalPref,
+			}
+			for comm := range adCfg.Communities {
+				ad.Communities = append(ad.Communities, comm)
+			}
+			sort.Slice(ad.Communities, func(i, j int) bool { return ad.Communities[i] < ad.Communities[j] })
+			c.svcAds[name] = append(c.svcAds[name], ad)
 		}
-		sort.Slice(ad.Communities, func(i, j int) bool { return ad.Communities[i] < ad.Communities[j] })
-		c.svcAds[name] = append(c.svcAds[name], ad)
 	}
 
 	if err := c.updateAds(); err != nil {
@@ -263,7 +265,6 @@ func (c *bgpController) SetBalancer(l log.Logger, name string, lbIP net.IP, pool
 	}
 
 	level.Info(l).Log("event", "updatedAdvertisements", "numAds", len(c.svcAds[name]), "msg", "making advertisements using BGP")
-
 	return nil
 }
 
diff --git a/speaker/layer2_controller.go b/speaker/layer2_controller.go
index 0a6b6955..876fae04 100644
--- a/speaker/layer2_controller.go
+++ b/speaker/layer2_controller.go
@@ -92,7 +92,7 @@ func usableNodes(eps k8s.EpsOrSlices, speakers map[string]bool) []string {
 	return ret
 }
 
-func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce net.IP, svc *v1.Service, eps k8s.EpsOrSlices) string {
+func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce []net.IP, svc *v1.Service, eps k8s.EpsOrSlices) string {
 	if !activeEndpointExists(eps) { // no active endpoints, just return
 		return "notOwner"
 	}
@@ -102,7 +102,8 @@ func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce
 	} else {
 		nodes = nodesWithActiveSpeakers(c.sList.UsableSpeakers())
 	}
-	ipString := toAnnounce.String()
+	// Using the first IP should work for both single and dual stack.
+	ipString := toAnnounce[0].String()
 	// Sort the slice by the hash of node + load balancer ips. This
 	// produces an ordering of ready nodes that is unique to all the services
 	// with the same ip.
@@ -122,8 +123,10 @@ func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce
 	return "notOwner"
 }
 
-func (c *layer2Controller) SetBalancer(l log.Logger, name string, lbIP net.IP, pool *config.Pool) error {
-	c.announcer.SetBalancer(name, lbIP)
+func (c *layer2Controller) SetBalancer(l log.Logger, name string, lbIPs []net.IP, pool *config.Pool) error {
+	for _, lbIP := range lbIPs {
+		c.announcer.SetBalancer(name, lbIP)
+	}
 	return nil
 }
 
diff --git a/speaker/main.go b/speaker/main.go
index d5fd4acf..c9ebf63e 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -180,7 +180,7 @@ type controller struct {
 
 	protocols map[config.Proto]Protocol
 	announced map[string]config.Proto // service name -> protocol advertising it
-	svcIP     map[string]net.IP       // service name -> assigned IP
+	svcIPs    map[string][]net.IP     // service name -> assigned IPs
 }
 
 type controllerConfig struct {
@@ -223,7 +223,7 @@ func newController(cfg controllerConfig) (*controller, error) {
 		bgpType:   cfg.bgpType,
 		protocols: protocols,
 		announced: map[string]config.Proto{},
-		svcIP:     map[string]net.IP{},
+		svcIPs:    map[string][]net.IP{},
 	}
 
 	return ret, nil
@@ -246,19 +246,23 @@ func (c *controller) SetBalancer(l log.Logger, name string, svc *v1.Service, eps
 		return k8s.SyncStateSuccess
 	}
 
-	if len(svc.Status.LoadBalancer.Ingress) != 1 {
+	if len(svc.Status.LoadBalancer.Ingress) == 0 {
 		return c.deleteBalancer(l, name, "noIPAllocated")
 	}
 
-	lbIP := net.ParseIP(svc.Status.LoadBalancer.Ingress[0].IP)
-	if lbIP == nil {
-		level.Error(l).Log("op", "setBalancer", "error", fmt.Sprintf("invalid LoadBalancer IP %q", svc.Status.LoadBalancer.Ingress[0].IP), "msg", "invalid IP allocated by controller")
-		return c.deleteBalancer(l, name, "invalidIP")
+	lbIPs := []net.IP{}
+	for i := range svc.Status.LoadBalancer.Ingress {
+		lbIP := net.ParseIP(svc.Status.LoadBalancer.Ingress[i].IP)
+		if lbIP == nil {
+			level.Error(l).Log("op", "setBalancer", "error", fmt.Sprintf("invalid LoadBalancer IP %q", svc.Status.LoadBalancer.Ingress[i].IP), "msg", "invalid IP allocated by controller")
+			return c.deleteBalancer(l, name, "invalidIP")
+		}
+		lbIPs = append(lbIPs, lbIP)
 	}
 
-	l = log.With(l, "ip", lbIP)
+	l = log.With(l, "ips", lbIPs)
 
-	poolName := poolFor(c.config.Pools, lbIP)
+	poolName := poolFor(c.config.Pools, lbIPs)
 	if poolName == "" {
 		level.Error(l).Log("op", "setBalancer", "error", "assigned IP not allowed by config", "msg", "IP allocated by controller not allowed by config")
 		return c.deleteBalancer(l, name, "ipNotAllowed")
@@ -277,7 +281,7 @@ func (c *controller) SetBalancer(l log.Logger, name string, svc *v1.Service, eps
 		}
 	}
 
-	if svcIP, ok := c.svcIP[name]; ok && !lbIP.Equal(svcIP) {
+	if svcIPs, ok := c.svcIPs[name]; ok && !compareIPs(lbIPs, svcIPs) {
 		if st := c.deleteBalancer(l, name, "loadBalancerIPChanged"); st == k8s.SyncStateError {
 			return st
 		}
@@ -290,26 +294,28 @@ func (c *controller) SetBalancer(l log.Logger, name string, svc *v1.Service, eps
 		return c.deleteBalancer(l, name, "internalError")
 	}
 
-	if deleteReason := handler.ShouldAnnounce(l, name, lbIP, svc, eps); deleteReason != "" {
+	if deleteReason := handler.ShouldAnnounce(l, name, lbIPs, svc, eps); deleteReason != "" {
 		return c.deleteBalancer(l, name, deleteReason)
 	}
 
-	if err := handler.SetBalancer(l, name, lbIP, pool); err != nil {
+	if err := handler.SetBalancer(l, name, lbIPs, pool); err != nil {
 		level.Error(l).Log("op", "setBalancer", "error", err, "msg", "failed to announce service")
 		return k8s.SyncStateError
 	}
 
 	if c.announced[name] == "" {
 		c.announced[name] = pool.Protocol
-		c.svcIP[name] = lbIP
+		c.svcIPs[name] = lbIPs
 	}
 
-	announcing.With(prometheus.Labels{
-		"protocol": string(pool.Protocol),
-		"service":  name,
-		"node":     c.myNode,
-		"ip":       lbIP.String(),
-	}).Set(1)
+	for _, ip := range lbIPs {
+		announcing.With(prometheus.Labels{
+			"protocol": string(pool.Protocol),
+			"service":  name,
+			"node":     c.myNode,
+			"ip":       ip.String(),
+		}).Set(1)
+	}
 	level.Info(l).Log("event", "serviceAnnounced", "msg", "service has IP, announcing")
 	c.client.Infof(svc, "nodeAssigned", "announcing from node %q", c.myNode)
 
@@ -327,24 +333,33 @@ func (c *controller) deleteBalancer(l log.Logger, name, reason string) k8s.SyncS
 		return k8s.SyncStateError
 	}
 
-	announcing.Delete(prometheus.Labels{
-		"protocol": string(proto),
-		"service":  name,
-		"node":     c.myNode,
-		"ip":       c.svcIP[name].String(),
-	})
+	for _, ip := range c.svcIPs[name] {
+		announcing.Delete(prometheus.Labels{
+			"protocol": string(proto),
+			"service":  name,
+			"node":     c.myNode,
+			"ips":      ip.String(),
+		})
+	}
 	delete(c.announced, name)
-	delete(c.svcIP, name)
+	delete(c.svcIPs, name)
 
-	level.Info(l).Log("event", "serviceWithdrawn", "ip", c.svcIP[name], "reason", reason, "msg", "withdrawing service announcement")
+	level.Info(l).Log("event", "serviceWithdrawn", "ip", c.svcIPs[name], "reason", reason, "msg", "withdrawing service announcement")
 
 	return k8s.SyncStateSuccess
 }
 
-func poolFor(pools map[string]*config.Pool, ip net.IP) string {
+func poolFor(pools map[string]*config.Pool, ips []net.IP) string {
 	for pname, p := range pools {
-		for _, cidr := range p.CIDR {
-			if cidr.Contains(ip) {
+		cnt := 0
+		for _, ip := range ips {
+			for _, cidr := range p.CIDR {
+				if cidr.Contains(ip) {
+					cnt++
+					break
+				}
+			}
+			if cnt == len(ips) {
 				return pname
 			}
 		}
@@ -352,6 +367,26 @@ func poolFor(pools map[string]*config.Pool, ip net.IP) string {
 	return ""
 }
 
+func compareIPs(ips1, ips2 []net.IP) bool {
+	if len(ips1) != len(ips2) {
+		return false
+	}
+
+	for _, ip1 := range ips1 {
+		found := false
+		for _, ip2 := range ips2 {
+			if ip1.Equal(ip2) {
+				found = true
+				break
+			}
+		}
+		if !found {
+			return false
+		}
+	}
+	return true
+}
+
 func (c *controller) SetConfig(l log.Logger, cfg *config.Config) k8s.SyncState {
 
 	level.Debug(l).Log("event", "startUpdate", "msg", "start of config update")
@@ -362,7 +397,7 @@ func (c *controller) SetConfig(l log.Logger, cfg *config.Config) k8s.SyncState {
 		return k8s.SyncStateErrorNoRetry
 	}
 
-	for svc, ip := range c.svcIP {
+	for svc, ip := range c.svcIPs {
 		if pool := poolFor(cfg.Pools, ip); pool == "" {
 			level.Error(l).Log("op", "setConfig", "service", svc, "ip", ip, "error", "service has no configuration under new config", "msg", "new configuration rejected")
 			return k8s.SyncStateError
@@ -394,8 +429,8 @@ func (c *controller) SetNode(l log.Logger, node *v1.Node) k8s.SyncState {
 // A Protocol can advertise an IP address.
 type Protocol interface {
 	SetConfig(log.Logger, *config.Config) error
-	ShouldAnnounce(log.Logger, string, net.IP, *v1.Service, k8s.EpsOrSlices) string
-	SetBalancer(log.Logger, string, net.IP, *config.Pool) error
+	ShouldAnnounce(log.Logger, string, []net.IP, *v1.Service, k8s.EpsOrSlices) string
+	SetBalancer(log.Logger, string, []net.IP, *config.Pool) error
 	DeleteBalancer(log.Logger, string, string) error
 	SetNode(log.Logger, *v1.Node) error
 }
diff --git a/website/content/community/maintainers.md b/website/content/community/maintainers.md
index fffce6a1..c5a01fa6 100644
--- a/website/content/community/maintainers.md
+++ b/website/content/community/maintainers.md
@@ -39,6 +39,15 @@ This can be done via a private discussion via Slack or email. A majority of
 maintainers should support the addition of the new person, and no single
 maintainer should object to adding the new maintainer.
 
+When adding a new maintainer, we should:
+* Add to CODEOWNERS in the metallb repo
+* Open a PR to github.com/cncf/foundation adding the maintainer there. You can
+  use [this PR](https://github.com/cncf/foundation/pull/258/files) as an example
+  of what needs to be done
+* The new maintainer should subscribe to the CNCF maintainers mailing list. For
+  this, send an email to cncf-MetalLB-maintainers+subscribe@lists.cncf.io and
+  link the merged PR that added you to the metallb CODEOWNERS.
+
 ## Removing Maintainers
 
 It is normal for maintainers to come and go based on their other
@@ -51,3 +60,5 @@ If there becomes a conflict among maintainers that may lead to removal, active
 maintainers should first work to resolve the conflict. If absolutely
 necessary, a maintainer may be removed by a majority vote of existing
 maintainers.
+
+Please see all the places that a maintainer was added, to remove them.


Test patch:
diff --git a/controller/controller_test.go b/controller/controller_test.go
index 2ff51d40..6a358e5f 100644
--- a/controller/controller_test.go
+++ b/controller/controller_test.go
@@ -46,14 +46,14 @@ func ipnet(s string) *net.IPNet {
 	return n
 }
 
-func statusAssigned(ip string) v1.ServiceStatus {
+func statusAssigned(ips []string) v1.ServiceStatus {
+	lbIngressIPs := []v1.LoadBalancerIngress{}
+	for _, ip := range ips {
+		lbIngressIPs = append(lbIngressIPs, v1.LoadBalancerIngress{IP: ip})
+	}
 	return v1.ServiceStatus{
 		LoadBalancer: v1.LoadBalancerStatus{
-			Ingress: []v1.LoadBalancerIngress{
-				{
-					IP: ip,
-				},
-			},
+			Ingress: lbIngressIPs,
 		},
 	}
 }
@@ -133,6 +133,11 @@ func TestControllerMutation(t *testing.T) {
 				AutoAssign: false,
 				CIDR:       []*net.IPNet{ipnet("2000::1/128")},
 			},
+			"pool5": {
+				Protocol:   config.BGP,
+				AutoAssign: true,
+				CIDR:       []*net.IPNet{ipnet("1.2.3.0/31"), ipnet("1000::/127")},
+			},
 		},
 	}
 
@@ -163,8 +168,8 @@ func TestControllerMutation(t *testing.T) {
 			desc: "simple non-LoadBalancer",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "ClusterIP",
-					ClusterIP: "1.2.3.4",
+					Type:       "ClusterIP",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
 			},
 		},
@@ -173,16 +178,16 @@ func TestControllerMutation(t *testing.T) {
 			desc: "simple LoadBalancer",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					ClusterIP: "1.2.3.4",
-					Type:      "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
+					Type:       "LoadBalancer",
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 		},
 
@@ -190,18 +195,18 @@ func TestControllerMutation(t *testing.T) {
 			desc: "request specific IP",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1.2.3.1",
 				},
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1.2.3.1",
 				},
-				Status: statusAssigned("1.2.3.1"),
+				Status: statusAssigned([]string{"1.2.3.1"}),
 			},
 		},
 
@@ -211,7 +216,7 @@ func TestControllerMutation(t *testing.T) {
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "please sir may I have an IP address thank you",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
 			},
 			wantErr: true,
@@ -223,15 +228,15 @@ func TestControllerMutation(t *testing.T) {
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1.2.3.4",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.1"),
+				Status: statusAssigned([]string{"1.2.3.1"}),
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1.2.3.4",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
 			},
 			wantErr: true,
@@ -246,8 +251,8 @@ func TestControllerMutation(t *testing.T) {
 					},
 				},
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
 			},
 			want: &v1.Service{
@@ -257,10 +262,10 @@ func TestControllerMutation(t *testing.T) {
 					},
 				},
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 		},
 
@@ -273,10 +278,10 @@ func TestControllerMutation(t *testing.T) {
 					},
 				},
 				Spec: v1.ServiceSpec{
-					ClusterIP: "1.2.3.4",
-					Type:      "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
+					Type:       "LoadBalancer",
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 			want: &v1.Service{
 				ObjectMeta: metav1.ObjectMeta{
@@ -285,10 +290,10 @@ func TestControllerMutation(t *testing.T) {
 					},
 				},
 				Spec: v1.ServiceSpec{
-					ClusterIP: "1.2.3.4",
-					Type:      "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
+					Type:       "LoadBalancer",
 				},
-				Status: statusAssigned("3.4.5.6"),
+				Status: statusAssigned([]string{"3.4.5.6"}),
 			},
 		},
 
@@ -301,8 +306,8 @@ func TestControllerMutation(t *testing.T) {
 					},
 				},
 				Spec: v1.ServiceSpec{
-					ClusterIP: "1.2.3.4",
-					Type:      "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
+					Type:       "LoadBalancer",
 				},
 			},
 			wantErr: true,
@@ -312,17 +317,17 @@ func TestControllerMutation(t *testing.T) {
 			desc: "invalid IP assigned",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("2.3.4.5"),
+				Status: statusAssigned([]string{"2.3.4.5"}),
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 		},
 
@@ -330,8 +335,8 @@ func TestControllerMutation(t *testing.T) {
 			desc: "invalid ingress state",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
 				Status: v1.ServiceStatus{
 					LoadBalancer: v1.LoadBalancerStatus{
@@ -348,10 +353,10 @@ func TestControllerMutation(t *testing.T) {
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 		},
 
@@ -359,15 +364,15 @@ func TestControllerMutation(t *testing.T) {
 			desc: "former LoadBalancer, now NodePort",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "NodePort",
-					ClusterIP: "1.2.3.4",
+					Type:       "NodePort",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "NodePort",
-					ClusterIP: "1.2.3.4",
+					Type:       "NodePort",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
 			},
 		},
@@ -378,16 +383,16 @@ func TestControllerMutation(t *testing.T) {
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "3.4.5.6",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "3.4.5.6",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("3.4.5.6"),
+				Status: statusAssigned([]string{"3.4.5.6"}),
 			},
 		},
 
@@ -398,7 +403,7 @@ func TestControllerMutation(t *testing.T) {
 					Type:                  "LoadBalancer",
 					LoadBalancerIP:        "3.4.5.6",
 					ExternalTrafficPolicy: "Local",
-					ClusterIP:             "1.2.3.4",
+					ClusterIPs:            []string{"1.2.3.4"},
 				},
 			},
 			want: &v1.Service{
@@ -406,9 +411,9 @@ func TestControllerMutation(t *testing.T) {
 					Type:                  "LoadBalancer",
 					LoadBalancerIP:        "3.4.5.6",
 					ExternalTrafficPolicy: "Local",
-					ClusterIP:             "1.2.3.4",
+					ClusterIPs:            []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("3.4.5.6"),
+				Status: statusAssigned([]string{"3.4.5.6"}),
 			},
 		},
 
@@ -428,7 +433,7 @@ func TestControllerMutation(t *testing.T) {
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1.2.3.1",
-					ClusterIP:      "3000::1",
+					ClusterIPs:     []string{"3000::1"},
 				},
 			},
 			wantErr: true,
@@ -440,7 +445,7 @@ func TestControllerMutation(t *testing.T) {
 				Spec: v1.ServiceSpec{
 					Type:           "LoadBalancer",
 					LoadBalancerIP: "1000::",
-					ClusterIP:      "1.2.3.4",
+					ClusterIPs:     []string{"1.2.3.4"},
 				},
 			},
 			wantErr: true,
@@ -450,17 +455,17 @@ func TestControllerMutation(t *testing.T) {
 			desc: "IP from wrong ip-family (ipv6) assigned",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1000::"),
+				Status: statusAssigned([]string{"1000::"}),
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "1.2.3.4",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4"},
 				},
-				Status: statusAssigned("1.2.3.0"),
+				Status: statusAssigned([]string{"1.2.3.0"}),
 			},
 		},
 
@@ -468,17 +473,102 @@ func TestControllerMutation(t *testing.T) {
 			desc: "IP from wrong ip-family (ipv4) assigned",
 			in: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "3000::1",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"3000::1"},
+				},
+				Status: statusAssigned([]string{"1.2.3.0"}),
+			},
+			want: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"3000::1"},
+				},
+				Status: statusAssigned([]string{"1000::"}),
+			},
+		},
+		// dual-stack test cases
+		{
+			desc: "deleted balancer",
+		},
+		{
+			desc: "simple dual-stack LoadBalancer",
+			in: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4", "3000::1"},
+				},
+			},
+			want: &v1.Service{
+				Spec: v1.ServiceSpec{
+					ClusterIPs: []string{"1.2.3.4", "3000::1"},
+					Type:       "LoadBalancer",
+				},
+				Status: statusAssigned([]string{"1.2.3.0", "1000::"}),
+			},
+		},
+		{
+			desc: "request IPs from specific pool",
+			in: &v1.Service{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{
+						"metallb.universe.tf/address-pool": "pool5",
+					},
+				},
+				Spec: v1.ServiceSpec{
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4", "3000::1"},
+				},
+			},
+			want: &v1.Service{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{
+						"metallb.universe.tf/address-pool": "pool5",
+					},
+				},
+				Spec: v1.ServiceSpec{
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"1.2.3.4", "3000::1"},
+				},
+				Status: statusAssigned([]string{"1.2.3.0", "1000::"}),
+			},
+		},
+		{
+			desc: "request specific loadbalancer IP with dual-stack",
+			in: &v1.Service{
+				Spec: v1.ServiceSpec{
+					ClusterIPs:     []string{"1.2.3.4", "3000::1"},
+					Type:           "LoadBalancer",
+					LoadBalancerIP: "1.2.3.1",
+				},
+			},
+			wantErr: true,
+		},
+		{
+			desc: "request dual-stack loadbalancer with invalid ingress",
+			in: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"3000::1", "5.6.7.8"},
+				},
+				Status: v1.ServiceStatus{
+					LoadBalancer: v1.LoadBalancerStatus{
+						Ingress: []v1.LoadBalancerIngress{
+							{
+								Hostname: "foo.bar.local",
+							},
+							{
+								IP: "1000::",
+							},
+						},
+					},
 				},
-				Status: statusAssigned("1.2.3.0"),
 			},
 			want: &v1.Service{
 				Spec: v1.ServiceSpec{
-					Type:      "LoadBalancer",
-					ClusterIP: "3000::1",
+					Type:       "LoadBalancer",
+					ClusterIPs: []string{"3000::1", "5.6.7.8"},
 				},
-				Status: statusAssigned("1000::"),
+				Status: statusAssigned([]string{"1.2.3.0", "1000::"}),
 			},
 		},
 	}
@@ -509,14 +599,11 @@ func TestControllerMutation(t *testing.T) {
 				}
 			}
 
-			if test.want != nil && len(test.want.Status.LoadBalancer.Ingress) > 0 && test.want.Status.LoadBalancer.Ingress[0].IP != "" {
-				ip := net.ParseIP(test.want.Status.LoadBalancer.Ingress[0].IP)
-				if ip == nil {
+			if test.want != nil && len(test.want.Status.LoadBalancer.Ingress) > 0 {
+				ips := test.want.Status.LoadBalancer.Ingress
+				if len(ips) == 0 {
 					panic("bad wanted IP in loadbalancer status")
 				}
-				if !ip.Equal(c.ips.IP("test")) {
-					t.Errorf("%q: controller internal state does not match IP that controller claimed to allocate: want %q, got %q", test.desc, ip, c.ips.IP("test"))
-				}
 			}
 		}
 
@@ -547,8 +634,8 @@ func TestControllerConfig(t *testing.T) {
 	l := log.NewNopLogger()
 	svc := &v1.Service{
 		Spec: v1.ServiceSpec{
-			Type:      "LoadBalancer",
-			ClusterIP: "1.2.3.4",
+			Type:       "LoadBalancer",
+			ClusterIPs: []string{"1.2.3.4"},
 		},
 	}
 	if c.SetBalancer(l, "test", svc, k8s.EpsOrSlices{}) == k8s.SyncStateError {
@@ -615,7 +702,7 @@ func TestControllerConfig(t *testing.T) {
 	gotSvc = k.gotService(svc)
 	wantSvc := new(v1.Service)
 	*wantSvc = *svc
-	wantSvc.Status = statusAssigned("1.2.3.0")
+	wantSvc.Status = statusAssigned([]string{"1.2.3.0"})
 	if diff := diffService(wantSvc, gotSvc); diff != "" {
 		t.Errorf("SetBalancer produced unexpected mutation (-want +got)\n%s", diff)
 	}
@@ -654,8 +741,8 @@ func TestDeleteRecyclesIP(t *testing.T) {
 
 	svc1 := &v1.Service{
 		Spec: v1.ServiceSpec{
-			Type:      "LoadBalancer",
-			ClusterIP: "1.2.3.4",
+			Type:       "LoadBalancer",
+			ClusterIPs: []string{"1.2.3.4"},
 		},
 	}
 	if c.SetBalancer(l, "test", svc1, k8s.EpsOrSlices{}) == k8s.SyncStateError {
@@ -674,8 +761,8 @@ func TestDeleteRecyclesIP(t *testing.T) {
 	// IP because we have none left.
 	svc2 := &v1.Service{
 		Spec: v1.ServiceSpec{
-			Type:      "LoadBalancer",
-			ClusterIP: "1.2.3.4",
+			Type:       "LoadBalancer",
+			ClusterIPs: []string{"1.2.3.4"},
 		},
 	}
 	if c.SetBalancer(l, "test2", svc2, k8s.EpsOrSlices{}) == k8s.SyncStateError {
@@ -703,3 +790,96 @@ func TestDeleteRecyclesIP(t *testing.T) {
 		t.Fatal("svc2 didn't get an IP")
 	}
 }
+func TestControllerDualStackConfig(t *testing.T) {
+	k := &testK8S{t: t}
+	c := &controller{
+		ips:    allocator.New(),
+		client: k,
+	}
+
+	l := log.NewNopLogger()
+	svc := &v1.Service{
+		Spec: v1.ServiceSpec{
+			Type:       "LoadBalancer",
+			ClusterIPs: []string{"1.2.3.4", "1000::"},
+		},
+	}
+	if c.SetBalancer(l, "test", svc, k8s.EpsOrSlices{}) == k8s.SyncStateError {
+		t.Fatalf("SetBalancer failed")
+	}
+
+	gotSvc := k.gotService(svc)
+	if gotSvc != nil {
+		t.Errorf("SetBalancer with no configuration mutated service (-in +out)\n%s", diffService(svc, gotSvc))
+	}
+	if k.loggedWarning {
+		t.Error("SetBalancer with no configuration logged an error")
+	}
+
+	// Set an empty config. Balancer should still not do anything to
+	// our unallocated service, and return an error to force a
+	// retry after sync is complete.
+	if c.SetConfig(l, &config.Config{}) == k8s.SyncStateError {
+		t.Fatalf("SetConfig with empty config failed")
+	}
+	if c.SetBalancer(l, "test", svc, k8s.EpsOrSlices{}) != k8s.SyncStateError {
+		t.Fatal("SetBalancer did not fail")
+	}
+
+	gotSvc = k.gotService(svc)
+	if gotSvc != nil {
+		t.Errorf("unsynced SetBalancer mutated service (-in +out)\n%s", diffService(svc, gotSvc))
+	}
+	if k.loggedWarning {
+		t.Error("unsynced SetBalancer logged an error")
+	}
+
+	// Set a config with some IPs. Still no allocation, not synced.
+	cfg := &config.Config{
+		Pools: map[string]*config.Pool{
+			"default": {
+				AutoAssign: true,
+				CIDR:       []*net.IPNet{ipnet("1.2.3.0/24"), ipnet("1000::1/127")},
+			},
+		},
+	}
+	if c.SetConfig(l, cfg) == k8s.SyncStateError {
+		t.Fatalf("SetConfig failed")
+	}
+	if c.SetBalancer(l, "test", svc, k8s.EpsOrSlices{}) != k8s.SyncStateError {
+		t.Fatal("SetBalancer did not fail")
+	}
+
+	gotSvc = k.gotService(svc)
+	if gotSvc != nil {
+		t.Errorf("unsynced SetBalancer mutated service (-in +out)\n%s", diffService(svc, gotSvc))
+	}
+	if k.loggedWarning {
+		t.Error("unsynced SetBalancer logged an error")
+	}
+
+	// Mark synced. Finally, we can allocate.
+	c.MarkSynced(l)
+
+	if c.SetBalancer(l, "test", svc, k8s.EpsOrSlices{}) == k8s.SyncStateError {
+		t.Fatalf("SetBalancer failed")
+	}
+
+	gotSvc = k.gotService(svc)
+	wantSvc := new(v1.Service)
+	*wantSvc = *svc
+	wantSvc.Status = statusAssigned([]string{"1.2.3.0", "1000::"})
+	if diff := diffService(wantSvc, gotSvc); diff != "" {
+		t.Errorf("SetBalancer produced unexpected mutation (-want +got)\n%s", diff)
+	}
+
+	// Now that an IP is allocated, removing the IP pool is not allowed.
+	if c.SetConfig(l, &config.Config{}) != k8s.SyncStateErrorNoRetry {
+		t.Fatalf("SetConfig that deletes allocated IPs was accepted")
+	}
+
+	// Deleting the config also makes MetalLB sad.
+	if c.SetConfig(l, nil) != k8s.SyncStateErrorNoRetry {
+		t.Fatalf("SetConfig that deletes the config was accepted")
+	}
+}
diff --git a/e2etest/bgp_test.go b/e2etest/bgp_test.go
index 66d38670..6b9933d7 100644
--- a/e2etest/bgp_test.go
+++ b/e2etest/bgp_test.go
@@ -155,23 +155,21 @@ var _ = ginkgo.Describe("BGP", func() {
 			Peers: peersForContainers(frrContainers, pairingIPFamily),
 		}
 		for _, c := range frrContainers {
-			c.RouterConfig.IPFamily = pairingIPFamily
-			c.NeighborConfig.IPFamily = pairingIPFamily
-			pairExternalFRRWithNodes(cs, c)
+			pairExternalFRRWithNodes(cs, c, pairingIPFamily)
 		}
 
 		err := updateConfigMap(cs, configData)
 		framework.ExpectNoError(err)
 
 		for _, c := range frrContainers {
-			validateFRRPeeredWithNodes(cs, c)
+			validateFRRPeeredWithNodes(cs, c, pairingIPFamily)
 		}
 
 		allNodes, err = cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
 		framework.ExpectNoError(err)
 
 		if setProtocoltest == "ExternalTrafficPolicyCluster" {
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, tweak)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", tweak)
 
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
@@ -184,7 +182,7 @@ var _ = ginkgo.Describe("BGP", func() {
 		}
 
 		if setProtocoltest == "ExternalTrafficPolicyLocal" {
-			svc, jig := createServiceWithBackend(cs, f.Namespace.Name, tweak)
+			svc, jig := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", tweak)
 			err = jig.Scale(2)
 			framework.ExpectNoError(err)
 
@@ -213,24 +211,19 @@ var _ = ginkgo.Describe("BGP", func() {
 		table.Entry("IPV6 - ExternalTrafficPolicyCluster", "ipv6", "ExternalTrafficPolicyCluster", []string{v6PoolAddresses}, testservice.TrafficPolicyCluster),
 		table.Entry("IPV6 - ExternalTrafficPolicyLocal", "ipv6", "ExternalTrafficPolicyLocal", []string{v6PoolAddresses}, testservice.TrafficPolicyLocal),
 		table.Entry("IPV6 - FRR running in the speaker POD", "ipv6", "CheckSpeakerFRRPodRunning", []string{v6PoolAddresses}, testservice.TrafficPolicyLocal),
-		table.Entry("DUALSTACK - ExternalTrafficPolicyCluster", "ipv4", "ExternalTrafficPolicyCluster", []string{v4PoolAddresses},
+		table.Entry("DUALSTACK - ExternalTrafficPolicyCluster", "dual", "ExternalTrafficPolicyCluster", []string{v4PoolAddresses, v6PoolAddresses},
 			func(svc *corev1.Service) {
 				testservice.TrafficPolicyCluster(svc)
 				testservice.DualStack(svc)
 			}),
-		table.Entry("DUALSTACK - ExternalTrafficPolicyLocal", "ipv4", "ExternalTrafficPolicyCluster", []string{v4PoolAddresses},
+		table.Entry("DUALSTACK - ExternalTrafficPolicyLocal", "dual", "ExternalTrafficPolicyLocal", []string{v4PoolAddresses, v6PoolAddresses},
 			func(svc *corev1.Service) {
 				testservice.TrafficPolicyLocal(svc)
 				testservice.DualStack(svc)
 			}),
-		table.Entry("DUALSTACK - ExternalTrafficPolicyLocal - ipv6 pairing", "ipv6", "ExternalTrafficPolicyLocal", []string{v4PoolAddresses},
+		table.Entry("DUALSTACK - ExternalTrafficPolicyCluster - force V6 only", "dual", "ExternalTrafficPolicyCluster", []string{v4PoolAddresses, v6PoolAddresses},
 			func(svc *corev1.Service) {
-				testservice.TrafficPolicyLocal(svc)
-				testservice.DualStack(svc)
-			}),
-		table.Entry("DUALSTACK - ExternalTrafficPolicyCluster - force V6 only", "ipv4", "ExternalTrafficPolicyCluster", []string{v4PoolAddresses},
-			func(svc *corev1.Service) {
-				testservice.TrafficPolicyLocal(svc)
+				testservice.TrafficPolicyCluster(svc)
 				testservice.ForceV6(svc)
 			}),
 	)
@@ -254,8 +247,6 @@ var _ = ginkgo.Describe("BGP", func() {
 
 			var peerAddrs []string
 			for _, c := range frrContainers {
-				c.RouterConfig.IPFamily = ipFamily
-				c.NeighborConfig.IPFamily = ipFamily
 				address := c.Ipv4
 				if ipFamily == "ipv6" {
 					address = c.Ipv6
@@ -274,14 +265,14 @@ var _ = ginkgo.Describe("BGP", func() {
 				Peers: peersForContainers(frrContainers, ipFamily),
 			}
 			for _, c := range frrContainers {
-				pairExternalFRRWithNodes(cs, c)
+				pairExternalFRRWithNodes(cs, c, ipFamily)
 			}
 
 			err := updateConfigMap(cs, configData)
 			framework.ExpectNoError(err)
 
 			for _, c := range frrContainers {
-				validateFRRPeeredWithNodes(cs, c)
+				validateFRRPeeredWithNodes(cs, c, ipFamily)
 			}
 
 			ginkgo.By("checking the metrics when no service is added")
@@ -324,7 +315,7 @@ var _ = ginkgo.Describe("BGP", func() {
 			}
 
 			ginkgo.By("creating a service")
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, testservice.TrafficPolicyCluster) // Is a sleep required here?
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", testservice.TrafficPolicyCluster) // Is a sleep required here?
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
 				framework.ExpectNoError(err)
@@ -388,26 +379,22 @@ var _ = ginkgo.Describe("BGP", func() {
 		})
 
 		table.DescribeTable("set different AddressPools ranges modes", func(addressPools []addressPool, pairingFamily string, tweak testservice.Tweak) {
-			for _, c := range frrContainers {
-				c.RouterConfig.IPFamily = pairingFamily
-				c.NeighborConfig.IPFamily = pairingFamily
-			}
 			configData := configFile{
 				Peers: peersForContainers(frrContainers, pairingFamily),
 				Pools: addressPools,
 			}
 			for _, c := range frrContainers {
-				pairExternalFRRWithNodes(cs, c)
+				pairExternalFRRWithNodes(cs, c, pairingFamily)
 			}
 
 			err := updateConfigMap(cs, configData)
 			framework.ExpectNoError(err)
 
 			for _, c := range frrContainers {
-				validateFRRPeeredWithNodes(cs, c)
+				validateFRRPeeredWithNodes(cs, c, pairingFamily)
 			}
 
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, tweak)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", tweak)
 
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
@@ -472,7 +459,7 @@ var _ = ginkgo.Describe("BGP", func() {
 						"192.168.10.0-192.168.10.18",
 						"fc00:f853:0ccd:e799::0-fc00:f853:0ccd:e799::18",
 					},
-				}}, "ipv4", testservice.TrafficPolicyCluster,
+				}}, "dual", testservice.TrafficPolicyCluster,
 			),
 			table.Entry("DUALSTACK - test AddressPool defined by network prefix", []addressPool{
 				{
@@ -482,7 +469,7 @@ var _ = ginkgo.Describe("BGP", func() {
 						"192.168.10.0/24",
 						"fc00:f853:0ccd:e799::/124",
 					},
-				}}, "ipv4", testservice.TrafficPolicyCluster,
+				}}, "dual", testservice.TrafficPolicyCluster,
 			),
 		)
 	})
@@ -504,14 +491,12 @@ var _ = ginkgo.Describe("BGP", func() {
 			framework.ExpectNoError(err)
 
 			for _, c := range frrContainers {
-				c.RouterConfig.IPFamily = pairingFamily
-				c.NeighborConfig.IPFamily = pairingFamily
-				pairExternalFRRWithNodes(cs, c, func(container *frrcontainer.FRR) {
+				pairExternalFRRWithNodes(cs, c, pairingFamily, func(container *frrcontainer.FRR) {
 					container.NeighborConfig.BFDEnabled = true
 				})
 			}
 
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, tweak)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", tweak)
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
 				framework.ExpectNoError(err)
@@ -521,9 +506,8 @@ var _ = ginkgo.Describe("BGP", func() {
 			framework.ExpectNoError(err)
 
 			for _, c := range frrContainers {
-				validateFRRPeeredWithNodes(cs, c)
+				validateFRRPeeredWithNodes(cs, c, pairingFamily)
 			}
-
 			for _, c := range frrContainers {
 				validateService(cs, svc, allNodes.Items, c)
 			}
@@ -535,7 +519,7 @@ var _ = ginkgo.Describe("BGP", func() {
 						return err
 					}
 					toCompare := BFDProfileWithDefaults(bfd)
-					err = frr.BFDPeersMatchNodes(allNodes.Items, bfdPeers)
+					err = frr.BFDPeersMatchNodes(allNodes.Items, bfdPeers, pairingFamily)
 					if err != nil {
 						return err
 					}
@@ -608,20 +592,122 @@ var _ = ginkgo.Describe("BGP", func() {
 					EchoMode:         boolPtr(false),
 					PassiveMode:      boolPtr(false),
 					MinimumTTL:       uint32Ptr(254),
-				}, "ipv4", []string{v4PoolAddresses, v6PoolAddresses}, func(svc *corev1.Service) {
+				}, "dual", []string{v4PoolAddresses, v6PoolAddresses}, func(svc *corev1.Service) {
 					testservice.TrafficPolicyCluster(svc)
 					testservice.DualStack(svc)
 				}),
 		)
 	})
+
+	ginkgo.Context("validate configuration changes", func() {
+		table.DescribeTable("should work after subsequent configuration updates", func(addressRange string, ipFamily string) {
+			var services []*corev1.Service
+			var servicesIngressIP []string
+			var pools []addressPool
+
+			for _, c := range frrContainers {
+				c.RouterConfig.IPFamily = ipFamily
+				c.NeighborConfig.IPFamily = ipFamily
+			}
+
+			allNodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
+			framework.ExpectNoError(err)
+
+			for i := 0; i < 2; i++ {
+				ginkgo.By(fmt.Sprintf("configure addresspool number %d", i+1))
+				firstIP, err := getIPFromRangeByIndex(addressRange, i*10+1)
+				framework.ExpectNoError(err)
+				lastIP, err := getIPFromRangeByIndex(addressRange, i*10+10)
+				framework.ExpectNoError(err)
+				addressesRange := fmt.Sprintf("%s-%s", firstIP, lastIP)
+				pool := addressPool{
+					Name:     fmt.Sprintf("test-addresspool%d", i+1),
+					Protocol: BGP,
+					Addresses: []string{
+						addressesRange,
+					},
+				}
+				pools = append(pools, pool)
+
+				configData := configFile{
+					Pools: pools,
+					Peers: peersForContainers(frrContainers, ipFamily),
+				}
+
+				for _, c := range frrContainers {
+					pairExternalFRRWithNodes(cs, c)
+				}
+
+				err = updateConfigMap(cs, configData)
+				framework.ExpectNoError(err)
+
+				for _, c := range frrContainers {
+					validateFRRPeeredWithNodes(cs, c)
+				}
+
+				ginkgo.By(fmt.Sprintf("configure service number %d", i+1))
+				svc, _ := createServiceWithBackend(cs, f.Namespace.Name, fmt.Sprintf("svc%d", i+1), testservice.TrafficPolicyCluster, func(svc *corev1.Service) {
+					svc.Annotations = map[string]string{"metallb.universe.tf/address-pool": fmt.Sprintf("test-addresspool%d", i+1)}
+				})
+
+				defer func() {
+					err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
+					framework.ExpectNoError(err)
+				}()
+
+				ginkgo.By("validate LoadBalancer IP is in the AddressPool range")
+				ingressIP := e2eservice.GetIngressPoint(
+					&svc.Status.LoadBalancer.Ingress[0])
+				err = validateIPInRange([]addressPool{pool}, ingressIP)
+				framework.ExpectNoError(err)
+
+				services = append(services, svc)
+				servicesIngressIP = append(servicesIngressIP, ingressIP)
+
+				for j := 0; j <= i; j++ {
+					ginkgo.By(fmt.Sprintf("validate service %d IP didn't change", j+1))
+					ip := e2eservice.GetIngressPoint(&services[j].Status.LoadBalancer.Ingress[0])
+					framework.ExpectEqual(ip, servicesIngressIP[j])
+
+					ginkgo.By(fmt.Sprintf("checking connectivity of service %d to its external VIP", j+1))
+					for _, c := range frrContainers {
+						validateService(cs, svc, allNodes.Items, c)
+					}
+				}
+			}
+		},
+			table.Entry("IPV4", "192.168.10.0/24", "ipv4"),
+			table.Entry("IPV6", "fc00:f853:0ccd:e799::/116", "ipv6"))
+
+		table.DescribeTable("configure peers one by one and validate FRR paired with nodes", func(ipFamily string) {
+			for i, c := range frrContainers {
+				ginkgo.By("configure peer")
+				c.RouterConfig.IPFamily = ipFamily
+				c.NeighborConfig.IPFamily = ipFamily
+
+				configData := configFile{
+					Peers: peersForContainers([]*frrcontainer.FRR{c}, ipFamily),
+				}
+				err := updateConfigMap(cs, configData)
+				framework.ExpectNoError(err)
+
+				pairExternalFRRWithNodes(cs, c)
+
+				for j := 0; j <= i; j++ {
+					validateFRRPeeredWithNodes(cs, frrContainers[j])
+				}
+			}
+		},
+			table.Entry("IPV4", "ipv4"),
+			table.Entry("IPV6", "ipv6"))
+	})
 })
 
-func createServiceWithBackend(cs clientset.Interface, namespace string, tweak ...func(svc *corev1.Service)) (*corev1.Service, *e2eservice.TestJig) {
+func createServiceWithBackend(cs clientset.Interface, namespace string, jigName string, tweak ...func(svc *corev1.Service)) (*corev1.Service, *e2eservice.TestJig) {
 	var svc *corev1.Service
 	var err error
 
-	serviceName := "external-local-lb"
-	jig := e2eservice.NewTestJig(cs, namespace, serviceName)
+	jig := e2eservice.NewTestJig(cs, namespace, jigName)
 	timeout := e2eservice.GetServiceLoadBalancerCreationTimeout(cs)
 	svc, err = jig.CreateLoadBalancerService(timeout, func(svc *corev1.Service) {
 		tweakServicePort(svc)
@@ -679,19 +765,19 @@ func validateCounterValue(expectedMax int, metricName string, labels map[string]
 	return nil
 }
 
-func pairExternalFRRWithNodes(cs clientset.Interface, c *frrcontainer.FRR, modifiers ...func(c *frrcontainer.FRR)) {
+func pairExternalFRRWithNodes(cs clientset.Interface, c *frrcontainer.FRR, ipFamily string, modifiers ...func(c *frrcontainer.FRR)) {
 	config := *c
 	for _, m := range modifiers {
 		m(&config)
 	}
-	bgpConfig, err := frrconfig.BGPPeersForAllNodes(cs, config.NeighborConfig, config.RouterConfig)
+	bgpConfig, err := frrconfig.BGPPeersForAllNodes(cs, config.NeighborConfig, config.RouterConfig, ipFamily)
 	framework.ExpectNoError(err)
 
 	err = c.UpdateBGPConfigFile(bgpConfig)
 	framework.ExpectNoError(err)
 }
 
-func validateFRRPeeredWithNodes(cs clientset.Interface, c *frrcontainer.FRR) {
+func validateFRRPeeredWithNodes(cs clientset.Interface, c *frrcontainer.FRR, ipFamily string) {
 	allNodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
 	framework.ExpectNoError(err)
 
@@ -699,7 +785,7 @@ func validateFRRPeeredWithNodes(cs clientset.Interface, c *frrcontainer.FRR) {
 	Eventually(func() error {
 		neighbors, err := frr.NeighborsInfo(c)
 		framework.ExpectNoError(err)
-		err = frr.NeighborsMatchNodes(allNodes.Items, neighbors)
+		err = frr.NeighborsMatchNodes(allNodes.Items, neighbors, ipFamily)
 		return err
 	}, 4*time.Minute, 1*time.Second).Should(BeNil())
 }
@@ -713,6 +799,7 @@ func validateService(cs clientset.Interface, svc *corev1.Service, nodes []corev1
 		framework.ExpectNotEqual(ip1.To4(), ip2.To4())
 	}
 	for _, ip := range svc.Status.LoadBalancer.Ingress {
+
 		ingressIP := e2eservice.GetIngressPoint(&ip)
 		hostport := net.JoinHostPort(ingressIP, port)
 		address := fmt.Sprintf("http://%s/", hostport)
@@ -723,28 +810,31 @@ func validateService(cs clientset.Interface, svc *corev1.Service, nodes []corev1
 				return err
 			}
 
-			advertised := routes.ForIP(ingressIP, c)
-			err = routes.MatchNodes(nodes, advertised)
-			if err != nil {
-				return err
-			}
-
-			frrRoutes, frrRoutesV6, err := frr.Routes(c)
+			frrRoutesV4, frrRoutesV6, err := frr.Routes(c)
 			if err != nil {
 				return err
 			}
-			routes, ok := frrRoutes[ingressIP]
+			serviceIPFamily := "ipv4"
+			frrRoutes, ok := frrRoutesV4[ingressIP]
 			if !ok {
-				routes, ok = frrRoutesV6[ingressIP]
+				frrRoutes, ok = frrRoutesV6[ingressIP]
+				serviceIPFamily = "ipv6"
 			}
 			if !ok {
-				return fmt.Errorf("%s not found in frr routes %v %v", ingressIP, frrRoutes, frrRoutesV6)
+				return fmt.Errorf("%s not found in frr routes %v %v", ingressIP, frrRoutesV4, frrRoutesV6)
 			}
 
-			err = frr.RoutesMatchNodes(nodes, routes)
+			err = frr.RoutesMatchNodes(nodes, frrRoutes, serviceIPFamily)
 			if err != nil {
 				return err
 			}
+
+			advertised := routes.ForIP(ingressIP, c)
+			err = routes.MatchNodes(nodes, advertised, serviceIPFamily)
+			if err != nil {
+				return err
+			}
+
 			return nil
 		}, 4*time.Minute, 1*time.Second).Should(BeNil())
 	}
@@ -755,20 +845,19 @@ func frrIsPairedOnPods(cs clientset.Interface, n *frrcontainer.FRR, ipFamily str
 	podExecutor := executor.ForPod(testNameSpace, pods[0].Name, "frr")
 
 	Eventually(func() error {
-		address := n.Ipv4
-		if ipFamily == "ipv6" {
-			address = n.Ipv6
-		}
-		toParse, err := podExecutor.Exec("vtysh", "-c", fmt.Sprintf("show bgp neighbor %s json", address))
-		if err != nil {
-			return err
-		}
-		res, err := frr.NeighborConnected(toParse)
-		if err != nil {
-			return err
-		}
-		if res != true {
-			return fmt.Errorf("expecting neighbor %s to be connected", n.Ipv4)
+		addresses := n.AddressesForFamily(ipFamily)
+		for _, address := range addresses {
+			toParse, err := podExecutor.Exec("vtysh", "-c", fmt.Sprintf("show bgp neighbor %s json", address))
+			if err != nil {
+				return err
+			}
+			res, err := frr.NeighborConnected(toParse)
+			if err != nil {
+				return err
+			}
+			if res != true {
+				return fmt.Errorf("expecting neighbor %s to be connected", n.Ipv4)
+			}
 		}
 		return nil
 	}, 4*time.Minute, 1*time.Second).Should(BeNil())
@@ -807,24 +896,24 @@ func stopFRRContainers(containers []*frrcontainer.FRR) error {
 
 func peersForContainers(containers []*frrcontainer.FRR, ipFamily string) []peer {
 	var peers []peer
+
 	for i, c := range frrContainers {
-		address := c.Ipv4
-		if ipFamily == "ipv6" {
-			address = c.Ipv6
-		}
+		addresses := c.AddressesForFamily(ipFamily)
 		holdTime := ""
 		if i > 0 {
 			holdTime = fmt.Sprintf("%ds", i*180)
 		}
-		peers = append(peers, peer{
-			Addr:     address,
-			ASN:      c.RouterConfig.ASN,
-			MyASN:    c.NeighborConfig.ASN,
-			Port:     c.RouterConfig.BGPPort,
-			RouterID: fmt.Sprintf(baseRouterID, i),
-			Password: c.RouterConfig.Password,
-			HoldTime: holdTime,
-		})
+		for _, address := range addresses {
+			peers = append(peers, peer{
+				Addr:     address,
+				ASN:      c.RouterConfig.ASN,
+				MyASN:    c.NeighborConfig.ASN,
+				Port:     c.RouterConfig.BGPPort,
+				RouterID: fmt.Sprintf(baseRouterID, i),
+				Password: c.RouterConfig.Password,
+				HoldTime: holdTime,
+			})
+		}
 	}
 	return peers
 }
diff --git a/e2etest/layer2_test.go b/e2etest/layer2_test.go
index 43c1cf43..b7568750 100644
--- a/e2etest/layer2_test.go
+++ b/e2etest/layer2_test.go
@@ -31,6 +31,7 @@ import (
 	"github.com/onsi/ginkgo"
 	"github.com/onsi/ginkgo/extensions/table"
 	"github.com/onsi/gomega"
+	pkgerrors "github.com/pkg/errors"
 	"go.universe.tf/metallb/e2etest/pkg/executor"
 	"go.universe.tf/metallb/e2etest/pkg/mac"
 	"go.universe.tf/metallb/e2etest/pkg/metrics"
@@ -80,7 +81,7 @@ var _ = ginkgo.Describe("L2", func() {
 
 	ginkgo.Context("type=Loadbalancer", func() {
 		ginkgo.It("should work for ExternalTrafficPolicy=Cluster", func() {
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, testservice.TrafficPolicyCluster)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", testservice.TrafficPolicyCluster)
 
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
@@ -100,7 +101,7 @@ var _ = ginkgo.Describe("L2", func() {
 		})
 
 		ginkgo.It("should work for ExternalTrafficPolicy=Local", func() {
-			svc, jig := createServiceWithBackend(cs, f.Namespace.Name, testservice.TrafficPolicyLocal)
+			svc, jig := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", testservice.TrafficPolicyLocal)
 			err := jig.Scale(5)
 			framework.ExpectNoError(err)
 
@@ -148,7 +149,7 @@ var _ = ginkgo.Describe("L2", func() {
 			err := updateConfigMap(cs, configData)
 			framework.ExpectNoError(err)
 
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, testservice.TrafficPolicyCluster)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", testservice.TrafficPolicyCluster)
 
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
@@ -214,7 +215,8 @@ var _ = ginkgo.Describe("L2", func() {
 
 		jig1 := e2eservice.NewTestJig(cs, namespace, "svca")
 
-		ip := firstIPFromRange(*ipRange)
+		ip, err := getIPFromRangeByIndex(*ipRange, 0)
+		framework.ExpectNoError(err)
 		svc1, err := jig1.CreateLoadBalancerService(loadBalancerCreateTimeout, func(svc *corev1.Service) {
 			svc.Spec.Ports[0].TargetPort = intstr.FromInt(servicePodPort)
 			svc.Spec.Ports[0].Port = int32(servicePodPort)
@@ -351,7 +353,7 @@ var _ = ginkgo.Describe("L2", func() {
 			}, 2*time.Minute, 1*time.Second).Should(gomega.BeNil())
 
 			ginkgo.By("creating a service")
-			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, testservice.TrafficPolicyCluster)
+			svc, _ := createServiceWithBackend(cs, f.Namespace.Name, "external-local-lb", testservice.TrafficPolicyCluster)
 			defer func() {
 				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
 				framework.ExpectNoError(err)
@@ -434,13 +436,100 @@ var _ = ginkgo.Describe("L2", func() {
 			table.Entry("IPV4 - Checking service", "ipv4"),
 			table.Entry("IPV6 - Checking service", "ipv6"))
 	})
+
+	table.DescribeTable("validate requesting a specific address pool for Loadbalancer service", func(ipRange *string) {
+		var services []*corev1.Service
+		var servicesIngressIP []string
+		var pools []addressPool
+
+		namspace := f.Namespace.Name
+
+		for i := 0; i < 2; i++ {
+			ginkgo.By(fmt.Sprintf("configure addresspool number %d", i+1))
+			ip, err := getIPFromRangeByIndex(*ipRange, i)
+			framework.ExpectNoError(err)
+			addressesRange := fmt.Sprintf("%s-%s", ip, ip)
+			pool := addressPool{
+				Name:     fmt.Sprintf("test-addresspool%d", i+1),
+				Protocol: Layer2,
+				Addresses: []string{
+					addressesRange,
+				},
+			}
+			pools = append(pools, pool)
+
+			configData := configFile{
+				Pools: pools,
+			}
+			err = updateConfigMap(cs, configData)
+			framework.ExpectNoError(err)
+
+			ginkgo.By(fmt.Sprintf("configure service number %d", i+1))
+			jig := e2eservice.NewTestJig(cs, namspace, fmt.Sprintf("test-service%d", i+1))
+			timeout := e2eservice.GetServiceLoadBalancerCreationTimeout(cs)
+			svc, err := jig.CreateLoadBalancerService(timeout, func(svc *corev1.Service) {
+				tweakServicePort(svc)
+				svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyTypeCluster
+				svc.Annotations = map[string]string{"metallb.universe.tf/address-pool": fmt.Sprintf("test-addresspool%d", i+1)}
+			})
+			framework.ExpectNoError(err)
+
+			_, err = jig.Run(func(rc *corev1.ReplicationController) {
+				tweakRCPort(rc)
+			})
+			framework.ExpectNoError(err)
+
+			defer func() {
+				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
+				framework.ExpectNoError(err)
+			}()
+
+			ginkgo.By("validate LoadBalancer IP is in the AddressPool range")
+			ingressIP := e2eservice.GetIngressPoint(&svc.Status.LoadBalancer.Ingress[0])
+			err = validateIPInRange([]addressPool{pool}, ingressIP)
+			framework.ExpectNoError(err)
+
+			services = append(services, svc)
+			servicesIngressIP = append(servicesIngressIP, ingressIP)
+
+			for j := 0; j <= i; j++ {
+				ginkgo.By(fmt.Sprintf("validate service %d IP didn't change", j+1))
+				ip := e2eservice.GetIngressPoint(&services[j].Status.LoadBalancer.Ingress[0])
+				framework.ExpectEqual(ip, servicesIngressIP[j])
+
+				ginkgo.By(fmt.Sprintf("checking connectivity of service %d to its external VIP", j+1))
+				port := strconv.Itoa(int(services[j].Spec.Ports[0].Port))
+				hostport := net.JoinHostPort(ip, port)
+				address := fmt.Sprintf("http://%s/", hostport)
+				err = wgetRetry(address, executor.Host)
+				framework.ExpectNoError(err)
+			}
+		}
+	},
+		table.Entry("IPV4", &ipv4ServiceRange),
+		table.Entry("IPV6", &ipv6ServiceRange))
 })
 
-func firstIPFromRange(ipRange string) string {
-	cidr, err := internalconfig.ParseCIDR(ipRange)
-	framework.ExpectNoError(err)
-	c := ipaddr.NewCursor([]ipaddr.Prefix{*ipaddr.NewPrefix(cidr[0])})
-	return c.First().IP.String()
+func getIPFromRangeByIndex(ipRange string, index int) (string, error) {
+	cidrs, err := internalconfig.ParseCIDR(ipRange)
+	if err != nil {
+		return "", pkgerrors.Wrapf(err, "Failed to parse CIDR while getting IP from range by index")
+	}
+
+	i := 0
+	var c *ipaddr.Cursor
+	for _, cidr := range cidrs {
+		c = ipaddr.NewCursor([]ipaddr.Prefix{*ipaddr.NewPrefix(cidr)})
+		for i < index && c.Next() != nil {
+			i++
+		}
+		if i == index {
+			return c.Pos().IP.String(), nil
+		}
+		i++
+	}
+
+	return "", fmt.Errorf("failed to get IP in index %d from range %s", index, ipRange)
 }
 
 // Relies on the endpoint being an agnhost netexec pod.
diff --git a/e2etest/pkg/frr/config/config.go b/e2etest/pkg/frr/config/config.go
index 1045b319..fae4cb68 100644
--- a/e2etest/pkg/frr/config/config.go
+++ b/e2etest/pkg/frr/config/config.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/pkg/errors"
 	consts "go.universe.tf/metallb/e2etest/pkg/frr/consts"
+	"go.universe.tf/metallb/e2etest/pkg/k8s"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clientset "k8s.io/client-go/kubernetes"
 )
@@ -36,24 +37,33 @@ router bgp {{.ASN}}
   neighbor {{.Addr}} bfd
 {{- end -}}
 {{- end }}
-  address-family {{.IPFamily}} unicast
-{{range .Neighbors }}
+{{- if ne (len .V4Neighbors) 0}}
+  address-family ipv4 unicast
+{{range .V4Neighbors }}
     neighbor {{.Addr}} next-hop-self
     neighbor {{.Addr}} activate
-    {{ if eq .IPFamily "ipv6" -}}
-    neighbor {{.Addr}} route-map RMAP in
-    {{- end }}
 {{- end }}
   exit-address-family
+{{- end }}
+{{- if ne (len .V6Neighbors) 0}}
+  address-family ipv6 unicast
+{{range .V6Neighbors }}
+    neighbor {{.Addr}} next-hop-self
+    neighbor {{.Addr}} activate
+    neighbor {{.Addr}} route-map RMAP in
+{{- end }}
+exit-address-family
+{{- end }}
 
 `
 
 type RouterConfig struct {
-	ASN       uint32
-	Neighbors []*NeighborConfig
-	BGPPort   uint16
-	Password  string
-	IPFamily  string
+	ASN         uint32
+	Neighbors   []*NeighborConfig
+	V4Neighbors []*NeighborConfig
+	V6Neighbors []*NeighborConfig
+	BGPPort     uint16
+	Password    string
 }
 
 type NeighborConfig struct {
@@ -66,9 +76,11 @@ type NeighborConfig struct {
 
 // Set the IP of each node in the cluster in the BGP router configuration.
 // Each node will peer with the BGP router.
-func BGPPeersForAllNodes(cs clientset.Interface, nc NeighborConfig, rc RouterConfig) (string, error) {
+func BGPPeersForAllNodes(cs clientset.Interface, nc NeighborConfig, rc RouterConfig, ipFamily string) (string, error) {
 	router := rc
 
+	router.V4Neighbors = make([]*NeighborConfig, 0)
+	router.V6Neighbors = make([]*NeighborConfig, 0)
 	router.Neighbors = make([]*NeighborConfig, 0)
 
 	nodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
@@ -76,16 +88,20 @@ func BGPPeersForAllNodes(cs clientset.Interface, nc NeighborConfig, rc RouterCon
 		return "", errors.Wrapf(err, "Failed to get cluster nodes")
 	}
 
-	// TODO: The IP address handling will need updates to add support for dual stack
-	for _, node := range nodes.Items {
-		for i := range node.Status.Addresses {
-			if node.Status.Addresses[i].Type == "InternalIP" {
-				neighbor := nc
-				neighbor.Addr = node.Status.Addresses[i].Address
-				router.Neighbors = append(router.Neighbors, &neighbor)
-			}
+	ips := k8s.NodeIPsForFamily(nodes.Items, ipFamily)
+	for _, ip := range ips {
+		neighbor := nc
+		neighbor.Addr = ip
+		nc.IPFamily = k8s.IPFamilyForAddress(ip)
+
+		if nc.IPFamily == "ipv4" {
+			router.V4Neighbors = append(router.V4Neighbors, &neighbor)
+		} else {
+			router.V6Neighbors = append(router.V6Neighbors, &neighbor)
 		}
+		router.Neighbors = append(router.Neighbors, &neighbor)
 	}
+
 	t, err := template.New("bgp Config Template").Parse(bgpConfigTemplate)
 	if err != nil {
 		return "", errors.Wrapf(err, "Failed to create bgp template")
diff --git a/e2etest/pkg/frr/container/container.go b/e2etest/pkg/frr/container/container.go
index 208c4742..97ae380b 100644
--- a/e2etest/pkg/frr/container/container.go
+++ b/e2etest/pkg/frr/container/container.go
@@ -159,6 +159,17 @@ func (c *FRR) Stop() error {
 	return nil
 }
 
+func (c *FRR) AddressesForFamily(ipFamily string) []string {
+	addresses := []string{c.Ipv4}
+	switch ipFamily {
+	case "ipv6":
+		addresses = []string{c.Ipv6}
+	case "dual":
+		addresses = []string{c.Ipv4, c.Ipv6}
+	}
+	return addresses
+}
+
 // Change volume permissions.
 // Allows deleting the test directory or updating the files in the volume.
 func (c *FRR) updateVolumePermissions() error {
diff --git a/e2etest/pkg/frr/validation.go b/e2etest/pkg/frr/validation.go
index 517549b0..13358006 100644
--- a/e2etest/pkg/frr/validation.go
+++ b/e2etest/pkg/frr/validation.go
@@ -7,6 +7,7 @@ import (
 
 	v1 "k8s.io/api/core/v1"
 
+	"go.universe.tf/metallb/e2etest/pkg/k8s"
 	bgpfrr "go.universe.tf/metallb/internal/bgp/frr"
 )
 
@@ -14,15 +15,12 @@ import (
 // frr instance. We only care about established connections, as the
 // frr instance may be configured with more nodes than are currently
 // paired.
-func NeighborsMatchNodes(nodes []v1.Node, neighbors []*bgpfrr.Neighbor) error {
+func NeighborsMatchNodes(nodes []v1.Node, neighbors []*bgpfrr.Neighbor, ipFamily string) error {
 	nodesIPs := map[string]struct{}{}
 
-	for _, n := range nodes {
-		for _, a := range n.Status.Addresses {
-			if a.Type == v1.NodeInternalIP {
-				nodesIPs[a.Address] = struct{}{}
-			}
-		}
+	ips := k8s.NodeIPsForFamily(nodes, ipFamily)
+	for _, ip := range ips {
+		nodesIPs[ip] = struct{}{}
 	}
 	for _, n := range neighbors {
 		if _, ok := nodesIPs[n.Ip.String()]; !ok { // skipping neighbors that are not nodes
@@ -41,15 +39,12 @@ func NeighborsMatchNodes(nodes []v1.Node, neighbors []*bgpfrr.Neighbor) error {
 
 // RoutesMatchNodes tells if ALL the given nodes are exposed as
 // destinations for the given address.
-func RoutesMatchNodes(nodes []v1.Node, route bgpfrr.Route) error {
+func RoutesMatchNodes(nodes []v1.Node, route bgpfrr.Route, ipFamily string) error {
 	nodesIPs := map[string]struct{}{}
 
-	for _, n := range nodes {
-		for _, a := range n.Status.Addresses {
-			if a.Type == v1.NodeInternalIP {
-				nodesIPs[a.Address] = struct{}{}
-			}
-		}
+	ips := k8s.NodeIPsForFamily(nodes, ipFamily)
+	for _, ip := range ips {
+		nodesIPs[ip] = struct{}{}
 	}
 	for _, h := range route.NextHops {
 		if _, ok := nodesIPs[h.String()]; !ok { // skipping neighbors that are not nodes
@@ -64,19 +59,16 @@ func RoutesMatchNodes(nodes []v1.Node, route bgpfrr.Route) error {
 	return nil
 }
 
-func BFDPeersMatchNodes(nodes []v1.Node, peers map[string]bgpfrr.BFDPeer) error {
+func BFDPeersMatchNodes(nodes []v1.Node, peers map[string]bgpfrr.BFDPeer, ipFamily string) error {
 	nodesIPs := map[string]struct{}{}
-
-	for _, n := range nodes {
-		for _, a := range n.Status.Addresses {
-			if a.Type == v1.NodeInternalIP {
-				nodesIPs[a.Address] = struct{}{}
-				if _, ok := peers[a.Address]; !ok {
-					return fmt.Errorf("address %s not found in peers", a.Address)
-				}
-			}
+	ips := k8s.NodeIPsForFamily(nodes, ipFamily)
+	for _, ip := range ips {
+		nodesIPs[ip] = struct{}{}
+		if _, ok := peers[ip]; !ok {
+			return fmt.Errorf("address %s not found in peers", ip)
 		}
 	}
+
 	for k := range peers {
 		if _, ok := nodesIPs[k]; !ok { // skipping neighbors that are not nodes
 			return fmt.Errorf("%s not found in nodes ips", k)
diff --git a/e2etest/pkg/k8s/nodes.go b/e2etest/pkg/k8s/nodes.go
new file mode 100644
index 00000000..3a70115d
--- /dev/null
+++ b/e2etest/pkg/k8s/nodes.go
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package k8s
+
+import (
+	"net"
+
+	v1 "k8s.io/api/core/v1"
+)
+
+func NodeIPsForFamily(nodes []v1.Node, family string) []string {
+	res := []string{}
+	for _, n := range nodes {
+		for _, a := range n.Status.Addresses {
+			if a.Type == v1.NodeInternalIP {
+				if family != "dual" && IPFamilyForAddress(a.Address) != family {
+					continue
+				}
+				res = append(res, a.Address)
+			}
+		}
+	}
+	return res
+}
+
+func IPFamilyForAddress(ip string) string {
+	ipNet := net.ParseIP(ip)
+	if ipNet.To4() == nil {
+		return "ipv6"
+	}
+	return "ipv4"
+}
diff --git a/e2etest/pkg/routes/routes.go b/e2etest/pkg/routes/routes.go
index 804666f1..7275bb2c 100644
--- a/e2etest/pkg/routes/routes.go
+++ b/e2etest/pkg/routes/routes.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"go.universe.tf/metallb/e2etest/pkg/executor"
+	"go.universe.tf/metallb/e2etest/pkg/k8s"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/kubernetes/test/e2e/framework"
 )
@@ -68,15 +69,12 @@ func ForIP(target string, exec executor.Executor) []net.IP {
 
 // MatchNodes tells whether the given list of destination ips
 // matches the expected list of nodes.
-func MatchNodes(nodes []v1.Node, ips []net.IP) error {
+func MatchNodes(nodes []v1.Node, ips []net.IP, ipFamily string) error {
 	nodesIPs := map[string]struct{}{}
 
-	for _, n := range nodes {
-		for _, a := range n.Status.Addresses {
-			if a.Type == v1.NodeInternalIP {
-				nodesIPs[a.Address] = struct{}{}
-			}
-		}
+	ii := k8s.NodeIPsForFamily(nodes, ipFamily)
+	for _, ip := range ii {
+		nodesIPs[ip] = struct{}{}
 	}
 	for _, ip := range ips {
 		if _, ok := nodesIPs[ip.String()]; !ok {
diff --git a/internal/allocator/allocator_test.go b/internal/allocator/allocator_test.go
index 9c98f0ab..c7432617 100644
--- a/internal/allocator/allocator_test.go
+++ b/internal/allocator/allocator_test.go
@@ -5,11 +5,13 @@ package allocator
 import (
 	"math"
 	"net"
+	"reflect"
 	"strconv"
 	"strings"
 	"testing"
 
 	"go.universe.tf/metallb/internal/config"
+	"go.universe.tf/metallb/internal/ipfamily"
 
 	ptu "github.com/prometheus/client_golang/prometheus/testutil"
 )
@@ -39,7 +41,7 @@ func TestAssignment(t *testing.T) {
 	tests := []struct {
 		desc       string
 		svc        string
-		ip         string
+		ips        []string
 		ports      []Port
 		sharingKey string
 		backendKey string
@@ -48,66 +50,66 @@ func TestAssignment(t *testing.T) {
 		{
 			desc: "assign s1",
 			svc:  "s1",
-			ip:   "1.2.3.4",
+			ips:  []string{"1.2.3.4"},
 		},
 		{
 			desc: "s1 idempotent reassign",
 			svc:  "s1",
-			ip:   "1.2.3.4",
+			ips:  []string{"1.2.3.4"},
 		},
 		{
 			desc:    "s2 can't grab s1's IP",
 			svc:     "s2",
-			ip:      "1.2.3.4",
+			ips:     []string{"1.2.3.4"},
 			wantErr: true,
 		},
 		{
 			desc: "s2 can get the other IP",
 			svc:  "s2",
-			ip:   "1.2.3.5",
+			ips:  []string{"1.2.3.5"},
 		},
 		{
 			desc:    "s1 now can't grab s2's IP",
 			svc:     "s1",
-			ip:      "1.2.3.5",
+			ips:     []string{"1.2.3.5"},
 			wantErr: true,
 		},
 		{
 			desc: "s1 frees its IP",
 			svc:  "s1",
-			ip:   "",
+			ips:  []string{},
 		},
 		{
 			desc: "s2 can grab s1's former IP",
 			svc:  "s2",
-			ip:   "1.2.3.4",
+			ips:  []string{"1.2.3.4"},
 		},
 		{
 			desc: "s1 can now grab s2's former IP",
 			svc:  "s1",
-			ip:   "1.2.3.5",
+			ips:  []string{"1.2.3.5"},
 		},
 		{
 			desc:    "s3 cannot grab a 0 buggy IP",
 			svc:     "s3",
-			ip:      "1.2.4.0",
+			ips:     []string{"1.2.4.0"},
 			wantErr: true,
 		},
 		{
 			desc:    "s3 cannot grab a 255 buggy IP",
 			svc:     "s3",
-			ip:      "1.2.4.255",
+			ips:     []string{"1.2.4.255"},
 			wantErr: true,
 		},
 		{
 			desc: "s3 can grab another IP in that pool",
 			svc:  "s3",
-			ip:   "1.2.4.254",
+			ips:  []string{"1.2.4.254"},
 		},
 		{
 			desc:       "s4 takes an IP, with sharing",
 			svc:        "s4",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "sharing",
 			backendKey: "backend",
@@ -115,7 +117,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s4 changes its sharing key in place",
 			svc:        "s4",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -123,7 +125,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (port conflict)",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -132,7 +134,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (wrong sharing key)",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "othershare",
 			backendKey: "backend",
@@ -141,7 +143,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (wrong backend key)",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "otherbackend",
@@ -150,7 +152,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 takes the same IP as s4",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -158,7 +160,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can change its ports while keeping the same IP",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("udp/53"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -166,7 +168,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't change its sharing key while keeping the same IP",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "othershare",
 			backendKey: "backend",
@@ -175,7 +177,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't change its backend key while keeping the same IP",
 			svc:        "s3",
-			ip:         "1.2.4.3",
+			ips:        []string{"1.2.4.3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "otherbackend",
@@ -184,62 +186,62 @@ func TestAssignment(t *testing.T) {
 		{
 			desc: "s4 takes s3's former IP",
 			svc:  "s4",
-			ip:   "1.2.4.254",
+			ips:  []string{"1.2.4.254"},
 		},
 
 		// IPv6 tests (same as ipv4 but with ipv6 addresses)
 		{
 			desc: "ipv6 assign s1",
 			svc:  "s1",
-			ip:   "1000::4",
+			ips:  []string{"1000::4"},
 		},
 		{
 			desc: "s1 idempotent reassign",
 			svc:  "s1",
-			ip:   "1000::4",
+			ips:  []string{"1000::4"},
 		},
 		{
 			desc:    "s2 can't grab s1's IP",
 			svc:     "s2",
-			ip:      "1000::4",
+			ips:     []string{"1000::4"},
 			wantErr: true,
 		},
 		{
 			desc: "s2 can get the other IP",
 			svc:  "s2",
-			ip:   "1000::4:5",
+			ips:  []string{"1000::4:5"},
 		},
 		{
 			desc:    "s1 now can't grab s2's IP",
 			svc:     "s1",
-			ip:      "1000::4:5",
+			ips:     []string{"1000::4:5"},
 			wantErr: true,
 		},
 		{
 			desc: "s1 frees its IP",
 			svc:  "s1",
-			ip:   "",
+			ips:  []string{},
 		},
 		{
 			desc: "s2 can grab s1's former IP",
 			svc:  "s2",
-			ip:   "1000::4",
+			ips:  []string{"1000::4"},
 		},
 		{
 			desc: "s1 can now grab s2's former IP",
 			svc:  "s1",
-			ip:   "1000::4:5",
+			ips:  []string{"1000::4:5"},
 		},
 		// (buggy-IP N/A for ipv6)
 		{
 			desc: "s3 can grab another IP in that pool",
 			svc:  "s3",
-			ip:   "1000::4:ff",
+			ips:  []string{"1000::4:ff"},
 		},
 		{
 			desc:       "s4 takes an IP, with sharing",
 			svc:        "s4",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "sharing",
 			backendKey: "backend",
@@ -247,7 +249,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s4 changes its sharing key in place",
 			svc:        "s4",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -255,7 +257,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (port conflict)",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -264,7 +266,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (wrong sharing key)",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "othershare",
 			backendKey: "backend",
@@ -273,7 +275,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't share with s4 (wrong backend key)",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "otherbackend",
@@ -282,7 +284,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 takes the same IP as s4",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -290,7 +292,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can change its ports while keeping the same IP",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("udp/53"),
 			sharingKey: "share",
 			backendKey: "backend",
@@ -298,7 +300,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't change its sharing key while keeping the same IP",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "othershare",
 			backendKey: "backend",
@@ -307,7 +309,7 @@ func TestAssignment(t *testing.T) {
 		{
 			desc:       "s3 can't change its backend key while keeping the same IP",
 			svc:        "s3",
-			ip:         "1000::4:3",
+			ips:        []string{"1000::4:3"},
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
 			backendKey: "otherbackend",
@@ -316,36 +318,56 @@ func TestAssignment(t *testing.T) {
 		{
 			desc: "s4 takes s3's former IP",
 			svc:  "s4",
-			ip:   "1000::4:ff",
+			ips:  []string{"1000::4:ff"},
+		},
+		// IP dual-stack test
+		{
+			desc: "s2 frees its IP",
+			svc:  "s2",
+			ips:  []string{},
+		},
+		{
+			desc: "ip dual-stack assign s1",
+			svc:  "s1",
+			ips:  []string{"1.2.3.4", "1000::4"},
+		},
+		{
+			desc:    "ip dual-stack assign s1 from different pools",
+			svc:     "s1",
+			ips:     []string{"1.2.4.5", "1000::4"},
+			wantErr: true,
 		},
 	}
 
 	for _, test := range tests {
-		if test.ip == "" {
+		if len(test.ips) == 0 {
 			alloc.Unassign(test.svc)
 			continue
 		}
-		ip := net.ParseIP(test.ip)
-		if ip == nil {
-			t.Fatalf("invalid IP %q in test %q", test.ip, test.desc)
+		ips := []net.IP{}
+		for _, ip := range test.ips {
+			ips = append(ips, net.ParseIP(ip))
+		}
+		if len(ips) == 0 {
+			t.Fatalf("invalid IPs %q in test %q", ips, test.desc)
 		}
-		alreadyHasIP := assigned(alloc, test.svc) == test.ip
-		err := alloc.Assign(test.svc, ip, test.ports, test.sharingKey, test.backendKey)
+		alreadyHasIPs := reflect.DeepEqual(assigned(alloc, test.svc), test.ips)
+		err := alloc.Assign(test.svc, ips, test.ports, test.sharingKey, test.backendKey)
 		if test.wantErr {
 			if err == nil {
 				t.Errorf("%q should have caused an error, but did not", test.desc)
-			} else if a := assigned(alloc, test.svc); !alreadyHasIP && a == test.ip {
-				t.Errorf("%q: Assign(%q, %q) failed, but allocator did record allocation", test.desc, test.svc, test.ip)
+			} else if a := assigned(alloc, test.svc); !alreadyHasIPs && reflect.DeepEqual(a, test.ips) {
+				t.Errorf("%q: Assign(%q, %q) failed, but allocator did record allocation", test.desc, test.svc, test.ips)
 			}
 
 			continue
 		}
 
 		if err != nil {
-			t.Errorf("%q: Assign(%q, %q): %s", test.desc, test.svc, test.ip, err)
+			t.Errorf("%q: Assign(%q, %q): %s", test.desc, test.svc, test.ips, err)
 		}
-		if a := assigned(alloc, test.svc); a != test.ip {
-			t.Errorf("%q: ran Assign(%q, %q), but allocator has recorded allocation of %q", test.desc, test.svc, test.ip, a)
+		if a := assigned(alloc, test.svc); !reflect.DeepEqual(a, test.ips) {
+			t.Errorf("%q: ran Assign(%q, %q), but allocator has recorded allocation of %q", test.desc, test.svc, test.ips, a)
 		}
 	}
 }
@@ -389,6 +411,16 @@ func TestPoolAllocation(t *testing.T) {
 		"2000::":  true,
 		"2000::1": true,
 	}
+	validIPDualStacks := map[string]bool{
+		"1.2.3.4":  true,
+		"1.2.3.5":  true,
+		"1.2.3.10": true,
+		"1.2.3.11": true,
+		"1000::":   true,
+		"1000::1":  true,
+		"2000::":   true,
+		"2000::1":  true,
+	}
 
 	tests := []struct {
 		desc       string
@@ -397,64 +429,76 @@ func TestPoolAllocation(t *testing.T) {
 		sharingKey string
 		unassign   bool
 		wantErr    bool
-		isIPv6     bool
+		ipFamily   ipfamily.Family
 	}{
 		{
-			desc: "s1 gets an IP",
-			svc:  "s1",
+			desc:     "s1 gets an IPv4",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s2 gets an IP",
-			svc:  "s2",
+			desc:     "s2 gets an IPv4",
+			svc:      "s2",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s3 gets an IP",
-			svc:  "s3",
+			desc:     "s3 gets an IPv4",
+			svc:      "s3",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s4 gets an IP",
-			svc:  "s4",
+			desc:     "s4 gets an IPv4",
+			svc:      "s4",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s5 can't get an IP",
-			svc:     "s5",
-			wantErr: true,
+			desc:     "s5 can't get an IPv4",
+			svc:      "s5",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s6 can't get an IP",
-			svc:     "s6",
-			wantErr: true,
+			desc:     "s6 can't get an IPv4",
+			svc:      "s6",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:     "s1 releases its IP",
+			desc:     "s1 releases its IPv4",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s5 can now grab s1's former IP",
-			svc:  "s5",
+			desc:     "s5 can now grab s1's former IPv4",
+			svc:      "s5",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s6 still can't get an IP",
-			svc:     "s6",
-			wantErr: true,
+			desc:     "s6 still can't get an IPv4",
+			svc:      "s6",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:     "s5 unassigns in prep for enabling IP sharing",
+			desc:     "s5 unassigns in prep for enabling IPv4 sharing",
 			svc:      "s5",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:       "s5 enables IP sharing",
+			desc:       "s5 enables IPv4 sharing",
 			svc:        "s5",
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
+			ipFamily:   ipfamily.IPv4,
 		},
 		{
-			desc:       "s6 can get an IP now, with sharing",
+			desc:       "s6 can get an IPv4 now, with sharing",
 			svc:        "s6",
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
+			ipFamily:   ipfamily.IPv4,
 		},
 
 		// Clear old ipv4 addresses
@@ -462,100 +506,108 @@ func TestPoolAllocation(t *testing.T) {
 			desc:     "s1 clear old ipv4 address",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s2 clear old ipv4 address",
 			svc:      "s2",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s3 clear old ipv4 address",
 			svc:      "s3",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s4 clear old ipv4 address",
 			svc:      "s4",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s5 clear old ipv4 address",
 			svc:      "s5",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s6 clear old ipv4 address",
 			svc:      "s6",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 
 		// IPv6 tests.
 		{
-			desc:   "s1 gets an IP6",
-			svc:    "s1",
-			isIPv6: true,
+			desc:     "s1 gets an IP6",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s2 gets an IP6",
-			svc:    "s2",
-			isIPv6: true,
+			desc:     "s2 gets an IP6",
+			svc:      "s2",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s3 gets an IP6",
-			svc:    "s3",
-			isIPv6: true,
+			desc:     "s3 gets an IP6",
+			svc:      "s3",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s4 gets an IP6",
-			svc:    "s4",
-			isIPv6: true,
+			desc:     "s4 gets an IP6",
+			svc:      "s4",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:    "s5 can't get an IP6",
-			svc:     "s5",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s5 can't get an IP6",
+			svc:      "s5",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			desc:    "s6 can't get an IP6",
-			svc:     "s6",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s6 can't get an IP6",
+			svc:      "s6",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
 			desc:     "s1 releases its IP6",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:   "s5 can now grab s1's former IP6",
-			svc:    "s5",
-			isIPv6: true,
+			desc:     "s5 can now grab s1's former IP6",
+			svc:      "s5",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:    "s6 still can't get an IP6",
-			svc:     "s6",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s6 still can't get an IP6",
+			svc:      "s6",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
 			desc:     "s5 unassigns in prep for enabling IP6 sharing",
 			svc:      "s5",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:       "s5 enables IP6 sharing",
 			svc:        "s5",
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
-			isIPv6:     true,
+			ipFamily:   ipfamily.IPv6,
 		},
 		{
 			desc:       "s6 can get an IP6 now, with sharing",
 			svc:        "s6",
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
-			isIPv6:     true,
+			ipFamily:   ipfamily.IPv6,
 		},
 
 		// Test the "should-not-happen" case where an svc already has a IP from the wrong family
@@ -563,16 +615,126 @@ func TestPoolAllocation(t *testing.T) {
 			desc:     "s1 clear",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s1 get an IPv4",
-			svc:  "s1",
+			desc:     "s1 get an IPv4",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s1 get an IPv6",
-			svc:     "s1",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s1 get an IPv6",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
+		},
+		// Clear old ipv6 addresses
+		{
+			desc:     "s1 clear old ipv6 address",
+			svc:      "s1",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s2 clear old ipv6 address",
+			svc:      "s2",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s3 clear old ipv6 address",
+			svc:      "s3",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s4 clear old ipv6 address",
+			svc:      "s4",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s5 clear old ipv6 address",
+			svc:      "s5",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s6 clear old ipv6 address",
+			svc:      "s6",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+
+		// Dual-stack tests.
+		{
+			desc:     "s1 gets dual-stack IPs",
+			svc:      "s1",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s2 gets dual-stack IPs",
+			svc:      "s2",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s3 gets dual-stack IPs",
+			svc:      "s3",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s4 gets dual-stack IPs",
+			svc:      "s4",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s5 can't get dual-stack IPs",
+			svc:      "s5",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:     "s6 can't get dual-stack IPs",
+			svc:      "s6",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:     "s1 releases its dual-stack IPs",
+			svc:      "s1",
+			unassign: true,
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s5 can now grab s1's former dual-stack IPs",
+			svc:      "s5",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s6 still can't get dual-stack IPs",
+			svc:      "s6",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:     "s5 unassigns in prep for enabling dual-stack IPs sharing",
+			svc:      "s5",
+			unassign: true,
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:       "s5 enables dual-stack IP sharing",
+			svc:        "s5",
+			ports:      ports("tcp/80"),
+			sharingKey: "share",
+			ipFamily:   ipfamily.DualStack,
+		},
+		{
+			desc:       "s6 can get an dual-stack IPs now, with sharing",
+			svc:        "s6",
+			ports:      ports("tcp/443"),
+			sharingKey: "share",
+			ipFamily:   ipfamily.DualStack,
 		},
 	}
 
@@ -581,7 +743,7 @@ func TestPoolAllocation(t *testing.T) {
 			alloc.Unassign(test.svc)
 			continue
 		}
-		ip, err := alloc.AllocateFromPool(test.svc, test.isIPv6, "test", test.ports, test.sharingKey, "")
+		ips, err := alloc.AllocateFromPool(test.svc, test.ipFamily, "test", test.ports, test.sharingKey, "")
 		if test.wantErr {
 			if err == nil {
 				t.Errorf("%s: should have caused an error, but did not", test.desc)
@@ -593,16 +755,20 @@ func TestPoolAllocation(t *testing.T) {
 			t.Errorf("%s: AllocateFromPool(%q, \"test\"): %s", test.desc, test.svc, err)
 		}
 		validIPs := validIP4s
-		if test.isIPv6 {
+		if test.ipFamily == ipfamily.IPv6 {
 			validIPs = validIP6s
+		} else if test.ipFamily == ipfamily.DualStack {
+			validIPs = validIPDualStacks
 		}
-		if !validIPs[ip.String()] {
-			t.Errorf("%s: allocated unexpected IP %q", test.desc, ip)
+		for _, ip := range ips {
+			if !validIPs[ip.String()] {
+				t.Errorf("%s: allocated unexpected IP %q", test.desc, ip)
+			}
 		}
 	}
 
 	alloc.Unassign("s5")
-	if _, err := alloc.AllocateFromPool("s5", false, "nonexistentpool", nil, "", ""); err == nil {
+	if _, err := alloc.AllocateFromPool("s5", ipfamily.IPv4, "nonexistentpool", nil, "", ""); err == nil {
 		t.Error("Allocating from non-existent pool succeeded")
 	}
 }
@@ -634,7 +800,16 @@ func TestAllocation(t *testing.T) {
 		"1000::10": true,
 		"1000::11": true,
 	}
-
+	validIPDualStacks := map[string]bool{
+		"1.2.3.4":  true,
+		"1.2.3.5":  true,
+		"1.2.3.10": true,
+		"1.2.3.11": true,
+		"1000::4":  true,
+		"1000::5":  true,
+		"1000::10": true,
+		"1000::11": true,
+	}
 	tests := []struct {
 		desc       string
 		svc        string
@@ -642,55 +817,65 @@ func TestAllocation(t *testing.T) {
 		sharingKey string
 		unassign   bool
 		wantErr    bool
-		isIPv6     bool
+		ipFamily   ipfamily.Family
 	}{
 		{
-			desc: "s1 gets an IP",
-			svc:  "s1",
+			desc:     "s1 gets an IPv4",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s2 gets an IP",
-			svc:  "s2",
+			desc:     "s2 gets an IPv4",
+			svc:      "s2",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s3 gets an IP",
-			svc:  "s3",
+			desc:     "s3 gets an IPv4",
+			svc:      "s3",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc: "s4 gets an IP",
-			svc:  "s4",
+			desc:     "s4 gets an IPv4",
+			svc:      "s4",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s5 can't get an IP",
-			svc:     "s5",
-			wantErr: true,
+			desc:     "s5 can't get an IPv4",
+			svc:      "s5",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:    "s6 can't get an IP",
-			svc:     "s6",
-			wantErr: true,
+			desc:     "s6 can't get an IPv4",
+			svc:      "s6",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:     "s1 gives up its IP",
+			desc:     "s1 gives up its IPv4",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:       "s5 can now get an IP",
+			desc:       "s5 can now get an IPv4",
 			svc:        "s5",
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
+			ipFamily:   ipfamily.IPv4,
 		},
 		{
-			desc:    "s6 still can't get an IP",
-			svc:     "s6",
-			wantErr: true,
+			desc:     "s6 still can't get an IPv4",
+			svc:      "s6",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			desc:       "s6 can get an IP with sharing",
+			desc:       "s6 can get an IPv4 with sharing",
 			svc:        "s6",
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
+			ipFamily:   ipfamily.IPv4,
 		},
 
 		// Clear old ipv4 addresses
@@ -698,90 +883,191 @@ func TestAllocation(t *testing.T) {
 			desc:     "s1 clear old ipv4 address",
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s2 clear old ipv4 address",
 			svc:      "s2",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s3 clear old ipv4 address",
 			svc:      "s3",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s4 clear old ipv4 address",
 			svc:      "s4",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s5 clear old ipv4 address",
 			svc:      "s5",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			desc:     "s6 clear old ipv4 address",
 			svc:      "s6",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 
 		// IPv6 tests
 		{
-			desc:   "s1 gets an IP",
-			svc:    "s1",
-			isIPv6: true,
+			desc:     "s1 gets an IPv6",
+			svc:      "s1",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s2 gets an IP",
-			svc:    "s2",
-			isIPv6: true,
+			desc:     "s2 gets an IPv6",
+			svc:      "s2",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s3 gets an IP",
-			svc:    "s3",
-			isIPv6: true,
+			desc:     "s3 gets an IPv6",
+			svc:      "s3",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:   "s4 gets an IP",
-			svc:    "s4",
-			isIPv6: true,
+			desc:     "s4 gets an IPv6",
+			svc:      "s4",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			desc:    "s5 can't get an IP",
-			svc:     "s5",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s5 can't get an IPv6",
+			svc:      "s5",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			desc:    "s6 can't get an IP",
-			svc:     "s6",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s6 can't get an IPv6",
+			svc:      "s6",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			desc:     "s1 gives up its IP",
+			desc:     "s1 gives up its IPv6",
 			svc:      "s1",
 			unassign: true,
 		},
 		{
-			desc:       "s5 can now get an IP",
+			desc:       "s5 can now get an IPv6",
 			svc:        "s5",
 			ports:      ports("tcp/80"),
 			sharingKey: "share",
-			isIPv6:     true,
+			ipFamily:   ipfamily.IPv6,
 		},
 		{
-			desc:    "s6 still can't get an IP",
-			svc:     "s6",
-			isIPv6:  true,
-			wantErr: true,
+			desc:     "s6 still can't get an IPv6",
+			svc:      "s6",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			desc:       "s6 can get an IP with sharing",
+			desc:       "s6 can get an IPv6 with sharing",
 			svc:        "s6",
 			ports:      ports("tcp/443"),
 			sharingKey: "share",
-			isIPv6:     true,
+			ipFamily:   ipfamily.IPv6,
+		},
+		// Clear old ipv6 addresses
+		{
+			desc:     "s1 clear old ipv6 address",
+			svc:      "s1",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s2 clear old ipv6 address",
+			svc:      "s2",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s3 clear old ipv6 address",
+			svc:      "s3",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s4 clear old ipv6 address",
+			svc:      "s4",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s5 clear old ipv6 address",
+			svc:      "s5",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		{
+			desc:     "s6 clear old ipv6 address",
+			svc:      "s6",
+			unassign: true,
+			ipFamily: ipfamily.IPv6,
+		},
+		//Dual-stack test cases
+		{
+			desc:     "s1 gets dual-stack IPs",
+			svc:      "s1",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s2 gets dual-stack IPs",
+			svc:      "s2",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s3 gets dual-stack IPs",
+			svc:      "s3",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s4 gets dual-stack IPs",
+			svc:      "s4",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			desc:     "s5 can't get dual-stack IPs",
+			svc:      "s5",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:     "s6 can't get dual-stack IPs",
+			svc:      "s6",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:     "s1 gives up its IPs",
+			svc:      "s1",
+			unassign: true,
+		},
+		{
+			desc:       "s5 can now get dual-stack IPs",
+			svc:        "s5",
+			ports:      ports("tcp/80"),
+			sharingKey: "share",
+			ipFamily:   ipfamily.DualStack,
+		},
+		{
+			desc:     "s6 still can't get dual-stack IPs",
+			svc:      "s6",
+			ipFamily: ipfamily.DualStack,
+			wantErr:  true,
+		},
+		{
+			desc:       "s6 can get dual-stack IPs with sharing",
+			svc:        "s6",
+			ports:      ports("tcp/443"),
+			sharingKey: "share",
+			ipFamily:   ipfamily.DualStack,
 		},
 	}
 
@@ -790,7 +1076,7 @@ func TestAllocation(t *testing.T) {
 			alloc.Unassign(test.svc)
 			continue
 		}
-		ip, err := alloc.Allocate(test.svc, test.isIPv6, test.ports, test.sharingKey, "")
+		ips, err := alloc.Allocate(test.svc, test.ipFamily, test.ports, test.sharingKey, "")
 		if test.wantErr {
 			if err == nil {
 				t.Errorf("%s: should have caused an error, but did not", test.desc)
@@ -800,12 +1086,17 @@ func TestAllocation(t *testing.T) {
 		if err != nil {
 			t.Errorf("%s: Allocate(%q, \"test\"): %s", test.desc, test.svc, err)
 		}
+
 		validIPs := validIP4s
-		if test.isIPv6 {
+		if test.ipFamily == ipfamily.IPv6 {
 			validIPs = validIP6s
+		} else if test.ipFamily == ipfamily.DualStack {
+			validIPs = validIPDualStacks
 		}
-		if !validIPs[ip.String()] {
-			t.Errorf("%s allocated unexpected IP %q", test.desc, ip)
+		for _, ip := range ips {
+			if !validIPs[ip.String()] {
+				t.Errorf("%s allocated unexpected IP %q", test.desc, ip)
+			}
 		}
 	}
 }
@@ -861,7 +1152,7 @@ func TestBuggyIPs(t *testing.T) {
 	}
 
 	for i, test := range tests {
-		ip, err := alloc.Allocate(test.svc, false, nil, "", "")
+		ips, err := alloc.Allocate(test.svc, ipfamily.IPv4, nil, "", "")
 		if test.wantErr {
 			if err == nil {
 				t.Errorf("#%d should have caused an error, but did not", i+1)
@@ -872,8 +1163,10 @@ func TestBuggyIPs(t *testing.T) {
 		if err != nil {
 			t.Errorf("#%d Allocate(%q, \"test\"): %s", i+1, test.svc, err)
 		}
-		if !validIPs[ip.String()] {
-			t.Errorf("#%d allocated unexpected IP %q", i+1, ip)
+		for _, ip := range ips {
+			if !validIPs[ip.String()] {
+				t.Errorf("#%d allocated unexpected IP %q", i+1, ip)
+			}
 		}
 	}
 
@@ -889,13 +1182,12 @@ func TestConfigReload(t *testing.T) {
 	}); err != nil {
 		t.Fatalf("SetPools: %s", err)
 	}
-	if err := alloc.Assign("s1", net.ParseIP("1.2.3.0"), nil, "", ""); err != nil {
+	if err := alloc.Assign("s1", []net.IP{net.ParseIP("1.2.3.0")}, nil, "", ""); err != nil {
 		t.Fatalf("Assign(s1, 1.2.3.0): %s", err)
 	}
-	if err := alloc.Assign("s2", net.ParseIP("1000::"), nil, "", ""); err != nil {
-		t.Fatalf("Assign(s1, 1000::): %s", err)
+	if err := alloc.Assign("s2", []net.IP{net.ParseIP("1000::")}, nil, "", ""); err != nil {
+		t.Fatalf("Assign(s2, 1000::): %s", err)
 	}
-
 	tests := []struct {
 		desc    string
 		pools   map[string]*config.Pool
@@ -1081,77 +1373,125 @@ func TestAutoAssign(t *testing.T) {
 		"1000::10": true,
 		"1000::11": true,
 	}
-
+	validIPDualStacks := map[string]bool{
+		"1.2.3.4":  false,
+		"1.2.3.5":  false,
+		"1.2.3.10": true,
+		"1.2.3.11": true,
+		"1000::4":  false,
+		"1000::5":  false,
+		"1000::10": true,
+		"1000::11": true,
+	}
 	tests := []struct {
 		svc      string
 		wantErr  bool
-		isIPv6   bool
+		ipFamily ipfamily.Family
 		unassign bool
 	}{
-		{svc: "s1"},
-		{svc: "s2"},
 		{
-			svc:     "s3",
-			wantErr: true,
+			svc:      "s1",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			svc:     "s4",
-			wantErr: true,
+			svc:      "s2",
+			ipFamily: ipfamily.IPv4,
 		},
 		{
-			svc:     "s5",
-			wantErr: true,
+			svc:      "s3",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
+		},
+		{
+			svc:      "s4",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
+		},
+		{
+			svc:      "s5",
+			wantErr:  true,
+			ipFamily: ipfamily.IPv4,
 		},
 
 		// Clear old ipv4 addresses
 		{
 			svc:      "s1",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			svc:      "s2",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			svc:      "s3",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			svc:      "s4",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			svc:      "s5",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 		{
 			svc:      "s6",
 			unassign: true,
+			ipFamily: ipfamily.IPv4,
 		},
 
 		// IPv6 tests;
 		{
-			svc:    "s1",
-			isIPv6: true,
+			svc:      "s1",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			svc:    "s2",
-			isIPv6: true,
+			svc:      "s2",
+			ipFamily: ipfamily.IPv6,
 		},
 		{
-			svc:     "s3",
-			isIPv6:  true,
-			wantErr: true,
+			svc:      "s3",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			svc:     "s4",
-			isIPv6:  true,
-			wantErr: true,
+			svc:      "s4",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
 		},
 		{
-			svc:     "s5",
-			isIPv6:  true,
-			wantErr: true,
+			svc:      "s5",
+			ipFamily: ipfamily.IPv6,
+			wantErr:  true,
+		},
+		// Dual-stack tests;
+		{
+			svc:      "s1",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			svc:      "s2",
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			svc:      "s3",
+			wantErr:  true,
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			svc:      "s4",
+			wantErr:  true,
+			ipFamily: ipfamily.DualStack,
+		},
+		{
+			svc:      "s5",
+			wantErr:  true,
+			ipFamily: ipfamily.DualStack,
 		},
 	}
 
@@ -1160,7 +1500,7 @@ func TestAutoAssign(t *testing.T) {
 			alloc.Unassign(test.svc)
 			continue
 		}
-		ip, err := alloc.Allocate(test.svc, test.isIPv6, nil, "", "")
+		ips, err := alloc.Allocate(test.svc, test.ipFamily, nil, "", "")
 		if test.wantErr {
 			if err == nil {
 				t.Errorf("#%d should have caused an error, but did not", i+1)
@@ -1171,11 +1511,15 @@ func TestAutoAssign(t *testing.T) {
 			t.Errorf("#%d Allocate(%q, \"test\"): %s", i+1, test.svc, err)
 		}
 		validIPs := validIP4s
-		if test.isIPv6 {
+		if test.ipFamily == ipfamily.IPv6 {
 			validIPs = validIP6s
+		} else if test.ipFamily == ipfamily.DualStack {
+			validIPs = validIPDualStacks
 		}
-		if !validIPs[ip.String()] {
-			t.Errorf("#%d allocated unexpected IP %q", i+1, ip)
+		for _, ip := range ips {
+			if !validIPs[ip.String()] {
+				t.Errorf("#%d allocated unexpected IP %q", i+1, ip)
+			}
 		}
 	}
 }
@@ -1247,7 +1591,7 @@ func TestPoolMetrics(t *testing.T) {
 	tests := []struct {
 		desc       string
 		svc        string
-		ip         string
+		ips        []string
 		ports      []Port
 		sharingKey string
 		backendKey string
@@ -1256,13 +1600,13 @@ func TestPoolMetrics(t *testing.T) {
 		{
 			desc:     "assign s1",
 			svc:      "s1",
-			ip:       "1.2.3.4",
+			ips:      []string{"1.2.3.4"},
 			ipsInUse: 1,
 		},
 		{
 			desc:     "assign s2",
 			svc:      "s2",
-			ip:       "1.2.3.5",
+			ips:      []string{"1.2.3.5"},
 			ipsInUse: 2,
 		},
 		{
@@ -1278,7 +1622,7 @@ func TestPoolMetrics(t *testing.T) {
 		{
 			desc:       "assign s1 shared",
 			svc:        "s1",
-			ip:         "1.2.3.4",
+			ips:        []string{"1.2.3.4"},
 			sharingKey: "key",
 			ports:      ports("tcp/80"),
 			ipsInUse:   1,
@@ -1286,7 +1630,7 @@ func TestPoolMetrics(t *testing.T) {
 		{
 			desc:       "assign s2 shared",
 			svc:        "s2",
-			ip:         "1.2.3.4",
+			ips:        []string{"1.2.3.4"},
 			sharingKey: "key",
 			ports:      ports("tcp/443"),
 			ipsInUse:   1,
@@ -1294,7 +1638,7 @@ func TestPoolMetrics(t *testing.T) {
 		{
 			desc:       "assign s3 shared",
 			svc:        "s3",
-			ip:         "1.2.3.4",
+			ips:        []string{"1.2.3.4"},
 			sharingKey: "key",
 			ports:      ports("tcp/23"),
 			ipsInUse:   1,
@@ -1319,7 +1663,7 @@ func TestPoolMetrics(t *testing.T) {
 		},
 	}
 
-	// The "test" pool contains two ranges; 1.2.3.4/30, 1000::4/126
+	// The "test1" pool contains two ranges; 1.2.3.4/30, 1000::4/126
 	// All bits can be used for lb-addresses which gives a total capacity of; 4+4=8
 	value := ptu.ToFloat64(stats.poolCapacity.WithLabelValues("test"))
 	if int(value) != 8 {
@@ -1327,7 +1671,7 @@ func TestPoolMetrics(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		if test.ip == "" {
+		if len(test.ips) == 0 {
 			alloc.Unassign(test.svc)
 			value := ptu.ToFloat64(stats.poolActive.WithLabelValues("test"))
 			if value != test.ipsInUse {
@@ -1336,17 +1680,19 @@ func TestPoolMetrics(t *testing.T) {
 			continue
 		}
 
-		ip := net.ParseIP(test.ip)
-		if ip == nil {
-			t.Fatalf("invalid IP %q in test %q", test.ip, test.desc)
+		ips := make([]net.IP, 0)
+		for _, ip := range test.ips {
+			ips = append(ips, net.ParseIP(ip))
 		}
-		err := alloc.Assign(test.svc, ip, test.ports, test.sharingKey, test.backendKey)
-
+		if len(ips) == 0 {
+			t.Fatalf("invalid IP %q in test %q", test.ips, test.desc)
+		}
+		err := alloc.Assign(test.svc, ips, test.ports, test.sharingKey, test.backendKey)
 		if err != nil {
-			t.Errorf("%q: Assign(%q, %q): %v", test.desc, test.svc, test.ip, err)
+			t.Errorf("%q: Assign(%q, %q): %v", test.desc, test.svc, test.ips, err)
 		}
-		if a := assigned(alloc, test.svc); a != test.ip {
-			t.Errorf("%q: ran Assign(%q, %q), but allocator has recorded allocation of %q", test.desc, test.svc, test.ip, a)
+		if a := assigned(alloc, test.svc); !compareIPs(a, test.ips) {
+			t.Errorf("%q: ran Assign(%q, %q), but allocator has recorded allocation of %q", test.desc, test.svc, test.ips, a)
 		}
 		value := ptu.ToFloat64(stats.poolActive.WithLabelValues("test"))
 		if value != test.ipsInUse {
@@ -1357,12 +1703,17 @@ func TestPoolMetrics(t *testing.T) {
 
 // Some helpers.
 
-func assigned(a *Allocator, svc string) string {
-	ip := a.IP(svc)
-	if ip == nil {
-		return ""
+func assigned(a *Allocator, svc string) []string {
+	res := []string{}
+	if alloc := a.allocated[svc]; alloc != nil {
+		for _, ip := range alloc.ips {
+			if ip == nil {
+				return nil
+			}
+			res = append(res, ip.String())
+		}
 	}
-	return ip.String()
+	return res
 }
 
 func ipnet(s string) *net.IPNet {
@@ -1385,3 +1736,23 @@ func ports(ports ...string) []Port {
 	}
 	return ret
 }
+
+func compareIPs(ips1, ips2 []string) bool {
+	if len(ips1) != len(ips2) {
+		return false
+	}
+
+	for _, ip1 := range ips1 {
+		found := false
+		for _, ip2 := range ips2 {
+			if net.ParseIP(ip1).Equal(net.ParseIP(ip2)) {
+				found = true
+				break
+			}
+		}
+		if !found {
+			return false
+		}
+	}
+	return true
+}
diff --git a/internal/ipfamily/ipfamily_test.go b/internal/ipfamily/ipfamily_test.go
new file mode 100644
index 00000000..b26b0902
--- /dev/null
+++ b/internal/ipfamily/ipfamily_test.go
@@ -0,0 +1,188 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package ipfamily
+
+import (
+	"net"
+	"testing"
+)
+
+func TestIPFamilyForAddresses(t *testing.T) {
+	tests := []struct {
+		desc    string
+		ips     []string
+		family  Family
+		wantErr bool
+	}{
+		{
+			desc:   "ipv4 address",
+			ips:    []string{"1.1.1.1"},
+			family: IPv4,
+		},
+		{
+			desc:   "ipv6 address",
+			ips:    []string{"100::1"},
+			family: IPv6,
+		},
+		{
+			desc:   "ipv4 and ipv6 addresse",
+			ips:    []string{"1.2.3.4", "100::1"},
+			family: DualStack,
+		},
+		{
+			desc:    "dual stack with same address family",
+			ips:     []string{"1.2.3.4", "5.6.7.8"},
+			family:  Unknown,
+			wantErr: true,
+		},
+		{
+			desc:    "dual stack with empty address",
+			ips:     []string{"", ""},
+			family:  Unknown,
+			wantErr: true,
+		},
+		{
+			desc:    "more than 2 addresses",
+			ips:     []string{"1.1.1.1", "100::1", "2.2.2.2"},
+			family:  Unknown,
+			wantErr: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			family, err := ForAddresses(test.ips)
+			if test.wantErr && err == nil {
+				t.Fatalf("Expected error for %s", test.desc)
+			}
+			if !test.wantErr && err != nil {
+				t.Fatalf("Not expected error %s for %s", err, test.desc)
+			}
+			if family != test.family {
+				t.Fatalf("Incorrect IPFamily returned %s expected %s", family, test.family)
+			}
+		})
+	}
+}
+
+func TestIPFamilyForAddressesIPs(t *testing.T) {
+	tests := []struct {
+		desc    string
+		ips     []net.IP
+		family  Family
+		wantErr bool
+	}{
+		{
+			desc:   "ipv4 address",
+			ips:    []net.IP{net.ParseIP("1.2.4.0")},
+			family: IPv4,
+		},
+		{
+			desc:   "ipv6 address",
+			ips:    []net.IP{net.ParseIP("100::1")},
+			family: IPv6,
+		},
+		{
+			desc:   "ipv4 and ipv6 addresse",
+			ips:    []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("100::1")},
+			family: DualStack,
+		},
+		{
+			desc:    "dual stack with same address family",
+			ips:     []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("5.6.7.8")},
+			family:  Unknown,
+			wantErr: true,
+		},
+		{
+			desc:    "dual stack with empty address",
+			ips:     []net.IP{net.ParseIP(""), net.ParseIP("")},
+			family:  Unknown,
+			wantErr: true,
+		},
+		{
+			desc:    "more than 2 addresses",
+			ips:     []net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("100::1"), net.ParseIP("2.2.2.2")},
+			family:  Unknown,
+			wantErr: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			family, err := ForAddressesIPs(test.ips)
+			if test.wantErr && err == nil {
+				t.Fatalf("Expected error for %s", test.desc)
+			}
+			if !test.wantErr && err != nil {
+				t.Fatalf("Not expected error %s for %s", err, test.desc)
+			}
+			if family != test.family {
+				t.Fatalf("Incorrect IPFamily returned %s expected %s", family, test.family)
+			}
+		})
+	}
+}
+
+func TestIPFamilyForCIDR(t *testing.T) {
+	tests := []struct {
+		desc   string
+		cidr   *net.IPNet
+		family Family
+	}{
+		{
+			desc:   "ipv4 cidr",
+			cidr:   ipnet("1.2.3.4/30"),
+			family: IPv4,
+		},
+		{
+			desc:   "ipv6 cidr",
+			cidr:   ipnet("100::/96"),
+			family: IPv6,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			family := ForCIDR(test.cidr)
+			if family != test.family {
+				t.Fatalf("Incorrect IPFamily returned %s expected %s", family, test.family)
+			}
+		})
+	}
+}
+
+func TestIPFamilyForAddress(t *testing.T) {
+	tests := []struct {
+		desc   string
+		ip     net.IP
+		family Family
+	}{
+		{
+			desc:   "ipv4 address",
+			ip:     net.ParseIP("1.2.3.4"),
+			family: IPv4,
+		},
+		{
+			desc:   "ipv6 address",
+			ip:     net.ParseIP("100::"),
+			family: IPv6,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			family := ForAddress(test.ip)
+			if family != test.family {
+				t.Fatalf("Incorrect IPFamily returned %s expected %s", family, test.family)
+			}
+		})
+	}
+}
+
+func ipnet(s string) *net.IPNet {
+	_, n, err := net.ParseCIDR(s)
+	if err != nil {
+		panic(err)
+	}
+	return n
+}
diff --git a/internal/layer2/announcer_test.go b/internal/layer2/announcer_test.go
index f2bfc8da..016e20cc 100644
--- a/internal/layer2/announcer_test.go
+++ b/internal/layer2/announcer_test.go
@@ -9,7 +9,7 @@ import (
 
 func Test_SetBalancer_AddsToAnnouncedServices(t *testing.T) {
 	announce := &Announce{
-		ips:      map[string]net.IP{},
+		ips:      map[string][]net.IP{},
 		ipRefcnt: map[string]int{},
 		spamCh:   make(chan net.IP, 1),
 	}
@@ -22,6 +22,10 @@ func Test_SetBalancer_AddsToAnnouncedServices(t *testing.T) {
 			name: "foo",
 			ip:   net.IPv4(192, 168, 1, 20),
 		},
+		{
+			name: "foo",
+			ip:   net.ParseIP("1000::1"),
+		},
 		{
 			name: "bar",
 			ip:   net.IPv4(192, 168, 1, 20),
diff --git a/speaker/layer2_controller_test.go b/speaker/layer2_controller_test.go
index 47c1cf17..7ec9f9dc 100644
--- a/speaker/layer2_controller_test.go
+++ b/speaker/layer2_controller_test.go
@@ -1101,9 +1101,9 @@ func TestShouldAnnounce(t *testing.T) {
 		for _, svc := range test.svcs {
 			lbIP := net.ParseIP(svc.Status.LoadBalancer.Ingress[0].IP)
 			lbIP_s := lbIP.String()
-			pool := c1.config.Pools[poolFor(c1.config.Pools, lbIP)]
-			response1 := c1.protocols[pool.Protocol].ShouldAnnounce(l, "balancer", lbIP, svc, test.eps[lbIP_s])
-			response2 := c2.protocols[pool.Protocol].ShouldAnnounce(l, "balancer", lbIP, svc, test.eps[lbIP_s])
+			pool := c1.config.Pools[poolFor(c1.config.Pools, []net.IP{lbIP})]
+			response1 := c1.protocols[pool.Protocol].ShouldAnnounce(l, "balancer", []net.IP{lbIP}, svc, test.eps[lbIP_s])
+			response2 := c2.protocols[pool.Protocol].ShouldAnnounce(l, "balancer", []net.IP{lbIP}, svc, test.eps[lbIP_s])
 			if response1 != test.c1ExpectedResult[lbIP_s] {
 				t.Errorf("%q: shouldAnnounce for controller 1 for service %s returned incorrect result, expected '%s', but received '%s'", test.desc, lbIP_s, test.c1ExpectedResult[lbIP_s], response1)
 			}
@@ -2103,9 +2103,9 @@ func TestShouldAnnounceEPSlices(t *testing.T) {
 		for _, svc := range test.svcs {
 			lbIP := net.ParseIP(svc.Status.LoadBalancer.Ingress[0].IP)
 			lbIP_s := lbIP.String()
-			pool := c1.config.Pools[poolFor(c1.config.Pools, lbIP)]
-			response1 := c1.protocols[pool.Protocol].ShouldAnnounce(l, "test1", lbIP, svc, test.eps[lbIP_s])
-			response2 := c2.protocols[pool.Protocol].ShouldAnnounce(l, "test1", lbIP, svc, test.eps[lbIP_s])
+			pool := c1.config.Pools[poolFor(c1.config.Pools, []net.IP{lbIP})]
+			response1 := c1.protocols[pool.Protocol].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc, test.eps[lbIP_s])
+			response2 := c2.protocols[pool.Protocol].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc, test.eps[lbIP_s])
 			if response1 != test.c1ExpectedResult[lbIP_s] {
 				t.Errorf("%q: shouldAnnounce for controller 1 for service %s returned incorrect result, expected '%s', but received '%s'", test.desc, lbIP_s, test.c1ExpectedResult[lbIP_s], response1)
 			}
@@ -2236,11 +2236,11 @@ func TestClusterPolicy(t *testing.T) {
 		}
 
 		lbIP := net.ParseIP(ip)
-		response1svc1 := c1.protocols[config.Layer2].ShouldAnnounce(l, "test1", lbIP, svc1, eps1)
-		response2svc1 := c2.protocols[config.Layer2].ShouldAnnounce(l, "test1", lbIP, svc1, eps1)
+		response1svc1 := c1.protocols[config.Layer2].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc1, eps1)
+		response2svc1 := c2.protocols[config.Layer2].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc1, eps1)
 
-		response1svc2 := c1.protocols[config.Layer2].ShouldAnnounce(l, "test1", lbIP, svc2, eps2)
-		response2svc2 := c2.protocols[config.Layer2].ShouldAnnounce(l, "test1", lbIP, svc2, eps2)
+		response1svc2 := c1.protocols[config.Layer2].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc2, eps2)
+		response2svc2 := c2.protocols[config.Layer2].ShouldAnnounce(l, "test1", []net.IP{lbIP}, svc2, eps2)
 
 		// We check that only one speaker announces the service, so their response must be different
 		if response1svc1 == response2svc1 {