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}