@@ -249,6 +249,42 @@ func verifySubnetStatusAfterEIPOperation(subnetClient *framework.SubnetClient, s
249249 }
250250}
251251
252+ // iptablesSaveNat returns the iptables-save output from the NAT gateway pod,
253+ // using the exact same detection logic as nat-gateway.sh to determine whether
254+ // to use iptables-legacy-save or iptables-save (nft backend).
255+ func iptablesSaveNat (natGwPodName string ) string {
256+ // Replicate nat-gateway.sh detection: if iptables-legacy -t nat -S INPUT 1 succeeds,
257+ // rules were written via iptables-legacy, so use iptables-legacy-save to read them.
258+ // NOTE: KubectlExec joins args with space and passes to "/bin/sh -c", so pass
259+ // the entire script as a single string to avoid double shell wrapping.
260+ cmd := []string {"if iptables-legacy -t nat -S INPUT 1 2>/dev/null; then iptables-legacy-save -t nat; else iptables-save -t nat; fi" }
261+ stdout , _ , err := framework .KubectlExec (framework .KubeOvnNamespace , natGwPodName , cmd ... )
262+ framework .ExpectNoError (err , "failed to exec iptables-save in NAT gateway pod %s" , natGwPodName )
263+ return string (stdout )
264+ }
265+
266+ // hairpinSnatChainExists checks if the HAIRPIN_SNAT chain exists in the NAT gateway pod.
267+ // Returns false on older versions that don't support this feature.
268+ func hairpinSnatChainExists (natGwPodName string ) bool {
269+ output := iptablesSaveNat (natGwPodName )
270+ return strings .Contains (output , ":HAIRPIN_SNAT" ) || strings .Contains (output , "-N HAIRPIN_SNAT" )
271+ }
272+
273+ // hairpinSnatRuleExists checks if hairpin SNAT rule exists in the NAT gateway pod
274+ // for the given CIDR and specific EIP.
275+ // Returns true if rule exists, false otherwise (including when HAIRPIN_SNAT chain doesn't exist).
276+ func hairpinSnatRuleExists (natGwPodName , cidr , eip string ) bool {
277+ output := iptablesSaveNat (natGwPodName )
278+ if ! strings .Contains (output , ":HAIRPIN_SNAT" ) && ! strings .Contains (output , "-N HAIRPIN_SNAT" ) {
279+ return false
280+ }
281+
282+ hairpinRulePattern := fmt .Sprintf ("-A HAIRPIN_SNAT -s %s -d %s -j SNAT --to-source %s" , cidr , cidr , eip )
283+ return strings .Contains (output , hairpinRulePattern )
284+ }
285+
286+ // hairpinSnatRuleExistsForCIDR checks if any hairpin SNAT rule exists for the
287+ // given CIDR, regardless of which EIP it uses.
252288var _ = framework .OrderedDescribe ("[group:iptables-vpc-nat-gw]" , func () {
253289 f := framework .NewDefaultFramework ("iptables-vpc-nat-gw" )
254290
@@ -264,6 +300,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
264300 var iptablesFIPClient * framework.IptablesFIPClient
265301 var iptablesSnatRuleClient * framework.IptablesSnatClient
266302 var iptablesDnatRuleClient * framework.IptablesDnatClient
303+ var podClient * framework.PodClient
267304
268305 var dockerExtNet1Network * dockernetwork.Inspect
269306 var net1NicName string
@@ -421,6 +458,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
421458 vpcName = "vpc-" + randomSuffix
422459 vpcNatGwName = "gw-" + randomSuffix
423460 overlaySubnetName = "overlay-subnet-" + randomSuffix
461+ podClient = f .PodClient ()
424462 })
425463
426464 framework .ConformanceIt ("[1] change gateway image and custom annotations" , func () {
@@ -543,6 +581,81 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
543581 iptablesSnatRuleClient .DeleteSync (snatName )
544582 })
545583
584+ // Verify hairpin SNAT rule is automatically created for internal CIDR
585+ ginkgo .By ("Verifying hairpin SNAT rule exists in NAT gateway pod" )
586+ vpcNatGwPodName := util .GenNatGwPodName (vpcNatGwName )
587+ snatEip = iptablesEIPClient .Get (snatEipName )
588+ if ! hairpinSnatChainExists (vpcNatGwPodName ) {
589+ framework .Logf ("HAIRPIN_SNAT chain not found, skipping hairpin SNAT verification (feature requires v1.15+)" )
590+ } else {
591+ gomega .Eventually (func () bool {
592+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
593+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
594+ "Hairpin SNAT rule should be created after SNAT creation" )
595+
596+ // Verify real data-path: internal pod accessing another internal pod via FIP EIP
597+ // Packet flow: client -> NAT GW (DNAT to serverIP + hairpin SNAT to EIP) -> server -> NAT GW (un-SNAT/DNAT) -> client
598+ ginkgo .By ("Verifying hairpin SNAT connectivity: internal pod (SNAT EIP) accessing another internal pod (FIP EIP)" )
599+ serverPodName := "server-" + randomSuffix
600+ clientPodName := "client-" + randomSuffix
601+ hairpinFipEipName := "hairpin-fip-eip-" + randomSuffix
602+ hairpinFipName := "hairpin-fip-" + randomSuffix
603+
604+ // Create server pod in overlay subnet with auto-assigned IP
605+ serverAnnotations := map [string ]string {
606+ util .LogicalSwitchAnnotation : overlaySubnetName ,
607+ }
608+ serverPort := "8080"
609+ serverArgs := []string {"netexec" , "--http-port" , serverPort }
610+ serverPod := framework .MakePod (f .Namespace .Name , serverPodName , nil , serverAnnotations , framework .AgnhostImage , nil , serverArgs )
611+ _ = podClient .CreateSync (serverPod )
612+ ginkgo .DeferCleanup (func () {
613+ ginkgo .By ("Cleaning up server pod " + serverPodName )
614+ podClient .DeleteSync (serverPodName )
615+ })
616+
617+ // Get server pod's auto-assigned IP for FIP binding
618+ createdServerPod := podClient .GetPod (serverPodName )
619+ serverPodIP := createdServerPod .Annotations [util .IPAddressAnnotation ]
620+ framework .ExpectNotEmpty (serverPodIP , "server pod should have an IP assigned" )
621+ framework .Logf ("Server pod %s has IP %s" , serverPodName , serverPodIP )
622+
623+ // Create a dedicated EIP and FIP to map FIP EIP -> server pod IP
624+ hairpinFipEip := framework .MakeIptablesEIP (hairpinFipEipName , "" , "" , "" , vpcNatGwName , "" , "" )
625+ _ = iptablesEIPClient .CreateSync (hairpinFipEip )
626+ ginkgo .DeferCleanup (func () {
627+ ginkgo .By ("Cleaning up hairpin FIP EIP " + hairpinFipEipName )
628+ iptablesEIPClient .DeleteSync (hairpinFipEipName )
629+ })
630+ hairpinFipEip = iptablesEIPClient .Get (hairpinFipEipName )
631+ framework .ExpectNotEmpty (hairpinFipEip .Status .IP , "hairpin FIP EIP should have an IP assigned" )
632+
633+ hairpinFip := framework .MakeIptablesFIPRule (hairpinFipName , hairpinFipEipName , serverPodIP )
634+ _ = iptablesFIPClient .CreateSync (hairpinFip )
635+ ginkgo .DeferCleanup (func () {
636+ ginkgo .By ("Cleaning up hairpin FIP " + hairpinFipName )
637+ iptablesFIPClient .DeleteSync (hairpinFipName )
638+ })
639+
640+ // Create client pod in same subnet (uses SNAT EIP for outbound traffic)
641+ clientAnnotations := map [string ]string {
642+ util .LogicalSwitchAnnotation : overlaySubnetName ,
643+ }
644+ clientPod := framework .MakePod (f .Namespace .Name , clientPodName , nil , clientAnnotations , framework .AgnhostImage , nil , []string {"pause" })
645+ _ = podClient .CreateSync (clientPod )
646+ ginkgo .DeferCleanup (func () {
647+ ginkgo .By ("Cleaning up client pod " + clientPodName )
648+ podClient .DeleteSync (clientPodName )
649+ })
650+
651+ // Test connectivity: client pod (SNAT EIP) -> server pod (FIP EIP)
652+ ginkgo .By ("Checking connectivity from client pod (SNAT EIP) to server pod (FIP EIP) " + hairpinFipEip .Status .IP )
653+ cmd := []string {"curl" , "-m" , "10" , fmt .Sprintf ("http://%s:%s/clientip" , hairpinFipEip .Status .IP , serverPort )}
654+ output , _ , err := framework .KubectlExec (f .Namespace .Name , clientPodName , cmd ... )
655+ framework .ExpectNoError (err , "Client pod (SNAT EIP) should reach server pod via FIP EIP through hairpin SNAT" )
656+ framework .Logf ("Hairpin SNAT connectivity verified, output: %s" , string (output ))
657+ }
658+
546659 ginkgo .By ("Creating iptables vip for dnat" )
547660 dnatVip := framework .MakeVip (f .Namespace .Name , dnatVipName , overlaySubnetName , "" , "" , "" )
548661 _ = vipClient .CreateSync (dnatVip )
@@ -618,8 +731,19 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
618731 iptablesSnatRuleClient .DeleteSync (sharedEipSnatName )
619732 })
620733
621- ginkgo .By ("Get share eip" )
734+ // Verify hairpin SNAT rule is created for the shared SNAT (same CIDR, different EIP).
735+ // Hairpin mirrors SNAT 1:1: each SNAT creates its own hairpin rule.
736+ ginkgo .By ("Getting share eip" )
622737 shareEip = iptablesEIPClient .Get (sharedEipName )
738+ framework .ExpectNotEmpty (shareEip .Status .IP , "shareEip.Status.IP should not be empty" )
739+ if hairpinSnatChainExists (vpcNatGwPodName ) {
740+ ginkgo .By ("Verifying hairpin SNAT rule exists for the shared SNAT EIP" )
741+ gomega .Eventually (func () bool {
742+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , shareEip .Status .IP )
743+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
744+ "Hairpin SNAT rule should be created for shared SNAT EIP" )
745+ }
746+
623747 ginkgo .By ("Get share dnat" )
624748 shareDnat = iptablesDnatRuleClient .Get (sharedEipDnatName )
625749 ginkgo .By ("Get share snat" )
@@ -643,6 +767,22 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
643767 // make sure eip is shared
644768 nats := []string {util .DnatUsingEip , util .FipUsingEip , util .SnatUsingEip }
645769 framework .ExpectEqual (shareEip .Status .Nat , strings .Join (nats , "," ))
770+
771+ // Verify hairpin SNAT rule cleanup when SNAT is deleted.
772+ // Hairpin lifecycle is 1:1 with SNAT: created together, deleted together.
773+ if hairpinSnatChainExists (vpcNatGwPodName ) {
774+ ginkgo .By ("Deleting snat to verify hairpin SNAT rule cleanup" )
775+ iptablesSnatRuleClient .DeleteSync (snatName )
776+ ginkgo .By ("Verifying hairpin SNAT rule for the deleted SNAT EIP is removed" )
777+ gomega .Eventually (func () bool {
778+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
779+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeFalse (),
780+ "Hairpin SNAT rule should be deleted after SNAT deletion" )
781+ ginkgo .By ("Verifying hairpin SNAT rule for the shared SNAT EIP still exists" )
782+ gomega .Expect (hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , shareEip .Status .IP )).To (gomega .BeTrue (),
783+ "Hairpin SNAT rule for the shared SNAT EIP should NOT be affected by deleting a different SNAT" )
784+ }
785+
646786 // All cleanup is handled by DeferCleanup above, no need for manual cleanup
647787 })
648788
0 commit comments