55 "encoding/json"
66 "flag"
77 "fmt"
8+ "regexp"
89 "strings"
910 "testing"
1011 "time"
@@ -249,6 +250,48 @@ func verifySubnetStatusAfterEIPOperation(subnetClient *framework.SubnetClient, s
249250 }
250251}
251252
253+ // iptablesSaveNat returns the iptables-save output from the NAT gateway pod,
254+ // using the exact same detection logic as nat-gateway.sh to determine whether
255+ // to use iptables-legacy-save or iptables-save (nft backend).
256+ func iptablesSaveNat (natGwPodName string ) string {
257+ // Replicate nat-gateway.sh detection: if iptables-legacy -t nat -S INPUT 1 succeeds,
258+ // rules were written via iptables-legacy, so use iptables-legacy-save to read them.
259+ // NOTE: KubectlExec joins args with space and passes to "/bin/sh -c", so pass
260+ // the entire script as a single string to avoid double shell wrapping.
261+ 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" }
262+ stdout , _ , err := framework .KubectlExec (framework .KubeOvnNamespace , natGwPodName , cmd ... )
263+ framework .ExpectNoError (err , "failed to exec iptables-save in NAT gateway pod %s" , natGwPodName )
264+ return string (stdout )
265+ }
266+
267+ // hairpinSnatChainExists checks if the HAIRPIN_SNAT chain exists in the NAT gateway pod.
268+ // Returns false on older versions that don't support this feature.
269+ func hairpinSnatChainExists (natGwPodName string ) bool {
270+ output := iptablesSaveNat (natGwPodName )
271+ return strings .Contains (output , ":HAIRPIN_SNAT" ) || strings .Contains (output , "-N HAIRPIN_SNAT" )
272+ }
273+
274+ // hairpinSnatRuleExists checks if hairpin SNAT rule exists in the NAT gateway pod
275+ // for the given CIDR and specific EIP.
276+ // Uses regex with word boundaries to prevent partial IP/CIDR matching
277+ // (e.g. EIP 10.1.69.21 must not match rule for 10.1.69.219).
278+ // Returns true if rule exists, false otherwise (including when HAIRPIN_SNAT chain doesn't exist).
279+ func hairpinSnatRuleExists (natGwPodName , cidr , eip string ) bool {
280+ output := iptablesSaveNat (natGwPodName )
281+ if ! strings .Contains (output , ":HAIRPIN_SNAT" ) && ! strings .Contains (output , "-N HAIRPIN_SNAT" ) {
282+ return false
283+ }
284+
285+ // Use regex with \b word boundaries for precise matching, and allow
286+ // optional trailing args like --random-fully after the EIP.
287+ hairpinRulePattern := fmt .Sprintf (
288+ `-A HAIRPIN_SNAT -s \b%s\b -d \b%s\b -j SNAT --to-source \b%s\b` ,
289+ regexp .QuoteMeta (cidr ), regexp .QuoteMeta (cidr ), regexp .QuoteMeta (eip ),
290+ )
291+ re := regexp .MustCompile (hairpinRulePattern )
292+ return re .MatchString (output )
293+ }
294+
252295var _ = framework .OrderedDescribe ("[group:iptables-vpc-nat-gw]" , func () {
253296 f := framework .NewDefaultFramework ("iptables-vpc-nat-gw" )
254297
@@ -264,6 +307,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
264307 var iptablesFIPClient * framework.IptablesFIPClient
265308 var iptablesSnatRuleClient * framework.IptablesSnatClient
266309 var iptablesDnatRuleClient * framework.IptablesDnatClient
310+ var podClient * framework.PodClient
267311
268312 var dockerExtNet1Network * dockernetwork.Inspect
269313 var net1NicName string
@@ -421,6 +465,7 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
421465 vpcName = "vpc-" + randomSuffix
422466 vpcNatGwName = "gw-" + randomSuffix
423467 overlaySubnetName = "overlay-subnet-" + randomSuffix
468+ podClient = f .PodClient ()
424469 })
425470
426471 framework .ConformanceIt ("[1] change gateway image and custom annotations" , func () {
@@ -543,6 +588,81 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
543588 iptablesSnatRuleClient .DeleteSync (snatName )
544589 })
545590
591+ // Verify hairpin SNAT rule is automatically created for internal CIDR
592+ ginkgo .By ("[hairpin SNAT] Verifying hairpin SNAT rule exists after SNAT creation" )
593+ vpcNatGwPodName := util .GenNatGwPodName (vpcNatGwName )
594+ snatEip = iptablesEIPClient .Get (snatEipName )
595+ if ! hairpinSnatChainExists (vpcNatGwPodName ) {
596+ framework .Logf ("HAIRPIN_SNAT chain not found, skipping hairpin SNAT verification (feature requires v1.15+)" )
597+ } else {
598+ gomega .Eventually (func () bool {
599+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
600+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
601+ "Hairpin SNAT rule should be created after SNAT creation" )
602+
603+ // Verify real data-path: internal pod accessing another internal pod via FIP EIP
604+ // Packet flow: client -> NAT GW (DNAT to serverIP + hairpin SNAT to EIP) -> server -> NAT GW (un-SNAT/DNAT) -> client
605+ ginkgo .By ("[hairpin SNAT] Verifying data-path connectivity: internal pod accessing another internal pod via FIP EIP" )
606+ serverPodName := "server-" + randomSuffix
607+ clientPodName := "client-" + randomSuffix
608+ hairpinFipEipName := "hairpin-fip-eip-" + randomSuffix
609+ hairpinFipName := "hairpin-fip-" + randomSuffix
610+
611+ // [hairpin SNAT] Create server pod in overlay subnet with auto-assigned IP
612+ serverAnnotations := map [string ]string {
613+ util .LogicalSwitchAnnotation : overlaySubnetName ,
614+ }
615+ serverPort := "8080"
616+ serverArgs := []string {"netexec" , "--http-port" , serverPort }
617+ serverPod := framework .MakePod (f .Namespace .Name , serverPodName , nil , serverAnnotations , framework .AgnhostImage , nil , serverArgs )
618+ _ = podClient .CreateSync (serverPod )
619+ ginkgo .DeferCleanup (func () {
620+ ginkgo .By ("Cleaning up server pod " + serverPodName )
621+ podClient .DeleteSync (serverPodName )
622+ })
623+
624+ // Get server pod's auto-assigned IP for FIP binding
625+ createdServerPod := podClient .GetPod (serverPodName )
626+ serverPodIP := createdServerPod .Annotations [util .IPAddressAnnotation ]
627+ framework .ExpectNotEmpty (serverPodIP , "server pod should have an IP assigned" )
628+ framework .Logf ("Server pod %s has IP %s" , serverPodName , serverPodIP )
629+
630+ // [hairpin SNAT] Create a dedicated EIP and FIP for the server pod
631+ hairpinFipEip := framework .MakeIptablesEIP (hairpinFipEipName , "" , "" , "" , vpcNatGwName , "" , "" )
632+ _ = iptablesEIPClient .CreateSync (hairpinFipEip )
633+ ginkgo .DeferCleanup (func () {
634+ ginkgo .By ("Cleaning up hairpin FIP EIP " + hairpinFipEipName )
635+ iptablesEIPClient .DeleteSync (hairpinFipEipName )
636+ })
637+ hairpinFipEip = iptablesEIPClient .Get (hairpinFipEipName )
638+ framework .ExpectNotEmpty (hairpinFipEip .Status .IP , "hairpin FIP EIP should have an IP assigned" )
639+
640+ hairpinFip := framework .MakeIptablesFIPRule (hairpinFipName , hairpinFipEipName , serverPodIP )
641+ _ = iptablesFIPClient .CreateSync (hairpinFip )
642+ ginkgo .DeferCleanup (func () {
643+ ginkgo .By ("Cleaning up hairpin FIP " + hairpinFipName )
644+ iptablesFIPClient .DeleteSync (hairpinFipName )
645+ })
646+
647+ // [hairpin SNAT] Create client pod in same subnet
648+ clientAnnotations := map [string ]string {
649+ util .LogicalSwitchAnnotation : overlaySubnetName ,
650+ }
651+ clientPod := framework .MakePod (f .Namespace .Name , clientPodName , nil , clientAnnotations , framework .AgnhostImage , nil , []string {"pause" })
652+ _ = podClient .CreateSync (clientPod )
653+ ginkgo .DeferCleanup (func () {
654+ ginkgo .By ("Cleaning up client pod " + clientPodName )
655+ podClient .DeleteSync (clientPodName )
656+ })
657+
658+ // [hairpin SNAT] Test connectivity: client -> FIP EIP -> server (same subnet)
659+ ginkgo .By ("[hairpin SNAT] Checking data-path: client pod -> FIP EIP " + hairpinFipEip .Status .IP + " -> server pod" )
660+ cmd := []string {"curl" , "-m" , "10" , fmt .Sprintf ("http://%s:%s/clientip" , hairpinFipEip .Status .IP , serverPort )}
661+ output , _ , err := framework .KubectlExec (f .Namespace .Name , clientPodName , cmd ... )
662+ framework .ExpectNoError (err , "[hairpin SNAT] client pod should reach server pod via FIP EIP" )
663+ framework .Logf ("[hairpin SNAT] connectivity verified, response: %s" , string (output ))
664+ }
665+
546666 ginkgo .By ("Creating iptables vip for dnat" )
547667 dnatVip := framework .MakeVip (f .Namespace .Name , dnatVipName , overlaySubnetName , "" , "" , "" )
548668 _ = vipClient .CreateSync (dnatVip )
@@ -618,8 +738,19 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
618738 iptablesSnatRuleClient .DeleteSync (sharedEipSnatName )
619739 })
620740
621- ginkgo .By ("Get share eip" )
741+ // Verify hairpin SNAT rule is created for the shared SNAT (same CIDR, different EIP).
742+ // Hairpin mirrors SNAT 1:1: each SNAT creates its own hairpin rule.
743+ ginkgo .By ("Getting share eip" )
622744 shareEip = iptablesEIPClient .Get (sharedEipName )
745+ framework .ExpectNotEmpty (shareEip .Status .IP , "shareEip.Status.IP should not be empty" )
746+ if hairpinSnatChainExists (vpcNatGwPodName ) {
747+ ginkgo .By ("[hairpin SNAT] Verifying hairpin SNAT rule exists for the shared SNAT EIP" )
748+ gomega .Eventually (func () bool {
749+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , shareEip .Status .IP )
750+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeTrue (),
751+ "Hairpin SNAT rule should be created for shared SNAT EIP" )
752+ }
753+
623754 ginkgo .By ("Get share dnat" )
624755 shareDnat = iptablesDnatRuleClient .Get (sharedEipDnatName )
625756 ginkgo .By ("Get share snat" )
@@ -643,6 +774,22 @@ var _ = framework.OrderedDescribe("[group:iptables-vpc-nat-gw]", func() {
643774 // make sure eip is shared
644775 nats := []string {util .DnatUsingEip , util .FipUsingEip , util .SnatUsingEip }
645776 framework .ExpectEqual (shareEip .Status .Nat , strings .Join (nats , "," ))
777+
778+ // Verify hairpin SNAT rule cleanup when SNAT is deleted.
779+ // Hairpin lifecycle is 1:1 with SNAT: created together, deleted together.
780+ if hairpinSnatChainExists (vpcNatGwPodName ) {
781+ ginkgo .By ("[hairpin SNAT] Deleting SNAT to verify hairpin rule cleanup" )
782+ iptablesSnatRuleClient .DeleteSync (snatName )
783+ ginkgo .By ("[hairpin SNAT] Verifying hairpin rule for the deleted SNAT EIP is removed" )
784+ gomega .Eventually (func () bool {
785+ return hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , snatEip .Status .IP )
786+ }, 30 * time .Second , 2 * time .Second ).Should (gomega .BeFalse (),
787+ "Hairpin SNAT rule should be deleted after SNAT deletion" )
788+ ginkgo .By ("[hairpin SNAT] Verifying hairpin rule for the shared SNAT EIP still exists" )
789+ gomega .Expect (hairpinSnatRuleExists (vpcNatGwPodName , overlaySubnetV4Cidr , shareEip .Status .IP )).To (gomega .BeTrue (),
790+ "Hairpin SNAT rule for the shared SNAT EIP should NOT be affected by deleting a different SNAT" )
791+ }
792+
646793 // All cleanup is handled by DeferCleanup above, no need for manual cleanup
647794 })
648795
0 commit comments