Skip site navigation (1)Skip section navigation (2)
Date:      Thu, 12 Feb 2026 11:58:56 +0000
From:      bugzilla-noreply@freebsd.org
To:        bugs@FreeBSD.org
Subject:   [Bug 293136] net.route.hash_outbound not automatically enabled for locally originated traffic over multipath ECMP routes
Message-ID:  <bug-293136-227@https.bugs.freebsd.org/bugzilla/>

index | next in thread | raw e-mail

https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=293136

            Bug ID: 293136
           Summary: net.route.hash_outbound not automatically enabled for
                    locally originated traffic over multipath ECMP routes
           Product: Base System
           Version: 15.0-RELEASE
          Hardware: amd64
                OS: Any
            Status: New
          Severity: Affects Some People
          Priority: ---
         Component: kern
          Assignee: bugs@FreeBSD.org
          Reporter: przemyslaw@mika.pro

Created attachment 268002
  --> https://bugs.freebsd.org/bugzilla/attachment.cgi?id=268002&action=edit
iperf test after /usr/local/etc/rc.d/bird forcestart

##TL;DR

net.route.hash_outbound defaults to 0 and the kernel never flips it to 1 when a
routing daemon (such as Bird3) installs multipath ECMP routes into the FIB.
This means locally originated traffic on a FreeBSD box always goes out through
a single next hop instead of being distributed. Forwarded traffic is fine. The
only workaround is a hack involving adding and removing a dummy multipath route
at boot to trigger an internal code path that flips the flag. The kernel should
automatically enable hash_outbound when multipath routes are present.

## Description

We run a FreeBSD router that aggregates bandwidth over multiple WireGuard
tunnels using ECMP (Equal Cost Multi Path). The setup has four primary fiber
tunnels (wg0 through wg3), two LTE backup tunnels (wg5 and wg6), and one mobile
VPN tunnel (wg4). Bird3 installs IPv6 multipath routes into the kernel FIB via
its "merge paths" mechanism. For example, the primary /48 prefix gets four
equal cost next hops (one per fiber tunnel), and the LTE backup group adds
another two. All tunnel endpoints are monitored with BFD. The multipath routes
are correctly present in the routing table, and forwarded traffic (packets
transiting the box) is hashed and distributed across the available next hops as
expected.

However, locally originated traffic (connections initiated by the FreeBSD host
itself, such as DNS queries, NTP, package fetches, BGP sessions sourced from
the box, and so on) is never distributed. It always leaves through the first
next hop in the multipath route. After some digging we found that the sysctl
`net.route.hash_outbound` controls this behavior and it defaults to `0`.

The problem is that this sysctl is not writable at runtime through the normal
`sysctl(8)` interface. The only way we found to flip it to `1` is a hack: we
add a dummy multipath route and immediately remove it at boot time, which
triggers an internal code path that sets `hash_outbound` to `1` as a side
effect. Once flipped, locally originated traffic is correctly hashed across all
ECMP next hops and everything works perfectly.

It would be great to see this fixed so that anyone running ECMP on FreeBSD does
not have to resort to the dummy route workaround.

## How to Reproduce

**Environment:** FreeBSD 15.0-RELEASE-p2, Bird3 (any recent version), multiple
WireGuard tunnels configured as point to point links (our reproducer uses
seven: wg0 through wg3 for primary fiber ECMP, wg5 and wg6 for LTE backup ECMP,
and wg4 for mobile VPN).

Set up the WireGuard tunnels on the FreeBSD box, each with its own /126 IPv6
transport subnet. Install Bird3 and use a configuration similar to the one
appended below. The key parts are the kernel protocol with `merge paths on` and
several static protocols that define the same /48 prefix with multiple next
hops at different preferences (200 for fiber, 100 for LTE, 10 for a blackhole
fallback). After Bird3 starts, verify that the multipath route is in the FIB:

# netstat -rn6 | grep 2001:db8:1::

You should see the prefix with multiple next hops listed (four when all fiber
tunnels are up). To demonstrate the problem clearly, run iperf3 in reverse
mode. Start an iperf3 server on a host reachable through the tunnels (for
example on the remote tunnel endpoint) and then initiate multiple parallel
streams from the FreeBSD box:

# iperf3 -6 -c 2001:db8:1:ffff::2 -R -P8 -t 30

The -R flag makes the remote side send traffic back to the FreeBSD host, so the
locally originated control connection (and the resulting reverse data flows)
should in theory be distributed across the ECMP next hops. While the test is
running, watch each tunnel interface with tcpdump or netstat -I:

# tcpdump -n -i wg0 &
# tcpdump -n -i wg1 &
# tcpdump -n -i wg2 &
# tcpdump -n -i wg3 &

You will find that all iperf3 traffic leaves through only one of the tunnels.
The other three tunnels carry no iperf3 traffic at all, even though the
multipath route has four equal cost next hops.

Now check the sysctl:

# sysctl net.route.hash_outbound
net.route.hash_outbound: 0

Attempting to set it directly fails:

# sysctl net.route.hash_outbound=1

