[PATCH 1/3] ipv6: route: consider IOAM options in ECMP hash policy 0

From: Hangbin Liu

Date: Wed Oct 28 2026 - 01:46:58 EST


From: Hangbin Liu <liuhangbin@xxxxxxxxxx>

RFC 9486 (C2) requires that the ECMP hash of a packet carrying IOAM
options is the same as the hash of the identical packet without them.

The default multipath hash policy (0) hashes basic.ip_proto, which
ip6_multipath_l3_keys() fills from the raw IPv6 nexthdr. An IOAM
Hop-by-Hop option is inserted ahead of the original next header, so a
transit router sees NEXTHDR_HOP (0) instead of e.g. IPPROTO_TCP and
may pick a different ECMP sibling than for the same packet without
IOAM.

Recover the nexthdr the packet would have had if its Hop-by-Hop
header carried only IOAM options (0x31, and 0x11 for DEX) plus
padding, and use it for the policy-0 key and for the ICMPv6-error
check, so errors that quote a flow keep hashing that flow. Any other
Hop-by-Hop option (Router Alert, Jumbo, ...) keeps hashing exactly as
before. The walk only runs for the rare nexthdr == NEXTHDR_HOP case
and is safe: ipv6_parse_hopopts() has already pulled and validated
the header before routing. ICMPv6-quoted inner headers are not
walked: they are payload and may be truncated.

Fixes: 3edede08ff37 ("ipv6: ioam: Support for IOAM injection with lwtunnels")
Signed-off-by: Hangbin Liu <liuhangbin@xxxxxxxxxx>
---
The ECMP path with and without IOAM is verified by
tools/testing/selftests/net/ioam6_ecmp.sh in the next patch.
---
include/uapi/linux/in6.h | 1 +
net/ipv6/route.c | 67 ++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 66 insertions(+), 2 deletions(-)

diff --git a/include/uapi/linux/in6.h b/include/uapi/linux/in6.h
index 5a47339ef7d7..2e66dc37e1e5 100644
--- a/include/uapi/linux/in6.h
+++ b/include/uapi/linux/in6.h
@@ -145,6 +145,7 @@ struct in6_flowlabel_req {
#define IPV6_TLV_PADN 1
#define IPV6_TLV_ROUTERALERT 5
#define IPV6_TLV_CALIPSO 7 /* RFC 5570 */
+#define IPV6_TLV_IOAM_DEX 17 /* RFC 9486 */
#define IPV6_TLV_IOAM 49 /* RFC 9486 */
#define IPV6_TLV_JUMBO 194
#define IPV6_TLV_HAO 201 /* home address option */
diff --git a/net/ipv6/route.c b/net/ipv6/route.c
index 94e5ef1c4f83..d0761f05d326 100644
--- a/net/ipv6/route.c
+++ b/net/ipv6/route.c
@@ -2342,6 +2342,60 @@ struct dst_entry *ip6_route_input_lookup(struct net *net,
}
EXPORT_SYMBOL_GPL(ip6_route_input_lookup);

+/* RFC 9486 (C2) requires that the ECMP hash of a packet carrying IOAM
+ * options is the same as the hash of the identical packet without them.
+ * Recover the nexthdr if its Hop-by-Hop header carried only IOAM options
+ * and padding, every other packet is hashed exactly as before.
+ */
+static u8 ip6_nexthdr_without_ioam(const struct sk_buff *skb,
+ const struct ipv6hdr *iph)
+{
+ const struct ipv6_opt_hdr *hhdr;
+ struct ipv6_opt_hdr hbuf;
+ bool has_ioam = false;
+ u8 obuf[2], *opt;
+ int off, len, i;
+
+ if (iph->nexthdr != NEXTHDR_HOP)
+ return iph->nexthdr;
+
+ /* The Hop-by-Hop header was already pulled and validated by
+ * ipv6_parse_hopopts() before routing, so this walk is safe.
+ */
+ off = sizeof(*iph);
+ hhdr = skb_header_pointer(skb, off, sizeof(hbuf), &hbuf);
+ if (!hhdr)
+ return iph->nexthdr;
+
+ len = (hhdr->hdrlen + 1) << 3;
+
+ for (i = 2; i < len; ) {
+ opt = skb_header_pointer(skb, off + i, sizeof(obuf), obuf);
+ if (!opt)
+ return iph->nexthdr;
+ if (opt[0] == IPV6_TLV_PAD1) {
+ i++;
+ continue;
+ }
+ if (opt[0] == IPV6_TLV_PADN) {
+ i += opt[1] + 2;
+ continue;
+ }
+ if (opt[0] != IPV6_TLV_IOAM && opt[0] != IPV6_TLV_IOAM_DEX)
+ return iph->nexthdr;
+
+ has_ioam = true;
+
+ /* Keep searching for the next TLV. As long as non-IOAM
+ * options (Router Alert, Jumbo, etc.) exist in the HBH
+ * header, we should preserve the original NEXTHDR_HOP.
+ */
+ i += opt[1] + 2;
+ }
+
+ return has_ioam ? hhdr->nexthdr : iph->nexthdr;
+}
+
static void ip6_multipath_l3_keys(const struct sk_buff *skb,
struct flow_keys *keys,
struct flow_keys *flkeys)
@@ -2353,8 +2407,10 @@ static void ip6_multipath_l3_keys(const struct sk_buff *skb,
const struct icmp6hdr *icmph;
struct ipv6hdr _inner_iph;
struct icmp6hdr _icmph;
+ u8 nexthdr;

- if (likely(outer_iph->nexthdr != IPPROTO_ICMPV6))
+ nexthdr = ip6_nexthdr_without_ioam(skb, outer_iph);
+ if (likely(nexthdr != IPPROTO_ICMPV6))
goto out;

icmph = skb_header_pointer(skb, skb_transport_offset(skb),
@@ -2383,7 +2439,14 @@ static void ip6_multipath_l3_keys(const struct sk_buff *skb,
keys->addrs.v6addrs.src = key_iph->saddr;
keys->addrs.v6addrs.dst = key_iph->daddr;
keys->tags.flow_label = ip6_flowlabel(key_iph);
- keys->basic.ip_proto = key_iph->nexthdr;
+ /* Only for the outer header: an ICMPv6-quoted inner header
+ * is payload, and its (possibly truncated) extension
+ * headers must not be walked here.
+ */
+ if (key_iph == outer_iph)
+ keys->basic.ip_proto = nexthdr;
+ else
+ keys->basic.ip_proto = key_iph->nexthdr;
}
}


--
2.56.0