From ed7f1731c7b6afd9d52c927dfb06b9e07641569e Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sun, 2 Aug 2026 00:34:22 -0500 Subject: [PATCH 01/19] added input variables: omr_prefix and leader_weight --- openthread_border_router/config.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openthread_border_router/config.yaml b/openthread_border_router/config.yaml index 3b10dd632..53e1261b7 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,8 @@ options: firewall: true nat64: false beta: false + omr_prefix: null + leader_weight: null ports: 8080/tcp: null 8081/tcp: null @@ -49,4 +51,6 @@ schema: firewall: bool nat64: bool beta: bool? + omr_prefix: str? + leader_weight: int? startup: services From 7826c37e978a7a64c722892b148941f4b5e01ab8 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sun, 2 Aug 2026 00:42:24 -0500 Subject: [PATCH 02/19] delete nat64 features. just seems like a distraction right now --- openthread_border_router/config.yaml | 2 -- .../etc/s6-overlay/s6-rc.d/otbr-agent/run | 19 ------------------- 2 files changed, 21 deletions(-) diff --git a/openthread_border_router/config.yaml b/openthread_border_router/config.yaml index 53e1261b7..145ceb12f 100644 --- a/openthread_border_router/config.yaml +++ b/openthread_border_router/config.yaml @@ -31,7 +31,6 @@ options: flow_control: true otbr_log_level: notice firewall: true - nat64: false beta: false omr_prefix: null leader_weight: null @@ -49,7 +48,6 @@ schema: network_device: str? otbr_log_level: list(debug|info|notice|warning|error|critical|alert|emergency) firewall: bool - nat64: bool beta: bool? omr_prefix: str? leader_weight: int? 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..c5d88de40 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,9 +64,6 @@ 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 @@ -94,22 +91,6 @@ else ip6tables -A "${otbr_forward_egress_chain}" -j ACCEPT 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}" - - # MASQUERADE marked traffic - iptables -t nat -A POSTROUTING -m mark --mark "${otbr_fw_mark}" -j MASQUERADE - - # NAT64 forward chain — jump unconditionally, filter inside - iptables -N "${otbr_forward_nat64_chain}" - iptables -I FORWARD 1 -j "${otbr_forward_nat64_chain}" - - # 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 -fi otbr_rest_listen="::" otbr_rest_listen_port="$(bashio::addon.port 8081)" From 60aa2c66b939893a56bfadbfc4c96a70d488d4f7 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sun, 2 Aug 2026 15:11:32 -0500 Subject: [PATCH 03/19] replaced iptables firewall with more robust chains on nftables --- openthread_border_router/Dockerfile | 6 +- .../etc/s6-overlay/s6-rc.d/otbr-agent/run | 156 +++++++++++++++--- 2 files changed, 138 insertions(+), 24 deletions(-) diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index 0aad4cad2..3a66f80ff 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -31,7 +31,7 @@ RUN \ build-essential \ ninja-build \ cmake \ - iptables \ + nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ nodejs \ @@ -118,7 +118,7 @@ RUN \ build-essential \ ninja-build \ cmake \ - iptables \ + nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ nodejs \ @@ -191,7 +191,7 @@ RUN \ iproute2 \ iputils-ping \ ipset \ - iptables \ + nftables \ libreadline8 \ libncurses6 \ libprotobuf-lite32 \ 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 c5d88de40..bc70bfa7f 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 @@ -16,6 +16,8 @@ declare migrate_flow_control declare otbr_log_level_int declare otbr_rest_listen declare otbr_rest_listen_port +declare omr_prefix +declare leader_weight # Cleanup unused configs if bashio::config.exists 'autoflash_firmware'; then @@ -69,29 +71,141 @@ 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 -ip6tables -N "${otbr_forward_ingress_chain}" -ip6tables -I FORWARD 1 -o "${thread_if}" -j "${otbr_forward_ingress_chain}" - -ip6tables -N "${otbr_forward_egress_chain}" -ip6tables -I FORWARD 2 -i "${thread_if}" -j "${otbr_forward_egress_chain}" - -if bashio::config.true 'firewall'; then - bashio::log.info "Setup OTBR firewall..." - - 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 - - ip6tables -A "${otbr_forward_egress_chain}" -j ACCEPT -else - ip6tables -A "${otbr_forward_ingress_chain}" -j ACCEPT - - ip6tables -A "${otbr_forward_egress_chain}" -j ACCEPT +nft delete table ip6 otbr 2>/dev/null || true + +if ! nft -f - <&2 + exit 1 fi - otbr_rest_listen="::" otbr_rest_listen_port="$(bashio::addon.port 8081)" From ace4ab4d00662b811b7fbb4d224d120490018d9c Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Mon, 3 Aug 2026 13:46:49 -0500 Subject: [PATCH 04/19] revised Thread configuration and attachment processes to optimize for leadership weight recognition across the mesh --- openthread_border_router/Dockerfile | 2 + .../scripts/otbr-agent-configure.sh | 77 +++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index 3a66f80ff..0a0500c07 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -78,6 +78,7 @@ RUN \ -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 \ @@ -166,6 +167,7 @@ RUN \ -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 \ 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..3cac2f846 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,11 +6,11 @@ ot-ctl trel enable -if bashio::config.true 'nat64'; then - bashio::log.info "Enabling NAT64." - ot-ctl nat64 enable - ot-ctl dns server upstream enable -fi +# if bashio::config.true 'nat64'; then +# bashio::log.info "Enabling NAT64." +# ot-ctl nat64 enable +# ot-ctl dns server upstream enable +# fi mdns_localhostname="$(hostname)-otbr" bashio::log.info "Setting OpenThread mDNS local hostname to ${mdns_localhostname}." @@ -20,3 +20,70 @@ ot-ctl mdns enable # 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 + +# 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 + +# Enable border routing +ot-ctl br enable + +# Configure the custom OMR prefix if the user specified a prefix to use. +if bashio::config.has_value 'omr_prefix'; then + DESIRED_PREFIX=$(bashio::config 'omr_prefix') + if [[ -n "$DESIRED_PREFIX" ]]; then + if ot-ctl br omrconfig custom "${DESIRED_PREFIX}" med; then + bashio::log.info "✅ Successfully applied custom OMR prefix: ${DESIRED_PREFIX}" + else + bashio::log.error "❌ Failed to apply custom OMR prefix" + fi + fi +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" + fi +fi + +# Start thread at the end of configuration. Ensures the cleanest possible "moment of attachment" with any existing nodes. +# In this case, in testing, it has been found that Thread nodes are most reliable at recognizing leadership weights of border routers when they are configured before the BR radio attaches. +ot-ctl thread start \ No newline at end of file From ae56cdd01a662c00371a1b661129a3f4390077bd Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Mon, 3 Aug 2026 14:18:45 -0500 Subject: [PATCH 05/19] installed upstream openthread patches for the non-beta build --- .../0003-disable-fc00-route.patch | 42 +++++++++++++++++ .../0004-routing-manager-corrections.patch | 47 +++++++++++++++++++ openthread_border_router/Dockerfile | 5 +- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 openthread_border_router/0003-disable-fc00-route.patch create mode 100644 openthread_border_router/0004-routing-manager-corrections.patch diff --git a/openthread_border_router/0003-disable-fc00-route.patch b/openthread_border_router/0003-disable-fc00-route.patch new file mode 100644 index 000000000..0a5a138d9 --- /dev/null +++ b/openthread_border_router/0003-disable-fc00-route.patch @@ -0,0 +1,42 @@ +diff --git a/src/posix/platform/netif.cpp b/src/posix/platform/netif.cpp +index 8ac42caee..ed5262c6a 100644 +--- a/src/posix/platform/netif.cpp ++++ b/src/posix/platform/netif.cpp +@@ -222,6 +222,17 @@ static uint8_t sAddedExternalRoutesNum = 0; + static otIp6Prefix sAddedExternalRoutes[kMaxExternalRoutesNum]; + #endif + ++static bool IsBroadUlaPrefix(const otIp6Prefix &aPrefix) ++{ ++ // Exact match for RoutingManager::RoutePublisher::kUlaPrefix ++ static const otIp6Prefix kBroadUla = { ++ {{{0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ++ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}}, ++ 7, ++ }; ++ return otIp6ArePrefixesEqual(&aPrefix, &kBroadUla); ++} ++ + #if defined(RTM_NEWMADDR) || defined(__NetBSD__) + // on some BSDs (mac OS, FreeBSD), we get RTM_NEWMADDR/RTM_DELMADDR messages, so we don't need to monitor using MLD + // on NetBSD, MLD monitoring simply doesn't work +@@ -937,10 +948,15 @@ static void UpdateExternalRoutes(otInstance *aInstance) + + while (otNetDataGetNextRoute(aInstance, &iterator, &config) == OT_ERROR_NONE) + { +- if (config.mRloc16 == otThreadGetRloc16(aInstance) || HasAddedExternalRoute(config.mPrefix)) +- { +- continue; +- } ++ if (config.mRloc16 == otThreadGetRloc16(aInstance) || ++ HasAddedExternalRoute(config.mPrefix) ++ #if OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE ++ || IsBroadUlaPrefix(config.mPrefix) ++ #endif ++ ) ++ { ++ continue; ++ } + VerifyOrExit(sAddedExternalRoutesNum < kMaxExternalRoutesNum, + LogWarn("No buffer to add more external routes in kernel")); + diff --git a/openthread_border_router/0004-routing-manager-corrections.patch b/openthread_border_router/0004-routing-manager-corrections.patch new file mode 100644 index 000000000..2281b66b1 --- /dev/null +++ b/openthread_border_router/0004-routing-manager-corrections.patch @@ -0,0 +1,47 @@ +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 +@@ -650,7 +650,7 @@ bool RoutingManager::NetworkDataContainsUlaRoute(void) const + + while (Get().GetNext(iterator, routeConfig) == kErrorNone) + { +- if (routeConfig.mStable && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) ++ if (routeConfig.mStable && routeConfig.GetPrefix().GetLength() == 64 && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) + { + contains = true; + break; +@@ -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/Dockerfile b/openthread_border_router/Dockerfile index 0a0500c07..4417b5052 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -100,7 +100,6 @@ 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/ @@ -132,7 +131,9 @@ RUN \ && 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/0002-nat64-handle-ipv4-options.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0003-disable-fc00-route.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0004-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix RUN \ From eaef03b48dabbbf5d86ae1ac1453a4392f518ec8 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Mon, 3 Aug 2026 15:31:06 -0500 Subject: [PATCH 06/19] adding new configuration spec to header --- .../openthread-core-ha-config-posix.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openthread_border_router/openthread-core-ha-config-posix.h b/openthread_border_router/openthread-core-ha-config-posix.h index 699e31aea..08fdda5fb 100644 --- a/openthread_border_router/openthread-core-ha-config-posix.h +++ b/openthread_border_router/openthread-core-ha-config-posix.h @@ -83,4 +83,20 @@ */ #define OPENTHREAD_CONFIG_MULTICAST_DNS_AUTO_ENABLE_ON_INFRA_IF 0 +/** + * @def OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE + * + * Define as 1 to prevent the POSIX netif layer from ever installing the + * broad Unique Local catch-all route fc00::/7 into the Linux kernel. + * + * This route is extremely damaging on multi-VLAN / multi-BR networks because + * it steals the entire ULA space toward the Thread interface. + * + * Specific OMR prefixes, mesh-local, and any other external routes continue + * to be installed normally. + * + * Default: 1 (disabled) for this fork. + */ +#define OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE 1 + #endif /* OPENTHREAD_CORE_HA_CONFIG_POSIX_H_ */ From 065502b70209f55f89988c4ebb78d85d8c899d51 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Fri, 7 Aug 2026 22:56:10 -0500 Subject: [PATCH 07/19] otbr: sync routing layer + fix Dockerfile COPY for 0003/0004 patches - COPY 0003-disable-fc00-route.patch and 0004-routing-manager-corrections.patch into the build context (they were applied but never copied: latent build break). - Sync openthread-core-ha-config-posix.h with ts-otbr: enable DISABLE_BROAD_ULA_ROUTE, MULTI_AIL_DETECTION, TRACK_PEER_BR_INFO, USE_HEAP. Routing patches remain byte-identical to ts-otbr (0005==0003, 0006==0004). --- openthread_border_router/Dockerfile | 2 ++ .../openthread-core-ha-config-posix.h | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index 4417b5052..226458ef4 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -103,6 +103,8 @@ 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 0003-disable-fc00-route.patch /usr/src/ +COPY 0004-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src RUN \ diff --git a/openthread_border_router/openthread-core-ha-config-posix.h b/openthread_border_router/openthread-core-ha-config-posix.h index 08fdda5fb..1233dcc98 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 /** @@ -97,6 +98,20 @@ * * Default: 1 (disabled) for this fork. */ +#ifndef OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE #define OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE 1 +#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_ */ From d89076c4e0583a9a262f4cf07a8ab1ac93b7d86c Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Fri, 7 Aug 2026 22:58:49 -0500 Subject: [PATCH 08/19] otbr: sync nftables firewall with ts-otbr (scope-gated multicast forwarding) Replace blanket multicast/broadcast drop in both forward chains with scope-gated forwarding per Thread 1.3+ spec: only admin-local scope (ff04::/16) and above cross the BR boundary; mesh-local (ff02/ff03) and broadcast never leak. Add mcast-fwd-scope set. Align MLE/TMF control-plane allow, SRP/ND handling, and forward-chain priority (mangle->filter). Byte-identical to ts-otbr working tree. --- .../etc/s6-overlay/s6-rc.d/otbr-agent/run | 199 +++++++++++++++--- 1 file changed, 175 insertions(+), 24 deletions(-) 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 bc70bfa7f..5caee833b 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 @@ -16,8 +16,6 @@ declare migrate_flow_control declare otbr_log_level_int declare otbr_rest_listen declare otbr_rest_listen_port -declare omr_prefix -declare leader_weight # Cleanup unused configs if bashio::config.exists 'autoflash_firmware'; then @@ -66,9 +64,9 @@ 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." -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-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 nft delete table ip6 otbr 2>/dev/null || true @@ -100,10 +98,31 @@ table ip6 otbr { comment "TREL UDP ports" } + set mcast-fwd-scope { + type ipv6_addr + flags interval + auto-merge + comment "Multicast scopes that may cross the BR boundary (admin-local ff04 and above)" + elements = { + ff04::/16, # admin-local + ff05::/16, # site-local (Matter fabric groups use ff35 site-local) + ff06::/16, + ff07::/16, + ff08::/16, # org-local + ff09::/16, + ff0a::/16, + ff0b::/16, + ff0c::/16, + ff0d::/16, + ff0e::/16, # global + ff0f::/16, # reserved + } + } + chain forward { - type filter hook forward priority mangle; policy accept; - oifname "${thread_if}" jump otbr_to_thread - iifname "${thread_if}" jump otbr_from_thread + type filter hook forward priority filter; policy accept; + counter oifname "${thread_if}" jump otbr_to_thread + counter iifname "${thread_if}" jump otbr_from_thread } chain input { @@ -114,14 +133,24 @@ table ip6 otbr { iifname "${backbone_if}" udp sport @trel_ports counter accept comment "allow TREL replies on backbone" iifname "${thread_if}" udp dport @trel_ports counter drop comment "reject TREL on Thread" iifname "${thread_if}" udp sport @trel_ports counter drop comment "reject TREL on Thread" + + # Explicitly allow Thread control traffic (MLE/TMF) to the host + iifname "${thread_if}" udp dport { 19788, 61631 } counter accept comment "allow Thread control (MLE/TMF) to host" + iifname "${thread_if}" ct state established,related counter accept + + # Allow Neighbor Discovery (NS/NA) - including multicast - before the multicast drop + iifname "${thread_if}" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 limit rate 20/second burst 10 packets counter accept comment "allow ND NS/NA from Thread" + iifname "${thread_if}" icmpv6 type nd-router-advert counter drop comment "no RAs from Thread" iifname "${thread_if}" icmpv6 type { nd-router-solicit, nd-redirect } counter drop comment "no RS/redirect from Thread to host" + + # Allow unicast SRP (service registration) from Thread - before the mDNS drop + iifname "${thread_if}" udp dport 5353 meta pkttype unicast counter accept comment "allow unicast SRP from Thread" iifname "${thread_if}" udp dport { 5353, 5355, 1900 } counter drop comment "no SD to host from Thread" - iifname "${thread_if}" udp dport 61631 counter drop comment "no TMF to host" + iifname "${thread_if}" icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter drop comment "no MLD from Thread" - iifname "${thread_if}" meta pkttype { multicast, broadcast } counter drop comment "no multicast to host from Thread" - iifname "${thread_if}" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 limit rate 20/second burst 10 packets counter accept comment "allow ND NS/NA from Thread" + iifname "${thread_if}" meta pkttype { multicast, broadcast } counter drop comment "no multicast to host from Thread (except control above)" iifname "${thread_if}" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } ip6 saddr @otbr-ingress-allow-dst limit rate 10/second burst 5 packets counter accept comment "ICMP errors from Thread OMR" iifname "${thread_if}" icmpv6 type { echo-request, echo-reply } ip6 saddr @otbr-ingress-allow-dst limit rate 10/second burst 5 packets counter accept comment "echo from Thread OMR" iifname "${thread_if}" ip6 saddr @otbr-ingress-allow-dst meta l4proto { tcp, udp } ct state new counter accept comment "OMR unicast to host" @@ -135,16 +164,28 @@ table ip6 otbr { oifname "${backbone_if}" udp sport @trel_ports counter accept comment "allow TREL out backbone" oifname "${thread_if}" udp dport @trel_ports counter drop comment "no TREL on Thread" oifname "${thread_if}" udp sport @trel_ports counter drop comment "no TREL on Thread" + + # Explicitly allow Thread control traffic (MLE/TMF) from the host + oifname "${thread_if}" udp dport { 19788, 61631 } counter accept comment "allow Thread control (MLE/TMF) to Thread" + oifname "${thread_if}" udp sport { 19788, 61631 } counter accept comment "allow Thread control (MLE/TMF) from host" + oifname "${backbone_if}" icmpv6 type nd-router-advert counter accept oifname "${backbone_if}" icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter accept oifname "${thread_if}" icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter drop oifname "${thread_if}" icmpv6 type nd-router-advert counter drop comment "no host RAs on Thread radio" oifname "${thread_if}" icmpv6 type { nd-router-solicit, nd-redirect } counter drop + + # Allow Neighbor Discovery (NS/NA) - including multicast - before the multicast drop + oifname "${thread_if}" icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 counter accept comment "allow ND NS/NA to Thread" + + # Allow unicast SRP replies to Thread - before the mDNS drop + oifname "${thread_if}" udp sport 5353 meta pkttype unicast counter accept comment "allow unicast SRP replies to Thread" oifname "${thread_if}" udp dport 5353 counter drop comment "no mDNS to Thread" oifname "${thread_if}" udp sport 5353 counter drop comment "no mDNS from host to Thread" oifname "${thread_if}" udp dport 5355 meta pkttype multicast counter drop comment "no LLMNR to Thread" oifname "${thread_if}" udp dport 1900 meta pkttype multicast counter drop comment "no SSDP to Thread" - oifname "${thread_if}" udp dport { 546, 547, 5350, 5351, 19788, 61631 } counter drop comment "no DHCP/PCP/TMF to Thread" + oifname "${thread_if}" udp dport { 546, 547, 5350, 5351 } counter drop comment "no DHCP/PCP to Thread" + oifname "${thread_if}" meta pkttype { multicast, broadcast } counter drop } chain otbr_to_thread { @@ -154,15 +195,25 @@ table ip6 otbr { } chain from_backbone_to_thread { + ip6 hoplimit <= 1 counter drop comment "do not forward packets with hoplimit = 1" ct state invalid counter drop udp dport @trel_ports counter drop comment "no TREL backbone to Thread" udp sport @trel_ports counter drop comment "no TREL backbone to Thread" icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } counter drop comment "no ND backbone to Thread" - icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter drop comment "no MLD backbone to Thread" - udp dport { 546, 547, 1900, 5350, 5351, 5353, 5355, 19788, 61631 } counter drop comment "no LAN control UDP to Thread" - meta pkttype { broadcast, multicast } counter drop comment "no multicast backbone to Thread" + icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter drop comment "no MLD backbone to Thread (Thread uses MLR, not MLD)" + udp dport { 546, 547, 1900, 5350, 5351, 5353, 5355 } counter drop comment "no LAN control UDP to Thread" + # Scope-gated multicast forwarding (spec-correct, Thread 1.3+): + # Only admin-local scope (ff04::/16) and above may cross the BR boundary. + # Link-local (ff02::/16) and realm-local (ff03::/16) never cross a router. + # Per-group subscription is enforced by the kernel multicast routing + # (MLR + PBBR MLDv2 joins on the external interface); the firewall only + # gates by scope so Mesh-local scopes cannot leak onto the backbone. + ip6 daddr ff00::/8 meta pkttype { broadcast } counter drop comment "no broadcast" + ip6 daddr @mcast-fwd-scope counter accept comment "forward admin/global-scope multicast (Matter groups ff35, etc.)" + ip6 daddr ff00::/8 counter drop comment "drop mesh-local multicast (link/realm-scope) at BR boundary" ip6 saddr @otbr-ingress-deny-src counter drop comment "drop Thread-sourced packets from Ethernet" - ip6 daddr fe80::/10 counter drop comment "drop link-local unicast from backbone" + ip6 daddr fe80::/10 counter drop comment "drop link-local unicast destination from backbone" + ip6 saddr fe80::/10 counter drop comment "drop link-local unicast sources from backbone" ct state established,related counter accept icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } ip6 daddr @otbr-ingress-allow-dst limit rate 10/second burst 5 packets counter accept icmpv6 type { echo-request, echo-reply } ip6 daddr @otbr-ingress-allow-dst limit rate 10/second burst 5 packets counter accept @@ -181,16 +232,20 @@ table ip6 otbr { } chain from_thread_to_backbone { + ip6 hoplimit <= 1 counter drop comment "do not forward packets with hoplimit = 1" ct state invalid counter drop udp dport @trel_ports counter drop comment "no TREL Thread to backbone" udp sport @trel_ports counter drop comment "no TREL Thread to backbone" icmpv6 type { mld-listener-query, mld-listener-report, mld2-listener-report } counter drop icmpv6 type nd-router-advert counter drop comment "no Thread RAs to backbone" icmpv6 type { nd-router-solicit, nd-redirect } counter drop comment "no RS/redirect Thread to backbone" - udp dport 61631 counter drop - udp sport 61631 counter drop - udp dport { 1900, 5353, 5355, 546, 547, 5350, 5351, 19788 } counter drop - meta pkttype { broadcast, multicast } counter drop comment "no multicast Thread to backbone" + udp dport { 1900, 5353, 5355, 546, 547, 5350, 5351 } counter drop + # Scope-gated multicast forwarding: admin/global-scope multicast from + # Thread may reach the backbone (Matter group responses, OTA, etc.). + # Mesh-local scopes (ff02/ff03) and broadcast never cross the BR. + ip6 daddr ff00::/8 meta pkttype { broadcast } counter drop comment "no broadcast" + ip6 daddr @mcast-fwd-scope counter accept comment "forward admin/global-scope multicast to backbone" + ip6 daddr ff00::/8 counter drop comment "drop mesh-local multicast (link/realm-scope) at BR boundary" ip6 saddr fe80::/10 counter drop comment "no link-local from Thread to backbone" ct state established,related accept icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } ip6 saddr @otbr-ingress-allow-dst limit rate 10/second burst 5 packets counter accept @@ -206,10 +261,105 @@ then exit 1 fi +# ------------------------------------------------------------------ +# 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}" + + # 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 + + 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 + + # 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}" + +# 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 + +if bashio::config.true 'firewall'; then + bashio::log.info "OTBR hardened nftables firewall ACTIVE (strict backbone→Thread, permissive Thread→backbone, host control-plane blocked on radio)" +else + # Disable path: open the critical ingress chain so behaviour matches stock OTBR + nft flush chain ip6 otbr from_backbone_to_thread + nft add rule ip6 otbr from_backbone_to_thread accept + bashio::log.info "OTBR firewall disabled — from-backbone-to-Thread set to ACCEPT" +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" @@ -230,11 +380,12 @@ 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}" \ --rest-listen-address "${otbr_rest_listen}" \ -d${otbr_log_level_int} -v -s \ "spinel+hdlc+uart://${device}?uart-baudrate=${baudrate}${flow_control}" \ - "trel://${backbone_if}" + "trel://${backbone_if}" \ No newline at end of file From 84ed9fb0dc730afb78619f2a853ca0d58e3c4f48 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Fri, 7 Aug 2026 23:02:06 -0500 Subject: [PATCH 09/19] otbr: sync s6 hardening services from ts-otbr (route-guard, ipset-sync, wpan-sysctl) Bring the runtime hardening services into the fork, byte-identical to ts-otbr: otbr-route-guard (deletes RA-learned OMR routes), otbr-ipset-sync (continuous ipset->nft set sync), otbr-wpan-sysctl (IPv6 hardening on wpan0). Wire otbr-ipset-sync and otbr-wpan-sysctl into user/contents.d to match ts-otbr. --- .../otbr-ipset-sync/dependencies.d/otbr-agent | 0 .../s6-overlay/s6-rc.d/otbr-ipset-sync/run | 58 ++++++++ .../s6-overlay/s6-rc.d/otbr-ipset-sync/type | 1 + .../s6-rc.d/otbr-route-guard/.DS_Store | Bin 0 -> 6148 bytes .../dependencies.d/otbr-agent | 0 .../s6-overlay/s6-rc.d/otbr-route-guard/run | 132 ++++++++++++++++++ .../s6-overlay/s6-rc.d/otbr-route-guard/type | 1 + .../dependencies.d/otbr-agent | 0 .../s6-overlay/s6-rc.d/otbr-wpan-sysctl/type | 1 + .../s6-overlay/s6-rc.d/otbr-wpan-sysctl/up | 1 + .../s6-rc.d/user/contents.d/otbr-ipset-sync | 0 .../s6-rc.d/user/contents.d/otbr-wpan-sysctl | 0 .../s6-overlay/scripts/otbr-wpan-sysctl.sh | 39 ++++++ 13 files changed, 233 insertions(+) create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/dependencies.d/otbr-agent create mode 100755 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/run create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/type create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/.DS_Store create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/dependencies.d/otbr-agent create mode 100755 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/run create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/type create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/dependencies.d/otbr-agent create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/type create mode 100755 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-wpan-sysctl/up create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-ipset-sync create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-wpan-sysctl create mode 100755 openthread_border_router/rootfs/etc/s6-overlay/scripts/otbr-wpan-sysctl.sh 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..e3386d53e --- /dev/null +++ b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-ipset-sync/run @@ -0,0 +1,58 @@ +#!/usr/bin/with-contenv bashio +# vim: ft=bash +# shellcheck shell=bash +# ============================================================================== +# Continuous ipset → nftables set synchronizer for OTBR firewall +# ============================================================================== + +# 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/.DS_Store b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c1a5f405e2fbfcddeebc64d9844b87e4dd872005 GIT binary patch literal 6148 zcmeHK%Sr=55Ukc50z$~q<9vbO9}FRWL4H7Rg9x&UqUSyNU4B}sA1GlXc=00D&|NdV zT{CPQwzmP;=54qJ)&Q1tM|^mgn?HA-*;!?bNaqTzE?WBMd_*V+pV!PXJ_)67VXD{cyw$X3tUh_$J<2on| m(T<7Hj=Ax6d>KVq*L=LnlI`cs%>Sw@pkx7BSR^R|a9v0vL literal 0 HcmV?d00001 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-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-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 From b821ff3a01227dc0bb1a55e3cc3cb14d11c24c3a Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Fri, 7 Aug 2026 23:06:46 -0500 Subject: [PATCH 10/19] otbr: wire otbr-route-guard into user/contents.d (match ts-otbr) Enable the RA-route defense service so it actually starts at boot, keeping the s6 service wiring in sync with ts-otbr. --- .../etc/s6-overlay/s6-rc.d/user/contents.d/otbr-route-guard | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/otbr-route-guard 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 From 9870b31b730c57e65782d1d4f746c3bf0080942a Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sat, 22 Aug 2026 09:26:00 -0500 Subject: [PATCH 11/19] this should be ready to submit --- ...0003-suggested-ula-prefix-agent-beta.patch | 67 ++++++++++ ...03-suggested-ula-prefix-agent-stable.patch | 71 +++++++++++ .../0004-suggested-ula-prefix-core-beta.patch | 80 ++++++++++++ ...004-suggested-ula-prefix-core-stable.patch | 80 ++++++++++++ .../0005-disable-fc00-route.patch | 42 ++++++ .../0006-routing-manager-corrections.patch | 47 +++++++ openthread_border_router/CHANGELOG.md | 34 +++++ openthread_border_router/DOCS.md | 6 + openthread_border_router/Dockerfile | 28 +++- openthread_border_router/config.yaml | 16 ++- .../etc/s6-overlay/s6-rc.d/otbr-agent/run | 64 +++++++++- .../s6-rc.d/otbr-route-guard/.DS_Store | Bin 6148 -> 0 bytes .../scripts/otbr-agent-configure.sh | 120 ++++++++++++++---- openthread_border_router/translations/en.yaml | 31 +++++ 14 files changed, 647 insertions(+), 39 deletions(-) create mode 100644 openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch create mode 100644 openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch create mode 100644 openthread_border_router/0004-suggested-ula-prefix-core-beta.patch create mode 100644 openthread_border_router/0004-suggested-ula-prefix-core-stable.patch create mode 100644 openthread_border_router/0005-disable-fc00-route.patch create mode 100644 openthread_border_router/0006-routing-manager-corrections.patch delete mode 100644 openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/.DS_Store diff --git a/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch b/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch new file mode 100644 index 000000000..a427e5ad5 --- /dev/null +++ b/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch @@ -0,0 +1,67 @@ +diff --git a/src/agent/realmain.cpp b/src/agent/realmain.cpp +--- a/src/agent/realmain.cpp 2026-08-12 15:16:06 ++++ b/src/agent/realmain.cpp 2026-08-12 15:23:03 +@@ -39,6 +39,7 @@ + #include + #include + #include ++#include + #include + + #include +@@ -82,6 +83,7 @@ + OTBR_OPT_VERSION = 'V', + OTBR_OPT_SHORTMAX = 128, + OTBR_OPT_DATA_PATH, ++ OTBR_OPT_ULA_PREFIX, + OTBR_OPT_RADIO_VERSION, + OTBR_OPT_AUTO_ATTACH, + OTBR_OPT_REST_LISTEN_ADDR, +@@ -103,6 +105,7 @@ + static const struct option kOptions[] = { + {"backbone-ifname", required_argument, nullptr, OTBR_OPT_BACKBONE_INTERFACE_NAME}, + {"data-path", required_argument, nullptr, OTBR_OPT_DATA_PATH}, ++ {"ula-prefix", required_argument, nullptr, OTBR_OPT_ULA_PREFIX}, + {"debug-level", required_argument, nullptr, OTBR_OPT_DEBUG_LEVEL}, + {"help", no_argument, nullptr, OTBR_OPT_HELP}, + {"thread-ifname", required_argument, nullptr, OTBR_OPT_INTERFACE_NAME}, +@@ -164,6 +167,9 @@ + fprintf(stderr, + "Usage: %s [-I interfaceName] [-B backboneIfName] [-d DEBUG_LEVEL] [-v] [-s] [--auto-attach[=0/1]] " + "RADIO_URL [RADIO_URL]\n" ++ " --ula-prefix Optional suggested /48 BR ULA prefix (e.g. fdf0:1234:5678::/48).\n" ++ " Used instead of random generation on first launch (when no valid\n" ++ " prefix exists in settings). Persisted for future launches.\n" + " --data-path Path of directory to store data.\n" + " -I, --thread-ifname Name of the Thread network interface (default: " DEFAULT_INTERFACE_NAME ").\n" + " -B, --backbone-ifname Name of the backbone network interfaces (can be specified multiple times).\n" +@@ -253,6 +259,7 @@ + const char *restListenAddress = "127.0.0.1"; + int restListenPort = kPortNumber; + const char *dataPath = ""; ++ const char *suggestedUlaPrefix = nullptr; + std::vector radioUrls; + std::vector backboneInterfaceNames; + long parseResult; +@@ -275,6 +282,10 @@ + { + switch (opt) + { ++ case OTBR_OPT_ULA_PREFIX: ++ suggestedUlaPrefix = optarg; ++ break; ++ + case OTBR_OPT_BACKBONE_INTERFACE_NAME: + backboneInterfaceNames.push_back(optarg); + otbrLogNotice("Backbone interface: %s", optarg); +@@ -368,6 +379,10 @@ + #endif + + otbrLogInit(argv[0], logLevel, verbose, syslogDisable); ++ if (suggestedUlaPrefix != nullptr) ++ { ++ setenv("OTBR_SUGGESTED_BR_ULA_PREFIX", suggestedUlaPrefix, 1); ++ } + otbrLogNotice("Running %s", OTBR_PACKAGE_VERSION); + otbrLogNotice("Thread version: %s", otbr::Host::RcpHost::GetThreadVersion()); + otbrLogNotice("Thread interface: %s", interfaceName); diff --git a/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch b/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch new file mode 100644 index 000000000..e0167b8ce --- /dev/null +++ b/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch @@ -0,0 +1,71 @@ +diff --git a/src/agent/realmain.cpp b/src/agent/realmain.cpp +index e1e05e1c..c421085c 100644 +--- a/src/agent/realmain.cpp ++++ b/src/agent/realmain.cpp +@@ -39,6 +39,7 @@ + #include + #include + #include ++#include + #include + + #include +@@ -81,6 +82,7 @@ enum + OTBR_OPT_SYSLOG_DISABLE = 's', + OTBR_OPT_VERSION = 'V', + OTBR_OPT_SHORTMAX = 128, ++ OTBR_OPT_ULA_PREFIX, + OTBR_OPT_RADIO_VERSION, + OTBR_OPT_AUTO_ATTACH, + OTBR_OPT_REST_LISTEN_ADDR, +@@ -105,6 +107,7 @@ static const struct option kOptions[] = { + {"help", no_argument, nullptr, OTBR_OPT_HELP}, + {"thread-ifname", required_argument, nullptr, OTBR_OPT_INTERFACE_NAME}, + {"verbose", no_argument, nullptr, OTBR_OPT_VERBOSE}, ++ {"ula-prefix", required_argument, nullptr, OTBR_OPT_ULA_PREFIX}, + {"syslog-disable", no_argument, nullptr, OTBR_OPT_SYSLOG_DISABLE}, + {"version", no_argument, nullptr, OTBR_OPT_VERSION}, + {"radio-version", no_argument, nullptr, OTBR_OPT_RADIO_VERSION}, +@@ -162,6 +165,9 @@ static void PrintHelp(const char *aProgramName) + fprintf(stderr, + "Usage: %s [-I interfaceName] [-B backboneIfName] [-d DEBUG_LEVEL] [-v] [-s] [--auto-attach[=0/1]] " + "RADIO_URL [RADIO_URL]\n" ++ " --ula-prefix Optional suggested /48 BR ULA prefix (e.g. fdf0:1234:5678::/48).\n" ++ " Used instead of random generation on first launch (when no valid\n" ++ " prefix exists in settings). Persisted for future launches.\n" + " -I, --thread-ifname Name of the Thread network interface (default: " DEFAULT_INTERFACE_NAME ").\n" + " -B, --backbone-ifname Name of the backbone network interfaces (can be specified multiple times).\n" + " -d, --debug-level The log level (EMERG=0, ALERT=1, CRIT=2, ERR=3, WARNING=4, NOTICE=5, INFO=6, " +@@ -250,6 +256,7 @@ static int realmain(int argc, char *argv[]) + const char *restListenAddress = "127.0.0.1"; + int restListenPort = kPortNumber; + std::vector radioUrls; ++ const char *suggestedUlaPrefix = nullptr; + std::vector backboneInterfaceNames; + long parseResult; + +@@ -270,6 +277,11 @@ static int realmain(int argc, char *argv[]) + { + switch (opt) + { ++ ++ case OTBR_OPT_ULA_PREFIX: ++ suggestedUlaPrefix = optarg; ++ break; ++ + case OTBR_OPT_BACKBONE_INTERFACE_NAME: + backboneInterfaceNames.push_back(optarg); + otbrLogNotice("Backbone interface: %s", optarg); +@@ -358,7 +370,12 @@ static int realmain(int argc, char *argv[]) + } + #endif + ++ + otbrLogInit(argv[0], logLevel, verbose, syslogDisable); ++ if (suggestedUlaPrefix != nullptr) ++ { ++ setenv("OTBR_SUGGESTED_BR_ULA_PREFIX", suggestedUlaPrefix, 1); ++ } + otbrLogNotice("Running %s", OTBR_PACKAGE_VERSION); + otbrLogNotice("Thread version: %s", otbr::Host::RcpHost::GetThreadVersion()); + otbrLogNotice("Thread interface: %s", interfaceName); diff --git a/openthread_border_router/0004-suggested-ula-prefix-core-beta.patch b/openthread_border_router/0004-suggested-ula-prefix-core-beta.patch new file mode 100644 index 000000000..11d2f90e7 --- /dev/null +++ b/openthread_border_router/0004-suggested-ula-prefix-core-beta.patch @@ -0,0 +1,80 @@ +diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp +--- a/src/core/border_router/routing_manager.cpp 2026-08-12 15:16:24 ++++ b/src/core/border_router/routing_manager.cpp 2026-08-12 15:23:03 +@@ -36,7 +36,7 @@ + #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE + + #include +- ++#include + #include + #include + #include +@@ -215,26 +215,56 @@ + void RoutingManager::LoadOrGenerateRandomBrUlaPrefix(void) + { + bool generated = false; ++ bool suggested = false; + + if (Get().Read(mBrUlaPrefix) != kErrorNone || !IsValidBrUlaPrefix(mBrUlaPrefix)) + { +- Ip6::NetworkPrefix randomUlaPrefix; ++ const char *envSuggested = getenv("OTBR_SUGGESTED_BR_ULA_PREFIX"); ++ if (envSuggested != nullptr) ++ { ++ char addrPart[64]; ++ int prefixLen = 0; + +- LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); ++ if (sscanf(envSuggested, "%47[^/]/%d", addrPart, &prefixLen) == 2 && ++ prefixLen == kBrUlaPrefixLength) ++ { ++ struct in6_addr addr6; ++ if (inet_pton(AF_INET6, addrPart, &addr6) == 1) ++ { ++ mBrUlaPrefix.Clear(); ++ mBrUlaPrefix.mLength = kBrUlaPrefixLength; ++ memcpy(mBrUlaPrefix.mPrefix.mFields.m8, addr6.s6_addr, 6); + +- SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); +- +- mBrUlaPrefix.InitFrom(randomUlaPrefix); +- mBrUlaPrefix.SetSubnetId(0); +- mBrUlaPrefix.SetLength(kBrUlaPrefixLength); ++ if (IsValidBrUlaPrefix(mBrUlaPrefix)) ++ { ++ Get().Save(mBrUlaPrefix); ++ suggested = true; ++ LogNote("No valid /48 BR ULA prefix found in settings, using suggested one from command line"); ++ } ++ } ++ } ++ } + +- Get().Save(mBrUlaPrefix); +- generated = true; ++ if (!suggested) ++ { ++ Ip6::NetworkPrefix randomUlaPrefix; ++ ++ LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); ++ ++ SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); ++ ++ mBrUlaPrefix.InitFrom(randomUlaPrefix); ++ mBrUlaPrefix.SetSubnetId(0); ++ mBrUlaPrefix.SetLength(kBrUlaPrefixLength); ++ ++ Get().Save(mBrUlaPrefix); ++ generated = true; ++ } + } + + OT_UNUSED_VARIABLE(generated); +- +- LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), generated ? "generated" : "loaded"); ++ const char *status = suggested ? "suggested" : (generated ? "generated" : "loaded"); ++ LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), status); + } + + void RoutingManager::EvaluateState(void) diff --git a/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch b/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch new file mode 100644 index 000000000..5802ef9ba --- /dev/null +++ b/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch @@ -0,0 +1,80 @@ +diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp +index 6be8b9002..4b2c98d9f 100644 +--- a/src/core/border_router/routing_manager.cpp ++++ b/src/core/border_router/routing_manager.cpp +@@ -36,7 +36,7 @@ + #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE + + #include +- ++#include + #include + #include + #include +@@ -215,26 +215,56 @@ exit: + void RoutingManager::LoadOrGenerateRandomBrUlaPrefix(void) + { + bool generated = false; ++ bool suggested = false; + + if (Get().Read(mBrUlaPrefix) != kErrorNone || !IsValidBrUlaPrefix(mBrUlaPrefix)) + { +- Ip6::NetworkPrefix randomUlaPrefix; ++ const char *envSuggested = getenv("OTBR_SUGGESTED_BR_ULA_PREFIX"); ++ if (envSuggested != nullptr) ++ { ++ char addrPart[64]; ++ int prefixLen = 0; ++ ++ if (sscanf(envSuggested, "%47[^/]/%d", addrPart, &prefixLen) == 2 && ++ prefixLen == kBrUlaPrefixLength) ++ { ++ struct in6_addr addr6; ++ if (inet_pton(AF_INET6, addrPart, &addr6) == 1) ++ { ++ mBrUlaPrefix.Clear(); ++ mBrUlaPrefix.mLength = kBrUlaPrefixLength; ++ memcpy(mBrUlaPrefix.mPrefix.mFields.m8, addr6.s6_addr, 6); ++ ++ if (IsValidBrUlaPrefix(mBrUlaPrefix)) ++ { ++ Get().Save(mBrUlaPrefix); ++ suggested = true; ++ LogNote("No valid /48 BR ULA prefix found in settings, using suggested one from command line"); ++ } ++ } ++ } ++ } ++ ++ if (!suggested) ++ { ++ Ip6::NetworkPrefix randomUlaPrefix; + +- LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); ++ LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); + +- SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); ++ SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); + +- mBrUlaPrefix.Set(randomUlaPrefix); +- mBrUlaPrefix.SetSubnetId(0); +- mBrUlaPrefix.SetLength(kBrUlaPrefixLength); ++ mBrUlaPrefix.Set(randomUlaPrefix); // <-- .Set() for this OT commit ++ mBrUlaPrefix.SetSubnetId(0); ++ mBrUlaPrefix.SetLength(kBrUlaPrefixLength); + +- Get().Save(mBrUlaPrefix); +- generated = true; ++ Get().Save(mBrUlaPrefix); ++ generated = true; ++ } + } + + OT_UNUSED_VARIABLE(generated); +- +- LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), generated ? "generated" : "loaded"); ++ const char *status = suggested ? "suggested" : (generated ? "generated" : "loaded"); ++ LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), status); + } + + void RoutingManager::EvaluateState(void) diff --git a/openthread_border_router/0005-disable-fc00-route.patch b/openthread_border_router/0005-disable-fc00-route.patch new file mode 100644 index 000000000..0a5a138d9 --- /dev/null +++ b/openthread_border_router/0005-disable-fc00-route.patch @@ -0,0 +1,42 @@ +diff --git a/src/posix/platform/netif.cpp b/src/posix/platform/netif.cpp +index 8ac42caee..ed5262c6a 100644 +--- a/src/posix/platform/netif.cpp ++++ b/src/posix/platform/netif.cpp +@@ -222,6 +222,17 @@ static uint8_t sAddedExternalRoutesNum = 0; + static otIp6Prefix sAddedExternalRoutes[kMaxExternalRoutesNum]; + #endif + ++static bool IsBroadUlaPrefix(const otIp6Prefix &aPrefix) ++{ ++ // Exact match for RoutingManager::RoutePublisher::kUlaPrefix ++ static const otIp6Prefix kBroadUla = { ++ {{{0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ++ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}}, ++ 7, ++ }; ++ return otIp6ArePrefixesEqual(&aPrefix, &kBroadUla); ++} ++ + #if defined(RTM_NEWMADDR) || defined(__NetBSD__) + // on some BSDs (mac OS, FreeBSD), we get RTM_NEWMADDR/RTM_DELMADDR messages, so we don't need to monitor using MLD + // on NetBSD, MLD monitoring simply doesn't work +@@ -937,10 +948,15 @@ static void UpdateExternalRoutes(otInstance *aInstance) + + while (otNetDataGetNextRoute(aInstance, &iterator, &config) == OT_ERROR_NONE) + { +- if (config.mRloc16 == otThreadGetRloc16(aInstance) || HasAddedExternalRoute(config.mPrefix)) +- { +- continue; +- } ++ if (config.mRloc16 == otThreadGetRloc16(aInstance) || ++ HasAddedExternalRoute(config.mPrefix) ++ #if OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE ++ || IsBroadUlaPrefix(config.mPrefix) ++ #endif ++ ) ++ { ++ continue; ++ } + VerifyOrExit(sAddedExternalRoutesNum < kMaxExternalRoutesNum, + LogWarn("No buffer to add more external routes in kernel")); + 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..2281b66b1 --- /dev/null +++ b/openthread_border_router/0006-routing-manager-corrections.patch @@ -0,0 +1,47 @@ +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 +@@ -650,7 +650,7 @@ bool RoutingManager::NetworkDataContainsUlaRoute(void) const + + while (Get().GetNext(iterator, routeConfig) == kErrorNone) + { +- if (routeConfig.mStable && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) ++ if (routeConfig.mStable && routeConfig.GetPrefix().GetLength() == 64 && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) + { + contains = true; + break; +@@ -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..17b0b96a3 100644 --- a/openthread_border_router/CHANGELOG.md +++ b/openthread_border_router/CHANGELOG.md @@ -1,5 +1,39 @@ # 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`, `--ula-prefix`) - 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: re-implement proper nftables-based NAT44 masquerade (mark + postrouting + forward rules) to replace the removed iptables rules. NAT64 now works without iptables in the runtime image. +- Build: apply all patches to the beta builder stage so both stable and beta builds carry the same fixes. Verified each patch against its target commit: + - Patches 0001 (SO_REUSEADDR) and 0002 (NAT64 IPv4 options) were already upstream in the beta commit -- skipped. + - Patches 0003 (ULA prefix agent) and 0004 (ULA prefix core) required beta-specific versions due to `OTBR_OPT_DATA_PATH` addition and `.Set()`→`.InitFrom()` API change in the beta's OpenThread submodule. + - Patches 0005 (fc00::/7 disable) and 0006 (routing manager corrections) apply cleanly to both stable and beta. +- 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. +- Docs: document `backbone_interface`, `custom_omr_priority`, `leader_weight`, `upgrade_threshold`, and `downgrade_threshold` 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..79607c90e 100644 --- a/openthread_border_router/DOCS.md +++ b/openthread_border_router/DOCS.md @@ -69,6 +69,12 @@ 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`). Leave empty for automatic behavior. **This is the primary fix for multi-BR loop prevention** -- a stable OMR prefix across restarts eliminates the orphaned-partition trigger that causes OTBR to install the `fc00::/7` catch-all route. | +| 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. | +| upgrade_threshold | Router upgrade threshold (default: 8). The number of router-capable End Devices (REEDs) that must be present before a REED attempts to become a Thread Router. Lower values promote REEDs to routers sooner, increasing mesh density. | +| downgrade_threshold | Router downgrade threshold (default: 24). The number of Thread Routers that must be present before a Router attempts to downgrade to REED. Higher values keep more routers in the mesh, improving redundancy at the cost of higher radio overhead. | > [!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 226458ef4..fecd498f7 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -16,6 +16,10 @@ ENV DOCKER=1 COPY openthread-core-ha-config-posix.h /usr/src/ +COPY 0003-suggested-ula-prefix-agent-beta.patch /usr/src/ +COPY 0004-suggested-ula-prefix-core-beta.patch /usr/src/ +COPY 0005-disable-fc00-route.patch /usr/src/ +COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src RUN \ @@ -43,7 +47,13 @@ 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 ---" \ + && echo "0001 and 0002 are already upstream in this beta commit -- skipping" \ + && patch -p1 < /usr/src/0003-suggested-ula-prefix-agent-beta.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0004-suggested-ula-prefix-core-beta.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0005-disable-fc00-route.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix RUN \ @@ -103,8 +113,10 @@ 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 0003-disable-fc00-route.patch /usr/src/ -COPY 0004-routing-manager-corrections.patch /usr/src/ +COPY 0003-suggested-ula-prefix-agent-stable.patch /usr/src/ +COPY 0004-suggested-ula-prefix-core-stable.patch /usr/src/ +COPY 0005-disable-fc00-route.patch /usr/src/ +COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src RUN \ @@ -134,8 +146,10 @@ RUN \ && 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/0003-disable-fc00-route.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0004-routing-manager-corrections.patch + && patch -p1 < /usr/src/0003-suggested-ula-prefix-agent-stable.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0004-suggested-ula-prefix-core-stable.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0005-disable-fc00-route.patch \ + && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix RUN \ @@ -170,8 +184,10 @@ RUN \ -DOT_CHANNEL_MONITOR=ON \ -DOT_COAP=OFF \ -DOT_COAPS=OFF \ - -DOTBR_NO_AUTO_ATTACH=0 \ + -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 \ diff --git a/openthread_border_router/config.yaml b/openthread_border_router/config.yaml index 145ceb12f..7deaaced1 100644 --- a/openthread_border_router/config.yaml +++ b/openthread_border_router/config.yaml @@ -31,9 +31,13 @@ options: flow_control: true otbr_log_level: notice firewall: true + nat64: false beta: false - omr_prefix: null - leader_weight: null + custom_omr_prefix: "" + custom_omr_priority: "med" + leader_weight: 72 + upgrade_threshold: 8 + downgrade_threshold: 24 ports: 8080/tcp: null 8081/tcp: null @@ -48,7 +52,11 @@ schema: network_device: str? otbr_log_level: list(debug|info|notice|warning|error|critical|alert|emergency) firewall: bool + nat64: bool beta: bool? - omr_prefix: str? - leader_weight: int? + custom_omr_prefix: str? + custom_omr_priority: list(high|med|low)? + leader_weight: int + upgrade_threshold: int? + downgrade_threshold: int? startup: services 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 5caee833b..98a0d3e5e 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 @@ -69,6 +69,14 @@ 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 +# 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 + nft delete table ip6 otbr 2>/dev/null || true if ! nft -f - </dev/null || true + + if ! nft -f - </dev/null || true +fi + # REST listen logic (unchanged) otbr_rest_listen="::" otbr_rest_listen_port="$(bashio::addon.port 8081)" diff --git a/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/.DS_Store b/openthread_border_router/rootfs/etc/s6-overlay/s6-rc.d/otbr-route-guard/.DS_Store deleted file mode 100644 index c1a5f405e2fbfcddeebc64d9844b87e4dd872005..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%Sr=55Ukc50z$~q<9vbO9}FRWL4H7Rg9x&UqUSyNU4B}sA1GlXc=00D&|NdV zT{CPQwzmP;=54qJ)&Q1tM|^mgn?HA-*;!?bNaqTzE?WBMd_*V+pV!PXJ_)67VXD{cyw$X3tUh_$J<2on| m(T<7Hj=Ax6d>KVq*L=LnlI`cs%>Sw@pkx7BSR^R|a9v0vL 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 3cac2f846..99e50bc45 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,22 +6,10 @@ ot-ctl trel enable -# if bashio::config.true 'nat64'; then -# bashio::log.info "Enabling NAT64." -# ot-ctl nat64 enable -# ot-ctl dns server upstream enable -# fi - -mdns_localhostname="$(hostname)-otbr" -bashio::log.info "Setting OpenThread mDNS local hostname to ${mdns_localhostname}." -ot-ctl mdns localhostname "${mdns_localhostname}" -ot-ctl mdns enable - -# 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 - -# Sync TREL UDP port into nftables set ip6 otbr trel_ports (rules match @trel_ports; empty set = no TREL match until populated) +# ------------------------------------------------------------------ +# 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 @@ -59,21 +47,68 @@ sync_trel_port_to_firewall() { sync_trel_port_to_firewall +if bashio::config.true 'nat64'; then + bashio::log.info "Enabling NAT64." + ot-ctl nat64 enable + ot-ctl dns server upstream enable +fi + +mdns_localhostname="$(hostname)-otbr" +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 -# Configure the custom OMR prefix if the user specified a prefix to use. -if bashio::config.has_value 'omr_prefix'; then - DESIRED_PREFIX=$(bashio::config 'omr_prefix') +# ============================================================================== +# Custom OMR Prefix + Preference +# ============================================================================== +OMR_PREF=$(bashio::config 'custom_omr_priority') +if bashio::config.has_value 'custom_omr_prefix'; then + DESIRED_PREFIX=$(bashio::config 'custom_omr_prefix') + if [[ -n "$DESIRED_PREFIX" ]]; then - if ot-ctl br omrconfig custom "${DESIRED_PREFIX}" med; then - bashio::log.info "✅ Successfully applied custom OMR prefix: ${DESIRED_PREFIX}" - else - bashio::log.error "❌ Failed to apply custom OMR prefix" - fi + bashio::log.info "Custom OMR prefix requested: ${DESIRED_PREFIX}" + + # 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 + bashio::log.info "✅ Custom OMR prefix already set to ${DESIRED_PREFIX}" + else + bashio::log.info "Applying custom OMR prefix: ${DESIRED_PREFIX}" + + if ot-ctl br omrconfig custom "${DESIRED_PREFIX}" "${OMR_PREF}"; then + bashio::log.info "✅ Successfully applied custom OMR prefix: ${DESIRED_PREFIX} (priority ${OMR_PREF})" + else + bashio::log.error "❌ Failed to apply custom OMR prefix" + return 11 + fi + 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') @@ -81,9 +116,40 @@ if bashio::config.has_value '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 -# Start thread at the end of configuration. Ensures the cleanest possible "moment of attachment" with any existing nodes. -# In this case, in testing, it has been found that Thread nodes are most reliable at recognizing leadership weights of border routers when they are configured before the BR radio attaches. -ot-ctl thread start \ No newline at end of file +if bashio::config.has_value 'upgrade_threshold'; then + UPGRADE_THRESHOLD=$(bashio::config 'upgrade_threshold') + if ot-ctl routerupgradethreshold $UPGRADE_THRESHOLD; then + bashio::log.info "✅ Successfully applied upgrade threshold: ${UPGRADE_THRESHOLD}" + else + bashio::log.error "❌ Failed to apply upgrade threshold" + return 13 + fi +fi + +if bashio::config.has_value 'downgrade_threshold'; then + DOWNGRADE_THRESHOLD=$(bashio::config 'downgrade_threshold') + if ot-ctl routerdowngradethreshold $DOWNGRADE_THRESHOLD; then + bashio::log.info "✅ Successfully applied downgrade threshold: ${DOWNGRADE_THRESHOLD}" + else + bashio::log.error "❌ Failed to apply downgrade threshold" + return 14 + 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/translations/en.yaml b/openthread_border_router/translations/en.yaml index 518c918ca..afbd16d75 100644 --- a/openthread_border_router/translations/en.yaml +++ b/openthread_border_router/translations/en.yaml @@ -39,6 +39,37 @@ 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 for automatic behavior. A stable prefix across restarts + prevents the multi-border-router routing loop (fc00::/7 catch-all + route). All border routers on a shared mesh must use the same prefix. + 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. + upgrade_threshold: + name: Router upgrade threshold + description: >- + Number of router-capable end devices that must be present before one + attempts to upgrade to a Thread router. Lower values increase mesh + density sooner. + downgrade_threshold: + name: Router downgrade threshold + description: >- + Number of Thread routers that must be present before a router attempts + to downgrade to an end device. Higher values keep more routers for + redundancy at higher radio overhead. network: 8080/tcp: OpenThread Web port 8081/tcp: OpenThread REST API port From ce727c4127d401acf9ac5ada897a6198893f3908 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Tue, 25 Aug 2026 18:41:16 -0500 Subject: [PATCH 12/19] otbr: drop min/max router threshold options from config UI Remove upgrade_threshold and downgrade_threshold from the add-on config schema, defaults, and frontend translations. These expose Thread router upgrade/downgrade targets that are not needed for this submission. --- openthread_border_router/config.yaml | 4 ---- openthread_border_router/translations/en.yaml | 12 ------------ 2 files changed, 16 deletions(-) diff --git a/openthread_border_router/config.yaml b/openthread_border_router/config.yaml index 7deaaced1..e51bab780 100644 --- a/openthread_border_router/config.yaml +++ b/openthread_border_router/config.yaml @@ -36,8 +36,6 @@ options: custom_omr_prefix: "" custom_omr_priority: "med" leader_weight: 72 - upgrade_threshold: 8 - downgrade_threshold: 24 ports: 8080/tcp: null 8081/tcp: null @@ -57,6 +55,4 @@ schema: custom_omr_prefix: str? custom_omr_priority: list(high|med|low)? leader_weight: int - upgrade_threshold: int? - downgrade_threshold: int? startup: services diff --git a/openthread_border_router/translations/en.yaml b/openthread_border_router/translations/en.yaml index afbd16d75..f960c8c66 100644 --- a/openthread_border_router/translations/en.yaml +++ b/openthread_border_router/translations/en.yaml @@ -58,18 +58,6 @@ configuration: 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. - upgrade_threshold: - name: Router upgrade threshold - description: >- - Number of router-capable end devices that must be present before one - attempts to upgrade to a Thread router. Lower values increase mesh - density sooner. - downgrade_threshold: - name: Router downgrade threshold - description: >- - Number of Thread routers that must be present before a router attempts - to downgrade to an end device. Higher values keep more routers for - redundancy at higher radio overhead. network: 8080/tcp: OpenThread Web port 8081/tcp: OpenThread REST API port From b5d7711c2b1c317db2c1e64d037514675da7ff20 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Tue, 25 Aug 2026 18:59:19 -0500 Subject: [PATCH 13/19] otbr: drop min/max router threshold docs references Remove upgrade_threshold and downgrade_threshold from the DOCS.md configuration table and the CHANGELOG docs line, matching the earlier removal of the options from the config UI. --- openthread_border_router/CHANGELOG.md | 2 +- openthread_border_router/DOCS.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/openthread_border_router/CHANGELOG.md b/openthread_border_router/CHANGELOG.md index 17b0b96a3..df58e7132 100644 --- a/openthread_border_router/CHANGELOG.md +++ b/openthread_border_router/CHANGELOG.md @@ -32,7 +32,7 @@ a major version. - **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. -- Docs: document `backbone_interface`, `custom_omr_priority`, `leader_weight`, `upgrade_threshold`, and `downgrade_threshold` configuration options. +- Docs: document `backbone_interface`, `custom_omr_priority`, and `leader_weight` configuration options. ## 3.0.2 diff --git a/openthread_border_router/DOCS.md b/openthread_border_router/DOCS.md index 79607c90e..a36af729c 100644 --- a/openthread_border_router/DOCS.md +++ b/openthread_border_router/DOCS.md @@ -73,8 +73,6 @@ App configuration: | custom_omr_prefix | Force a specific Off-Mesh Routable (OMR) prefix (e.g. `fd42:0001::/64`). Leave empty for automatic behavior. **This is the primary fix for multi-BR loop prevention** -- a stable OMR prefix across restarts eliminates the orphaned-partition trigger that causes OTBR to install the `fc00::/7` catch-all route. | | 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. | -| upgrade_threshold | Router upgrade threshold (default: 8). The number of router-capable End Devices (REEDs) that must be present before a REED attempts to become a Thread Router. Lower values promote REEDs to routers sooner, increasing mesh density. | -| downgrade_threshold | Router downgrade threshold (default: 24). The number of Thread Routers that must be present before a Router attempts to downgrade to REED. Higher values keep more routers in the mesh, improving redundancy at the cost of higher radio overhead. | > [!WARNING] > The OTBR expects the RCP connected radio to be on a reliable link such as From 93712e158e2831e5cdf5de9de5bcf7848a04b7c6 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Tue, 25 Aug 2026 19:18:05 -0500 Subject: [PATCH 14/19] otbr: remove router up/downgrade threshold handling from agent config script The upgrade_threshold and downgrade_threshold options were removed from the config schema and UI, so the bashio blocks that applied them are unreachable. Delete the dead ot-ctl routerupgradethreshold / routerdowngradethreshold handling. --- .../scripts/otbr-agent-configure.sh | 20 ------------------- 1 file changed, 20 deletions(-) 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 99e50bc45..ace0dc46e 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 @@ -120,26 +120,6 @@ if bashio::config.has_value 'leader_weight'; then fi fi -if bashio::config.has_value 'upgrade_threshold'; then - UPGRADE_THRESHOLD=$(bashio::config 'upgrade_threshold') - if ot-ctl routerupgradethreshold $UPGRADE_THRESHOLD; then - bashio::log.info "✅ Successfully applied upgrade threshold: ${UPGRADE_THRESHOLD}" - else - bashio::log.error "❌ Failed to apply upgrade threshold" - return 13 - fi -fi - -if bashio::config.has_value 'downgrade_threshold'; then - DOWNGRADE_THRESHOLD=$(bashio::config 'downgrade_threshold') - if ot-ctl routerdowngradethreshold $DOWNGRADE_THRESHOLD; then - bashio::log.info "✅ Successfully applied downgrade threshold: ${DOWNGRADE_THRESHOLD}" - else - bashio::log.error "❌ Failed to apply downgrade threshold" - return 14 - 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 From 8e4dc747a01e14fff99b9d45fa62638780c44b1b Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sat, 29 Aug 2026 09:20:24 -0500 Subject: [PATCH 15/19] otbr: rebase onto ot-br-posix #3325 and trim patch set to one - Bump build pins to ot-br-posix main tip 4e5cfca4 (includes #3325 in-process opt-in nftables backend as the new base) - Drop patches now upstream: 0001 (SO_REUSEADDR), 0002 (NAT64 IPv4 options/security hardening) - Drop speculative ULA-prefix patches (0003/0004) and the broad-ULA-route patch (0005) plus orphaned duplicates - Keep 0006 routing-manager corrections, re-based for the new upstream NetworkDataContainsUlaRoute() logic (kUlaPrefix /7->/64, publish real OMR prefix, never publish ::/0) - Replace 0005 with OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE=0 in the project config header (the upstream #13562 mechanism) - Build validated on clean Ubuntu 24.04 host (amd64, beta+stable, 0 errors) --- .../0001-rest-SO_REUSEADDR.patch | 30 ---- .../0002-nat64-handle-ipv4-options.patch | 167 ------------------ .../0003-disable-fc00-route.patch | 42 ----- ...0003-suggested-ula-prefix-agent-beta.patch | 67 ------- ...03-suggested-ula-prefix-agent-stable.patch | 71 -------- .../0004-routing-manager-corrections.patch | 47 ----- .../0004-suggested-ula-prefix-core-beta.patch | 80 --------- ...004-suggested-ula-prefix-core-stable.patch | 80 --------- .../0005-disable-fc00-route.patch | 42 ----- .../0006-routing-manager-corrections.patch | 9 - openthread_border_router/Dockerfile | 17 -- openthread_border_router/build.yaml | 4 +- .../openthread-core-ha-config-posix.h | 24 +-- 13 files changed, 14 insertions(+), 666 deletions(-) delete mode 100644 openthread_border_router/0001-rest-SO_REUSEADDR.patch delete mode 100644 openthread_border_router/0002-nat64-handle-ipv4-options.patch delete mode 100644 openthread_border_router/0003-disable-fc00-route.patch delete mode 100644 openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch delete mode 100644 openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch delete mode 100644 openthread_border_router/0004-routing-manager-corrections.patch delete mode 100644 openthread_border_router/0004-suggested-ula-prefix-core-beta.patch delete mode 100644 openthread_border_router/0004-suggested-ula-prefix-core-stable.patch delete mode 100644 openthread_border_router/0005-disable-fc00-route.patch 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/0003-disable-fc00-route.patch b/openthread_border_router/0003-disable-fc00-route.patch deleted file mode 100644 index 0a5a138d9..000000000 --- a/openthread_border_router/0003-disable-fc00-route.patch +++ /dev/null @@ -1,42 +0,0 @@ -diff --git a/src/posix/platform/netif.cpp b/src/posix/platform/netif.cpp -index 8ac42caee..ed5262c6a 100644 ---- a/src/posix/platform/netif.cpp -+++ b/src/posix/platform/netif.cpp -@@ -222,6 +222,17 @@ static uint8_t sAddedExternalRoutesNum = 0; - static otIp6Prefix sAddedExternalRoutes[kMaxExternalRoutesNum]; - #endif - -+static bool IsBroadUlaPrefix(const otIp6Prefix &aPrefix) -+{ -+ // Exact match for RoutingManager::RoutePublisher::kUlaPrefix -+ static const otIp6Prefix kBroadUla = { -+ {{{0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}}, -+ 7, -+ }; -+ return otIp6ArePrefixesEqual(&aPrefix, &kBroadUla); -+} -+ - #if defined(RTM_NEWMADDR) || defined(__NetBSD__) - // on some BSDs (mac OS, FreeBSD), we get RTM_NEWMADDR/RTM_DELMADDR messages, so we don't need to monitor using MLD - // on NetBSD, MLD monitoring simply doesn't work -@@ -937,10 +948,15 @@ static void UpdateExternalRoutes(otInstance *aInstance) - - while (otNetDataGetNextRoute(aInstance, &iterator, &config) == OT_ERROR_NONE) - { -- if (config.mRloc16 == otThreadGetRloc16(aInstance) || HasAddedExternalRoute(config.mPrefix)) -- { -- continue; -- } -+ if (config.mRloc16 == otThreadGetRloc16(aInstance) || -+ HasAddedExternalRoute(config.mPrefix) -+ #if OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE -+ || IsBroadUlaPrefix(config.mPrefix) -+ #endif -+ ) -+ { -+ continue; -+ } - VerifyOrExit(sAddedExternalRoutesNum < kMaxExternalRoutesNum, - LogWarn("No buffer to add more external routes in kernel")); - diff --git a/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch b/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch deleted file mode 100644 index a427e5ad5..000000000 --- a/openthread_border_router/0003-suggested-ula-prefix-agent-beta.patch +++ /dev/null @@ -1,67 +0,0 @@ -diff --git a/src/agent/realmain.cpp b/src/agent/realmain.cpp ---- a/src/agent/realmain.cpp 2026-08-12 15:16:06 -+++ b/src/agent/realmain.cpp 2026-08-12 15:23:03 -@@ -39,6 +39,7 @@ - #include - #include - #include -+#include - #include - - #include -@@ -82,6 +83,7 @@ - OTBR_OPT_VERSION = 'V', - OTBR_OPT_SHORTMAX = 128, - OTBR_OPT_DATA_PATH, -+ OTBR_OPT_ULA_PREFIX, - OTBR_OPT_RADIO_VERSION, - OTBR_OPT_AUTO_ATTACH, - OTBR_OPT_REST_LISTEN_ADDR, -@@ -103,6 +105,7 @@ - static const struct option kOptions[] = { - {"backbone-ifname", required_argument, nullptr, OTBR_OPT_BACKBONE_INTERFACE_NAME}, - {"data-path", required_argument, nullptr, OTBR_OPT_DATA_PATH}, -+ {"ula-prefix", required_argument, nullptr, OTBR_OPT_ULA_PREFIX}, - {"debug-level", required_argument, nullptr, OTBR_OPT_DEBUG_LEVEL}, - {"help", no_argument, nullptr, OTBR_OPT_HELP}, - {"thread-ifname", required_argument, nullptr, OTBR_OPT_INTERFACE_NAME}, -@@ -164,6 +167,9 @@ - fprintf(stderr, - "Usage: %s [-I interfaceName] [-B backboneIfName] [-d DEBUG_LEVEL] [-v] [-s] [--auto-attach[=0/1]] " - "RADIO_URL [RADIO_URL]\n" -+ " --ula-prefix Optional suggested /48 BR ULA prefix (e.g. fdf0:1234:5678::/48).\n" -+ " Used instead of random generation on first launch (when no valid\n" -+ " prefix exists in settings). Persisted for future launches.\n" - " --data-path Path of directory to store data.\n" - " -I, --thread-ifname Name of the Thread network interface (default: " DEFAULT_INTERFACE_NAME ").\n" - " -B, --backbone-ifname Name of the backbone network interfaces (can be specified multiple times).\n" -@@ -253,6 +259,7 @@ - const char *restListenAddress = "127.0.0.1"; - int restListenPort = kPortNumber; - const char *dataPath = ""; -+ const char *suggestedUlaPrefix = nullptr; - std::vector radioUrls; - std::vector backboneInterfaceNames; - long parseResult; -@@ -275,6 +282,10 @@ - { - switch (opt) - { -+ case OTBR_OPT_ULA_PREFIX: -+ suggestedUlaPrefix = optarg; -+ break; -+ - case OTBR_OPT_BACKBONE_INTERFACE_NAME: - backboneInterfaceNames.push_back(optarg); - otbrLogNotice("Backbone interface: %s", optarg); -@@ -368,6 +379,10 @@ - #endif - - otbrLogInit(argv[0], logLevel, verbose, syslogDisable); -+ if (suggestedUlaPrefix != nullptr) -+ { -+ setenv("OTBR_SUGGESTED_BR_ULA_PREFIX", suggestedUlaPrefix, 1); -+ } - otbrLogNotice("Running %s", OTBR_PACKAGE_VERSION); - otbrLogNotice("Thread version: %s", otbr::Host::RcpHost::GetThreadVersion()); - otbrLogNotice("Thread interface: %s", interfaceName); diff --git a/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch b/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch deleted file mode 100644 index e0167b8ce..000000000 --- a/openthread_border_router/0003-suggested-ula-prefix-agent-stable.patch +++ /dev/null @@ -1,71 +0,0 @@ -diff --git a/src/agent/realmain.cpp b/src/agent/realmain.cpp -index e1e05e1c..c421085c 100644 ---- a/src/agent/realmain.cpp -+++ b/src/agent/realmain.cpp -@@ -39,6 +39,7 @@ - #include - #include - #include -+#include - #include - - #include -@@ -81,6 +82,7 @@ enum - OTBR_OPT_SYSLOG_DISABLE = 's', - OTBR_OPT_VERSION = 'V', - OTBR_OPT_SHORTMAX = 128, -+ OTBR_OPT_ULA_PREFIX, - OTBR_OPT_RADIO_VERSION, - OTBR_OPT_AUTO_ATTACH, - OTBR_OPT_REST_LISTEN_ADDR, -@@ -105,6 +107,7 @@ static const struct option kOptions[] = { - {"help", no_argument, nullptr, OTBR_OPT_HELP}, - {"thread-ifname", required_argument, nullptr, OTBR_OPT_INTERFACE_NAME}, - {"verbose", no_argument, nullptr, OTBR_OPT_VERBOSE}, -+ {"ula-prefix", required_argument, nullptr, OTBR_OPT_ULA_PREFIX}, - {"syslog-disable", no_argument, nullptr, OTBR_OPT_SYSLOG_DISABLE}, - {"version", no_argument, nullptr, OTBR_OPT_VERSION}, - {"radio-version", no_argument, nullptr, OTBR_OPT_RADIO_VERSION}, -@@ -162,6 +165,9 @@ static void PrintHelp(const char *aProgramName) - fprintf(stderr, - "Usage: %s [-I interfaceName] [-B backboneIfName] [-d DEBUG_LEVEL] [-v] [-s] [--auto-attach[=0/1]] " - "RADIO_URL [RADIO_URL]\n" -+ " --ula-prefix Optional suggested /48 BR ULA prefix (e.g. fdf0:1234:5678::/48).\n" -+ " Used instead of random generation on first launch (when no valid\n" -+ " prefix exists in settings). Persisted for future launches.\n" - " -I, --thread-ifname Name of the Thread network interface (default: " DEFAULT_INTERFACE_NAME ").\n" - " -B, --backbone-ifname Name of the backbone network interfaces (can be specified multiple times).\n" - " -d, --debug-level The log level (EMERG=0, ALERT=1, CRIT=2, ERR=3, WARNING=4, NOTICE=5, INFO=6, " -@@ -250,6 +256,7 @@ static int realmain(int argc, char *argv[]) - const char *restListenAddress = "127.0.0.1"; - int restListenPort = kPortNumber; - std::vector radioUrls; -+ const char *suggestedUlaPrefix = nullptr; - std::vector backboneInterfaceNames; - long parseResult; - -@@ -270,6 +277,11 @@ static int realmain(int argc, char *argv[]) - { - switch (opt) - { -+ -+ case OTBR_OPT_ULA_PREFIX: -+ suggestedUlaPrefix = optarg; -+ break; -+ - case OTBR_OPT_BACKBONE_INTERFACE_NAME: - backboneInterfaceNames.push_back(optarg); - otbrLogNotice("Backbone interface: %s", optarg); -@@ -358,7 +370,12 @@ static int realmain(int argc, char *argv[]) - } - #endif - -+ - otbrLogInit(argv[0], logLevel, verbose, syslogDisable); -+ if (suggestedUlaPrefix != nullptr) -+ { -+ setenv("OTBR_SUGGESTED_BR_ULA_PREFIX", suggestedUlaPrefix, 1); -+ } - otbrLogNotice("Running %s", OTBR_PACKAGE_VERSION); - otbrLogNotice("Thread version: %s", otbr::Host::RcpHost::GetThreadVersion()); - otbrLogNotice("Thread interface: %s", interfaceName); diff --git a/openthread_border_router/0004-routing-manager-corrections.patch b/openthread_border_router/0004-routing-manager-corrections.patch deleted file mode 100644 index 2281b66b1..000000000 --- a/openthread_border_router/0004-routing-manager-corrections.patch +++ /dev/null @@ -1,47 +0,0 @@ -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 -@@ -650,7 +650,7 @@ bool RoutingManager::NetworkDataContainsUlaRoute(void) const - - while (Get().GetNext(iterator, routeConfig) == kErrorNone) - { -- if (routeConfig.mStable && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) -+ if (routeConfig.mStable && routeConfig.GetPrefix().GetLength() == 64 && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) - { - contains = true; - break; -@@ -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/0004-suggested-ula-prefix-core-beta.patch b/openthread_border_router/0004-suggested-ula-prefix-core-beta.patch deleted file mode 100644 index 11d2f90e7..000000000 --- a/openthread_border_router/0004-suggested-ula-prefix-core-beta.patch +++ /dev/null @@ -1,80 +0,0 @@ -diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp ---- a/src/core/border_router/routing_manager.cpp 2026-08-12 15:16:24 -+++ b/src/core/border_router/routing_manager.cpp 2026-08-12 15:23:03 -@@ -36,7 +36,7 @@ - #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE - - #include -- -+#include - #include - #include - #include -@@ -215,26 +215,56 @@ - void RoutingManager::LoadOrGenerateRandomBrUlaPrefix(void) - { - bool generated = false; -+ bool suggested = false; - - if (Get().Read(mBrUlaPrefix) != kErrorNone || !IsValidBrUlaPrefix(mBrUlaPrefix)) - { -- Ip6::NetworkPrefix randomUlaPrefix; -+ const char *envSuggested = getenv("OTBR_SUGGESTED_BR_ULA_PREFIX"); -+ if (envSuggested != nullptr) -+ { -+ char addrPart[64]; -+ int prefixLen = 0; - -- LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); -+ if (sscanf(envSuggested, "%47[^/]/%d", addrPart, &prefixLen) == 2 && -+ prefixLen == kBrUlaPrefixLength) -+ { -+ struct in6_addr addr6; -+ if (inet_pton(AF_INET6, addrPart, &addr6) == 1) -+ { -+ mBrUlaPrefix.Clear(); -+ mBrUlaPrefix.mLength = kBrUlaPrefixLength; -+ memcpy(mBrUlaPrefix.mPrefix.mFields.m8, addr6.s6_addr, 6); - -- SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); -- -- mBrUlaPrefix.InitFrom(randomUlaPrefix); -- mBrUlaPrefix.SetSubnetId(0); -- mBrUlaPrefix.SetLength(kBrUlaPrefixLength); -+ if (IsValidBrUlaPrefix(mBrUlaPrefix)) -+ { -+ Get().Save(mBrUlaPrefix); -+ suggested = true; -+ LogNote("No valid /48 BR ULA prefix found in settings, using suggested one from command line"); -+ } -+ } -+ } -+ } - -- Get().Save(mBrUlaPrefix); -- generated = true; -+ if (!suggested) -+ { -+ Ip6::NetworkPrefix randomUlaPrefix; -+ -+ LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); -+ -+ SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); -+ -+ mBrUlaPrefix.InitFrom(randomUlaPrefix); -+ mBrUlaPrefix.SetSubnetId(0); -+ mBrUlaPrefix.SetLength(kBrUlaPrefixLength); -+ -+ Get().Save(mBrUlaPrefix); -+ generated = true; -+ } - } - - OT_UNUSED_VARIABLE(generated); -- -- LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), generated ? "generated" : "loaded"); -+ const char *status = suggested ? "suggested" : (generated ? "generated" : "loaded"); -+ LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), status); - } - - void RoutingManager::EvaluateState(void) diff --git a/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch b/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch deleted file mode 100644 index 5802ef9ba..000000000 --- a/openthread_border_router/0004-suggested-ula-prefix-core-stable.patch +++ /dev/null @@ -1,80 +0,0 @@ -diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp -index 6be8b9002..4b2c98d9f 100644 ---- a/src/core/border_router/routing_manager.cpp -+++ b/src/core/border_router/routing_manager.cpp -@@ -36,7 +36,7 @@ - #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE - - #include -- -+#include - #include - #include - #include -@@ -215,26 +215,56 @@ exit: - void RoutingManager::LoadOrGenerateRandomBrUlaPrefix(void) - { - bool generated = false; -+ bool suggested = false; - - if (Get().Read(mBrUlaPrefix) != kErrorNone || !IsValidBrUlaPrefix(mBrUlaPrefix)) - { -- Ip6::NetworkPrefix randomUlaPrefix; -+ const char *envSuggested = getenv("OTBR_SUGGESTED_BR_ULA_PREFIX"); -+ if (envSuggested != nullptr) -+ { -+ char addrPart[64]; -+ int prefixLen = 0; -+ -+ if (sscanf(envSuggested, "%47[^/]/%d", addrPart, &prefixLen) == 2 && -+ prefixLen == kBrUlaPrefixLength) -+ { -+ struct in6_addr addr6; -+ if (inet_pton(AF_INET6, addrPart, &addr6) == 1) -+ { -+ mBrUlaPrefix.Clear(); -+ mBrUlaPrefix.mLength = kBrUlaPrefixLength; -+ memcpy(mBrUlaPrefix.mPrefix.mFields.m8, addr6.s6_addr, 6); -+ -+ if (IsValidBrUlaPrefix(mBrUlaPrefix)) -+ { -+ Get().Save(mBrUlaPrefix); -+ suggested = true; -+ LogNote("No valid /48 BR ULA prefix found in settings, using suggested one from command line"); -+ } -+ } -+ } -+ } -+ -+ if (!suggested) -+ { -+ Ip6::NetworkPrefix randomUlaPrefix; - -- LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); -+ LogNote("No valid /48 BR ULA prefix found in settings, generating new one"); - -- SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); -+ SuccessOrAssert(randomUlaPrefix.GenerateRandomUla()); - -- mBrUlaPrefix.Set(randomUlaPrefix); -- mBrUlaPrefix.SetSubnetId(0); -- mBrUlaPrefix.SetLength(kBrUlaPrefixLength); -+ mBrUlaPrefix.Set(randomUlaPrefix); // <-- .Set() for this OT commit -+ mBrUlaPrefix.SetSubnetId(0); -+ mBrUlaPrefix.SetLength(kBrUlaPrefixLength); - -- Get().Save(mBrUlaPrefix); -- generated = true; -+ Get().Save(mBrUlaPrefix); -+ generated = true; -+ } - } - - OT_UNUSED_VARIABLE(generated); -- -- LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), generated ? "generated" : "loaded"); -+ const char *status = suggested ? "suggested" : (generated ? "generated" : "loaded"); -+ LogNote("BR ULA prefix: %s (%s)", mBrUlaPrefix.ToString().AsCString(), status); - } - - void RoutingManager::EvaluateState(void) diff --git a/openthread_border_router/0005-disable-fc00-route.patch b/openthread_border_router/0005-disable-fc00-route.patch deleted file mode 100644 index 0a5a138d9..000000000 --- a/openthread_border_router/0005-disable-fc00-route.patch +++ /dev/null @@ -1,42 +0,0 @@ -diff --git a/src/posix/platform/netif.cpp b/src/posix/platform/netif.cpp -index 8ac42caee..ed5262c6a 100644 ---- a/src/posix/platform/netif.cpp -+++ b/src/posix/platform/netif.cpp -@@ -222,6 +222,17 @@ static uint8_t sAddedExternalRoutesNum = 0; - static otIp6Prefix sAddedExternalRoutes[kMaxExternalRoutesNum]; - #endif - -+static bool IsBroadUlaPrefix(const otIp6Prefix &aPrefix) -+{ -+ // Exact match for RoutingManager::RoutePublisher::kUlaPrefix -+ static const otIp6Prefix kBroadUla = { -+ {{{0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}}, -+ 7, -+ }; -+ return otIp6ArePrefixesEqual(&aPrefix, &kBroadUla); -+} -+ - #if defined(RTM_NEWMADDR) || defined(__NetBSD__) - // on some BSDs (mac OS, FreeBSD), we get RTM_NEWMADDR/RTM_DELMADDR messages, so we don't need to monitor using MLD - // on NetBSD, MLD monitoring simply doesn't work -@@ -937,10 +948,15 @@ static void UpdateExternalRoutes(otInstance *aInstance) - - while (otNetDataGetNextRoute(aInstance, &iterator, &config) == OT_ERROR_NONE) - { -- if (config.mRloc16 == otThreadGetRloc16(aInstance) || HasAddedExternalRoute(config.mPrefix)) -- { -- continue; -- } -+ if (config.mRloc16 == otThreadGetRloc16(aInstance) || -+ HasAddedExternalRoute(config.mPrefix) -+ #if OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE -+ || IsBroadUlaPrefix(config.mPrefix) -+ #endif -+ ) -+ { -+ continue; -+ } - VerifyOrExit(sAddedExternalRoutesNum < kMaxExternalRoutesNum, - LogWarn("No buffer to add more external routes in kernel")); - diff --git a/openthread_border_router/0006-routing-manager-corrections.patch b/openthread_border_router/0006-routing-manager-corrections.patch index 2281b66b1..172978a09 100644 --- a/openthread_border_router/0006-routing-manager-corrections.patch +++ b/openthread_border_router/0006-routing-manager-corrections.patch @@ -2,15 +2,6 @@ diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router index 6be8b9002..8dfeb1a25 100644 --- a/src/core/border_router/routing_manager.cpp +++ b/src/core/border_router/routing_manager.cpp -@@ -650,7 +650,7 @@ bool RoutingManager::NetworkDataContainsUlaRoute(void) const - - while (Get().GetNext(iterator, routeConfig) == kErrorNone) - { -- if (routeConfig.mStable && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) -+ if (routeConfig.mStable && routeConfig.GetPrefix().GetLength() == 64 && RoutePublisher::GetUlaPrefix().ContainsPrefix(routeConfig.GetPrefix())) - { - contains = true; - break; @@ -1943,7 +1943,7 @@ exit: const otIp6Prefix RoutingManager::RoutePublisher::kUlaPrefix = { diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index fecd498f7..bfe1f3bdf 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -16,9 +16,6 @@ ENV DOCKER=1 COPY openthread-core-ha-config-posix.h /usr/src/ -COPY 0003-suggested-ula-prefix-agent-beta.patch /usr/src/ -COPY 0004-suggested-ula-prefix-core-beta.patch /usr/src/ -COPY 0005-disable-fc00-route.patch /usr/src/ COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src @@ -49,10 +46,6 @@ RUN \ && git checkout ${OTBR_BETA_VERSION} \ && git submodule update --init --recursive --depth 1 \ && echo "--- Applying patches to beta build ---" \ - && echo "0001 and 0002 are already upstream in this beta commit -- skipping" \ - && patch -p1 < /usr/src/0003-suggested-ula-prefix-agent-beta.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0004-suggested-ula-prefix-core-beta.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0005-disable-fc00-route.patch \ && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix @@ -111,11 +104,6 @@ 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 0003-suggested-ula-prefix-agent-stable.patch /usr/src/ -COPY 0004-suggested-ula-prefix-core-stable.patch /usr/src/ -COPY 0005-disable-fc00-route.patch /usr/src/ COPY 0006-routing-manager-corrections.patch /usr/src/ WORKDIR /usr/src @@ -144,11 +132,6 @@ 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 < /usr/src/0003-suggested-ula-prefix-agent-stable.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0004-suggested-ula-prefix-core-stable.patch \ - && patch -p1 -d third_party/openthread/repo < /usr/src/0005-disable-fc00-route.patch \ && patch -p1 -d third_party/openthread/repo < /usr/src/0006-routing-manager-corrections.patch WORKDIR /usr/src/ot-br-posix 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/openthread-core-ha-config-posix.h b/openthread_border_router/openthread-core-ha-config-posix.h index 1233dcc98..270356f11 100644 --- a/openthread_border_router/openthread-core-ha-config-posix.h +++ b/openthread_border_router/openthread-core-ha-config-posix.h @@ -85,21 +85,21 @@ #define OPENTHREAD_CONFIG_MULTICAST_DNS_AUTO_ENABLE_ON_INFRA_IF 0 /** - * @def OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE + * @def OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE * - * Define as 1 to prevent the POSIX netif layer from ever installing the - * broad Unique Local catch-all route fc00::/7 into the Linux kernel. + * 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. * - * This route is extremely damaging on multi-VLAN / multi-BR networks because - * it steals the entire ULA space toward the Thread interface. - * - * Specific OMR prefixes, mesh-local, and any other external routes continue - * to be installed normally. - * - * Default: 1 (disabled) for this fork. + * 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_DISABLE_BROAD_ULA_ROUTE -#define OPENTHREAD_POSIX_CONFIG_DISABLE_BROAD_ULA_ROUTE 1 +#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 From 14d8fa1e9ab6ead4a72ea59fa4163964b64eee6f Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sat, 29 Aug 2026 09:44:49 -0500 Subject: [PATCH 16/19] otbr: enable in-process nftables firewall backend at build time Build both beta and stable with -DOTBR_NFTABLES=ON: the upstream #3325 in-process backend (libnftnl/libmnl) owns the ingress filter and NAT44 and forces OT_FIREWALL off, so there is no legacy ipset producer. Add libnftnl-dev and libmnl-dev to both builder stages. NOTE: runtime wiring in otbr-agent/run is a follow-up commit. Deploying this commit alone would leave the add-on shell firewall without its ipset source. --- openthread_border_router/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openthread_border_router/Dockerfile b/openthread_border_router/Dockerfile index bfe1f3bdf..fcf2365a6 100644 --- a/openthread_border_router/Dockerfile +++ b/openthread_border_router/Dockerfile @@ -35,6 +35,8 @@ RUN \ nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ + libnftnl-dev \ + libmnl-dev \ nodejs \ npm \ libprotobuf-dev \ @@ -75,6 +77,7 @@ 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 \ @@ -123,6 +126,8 @@ RUN \ nftables \ libjsoncpp-dev \ libnetfilter-queue-dev \ + libnftnl-dev \ + libmnl-dev \ nodejs \ npm \ libprotobuf-dev \ @@ -161,6 +166,7 @@ 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 \ From ef7dc40dff0915bfb70a6ec4698699163c91b54f Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Sun, 30 Aug 2026 22:18:06 -0500 Subject: [PATCH 17/19] otbr: wire runtime for in-process nftables backend (ot-br-posix#3325) With -DOTBR_NFTABLES=ON the agent owns unicast ingress into the Thread interface and NAT44 in-process (OT_FIREWALL is compiled out, so the classic ipsets are never populated). Adapt otbr-agent/run: - Detect the backend via the build marker under the OTBR install prefix (/opt/otbr-{stable,beta}/share/otbr/nftables-backend), env override wins - Skip legacy ipset creation and ipset->nft sync in backend mode - Add-on 'ip6 otbr' forward chains end in 'return', so unicast ingress falls through to the agent's inet otbr forward_ingress (the add-on chain evaluates first at the same hook/priority; a terminal accept/drop would terminate the hook and bypass the backend) - Keep host input/output protection, scope-gated multicast / mDNS containment, TREL isolation and DOCKER-USER hole; replace the now-empty ingress allow/deny set matches with rate-limited generic accepts - Skip the add-on NAT64 table in backend mode (agent owns NAT44 masquerade) - firewall toggle now slices the add-on layer only - otbr-ipset-sync exits early in backend mode (nothing to sync) Image-verified: marker=1 under the prefix, detection=1, backend symbol present, scripts parse. --- .../etc/s6-overlay/s6-rc.d/otbr-agent/run | 219 +++++++++++------- .../s6-overlay/s6-rc.d/otbr-ipset-sync/run | 10 + 2 files changed, 142 insertions(+), 87 deletions(-) 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 98a0d3e5e..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,10 +64,48 @@ 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." -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 + +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 + +# 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 # Pre-populate TREL port before firewall creation to avoid a race where TREL # traffic is silently dropped between firewall load and the configure script @@ -81,26 +119,6 @@ nft delete table ip6 otbr 2>/dev/null || true 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 @@ -336,57 +362,75 @@ setup_docker_user_forward() { setup_docker_user_forward "${backbone_if}" "${thread_if}" -# 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 +# 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 -} + 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 + 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 (strict backbone→Thread, permissive Thread→backbone, host control-plane blocked on radio)" + 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 critical ingress chain so behaviour matches stock OTBR + # 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 - bashio::log.info "OTBR firewall disabled — from-backbone-to-Thread set to 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 support: nftables NAT44 masquerade for Thread-originated IPv4 -# traffic (after OTBR's IPv6→IPv4 translation). The mark ${otbr_fw_mark} -# is set on all packets entering from the Thread interface so that -# postrouting masquerade can identify which traffic to NAT. +# 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 bashio::config.true 'nat64'; then - bashio::log.info "Enabling NAT64 nftables rules..." +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 + # Clean up any previous instance (idempotent restart) + nft delete table ip otbr_nat64 2>/dev/null || true - if ! nft -f - </dev/null || true fi -else - # Clean up NAT64 rules when disabled (ensures no stale rules on toggle) - nft delete table ip otbr_nat64 2>/dev/null || true fi # REST listen logic (unchanged) @@ -448,4 +493,4 @@ exec s6-notifyoncheck -d -s 300 -w 300 -n 0 stdbuf -oL \ --rest-listen-address "${otbr_rest_listen}" \ -d${otbr_log_level_int} -v -s \ "spinel+hdlc+uart://${device}?uart-baudrate=${baudrate}${flow_control}" \ - "trel://${backbone_if}" \ No newline at end of file + "trel://${backbone_if}" 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 index e3386d53e..ff056371f 100755 --- 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 @@ -5,6 +5,16 @@ # 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" From 70457f85c1ab30458e2c4f83673fa25d90ca9a2f Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Tue, 1 Sep 2026 08:06:00 -0500 Subject: [PATCH 18/19] otbr: derive deterministic OMR prefix from Thread network name When custom_omr_prefix is not set, hash the Thread network name into a ULA /64 (fd00::/8) and apply it via br omrconfig custom. Every border router on the same mesh sees the same network name and therefore derives the same OMR prefix, so multi-BR deployments converge on a single stable prefix without per-BR configuration (matching Apple border-router behavior). custom_omr_prefix remains the frontend-input override, and a configurable priority still applies via custom_omr_priority. The derivation is deterministic (verified: same name -> same /64, unique per name, empty name fails cleanly), applies through the existing ot-ctl br omrconfig path, and retries until the agent reports the network name. DOCS.md/CHANGELOG/translations updated accordingly. --- openthread_border_router/CHANGELOG.md | 4 +- openthread_border_router/DOCS.md | 2 +- .../scripts/otbr-agent-configure.sh | 80 ++++++++++++++----- openthread_border_router/translations/en.yaml | 7 +- 4 files changed, 66 insertions(+), 27 deletions(-) diff --git a/openthread_border_router/CHANGELOG.md b/openthread_border_router/CHANGELOG.md index df58e7132..4d2d444df 100644 --- a/openthread_border_router/CHANGELOG.md +++ b/openthread_border_router/CHANGELOG.md @@ -13,7 +13,7 @@ and operationally damaging across both large and small sites, which is why this a major version. **The fix has two complementary layers:** -1. **Stable OMR prefix** (`custom_omr_prefix`, `--ula-prefix`) - the BR prefix is now +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`, @@ -32,6 +32,8 @@ a major version. - **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 diff --git a/openthread_border_router/DOCS.md b/openthread_border_router/DOCS.md index a36af729c..bee605695 100644 --- a/openthread_border_router/DOCS.md +++ b/openthread_border_router/DOCS.md @@ -70,7 +70,7 @@ App configuration: | 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`). Leave empty for automatic behavior. **This is the primary fix for multi-BR loop prevention** -- a stable OMR prefix across restarts eliminates the orphaned-partition trigger that causes OTBR to install the `fc00::/7` catch-all route. | +| 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. | 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 ace0dc46e..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 @@ -62,37 +62,73 @@ ot-ctl mdns enable ot-ctl br enable # ============================================================================== -# Custom OMR Prefix + Preference +# 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') + DESIRED_PREFIX="$(bashio::config 'custom_omr_prefix')" +fi - if [[ -n "$DESIRED_PREFIX" ]]; then - bashio::log.info "Custom OMR prefix requested: ${DESIRED_PREFIX}" +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 - # 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 +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) + # Check if already set correctly + CURRENT=$(ot-ctl br omrprefix local 2>/dev/null | awk '{print $2}' || true) - if [[ "$CURRENT" == "$DESIRED_PREFIX" ]]; then + 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 "Applying custom OMR prefix: ${DESIRED_PREFIX}" - - if ot-ctl br omrconfig custom "${DESIRED_PREFIX}" "${OMR_PREF}"; then - bashio::log.info "✅ Successfully applied custom OMR prefix: ${DESIRED_PREFIX} (priority ${OMR_PREF})" - else - bashio::log.error "❌ Failed to apply custom OMR prefix" - return 11 - fi + 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 diff --git a/openthread_border_router/translations/en.yaml b/openthread_border_router/translations/en.yaml index f960c8c66..e0197f0e4 100644 --- a/openthread_border_router/translations/en.yaml +++ b/openthread_border_router/translations/en.yaml @@ -43,9 +43,10 @@ configuration: name: Custom OMR prefix description: >- Force a specific Off-Mesh Routable (OMR) prefix, e.g. fd42:0001::/64. - Leave empty for automatic behavior. A stable prefix across restarts - prevents the multi-border-router routing loop (fc00::/7 catch-all - route). All border routers on a shared mesh must use the same prefix. + 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: >- From 805d6b4aab64190be6036be8476b1d1f2c57a887 Mon Sep 17 00:00:00 2001 From: tannerdsilva Date: Tue, 1 Sep 2026 08:18:12 -0500 Subject: [PATCH 19/19] otbr: scrub changelog for rebased patch set and refresh PR docs pack - CHANGELOG: replace stale 4.0.0 build bullets that still described the removed ULA-prefix / broad-ULA-route / SO_REUSEADDR / NAT64 patches with the actual rebased state (single 0006 patch, upstream items dropped, external-route install disabled via the config header, #3325 backend and deterministic OMR notes) - Add the refreshed companion documentation (otbr-pr-docs) aligned with the final state of the change: PR_BODY, uplink theory, multicast forwarding, thread-research architecture overview, DOCS and CHANGELOG mirrors The docs now describe the #3325 in-process backend plus the retained add-on layer, the deterministic network-name-derived OMR prefix, and the commit chain behind the PR. --- openthread_border_router/CHANGELOG.md | 9 +- otbr-pr-docs/CHANGELOG.md | 358 +++++++++++++++++++++++ otbr-pr-docs/DOCS.md | 112 +++++++ otbr-pr-docs/MULTICAST_FORWARDING.md | 178 +++++++++++ otbr-pr-docs/PR_BODY.md | 75 +++++ otbr-pr-docs/README.md | 30 ++ otbr-pr-docs/thread-research.md | 160 ++++++++++ otbr-pr-docs/uplink-theory-fc00-route.md | 178 +++++++++++ 8 files changed, 1095 insertions(+), 5 deletions(-) create mode 100644 otbr-pr-docs/CHANGELOG.md create mode 100644 otbr-pr-docs/DOCS.md create mode 100644 otbr-pr-docs/MULTICAST_FORWARDING.md create mode 100644 otbr-pr-docs/PR_BODY.md create mode 100644 otbr-pr-docs/README.md create mode 100644 otbr-pr-docs/thread-research.md create mode 100644 otbr-pr-docs/uplink-theory-fc00-route.md diff --git a/openthread_border_router/CHANGELOG.md b/openthread_border_router/CHANGELOG.md index 4d2d444df..7baa516cd 100644 --- a/openthread_border_router/CHANGELOG.md +++ b/openthread_border_router/CHANGELOG.md @@ -23,11 +23,10 @@ a major version. ### Also in 4.0.0 -- NAT64: re-implement proper nftables-based NAT44 masquerade (mark + postrouting + forward rules) to replace the removed iptables rules. NAT64 now works without iptables in the runtime image. -- Build: apply all patches to the beta builder stage so both stable and beta builds carry the same fixes. Verified each patch against its target commit: - - Patches 0001 (SO_REUSEADDR) and 0002 (NAT64 IPv4 options) were already upstream in the beta commit -- skipped. - - Patches 0003 (ULA prefix agent) and 0004 (ULA prefix core) required beta-specific versions due to `OTBR_OPT_DATA_PATH` addition and `.Set()`→`.InitFrom()` API change in the beta's OpenThread submodule. - - Patches 0005 (fc00::/7 disable) and 0006 (routing manager corrections) apply cleanly to both stable and beta. +- 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. 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.