Files
arr-init/modules/servarr.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

448 lines
14 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.arrInit;
scriptDir = ../scripts;
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
pyyaml
requests
]
);
downloadClientModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the download client (e.g. \"qBittorrent\").";
example = "qBittorrent";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Servarr API.";
example = "QBittorrent";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Servarr API.";
example = "QBittorrentSettings";
};
protocol = lib.mkOption {
type = lib.types.enum [
"torrent"
"usenet"
];
default = "torrent";
description = "Download protocol type.";
};
fields = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Flat key/value pairs for the download client configuration.
These are converted to the API's [{name, value}] array format.
'';
example = {
host = "192.168.15.1";
port = 6011;
useSsl = false;
tvCategory = "tvshows";
};
};
serviceName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Name of the systemd service for this download client.
When set, the init service will depend on (After + Requires) this service,
ensuring the download client is running before health checks execute.
'';
example = "qbittorrent";
};
};
};
syncedAppModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the application to sync (e.g. \"Sonarr\").";
example = "Sonarr";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Prowlarr application API.";
example = "Sonarr";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Prowlarr application API.";
example = "SonarrSettings";
};
syncLevel = lib.mkOption {
type = lib.types.str;
default = "fullSync";
description = "Sync level for the application.";
};
prowlarrUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the Prowlarr instance.";
example = "http://localhost:9696";
};
baseUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the target application.";
example = "http://localhost:8989";
};
apiKeyFrom = lib.mkOption {
type = lib.types.str;
description = "Path to the config.xml file to read the API key from at runtime.";
example = "/services/sonarr/config.xml";
};
syncCategories = lib.mkOption {
type = lib.types.listOf lib.types.int;
default = [ ];
description = ''
List of Newznab category IDs to sync for this application.
When empty (default), categories are auto-detected at runtime
by querying Prowlarr's indexer/categories API endpoint and
collecting all IDs under the parent category matching the
implementation type (e.g. Sonarr -> TV, Radarr -> Movies).
'';
example = [
5000
5010
5020
];
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on for reading the API key.";
example = "sonarr";
};
};
};
instanceModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Servarr application API initialization";
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service this init depends on.";
example = "sonarr";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the application data directory containing config.xml.";
example = "/var/lib/sonarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Servarr application API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the Servarr application.";
example = 8989;
};
apiVersion = lib.mkOption {
type = lib.types.str;
default = "v3";
description = "API version string used in the base URL.";
};
networkNamespacePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, run this init service inside the given network namespace path (e.g. /run/netns/wg).";
};
networkNamespaceService = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Systemd service that manages the network namespace.
When set, the init service orders after this service.
'';
example = "wg";
};
downloadClients = lib.mkOption {
type = lib.types.listOf downloadClientModule;
default = [ ];
description = "List of download clients to configure via the API.";
};
rootFolders = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of root folder paths to configure via the API.";
example = [
"/media/tv"
"/media/movies"
];
};
syncedApps = lib.mkOption {
type = lib.types.listOf syncedAppModule;
default = [ ];
description = "Applications to register for indexer sync (Prowlarr only).";
};
healthChecks = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
When enabled, the init service will verify connectivity after provisioning:
- Tests all download clients are reachable via the application's testall API
- For Prowlarr instances: tests all synced applications are reachable
The init service will fail if any health check fails after all retries.
'';
};
healthCheckRetries = lib.mkOption {
type = lib.types.ints.unsigned;
default = 5;
description = ''
Number of times to retry health checks before failing.
Each retry waits healthCheckInterval seconds.
'';
};
healthCheckInterval = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
description = "Seconds to wait between health check retries.";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = ''
Seconds to wait for the application 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.
'';
};
naming = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Naming configuration to set via the API's config/naming endpoint.
Keys/values map directly to the API fields (e.g. renameEpisodes,
standardEpisodeFormat for Sonarr; renameMovies, standardMovieFormat
for Radarr). Only specified fields are updated; unspecified fields
retain their current values.
'';
example = {
renameEpisodes = true;
standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}";
seasonFolderFormat = "Season {season}";
seriesFolderFormat = "{Series Title}";
};
};
configXml = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = { };
description = ''
XML elements to ensure in the service's config.xml before startup.
Each key-value pair corresponds to a direct child element of the
<Config> root. Existing elements are updated if their value differs;
new elements are added. Undeclared elements are preserved.
This runs as a preStart hook on the main service, guaranteeing
config.xml is correct before the application reads it.
'';
example = {
Port = 9696;
BindAddress = "*";
AnalyticsEnabled = false;
};
};
};
};
mkInitConfig =
name: inst:
builtins.toJSON {
inherit name;
inherit (inst)
dataDir
bindAddress
port
apiVersion
apiTimeout
healthChecks
healthCheckRetries
healthCheckInterval
rootFolders
naming
;
downloadClients = map (dc: {
inherit (dc)
name
implementation
configContract
protocol
fields
;
}) inst.downloadClients;
syncedApps = map (app: {
inherit (app)
name
implementation
configContract
syncLevel
prowlarrUrl
baseUrl
apiKeyFrom
syncCategories
;
}) inst.syncedApps;
};
mkConfigFile = name: inst: pkgs.writeText "${name}-init-config.json" (mkInitConfig name inst);
getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps;
getDownloadClientDeps =
inst:
lib.concatMap (
dc: lib.optional (dc.serviceName != null) "${dc.serviceName}.service"
) inst.downloadClients;
enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg;
configXmlInstances = lib.filterAttrs (_: inst: inst.configXml != { }) enabledInstances;
mkConfigXmlFile =
name: inst:
pkgs.writeText "${name}-config-xml.json" (
builtins.toJSON {
inherit (inst) dataDir;
elements = inst.configXml;
}
);
# Shared hardening options for all init services.
hardeningConfig = {
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ProtectHome = true;
SystemCallArchitectures = "native";
};
in
{
options.services.arrInit = lib.mkOption {
type = lib.types.attrsOf instanceModule;
default = { };
description = ''
Attribute set of Servarr application instances to initialize via their APIs.
Each instance generates a systemd oneshot service that idempotently configures
download clients, root folders, and synced applications.
'';
};
config = lib.mkIf (enabledInstances != { }) {
assertions =
let
configXmlTargets = map (inst: inst.serviceName) (builtins.attrValues configXmlInstances);
in
[
{
# Two arrInit entries targeting the same systemd service with configXml
# would silently collide on the preStart definition; only one would win.
# Force the user to deduplicate instead of producing surprising behaviour.
assertion = (lib.length configXmlTargets) == (lib.length (lib.unique configXmlTargets));
message = ''
services.arrInit: multiple entries target the same serviceName with configXml.
Each systemd service may have configXml defined by at most one arrInit entry.
Targets: ${lib.concatStringsSep ", " configXmlTargets}
'';
}
];
systemd.services =
# Init services: oneshot units that configure the app via HTTP API
(lib.mapAttrs' (
name: inst:
lib.nameValuePair "${inst.serviceName}-init" {
description = "Initialize ${name} API connections";
after = [
"${inst.serviceName}.service"
]
++ (getSyncedAppDeps inst)
++ (getDownloadClientDeps inst)
++ (lib.optional (inst.networkNamespaceService != null) "${inst.networkNamespaceService}.service");
requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst);
wantedBy = [ "multi-user.target" ];
environment.PYTHONPATH = "${scriptDir}";
unitConfig = {
StartLimitIntervalSec = 5 * (inst.apiTimeout + 30);
StartLimitBurst = 5;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/servarr_init.py ${mkConfigFile name inst}";
}
// hardeningConfig
// lib.optionalAttrs (inst.networkNamespacePath != null) {
NetworkNamespacePath = inst.networkNamespacePath;
};
}
) enabledInstances)
# config.xml preStart: ensure declared elements exist before the service reads them
// (lib.mapAttrs' (
name: inst:
lib.nameValuePair inst.serviceName {
preStart = lib.mkBefore (
"${pythonEnv}/bin/python3 ${scriptDir}/ensure_config_xml.py ${mkConfigXmlFile name inst}"
);
}
) configXmlInstances);
};
}