Files
arr-init/modules/bazarr.nix
Simon Gardling 6dde2a3e0d servarr: add configXml option with preStart hook
Adds services.arrInit.<name>.configXml for declaratively ensuring XML
elements exist in a Servarr config.xml before the service starts.

Generates a preStart hook on the main service that runs a Python helper
to patch or create config.xml. Undeclared elements are preserved;
declared elements are written with exact values.

Primary use case: preventing recurring Prowlarr 'not listening on port'
failures when config.xml loses the <Port> element — now guaranteed to
exist before Prowlarr starts.

Hardening:
- Atomic writes (tmp + rename): power loss cannot corrupt config.xml
- Malformed XML recovery: fresh <Config> root instead of blocking boot
- Secure default mode (0600) for new files containing ApiKey
- Preserves existing file mode on rewrite
- Assertion against duplicate serviceName targeting

Tests (10 subtests): creates-from-missing, patches-existing, preserves-
undeclared, corrects-tampered, idempotent, malformed-recovery,
ownership-preserved, not-world-readable.
2026-04-17 00:45:21 -04:00

181 lines
4.7 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.bazarrInit;
scriptDir = ../scripts;
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
pyyaml
requests
]
);
bazarrProviderModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "provider connection";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the provider's data directory containing config.xml.";
example = "/services/sonarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address of the provider (Sonarr/Radarr) for Bazarr to connect to.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the provider.";
example = 8989;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "sonarr";
};
};
};
bazarrInitModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Bazarr API initialization";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to Bazarr's data directory containing config/config.yaml.";
example = "/services/bazarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Bazarr API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "API port of Bazarr.";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = ''
Seconds to wait for the Bazarr API to become available before
considering the init attempt failed. When the API is not reachable
within this window, the service exits non-zero and systemd's
Restart=on-failure will schedule another attempt after RestartSec.
The systemd start limit is computed from this value to allow 5 full
retry cycles before the unit enters permanent failure.
'';
};
sonarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Sonarr provider configuration.";
};
radarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Radarr provider configuration.";
};
};
};
mkBazarrInitConfig = builtins.toJSON {
dataDir = cfg.dataDir;
bindAddress = cfg.bindAddress;
port = cfg.port;
apiTimeout = cfg.apiTimeout;
providers =
{ }
// lib.optionalAttrs cfg.sonarr.enable {
sonarr = {
enable = true;
dataDir = cfg.sonarr.dataDir;
bindAddress = cfg.sonarr.bindAddress;
port = cfg.sonarr.port;
};
}
// lib.optionalAttrs cfg.radarr.enable {
radarr = {
enable = true;
dataDir = cfg.radarr.dataDir;
bindAddress = cfg.radarr.bindAddress;
port = cfg.radarr.port;
};
};
};
configFile = pkgs.writeText "bazarr-init-config.json" mkBazarrInitConfig;
bazarrDeps = [
"bazarr.service"
]
++ (lib.optional cfg.sonarr.enable "${cfg.sonarr.serviceName}.service")
++ (lib.optional cfg.radarr.enable "${cfg.radarr.serviceName}.service");
hardeningConfig = {
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ProtectHome = true;
SystemCallArchitectures = "native";
};
in
{
options.services.bazarrInit = lib.mkOption {
type = bazarrInitModule;
default = {
enable = false;
};
description = ''
Bazarr API initialization for connecting Sonarr and Radarr providers.
Bazarr uses a different API than Servarr applications, so it has its own module.
'';
};
config = lib.mkIf cfg.enable {
systemd.services.bazarr-init = {
description = "Initialize Bazarr API connections";
after = bazarrDeps;
requires = bazarrDeps;
wantedBy = [ "multi-user.target" ];
environment.PYTHONPATH = "${scriptDir}";
unitConfig = {
StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30);
StartLimitBurst = 5;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/bazarr_init.py ${configFile}";
}
// hardeningConfig;
};
};
}