diff --git a/patches/nixpkgs/0002-jellyfin-add-declarative-network-xml-options.patch b/patches/nixpkgs/0002-jellyfin-add-declarative-network-xml-options.patch new file mode 100644 index 0000000..2bcfd11 --- /dev/null +++ b/patches/nixpkgs/0002-jellyfin-add-declarative-network-xml-options.patch @@ -0,0 +1,443 @@ +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 +