IP Routing Decision and Forwarding
Every IPv4 packet the kernel touches must answer one question before it can move: where does this go next? The routing decision is the act of consulting the Forwarding Information Base (FIB) for a given packet and producing a verdict — deliver it locally, forward it to a next-hop, or drop it — bundled into a
struct rtable(a routing-table cache entry wrapping a genericstruct dst_entry) that is then attached to the packet’sstruct sk_buff. On the receive side this isip_route_input_noref()→ip_route_input_slow(); on the transmit side it isip_route_output_flow()→ip_route_output_key_hash_rcu(). Both ultimately callfib_lookup()and then build adstwhoseinput/outputfunction pointers are the decision:ip_local_deliver,ip_forward, orip_error. Since Linux 3.6 there is no global route cache — the lookup runs essentially per-packet against the FIB trie, with only a thin per-next-hopdstcache softening the cost (DaveM, netdev 2012-07-01). Verified againstnet/ipv4/route.cat the v6.12 LTS tag.
Mental Model
Think of the routing decision as a function that maps a flow key (mostly the destination IP, plus a handful of secondary fields) to a behaviour (a pair of function pointers and a device). The FIB is the read-mostly lookup table; the dst_entry is the compiled answer the packet carries so that downstream code (dst_input(skb) / dst_output(net, sk, skb)) does not re-derive it. The crucial structural fact is that the decision is encoded as code, not data: rth->dst.input = ip_local_deliver means “this packet is for us,” ip_forward means “route it onward,” ip_error means “send an ICMP unreachable.” The stack just calls skb_dst(skb)->input(skb) and the right thing happens.
flowchart TB RX["Frame arrives<br/>ip_rcv → ip_rcv_finish_core"] --> HASDST{"skb already<br/>has a dst?<br/>(early demux / hint)"} HASDST -->|"yes"| USE["use cached dst"] HASDST -->|"no"| IRIN["ip_route_input_noref()<br/>build flowi4 key:<br/>daddr/saddr/tos/iif/mark"] IRIN --> FIB["fib_lookup()<br/>longest-prefix match<br/>in the FIB trie"] FIB --> TYPE{"res.type?"} TYPE -->|"RTN_LOCAL"| LOCAL["dst.input = ip_local_deliver<br/>→ up to TCP/UDP"] TYPE -->|"RTN_UNICAST<br/>+ ip_forward=1"| FWD["dst.input = ip_forward<br/>→ decrement TTL, re-emit"] TYPE -->|"unreachable /<br/>forwarding off"| ERR["dst.input = ip_error<br/>→ ICMP unreachable / drop"] LOCAL --> DSTIN["dst_input(skb)<br/>calls skb_dst(skb)->input(skb)"] FWD --> DSTIN ERR --> DSTIN
The receive-side routing decision. What it shows: after ip_rcv validates the header, ip_rcv_finish_core() either reuses a dst (from TCP/UDP early-demux or a batched lookup hint) or calls ip_route_input_noref(), which performs the FIB lookup and selects a dst whose input pointer encodes the fate of the packet. The insight to take: the three classic outcomes — local-deliver, forward, drop — are not if/else branches scattered through the stack; they are three different dst.input function pointers chosen once at routing time, after which dst_input(skb) dispatches uniformly. (Code path verified against net/ipv4/ip_input.c and net/ipv4/route.c, v6.12.)
The Routing Key — struct flowi4
A route lookup is not “look up the destination IP.” Linux routes on a flow key, struct flowi4, assembled fresh for each lookup. In ip_route_input_slow() the key is built field-by-field (net/ipv4/route.c, v6.12):
fl4.flowi4_l3mdev = 0;
fl4.flowi4_oif = 0; /* output interface: 0 = let routing pick */
fl4.flowi4_iif = dev->ifindex; /* input interface */
fl4.flowi4_mark = skb->mark; /* firewall/conntrack mark for policy routing */
fl4.flowi4_tos = tos; /* Type-of-Service / DSCP bits */
fl4.flowi4_scope = RT_SCOPE_UNIVERSE;
fl4.daddr = daddr; /* destination — the primary match key */
fl4.saddr = saddr; /* source — used for source validation / rules */The fields that actually steer the decision are:
daddr— the destination address. This is the only field the basic FIB trie matches on (longest-prefix). Everything else is a filter or a policy-rule selector.saddr— the source. The plainmain-table lookup ignores it for the route, but it drives reverse-path validation (below) and policy rules (fromselectors, see Policy Routing and Multiple Tables).flowi4_tos— the Type-of-Service / DSCP byte. A route added withtos/dscponly matches packets carrying that value, so TOS can split traffic across routes.flowi4_oif— output interface. On output lookups a nonzerooifconstrains the route to that device; on input it is 0.flowi4_mark— theskb->mark(a 32-bit tag set by netfilter, tc, or sockets). It is meaningless to the FIB trie itself but is a first-class selector for policy routing rules (ip rule add fwmark ... lookup ...), which is howfwmark-based routing works.
Uncertain
Verify: the precise set of
flowi4fields that participate in the plainmain-table FIB-trie match versus those used only by rules (fib4_rules). Reason:fib_table_lookup()matches ondaddr(longest prefix) and filters ontos/dscpandscope;saddr/mark/oifenter mainly through__fib_lookup()→fib_rules_lookup()whenCONFIG_IP_MULTIPLE_TABLESand custom rules exist. The boundary is config- and rule-dependent. To resolve: tracefib_table_lookup()innet/ipv4/fib_trie.c(matchesflp->daddr,flp->flowi4_tos/fa_dscp,flowi4_scope) againstfib4_rule_match()innet/ipv4/fib_rules.c. uncertain
Mechanical Walk-through — The Input Path
When a frame is determined to be IPv4, ip_rcv() runs sanity checks (version, header length, checksum, total length) in ip_rcv_core(), then hands the packet to ip_rcv_finish() via the NF_INET_PRE_ROUTING netfilter hook. ip_rcv_finish() calls ip_rcv_finish_core() (net/ipv4/ip_input.c, v6.12), which is where the routing decision lives.
Step 1 — can we skip the lookup? Two shortcuts can pre-populate skb’s dst so no FIB lookup is needed. The first is the lookup hint (ip_can_use_hint() / ip_route_use_hint()): when a list of packets is processed together (ip_list_rcv), a packet with the same destination as its predecessor reuses the predecessor’s route. The second is early demux: for TCP and UDP, if sysctl_ip_early_demux is on and the packet is not a fragment, tcp_v4_early_demux() / udp_v4_early_demux() try to find the owning struct sock directly and borrow its cached dst, skipping routing entirely for established connections. Both are pure optimizations; if neither fires, skb_valid_dst(skb) is false and the real lookup runs.
Step 2 — ip_route_input_noref(). This masks the TOS to the DSCP bits, takes the RCU read lock, and calls ip_route_input_rcu(). Multicast destinations are split off to ip_route_input_mc(); everything else falls through to ip_route_input_slow() — the heart of the decision (net/ipv4/route.c, v6.12).
Step 3 — martian and special-address checks. Before any FIB lookup, ip_route_input_slow() rejects “martian” sources (multicast or limited-broadcast source addresses, zeronet sources) and martian destinations (zeronet destination, loopback addresses arriving on a non-loopback device unless route_localnet is set). Limited broadcast (255.255.255.255) and the all-zeros pair short-circuit to brd_input. These are RFC 1812 sanity rules that the FIB trie cannot easily express.
Step 4 — fib_lookup(). The flow key is passed to fib_lookup(net, &fl4, res, 0). This is the longest-prefix-match against the FIB trie, returning a struct fib_result whose type field is the verdict — RTN_LOCAL, RTN_UNICAST, RTN_BROADCAST, RTN_UNREACHABLE, etc. If fib_lookup() returns nonzero (no route) and forwarding is off, the error becomes -EHOSTUNREACH; otherwise it jumps to no_route, which sets res.type = RTN_UNREACHABLE and falls into the local-input path so an ICMP error can be generated. The mechanics of how fib_lookup() walks the trie and chooses a next-hop are owned by The Routing Subsystem and FIB.
Step 5 — branch on res.type.
RTN_LOCAL→ callfib_validate_source()(reverse-path check, below); on successgoto local_input, which allocates anrtablewithdst.input = ip_local_deliverso the packet climbs to the transport layer. The host is the destination.RTN_UNICASTwithIN_DEV_FORWARD(in_dev)true →goto make_route→ip_mkroute_input(), which (possibly after ECMP next-hop selection) allocates anrtablewithdst.input = ip_forward. The packet is not for us; we are a router for it.- Forwarding disabled (
!IN_DEV_FORWARD) →err = -EHOSTUNREACH,goto no_route. The packet is dropped (and an ICMP unreachable may be sent). This is exactly why a host withip_forward=0silently swallows transit traffic. - Non-unicast, non-local (e.g. an address the FIB calls unreachable) →
goto martian_destination.
Step 6 — build and cache the dst. The local_input/make_route paths call rt_dst_alloc() to allocate the rtable, set its input/output pointers, and — if the next-hop and result are cacheable (do_cache) — store it in the next-hop’s per-CPU/per-nexthop dst slot via rt_cache_route() (see caching, below). Finally skb_dst_set(skb, &rth->dst) attaches the answer to the packet. Back in ip_rcv_finish(), dst_input(skb) calls skb_dst(skb)->input(skb) — ip_local_deliver, ip_forward, or ip_error — and the packet continues.
The Output Path — ip_route_output_flow()
A locally generated packet (a send()/connect(), an ICMP reply, a SYN from this host) routes through the output side. The entry point most callers use is ip_route_output_flow(net, flp4, sk), which wraps __ip_route_output_key() → ip_route_output_key_hash() → ip_route_output_key_hash_rcu() (net/ipv4/route.c, v6.12). The structure mirrors input but the constraints differ:
- If the caller supplied a source address (
fl4->saddr), it is validated to be a local address (__ip_dev_find()), unlessFLOWI_FLAG_ANYSRCis set. You cannot normally send from an address you do not own. - If the caller supplied an output interface (
fl4->flowi4_oif), the device is looked up and must beIFF_UP; multicast/broadcast/IGMP destinations can shortcut tomake_routewith an auto-selected source viainet_select_addr(). - If there is no destination (
!fl4->daddr), the route is forced to loopback (INADDR_LOOPBACK,RTN_LOCAL) — this is howconnect()to a local address or0.0.0.0resolves. - Otherwise
fib_lookup()runs.RTN_LOCALresults route via the loopback device (the packet never hits the wire); unicast results callfib_select_path()to pick a next-hop (and an ECMP member if the route is multipath, see The Routing Subsystem and FIB). Then__mkroute_output()builds thertablewithdst.outputset appropriately (ip_outputfor the normal egress path).
The output dst is what TCP caches on the socket (sk->sk_dst_cache) so that a long-lived connection does not re-route every segment — the socket-level equivalent of the old route cache.
Forwarding — ip_forward() in Detail
When the route says dst.input = ip_forward, transit packets land in ip_forward() (net/ipv4/ip_forward.c, v6.12). The sequence:
- Sanity drops. Drop if
pkt_type != PACKET_HOST(the frame was not addressed to our MAC), ifskb->skis set (a forwarded packet should not be socket-owned), or if it failed XFRM/IPsec forward policy. - TTL check (the hop limit).
if (ip_hdr(skb)->ttl <= 1) goto too_many_hops;— a packet arriving with TTL 1 cannot be forwarded; the router emitsICMP_TIME_EXCEEDED/ICMP_EXC_TTLand drops it. This is the mechanism that makestraceroutework: each hop decrements TTL and the hop that hits zero reports itself. - MTU / fragmentation-needed.
ip_exceeds_mtu()checks the outgoing route’s MTU. If the packet is larger and has the Don’t-Fragment bit set, the router sendsICMP_DEST_UNREACH/ICMP_FRAG_NEEDEDcarrying the MTU — the signal that drives Path MTU Discovery — and drops it. - Copy-on-write + TTL decrement.
skb_cow()ensures the header is writable (the buffer may be shared/cloned), thenip_decrease_ttl(iph)decrements TTL and incrementally updates the IP header checksum (TTL is a header field, so the checksum must change). The decrement happens after the COW so we never mutate a shared buffer. - ICMP redirect. If
IPSKB_DOREDIRECTis set (the packet is being sent back out the interface it came in on, toward a better gateway on the same subnet),ip_rt_send_redirect()emits anICMP_REDIRECTadvising the sender of the better next-hop. - The FORWARD hook and transmit. Finally
NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, ...)runs the netfilter FORWARD chain; if accepted,ip_forward_finish()→dst_output()sends the packet out the chosen interface. The next-hop’s MAC is resolved by the neighbour subsystem (ARP) at transmit time.
The master switch is net.ipv4.ip_forward, which gates IN_DEV_FORWARD. Per the kernel docs it is “special, its change resets all configuration parameters to their default state” and defaults to 0 (ip-sysctl.html). Without it, RTN_UNICAST transit packets are dropped at step 5 of the input walk-through, never reaching ip_forward() at all.
Reverse-Path Filtering (rp_filter)
Both locally-destined and forwarded packets run fib_validate_source() → __fib_validate_source() (net/ipv4/fib_frontend.c, v6.12) to defend against source-address spoofing. For RTN_LOCAL/broadcast packets the call is made directly in ip_route_input_slow(); for transit unicast it happens inside __mkroute_input() (net/ipv4/route.c line ~1788, v6.12) — the function on the make_route → ip_mkroute_input() forward path. This matters: reverse-path filtering protects a router against spoofed transit traffic, not just a host against spoofed local-destined traffic. The idea (RFC 3704) is: if I reverse the lookup — route back toward the packet’s claimed source — does the answer point out the interface the packet actually arrived on? If not, the source is implausible and the packet is dropped with -EXDEV, which ip_rcv_finish_core() maps to SKB_DROP_REASON_IP_RPFILTER and counts in LINUX_MIB_IPRPFILTER.
The behaviour is controlled by the rp_filter sysctl, whose effective value is max(conf/all/rp_filter, conf/<iface>/rp_filter) (ip-sysctl.html):
- 0 — no source validation.
- 1 — strict mode (RFC 3704 Strict Reverse Path): the packet must arrive on the interface that is the best reverse route. In
__fib_validate_source()this is theif (rpf == 1) goto e_rpf;branch — if the input device does not match the reverse-lookup’s device, it fails immediately. - 2 — loose mode (RFC 3704 Loose Reverse Path): the source need only be reachable via some interface, not necessarily the one it arrived on. The code retries the lookup with
FIB_LOOKUP_IGNORE_LINKSTATEand only fails if no route to the source exists at all. Loose mode is the correct choice under asymmetric routing (the return path differs from the forward path), where strict mode would wrongly drop legitimate traffic.
The kernel default is 0, but most distributions ship rp_filter=1 or 2 via /etc/sysctl.d, so the “default” you observe depends on the distro, not the kernel.
Uncertain
Verify: that mainstream distributions still default
rp_filterto a nonzero value as of 2026 (historically Fedora/RHEL set 1, Debian/Ubuntu vary; systemd has set strict mode in the past). Reason: this is a userspace/distro policy file, not a kernel constant, and it drifts. To resolve: read/proc/sys/net/ipv4/conf/all/rp_filterand the distro’ssysctl.don the target system. uncertain
Why There Is No Route Cache Anymore (3.6)
Through Linux 3.5 the kernel kept a global, hashed route cache (rt_hash_table): every distinct flow that was routed got a cache entry keyed on the full flow tuple, so repeat lookups were O(1) hash hits. David S. Miller removed it in 3.6: the patch “ipv4: Delete routing cache” was posted 2012-07-01 (netdev — I read the patch text; it deletes ~1000 lines including rt_hash_table, rt_garbage_collect(), and rt_check_expire()) and he later presented the rationale in his 2013 “The Routing Cache is Dead, Now What?” netfilter-workshop slides (title/author/date confirmed from the PDF metadata; the slide body would not convert — see flag). The cache was a security and performance liability: it grew unboundedly under traffic, its garbage collector was complex and DoS-prone (per thermalcircle part 2, “you could easily fill the cache with entries basically by sending packets to random destinations”), and it required a global lock/genid scheme that hurt scaling on many cores. Removal became feasible once the FIB-trie lookup was optimized in 2.6.39 (thermalcircle part 2).
Uncertain
Verify: the finer rationale in DaveM’s “The Routing Cache is Dead, Now What?” slides. Reason: the PDF’s binary did not convert to text via fetch, so only its title/author/date metadata was confirmed; the 3.6-removal facts here rest on the openwall patch text and thermalcircle part 2, both of which I read in full. To resolve: open the PDF directly. uncertain
The replacement model is per-lookup against the FIB trie, which was already fast (longest-prefix in O(W) where W is the address width), made faster and SMP-friendly with RCU. What survives is a much smaller cache: each FIB next-hop (fib_nh_common) carries a per-CPU output dst (nhc_pcpu_rth_output) and a single input dst (nhc_rth_input). When ip_route_input_slow()/__mkroute_output() builds a dst, rt_cache_route() tries to store it in that slot with a cmpxchg; the next packet to the same next-hop reuses it if rt_cache_valid() (the genid still matches). This is a next-hop cache, not a flow cache — it has a tiny, bounded footprint and cannot be exhausted by a flood of distinct destination addresses, which is precisely why it replaced the old design (thermalcircle part 2). Sockets and TCP additionally cache their own output dst (sk_dst_cache), and IP-fragment/PMTU exceptions live in a separate per-next-hop nhc_exceptions hash.
The dst_entry / rtable Result
The product of a routing decision is a struct rtable (include/net/route.h, v6.12) whose first member is a generic struct dst_entry (include/net/dst.h). The fields that matter:
dst.input/dst.output— the function pointers that are the verdict (ip_local_deliver,ip_forward,ip_error;ip_outputon egress).dst.dev— the output network device.dst.obsolete—DST_OBSOLETE_NONE/_DEAD/_FORCE_CHK; a nonzero value forcesdst_ops->check()re-validation, the mechanism by which a cacheddstis invalidated when routes change.rt_type—RTN_UNICAST/RTN_LOCAL/RTN_BROADCAST/RTN_MULTICAST/RTN_UNREACHABLE.rt_flags—RTCF_LOCAL,RTCF_BROADCAST,RTCF_DOREDIRECT, etc.rt_is_input— 1 if thisdstwas built on the input path (selectsnhc_rth_inputvs the per-CPU output slot inrt_cache_route()).rt_uses_gateway/rt_gw4— whether the next-hop is a gateway (off-link router) and, if so, its IPv4 address. Strict-source-route forwarding (opt->is_strictroute && rt->rt_uses_gateway) fails here.rt_genid— the routing generation id; bumping the per-namespace genid is how mass invalidation (“flush the routes”) is implemented without walking everydst.
The dst is reference-counted (__rcuref). skb_dst_set() takes a reference and attaches it; skb_dst_set_noref() attaches a borrowed reference valid only under RCU (used for the cheap cached-dst fast path).
Configuration and Inspection
# Turn this host into a router (the master forwarding switch).
sysctl -w net.ipv4.ip_forward=1
# Reverse-path filter: 1 = strict (RFC3704), 2 = loose (asymmetric-safe).
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth0.rp_filter=2 # effective = max(all, eth0)
# Ask the kernel to *show its routing decision* for a packet, without
# sending anything. This runs the real fib_lookup() machinery:
ip route get 8.8.8.8
# 8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.50 uid 1000
# cache
# 'via' = next-hop gateway, 'dev' = output interface (dst.dev),
# 'src' = chosen source address, 'cache' = a per-nexthop dst was used.
ip route get 8.8.8.8 from 10.0.0.1 iif eth1 mark 0x10
# ^ exercises saddr / iif / fwmark as part of the flow key (policy routing)ip route get is the single best diagnostic: it invokes inet_rtm_getroute() which calls the same ip_route_input_rcu()/ip_route_output_key_hash_rcu() paths as a real packet (net/ipv4/route.c), so its output is the actual decision, not a guess.
Failure Modes and Diagnosis
- Transit packets vanish silently. Almost always
ip_forward=0. The input walk-through dropsRTN_UNICASTpackets with-EHOSTUNREACHbeforeip_forward()runs, so no ICMP is even generated by default. Fix:sysctl net.ipv4.ip_forward=1. - Legitimate traffic dropped on a multi-homed host / asymmetric path.
rp_filter=1(strict) drops packets whose source’s best reverse route points out a different interface. Symptom:IPRPFILTERincrements innstat//proc/net/netstat, drop reasonSKB_DROP_REASON_IP_RPFILTER. Fix: setrp_filter=2(loose) or 0, or fix routing symmetry. ip route getsaysunreachable. No matching FIB entry and forwarding context disallows it — check the route exists and the right table/rule selects it (Policy Routing and Multiple Tables).- Forwarding works but is slow / per-packet expensive. Without the old route cache, every packet does a trie lookup; on a busy router this is normal and expected. The mitigations are the per-next-hop
dstcache (automatic), the netfilter flowtable offload, and hardware/XDP fast paths — not a return of the flow cache. Martian sourcelog spam. A packet arrived with an impossible source (e.g. a loopback or zeronet source on a real interface), logged byip_handle_martian_source()whenlog_martiansis on. Usually a misconfigured peer or spoofing.
See Also
- The Routing Subsystem and FIB — what
fib_lookup()actually does: the FIB tables, the LPC-trie,fib_info/fib_nh, ECMP, and route installation. This note uses the FIB; that note is the FIB. - Policy Routing and Multiple Tables — how
fwmark,from,iif, andip ruleselect which table the lookup consults before the trie match. - The Neighbour Subsystem — resolving the chosen next-hop IP to a link-layer (MAC) address (ARP/NDP) at transmit time.
- The IP Layer — the
ip_rcv/ip_outputenvelope around this decision. - IP Fragmentation and Reassembly — the MTU/
ICMP_FRAG_NEEDEDpathip_forward()triggers. - The Network Receive Path / The Network Transmit Path — the NAPI/softirq context this runs in.
- MOC: Linux Networking Stack MOC (§3, §6)