@@ -39,6 +39,8 @@ function show_help() {
3939 echo " dnat-del - Delete DNAT rule"
4040 echo " snat-add - Add SNAT rule"
4141 echo " snat-del - Delete SNAT rule"
42+ echo " hairpin-snat-add - Add hairpin SNAT rule for internal FIP access"
43+ echo " hairpin-snat-del - Delete hairpin SNAT rule"
4244 echo " qos-add - Add QoS rule"
4345 echo " qos-del - Delete QoS rule"
4446 echo " eip-ingress-qos-add - Add EIP ingress QoS"
@@ -124,6 +126,7 @@ function init() {
124126 $iptables_cmd -t nat -N SNAT_FILTER
125127 $iptables_cmd -t nat -N EXCLUSIVE_DNAT # floatingIp DNAT
126128 $iptables_cmd -t nat -N EXCLUSIVE_SNAT # floatingIp SNAT
129+ $iptables_cmd -t nat -N HAIRPIN_SNAT # hairpin SNAT for internal FIP access
127130 $iptables_cmd -t nat -N SHARED_DNAT
128131 $iptables_cmd -t nat -N SHARED_SNAT
129132
@@ -134,6 +137,7 @@ function init() {
134137 $iptables_cmd -t nat -A POSTROUTING -j SNAT_FILTER
135138 $iptables_cmd -t nat -A SNAT_FILTER -j EXCLUSIVE_SNAT
136139 $iptables_cmd -t nat -A SNAT_FILTER -j SHARED_SNAT
140+ $iptables_cmd -t nat -A SNAT_FILTER -j HAIRPIN_SNAT
137141
138142 # Send gratuitous ARP for all the IPs on the external network interface at initialization
139143 # This is especially useful to update the MAC of the nexthop we announce to the BGP speaker
@@ -153,6 +157,26 @@ function get_iptables_version() {
153157 exec_cmd " $iptables_cmd --version"
154158}
155159
160+ # Check if the given CIDR exists in VPC_INTERFACE's routes (indicates it's an internal CIDR)
161+ # This is used to determine if hairpin SNAT is needed for a given SNAT rule
162+ # Args: $1 - CIDR to check (e.g., "10.0.1.0/24")
163+ # Returns: 0 if the CIDR is found in VPC_INTERFACE routes, 1 otherwise
164+ # Example: VPC_INTERFACE=eth0, route "10.0.1.0/24 dev eth0" exists
165+ # is_internal_cidr "10.0.1.0/24" -> returns 0 (true)
166+ # is_internal_cidr "192.168.1.0/24" -> returns 1 (false, not in routes)
167+ function is_internal_cidr() {
168+ local cidr=" $1 "
169+ if [ -z " $cidr " ]; then
170+ return 1
171+ fi
172+ # Match CIDR at the start of line to ensure exact match
173+ # e.g., "10.0.1.0/24 dev eth0" matches, but "10.0.1.0/25 ..." does not
174+ if ip -4 route show dev " $VPC_INTERFACE " | grep -q " ^$cidr " ; then
175+ return 0
176+ fi
177+ return 1
178+ }
179+
156180function add_vpc_internal_route() {
157181 # make sure inited
158182 check_inited
@@ -270,9 +294,16 @@ function add_snat() {
270294 eip=(${arr[0]// \/ / } )
271295 internalCIDR=${arr[1]}
272296 randomFullyOption=${arr[2]}
273- # check if already exist
274- $iptables_save_cmd | grep SHARED_SNAT | grep " \-s $internalCIDR " | grep " source $eip " && exit 0
275- exec_cmd " $iptables_cmd -t nat -A SHARED_SNAT -o $EXTERNAL_INTERFACE -s $internalCIDR -j SNAT --to-source $eip $randomFullyOption "
297+ # check if already exist, skip adding if exists (idempotent)
298+ if ! $iptables_save_cmd | grep SHARED_SNAT | grep " \-s $internalCIDR " | grep " source $eip " > /dev/null; then
299+ exec_cmd " $iptables_cmd -t nat -A SHARED_SNAT -o $EXTERNAL_INTERFACE -s $internalCIDR -j SNAT --to-source $eip $randomFullyOption "
300+ fi
301+ # Add hairpin SNAT when internalCIDR is routed via VPC_INTERFACE
302+ # This enables internal VMs to access other internal VMs via FIP
303+ if is_internal_cidr " $internalCIDR " ; then
304+ echo " SNAT cidr $internalCIDR is internal, adding hairpin SNAT with EIP $eip "
305+ add_hairpin_snat " $eip ,$internalCIDR "
306+ fi
276307 done
277308}
278309function del_snat() {
@@ -290,6 +321,71 @@ function del_snat() {
290321 ruleMatch=$( echo $ruleMatch | sed ' s/-A //' )
291322 exec_cmd " $iptables_cmd -t nat -D $ruleMatch "
292323 fi
324+ # Delete hairpin SNAT when internalCIDR is routed via VPC_INTERFACE
325+ if is_internal_cidr " $internalCIDR " ; then
326+ echo " SNAT cidr $internalCIDR is internal, deleting hairpin SNAT with EIP $eip "
327+ del_hairpin_snat " $eip ,$internalCIDR "
328+ fi
329+ done
330+ }
331+
332+ # Hairpin SNAT: Enables internal VM to access another internal VM's FIP
333+ # Packet flow when VM A accesses VM B's EIP:
334+ # 1. VM A (10.0.1.6) -> EIP (10.1.69.216) arrives at NAT GW
335+ # 2. DNAT translates destination to VM B's internal IP (10.0.1.11)
336+ # 3. Without hairpin SNAT, reply from VM B goes directly to VM A (same subnet),
337+ # but VM A expects reply from EIP, causing asymmetric routing failure
338+ # 4. Hairpin SNAT translates source to EIP, ensuring symmetric return path via NAT GW
339+ #
340+ # RECOMMENDED: NAT-GW binds to a single VPC internal subnet. In this case,
341+ # only one hairpin SNAT rule is needed (matching the VPC's directly connected route).
342+ #
343+ # Multi-subnet scenarios are supported but NOT recommended. For multiple subnets,
344+ # create separate NAT gateways for each subnet to achieve more direct forwarding paths.
345+ # Each CIDR can only have one hairpin rule to avoid conflicting SNAT sources.
346+ #
347+ # Rule format: eip,internalCIDR
348+ # Example: 10.1.69.219,10.0.1.0/24
349+ # Creates: iptables -t nat -A HAIRPIN_SNAT -s 10.0.1.0/24 -d 10.0.1.0/24 -j SNAT --to-source 10.1.69.219
350+ function add_hairpin_snat() {
351+ # make sure inited
352+ check_inited
353+ for rule in $@
354+ do
355+ arr=(${rule// ,/ } )
356+ eip=(${arr[0]// \/ / } )
357+ internalCIDR=${arr[1]}
358+
359+ # Check if this exact rule already exists (idempotent)
360+ if $iptables_save_cmd -t nat | grep HAIRPIN_SNAT | grep " \-s $internalCIDR " | grep " \-d $internalCIDR " | grep " source $eip " > /dev/null; then
361+ echo " Hairpin SNAT rule for $internalCIDR with EIP $eip already exists, skipping"
362+ continue
363+ fi
364+
365+ # Check if this CIDR already has a hairpin rule with a different EIP
366+ if $iptables_save_cmd -t nat | grep HAIRPIN_SNAT | grep " \-s $internalCIDR " | grep " \-d $internalCIDR " > /dev/null; then
367+ echo " WARNING: Hairpin SNAT rule for $internalCIDR already exists with different EIP. Skipping."
368+ continue
369+ fi
370+
371+ exec_cmd " $iptables_cmd -t nat -A HAIRPIN_SNAT -s $internalCIDR -d $internalCIDR -j SNAT --to-source $eip "
372+ echo " Hairpin SNAT rule added: $internalCIDR -> $eip "
373+ done
374+ }
375+
376+ function del_hairpin_snat() {
377+ # make sure inited
378+ check_inited
379+ for rule in $@
380+ do
381+ arr=(${rule// ,/ } )
382+ eip=(${arr[0]// \/ / } )
383+ internalCIDR=${arr[1]}
384+ # check if rule exists (idempotent - skip if not found)
385+ if $iptables_save_cmd -t nat | grep HAIRPIN_SNAT | grep " \-s $internalCIDR " | grep " \-d $internalCIDR " | grep " source $eip " > /dev/null; then
386+ exec_cmd " $iptables_cmd -t nat -D HAIRPIN_SNAT -s $internalCIDR -d $internalCIDR -j SNAT --to-source $eip "
387+ echo " Hairpin SNAT rule deleted: $internalCIDR -> $eip "
388+ fi
293389 done
294390}
295391
@@ -562,6 +658,14 @@ case $opt in
562658 echo " floating-ip-del $rules "
563659 del_floating_ip $rules
564660 ;;
661+ hairpin-snat-add)
662+ echo " hairpin-snat-add $rules "
663+ add_hairpin_snat $rules
664+ ;;
665+ hairpin-snat-del)
666+ echo " hairpin-snat-del $rules "
667+ del_hairpin_snat $rules
668+ ;;
565669 get-iptables-version)
566670 echo " get-iptables-version $rules "
567671 get_iptables_version $rules
0 commit comments