Repository: metallb/metallb
Pull request #1065 (2021-11-19 01:14:00)

Refactor Metallb svc controller and speaker to support dual-stack
Refactor Metallb service controller to support dual stack, this PR is continuation of the effort started by 
PR #727 while trying to share most of the code between single stack and dual stack services, also it modifies to speaker code to return more than one address instead of one Loadbalancer IP.

### - Testing 

 - bring up kind dual-stack cluster 
 - create metallb configmap
 ```
  k -n metallb-system get configmap config -o yaml
 apiVersion: v1
 data:
   config: |
     peers:
     - peer-address: fc00:f853:ccd:e793::5
       peer-asn: 64512
       my-asn: 64512
     address-pools:
     - name: dev-env-bgp
       protocol: bgp
       addresses:
       - 192.168.10.0/24
       - fc00:f853:0ccd:e799::/124
 kind: ConfigMap
 ```
 - create dual-stack svc
 ```
  k create -f dev-env/testsvc_dualstack.yaml 
 ```
 - loadbalancer svc
 ```
  k get svc nginx 
 NAME    TYPE           CLUSTER-IP   EXTERNAL-IP                         PORT(S)        AGE
 nginx   LoadBalancer   10.96.6.42   192.168.10.0,fc00:f853:ccd:e799::   80:30253/TCP   18s
 ```


fedepaol: A general comment: we should also extend unit tests to cover dual stack.
fedepaol: LGTM, let's rebase to import the tests and :crossed_fingers: 

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
 }


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/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 {