Skip to content

Border Router cannot route mesh→LAN traffic to hosts using its own advertised on-link prefix (route table in libopenthread_br never covers the self-advertised PIO) #204

Description

@littleant

Note

This issue was researched and written by an AI — Claude (Fable 5), not a human. It was produced by analyzing the ESP-IDF/OpenThread/lwIP sources and the symbol tables of the prebuilt libopenthread_br.a on behalf of the reporting user. The human user reviewed the write-up and personally verified the failure and the workaround on real hardware (packet captures, pings, flashing). Please read with the usual scrutiny applied to automated analysis.

Environment

  • Example: examples/basic_thread_border_router (ESP32-S3 + RCP, Wi-Fi backbone)
  • ESP-IDF: v5.5.4
  • esp-thread-lib (components/openthread/lib): commit 2e2d91a ("update thread-lib for upstream a12ff0d0f")
  • LAN: AVM FRITZ!Box advertising a GUA prefix and its own ULA prefix via RA

Symptom

Thread end devices become unreachable from LAN hosts (e.g. a Matter
controller) whenever the host sources its traffic from the Border Router's
self-advertised on-link prefix. The mesh looks healthy the whole time (BR is
leader, good link quality, devices "connected"), which makes this very hard
to diagnose.

OpenThread's routing manager derives a local on-link prefix from the extended
PAN ID (fd17:9499:2de5:90bf::/64 here) and advertises it as a PIO on the
backbone link whenever it has not discovered a favored on-link prefix from
another router — e.g. transiently at startup or after a Wi-Fi reconnect,
before the infrastructure router's RA has been received/processed. LAN hosts
then autoconfigure fd17:... addresses (1800 s lifetimes) and — by RFC 6724
rule 8 (longest prefix match against the Thread OMR ULA) — prefer them as
source for Thread destinations. Even after the BR later deprecates the
prefix, hosts hold the addresses until the valid lifetime expires, so the
breakage persists long after the triggering window.

Reproduction from a LAN host (ip -6 route get <omr-address> confirmed the
kernel picked the fd17 source):

ping source result
host GUA (2a00:xxxx:xxxx:xx00::/64) works, normal RTT
infra router ULA (fd06:1f1e:b316::/64) works, normal RTT
BR on-link prefix (fd17:9499:2de5:90bf::/64) 100 % loss

Note the infrastructure prefixes are fully favored-eligible per
OnLinkPrefix::IsFavoredOver() (rdisc6 capture of the FRITZ!Box RA):

PIO Valid lifetime Preferred lifetime Flags
2a00:xxxx:xxxx:xx00::/64 (GUA) 7200 s 3600 s on-link + autonomous
fd06:1f1e:b316::/64 (ULA) 7200 s 3600 s on-link + autonomous

so the local on-link prefix advertisement is the transient/redundancy case
that OpenThread intentionally supports — and a later capture showed the OTBR
no longer sending the fd17 PIO (deprecating), consistent with the
intermittent trigger. RFC 4862 address retention on the hosts turns that
transient into a long-lived outage.

Root cause

The Thread-side routing is correct: RoutePublisher publishes ::/0 /
fc00::/7 into network data (and keeps the ULA route published while the
on-link prefix is deprecating, precisely so return traffic still routes to
the BR). Device replies to fd17:... destinations therefore do reach the BR.
The packets are lost inside the BR's LAN-side forwarding.

lwip_hook_ip6_route() / lwip_hook_nd6_get_gw() are implemented in the
closed-source libopenthread_br.a (esp_openthread_route_table.c.obj).
Inspecting the library symbols shows the route table is populated exclusively
from received RAs:

  • esp_openthread_route_table_add_route_entry is referenced only by
    esp_openthread_nd6.c.obj (the ICMPv6 ND receive path)
  • nothing consumes otBorderRoutingGetOnLinkPrefix() to install a route for
    the prefix the BR itself advertises

Since the BR never receives its own RA, no route entry for
fd17:9499:2de5:90bf::/64 ever exists. Mesh→LAN packets destined to hosts in
that prefix either fail routing or are handed to the default gateway learned
from the infrastructure router, which has no route for the prefix — a silent
blackhole for exactly the addresses the BR told the hosts to use.

Expected behavior

Whenever the routing manager publishes/advertises (or is still deprecating)
its local on-link prefix, the BR must be able to deliver packets to that
prefix on-link on the backbone interface — e.g. by installing a corresponding
on-link route in the route table, or by assigning the BR an address inside
the prefix.

Workaround (verified)

Assigning the BR a static backbone-netif address inside the local on-link
prefix fixes it: lwIP gives statically configured addresses an implied
on-link /64 (RFC 5942), and nd6_get_next_hop_entry() checks on-link status
before the ND6_GET_GW hook, so replies are delivered directly via neighbor
discovery:

otIp6Prefix prefix;
esp_netif_t *backbone = esp_openthread_get_backbone_netif();
esp_ip6_addr_t lladdr, addr = {0};

esp_netif_get_ip6_linklocal(backbone, &lladdr);
otBorderRoutingGetOnLinkPrefix(esp_openthread_get_instance(), &prefix);
memcpy(addr.addr, prefix.mPrefix.mFields.m8, 8);
memcpy((uint8_t *)addr.addr + 8, (const uint8_t *)lladdr.addr + 8, 8);
esp_netif_add_ip6_address(backbone, addr, true);

(run on OPENTHREAD_EVENT_ATTACHED / OPENTHREAD_EVENT_DATASET_CHANGED,
refreshed if the prefix changes)

Flashed and verified on the affected network: pings to Thread devices sourced
from the fd17:... host addresses now get replies with normal Thread RTTs,
where they previously had 100 % loss.

This resolves the connectivity loss, but the proper fix belongs in
libopenthread_br, since every deployment where the BR's own on-link prefix
wins source selection on a host is affected.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions