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.
This commit is contained in:
2026-04-17 00:38:32 -04:00
parent a1ae022dc3
commit 6dde2a3e0d
14 changed files with 684 additions and 212 deletions

View File

@@ -271,6 +271,31 @@ let
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;
};
};
};
};
@@ -324,6 +349,16 @@ let
) 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 = {
@@ -349,26 +384,44 @@ in
};
config = lib.mkIf (enabledInstances != { }) {
systemd.services = lib.mapAttrs' (
name: inst:
lib.nameValuePair "${inst.serviceName}-init" {
description = "Initialize ${name} API connections";
after =
[
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 =
{
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";
@@ -379,7 +432,16 @@ in
// lib.optionalAttrs (inst.networkNamespacePath != null) {
NetworkNamespacePath = inst.networkNamespacePath;
};
}
) enabledInstances;
}
) 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);
};
}