Unverified Commit acfb9c45 authored by Sandro Jäckel's avatar Sandro Jäckel Committed by GitHub
Browse files

openthread-border-router: init at 0-unstable-2025-06-12 (#502388)

parents c466c77a fb7a09fb
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -46,6 +46,8 @@

<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->

- [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).
+1 −0
Original line number Diff line number Diff line
@@ -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
+348 −0
Original line number Diff line number Diff line
{
  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&region=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 = "";
        };
      };
    };
  };
}
+16 −0
Original line number Diff line number Diff line
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()
+122 −0
Original line number Diff line number Diff line
{
  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;
  };
}