@@ -249,6 +249,33 @@ func verifySubnetStatusAfterEIPOperation(subnetClient *framework.SubnetClient, s
249249 }
250250}
251251
252+ // hairpinSnatChainExists checks if the HAIRPIN_SNAT chain exists in the NAT gateway pod.
253+ // Returns false on older versions that don't support this feature.
254+ func hairpinSnatChainExists (natGwPodName string ) bool {
255+ cmd := []string {"iptables-save" , "-t" , "nat" }
256+ stdout , _ , err := framework .KubectlExec (framework .KubeOvnNamespace , natGwPodName , cmd ... )
257+ framework .ExpectNoError (err , "failed to exec iptables-save in NAT gateway pod %s" , natGwPodName )
258+
259+ output := string (stdout )
260+ return strings .Contains (output , ":HAIRPIN_SNAT" ) || strings .Contains (output , "-N HAIRPIN_SNAT" )
261+ }
262+
263+ // hairpinSnatRuleExists checks if hairpin SNAT rule exists in the NAT gateway pod.
264+ // Returns true if rule exists, false otherwise (including when HAIRPIN_SNAT chain doesn't exist).
265+ func hairpinSnatRuleExists (natGwPodName , cidr , eip string ) bool {
266+ cmd := []string {"iptables-save" , "-t" , "nat" }
267+ stdout , _ , err := framework .KubectlExec (framework .KubeOvnNamespace , natGwPodName , cmd ... )
268+ framework .ExpectNoError (err , "failed to exec iptables-save in NAT gateway pod %s" , natGwPodName )
269+
270+ output := string (stdout )
271+ if ! strings .Contains (output , ":HAIRPIN_SNAT" ) && ! strings .Contains (output , "-N HAIRPIN_SNAT" ) {
272+ return false
273+ }
274+
275+ hairpinRulePattern := fmt .Sprintf ("-A HAIRPIN_SNAT -s %s -d %s -j SNAT --to-source %s" , cidr , cidr , eip )
276+ return strings .Contains (output , hairpinRulePattern )
277+ }
278+
252279var _ = framework .OrderedDescribe ("[group:iptables-vpc-nat-gw]" , func () {
253280 f := framework .NewDefaultFramework ("iptables-vpc-nat-gw" )
254281
@@ -264,6 +291,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
264291 var iptablesFIPClient * framework.IptablesFIPClient
265292 var iptablesSnatRuleClient * framework.IptablesSnatClient
266293 var iptablesDnatRuleClient * framework.IptablesDnatClient
294+ var podClient * framework.PodClient
267295
268296 var dockerExtNet1Network * dockernetwork.Inspect
269297 var net1NicName string
@@ -421,6 +449,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
421449 vpcName = "vpc-" + randomSuffix
422450 vpcNatGwName = "gw-" + randomSuffix
423451 overlaySubnetName = "overlay-subnet-" + randomSuffix
452+ podClient = f .PodClient ()
424453 })
425454
426455 framework .ConformanceIt ("[1] change gateway image and custom annotations" , func () {
@@ -543,6 +572,81 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
543572 iptablesSnatRuleClient .DeleteSync (snatName )
544573 })
545574
575+ // Verify hairpin SNAT rule is automatically created for internal CIDR
576+ ginkgo .By ("Verifying hairpin SNAT rule exists in NAT gateway pod" )
577+ vpcNatGwPodName := util .GenNatGwPodName (vpcNatGwName )
578+ snatEip = iptablesEIPClient .Get (snatEipName )
579+ if ! hairpinSnatChainExists (vpcNatGwPodName ) {
580+ framework .Logf ("HAIRPIN_SNAT chain not found, skipping hairpin SNAT verification (feature requires v1.15+)" )
581+ } else {
582+ gomega .Eventually (func () bool {
583+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
584+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
585+ "Hairpin SNAT rule should be created after SNAT creation" )
586+
587+ // Verify real data-path: internal pod accessing another internal pod via FIP EIP
588+ // Packet flow: client -> NAT GW (DNAT to serverIP + hairpin SNAT to EIP) -> server -> NAT GW (un-SNAT/DNAT) -> client
589+ ginkgo .By ("Verifying hairpin SNAT connectivity: internal pod (SNAT EIP) accessing another internal pod (FIP EIP)" )
590+ serverPodName := "server-" + randomSuffix
591+ clientPodName := "client-" + randomSuffix
592+ hairpinFipEipName := "hairpin-fip-eip-" + randomSuffix
593+ hairpinFipName := "hairpin-fip-" + randomSuffix
594+
595+ // Create server pod in overlay subnet with auto-assigned IP
596+ serverAnnotations := map [string ]string {
597+ util .LogicalSwitchAnnotation : overlaySubnetName ,
598+ }
599+ serverPort := "8080"
600+ serverArgs := []string {"netexec" , "--http-port" , serverPort }
601+ serverPod := framework .MakePod (f .Namespace .Name , serverPodName , nil , serverAnnotations , framework .AgnhostImage , nil , serverArgs )
602+ _ = podClient .CreateSync (serverPod )
603+ ginkgo .DeferCleanup (func () {
604+ ginkgo .By ("Cleaning up server pod " + serverPodName )
605+ podClient .DeleteSync (serverPodName )
606+ })
607+
608+ // Get server pod's auto-assigned IP for FIP binding
609+ createdServerPod := podClient .GetPod (serverPodName )
610+ serverPodIP := createdServerPod .Annotations [util .IPAddressAnnotation ]
611+ framework .ExpectNotEmpty (serverPodIP , "server pod should have an IP assigned" )
612+ framework .Logf ("Server pod %s has IP %s" , serverPodName , serverPodIP )
613+
614+ // Create a dedicated EIP and FIP to map FIP EIP -> server pod IP
615+ hairpinFipEip := framework .MakeIptablesEIP (hairpinFipEipName , "" , "" , "" , vpcNatGwName , "" , "" )
616+ _ = iptablesEIPClient .CreateSync (hairpinFipEip )
617+ ginkgo .DeferCleanup (func () {
618+ ginkgo .By ("Cleaning up hairpin FIP EIP " + hairpinFipEipName )
619+ iptablesEIPClient .DeleteSync (hairpinFipEipName )
620+ })
621+ hairpinFipEip = iptablesEIPClient .Get (hairpinFipEipName )
622+ framework .ExpectNotEmpty (hairpinFipEip .Status .IP , "hairpin FIP EIP should have an IP assigned" )
623+
624+ hairpinFip := framework .MakeIptablesFIPRule (hairpinFipName , hairpinFipEipName , serverPodIP )
625+ _ = iptablesFIPClient .CreateSync (hairpinFip )
626+ ginkgo .DeferCleanup (func () {
627+ ginkgo .By ("Cleaning up hairpin FIP " + hairpinFipName )
628+ iptablesFIPClient .DeleteSync (hairpinFipName )
629+ })
630+
631+ // Create client pod in same subnet (uses SNAT EIP for outbound traffic)
632+ clientAnnotations := map [string ]string {
633+ util .LogicalSwitchAnnotation : overlaySubnetName ,
634+ }
635+ clientPod := framework .MakePod (f .Namespace .Name , clientPodName , nil , clientAnnotations , framework .AgnhostImage , nil , []string {"pause" })
636+ _ = podClient .CreateSync (clientPod )
637+ ginkgo .DeferCleanup (func () {
638+ ginkgo .By ("Cleaning up client pod " + clientPodName )
639+ podClient .DeleteSync (clientPodName )
640+ })
641+
642+ // Test connectivity: client pod (SNAT EIP) -> server pod (FIP EIP)
643+ ginkgo .By ("Checking connectivity from client pod (SNAT EIP) to server pod (FIP EIP) " + hairpinFipEip .Status .IP )
644+ cmd := []string {"curl" , "-m" , "10" , fmt .Sprintf ("http://%s:%s/clientip" , hairpinFipEip .Status .IP , serverPort )}
645+ output , _ , err := framework .KubectlExec (f .Namespace .Name , clientPodName , cmd ... )
646+ framework .ExpectNoError (err , "Client pod (SNAT EIP) should reach server pod via FIP EIP through hairpin SNAT" )
647+ framework .Logf ("Hairpin SNAT connectivity verified, output: %s" , string (output ))
648+ }
649+
546650 ginkgo .By ("Creating iptables vip for dnat" )
547651 dnatVip := framework .MakeVip (f .Namespace .Name , dnatVipName , overlaySubnetName , "" , "" , "" )
548652 _ = vipClient .CreateSync (dnatVip )
@@ -618,8 +722,18 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
618722 iptablesSnatRuleClient .DeleteSync (sharedEipSnatName )
619723 })
620724
621- ginkgo .By ("Get share eip" )
725+ // Verify hairpin SNAT rule is created for shared SNAT as well
726+ ginkgo .By ("Getting share eip" )
622727 shareEip = iptablesEIPClient .Get (sharedEipName )
728+ framework .ExpectNotEmpty (shareEip .Status .IP , "shareEip.Status.IP should not be empty" )
729+ if hairpinSnatChainExists (vpcNatGwPodName ) {
730+ ginkgo .By ("Verifying hairpin SNAT rule exists for shared snat" )
731+ gomega .Eventually (func () bool {
732+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , shareEip .Status .IP )
733+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
734+ "Hairpin SNAT rule should be created after shared SNAT creation" )
735+ }
736+
623737 ginkgo .By ("Get share dnat" )
624738 shareDnat = iptablesDnatRuleClient .Get (sharedEipDnatName )
625739 ginkgo .By ("Get share snat" )
@@ -643,6 +757,18 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
643757 // make sure eip is shared
644758 nats := []string {util .DnatUsingEip , util .FipUsingEip , util .SnatUsingEip }
645759 framework .ExpectEqual (shareEip .Status .Nat , strings .Join (nats , "," ))
760+
761+ // Verify hairpin SNAT rule cleanup when SNAT is deleted
762+ if hairpinSnatChainExists (vpcNatGwPodName ) {
763+ ginkgo .By ("Deleting snat to verify hairpin SNAT rule cleanup" )
764+ iptablesSnatRuleClient .DeleteSync (snatName )
765+ ginkgo .By ("Verifying hairpin SNAT rule is deleted after snat deletion" )
766+ gomega .Eventually (func () bool {
767+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
768+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeFalse (),
769+ "Hairpin SNAT rule should be deleted after SNAT deletion" )
770+ }
771+
646772 // All cleanup is handled by DeferCleanup above, no need for manual cleanup
647773 })
648774
0 commit comments