{ 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 ]; }; }; }