{ config, lib, pkgs, ... }: let testServiceConfigs = { zpool_ssds = ""; https = { domain = "test.local"; }; ports = { jellyfin = 8096; }; jellyfin = { dataDir = "/var/lib/jellyfin"; cacheDir = "/var/cache/jellyfin"; }; media_group = "media"; }; testLib = lib.extend ( final: prev: { serviceMountWithZpool = serviceName: zpool: dirs: { ... }: { }; optimizePackage = pkg: pkg; # No-op for testing } ); jellyfinModule = { config, pkgs, ... }: { imports = [ (import ../services/jellyfin.nix { inherit config pkgs; lib = testLib; service_configs = testServiceConfigs; }) ]; }; in pkgs.testers.runNixOSTest { name = "fail2ban-jellyfin"; nodes = { server = { config, lib, pkgs, ... }: { imports = [ ../modules/security.nix jellyfinModule ]; # Create the media group users.groups.media = { }; # Disable ZFS mount dependency systemd.services."jellyfin-mounts".enable = lib.mkForce false; systemd.services.jellyfin = { wants = lib.mkForce [ ]; after = lib.mkForce [ ]; requires = lib.mkForce [ ]; }; # Override for faster testing and correct port services.fail2ban.jails.jellyfin.settings = { maxretry = lib.mkForce 3; # In test, we connect directly to Jellyfin port, not via Caddy port = lib.mkForce "8096"; }; # Create log directory and placeholder log file for fail2ban # Jellyfin logs to files, not systemd journal systemd.tmpfiles.rules = [ "d /var/lib/jellyfin/log 0755 jellyfin jellyfin" "f /var/lib/jellyfin/log/log_placeholder.log 0644 jellyfin jellyfin" ]; # Make fail2ban start after Jellyfin systemd.services.fail2ban = { wants = [ "jellyfin.service" ]; after = [ "jellyfin.service" ]; }; # Give jellyfin more disk space and memory virtualisation.diskSize = 3 * 1024; virtualisation.memorySize = 2 * 1024; }; client = { environment.systemPackages = [ pkgs.curl ]; }; }; testScript = '' import time import re start_all() server.wait_for_unit("jellyfin.service") server.wait_for_unit("fail2ban.service") server.wait_for_open_port(8096) server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60) time.sleep(2) # Wait for Jellyfin to create real log files and reload fail2ban server.wait_until_succeeds("ls /var/lib/jellyfin/log/log_2*.log", timeout=30) server.succeed("fail2ban-client reload jellyfin") with subtest("Verify jellyfin jail is active"): status = server.succeed("fail2ban-client status") assert "jellyfin" in status, f"jellyfin jail not found in: {status}" with subtest("Generate failed login attempts"): # Use -4 to force IPv4 for consistent IP tracking for i in range(4): client.execute(""" curl -4 -s -X POST http://server:8096/Users/authenticatebyname \ -H 'Content-Type: application/json' \ -H 'X-Emby-Authorization: MediaBrowser Client="test", Device="test", DeviceId="test", Version="1.0"' \ -d '{"Username":"baduser","Pw":"badpass"}' || true """) time.sleep(0.5) with subtest("Verify IP is banned"): time.sleep(3) status = server.succeed("fail2ban-client status jellyfin") print(f"jellyfin jail status: {status}") # Check that at least 1 IP is banned match = re.search(r"Currently banned:\s*(\d+)", status) assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}" with subtest("Verify banned client cannot connect"): # Use -4 to test with same IP that was banned exit_code = client.execute("curl -4 -s --max-time 3 http://server:8096/ 2>&1")[0] assert exit_code != 0, "Connection should be blocked" ''; }