diff --git a/secrets/jellyfin-api-key b/secrets/jellyfin-api-key new file mode 100644 index 0000000..a91e5c3 Binary files /dev/null and b/secrets/jellyfin-api-key differ diff --git a/services/jellyfin-qbittorrent-monitor.py b/services/jellyfin-qbittorrent-monitor.py new file mode 100644 index 0000000..f363c28 --- /dev/null +++ b/services/jellyfin-qbittorrent-monitor.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 + +import requests +import time +import logging +from datetime import datetime +import sys +import signal +import json + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +class JellyfinQBittorrentMonitor: + def __init__( + self, + jellyfin_url="http://localhost:8096", + qbittorrent_url="http://localhost:8080", + check_interval=30, + jellyfin_api_key=None, + ): + self.jellyfin_url = jellyfin_url + self.qbittorrent_url = qbittorrent_url + self.check_interval = check_interval + self.jellyfin_api_key = jellyfin_api_key + self.last_streaming_state = None + self.throttle_active = False + self.running = True + self.session = requests.Session() # Use session for cookies + + # Hysteresis settings to prevent rapid switching + self.streaming_start_delay = 10 # seconds to wait before throttling + self.streaming_stop_delay = 60 # seconds to wait before removing throttle + self.last_state_change = 0 + + # Try to authenticate with qBittorrent + self.authenticate_qbittorrent() + + def signal_handler(self, signum, frame): + logger.info("Received shutdown signal, cleaning up...") + self.running = False + self.restore_normal_limits() + sys.exit(0) + + def authenticate_qbittorrent(self): + """Try to authenticate with qBittorrent using empty credentials (for whitelist)""" + logger.info("Attempting to authenticate with qBittorrent...") + + try: + # First, try to access a simple endpoint to see if auth is needed + test_response = self.session.get( + f"{self.qbittorrent_url}/api/v2/app/version", timeout=5 + ) + logger.info( + f"Version endpoint test: HTTP {test_response.status_code}, Response: {test_response.text}" + ) + + if test_response.status_code == 200: + logger.info( + "qBittorrent accessible without explicit login - subnet whitelist working" + ) + return True + + except Exception as e: + logger.info(f"Version endpoint failed: {e}") + + try: + # Try login with empty credentials (should work with subnet whitelist) + login_data = {"username": "", "password": ""} + headers = { + "Referer": self.qbittorrent_url, + "Content-Type": "application/x-www-form-urlencoded", + } + + logger.info(f"Attempting login to {self.qbittorrent_url}/login") + response = self.session.post( + f"{self.qbittorrent_url}/login", + data=login_data, + headers=headers, + timeout=10, + ) + + logger.info( + f"Login response: HTTP {response.status_code}, Response: '{response.text}'" + ) + + if response.status_code == 200: + if "Ok." in response.text or response.text.strip() == "Ok.": + logger.info("Successfully authenticated with qBittorrent") + return True + elif "Fails." in response.text: + logger.warning( + "qBittorrent login failed - authentication may be required" + ) + else: + logger.info(f"Unexpected login response: '{response.text}'") + else: + logger.warning(f"Login request failed with HTTP {response.status_code}") + + except Exception as e: + logger.error(f"Could not authenticate with qBittorrent: {e}") + + return False + + def check_jellyfin_sessions(self): + """Check if anyone is actively streaming from Jellyfin""" + try: + headers = {} + if self.jellyfin_api_key: + headers["X-Emby-Token"] = self.jellyfin_api_key + + response = requests.get( + f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10 + ) + response.raise_for_status() + sessions = response.json() + + # Count active streaming sessions + active_streams = [] + for session in sessions: + if ( + "NowPlayingItem" in session + and session.get("PlayState", {}).get("IsPaused", True) == False + ): + item = session["NowPlayingItem"] + user = session.get("UserName", "Unknown") + active_streams.append(f"{user}: {item.get('Name', 'Unknown')}") + + return active_streams + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to check Jellyfin sessions: {e}") + return [] + except json.JSONDecodeError as e: + logger.error(f"Failed to parse Jellyfin response: {e}") + return [] + + def check_qbittorrent_alternate_limits(self): + """Check if alternate speed limits are currently enabled""" + # For qBittorrent v5.1.0, use API v2 with GET requests + try: + # Try the transfer info endpoint first (more reliable) + response = self.session.get( + f"{self.qbittorrent_url}/api/v2/transfer/info", timeout=10 + ) + logger.info(f"Transfer info endpoint: HTTP {response.status_code}") + + if response.status_code == 200: + data = response.json() + logger.info(f"Transfer info keys: {list(data.keys())}") + + # Check for alternative speed limit status in the response + if "use_alt_speed_limits" in data: + is_enabled = data["use_alt_speed_limits"] + logger.info(f"Alternative speed limits enabled: {is_enabled}") + return is_enabled + + response.raise_for_status() + + except requests.exceptions.RequestException as e: + logger.error(f"Transfer info endpoint failed: {e}") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse transfer info JSON: {e}") + + # Fallback: try app preferences endpoint + try: + response = self.session.get( + f"{self.qbittorrent_url}/api/v2/app/preferences", timeout=10 + ) + logger.info(f"Preferences endpoint: HTTP {response.status_code}") + + if response.status_code == 200: + data = response.json() + # Look for alternative speed settings + if "alt_up_limit" in data or "scheduler_enabled" in data: + # Check if alternative speeds are currently active + # This is a bit indirect but should work + logger.info( + "Found preferences data, assuming alt speeds not active by default" + ) + return False + + except Exception as e: + logger.error(f"Preferences endpoint failed: {e}") + + logger.error( + "Failed to check qBittorrent alternate limits status: all endpoints failed" + ) + return False + + def toggle_qbittorrent_limits(self, enable_throttle): + """Toggle qBittorrent alternate speed limits""" + try: + # Check current state + current_throttle = self.check_qbittorrent_alternate_limits() + + if enable_throttle and not current_throttle: + try: + # Use API v2 POST endpoint to toggle alternative speed limits + response = self.session.post( + f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode", + timeout=10, + ) + logger.info( + f"Toggle enable response: HTTP {response.status_code}, {response.text[:100]}" + ) + response.raise_for_status() + self.throttle_active = True + logger.info("✓ Enabled alternate speed limits (throttling)") + return + except requests.exceptions.RequestException as e: + logger.error(f"Failed to enable alternate speed limits: {e}") + + elif not enable_throttle and current_throttle: + try: + # Use API v2 POST endpoint to toggle alternative speed limits + response = self.session.post( + f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode", + timeout=10, + ) + logger.info( + f"Toggle disable response: HTTP {response.status_code}, {response.text[:100]}" + ) + response.raise_for_status() + self.throttle_active = False + logger.info("✓ Disabled alternate speed limits (normal)") + return + except requests.exceptions.RequestException as e: + logger.error(f"Failed to disable alternate speed limits: {e}") + + except Exception as e: + logger.error(f"Failed to toggle qBittorrent limits: {e}") + + def restore_normal_limits(self): + """Ensure normal speed limits are restored on shutdown""" + if self.throttle_active: + logger.info("Restoring normal speed limits before shutdown...") + self.toggle_qbittorrent_limits(False) + + def should_change_state(self, new_streaming_state): + """Apply hysteresis to prevent rapid state changes""" + now = time.time() + + # If state hasn't changed, no action needed + if new_streaming_state == self.last_streaming_state: + return False + + # Calculate time since last state change + time_since_change = now - self.last_state_change + + # If we want to start throttling (streaming started) + if new_streaming_state and not self.last_streaming_state: + if time_since_change >= self.streaming_start_delay: + self.last_state_change = now + return True + + # If we want to stop throttling (streaming stopped) + elif not new_streaming_state and self.last_streaming_state: + if time_since_change >= self.streaming_stop_delay: + self.last_state_change = now + return True + + return False + + def run(self): + """Main monitoring loop""" + logger.info("Starting Jellyfin-qBittorrent monitor") + logger.info(f"Jellyfin URL: {self.jellyfin_url}") + logger.info(f"qBittorrent URL: {self.qbittorrent_url}") + logger.info(f"Check interval: {self.check_interval}s") + + # Set up signal handlers + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + while self.running: + try: + # Check for active streaming + active_streams = self.check_jellyfin_sessions() + streaming_active = len(active_streams) > 0 + + # Log current status + if streaming_active: + logger.info( + f"Active streams ({len(active_streams)}): {', '.join(active_streams)}" + ) + else: + logger.debug("No active streaming sessions") + + # Apply hysteresis and change state if needed + if self.should_change_state(streaming_active): + self.last_streaming_state = streaming_active + self.toggle_qbittorrent_limits(streaming_active) + + time.sleep(self.check_interval) + + except KeyboardInterrupt: + break + except Exception as e: + logger.error(f"Unexpected error in monitoring loop: {e}") + time.sleep(self.check_interval) + + self.restore_normal_limits() + logger.info("Monitor stopped") + + +if __name__ == "__main__": + import os + + # Configuration from environment variables + jellyfin_url = os.getenv("JELLYFIN_URL", "http://localhost:8096") + qbittorrent_url = os.getenv("QBITTORRENT_URL", "http://localhost:8080") + check_interval = int(os.getenv("CHECK_INTERVAL", "30")) + jellyfin_api_key = os.getenv("JELLYFIN_API_KEY") + + monitor = JellyfinQBittorrentMonitor( + jellyfin_url=jellyfin_url, + qbittorrent_url=qbittorrent_url, + check_interval=check_interval, + jellyfin_api_key=jellyfin_api_key, + ) + + monitor.run() diff --git a/services/qbittorrent.nix b/services/qbittorrent.nix index 42a7c7e..f6a0589 100644 --- a/services/qbittorrent.nix +++ b/services/qbittorrent.nix @@ -56,6 +56,10 @@ GlobalUPSpeedLimit = 1000; GlobalDLSpeedLimit = 1000; + + # Alternate speed limits for when Jellyfin is streaming + AlternativeGlobalUPSpeedLimit = 500; # 500 KB/s when throttled + AlternativeGlobalDLSpeedLimit = 800; # 800 KB/s when throttled IncludeOverheadInLimits = true; GlobalMaxRatio = 6.0; diff --git a/services/wg.nix b/services/wg.nix index 3f22593..540ec58 100644 --- a/services/wg.nix +++ b/services/wg.nix @@ -19,51 +19,44 @@ nload ]; - networking.firewall.extraCommands = '' - # Exempt local traffic from marking - iptables -t mangle -A POSTROUTING -s ${service_configs.https.wg_ip}/24 -d 192.168.1.0/24 -j RETURN + 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" ]; - # Mark all other traffic from the VPN namespace - iptables -t mangle -A POSTROUTING -s ${service_configs.https.wg_ip}/24 -j MARK --set-mark 1 - ''; + serviceConfig = { + Type = "simple"; + ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" '' + export JELLYFIN_API_KEY=$(cat ${../secrets/jellyfin-api-key}) + exec ${ + pkgs.python3.withPackages (ps: with ps; [ requests ]) + }/bin/python ${./jellyfin-qbittorrent-monitor.py} + ''; + Restart = "always"; + RestartSec = "10s"; - systemd.services."traffic-shaping" = - let - upload_pipe = 44; - high_prio = 40; - low_prio = 4; - in - { - description = "Apply QoS to prioritize non-VPN traffic"; - after = [ - "network.target" - "vpn-wg.service" - ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - ExecStart = pkgs.writeShellScript "tc-setup" '' - # Add HTB qdisc to physical interface - ${pkgs.iproute2}/bin/tc qdisc add dev ${eth_interface} root handle 1: htb default 10 - - # Define classes: - # - Class 1:10 (high priority, unmarked) - # - Class 1:20 (low priority, marked VPN traffic) - ${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1: classid 1:1 htb rate ${builtins.toString upload_pipe}mbit ceil ${builtins.toString upload_pipe}mbit - ${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1:1 classid 1:10 htb rate ${builtins.toString high_prio}mbit ceil ${builtins.toString upload_pipe}mbit prio 1 - ${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1:1 classid 1:20 htb rate ${builtins.toString low_prio}mbit ceil ${builtins.toString upload_pipe}mbit prio 2 - - # Direct marked packets to low-priority class - ${pkgs.iproute2}/bin/tc filter add dev ${eth_interface} parent 1: protocol ip prio 1 handle 1 fw flowid 1:20 - ''; - - ExecStop = pkgs.writeShellScript "tc-stop" '' - ${pkgs.iproute2}/bin/tc filter del dev ${eth_interface} parent 1: - ${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:20 - ${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:10 - ${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:1 - ${pkgs.iproute2}/bin/tc qdisc del dev ${eth_interface} root - ''; - }; + # 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"; + }; + }; }