phase 2: promote services/, tests/, patches/, lib/, scripts/

This commit is contained in:
primary
2026-04-18 00:47:39 -04:00
parent 99e98e39b7
commit 999ed05d9f
86 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,379 @@
From ab57092a60123e361cf0de1c1a314a9888c45219 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
Date: Sat, 21 Mar 2026 09:24:39 -0400
Subject: [PATCH] temp
---
.../services/networking/firefox-syncserver.md | 23 +++
.../networking/firefox-syncserver.nix | 140 ++++++++++++++----
pkgs/by-name/sy/syncstorage-rs/package.nix | 49 ++++--
3 files changed, 174 insertions(+), 38 deletions(-)
diff --git a/nixos/modules/services/networking/firefox-syncserver.md b/nixos/modules/services/networking/firefox-syncserver.md
index 991e97f799d6..3bc45cfa5640 100644
--- a/nixos/modules/services/networking/firefox-syncserver.md
+++ b/nixos/modules/services/networking/firefox-syncserver.md
@@ -32,6 +32,29 @@ This configuration should never be used in production. It is not encrypted and
stores its secrets in a world-readable location.
:::
+## Database backends {#module-services-firefox-syncserver-database}
+
+The sync server supports MySQL/MariaDB (the default) and PostgreSQL as database
+backends. Set `database.type` to choose the backend:
+
+```nix
+{
+ services.firefox-syncserver = {
+ enable = true;
+ database.type = "postgresql";
+ secrets = "/run/secrets/firefox-syncserver";
+ singleNode = {
+ enable = true;
+ hostname = "localhost";
+ url = "http://localhost:5000";
+ };
+ };
+}
+```
+
+When `database.createLocally` is `true` (the default), the module will
+automatically enable and configure the corresponding database service.
+
## More detailed setup {#module-services-firefox-syncserver-configuration}
The `firefox-syncserver` service provides a number of options to make setting up
diff --git a/nixos/modules/services/networking/firefox-syncserver.nix b/nixos/modules/services/networking/firefox-syncserver.nix
index 6a50e49fc096..70a56314e323 100644
--- a/nixos/modules/services/networking/firefox-syncserver.nix
+++ b/nixos/modules/services/networking/firefox-syncserver.nix
@@ -13,7 +13,21 @@ let
defaultUser = "firefox-syncserver";
dbIsLocal = cfg.database.host == "localhost";
- dbURL = "mysql://${cfg.database.user}@${cfg.database.host}/${cfg.database.name}${lib.optionalString dbIsLocal "?socket=/run/mysqld/mysqld.sock"}";
+ dbIsMySQL = cfg.database.type == "mysql";
+ dbIsPostgreSQL = cfg.database.type == "postgresql";
+
+ dbURL =
+ if dbIsMySQL then
+ "mysql://${cfg.database.user}@${cfg.database.host}/${cfg.database.name}${lib.optionalString dbIsLocal "?socket=/run/mysqld/mysqld.sock"}"
+ else
+ "postgres://${cfg.database.user}@${cfg.database.host}/${cfg.database.name}${lib.optionalString dbIsLocal "?host=/run/postgresql"}";
+
+ # postgresql.target waits for postgresql-setup.service (which runs
+ # ensureDatabases / ensureUsers) to complete, avoiding race conditions
+ # where the syncserver starts before its database and role exist.
+ dbService = if dbIsMySQL then "mysql.service" else "postgresql.target";
+
+ syncserver = cfg.package.override { dbBackend = cfg.database.type; };
format = pkgs.formats.toml { };
settings = {
@@ -22,7 +36,7 @@ let
database_url = dbURL;
};
tokenserver = {
- node_type = "mysql";
+ node_type = if dbIsMySQL then "mysql" else "postgres";
database_url = dbURL;
fxa_email_domain = "api.accounts.firefox.com";
fxa_oauth_server_url = "https://oauth.accounts.firefox.com/v1";
@@ -41,7 +55,8 @@ let
};
};
configFile = format.generate "syncstorage.toml" (lib.recursiveUpdate settings cfg.settings);
- setupScript = pkgs.writeShellScript "firefox-syncserver-setup" ''
+
+ mysqlSetupScript = pkgs.writeShellScript "firefox-syncserver-setup" ''
set -euo pipefail
shopt -s inherit_errexit
@@ -79,6 +94,47 @@ let
echo "Single-node setup failed"
exit 1
'';
+
+ postgresqlSetupScript = pkgs.writeShellScript "firefox-syncserver-setup" ''
+ set -euo pipefail
+ shopt -s inherit_errexit
+
+ schema_configured() {
+ psql -d ${cfg.database.name} -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'services')" | grep -q t
+ }
+
+ update_config() {
+ psql -d ${cfg.database.name} <<'EOF'
+ BEGIN;
+
+ INSERT INTO services (id, service, pattern)
+ VALUES (1, 'sync-1.5', '{node}/1.5/{uid}')
+ ON CONFLICT (id) DO UPDATE SET service = 'sync-1.5', pattern = '{node}/1.5/{uid}';
+ INSERT INTO nodes (id, service, node, available, current_load,
+ capacity, downed, backoff)
+ VALUES (1, 1, '${cfg.singleNode.url}', ${toString cfg.singleNode.capacity},
+ 0, ${toString cfg.singleNode.capacity}, 0, 0)
+ ON CONFLICT (id) DO UPDATE SET node = '${cfg.singleNode.url}', capacity = ${toString cfg.singleNode.capacity};
+
+ COMMIT;
+ EOF
+ }
+
+
+ for (( try = 0; try < 60; try++ )); do
+ if ! schema_configured; then
+ sleep 2
+ else
+ update_config
+ exit 0
+ fi
+ done
+
+ echo "Single-node setup failed"
+ exit 1
+ '';
+
+ setupScript = if dbIsMySQL then mysqlSetupScript else postgresqlSetupScript;
in
{
@@ -88,25 +144,26 @@ in
the Firefox Sync storage service.
Out of the box this will not be very useful unless you also configure at least
- one service and one nodes by inserting them into the mysql database manually, e.g.
- by running
-
- ```
- INSERT INTO `services` (`id`, `service`, `pattern`) VALUES ('1', 'sync-1.5', '{node}/1.5/{uid}');
- INSERT INTO `nodes` (`id`, `service`, `node`, `available`, `current_load`,
- `capacity`, `downed`, `backoff`)
- VALUES ('1', '1', 'https://mydomain.tld', '1', '0', '10', '0', '0');
- ```
+ one service and one nodes by inserting them into the database manually, e.g.
+ by running the equivalent SQL for your database backend.
{option}`${opt.singleNode.enable}` does this automatically when enabled
'';
package = lib.mkPackageOption pkgs "syncstorage-rs" { };
+ database.type = lib.mkOption {
+ type = lib.types.enum [
+ "mysql"
+ "postgresql"
+ ];
+ default = "mysql";
+ description = ''
+ Which database backend to use for storage.
+ '';
+ };
+
database.name = lib.mkOption {
- # the mysql module does not allow `-quoting without resorting to shell
- # escaping, so we restrict db names for forward compaitiblity should this
- # behavior ever change.
type = lib.types.strMatching "[a-z_][a-z0-9_]*";
default = defaultDatabase;
description = ''
@@ -117,9 +174,15 @@ in
database.user = lib.mkOption {
type = lib.types.str;
- default = defaultUser;
+ default = if dbIsPostgreSQL then defaultDatabase else defaultUser;
+ defaultText = lib.literalExpression ''
+ if database.type == "postgresql" then "${defaultDatabase}" else "${defaultUser}"
+ '';
description = ''
- Username for database connections.
+ Username for database connections. When using PostgreSQL with
+ `createLocally`, this defaults to the database name so that
+ `ensureDBOwnership` works (it requires user and database names
+ to match).
'';
};
@@ -137,7 +200,8 @@ in
default = true;
description = ''
Whether to create database and user on the local machine if they do not exist.
- This includes enabling unix domain socket authentication for the configured user.
+ This includes enabling the configured database service and setting up
+ authentication for the configured user.
'';
};
@@ -237,7 +301,7 @@ in
};
config = lib.mkIf cfg.enable {
- services.mysql = lib.mkIf cfg.database.createLocally {
+ services.mysql = lib.mkIf (cfg.database.createLocally && dbIsMySQL) {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
@@ -250,16 +314,27 @@ in
];
};
+ services.postgresql = lib.mkIf (cfg.database.createLocally && dbIsPostgreSQL) {
+ enable = true;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ {
+ name = cfg.database.user;
+ ensureDBOwnership = true;
+ }
+ ];
+ };
+
systemd.services.firefox-syncserver = {
wantedBy = [ "multi-user.target" ];
- requires = lib.mkIf dbIsLocal [ "mysql.service" ];
- after = lib.mkIf dbIsLocal [ "mysql.service" ];
+ requires = lib.mkIf dbIsLocal [ dbService ];
+ after = lib.mkIf dbIsLocal [ dbService ];
restartTriggers = lib.optional cfg.singleNode.enable setupScript;
environment.RUST_LOG = cfg.logLevel;
serviceConfig = {
- User = defaultUser;
- Group = defaultUser;
- ExecStart = "${cfg.package}/bin/syncserver --config ${configFile}";
+ User = cfg.database.user;
+ Group = cfg.database.user;
+ ExecStart = "${syncserver}/bin/syncserver --config ${configFile}";
EnvironmentFile = lib.mkIf (cfg.secrets != null) "${cfg.secrets}";
# hardening
@@ -303,10 +378,19 @@ in
systemd.services.firefox-syncserver-setup = lib.mkIf cfg.singleNode.enable {
wantedBy = [ "firefox-syncserver.service" ];
- requires = [ "firefox-syncserver.service" ] ++ lib.optional dbIsLocal "mysql.service";
- after = [ "firefox-syncserver.service" ] ++ lib.optional dbIsLocal "mysql.service";
- path = [ config.services.mysql.package ];
- serviceConfig.ExecStart = [ "${setupScript}" ];
+ requires = [ "firefox-syncserver.service" ] ++ lib.optional dbIsLocal dbService;
+ after = [ "firefox-syncserver.service" ] ++ lib.optional dbIsLocal dbService;
+ path =
+ if dbIsMySQL then [ config.services.mysql.package ] else [ config.services.postgresql.package ];
+ serviceConfig = {
+ ExecStart = [ "${setupScript}" ];
+ }
+ // lib.optionalAttrs dbIsPostgreSQL {
+ # PostgreSQL peer authentication requires the system user to match the
+ # database user. Run as the superuser so we can access all databases.
+ User = "postgres";
+ Group = "postgres";
+ };
};
services.nginx.virtualHosts = lib.mkIf cfg.singleNode.enableNginx {
diff --git a/pkgs/by-name/sy/syncstorage-rs/package.nix b/pkgs/by-name/sy/syncstorage-rs/package.nix
index 39b2b53ab03c..944ed72525af 100644
--- a/pkgs/by-name/sy/syncstorage-rs/package.nix
+++ b/pkgs/by-name/sy/syncstorage-rs/package.nix
@@ -1,14 +1,18 @@
{
fetchFromGitHub,
+ fetchurl,
rustPlatform,
pkg-config,
python3,
cmake,
libmysqlclient,
+ libpq,
+ openssl,
makeBinaryWrapper,
lib,
nix-update-script,
nixosTests,
+ dbBackend ? "mysql",
}:
let
@@ -19,17 +23,23 @@ let
p.tokenlib
p.cryptography
]);
+ # utoipa-swagger-ui downloads Swagger UI assets at build time.
+ # Prefetch the archive for sandboxed builds.
+ swaggerUi = fetchurl {
+ url = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.17.14.zip";
+ hash = "sha256-SBJE0IEgl7Efuu73n3HZQrFxYX+cn5UU5jrL4T5xzNw=";
+ };
in
-rustPlatform.buildRustPackage rec {
+rustPlatform.buildRustPackage (finalAttrs: {
pname = "syncstorage-rs";
- version = "0.21.1-unstable-2026-01-26";
+ version = "0.21.1-unstable-2026-02-24";
src = fetchFromGitHub {
owner = "mozilla-services";
repo = "syncstorage-rs";
- rev = "11659d98f9c69948a0aab353437ce2036c388711";
- hash = "sha256-G37QvxTNh/C3gmKG0UYHI6QBr0F+KLGRNI/Sx33uOsc=";
+ rev = "50a739b58dc9ec81995f86e71d992aa14ccc450e";
+ hash = "sha256-idq0RGdwoV6GVuq36IVVVCFbyMTe8i/EpVWE59D/dhM=";
};
nativeBuildInputs = [
@@ -39,16 +49,35 @@ rustPlatform.buildRustPackage rec {
python3
];
- buildInputs = [
- libmysqlclient
- ];
+ buildInputs =
+ lib.optional (dbBackend == "mysql") libmysqlclient
+ ++ lib.optionals (dbBackend == "postgresql") [
+ libpq
+ openssl
+ ];
+
+ buildNoDefaultFeatures = true;
+ # The syncserver "postgres" feature only enables syncstorage-db/postgres.
+ # tokenserver-db/postgres must be enabled separately so the tokenserver
+ # can also connect to PostgreSQL (it dispatches on the URL scheme at runtime).
+ buildFeatures =
+ let
+ cargoFeature = if dbBackend == "postgresql" then "postgres" else dbBackend;
+ in
+ [
+ cargoFeature
+ "tokenserver-db/${cargoFeature}"
+ "py_verifier"
+ ];
+
+ SWAGGER_UI_DOWNLOAD_URL = "file://${swaggerUi}";
preFixup = ''
wrapProgram $out/bin/syncserver \
--prefix PATH : ${lib.makeBinPath [ pyFxADeps ]}
'';
- cargoHash = "sha256-9Dcf5mDyK/XjsKTlCPXTHoBkIq+FFPDg1zfK24Y9nHQ=";
+ cargoHash = "sha256-80EztkSX+SnmqsRWIXbChUB8AeV1Tp9WXoWNbDY8rUE=";
# almost all tests need a DB to test against
doCheck = false;
@@ -60,10 +89,10 @@ rustPlatform.buildRustPackage rec {
meta = {
description = "Mozilla Sync Storage built with Rust";
homepage = "https://github.com/mozilla-services/syncstorage-rs";
- changelog = "https://github.com/mozilla-services/syncstorage-rs/releases/tag/${version}";
+ changelog = "https://github.com/mozilla-services/syncstorage-rs/releases/tag/${finalAttrs.version}";
license = lib.licenses.mpl20;
maintainers = [ ];
platforms = lib.platforms.linux;
mainProgram = "syncserver";
};
-}
+})
--
2.53.0

View File

@@ -0,0 +1,443 @@
From f0582558f0a8b0ef543b3251c4a07afab89fde63 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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
</EncodingOptions>
'';
encodingXmlFile = pkgs.writeText "encoding.xml" encodingXmlText;
+ stringListToXml =
+ tag: items:
+ if items == [ ] then
+ "<${tag} />"
+ else
+ "<${tag}>\n ${
+ concatMapStringsSep "\n " (item: "<string>${escapeXML item}</string>") items
+ }\n </${tag}>";
+ networkXmlText = ''
+ <?xml version="1.0" encoding="utf-8"?>
+ <NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
+ <BaseUrl>${escapeXML cfg.network.baseUrl}</BaseUrl>
+ <EnableHttps>${boolToString cfg.network.enableHttps}</EnableHttps>
+ <RequireHttps>${boolToString cfg.network.requireHttps}</RequireHttps>
+ <InternalHttpPort>${toString cfg.network.internalHttpPort}</InternalHttpPort>
+ <InternalHttpsPort>${toString cfg.network.internalHttpsPort}</InternalHttpsPort>
+ <PublicHttpPort>${toString cfg.network.publicHttpPort}</PublicHttpPort>
+ <PublicHttpsPort>${toString cfg.network.publicHttpsPort}</PublicHttpsPort>
+ <AutoDiscovery>${boolToString cfg.network.autoDiscovery}</AutoDiscovery>
+ <EnableUPnP>${boolToString cfg.network.enableUPnP}</EnableUPnP>
+ <EnableIPv4>${boolToString cfg.network.enableIPv4}</EnableIPv4>
+ <EnableIPv6>${boolToString cfg.network.enableIPv6}</EnableIPv6>
+ <EnableRemoteAccess>${boolToString cfg.network.enableRemoteAccess}</EnableRemoteAccess>
+ ${stringListToXml "LocalNetworkSubnets" cfg.network.localNetworkSubnets}
+ ${stringListToXml "LocalNetworkAddresses" cfg.network.localNetworkAddresses}
+ ${stringListToXml "KnownProxies" cfg.network.knownProxies}
+ <IgnoreVirtualInterfaces>${boolToString cfg.network.ignoreVirtualInterfaces}</IgnoreVirtualInterfaces>
+ ${stringListToXml "VirtualInterfaceNames" cfg.network.virtualInterfaceNames}
+ <EnablePublishedServerUriByRequest>${boolToString cfg.network.enablePublishedServerUriByRequest}</EnablePublishedServerUriByRequest>
+ ${stringListToXml "PublishedServerUriBySubnet" cfg.network.publishedServerUriBySubnet}
+ ${stringListToXml "RemoteIPFilter" cfg.network.remoteIPFilter}
+ <IsRemoteIPFilterBlacklist>${boolToString cfg.network.isRemoteIPFilterBlacklist}</IsRemoteIPFilterBlacklist>
+ </NetworkConfiguration>
+ '';
+ 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 <source> <destination> <force> <description>
+ #
+ # Installs a NixOS-declared XML config at <destination>, preserving
+ # any existing file as a timestamped backup when <force> is true.
+ # With <force>=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 '<string>192.168.1.0/24</string>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<string>10.0.0.0/8</string>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<string>127.0.0.1</string>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<string>203.0.113.5</string>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<IsRemoteIPFilterBlacklist>true</IsRemoteIPFilterBlacklist>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<EnableIPv6>false</EnableIPv6>' /var/lib/jellyfin/config/network.xml")
+ machineWithNetworkConfig.succeed("grep -F '<EnableUPnP>false</EnableUPnP>' /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 '<!-- NETMARKER -->' > /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