diff --git a/configuration.nix b/configuration.nix index dc099b5..4dd49c9 100644 --- a/configuration.nix +++ b/configuration.nix @@ -28,6 +28,7 @@ ./services/wg.nix ./services/qbittorrent.nix + ./services/jellyfin-qbittorrent-monitor.nix ./services/bitmagnet.nix ./services/soulseek.nix diff --git a/services/jellyfin-qbittorrent-monitor.nix b/services/jellyfin-qbittorrent-monitor.nix new file mode 100644 index 0000000..a660b11 --- /dev/null +++ b/services/jellyfin-qbittorrent-monitor.nix @@ -0,0 +1,48 @@ +{ + pkgs, + service_configs, + config, + ... +}: +{ + systemd.services."jellyfin-qbittorrent-monitor" = { + description = "Monitor Jellyfin streaming and control qBittorrent rate limits"; + after = [ + "network.target" + "jellyfin.service" + "qbittorrent.service" + ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" '' + export JELLYFIN_API_KEY=$(cat ${config.age.secrets.jellyfin-api-key.path}) + exec ${ + pkgs.python3.withPackages (ps: with ps; [ requests ]) + }/bin/python ${./jellyfin-qbittorrent-monitor.py} + ''; + Restart = "always"; + RestartSec = "10s"; + + # Security hardening + DynamicUser = true; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + }; + + environment = { + JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}"; + QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}"; + CHECK_INTERVAL = "30"; + }; + }; +} \ No newline at end of file diff --git a/services/wg.nix b/services/wg.nix index 9ac529d..2688da0 100644 --- a/services/wg.nix +++ b/services/wg.nix @@ -15,44 +15,4 @@ ]; }; - systemd.services."jellyfin-qbittorrent-monitor" = { - description = "Monitor Jellyfin streaming and control qBittorrent rate limits"; - after = [ - "network.target" - "jellyfin.service" - "qbittorrent.service" - ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "simple"; - ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" '' - export JELLYFIN_API_KEY=$(cat ${config.age.secrets.jellyfin-api-key.path}) - exec ${ - pkgs.python3.withPackages (ps: with ps; [ requests ]) - }/bin/python ${./jellyfin-qbittorrent-monitor.py} - ''; - Restart = "always"; - RestartSec = "10s"; - - # Security hardening - DynamicUser = true; - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - MemoryDenyWriteExecute = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - RemoveIPC = true; - }; - - environment = { - JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}"; - QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}"; - CHECK_INTERVAL = "30"; - }; - }; } diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix new file mode 100644 index 0000000..3def72a --- /dev/null +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -0,0 +1,201 @@ +{ + config, + lib, + pkgs, + ... +}: +pkgs.testers.runNixOSTest { + name = "jellyfin-qbittorrent-monitor"; + + nodes = { + server = + { pkgs, config, ... }: + { + # Mock qBittorrent service + systemd.services.mock-qbittorrent = { + description = "Mock qBittorrent API server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = lib.getExe ( + pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } '' + import http.server + import socketserver + + + class MockQBittorrentHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/api/v2/transfer/speedLimitsMode': + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + response = '1' if getattr(self.server, 'speed_limits_mode', False) else '0' + self.wfile.write(response.encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == '/api/v2/transfer/toggleSpeedLimitsMode': + self.server.speed_limits_mode = not getattr(self.server, 'speed_limits_mode', False) + self.send_response(200) + self.end_headers() + print(f'MONITOR_TEST: Speed limits toggled to {self.server.speed_limits_mode}') + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + print(f'qBittorrent Mock: {format % args}') + + + with socketserver.TCPServer(('127.0.0.1', 8080), MockQBittorrentHandler) as httpd: + httpd.speed_limits_mode = False + print('Mock qBittorrent server started on port 8080') + httpd.serve_forever() + '' + ); + Restart = "always"; + RestartSec = "5s"; + }; + }; + + # Mock Jellyfin service with controllable streaming state + systemd.services.mock-jellyfin = { + description = "Mock Jellyfin API server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = lib.getExe ( + pkgs.writers.writePython3Bin "mock-jellyfin" { } '' + import http.server + import socketserver + import os + import json + + + class MockJellyfinHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/Sessions': + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + + # Check if we should simulate streaming + streaming_file = '/tmp/jellyfin_streaming' + if os.path.exists(streaming_file): + # Simulate external streaming session + sessions = [{ + 'Id': 'test-session-1', + 'UserName': 'ExternalUser', + 'RemoteEndPoint': '203.0.113.42', # External IP + 'NowPlayingItem': { + 'Name': 'Test Movie', + 'Type': 'Movie' + }, + 'PlayState': { + 'IsPaused': False + } + }] + else: + # No streaming sessions + sessions = [] + + self.wfile.write(json.dumps(sessions).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + print(f'Jellyfin Mock: {format % args}') + + + with socketserver.TCPServer(('127.0.0.1', 8096), MockJellyfinHandler) as httpd: + print('Mock Jellyfin server started on port 8096') + httpd.serve_forever() + '' + ); + Restart = "always"; + RestartSec = "5s"; + }; + }; + + environment.systemPackages = with pkgs; [ + curl + python3 + ]; + + networking.firewall.allowedTCPPorts = [ + 8096 + 8080 + ]; + }; + }; + + testScript = '' + start_all() + + # Wait for services to start + server.wait_for_unit("multi-user.target") + server.wait_for_unit("mock-jellyfin.service") + server.wait_for_unit("mock-qbittorrent.service") + + # Wait for services to be accessible + server.wait_for_open_port(8096) # Mock Jellyfin + server.wait_for_open_port(8080) # Mock qBittorrent + + import time + time.sleep(5) + + # TEST 1: Verify initial state - no streaming, normal speed limits + qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + assert qbt_result.strip() == "0", "qBittorrent should start with normal speed limits" + + # Verify no streaming sessions initially + sessions_result = server.succeed("curl -s http://localhost:8096/Sessions") + print(f"Initial Jellyfin sessions: {sessions_result}") + assert "[]" in sessions_result, "Should be no streaming sessions initially" + + # Start the monitor + python_path = "${pkgs.python3.withPackages (ps: with ps; [ requests ])}/bin/python" + monitor_path = "${../services/jellyfin-qbittorrent-monitor.py}" + server.succeed(f""" + systemd-run --unit=jellyfin-qbittorrent-monitor-test \\ + --setenv=JELLYFIN_URL=http://localhost:8096 \\ + --setenv=QBITTORRENT_URL=http://localhost:8080 \\ + --setenv=CHECK_INTERVAL=2 \\ + {python_path} {monitor_path} + """) + + # Wait for monitor to start + time.sleep(3) + + # TEST 2: Verify monitor runs and keeps normal limits with no streaming + qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + assert qbt_result.strip() == "0", "Should maintain normal speed limits with no streaming" + + # TEST 3: Simulate external streaming and verify throttling + print("\\nSimulating external streaming session...") + server.succeed("touch /tmp/jellyfin_streaming") # Signal mock to return streaming session + + # Wait for monitor to detect streaming and apply throttling (includes hysteresis delay) + time.sleep(15) + + qbt_result_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + + # Check if throttling was enabled + assert qbt_result_streaming.strip() == "1", "External streaming should enable qBittorrent throttling" + + # TEST 4: Stop streaming and verify throttling is removed + print("\\nStopping streaming session...") + server.succeed("rm -f /tmp/jellyfin_streaming") # Signal mock to return no sessions + + # Wait for monitor to detect no streaming and remove throttling (includes hysteresis delay) + time.sleep(65) + + qbt_result_no_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + assert qbt_result_no_streaming.strip() == "0", "No streaming should disable qBittorrent throttling" + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index e2b6e5e..d1be6a6 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -11,4 +11,5 @@ in zfsTest = handleTest ./zfs.nix; testTest = handleTest ./testTest.nix; minecraftTest = handleTest ./minecraft.nix; + jellyfinQbittorrentMonitorTest = handleTest ./jellyfin-qbittorrent-monitor.nix; }