nixos/homebridge: init

This commit is contained in:
Frank Moda 2024-01-24 19:51:12 -05:00 committed by Masum Reza
parent b26ab81084
commit 12ed2954d4
5 changed files with 525 additions and 0 deletions

View File

@ -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).

View File

@ -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

View File

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

View File

@ -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;

View File

@ -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"))
'';
}