nixos/homebridge: init

Changed files
+525
nixos
doc
manual
release-notes
modules
services
home-automation
tests
+2
nixos/doc/manual/release-notes/rl-2511.section.md
···
- 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).
+1
nixos/modules/module-list.nix
···
./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
+433
nixos/modules/services/home-automation/homebridge.nix
···
+
{
+
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 ];
+
};
+
};
+
}
+1
nixos/tests/all-tests.nix
···
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;
+88
nixos/tests/homebridge.nix
···
+
{
+
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"))
+
'';
+
}