Skip to content

NetworkEgress Service omits targetPort — clients behind Istio ambient get connection reset #379

Description

@raulmartinezr

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

  1. Cluster with Istio ambient; a namespace labeled istio.io/dataplane-mode: ambient.
  2. Create a NetworkEgress in that namespace targeting any reachable peer FQDN/port.
  3. curl http://<egress-service>:<port>/ from a pod in that namespace → Recv failure: Connection reset by peer.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions