at master 14 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7 8let 9 cfg = config.services.homebridge; 10 11 restartCommand = "sudo -n systemctl restart homebridge"; 12 13 defaultConfigUIPlatform = { 14 inherit (cfg.uiSettings) 15 platform 16 name 17 port 18 restart 19 log 20 ; 21 }; 22 23 defaultConfig = { 24 description = "Homebridge"; 25 bridge = { 26 inherit (cfg.settings.bridge) name port; 27 # These have to be set at least once, otherwise the homebridge will not work 28 username = "CC:22:3D:E3:CE:30"; 29 pin = "031-45-154"; 30 }; 31 platforms = [ 32 defaultConfigUIPlatform 33 ]; 34 }; 35 36 defaultConfigFile = settingsFormat.generate "config.json" defaultConfig; 37 38 nixOverrideConfig = cfg.settings // { 39 platforms = [ cfg.uiSettings ] ++ cfg.settings.platforms; 40 }; 41 42 nixOverrideConfigFile = settingsFormat.generate "nixOverrideConfig.json" nixOverrideConfig; 43 44 # Create a single jq filter that updates all fields at once 45 # Platforms need to be unique by "platform" 46 # Accessories need to be unique by "name" 47 jqMergeFilter = '' 48 reduce .[] as $item ( 49 {}; 50 . * $item + { 51 "platforms": ( 52 ((.platforms // []) + ($item.platforms // [])) | 53 group_by(.platform) | 54 map(reduce .[] as $platform ({}; . * $platform)) 55 ), 56 "accessories": ( 57 ((.accessories // []) + ($item.accessories // [])) | 58 group_by(.name) | 59 map(reduce .[] as $accessory ({}; . * $accessory)) 60 ) 61 } 62 ) 63 ''; 64 65 jqMergeFilterFile = pkgs.writeTextFile { 66 name = "jqMergeFilter.jq"; 67 text = jqMergeFilter; 68 }; 69 70 # Validation function to ensure no platform has the platform "config". 71 # We want to make sure settings for the "config" platform are set in uiSettings. 72 validatePlatforms = 73 platforms: 74 let 75 conflictingPlatforms = builtins.filter (p: p.platform == "config") platforms; 76 in 77 if builtins.length conflictingPlatforms > 0 then 78 throw "The platforms list must not contain any platform with platform type 'config'. Use the uiSettings attribute instead." 79 else 80 platforms; 81 82 settingsFormat = pkgs.formats.json { }; 83in 84{ 85 options.services.homebridge = with lib.types; { 86 87 # Basic Example 88 # { 89 # services.homebridge = { 90 # enable = true; 91 # # Necessary for service to be reachable 92 # openFirewall = true; 93 # }; 94 # } 95 96 enable = lib.mkEnableOption "Homebridge: Homekit home automation"; 97 98 user = lib.mkOption { 99 type = str; 100 default = "homebridge"; 101 description = "User to run homebridge as."; 102 }; 103 104 group = lib.mkOption { 105 type = str; 106 default = "homebridge"; 107 description = "Group to run homebridge as."; 108 }; 109 110 openFirewall = lib.mkEnableOption "" // { 111 description = '' 112 Open ports in the firewall for the Homebridge web interface and service. 113 ''; 114 }; 115 116 userStoragePath = lib.mkOption { 117 type = str; 118 default = "/var/lib/homebridge"; 119 description = '' 120 Path to store homebridge user files (needs to be writeable). 121 ''; 122 }; 123 124 pluginPath = lib.mkOption { 125 type = str; 126 default = "/var/lib/homebridge/node_modules"; 127 description = '' 128 Path to the plugin download directory (needs to be writeable). 129 Seems this needs to end with node_modules, as Homebridge will run npm 130 on the parent directory. 131 ''; 132 }; 133 134 environmentFile = lib.mkOption { 135 type = types.nullOr types.str; 136 default = null; 137 description = '' 138 Path to an environment-file which may contain secrets. 139 ''; 140 }; 141 142 settings = lib.mkOption { 143 default = { }; 144 description = '' 145 Configuration options for homebridge. 146 147 For more details, see [the homebridge documentation](https://github.com/homebridge/homebridge/wiki/Homebridge-Config-JSON-Explained). 148 ''; 149 type = submodule { 150 freeformType = settingsFormat.type; 151 options = { 152 description = lib.mkOption { 153 type = str; 154 default = "Homebridge"; 155 description = "Description of the homebridge instance."; 156 readOnly = true; 157 }; 158 159 bridge.name = lib.mkOption { 160 type = str; 161 default = "Homebridge"; 162 description = "Name of the homebridge"; 163 }; 164 165 bridge.port = lib.mkOption { 166 type = port; 167 default = 51826; 168 description = "The port homebridge listens on"; 169 }; 170 171 platforms = lib.mkOption { 172 description = "Homebridge Platforms"; 173 default = [ ]; 174 apply = validatePlatforms; 175 type = listOf (submodule { 176 freeformType = settingsFormat.type; 177 options = { 178 name = lib.mkOption { 179 type = str; 180 description = "Name of the platform"; 181 }; 182 platform = lib.mkOption { 183 type = str; 184 description = "Platform type"; 185 }; 186 }; 187 }); 188 }; 189 190 accessories = lib.mkOption { 191 description = "Homebridge Accessories"; 192 default = [ ]; 193 type = listOf (submodule { 194 freeformType = settingsFormat.type; 195 options = { 196 name = lib.mkOption { 197 type = str; 198 description = "Name of the accessory"; 199 }; 200 accessory = lib.mkOption { 201 type = str; 202 description = "Accessory type"; 203 }; 204 }; 205 }); 206 }; 207 }; 208 }; 209 }; 210 211 # Defines the parameters for the Homebridge UI Plugin. 212 # This submodule will get merged into the "platforms" array 213 # inside settings. 214 uiSettings = lib.mkOption { 215 # Full list of UI settings can be found here: https://github.com/homebridge/homebridge-config-ui-x/wiki/Config-Options 216 default = { }; 217 description = '' 218 Configuration options for homebridge config UI plugin. 219 220 For more details, see [the homebridge-config-ui-x documentation](https://github.com/homebridge/homebridge-config-ui-x/wiki/Config-Options). 221 ''; 222 type = submodule { 223 freeformType = settingsFormat.type; 224 options = { 225 ## Following parameters must be set, and can't be changed. 226 227 # Must be "config" for UI service to see its config 228 platform = lib.mkOption { 229 type = str; 230 default = "config"; 231 description = "Type of the homebridge UI platform"; 232 readOnly = true; 233 }; 234 235 name = lib.mkOption { 236 type = str; 237 default = "Config"; 238 description = "Name of the homebridge UI platform"; 239 readOnly = true; 240 }; 241 242 # Homebridge can be installed many ways, but we're forcing a double service systemd setup 243 # This command will restart both services 244 restart = lib.mkOption { 245 type = str; 246 default = restartCommand; 247 description = "Command to restart the homebridge UI service"; 248 readOnly = true; 249 }; 250 251 # We're using systemd, so make sure logs is setup to pull from systemd 252 log.method = lib.mkOption { 253 type = str; 254 default = "systemd"; 255 description = "Method to use for logging"; 256 readOnly = true; 257 }; 258 259 log.service = lib.mkOption { 260 type = str; 261 default = "homebridge"; 262 description = "Name of the systemd service to log to"; 263 readOnly = true; 264 }; 265 266 # The following options are allowed to be changed. 267 port = lib.mkOption { 268 type = port; 269 default = 8581; 270 description = "The port the UI web service should listen on"; 271 }; 272 }; 273 }; 274 }; 275 }; 276 277 config = lib.mkIf cfg.enable { 278 systemd.services.homebridge = { 279 description = "Homebridge"; 280 wants = [ "network-online.target" ]; 281 after = [ 282 "syslog.target" 283 "network-online.target" 284 ]; 285 wantedBy = [ "multi-user.target" ]; 286 287 # On start, if the config file is missing, create a default one 288 # Otherwise, ensure that the config file is using the 289 # properties as specified by nix. 290 # Not sure if there is a better way to do this than to use jq 291 # to replace sections of json. 292 preStart = '' 293 # If the user storage path does not exist, create it 294 if [ ! -d "${cfg.userStoragePath}" ]; then 295 install -d -m 700 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}" 296 fi 297 # If there is no config file, create a placeholder default 298 if [ ! -e "${cfg.userStoragePath}/config.json" ]; then 299 install -D -m 600 -o ${cfg.user} -g ${cfg.group} "${defaultConfigFile}" "${cfg.userStoragePath}/config.json" 300 fi 301 302 # Apply all nix override settings to config.json in a single jq operation 303 ${pkgs.jq}/bin/jq -s -f "${jqMergeFilterFile}" "${cfg.userStoragePath}/config.json" "${nixOverrideConfigFile}" | ${pkgs.jq}/bin/jq . > "${cfg.userStoragePath}/config.json.tmp" 304 install -D -m 600 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}/config.json.tmp" "${cfg.userStoragePath}/config.json" 305 306 # Remove temporary files 307 rm "${cfg.userStoragePath}/config.json.tmp" 308 309 # Make sure plugin directory exists 310 install -d -m 755 -o ${cfg.user} -g ${cfg.group} "${cfg.pluginPath}" 311 312 # In order for hb-service to detect the homebridge installation, we need to create a folder structure 313 # where homebridge and homebrdige-config-ui-x node modules are side by side, and then point 314 # UIX_BASE_PATH_OVERRIDE at the homebridge-config-ui-x node module in the service environment. 315 # So, first create a directory to symlink these packages to 316 install -d -m 755 -o ${cfg.user} -g ${cfg.group} "${cfg.userStoragePath}/homebridge-packages" 317 318 # Then, symlink in the homebridge and homebridge-config-ui-x packages 319 rm -rf "${cfg.userStoragePath}/homebridge-packages/homebridge" 320 ln -s "${pkgs.homebridge}/lib/node_modules/homebridge" "${cfg.userStoragePath}/homebridge-packages/homebridge" 321 rm -rf "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x" 322 ln -s "${pkgs.homebridge-config-ui-x}/lib/node_modules/homebridge-config-ui-x" "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x" 323 ''; 324 325 # hb-service environment variables based on source code analysis 326 environment = { 327 HOMEBRIDGE_CONFIG_UI_TERMINAL = "1"; 328 DISABLE_OPENCOLLECTIVE = "true"; 329 # Required or homebridge will search the global npm namespace 330 UIX_STRICT_PLUGIN_RESOLUTION = "1"; 331 # Workaround to ensure homebridge does not run in sudo mode 332 HOMEBRIDGE_APT_PACKAGE = "1"; 333 # Required to get the service to detect the homebridge install correctly 334 UIX_BASE_PATH_OVERRIDE = "${cfg.userStoragePath}/homebridge-packages/homebridge-config-ui-x"; 335 }; 336 337 path = with pkgs; [ 338 # Tools listed in homebridge's installation documentations: 339 # https://github.com/homebridge/homebridge/wiki/Install-Homebridge-on-Arch-Linux 340 nodejs 341 nettools 342 gcc 343 gnumake 344 # Required for access to systemctl and journalctl 345 systemd 346 # Required for access to sudo 347 "/run/wrappers" 348 # Some plugins need bash to download tools 349 bash 350 ]; 351 352 # Settings from https://github.com/homebridge/homebridge-config-ui-x/blob/latest/src/bin/platforms/linux.ts 353 serviceConfig = { 354 Type = "simple"; 355 User = cfg.user; 356 PermissionsStartOnly = true; 357 StateDirectory = "homebridge"; 358 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ]; 359 ExecStart = "${pkgs.homebridge-config-ui-x}/bin/hb-service run -U ${cfg.userStoragePath} -P ${cfg.pluginPath}"; 360 Restart = "always"; 361 RestartSec = 3; 362 KillMode = "process"; 363 CapabilityBoundingSet = [ 364 "CAP_IPC_LOCK" 365 "CAP_NET_ADMIN" 366 "CAP_NET_BIND_SERVICE" 367 "CAP_NET_RAW" 368 "CAP_SETGID" 369 "CAP_SETUID" 370 "CAP_SYS_CHROOT" 371 "CAP_CHOWN" 372 "CAP_FOWNER" 373 "CAP_DAC_OVERRIDE" 374 "CAP_AUDIT_WRITE" 375 "CAP_SYS_ADMIN" 376 ]; 377 AmbientCapabilities = [ 378 "CAP_NET_RAW" 379 "CAP_NET_BIND_SERVICE" 380 ]; 381 }; 382 }; 383 384 # Create a user whose home folder is the user storage path 385 users.users = lib.mkIf (cfg.user == "homebridge") { 386 homebridge = { 387 inherit (cfg) group; 388 # Necessary so that this user can run journalctl 389 extraGroups = [ "systemd-journal" ]; 390 description = "homebridge user"; 391 isSystemUser = true; 392 home = cfg.userStoragePath; 393 }; 394 }; 395 396 users.groups = lib.mkIf (cfg.group == "homebridge") { 397 homebridge = { }; 398 }; 399 400 # Need passwordless sudo for a few commands 401 # homebridge-config-ui-x needs for some features 402 security.sudo.extraRules = [ 403 { 404 users = [ cfg.user ]; 405 commands = [ 406 { 407 # Ability to restart homebridge service 408 command = "${pkgs.systemd}/bin/systemctl restart homebridge"; 409 options = [ "NOPASSWD" ]; 410 } 411 { 412 # Ability to shutdown server 413 command = "${pkgs.systemd}/bin/shutdown -h now"; 414 options = [ "NOPASSWD" ]; 415 } 416 { 417 # Ability to restart server 418 command = "${pkgs.systemd}/bin/shutdown -r now"; 419 options = [ "NOPASSWD" ]; 420 } 421 ]; 422 } 423 ]; 424 425 networking.firewall = { 426 allowedTCPPorts = lib.mkIf cfg.openFirewall [ 427 cfg.settings.bridge.port 428 cfg.uiSettings.port 429 ]; 430 allowedUDPPorts = lib.mkIf cfg.openFirewall [ 5353 ]; 431 }; 432 }; 433}