Repository: linkerd/linkerd2
Pull request #12541 (2024-05-02 16:13:21)

Fix `linkerd dg endpoints` to work with IPv6
```
# BEFORE
$ bin/linkerd dg endpoints family-server.default.svc.cluster.local:8080
NAMESPACE   IP        PORT   POD                              SERVICE
default     0.0.0.0   8080   family-server-7cf95b6b89-x6cb9   family-server.default

# AFTER
$ bin/linkerd dg endpoints family-server.default.svc.cluster.local:8080
NAMESPACE   IP                      PORT   POD                              SERVICE
default     fd00:10:244::5          8080   family-server-7cf95b6b89-x6cb9   family-server.default
```


Patch:
diff --git a/charts/linkerd-control-plane/templates/destination.yaml b/charts/linkerd-control-plane/templates/destination.yaml
index 6d12557a1..91d8fc67f 100644
--- a/charts/linkerd-control-plane/templates/destination.yaml
+++ b/charts/linkerd-control-plane/templates/destination.yaml
@@ -205,6 +205,7 @@ spec:
         - -cluster-domain={{.Values.clusterDomain}}
         - -identity-trust-domain={{.Values.identityTrustDomain | default .Values.clusterDomain}}
         - -default-opaque-ports={{.Values.proxy.opaquePorts}}
+        - -enable-ipv6={{not .Values.disableIPv6}}
         - -enable-pprof={{.Values.enablePprof | default false}}
         {{- if (.Values.destinationController).meshedHttp2ClientProtobuf }}
         - --meshed-http2-client-params={{ toJson .Values.destinationController.meshedHttp2ClientProtobuf }}