The workaround is to add and immediately delete a dummy multipath route, which
triggers the kernel to flip hash_outbound to 1 internally:

# route add -6 2001:db8:dead::/48 2001:db8:1:ffff::2 2001:db8:1:ffff::6
# route delete -6 2001:db8:dead::/48
# sysctl net.route.hash_outbound
net.route.hash_outbound: 1

After this, locally originated traffic is correctly distributed across all ECMP
next hops.

### Expected Behavior
The kernel should automatically enable net.route.hash_outbound whenever a route
utilizing a nexthop group (ECMP) is successfully installed in the FIB,
regardless of the method used to install it.
Currently, it appears that the logic to flip this flag might be tied to
specific route modification paths (e.g., incrementally adding next hops to an
existing route, which route add likely triggers). However, when a routing
daemon like Bird3 installs a complete multipath route in a single operation (or
replaces an existing one), this check seems to be bypassed. The kernel should
generically verify if the new route is a multipath group after any route
addition and enable the hashing of locally originated traffic if so.

### Actual Behavior

`net.route.hash_outbound` defaults to `0` and is not writable at runtime.
Locally originated traffic always uses a single next hop. The only way to
enable proper ECMP hashing for local traffic is the dummy route side effect
described above.

### Reproducer Bird3 Config (anonymized)

See the attached `bird.conf` bellow. All ASNs, prefixes and addresses have been
replaced with documentation ranges (RFC 3849 / RFC 5737). The original setup
uses real routable prefixes, seven WireGuard tunnels (four fiber primary, two
LTE backup, one mobile VPN) and two BGP sessions to an upstream provider. The
structure and behavior are identical.


```
log "/var/log/bird.log" { debug, trace, info, remote, warning, error, auth,
fatal, bug };
router id 198.51.100.1;

filter as54321_v6
prefix set allnet6;
{
    allnet6 = [
        2001:db8:1::/48
    ];
    if ! (net ~ allnet6) then reject;
    accept;
}

filter export_to_kernel_v6
prefix set peering6;
{
    peering6 = [
        2001:db8:ff::18/126,
        2001:db8:ff::1c/126
    ];
    if (net ~ peering6) then reject;
    accept;
}

filter export_to_kernel_v4 {
    accept;
}

protocol device {
    scan time 5;

    interface "vtnet0" {
        preferred 2001:db8:1:abc::1;
    };
};

protocol direct {
    interface "vtnet0", "wg0", "wg1", "wg2", "wg3", "wg4", "wg5", "wg6";

    ipv4 {
        table master4;
    };
    ipv6 {
        table master6;
    };
}

protocol kernel kipv6 {
    learn;
    merge paths on;
    merge paths limit 4;

    ipv6 {
        table master6;
        import all;
        export filter export_to_kernel_v6;
    };
}

protocol kernel kipv4 {
    ipv4 { export none; import none; };
    learn;
    persist;
}

protocol bfd {
    interface "wg0", "wg1", "wg2", "wg3" {
        min rx interval 300 ms;
        min tx interval 300 ms;
        multiplier 3;
    };

    interface "wg5", "wg6" {
        min rx interval 1000 ms;
        min tx interval 1000 ms;
        multiplier 3;
    };
}

protocol static ecmp_v6_fiber {
    ipv6 { table master6; preference 200; };

    route 2001:db8:1::/48
        via 2001:db8:1:ffff::2 bfd
        via 2001:db8:1:ffff::6 bfd
        via 2001:db8:1:ffff::a bfd
        via 2001:db8:1:ffff::e bfd;
}

protocol static ecmp_v6_lte {
    ipv6 { table master6; preference 100; };

    route 2001:db8:1::/48
        via 2001:db8:1:ffff::12 bfd
        via 2001:db8:1:ffff::16 bfd;
}

protocol static mobile_v6 {
    ipv6 { table master6; preference 250; };
    route 2001:db8:1:abc::/64 via "wg4";
}

protocol static always_announce_v6 {
    ipv6 { table master6; preference 10; };
    route 2001:db8:1::/48 blackhole;
}

template bgp Upstream1_v6 {
    local 2001:db8:ff::1a as 12345;
    source address 2001:db8:ff::1a;

    ipv6 {
        table master6;
        import all;
        export filter as54321_v6;
    };
}

protocol bgp Upstream1_IPv6 from Upstream1_v6 {
    description "Upstream IPv6 session 1";
    neighbor 2001:db8:ff::19 as 54321;
}

template bgp Upstream2_v6 {
    local 2001:db8:ff::1e as 12345;
    source address 2001:db8:ff::1e;

    ipv6 {
        table master6;
        import all;
        export filter as54321_v6;
    };
}

protocol bgp Upstream2_IPv6 from Upstream2_v6 {
    description "Upstream IPv6 session 2";
    neighbor 2001:db8:ff::1d as 54321;
}
```

This report was created using AI but was ultimately approved by a real human.

-- 
You are receiving this mail because:
You are the assignee for the bug.

home | help

Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?bug-293136-227>