From 5e7cbf6696714dc254c88adad8e19f5264991eab Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Sun, 22 Mar 2026 19:56:26 -0700 Subject: [PATCH 1/3] openthread-border-router: init at 0-unstable-2025-06-12 Co-authored-by: Mathieu Rene --- .../firewall-script.patch | 16 +++ .../op/openthread-border-router/package.nix | 122 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 pkgs/by-name/op/openthread-border-router/firewall-script.patch create mode 100644 pkgs/by-name/op/openthread-border-router/package.nix diff --git a/pkgs/by-name/op/openthread-border-router/firewall-script.patch b/pkgs/by-name/op/openthread-border-router/firewall-script.patch new file mode 100644 index 000000000000..013e5b18748b --- /dev/null +++ b/pkgs/by-name/op/openthread-border-router/firewall-script.patch @@ -0,0 +1,16 @@ +diff --git a/script/otbr-firewall b/script/otbr-firewall +--- a/script/otbr-firewall ++++ b/script/otbr-firewall +@@ -38,12 +38,8 @@ + # Description: This service sets up firewall for OTBR. + ### END INIT INFO + +-THREAD_IF="wpan0" + OTBR_FORWARD_INGRESS_CHAIN="OTBR_FORWARD_INGRESS" + +-. /lib/lsb/init-functions +-. /lib/init/vars.sh +- + set -euxo pipefail + + ipset_destroy_if_exist() diff --git a/pkgs/by-name/op/openthread-border-router/package.nix b/pkgs/by-name/op/openthread-border-router/package.nix new file mode 100644 index 000000000000..d9b1d429492d --- /dev/null +++ b/pkgs/by-name/op/openthread-border-router/package.nix @@ -0,0 +1,122 @@ +{ + lib, + stdenv, + fetchFromGitHub, + cmake, + pkg-config, + systemdLibs, + avahi, + dbus, + protobuf, + jsoncpp, + boost, + libnetfilter_queue, + libnfnetlink, + nodejs, + bashNonInteractive, + buildNpmPackage, +}: +let + pname = "ot-br-posix"; + version = "0-unstable-2025-06-12"; + + src = fetchFromGitHub { + owner = "openthread"; + repo = "ot-br-posix"; + rev = "thread-reference-20250612"; + hash = "sha256-lPMMLtbPu9NpDcBCZE6XID7u1maCAhkZiSDEyFq7yvg="; + fetchSubmodules = true; + }; + + frontendModules = buildNpmPackage { + pname = "${pname}-frontend"; + inherit version; + src = "${src}/src/web/web-service/frontend"; + npmDepsHash = "sha256-7UVfPICyIbHEClpr3p7eDR46OUzS8mVf6P7phnDpVLk="; + dontNpmBuild = true; + }; +in +stdenv.mkDerivation { + inherit pname version src; + + strictDeps = true; + __structuredAttrs = true; + + # warning _FORTIFY_SOURCE requires compiling with optimization (-O) + env.NIX_CFLAGS_COMPILE = "-O"; + + patches = [ + # Patch the firewall script so we can run it within the systemd start script + ./firewall-script.patch + ]; + + nativeBuildInputs = [ + pkg-config + cmake + nodejs + ]; + + # Adding npmConfigHook and manually passing fetchNpmDeps was resulting in ENOTCACHED errors + postConfigure = '' + ln -sf ${frontendModules}/lib/node_modules/otbr-web/node_modules ./src/web/web-service/frontend/ + ''; + + buildInputs = [ + avahi # TODO: upstream deprecated OTBR_MDNS=avahi after this release (https://github.com/openthread/ot-br-posix/pull/3240) + systemdLibs + protobuf + jsoncpp + boost + libnetfilter_queue + libnfnetlink + dbus + (lib.getBin bashNonInteractive) + ]; + + postInstall = '' + mkdir -p $out/bin + cp ../script/otbr-firewall $out/bin/ + chmod +x $out/bin/otbr-firewall + ''; + + cmakeFlags = [ + (lib.cmakeFeature "CMAKE_POLICY_VERSION_MINIMUM" "3.5") + + (lib.cmakeBool "BUILD_TESTING" false) + (lib.cmakeBool "INSTALL_SYSTEMD_UNIT" false) + (lib.cmakeBool "Boost_USE_STATIC_LIBS" false) + (lib.cmakeBool "OTBR_REST" true) + + (lib.cmakeBool "OTBR_WEB" true) + (lib.cmakeBool "OTBR_NAT64" true) + (lib.cmakeBool "OTBR_BACKBONE_ROUTER" true) + (lib.cmakeBool "OTBR_BORDER_ROUTING" true) + (lib.cmakeBool "OTBR_DBUS" true) + (lib.cmakeBool "OTBR_TREL" true) + + (lib.cmakeFeature "OTBR_VERSION" version) + (lib.cmakeBool "OTBR_DNSSD_DISCOVERY_PROXY" true) + (lib.cmakeBool "OTBR_SRP_ADVERTISING_PROXY" true) + (lib.cmakeBool "OTBR_DUA_ROUTING" true) + (lib.cmakeBool "OTBR_DNS_UPSTREAM_QUERY" true) + + (lib.cmakeBool "OT_CHANNEL_MANAGER" true) + (lib.cmakeBool "OT_CHANNEL_MONITOR" true) + + # Required by protobuf + (lib.cmakeFeature "CMAKE_CXX_STANDARD" "17") + ]; + + meta = { + description = "Thread border router for POSIX-based platforms"; + homepage = "https://github.com/openthread/ot-br-posix"; + license = lib.licenses.bsd3; + maintainers = with lib.maintainers; [ + jamiemagee + leonm1 + mrene + ]; + mainProgram = "ot-ctl"; + platforms = lib.platforms.linux; + }; +} From a580213d749c33dae551c0d9100da83c1c606ab4 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Sun, 22 Mar 2026 20:09:17 -0700 Subject: [PATCH 2/3] nixos/openthread-border-router: add module Co-authored-by: Mathieu Rene --- nixos/modules/module-list.nix | 1 + .../openthread-border-router.nix | 348 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 nixos/modules/services/home-automation/openthread-border-router.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 1c066b30f915..69e7dea773cf 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -729,6 +729,7 @@ ./services/home-automation/home-assistant.nix ./services/home-automation/homebridge.nix ./services/home-automation/matter-server.nix + ./services/home-automation/openthread-border-router.nix ./services/home-automation/wyoming/faster-whisper.nix ./services/home-automation/wyoming/openwakeword.nix ./services/home-automation/wyoming/piper.nix diff --git a/nixos/modules/services/home-automation/openthread-border-router.nix b/nixos/modules/services/home-automation/openthread-border-router.nix new file mode 100644 index 000000000000..93e010c1c6f5 --- /dev/null +++ b/nixos/modules/services/home-automation/openthread-border-router.nix @@ -0,0 +1,348 @@ +{ + lib, + config, + pkgs, + utils, + ... +}: + +let + cfg = config.services.openthread-border-router; + logLevelMappings = { + "emerg" = 0; + "alert" = 1; + "crit" = 2; + "err" = 3; + "warning" = 4; + "notice" = 5; + "info" = 6; + "debug" = 7; + }; + logLevel = lib.getAttr cfg.logLevel logLevelMappings; + # Use correct iptables for otbr-firewall (legacy vs nf-compat) + iptables = + let + inherit (config.networking) firewall; + in + if firewall.backend == "iptables" then firewall.package else pkgs.iptables; +in +{ + meta.maintainers = with lib.maintainers; [ + jamiemagee + leonm1 + mrene + ]; + + options.services.openthread-border-router = { + enable = lib.mkEnableOption "the OpenThread Border Router"; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the REST API and web interface ports."; + }; + + package = lib.mkPackageOption pkgs "openthread-border-router" { }; + + backboneInterfaces = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "eth0" ]; + description = "The network interfaces on which to advertise the thread ipv6 mesh prefix. Can be specified multiple times."; + }; + + interfaceName = lib.mkOption { + type = lib.types.str; + default = "wpan0"; + description = "The network interface to create for thread packets."; + }; + + logLevel = lib.mkOption { + type = lib.types.enum (lib.attrNames logLevelMappings); + default = "err"; + description = "The level to use when logging messages."; + }; + + rest = { + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "The address on which to listen for REST API requests."; + example = "::"; + }; + + listenPort = lib.mkOption { + type = lib.types.port; + default = 8081; + description = "The port on which to listen for REST API requests. Warning: the web interface relies on this value being set to 8081."; + }; + }; + + web = { + enable = lib.mkEnableOption "the web interface"; + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "The address on which the web interface should listen."; + example = "::"; + }; + + listenPort = lib.mkOption { + type = lib.types.port; + default = 8082; + description = "The port on which the web interface should listen."; + }; + }; + + radio = { + device = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + The device name of the serial port of the radio device. + Ignored if {option}`services.openthread-border-router.radio.url` is set. + ''; + }; + + baudRate = lib.mkOption { + type = lib.types.ints.positive; + default = 115200; + description = '' + The baud rate of the radio device. + Ignored if {option}`services.openthread-border-router.radio.url` is set. + ''; + }; + + flowControl = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable hardware flow control. + Ignored if {option}`services.openthread-border-router.radio.url` is set. + ''; + }; + + urlQueryString = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Extra URL query string parameters. + Ignored if {option}`services.openthread-border-router.radio.url` is set. + ''; + example = "bus-latency=100®ion=ca"; + }; + + url = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "The URL of the radio device to use."; + example = "spinel+hdlc+uart:///dev/ttyUSB0?uart-baudrate=460800&uart-flow-control"; + }; + + extraDevices = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra devices to add to the radio device."; + example = [ "trel://eth0" ]; + }; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra arguments to pass to the otbr-agent daemon."; + example = [ "--radio-version" ]; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.radio.device != null || cfg.radio.url != null; + message = "services.openthread-border-router requires either radio.device or radio.url to be set."; + } + ]; + + warnings = lib.optional (cfg.web.enable && cfg.rest.listenPort != 8081) '' + The openthread-border-router web interface is hardcoded to talk to the REST API on port 8081, but its + port has been changed to ${toString cfg.rest.listenPort}. Some features will be broken. + ''; + + services.openthread-border-router.radio.url = lib.mkIf (cfg.radio.device != null) ( + lib.mkDefault ( + "spinel+hdlc+uart://${cfg.radio.device}?" + + lib.concatStringsSep "&" ( + [ "uart-baudrate=${toString cfg.radio.baudRate}" ] + ++ lib.optional cfg.radio.flowControl "uart-flow-control" + ++ lib.optional (cfg.radio.urlQueryString != "") cfg.radio.urlQueryString + ) + ) + ); + + # ot-ctl can be used to query the router instance + environment.systemPackages = [ cfg.package ]; + + # Make sure we have ipv6 support, and that forwarding is enabled + networking.enableIPv6 = true; + networking.firewall.allowedTCPPorts = + lib.optional cfg.openFirewall cfg.rest.listenPort + ++ lib.optional (cfg.openFirewall && cfg.web.enable) cfg.web.listenPort; + boot.kernel.sysctl = { + "net.ipv4.conf.all.forwarding" = 1; + "net.ipv6.conf.all.forwarding" = 1; + } + // lib.listToAttrs ( + lib.concatMap (iface: [ + { + name = "net.ipv6.conf.${iface}.accept_ra"; + value = 2; + } + { + name = "net.ipv6.conf.${iface}.accept_ra_rt_info_max_plen"; + value = 64; + } + ]) cfg.backboneInterfaces + ); + + # OTBR uses avahi for mDNS service publishing + services.avahi = { + enable = lib.mkDefault true; + publish = { + enable = lib.mkDefault true; + userServices = lib.mkDefault true; + }; + }; + + # The upstream service files (src/agent/otbr-agent.service.in, src/web/otbr-web.service.in) use + # EnvironmentFile and CMake-substituted platform scripts that don't translate to NixOS, so the + # services are rebuilt here from typed module options instead. + systemd.services = { + # The agent keeps its local state in /var/lib/thread + otbr-agent = { + description = "OpenThread Border Router Agent"; + wantedBy = [ "multi-user.target" ]; + requires = [ "network-online.target" ]; + after = [ "network-online.target" ]; + environment = { + THREAD_IF = cfg.interfaceName; + }; + serviceConfig = { + ExecStartPre = "${utils.escapeSystemdExecArg (lib.getExe' cfg.package "otbr-firewall")} start"; + ExecStart = lib.concatStringsSep " " ( + lib.concatLists [ + [ + (lib.getExe' cfg.package "otbr-agent") + "--verbose" + ] + (map (iface: "--backbone-ifname ${utils.escapeSystemdExecArg iface}") cfg.backboneInterfaces) + [ + "--thread-ifname ${utils.escapeSystemdExecArg cfg.interfaceName}" + "--debug-level ${toString logLevel}" + ] + (lib.optional (cfg.rest.listenPort != 0) "--rest-listen-port ${toString cfg.rest.listenPort}") + (lib.optional ( + cfg.rest.listenAddress != "" + ) "--rest-listen-address ${utils.escapeSystemdExecArg cfg.rest.listenAddress}") + (lib.optional (cfg.radio.url != null) (utils.escapeSystemdExecArg cfg.radio.url)) + (map utils.escapeSystemdExecArg cfg.radio.extraDevices) + (map utils.escapeSystemdExecArg cfg.extraArgs) + ] + ); + ExecStopPost = "${utils.escapeSystemdExecArg (lib.getExe' cfg.package "otbr-firewall")} stop"; + KillMode = "mixed"; + Restart = "on-failure"; + RestartSec = 5; + RestartPreventExitStatus = "SIGKILL"; + + # Hardening options (not present in upstream service definitions) + StateDirectory = "thread"; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + NoNewPrivileges = true; + LockPersonality = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + + CapabilityBoundingSet = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + ]; + }; + path = [ + pkgs.ipset + iptables + ]; + }; + + # Sync with: src/web/otbr-web.service.in + otbr-web = lib.mkIf cfg.web.enable { + description = "OpenThread Border Router Web Interface"; + after = [ "otbr-agent.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = lib.concatStringsSep " " ( + lib.concatLists [ + [ + (lib.getExe' cfg.package "otbr-web") + "-I" + (utils.escapeSystemdExecArg cfg.interfaceName) + "-d" + (toString logLevel) + ] + (lib.optional ( + cfg.web.listenAddress != "" + ) "-a ${utils.escapeSystemdExecArg cfg.web.listenAddress}") + (lib.optional (cfg.web.listenPort != 0) "-p ${toString cfg.web.listenPort}") + ] + ); + + # Hardening options (not present in upstream service definitions) + DynamicUser = true; + PrivateUsers = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + NoNewPrivileges = true; + LockPersonality = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + CapabilityBoundingSet = ""; + }; + }; + }; + }; +} From fb7a09fb81db395e719a06bb773a2e553424004e Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Sun, 22 Mar 2026 20:09:18 -0700 Subject: [PATCH 3/3] nixos/release-notes: mention openthread-border-router --- nixos/doc/manual/release-notes/rl-2605.section.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index e7c7d13e1c0a..0f90f682d574 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -46,6 +46,8 @@ +- [OpenThread Border Router](https://openthread.io/), a Thread border router for POSIX-based platforms that bridges Thread mesh networks to IP networks. Available as [services.openthread-border-router](#opt-services.openthread-border-router.enable). + - [Meshtastic](https://meshtastic.org), an open-source, off-grid, decentralised mesh network designed to run on affordable, low-power devices. Available as [services.meshtasticd] (#opt-services.meshtasticd.enable).