[PATCH v2 2/2] selftests: net: add act_nat ICMP inner checksum test

David Carlier
2026-04-04T12:03:17Z

Verify that act_nat correctly updates the inner IP header
checksum when rewriting addresses inside ICMP error payloads.

The test sets up two namespaces with act_nat on a veth pair,
triggers an ICMP destination unreachable, and validates the
inner IP header checksum in the received ICMP error.

Signed-off-by: David Carlier <devnexen@gmail.com>
---
tools/testing/selftests/net/Makefile | 1 +
.../selftests/net/act_nat_icmp_csum.sh | 72 +++++++++
.../selftests/net/act_nat_icmp_csum_verify.py | 144 ++++++++++++++++++
3 files changed, 217 insertions(+)
create mode 100755 tools/testing/selftests/net/act_nat_icmp_csum.sh
create mode 100755 tools/testing/selftests/net/act_nat_icmp_csum_verify.py

diff --git a/tools/testing/selftests/net/Makefile b/tools/testing/selftests/net/Makefile
index 6bced3ed798b..1683db55e36f 100644
--- a/tools/testing/selftests/net/Makefile
+++ b/tools/testing/selftests/net/Makefile
@@ -7,6 +7,7 @@ CFLAGS += -I../../../../usr/include/ $(KHDR_INCLUDES)
CFLAGS += -I../

