From 12ed2954d4bef2867a56c78c96e3906fee25ec46 Mon Sep 17 00:00:00 2001 From: Frank Moda Date: Wed, 24 Jan 2024 19:51:12 -0500 Subject: [PATCH] nixos/homebridge: init --- .../manual/release-notes/rl-2511.section.md | 2 + nixos/modules/module-list.nix | 1 + .../services/home-automation/homebridge.nix | 433 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/homebridge.nix | 88 ++++ 5 files changed, 525 insertions(+) create mode 100644 nixos/modules/services/home-automation/homebridge.nix create mode 100644 nixos/tests/homebridge.nix diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 588eff3b51b5..90ff52647228 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -27,6 +27,8 @@ - Options under [networking.getaddrinfo](#opt-networking.getaddrinfo.enable) are now allowed to declaratively configure address selection and sorting behavior of `getaddrinfo` in dual-stack networks. +- [Homebridge](https://github.com/homebridge/homebridge), a lightweight Node.js server you can run on your home network that emulates the iOS HomeKit API. Available as [services.homebridge](#opt-services.homebridge.enable). + - [LACT](https://github.com/ilya-zlobintsev/LACT), a GPU monitoring and configuration tool, can now be enabled through [services.lact.enable](#opt-services.lact.enable). Note that for LACT to work properly on AMD GPU systems, you need to enable [hardware.amdgpu.overdrive.enable](#opt-hardware.amdgpu.overdrive.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 373a2df52894..44e94b934fb7 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -696,6 +696,7 @@ ./services/home-automation/evcc.nix ./services/home-automation/govee2mqtt.nix ./services/home-automation/home-assistant.nix + ./services/home-automation/homebridge.nix ./services/home-automation/matter-server.nix ./services/home-automation/wyoming/faster-whisper.nix ./services/home-automation/wyoming/openwakeword.nix diff --git a/nixos/modules/services/home-automation/homebridge.nix b/nixos/modules/services/home-automation/homebridge.nix new file mode 100644 index 000000000000..9f0d379c6514 --- /dev/null +++ b/nixos/modules/services/home-automation/homebridge.nix @@ -0,0 +1,433 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.homebridge; + + restartCommand = "sudo -n systemctl restart homebridge"; + + defaultConfigUIPlatform = { + inherit (cfg.uiSettings) + platform + name + port + restart + log + ; + }; + + defaultConfig = { + description = "Homebridge"; + bridge = { + inherit (cfg.settings.bridge) name port; + # These have to be set at least once, otherwise the homebridge will not work + username = "CC:22:3D:E3:CE:30"; + pin = "031-45-154"; + }; + platforms = [ + defaultConfigUIPlatform + ]; + }; + + defaultConfigFile = settingsFormat.generate "config.json" defaultConfig; + + nixOverrideConfig = cfg.settings // { + platforms = [ cfg.uiSettings ] ++ cfg.settings.platforms; + }; + + nixOverrideConfigFile = settingsFormat.generate "nixOverrideConfig.json" nixOverrideConfig; + + # Create a single jq filter that updates all fields at once + # Platforms need to be unique by "platform" + # Accessories need to be unique by "name" + jqMergeFilter = '' + reduce .[] as $item ( + {}; + . * $item + { + "platforms": ( + ((.platforms // []) + ($item.platforms // [])) | + group_by(.platform) | + map(reduce .[] as $platform ({}; . * $platform)) + ), + "accessories": ( + ((.accessories // []) + ($item.accessories // [])) | + group_by(.name) | + map(reduce .[] as $accessory ({}; . * $accessory)) + ) + } + ) + ''; + + jqMergeFilterFile = pkgs.writeTextFile { + name = "jqMergeFilter.jq"; + text = jqMergeFilter; + }; + + # Validation function to ensure no platform has the platform "config". + # We want to make sure settings for the "config" platform are set in uiSettings. + validatePlatforms = + platforms: + let + conflictingPlatforms = builtins.filter (p: p.platform == "config") platforms; + in + if builtins.length conflictingPlatforms > 0 then + throw "The platforms list must not contain any platform with platform type 'config'. Use the uiSettings attribute instead." + else + platforms; + + settingsFormat = pkgs.formats.json { }; +in +{ + options.services.homebridge = with lib.types; { + + # Basic Example + # { + # services.homebridge = { + # enable = true; + # # Necessary for service to be reachable + # openFirewall = true; + # }; + # } + + enable = lib.mkEnableOption "Homebridge: Homekit home automation"; + + user = lib.mkOption { + type = str; + default = "homebridge"; + description = "User to run homebridge as."; + }; + + group = lib.mkOption { + type = str; + default = "homebridge"; + description = "Group to run homebridge as."; + }; + + openFirewall = lib.mkEnableOption "" // { + description = '' + Open ports in the firewall for the Homebridge web interface and service. + ''; + }; + + userStoragePath = lib.mkOption { + type = str; + default = "/var/lib/homebridge"; + description = '' + Path to store homebridge user files (needs to be writeable). + ''; + }; + + pluginPath = lib.mkOption { + type = str; + default = "/var/lib/homebridge/node_modules"; + description = '' + Path to the plugin download directory (needs to be writeable). + Seems this needs to end with node_modules, as Homebridge will run npm + on the parent directory. + ''; + }; + + environmentFile = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to an environment-file which may contain secrets. + ''; + }; + + settings = lib.mkOption { + default = { }; + description = '' + Configuration options for homebridge. + + For more details, see [the homebridge documentation](https://github.com/homebridge/homebridge/wiki/Homebridge-Config-JSON-Explained). + ''; + type = submodule { + freeformType = settingsFormat.type; + options = { + description = lib.mkOption { + type = str; + default = "Homebridge"; + description = "Description of the homebridge instance."; + readOnly = true; + }; + + bridge.name = lib.mkOption { + type = str; + default = "Homebridge"; + description = "Name of the homebridge"; + }; + + bridge.port = lib.mkOption { + type = port; + default = 51826; + description = "The port homebridge listens on"; + }; + + platforms = lib.mkOption { + description = "Homebridge Platforms"; + default = [ ]; + apply = validatePlatforms; + type = listOf (submodule { + freeformType = settingsFormat.type; + options = { + name = lib.mkOption { + type = str; + description = "Name of the platform"; + }; + platform = lib.mkOption { + type = str; + description = "Platform type"; + }; + }; + }); + }; + + accessories = lib.mkOption { + description = "Homebridge Accessories"; + default = [ ]; + type = listOf (submodule { + freeformType = settingsFormat.type; + options = { + name = lib.mkOption { + type = str; + description = "Name of the accessory"; + }; + accessory = lib.mkOption { + type = str; + description = "Accessory type"; + }; + }; + }); + }; + }; + }; + }; + + # Defines the parameters for the Homebridge UI Plugin. + # This submodule will get merged into the "platforms" array + # inside settings. + uiSettings = lib.mkOption { + # Full list of UI settings can be found here: https://github.com/homebridge/homebridge-config-ui-x/wiki/Config-Options + default = { }; + description = '' + Configuration options for homebridge config UI plugin. + + For more details, see [the homebridge-config-ui-x documentation](https://github.com/homebridge/homebridge-config-ui-x/wiki/Config-Options). + ''; + type = submodule { + freeformType = settingsFormat.type; + options = { + ## Following parameters must be set, and can't be changed. + + # Must be "config" for UI service to see its config + platform = lib.mkOption { + type = str; + default = "config"; + description = "Type of the homebridge UI platform"; + readOnly = true; + }; + + name = lib.mkOption { + type = str; + default = "Config"; + description = "Name of the homebridge UI platform"; + readOnly = true; + }; + + # Homebridge can be installed many ways, but we're forcing a double service systemd setup + # This command will restart both services + restart = lib.mkOption { + type = str; + default = restartCommand; + description = "Command to restart the homebridge UI service"; + readOnly = true; + }; + + # We're using systemd, so make sure logs is setup to pull from systemd + log.method = lib.mkOption { + type = str; + default = "systemd"; + description = "Method to use for logging"; + readOnly = true; + }; + + log.service = lib.mkOption { + type = str; + default = "homebridge"; + description = "Name of the systemd service to log to"; + readOnly = true; + }; + + # The following options are allowed to be changed. + port = lib.mkOption { + type = port; + default = 8581; + description = "The port the UI web service should listen on"; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.homebridge = { + description = "Homebridge"; + wants = [ "network-online.target" ]; + after = [ + "syslog.target" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + + # On start, if the config file is missing, create a default one + # Otherwise, ensure that the config file is using the + # properties as specified by nix. + # Not sure if there is a better way to do this than to use jq + # to replace sections of json. + preStart = '' + # If the user storage path does not exist, create it + if [ ! -d "${cfg.userStoragePath}" ]; then + install -d -m 700 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}" + fi + # If there is no config file, create a placeholder default + if [ ! -e "${cfg.userStoragePath}/config.json" ]; then + install -D -m 600 -o ${cfg.user} -g ${cfg.group} "${defaultConfigFile}" "${cfg.userStoragePath}/config.json" + fi + + # Apply all nix override settings to config.json in a single jq operation + ${pkgs.jq}/bin/jq -s -f "${jqMergeFilterFile}" "${cfg.userStoragePath}/config.json" "${nixOverrideConfigFile}" | ${pkgs.jq}/bin/jq . > "${cfg.userStoragePath}/config.json.tmp" + install -D -m 600 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}/config.json.tmp" "${cfg.userStoragePath}/config.json" + + # Remove temporary files + rm "${cfg.userStoragePath}/config.json.tmp" + + # Make sure plugin directory exists + install -d -m 755 -o ${cfg.user} -g ${cfg.group} "${cfg.pluginPath}" + + # In order for hb-service to detect the homebridge installation, we need to create a folder structure + # where homebridge and homebrdige-config-ui-x node modules are side by side, and then point + # UIX_BASE_PATH_OVERRIDE at the homebridge-config-ui-x node module in the service environment. + # So, first create a directory to symlink these packages to + install -d -m 755 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}/homebridge-packages" + + # Then, symlink in the homebridge and homebridge-config-ui-x packages + rm -rf "${cfg.userStoragePath}/homebridge-packages/homebridge" + ln -s "${pkgs.homebridge}/lib/node_modules/homebridge" "${cfg.userStoragePath}/homebridge-packages/homebridge" + rm -rf "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x" + ln -s "${pkgs.homebridge-config-ui-x}/lib/node_modules/homebridge-config-ui-x" "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x" + ''; + + # hb-service environment variables based on source code analysis + environment = { + HOMEBRIDGE_CONFIG_UI_TERMINAL = "1"; + DISABLE_OPENCOLLECTIVE = "true"; + # Required or homebridge will search the global npm namespace + UIX_STRICT_PLUGIN_RESOLUTION = "1"; + # Workaround to ensure homebridge does not run in sudo mode + HOMEBRIDGE_APT_PACKAGE = "1"; + # Required to get the service to detect the homebridge install correctly + UIX_BASE_PATH_OVERRIDE = "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x"; + }; + + path = with pkgs; [ + # Tools listed in homebridge's installation documentations: + # https://github.com/homebridge/homebridge/wiki/Install-Homebridge-on-Arch-Linux + nodejs + nettools + gcc + gnumake + # Required for access to systemctl and journalctl + systemd + # Required for access to sudo + "/run/wrappers" + # Some plugins need bash to download tools + bash + ]; + + # Settings from https://github.com/homebridge/homebridge-config-ui-x/blob/latest/src/bin/platforms/linux.ts + serviceConfig = { + Type = "simple"; + User = cfg.user; + PermissionsStartOnly = true; + StateDirectory = "homebridge"; + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ]; + ExecStart = "${pkgs.homebridge-config-ui-x}/bin/hb-service run -U ${cfg.userStoragePath} -P ${cfg.pluginPath}"; + Restart = "always"; + RestartSec = 3; + KillMode = "process"; + CapabilityBoundingSet = [ + "CAP_IPC_LOCK" + "CAP_NET_ADMIN" + "CAP_NET_BIND_SERVICE" + "CAP_NET_RAW" + "CAP_SETGID" + "CAP_SETUID" + "CAP_SYS_CHROOT" + "CAP_CHOWN" + "CAP_FOWNER" + "CAP_DAC_OVERRIDE" + "CAP_AUDIT_WRITE" + "CAP_SYS_ADMIN" + ]; + AmbientCapabilities = [ + "CAP_NET_RAW" + "CAP_NET_BIND_SERVICE" + ]; + }; + }; + + # Create a user whose home folder is the user storage path + users.users = lib.mkIf (cfg.user == "homebridge") { + homebridge = { + inherit (cfg) group; + # Necessary so that this user can run journalctl + extraGroups = [ "systemd-journal" ]; + description = "homebridge user"; + isSystemUser = true; + home = cfg.userStoragePath; + }; + }; + + users.groups = lib.mkIf (cfg.group == "homebridge") { + homebridge = { }; + }; + + # Need passwordless sudo for a few commands + # homebridge-config-ui-x needs for some features + security.sudo.extraRules = [ + { + users = [ cfg.user ]; + commands = [ + { + # Ability to restart homebridge service + command = "${pkgs.systemd}/bin/systemctl restart homebridge"; + options = [ "NOPASSWD" ]; + } + { + # Ability to shutdown server + command = "${pkgs.systemd}/bin/shutdown -h now"; + options = [ "NOPASSWD" ]; + } + { + # Ability to restart server + command = "${pkgs.systemd}/bin/shutdown -r now"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; + + networking.firewall = { + allowedTCPPorts = lib.mkIf cfg.openFirewall [ + cfg.settings.bridge.port + cfg.uiSettings.port + ]; + allowedUDPPorts = lib.mkIf cfg.openFirewall [ 5353 ]; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index fd7caf0bacf6..1eed4b3d7187 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -700,6 +700,7 @@ in hledger-web = runTest ./hledger-web.nix; hockeypuck = runTest ./hockeypuck.nix; home-assistant = runTest ./home-assistant.nix; + homebridge = runTest ./homebridge.nix; hostname = handleTest ./hostname.nix { }; hound = runTest ./hound.nix; hub = runTest ./git/hub.nix; diff --git a/nixos/tests/homebridge.nix b/nixos/tests/homebridge.nix new file mode 100644 index 000000000000..ce0f85caa413 --- /dev/null +++ b/nixos/tests/homebridge.nix @@ -0,0 +1,88 @@ +{ + lib, + ... +}: + +let + userStoragePath = "/var/lib/foobar"; + pluginPath = "${userStoragePath}/node_modules"; +in +{ + name = "homebridge"; + meta.maintainers = with lib.maintainers; [ fmoda3 ]; + + nodes.homebridge = + { pkgs, ... }: + { + services.homebridge = { + enable = true; + inherit userStoragePath pluginPath; + + settings = { + bridge = { + name = "Homebridge"; + port = 51826; + }; + }; + + uiSettings = { + port = 8581; + }; + }; + + # Cause a configuration change inside `config.json` and verify that the process is being reloaded. + specialisation.differentName = { + inheritParentConfig = true; + configuration.services.homebridge.settings.bridge.name = lib.mkForce "Test Home"; + }; + }; + + testScript = + { nodes, ... }: + let + system = nodes.homebridge.system.build.toplevel; + in + '' + import json + + start_all() + + + def get_homebridge_journal_cursor() -> str: + exit, out = homebridge.execute("journalctl -u homebridge.service -n1 -o json-pretty --output-fields=__CURSOR") + assert exit == 0 + return json.loads(out)["__CURSOR"] + + + def wait_for_homebridge(cursor): + homebridge.wait_until_succeeds(f"journalctl --after-cursor='{cursor}' -u homebridge.service | grep -q 'Logging to'") + + + homebridge.wait_for_unit("homebridge.service") + homebridge_cursor = get_homebridge_journal_cursor() + + with subtest("Check that JSON configuration file is in place"): + homebridge.succeed("test -f ${userStoragePath}/config.json") + + with subtest("Check that Homebridge's web interface and API can be reached"): + wait_for_homebridge(homebridge_cursor) + homebridge.wait_for_open_port(51826) + homebridge.wait_for_open_port(8581) + homebridge.succeed("curl --fail http://localhost:8581/") + + with subtest("Check service restart from SIGHUP"): + homebridge_pid = homebridge.succeed("systemctl show --property=MainPID homebridge.service") + homebridge_cursor = get_homebridge_journal_cursor() + homebridge.succeed("${system}/specialisation/differentName/bin/switch-to-configuration test") + wait_for_homebridge(homebridge_cursor) + new_homebridge_pid = homebridge.succeed("systemctl show --property=MainPID homebridge.service") + assert homebridge_pid != new_homebridge_pid, "The PID of the homebridge process must change after sending SIGHUP" + + with subtest("Check that no errors were logged"): + homebridge.fail("journalctl -u homebridge -o cat | grep -q ERROR") + + with subtest("Check systemd unit hardening"): + homebridge.log(homebridge.succeed("systemctl cat homebridge.service")) + homebridge.log(homebridge.succeed("systemd-analyze security homebridge.service")) + ''; +}