Repository: antrea-io/antrea
Pull request #6287 (2024-05-05 03:16:29)

Fix antctl mc deploy command usage
This patch ensures that `v` is optional in the version tag and improves the error message if the specified version tag is not found.

If someone provide an invalid tag the error message would be,

```
$ ./bin/antctl mc  deploy leadercluster -n antrea-multicluster --antrea-version 2.15.0
Error: specified version tag is not found
```

This PR addresses the issue #6151


Patch:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index fb0233b38..9a9e0c658 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -7,7 +7,24 @@ on:
     branches: [ "main" ]
 
 jobs:
+  check-changes:
+    name: Check whether tests need to be run based on diff
+    runs-on: [ubuntu-latest]
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        fetch-depth: 0
+        show-progress: false
+    - uses: antrea-io/has-changes@v2
+      id: check_diff
+      with:
+        paths-ignore: docs/* ci/jenkins/* *.md hack/.notableofcontents
+    outputs:
+      has_changes: ${{ steps.check_diff.outputs.has_changes }}
+
   analyze-on-linux:
+    needs: check-changes
+    if: ${{ needs.check-changes.outputs.has_changes == 'yes' }}
     name: Analyze on Linux
     runs-on: ubuntu-latest
     permissions:
@@ -50,6 +67,8 @@ jobs:
         category: "/language:${{matrix.language}}"
 
   analyze-on-windows:
+    needs: check-changes
+    if: ${{ needs.check-changes.outputs.has_changes == 'yes' }}
     name: Analyze on Windows
     runs-on: windows-latest
     permissions:
diff --git a/.github/workflows/kind.yml b/.github/workflows/kind.yml
index 845d5e3a9..e252cde02 100644
--- a/.github/workflows/kind.yml
+++ b/.github/workflows/kind.yml
@@ -238,7 +238,8 @@ jobs:
             --proxy-all \
             --node-ipam \
             --extra-vlan \
-            --multicast
+            --multicast \
+            --networkpolicy-evaluation
       - name: Tar coverage files
         run: tar -czf test-e2e-encap-all-features-enabled-coverage.tar.gz test-e2e-encap-all-features-enabled-coverage
       - name: Upload coverage for test-e2e-encap-all-features-enabled-coverage
@@ -771,10 +772,9 @@ jobs:
           sudo mv kind /usr/local/bin
       - name: Create Kind Cluster
         run: |
-          kind create cluster --config ci/kind/config-3nodes.yml
-      - name: Load Docker images and deploy Antrea
+          ./ci/kind/kind-setup.sh create kind --ip-family dual
+      - name: Deploy Antrea
         run: |
-          kind load docker-image antrea/antrea-controller-ubuntu-coverage:latest antrea/antrea-agent-ubuntu-coverage:latest
           kubectl apply -f build/yamls/antrea.yml
       - name: Build antctl binary
         run: |
diff --git a/.github/workflows/trivy_scan.yml b/.github/workflows/trivy_scan.yml
index 0710e6aa0..52993ab2b 100644
--- a/.github/workflows/trivy_scan.yml
+++ b/.github/workflows/trivy_scan.yml
@@ -37,7 +37,7 @@ jobs:
         docker pull antrea/antrea-controller-ubuntu:${{ steps.find-antrea-greatest-version.outputs.antrea_version }}
     - name: Run Trivy vulnerability scanner on latest antrea-agent Docker image
       if: ${{ always() && steps.pull.conclusion == 'success' }}
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       # we cannot use .trivy.yml as we need to override some config parameters
       # and that is not supported by aquasecurity/trivy-action
       with:
@@ -50,7 +50,7 @@ jobs:
         output: 'trivy.latest.txt'
     - name: Run Trivy vulnerability scanner on latest antrea-controller Docker image
       if: ${{ always() && steps.pull.conclusion == 'success' }}
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       # we cannot use .trivy.yml as we need to override some config parameters
       # and that is not supported by aquasecurity/trivy-action
       with:
@@ -63,7 +63,7 @@ jobs:
         output: 'trivy.latest.txt'
     - name: Run Trivy vulnerability scanner on antrea-agent Docker image for latest released version
       if: ${{ always() && steps.pull.conclusion == 'success' }}
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       with:
         scan-type: 'image'
         image-ref: 'antrea/antrea-agent-ubuntu:${{ steps.find-antrea-greatest-version.outputs.antrea_version }}'
@@ -74,7 +74,7 @@ jobs:
         output: 'trivy.${{ steps.find-antrea-greatest-version.outputs.antrea_version }}.txt'
     - name: Run Trivy vulnerability scanner on antrea-controller Docker image for latest released version
       if: ${{ always() && steps.pull.conclusion == 'success' }}
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       with:
         scan-type: 'image'
         image-ref: 'antrea/antrea-controller-ubuntu:${{ steps.find-antrea-greatest-version.outputs.antrea_version }}'
diff --git a/.github/workflows/trivy_scan_before_release.yml b/.github/workflows/trivy_scan_before_release.yml
index b68f179b7..398804097 100644
--- a/.github/workflows/trivy_scan_before_release.yml
+++ b/.github/workflows/trivy_scan_before_release.yml
@@ -15,13 +15,13 @@ jobs:
       run: |
         ./hack/build-antrea-linux-all.sh --pull
     - name: Run Trivy vulnerability scanner on the antrea-agent Docker image
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       with:
         scan-type: 'image'
         image-ref: 'antrea/antrea-agent-ubuntu:latest'
         trivy-config: '.trivy.yml'
     - name: Run Trivy vulnerability scanner on the antrea-controller Docker image
-      uses: aquasecurity/trivy-action@0.19.0
+      uses: aquasecurity/trivy-action@0.20.0
       with:
         scan-type: 'image'
         image-ref: 'antrea/antrea-controller-ubuntu:latest'
diff --git a/build/charts/antrea/crds/traceflow.yaml b/build/charts/antrea/crds/traceflow.yaml
index 2df4650a1..115aa5f22 100644
--- a/build/charts/antrea/crds/traceflow.yaml
+++ b/build/charts/antrea/crds/traceflow.yaml
@@ -217,6 +217,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/images/scripts/start_ovs b/build/images/scripts/start_ovs
index daff5c5a7..4949a37b0 100755
--- a/build/images/scripts/start_ovs
+++ b/build/images/scripts/start_ovs
@@ -130,9 +130,12 @@ function quit {
     # been restored. The uplink is moved to the OVS bridge to support AntreaFlexibleIPAM mode.
     while [ "`ovsdb-client dump Port|grep antrea-type=uplink`" != "" ]; do
       log_info $CONTAINER_NAME "Uplink found on OVS, wait 1s and retry..."
-      sleep 1 &
-      SLEEP_PID=$!
-      wait $SLEEP_PID
+      # If a second signal (besides SIGKILL) is received, bash will wait for the
+      # sleep to complete, and this function will run again. There is no point
+      # in running sleep in the background here (and using wait): it would just
+      # create extra "concurrent" sleep processes in case multiple signals are
+      # received.
+      sleep 1
     done
     stop_ovs
     # terminate background sleep process
diff --git a/build/yamls/antrea-aks.yml b/build/yamls/antrea-aks.yml
index 1856ea30c..5581d58a1 100644
--- a/build/yamls/antrea-aks.yml
+++ b/build/yamls/antrea-aks.yml
@@ -3101,6 +3101,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/yamls/antrea-crds.yml b/build/yamls/antrea-crds.yml
index d047fa884..b41fc8e8e 100644
--- a/build/yamls/antrea-crds.yml
+++ b/build/yamls/antrea-crds.yml
@@ -3074,6 +3074,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/yamls/antrea-eks.yml b/build/yamls/antrea-eks.yml
index d59e30e5f..e60b8f5c8 100644
--- a/build/yamls/antrea-eks.yml
+++ b/build/yamls/antrea-eks.yml
@@ -3101,6 +3101,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/yamls/antrea-gke.yml b/build/yamls/antrea-gke.yml
index 33a0f87d1..89bc22a6b 100644
--- a/build/yamls/antrea-gke.yml
+++ b/build/yamls/antrea-gke.yml
@@ -3101,6 +3101,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml
index 702ec6728..8fd779217 100644
--- a/build/yamls/antrea-ipsec.yml
+++ b/build/yamls/antrea-ipsec.yml
@@ -3101,6 +3101,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml
index 76fb1bf1c..b7d129b0c 100644
--- a/build/yamls/antrea.yml
+++ b/build/yamls/antrea.yml
@@ -3101,6 +3101,8 @@ spec:
                               type: string
                             egressNode:
                               type: string
+                            srcPodIP:
+                              type: string
                 capturedPacket:
                   properties:
                     srcIP:
diff --git a/docs/antrea-proxy.md b/docs/antrea-proxy.md
index 77e74c88a..1469a76fb 100644
--- a/docs/antrea-proxy.md
+++ b/docs/antrea-proxy.md
@@ -253,6 +253,26 @@ data:
       proxyLoadBalancerIPs: false
 ```
 
+With the above configuration, AntreaProxy will ignore all external `loadBalancerIP`s.
+Starting with K8s v1.29, feature [LoadBalancerIPMode](https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-ip-mode)
+was introduced, providing users with a more fine-grained mechanism to control how
+every external `loadBalancerIP` behaves in a LoadBalancer Service.
+
+- If the value of `LoadBalancerIPMode` is `LoadBalancerIPModeVIP` or nil, the
+  traffic destined for the corresponding external `loadBalancerIP` should follow
+  the default behavior and get load-balanced at the source Node.
+- If the value of `LoadBalancerIPMode` is `LoadBalancerIPModeProxy`, the traffic
+  destined for the corresponding external `loadBalancerIP` should be sent to the
+  external LoadBalancer.
+
+Starting with Antrea v2.0, AntreaProxy will respect `LoadBalancerIPMode` in LoadBalancer
+Services when the configuration option `proxyLoadBalancerIPs` is set to `true`
+(default). In this case, AntreaProxy will serve only the external `loadBalancerIP`s
+configured with `LoadBalancerIPModeVIP`, and those configured with
+`LoadBalancerIPModeProxy` will bypass AntreaProxy. If the configuration option
+`proxyLoadBalancerIPs` is set to `false`, AntreaProxy will ignore the external
+`loadBalancerIP`s even if configured with `LoadBalancerIPModeVIP`.
+
 There are two important prerequisites for this feature:
 
 * You must enable `proxyAll` and [remove kube-proxy](#removing-kube-proxy) from
diff --git a/docs/noencap-hybrid-modes.md b/docs/noencap-hybrid-modes.md
index d6f56a410..ab0c3b311 100644
--- a/docs/noencap-hybrid-modes.md
+++ b/docs/noencap-hybrid-modes.md
@@ -87,7 +87,7 @@ network is able to route Pod traffic between Nodes. This Route Controller
 functionality is supported by the Cloud Provider implementations of the major
 clouds, including: [AWS](https://github.com/kubernetes/cloud-provider-aws),
 [Azure](https://github.com/kubernetes-sigs/cloud-provider-azure),
-[GCE](https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/legacy-cloud-providers/gce),
+[GCP](https://github.com/kubernetes/cloud-provider-gcp),
 and [vSphere (with NSX-T)](https://github.com/kubernetes/cloud-provider-vsphere).
 
 * Run a routing protocol or even manually configure routers to add routes to
diff --git a/go.mod b/go.mod
index b0e0a3630..45e998636 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,7 @@ require (
 	github.com/containernetworking/plugins v1.1.1
 	github.com/coreos/go-iptables v0.7.0
 	github.com/davecgh/go-spew v1.1.1
+	github.com/fatih/color v1.16.0
 	github.com/fsnotify/fsnotify v1.7.0
 	github.com/gammazero/deque v0.1.2
 	github.com/go-logr/logr v1.4.1
@@ -39,7 +40,7 @@ require (
 	github.com/mdlayher/packet v1.1.2
 	github.com/miekg/dns v1.1.59
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822
-	github.com/onsi/ginkgo/v2 v2.17.2
+	github.com/onsi/ginkgo/v2 v2.17.3
 	github.com/onsi/gomega v1.33.1
 	github.com/pkg/sftp v1.13.6
 	github.com/prometheus/client_golang v1.18.0
@@ -53,16 +54,16 @@ require (
 	github.com/vishvananda/netlink v1.2.1-beta.2
 	github.com/vmware/go-ipfix v0.9.0
 	go.uber.org/mock v0.4.0
-	golang.org/x/crypto v0.22.0
+	golang.org/x/crypto v0.23.0
 	golang.org/x/mod v0.17.0
-	golang.org/x/net v0.24.0
+	golang.org/x/net v0.25.0
 	golang.org/x/sync v0.7.0
-	golang.org/x/sys v0.19.0
+	golang.org/x/sys v0.20.0
 	golang.org/x/time v0.5.0
-	golang.org/x/tools v0.20.0
+	golang.org/x/tools v0.21.0
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210506160403-92e472f520a5
 	google.golang.org/grpc v1.63.2
-	google.golang.org/protobuf v1.34.0
+	google.golang.org/protobuf v1.34.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/yaml.v2 v2.4.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -126,7 +127,6 @@ require (
 	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
 	github.com/evanphx/json-patch/v5 v5.6.0 // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
-	github.com/fatih/color v1.15.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fvbommel/sortorder v1.1.0 // indirect
 	github.com/go-errors/errors v1.4.2 // indirect
@@ -166,7 +166,7 @@ require (
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-isatty v0.0.19 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/mdlayher/genetlink v1.0.0 // indirect
 	github.com/mdlayher/netlink v1.7.2 // indirect
@@ -219,8 +219,8 @@ require (
 	go.uber.org/zap v1.25.0 // indirect
 	golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
 	golang.org/x/oauth2 v0.17.0 // indirect
-	golang.org/x/term v0.19.0 // indirect
-	golang.org/x/text v0.14.0 // indirect
+	golang.org/x/term v0.20.0 // indirect
+	golang.org/x/text v0.15.0 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
diff --git a/go.sum b/go.sum
index 6e212947a..39a598cd6 100644
--- a/go.sum
+++ b/go.sum
@@ -211,8 +211,8 @@ github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2Vvl
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
-github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -505,8 +505,8 @@ github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -578,8 +578,8 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
 github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
-github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g=
-github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc=
+github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU=
+github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc=
 github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
@@ -815,8 +815,8 @@ golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e/go.mod h1:P+XmwS30IXTQdn5
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
 golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
 golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
@@ -866,8 +866,8 @@ golang.org/x/net v0.0.0-20210504132125-bbd867fde50d/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -942,16 +942,16 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
-golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
-golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
+golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -962,8 +962,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -987,8 +987,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
-golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
+golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
+golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1039,8 +1039,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
-google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/agent/controller/networkpolicy/node_reconciler_linux.go b/pkg/agent/controller/networkpolicy/node_reconciler_linux.go
index 9b88a77b7..dba3bb55b 100644
--- a/pkg/agent/controller/networkpolicy/node_reconciler_linux.go
+++ b/pkg/agent/controller/networkpolicy/node_reconciler_linux.go
@@ -350,7 +350,7 @@ func (r *nodeReconciler) computeIPTRules(rule *CompletedRule) (map[iptables.Prot
 	} else {
 		coreIPTChain = config.NodeNetworkPolicyEgressRulesChain
 	}
-	coreIPTRuleComment := fmt.Sprintf("Antrea: for rule %s, policy %s", ruleID, rule.SourceRef.ToString())
+	coreIPTRuleComment := fmt.Sprintf("Antrea: for rule %s, policy %s", rule.Name, rule.SourceRef.ToString())
 	lastRealized.coreIPTChain = coreIPTChain
 
 	nodePolicyRules := make(map[iptables.Protocol]*types.NodePolicyRule)
diff --git a/pkg/agent/controller/traceflow/packetin.go b/pkg/agent/controller/traceflow/packetin.go
index f67b8fdac..623714545 100644
--- a/pkg/agent/controller/traceflow/packetin.go
+++ b/pkg/agent/controller/traceflow/packetin.go
@@ -173,6 +173,17 @@ func (c *Controller) parsePacketIn(pktIn *ofctrl.PacketIn) (*crdv1beta1.Traceflo
 		ob := new(crdv1beta1.Observation)
 		ob.Component = crdv1beta1.ComponentSpoofGuard
 		ob.Action = crdv1beta1.ActionForwarded
+		// For SNATed packet(hairpin), ipSrc and ctNwSrc are different.
+		// We noticed that ctNwSrc is invalid for ICMPv6 packets: it should contain
+		// the original src Pod IP but it is always empty due to an issue in OVS.
+		// https://github.com/openvswitch/ovs-issues/issues/327
+		if isValidCtNw(ctNwSrc) {
+			ob.SrcPodIP = ctNwSrc
+		} else {
+			// In the case of ICMPv6, since ctNwSrc is invalid, we can use ipSrc as
+			// hairpin is not applicable, so ipSrc always contains src pod IP.
+			ob.SrcPodIP = ipSrc
+		}
 		obs = append(obs, *ob)
 	} else {
 		ob := new(crdv1beta1.Observation)
@@ -461,10 +472,7 @@ func isValidCtNw(ipStr string) bool {
 	}
 	// Reserved by IETF [RFC3513][RFC4291]
 	_, cidr, _ := net.ParseCIDR("0000::/8")
-	if cidr.Contains(ip) {
-		return false
-	}
-	return true
+	return !cidr.Contains(ip)
 }
 
 func parseCapturedPacket(pktIn *ofctrl.PacketIn) *crdv1beta1.Packet {
diff --git a/pkg/agent/multicast/mcast_controller.go b/pkg/agent/multicast/mcast_controller.go
index 8d9b02509..cfb9facf4 100644
--- a/pkg/agent/multicast/mcast_controller.go
+++ b/pkg/agent/multicast/mcast_controller.go
@@ -113,7 +113,6 @@ func (c *Controller) addGroupMemberStatus(e *mcastGroupEvent) {
 	c.groupCache.Add(status)
 	c.queue.Add(e.group.String())
 	klog.InfoS("Added new multicast group to cache", "group", e.group, "interface", e.iface.InterfaceName)
-	return
 }
 
 // updateGroupMemberStatus updates the group status in groupCache. If a "join" message is sent from an existing member,
@@ -162,7 +161,6 @@ func (c *Controller) updateGroupMemberStatus(obj interface{}, e *mcastGroupEvent
 			}
 		}
 	}
-	return
 }
 
 // checkLastMember sends out a query message on the group to check if there are still members in the group. If no new
@@ -295,7 +293,7 @@ func NewMulticastController(ofClient openflow.Client,
 	groupCache := cache.NewIndexer(getGroupEventKey, cache.Indexers{
 		podInterfaceIndex: podInterfaceIndexFunc,
 	})
-	multicastRouteClient := newRouteClient(nodeConfig, groupCache, multicastSocket, multicastInterfaces, isEncap, enableFlexibleIPAM)
+	multicastRouteClient := newRouteClient(nodeConfig, groupCache, multicastSocket, multicastInterfaces, enableFlexibleIPAM)
 	c := &Controller{
 		ofClient:             ofClient,
 		ifaceStore:           ifaceStore,
@@ -497,7 +495,7 @@ func (c *Controller) syncGroup(groupKey string) error {
 	deleteLocalMulticastGroup := func() error {
 		err := c.mRouteClient.deleteInboundMrouteEntryByGroup(status.group)
 		if err != nil {
-			klog.ErrorS(err, "Cannot delete multicast group", "group", groupKey)
+			klog.ErrorS(err, "Failed to delete multicast group", "group", groupKey)
 			return err
 		}
 		klog.InfoS("Removed multicast route entry", "group", status.group)
diff --git a/pkg/agent/multicast/mcast_route.go b/pkg/agent/multicast/mcast_route.go
index fd5d48154..aa5c8260a 100644
--- a/pkg/agent/multicast/mcast_route.go
+++ b/pkg/agent/multicast/mcast_route.go
@@ -19,6 +19,7 @@ import (
 	"fmt"
 	"net"
 	"strings"
+	"time"
 
 	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/client-go/tools/cache"
@@ -34,13 +35,14 @@ const (
 	MulticastRecvBufferSize = 128
 )
 
-func newRouteClient(nodeconfig *config.NodeConfig, groupCache cache.Indexer, multicastSocket RouteInterface, multicastInterfaces sets.Set[string], encapEnabled bool, flexibleIPAMEnabled bool) *MRouteClient {
+func newRouteClient(nodeconfig *config.NodeConfig, groupCache cache.Indexer, multicastSocket RouteInterface, multicastInterfaces sets.Set[string], flexibleIPAMEnabled bool) *MRouteClient {
 	var m = &MRouteClient{
 		igmpMsgChan:         make(chan []byte, workerCount),
 		nodeConfig:          nodeconfig,
 		groupCache:          groupCache,
 		inboundRouteCache:   cache.NewIndexer(getMulticastInboundEntryKey, cache.Indexers{GroupNameIndexName: inboundGroupIndexFunc}),
 		multicastInterfaces: sets.List(multicastInterfaces),
+		outboundRouteCache:  cache.NewIndexer(getMulticastOutboundEntryKey, cache.Indexers{}),
 		socket:              multicastSocket,
 		flexibleIPAMEnabled: flexibleIPAMEnabled,
 	}
@@ -75,6 +77,7 @@ type MRouteClient struct {
 	nodeConfig                *config.NodeConfig
 	multicastInterfaces       []string
 	inboundRouteCache         cache.Indexer
+	outboundRouteCache        cache.Indexer
 	groupCache                cache.Indexer
 	socket                    RouteInterface
 	multicastInterfaceConfigs []multicastInterfaceConfig
@@ -152,23 +155,48 @@ func (c *MRouteClient) deleteInboundMrouteEntryByGroup(group net.IP) (err error)
 	mEntries, _ := c.inboundRouteCache.ByIndex(GroupNameIndexName, group.String())
 	for _, route := range mEntries {
 		entry := route.(*inboundMulticastRouteEntry)
-		err := c.socket.DelMrouteEntry(net.ParseIP(entry.src), net.ParseIP(entry.group), entry.vif)
+		err := c.deleteInboundMRoute(entry)
 		if err != nil {
 			return err
 		}
-		c.inboundRouteCache.Delete(route)
 	}
 	return nil
 }
 
+func (c *MRouteClient) deleteInboundMRoute(mRoute *inboundMulticastRouteEntry) error {
+	err := c.socket.DelMrouteEntry(net.ParseIP(mRoute.src), net.ParseIP(mRoute.group), mRoute.vif)
+	if err != nil {
+		return err
+	}
+	c.inboundRouteCache.Delete(mRoute)
+	return nil
+}
+
+func (c *MRouteClient) deleteOutboundMRoute(mRoute *outboundMulticastRouteEntry) error {
+	err := c.socket.DelMrouteEntry(net.ParseIP(mRoute.src), net.ParseIP(mRoute.group), c.internalInterfaceVIF)
+	if err != nil {
+		return err
+	}
+	c.outboundRouteCache.Delete(mRoute)
+	return nil
+}
+
 // addOutboundMrouteEntry configures multicast route from Antrea gateway to all the multicast interfaces,
 // allowing multicast srcNode Pods to send multicast traffic to external.
-func (c *MRouteClient) addOutboundMrouteEntry(src net.IP, group net.IP) (err error) {
+func (c *MRouteClient) addOutboundMrouteEntry(src net.IP, group net.IP) error {
 	klog.V(2).InfoS("Adding outbound multicast route entry", "src", src, "group", group, "outboundVIFs", c.externalInterfaceVIFs)
-	err = c.socket.AddMrouteEntry(src, group, c.internalInterfaceVIF, c.externalInterfaceVIFs)
+	err := c.socket.AddMrouteEntry(src, group, c.internalInterfaceVIF, c.externalInterfaceVIFs)
 	if err != nil {
 		return err
 	}
+	routeEntry := &outboundMulticastRouteEntry{
+		multicastRouteEntry: multicastRouteEntry{
+			group:       group.String(),
+			src:         src.String(),
+			updatedTime: time.Now(),
+		},
+	}
+	c.outboundRouteCache.Add(routeEntry)
 	return nil
 }
 
@@ -181,15 +209,43 @@ func (c *MRouteClient) addInboundMrouteEntry(src net.IP, group net.IP, inboundVI
 		return err
 	}
 	routeEntry := &inboundMulticastRouteEntry{
-		group: group.String(),
-		src:   src.String(),
-		vif:   inboundVIF,
+		vif: inboundVIF,
+		multicastRouteEntry: multicastRouteEntry{
+			group:       group.String(),
+			src:         src.String(),
+			updatedTime: time.Now(),
+		},
 	}
 	c.inboundRouteCache.Add(routeEntry)
 	return nil
 }
 
+// Field pktCount and updatedTime are used for removing stale multicast routes.
+type multicastRouteEntry struct {
+	group       string
+	src         string
+	pktCount    uint32
+	updatedTime time.Time
+}
+
+// outboundMulticastRouteEntry encodes the outbound multicast routing entry.
+// For example,
+//
+//	type outboundMulticastRouteEntry struct {
+//		group "226.94.9.9"
+//		src   "10.0.0.55"
+//	} encodes the multicast route entry from Antrea gateway to multicast interfaces
+//
+// (10.0.0.55,226.94.9.9)           Iif: antrea-gw0      Oifs: list of multicastInterfaces.
+//
+// The iif is always Antrea gateway and oifs are always outbound interfaces
+// so we do not put them in the struct.
+type outboundMulticastRouteEntry struct {
+	multicastRouteEntry
+}
+
 // inboundMulticastRouteEntry encodes the inbound multicast routing entry.
+// It has extra field vif to represent inbound interface VIF.
 // For example,
 //
 //	type inboundMulticastRouteEntry struct {
@@ -201,9 +257,8 @@ func (c *MRouteClient) addInboundMrouteEntry(src net.IP, group net.IP, inboundVI
 // (10.0.0.55,226.94.9.9)           Iif: wlan0      Oifs: antrea-gw0.
 // The oif is always Antrea gateway so we do not put it in the struct.
 type inboundMulticastRouteEntry struct {
-	group string
-	src   string
-	vif   uint16
+	multicastRouteEntry
+	vif uint16
 }
 
 func getMulticastInboundEntryKey(obj interface{}) (string, error) {
@@ -211,6 +266,11 @@ func getMulticastInboundEntryKey(obj interface{}) (string, error) {
 	return entry.group + "/" + entry.src + "/" + fmt.Sprint(entry.vif), nil
 }
 
+func getMulticastOutboundEntryKey(obj interface{}) (string, error) {
+	entry := obj.(*outboundMulticastRouteEntry)
+	return entry.group + "/" + entry.src, nil
+}
+
 func inboundGroupIndexFunc(obj interface{}) ([]string, error) {
 	entry, ok := obj.(*inboundMulticastRouteEntry)
 	if !ok {
@@ -277,10 +337,12 @@ type RouteInterface interface {
 	MulticastInterfaceLeaveMgroup(mgroup net.IP, ifaceIP net.IP, ifaceName string) error
 	// AddMrouteEntry adds multicast route with specified source(src), multicast group IP(group),
 	// inbound multicast interface(iif) and outbound multicast interfaces(oifs).
-	AddMrouteEntry(src net.IP, group net.IP, iif uint16, oifs []uint16) (err error)
+	AddMrouteEntry(src net.IP, group net.IP, iif uint16, oifs []uint16) error
+	// GetMroutePacketCount returns the number of routed packets by the multicast route entry.
+	GetMroutePacketCount(src net.IP, group net.IP) (uint32, error)
 	// DelMrouteEntry deletes multicast route with specified source(src), multicast group IP(group),
 	// inbound multicast interface(iif).
-	DelMrouteEntry(src net.IP, group net.IP, iif uint16) (err error)
+	DelMrouteEntry(src net.IP, group net.IP, iif uint16) error
 	// FlushMRoute flushes static multicast routing entries.
 	FlushMRoute()
 	// GetFD returns socket file descriptor.
diff --git a/pkg/agent/multicast/mcast_route_linux.go b/pkg/agent/multicast/mcast_route_linux.go
index 9695564be..3c870752f 100644
--- a/pkg/agent/multicast/mcast_route_linux.go
+++ b/pkg/agent/multicast/mcast_route_linux.go
@@ -21,12 +21,18 @@ import (
 	"fmt"
 	"net"
 	"syscall"
+	"time"
 
+	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/klog/v2"
 
 	"antrea.io/antrea/pkg/util/runtime"
 )
 
+const (
+	mRouteTimeout = time.Minute * 10
+)
+
 // parseIGMPMsg parses the kernel version into parsedIGMPMsg. Note we need to consider the change
 // after linux 5.9 in the igmpmsg struct when parsing vif. Please check
 // https://github.com/torvalds/linux/commit/c8715a8e9f38906e73d6d78764216742db13ba0e.
@@ -78,6 +84,12 @@ func (c *MRouteClient) run(stopCh <-chan struct{}) {
 		}
 	}()
 
+	// Check packet count difference every minute for each multicast route and
+	// remove ones that do not route any packets in past mRouteTimeout.
+	// The remaining multicast routes' statistics are getting updated by
+	// this process as well.
+	go wait.NonSlidingUntil(c.updateMrouteStats, time.Minute, stopCh)
+
 	for i := 0; i < int(workerCount); i++ {
 		go c.worker(stopCh)
 	}
@@ -85,3 +97,59 @@ func (c *MRouteClient) run(stopCh <-chan struct{}) {
 	c.socket.FlushMRoute()
 	syscall.Close(c.socket.GetFD())
 }
+
+func (c *MRouteClient) updateMulticastRouteStatsEntry(entry multicastRouteEntry) (isStale bool, newEntry *multicastRouteEntry) {
+	packetCount, err := c.socket.GetMroutePacketCount(net.ParseIP(entry.src), net.ParseIP(entry.group))
+	if err != nil {
+		klog.ErrorS(err, "Failed to get packet count for multicast route", "route", entry)
+		return false, nil
+	}
+	packetCountDiff := packetCount - entry.pktCount
+	klog.V(4).Infof("Multicast route %v routes %d packets in last %s", entry, packetCountDiff, time.Minute)
+	now := time.Now()
+	if packetCountDiff == uint32(0) {
+		return now.Sub(entry.updatedTime) > mRouteTimeout, nil
+	}
+	newEntry = &multicastRouteEntry{group: entry.group, src: entry.src, pktCount: packetCount, updatedTime: now}
+	return false, newEntry
+}
+
+func (c *MRouteClient) updateInboundMrouteStats() {
+	for _, obj := range c.inboundRouteCache.List() {
+		entry := obj.(*inboundMulticastRouteEntry)
+		isStale, newEntry := c.updateMulticastRouteStatsEntry(entry.multicastRouteEntry)
+		if isStale {
+			klog.V(2).InfoS("Deleting stale inbound multicast route", "group", entry.group, "source", entry.src, "VIF", entry.vif)
+			err := c.deleteInboundMRoute(entry)
+			if err != nil {
+				klog.ErrorS(err, "Failed to delete inbound multicast route", "group", entry.group, "source", entry.src, "VIF", entry.vif)
+			}
+		} else if newEntry != nil {
+			newInboundEntry := inboundMulticastRouteEntry{*newEntry, entry.vif}
+			c.inboundRouteCache.Update(&newInboundEntry)
+		}
+	}
+}
+
+func (c *MRouteClient) updateOutboundMrouteStats() {
+	for _, obj := range c.outboundRouteCache.List() {
+		entry := obj.(*outboundMulticastRouteEntry)
+		isStale, newEntry := c.updateMulticastRouteStatsEntry(entry.multicastRouteEntry)
+		if isStale {
+			klog.V(2).InfoS("Deleting stale outbound multicast route", "group", entry.group, "source", entry.src)
+			err := c.deleteOutboundMRoute(entry)
+			if err != nil {
+				klog.ErrorS(err, "Failed to delete outbound multicast route", "group", entry.group, "source", entry.src)
+			}
+		} else if newEntry != nil {
+			newOutboundEntry := outboundMulticastRouteEntry{*newEntry}
+			c.outboundRouteCache.Update(&newOutboundEntry)
+		}
+	}
+}
+
+func (c *MRouteClient) updateMrouteStats() {
+	klog.V(2).InfoS("Updating multicast route statistics and removing stale multicast routes")
+	c.updateInboundMrouteStats()
+	c.updateOutboundMrouteStats()
+}
diff --git a/pkg/agent/multicast/mcast_socket_linux.go b/pkg/agent/multicast/mcast_socket_linux.go
index f8c946b06..3ed1ebab2 100644
--- a/pkg/agent/multicast/mcast_socket_linux.go
+++ b/pkg/agent/multicast/mcast_socket_linux.go
@@ -60,7 +60,23 @@ func (s *Socket) AddMrouteEntry(src net.IP, group net.IP, iif uint16, oifVIFs []
 	return multicastsyscall.SetsockoptMfcctl(s.GetFD(), syscall.IPPROTO_IP, multicastsyscall.MRT_ADD_MFC, mc)
 }
 
-func (s *Socket) DelMrouteEntry(src net.IP, group net.IP, iif uint16) (err error) {
+// GetMroutePacketCount returns the number of routed packets by the multicast route entry.
+// The current implementation only supports IPv4 multicast routes.
+func (s *Socket) GetMroutePacketCount(src net.IP, group net.IP) (uint32, error) {
+	srcIP := src.To4()
+	groupIP := group.To4()
+	siocSgReq := multicastsyscall.SiocSgReq{
+		Src: [4]byte{srcIP[0], srcIP[1], srcIP[2], srcIP[3]},
+		Grp: [4]byte{groupIP[0], groupIP[1], groupIP[2], groupIP[3]},
+	}
+	err := multicastsyscall.IoctlGetSiocSgReq(s.GetFD(), &siocSgReq)
+	if err != nil {
+		return 0, err
+	}
+	return siocSgReq.Pktcnt, nil
+}
+
+func (s *Socket) DelMrouteEntry(src net.IP, group net.IP, iif uint16) error {
 	mc := &multicastsyscall.Mfcctl{}
 	origin := src.To4()
 	mc.Origin = [4]byte{origin[0], origin[1], origin[2], origin[3]}
diff --git a/pkg/agent/multicast/mcast_socket_others.go b/pkg/agent/multicast/mcast_socket_others.go
index f60848c1f..bcadabc4e 100644
--- a/pkg/agent/multicast/mcast_socket_others.go
+++ b/pkg/agent/multicast/mcast_socket_others.go
@@ -27,11 +27,15 @@ const (
 	SizeofIgmpmsg  = 0
 )
 
-func (s *Socket) AddMrouteEntry(src net.IP, group net.IP, iif uint16, oifVIFs []uint16) (err error) {
+func (s *Socket) AddMrouteEntry(src net.IP, group net.IP, iif uint16, oifVIFs []uint16) error {
 	return nil
 }
 
-func (s *Socket) DelMrouteEntry(src net.IP, group net.IP, iif uint16) (err error) {
+func (s *Socket) GetMroutePacketCount(src net.IP, group net.IP) (uint32, error) {
+	return 0, nil
+}
+
+func (s *Socket) DelMrouteEntry(src net.IP, group net.IP, iif uint16) error {
 	return nil
 }
 
diff --git a/pkg/agent/nodeportlocal/k8s/npl_controller.go b/pkg/agent/nodeportlocal/k8s/npl_controller.go
index 592098e7a..9469af9ad 100644
--- a/pkg/agent/nodeportlocal/k8s/npl_controller.go
+++ b/pkg/agent/nodeportlocal/k8s/npl_controller.go
@@ -493,9 +493,13 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 		}
 		podPorts[targetPortProto] = struct{}{}
 		portData := c.portTable.GetEntry(podIP, port, protocol)
-		if portData != nil && !portData.ProtocolInUse(protocol) {
-			// If the PortTable has an entry for the Pod but does not have an
-			// entry with protocol, we enforce AddRule for the missing Protocol.
+		// Special handling for a rule that was previously marked for deletion but could not
+		// be deleted properly: we have to retry now.
+		if portData != nil && portData.Defunct() {
+			klog.InfoS("Deleting defunct rule for Pod to prevent re-use", "pod", klog.KObj(pod), "podIP", podIP, "port", port, "protocol", protocol)
+			if err := c.portTable.DeleteRule(podIP, port, protocol); err != nil {
+				return fmt.Errorf("failed to delete defunct rule for Pod IP %s, Pod Port %d, Protocol %s: %w", podIP, port, protocol, err)
+			}
 			portData = nil
 		}
 		if portData == nil {
@@ -526,13 +530,11 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 	// second, delete any existing rule that is not needed based on the current Pod
 	// specification.
 	entries := c.portTable.GetDataForPodIP(podIP)
-	if nplExists {
-		for _, data := range entries {
-			proto := data.Protocol
-			if _, exists := podPorts[util.BuildPortProto(fmt.Sprint(data.PodPort), proto.Protocol)]; !exists {
-				if err := c.portTable.DeleteRule(podIP, int(data.PodPort), proto.Protocol); err != nil {
-					return fmt.Errorf("failed to delete rule for Pod IP %s, Pod Port %d, Protocol %s: %v", podIP, data.PodPort, proto.Protocol, err)
-				}
+	for _, data := range entries {
+		proto := data.Protocol
+		if _, exists := podPorts[util.BuildPortProto(fmt.Sprint(data.PodPort), proto.Protocol)]; !exists {
+			if err := c.portTable.DeleteRule(podIP, int(data.PodPort), proto.Protocol); err != nil {
+				return fmt.Errorf("failed to delete rule for Pod IP %s, Pod Port %d, Protocol %s: %w", podIP, data.PodPort, proto.Protocol, err)
 			}
 		}
 	}
diff --git a/pkg/agent/nodeportlocal/portcache/port_table.go b/pkg/agent/nodeportlocal/portcache/port_table.go
index c2c99203e..b1856861b 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table.go
@@ -32,13 +32,8 @@ const (
 	PodIPIndex       = "podIPIndex"
 )
 
-// protocolSocketState represents the state of the socket corresponding to a
-// given (Node port, protocol) tuple.
-type protocolSocketState int
-
 type ProtocolSocketData struct {
 	Protocol string
-	State    protocolSocketState
 	socket   io.Closer
 }
 
@@ -47,14 +42,13 @@ type NodePortData struct {
 	PodPort  int
 	PodIP    string
 	Protocol ProtocolSocketData
+	// defunct is used to indicate that a rule has been partially deleted: it is no longer
+	// usable and deletion needs to be re-attempted.
+	defunct bool
 }
 
-func (d *NodePortData) ProtocolInUse(protocol string) bool {
-	protocolSocketData := d.Protocol
-	if protocolSocketData.Protocol == protocol {
-		return protocolSocketData.State == stateInUse
-	}
-	return false
+func (d *NodePortData) Defunct() bool {
+	return d.defunct
 }
 
 type LocalPortOpener interface {
@@ -204,8 +198,8 @@ func podIPPortProtoFormat(ip string, port int, protocol string) string {
 }
 
 func (pt *PortTable) getEntryByPodIPPortProto(ip string, port int, protocol string) *NodePortData {
-	data, exists := pt.getPortTableCacheFromPodEndpointIndex(podIPPortProtoFormat(ip, port, protocol))
-	if exists == false {
+	data, ok := pt.getPortTableCacheFromPodEndpointIndex(podIPPortProtoFormat(ip, port, protocol))
+	if !ok {
 		return nil
 	}
 	return data
@@ -214,10 +208,8 @@ func (pt *PortTable) getEntryByPodIPPortProto(ip string, port int, protocol stri
 func (pt *PortTable) RuleExists(podIP string, podPort int, protocol string) bool {
 	pt.tableLock.RLock()
 	defer pt.tableLock.RUnlock()
-	if data := pt.getEntryByPodIPPortProto(podIP, podPort, protocol); data != nil {
-		return data.ProtocolInUse(protocol)
-	}
-	return false
+	data := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	return data != nil
 }
 
 // nodePortProtoFormat formats the nodeport, protocol to string port:protocol.
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_others.go b/pkg/agent/nodeportlocal/portcache/port_table_others.go
index 30ec417e7..c13eaf1d7 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_others.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_others.go
@@ -26,18 +26,6 @@ import (
 	"antrea.io/antrea/pkg/agent/nodeportlocal/rules"
 )
 
-const (
-	// stateOpen means that a listening socket has been opened for the
-	// protocol (as a means to reserve the port for this protocol), but no
-	// NPL rule has been installed for it.
-	stateOpen protocolSocketState = iota
-	// stateInUse means that a listening socket has been opened AND a NPL
-	// rule has been installed.
-	stateInUse
-	// stateClosed means that the socket has been closed.
-	stateClosed
-)
-
 func openSocketsForPort(localPortOpener LocalPortOpener, port int, protocol string) (ProtocolSocketData, error) {
 	// Port only needs to be available for the protocol used by the NPL rule.
 	// We don't need to allocate the same nodePort for all protocols anymore.
@@ -48,7 +36,6 @@ func openSocketsForPort(localPortOpener LocalPortOpener, port int, protocol stri
 	}
 	protocolData := ProtocolSocketData{
 		Protocol: protocol,
-		State:    stateInUse,
 		socket:   socket,
 	}
 	return protocolData, nil
@@ -83,28 +70,6 @@ func (pt *PortTable) getFreePort(podIP string, podPort int, protocol string) (in
 	return 0, ProtocolSocketData{}, fmt.Errorf("no free port found")
 }
 
-func (d *NodePortData) CloseSockets() error {
-	if d.Protocol.Protocol != "" {
-		protocolSocketData := &d.Protocol
-		switch protocolSocketData.State {
-		case stateClosed:
-			// already closed
-			return nil
-		case stateInUse:
-			// should not happen
-			return fmt.Errorf("protocol %s is still in use, cannot release socket", protocolSocketData.Protocol)
-		case stateOpen:
-			if err := protocolSocketData.socket.Close(); err != nil {
-				return fmt.Errorf("error when releasing local port %d with protocol %s: %v", d.NodePort, protocolSocketData.Protocol, err)
-			}
-			protocolSocketData.State = stateClosed
-		default:
-			return fmt.Errorf("invalid protocol socket state")
-		}
-	}
-	return nil
-}
-
 func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, error) {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
@@ -128,11 +93,38 @@ func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, e
 		pt.addPortTableCache(npData)
 	} else {
 		// Only add rules if the entry does not exist.
-		return 0, fmt.Errorf("existed Linux Nodeport entry for %s:%d:%s", podIP, podPort, protocol)
+		return 0, fmt.Errorf("existing Linux Nodeport entry for %s:%d:%s", podIP, podPort, protocol)
 	}
 	return npData.NodePort, nil
 }
 
+func (pt *PortTable) deleteRule(data *NodePortData) error {
+	protocolSocketData := &data.Protocol
+	protocol := protocolSocketData.Protocol
+
+	// In theory, we should not be modifying a cache item in-place. However, the field we are
+	// modifying (defunct) does NOT participate in indexing and the modification is thread-safe
+	// because of pt.tableLock.
+	// TODO: stop modifying cache items in-place.
+	// We could set defunct after the call to DeleteRule, because a failed call to DeleteRule
+	// should mean that the rule is still present and valid, but there is no harm in being more
+	// conservative.
+	data.defunct = true
+
+	// Calling DeleteRule is idempotent.
+	if err := pt.PodPortRules.DeleteRule(data.NodePort, data.PodIP, data.PodPort, protocol); err != nil {
+		return err
+	}
+	if err := protocolSocketData.socket.Close(); err != nil {
+		return fmt.Errorf("error when releasing local port %d with protocol %s: %w", data.NodePort, protocol, err)
+	}
+	// We don't need to delete cache from different indexes repeatedly because they map to the same entry.
+	// Deletion errors are not possible because our Index functions cannot return errors.
+	// See https://github.com/kubernetes/client-go/blob/3aa45779f2e5592d52edf68da66abfbd0805e413/tools/cache/store.go#L189-L196
+	pt.deletePortTableCache(data)
+	return nil
+}
+
 func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) error {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
@@ -141,15 +133,7 @@ func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) erro
 		// Delete not required when the PortTable entry does not exist
 		return nil
 	}
-	if err := pt.PodPortRules.DeleteRule(data.NodePort, podIP, podPort, protocol); err != nil {
-		return err
-	}
-	if err := data.CloseSockets(); err != nil {
-		return err
-	}
-	// We don't need to delete cache from different indexes repeatedly because they map to the same entry.
-	pt.deletePortTableCache(data)
-	return nil
+	return pt.deleteRule(data)
 }
 
 func (pt *PortTable) DeleteRulesForPod(podIP string) error {
@@ -157,14 +141,7 @@ func (pt *PortTable) DeleteRulesForPod(podIP string) error {
 	defer pt.tableLock.Unlock()
 	podEntries := pt.getDataForPodIP(podIP)
 	for _, podEntry := range podEntries {
-		protocolSocketData := podEntry.Protocol
-		if err := pt.PodPortRules.DeleteRule(podEntry.NodePort, podIP, podEntry.PodPort, protocolSocketData.Protocol); err != nil {
-			return err
-		}
-		if err := protocolSocketData.socket.Close(); err != nil {
-			return fmt.Errorf("error when releasing local port %d with protocol %s: %v", podEntry.NodePort, protocolSocketData.Protocol, err)
-		}
-		pt.deletePortTableCache(podEntry)
+		return pt.deleteRule(podEntry)
 	}
 	return nil
 }
@@ -177,16 +154,11 @@ func (pt *PortTable) syncRules() error {
 	nplPorts := make([]rules.PodNodePort, 0, len(objs))
 	for _, obj := range objs {
 		npData := obj.(*NodePortData)
-		protocols := make([]string, 0, 1)
-		protocol := npData.Protocol
-		if protocol.State == stateInUse {
-			protocols = append(protocols, protocol.Protocol)
-		}
 		nplPorts = append(nplPorts, rules.PodNodePort{
 			NodePort: npData.NodePort,
 			PodPort:  npData.PodPort,
 			PodIP:    npData.PodIP,
-			Protocol: protocols[0],
+			Protocol: npData.Protocol.Protocol,
 		})
 	}
 	if err := pt.PodPortRules.AddAllRules(nplPorts); err != nil {
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_windows.go b/pkg/agent/nodeportlocal/portcache/port_table_windows.go
index aff2a3224..e084bde18 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_windows.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_windows.go
@@ -25,11 +25,6 @@ import (
 	"antrea.io/antrea/pkg/agent/nodeportlocal/rules"
 )
 
-const (
-	// stateInUse means that the NPL rule has been installed.
-	stateInUse protocolSocketState = 1
-)
-
 func addRuleForPort(podPortRules rules.PodPortRules, port int, podIP string, podPort int, protocol string) (ProtocolSocketData, error) {
 	// Only the protocol used here should be returned if NetNatStaticMapping rule
 	// can be inserted to an unused protocol port.
@@ -40,7 +35,6 @@ func addRuleForPort(podPortRules rules.PodPortRules, port int, podIP string, pod
 	}
 	protocolData := ProtocolSocketData{
 		Protocol: protocol,
-		State:    stateInUse,
 		socket:   nil,
 	}
 	return protocolData, nil
@@ -137,6 +131,8 @@ func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) erro
 		return nil
 	}
 
+	data.defunct = true
+	// Calling DeleteRule is idempotent.
 	if err := pt.PodPortRules.DeleteRule(data.NodePort, podIP, podPort, protocol); err != nil {
 		return err
 	}
diff --git a/pkg/agent/secondarynetwork/init.go b/pkg/agent/secondarynetwork/init_linux.go
similarity index 99%
rename from pkg/agent/secondarynetwork/init.go
rename to pkg/agent/secondarynetwork/init_linux.go
index 589ce6000..e0945d680 100644
--- a/pkg/agent/secondarynetwork/init.go
+++ b/pkg/agent/secondarynetwork/init_linux.go
@@ -1,3 +1,6 @@
+//go:build linux
+// +build linux
+
 // Copyright 2023 Antrea Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/pkg/agent/secondarynetwork/init_windows.go b/pkg/agent/secondarynetwork/init_windows.go
new file mode 100644
index 000000000..45b862106
--- /dev/null
+++ b/pkg/agent/secondarynetwork/init_windows.go
@@ -0,0 +1,46 @@
+//go:build windows
+// +build windows
+
+// Copyright 2024 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secondarynetwork
+
+import (
+	"errors"
+
+	"github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb"
+	clientset "k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/cache"
+	componentbaseconfig "k8s.io/component-base/config"
+
+	agentconfig "antrea.io/antrea/pkg/config/agent"
+	"antrea.io/antrea/pkg/util/channel"
+)
+
+func Initialize(
+	clientConnectionConfig componentbaseconfig.ClientConnectionConfiguration,
+	kubeAPIServerOverride string,
+	k8sClient clientset.Interface,
+	podInformer cache.SharedIndexInformer,
+	nodeName string,
+	podUpdateSubscriber channel.Subscriber,
+	stopCh <-chan struct{},
+	secNetConfig *agentconfig.SecondaryNetworkConfig, ovsdb *ovsdb.OVSDB) error {
+	return errors.New("not supported on Windows")
+}
+
+func RestoreHostInterfaceConfiguration(secNetConfig *agentconfig.SecondaryNetworkConfig) {
+	// Not supported on Windows.
+}
diff --git a/pkg/agent/util/net.go b/pkg/agent/util/net.go
index 39504461b..14d43757b 100644
--- a/pkg/agent/util/net.go
+++ b/pkg/agent/util/net.go
@@ -263,25 +263,6 @@ func GetIPNetDeviceByCIDRs(cidrsList []string) (v4IPNet, v6IPNet *net.IPNet, lin
 	return nil, nil, nil, fmt.Errorf("unable to find local IP and device")
 }
 
-func GetAllIPNetsByName(ifaceName string) ([]*net.IPNet, error) {
-	ips := []*net.IPNet{}
-	adapter, err := netInterfaceByName(ifaceName)
-	if err != nil {
-		return nil, err
-	}
-	addrs, _ := netInterfaceAddrs(adapter)
-	for _, addr := range addrs {
-		if ip, ipNet, err := net.ParseCIDR(addr.String()); err != nil {
-			klog.Warningf("Unable to parse addr %+v, err=%+v", addr, err)
-		} else if !ip.IsLinkLocalUnicast() {
-			ipNet.IP = ip
-			ips = append(ips, ipNet)
-		}
-	}
-	klog.InfoS("Found IPs on interface", "IPs", ips, "interface", ifaceName)
-	return ips, nil
-}
-
 func GetIPv4Addr(ips []net.IP) net.IP {
 	for _, ip := range ips {
 		if ip.To4() != nil {
@@ -434,7 +415,7 @@ func GenerateRandomMAC() net.HardwareAddr {
 	return buf
 }
 
-func GetIPNetsByLink(link *net.Interface) ([]*net.IPNet, error) {
+func getIPNetsByLink(link *net.Interface) ([]*net.IPNet, error) {
 	addrList, err := netInterfaceAddrs(link)
 	if err != nil {
 		return nil, err
diff --git a/pkg/agent/util/net_linux.go b/pkg/agent/util/net_linux.go
index 3c170a85e..ca4733193 100644
--- a/pkg/agent/util/net_linux.go
+++ b/pkg/agent/util/net_linux.go
@@ -39,7 +39,6 @@ import (
 )
 
 var (
-	// netlinkUtil is introduced for testing.
 	netlinkUtil utilnetlink.Interface = &netlink.Handle{}
 
 	// Declared variables which are meant to be overridden for testing.
@@ -265,7 +264,7 @@ func GetInterfaceConfig(ifName string) (*net.Interface, []*net.IPNet, []interfac
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("failed to get interface by name %s: %v", ifName, err)
 	}
-	addrs, err := GetIPNetsByLink(iface)
+	addrs, err := getIPNetsByLink(iface)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("failed to get address for interface %s: %v", ifName, err)
 	}
@@ -379,19 +378,6 @@ func renameHostInterface(oriName string, newName string) error {
 	return nil
 }
 
-func interfaceExists(name string) (bool, error) {
-	intfs, err := netInterfaces()
-	if err != nil {
-		return false, err
-	}
-	for _, intf := range intfs {
-		if intf.Name == name {
-			return true, nil
-		}
-	}
-	return false, nil
-}
-
 // PrepareHostInterfaceConnection prepares host interface connection to the OVS bridge client by:
 // 1. Renaming the host interface (a bridged suffix will be added to it).
 // 2. Creating an internal port (original name of the host interface will be used here).
@@ -475,55 +461,55 @@ func PrepareHostInterfaceConnection(
 func RestoreHostInterfaceConfiguration(brName string, interfaceName string) {
 	klog.V(4).InfoS("Restoring bridge config to host interface")
 	bridgedName := GenerateUplinkInterfaceName(interfaceName)
-	// restore if interface eth0~ exists
-	if exists, err := interfaceExists(bridgedName); err != nil {
-		klog.ErrorS(err, "Failed to check if interface exists", "interface", bridgedName)
+	// restore only when interface eth0~ exists
+	if !HostInterfaceExists(bridgedName) {
 		return
-	} else if exists {
-		// get interface config
-		var interfaceIPs []*net.IPNet
-		var interfaceRoutes []interface{}
-		if exists, err = interfaceExists(interfaceName); err != nil {
-			klog.ErrorS(err, "Failed to check if interface exists", "interface", interfaceName)
-		} else if exists {
-			_, interfaceIPs, interfaceRoutes, err = GetInterfaceConfig(interfaceName)
-			if err != nil {
-				klog.ErrorS(err, "Failed to get interface config", "interface", interfaceName)
-			}
+	}
 
-			// delete internal port (eth0)
-			if err = deleteOVSPort(brName, interfaceName); err != nil {
-				klog.ErrorS(err, "Delete OVS port failed", "port", bridgedName)
-			}
+	// get interface config
+	var err error
+	var interfaceIPs []*net.IPNet
+	var interfaceRoutes []interface{}
+	if HostInterfaceExists(interfaceName) {
+		_, interfaceIPs, interfaceRoutes, err = GetInterfaceConfig(interfaceName)
+		if err != nil {
+			klog.ErrorS(err, "Failed to get interface config", "interface", interfaceName)
 		}
-		// remove host interface (eth0~) from bridge
-		if err = deleteOVSPort(brName, bridgedName); err != nil {
+
+		// delete internal port (eth0)
+		if err = deleteOVSPort(brName, interfaceName); err != nil {
 			klog.ErrorS(err, "Delete OVS port failed", "port", bridgedName)
-			return
 		}
+	}
+	// remove host interface (eth0~) from bridge
+	if err = deleteOVSPort(brName, bridgedName); err != nil {
+		klog.ErrorS(err, "Delete OVS port failed", "port", bridgedName)
+		return
+	}
 
-		// rename host interface(eth0~ -> eth0)
-		if err = RenameInterface(bridgedName, interfaceName); err != nil {
-			klog.ErrorS(err, "Restore host interface name failed", "from", bridgedName, "to", interfaceName)
-		} else {
-			var link netlink.Link
-			if link, err = netlink.LinkByName(interfaceName); err != nil {
-				klog.ErrorS(err, "Failed to get link", "interface", interfaceName)
-			} else {
-				if len(interfaceIPs) > 0 {
-					// restore IPs to eth0
-					if err = ConfigureLinkAddresses(link.Attrs().Index, interfaceIPs); err != nil {
-						klog.ErrorS(err, "Restore IPs to host interface failed", "interface", interfaceName)
-					}
-				}
-				if len(interfaceRoutes) > 0 {
-					// restore routes to eth0
-					if err = ConfigureLinkRoutes(link, interfaceRoutes); err != nil {
-						klog.ErrorS(err, "Restore routes to host interface failed", "interface", interfaceName)
-					}
-				}
-			}
+	// rename host interface(eth0~ -> eth0)
+	if err = RenameInterface(bridgedName, interfaceName); err != nil {
+		klog.ErrorS(err, "Restore host interface name failed", "from", bridgedName, "to", interfaceName)
+		return
+	}
+	var link netlink.Link
+	if link, err = netlink.LinkByName(interfaceName); err != nil {
+		klog.ErrorS(err, "Failed to get link", "interface", interfaceName)
+		return
+	}
+	if len(interfaceIPs) > 0 {
+		// restore IPs to eth0
+		if err = ConfigureLinkAddresses(link.Attrs().Index, interfaceIPs); err != nil {
+			klog.ErrorS(err, "Restore IPs to host interface failed", "interface", interfaceName)
+			return
+		}
+	}
+	if len(interfaceRoutes) > 0 {
+		// restore routes to eth0
+		if err = ConfigureLinkRoutes(link, interfaceRoutes); err != nil {
+			klog.ErrorS(err, "Restore routes to host interface failed", "interface", interfaceName)
+			return
 		}
-		klog.V(4).InfoS("Finished restoring bridge config to host interface", "interface", interfaceName, "bridge", brName)
 	}
+	klog.V(2).InfoS("Finished restoring bridge config to host interface", "interface", interfaceName, "bridge", brName)
 }
diff --git a/pkg/agent/util/net_windows.go b/pkg/agent/util/net_windows.go
index ae73dc899..4930e072a 100644
--- a/pkg/agent/util/net_windows.go
+++ b/pkg/agent/util/net_windows.go
@@ -43,7 +43,6 @@ import (
 	ps "antrea.io/antrea/pkg/agent/util/powershell"
 	antreasyscall "antrea.io/antrea/pkg/agent/util/syscall"
 	binding "antrea.io/antrea/pkg/ovs/openflow"
-	"antrea.io/antrea/pkg/ovs/ovsconfig"
 	iputil "antrea.io/antrea/pkg/util/ip"
 )
 
@@ -996,7 +995,7 @@ func GetInterfaceConfig(ifName string) (*net.Interface, []*net.IPNet, []interfac
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("failed to get interface %s: %v", ifName, err)
 	}
-	addrs, err := GetIPNetsByLink(iface)
+	addrs, err := getIPNetsByLink(iface)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("failed to get address for interface %s: %v", iface.Name, err)
 	}
@@ -1326,9 +1325,3 @@ func adapterAddresses() ([]*windows.IpAdapterAddresses, error) {
 	}
 	return aas, nil
 }
-
-func PrepareHostInterfaceConnection(_ ovsconfig.OVSBridgeClient, ifaceName string, _ int32, _ map[string]interface{}) (string, bool, error) {
-	return ifaceName, false, nil
-}
-
-func RestoreHostInterfaceConfiguration(_ string, _ string) {}
diff --git a/pkg/agent/util/syscall/linux/types.go b/pkg/agent/util/syscall/linux/types.go
index 883bfb250..0f3d64614 100644
--- a/pkg/agent/util/syscall/linux/types.go
+++ b/pkg/agent/util/syscall/linux/types.go
@@ -47,6 +47,7 @@ const (
 
 type Mfcctl C.struct_mfcctl
 type Vifctl C.struct_vifctl_with_ifindex
+type SiocSgReq C.struct_siocsgreq
 
 const SizeofMfcctl = C.sizeof_struct_mfcctl
 const SizeofVifctl = C.sizeof_struct_vifctl_with_ifindex
diff --git a/pkg/agent/util/syscall/syscall_unix.go b/pkg/agent/util/syscall/syscall_unix.go
index 6a1a494f9..46c4c4f4d 100644
--- a/pkg/agent/util/syscall/syscall_unix.go
+++ b/pkg/agent/util/syscall/syscall_unix.go
@@ -34,7 +34,13 @@ func setsockopt(s int, level int, name int, val unsafe.Pointer, vallen uintptr)
 	return
 }
 
-// Please add your wrapped syscall functions below
+func ioctlPtr(fd int, req uint, arg unsafe.Pointer) (err error) {
+	_, _, e1 := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg))
+	if e1 != 0 {
+		return e1
+	}
+	return
+}
 
 func SetsockoptMfcctl(fd, level, opt int, mfcctl *Mfcctl) error {
 	return setsockopt(fd, level, opt, unsafe.Pointer(mfcctl), SizeofMfcctl)
@@ -43,3 +49,7 @@ func SetsockoptMfcctl(fd, level, opt int, mfcctl *Mfcctl) error {
 func SetsockoptVifctl(fd, level, opt int, vifctl *Vifctl) error {
 	return setsockopt(fd, level, opt, unsafe.Pointer(vifctl), SizeofVifctl)
 }
+
+func IoctlGetSiocSgReq(fd int, siocsgreq *SiocSgReq) error {
+	return ioctlPtr(fd, SIOCGETSGCNT, unsafe.Pointer(siocsgreq))
+}
diff --git a/pkg/agent/util/syscall/ztypes_linux.go b/pkg/agent/util/syscall/ztypes_linux.go
index 2d064ccfe..ec443eee2 100644
--- a/pkg/agent/util/syscall/ztypes_linux.go
+++ b/pkg/agent/util/syscall/ztypes_linux.go
@@ -26,6 +26,7 @@ const (
 	MRT_INIT         = 0xc8
 	MRT_FLUSH        = 0xd4
 	MAXVIFS          = 0x20
+	SIOCGETSGCNT     = 0x89e1
 )
 
 type Mfcctl struct {
@@ -35,7 +36,7 @@ type Mfcctl struct {
 	Ttls     [32]uint8
 	Pkt_cnt  uint32
 	Byte_cnt uint32
-	Wrong_if uint32
+	Wrong_if uint32 /* number wrong of iif hits */
 	Expire   int32
 }
 