TEST_PROGS := \
+ act_nat_icmp_csum.sh \
altnames.sh \
amt.sh \
arp_ndisc_evict_nocarrier.sh \
diff --git a/tools/testing/selftests/net/act_nat_icmp_csum.sh b/tools/testing/selftests/net/act_nat_icmp_csum.sh
new file mode 100755
index 000000000000..2a0986bfe577
--- /dev/null
+++ b/tools/testing/selftests/net/act_nat_icmp_csum.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+#
+# Test that act_nat correctly updates the inner IP header checksum
+# when rewriting addresses inside ICMP error payloads.
+#
+# Setup:
+# +---------------------+ +---------------------+
+# | NS1 | | NS2 |
+# | | | |
+# | +-------+ | | +-------+ |
+# | | veth0 +---+----------------------+---+ veth0 | |
+# | +-------+ | | +-------+ |
+# | 10.0.1.1/24 | | 10.0.2.1/24 |
+# +---------------------+ +---------------------+
+#
+# On NS1's veth0:
+# egress act_nat: src 10.0.1.0/24 -> 10.0.2.0/24
+# ingress act_nat: dst 10.0.2.0/24 -> 10.0.1.0/24
+#
+# NS1 pings 10.0.2.99 (unreachable in NS2). NS2 sends back an ICMP
+# "destination host unreachable". The ICMP error contains a copy of the
+# original IP header whose source was NATted. On ingress, act_nat rewrites
+# the inner destination back. The inner IP header checksum must be updated.
+#
+# We use a raw ICMP socket in NS1 to receive the post-NAT ICMP error
+# and verify the inner IP header checksum is correct.
+
+source lib.sh
+
+cleanup()
+{
+ cleanup_ns $NS1 $NS2
+}
+
+trap cleanup EXIT
+
+# Check for required modules
+for mod in act_nat cls_u32 sch_ingress; do
+ modinfo $mod &>/dev/null || { echo "SKIP: Need $mod module"; exit $ksft_skip; }
+done
+
+setup_ns NS1 NS2
+
+ip -netns $NS1 link add veth0 type veth peer name veth0 netns $NS2
+ip -netns $NS1 link set dev veth0 up
+ip -netns $NS2 link set dev veth0 up
+
+ip -netns $NS1 addr add 10.0.1.1/24 dev veth0
+ip -netns $NS2 addr add 10.0.2.1/24 dev veth0
+
+ip netns exec $NS2 sysctl -qw net.ipv4.ip_forward=1
+ip netns exec $NS2 sysctl -qw net.ipv4.icmp_ratelimit=0
+
+ip -netns $NS1 route add 10.0.2.0/24 dev veth0
+
+# act_nat on NS1's veth0
+ip netns exec $NS1 tc qdisc add dev veth0 clsact
+
+# Egress: rewrite src 10.0.1.x -> 10.0.2.x
+ip netns exec $NS1 tc filter add dev veth0 egress protocol ip prio 1 \
+ u32 match ip src 10.0.1.0/24 \
+ action nat egress 10.0.1.0/24 10.0.2.0
+
+# Ingress: rewrite dst 10.0.2.x -> 10.0.1.x
+ip netns exec $NS1 tc filter add dev veth0 ingress protocol ip prio 1 \
+ u32 match ip dst 10.0.2.0/24 \
+ action nat ingress 10.0.2.0/24 10.0.1.0
+
+# Run the test: send ping and capture the ICMP error via raw socket
+ip netns exec $NS1 python3 "$(dirname "$0")/act_nat_icmp_csum_verify.py"
+exit $?
diff --git a/tools/testing/selftests/net/act_nat_icmp_csum_verify.py b/tools/testing/selftests/net/act_nat_icmp_csum_verify.py
new file mode 100755
index 000000000000..5d62831f6f49
--- /dev/null
+++ b/tools/testing/selftests/net/act_nat_icmp_csum_verify.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+#
+# Verify that act_nat correctly updates the inner IP header checksum
+# in ICMP error packets. Sends a ping to an unreachable host and
+# captures the resulting ICMP error via a raw socket, then validates
+# the inner IP header checksum.
+#
+# This script is expected to run inside a network namespace that has
+# act_nat configured on its veth interface.
+
+import socket
+import struct
+import sys
+import os
+import signal
+
+
+def ip_checksum(header_bytes):
+ """Compute IP header checksum."""
+ if len(header_bytes) % 2:
+ header_bytes += b'\x00'
+ total = 0
+ for i in range(0, len(header_bytes), 2):
+ total += (header_bytes[i] << 8) + header_bytes[i + 1]
+ while total >> 16:
+ total = (total & 0xffff) + (total >> 16)
+ return (~total) & 0xffff
+
+
+def verify_inner_checksum(icmp_payload):
+ """Extract and verify the inner IP header checksum from ICMP error."""
+ # ICMP error payload starts with the original IP header
+ if len(icmp_payload) < 20:
+ return None, "inner IP header too short"
+
+ inner_ihl = (icmp_payload[0] & 0x0f) * 4
+ if len(icmp_payload) < inner_ihl:
+ return None, "inner IP header truncated"
+
+ inner_hdr = icmp_payload[:inner_ihl]
+
+ stored_csum = (inner_hdr[10] << 8) | inner_hdr[11]
+
+ # Zero out checksum field and recompute
+ hdr_for_csum = bytearray(inner_hdr)
+ hdr_for_csum[10] = 0
+ hdr_for_csum[11] = 0
+ computed_csum = ip_checksum(bytes(hdr_for_csum))
+
+ inner_src = socket.inet_ntoa(inner_hdr[12:16])
+ inner_dst = socket.inet_ntoa(inner_hdr[16:20])
+ info = f"inner src={inner_src} dst={inner_dst}"
+
+ if stored_csum == computed_csum:
+ return True, f"valid (0x{stored_csum:04x}) {info}"
+ else:
+ return False, (f"mismatch: stored=0x{stored_csum:04x} "
+ f"computed=0x{computed_csum:04x} {info}")
+
+
+def main():
+ # Open raw ICMP socket to receive ICMP errors
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_RAW,
+ socket.IPPROTO_ICMP)
+ except PermissionError:
+ print("SKIP - need CAP_NET_RAW")
+ return 4
+
+ sock.settimeout(5)
+
+ # Send a ping to an unreachable address to trigger ICMP error
+ try:
+ ping_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
+ socket.IPPROTO_ICMP)
+ except (PermissionError, OSError):
+ # Fallback: use raw socket for ping
+ ping_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW,
+ socket.IPPROTO_ICMP)
+
+ # ICMP echo request: type=8, code=0, checksum, id, seq
+ icmp_id = os.getpid() & 0xffff
+ icmp_echo = struct.pack('!BBHHH', 8, 0, 0, icmp_id, 1)
+ # Compute ICMP checksum
+ csum = ip_checksum(icmp_echo)
+ icmp_echo = struct.pack('!BBHHH', 8, 0, csum, icmp_id, 1)
+
+ try:
+ ping_sock.sendto(icmp_echo, ('10.0.2.99', 0))
+ except OSError:
+ pass
+ ping_sock.close()
+
+ # Wait for ICMP error response
+ try:
+ data, addr = sock.recvfrom(4096)
+ except socket.timeout:
+ print("SKIP - no ICMP error received (timeout)")
+ sock.close()
+ return 4
+
+ sock.close()
+
+ # Parse outer IP header
+ if len(data) < 20:
+ print("SKIP - received packet too short")
+ return 4
+
+ outer_ihl = (data[0] & 0x0f) * 4
+
+ # ICMP header at offset outer_ihl
+ icmp_offset = outer_ihl
+ if len(data) < icmp_offset + 8:
+ print("SKIP - packet too short for ICMP header")
+ return 4
+
+ icmp_type = data[icmp_offset]
+ icmp_code = data[icmp_offset + 1]
+
+ # Expect ICMP dest unreachable (type 3) or similar error
+ if icmp_type not in (3, 4, 5, 11, 12):
+ print(f"SKIP - received ICMP type {icmp_type}, not an error")
+ return 4
+
+ # Inner IP header starts after ICMP header (8 bytes)
+ inner_offset = icmp_offset + 8
+ inner_payload = data[inner_offset:]
+
+ result, msg = verify_inner_checksum(inner_payload)
+
+ if result is None:
+ print(f"SKIP - {msg}")
+ return 4
+ elif result:
+ print(f"OK - inner IP header checksum {msg}")
+ return 0
+ else:
+ print(f"FAIL - inner IP header checksum {msg}")
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
--
2.53.0