tc's nat action rewrites an IPv4 address at the traffic-control layer: no conntrack, no port allocation, just a straight address swap. You'll finish with a reversible tc filter that translates an IPv4 destination on ingress or an IPv4 source on egress. The examples use the stateless nat action from iproute2 6.1.0-1ubuntu6.4, paired with a u32 filter for a predictable lookup path.
The addresses below are documentation ranges. Replace them with values from your own network before applying anything.
Warning: These rules change packet handling immediately. A mistaken direction or address range can interrupt traffic. Apply them from a console or a maintenance path, keep the matching delete commands ready, and do not use a production interface for the first test.
Start with read-only checks. nat is an action used inside a tc filter command, not a separate tc nat command:
$ tc -V
tc utility, iproute2-6.1.0, libbpf 1.3.0
$ man 8 tc-nat
The installed package on this machine is:
$ dpkg-query -W -f='${Package} ${Version}\n' iproute2
iproute2 6.1.0-1ubuntu6.4
Checkpoint: If tc -V reports a different release, read the local tc-nat(8) page before copying these examples. The action's syntax and supported details are version-specific.
ingress translates the destination address, which is DNAT-like behaviour. egress translates the source address, which is SNAT-like behaviour. The action is stateless: it creates no conntrack entry and remembers no flow.
Set the interface and reserve a filter preference that's unused on your host. These are ordinary shell assignments and change nothing on the network:
DEV='IFACE_NAME'
PREF='49152'
OLD_NET='203.0.113.0/24'
NEW_NET='198.51.100.0/24'
Replace IFACE_NAME with an actual device, such as ens3; do not leave the literal placeholder in place. Confirm the device and inspect existing filters before choosing a preference:
$ ip link show dev "$DEV"
$ tc filter show dev "$DEV" ingress
$ tc filter show dev "$DEV" egress
Two rules sharing a preference can be ordered unexpectedly, or rejected outright. Choose a free value and record it so the rule can be removed precisely.
This command matches IPv4 destination addresses in 203.0.113.0/24 and rewrites them into the corresponding addresses in 198.51.100.0/24:
$ sudo tc filter add dev "$DEV" ingress protocol ip pref "$PREF" \
u32 match ip dst "$OLD_NET" \
action nat ingress "$OLD_NET" "$NEW_NET"
The filter selects packets; the action performs the translation. The first address is the old range, the second the new one. Both are /24 here, so the host bits are retained: 203.0.113.17 maps to 198.51.100.17.
The mask or prefix from OLD is reused for NEW, which is why a one-to-one network mapping needs matching ranges. The action takes the leading bits from NEW and the remaining bits from the original address.
Checkpoint: Inspect the installed rule before sending real traffic:
$ sudo tc filter show dev "$DEV" ingress
Exact handles and formatting vary. What matters is that the filter sits on the intended device and ingress hook, and the action says nat ingress with the ranges you selected.
For outbound packets, match the source range and use egress. Skip this second example if the first rule is the one you actually mean to test; use a different preference only when both rules are deliberately part of the design:
$ EGRESS_PREF='49153'
$ OLD_SOURCE='198.51.100.0/24'
$ NEW_SOURCE='203.0.113.0/24'
$ sudo tc filter add dev "$DEV" egress protocol ip pref "$EGRESS_PREF" \
u32 match ip src "$OLD_SOURCE" \
action nat egress "$OLD_SOURCE" "$NEW_SOURCE"
nat egress changes the source address only. It does not translate the destination, and it does not make replies track the original flow: you still have to supply the routing, return path, firewall policy and any application-level assumptions yourself.
Verify the egress hook:
$ sudo tc filter show dev "$DEV" egress
The action recalculates checksums for TCP and UDP. Its ICMP support is limited: when an ICMP packet carries an embedded IP header, the address in that embedded header gets translated too.
That support is not a general protocol proxy. This is address rewriting at the traffic-control layer, with no connection tracking, port allocation, stateful filtering or automatic reverse rules. Test the protocols and return traffic your service actually uses. A successful tc filter show proves the rule is installed, not that an application flow is correct.
Use packet capture and counters as part of a controlled test. This is read-only, but choose the interface and filter carefully:
$ sudo tc -s filter show dev "$DEV" ingress
$ sudo tcpdump -ni "$DEV" 'ip'
Stop the capture with Ctrl-C. If counters stay at zero, check the hook, protocol, address range and traffic direction before changing the rule.
Removing a filter is a privileged, state-changing operation. Use the exact device, hook and preference you recorded earlier. For the ingress example:
$ sudo tc filter del dev "$DEV" ingress protocol ip pref "$PREF"
$ sudo tc filter show dev "$DEV" ingress
For the optional egress example, remove its separate preference:
$ sudo tc filter del dev "$DEV" egress protocol ip pref "$EGRESS_PREF"
$ sudo tc filter show dev "$DEV" egress
If deletion reports the filter doesn't exist, do not broaden the command into a flush. Inspect the filter list and identify the actual preference first: a broad flush can remove unrelated traffic-control policy. If you also created a temporary clsact qdisc for this test, remove it only after reviewing every filter on it, and only if you know it wasn't already in use.
tc-nat(8) page.u32 match and nat action use the intended address ranges and prefix.tc -s filter show and packet capture gave you a way to test it.