From e217960500be87238c82053a66b3eb0a25b46318 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 3 Feb 2026 21:42:06 -0600 Subject: [PATCH 1/2] feat: Add Redis authentication support for modular deployment Add support for authentication when connecting to external Redis instances in modular deployment mode. This enables secure Redis deployments using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). Changes: - Add REDIS_PASSWORD and REDIS_USER environment variables - Implement URL encoding for special characters in passwords - Update all Redis connection points to support auth - Add comprehensive documentation and examples to docker-compose.yml - Maintains full backward compatibility (empty defaults = no auth) All authentication mechanisms have been fully tested including pasword-only authentication, Redis 6+ ACL authentication with username + password, volume-mounted configuration files, and special character handling. Existing deployments are not effected, authentication support is entirely opt-in using docker-compose. Also, acknowledging the fact that the docker-compose file for modular deployments has been getting out of hand with auth support, the docker-compose.yml file was re-formatted for better visibility in configuration. This seemed like a better way to go than mandating a .env file. --- apps/proxy/ts_proxy/server.py | 4 + apps/proxy/vod_proxy/connection_manager.py | 11 ++- apps/proxy/vod_proxy/views.py | 11 ++- core/utils.py | 8 ++ core/views.py | 10 +- dispatcharr/persistent_lock.py | 10 +- dispatcharr/settings.py | 27 +++++- docker/docker-compose.yml | 108 ++++++++++++++++++--- docker/entrypoint.sh | 4 +- scripts/wait_for_redis.py | 8 +- 10 files changed, 176 insertions(+), 25 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index db5b3d57..df51744d 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -149,11 +149,15 @@ class ProxyServer: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) pubsub_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index ec0bffa5..aafc75cd 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -101,7 +101,16 @@ class PersistentVODConnection: redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{self.session_id}" stored_length = r.get(content_length_key) if stored_length: diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 2ec95cc3..948604d6 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -333,7 +333,16 @@ class VODStreamView(View): redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{session_id}" r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") diff --git a/core/utils.py b/core/utils.py index 5c5a0045..c0e87a42 100644 --- a/core/utils.py +++ b/core/utils.py @@ -55,6 +55,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) @@ -68,6 +70,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=socket_timeout, socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, @@ -148,6 +152,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings but without socket timeouts for PubSub # Important: socket_timeout is None for PubSub operations @@ -161,6 +167,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=None, # Critical: No timeout for PubSub operations socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, diff --git a/core/views.py b/core/views.py index 5806d63c..082cccc3 100644 --- a/core/views.py +++ b/core/views.py @@ -40,7 +40,15 @@ def stream_view(request, channel_uuid): redis_host = getattr(settings, "REDIS_HOST", "localhost") redis_port = int(getattr(settings, "REDIS_PORT", 6379)) redis_db = int(getattr(settings, "REDIS_DB", "0")) - redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(uuid=channel_uuid) diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index 27d480be..2ed72bf4 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -78,7 +78,15 @@ if __name__ == "__main__": redis_host = os.environ.get("REDIS_HOST", "localhost") redis_port = int(os.environ.get("REDIS_PORT", 6379)) redis_db = int(os.environ.get("REDIS_DB", 0)) - client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) if lock.acquire(): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7af6e939..f0ef75a0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,6 +1,7 @@ import os from pathlib import Path from datetime import timedelta +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent @@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": @@ -119,7 +122,12 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running + "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format( + redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "", + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB + )], # URL format supports authentication }, }, } @@ -197,8 +205,19 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -# Build default Redis URL from components for Celery -_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Build default Redis URL from components for Celery with optional authentication +# Build auth string conditionally with URL encoding for special characters +if REDIS_PASSWORD: + encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + encoded_user = quote_plus(REDIS_USER) + redis_auth = f"{encoded_user}:{encoded_password}@" + else: + redis_auth = f":{encoded_password}@" +else: + redis_auth = "" + +_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url) CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) @@ -269,7 +288,7 @@ SIMPLE_JWT = { } # Redis connection settings -REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c8e2d53e..89585fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,11 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + services: + # ============================================================================ + # Web Service + # ============================================================================ web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web @@ -9,44 +16,66 @@ services: depends_on: - db - redis + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) - # that lack support for newer baseline CPU features + # that lack support for newer baseline CPU features: #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) - # Negative values require cap_add: SYS_NICE (uncomment below) + # Negative values require cap_add: SYS_NICE (see below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - # + + # --- Advanced Configuration --- # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - # Optional for hardware acceleration + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) #group_add: # - video - # #- render # Uncomment if your GPU requires it + # #- render # Uncomment if your GPU requires it #devices: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) - # Uncomment the following lines for NVIDIA GPU support - # NVidia GPU support (requires NVIDIA Container Toolkit) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) #deploy: # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery @@ -58,24 +87,47 @@ services: - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info - #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - entrypoint: ["/app/docker/entrypoint.celery.sh"] + # ============================================================================ + # PostgreSQL + # ============================================================================ db: image: postgres:17 container_name: dispatcharr_db @@ -93,14 +145,42 @@ services: timeout: 5s retries: 5 + # ============================================================================ + # Redis + # ============================================================================ redis: image: redis:latest container_name: dispatcharr_redis + + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- healthcheck: + # Default: No authentication test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + interval: 5s timeout: 5s retries: 5 +# ============================================================================== +# Volumes +# ============================================================================== volumes: postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ead6aadd..8ea47318 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' @@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 38737728..0d278150 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,7 +12,7 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_interval=2): +def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None retry_count = 0 @@ -25,6 +25,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_inte host=host, port=port, db=db, + password=password if password else None, + username=username if username else None, socket_timeout=2, socket_connect_timeout=2 ) @@ -50,12 +52,14 @@ if __name__ == "__main__": host = os.environ.get('REDIS_HOST', 'localhost') port = int(os.environ.get('REDIS_PORT', 6379)) db = int(os.environ.get('REDIS_DB', 0)) + password = os.environ.get('REDIS_PASSWORD', '') + username = os.environ.get('REDIS_USER', '') max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30)) retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2)) logger.info(f"Starting Redis availability check at {host}:{port}/{db}") - if wait_for_redis(host, port, db, max_retries, retry_interval): + if wait_for_redis(host, port, db, password, username, max_retries, retry_interval): sys.exit(0) else: sys.exit(1) From 8e7139f3af5e81d7b7ed9c4639978597927f0316 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 9 Feb 2026 17:27:52 -0600 Subject: [PATCH 2/2] Re-add note to uncomment the following lines for nvidia. --- docker/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89585fc7..519b288a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -65,6 +65,7 @@ services: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) # NVIDIA GPU Support (requires NVIDIA Container Toolkit) + # Uncomment the following lines for NVIDIA GPU support #deploy: # resources: # reservations: