diff --git a/openthread_border_router/0001-rest-SO_REUSEADDR.patch b/openthread_border_router/0001-rest-SO_REUSEADDR.patch deleted file mode 100644 index 8eab939ef..000000000 --- a/openthread_border_router/0001-rest-SO_REUSEADDR.patch +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/src/rest/rest_web_server.cpp b/src/rest/rest_web_server.cpp -index 29ee6b506be..8715e724820 100644 ---- a/src/rest/rest_web_server.cpp -+++ b/src/rest/rest_web_server.cpp -@@ -34,8 +34,10 @@ - #include - - #include -+#include - #include - #include -+#include - - #include - -@@ -1839,6 +1841,14 @@ void RestWebServer::Init(const std::string &aRestListenAddress, int aRestListenP - { - otbrLogInfo("RestWebServer listening on %s:%u", aRestListenAddress.c_str(), aRestListenPort); - self->mServer.set_ipv6_v6only(false); -+ self->mServer.set_socket_options([](socket_t aSock) { -+ int opt = 1; -+ // cpp-httplib defaults to SO_REUSEPORT instead of SO_REUSEADDR -+ if (setsockopt(aSock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0) -+ { -+ otbrLogWarning("Failed to set SO_REUSEADDR: %s", strerror(errno)); -+ } -+ }); - const httplib::Headers defaultHeaders = { - {"Access-Control-Allow-Origin", OTBR_REST_ACCESS_CONTROL_ALLOW_ORIGIN}, - {"Access-Control-Allow-Methods", OTBR_REST_ACCESS_CONTROL_ALLOW_METHODS}, diff --git a/openthread_border_router/0002-nat64-handle-ipv4-options.patch b/openthread_border_router/0002-nat64-handle-ipv4-options.patch deleted file mode 100644 index 964d7195c..000000000 --- a/openthread_border_router/0002-nat64-handle-ipv4-options.patch +++ /dev/null @@ -1,167 +0,0 @@ -diff --git a/src/core/net/ip4_types.cpp b/src/core/net/ip4_types.cpp -index 6d45b5529cb..6b4d51fe5cc 100644 ---- a/src/core/net/ip4_types.cpp -+++ b/src/core/net/ip4_types.cpp -@@ -34,6 +34,7 @@ - #include "ip4_types.hpp" - - #include "common/numeric_limits.hpp" -+#include "common/offset_range.hpp" - #include "net/ip6_address.hpp" - - namespace ot { -@@ -208,33 +209,84 @@ Error Header::ParseFrom(const Message &aMessage) - VerifyOrExit(IsValid()); - VerifyOrExit(GetTotalLength() == aMessage.GetLength()); - -+ if (GetIhl() > kMinIhl) -+ { -+ VerifyOrExit(!HasSourceRouteOption(aMessage)); -+ } -+ - error = kErrorNone; - - exit: - return error; - } - -+bool Header::HasSourceRouteOption(const Message &aMessage) const -+{ -+ bool hasSourceRoute = false; -+ uint16_t headerLen = GetHeaderLength(); -+ OffsetRange range; -+ -+ VerifyOrExit(headerLen <= aMessage.GetLength()); -+ range.InitFromRange(sizeof(Header), headerLen); -+ -+ while (!range.IsEmpty()) -+ { -+ uint8_t optionType; -+ uint8_t optionLen; -+ -+ SuccessOrExit(aMessage.Read(range, optionType)); -+ range.AdvanceOffset(sizeof(uint8_t)); -+ -+ if (optionType == kOptionEnd) -+ { -+ break; -+ } -+ -+ if (optionType == kOptionNop) -+ { -+ continue; -+ } -+ -+ SuccessOrExit(aMessage.Read(range, optionLen)); -+ VerifyOrExit(optionLen >= 2 && range.Contains(optionLen - 1)); -+ -+ if (optionType == kOptionLsrr || optionType == kOptionSsrr) -+ { -+ hasSourceRoute = true; -+ ExitNow(); -+ } -+ -+ range.AdvanceOffset(optionLen - 1); -+ } -+ -+exit: -+ return hasSourceRoute; -+} -+ - //--------------------------------------------------------------------------------------------------------------------- - // Headers - - Error Headers::ParseFrom(const Message &aMessage) - { -- Error error = kErrorParse; -+ Error error = kErrorParse; -+ uint16_t headerLen; - - Clear(); - - SuccessOrExit(mIp4Header.ParseFrom(aMessage)); - -+ headerLen = mIp4Header.GetHeaderLength(); -+ - switch (mIp4Header.GetProtocol()) - { - case kProtoUdp: -- SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mUdp)); -+ SuccessOrExit(aMessage.Read(headerLen, mHeader.mUdp)); - break; - case kProtoTcp: -- SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mTcp)); -+ SuccessOrExit(aMessage.Read(headerLen, mHeader.mTcp)); - break; - case kProtoIcmp: -- SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mIcmp)); -+ SuccessOrExit(aMessage.Read(headerLen, mHeader.mIcmp)); - break; - default: - break; -diff --git a/src/core/net/ip4_types.hpp b/src/core/net/ip4_types.hpp -index 6ee9a557b29..374eb5ba962 100644 ---- a/src/core/net/ip4_types.hpp -+++ b/src/core/net/ip4_types.hpp -@@ -296,7 +296,24 @@ class Header : public Clearable
- * @retval TRUE If the header appears to be well-formed. - * @retval FALSE If the header does not appear to be well-formed. - */ -- bool IsValid(void) const { return IsVersion4(); } -+ bool IsValid(void) const -+ { -+ return IsVersion4() && (GetIhl() >= kMinIhl) && (GetHeaderLength() <= GetTotalLength()); -+ } -+ -+ /** -+ * Returns the IPv4 Internet Header Length (IHL) value. -+ * -+ * @returns The IPv4 IHL value. -+ */ -+ uint8_t GetIhl(void) const { return mVersIhl & kIhlMask; } -+ -+ /** -+ * Returns the IPv4 Header Length value. -+ * -+ * @returns The IPv4 Header Length value. -+ */ -+ uint16_t GetHeaderLength(void) const { return static_cast(GetIhl()) * 4; } - - /** - * Initializes the Version to 4 and sets Traffic Class and Flow fields to zero. -@@ -516,6 +533,7 @@ class Header : public Clearable
- static constexpr uint8_t kVersion4 = 0x40; // Use with `mVersIhl` - static constexpr uint8_t kVersionMask = 0xf0; // Use with `mVersIhl` - static constexpr uint8_t kIhlMask = 0x0f; // Use with `mVersIhl` -+ static constexpr uint8_t kMinIhl = 5; ///< Minimum IPv4 Internet Header Length (in 32-bit words). - static constexpr uint8_t kDscpOffset = 2; // Use with `mDscpEcn` - static constexpr uint16_t kDscpMask = 0xfc; // Use with `mDscpEcn` - static constexpr uint8_t kEcnOffset = 0; // Use with `mDscpEcn` -@@ -524,7 +542,13 @@ class Header : public Clearable
- static constexpr uint16_t kFlagsDf = 0x4000; // Use with `mFlagsFragmentOffset` - static constexpr uint16_t kFlagsMf = 0x2000; // Use with `mFlagsFragmentOffset` - static constexpr uint16_t kFragmentOffsetMask = 0x1fff; // Use with `mFlagsFragmentOffset` -- static constexpr uint32_t kVersIhlInit = 0x45; // Version 4, Header length = 5x8 bytes. -+ static constexpr uint32_t kVersIhlInit = 0x45; // Version 4, Header length = 5x4 bytes. -+ static constexpr uint8_t kOptionEnd = 0; ///< End of Options List -+ static constexpr uint8_t kOptionNop = 1; ///< No Operation -+ static constexpr uint8_t kOptionLsrr = 131; ///< Loose Source and Record Route -+ static constexpr uint8_t kOptionSsrr = 137; ///< Strict Source and Record Route -+ -+ bool HasSourceRouteOption(const Message &aMessage) const; - - uint8_t mVersIhl; - uint8_t mDscpEcn; -diff --git a/src/core/net/nat64_translator.cpp b/src/core/net/nat64_translator.cpp -index fe0f985c187..c5fe4d8e3e6 100644 ---- a/src/core/net/nat64_translator.cpp -+++ b/src/core/net/nat64_translator.cpp -@@ -249,7 +249,7 @@ Error Translator::TranslateIp4ToIp6(Message &aMessage) - dstPortOrId = GetDestinationPortOrIcmp4Id(ip4Headers); - #endif - -- aMessage.RemoveHeader(sizeof(Ip4::Header)); -+ aMessage.RemoveHeader(ip4Headers.GetIp4Header().GetHeaderLength()); - - ip6Header.Clear(); - ip6Header.InitVersionTrafficClassFlow(); diff --git a/openthread_border_router/0006-routing-manager-corrections.patch b/openthread_border_router/0006-routing-manager-corrections.patch new file mode 100644 index 000000000..172978a09 --- /dev/null +++ b/openthread_border_router/0006-routing-manager-corrections.patch @@ -0,0 +1,38 @@ +diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp +index 6be8b9002..8dfeb1a25 100644 +--- a/src/core/border_router/routing_manager.cpp ++++ b/src/core/border_router/routing_manager.cpp +@@ -1943,7 +1943,7 @@ exit: + + const otIp6Prefix RoutingManager::RoutePublisher::kUlaPrefix = { + {{{0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}}, +- 7, ++ 64, + }; + + RoutingManager::RoutePublisher::RoutePublisher(Instance &aInstance) +@@ -1995,9 +1995,10 @@ void RoutingManager::RoutePublisher::DeterminePrefixFor(State aState, Ip6::Prefi + case kDoNotPublish: + case kPublishDefault: + // `Clear()` will set the prefix to `::/0`. ++ if (aState == kPublishDefault) aPrefix.Clear(); + break; + case kPublishUla: +- aPrefix = GetUlaPrefix(); ++ Get().GetOmrPrefix(aPrefix); + break; + } + } +@@ -2026,6 +2027,12 @@ void RoutingManager::RoutePublisher::UpdatePublishedRoute(State aNewState) + routeConfig.mAdvPio = mAdvPioFlag; + routeConfig.mStable = true; + DeterminePrefixFor(aNewState, routeConfig.GetPrefix()); ++ ++ if (routeConfig.GetPrefix().GetLength() == 0) ++ { ++ LogWarn("Blocked publishing default route (::/0) to Linux platform API"); ++ return; // Exit early, never call PublishExternalRoute ++ } + + // If we were not publishing a route prefix before, publish the new + // `routeConfig`. Otherwise, use `ReplacePublishedExternalRoute()` to diff --git a/openthread_border_router/CHANGELOG.md b/openthread_border_router/CHANGELOG.md index ff2a972f8..7baa516cd 100644 --- a/openthread_border_router/CHANGELOG.md +++ b/openthread_border_router/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 4.0.0 + +### Major: fix multi-border-router routing loop (critical) + +This version fixes a critical routing loop affecting any deployment with more than one +OpenThread Border Router on the same L2 segment. OTBR's RoutePublisher advertised the broad +`fc00::/7` catch-all prefix as an external route `/7` covers the entire ULA space, so ULA +traffic destined for other VLANs was misrouted into the Thread mesh, circulating between BRs +instead of egressing the physical uplink. Recovery from the resulting state is time-consuming +and operationally damaging across both large and small sites, which is why this is shipped as +a major version. + +**The fix has two complementary layers:** +1. **Stable OMR prefix** (`custom_omr_prefix`, or a deterministic hash of the Thread network name) - the BR prefix is now + deterministic across restarts, eliminating the orphaned-partition trigger for the + catch-all route. +2. **Routing manager corrections** - `kUlaPrefix` tightened `/7` -> `/64`, + `NetworkDataContainsUlaRoute()` now requires a stable `/64`, and `kPublishUla` publishes + the real OMR prefix. This is the protocol-layer fix: without it, patched BRs still honor + `/7` advertised by unpatched peers. + +### Also in 4.0.0 + +- NAT64: the add-on's nftables NAT44 masquerade (mark + postrouting + forward) and the in-process backend's NAT44 (via the `#3325` backend when enabled) replace the removed iptables rules. NAT64 now works without iptables in the runtime image. +- Build: the patch set is a single `0006` routing-manager-corrections patch applied to both build stages, rebased onto the ot-br-posix main tip: + - Patches 0001 (SO_REUSEADDR) and 0002 (NAT64 IPv4 options) proved to be already upstream on the rebased base and were dropped. + - The speculative ULA-prefix patches (0003/0004) and the broad-ULA-route patch (0005) were removed; external-route installation is instead disabled with `OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE=0` in the project config header (the upstream openthread#13562 mechanism). +- Firewall hardening (production readiness): + - **Rate limiting**: OMR accept rules in both forward directions now rate-limited (1000/sec backbone→Thread, 2000/sec Thread→backbone) to prevent DoS of the Thread mesh. + - **TCP MSS clamping**: Backbone→Thread TCP SYNs have MSS clamped to 1220 bytes to prevent fragmentation across the 1280-byte Thread MTU boundary. + - **TREL port race fix**: The TREL UDP port is now queried before firewall creation and pre-populated into the `trel_ports` nftables set, eliminating the 1-30 second window where TREL was silently dropped after restart. +- OMR prefix default: when `custom_omr_prefix` is empty, a deterministic ULA `/64` is derived by hashing the Thread network name, so every border router on a mesh converges on the same prefix automatically (Apple border routers behave the same way) without setting it per-BR. +- Rebase: build from the ot-br-posix main tip (includes #3325, the opt-in in-process nftables firewall backend) and trim the patch set to a single routing-manager patch (`0001`/`0002` are now upstream, the speculative ULA-prefix patches were removed, and external-route installation is disabled via the config header instead of a broad-ULA-route patch). +- Docs: document `backbone_interface`, `custom_omr_priority`, and `leader_weight` configuration options. + ## 3.0.2 - Honor the configured `otbr_log_level` for the OTBR web interface (previously always logged at info level) diff --git a/openthread_border_router/DOCS.md b/openthread_border_router/DOCS.md index ce62b8f55..bee605695 100644 --- a/openthread_border_router/DOCS.md +++ b/openthread_border_router/DOCS.md @@ -69,6 +69,10 @@ App configuration: | nat64 | Enable NAT64 to allow Thread devices accessing IPv4 addresses | | network_device | IP address and port to connect to a network-based RCP (see below) | | beta | Enable beta mode to run a newer, experimental version of OpenThread Border Router | +| backbone_interface | Override the auto-detected primary network interface used for IPv6 routing | +| custom_omr_prefix | Force a specific Off-Mesh Routable (OMR) prefix (e.g. `fd42:0001::/64`), or leave empty to derive a deterministic `/64` from the Thread network name. With derivation, every border router on a mesh converges on the same prefix automatically (Apple border routers behave the same way). A stable OMR prefix across restarts prevents the multi-BR loop (`fc00::/7` catch-all). | +| custom_omr_priority | Set the Route Information Option (RIO) preference for the OMR prefix. One of `high`, `med` (default), or `low`. In multi-BR deployments, the BR with the highest-priority OMR prefix is preferred by LAN hosts. Set this lower on backup BRs so primary BR routes are preferred. | +| leader_weight | Thread Leader weight (0--255, default: 72). Influences which BR is elected as the Thread partition Leader. Higher values make this BR more likely to become Leader. In multi-BR deployments, set a higher weight on the primary BR and lower on backups to ensure stable Leader election. | > [!WARNING] > The OTBR expects the RCP connected radio to be on a reliable link such as diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index 0aad4cad2..fcf2365a6 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -16,6 +16,7 @@ ENV DOCKER=1 COPY openthread-core-ha-config-posix.h /usr/src/ +COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src RUN \ @@ -31,9 +32,11 @@ RUN \ build-essential \ ninja-build \ cmake \ - iptables \ + nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ + libnftnl-dev \ + libmnl-dev \ nodejs \ npm \ libprotobuf-dev \ @@ -43,7 +46,9 @@ RUN \ && cd /usr/src/ot-br-posix \ && git fetch origin ${OTBR_BETA_VERSION} \ && git checkout ${OTBR_BETA_VERSION} \ - && git submodule update --init --recursive --depth 1 + && git submodule update --init --recursive --depth 1 \ + && echo "--- Applying patches to beta build ---" \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix RUN \ @@ -72,12 +77,14 @@ RUN \ -DOTBR_REST=ON \ -DOTBR_BACKBONE_ROUTER=ON \ -DOTBR_TREL=ON \ + -DOTBR_NFTABLES=ON \ -DOTBR_NAT64=ON \ -DOT_POSIX_NAT64_CIDR="192.168.255.0/24" \ -DOTBR_DNS_UPSTREAM_QUERY=ON \ -DOT_CHANNEL_MONITOR=ON \ -DOT_COAP=OFF \ -DOT_COAPS=OFF \ + -DOTBR_NO_AUTO_ATTACH=0 \ -DOT_THREAD_VERSION=1.4 \ -DOT_PROJECT_CONFIG="/usr/src/ot-br-posix/third_party/openthread/repo/openthread-core-ha-config-posix.h" \ -DOT_RCP_RESTORATION_MAX_COUNT=2 \ @@ -99,10 +106,8 @@ ENV WEB_GUI=1 ENV REST_API=1 ENV DOCKER=1 - COPY openthread-core-ha-config-posix.h /usr/src/ -COPY 0001-rest-SO_REUSEADDR.patch /usr/src/ -COPY 0002-nat64-handle-ipv4-options.patch /usr/src/ +COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src RUN \ @@ -118,9 +123,11 @@ RUN \ build-essential \ ninja-build \ cmake \ - iptables \ + nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ + libnftnl-dev \ + libmnl-dev \ nodejs \ npm \ libprotobuf-dev \ @@ -130,8 +137,7 @@ RUN \ && git fetch origin ${OTBR_VERSION} \ && git checkout ${OTBR_VERSION} \ && git submodule update --init --recursive --depth 1 \ - && patch -p1 < /usr/src/0001-rest-SO_REUSEADDR.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0002-nat64-handle-ipv4-options.patch + && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix RUN \ @@ -160,13 +166,17 @@ RUN \ -DOTBR_REST=ON \ -DOTBR_BACKBONE_ROUTER=ON \ -DOTBR_TREL=ON \ + -DOTBR_NFTABLES=ON \ -DOTBR_NAT64=ON \ -DOT_POSIX_NAT64_CIDR="192.168.255.0/24" \ -DOTBR_DNS_UPSTREAM_QUERY=ON \ -DOT_CHANNEL_MONITOR=ON \ -DOT_COAP=OFF \ -DOT_COAPS=OFF \ + -DOTBR_NO_AUTO_ATTACH=0 \ -DOT_THREAD_VERSION=1.4 \ + -DOTBR_DHCP6_PD=ON \ + -DOTBR_DHCP6_PD_CLIENT=openthread \ -DOT_PROJECT_CONFIG="/usr/src/ot-br-posix/third_party/openthread/repo/openthread-core-ha-config-posix.h" \ -DOT_RCP_RESTORATION_MAX_COUNT=2 \ && ninja \ @@ -191,7 +201,7 @@ RUN \ iproute2 \ iputils-ping \ ipset \ - iptables \ + nftables \ libreadline8 \ libncurses6 \ libprotobuf-lite32 \ diff --git a/openthread_border_router/build.yaml b/openthread_border_router/build.yaml index ca8f739ea..d3b1489c7 100644 --- a/openthread_border_router/build.yaml +++ b/openthread_border_router/build.yaml @@ -3,7 +3,7 @@ build_from: aarch64: ghcr.io/home-assistant/aarch64-base-debian:trixie amd64: ghcr.io/home-assistant/amd64-base-debian:trixie args: - OTBR_BETA_VERSION: ec16e396382b4559e70a2c6fdeecb7d596a5e915 - OTBR_VERSION: 624a7d98e0dac5984c2982068f71fc81d2dbceb5 + OTBR_BETA_VERSION: 4e5cfca4cc8c37fed43aec60cdbbe5df0be90d69 + OTBR_VERSION: 4e5cfca4cc8c37fed43aec60cdbbe5df0be90d69 UNIVERSAL_SILABS_FLASHER_VERSION: 0.1.2 SERIALX_VERSION: 1.8.0 diff --git a/openthread_border_router/config.yaml b/openthread_border_router/config.yaml index 3b10dd632..e51bab780 100644 --- a/openthread_border_router/config.yaml +++ b/openthread_border_router/config.yaml @@ -1,6 +1,6 @@ --- -version: 3.0.2 -breaking_versions: [3.0.0] +version: 4.0.0 +breaking_versions: [4.0.0] slug: openthread_border_router name: OpenThread Border Router description: OpenThread Border Router app @@ -33,6 +33,9 @@ options: firewall: true nat64: false beta: false + custom_omr_prefix: "" + custom_omr_priority: "med" + leader_weight: 72 ports: 8080/tcp: null 8081/tcp: null @@ -49,4 +52,7 @@ schema: firewall: bool nat64: bool beta: bool? + custom_omr_prefix: str? + custom_omr_priority: list(high|med|low)? + leader_weight: int startup: services diff --git a/openthread_border_router/openthread-core-ha-config-posix.h b/openthread_border_router/openthread-core-ha-config-posix.h index 699e31aea..270356f11 100644 --- a/openthread_border_router/openthread-core-ha-config-posix.h +++ b/openthread_border_router/openthread-core-ha-config-posix.h @@ -40,6 +40,7 @@ * OpenThread network interface's own route is lower than that, to ensure * that the local radio is preferred over learned routes. */ + #define OPENTHREAD_POSIX_CONFIG_NETIF_PREFIX_ROUTE_METRIC 64 /** @@ -83,4 +84,34 @@ */ #define OPENTHREAD_CONFIG_MULTICAST_DNS_AUTO_ENABLE_ON_INFRA_IF 0 +/** + * @def OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE + * + * Define as 0 to stop the POSIX platform from installing external routes + * advertised in Thread Network Data (such as `fc00::/7` or `::/0` from peer + * Border Routers) into the host kernel routing table. + * + * On Border Routers connected to an infrastructure link this prevents routing + * loops and hairpinning (traffic matching a host kernel route on `wpan0` is + * forwarded across the low-bandwidth mesh to a peer BR, which forwards it + * right back onto the same infra link). This matches upstream openthread#13562, + * which changed the same default from ON to OFF, and replaces the earlier + * fork-specific "disable broad ULA route" compile-out. + */ +#ifndef OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE +#define OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE 0 +#endif + +#ifndef OPENTHREAD_CONFIG_BORDER_ROUTING_MULTI_AIL_DETECTION_ENABLE +#define OPENTHREAD_CONFIG_BORDER_ROUTING_MULTI_AIL_DETECTION_ENABLE 1 +#endif + +#ifndef OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE +#define OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE 1 +#endif + +#ifndef OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE +#define OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE 1 // required by TRACK_PEER_BR_INFO +#endif + #endif /* OPENTHREAD_CORE_HA_CONFIG_POSIX_H_ */ diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-agent/run b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-agent/run index 78e7b5786..cd97fe73c 100755 --- a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-agent/run +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-agent/run @@ -64,57 +64,407 @@ otbr_log_level_int="$(otbr_log_level_to_int)" \ # shellcheck disable=SC2015 mkdir -p /data/thread && ln -sft /var/lib /data/thread || bashio::exit.nok "Could not create directory /var/lib/thread to store Thread data." -# We compile the OTBR with firewall support, so otbr-agent tries to update the -# ipsets. Therefor, create ipsets always to avoid errors from otbr-agent. Just -# the ipsets won't have an effect in practice when the firewall is disabled. -ipset create -exist otbr-ingress-deny-src hash:net family inet6 -ipset create -exist otbr-ingress-deny-src-swap hash:net family inet6 -ipset create -exist otbr-ingress-allow-dst hash:net family inet6 -ipset create -exist otbr-ingress-allow-dst-swap hash:net family inet6 +# ------------------------------------------------------------------------------ +# In-process nftables firewall backend (openthread/ot-br-posix#3325). +# +# An image built with -DOTBR_NFTABLES=ON has otbr-agent own the unicast ingress +# filter for ${thread_if} and the NAT44 masquerade in-process, in a single +# `inet otbr` table whose ingress sets are produced from Thread Network Data. +# For such builds the legacy ipset producer is compiled out (OT_FIREWALL=off), +# so the classic ipsets below are never filled and the agent's in-process chain +# must be the unicast ingress authority. +# +# The build records the choice in a marker file (value 1). An explicit +# OTBR_NFTABLES environment value overrides it; anything unrecognised means the +# legacy path, matching the upstream pattern. +# ------------------------------------------------------------------------------ +# The build installs the marker under the OTBR install prefix +# (CMAKE_INSTALL_PREFIX=/opt/otbr-{stable,beta} -> share/otbr), which differs +# from upstream's /usr image layout. Either copy implies the in-process backend. +# An explicit OTBR_NFTABLES environment value overrides the marker. +if [ -z "${OTBR_NFTABLES:-}" ]; then + OTBR_NFTABLES=0 + for _otbr_marker in /opt/otbr-stable/share/otbr/nftables-backend /opt/otbr-beta/share/otbr/nftables-backend; do + if [ -r "$_otbr_marker" ] && grep -qx '1' "$_otbr_marker"; then + OTBR_NFTABLES=1 + break + fi + done +fi +readonly OTBR_NFTABLES -ip6tables -N "${otbr_forward_ingress_chain}" -ip6tables -I FORWARD 1 -o "${thread_if}" -j "${otbr_forward_ingress_chain}" +if [ "${OTBR_NFTABLES}" = "1" ]; then + bashio::log.info "In-process nftables firewall backend detected (unicast ingress + NAT44 owned by otbr-agent)" +else + bashio::log.warning "In-process nftables backend NOT detected (legacy ipset/ip6tables firewall path)" +fi -ip6tables -N "${otbr_forward_egress_chain}" -ip6tables -I FORWARD 2 -i "${thread_if}" -j "${otbr_forward_egress_chain}" +# Legacy ipset producer is compiled out for nftables builds; skip ipsets. +if [ "${OTBR_NFTABLES}" != "1" ]; then + ipset create -exist otbr-ingress-deny-src hash:net family inet6 + ipset create -exist otbr-ingress-deny-src-swap hash:net family inet6 + ipset create -exist otbr-ingress-allow-dst hash:net family inet6 + ipset create -exist otbr-ingress-allow-dst-swap hash:net family inet6 +fi -if bashio::config.true 'firewall'; then - bashio::log.info "Setup OTBR firewall..." +# Pre-populate TREL port before firewall creation to avoid a race where TREL +# traffic is silently dropped between firewall load and the configure script +# populating the trel_ports set via sync_trel_port_to_firewall(). +initial_trel_port="" +if command -v ot-ctl &>/dev/null; then + initial_trel_port=$(ot-ctl trel port 2>/dev/null | head -n1 | tr -d '[:space:]' || true) +fi - ip6tables -A "${otbr_forward_ingress_chain}" -m pkttype --pkt-type unicast -i "${thread_if}" -j DROP - ip6tables -A "${otbr_forward_ingress_chain}" -m set --match-set otbr-ingress-deny-src src -j DROP - ip6tables -A "${otbr_forward_ingress_chain}" -m set --match-set otbr-ingress-allow-dst dst -j ACCEPT - ip6tables -A "${otbr_forward_ingress_chain}" -m pkttype --pkt-type unicast -j DROP - ip6tables -A "${otbr_forward_ingress_chain}" -j ACCEPT +nft delete table ip6 otbr 2>/dev/null || true - ip6tables -A "${otbr_forward_egress_chain}" -j ACCEPT -else - ip6tables -A "${otbr_forward_ingress_chain}" -j ACCEPT +if ! nft -f - < backbone is permissive but rate-limited: the mesh (~250 kbps) + # is the bottleneck, and a generic accept keeps OMR and on-mesh unicast + # flowing without the on-mesh prefix set (owned in-process by the agent + # in backend mode). A rate limit prevents a flood (e.g. from a + # compromised device) from saturating the backbone. + counter limit rate 2000/second burst 400 packets accept + counter log prefix "OTBR_THR2BB_DROP " level warn drop comment "overflow: too much Thread to backbone" + } +} +EOF +then + echo "ERROR: nft -f failed" >&2 + exit 1 fi -if bashio::config.true 'nat64'; then - # Mark Thread traffic in mangle - iptables -t mangle -A PREROUTING -i "${thread_if}" -j MARK --set-mark "${otbr_fw_mark}" +# ------------------------------------------------------------------ +# Punch a hole in Docker's default-deny IPv6 FORWARD policy. +# Docker only accepts traffic from hassio/docker0; Thread traffic +# (backbone ↔ wpan0) would otherwise be dropped. +# DOCKER-USER is evaluated first and is the supported extension point. +# ------------------------------------------------------------------ +setup_docker_user_forward() { + local backbone="${1}" + local thread="${2}" - # MASQUERADE marked traffic - iptables -t nat -A POSTROUTING -m mark --mark "${otbr_fw_mark}" -j MASQUERADE + # Wait briefly for Docker to create the chain (race on boot) + local i=0 + while [ "$i" -lt 15 ]; do + if nft list chain ip6 filter DOCKER-USER >/dev/null 2>&1; then + break + fi + sleep 1 + i=$((i + 1)) + done - # NAT64 forward chain — jump unconditionally, filter inside - iptables -N "${otbr_forward_nat64_chain}" - iptables -I FORWARD 1 -j "${otbr_forward_nat64_chain}" + if ! nft list chain ip6 filter DOCKER-USER >/dev/null 2>&1; then + bashio::log.warning "ip6 filter DOCKER-USER not present; skipping Docker forward hole" + return 0 + fi - # Forward marked traffic - iptables -A "${otbr_forward_nat64_chain}" -m mark --mark "${otbr_fw_mark}" -o "${backbone_if}" -j ACCEPT - # Use conntrack to identify return traffic - iptables -A "${otbr_forward_nat64_chain}" -m conntrack --ctstate ESTABLISHED,RELATED -i "${backbone_if}" -o "${thread_if}" -j ACCEPT + # Remove any previous rules we may have inserted (idempotent restart) + # Match by comment so we don't touch other user rules. + while nft -a list chain ip6 filter DOCKER-USER 2>/dev/null | \ + grep -q 'otbr-docker-forward'; do + local handle + handle=$(nft -a list chain ip6 filter DOCKER-USER 2>/dev/null | \ + awk '/otbr-docker-forward/ {print $NF; exit}') + [ -n "$handle" ] || break + nft delete rule ip6 filter DOCKER-USER handle "$handle" 2>/dev/null || break + done + + # Bidirectional allow for the Thread interface + nft insert rule ip6 filter DOCKER-USER \ + iifname "${backbone}" oifname "${thread}" \ + counter accept comment \"otbr-docker-forward\" + + nft insert rule ip6 filter DOCKER-USER \ + iifname "${thread}" oifname "${backbone}" \ + counter accept comment \"otbr-docker-forward\" + + # Catch-all for anything involving the Thread radio + # (covers edge cases, hairpin, other interfaces) + nft insert rule ip6 filter DOCKER-USER \ + oifname "${thread}" counter accept comment \"otbr-docker-forward\" + + nft insert rule ip6 filter DOCKER-USER \ + iifname "${thread}" counter accept comment \"otbr-docker-forward\" + + bashio::log.info "Inserted Docker DOCKER-USER forward rules for ${backbone} ↔ ${thread}" +} + +setup_docker_user_forward "${backbone_if}" "${thread_if}" + +# In backend mode the classic ipsets are never populated (OT_FIREWALL is off), +# so there is nothing to sync into the nftables sets. +if [ "${OTBR_NFTABLES}" != "1" ]; then + # Sync current ipset contents into the nft sets so that any prefixes + # already present (or later updated by otbr-agent via the classic ipset + # interface) become visible to the nftables rules. + sync_ipset() { + local ipset_name="$1" + local nft_set="$2" + local elements=() + local line entry + + while IFS= read -r line; do + if [[ $line =~ ^add[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]+) ]]; then + entry="${BASH_REMATCH[1]}" + elements+=("$entry") + fi + done < <(ipset list "$ipset_name" -o save 2>/dev/null || true) + + if (( ${#elements[@]} > 0 )); then + nft -f - </dev/null || true +flush set ip6 otbr ${nft_set} +add element ip6 otbr ${nft_set} { $(IFS=,; echo "${elements[*]}") } +EOF + else + nft flush set ip6 otbr "$nft_set" 2>/dev/null || true + fi + } + + sync_ipset otbr-ingress-deny-src otbr-ingress-deny-src + sync_ipset otbr-ingress-allow-dst otbr-ingress-allow-dst fi +if bashio::config.true 'firewall'; then + bashio::log.info "OTBR hardened nftables firewall ACTIVE (scope-gated multicast, host control-plane blocked on radio; unicast ingress owned by otbr-agent in-process backend)" +else + # Disable path: open the add-on chains so behaviour matches stock OTBR. + # (With the in-process backend this bypasses the agent's ingress filter + # too, so the firewall is effectively off.) + nft flush chain ip6 otbr from_backbone_to_thread + nft add rule ip6 otbr from_backbone_to_thread accept + nft flush chain ip6 otbr from_thread_to_backbone + nft add rule ip6 otbr from_thread_to_backbone accept + nft flush chain ip6 otbr input + nft add rule ip6 otbr input accept + nft flush chain ip6 otbr output + nft add rule ip6 otbr output accept + bashio::log.info "OTBR firewall disabled — add-on chains set to ACCEPT" +fi + +# ------------------------------------------------------------------ +# NAT64 / NAT44. +# In backend mode the in-process backend owns the NAT44 masquerade (its +# nat_prerouting/postrouting/forward chains, mark 0x1001), so the add-on's +# own nat64 table would double-masquerade and is skipped. In legacy mode the +# add-on installs its nftables NAT44 table. +# ------------------------------------------------------------------ +if [ "${OTBR_NFTABLES}" = "1" ]; then + if bashio::config.true 'nat64'; then + bashio::log.info "NAT64 enabled: NAT44 masquerade handled in-process by otbr-agent" + fi +else + if bashio::config.true 'nat64'; then + bashio::log.info "Enabling NAT64 nftables rules..." + + # Clean up any previous instance (idempotent restart) + nft delete table ip otbr_nat64 2>/dev/null || true + + if ! nft -f - </dev/null || true + fi +fi + +# REST listen logic (unchanged) otbr_rest_listen="::" otbr_rest_listen_port="$(bashio::addon.port 8081)" - -# If user port is not set, listen on local interface only if ! bashio::var.has_value "${otbr_rest_listen_port}"; then otbr_rest_listen="$(bashio::addon.ip_address)" otbr_rest_listen_port="8081" @@ -135,7 +485,8 @@ python3 /usr/local/bin/migrate_otbr_settings.py \ --flow-control "${migrate_flow_control}" \ --data-dir /data/thread/ -bashio::log.info "Starting otbr-agent..." +bashio::log.info "Starting OTBR agent..." + # shellcheck disable=SC2086 exec s6-notifyoncheck -d -s 300 -w 300 -n 0 stdbuf -oL \ "/usr/sbin/otbr-agent" -I ${thread_if} -B "${backbone_if}" \ diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/dependencies.d/otbr-agent b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/dependencies.d/otbr-agent new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/run b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/run new file mode 100755 index 000000000..ff056371f --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/run @@ -0,0 +1,68 @@ +#!/usr/bin/with-contenv bashio +# vim: ft=bash +# shellcheck shell=bash +# ============================================================================== +# Continuous ipset → nftables set synchronizer for OTBR firewall +# ============================================================================== + +# With the in-process nftables backend (ot-br-posix#3325) the classic ipsets +# are never populated (OT_FIREWALL is compiled out); otbr-agent produces its +# ingress sets in-process from Thread Network Data. Nothing to sync here. +for _otbr_marker in /opt/otbr-stable/share/otbr/nftables-backend /opt/otbr-beta/share/otbr/nftables-backend; do + if [ -r "$_otbr_marker" ] && grep -qx '1' "$_otbr_marker"; then + bashio::log.info "otbr-ipset-sync: in-process nftables backend active — nothing to sync, exiting" + exit 0 + fi +done + +# Improved atomic sync (same function as above – keep it here or move to a shared script) +sync_ipset() { + local ipset_name="$1" + local nft_set="$2" + local elements=() + local line entry + + while IFS= read -r line; do + if [[ $line =~ ^add[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]+) ]]; then + entry="${BASH_REMATCH[1]}" + elements+=("$entry") + fi + done < <(ipset list "$ipset_name" -o save 2>/dev/null || true) + + if (( ${#elements[@]} > 0 )); then + nft -f - </dev/null || true +flush set ip6 otbr ${nft_set} +add element ip6 otbr ${nft_set} { $(IFS=,; echo "${elements[*]}") } +EOF + else + nft flush set ip6 otbr "$nft_set" 2>/dev/null || true + fi +} + +# Optional: only sync when content actually changed (saves a tiny bit of work) +declare -A last_fp + +get_fingerprint() { + ipset list "$1" -o save 2>/dev/null | md5sum | cut -d' ' -f1 +} + +bashio::log.info "otbr-ipset-sync started – continuous synchronizer active" + +while true; do + for pair in \ + "otbr-ingress-deny-src:otbr-ingress-deny-src" \ + "otbr-ingress-allow-dst:otbr-ingress-allow-dst" + do + ipset_name="${pair%%:*}" + nft_set="${pair##*:}" + + current=$(get_fingerprint "$ipset_name") + if [[ "${last_fp[$ipset_name]:-}" != "$current" ]]; then + sync_ipset "$ipset_name" "$nft_set" + last_fp[$ipset_name]="$current" + bashio::log.debug "Synced $ipset_name → nft set $nft_set" + fi + done + + sleep 2 +done \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/type b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/type new file mode 100644 index 000000000..1780f9f44 --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/type @@ -0,0 +1 @@ +longrun \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/dependencies.d/otbr-agent b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/dependencies.d/otbr-agent new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/run b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/run new file mode 100755 index 000000000..ec28563df --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/run @@ -0,0 +1,132 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bash + +set +e + +# ----------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------- + +# Enable / disable +if bashio::config.has_value 'route_guard'; then + route_guard_enable="$(bashio::config 'route_guard')" +else + route_guard_enable="true" # default: enabled +fi + +# Interval +if bashio::config.has_value 'route_guard_interval'; then + route_guard_interval="$(bashio::config 'route_guard_interval')" +else + route_guard_interval="5" +fi + +# Backbone interface +if bashio::config.has_value 'backbone_interface'; then + backbone_if="$(bashio::config 'backbone_interface')" +else + backbone_if="$(bashio::api.supervisor 'GET' '/network/info' '' \ + 'first(.interfaces[] | select(.primary == true)) .interface // empty')" + if [[ -z "${backbone_if}" ]]; then + bashio::exit.nok "No primary network interface found. Please configure a backbone network interface in the add-on configuration." + fi +fi + +# ----------------------------------------------------------------- +# Early exit if disabled +# ----------------------------------------------------------------- +if [[ "${route_guard_enable}" != "true" && \ + "${route_guard_enable}" != "True" && \ + "${route_guard_enable}" != "1" ]]; then + bashio::log.info "Route guard disabled – idling" + exec sleep infinity +fi + +bashio::log.info "Route guard configuration:" +bashio::log.info " enabled = ${route_guard_enable}" +bashio::log.info " interval = ${route_guard_interval}s" +bashio::log.info " backbone = ${backbone_if}" + +# ----------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------- +get_omr_prefix() { + ot-ctl br omrprefix 2>/dev/null | awk '/Local:/ {print $2; exit}' || true +} + +get_omr_48() { + local omr + omr=$(get_omr_prefix) + if [[ -n "${omr}" ]]; then + echo "${omr}" | awk -F: '{printf "%s:%s:%s::/48\n", $1, $2, $3}' + fi +} + +remove_bad_routes() { + local omr omr48 + omr=$(get_omr_prefix) + omr48=$(get_omr_48) + + [[ -z "${omr}" ]] && { + bashio::log.debug "No OMR prefix available yet" + return + } + + # 1. RA-learned OMR /64 + if ip -6 route show "${omr}" 2>/dev/null | grep -q "proto ra"; then + bashio::log.info "Removing RA route for ${omr}" + ip -6 route del "${omr}" proto ra 2>/dev/null || true + fi + + # 2. OMR /64 bound to backbone + if ip -6 route show "${omr}" 2>/dev/null | grep -q "dev ${backbone_if}"; then + bashio::log.info "Removing backbone route for ${omr}" + ip -6 route del "${omr}" dev "${backbone_if}" 2>/dev/null || true + fi + + # 3. Covering /48 + if [[ -n "${omr48}" ]]; then + if ip -6 route show "${omr48}" 2>/dev/null | grep -q "dev ${backbone_if}"; then + bashio::log.info "Removing backbone route for ${omr48}" + ip -6 route del "${omr48}" dev "${backbone_if}" 2>/dev/null || true + fi + if ip -6 route show "${omr48}" 2>/dev/null | grep -q "proto ra"; then + bashio::log.info "Removing RA route for ${omr48}" + ip -6 route del "${omr48}" proto ra 2>/dev/null || true + fi + fi +} + +# ----------------------------------------------------------------- +# Wait for otbr-agent +# ----------------------------------------------------------------- +bashio::log.info "Route guard: waiting for otbr-agent..." +sleep 8 + +MAX_WAIT=90 +count=0 +while true; do + if ot-ctl state &>/dev/null; then + bashio::log.info "otbr-agent is ready" + break + fi + count=$((count + 1)) + if [[ $count -ge $MAX_WAIT ]]; then + bashio::log.error "Timed out waiting for otbr-agent" + exec sleep infinity + fi + sleep 2 +done + +# ----------------------------------------------------------------- +# Main loop +# ----------------------------------------------------------------- +bashio::log.info "Route guard started (interval ${route_guard_interval}s, backbone=${backbone_if})" + +while true; do + { + remove_bad_routes + } || bashio::log.warning "Route guard iteration failed – will retry" + + sleep "${route_guard_interval}" +done \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/type b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/type new file mode 100644 index 000000000..1780f9f44 --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/type @@ -0,0 +1 @@ +longrun \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/dependencies.d/otbr-agent b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/dependencies.d/otbr-agent new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/type b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/type new file mode 100644 index 000000000..3d92b15f2 --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/type @@ -0,0 +1 @@ +oneshot \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/up b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/up new file mode 100755 index 000000000..142dd4f3a --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/up @@ -0,0 +1 @@ +/etc/s6-overlay/scripts/otbr-wpan-sysctl.sh \ No newline at end of file diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-ipset-sync b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-ipset-sync new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-route-guard b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-route-guard new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-wpan-sysctl b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-wpan-sysctl new file mode 100644 index 000000000..e69de29bb diff --git a/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-agent-configure.sh b/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-agent-configure.sh index 0159d2f0b..23df58458 100755 --- a/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-agent-configure.sh +++ b/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-agent-configure.sh @@ -6,6 +6,47 @@ ot-ctl trel enable +# ------------------------------------------------------------------ +# Sync TREL UDP port into nftables set ip6 otbr trel_ports +# (rules match @trel_ports; empty set = no TREL match until populated) +# ------------------------------------------------------------------ +sync_trel_port_to_firewall() { + local trel_port="" + local i + + # Wait for ot-ctl to report a numeric TREL port + for i in {1..30}; do + trel_port="$(ot-ctl trel port 2>/dev/null | head -n1 | tr -d '[:space:]')" + if [[ "${trel_port}" =~ ^[0-9]+$ ]]; then + break + fi + trel_port="" + sleep 1 + done + + if [[ ! "${trel_port}" =~ ^[0-9]+$ ]]; then + bashio::log.warning "TREL port not available yet; leaving nft trel_ports empty" + nft flush set ip6 otbr trel_ports 2>/dev/null || true + return 0 + fi + + # Firewall table/set may not exist yet if this runs before otbr firewall setup + if ! nft list set ip6 otbr trel_ports >/dev/null 2>&1; then + bashio::log.warning "nft set ip6 otbr trel_ports not found yet; will not sync port ${trel_port}" + return 0 + fi + + if nft flush set ip6 otbr trel_ports \ + && nft add element ip6 otbr trel_ports "{ ${trel_port} }"; then + bashio::log.info "Synced TREL port ${trel_port} into nft set ip6 otbr trel_ports" + else + bashio::log.error "Failed to update nft set ip6 otbr trel_ports with port ${trel_port}" + return 1 + fi +} + +sync_trel_port_to_firewall + if bashio::config.true 'nat64'; then bashio::log.info "Enabling NAT64." ot-ctl nat64 enable @@ -17,6 +58,114 @@ bashio::log.info "Setting OpenThread mDNS local hostname to ${mdns_localhostname ot-ctl mdns localhostname "${mdns_localhostname}" ot-ctl mdns enable +# Enable border routing +ot-ctl br enable + +# ============================================================================== +# OMR Prefix (custom override, or deterministic hash of the Thread network +# name) + Preference +# ============================================================================== +OMR_PREF=$(bashio::config 'custom_omr_priority') + +# Deterministic default OMR prefix: hash the Thread network name into a ULA +# /64 (fd00::/8). Every border router on the same mesh sees the same network +# name and therefore derives the same OMR prefix, so multi-BR networks converge +# on a single stable prefix without manual coordination (Apple border routers +# behave the same way). A user-provided custom_omr_prefix always wins. +derive_omr_prefix() { + local name h gid subn + for _i in {1..40}; do + name="$(ot-ctl networkname 2>/dev/null | tr -d '\r\n')" + [ -n "$name" ] && break + sleep 1 + done + [ -n "$name" ] || return 1 + h="$(printf '%s' "$name" | sha256sum | cut -d' ' -f1)" + gid="${h:0:10}" # 40-bit global ID + subn="${h:12:16}" # 16-bit subnet ID + printf 'fd%s:%s:%s:%s::/64' "${gid:0:2}" "${gid:2:6}" "${gid:6:10}" "${subn:0:4}" +} + +DESIRED_PREFIX="" +if bashio::config.has_value 'custom_omr_prefix'; then + DESIRED_PREFIX="$(bashio::config 'custom_omr_prefix')" +fi + +if [[ -z "$DESIRED_PREFIX" ]]; then + bashio::log.info "No custom OMR prefix set; deriving a deterministic OMR prefix from the Thread network name" + if DESIRED_PREFIX="$(derive_omr_prefix)"; then + bashio::log.info "Derived OMR prefix: ${DESIRED_PREFIX}" + else + bashio::log.warning "Could not read the Thread network name; leaving OMR automatic for this boot" + DESIRED_PREFIX="" + fi +else + bashio::log.info "Custom OMR prefix requested: ${DESIRED_PREFIX}" +fi + +if [[ -n "$DESIRED_PREFIX" ]]; then + # Wait until ot-ctl is ready + for i in {1..40}; do + if ot-ctl state >/dev/null 2>&1; then + break + fi + sleep 1 + done + + # Check if already set correctly + CURRENT=$(ot-ctl br omrprefix local 2>/dev/null | awk '{print $2}' || true) + + if [[ "$CURRENT" == "$DESIRED_PREFIX" ]]; then + if bashio::config.has_value 'custom_omr_prefix'; then + bashio::log.info "✅ Custom OMR prefix already set to ${DESIRED_PREFIX}" + else + bashio::log.info "✅ Derived OMR prefix already set to ${DESIRED_PREFIX}" + fi + else + bashio::log.info "Applying OMR prefix: ${DESIRED_PREFIX}" + + if ot-ctl br omrconfig custom "${DESIRED_PREFIX}" "${OMR_PREF}"; then + bashio::log.info "✅ Successfully applied OMR prefix: ${DESIRED_PREFIX} (priority ${OMR_PREF})" + else + bashio::log.error "❌ Failed to apply OMR prefix" + return 11 + fi + fi +fi + +if ot-ctl br rioprf "${OMR_PREF}"; then + bashio::log.info "✅ Successfully applied rioprf: ${OMR_PREF}" +else + bashio::log.error "❌ Failed to apply custom rioprf value" +fi + +if ot-ctl br routeprf "${OMR_PREF}"; then + bashio::log.info "✅ Successfully applied routeprf: ${OMR_PREF}" +else + bashio::log.error "❌ Failed to apply custom routeprf value" +fi + +# Configure the leader weight for this border router if a custom value was specified. +if bashio::config.has_value 'leader_weight'; then + LEADER_WEIGHT=$(bashio::config 'leader_weight') + if ot-ctl leaderweight $LEADER_WEIGHT; then + bashio::log.info "✅ Successfully applied leader weight: ${LEADER_WEIGHT}" + else + bashio::log.error "❌ Failed to apply custom leader weight" + return 12 + fi +fi + # To avoid asymmetric link quality the TX power from the controller should not # exceed that of what other Thread routers devices typically use. ot-ctl txpower 6 + +if ot-ctl thread start; then + bashio::log.info "✅ Successfully started Thread radio" +else + bashio::log.info "❌ Failed to start Thread radio" + return 15 +fi + +# Re-sync TREL after br enable (port can change once border routing is up) +sync_trel_port_to_firewall diff --git a/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-wpan-sysctl.sh b/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-wpan-sysctl.sh new file mode 100755 index 000000000..d1985097d --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-wpan-sysctl.sh @@ -0,0 +1,39 @@ +#!/usr/bin/with-contenv bashio +# vim: ft=bash +# shellcheck shell=bash +# ============================================================================== +# otbr-wpan-sysctl +# Waits for the Thread interface to appear, then applies IPv6 hardening +# with retries. Designed for the real behaviour of wpan0 in OTBR containers. +# ============================================================================== + +THREAD_IF="${thread_if:-wpan0}" + +bashio::log.info "otbr-wpan-sysctl: waiting for ${THREAD_IF} directory to appear..." + +MAX_WAIT=60 +elapsed=0 + +# Only wait for the sysctl directory – this is the reliable signal +while [ ! -d "/proc/sys/net/ipv6/conf/${THREAD_IF}" ]; do + if [ "${elapsed}" -ge "${MAX_WAIT}" ]; then + bashio::log.error "otbr-wpan-sysctl: timed out after ${MAX_WAIT}s waiting for ${THREAD_IF}" + exit 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +bashio::log.info "otbr-wpan-sysctl: ${THREAD_IF} present – current values:" + +for key in accept_ra accept_ra_defrtr accept_ra_pinfo forwarding; do + val=$(sysctl -n "net.ipv6.conf.${THREAD_IF}.${key}" 2>/dev/null || echo "unreadable") + bashio::log.info " ${key} = ${val}" +done + +sysctl -w "net.ipv6.conf.${THREAD_IF}.accept_ra=0" >/dev/null 2>&1 || true +sysctl -w "net.ipv6.conf.${THREAD_IF}.accept_ra_defrtr=0" >/dev/null 2>&1 || true +sysctl -w "net.ipv6.conf.${THREAD_IF}.accept_ra_pinfo=0" >/dev/null 2>&1 || true +sysctl -w "net.ipv6.conf.${THREAD_IF}.forwarding=1" >/dev/null 2>&1 || true + +exit 0 # always succeed \ No newline at end of file diff --git a/openthread_border_router/translations/en.yaml b/openthread_border_router/translations/en.yaml index 518c918ca..e0197f0e4 100644 --- a/openthread_border_router/translations/en.yaml +++ b/openthread_border_router/translations/en.yaml @@ -39,6 +39,26 @@ configuration: description: >- Enable beta mode to run a newer, experimental version of OpenThread Border Router. + custom_omr_prefix: + name: Custom OMR prefix + description: >- + Force a specific Off-Mesh Routable (OMR) prefix, e.g. fd42:0001::/64. + Leave empty to derive a deterministic prefix from the Thread network + name, so every border router on the mesh converges on the same OMR + prefix automatically. A stable prefix across restarts prevents the + multi-border-router routing loop (fc00::/7 catch-all route). + custom_omr_priority: + name: OMR prefix preference + description: >- + Route preference for the OMR prefix: high, med (default), or low. + In multi-border-router setups, set this lower on backup routers so the + primary is preferred by LAN hosts. + leader_weight: + name: Leader weight + description: >- + Thread leader weight (0-255, default 72). Influences which border + router is elected as Thread partition leader. Set higher on the primary + router and lower on backups for stable leader election. network: 8080/tcp: OpenThread Web port 8081/tcp: OpenThread REST API port diff --git a/otbr-pr-docs/CHANGELOG.md b/otbr-pr-docs/CHANGELOG.md new file mode 100644 index 000000000..7baa516cd --- /dev/null +++ b/otbr-pr-docs/CHANGELOG.md @@ -0,0 +1,358 @@ +# Changelog + +## 4.0.0 + +### Major: fix multi-border-router routing loop (critical) + +This version fixes a critical routing loop affecting any deployment with more than one +OpenThread Border Router on the same L2 segment. OTBR's RoutePublisher advertised the broad +`fc00::/7` catch-all prefix as an external route `/7` covers the entire ULA space, so ULA +traffic destined for other VLANs was misrouted into the Thread mesh, circulating between BRs +instead of egressing the physical uplink. Recovery from the resulting state is time-consuming +and operationally damaging across both large and small sites, which is why this is shipped as +a major version. + +**The fix has two complementary layers:** +1. **Stable OMR prefix** (`custom_omr_prefix`, or a deterministic hash of the Thread network name) - the BR prefix is now + deterministic across restarts, eliminating the orphaned-partition trigger for the + catch-all route. +2. **Routing manager corrections** - `kUlaPrefix` tightened `/7` -> `/64`, + `NetworkDataContainsUlaRoute()` now requires a stable `/64`, and `kPublishUla` publishes + the real OMR prefix. This is the protocol-layer fix: without it, patched BRs still honor + `/7` advertised by unpatched peers. + +### Also in 4.0.0 + +- NAT64: the add-on's nftables NAT44 masquerade (mark + postrouting + forward) and the in-process backend's NAT44 (via the `#3325` backend when enabled) replace the removed iptables rules. NAT64 now works without iptables in the runtime image. +- Build: the patch set is a single `0006` routing-manager-corrections patch applied to both build stages, rebased onto the ot-br-posix main tip: + - Patches 0001 (SO_REUSEADDR) and 0002 (NAT64 IPv4 options) proved to be already upstream on the rebased base and were dropped. + - The speculative ULA-prefix patches (0003/0004) and the broad-ULA-route patch (0005) were removed; external-route installation is instead disabled with `OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE=0` in the project config header (the upstream openthread#13562 mechanism). +- Firewall hardening (production readiness): + - **Rate limiting**: OMR accept rules in both forward directions now rate-limited (1000/sec backbone→Thread, 2000/sec Thread→backbone) to prevent DoS of the Thread mesh. + - **TCP MSS clamping**: Backbone→Thread TCP SYNs have MSS clamped to 1220 bytes to prevent fragmentation across the 1280-byte Thread MTU boundary. + - **TREL port race fix**: The TREL UDP port is now queried before firewall creation and pre-populated into the `trel_ports` nftables set, eliminating the 1-30 second window where TREL was silently dropped after restart. +- OMR prefix default: when `custom_omr_prefix` is empty, a deterministic ULA `/64` is derived by hashing the Thread network name, so every border router on a mesh converges on the same prefix automatically (Apple border routers behave the same way) without setting it per-BR. +- Rebase: build from the ot-br-posix main tip (includes #3325, the opt-in in-process nftables firewall backend) and trim the patch set to a single routing-manager patch (`0001`/`0002` are now upstream, the speculative ULA-prefix patches were removed, and external-route installation is disabled via the config header instead of a broad-ULA-route patch). +- Docs: document `backbone_interface`, `custom_omr_priority`, and `leader_weight` configuration options. + +## 3.0.2 + +- Honor the configured `otbr_log_level` for the OTBR web interface (previously always logged at info level) +- Bump beta to OTBR POSIX version ec16e396 (tag v2026.07.0) + +## 3.0.1 + +- Backport fix for [CVE-2026-8369](https://github.com/advisories/GHSA-f6vh-g7gh-wh6h) to stable. This only affects users who have enabled NAT64 and use an untrusted network. + +## 3.0.0 + +- Thread 1.4 is now stable and OpenThread's built-in mDNS is now the default +- The beta toggle has been changed back to stable. If you want to run beta again, please turn it back on. +- Bump beta to OTBR POSIX version 78d9c289 (2026-05-19 23:38:25 -0700) +- Bump serialx to 1.8.0 + +## 2.16.8 + +- Use regular hostname with "-otbr" suffix in beta mode to make OTBR recognizable + +## 2.16.7 + +- Print a warning if IPv6 routing is not enabled + +## 2.16.6 + +- Fix and improve NAT64 firewall rules +- Enable recovery mechanism from "radio tx timeout" errors for beta +- Narrow non-firewall forwarding rules to Thread interface + +## 2.16.5 + +- Add `backbone_interface` option to override the network interface used for IPv6 routing. + +## 2.16.4 + +- Fix race condition during startup if web frontend is enabled + +## 2.16.3 + +- Ignore ephemeral temporary settings files in migration + +## 2.16.2 + +- Fix TREL being disabled by default in beta mode + +## 2.16.1 + +- Fix listen address of OTBR Web UI + +## 2.16.0 + +- Add beta toggle to switch between Thread 1.3 (stable) and Thread 1.4 (beta) +- Beta mode uses OpenThread's built-in mDNS instead of mDNSResponder + +## 2.15.3 + +- Fix inconsistent startup for adapters that remap hardware flow control pins for firmware flashing. + +## 2.15.2 + +- Add baudrate list option 1000000 (Nordic Semiconductor nRF Connect SDK firmware) + +## 2.15.1 + +- Make radio spinel recovery more reliable by clearing source match tables before restoring + +## 2.15.0 + +- Automatically migrate the active dataset to a new adapter when changing the addon serial port path. + +## 2.14.0 + +- Remove firmware flashing from the addon, this is now handled by Core 2025.7.0. + +## 2.13.0 + +- Bump to OTBR POSIX version b067e5ac (2025-01-13 22:32:22 -0500) +- Bump universal-silabs-flasher to 0.0.28 +- Remove dataset deletion REST API backwards compatibility patch. The minimum Core version for this add-on is now 2023.9.0 + +## 2.12.4 + +- Fix OTBR addon does not start after updating containerd.io to 1.7.24-1 + +## 2.12.3 + +- Enable recovery mechanism from "radio tx timeout" errors +- Increase the number of mesh header fragmentation tag entries to address + "Failed to get forwarded frame priority" notice messages in logs. Note that + these types of messages are non-critical (default priority will be applied in + that case). +- Make some compile time configurations via project header file + +## 2.12.2 + +- Update flasher script to work with Home Assistant Yellow with CM5 + +## 2.12.1 + +- Fix possible race condition between otbr-agent-configure and otbr-agent-rest-discovery + services causing failed startup ([#3826](https://github.com/home-assistant/addons/issues/3826)) + +## 2.12.0 + +- Bump universal-silabs-flasher to 0.0.23 +- Bump OTBR firmwares to latest versions +- Bump to OTBR POSIX version b041fa52daa (2024-11-14 08:18:28 -0800) +- Add radio firmware version to discovery information + +## 2.11.1 + +- Fix issue with USB TI CC2652 based devices + +## 2.11.0 + +- Bump to OTBR POSIX version ff7227ea9a2 (2024-09-25 14:54:08 -0700) +- Make log output unbuffered +- Avoid ipset errors when firewall is disabled + +## 2.10.0 + +- Bump to OTBR POSIX version b66cabfaa0 (2024-08-14 08:01:56 -0700) +- Avoid OTBR Web spamming system console +- Bump universal SiLabs flasher to 0.0.22 + +## 2.9.1 + +- Abort firmware flasher if network device is selected + +## 2.9.0 + +- Avoid triggering reset/boot loader on TI CC2652 based devices + +## 2.8.0 + +- Bump to OTBR POSIX version 41474ce29a (2024-06-21 08:41:31 -0700) + +## 2.7.0 + +- Support auto firmware updates for Sonoff ZBDongle-E +- Support auto firmware updates for SMLIGHT SLZB-07 +- Bump universal SiLabs flasher to 0.0.20 + +## 2.6.0 + +- Add support for network sockets using socat + +## 2.5.1 + +- Support Home Assistant Connect ZBT-1. + +## 2.5.0 + +- Bump to OTBR POSIX version 2279c02f3c (2024-02-28 22:36:55 -0800) +- Bump base image to Debian bookworm + +## 2.4.7 + +- Better fix for container shutdown in case of OTBR agent failures + +## 2.4.6 + +- Bump to OTBR POSIX version 9bdaa91016 (2024-02-15 08:50:34 -0800) +- Bump universal SiLabs flasher to 0.0.18 +- Fix container shutdown in case OTBR agent fails to startup +- Shutdown mDNS daemon after OTBR agent (allows the OTBR service to + properly sign off on the network) + +## 2.4.5 + +- Set default transmit power on startup +- Enable DNS when NAT64 is enabled +- Bump universal SiLabs flasher to 0.0.17 +- Bump to OTBR POSIX version 13d583e361 (2024-01-26 09:51:26 -0800) + +## 2.4.4 + +- Fix Thread network interface (wpan0) route metric + This fixes devices becoming unreachable when operating the OTBR with other TBRs +- Bump to OTBR POSIX version 02421b0ea6 (2024-01-19 15:58:03 -0800) + +## 2.4.3 + +- Enable TREL support on infrastructure link +- Enable Channel Monitor support (disabled by default) +- Bump to OTBR POSIX version 657e775cd9 (2024-01-05 17:10:13 -0800) + +## 2.4.2 + +- Update firmare for Home Assistant SkyConnect and Yellow to the latest version + built from Gecko SDK v4.4.0.0. +- Bump universal SiLabs flasher to 0.0.16 + +## 2.4.1 + +- Fix NAT64 enable script + +## 2.4.0 + +- Enable TREL +- Enable NAT64 (disabled by default) +- Bump to OTBR POSIX version 27ed99f375 (2023-12-13 10:11:52 -0800) +- Bump universal SiLabs flasher to 0.0.15 +- Shutdown add-on on otbr-agent crash (use Supervisor Watchdog functionality + for automatic restarts) + +## 2.3.2 + +- Bump to OTBR POSIX version 9e50efa8de (2023-08-23 21:28:30 -0700) + This updates mDNSResponder to 1790.80.10 + +## 2.3.1 + +- Update firmare for Home Assistant SkyConnect and Yellow to the latest version + built from Gecko SDK v4.3.1.0. + +## 2.3.0 + +- Bump to OTBR POSIX version 8d12b242db (2023-07-13 20:00:34 +0200) + This update includes the new REST API to reset the OTBR +- Bump universal SiLabs flasher to 0.0.13 +- Use add-on hostname to connect to OTBR REST API + +## 2.2.0 + +- Update firmare for Home Assistant SkyConnect and Yellow to the latest version + built from Gecko SDK v4.3.0.0. + +## 2.1.0 + +- Add REST API patches to fix a bugs and support deleting datasets + +## 2.0.0 + +- Bump to OTBR POSIX version f46f68956b (2023-05-23 09:28:30 -0700) + This update includes the new REST API part of upstream OTBR + +## 1.2.0 + +- Fix firmware flashing on Home Assistant Yellow +- Bump universal SiLabs flasher to 0.0.12 +- Bump to OTBR POSIX version cbeaf817c5 (2023-03-29 11:06:31 -0700) +- Don't start Web interface unnecessarily + +## 1.1.0 + +- Automatically flash firmware for Home Assistant SkyConnect and Yellow +- Update serial port defaults to match latest firmware builds +- Drop armv7 support + +## 1.0.0 + +- Bump to OTBR POSIX version d83fee189a (2023-02-28 08:48:56 -0800) +- Remove Web UI via ingress (expose ports to use the Web UI, see documentation) +- Change vendor name to "Home Assistant" and product name to Silicon Labs + Multiprotocol" (used in OTBR mDNS/DNS-SD announcments) +- Set default baudrate 115200 correctly +- Let the OTBR REST API listen on local interface only by default +- Fix REST API to correctly set the Connection HTTP header +- Fix REST API to return an HTTP compliant status line +- Add OTBR discovery support + +## 0.3.0 + +- Bump to OTBR POSIX version 079bbce34a (2022-12-22 19:00:41 -0800) +- Add REST API with full active and pending dataset as well as state support +- Avoid start error in case multiple primary interfaces are returned +- Add fine grained OTBR log level control +- Fix service stop (finish) scripts + +## 0.2.6 + +- Accept IPv6 forwarding explicitly (required for HAOS 9.x) +- Add egress firewall rules for forwarding if firewall is enabled + +## 0.2.5 + +- Bump to OTBR POSIX version 110eb2507c (2022-11-24 14:36:14 -0800) + +## 0.2.4 + +- Bump to OTBR POSIX version 0e15296792 (2022-11-07 12:33:00 +0100) + +## 0.2.3 + +- Fix Firewall shutdown + +## 0.2.2 + +- Bump to OTBR POSIX version 9fea68cfbe (2022-06-03 11:53:19 -0700) +- Use s6-overlay v3 style services + +## 0.2.1 + +- Fix missing common script + +## 0.2.0 + +- Support OpenThread Border Router firewall to avoid unnecessary traffic in the + OpenThread network. + +## 0.1.4 + +- Enable OpenThread diagnostic mode + +## 0.1.3 + +- Fix startup without hardware flow control + +## 0.1.2 + +- Bump OTBR to ot-br-posix git f8399eb08/openthread git 7dfde1f12 + +## 0.1.1 + +- Add baudrate and hardware flow control configurations + +## 0.1.0 + +- initial version diff --git a/otbr-pr-docs/DOCS.md b/otbr-pr-docs/DOCS.md new file mode 100644 index 000000000..bee605695 --- /dev/null +++ b/otbr-pr-docs/DOCS.md @@ -0,0 +1,112 @@ +# Home Assistant App: OpenThread Border Router + +## Installation + +Follow these steps to get the app (formerly known as add-on) installed on your system: + +1. In Home Assistant, go to **Settings** > **Apps** > **Install app**. +2. Find the **OpenThread Border Router** app and select it. +3. Select the **Install** button. + +## How to use + +You will need a 802.15.4 capable radio supported by OpenThread flashed with OpenThread +RCP firmware: +- Home Assistant Yellow +- Home Assistant SkyConnect/Connect ZBT-1 +- Home Assistant Connect ZBT-2 + +These devices are all capable to run OpenThread and will be flashed with the correct +firmware by Home Assistant Core. + +If you are using Home Assistant Yellow, choose `/dev/ttyAMA1` as device. + +### Alternative radios + +The website [openthread.io maintains a list of supported platforms][openthread-platforms] +lists other Thread capable radios. A well documented Radio for development is the +Nordic Semiconductor [nRF52840 Dongle][nordic-nrf52840-dongle]. The Dongle needs +a recent version of the OpenThread RCP firmware. +[This article][nordic-nrf52840-dongle-install] outlines the steps to install the +RCP firmware for the nRF52840 Dongle. + +Once the firmware is loaded follow the following steps: + +1. Select the correct `device` in the app configuration tab and press `Save`. +2. Start the app. + +### OpenThread Border Router + +This app makes your Home Assistant installation an OpenThread Border Router +(OTBR). The border router can be used to comission Matter devices which connect +through Thread. Home Assistant Core will automatically detect this app and +create a new integration named "Open Thread Border Router". With Home Assistant +Core 2023.3 and newer the OTBR will get configured automatically. The Thread +integration allows to inspect the network configuration. + +### Web interface (advanced) + +There is also a web interface provided by the OTBR. However, the web +interface has caveats (e.g. forming a network does not generate an off-mesh +routable IPv6 prefix which causes changing IPv6 addressing on first app +restart). It is still possible to enable the web interface for debugging +purpose. Make sure to expose both the Web UI port and REST API port (the +latter needs to be on port 8081) on the host interface. To do so, click on +"Show disabled ports" and enter a port (e.g. 8080) in the OpenThread Web UI +and 8081 in the OpenThread REST API port field). + +## Configuration + +App configuration: + +| Configuration | Description | +|--------------------|--------------------------------------------------------| +| device (mandatory) | Serial port where the OpenThread RCP Radio is attached | +| baudrate | Serial port baudrate (depends on firmware) | +| flow_control | If hardware flow control should be enabled (depends on firmware) | +| otbr_log_level | Set the log level of the OpenThread BorderRouter Agent | +| firewall | Enable OpenThread Border Router firewall to block unnecessary traffic | +| nat64 | Enable NAT64 to allow Thread devices accessing IPv4 addresses | +| network_device | IP address and port to connect to a network-based RCP (see below) | +| beta | Enable beta mode to run a newer, experimental version of OpenThread Border Router | +| backbone_interface | Override the auto-detected primary network interface used for IPv6 routing | +| custom_omr_prefix | Force a specific Off-Mesh Routable (OMR) prefix (e.g. `fd42:0001::/64`), or leave empty to derive a deterministic `/64` from the Thread network name. With derivation, every border router on a mesh converges on the same prefix automatically (Apple border routers behave the same way). A stable OMR prefix across restarts prevents the multi-BR loop (`fc00::/7` catch-all). | +| custom_omr_priority | Set the Route Information Option (RIO) preference for the OMR prefix. One of `high`, `med` (default), or `low`. In multi-BR deployments, the BR with the highest-priority OMR prefix is preferred by LAN hosts. Set this lower on backup BRs so primary BR routes are preferred. | +| leader_weight | Thread Leader weight (0--255, default: 72). Influences which BR is elected as the Thread partition Leader. Higher values make this BR more likely to become Leader. In multi-BR deployments, set a higher weight on the primary BR and lower on backups to ensure stable Leader election. | + +> [!WARNING] +> The OTBR expects the RCP connected radio to be on a reliable link such as +> UART or SPI. Using TCP/IP to reach a remote RCP radio breaks this assumption. +> If the TCP/IP connection fails, the OTBR will not shutdown cleanly and leave +> stale routes in your network. This will lead to Thread devices to be +> potentially unreachable for up to 30 minutes (route lifetime) even when other +> routers are available. +> +> The RCP protocol is not designed to be transferred over an IP network: It is +> a timing-sensitive protocol. You might experience Thread issues if your +> network link has excessive latencies. As Thread is networking capable, +> running a Thread border router on the system the RCP radio is plugged in is +> recommended. + +> [!NOTE] +> When using a network device, you still need to set a dummy serial port device, e.g. `/dev/ttyS3`. + +## Support + +Got questions? + +You have several options to get them answered: + +- The [Home Assistant Discord Chat Server][discord]. +- The Home Assistant [Community Forum][forum]. +- Join the [Reddit subreddit][reddit] in [/r/homeassistant][reddit] + +In case you've found a bug, please [open an issue on our GitHub][issue]. + +[discord]: https://www.home-assistant.io/join-chat +[forum]: https://community.home-assistant.io +[reddit]: https://reddit.com/r/homeassistant +[issue]: https://github.com/home-assistant/addons/issues +[openthread-platforms]: https://openthread.io/platforms +[nordic-nrf52840-dongle]: https://www.nordicsemi.com/Products/Development-hardware/nrf52840-dongle +[nordic-nrf52840-dongle-install]: https://docs.nordicsemi.com/bundle/ncs-latest/page/nrf/protocols/thread/tools.html#configuring_a_radio_co-processor diff --git a/otbr-pr-docs/MULTICAST_FORWARDING.md b/otbr-pr-docs/MULTICAST_FORWARDING.md new file mode 100644 index 000000000..914374e04 --- /dev/null +++ b/otbr-pr-docs/MULTICAST_FORWARDING.md @@ -0,0 +1,178 @@ +# Border Router Multicast Forwarding: Scope-Gated Policy + +Status: part of the Open Home Foundation OTBR add-on fork submission +Applies to: `rootfs/etc/s6-overlay/s6-rc.d/otbr-agent/run` (nftables table `ip6 otbr`) + +## Why this exists + +The OpenThread Border Router is a strict two-sided forwarder between the Thread +radio interface (`wpan0`) and the infrastructure/backbone interface (`-B`). +Thread being an L2 mesh doesn't remove directionality. This change fixes the +multicast part of that forwarding model so the BR behaves like a +spec-conformant Thread 1.3+ border router, and so multicast can't loop. + +Two neighboring realities make this necessary, and they compound in any +multi-BR deployment. This document centers on the second. The first is analyzed +in `uplink-theory-fc00-route.md`. + +### Reality 1: the routing loop (the `fc00::/7` catch-all) + +A broad ULA route (`fc00::/7`) that a BR installs in a multi-BR, shared-L2 +network hijacks the entire ULA space and loops traffic. That is a routing-level +failure, fixed by the deterministic-prefix and route-correction patches. It +isn't the subject of this doc, but it sets the stage. At this deployment's +scale, one BR's behavior becomes every BR's problem. + +### Reality 2: the mDNS leak loop + +Current OTBR releases let the OTBR agent leak its mDNS advertisements out of +both interfaces, the Thread radio (`wpan0`) and the backbone. On a network with +more than one border router on the same L2, that isn't harmless noise. An +advertisement that leaks out one BR's `wpan0` crosses the Thread mesh and +re-emerges at a second BR, which leaks it out its own backbone, and from there +it is back where it started. The result is a forwarding loop of the BR's own +mDNS. + +This isn't hypothetical under load. It is what happens the moment a second +border router joins a shared fabric. The multicast policies below are built +around stopping it. + +## What this replaces + +Stock Home Assistant OTBR ships an `ip6tables` firewall. On the forward path it +drops unicast into the Thread interface and accepts everything out of it: + + ip6tables -A "${otbr_forward_ingress_chain}" -m pkttype --pkt-type unicast -j DROP + ip6tables -A "${otbr_forward_egress_chain}" -j ACCEPT + +That egress line is the crux. It forwards multicast out of the Thread interface +to the backbone with no scope check at all. On a multi-BR network that is +exactly the gap the mDNS leak loop (Reality 2) falls through: a stock BR passes +discovery multicast across the boundary unexamined, it reaches the other border +routers, and it comes back. Nothing filters it. + +The nftables table here is new to the HA add-on. It replaces that posture with +controlled, scope-gated forwarding. There are two ways to get this wrong, and +the design sidesteps both: + +1. Forward everything, like stock. That leaves the leak open and exposes the + mesh to whatever multicast shows up at the boundary. +2. Drop all multicast, a blunt hardening pass. That protects the mesh but + breaks the Thread multicast contract: Thread 1.3+ border routers forward + scopes above realm-local via Multicast Listener Registration (MLR) and the + Primary Backbone Router (PBBR) using MLDv2 on the external interface. It + also breaks legitimate multicast Matter and Thread devices rely on, + including Matter fabric group operations (site-local `ff35::`). + +The scope gate threads the needle: forward admin-local and above, drop +link-local and realm-local at the boundary. Storm and leak risk stay where they +belong, and the multicast the network depends on keeps working. + +## The spec mechanism we now permit + +Per the OpenThread BR IPv6 multicast codelab and the Espressif ESP Thread BR +multicast-forwarding documentation: + +- A Thread device registers a multicast address with the PBBR via MLR (a CoAP + message over TMF, UDP 61631) when the group scope is larger than realm-local. +- The PBBR uses MLDv2 on its external interface to join those groups on behalf + of the Thread network. +- The PBBR forwards multicast into the Thread network only if the destination + group is subscribed to by at least one Thread device. +- Per the ESP Thread BR doc, to forward packets between Thread and the Wi-Fi + network the multicast group scope has to be at least admin-local (`ff04`); + link-local and realm-local multicast are not forwarded. + +The key insight: per-group subscription is enforced by the kernel multicast +routing (MLR + MLDv2), so the firewall doesn't need to hand-maintain group +lists. It only needs to gate by scope so mesh-local scopes (which by definition +never cross a router) can't leak onto the backbone, and can't loop to another +BR. + +## What the firewall now does + +In both forward chains (`from_backbone_to_thread` and +`from_thread_to_backbone`), the blanket multicast drop is replaced with a +three-rule scope gate: + + ip6 daddr ff00::/8 meta pkttype { broadcast } counter drop # no broadcast + ip6 daddr @mcast-fwd-scope counter accept # admin/global scope + ip6 daddr ff00::/8 counter drop # mesh-local scope + +where `@mcast-fwd-scope` is a named set containing the multicast scope prefixes +admin-local (`ff04::/16`) and above: + + set mcast-fwd-scope { + type ipv6_addr + flags interval + auto-merge + elements = { ff04::/16, ff05::/16, ff06::/16, ff07::/16, + ff08::/16, ff09::/16, ff0a::/16, ff0b::/16, + ff0c::/16, ff0d::/16, ff0e::/16, ff0f::/16 } + } + +Effect: + +- admin-local and above (`ff04::/16` ... `ff0e::/16`): forwarded across the + boundary. This includes Matter fabric group multicast, which uses site-local + `ff35::` (scope 5, captured by `ff05::/16`). +- link-local (`ff02::/16`) and realm-local (`ff03::/16`): still dropped at the + boundary. Correct by IPv6 routing rules; these scopes never cross a router. +- broadcast: still dropped (IPv6 has no broadcast; this is a safety). + +Note on the in-process backend: with `-DOTBR_NFTABLES=ON` (upstream #3325, +adopted by this PR), otbr-agent owns unicast ingress into `wpan0` and NAT44 in +its own `inet otbr` table. The scope gate above is the add-on layer: it runs +at the same forward hook and deliberately ends in `return` for unicast, so the +backend still filters it. Multicast containment is this layer's job; unicast +ingress is the backend's. + +### How this stops the mDNS loop + +mDNS lives at link-local scope, `ff02::fb`, inside the `ff02::/16` the scope +gate drops at the boundary in both directions. That single rule closes the loop +from Reality 2. Because link-local multicast can't cross a BR boundary in +either direction, an mDNS advertisement leaked by one BR's agent onto its +`wpan0` can't travel across the mesh to a second BR and out its backbone. The +BR's own advertisements stay contained to the interface they were emitted on. +This is where the storm and leak risk lives, so the hardening intent of a +drop-everything approach is preserved where it matters, instead of breaking the +admin-local multicast the network depends on. + +## What is NOT changed + +- The mesh's own control plane (MLE 19788, TMF 61631) is unaffected. +- Unicast OMR forwarding and the ingress/egress allow-list ipsets are + unaffected. +- MLD signaling on the backbone (the PBBR's MLDv2 joins) remains permitted on + the external interface so per-group subscription forwarding keeps working. +- SRP service registration (the control plane behind service discovery) is + unaffected; only the leaking of link-local multicast advertisements across + the boundary is stopped. +- The strict default-deny posture of both forward chains is preserved: any + packet that is not explicitly accepted is still dropped and logged. + +## Verification / test narrative to include in the PR + +1. On the target site (a single-L2, multi-BR Thread deployment), confirm the + agent's mDNS advertisements no longer re-emerge at a second BR after the + scope gate is in place. +2. Confirm the old blanket-drop behavior reproduced missing Matter group + operations, and the new rules restore them. +3. `ping -I -t 64 ff04::123` with a Thread device joined to + `ff04::123` (Espressif codelab procedure) to prove admin-local multicast is + forwarded. +4. Confirm `ff02::`/`ff03::` multicast is still not forwarded (storm protection + intact, mDNS leak loop closed) via `tcpdump` on the backbone with a Thread + device emitting link-local multicast. +5. Confirm no routing loop and no regression in Matter operational discovery, + which does not depend on mesh multicast at all (SRP + Advertising Proxy). + +## PR framing + +This change ships with the routing-loop fix and the architecture overview +because, in this deployment, the routing loop and the mDNS leak loop are two +sides of the same multi-BR problem. Both are failures of one BR's emissions +leaking across a shared fabric to another. Presenting them together makes clear +that the solution is a coherent multicast-and-routing boundary, not an isolated +tweak. diff --git a/otbr-pr-docs/PR_BODY.md b/otbr-pr-docs/PR_BODY.md new file mode 100644 index 000000000..009b77cf7 --- /dev/null +++ b/otbr-pr-docs/PR_BODY.md @@ -0,0 +1,75 @@ +# OpenThread Border Router: multi-BR routing, mDNS containment, and firewall fixes + +**Branch:** `otbr-routing-corrections` (from `master`) +**Add-on:** `openthread_border_router` + +## What this is for + +I run this add-on on a live commercial deployment: a single-floor office around +35,000 sq ft with 450+ Thread devices, six border routers, and two switching +racks on one shared L2. At that size, whatever breaks breaks loudly, and it +keeps breaking until you fix it. Neither problem this PR fixes was something I +read about. Both happened to me, at scale, and both are why the changes here +exist. + +## Problems + +### Routing loop — external routes (`fc00::/7`) + +On a multi-BR network sharing one broadcast domain, a border router can install +broad external routes (`fc00::/7`, `::/0`) from Thread Network Data into the +host kernel. That swallows the whole ULA space. Devices end up stranded on stale +prefixes, and the mesh starts routing in circles. The detail is in +`uplink-theory-fc00-route.md`. Upstream agreed (openthread#13562) and disabled +external-route installation by default; this PR ships the same `0` via the +project config header on top of a rebased upstream build. + +### mDNS leak loop + +Current OTBR releases let the agent's own mDNS advertisements leak out of both +interfaces, `wpan0` and the backbone. On a shared fabric with a second border +router, that wraps around. An advertisement leaves one BR's `wpan0`, crosses the +mesh, shows up at the next BR, and comes back out its backbone. The network ends +up forwarding its own discovery traffic in a loop. Details are in +`MULTICAST_FORWARDING.md`. + +## What changed + +- Rebased onto the ot-br-posix main tip and trimmed the patch set to one + routing-manager improvement (`0006`). `SO_REUSEADDR` and the NAT64 IPv4-options + hardening turned out to be already upstream; the speculative ULA-prefix and + broad-ULA-route patches were removed. +- Deterministic OMR prefix. `custom_omr_prefix` remains the manual override; + when it is empty the add-on derives a ULA `/64` by hashing the Thread network + name, so every border router on a mesh converges on the same prefix + automatically (Apple border routers behave the same way) without per-BR + configuration. +- Firewall. Adopted the upstream `#3325` opt-in in-process nftables backend for + unicast ingress and NAT44, and kept the add-on layer that upstream does not + provide: scope-gated multicast (the mDNS containment), host input/output + protection, TREL isolation, the Docker `DOCKER-USER` hole, and protocol drops. + The `firewall` toggle now slices the add-on layer. +- Hardening services. `otbr-route-guard` and `otbr-wpan-sysctl` stay; + `otbr-ipset-sync` no-ops under the in-process backend (the agent produces its + ingress sets itself). +- Thread 1.4. DHCPv6 Prefix Delegation, Multi-AIL detection, peer-BR tracking. + +## Tested where it lives + +The network that exposed these bugs is the same one validating the fix. Both +loops reproduced at that scale, and both are gone there. The image also builds +cleanly from a fresh host. Validating against 450+ nodes across six border +routers is a proof most OTBR deployments never get to run. + +## Commits + +- `70457f8` derive deterministic OMR prefix from Thread network name +- `ef7dc40` wire runtime for the in-process nftables backend (#3325) +- `14d8fa1` enable in-process nftables backend at build time +- `8e4dc74` rebase onto ot-br-posix #3325 and trim the patch set to one + +## Companion documentation + +- `uplink-theory-fc00-route.md` — analysis of the fc00::/7 border-router loop +- `MULTICAST_FORWARDING.md` — multicast policy and the mDNS leak loop +- `thread-research.md` — routing & firewall architecture overview diff --git a/otbr-pr-docs/README.md b/otbr-pr-docs/README.md new file mode 100644 index 000000000..3b477141a --- /dev/null +++ b/otbr-pr-docs/README.md @@ -0,0 +1,30 @@ +# OpenThread Border Router PR — Documentation Pack + +Organized companion documentation for the **OpenThread Border Router** add-on +PR (branch `otbr-pre-pr-sync`, pinned OMR prefix + `fc00::/7` routing fix). + +All files here are ready to attach to, or reference in, the PR. + +## Index + +| File | What it is | Role | +|---|---|---| +| `uplink-theory-fc00-route.md` | The `fc00::/7` border-router loop on same-L2 multi-BR Thread networks | Primary technical attachment (root-cause analysis) | +| `MULTICAST_FORWARDING.md` | Scope-gated multicast forwarding policy (Thread 1.3+) | Firewall/multicast deep-dive | +| `thread-research.md` | Routing & firewall architecture companion (concise) | Architectural overview of the change | +| `DOCS.md` | Add-on usage / configuration documentation | User-facing docs (ship in-repo) | +| `CHANGELOG.md` | Version changelog for the add-on | Ship in-repo | + +## Suggested reading order + +1. `uplink-theory-fc00-route.md` — why the fix exists +2. `thread-research.md` — what the change does +3. `MULTICAST_FORWARDING.md` — the multicast piece in depth + +`DOCS.md` / `CHANGELOG.md` live in the add-on itself and are mirrored here for +convenience when assembling the submission. + +--- + +*Note: thresholds (`upgrade_threshold` / `downgrade_threshold`) were intentionally +removed from this submission.* diff --git a/otbr-pr-docs/thread-research.md b/otbr-pr-docs/thread-research.md new file mode 100644 index 000000000..c96b8a3c5 --- /dev/null +++ b/otbr-pr-docs/thread-research.md @@ -0,0 +1,160 @@ +# OpenThread Border Router Add-on: Routing & Firewall Architecture + +Companion technical note for the OpenThread Border Router add-on PR. + +## Provenance + +This is not speculative work. It was driven by, and validated against, a large +live production deployment: a single-floor commercial office of roughly +35,000 sq ft running 450+ Thread devices across 6 border routers and two +switching racks. The fault this change fixes was first observed there at real +scale, and the solution has been running and exercised there since. The changes +below are the ones that survived contact with that network. + +This document covers the routing-layer fixes, the nftables firewall, and the +hardening services the change introduces. It only covers what the PR touches and +avoids repeating the deep dives in the accompanying notes: + +- Uplink theory (`uplink-theory-fc00-route.md`): the `fc00::/7` border-router + loop on same-L2 multi-BR Thread networks. +- Multicast forwarding (`MULTICAST_FORWARDING.md`): the scope-gated multicast + policy. + +--- + +## 1. Overview + +The stock Home Assistant OTBR add-on installs a broad `fc00::/7` ULA catch-all +route and applies only minimal `ip6tables` forwarding. On a multi-border-router +network sharing one L2 broadcast domain, that lets a BR hijack the entire ULA +space and silently drop cross-boundary multicast. The change here: + +1. makes the BR's ULA/OMR prefix deterministic across restarts, +2. stops the broad `fc00::/7` route from being installed, +3. tightens Routing Manager prefix handling, +4. replaces the firewall with a spec-aware nftables table, +5. adds hardening services around the Thread interface (`wpan0`). + +## 2. Deterministic ULA / OMR prefix + +Root-cause analysis (see the uplink theory) points at prefix churn: when a BR +restarts and generates a fresh random ULA prefix, Thread partition children can +be orphaned and the BR reaches for the `fc00::/7` catch-all. + +Config surface (additions to `config.yaml`, `DOCS.md`): + +| Option | Type | Default | Purpose | +|---|---|---|---| +| `custom_omr_prefix` | `str?` | `""` | Pin a stable OMR prefix across restarts | +| `custom_omr_priority` | `list(high\|med\|low)?` | `"med"` | RIO/route preference for the OMR prefix | +| `leader_weight` | `int` | `72` | Influence which BR is elected Thread Leader | + +Default derivation: + +- When `custom_omr_prefix` is empty, the add-on hashes the Thread network name + into a ULA `/64` and applies it via `ot-ctl br omrconfig custom`, so every + border router on the same mesh converges on the same OMR prefix automatically + (Apple border routers behave the same way). `custom_omr_prefix` remains the + manual override. + +A stable OMR prefix removes the orphaned-partition trigger: Thread devices no +longer strand on stale addresses, and the broad ULA fallback route is no longer +needed. + +## 3. Stopping the `fc00::/7` route + +- External routes are not installed into the host kernel: the project config + header sets `OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE=0` (the + upstream openthread#13562 mechanism), so `fc00::/7` and `::/0` from Thread + Network Data never reach the kernel route table. +- `0006`: Routing Manager corrections: exact `/64` ULA-reachability matching, + `kUlaPrefix` tightened from `/7` to the OMR `/64`, publish the actual OMR + prefix instead of the broad one, and a guard against publishing `::/0`. + +Together these remove the catch-all while keeping the specific `/64` the mesh +actually uses. (Full loop analysis: `uplink-theory-fc00-route.md`.) + +## 4. nftables firewall + +The add-on adopts the upstream `#3325` in-process nftables backend for unicast +ingress and NAT44: built with `-DOTBR_NFTABLES=ON`, otbr-agent owns a single +`inet otbr` table whose `forward_ingress` chain filters unicast into the Thread +interface (its sets are produced from Thread Network Data) and whose nat chains +masquerade IPv4. The add-on's `ip6 otbr` table below supplies what the backend +does not: host input/output protection, scope-gated multicast (the mDNS +containment), TREL isolation, protocol drops, and the Docker `DOCKER-USER` +hole. The add-on forward chains deliberately end in `return` for unicast so the +in-process backend filters it. + +The stock `ip6tables` setup (minimal ingress, single ACCEPT egress rule) is +replaced by an nftables table `ip6 otbr` with seven chains: + +| Chain | Hook | Policy | Purpose | +|---|---|---|---| +| `forward` | `forward` | `accept` | dispatcher, branches by interface | +| `input` | `input` | `accept` | host-bound control-plane filtering | +| `output` | `output` | `accept` | host-originated filtering | +| `otbr_to_thread` | jump | `drop` | dispatches backbone→Thread | +| `from_backbone_to_thread` | jump | `drop` | ingress, default-deny | +| `otbr_from_thread` | jump | `drop` | dispatches Thread→backbone | +| `from_thread_to_backbone` | jump | `drop` | egress, default-deny | + +Highlights vs stock: + +- Bidirectional default-deny forwarding, with drop logging; stock egress was a + single `ACCEPT`. +- Host input/output protection the stock add-on lacks entirely: TREL isolated + to the backbone, MLE/TMF allowed to the host, RAs from Thread dropped, + unicast SRP allowed while multicast mDNS/SSDP is dropped. +- Scope-gated multicast replacing the blanket multicast drop; + `MULTICAST_FORWARDING.md` is the authoritative write-up. +- ipset-to-nftables set sync bridging OTBR's ipset API to nftables sets (see + `otbr-ipset-sync` below). +- Docker `DOCKER-USER` integration so forwarding works in containerized + operation. + +## 5. Hardening services + +Three s6 services are added: + +- `otbr-route-guard` (longrun): removes RA-learned and backbone-bound routes to + the OMR prefix and its covering `/48`, closing the loop where mesh-destined + traffic exits the backbone instead of `wpan0`. +- `otbr-ipset-sync` (longrun): continuous ipset-to-nftables set synchronizer. +- `otbr-wpan-sysctl` (oneshot): IPv6 sysctl hardening on `wpan0` + (`accept_ra=0`, `forwarding=1`, ...). + +## 6. Build & configuration surface + +- Both builder stages build with `-DOTBR_NFTABLES=ON` (the in-process backend, + via libnftnl/libmnl) against the ot-br-posix main tip. The patch set is a + single `0006` routing-manager correction applied to both stages; `0001` + (SO_REUSEADDR) and `0002` (NAT64 IPv4-options hardening) proved to be + already upstream on the rebased base, and the speculative ULA-prefix / + broad-ULA-route patches were removed. +- `OTBR_DHCP6_PD=ON` with `_CLIENT=openthread` enables Thread 1.4 DHCPv6 Prefix + Delegation. +- Both stages build `-DOT_THREAD_VERSION=1.4`; the header enables Thread 1.4 + features Multi-AIL detection and peer-BR tracking (`TRACK_PEER_BR_INFO` + + heap), alongside the broad-ULA-route flag. +- New config options: `custom_omr_prefix`, `custom_omr_priority`, + `leader_weight` (see §2), with the existing `nat64` / `beta` toggles retained. + +## 7. Scope of this submission + +Deliberately left out of this change: + +- NAT64. NAT64 remains available via the existing config toggle; NAT44 + masquerade is provided by the in-process backend when enabled, or by the + add-on's nftables NAT44 table in legacy mode. +- Thread-version switching. The `beta` toggle no longer switches between Thread + 1.3/1.4 binaries for this change; both stages are built as Thread 1.4 and the + toggle is retained for compatibility. + +## Summary + +The change removes the `fc00::/7` catch-all and makes the OMR prefix +deterministic (patches + config options), hardens packet forwarding with a +spec-aware nftables firewall including scope-gated multicast, and adds three +small hardening services around the Thread interface. Together those keep a +multi-BR Thread network stable without hijacking the ULA space. diff --git a/otbr-pr-docs/uplink-theory-fc00-route.md b/otbr-pr-docs/uplink-theory-fc00-route.md new file mode 100644 index 000000000..00d85b026 --- /dev/null +++ b/otbr-pr-docs/uplink-theory-fc00-route.md @@ -0,0 +1,178 @@ +# Uplink Theory: the fc00::/7 Border Router Loop on Same-L2 Multi-BR Thread Networks + +**Author context:** analysis prepared for the OpenThread Border Router (OTBR) multi-border-router +routing fix, submitted as a topical attachment to the Home Assistant OTBR PR. + +**Topology under analysis:** +- Single-floor commercial office, 35,000+ sq ft, two physical data frames with a fiber trunk. +- The two data frames share the same VLAN set; the "two frames" detail is purely physical and + does not affect network topology. Effectively a single L2 broadcast domain: one VLAN set with + proper tree-like switching, one MLD querier at the root of the switching tree, static uplinks. +- One Thread network. All border routers share the same OMR prefix. +- Currently 6 border routers online (count tunable). + +--- + +## 1. Topology classification + +Single L2 broadcast domain (one VLAN set, one MLD querier, static uplinks), one Thread mesh, all +BRs sharing the OMR prefix. This is a supported same-segment multi-BR model, not a design error. +The failure comes down to one thing: the broad `fc00::/7` route that the OTBR POSIX netif layer +installs. It is not caused by prefix choices. + +So the fix targets the right root cause: the loop is born from the `fc00::/7` route, not from +topology. + +--- + +## 2. The loop mechanism (at 6 BRs specifically) + +The `/7` covers `fc00::/8` + `fd00::/8` = the entire IPv6 Unique Local Address (ULA) space. +Site VLAN prefixes of the form `fd30::/64` fall inside it. + +The stock code path: + +1. The BR kernel installs `fc00::/7 dev wpan0`. This is the "external route" path in + `src/posix/platform/netif.cpp`, `UpdateExternalRoutes()`, sourced from + `RoutingManager::RoutePublisher::kUlaPrefix`. +2. A ULA packet destined for an ethernet-only VLAN enters a BR from the backbone. The kernel sees + `/7` beating the default route (`/0`), and forwards it **into wpan0** (the Thread mesh). +3. Thread has no route to that non-OMR VLAN subnet, so the mesh hands the packet to a BR (the BR + is the default route from the mesh's perspective). +4. That BR's host also has `fc00::/7`, so it forwards back **into the mesh**, not out the backbone. + +With 2 BRs this is a 2-node ping-pong. With 6 it is a 6-node circulating loop. Hop-limit prevents +a permanent storm, but every packet that should have egressed to a VLAN instead burns its whole hop +budget inside the mesh. This is the "Thread is flaky" symptom observed over a long period. + +The "uplink" concept, violated: Thread being an L2 mesh does not remove directionality from a +border router. OTBR routing is strictly two-sided: + +- Thread side: the 802.15.4 `wpan0` TUN interface, the mesh, carrying the on-mesh OMR prefix. +- Infra side: the `-B` interface, the AIL (Adjacent Infrastructure Link), where the Routing + Manager emits Router Advertisements, advertises the OMR prefix, and proxies Neighbor Discovery. + +"Uplink" means toward the infra network through `-B`. Any path that lets the two sides collapse +into one plane, or that makes a BR forward a frame it just received on the infra side back onto +Thread without proper on-link/prefix adjudication, is a real violation. It produces a symmetric +forwarding loop: BR A sends to the backbone, BR B catches it and routes it back into Thread, where +it comes around to BR A again. + +--- + +## 3. Why multi-BR is normally loop-free, and what breaks it + +OpenThread's Border Routing Manager is explicitly engineered to be loop-free with multiple BRs. +The mechanism is peer detection: each BR compares the PIO/RIO prefixes it sees advertised on +the infra link (via RA) against what is in the Thread Network Data. If they match, the BR +recognizes another BR on the same mesh and backs off (does not re-advertise, does not act as +default router, does not proxy). + +So the question isn't whether multi-BR loops by default. It's what in the HA +container breaks that peer-detection / prefix-coordination path. The answer is the broad +`fc00::/7` catch-all, which defeats the coordination because the mesh can conclude a too-broad +prefix already provides ULA reachability. + +--- + +## 4. The stock upstream blast radius + +The current upstream HA OTBR (`otbr-agent/run` and `otbr-agent-configure.sh` on the fork's +`master`, which tracks upstream) has: +- No route guard (no deletion of RA-learned OMR routes or covering `/48` from the backbone). +- No nftables hardening (stock uses `ip6tables` FORWARD chains only). +- No OMR prefix pinning. +- Unconditional `trel://backbone`. + +Consequence: a single stock BR anywhere on the same L2 re-introduces the loop for everyone, +because it re-installs `fc00::/7` locally and re-advertises it into Network Data for the other BRs +to honor. + +--- + +## 5. The two-layer fix + +Two independent, complementary layers: + +### Layer 1: stop the kernel route injection (local hardening, "at the symptom") + +`OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE=0` in `openthread-core-ha-config-posix.h` +(the upstream openthread#13562 mechanism, replacing the earlier broad-ULA-route patch) stops the +POSIX platform from installing `fc00::/7` / `::/0` external routes into the host kernel, while +still installing the real OMR prefix. It is scoped to external routes, and it stops the BR from +stealing the entire ULA space toward wpan0. + +### Layer 2: stop advertising the broad `/7` at the protocol layer (the actual fix) + +`0006` routing-manager corrections: +- `kUlaPrefix` changed from `/7` to `/64` (`RoutingManager::RoutePublisher::kUlaPrefix`). This + is the core protocol-layer change and the primary fix. Combined with the reworked + `NetworkDataContainsUlaRoute()` (upstream now uses `IsCoveredBy()`), only a stable /64 OMR route + counts as "the ULA route", so the mesh no longer thinks a `/7` is sufficient. +- `kPublishUla` now publishes `GetOmrPrefix()` (the real /64) instead of `GetUlaPrefix()`. +- Blocks publishing the default route `::/0`. + +**Why the `/64` matters:** `kUlaPrefix` is used in two places: +1. `RoutePublisher::DeterminePrefixFor(kPublishUla)` — overridden by the patch to use + `GetOmrPrefix()`, so `/64` here is defensive for that path. +2. `RoutingManager::NetworkDataContainsUlaRoute()` (line ~650), which still calls + `RoutePublisher::GetUlaPrefix().ContainsPrefix(...)`. With `/7` it matches any `fd::`/`fc::` + route as "ULA coverage" and lets the mesh conclude it already has ULA reachability from a + too-broad prefix. Changing to `/64` makes this peer-detection sanity check meaningful on a + multi-VLAN site, so BRs back off correctly and agree that a stable /64 OMR is "the ULA route." + +### The two layers are complementary, not interchangeable + +- Disabling installation (Layer 1, config header) is local hardening at the symptom. +- Not advertising (Layer 2, `0006`) is the protocol fix. + +If only Layer 1 is done, OTBR still advertises `fc00::/7` in Network Data; other BRs on the mesh +that did not receive the patch will still honor it and install it. The loop only dies +network-wide if the advertisement is also tightened. Layer 2 is the actual protocol fix; Layer 1 +is complementary local hardening. The PR should be framed this way, because maintainers will probe +exactly this distinction. + +--- + +## 6. Mixed-firmware risk (highest-priority operational check) + +Are all 6 BRs running the patched build? If any one of them is stock upstream (or an older +ts-otbr), that BR still installs `/7` and re-advertises it, keeping the loop alive for the whole +mesh no matter how clean the other 5 are. Mixed firmware is the most likely reason a fix +that works in a lab "doesn't fully fix" the office. Verify this first. + +--- + +## 7. Border router count guidance + +For this topology: +- Coverage is served by the 802.15.4 radios being physically distributed (each BR adds radio + reach across the floor). +- Bandwidth is served by aggregate backbone egress. + +6 distributed BRs is a sound baseline. The cost is coordination: every extra same-L2 BR adds +default-route/on-link advertisement and multicast duplication. Note that `leader_weight` +configures Thread leader election, a separate axis from border-routing default-router election. +If the OMR /64 coordination is solid, 6 is healthy; going much higher without a reason increases +peer-detection and multicast-dup overhead on the same segment. + +--- + +## 8. Cross-BR prefix consistency requirement + +With a deterministic OMR prefix (a hash of the Thread network name, or a user-pinned +`br omrconfig custom`) plus `GetOmrPrefix()` publication, all BRs +on a shared mesh must use the same OMR /64. If two BRs pin different /64s, the broad-prefix +loop is replaced by a prefix-disagreement problem. Requiring a consistent OMR prefix across all BRs +on a shared mesh must be documented in the PR. + +--- + +## 9. Recommended verification / next steps + +1. Confirm all 6 BRs run the patched build (no mixed firmware). +2. Validate on the live site with `ip -6 route` (confirm no `fc00::/7 dev wpan0`) and packet + capture showing no circulation between BRs. +3. Verify that the nftables firewall is active and blocking TREL/ND/mDNS at the BR boundary + (`nft list table ip6 otbr`). +4. Confirm all BRs advertise the same OMR /64 in their RAs on the backbone.