@@ -48,6 +49,18 @@ type Vifctl struct {
 	Rmt_addr    [4]byte /* in_addr */
 }
 
+// SiocSgReq is the Golang version of Linux kernel struct sioc_sg_req.
+// Please check https://github.com/torvalds/linux/blob/master/include/uapi/linux/mroute.h#L92.
+// The struct encodes the packet count and byte count of a multicast route
+// identified by Src(source) and Grp(group).
+type SiocSgReq = struct {
+	Src      [4]byte /* in_addr */
+	Grp      [4]byte /* in_addr */
+	Pktcnt   uint32
+	Bytecnt  uint32
+	Wrong_if uint32 /* number wrong of iif hits */
+}
+
 const SizeofMfcctl = 0x3c
 const SizeofVifctl = 0x10
 const SizeofIgmpmsg = 0x14
diff --git a/pkg/antctl/raw/check/installation/command.go b/pkg/antctl/raw/check/installation/command.go
index 405f7b13f..63676def4 100644
--- a/pkg/antctl/raw/check/installation/command.go
+++ b/pkg/antctl/raw/check/installation/command.go
@@ -17,11 +17,13 @@ package installation
 import (
 	"context"
 	"crypto/rand"
+	"errors"
 	"fmt"
 	"net"
 	"os"
 	"time"
 
+	"github.com/fatih/color"
 	"github.com/spf13/cobra"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
@@ -68,7 +70,21 @@ const (
 	podReadyTimeout             = 1 * time.Minute
 )
 
+type notRunnableError struct {
+	reason string
+}
+
+func (e notRunnableError) Error() string {
+	return fmt.Sprintf("test is not runnable: %s", e.reason)
+}
+
+func newNotRunnableError(reason string) notRunnableError {
+	return notRunnableError{reason: reason}
+}
+
 type Test interface {
+	// Run executes the test using the provided testContext. It returns a non-nil error when the test doesn't succeed.
+	// If a test is not runnable, notRunnableError should be wrapped in the returned error.
 	Run(ctx context.Context, testContext *testContext) error
 }
 
@@ -99,16 +115,27 @@ func Run(o *options) error {
 	if err := testContext.setup(ctx); err != nil {
 		return err
 	}
+	var numSuccess, numFailure, numSkipped int
 	for name, test := range testsRegistry {
 		testContext.Header("Running test: %s", name)
 		if err := test.Run(ctx, testContext); err != nil {
-			testContext.Header("Test %s failed: %s", name, err)
+			if errors.As(err, new(notRunnableError)) {
+				testContext.Warning("Test %s was skipped: %v", name, err)
+				numSkipped++
+			} else {
+				testContext.Fail("Test %s failed: %v", name, err)
+				numFailure++
+			}
 		} else {
-			testContext.Header("Test %s passed", name)
+			testContext.Success("Test %s passed", name)
+			numSuccess++
 		}
 	}
-	testContext.Log("Test finished")
+	testContext.Log("Test finished: %v tests succeeded, %v tests failed, %v tests were skipped", numSuccess, numFailure, numSkipped)
 	testContext.teardown(ctx)
+	if numFailure > 0 {
+		return fmt.Errorf("%v/%v tests failed", numFailure, len(testsRegistry))
+	}
 	return nil
 }
 
@@ -304,13 +331,6 @@ func (t *testContext) setup(ctx context.Context) error {
 	if err != nil {
 		return fmt.Errorf("unable to create Deployment %s: %s", clientDeploymentName, err)
 	}
-
-	t.Log("Deploying echo-other-node Service %s...", echoOtherNodeDeploymentName)
-	svc = newService(echoOtherNodeDeploymentName, map[string]string{"name": echoOtherNodeDeploymentName}, 80)
-	_, err = t.client.CoreV1().Services(t.namespace).Create(ctx, svc, metav1.CreateOptions{})
-	if err != nil {
-		return err
-	}
 	echoOtherNodeDeployment := newDeployment(deploymentParameters{
 		Name:    echoOtherNodeDeploymentName,
 		Role:    kindEchoName,
@@ -339,6 +359,12 @@ func (t *testContext) setup(ctx context.Context) error {
 		return fmt.Errorf("unable to list Nodes: %s", err)
 	}
 	if len(nodes.Items) >= 2 {
+		t.Log("Deploying echo-other-node Service %s...", echoOtherNodeDeploymentName)
+		svc = newService(echoOtherNodeDeploymentName, map[string]string{"name": echoOtherNodeDeploymentName}, 80)
+		_, err = t.client.CoreV1().Services(t.namespace).Create(ctx, svc, metav1.CreateOptions{})
+		if err != nil {
+			return err
+		}
 		_, err = t.client.AppsV1().Deployments(t.namespace).Create(ctx, echoOtherNodeDeployment, metav1.CreateOptions{})
 		if err != nil {
 			return fmt.Errorf("unable to create Deployment %s: %s", echoOtherNodeDeploymentName, err)
@@ -397,6 +423,18 @@ func (t *testContext) Log(format string, a ...interface{}) {
 	fmt.Fprintf(os.Stdout, fmt.Sprintf("[%s] ", t.clusterName)+format+"\n", a...)
 }
 
+func (t *testContext) Success(format string, a ...interface{}) {
+	fmt.Fprintf(os.Stdout, fmt.Sprintf("[%s] ", t.clusterName)+color.GreenString(format, a...)+"\n")
+}
+
+func (t *testContext) Fail(format string, a ...interface{}) {
+	fmt.Fprintf(os.Stdout, fmt.Sprintf("[%s] ", t.clusterName)+color.RedString(format, a...)+"\n")
+}
+
+func (t *testContext) Warning(format string, a ...interface{}) {
+	fmt.Fprintf(os.Stdout, fmt.Sprintf("[%s] ", t.clusterName)+color.YellowString(format, a...)+"\n")
+}
+
 func (t *testContext) Header(format string, a ...interface{}) {
 	t.Log("-------------------------------------------------------------------------------------------")
 	t.Log(format, a...)
diff --git a/pkg/antctl/raw/multicluster/deploy/deploy_helper.go b/pkg/antctl/raw/multicluster/deploy/deploy_helper.go
index 081715ffc..a37667ad4 100644
--- a/pkg/antctl/raw/multicluster/deploy/deploy_helper.go
+++ b/pkg/antctl/raw/multicluster/deploy/deploy_helper.go
@@ -54,6 +54,9 @@ var getAPIGroupResources = getAPIGroupResourcesWrapper
 
 func generateManifests(role string, version string) ([]string, error) {
 	var manifests []string
+	if version != "latest" && !strings.HasPrefix(version, "v") {
+		version = fmt.Sprintf("v%s", version)
+	}
 	switch role {
 	case leaderRole:
 		manifests = []string{
@@ -176,6 +179,9 @@ func deploy(cmd *cobra.Command, role string, version string, namespace string, f
 		for _, manifest := range manifests {
 			// #nosec G107
 			resp, err := httpGet(manifest)
+			if resp.StatusCode == 404 {
+				return fmt.Errorf("manifest %s not found", manifest)
+			}
 			if err != nil {
 				return err
 			}
diff --git a/pkg/apis/crd/v1beta1/types.go b/pkg/apis/crd/v1beta1/types.go
index 8c81064b4..6f3dc018d 100644
--- a/pkg/apis/crd/v1beta1/types.go
+++ b/pkg/apis/crd/v1beta1/types.go
@@ -1268,6 +1268,8 @@ type Observation struct {
 	EgressIP    string `json:"egressIP,omitempty" yaml:"egressIP,omitempty"`
 	// EgressNode is the name of the Egress Node.
 	EgressNode string `json:"egressNode,omitempty" yaml:"egressNode,omitempty"`
+	// SrcPodIP is the IP of source Pod.
+	SrcPodIP string `json:"srcPodIP,omitempty" yaml:"srcPodIP,omitempty"`
 }
 
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
diff --git a/pkg/apiserver/openapi/zz_generated.openapi.go b/pkg/apiserver/openapi/zz_generated.openapi.go
index bf58ce0d3..2350ef803 100644
--- a/pkg/apiserver/openapi/zz_generated.openapi.go
+++ b/pkg/apiserver/openapi/zz_generated.openapi.go
@@ -5401,6 +5401,13 @@ func schema_pkg_apis_crd_v1beta1_Observation(ref common.ReferenceCallback) commo
 							Format:      "",
 						},
 					},
+					"srcPodIP": {
+						SchemaProps: spec.SchemaProps{
+							Description: "SrcPodIP is the IP of source Pod.",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
 				},
 			},
 		},


Test patch:
diff --git a/ci/jenkins/test-mc.sh b/ci/jenkins/test-mc.sh
index 83283ae83..f7f1f7c7b 100755
--- a/ci/jenkins/test-mc.sh
+++ b/ci/jenkins/test-mc.sh
@@ -136,7 +136,7 @@ function clean_tmp() {
 function clean_images() {
     docker images --format "{{.Repository}}:{{.Tag}}" | grep -E 'mc-controller|antrea-ubuntu' | xargs -r docker rmi -f || true
     # Clean up dangling images generated in previous builds.
-    docker image prune -f --filter "until=24h" || true > /dev/null
+    docker image prune -af --filter "until=24h" || true > /dev/null
     check_and_cleanup_docker_build_cache
 }
 
diff --git a/ci/kind/test-e2e-kind.sh b/ci/kind/test-e2e-kind.sh
index fded9d433..34c28b482 100755
--- a/ci/kind/test-e2e-kind.sh
+++ b/ci/kind/test-e2e-kind.sh
@@ -29,14 +29,15 @@ _usage="Usage: $0 [--encap-mode <mode>] [--ip-family <v4|v6|dual>] [--coverage]
         --run                         Run only tests matching the regexp.
         --proxy-all                   Enables Antrea proxy with all Service support.
         --load-balancer-mode          LoadBalancer mode.
-        --node-ipam                   Enables Antrea NodeIPAN.
+        --node-ipam                   Enables Antrea NodeIPAM.
         --multicast                   Enables Multicast.
         --flow-visibility             Only run flow visibility related e2e tests.
+        --networkpolicy-evaluation    Configures additional NetworkPolicy evaluation level when running e2e tests.
         --extra-network               Creates an extra network that worker Nodes will connect to. Cannot be specified with the hybrid mode.
         --extra-vlan                  Creates an subnet-based VLAN that worker Nodes will connect to.
         --deploy-external-server      Deploy a container running as an external server for the cluster.
         --skip                        A comma-separated list of keywords, with which tests should be skipped.
-        --coverage                    Enables measure Antrea code coverage when run e2e tests on kind.
+        --coverage                    Enables measure Antrea code coverage when running e2e tests on kind.
         --setup-only                  Only perform setting up the cluster and run test.
         --cleanup-only                Only perform cleaning up the cluster.
         --test-only                   Only run test on current cluster. Not set up/clean up the cluster.
@@ -76,6 +77,7 @@ load_balancer_mode=""
 node_ipam=false
 multicast=false
 flow_visibility=false
+np_evaluation=false
 extra_network=false
 extra_vlan=false
 deploy_external_server=false
@@ -126,6 +128,10 @@ case $key in
     flow_visibility=true
     shift
     ;;
+    --networkpolicy-evaluation)
+    np_evaluation=true
+    shift
+    ;;
     --encap-mode)
     mode="$2"
     shift 2
@@ -368,11 +374,16 @@ function run_test {
     RUN_OPT="-run $run"
   fi
 
+  np_evaluation_flag=""
+  if $np_evaluation; then
+    np_evaluation_flag="--networkpolicy-evaluation"
+  fi
+
   external_server_cid=$(docker ps -f name="^antrea-external-server" --format '{{.ID}}')
   external_server_ips=$(docker inspect $external_server_cid -f '{{.NetworkSettings.Networks.kind.IPAddress}},{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')
   EXTRA_ARGS="$vlan_args --external-server-ips $external_server_ips"
 
-  go test -v -timeout=$timeout $RUN_OPT antrea.io/antrea/test/e2e $flow_visibility_args -provider=kind --logs-export-dir=$ANTREA_LOG_DIR --skip-cases=$skiplist $coverage_args $EXTRA_ARGS
+  go test -v -timeout=$timeout $RUN_OPT antrea.io/antrea/test/e2e $flow_visibility_args -provider=kind --logs-export-dir=$ANTREA_LOG_DIR $np_evaluation_flag --skip-cases=$skiplist $coverage_args $EXTRA_ARGS
 }
 
 if [[ "$mode" == "" ]] || [[ "$mode" == "encap" ]]; then
diff --git a/ci/test-conformance-aks.sh b/ci/test-conformance-aks.sh
index 4e692a4ff..c4738373f 100755
--- a/ci/test-conformance-aks.sh
+++ b/ci/test-conformance-aks.sh
@@ -191,7 +191,7 @@ function deliver_antrea_to_aks() {
     fi
     # Clean up dangling images generated in previous builds. Recent ones must be excluded
     # because they might be being used in other builds running simultaneously.
-    docker image prune -f --filter "until=2h" > /dev/null
+    docker image prune -af --filter "until=2h" > /dev/null
     docker system df -v
     check_and_cleanup_docker_build_cache
 
diff --git a/ci/test-conformance-eks.sh b/ci/test-conformance-eks.sh
index 2059bb3f6..a2fc300aa 100755
--- a/ci/test-conformance-eks.sh
+++ b/ci/test-conformance-eks.sh
@@ -268,7 +268,7 @@ function deliver_antrea_to_eks() {
     fi
     # Clean up dangling images generated in previous builds. Recent ones must be excluded
     # because they might be being used in other builds running simultaneously.
-    docker image prune -f --filter "until=2h" > /dev/null
+    docker image prune -af --filter "until=2h" > /dev/null
     docker system df -v
     check_and_cleanup_docker_build_cache
     set -e
diff --git a/ci/test-conformance-gke.sh b/ci/test-conformance-gke.sh
index 255fbcf3f..750ff1926 100755
--- a/ci/test-conformance-gke.sh
+++ b/ci/test-conformance-gke.sh
@@ -216,7 +216,7 @@ function deliver_antrea_to_gke() {
     fi
     # Clean up dangling images generated in previous builds. Recent ones must be excluded
     # because they might be being used in other builds running simultaneously.
-    docker image prune -f --filter "until=2h" > /dev/null
+    docker image prune -af --filter "until=2h" > /dev/null
     docker system df -v
     check_and_cleanup_docker_build_cache
     set -e
diff --git a/pkg/agent/controller/networkpolicy/node_reconciler_linux_test.go b/pkg/agent/controller/networkpolicy/node_reconciler_linux_test.go
index 75abd9f6b..08428520c 100644
--- a/pkg/agent/controller/networkpolicy/node_reconciler_linux_test.go
+++ b/pkg/agent/controller/networkpolicy/node_reconciler_linux_test.go
@@ -76,7 +76,7 @@ var (
 	ingressRule1   = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID1,
-			Name:           "rule-01",
+			Name:           "ingress-rule-01",
 			PolicyName:     "ingress-policy",
 			From:           ipBlocks,
 			Direction:      v1beta2.DirectionIn,
@@ -93,7 +93,7 @@ var (
 	ingressRule2 = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID2,
-			Name:           "rule-02",
+			Name:           "ingress-rule-02",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP443},
@@ -109,7 +109,7 @@ var (
 	ingressRule3 = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			From:           ipBlocksToMatchAny,
 			Direction:      v1beta2.DirectionIn,
@@ -127,7 +127,7 @@ var (
 	updatedIngressRule3WithOneFromAddress = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP8080},
@@ -143,7 +143,7 @@ var (
 	updatedIngressRule3WithAnotherFromAddress = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP8080},
@@ -159,7 +159,7 @@ var (
 	updatedIngressRule3WithMultipleFromAddresses = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP8080},
@@ -175,7 +175,7 @@ var (
 	updatedIngressRule3WithOtherMultipleFromAddresses = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP8080},
@@ -191,7 +191,7 @@ var (
 	updatedIngressRule3WithFromNoAddress = &CompletedRule{
 		rule: &rule{
 			ID:             ingressRuleID3,
-			Name:           "rule-03",
+			Name:           "ingress-rule-03",
 			PolicyName:     "ingress-policy",
 			Direction:      v1beta2.DirectionIn,
 			Services:       []v1beta2.Service{serviceTCP8080},
@@ -207,7 +207,7 @@ var (
 	egressRule1 = &CompletedRule{
 		rule: &rule{
 			ID:             egressRuleID1,
-			Name:           "rule-01",
+			Name:           "egress-rule-01",
 			PolicyName:     "egress-policy",
 			Direction:      v1beta2.DirectionOut,
 			Services:       []v1beta2.Service{serviceTCP80, serviceTCP443},
@@ -223,7 +223,7 @@ var (
 	egressRule2 = &CompletedRule{
 		rule: &rule{
 			ID:             egressRuleID2,
-			Name:           "rule-02",
+			Name:           "egress-rule-02",
 			PolicyName:     "egress-policy",
 			Direction:      v1beta2.DirectionOut,
 			Services:       []v1beta2.Service{serviceTCP443},
@@ -264,7 +264,7 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				mockRouteClient.AddOrUpdateNodeNetworkPolicyIPSet("ANTREA-POL-INGRESSRULE1-4", sets.New[string]("1.1.1.1/32", "192.168.1.0/25"), false).Times(1)
@@ -294,7 +294,7 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				mockRouteClient.AddOrUpdateNodeNetworkPolicyIPTables([]string{"ANTREA-POL-EGRESSRULE1"}, serviceRules, true).Times(1)
@@ -322,12 +322,12 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRulesIPv4 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesIPv6 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				mockRouteClient.AddOrUpdateNodeNetworkPolicyIPSet("ANTREA-POL-INGRESSRULE1-4", sets.New[string]("1.1.1.1/32", "192.168.1.0/25"), false).Times(1)
@@ -363,31 +363,31 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRules1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDeleted3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDelete2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -416,13 +416,13 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 			expectedCalls: func(mockRouteClient *routetest.MockInterfaceMockRecorder) {
 				coreRules3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				serviceRules1 := [][]string{
@@ -433,20 +433,20 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRules1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDelete3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDelete1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				mockRouteClient.AddOrUpdateNodeNetworkPolicyIPTables([]string{"ANTREA-POL-INGRESS-RULES"}, coreRules3, false).Times(1)
@@ -476,7 +476,7 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 			expectedCalls: func(mockRouteClient *routetest.MockInterfaceMockRecorder) {
 				coreRules2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				serviceRules1 := [][]string{
@@ -487,26 +487,26 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 				}
 				coreRules1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDelete2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRulesDelete1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -537,22 +537,22 @@ func TestNodeReconcilerReconcileAndForget(t *testing.T) {
 			expectedCalls: func(mockRouteClient *routetest.MockInterfaceMockRecorder) {
 				coreRules1 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules2 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules3 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.2/32 -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.2/32 -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules4 := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE3-4 src -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule3, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE3-4 src -p tcp --dport 8080 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-03, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				coreRules6 := coreRules2
@@ -666,8 +666,8 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -681,7 +681,7 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				updatedCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -710,8 +710,8 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -725,7 +725,7 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				updatedCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -753,14 +753,14 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				ipv4CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				ipv6CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -775,12 +775,12 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				ipv6SvcRules := ipv4SvcRules
 				updatedIPv4CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedIPv6CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -815,8 +815,8 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -830,7 +830,7 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				updatedCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -857,8 +857,8 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				coreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -872,7 +872,7 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				updatedCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -900,14 +900,14 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				}
 				ipv4CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				ipv6CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				svcChains := []string{
@@ -922,12 +922,12 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				ipv6SvcRules := ipv4SvcRules
 				updatedIPv4CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedIPv6CoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				mockRouteClient.AddOrUpdateNodeNetworkPolicyIPTables(svcChains, ipv4SvcRules, false).Times(1)
@@ -973,24 +973,24 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				egressCoreChains := []string{"ANTREA-POL-EGRESS-RULES"}
 				ingressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-4 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				egressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedIngressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedEgressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 1.1.1.1/32 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
@@ -1038,24 +1038,24 @@ func TestNodeReconcilerBatchReconcileAndForget(t *testing.T) {
 				egressCoreChains := []string{"ANTREA-POL-EGRESS-RULES"}
 				ingressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -m set --match-set ANTREA-POL-INGRESSRULE1-6 src -j ANTREA-POL-INGRESSRULE1 -m comment --comment "Antrea: for rule ingress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				egressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egressRule1, policy AntreaClusterNetworkPolicy:name1"`,
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -j ANTREA-POL-EGRESSRULE1 -m comment --comment "Antrea: for rule egress-rule-01, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedIngressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-INGRESS-RULES -s 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule ingress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 				updatedEgressCoreRules := [][]string{
 					{
-						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egressRule2, policy AntreaClusterNetworkPolicy:name1"`,
+						`-A ANTREA-POL-EGRESS-RULES -d 2002:1a23:fb44::1/128 -p tcp --dport 443 -j ACCEPT -m comment --comment "Antrea: for rule egress-rule-02, policy AntreaClusterNetworkPolicy:name1"`,
 					},
 				}
 
diff --git a/pkg/agent/controller/traceflow/packetin_test.go b/pkg/agent/controller/traceflow/packetin_test.go
index bd6e97d30..253405820 100644
--- a/pkg/agent/controller/traceflow/packetin_test.go
+++ b/pkg/agent/controller/traceflow/packetin_test.go
@@ -209,8 +209,8 @@ func getTestPacketBytes(dstIP string) []byte {
 		Protocol: uint8(8),
 		DSCP:     1,
 		Length:   20,
-		NWSrc:    net.IP(pod1IPv4),
-		NWDst:    net.IP(dstIP),
+		NWSrc:    net.ParseIP(pod1IPv4),
+		NWDst:    net.ParseIP(dstIP),
 	}
 	ethernetPkt := protocol.NewEthernet()
 	ethernetPkt.HWSrc = pod1MAC
@@ -238,6 +238,13 @@ func TestParsePacketIn(t *testing.T) {
 			Data: 1,
 		},
 	}
+	matchCTSrc := &openflow15.MatchField{
+		Class: openflow15.OXM_CLASS_NXM_1,
+		Field: openflow15.NXM_NX_CT_NW_SRC,
+		Value: &openflow15.Ipv4SrcField{
+			Ipv4Src: net.ParseIP(pod1IPv4),
+		},
+	}
 	matchTunDst := openflow15.NewTunnelIpv4DstField(net.ParseIP(egressIP), nil)
 
 	conjData := make([]byte, 8)
@@ -298,7 +305,7 @@ func TestParsePacketIn(t *testing.T) {
 				PacketIn: &openflow15.PacketIn{
 					TableId: openflow.OutputTable.GetID(),
 					Match: openflow15.Match{
-						Fields: []openflow15.MatchField{*matchOutPort, *matchPktMark},
+						Fields: []openflow15.MatchField{*matchOutPort, *matchPktMark, *matchCTSrc},
 					},
 					Data: util.NewBuffer(pktBytesPodToIP),
 				},
@@ -329,6 +336,7 @@ func TestParsePacketIn(t *testing.T) {
 					{
 						Component: crdv1beta1.ComponentSpoofGuard,
 						Action:    crdv1beta1.ActionForwarded,
+						SrcPodIP:  pod1IPv4,
 					},
 					{
 						Component:  crdv1beta1.ComponentEgress,
@@ -365,6 +373,8 @@ func TestParsePacketIn(t *testing.T) {
 				PacketIn: &openflow15.PacketIn{
 					TableId: openflow.OutputTable.GetID(),
 					Match: openflow15.Match{
+						// We are omitting matchCTSrc intentionally here to test
+						// the case where there is no valid ct_nw_src match in the packet metadata.
 						Fields: []openflow15.MatchField{*matchTunDst, *matchOutPort},
 					},
 					Data: util.NewBuffer(pktBytesPodToIP),
@@ -396,6 +406,7 @@ func TestParsePacketIn(t *testing.T) {
 					{
 						Component: crdv1beta1.ComponentSpoofGuard,
 						Action:    crdv1beta1.ActionForwarded,
+						SrcPodIP:  pod1IPv4,
 					},
 					{
 						Component:  crdv1beta1.ComponentEgress,
@@ -489,7 +500,7 @@ func TestParsePacketIn(t *testing.T) {
 				PacketIn: &openflow15.PacketIn{
 					TableId: openflow.EgressRuleTable.GetID(),
 					Match: openflow15.Match{
-						Fields: []openflow15.MatchField{*matchTFEgressConjID},
+						Fields: []openflow15.MatchField{*matchTFEgressConjID, *matchCTSrc},
 					},
 					Data: util.NewBuffer(pktBytesPodToPod),
 				},
@@ -531,6 +542,7 @@ func TestParsePacketIn(t *testing.T) {
 					{
 						Component: crdv1beta1.ComponentSpoofGuard,
 						Action:    crdv1beta1.ActionForwarded,
+						SrcPodIP:  pod1IPv4,
 					},
 					{
 						Component:         crdv1beta1.ComponentNetworkPolicy,
@@ -618,7 +630,7 @@ func TestParsePacketIn(t *testing.T) {
 				PacketIn: &openflow15.PacketIn{
 					TableId: openflow.EgressMetricTable.GetID(),
 					Match: openflow15.Match{
-						Fields: []openflow15.MatchField{*matchAPConjID},
+						Fields: []openflow15.MatchField{*matchAPConjID, *matchCTSrc},
 					},
 					Data: util.NewBuffer(pktBytesPodToPod),
 				},
@@ -658,6 +670,7 @@ func TestParsePacketIn(t *testing.T) {
 					{
 						Component: crdv1beta1.ComponentSpoofGuard,
 						Action:    crdv1beta1.ActionForwarded,
+						SrcPodIP:  pod1IPv4,
 					},
 					{
 						Component:         crdv1beta1.ComponentNetworkPolicy,
diff --git a/pkg/agent/multicast/mcast_route_test.go b/pkg/agent/multicast/mcast_route_test.go
index 2a5ffbc38..47fc1111e 100644
--- a/pkg/agent/multicast/mcast_route_test.go
+++ b/pkg/agent/multicast/mcast_route_test.go
@@ -109,20 +109,20 @@ func TestDeleteInboundMrouteEntryByGroup(t *testing.T) {
 			name:  "two entries matched",
 			group: net.ParseIP("224.3.4.5"),
 			currRouteEntries: []inboundMulticastRouteEntry{
-				{group: "224.3.4.5", src: "10.3.4.6", vif: 1},
-				{group: "224.3.4.5", src: "10.3.4.7", vif: 2},
-				{group: "224.3.4.7", src: "10.3.4.7", vif: 2},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.5", src: "10.3.4.6"}, vif: 1},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.5", src: "10.3.4.7"}, vif: 2},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.7", src: "10.3.4.7"}, vif: 2},
 			},
 			deletedRouteEntries: []inboundMulticastRouteEntry{
-				{group: "224.3.4.5", src: "10.3.4.6", vif: 1},
-				{group: "224.3.4.5", src: "10.3.4.7", vif: 2},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.5", src: "10.3.4.6"}, vif: 1},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.5", src: "10.3.4.7"}, vif: 2},
 			},
 		},
 		{
 			name:  "no entry match",
 			group: net.ParseIP("224.3.4.6"),
 			currRouteEntries: []inboundMulticastRouteEntry{
-				{group: "224.3.4.5", src: "10.3.4.6", vif: 1},
+				{multicastRouteEntry: multicastRouteEntry{group: "224.3.4.5", src: "10.3.4.6"}, vif: 1},
 			},
 			deletedRouteEntries: []inboundMulticastRouteEntry{}},
 	} {
@@ -139,6 +139,131 @@ func TestDeleteInboundMrouteEntryByGroup(t *testing.T) {
 	}
 }
 
+func TestUpdateOutboundMrouteStats(t *testing.T) {
+	mRoute := newMockMulticastRouteClient(t)
+	err := mRoute.initialize(t)
+	require.NoError(t, err)
+	now := time.Now()
+	for _, m := range []struct {
+		isStale     bool
+		currStats   uint32
+		group       string
+		source      string
+		packetCount uint32
+		createdTime time.Time
+	}{
+		{
+			group:       "224.3.5.7",
+			source:      "10.1.2.3",
+			createdTime: now,
+			isStale:     false,
+			currStats:   0,
+		},
+		{
+			group:       "224.3.5.8",
+			source:      "10.1.2.4",
+			createdTime: now.Add(time.Duration(-mRouteTimeout)),
+			packetCount: 10,
+			isStale:     false,
+			currStats:   9,
+		},
+		{
+			group:       "224.3.5.9",
+			source:      "10.1.2.5",
+			createdTime: now.Add(time.Duration(-mRouteTimeout)),
+			packetCount: 0,
+			isStale:     true,
+			currStats:   0,
+		},
+	} {
+		outboundMrouteEntry := &outboundMulticastRouteEntry{
+			multicastRouteEntry: multicastRouteEntry{
+				src:         m.source,
+				group:       m.group,
+				pktCount:    m.packetCount,
+				updatedTime: m.createdTime,
+			},
+		}
+		mRoute.outboundRouteCache.Add(outboundMrouteEntry)
+		mockMulticastSocket.EXPECT().GetMroutePacketCount(net.ParseIP(m.source), net.ParseIP(m.group)).Times(1).Return(m.currStats, nil)
+		if m.isStale {
+			mockMulticastSocket.EXPECT().DelMrouteEntry(net.ParseIP(m.source), net.ParseIP(m.group), uint16(0)).Times(1)
+		}
+		isStale := m.isStale
+		defer func() {
+			_, exist, _ := mRoute.outboundRouteCache.Get(outboundMrouteEntry)
+			require.Equal(t, !isStale, exist)
+		}()
+	}
+	mRoute.updateMrouteStats()
+}
+
+func TestUpdateInboundMrouteStats(t *testing.T) {
+	mRoute := newMockMulticastRouteClient(t)
+	err := mRoute.initialize(t)
+	require.NoError(t, err)
+	now := time.Now()
+	for _, m := range []struct {
+		isStale         bool
+		currPacketCount uint32
+		vif             uint16
+		group           string
+		source          string
+		packetCount     uint32
+		updatedTime     time.Time
+	}{
+		{
+			group:           "224.3.5.7",
+			source:          "192.168.50.60",
+			updatedTime:     now,
+			isStale:         false,
+			currPacketCount: 0,
+			vif:             3,
+		},
+		{
+			group:           "224.3.5.8",
+			source:          "192.168.50.61",
+			updatedTime:     now.Add(time.Duration(-mRouteTimeout)),
+			packetCount:     10,
+			isStale:         false,
+			currPacketCount: 9,
+			vif:             4,
+		},
+		{
+			group:           "224.3.5.9",
+			source:          "192.168.50.62",
+			updatedTime:     now.Add(time.Duration(-mRouteTimeout)),
+			packetCount:     5,
+			isStale:         true,
+			currPacketCount: 5,
+			vif:             5,
+		},
+	} {
+		inboundMrouteEntry := &inboundMulticastRouteEntry{
+			multicastRouteEntry: multicastRouteEntry{
+				src:         m.source,
+				group:       m.group,
+				pktCount:    m.packetCount,
+				updatedTime: m.updatedTime,
+			},
+			vif: m.vif,
+		}
+		mRoute.inboundRouteCache.Add(inboundMrouteEntry)
+		_, exist, _ := mRoute.inboundRouteCache.Get(inboundMrouteEntry)
+		require.True(t, exist)
+		mockMulticastSocket.EXPECT().GetMroutePacketCount(net.ParseIP(m.source), net.ParseIP(m.group)).Times(1).Return(m.currPacketCount, nil)
+		if m.isStale {
+			mockMulticastSocket.EXPECT().DelMrouteEntry(net.ParseIP(m.source), net.ParseIP(m.group), m.vif).Times(1)
+		}
+		isStale := m.isStale
+		defer func() {
+			_, exist, _ := mRoute.inboundRouteCache.Get(inboundMrouteEntry)
+			require.Equal(t, !isStale, exist)
+		}()
+	}
+	mRoute.updateMrouteStats()
+}
+
 func TestProcessIGMPNocacheMsg(t *testing.T) {
 	mRoute := newMockMulticastRouteClient(t)
 	err := mRoute.initialize(t)
@@ -236,7 +361,7 @@ func newMockMulticastRouteClient(t *testing.T) *MRouteClient {
 	groupCache := cache.NewIndexer(getGroupEventKey, cache.Indexers{
 		podInterfaceIndex: podInterfaceIndexFunc,
 	})
-	return newRouteClient(nodeConfig, groupCache, mockMulticastSocket, sets.New[string](if1.InterfaceName), false, false)
+	return newRouteClient(nodeConfig, groupCache, mockMulticastSocket, sets.New[string](if1.InterfaceName), false)
 }
 
 func (c *MRouteClient) initialize(t *testing.T) error {
diff --git a/pkg/agent/multicast/testing/mock_multicast.go b/pkg/agent/multicast/testing/mock_multicast.go
index 0c13ef9b3..277876ca2 100644
--- a/pkg/agent/multicast/testing/mock_multicast.go
+++ b/pkg/agent/multicast/testing/mock_multicast.go
@@ -122,6 +122,21 @@ func (mr *MockRouteInterfaceMockRecorder) GetFD() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFD", reflect.TypeOf((*MockRouteInterface)(nil).GetFD))
 }
 
+// GetMroutePacketCount mocks base method.
+func (m *MockRouteInterface) GetMroutePacketCount(arg0, arg1 net.IP) (uint32, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMroutePacketCount", arg0, arg1)
+	ret0, _ := ret[0].(uint32)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetMroutePacketCount indicates an expected call of GetMroutePacketCount.
+func (mr *MockRouteInterfaceMockRecorder) GetMroutePacketCount(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMroutePacketCount", reflect.TypeOf((*MockRouteInterface)(nil).GetMroutePacketCount), arg0, arg1)
+}
+
 // MulticastInterfaceJoinMgroup mocks base method.
 func (m *MockRouteInterface) MulticastInterfaceJoinMgroup(arg0, arg1 net.IP, arg2 string) error {
 	m.ctrl.T.Helper()
diff --git a/pkg/agent/nodeportlocal/npl_agent_test.go b/pkg/agent/nodeportlocal/npl_agent_test.go
index 53b33f61a..bcdd90d92 100644
--- a/pkg/agent/nodeportlocal/npl_agent_test.go
+++ b/pkg/agent/nodeportlocal/npl_agent_test.go
@@ -175,11 +175,12 @@ func getTestSvcWithPortName(portName string) *corev1.Service {
 
 type testData struct {
 	*testing.T
-	stopCh    chan struct{}
-	ctrl      *gomock.Controller
-	k8sClient *k8sfake.Clientset
-	portTable *portcache.PortTable
-	wg        sync.WaitGroup
+	stopCh      chan struct{}
+	ctrl        *gomock.Controller
+	k8sClient   *k8sfake.Clientset
+	portTable   *portcache.PortTable
+	svcInformer cache.SharedIndexInformer
+	wg          sync.WaitGroup
 }
 
 func (t *testData) runWrapper(c *k8s.NPLController) {
@@ -233,22 +234,18 @@ func setUp(t *testing.T, tc *testConfig, objects ...runtime.Object) *testData {
 		mockPortOpener.EXPECT().OpenLocalPort(gomock.Any(), gomock.Any()).AnyTimes().Return(&fakeSocket{}, nil)
 	}
 
-	data := &testData{
-		T:         t,
-		stopCh:    make(chan struct{}),
-		ctrl:      mockCtrl,
-		k8sClient: k8sfake.NewSimpleClientset(objects...),
-		portTable: newPortTable(mockIPTables, mockPortOpener),
-	}
+	k8sClient := k8sfake.NewSimpleClientset(objects...)
+
+	portTable := newPortTable(mockIPTables, mockPortOpener)
 
 	resyncPeriod := 0 * time.Minute
 	// informerFactory is initialized and started from cmd/antrea-agent/agent.go
-	informerFactory := informers.NewSharedInformerFactory(data.k8sClient, resyncPeriod)
+	informerFactory := informers.NewSharedInformerFactory(k8sClient, resyncPeriod)
 	listOptions := func(options *metav1.ListOptions) {
 		options.FieldSelector = fields.OneTermEqualSelector("spec.nodeName", defaultNodeName).String()
 	}
 	localPodInformer := coreinformers.NewFilteredPodInformer(
-		data.k8sClient,
+		k8sClient,
 		metav1.NamespaceAll,
 		resyncPeriod,
 		cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, // NamespaceIndex is used in NPLController.
@@ -256,7 +253,16 @@ func setUp(t *testing.T, tc *testConfig, objects ...runtime.Object) *testData {
 	)
 	svcInformer := informerFactory.Core().V1().Services().Informer()
 
-	c := k8s.NewNPLController(data.k8sClient, localPodInformer, svcInformer, data.portTable, defaultNodeName)
+	c := k8s.NewNPLController(k8sClient, localPodInformer, svcInformer, portTable, defaultNodeName)
+
+	data := &testData{
+		T:           t,
+		stopCh:      make(chan struct{}),
+		ctrl:        mockCtrl,
+		k8sClient:   k8sClient,
+		portTable:   portTable,
+		svcInformer: svcInformer,
+	}
 
 	data.runWrapper(c)
 	informerFactory.Start(data.stopCh)
@@ -303,31 +309,41 @@ func (t *testData) tearDown() {
 	t.wg.Wait()
 }
 
-func (t *testData) pollForPodAnnotation(podName string, found bool) ([]types.NPLAnnotation, error) {
-	var data string
-	var exists bool
+func conditionMatchAll([]types.NPLAnnotation) bool {
+	return true
+}
+
+// If conditionFn is nil, we will assume you are looking for a non-existing annotation.
+// If you want to match all, use conditionMatchAll as the conditionFn.
+func (t *testData) pollForPodAnnotationWithCondition(podName string, conditionFn func([]types.NPLAnnotation) bool) ([]types.NPLAnnotation, error) {
+	var nplValue []types.NPLAnnotation
 	// do not use PollImmediate: 1 second is reserved for the controller to do his job and
 	// update Pod NPL annotations as needed.
 	err := wait.PollUntilContextTimeout(context.Background(), time.Second, 20*time.Second, false, func(ctx context.Context) (bool, error) {
 		updatedPod, err := t.k8sClient.CoreV1().Pods(defaultNS).Get(context.TODO(), podName, metav1.GetOptions{})
 		require.NoError(t, err, "Failed to get Pod")
 		annotation := updatedPod.GetAnnotations()
-		data, exists = annotation[types.NPLAnnotationKey]
-		if found {
-			return exists, nil
+		data, exists := annotation[types.NPLAnnotationKey]
+		if !exists {
+			return conditionFn == nil, nil
 		}
-		return !exists, nil
+		if conditionFn == nil {
+			return false, nil
+		}
+		if err := json.Unmarshal([]byte(data), &nplValue); err != nil {
+			return false, err
+		}
+		return conditionFn(nplValue), nil
 	})
+	return nplValue, err
+}
 
-	if err != nil {
-		return []types.NPLAnnotation{}, err
-	}
-	if data == "" {
-		return []types.NPLAnnotation{}, nil
+func (t *testData) pollForPodAnnotation(podName string, found bool) ([]types.NPLAnnotation, error) {
+	var conditionFn func([]types.NPLAnnotation) bool
+	if found {
+		conditionFn = conditionMatchAll
 	}
-	var nplValue []types.NPLAnnotation
-	err = json.Unmarshal([]byte(data), &nplValue)
-	return nplValue, err
+	return t.pollForPodAnnotationWithCondition(podName, conditionFn)
 }
 
 func (t *testData) updateServiceOrFail(testSvc *corev1.Service) {
@@ -495,6 +511,7 @@ func TestPodDelete(t *testing.T) {
 
 // TestPodAddMultiPort creates a Pod and a Service with two target ports.
 // It verifies that the Pod's NPL annotation and the local port table are updated with both ports.
+// It then updates the Service to remove one of the target ports.
 func TestAddMultiPortPodSvc(t *testing.T) {
 	newPort := 90
 	testSvc := getTestSvc(defaultPort, int32(newPort))
@@ -508,6 +525,16 @@ func TestAddMultiPortPodSvc(t *testing.T) {
 	expectedAnnotations.Check(t, value)
 	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
 	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+
+	// Remove the second target port.
+	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
+	testData.updateServiceOrFail(testSvc)
+	// Wait for annotation to be updated (single mapping).
+	value, err = testData.pollForPodAnnotationWithCondition(testPod.Name, func(value []types.NPLAnnotation) bool { return len(value) == 1 })
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotations = newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
+	expectedAnnotations.Check(t, value)
+	assert.False(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
 }
 
 // TestPodAddMultiPort creates a Pod with multiple ports and a Service with only one target port.
@@ -805,3 +832,99 @@ func TestSyncRulesError(t *testing.T) {
 	testData, _, _ := setUpWithTestServiceAndPod(t, testConfig, nil)
 	defer testData.tearDown()
 }
+
+func TestSingleRuleDeletionError(t *testing.T) {
+	newPort := 90
+	testSvc := getTestSvc(defaultPort, int32(newPort))
+	testPod := getTestPod()
+
+	testConfig := newTestConfig().withCustomPodPortRulesExpectations(func(mockIPTables *rulestesting.MockPodPortRules) {
+		mockIPTables.EXPECT().AddAllRules(gomock.Any()).AnyTimes()
+		gomock.InOrder(
+			mockIPTables.EXPECT().AddRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2),
+			mockIPTables.EXPECT().DeleteRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("iptables failure")),
+			mockIPTables.EXPECT().DeleteRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		)
+	})
+
+	testData := setUp(t, testConfig, testSvc, testPod)
+	defer testData.tearDown()
+
+	value, err := testData.pollForPodAnnotation(testPod.Name, true)
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, newPort, protocolTCP)
+	expectedAnnotations.Check(t, value)
+	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+
+	// Remove the second target port, to force one mapping to be deleted.
+	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
+	testData.updateServiceOrFail(testSvc)
+	// The first deletion attempt will fail, but the second should succeed.
+	// Wait for annotation to be updated (single mapping).
+	value, err = testData.pollForPodAnnotationWithCondition(testPod.Name, func(value []types.NPLAnnotation) bool { return len(value) == 1 })
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotations = newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
+	expectedAnnotations.Check(t, value)
+	assert.False(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+}
+
+func TestPreventDefunctRuleReuse(t *testing.T) {
+	newPort := 90
+	testSvc := getTestSvc(defaultPort, int32(newPort))
+	testPod := getTestPod()
+
+	var testData *testData
+
+	ports := testSvc.Spec.Ports
+	// This function will be executed synchronously when DeleteRule is called for the first time
+	// and we simulate a failure. It restores the second target port for the Service, which was
+	// deleted previously, and waits for the change to be reflected in the informer's
+	// store. After that, we know that the next time the NPL controller processes the test Pod,
+	// it will need to ensure that both NPL mappings are configured correctly. Because one of
+	// the rules will be marked as "defunct", it will first need to delete the rule properly
+	// before adding it back.
+	restoreServiceTargetPorts := func() {
+		testSvc.Spec.Ports = ports
+		_, err := testData.k8sClient.CoreV1().Services(defaultNS).Update(context.TODO(), testSvc, metav1.UpdateOptions{})
+		if !assert.NoError(t, err) {
+			return
+		}
+		assert.EventuallyWithT(t, func(c *assert.CollectT) {
+			obj, exists, err := testData.svcInformer.GetIndexer().GetByKey(testSvc.Namespace + "/" + testSvc.Name)
+			if !assert.NoError(t, err) || !assert.True(t, exists) {
+				return
+			}
+			svc := obj.(*corev1.Service)
+			assert.Len(t, svc.Spec.Ports, 2)
+		}, 2*time.Second, 50*time.Millisecond)
+	}
+
+	testConfig := newTestConfig().withCustomPodPortRulesExpectations(func(mockIPTables *rulestesting.MockPodPortRules) {
+		mockIPTables.EXPECT().AddAllRules(gomock.Any()).AnyTimes()
+		gomock.InOrder(
+			mockIPTables.EXPECT().AddRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2),
+			mockIPTables.EXPECT().DeleteRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
+				func(nodePort int, podIP string, podPort int, protocol string) { restoreServiceTargetPorts() },
+			).Return(fmt.Errorf("iptables failure")),
+			mockIPTables.EXPECT().DeleteRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+			mockIPTables.EXPECT().AddRule(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()),
+		)
+	})
+
+	testData = setUp(t, testConfig, testSvc, testPod)
+	defer testData.tearDown()
+
+	value, err := testData.pollForPodAnnotation(testPod.Name, true)
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, newPort, protocolTCP)
+	expectedAnnotations.Check(t, value)
+	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+
+	// Remove the second target port, to force one mapping to be deleted.
+	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
+	testData.updateServiceOrFail(testSvc)
+
+	assert.Eventually(t, testData.ctrl.Satisfied, 2*time.Second, 50*time.Millisecond)
+}
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_others_test.go b/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
index 2ac40b34e..c5e1e4245 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
@@ -18,9 +18,12 @@
 package portcache
 
 import (
+	"fmt"
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 
 	portcachetesting "antrea.io/antrea/pkg/agent/nodeportlocal/portcache/testing"
@@ -73,3 +76,56 @@ func TestRestoreRules(t *testing.T) {
 		t.Fatalf("Rule restoration not complete after %v", timeout)
 	}
 }
+
+type mockCloser struct {
+	closeErr error
+}
+
+func (m *mockCloser) Close() error {
+	return m.closeErr
+}
+
+func TestDeleteRule(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	mockIPTables := rulestesting.NewMockPodPortRules(mockCtrl)
+	mockPortOpener := portcachetesting.NewMockLocalPortOpener(mockCtrl)
+	portTable := newPortTable(mockIPTables, mockPortOpener)
+
+	const (
+		podPort  = 1001
+		protocol = "tcp"
+	)
+
+	closer := &mockCloser{}
+
+	data := &NodePortData{
+		NodePort: nodePort1,
+		PodPort:  podPort,
+		PodIP:    podIP,
+		Protocol: ProtocolSocketData{
+			Protocol: protocol,
+			socket:   closer,
+		},
+	}
+
+	require.NoError(t, portTable.addPortTableCache(data))
+	assert.False(t, data.Defunct())
+
+	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol).Return(fmt.Errorf("iptables error"))
+	require.ErrorContains(t, portTable.DeleteRule(podIP, podPort, protocol), "iptables error")
+
+	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol)
+	closer.closeErr = fmt.Errorf("close error")
+	require.ErrorContains(t, portTable.DeleteRule(podIP, podPort, protocol), "close error")
+	assert.True(t, data.Defunct())
+
+	closer.closeErr = nil
+
+	// First successful call to DeleteRule.
+	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol)
+	assert.NoError(t, portTable.DeleteRule(podIP, podPort, protocol))
+
+	// Calling DeleteRule again will return immediately as the NodePortData entry has been
+	// removed from the cache.
+	assert.NoError(t, portTable.DeleteRule(podIP, podPort, protocol))
+}
diff --git a/pkg/agent/secondarynetwork/init_test.go b/pkg/agent/secondarynetwork/init_linux_test.go
similarity index 99%
rename from pkg/agent/secondarynetwork/init_test.go
rename to pkg/agent/secondarynetwork/init_linux_test.go
index 7908afd76..f931c509d 100644
--- a/pkg/agent/secondarynetwork/init_test.go
+++ b/pkg/agent/secondarynetwork/init_linux_test.go
@@ -1,3 +1,6 @@
+//go:build linux
+// +build linux
+
 // Copyright 2023 Antrea Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/pkg/agent/util/net_test.go b/pkg/agent/util/net_test.go
index ea952c3ac..dc97db63e 100644
--- a/pkg/agent/util/net_test.go
+++ b/pkg/agent/util/net_test.go
@@ -226,48 +226,6 @@ func TestGetIPNetDeviceFromIP(t *testing.T) {
 	}
 }
 
-func TestGetAllIPNetsByName(t *testing.T) {
-	tests := []struct {
-		name                 string
-		testNetInterfaceName string
-		testNetInterface     net.Interface
-		testNetInterfaceErr  error
-		wantIPNets           []*net.IPNet
-	}{
-		{
-			name:                 "IPv4",
-			testNetInterfaceName: "0",
-			wantIPNets:           []*net.IPNet{&ipv4PublicIPNet},
-		},
-		{
-			name:                 "IPv6",
-			testNetInterfaceName: "1",
-			wantIPNets: []*net.IPNet{
-				{
-					IP:   ipv6Global,
-					Mask: net.CIDRMask(128, 128),
-				},
-			},
-		},
-		{
-			name:                 "Invalid",
-			testNetInterfaceName: "0",
-			testNetInterfaceErr:  testInvalidErr,
-		},
-	}
-
-	for _, tc := range tests {
-		t.Run(tc.name, func(t *testing.T) {
-			tc.testNetInterface = generateNetInterface(tc.testNetInterfaceName)
-			defer mockNetInterfaceByName(&tc.testNetInterface, tc.testNetInterfaceErr)()
-			defer mockNetInterfaceAddrs(tc.testNetInterface, nil)()
-			gotIPNets, gotErr := GetAllIPNetsByName(tc.name)
-			assert.Equal(t, tc.wantIPNets, gotIPNets)
-			assert.Equal(t, tc.testNetInterfaceErr, gotErr)
-		})
-	}
-}
-
 func TestGetIPNetDeviceByName(t *testing.T) {
 	tests := []struct {
 		name                 string
@@ -544,7 +502,7 @@ func TestGetIPNetsByLink(t *testing.T) {
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
 			defer mockNetInterfaceAddrs(testNetInterface, tc.testNetInterfaceErr)()
-			gotIPNets, gotErr := GetIPNetsByLink(&testNetInterface)
+			gotIPNets, gotErr := getIPNetsByLink(&testNetInterface)
 			assert.Equal(t, tc.wantIPNets, gotIPNets)
 			assert.Equal(t, tc.testNetInterfaceErr, gotErr)
 		})
diff --git a/pkg/antctl/raw/check/installation/test_podtopodinternode.go b/pkg/antctl/raw/check/installation/test_podtopodinternode.go
index b40f7719a..556e72faf 100644
--- a/pkg/antctl/raw/check/installation/test_podtopodinternode.go
+++ b/pkg/antctl/raw/check/installation/test_podtopodinternode.go
@@ -29,7 +29,7 @@ func init() {
 
 func (t *PodToPodInterNodeConnectivityTest) Run(ctx context.Context, testContext *testContext) error {
 	if testContext.echoOtherNodePod == nil {
-		return fmt.Errorf("Skipping Inter-Node test because multiple Nodes are not available")
+		return newNotRunnableError("Inter-Node test requires multiple Nodes")
 	}
 	for _, clientPod := range testContext.clientPods {
 		srcPod := testContext.namespace + "/" + clientPod.Name
diff --git a/pkg/antctl/raw/check/installation/test_podtoserviceinternode.go b/pkg/antctl/raw/check/installation/test_podtoserviceinternode.go
new file mode 100644
index 000000000..19ec6a76f
--- /dev/null
+++ b/pkg/antctl/raw/check/installation/test_podtoserviceinternode.go
@@ -0,0 +1,44 @@
+// Copyright 2024 Antrea Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package installation
+
+import (
+	"context"
+	"fmt"
+
+	"antrea.io/antrea/pkg/antctl/raw/check"
+)
+
+type PodToServiceInterNodeConnectivityTest struct{}
+
+func init() {
+	RegisterTest("pod-to-service-internode-connectivity", &PodToServiceInterNodeConnectivityTest{})
+}
+
+func (t *PodToServiceInterNodeConnectivityTest) Run(ctx context.Context, testContext *testContext) error {
+	if testContext.echoOtherNodePod == nil {
+		return newNotRunnableError("Inter-Node test requires multiple Nodes")
+	}
+	service := echoOtherNodeDeploymentName
+	for _, clientPod := range testContext.clientPods {
+		testContext.Log("Validating from Pod %s to Service %s in Namespace %s...", clientPod.Name, service, testContext.namespace)
+		_, _, err := check.ExecInPod(ctx, testContext.client, testContext.config, testContext.namespace, clientPod.Name, "", agnhostConnectCommand(service, "80"))
+		if err != nil {
+			return fmt.Errorf("client Pod %s was not able to communicate with Service %s", clientPod.Name, service)
+		}
+		testContext.Log("client Pod %s was able to communicate with Service %s", clientPod.Name, service)
+	}
+	return nil
+}
diff --git a/pkg/antctl/raw/check/installation/test_podtoserviceintranode.go b/pkg/antctl/raw/check/installation/test_podtoserviceintranode.go
new file mode 100644
index 000000000..30d2cb288
--- /dev/null
+++ b/pkg/antctl/raw/check/installation/test_podtoserviceintranode.go
@@ -0,0 +1,41 @@
+// Copyright 2024 Antrea Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package installation
+
+import (
+	"context"
+	"fmt"
+
+	"antrea.io/antrea/pkg/antctl/raw/check"
+)
+
+type PodToServiceIntraNodeConnectivityTest struct{}
+
+func init() {
+	RegisterTest("pod-to-service-intranode-connectivity", &PodToServiceIntraNodeConnectivityTest{})
+}
+
+func (t *PodToServiceIntraNodeConnectivityTest) Run(ctx context.Context, testContext *testContext) error {
+	service := echoSameNodeDeploymentName
+	for _, clientPod := range testContext.clientPods {
+		testContext.Log("Validating from Pod %s to Service %s in Namespace %s...", clientPod.Name, service, testContext.namespace)
+		_, _, err := check.ExecInPod(ctx, testContext.client, testContext.config, testContext.namespace, clientPod.Name, "", agnhostConnectCommand(service, "80"))
+		if err != nil {
+			return fmt.Errorf("client Pod %s was not able to communicate with Service %s", clientPod.Name, service)
+		}
+		testContext.Log("client Pod %s was able to communicate with Service %s", clientPod.Name, service)
+	}
+	return nil
+}
diff --git a/pkg/antctl/raw/multicluster/deploy/deploy_helper_test.go b/pkg/antctl/raw/multicluster/deploy/deploy_helper_test.go
index 736d430e8..1b91cc228 100644
--- a/pkg/antctl/raw/multicluster/deploy/deploy_helper_test.go
+++ b/pkg/antctl/raw/multicluster/deploy/deploy_helper_test.go
@@ -84,6 +84,22 @@ func TestGenerateManifests(t *testing.T) {
 				"https://github.com/antrea-io/antrea/releases/download/v1.14.0/antrea-multicluster-member.yml",
 			},
 		},
+		{
+			name:    "generate versioned leader manifests without the prefix v in the version",
+			role:    "leader",
+			version: "1.14.0",
+			expectedManifests: []string{
+				"https://github.com/antrea-io/antrea/releases/download/v1.14.0/antrea-multicluster-leader.yml",
+			},
+		},
+		{
+			name:    "generate versioned member manifests without the prefix v in the version",
+			role:    "member",
+			version: "1.14.0",
+			expectedManifests: []string{
+				"https://github.com/antrea-io/antrea/releases/download/v1.14.0/antrea-multicluster-member.yml",
+			},
+		},
 		{
 			name:        "invalid role",
 			role:        "member1",
diff --git a/test/e2e/antreapolicy_test.go b/test/e2e/antreapolicy_test.go
index 9d91bf745..6d49539a4 100644
--- a/test/e2e/antreapolicy_test.go
+++ b/test/e2e/antreapolicy_test.go
@@ -72,9 +72,11 @@ const (
 	// Verification of deleting/creating resources timed out.
 	timeout = 10 * time.Second
 	// audit log directory on Antrea Agent
-	logDir          = "/var/log/antrea/networkpolicy/"
-	logfileName     = "np.log"
-	defaultTierName = "application"
+	logDir              = "/var/log/antrea/networkpolicy/"
+	logfileName         = "np.log"
+	npEvaluationPodName = "antctl"
+	defaultTierName     = "application"
+	defaultDenyKNPName  = "default-deny-namespace"
 )
 
 func failOnError(err error, t *testing.T) {
@@ -157,7 +159,7 @@ func applyDefaultDenyToAllNamespaces(k8s *KubernetesUtils, namespaces map[string
 	}
 	for _, ns := range namespaces {
 		builder := &NetworkPolicySpecBuilder{}
-		builder = builder.SetName(ns.Name, "default-deny-namespace")
+		builder = builder.SetName(ns.Name, defaultDenyKNPName)
 		builder.SetTypeIngress()
 		if _, err := k8s.CreateOrUpdateNetworkPolicy(builder.Get()); err != nil {
 			return err
@@ -469,37 +471,25 @@ func testACNPSourcePort(t *testing.T) {
 
 	testSteps := []*TestStep{
 		{
-			"Port 80",
-			reachability,
-			[]metav1.Object{builder.Get()},
-			[]int32{80},
-			ProtocolTCP,
-			0,
-			nil,
-			nil,
-			nil,
+			Name:          "Port 80",
+			Reachability:  reachability,
+			TestResources: []metav1.Object{builder.Get()},
+			Ports:         []int32{80},
+			Protocol:      ProtocolTCP,
 		},
 		{
-			"Port 81",
-			updatedReachability,
-			[]metav1.Object{builder2.Get()},
-			[]int32{81},
-			ProtocolTCP,
-			0,
-			nil,
-			nil,
-			nil,
+			Name:          "Port 81",
+			Reachability:  updatedReachability,
+			TestResources: []metav1.Object{builder2.Get()},
+			Ports:         []int32{81},
+			Protocol:      ProtocolTCP,
 		},
 		{
-			"Port range 80-81",
-			reachability,
-			[]metav1.Object{builder3.Get()},
-			[]int32{80, 81},
-			ProtocolTCP,
-			0,
-			nil,
-			nil,
-			nil,
+			Name:          "Port range 80-81",
+			Reachability:  reachability,
+			TestResources: []metav1.Object{builder3.Get()},
+			Ports:         []int32{80, 81},
+			Protocol:      ProtocolTCP,
 		},
 	}
 	testCase := []*TestCase{
@@ -541,7 +531,7 @@ func testACNPAllowXBtoYA(t *testing.T) {
 // testACNPPriorityOverrideDefaultDeny tests priority override in ACNP. It applies a higher priority ACNP to drop
 // traffic from namespace Z to X/A, and in the meantime applies a lower priority ACNP to allow traffic from Z to X.
 // It is tested with default deny k8s NetworkPolicies in all namespaces.
-func testACNPPriorityOverrideDefaultDeny(t *testing.T) {
+func testACNPPriorityOverrideDefaultDeny(t *testing.T, data *TestData) {
 	builder1 := &ClusterNetworkPolicySpecBuilder{}
 	builder1 = builder1.SetName("acnp-priority2").
 		SetPriority(2).
@@ -566,10 +556,16 @@ func testACNPPriorityOverrideDefaultDeny(t *testing.T) {
 	reachabilityBothACNP.Expect(getPod("z", "c"), getPod("x", "c"), Connected)
 	reachabilityBothACNP.ExpectSelf(allPods, Connected)
 
+	evaluationBothACNPs := NewNPEvaluation(allPods).
+		Expect(getPod("y", "a"), getPod("x", "a"), defaultDenyKNPName, NPEvalIsolate).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder2.Name, NPEvalDrop).
+		Expect(getPod("z", "b"), getPod("x", "b"), builder1.Name, NPEvalAllow)
+
 	testStep := []*TestStep{
 		{
 			Name:          "Both ACNP",
 			Reachability:  reachabilityBothACNP,
+			NPEvaluation:  evaluationBothACNPs,
 			TestResources: []metav1.Object{builder1.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -578,7 +574,7 @@ func testACNPPriorityOverrideDefaultDeny(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP PriorityOverride Default Deny", testStep},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
 // testACNPAllowNoDefaultIsolation tests that no default isolation rules are created for Policies.
@@ -1645,7 +1641,7 @@ func testANNPNestedGroupCreateAndUpdate(t *testing.T, data *TestData) {
 
 // testBaselineNamespaceIsolation tests that an ACNP in the baseline Tier is able to enforce default namespace isolation,
 // which can be later overridden by developer K8s NetworkPolicies.
-func testBaselineNamespaceIsolation(t *testing.T) {
+func testBaselineNamespaceIsolation(t *testing.T, data *TestData) {
 	builder := &ClusterNetworkPolicySpecBuilder{}
 	nsExpOtherThanX := metav1.LabelSelectorRequirement{
 		Key:      "ns",
@@ -1664,6 +1660,13 @@ func testBaselineNamespaceIsolation(t *testing.T) {
 	reachability.ExpectNamespaceIngressFromNamespace(getNS("x"), getNS("y"), Dropped)
 	reachability.ExpectNamespaceIngressFromNamespace(getNS("x"), getNS("z"), Dropped)
 
+	evaluation := NewNPEvaluation(allPods).
+		Expect(getPod("y", "a"), getPod("x", "a"), builder.Name, NPEvalDrop).
+		Expect(getPod("y", "b"), getPod("x", "a"), builder.Name, NPEvalDrop).
+		Expect(getPod("z", "a"), getPod("x", "a"), builder.Name, NPEvalDrop).
+		ExpectNone(getPod("x", "b"), getPod("x", "a")).
+		ExpectNone(getPod("z", "b"), getPod("y", "b"))
+
 	// create a K8s NetworkPolicy for the x/a Pod to allow ingress traffic from the y/a Pod.
 	// It should open up ingress from y/a and additionally deny ingress from x/b and x/c based on
 	// Kubernetes NetworkPolicy default isolation model, since it's evaluated before the baseline tier.
@@ -1682,10 +1685,19 @@ func testBaselineNamespaceIsolation(t *testing.T) {
 	reachabilityUpdated.ExpectEgressToNamespace(getPod("y", "b"), getNS("x"), Dropped)
 	reachabilityUpdated.ExpectEgressToNamespace(getPod("y", "c"), getNS("x"), Dropped)
 	reachabilityUpdated.ExpectNamespaceIngressFromNamespace(getNS("x"), getNS("z"), Dropped)
+
+	evaluationUpdated := NewNPEvaluation(allPods).
+		Expect(getPod("y", "a"), getPod("x", "a"), k8sNPBuilder.Name, NPEvalAllow).
+		Expect(getPod("y", "b"), getPod("x", "a"), k8sNPBuilder.Name, NPEvalIsolate).
+		Expect(getPod("z", "a"), getPod("x", "a"), k8sNPBuilder.Name, NPEvalIsolate).
+		Expect(getPod("x", "b"), getPod("x", "a"), k8sNPBuilder.Name, NPEvalIsolate).
+		ExpectNone(getPod("z", "b"), getPod("y", "b"))
+
 	testStep := []*TestStep{
 		{
 			Name:          "Baseline ACNP",
 			Reachability:  reachability,
+			NPEvaluation:  evaluation,
 			TestResources: []metav1.Object{builder.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1693,6 +1705,7 @@ func testBaselineNamespaceIsolation(t *testing.T) {
 		{
 			Name:          "Baseline ACNP with KNP",
 			Reachability:  reachabilityUpdated,
+			NPEvaluation:  evaluationUpdated,
 			TestResources: []metav1.Object{builder.Get(), k8sNPBuilder.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1701,7 +1714,7 @@ func testBaselineNamespaceIsolation(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP baseline tier namespace isolation", testStep},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 	// Cleanup the K8s NetworkPolicy created for this test.
 	failOnError(k8sUtils.CleanNetworkPolicies(map[string]TestNamespaceMeta{"x": {Name: getNS("x")}}), t)
 	time.Sleep(networkPolicyDelay)
@@ -1709,7 +1722,7 @@ func testBaselineNamespaceIsolation(t *testing.T) {
 
 // testACNPPriorityOverride tests priority overriding in three ACNPs. Those three ACNPs are applied in a specific order to
 // test priority reassignment, and each controls a smaller set of traffic patterns as priority increases.
-func testACNPPriorityOverride(t *testing.T) {
+func testACNPPriorityOverride(t *testing.T, data *TestData) {
 	builder1 := &ClusterNetworkPolicySpecBuilder{}
 	builder1 = builder1.SetName("acnp-priority1").
 		SetPriority(1.001).
@@ -1751,10 +1764,22 @@ func testACNPPriorityOverride(t *testing.T) {
 	reachabilityAllACNPs.Expect(getPod("z", "c"), getPod("x", "b"), Dropped)
 	reachabilityAllACNPs.Expect(getPod("z", "c"), getPod("x", "c"), Dropped)
 
+	evaluationTwoACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder2.Name, NPEvalAllow).
+		Expect(getPod("z", "b"), getPod("x", "b"), builder3.Name, NPEvalDrop)
+
+	evaluationAllACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "a"), getPod("x", "a"), builder2.Name, NPEvalAllow).
+		Expect(getPod("z", "a"), getPod("x", "b"), builder3.Name, NPEvalDrop).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder1.Name, NPEvalDrop)
+
 	testStepTwoACNP := []*TestStep{
 		{
 			Name:          "Two Policies with different priorities",
 			Reachability:  reachabilityTwoACNPs,
+			NPEvaluation:  evaluationTwoACNPs,
 			TestResources: []metav1.Object{builder3.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1765,6 +1790,7 @@ func testACNPPriorityOverride(t *testing.T) {
 		{
 			Name:          "All three Policies",
 			Reachability:  reachabilityAllACNPs,
+			NPEvaluation:  evaluationAllACNPs,
 			TestResources: []metav1.Object{builder3.Get(), builder1.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1774,12 +1800,12 @@ func testACNPPriorityOverride(t *testing.T) {
 		{"ACNP PriorityOverride Intermediate", testStepTwoACNP},
 		{"ACNP PriorityOverride All", testStepAll},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
 // testACNPTierOverride tests tier priority overriding in three ACNPs. Each ACNP controls a smaller set of traffic patterns
 // as tier priority increases.
-func testACNPTierOverride(t *testing.T) {
+func testACNPTierOverride(t *testing.T, data *TestData) {
 	builder1 := &ClusterNetworkPolicySpecBuilder{}
 	builder1 = builder1.SetName("acnp-tier-emergency").
 		SetTier("emergency").
@@ -1824,10 +1850,22 @@ func testACNPTierOverride(t *testing.T) {
 	reachabilityAllACNPs.Expect(getPod("z", "c"), getPod("x", "b"), Dropped)
 	reachabilityAllACNPs.Expect(getPod("z", "c"), getPod("x", "c"), Dropped)
 
+	evaluationTwoACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder2.Name, NPEvalAllow).
+		Expect(getPod("z", "b"), getPod("x", "b"), builder3.Name, NPEvalDrop)
+
+	evaluationAllACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "a"), getPod("x", "a"), builder2.Name, NPEvalAllow).
+		Expect(getPod("z", "a"), getPod("x", "b"), builder3.Name, NPEvalDrop).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder1.Name, NPEvalDrop)
+
 	testStepTwoACNP := []*TestStep{
 		{
 			Name:          "Two Policies in different tiers",
 			Reachability:  reachabilityTwoACNPs,
+			NPEvaluation:  evaluationTwoACNPs,
 			TestResources: []metav1.Object{builder3.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1837,6 +1875,7 @@ func testACNPTierOverride(t *testing.T) {
 		{
 			Name:          "All three Policies in different tiers",
 			Reachability:  reachabilityAllACNPs,
+			NPEvaluation:  evaluationAllACNPs,
 			TestResources: []metav1.Object{builder3.Get(), builder1.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1846,12 +1885,12 @@ func testACNPTierOverride(t *testing.T) {
 		{"ACNP TierOverride Intermediate", testStepTwoACNP},
 		{"ACNP TierOverride All", testStepAll},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
-// testACNPTierOverride tests tier priority overriding in three ACNPs with custom created tiers. Each ACNP controls a
+// testACNPTierOverride tests tier priority overriding in two ACNPs with custom created tiers. Each ACNP controls a
 // smaller set of traffic patterns as tier priority increases.
-func testACNPCustomTiers(t *testing.T) {
+func testACNPCustomTiers(t *testing.T, data *TestData) {
 	k8sUtils.DeleteTier("high-priority")
 	k8sUtils.DeleteTier("low-priority")
 	// Create two custom tiers with tier priority immediately next to each other.
@@ -1885,10 +1924,17 @@ func testACNPCustomTiers(t *testing.T) {
 	reachabilityTwoACNPs.Expect(getPod("z", "b"), getPod("x", "c"), Dropped)
 	reachabilityTwoACNPs.Expect(getPod("z", "c"), getPod("x", "b"), Dropped)
 	reachabilityTwoACNPs.Expect(getPod("z", "c"), getPod("x", "c"), Dropped)
+
+	evaluationTwoACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "b"), getPod("x", "a"), builder1.Name, NPEvalAllow).
+		Expect(getPod("z", "b"), getPod("x", "b"), builder2.Name, NPEvalDrop)
+
 	testStepTwoACNP := []*TestStep{
 		{
 			Name:          "Two Policies in different tiers",
 			Reachability:  reachabilityTwoACNPs,
+			NPEvaluation:  evaluationTwoACNPs,
 			TestResources: []metav1.Object{builder2.Get(), builder1.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1897,7 +1943,7 @@ func testACNPCustomTiers(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP Custom Tier priority", testStepTwoACNP},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 	// Cleanup customized tiers. ACNPs created in those tiers need to be deleted first.
 	failOnError(k8sUtils.CleanACNPs(), t)
 	failOnError(k8sUtils.DeleteTier("high-priority"), t)
@@ -1906,7 +1952,7 @@ func testACNPCustomTiers(t *testing.T) {
 
 // testACNPPriorityConflictingRule tests that if there are two Policies in the cluster with rules that conflicts with
 // each other, the ACNP with higher priority will prevail.
-func testACNPPriorityConflictingRule(t *testing.T) {
+func testACNPPriorityConflictingRule(t *testing.T, data *TestData) {
 	builder1 := &ClusterNetworkPolicySpecBuilder{}
 	builder1 = builder1.SetName("acnp-drop").
 		SetPriority(1).
@@ -1927,10 +1973,16 @@ func testACNPPriorityConflictingRule(t *testing.T) {
 	reachabilityBothACNP.ExpectEgressToNamespace(getPod("z", "a"), getNS("x"), Dropped)
 	reachabilityBothACNP.ExpectEgressToNamespace(getPod("z", "b"), getNS("x"), Dropped)
 	reachabilityBothACNP.ExpectEgressToNamespace(getPod("z", "c"), getNS("x"), Dropped)
+
+	evaluationBothACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("y", "a"), getPod("x", "a")).
+		Expect(getPod("z", "a"), getPod("x", "a"), builder1.Name, NPEvalDrop)
+
 	testStep := []*TestStep{
 		{
 			Name:          "Both ACNP",
 			Reachability:  reachabilityBothACNP,
+			NPEvaluation:  evaluationBothACNPs,
 			TestResources: []metav1.Object{builder1.Get(), builder2.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1939,12 +1991,12 @@ func testACNPPriorityConflictingRule(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP Priority Conflicting Rule", testStep},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
 // testACNPRulePriority tests that if there are two rules in the cluster that conflicts with each other, the rule with
 // higher precedence will prevail.
-func testACNPRulePriority(t *testing.T) {
+func testACNPRulePriority(t *testing.T, data *TestData) {
 	builder1 := &ClusterNetworkPolicySpecBuilder{}
 	// acnp-deny will apply to all pods in namespace x
 	builder1 = builder1.SetName("acnp-deny").
@@ -1972,10 +2024,17 @@ func testACNPRulePriority(t *testing.T) {
 	reachabilityBothACNP.ExpectIngressFromNamespace(getPod("y", "a"), getNS("x"), Dropped)
 	reachabilityBothACNP.ExpectIngressFromNamespace(getPod("y", "b"), getNS("x"), Dropped)
 	reachabilityBothACNP.ExpectIngressFromNamespace(getPod("y", "c"), getNS("x"), Dropped)
+
+	evaluationBothACNPs := NewNPEvaluation(allPods).
+		ExpectNone(getPod("x", "b"), getPod("x", "a")).
+		Expect(getPod("x", "a"), getPod("y", "a"), builder1.Name, NPEvalDrop).
+		Expect(getPod("x", "a"), getPod("z", "a"), builder2.Name, NPEvalAllow)
+
 	testStep := []*TestStep{
 		{
 			Name:          "Both ACNP",
 			Reachability:  reachabilityBothACNP,
+			NPEvaluation:  evaluationBothACNPs,
 			TestResources: []metav1.Object{builder2.Get(), builder1.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -1984,7 +2043,7 @@ func testACNPRulePriority(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP Rule Priority", testStep},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
 // testACNPPortRange tests the port range in an ACNP can work.
@@ -2018,7 +2077,7 @@ func testACNPPortRange(t *testing.T) {
 }
 
 // testACNPRejectEgress tests that an ACNP is able to reject egress traffic from pods labelled A to namespace Z.
-func testACNPRejectEgress(t *testing.T) {
+func testACNPRejectEgress(t *testing.T, data *TestData) {
 	builder := &ClusterNetworkPolicySpecBuilder{}
 	builder = builder.SetName("acnp-reject-a-to-z-egress").
 		SetPriority(1.0).
@@ -2031,10 +2090,16 @@ func testACNPRejectEgress(t *testing.T) {
 	reachability.ExpectEgressToNamespace(getPod("y", "a"), getNS("z"), Rejected)
 	reachability.Expect(getPod("z", "a"), getPod("z", "b"), Rejected)
 	reachability.Expect(getPod("z", "a"), getPod("z", "c"), Rejected)
+
+	evaluation := NewNPEvaluation(allPods).
+		ExpectNone(getPod("x", "b"), getPod("z", "a")).
+		Expect(getPod("x", "a"), getPod("z", "a"), builder.Name, NPEvalReject)
+
 	testStep := []*TestStep{
 		{
 			Name:          "Port 80",
 			Reachability:  reachability,
+			NPEvaluation:  evaluation,
 			TestResources: []metav1.Object{builder.Get()},
 			Ports:         []int32{80},
 			Protocol:      ProtocolTCP,
@@ -2043,7 +2108,7 @@ func testACNPRejectEgress(t *testing.T) {
 	testCase := []*TestCase{
 		{"ACNP Reject egress From All Pod:a to NS:z", testStep},
 	}
-	executeTests(t, testCase)
+	executeTestsWithData(t, testCase, data)
 }
 
 // testACNPRejectIngress tests that an ACNP is able to reject ingress traffic from pods labelled A to namespace Z.
@@ -2343,7 +2408,7 @@ func testANNPBasic(t *testing.T) {
 // update on the Antrea NetworkPolicy allows traffic from X/B to Y/A on port 80.
 func testANNPUpdate(t *testing.T, data *TestData) {
 	builder := &AntreaNetworkPolicySpecBuilder{}
-	builder = builder.SetName(getNS("y"), "np-before-update").
+	builder = builder.SetName(getNS("y"), "np-update").
 		SetPriority(1.0).
 		SetAppliedToGroup([]ANNPAppliedToSpec{{PodSelector: map[string]string{"pod": "a"}}})
 	builder.AddIngress(ProtocolTCP, &p80, nil, nil, nil, nil, nil, nil, nil, nil, map[string]string{"pod": "b"}, map[string]string{"ns": getNS("x")}, nil,
@@ -2351,6 +2416,9 @@ func testANNPUpdate(t *testing.T, data *TestData) {
 
 	reachability := NewReachability(allPods, Connected)
 	reachability.Expect(getPod("x", "b"), getPod("y", "a"), Dropped)
+	evaluation := NewNPEvaluation(allPods).
+		ExpectNone(getPod("x", "a"), getPod("y", "a")).
+		Expect(getPod("x", "b"), getPod("y", "a"), builder.Name, NPEvalDrop)
 	annp, err := k8sUtils.CreateOrUpdateANNP(builder.Get())
 	failOnError(err, t)
 	failOnError(data.waitForANNPRealized(t, annp.Namespace, annp.Name, policyRealizedTimeout), t)
@@ -2360,14 +2428,20 @@ func testANNPUpdate(t *testing.T, data *TestData) {
 		t.Errorf("Failure -- %d wrong results", wrong)
 		reachability.PrintSummary(true, true, true)
 	}
+	if testOptions.npEvaluation {
+		doEvaluation(t, data, evaluation)
+	}
 
 	updatedBuilder := &AntreaNetworkPolicySpecBuilder{}
-	updatedBuilder = updatedBuilder.SetName(getNS("y"), "np-before-update").
+	updatedBuilder = updatedBuilder.SetName(getNS("y"), "np-update").
 		SetPriority(1.0).
 		SetAppliedToGroup([]ANNPAppliedToSpec{{PodSelector: map[string]string{"pod": "a"}}})
 	updatedBuilder.AddIngress(ProtocolTCP, &p80, nil, nil, nil, nil, nil, nil, nil, nil, map[string]string{"pod": "b"}, map[string]string{"ns": getNS("x")}, nil,
 		nil, nil, nil, nil, crdv1beta1.RuleActionAllow, "", "")
 	updatedReachability := NewReachability(allPods, Connected)
+	updatedEvaluation := NewNPEvaluation(allPods).
+		ExpectNone(getPod("x", "a"), getPod("y", "a")).
+		Expect(getPod("x", "b"), getPod("y", "a"), builder.Name, NPEvalAllow)
 	annp, err = k8sUtils.CreateOrUpdateANNP(updatedBuilder.Get())
 	failOnError(err, t)
 	failOnError(data.waitForANNPRealized(t, annp.Namespace, annp.Name, policyRealizedTimeout), t)
@@ -2377,6 +2451,10 @@ func testANNPUpdate(t *testing.T, data *TestData) {
 		t.Errorf("Failure -- %d wrong results", wrong)
 		reachability.PrintSummary(true, true, true)
 	}
+	if testOptions.npEvaluation {
+		doEvaluation(t, data, updatedEvaluation)
+	}
+
 	failOnError(k8sUtils.DeleteANNP(annp.Namespace, annp.Name), t)
 }
 
@@ -2402,6 +2480,9 @@ func testANNPMultipleAppliedTo(t *testing.T, data *TestData, singleRule bool) {
 
 	reachability := NewReachability(allPods, Connected)
 	reachability.Expect(getPod("x", "b"), getPod("y", "a"), Dropped)
+	evaluation := NewNPEvaluation(allPods).
+		ExpectNone(getPod("x", "b"), getPod("y", "c")).
+		Expect(getPod("x", "b"), getPod("y", "a"), builder.Name, NPEvalDrop)
 
 	annp, err := k8sUtils.CreateOrUpdateANNP(builder.Get())
 	failOnError(err, t)
@@ -2412,6 +2493,9 @@ func testANNPMultipleAppliedTo(t *testing.T, data *TestData, singleRule bool) {
 		t.Errorf("Failure -- %d wrong results", wrong)
 		reachability.PrintSummary(true, true, true)
 	}
+	if testOptions.npEvaluation {
+		doEvaluation(t, data, evaluation)
+	}
 
 	t.Logf("Making the Policy apply to y/c by labeling it with the temporary label that matches the dummy AppliedTo")
 	podYC, err := k8sUtils.GetPodByLabel(getNS("y"), "c")
@@ -2424,6 +2508,9 @@ func testANNPMultipleAppliedTo(t *testing.T, data *TestData, singleRule bool) {
 	reachability = NewReachability(allPods, Connected)
 	reachability.Expect(getPod("x", "b"), getPod("y", "a"), Dropped)
 	reachability.Expect(getPod("x", "b"), getPod("y", "c"), Dropped)
+	updatedEvaluation := NewNPEvaluation(allPods).
+		Expect(getPod("x", "b"), getPod("y", "c"), builder.Name, NPEvalDrop).
+		Expect(getPod("x", "b"), getPod("y", "a"), builder.Name, NPEvalDrop)
 	time.Sleep(networkPolicyDelay)
 	k8sUtils.Validate(allPods, reachability, []int32{80}, ProtocolTCP)
 	_, wrong, _ = reachability.Summary()
@@ -2431,6 +2518,9 @@ func testANNPMultipleAppliedTo(t *testing.T, data *TestData, singleRule bool) {
 		t.Errorf("Failure -- %d wrong results", wrong)
 		reachability.PrintSummary(true, true, true)
 	}
+	if testOptions.npEvaluation {
+		doEvaluation(t, data, updatedEvaluation)
+	}
 
 	t.Logf("Making the Policy not apply to y/c by removing the temporary label")
 	delete(podYC.Labels, tempLabel)
@@ -2445,6 +2535,9 @@ func testANNPMultipleAppliedTo(t *testing.T, data *TestData, singleRule bool) {
 		t.Errorf("Failure -- %d wrong results", wrong)
 		reachability.PrintSummary(true, true, true)
 	}
+	if testOptions.npEvaluation {
+		doEvaluation(t, data, evaluation)
+	}
 
 	failOnError(k8sUtils.DeleteANNP(builder.Namespace, builder.Name), t)
 }
@@ -4127,9 +4220,9 @@ func executeTests(t *testing.T, testList []*TestCase) {
 
 func executeTestsWithData(t *testing.T, testList []*TestCase, data *TestData) {
 	for _, testCase := range testList {
-		log.Infof("running test case %s", testCase.Name)
+		log.Infof("Running test case %s", testCase.Name)
 		for _, step := range testCase.Steps {
-			log.Infof("running step %s of test case %s", step.Name, testCase.Name)
+			log.Infof("Running step %s of test case %s", step.Name, testCase.Name)
 			applyTestStepResources(t, step)
 			if step.CustomSetup != nil {
 				step.CustomSetup()
@@ -4156,6 +4249,13 @@ func executeTestsWithData(t *testing.T, testList []*TestCase, data *TestData) {
 			if step.CustomTeardown != nil {
 				step.CustomTeardown()
 			}
+			if step.NPEvaluation != nil {
+				if data == nil {
+					t.Errorf("test case %s with networkpolicyevaluation must set test data", testCase.Name)
+				} else if testOptions.npEvaluation {
+					doEvaluation(t, data, step.NPEvaluation)
+				}
+			}
 		}
 		log.Debug("Cleaning-up all policies and groups created by this Testcase")
 		cleanupTestCaseResources(t, testCase)
@@ -4180,6 +4280,43 @@ func doProbe(t *testing.T, data *TestData, p *CustomProbe, protocol AntreaPolicy
 	}
 }
 
+func doEvaluation(t *testing.T, data *TestData, npEvaluation *NPEvaluation) {
+	log.Infof("Running networkpolicyevaluation tests")
+	antreaController, err := data.getAntreaController()
+	require.NoError(t, err, "Error occurred when trying to get the Antrea Controller")
+	antctlArgs := []string{"antctl", "query", "networkpolicyevaluation"}
+	getRealizedPod := func(label Pod) string {
+		realizedPod, err := k8sUtils.GetPodByLabel(label.Namespace(), label.PodName())
+		require.NoError(t, err, "Failed to get Pod %s", label.String())
+		return realizedPod.Namespace + "/" + realizedPod.Name
+	}
+	processResponse := func(stdout string) map[string]string {
+		// the first row is the header row, the second row contains the actual value
+		rows := strings.Split(strings.TrimSpace(stdout), "\n")
+		require.Len(t, rows, 2)
+		headerFields := strings.Fields(rows[0])
+		valueFields := strings.Fields(rows[1])
+		require.Equal(t, len(headerFields), len(valueFields))
+		antctlData := make(map[string]string)
+		for idx, name := range headerFields {
+			antctlData[name] = valueFields[idx]
+		}
+		return antctlData
+	}
+
+	for _, eval := range npEvaluation.Assertions {
+		cmds := append(antctlArgs, "-S", getRealizedPod(eval.Source), "-D", getRealizedPod(eval.Destination))
+		log.Tracef("Running command: %s", strings.Join(cmds, " "))
+		stdout, stderr, err := data.RunCommandFromPod(antreaNamespace, antreaController.Name, "antrea-controller", cmds)
+		if !assert.NoError(t, err, "Failure -- Command was not successful:\n%s", antctlOutput(stdout, stderr)) {
+			continue
+		}
+		gotEval := processResponse(stdout)
+		assert.Equal(t, eval.NPName, gotEval["NAME"], "Failure -- Policy name mismatch in NetworkPolicyEvaluation response")
+		assert.EqualValues(t, eval.Action, gotEval["ACTION"], "Failure -- Policy rule action mismatch in NetworkPolicyEvaluation response")
+	}
+}
+
 // applyTestStepResources creates in the resources of a testStep in specified order.
 // The ordering can be used to test different scenarios, like creating an ACNP before
 // creating its referred ClusterGroup, and vice versa.
@@ -4379,7 +4516,7 @@ func TestAntreaPolicy(t *testing.T) {
 		applyDefaultDenyToAllNamespaces(k8sUtils, namespaces)
 		t.Run("Case=ACNPAllowXBtoA", func(t *testing.T) { testACNPAllowXBtoA(t) })
 		t.Run("Case=ACNPAllowXBtoYA", func(t *testing.T) { testACNPAllowXBtoYA(t) })
-		t.Run("Case=ACNPPriorityOverrideDefaultDeny", func(t *testing.T) { testACNPPriorityOverrideDefaultDeny(t) })
+		t.Run("Case=ACNPPriorityOverrideDefaultDeny", func(t *testing.T) { testACNPPriorityOverrideDefaultDeny(t, data) }) // Includes evaluation.
 		cleanupDefaultDenyNPs(k8sUtils, namespaces)
 	})
 
@@ -4394,23 +4531,23 @@ func TestAntreaPolicy(t *testing.T) {
 		t.Run("Case=ACNPDropIngressInNamespace", func(t *testing.T) { testACNPDropIngressInSelectedNamespace(t) })
 		t.Run("Case=ACNPPortRange", func(t *testing.T) { testACNPPortRange(t) })
 		t.Run("Case=ACNPSourcePort", func(t *testing.T) { testACNPSourcePort(t) })
-		t.Run("Case=ACNPRejectEgress", func(t *testing.T) { testACNPRejectEgress(t) })
+		t.Run("Case=ACNPRejectEgress", func(t *testing.T) { testACNPRejectEgress(t, data) }) // Includes evaluation.
 		t.Run("Case=ACNPRejectIngress", func(t *testing.T) { testACNPRejectIngress(t, ProtocolTCP) })
 		t.Run("Case=ACNPRejectIngressUDP", func(t *testing.T) { testACNPRejectIngress(t, ProtocolUDP) })
 		t.Run("Case=RejectServiceTraffic", func(t *testing.T) { testRejectServiceTraffic(t, data, data.testNamespace, data.testNamespace) })
 		t.Run("Case=RejectNoInfiniteLoop", func(t *testing.T) { testRejectNoInfiniteLoop(t, data, data.testNamespace, data.testNamespace) })
 		t.Run("Case=ACNPNoEffectOnOtherProtocols", func(t *testing.T) { testACNPNoEffectOnOtherProtocols(t) })
-		t.Run("Case=ACNPBaselinePolicy", func(t *testing.T) { testBaselineNamespaceIsolation(t) })
-		t.Run("Case=ACNPPriorityOverride", func(t *testing.T) { testACNPPriorityOverride(t) })
-		t.Run("Case=ACNPTierOverride", func(t *testing.T) { testACNPTierOverride(t) })
-		t.Run("Case=ACNPCustomTiers", func(t *testing.T) { testACNPCustomTiers(t) })
-		t.Run("Case=ACNPPriorityConflictingRule", func(t *testing.T) { testACNPPriorityConflictingRule(t) })
-		t.Run("Case=ACNPRulePriority", func(t *testing.T) { testACNPRulePriority(t) })
+		t.Run("Case=ACNPBaselinePolicy", func(t *testing.T) { testBaselineNamespaceIsolation(t, data) })           // Includes evaluation.
+		t.Run("Case=ACNPPriorityOverride", func(t *testing.T) { testACNPPriorityOverride(t, data) })               // Includes evaluation.
+		t.Run("Case=ACNPTierOverride", func(t *testing.T) { testACNPTierOverride(t, data) })                       // Includes evaluation.
+		t.Run("Case=ACNPCustomTiers", func(t *testing.T) { testACNPCustomTiers(t, data) })                         // Includes evaluation.
+		t.Run("Case=ACNPPriorityConflictingRule", func(t *testing.T) { testACNPPriorityConflictingRule(t, data) }) // Includes evaluation.
+		t.Run("Case=ACNPRulePriority", func(t *testing.T) { testACNPRulePriority(t, data) })                       // Includes evaluation.
 		t.Run("Case=ANNPPortRange", func(t *testing.T) { testANNPPortRange(t) })
 		t.Run("Case=ANNPBasic", func(t *testing.T) { testANNPBasic(t) })
-		t.Run("Case=ANNPUpdate", func(t *testing.T) { testANNPUpdate(t, data) })
-		t.Run("Case=testANNPMultipleAppliedToSingleRule", func(t *testing.T) { testANNPMultipleAppliedTo(t, data, true) })
-		t.Run("Case=testANNPMultipleAppliedToMultipleRules", func(t *testing.T) { testANNPMultipleAppliedTo(t, data, false) })
+		t.Run("Case=ANNPUpdate", func(t *testing.T) { testANNPUpdate(t, data) })                                               // Includes evaluation.
+		t.Run("Case=testANNPMultipleAppliedToSingleRule", func(t *testing.T) { testANNPMultipleAppliedTo(t, data, true) })     // Includes evaluation.
+		t.Run("Case=testANNPMultipleAppliedToMultipleRules", func(t *testing.T) { testANNPMultipleAppliedTo(t, data, false) }) // Includes evaluation.
 		t.Run("Case=AppliedToPerRule", func(t *testing.T) { testAppliedToPerRule(t) })
 		t.Run("Case=ACNPNamespaceIsolation", func(t *testing.T) { testACNPNamespaceIsolation(t) })
 		t.Run("Case=ACNPStrictNamespaceIsolation", func(t *testing.T) { testACNPStrictNamespacesIsolation(t) })
diff --git a/test/e2e/fixtures.go b/test/e2e/fixtures.go
index 71fa87275..f29615371 100644
--- a/test/e2e/fixtures.go
+++ b/test/e2e/fixtures.go
@@ -159,6 +159,16 @@ func skipIfNoVMs(tb testing.TB) {
 	}
 }
 
+func skipIfMulticastEnabled(tb testing.TB, data *TestData) {
+	agentConf, err := data.GetAntreaAgentConf()
+	if err != nil {
+		tb.Fatalf("Error getting option multicast.enable value")
+	}
+	if agentConf.Multicast.Enable {
+		tb.Skipf("Skipping test because option multicast.enable is true")
+	}
+}
+
 func skipIfFeatureDisabled(tb testing.TB, feature featuregate.Feature, checkAgent bool, checkController bool) {
 	if checkAgent {
 		if featureGate, err := GetAgentFeatures(); err != nil {
@@ -675,6 +685,7 @@ func testMain(m *testing.M) int {
 	flag.BoolVar(&testOptions.enableCoverage, "coverage", false, "Run tests and measure coverage")
 	flag.BoolVar(&testOptions.enableAntreaIPAM, "antrea-ipam", false, "Run tests with AntreaIPAM")
 	flag.BoolVar(&testOptions.flowVisibility, "flow-visibility", false, "Run flow visibility tests")
+	flag.BoolVar(&testOptions.npEvaluation, "networkpolicy-evaluation", false, "Run NetworkPolicy evaluation tests")
 	flag.BoolVar(&testOptions.deployAntrea, "deploy-antrea", true, "Deploy Antrea before running tests")
 	flag.StringVar(&testOptions.coverageDir, "coverage-dir", "", "Directory for coverage data files")
 	flag.StringVar(&testOptions.skipCases, "skip-cases", "", "Key words to skip cases")
diff --git a/test/e2e/framework.go b/test/e2e/framework.go
index 140555f00..c72a99078 100644
--- a/test/e2e/framework.go
+++ b/test/e2e/framework.go
@@ -209,6 +209,7 @@ type TestOptions struct {
 	enableCoverage      bool
 	enableAntreaIPAM    bool
 	flowVisibility      bool
+	npEvaluation        bool
 	coverageDir         string
 	skipCases           string
 	linuxVMs            string
diff --git a/test/e2e/ipsec_test.go b/test/e2e/ipsec_test.go
index b972f18ec..931b8e49f 100644
--- a/test/e2e/ipsec_test.go
+++ b/test/e2e/ipsec_test.go
@@ -42,6 +42,7 @@ func TestIPSec(t *testing.T) {
 		t.Fatalf("Error when setting up test: %v", err)
 	}
 	defer teardownTest(t, data)
+	skipIfMulticastEnabled(t, data)
 
 	t.Logf("Redeploy Antrea with IPsec tunnel enabled")
 	data.redeployAntrea(t, deployAntreaIPsec)
diff --git a/test/e2e/k8s_util.go b/test/e2e/k8s_util.go
index a614f83b1..d665cce4c 100644
--- a/test/e2e/k8s_util.go
+++ b/test/e2e/k8s_util.go
@@ -64,6 +64,7 @@ type TestCase struct {
 type TestStep struct {
 	Name           string
 	Reachability   *Reachability
+	NPEvaluation   *NPEvaluation
 	TestResources  []metav1.Object
 	Ports          []int32
 	Protocol       utils.AntreaPolicyProtocol
diff --git a/test/e2e/nodeportlocal_test.go b/test/e2e/nodeportlocal_test.go
index 6aa801c38..21bb85d5a 100644
--- a/test/e2e/nodeportlocal_test.go
+++ b/test/e2e/nodeportlocal_test.go
@@ -95,7 +95,7 @@ func TestNodePortLocal(t *testing.T) {
 	t.Run("testNPLChangePortRangeAgentRestart", func(t *testing.T) { testNPLChangePortRangeAgentRestart(t, data) })
 }
 
-func getNPLAnnotations(t *testing.T, data *TestData, r *require.Assertions, testPodName string, conditionFn func(types.NPLAnnotation) bool) ([]types.NPLAnnotation, string) {
+func getNPLAnnotations(t *testing.T, data *TestData, r *require.Assertions, testPodName string, conditionFn func([]types.NPLAnnotation) bool) ([]types.NPLAnnotation, string) {
 	var nplAnnotations []types.NPLAnnotation
 	var testPodIP *PodIPs
 
@@ -135,12 +135,8 @@ func getNPLAnnotations(t *testing.T, data *TestData, r *require.Assertions, test
 				return false, nil
 			}
 			json.Unmarshal([]byte(nplAnn), &nplAnnotations)
-			if conditionFn != nil {
-				for _, annotation := range nplAnnotations {
-					if !conditionFn(annotation) {
-						return false, nil
-					}
-				}
+			if conditionFn != nil && !conditionFn(nplAnnotations) {
+				return false, nil
 			}
 			return found, nil
 		})
@@ -495,13 +491,11 @@ func NPLTestPodAddMultiProtocol(t *testing.T, data *TestData) {
 		Add(nil, 8080, "tcp").Add(nil, 8080, "udp")
 
 	// Creating a Pod using agnhost image to support multiple protocols, instead of nginx.
-	cmd := []string{"/bin/bash", "-c"}
-	args := []string{
-		fmt.Sprintf("/agnhost serve-hostname --udp --http=false --port %d & /agnhost serve-hostname --tcp --http=false --port %d", 8080, 8080),
-	}
+
+	args := []string{"serve-hostname", "--tcp", "--udp", "--http=false", "--port=8080"}
 	port := corev1.ContainerPort{ContainerPort: 8080}
 	containerName := fmt.Sprintf("c%v", 8080)
-	err := NewPodBuilder(testPodName, data.testNamespace, agnhostImage).OnNode(serverNode).WithContainerName(containerName).WithCommand(cmd).WithArgs(args).WithPorts([]corev1.ContainerPort{port}).WithLabels(selector).Create(testData)
+	err := NewPodBuilder(testPodName, data.testNamespace, agnhostImage).OnNode(serverNode).WithContainerName(containerName).WithArgs(args).WithPorts([]corev1.ContainerPort{port}).WithLabels(selector).Create(testData)
 	r.NoError(err, "Error creating test Pod: %v", err)
 
 	nplAnnotations, testPodIP := getNPLAnnotations(t, testData, r, testPodName, nil)
@@ -517,14 +511,26 @@ func NPLTestPodAddMultiProtocol(t *testing.T, data *TestData) {
 	r.NoError(err, "Error when getting Antrea Agent Pod on Node '%s'", serverNode)
 
 	checkNPLRules(t, testData, r, nplAnnotations, antreaPod, testPodIP, serverNode, true)
+	expectedAnnotations.Check(t, nplAnnotations)
+	checkTrafficForNPL(testData, r, nplAnnotations, clientName)
+
+	// We now delete one of the Services, and we expect the corresponding NPL rule to be deleted.
+	testData.DeleteService(data.testNamespace, "agnhost2")
+	expectedAnnotations = newExpectedNPLAnnotations(defaultStartPort, defaultEndPort).
+		Add(nil, 8080, "tcp")
+	// Wait until we have only one NPL rule annotation.
+	conditionFn := func(annotations []types.NPLAnnotation) bool {
+		return len(annotations) == 1
+	}
+	nplAnnotations, testPodIP = getNPLAnnotations(t, testData, r, testPodName, conditionFn)
 
+	checkNPLRules(t, testData, r, nplAnnotations, antreaPod, testPodIP, serverNode, true)
 	expectedAnnotations.Check(t, nplAnnotations)
 	checkTrafficForNPL(testData, r, nplAnnotations, clientName)
 
 	testData.DeletePod(data.testNamespace, clientName)
 	testData.DeletePod(data.testNamespace, testPodName)
 	testData.DeleteService(data.testNamespace, "agnhost1")
-	testData.DeleteService(data.testNamespace, "agnhost2")
 	checkNPLRules(t, testData, r, nplAnnotations, antreaPod, testPodIP, serverNode, false)
 }
 
@@ -697,8 +703,11 @@ func testNPLChangePortRangeAgentRestart(t *testing.T, data *TestData) {
 	}
 
 	for _, testPodName := range testPods {
-		conditionFn := func(ann types.NPLAnnotation) bool {
-			return ann.NodePort >= updatedStartPort
+		conditionFn := func(annotations []types.NPLAnnotation) bool {
+			for idx := range annotations {
+				return annotations[idx].NodePort >= updatedStartPort
+			}
+			return true
 		}
 		nplAnnotations, testPodIP := getNPLAnnotations(t, data, r, testPodName, conditionFn)
 
diff --git a/test/e2e/proxy_test.go b/test/e2e/proxy_test.go
index e354ac4e8..9f87aa126 100644
--- a/test/e2e/proxy_test.go
+++ b/test/e2e/proxy_test.go
@@ -155,15 +155,10 @@ func testProxyLoadBalancerService(t *testing.T, isIPv6 bool) {
 
 	// Create a toolbox Pod on every Node. The toolbox Pod is used as a client.
 	nodes := []string{nodeName(0), nodeName(1)}
-	var toolboxes, toolboxIPs []string
+	var toolboxes []string
 	for idx, node := range nodes {
-		podName, ips, _ := createAndWaitForPod(t, data, data.createToolboxPodOnNode, fmt.Sprintf("toolbox-%d-", idx), node, data.testNamespace, false)
+		podName, _, _ := createAndWaitForPod(t, data, data.createToolboxPodOnNode, fmt.Sprintf("toolbox-%d-", idx), node, data.testNamespace, false)
 		toolboxes = append(toolboxes, podName)
-		if !isIPv6 {
-			toolboxIPs = append(toolboxIPs, ips.IPv4.String())
-		} else {
-			toolboxIPs = append(toolboxIPs, ips.IPv6.String())
-		}
 	}
 
 	clusterIngressIP := []string{"169.254.169.1"}
@@ -209,7 +204,7 @@ func testProxyLoadBalancerService(t *testing.T, isIPv6 bool) {
 		createAgnhostPod(t, data, agnhosts[idx], node, false)
 	}
 	t.Run("Non-HostNetwork Endpoints", func(t *testing.T) {
-		loadBalancerTestCases(t, data, clusterUrl, localUrl, healthExpected, nodes, healthUrls, toolboxes, toolboxIPs, agnhosts)
+		loadBalancerTestCases(t, data, clusterUrl, localUrl, healthExpected, nodes, healthUrls, toolboxes)
 	})
 
 	// Delete agnhost Pods which are not on host network and create new agnhost Pods which are on host network.
@@ -219,11 +214,11 @@ func testProxyLoadBalancerService(t *testing.T, isIPv6 bool) {
 		createAgnhostPod(t, data, hostAgnhosts[idx], node, true)
 	}
 	t.Run("HostNetwork Endpoints", func(t *testing.T) {
-		loadBalancerTestCases(t, data, clusterUrl, localUrl, healthExpected, nodes, healthUrls, toolboxes, toolboxIPs, nodes)
+		loadBalancerTestCases(t, data, clusterUrl, localUrl, healthExpected, nodes, healthUrls, toolboxes)
 	})
 }
 
-func loadBalancerTestCases(t *testing.T, data *TestData, clusterUrl, localUrl, healthExpected string, nodes, healthUrls, pods, podIPs, hostnames []string) {
+func loadBalancerTestCases(t *testing.T, data *TestData, clusterUrl, localUrl, healthExpected string, nodes, healthUrls, pods []string) {
 	t.Run("ExternalTrafficPolicy:Cluster/Client:Node", func(t *testing.T) {
 		testLoadBalancerClusterFromNode(t, data, nodes, clusterUrl)
 	})
@@ -259,7 +254,7 @@ func testLoadBalancerLocalFromNode(t *testing.T, data *TestData, nodes, healthUr
 		for _, healthUrl := range healthUrls {
 			healthOutput, _, err := probeHealthFromNode(node, healthUrl, data)
 			require.NoError(t, err, "Service LoadBalancer whose externalTrafficPolicy is Local should have a response for healthcheck")
-			require.Equal(t, healthOutput, healthExpected)
+			require.Equal(t, healthExpected, healthOutput)
 		}
 	}
 }
@@ -302,15 +297,10 @@ func testProxyNodePortService(t *testing.T, isIPv6 bool) {
 	}
 
 	// Create a toolbox Pod on every Node. The toolbox Pod is used as a client.
-	var toolboxes, toolboxIPs []string
+	var toolboxes []string
 	for idx, node := range nodes {
-		podName, ips, _ := createAndWaitForPod(t, data, data.createToolboxPodOnNode, fmt.Sprintf("toolbox-%d-", idx), node, data.testNamespace, false)
+		podName, _, _ := createAndWaitForPod(t, data, data.createToolboxPodOnNode, fmt.Sprintf("toolbox-%d-", idx), node, data.testNamespace, false)
 		toolboxes = append(toolboxes, podName)
-		if !isIPv6 {
-			toolboxIPs = append(toolboxIPs, ips.IPv4.String())
-		} else {
-			toolboxIPs = append(toolboxIPs, ips.IPv6.String())
-		}
 	}
 
 	// Create two NodePort Services. The externalTrafficPolicy of one Service is Cluster, and the externalTrafficPolicy
@@ -341,7 +331,7 @@ func testProxyNodePortService(t *testing.T, isIPv6 bool) {
 		createAgnhostPod(t, data, agnhosts[idx], node, false)
 	}
 	t.Run("Non-HostNetwork Endpoints", func(t *testing.T) {
-		nodePortTestCases(t, data, portCluster, portLocal, nodes, nodeIPs, toolboxes, toolboxIPs, agnhosts, false)
+		nodePortTestCases(t, data, portCluster, portLocal, nodes, nodeIPs, toolboxes, agnhosts, false)
 	})
 
 	// Delete agnhost Pods which are not on host network and create new agnhost Pods which are on host network.
@@ -351,11 +341,11 @@ func testProxyNodePortService(t *testing.T, isIPv6 bool) {
 		createAgnhostPod(t, data, hostAgnhosts[idx], node, true)
 	}
 	t.Run("HostNetwork Endpoints", func(t *testing.T) {
-		nodePortTestCases(t, data, portCluster, portLocal, nodes, nodeIPs, toolboxes, toolboxIPs, nodes, true)
+		nodePortTestCases(t, data, portCluster, portLocal, nodes, nodeIPs, toolboxes, nodes, true)
 	})
 }
 
-func nodePortTestCases(t *testing.T, data *TestData, portStrCluster, portStrLocal string, nodes, nodeIPs, pods, podIPs, hostnames []string, hostNetwork bool) {
+func nodePortTestCases(t *testing.T, data *TestData, portStrCluster, portStrLocal string, nodes, nodeIPs, pods, hostnames []string, hostNetwork bool) {
 	var clusterUrls, localUrls []string
 	for _, nodeIP := range nodeIPs {
 		clusterUrls = append(clusterUrls, net.JoinHostPort(nodeIP, portStrCluster))
diff --git a/test/e2e/reachability.go b/test/e2e/reachability.go
index 48166588a..27bea99c8 100644
--- a/test/e2e/reachability.go
+++ b/test/e2e/reachability.go
@@ -381,3 +381,51 @@ func (r *Reachability) PrintSummary(printExpected bool, printObserved bool, prin
 		fmt.Printf("comparison:\n\n%s\n\n\n", comparison.PrettyPrint(""))
 	}
 }
+
+type NPEvalActionType string
+
+const (
+	NPEvalNone    NPEvalActionType = "<NONE>"
+	NPEvalAllow   NPEvalActionType = "Allow"
+	NPEvalDrop    NPEvalActionType = "Drop"
+	NPEvalIsolate NPEvalActionType = "Isolate"
+	NPEvalReject  NPEvalActionType = "Reject"
+)
+
+type NPEvaluationSpec struct {
+	Source      Pod
+	Destination Pod
+	NPName      string
+	Action      NPEvalActionType
+}
+
+type NPEvaluation struct {
+	itemSet    map[string]bool
+	Assertions []*NPEvaluationSpec
+}
+
+func NewNPEvaluation(pods []Pod) *NPEvaluation {
+	itemSet := map[string]bool{}
+	for _, pod := range pods {
+		itemSet[pod.String()] = true
+	}
+	return &NPEvaluation{
+		itemSet:    itemSet,
+		Assertions: []*NPEvaluationSpec{},
+	}
+}
+
+func (e *NPEvaluation) Expect(from, to Pod, npName string, action NPEvalActionType) *NPEvaluation {
+	if _, ok := e.itemSet[from.String()]; !ok {
+		panic(fmt.Errorf("key %s not allowed", from))
+	}
+	if _, ok := e.itemSet[to.String()]; !ok {
+		panic(fmt.Errorf("key %s not allowed", to))
+	}
+	e.Assertions = append(e.Assertions, &NPEvaluationSpec{from, to, npName, action})
+	return e
+}
+
+func (e *NPEvaluation) ExpectNone(from Pod, to Pod) *NPEvaluation {
+	return e.Expect(from, to, string(NPEvalNone), NPEvalNone)
+}
diff --git a/test/e2e/traceflow_test.go b/test/e2e/traceflow_test.go
index f8df54783..dff030cc0 100644
--- a/test/e2e/traceflow_test.go
+++ b/test/e2e/traceflow_test.go
@@ -115,12 +115,20 @@ func testTraceflowIntraNodeANNP(t *testing.T, data *TestData) {
 		nodeIdx = clusterInfo.windowsNodes[0]
 	}
 	node1 := nodeName(nodeIdx)
-	node1Pods, _, node1CleanupFn := createTestAgnhostPods(t, data, 3, data.testNamespace, node1)
+	node1Pods, node1PodIPs, node1CleanupFn := createTestAgnhostPods(t, data, 3, data.testNamespace, node1)
 	defer node1CleanupFn()
 	// Give a little time for Windows containerd Nodes to setup OVS.
 	// Containerd configures port asynchronously, which could cause execution time of installing flow longer than docker.
 	time.Sleep(time.Second * 1)
 
+	var pod0IPv4Str, pod0IPv6Str string
+	if node1PodIPs[0].IPv4 != nil {
+		pod0IPv4Str = node1PodIPs[0].IPv4.String()
+	}
+	if node1PodIPs[0].IPv6 != nil {
+		pod0IPv6Str = node1PodIPs[0].IPv6.String()
+	}
+
 	var denyIngress *v1beta1.NetworkPolicy
 	denyIngressName := "test-annp-deny-ingress"
 	if denyIngress, err = data.createANNPDenyIngress("antrea-e2e", node1Pods[1], denyIngressName, false); err != nil {
@@ -187,6 +195,7 @@ func testTraceflowIntraNodeANNP(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:         v1beta1.ComponentNetworkPolicy,
@@ -237,6 +246,7 @@ func testTraceflowIntraNodeANNP(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:         v1beta1.ComponentNetworkPolicy,
@@ -287,6 +297,7 @@ func testTraceflowIntraNodeANNP(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:         v1beta1.ComponentNetworkPolicy,
@@ -325,10 +336,13 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 	// Give a little time for Windows containerd Nodes to setup OVS.
 	// Containerd configures port asynchronously, which could cause execution time of installing flow longer than docker.
 	time.Sleep(time.Second * 1)
-	var pod0IPv4Str, pod1IPv4Str, dstPodIPv4Str, dstPodIPv6Str string
+	var pod0IPv4Str, pod0IPv6Str, pod1IPv4Str, dstPodIPv4Str, dstPodIPv6Str string
 	if node1IPs[0].IPv4 != nil {
 		pod0IPv4Str = node1IPs[0].IPv4.String()
 	}
+	if node1IPs[0].IPv6 != nil {
+		pod0IPv6Str = node1IPs[0].IPv6.String()
+	}
 	if node1IPs[1].IPv4 != nil {
 		pod1IPv4Str = node1IPs[1].IPv4.String()
 	}
@@ -423,6 +437,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -476,6 +491,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -528,6 +544,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -574,6 +591,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -638,6 +656,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -753,6 +772,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -806,6 +826,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -858,6 +879,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -904,6 +926,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -978,6 +1001,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1027,6 +1051,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1076,6 +1101,7 @@ func testTraceflowIntraNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  pod0IPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1118,18 +1144,24 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 	node1 := nodeName(nodeIdx0)
 	node2 := nodeName(nodeIdx1)
 
-	node1Pods, _, node1CleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, node1)
+	node1Pods, node1IPs, node1CleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, node1)
 	node2Pods, node2IPs, node2CleanupFn := createTestAgnhostPods(t, data, 3, data.testNamespace, node2)
 	gatewayIPv4, gatewayIPv6 := nodeGatewayIPs(1)
 	defer node1CleanupFn()
 	defer node2CleanupFn()
-	var dstPodIPv4Str, dstPodIPv6Str string
+	var dstPodIPv4Str, dstPodIPv6Str, srcPodIPv4Str, srcPodIPv6Str string
 	if node2IPs[0].IPv4 != nil {
 		dstPodIPv4Str = node2IPs[0].IPv4.String()
 	}
 	if node2IPs[0].IPv6 != nil {
 		dstPodIPv6Str = node2IPs[0].IPv6.String()
 	}
+	if node1IPs[0].IPv4 != nil {
+		srcPodIPv4Str = node1IPs[0].IPv4.String()
+	}
+	if node1IPs[0].IPv6 != nil {
+		srcPodIPv6Str = node1IPs[0].IPv6.String()
+	}
 
 	// Create Service backend Pod. The "hairpin" testcases require the Service to have a single backend Pod,
 	// and no more, in order to be deterministic.
@@ -1241,6 +1273,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1307,6 +1340,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1368,6 +1402,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1439,6 +1474,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv4Str,
 						},
 						{
 							Component:       v1beta1.ComponentLB,
@@ -1516,6 +1552,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  agnhostIPv4Str,
 						},
 						{
 							Component:       v1beta1.ComponentLB,
@@ -1567,6 +1604,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv4Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1635,6 +1673,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1704,6 +1743,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1764,6 +1804,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -1832,6 +1873,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv6Str,
 						},
 						{
 							Component:       v1beta1.ComponentLB,
@@ -1906,6 +1948,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  agnhostIPv6Str,
 						},
 						{
 							Component:       v1beta1.ComponentLB,
@@ -1962,6 +2005,7 @@ func testTraceflowInterNode(t *testing.T, data *TestData) {
 						{
 							Component: v1beta1.ComponentSpoofGuard,
 							Action:    v1beta1.ActionForwarded,
+							SrcPodIP:  srcPodIPv6Str,
 						},
 						{
 							Component:     v1beta1.ComponentNetworkPolicy,
@@ -2017,12 +2061,17 @@ func testTraceflowExternalIP(t *testing.T, data *TestData) {
 	}
 	node := nodeName(nodeIdx)
 	nodeIP := nodeIP(nodeIdx)
-	podNames, _, cleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, node)
+	podNames, podIPs, cleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, node)
 	defer cleanupFn()
 	// Give a little time for Windows containerd Nodes to setup OVS.
 	// Containerd configures port asynchronously, which could cause execution time of installing flow longer than docker.
 	time.Sleep(time.Second * 1)
-
+	var srcPodIP string
+	if podIPs[0].IPv4 != nil {
+		srcPodIP = podIPs[0].IPv4.String()
+	} else {
+		srcPodIP = podIPs[0].IPv6.String()
+	}
 	testcase := testcase{
 		name:      "nodeIPDestination",
 		ipVersion: 4,
@@ -2053,6 +2102,7 @@ func testTraceflowExternalIP(t *testing.T, data *TestData) {
 					{
 						Component: v1beta1.ComponentSpoofGuard,
 						Action:    v1beta1.ActionForwarded,
+						SrcPodIP:  srcPodIP,
 					},
 					{
 						Component:     v1beta1.ComponentForwarding,
@@ -2072,8 +2122,14 @@ func testTraceflowEgress(t *testing.T, data *TestData) {
 	egressIP := nodeIP(0)
 	externalDstIP := "1.1.1.1"
 
-	localPodNames, _, localCleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, egressNode)
+	localPodNames, localPodIPs, localCleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, egressNode)
 	defer localCleanupFn()
+	var srcPodIP string
+	if localPodIPs[0].IPv4 != nil {
+		srcPodIP = localPodIPs[0].IPv4.String()
+	} else {
+		srcPodIP = localPodIPs[0].IPv6.String()
+	}
 
 	matchExpressions := []metav1.LabelSelectorRequirement{
 		{
@@ -2116,6 +2172,7 @@ func testTraceflowEgress(t *testing.T, data *TestData) {
 					{
 						Component: v1beta1.ComponentSpoofGuard,
 						Action:    v1beta1.ActionForwarded,
+						SrcPodIP:  srcPodIP,
 					},
 					{
 						Component:  v1beta1.ComponentEgress,
@@ -2140,8 +2197,13 @@ func testTraceflowEgress(t *testing.T, data *TestData) {
 
 	skipIfNumNodesLessThan(t, 2)
 	remoteNode := nodeName(1)
-	remotePodNames, _, remoteCleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, remoteNode)
+	remotePodNames, remotePodIPs, remoteCleanupFn := createTestAgnhostPods(t, data, 1, data.testNamespace, remoteNode)
 	defer remoteCleanupFn()
+	if remotePodIPs[0].IPv4 != nil {
+		srcPodIP = remotePodIPs[0].IPv4.String()
+	} else {
+		srcPodIP = remotePodIPs[0].IPv6.String()
+	}
 
 	toUpdate := egress.DeepCopy()
 	err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
@@ -2188,6 +2250,7 @@ func testTraceflowEgress(t *testing.T, data *TestData) {
 					{
 						Component: v1beta1.ComponentSpoofGuard,
 						Action:    v1beta1.ActionForwarded,
+						SrcPodIP:  srcPodIP,
 					},
 					{
 						Component:  v1beta1.ComponentEgress,
@@ -2344,6 +2407,7 @@ func compareObservations(expected v1beta1.NodeResult, actual v1beta1.NodeResult)
 		if exObs[i].Component != acObs[i].Component ||
 			exObs[i].ComponentInfo != acObs[i].ComponentInfo ||
 			exObs[i].Pod != acObs[i].Pod ||
+			exObs[i].SrcPodIP != acObs[i].SrcPodIP ||
 			exObs[i].TranslatedDstIP != acObs[i].TranslatedDstIP ||
 			exObs[i].EgressIP != acObs[i].EgressIP ||
 			exObs[i].Egress != acObs[i].Egress ||
diff --git a/test/e2e/wireguard_test.go b/test/e2e/wireguard_test.go
index 89c86b83d..5b27a97a7 100644
--- a/test/e2e/wireguard_test.go
+++ b/test/e2e/wireguard_test.go
@@ -40,17 +40,15 @@ func TestWireGuard(t *testing.T) {
 		t.Fatalf("Error when setting up test: %v", err)
 	}
 	defer teardownTest(t, data)
+	skipIfMulticastEnabled(t, data)
 	skipIfEncapModeIsNot(t, data, config.TrafficEncapModeEncap)
 	for _, node := range clusterInfo.nodes {
 		skipIfMissingKernelModule(t, data, node.name, []string{"wireguard"})
 	}
 	var previousTrafficEncryptionMode string
-	var previousMulticastEnabledState bool
 	ac := func(config *agentconfig.AgentConfig) {
 		previousTrafficEncryptionMode = config.TrafficEncryptionMode
 		config.TrafficEncryptionMode = "wireguard"
-		previousMulticastEnabledState = config.Multicast.Enable
-		config.Multicast.Enable = false
 	}
 	if err := data.mutateAntreaConfigMap(nil, ac, false, true); err != nil {
 		t.Fatalf("Failed to enable WireGuard tunnel: %v", err)
@@ -58,7 +56,6 @@ func TestWireGuard(t *testing.T) {
 	defer func() {
 		ac := func(config *agentconfig.AgentConfig) {
 			config.TrafficEncryptionMode = previousTrafficEncryptionMode
-			config.Multicast.Enable = previousMulticastEnabledState
 		}
 		if err := data.mutateAntreaConfigMap(nil, ac, false, true); err != nil {
 			t.Errorf("Failed to disable WireGuard tunnel: %v", err)