nixos/homebridge: init
This commit is contained in:
parent
b26ab81084
commit
12ed2954d4
@ -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).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
433
nixos/modules/services/home-automation/homebridge.nix
Normal file
433
nixos/modules/services/home-automation/homebridge.nix
Normal 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 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
88
nixos/tests/homebridge.nix
Normal file
88
nixos/tests/homebridge.nix
Normal 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"))
|
||||
'';
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user