TL;DR: The Service created for a NetworkEgress sets targetPort equal to port instead of the allocated router forward port. Standard Kubernetes dataplanes ignore this (the manual EndpointSlice port wins), but Istio ambient's ztunnel resolves the VIP using the Service targetPort, so every mesh-captured client gets connection reset by peer. Setting targetPort to the forward port fixes it (verified live).
Environment
- kubernetes-operator 0.8.0 (Helm chart 0.8.0), NetBird 0.74.6 self-hosted
- EKS 1.34, Cilium (kube-proxy replacement), Istio 1.28.6 ambient mesh
What happens
NetworkEgress reconciles fine (Ready=True) and creates:
- a selectorless Service:
port: 9876, targetPort: 9876 (defaulted, since the controller never sets it)
- a manual EndpointSlice: router pod IP,
port: 38035 (the forward port the router actually listens on)
The two objects contradict each other, and each dataplane believes a different one:
| Client pod |
direct podIP:38035 |
Service VIP :9876 |
| ambient-captured (ztunnel) |
200 |
connection reset after the request is sent |
not captured (istio.io/dataplane-mode: none) |
200 |
200 |
Cilium programs the VIP correctly from the slice (cilium service list shows VIP:9876 -> podIP:38035), which is why non-captured pods work: for selectorless Services the EndpointSlice port is authoritative and targetPort is ignored. ztunnel instead resolves the VIP with the Service's targetPort and dials podIP:9876, where nothing listens, so the TCP connection is reset.
In an ambient-mesh cluster this means no meshed workload can consume a NetworkEgress Service — which is most workloads in such clusters.
Reproduction
- Cluster with Istio ambient; a namespace labeled
istio.io/dataplane-mode: ambient.
- Create a
NetworkEgress in that namespace targeting any reachable peer FQDN/port.
curl http://<egress-service>:<port>/ from a pod in that namespace → Recv failure: Connection reset by peer.
- Same curl from a pod labeled
istio.io/dataplane-mode: none → works.
Fix (verified)
The controller builds the Service ports without a target port (networkegress_controller.go):
ports = append(ports, corev1ac.ServicePort().WithName(port.Name).WithPort(port.Port))
Adding .WithTargetPort(intstr.FromInt32(<allocated forward port>)) makes the Service agree with the EndpointSlice, and both resolution paths converge. Verified live: after kubectl patch svc <egress> --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":38035}]', the same ambient-captured pod that got the reset receives HTTP 200 through the tunnel.
Arguably ztunnel should also honor manual EndpointSlice ports for selectorless Services (happy to file that on the Istio side too), but making the operator emit self-consistent objects fixes it for every dataplane today.
TL;DR: The Service created for a
NetworkEgresssetstargetPortequal toportinstead of the allocated router forward port. Standard Kubernetes dataplanes ignore this (the manual EndpointSlice port wins), but Istio ambient's ztunnel resolves the VIP using the ServicetargetPort, so every mesh-captured client getsconnection reset by peer. SettingtargetPortto the forward port fixes it (verified live).Environment
What happens
NetworkEgressreconciles fine (Ready=True) and creates:port: 9876,targetPort: 9876(defaulted, since the controller never sets it)port: 38035(the forward port the router actually listens on)The two objects contradict each other, and each dataplane believes a different one:
podIP:38035:9876istio.io/dataplane-mode: none)Cilium programs the VIP correctly from the slice (
cilium service listshowsVIP:9876 -> podIP:38035), which is why non-captured pods work: for selectorless Services the EndpointSlice port is authoritative andtargetPortis ignored. ztunnel instead resolves the VIP with the Service'stargetPortand dialspodIP:9876, where nothing listens, so the TCP connection is reset.In an ambient-mesh cluster this means no meshed workload can consume a NetworkEgress Service — which is most workloads in such clusters.
Reproduction
istio.io/dataplane-mode: ambient.NetworkEgressin that namespace targeting any reachable peer FQDN/port.curl http://<egress-service>:<port>/from a pod in that namespace →Recv failure: Connection reset by peer.istio.io/dataplane-mode: none→ works.Fix (verified)
The controller builds the Service ports without a target port (
networkegress_controller.go):Adding
.WithTargetPort(intstr.FromInt32(<allocated forward port>))makes the Service agree with the EndpointSlice, and both resolution paths converge. Verified live: afterkubectl patch svc <egress> --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":38035}]', the same ambient-captured pod that got the reset receives HTTP 200 through the tunnel.Arguably ztunnel should also honor manual EndpointSlice ports for selectorless Services (happy to file that on the Istio side too), but making the operator emit self-consistent objects fixes it for every dataplane today.