From f0582558f0a8b0ef543b3251c4a07afab89fde63 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 17 Apr 2026 19:37:11 -0400 Subject: [PATCH] nixos/jellyfin: add declarative network.xml options Adds services.jellyfin.network.* (baseUrl, ports, IPv4/6, LAN subnets, known proxies, remote IP filter, etc.) and services.jellyfin.forceNetworkConfig, mirroring the existing hardwareAcceleration / forceEncodingConfig pattern. Motivation: running Jellyfin behind a reverse proxy requires configuring KnownProxies (so the real client IP is extracted from X-Forwarded-For) and LocalNetworkSubnets (so LAN clients are correctly classified and not subject to RemoteClientBitrateLimit). These settings previously had no declarative option -- they could only be set via the web dashboard or by hand-editing network.xml, with no guarantee they would survive a reinstall or be consistent across deployments. Implementation: - Adds a networkXmlText template alongside the existing encodingXmlText. - Factors the force-vs-soft install logic out of preStart into a small 'manage_config_xml' shell helper; encoding.xml and network.xml now share the same install/backup semantics. - Extends the VM test with a machineWithNetworkConfig node and a subtest that verifies the declared values land in network.xml, Jellyfin parses them at startup, and the backup-on-overwrite path works (same shape as the existing 'Force encoding config' subtest). --- nixos/modules/services/misc/jellyfin.nix | 303 ++++++++++++++++++++--- nixos/tests/jellyfin.nix | 50 ++++ 2 files changed, 317 insertions(+), 36 deletions(-) diff --git a/nixos/modules/services/misc/jellyfin.nix b/nixos/modules/services/misc/jellyfin.nix index 5c08fc478e45..387da907c652 100644 --- a/nixos/modules/services/misc/jellyfin.nix +++ b/nixos/modules/services/misc/jellyfin.nix @@ -26,8 +26,10 @@ let bool enum ints + listOf nullOr path + port str submodule ; @@ -68,6 +70,41 @@ let ''; encodingXmlFile = pkgs.writeText "encoding.xml" encodingXmlText; + stringListToXml = + tag: items: + if items == [ ] then + "<${tag} />" + else + "<${tag}>\n ${ + concatMapStringsSep "\n " (item: "${escapeXML item}") items + }\n "; + networkXmlText = '' + + + ${escapeXML cfg.network.baseUrl} + ${boolToString cfg.network.enableHttps} + ${boolToString cfg.network.requireHttps} + ${toString cfg.network.internalHttpPort} + ${toString cfg.network.internalHttpsPort} + ${toString cfg.network.publicHttpPort} + ${toString cfg.network.publicHttpsPort} + ${boolToString cfg.network.autoDiscovery} + ${boolToString cfg.network.enableUPnP} + ${boolToString cfg.network.enableIPv4} + ${boolToString cfg.network.enableIPv6} + ${boolToString cfg.network.enableRemoteAccess} + ${stringListToXml "LocalNetworkSubnets" cfg.network.localNetworkSubnets} + ${stringListToXml "LocalNetworkAddresses" cfg.network.localNetworkAddresses} + ${stringListToXml "KnownProxies" cfg.network.knownProxies} + ${boolToString cfg.network.ignoreVirtualInterfaces} + ${stringListToXml "VirtualInterfaceNames" cfg.network.virtualInterfaceNames} + ${boolToString cfg.network.enablePublishedServerUriByRequest} + ${stringListToXml "PublishedServerUriBySubnet" cfg.network.publishedServerUriBySubnet} + ${stringListToXml "RemoteIPFilter" cfg.network.remoteIPFilter} + ${boolToString cfg.network.isRemoteIPFilterBlacklist} + + ''; + networkXmlFile = pkgs.writeText "network.xml" networkXmlText; codecListToType = desc: list: submodule { @@ -205,6 +242,196 @@ in ''; }; + network = { + baseUrl = mkOption { + type = str; + default = ""; + example = "/jellyfin"; + description = '' + Prefix added to Jellyfin's internal URLs when it sits behind a reverse proxy at a sub-path. + Leave empty when Jellyfin is served at the root of its host. + ''; + }; + + enableHttps = mkOption { + type = bool; + default = false; + description = '' + Serve HTTPS directly from Jellyfin. Usually unnecessary when terminating TLS in a reverse proxy. + ''; + }; + + requireHttps = mkOption { + type = bool; + default = false; + description = '' + Redirect plaintext HTTP requests to HTTPS. Only meaningful when {option}`enableHttps` is true. + ''; + }; + + internalHttpPort = mkOption { + type = port; + default = 8096; + description = "TCP port Jellyfin binds for HTTP."; + }; + + internalHttpsPort = mkOption { + type = port; + default = 8920; + description = "TCP port Jellyfin binds for HTTPS. Only used when {option}`enableHttps` is true."; + }; + + publicHttpPort = mkOption { + type = port; + default = 8096; + description = "HTTP port Jellyfin advertises in server discovery responses and published URIs."; + }; + + publicHttpsPort = mkOption { + type = port; + default = 8920; + description = "HTTPS port Jellyfin advertises in server discovery responses and published URIs."; + }; + + autoDiscovery = mkOption { + type = bool; + default = true; + description = "Respond to LAN client auto-discovery broadcasts (UDP 7359)."; + }; + + enableUPnP = mkOption { + type = bool; + default = false; + description = "Attempt to open the public ports on the router via UPnP."; + }; + + enableIPv4 = mkOption { + type = bool; + default = true; + description = "Listen on IPv4."; + }; + + enableIPv6 = mkOption { + type = bool; + default = true; + description = "Listen on IPv6."; + }; + + enableRemoteAccess = mkOption { + type = bool; + default = true; + description = '' + Allow connections from clients outside the subnets listed in {option}`localNetworkSubnets`. + When false, Jellyfin rejects non-local requests regardless of reverse proxy configuration. + ''; + }; + + localNetworkSubnets = mkOption { + type = listOf str; + default = [ ]; + example = [ + "192.168.1.0/24" + "10.0.0.0/8" + ]; + description = '' + CIDR ranges (or bare IPs) that Jellyfin classifies as the local network. + Clients originating from these ranges -- as seen after {option}`knownProxies` X-Forwarded-For + unwrapping -- are not subject to {option}`services.jellyfin` remote-client bitrate limits. + ''; + }; + + localNetworkAddresses = mkOption { + type = listOf str; + default = [ ]; + example = [ "192.168.1.50" ]; + description = '' + Specific interface addresses Jellyfin binds to. Leave empty to bind all interfaces. + ''; + }; + + knownProxies = mkOption { + type = listOf str; + default = [ ]; + example = [ "127.0.0.1" ]; + description = '' + Addresses of reverse proxies trusted to forward the real client IP via `X-Forwarded-For`. + Without this, Jellyfin sees the proxy's address for every request and cannot apply + {option}`localNetworkSubnets` classification to the true client. + ''; + }; + + ignoreVirtualInterfaces = mkOption { + type = bool; + default = true; + description = "Skip virtual network interfaces (matching {option}`virtualInterfaceNames`) during auto-bind."; + }; + + virtualInterfaceNames = mkOption { + type = listOf str; + default = [ "veth" ]; + description = "Interface name prefixes treated as virtual when {option}`ignoreVirtualInterfaces` is true."; + }; + + enablePublishedServerUriByRequest = mkOption { + type = bool; + default = false; + description = '' + Derive the server's public URI from the incoming request's Host header instead of any + configured {option}`publishedServerUriBySubnet` entry. + ''; + }; + + publishedServerUriBySubnet = mkOption { + type = listOf str; + default = [ ]; + example = [ "192.168.1.0/24=http://jellyfin.lan:8096" ]; + description = '' + Per-subnet overrides for the URI Jellyfin advertises to clients, in `subnet=uri` form. + ''; + }; + + remoteIPFilter = mkOption { + type = listOf str; + default = [ ]; + example = [ "203.0.113.0/24" ]; + description = '' + IPs or CIDRs used as the allow- or denylist for remote access. + Behaviour is controlled by {option}`isRemoteIPFilterBlacklist`. + ''; + }; + + isRemoteIPFilterBlacklist = mkOption { + type = bool; + default = false; + description = '' + When true, {option}`remoteIPFilter` is a denylist; when false, it is an allowlist + (and an empty list allows all remote addresses). + ''; + }; + }; + + forceNetworkConfig = mkOption { + type = bool; + default = false; + description = '' + Whether to overwrite Jellyfin's `network.xml` configuration file on each service start. + + When enabled, the network configuration specified in {option}`services.jellyfin.network` + is applied on every service restart. A backup of the existing `network.xml` will be + created at `network.xml.backup-$timestamp`. + + ::: {.warning} + Enabling this option means that any changes made to networking settings through + Jellyfin's web dashboard will be lost on the next service restart. The NixOS configuration + becomes the single source of truth for network settings. + ::: + + When disabled (the default), the network configuration is only written if no `network.xml` + exists yet. This allows settings to be changed through Jellyfin's web dashboard and persist + across restarts, but means the NixOS configuration options will be ignored after the initial setup. + ''; + }; + transcoding = { maxConcurrentStreams = mkOption { type = nullOr ints.positive; @@ -384,46 +611,50 @@ in wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; - preStart = mkIf cfg.hardwareAcceleration.enable ( - '' - configDir=${escapeShellArg cfg.configDir} - encodingXml="$configDir/encoding.xml" - '' - + ( - if cfg.forceEncodingConfig then - '' - if [[ -e $encodingXml ]]; then + preStart = + let + # manage_config_xml + # + # Installs a NixOS-declared XML config at , preserving + # any existing file as a timestamped backup when is true. + # With =false, leaves existing files untouched and warns if + # the on-disk content differs from the declared content. + helper = '' + manage_config_xml() { + local src="$1" dest="$2" force="$3" desc="$4" + if [[ -e "$dest" ]]; then # this intentionally removes trailing newlines - currentText="$(<"$encodingXml")" - configuredText="$(<${encodingXmlFile})" - if [[ $currentText == "$configuredText" ]]; then - # don't need to do anything - exit 0 - else - encodingXmlBackup="$configDir/encoding.xml.backup-$(date -u +"%FT%H_%M_%SZ")" - mv --update=none-fail -T "$encodingXml" "$encodingXmlBackup" + local currentText configuredText + currentText="$(<"$dest")" + configuredText="$(<"$src")" + if [[ "$currentText" == "$configuredText" ]]; then + return 0 fi - fi - cp --update=none-fail -T ${encodingXmlFile} "$encodingXml" - chmod u+w "$encodingXml" - '' - else - '' - if [[ -e $encodingXml ]]; then - # this intentionally removes trailing newlines - currentText="$(<"$encodingXml")" - configuredText="$(<${encodingXmlFile})" - if [[ $currentText != "$configuredText" ]]; then - echo "WARN: $encodingXml already exists and is different from the configured settings. transcoding options NOT applied." >&2 - echo "WARN: Set config.services.jellyfin.forceEncodingConfig = true to override." >&2 + if [[ "$force" == true ]]; then + local backup + backup="$dest.backup-$(date -u +"%FT%H_%M_%SZ")" + mv --update=none-fail -T "$dest" "$backup" + else + echo "WARN: $dest already exists and is different from the configured settings. $desc options NOT applied." >&2 + echo "WARN: Set the corresponding force*Config option to override." >&2 + return 0 fi - else - cp --update=none-fail -T ${encodingXmlFile} "$encodingXml" - chmod u+w "$encodingXml" fi - '' - ) - ); + cp --update=none-fail -T "$src" "$dest" + chmod u+w "$dest" + } + configDir=${escapeShellArg cfg.configDir} + ''; + in + ( + helper + + optionalString cfg.hardwareAcceleration.enable '' + manage_config_xml ${encodingXmlFile} "$configDir/encoding.xml" ${boolToString cfg.forceEncodingConfig} transcoding + '' + + '' + manage_config_xml ${networkXmlFile} "$configDir/network.xml" ${boolToString cfg.forceNetworkConfig} network + '' + ); # This is mostly follows: https://github.com/jellyfin/jellyfin/blob/master/fedora/jellyfin.service # Upstream also disable some hardenings when running in LXC, we do the same with the isContainer option diff --git a/nixos/tests/jellyfin.nix b/nixos/tests/jellyfin.nix index 4896c13d4eca..0c9191960f78 100644 --- a/nixos/tests/jellyfin.nix +++ b/nixos/tests/jellyfin.nix @@ -63,6 +63,26 @@ environment.systemPackages = with pkgs; [ ffmpeg ]; virtualisation.diskSize = 3 * 1024; }; + + machineWithNetworkConfig = { + services.jellyfin = { + enable = true; + forceNetworkConfig = true; + network = { + localNetworkSubnets = [ + "192.168.1.0/24" + "10.0.0.0/8" + ]; + knownProxies = [ "127.0.0.1" ]; + enableUPnP = false; + enableIPv6 = false; + remoteIPFilter = [ "203.0.113.5" ]; + isRemoteIPFilterBlacklist = true; + }; + }; + environment.systemPackages = with pkgs; [ ffmpeg ]; + virtualisation.diskSize = 3 * 1024; + }; }; # Documentation of the Jellyfin API: https://api.jellyfin.org/ @@ -122,6 +142,36 @@ # Verify the new encoding.xml does not have the marker (was overwritten) machineWithForceConfig.fail("grep -q 'MARKER' /var/lib/jellyfin/config/encoding.xml") + # Test forceNetworkConfig and network.xml generation + with subtest("Force network config writes declared values and backs up on overwrite"): + wait_for_jellyfin(machineWithNetworkConfig) + + # Verify network.xml exists and contains the declared values + machineWithNetworkConfig.succeed("test -f /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F '192.168.1.0/24' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F '10.0.0.0/8' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F '127.0.0.1' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F '203.0.113.5' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F 'true' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F 'false' /var/lib/jellyfin/config/network.xml") + machineWithNetworkConfig.succeed("grep -F 'false' /var/lib/jellyfin/config/network.xml") + + # Stop service before modifying config + machineWithNetworkConfig.succeed("systemctl stop jellyfin.service") + + # Plant a marker so we can prove the backup-and-overwrite path runs + machineWithNetworkConfig.succeed("echo '' > /var/lib/jellyfin/config/network.xml") + + # Restart the service to trigger the backup + machineWithNetworkConfig.succeed("systemctl restart jellyfin.service") + wait_for_jellyfin(machineWithNetworkConfig) + + # Verify the marked content was preserved as a timestamped backup + machineWithNetworkConfig.succeed("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml.backup-*") + + # Verify the new network.xml does not have the marker (was overwritten) + machineWithNetworkConfig.fail("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml") + auth_header = 'MediaBrowser Client="NixOS Integration Tests", DeviceId="1337", Device="Apple II", Version="20.09"' -- 2.53.0