448 lines
14 KiB
Nix
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);
|
|
};
|
|
}
|