diff --git a/cli/cmd/endpoints.go b/cli/cmd/endpoints.go
index 5d4709265..8c76b1983 100644
--- a/cli/cmd/endpoints.go
+++ b/cli/cmd/endpoints.go
@@ -3,11 +3,9 @@ package cmd
 import (
 	"bytes"
 	"context"
-	"encoding/binary"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"net"
 	"os"
 	"sort"
 	"strings"
@@ -17,6 +15,7 @@ import (
 	destinationPb "github.com/linkerd/linkerd2-proxy-api/go/destination"
 	netPb "github.com/linkerd/linkerd2-proxy-api/go/net"
 	"github.com/linkerd/linkerd2/controller/api/destination"
+	"github.com/linkerd/linkerd2/pkg/addr"
 	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	log "github.com/sirupsen/logrus"
@@ -218,10 +217,11 @@ func requestEndpointsFromAPI(client destinationPb.DestinationClient, token strin
 }
 
 func getIP(tcpAddr *netPb.TcpAddress) string {
-	ip := tcpAddr.GetIp().GetIpv4()
-	b := make([]byte, 4)
-	binary.BigEndian.PutUint32(b, ip)
-	return net.IP(b).String()
+	ip := addr.FromProxyAPI(tcpAddr.GetIp())
+	if ip == nil {
+		return ""
+	}
+	return addr.PublicIPToString(ip)
 }
 
 func renderEndpoints(endpoints endpointsInfo, options *endpointsOptions) string {
diff --git a/controller/api/destination/endpoint_translator.go b/controller/api/destination/endpoint_translator.go
index bb16e828f..9c75e8fa4 100644
--- a/controller/api/destination/endpoint_translator.go
+++ b/controller/api/destination/endpoint_translator.go
@@ -40,6 +40,8 @@ type (
 
 		enableH2Upgrade,
 		enableEndpointFiltering,
+		enableIPv6,
+
 		extEndpointZoneWeights bool
 
 		meshedHTTP2ClientParams *pb.Http2ClientParams
@@ -83,6 +85,7 @@ func newEndpointTranslator(
 	identityTrustDomain string,
 	enableH2Upgrade,
 	enableEndpointFiltering,
+	enableIPv6,
 	extEndpointZoneWeights bool,
 	meshedHTTP2ClientParams *pb.Http2ClientParams,
 	service string,
@@ -114,6 +117,7 @@ func newEndpointTranslator(
 		defaultOpaquePorts,
 		enableH2Upgrade,
 		enableEndpointFiltering,
+		enableIPv6,
 		extEndpointZoneWeights,
 		meshedHTTP2ClientParams,
 
@@ -239,6 +243,7 @@ func (et *endpointTranslator) noEndpoints(exists bool) {
 
 func (et *endpointTranslator) sendFilteredUpdate() {
 	filtered := et.filterAddresses()
+	filtered = et.selectAddressFamily(filtered)
 	diffAdd, diffRemove := et.diffEndpoints(filtered)
 
 	if len(diffAdd.Addresses) > 0 {
@@ -251,6 +256,33 @@ func (et *endpointTranslator) sendFilteredUpdate() {
 	et.filteredSnapshot = filtered
 }
 
+func (et *endpointTranslator) selectAddressFamily(addresses watcher.AddressSet) watcher.AddressSet {
+	filtered := make(map[watcher.ID]watcher.Address)
+	for id, addr := range addresses.Addresses {
+		if id.IPFamily == corev1.IPv6Protocol && !et.enableIPv6 {
+			continue
+		}
+
+		if id.IPFamily == corev1.IPv4Protocol && et.enableIPv6 {
+			// Only consider IPv4 address for which there's not already an IPv6
+			// alternative
+			altID := id
+			altID.IPFamily = corev1.IPv6Protocol
+			if _, ok := addresses.Addresses[altID]; ok {
+				continue
+			}
+		}
+
+		filtered[id] = addr
+	}
+
+	return watcher.AddressSet{
+		Addresses:          filtered,
+		Labels:             addresses.Labels,
+		LocalTrafficPolicy: addresses.LocalTrafficPolicy,
+	}
+}
+
 // filterAddresses is responsible for filtering endpoints based on the node's
 // topology zone. The client will only receive endpoints with the same
 // consumption zone as the node. An endpoints consumption zone is set
diff --git a/controller/api/destination/server.go b/controller/api/destination/server.go
index 93a5f1299..bde9ff7ba 100644
--- a/controller/api/destination/server.go
+++ b/controller/api/destination/server.go
@@ -32,6 +32,7 @@ type (
 
 		EnableH2Upgrade,
 		EnableEndpointSlices,
+		EnableIPv6,
 		ExtEndpointZoneWeights bool
 
 		MeshedHttp2ClientParams *pb.Http2ClientParams
@@ -188,6 +189,7 @@ func (s *server) Get(dest *pb.GetDestination, stream pb.Destination_GetServer) e
 			remoteConfig.TrustDomain,
 			s.config.EnableH2Upgrade,
 			false, // Disable endpoint filtering for remote discovery.
+			s.config.EnableIPv6,
 			s.config.ExtEndpointZoneWeights,
 			s.config.MeshedHttp2ClientParams,
 			fmt.Sprintf("%s.%s.svc.%s:%d", remoteSvc, service.Namespace, remoteConfig.ClusterDomain, port),
@@ -220,6 +222,7 @@ func (s *server) Get(dest *pb.GetDestination, stream pb.Destination_GetServer) e
 			s.config.IdentityTrustDomain,
 			s.config.EnableH2Upgrade,
 			true,
+			s.config.EnableIPv6,
 			s.config.ExtEndpointZoneWeights,
 			s.config.MeshedHttp2ClientParams,
 			dest.GetPath(),
diff --git a/controller/api/destination/watcher/endpoints_watcher.go b/controller/api/destination/watcher/endpoints_watcher.go
index e2be89ef7..b4e9dba00 100644
--- a/controller/api/destination/watcher/endpoints_watcher.go
+++ b/controller/api/destination/watcher/endpoints_watcher.go
@@ -60,6 +60,12 @@ type (
 	}
 
 	// AddressSet is a set of Address, indexed by ID.
+	// The ID can be either:
+	// 1) A reference to service: id.Name contains both the service name and
+	// the target IP and port (see newServiceRefAddress)
+	// 2) A reference to a pod: id.Name refers to the pod's name, and
+	// id.IPFamily refers to the ES AddressType (see newPodRefAddress).
+	// 3) A reference to an ExternalWorkload: id.Name refers to the EW's name.
 	AddressSet struct {
 		Addresses          map[ID]Address
 		Labels             map[string]string
@@ -365,7 +371,7 @@ func (ew *EndpointsWatcher) addEndpoints(obj interface{}) {
 		return
 	}
 
-	id := ServiceID{endpoints.Namespace, endpoints.Name}
+	id := ServiceID{Namespace: endpoints.Namespace, Name: endpoints.Name}
 	sp := ew.getOrNewServicePublisher(id)
 	sp.updateEndpoints(endpoints)
 }
@@ -389,7 +395,7 @@ func (ew *EndpointsWatcher) updateEndpoints(oldObj interface{}, newObj interface
 		endpointsInformerLag.Observe(delta.Seconds())
 	}
 
-	id := ServiceID{newEndpoints.Namespace, newEndpoints.Name}
+	id := ServiceID{Namespace: newEndpoints.Namespace, Name: newEndpoints.Name}
 	sp := ew.getOrNewServicePublisher(id)
 	sp.updateEndpoints(newEndpoints)
 }
@@ -601,7 +607,8 @@ func (sp *servicePublisher) deleteEndpoints() {
 func (sp *servicePublisher) addEndpointSlice(newSlice *discovery.EndpointSlice) {
 	sp.Lock()
 	defer sp.Unlock()
-	sp.log.Debugf("Adding EndpointSlice for %s", sp.id)
+
+	sp.log.Debugf("Adding ES %s/%s", newSlice.Namespace, newSlice.Name)
 	for _, port := range sp.ports {
 		port.addEndpointSlice(newSlice)
 	}
@@ -610,7 +617,8 @@ func (sp *servicePublisher) addEndpointSlice(newSlice *discovery.EndpointSlice)
 func (sp *servicePublisher) updateEndpointSlice(oldSlice *discovery.EndpointSlice, newSlice *discovery.EndpointSlice) {
 	sp.Lock()
 	defer sp.Unlock()
-	sp.log.Debugf("Updating EndpointSlice for %s", sp.id)
+
+	sp.log.Debugf("Updating ES %s/%s", oldSlice.Namespace, oldSlice.Name)
 	for _, port := range sp.ports {
 		port.updateEndpointSlice(oldSlice, newSlice)
 	}
@@ -619,7 +627,8 @@ func (sp *servicePublisher) updateEndpointSlice(oldSlice *discovery.EndpointSlic
 func (sp *servicePublisher) deleteEndpointSlice(es *discovery.EndpointSlice) {
 	sp.Lock()
 	defer sp.Unlock()
-	sp.log.Debugf("Deleting EndpointSlice for %s", sp.id)
+
+	sp.log.Debugf("Deleting ES %s/%s", es.Namespace, es.Name)
 	for _, port := range sp.ports {
 		port.deleteEndpointSlice(es)
 	}
@@ -926,7 +935,13 @@ func (pp *portPublisher) endpointSliceToAddresses(es *discovery.EndpointSlice) A
 
 		if endpoint.TargetRef.Kind == endpointTargetRefPod {
 			for _, IPAddr := range endpoint.Addresses {
-				address, id, err := pp.newPodRefAddress(resolvedPort, IPAddr, endpoint.TargetRef.Name, endpoint.TargetRef.Namespace)
+				address, id, err := pp.newPodRefAddress(
+					resolvedPort,
+					es.AddressType,
+					IPAddr,
+					endpoint.TargetRef.Name,
+					endpoint.TargetRef.Namespace,
+				)
 				if err != nil {
 					pp.log.Errorf("Unable to create new address:%v", err)
 					continue
@@ -1023,6 +1038,7 @@ func (pp *portPublisher) endpointSliceToIDs(es *discovery.EndpointSlice) []ID {
 			ids = append(ids, PodID{
 				Name:      endpoint.TargetRef.Name,
 				Namespace: endpoint.TargetRef.Namespace,
+				IPFamily:  corev1.IPFamily(es.AddressType),
 			})
 		} else if endpoint.TargetRef.Kind == endpointTargetRefExternalWorkload {
 			ids = append(ids, ExternalWorkloadID{
@@ -1062,7 +1078,13 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) Addre
 			}
 
 			if endpoint.TargetRef.Kind == endpointTargetRefPod {
-				address, id, err := pp.newPodRefAddress(resolvedPort, endpoint.IP, endpoint.TargetRef.Name, endpoint.TargetRef.Namespace)
+				address, id, err := pp.newPodRefAddress(
+					resolvedPort,
+					"",
+					endpoint.IP,
+					endpoint.TargetRef.Name,
+					endpoint.TargetRef.Namespace,
+				)
 				if err != nil {
 					pp.log.Errorf("Unable to create new address:%v", err)
 					continue
@@ -1095,10 +1117,17 @@ func (pp *portPublisher) newServiceRefAddress(endpointPort Port, endpointIP, ser
 	return Address{IP: endpointIP, Port: endpointPort}, id
 }
 
-func (pp *portPublisher) newPodRefAddress(endpointPort Port, endpointIP, podName, podNamespace string) (Address, PodID, error) {
+func (pp *portPublisher) newPodRefAddress(
+	endpointPort Port,
+	ipFamily discovery.AddressType,
+	endpointIP,
+	podName,
+	podNamespace string,
+) (Address, PodID, error) {
 	id := PodID{
 		Name:      podName,
 		Namespace: podNamespace,
+		IPFamily:  corev1.IPFamily(ipFamily),
 	}
 	pod, err := pp.k8sAPI.Pod().Lister().Pods(id.Namespace).Get(id.Name)
 	if err != nil {
@@ -1462,12 +1491,12 @@ func getEndpointSliceServiceID(es *discovery.EndpointSlice) (ServiceID, error) {
 	}
 
 	if svc, ok := es.Labels[discovery.LabelServiceName]; ok {
-		return ServiceID{es.Namespace, svc}, nil
+		return ServiceID{Namespace: es.Namespace, Name: svc}, nil
 	}
 
 	for _, ref := range es.OwnerReferences {
 		if ref.Kind == "Service" && ref.Name != "" {
-			return ServiceID{es.Namespace, ref.Name}, nil
+			return ServiceID{Namespace: es.Namespace, Name: ref.Name}, nil
 		}
 	}
 
diff --git a/controller/api/destination/watcher/k8s.go b/controller/api/destination/watcher/k8s.go
index 06a1a57a2..e802ef079 100644
--- a/controller/api/destination/watcher/k8s.go
+++ b/controller/api/destination/watcher/k8s.go
@@ -33,6 +33,9 @@ type (
 	ID struct {
 		Namespace string
 		Name      string
+
+		// Only used for PodID
+		IPFamily corev1.IPFamily
 	}
 	// ServiceID is the namespace-qualified name of a service.
 	ServiceID = ID
diff --git a/controller/cmd/destination/main.go b/controller/cmd/destination/main.go
index 2363b4cad..5532ee8ae 100644
--- a/controller/cmd/destination/main.go
+++ b/controller/cmd/destination/main.go
@@ -36,6 +36,8 @@ func Main(args []string) {
 		"Enable transparently upgraded HTTP2 connections among pods in the service mesh")
 	enableEndpointSlices := cmd.Bool("enable-endpoint-slices", true,
 		"Enable the usage of EndpointSlice informers and resources")
+	enableIPv6 := cmd.Bool("enable-ipv6", true,
+		"Set to true to allow discovering IPv6 endpoints and preferring IPv6 when both IPv4 and IPv6 are available")
 	trustDomain := cmd.String("identity-trust-domain", "", "configures the name suffix used for identities")
 	clusterDomain := cmd.String("cluster-domain", "", "kubernetes cluster domain")
 	defaultOpaquePorts := cmd.String("default-opaque-ports", "", "configures the default opaque ports")
@@ -62,6 +64,10 @@ func Main(args []string) {
 
 	flags.ConfigureAndParse(cmd, args)
 
+	if *enableIPv6 && !*enableEndpointSlices {
+		log.Fatal("If --enable-ipv6=true then --enable-endpoint-slices needs to be true")
+	}
+
 	var meshedHTTP2ClientParams *pb.Http2ClientParams
 	if meshedHTTP2ClientParamsJSON != nil && *meshedHTTP2ClientParamsJSON != "" {
 		meshedHTTP2ClientParams = &pb.Http2ClientParams{}
@@ -167,6 +173,7 @@ func Main(args []string) {
 		DefaultOpaquePorts:      opaquePorts,
 		EnableH2Upgrade:         *enableH2Upgrade,
 		EnableEndpointSlices:    *enableEndpointSlices,
+		EnableIPv6:              *enableIPv6,
 		ExtEndpointZoneWeights:  *extEndpointZoneWeights,
 		MeshedHttp2ClientParams: meshedHTTP2ClientParams,
 	}
diff --git a/pkg/addr/addr.go b/pkg/addr/addr.go
index 53a8e53e0..c325a634a 100644
--- a/pkg/addr/addr.go
+++ b/pkg/addr/addr.go
@@ -46,7 +46,7 @@ func PublicIPToString(ip *l5dNetPb.IPAddress) string {
 
 // ProxyAddressToString formats a Proxy API TCPAddress as a string.
 func ProxyAddressToString(addr *pb.TcpAddress) string {
-	vizIP := proxyToVizIPAddr(addr.GetIp())
+	vizIP := FromProxyAPI(addr.GetIp())
 	if vizIP == nil {
 		return ""
 	}
@@ -90,13 +90,13 @@ func ParsePublicIP(ip string) (*l5dNetPb.IPAddress, error) {
 	if err != nil {
 		return nil, err
 	}
-	return proxyToVizIPAddr(addr), nil
+	return FromProxyAPI(addr), nil
 }
 
 // NetToPublic converts a Proxy API TCPAddress to a Viz API
 // TCPAddress.
 func NetToPublic(net *pb.TcpAddress) *l5dNetPb.TcpAddress {
-	ip := proxyToVizIPAddr(net.GetIp())
+	ip := FromProxyAPI(net.GetIp())
 
 	return &l5dNetPb.TcpAddress{
 		Ip:   ip,
@@ -104,7 +104,9 @@ func NetToPublic(net *pb.TcpAddress) *l5dNetPb.TcpAddress {
 	}
 }
 
-func proxyToVizIPAddr(net *pb.IPAddress) *l5dNetPb.IPAddress {
+// FromProxyAPI casts an IPAddress from the linkerd2-proxy-api.go.net package
+// to the linkerd2.controller.gen.common.net package
+func FromProxyAPI(net *pb.IPAddress) *l5dNetPb.IPAddress {
 	switch ip := net.GetIp().(type) {
 	case *pb.IPAddress_Ipv6:
 		return &l5dNetPb.IPAddress{


Test patch:
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index d4c21e88b..3491927a3 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -1476,6 +1476,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         - -trace-collector=collector.linkerd-jaeger.svc.cluster.local:55678
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 6ff9e74f5..1554d1ae3 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 9bfecd660..e23b71d84 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: my.custom.registry/linkerd-io/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 6ff9e74f5..1554d1ae3 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index 2e12e1638..e2565a2a0 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index c2b4b50d7..fe1138bed 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -1464,6 +1464,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 68c7c60a3..2e5eb9d48 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -1597,6 +1597,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index a18b7c924..2ff16790d 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -1597,6 +1597,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 850f2c9fb..e1eb7409e 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -1406,6 +1406,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_helm_control_plane_output.golden b/cli/cmd/testdata/install_helm_control_plane_output.golden
index 4d1b38b43..923039b72 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output.golden
@@ -1450,6 +1450,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=test.trust.domain
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:linkerd-version
diff --git a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
index e1c295f90..59c93ac12 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
@@ -1572,6 +1572,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=test.trust.domain
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:linkerd-version
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 3c2eec13a..499f31d6d 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -1584,6 +1584,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=test.trust.domain
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:linkerd-version
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index d47bdfdef..9a21dad6c 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -1562,6 +1562,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=test.trust.domain
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:linkerd-version
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 88fc05f74..79644bcd9 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -1469,6 +1469,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 0dd736e8e..87ae7fed2 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -1407,6 +1407,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,443,587,3306,5432,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         image: ControllerImage:LinkerdVersion
         imagePullPolicy: ImagePullPolicy
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index 15b589faa..e1336c069 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=cluster.local
         - -identity-trust-domain=cluster.local
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 115b2a1b2..bc703722a 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -1475,6 +1475,7 @@ spec:
         - -cluster-domain=example.com
         - -identity-trust-domain=example.com
         - -default-opaque-ports=25,587,3306,4444,5432,6379,9300,11211
+        - -enable-ipv6=true
         - -enable-pprof=false
         - --meshed-http2-client-params={"keep_alive":{"interval":{"seconds":10},"timeout":{"seconds":3},"while_idle":true}}
         image: cr.l5d.io/linkerd/controller:install-control-plane-version
diff --git a/controller/api/destination/endpoint_translator_test.go b/controller/api/destination/endpoint_translator_test.go
index b29e3c6e5..4b8f856a2 100644
--- a/controller/api/destination/endpoint_translator_test.go
+++ b/controller/api/destination/endpoint_translator_test.go
@@ -2,6 +2,7 @@ package destination
 
 import (
 	"fmt"
+	"net/netip"
 	"sort"
 	"strings"
 	"sync"
@@ -41,6 +42,26 @@ var (
 		OwnerName: "rc-name",
 	}
 
+	pod1IPv6 = watcher.Address{
+		IP:   "2001:0db8:85a3:0000:0000:8a2e:0370:7333",
+		Port: 1,
+		Pod: &corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "pod1",
+				Namespace: "ns",
+				Labels: map[string]string{
+					k8s.ControllerNSLabel:    "linkerd",
+					k8s.ProxyDeploymentLabel: "deployment-name",
+				},
+			},
+			Spec: corev1.PodSpec{
+				ServiceAccountName: "serviceaccount-name",
+			},
+		},
+		OwnerKind: "replicationcontroller",
+		OwnerName: "rc-name",
+	}
+
 	pod2 = watcher.Address{
 		IP:   "1.1.1.2",
 		Port: 2,
@@ -400,8 +421,8 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 		translator.Start()
 		defer translator.Stop()
 
-		translator.Add(mkAddressSetForPods(pod1, pod2))
-		translator.Remove(mkAddressSetForPods(pod2))
+		translator.Add(mkAddressSetForPods(t, pod1, pod2))
+		translator.Remove(mkAddressSetForPods(t, pod2))
 
 		expectedNumUpdates := 2
 		<-mockGetServer.updatesReceived // Add
@@ -417,8 +438,8 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 		translator.Start()
 		defer translator.Stop()
 
-		translator.Add(mkAddressSetForPods(pod1, pod2, pod3))
-		translator.Remove(mkAddressSetForPods(pod3))
+		translator.Add(mkAddressSetForPods(t, pod1, pod2, pod3))
+		translator.Remove(mkAddressSetForPods(t, pod3))
 
 		addressesAdded := (<-mockGetServer.updatesReceived).GetAdd().Addrs
 		actualNumberOfAdded := len(addressesAdded)
@@ -447,7 +468,7 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 		translator.Start()
 		defer translator.Stop()
 
-		translator.Add(mkAddressSetForPods(pod1))
+		translator.Add(mkAddressSetForPods(t, pod1))
 
 		update := <-mockGetServer.updatesReceived
 
@@ -479,7 +500,7 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 		translator.Start()
 		defer translator.Stop()
 
-		translator.Add(mkAddressSetForPods(pod1))
+		translator.Add(mkAddressSetForPods(t, pod1))
 
 		addrs := (<-mockGetServer.updatesReceived).GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -518,6 +539,56 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 			t.Fatalf("ProtocolHint: %v", diff)
 		}
 	})
+
+	t.Run("Sends IPv6 only when pod has both IPv4 and IPv6", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+		translator.Start()
+		defer translator.Stop()
+
+		translator.Add(mkAddressSetForPods(t, pod1, pod1IPv6))
+
+		addrs := (<-mockGetServer.updatesReceived).GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+		if ipPort := addr.ProxyAddressToString(addrs[0].GetAddr()); ipPort != "[2001:db8:85a3::8a2e:370:7333]:1" {
+			t.Fatalf("Expected address to be [%s], got [%s]", "[2001:db8:85a3::8a2e:370:7333]:1", ipPort)
+		}
+
+		if updates := len(mockGetServer.updatesReceived); updates > 0 {
+			t.Fatalf("Expected to receive no more messages, received [%d]", updates)
+		}
+	})
+
+	t.Run("Sends IPv4 only when pod has both IPv4 and IPv6 but the latter in another zone ", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+		translator.Start()
+		defer translator.Stop()
+
+		pod1West1a := pod1
+		pod1West1a.ForZones = []v1.ForZone{
+			{Name: "west-1a"},
+		}
+
+		pod1IPv6West1b := pod1IPv6
+		pod1IPv6West1b.ForZones = []v1.ForZone{
+			{Name: "west-1b"},
+		}
+
+		translator.Add(mkAddressSetForPods(t, pod1West1a, pod1IPv6West1b))
+
+		addrs := (<-mockGetServer.updatesReceived).GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+		if ipPort := addr.ProxyAddressToString(addrs[0].GetAddr()); ipPort != "1.1.1.1:1" {
+			t.Fatalf("Expected address to be [%s], got [%s]", "1.1.1.1:1", ipPort)
+		}
+
+		if updates := len(mockGetServer.updatesReceived); updates > 0 {
+			t.Fatalf("Expected to receive no more messages, received [%d]", updates)
+		}
+	})
 }
 
 func TestEndpointTranslatorExternalWorkloads(t *testing.T) {
@@ -842,13 +913,30 @@ func mkAddressSetForServices(gatewayAddresses ...watcher.Address) watcher.Addres
 	return set
 }
 
-func mkAddressSetForPods(podAddresses ...watcher.Address) watcher.AddressSet {
+func mkAddressSetForPods(t *testing.T, podAddresses ...watcher.Address) watcher.AddressSet {
+	t.Helper()
+
 	set := watcher.AddressSet{
 		Addresses: make(map[watcher.PodID]watcher.Address),
 		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
 	}
 	for _, p := range podAddresses {
-		id := watcher.PodID{Name: p.Pod.Name, Namespace: p.Pod.Namespace}
+		// The IP family is set on the PodID used to index the
+		// watcher.Address; here we simply detect it
+		fam := corev1.IPv4Protocol
+		addr, err := netip.ParseAddr(p.IP)
+		if err != nil {
+			t.Fatalf("Invalid IP '%s': %s", p.IP, err)
+		}
+		if addr.Is6() {
+			fam = corev1.IPv6Protocol
+		}
+
+		id := watcher.PodID{
+			Name:      p.Pod.Name,
+			Namespace: p.Pod.Namespace,
+			IPFamily:  fam,
+		}
 		set.Addresses[id] = p
 	}
 	return set
diff --git a/controller/api/destination/server_ipv6_test.go b/controller/api/destination/server_ipv6_test.go
new file mode 100644
index 000000000..0f058352f
--- /dev/null
+++ b/controller/api/destination/server_ipv6_test.go
@@ -0,0 +1,179 @@
+package destination
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
+	"github.com/linkerd/linkerd2/controller/api/util"
+	corev1 "k8s.io/api/core/v1"
+	discovery "k8s.io/api/discovery/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+func TestIPv6(t *testing.T) {
+	port := int32(port)
+	protocol := corev1.ProtocolTCP
+
+	server := makeServer(t)
+	defer server.clusterStore.UnregisterGauges()
+
+	stream := &bufferingGetStream{
+		updates:          make(chan *pb.Update, 50),
+		MockServerStream: util.NewMockServerStream(),
+	}
+	defer stream.Cancel()
+
+	t.Run("Return only IPv6 endpoint for dual-stack service", func(t *testing.T) {
+		testReturnEndpointsForServer(t, server, stream, fullyQualifiedNameDual, podIPv6Dual, uint32(port))
+	})
+
+	t.Run("Returns only IPv4 endpoint when service becomes single-stack IPv4", func(t *testing.T) {
+		patch := []byte(`{"spec":{"clusterIPs": ["172.17.13.0"], "ipFamilies":["IPv4"]}}`)
+		_, err := server.k8sAPI.Client.CoreV1().Services("ns").Patch(context.Background(), "name-ds", types.MergePatchType, patch, metav1.PatchOptions{})
+		if err != nil {
+			t.Fatalf("Failed patching name-ds service: %s", err)
+		}
+		if err = server.k8sAPI.Client.DiscoveryV1().EndpointSlices("ns").Delete(context.Background(), "name-ds-ipv6", metav1.DeleteOptions{}); err != nil {
+			t.Fatalf("Failed deleting name-ds-ipv6 ES: %s", err)
+		}
+
+		update := <-stream.updates
+		if updateAddAddress(t, update)[0] != "172.17.0.19:8989" {
+			t.Fatalf("Expected %s but got %s", "172.17.0.19:8989", updateAddAddress(t, update)[0])
+		}
+
+		update = <-stream.updates
+		if updateRemoveAddress(t, update)[0] != "[2001:db8::94]:8989" {
+			t.Fatalf("Expected %s but got %s", "[2001:db8::94]:8989", updateRemoveAddress(t, update)[0])
+		}
+	})
+
+	t.Run("Returns only IPv6 endpoint when service becomes dual-stack again", func(t *testing.T) {
+		// We patch the service to become dual-stack again and we add the IPv6
+		// ES. We should receive the events for the removal of the IPv4 ES and
+		// the addition of the IPv6 one.
+		patch := []byte(`{"spec":{"clusterIPs": ["172.17.13.0","2001:db8::88"], "ipFamilies":["IPv4","IPv6"]}}`)
+		_, err := server.k8sAPI.Client.CoreV1().Services("ns").Patch(context.Background(), "name-ds", types.MergePatchType, patch, metav1.PatchOptions{})
+		if err != nil {
+			t.Fatalf("Failed patching name-ds service: %s", err)
+		}
+
+		es := &discovery.EndpointSlice{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "EndpointSlice",
+				APIVersion: "discovery.k8s.io/v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "name-ds-ipv6",
+				Namespace: "ns",
+				Labels: map[string]string{
+					"kubernetes.io/service-name": "name-ds",
+				},
+			},
+			AddressType: discovery.AddressTypeIPv6,
+			Ports: []discovery.EndpointPort{
+				{
+					Port:     &port,
+					Protocol: &protocol,
+				},
+			},
+			Endpoints: []discovery.Endpoint{
+				{
+					Addresses: []string{"2001:db8::94"},
+					TargetRef: &corev1.ObjectReference{
+						Kind:      "Pod",
+						Namespace: "ns",
+						Name:      "name-ds",
+					},
+				},
+			},
+		}
+		if _, err := server.k8sAPI.Client.DiscoveryV1().EndpointSlices("ns").Create(context.Background(), es, metav1.CreateOptions{}); err != nil {
+			t.Fatalf("Failed creating name-ds-ipv6 ES: %s", err)
+		}
+
+		update := <-stream.updates
+		if updateAddAddress(t, update)[0] != "[2001:db8::94]:8989" {
+			t.Fatalf("Expected %s but got %s", "[2001:db8::94]:8989", updateAddAddress(t, update)[0])
+		}
+
+		update = <-stream.updates
+		if updateRemoveAddress(t, update)[0] != "172.17.0.19:8989" {
+			t.Fatalf("Expected %s but got %s", "172.17.0.19:8989", updateRemoveAddress(t, update)[0])
+		}
+	})
+
+	t.Run("Doesn't return anything when adding an IPv4 to the dual-stack service", func(t *testing.T) {
+		es := &discovery.EndpointSlice{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "EndpointSlice",
+				APIVersion: "discovery.k8s.io/v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "name-ds-ipv4-2",
+				Namespace: "ns",
+				Labels: map[string]string{
+					"kubernetes.io/service-name": "name-ds",
+				},
+			},
+			AddressType: discovery.AddressTypeIPv4,
+			Ports: []discovery.EndpointPort{
+				{
+					Port:     &port,
+					Protocol: &protocol,
+				},
+			},
+			Endpoints: []discovery.Endpoint{
+				{
+					Addresses: []string{"172.17.0.20"},
+					TargetRef: &corev1.ObjectReference{
+						Kind:      "Pod",
+						Namespace: "ns",
+						Name:      "name-ds",
+					},
+				},
+			},
+		}
+		if _, err := server.k8sAPI.Client.DiscoveryV1().EndpointSlices("ns").Create(context.Background(), es, metav1.CreateOptions{}); err != nil {
+			t.Fatalf("Failed creating name-ds-ipv4-2 ES: %s", err)
+		}
+
+		time.Sleep(50 * time.Millisecond)
+
+		if len(stream.updates) != 0 {
+			t.Fatalf("Expected no events but got %#v", stream.updates)
+		}
+	})
+
+	t.Run("Doesn't return anything when removing an IPv4 ES from the dual-stack service", func(t *testing.T) {
+		if err := server.k8sAPI.Client.DiscoveryV1().EndpointSlices("ns").Delete(context.Background(), "name-ds-ipv4-2", metav1.DeleteOptions{}); err != nil {
+			t.Fatalf("Failed deleting name-ds-ipv4-2 ES: %s", err)
+		}
+
+		time.Sleep(50 * time.Millisecond)
+
+		if len(stream.updates) != 0 {
+			t.Fatalf("Expected no events but got %#v", stream.updates)
+		}
+	})
+
+	t.Run("Doesn't return anything when the service becomes single-stack IPv6", func(t *testing.T) {
+		patch := []byte(`{"spec":{"clusterIPs": ["2001:db8::88"], "ipFamilies":["IPv6"]}}`)
+		_, err := server.k8sAPI.Client.CoreV1().Services("ns").Patch(context.Background(), "name-ds", types.MergePatchType, patch, metav1.PatchOptions{})
+		if err != nil {
+			t.Fatalf("Failed patching name-ds service: %s", err)
+		}
+		if err := server.k8sAPI.Client.DiscoveryV1().EndpointSlices("ns").Delete(context.Background(), "name-ds-ipv4", metav1.DeleteOptions{}); err != nil {
+			t.Fatalf("Failed deleting name-ds-ipv4 ES: %s", err)
+		}
+
+		time.Sleep(50 * time.Millisecond)
+
+		if len(stream.updates) != 0 {
+			t.Fatalf("Expected no events but got %#v", stream.updates)
+		}
+	})
+}
diff --git a/controller/api/destination/server_test.go b/controller/api/destination/server_test.go
index 8f9687f2a..e7a1c8d66 100644
--- a/controller/api/destination/server_test.go
+++ b/controller/api/destination/server_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	gonet "net"
+	"net/netip"
 	"reflect"
 	"testing"
 	"time"
@@ -27,6 +28,8 @@ import (
 )
 
 const fullyQualifiedName = "name1.ns.svc.mycluster.local"
+const fullyQualifiedNameIPv6 = "name-ipv6.ns.svc.mycluster.local"
+const fullyQualifiedNameDual = "name-ds.ns.svc.mycluster.local"
 const fullyQualifiedNameOpaque = "name3.ns.svc.mycluster.local"
 const fullyQualifiedNameOpaqueService = "name4.ns.svc.mycluster.local"
 const fullyQualifiedNameSkipped = "name5.ns.svc.mycluster.local"
@@ -36,6 +39,7 @@ const clusterIPv6 = "2001:db8::88"
 const clusterIPOpaque = "172.17.12.1"
 const podIP1 = "172.17.0.12"
 const podIP1v6 = "2001:db8::68"
+const podIPv6Dual = "2001:db8::94"
 const podIP2 = "172.17.0.13"
 const podIPOpaque = "172.17.0.14"
 const podIPSkipped = "172.17.0.15"
@@ -82,38 +86,16 @@ func TestGet(t *testing.T) {
 		}
 	})
 
-	t.Run("Returns endpoints", func(t *testing.T) {
-		server := makeServer(t)
-		defer server.clusterStore.UnregisterGauges()
-
-		stream := &bufferingGetStream{
-			updates:          make(chan *pb.Update, 50),
-			MockServerStream: util.NewMockServerStream(),
-		}
-		defer stream.Cancel()
-		errs := make(chan error)
-
-		// server.Get blocks until the grpc stream is complete so we call it
-		// in a goroutine and watch stream.updates for updates.
-		go func() {
-			err := server.Get(&pb.GetDestination{Scheme: "k8s", Path: fmt.Sprintf("%s:%d", fullyQualifiedName, port)}, stream)
-			if err != nil {
-				errs <- err
-			}
-		}()
+	t.Run("Returns endpoints (IPv4)", func(t *testing.T) {
+		testReturnEndpoints(t, fullyQualifiedName, podIP1, port)
+	})
 
-		select {
-		case update := <-stream.updates:
-			if updateAddAddress(t, update)[0] != fmt.Sprintf("%s:%d", podIP1, port) {
-				t.Fatalf("Expected %s but got %s", fmt.Sprintf("%s:%d", podIP1, port), updateAddAddress(t, update)[0])
-			}
+	t.Run("Returns endpoints (IPv6)", func(t *testing.T) {
+		testReturnEndpoints(t, fullyQualifiedNameIPv6, podIP1v6, port)
+	})
 
-			if len(stream.updates) != 0 {
-				t.Fatalf("Expected 1 update but got %d: %v", 1+len(stream.updates), stream.updates)
-			}
-		case err := <-errs:
-			t.Fatalf("Got error: %s", err)
-		}
+	t.Run("Returns endpoints (dual-stack)", func(t *testing.T) {
+		testReturnEndpoints(t, fullyQualifiedNameDual, podIPv6Dual, port)
 	})
 
 	t.Run("Sets meshed HTTP/2 client params", func(t *testing.T) {
@@ -494,7 +476,7 @@ func TestGetProfiles(t *testing.T) {
 		stream := profileStream(t, server, clusterIPv6, port, "")
 		defer stream.Cancel()
 		profile := assertSingleProfile(t, stream.Updates())
-		if profile.FullyQualifiedName != fullyQualifiedName {
+		if profile.FullyQualifiedName != fullyQualifiedNameDual {
 			t.Fatalf("Expected fully qualified name '%s', but got '%s'", fullyQualifiedName, profile.FullyQualifiedName)
 		}
 		if profile.OpaqueProtocol {
@@ -608,10 +590,10 @@ func TestGetProfiles(t *testing.T) {
 		server := makeServer(t)
 		defer server.clusterStore.UnregisterGauges()
 
-		stream := profileStream(t, server, podIP1v6, port, "ns:ns")
+		stream := profileStream(t, server, podIPv6Dual, port, "ns:ns")
 		defer stream.Cancel()
 
-		epAddr, err := toAddress(podIP1v6, port)
+		epAddr, err := toAddress(podIPv6Dual, port)
 		if err != nil {
 			t.Fatalf("Got error: %s", err)
 		}
@@ -1181,6 +1163,19 @@ func updateAddAddress(t *testing.T, update *pb.Update) []string {
 	return ips
 }
 
+func updateRemoveAddress(t *testing.T, update *pb.Update) []string {
+	t.Helper()
+	add, ok := update.GetUpdate().(*pb.Update_Remove)
+	if !ok {
+		t.Fatalf("Update expected to be a remove, but was %+v", update)
+	}
+	ips := []string{}
+	for _, ip := range add.Remove.Addrs {
+		ips = append(ips, addr.ProxyAddressToString(ip))
+	}
+	return ips
+}
+
 func toAddress(path string, port uint32) (*net.TcpAddress, error) {
 	ip, err := addr.ParseProxyIP(path)
 	if err != nil {
@@ -1196,7 +1191,6 @@ func TestIpWatcherGetSvcID(t *testing.T) {
 	name := "service"
 	namespace := "test"
 	clusterIP := "10.245.0.1"
-	clusterIPv6 := "2001:db8::68"
 	k8sConfigs := `
 apiVersion: v1
 kind: Service
@@ -1208,7 +1202,7 @@ spec:
   clusterIP: 10.245.0.1
   clusterIPs:
   - 10.245.0.1
-  - 2001:db8::68
+  - 2001:db8::88
   ports:
   - port: 1234`
 
@@ -1264,6 +1258,57 @@ spec:
 	})
 }
 
+func testReturnEndpoints(t *testing.T, fqdn, ip string, port uint32) {
+	t.Helper()
+
+	server := makeServer(t)
+	defer server.clusterStore.UnregisterGauges()
+
+	stream := &bufferingGetStream{
+		updates:          make(chan *pb.Update, 50),
+		MockServerStream: util.NewMockServerStream(),
+	}
+	defer stream.Cancel()
+
+	testReturnEndpointsForServer(t, server, stream, fqdn, ip, port)
+}
+
+func testReturnEndpointsForServer(t *testing.T, server *server, stream *bufferingGetStream, fqdn, ip string, port uint32) {
+	t.Helper()
+
+	errs := make(chan error)
+	// server.Get blocks until the grpc stream is complete so we call it
+	// in a goroutine and watch stream.updates for updates.
+	go func() {
+		err := server.Get(&pb.GetDestination{Scheme: "k8s", Path: fmt.Sprintf("%s:%d", fqdn, port)}, stream)
+		if err != nil {
+			errs <- err
+		}
+	}()
+
+	addr := fmt.Sprintf("%s:%d", ip, port)
+	parsedIP, err := netip.ParseAddr(ip)
+	if err != nil {
+		t.Fatalf("Invalid IP [%s]: %s", ip, err)
+	}
+	if parsedIP.Is6() {
+		addr = fmt.Sprintf("[%s]:%d", ip, port)
+	}
+
+	select {
+	case update := <-stream.updates:
+		if updateAddAddress(t, update)[0] != addr {
+			t.Fatalf("Expected %s but got %s", addr, updateAddAddress(t, update)[0])
+		}
+
+		if len(stream.updates) != 0 {
+			t.Fatalf("Expected 1 update but got %d: %v", 1+len(stream.updates), stream.updates)
+		}
+	case err := <-errs:
+		t.Fatalf("Got error: %s", err)
+	}
+}
+
 func assertSingleProfile(t *testing.T, updates []*pb.DestinationProfile) *pb.DestinationProfile {
 	t.Helper()
 	// Under normal conditions the creation of resources by the fake API will
diff --git a/controller/api/destination/test_util.go b/controller/api/destination/test_util.go
index 246aaee19..041232d40 100644
--- a/controller/api/destination/test_util.go
+++ b/controller/api/destination/test_util.go
@@ -32,10 +32,11 @@ metadata:
   namespace: ns
 spec:
   type: LoadBalancer
+  ipFamilies:
+  - IPv4
   clusterIP: 172.17.12.0
   clusterIPs:
   - 172.17.12.0
-  - 2001:db8::88
   ports:
   - port: 8989`,
 		`
@@ -55,25 +56,6 @@ endpoints:
     name: name1-1
     namespace: ns
 ports:
-- port: 8989
-  protocol: TCP`,
-		`
-apiVersion: discovery.k8s.io/v1
-kind: EndpointSlice
-metadata:
-  name: name1-ipv6
-  namespace: ns
-  labels:
-    kubernetes.io/service-name: name1
-addressType: IPv6
-endpoints:
-- addresses:
-  - 2001:db8::90
-  targetRef:
-    kind: Pod
-    name: name1-1
-    namespace: ns
-ports:
 - port: 8989
   protocol: TCP`,
 		`
@@ -92,7 +74,6 @@ status:
   podIP: 172.17.0.12
   podIPs:
   - ip: 172.17.0.12
-  - ip: 2001:db8::68
 spec:
   containers:
     - env:
@@ -752,7 +733,8 @@ ports:
 - port: 80
   protocol: TCP`,
 	}
-	extenalNameResources := []string{
+
+	externalNameResources := []string{
 		`
 apiVersion: v1
 kind: Service
@@ -764,6 +746,158 @@ spec:
   externalName: linkerd.io`,
 	}
 
+	ipv6 := []string{
+		`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name-ipv6
+  namespace: ns
+spec:
+  type: ClusterIP
+  ipFamilies:
+  - IPv6
+  clusterIP: 2001:db8::93
+  clusterIPs:
+  - 2001:db8::93
+  ports:
+  - port: 8989`,
+		`
+apiVersion: discovery.k8s.io/v1
+kind: EndpointSlice
+metadata:
+  name: name-ipv6
+  namespace: ns
+  labels:
+    kubernetes.io/service-name: name-ipv6
+addressType: IPv6
+endpoints:
+- addresses:
+  - 2001:db8::68
+  targetRef:
+    kind: Pod
+    name: name-ipv6
+    namespace: ns
+ports:
+- port: 8989
+  protocol: TCP`,
+		`
+apiVersion: v1
+kind: Pod
+metadata:
+  labels:
+    linkerd.io/control-plane-ns: linkerd
+  name: name-ipv6
+  namespace: ns
+status:
+  phase: Running
+  conditions:
+  - type: Ready
+    status: "True"
+  podIP: 2001:db8::68
+  podIPs:
+  - ip: 2001:db8::68
+spec:
+  containers:
+    - env:
+      - name: LINKERD2_PROXY_INBOUND_LISTEN_ADDR
+        value: 0.0.0.0:4143
+      name: linkerd-proxy`,
+	}
+
+	dualStack := []string{
+		`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name-ds
+  namespace: ns
+spec:
+  type: ClusterIP
+  ipFamilies:
+  - IPv4
+  - IPv6
+  clusterIP: 172.17.13.0
+  clusterIPs:
+  - 172.17.13.0
+  - 2001:db8::88
+  ports:
+  - port: 8989`,
+		`
+apiVersion: discovery.k8s.io/v1
+kind: EndpointSlice
+metadata:
+  name: name-ds-ipv4
+  namespace: ns
+  labels:
+    kubernetes.io/service-name: name-ds
+addressType: IPv4
+endpoints:
+- addresses:
+  - 172.17.0.19
+  targetRef:
+    kind: Pod
+    name: name-ds
+    namespace: ns
+ports:
+- port: 8989
+  protocol: TCP`,
+		`
+apiVersion: discovery.k8s.io/v1
+kind: EndpointSlice
+metadata:
+  name: name-ds-ipv6
+  namespace: ns
+  labels:
+    kubernetes.io/service-name: name-ds
+addressType: IPv6
+endpoints:
+- addresses:
+  - 2001:db8::94
+  targetRef:
+    kind: Pod
+    name: name-ds
+    namespace: ns
+ports:
+- port: 8989
+  protocol: TCP`,
+		`
+apiVersion: v1
+kind: Pod
+metadata:
+  labels:
+    linkerd.io/control-plane-ns: linkerd
+  name: name-ds
+  namespace: ns
+status:
+  phase: Running
+  conditions:
+  - type: Ready
+    status: "True"
+  podIP: 172.17.0.19
+  podIPs:
+  - ip: 172.17.0.19
+  - ip: 2001:db8::94
+spec:
+  containers:
+    - env:
+      - name: LINKERD2_PROXY_INBOUND_LISTEN_ADDR
+        value: 0.0.0.0:4143
+      name: linkerd-proxy`,
+		`
+apiVersion: linkerd.io/v1alpha2
+kind: ServiceProfile
+metadata:
+  name: name-ds.ns.svc.mycluster.local
+  namespace: ns
+spec:
+  routes:
+  - name: route1
+    isRetryable: false
+    condition:
+      pathRegex: "/a/b/c"`,
+	}
+
 	res := append(meshedPodResources, clientSP...)
 	res = append(res, unmeshedPod)
 	res = append(res, meshedOpaquePodResources...)
@@ -776,7 +910,9 @@ spec:
 	res = append(res, mirrorServiceResources...)
 	res = append(res, destinationCredentialsResources...)
 	res = append(res, externalWorkloads...)
-	res = append(res, extenalNameResources...)
+	res = append(res, externalNameResources...)
+	res = append(res, ipv6...)
+	res = append(res, dualStack...)
 	k8sAPI, l5dClient, err := k8s.NewFakeAPIWithL5dClient(res...)
 	if err != nil {
 		t.Fatalf("NewFakeAPIWithL5dClient returned an error: %s", err)
@@ -833,6 +969,7 @@ spec:
 		pb.UnimplementedDestinationServer{},
 		Config{
 			EnableH2Upgrade:     true,
+			EnableIPv6:          true,
 			ControllerNS:        "linkerd",
 			ClusterDomain:       "mycluster.local",
 			IdentityTrustDomain: "trust.domain",
@@ -928,6 +1065,7 @@ metadata:
 		"linkerd",
 		"trust.domain",
 		true,
+		true,
 		true,  // enableEndpointFiltering
 		false, // extEndpointZoneWeights
 		nil,   // meshedHttp2ClientParams