From b30a24e2fb384383466a033a4ebb809ac9399a66 Mon Sep 17 00:00:00 2001 From: None <190783071+CodeBormen@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:24:09 -0500 Subject: [PATCH] feat: add TLS connection support for Redis and PostgreSQL (#950) - Add 10 env vars for Redis/PostgreSQL TLS (REDIS_SSL, REDIS_SSL_VERIFY, REDIS_SSL_CA_CERT, REDIS_SSL_CERT, REDIS_SSL_KEY, POSTGRES_SSL, POSTGRES_SSL_MODE, POSTGRES_SSL_CA_CERT, POSTGRES_SSL_CERT, POSTGRES_SSL_KEY) - Propagate SSL params to all Redis connection points: RedisClient, stream view, PubSub, client manager, Celery broker/result backend, Django Channels, and pre-Django startup scripts - Add Celery SSL config via URL query string params (required by Kombu's internal URL parsing) - Add PostgreSQL sslmode and certificate options to DATABASES config - Add startup validation: cert file existence, URL scheme conflict detection, TLS status logging - Add TLS error hints to Redis connection failure messages - Add Connection Security panel in System Settings (modular mode only) showing encryption, verification, and mTLS status - Add PG client key permission fix in web and celery entrypoints for Docker Desktop compatibility (0777 to 0600) - Add TLS env var passthrough in entrypoint for su - login shells - Document TLS env vars in modular docker-compose.yml - Change env_mode from dev/prod to actual DISPATCHARR_ENV value for modular mode detection --- apps/proxy/ts_proxy/client_manager.py | 3 +- apps/proxy/ts_proxy/server.py | 4 +- core/api_views.py | 17 +- core/utils.py | 25 ++- core/views.py | 4 +- dispatcharr/persistent_lock.py | 24 ++- dispatcharr/settings.py | 176 ++++++++++++++++-- docker/docker-compose.yml | 36 +++- docker/entrypoint.celery.sh | 17 ++ docker/entrypoint.sh | 30 +++ .../settings/ConnectionSecurityPanel.jsx | 174 +++++++++++++++++ .../forms/settings/SystemSettingsForm.jsx | 13 +- .../__tests__/SystemSettingsForm.test.jsx | 7 + .../src/store/__tests__/settings.test.jsx | 6 +- frontend/src/store/settings.jsx | 4 +- scripts/wait_for_redis.py | 48 ++++- 16 files changed, 540 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py index ea2aa5b0..d62551c1 100644 --- a/apps/proxy/ts_proxy/client_manager.py +++ b/apps/proxy/ts_proxy/client_manager.py @@ -58,7 +58,8 @@ class ClientManager: from django.conf import settings redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0') - redis_client = redis.Redis.from_url(redis_url, decode_responses=True) + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + redis_client = redis.Redis.from_url(redis_url, decode_responses=True, **ssl_params) all_channels = [] cursor = 0 diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index b72b350a..50729991 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -166,6 +166,7 @@ class ProxyServer: redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) pubsub_client = redis.Redis( host=redis_host, port=redis_port, @@ -175,7 +176,8 @@ class ProxyServer: socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, - health_check_interval=30 + health_check_interval=30, + **ssl_params ) logger.info("Created fallback Redis PubSub client for event listener") diff --git a/core/api_views.py b/core/api_views.py index dd15886c..a81ddf54 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -3,6 +3,7 @@ import json import ipaddress import logging +from django.conf import settings as django_settings from django.db import models from rest_framework import viewsets, status from rest_framework.response import Response @@ -301,7 +302,9 @@ def environment(request): country_code = None country_name = None - # 4) Get environment mode from system environment variable + # 4) Get environment mode and TLS status from settings + postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False) + return Response( { "authenticated": True, @@ -309,7 +312,17 @@ def environment(request): "local_ip": local_ip, "country_code": country_code, "country_name": country_name, - "env_mode": "dev" if os.getenv("DISPATCHARR_ENV") == "dev" else "prod", + "env_mode": os.getenv("DISPATCHARR_ENV", "aio"), + "redis_tls": { + "enabled": getattr(django_settings, "REDIS_SSL", False), + "verify": getattr(django_settings, "REDIS_SSL_VERIFY", True), + "mtls": bool(getattr(django_settings, "REDIS_SSL_CERT", "") and getattr(django_settings, "REDIS_SSL_KEY", "")), + }, + "postgres_tls": { + "enabled": postgres_ssl, + "ssl_mode": getattr(django_settings, "POSTGRES_SSL_MODE", "verify-full") if postgres_ssl else None, + "mtls": bool(getattr(django_settings, "POSTGRES_SSL_CERT", "") and getattr(django_settings, "POSTGRES_SSL_KEY", "")), + }, } ) diff --git a/core/utils.py b/core/utils.py index ef5aa702..20714724 100644 --- a/core/utils.py +++ b/core/utils.py @@ -13,6 +13,8 @@ from django.core.validators import URLValidator from django.core.exceptions import ValidationError import gc +_REDIS_TLS_HINT = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" + logger = logging.getLogger(__name__) # Import the command detector @@ -65,6 +67,9 @@ class RedisClient: socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True) retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True) + # TLS params from settings (empty dict when TLS is disabled) + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + # Create Redis client with better defaults client = redis.Redis( host=redis_host, @@ -76,7 +81,8 @@ class RedisClient: socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, health_check_interval=health_check_interval, - retry_on_timeout=retry_on_timeout + retry_on_timeout=retry_on_timeout, + **ssl_params ) # Validate connection with ping @@ -125,8 +131,9 @@ class RedisClient: except (ConnectionError, TimeoutError) as e: retry_count += 1 + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" if retry_count >= max_retries: - logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}") + logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}") return None else: # Use exponential backoff for retries @@ -135,7 +142,8 @@ class RedisClient: time.sleep(wait_time) except Exception as e: - logger.error(f"Unexpected error connecting to Redis: {e}") + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + logger.error(f"Unexpected error connecting to Redis: {e}{_tls_hint}") return None return cls._client @@ -161,6 +169,8 @@ class RedisClient: health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30) retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True) + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + # Create Redis client with PubSub-optimized settings - no timeout client = redis.Redis( host=redis_host, @@ -172,7 +182,8 @@ class RedisClient: socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, health_check_interval=health_check_interval, - retry_on_timeout=retry_on_timeout + retry_on_timeout=retry_on_timeout, + **ssl_params ) # Validate connection with ping @@ -185,8 +196,9 @@ class RedisClient: except (ConnectionError, TimeoutError) as e: retry_count += 1 + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" if retry_count >= max_retries: - logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}") + logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}{_tls_hint}") return None else: # Use exponential backoff for retries @@ -195,7 +207,8 @@ class RedisClient: time.sleep(wait_time) except Exception as e: - logger.error(f"Unexpected error connecting to Redis for PubSub: {e}") + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + logger.error(f"Unexpected error connecting to Redis for PubSub: {e}{_tls_hint}") return None return cls._pubsub_client diff --git a/core/views.py b/core/views.py index 082cccc3..b3e6dfb2 100644 --- a/core/views.py +++ b/core/views.py @@ -42,12 +42,14 @@ def stream_view(request, channel_uuid): redis_db = int(getattr(settings, "REDIS_DB", "0")) redis_password = getattr(settings, "REDIS_PASSWORD", "") redis_user = getattr(settings, "REDIS_USER", "") + ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {}) 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 + username=redis_user if redis_user else None, + **ssl_params ) # Retrieve the channel by the provided stream_id. diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index 2ed72bf4..64e9e6ba 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -74,18 +74,40 @@ class PersistentLock: # Example usage (for testing purposes only): if __name__ == "__main__": import os + import sys # Connect to Redis using environment variables; adjust connection parameters as needed. 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)) redis_password = os.environ.get("REDIS_PASSWORD", "") redis_user = os.environ.get("REDIS_USER", "") + ssl_kwargs = {} + if os.environ.get("REDIS_SSL", "false").lower() == "true": + import ssl as _ssl + ssl_kwargs["ssl"] = True + ssl_kwargs["ssl_cert_reqs"] = ( + _ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true" + else _ssl.CERT_NONE + ) + for env_var, key in [ + ("REDIS_SSL_CA_CERT", "ssl_ca_certs"), + ("REDIS_SSL_CERT", "ssl_certfile"), + ("REDIS_SSL_KEY", "ssl_keyfile"), + ]: + path = os.environ.get(env_var, "") + if path: + if not os.path.isfile(path): + print(f"Redis TLS: {env_var}={path!r} — file not found.") + sys.exit(1) + ssl_kwargs[key] = path + 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 + username=redis_user if redis_user else None, + **ssl_kwargs ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index b0d387b2..ec61eb4d 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,7 +1,23 @@ import os +import ssl from pathlib import Path from datetime import timedelta from urllib.parse import quote_plus +from django.core.exceptions import ImproperlyConfigured + + +def _validate_tls_cert_paths(paths, service_name): + """Validate that configured TLS certificate file paths exist on disk. + + Raises ImproperlyConfigured with a clear message identifying the + service and missing file so operators can fix their environment. + """ + for env_var, file_path in paths: + if file_path and not Path(file_path).is_file(): + raise ImproperlyConfigured( + f"{service_name} TLS: {env_var}={file_path!r} — file not found. " + f"Check that the certificate file exists and the volume is mounted correctly." + ) BASE_DIR = Path(__file__).resolve().parent.parent @@ -12,6 +28,37 @@ REDIS_DB = os.environ.get("REDIS_DB", "0") REDIS_USER = os.environ.get("REDIS_USER", "") REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") +# Redis TLS configuration +REDIS_SSL = os.environ.get("REDIS_SSL", "false").lower() == "true" +REDIS_SSL_VERIFY = os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true" +REDIS_SSL_CA_CERT = os.environ.get("REDIS_SSL_CA_CERT", "") +REDIS_SSL_CERT = os.environ.get("REDIS_SSL_CERT", "") +REDIS_SSL_KEY = os.environ.get("REDIS_SSL_KEY", "") + +# Reusable dict of SSL kwargs for redis.Redis() constructors +REDIS_SSL_PARAMS = {} +if REDIS_SSL: + _validate_tls_cert_paths([ + ("REDIS_SSL_CA_CERT", REDIS_SSL_CA_CERT), + ("REDIS_SSL_CERT", REDIS_SSL_CERT), + ("REDIS_SSL_KEY", REDIS_SSL_KEY), + ], "Redis") + + REDIS_SSL_PARAMS["ssl"] = True + REDIS_SSL_PARAMS["ssl_cert_reqs"] = ssl.CERT_REQUIRED if REDIS_SSL_VERIFY else ssl.CERT_NONE + if REDIS_SSL_CA_CERT: + REDIS_SSL_PARAMS["ssl_ca_certs"] = REDIS_SSL_CA_CERT + if REDIS_SSL_CERT: + REDIS_SSL_PARAMS["ssl_certfile"] = REDIS_SSL_CERT + if REDIS_SSL_KEY: + REDIS_SSL_PARAMS["ssl_keyfile"] = REDIS_SSL_KEY + + _mtls = "enabled" if REDIS_SSL_CERT and REDIS_SSL_KEY else "disabled" + _verify = "on" if REDIS_SSL_VERIFY else "off" + print(f"Redis TLS: enabled (verify={_verify}, mTLS={_mtls})") +else: + print("Redis TLS: disabled") + # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": DEBUG = True @@ -120,20 +167,46 @@ TEMPLATES = [ WSGI_APPLICATION = "dispatcharr.wsgi.application" ASGI_APPLICATION = "dispatcharr.asgi.application" +_redis_scheme = "rediss" if REDIS_SSL else "redis" + +# URL-encoded auth string shared by CHANNEL_LAYERS and Celery broker URLs +if REDIS_PASSWORD: + _encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + _redis_auth = f"{quote_plus(REDIS_USER)}:{_encoded_password}@" + else: + _redis_auth = f":{_encoded_password}@" +else: + _redis_auth = "" + +_channels_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# channels_redis accepts either a URL string or a dict with "address" + kwargs. +# When TLS is enabled, pass SSL params alongside the URL so the connection pool +# uses the correct CA cert and verification settings. +if REDIS_SSL: + # Filter out "ssl" key — the rediss:// scheme already enables SSL. + # Passing ssl=True as a kwarg to aioredis from_url causes an error. + _channels_ssl = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"} + _channels_host = {"address": _channels_redis_url, **_channels_ssl} +else: + _channels_host = _channels_redis_url + CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "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 + "hosts": [_channels_host], }, }, } +# PostgreSQL TLS configuration (defined before DATABASES for module-level access) +POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true" +POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full") +POSTGRES_SSL_CA_CERT = os.environ.get("POSTGRES_SSL_CA_CERT", "") +POSTGRES_SSL_CERT = os.environ.get("POSTGRES_SSL_CERT", "") +POSTGRES_SSL_KEY = os.environ.get("POSTGRES_SSL_KEY", "") + if os.getenv("DB_ENGINE", None) == "sqlite": DATABASES = { "default": { @@ -154,6 +227,28 @@ else: } } + if POSTGRES_SSL: + _validate_tls_cert_paths([ + ("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT), + ("POSTGRES_SSL_CERT", POSTGRES_SSL_CERT), + ("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY), + ], "PostgreSQL") + + DATABASES["default"]["OPTIONS"] = { + "sslmode": POSTGRES_SSL_MODE, + } + if POSTGRES_SSL_CA_CERT: + DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT + if POSTGRES_SSL_CERT: + DATABASES["default"]["OPTIONS"]["sslcert"] = POSTGRES_SSL_CERT + if POSTGRES_SSL_KEY: + DATABASES["default"]["OPTIONS"]["sslkey"] = POSTGRES_SSL_KEY + + _mtls = "enabled" if POSTGRES_SSL_CERT and POSTGRES_SSL_KEY else "disabled" + print(f"PostgreSQL TLS: enabled (sslmode={POSTGRES_SSL_MODE}, mTLS={_mtls})") + else: + print("PostgreSQL TLS: disabled") + AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -208,22 +303,52 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -# 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}@" +_default_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Celery/Kombu require SSL parameters in the URL query string because +# internal URL parsing can overwrite the CELERY_BROKER_USE_SSL dict. +if REDIS_SSL: + _celery_ssl_params = [ + f"ssl_cert_reqs={'CERT_REQUIRED' if REDIS_SSL_VERIFY else 'CERT_NONE'}", + ] + if REDIS_SSL_CA_CERT: + _celery_ssl_params.append(f"ssl_ca_certs={REDIS_SSL_CA_CERT}") + if REDIS_SSL_CERT: + _celery_ssl_params.append(f"ssl_certfile={REDIS_SSL_CERT}") + if REDIS_SSL_KEY: + _celery_ssl_params.append(f"ssl_keyfile={REDIS_SSL_KEY}") + _default_celery_url = f"{_default_redis_url}?{'&'.join(_celery_ssl_params)}" 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) + _default_celery_url = _default_redis_url +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_celery_url) CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) +# Validate that URL overrides don't conflict with TLS settings +for _url_var, _url_val in [ + ("CELERY_BROKER_URL", CELERY_BROKER_URL), + ("CELERY_RESULT_BACKEND", CELERY_RESULT_BACKEND), +]: + _is_override = os.environ.get(_url_var) is not None + if not _is_override: + continue + _url_is_ssl = _url_val.startswith("rediss://") + if REDIS_SSL and not _url_is_ssl: + raise ImproperlyConfigured( + f"REDIS_SSL is enabled but {_url_var} uses redis:// (plaintext). " + f"Change the URL scheme to rediss:// or remove the {_url_var} override." + ) + if not REDIS_SSL and _url_is_ssl: + raise ImproperlyConfigured( + f"{_url_var} uses rediss:// (TLS) but REDIS_SSL is not enabled. " + f"Set REDIS_SSL=true and configure the TLS certificate settings." + ) + +# Celery TLS configuration — required in addition to the rediss:// URL scheme. +# Uses the same cert params as REDIS_SSL_PARAMS, minus the "ssl" key that +# redis-py needs but Celery/Kombu does not. +if REDIS_SSL: + CELERY_BROKER_USE_SSL = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"} + CELERY_RESULT_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL + # Configure Redis key prefix CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = { "global_keyprefix": "celery-tasks:", # Set the Redis key prefix for Celery @@ -299,8 +424,19 @@ SIMPLE_JWT = { "BLACKLIST_AFTER_ROTATION": True, # Optional: Whether to blacklist refresh tokens } -# Redis connection settings +# Redis connection settings — _default_redis_url uses rediss:// when REDIS_SSL is enabled REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) +if os.environ.get("REDIS_URL") is not None: + if REDIS_SSL and not REDIS_URL.startswith("rediss://"): + raise ImproperlyConfigured( + "REDIS_SSL is enabled but REDIS_URL uses redis:// (plaintext). " + "Change the URL scheme to rediss:// or remove the REDIS_URL override." + ) + if not REDIS_SSL and REDIS_URL.startswith("rediss://"): + raise ImproperlyConfigured( + "REDIS_URL uses rediss:// (TLS) but REDIS_SSL is not enabled. " + "Set REDIS_SSL=true and configure the TLS certificate settings." + ) 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 d674460a..547ac249 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,7 @@ services: - 9191:9191 volumes: - ./data:/data + #- ./certs:/certs:ro # TLS certificates (optional) depends_on: db: condition: service_healthy @@ -33,15 +34,26 @@ services: - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + # PostgreSQL TLS (optional) — mount certs via the volume above + #- POSTGRES_SSL=true # required to enable TLS + #- POSTGRES_SSL_MODE=verify-full # optional: verify-full (default) | verify-ca | require + #- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt # optional: CA cert to verify the server + #- POSTGRES_SSL_CERT=/certs/postgres/client.crt # optional: client cert (only if server requires client auth) + #- POSTGRES_SSL_KEY=/certs/postgres/client.key # optional: client key (only if server requires client auth) # 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 + # Redis TLS (optional) — mount certs via the volume above + #- REDIS_SSL=true # required to enable TLS + #- REDIS_SSL_VERIFY=true # optional: set false for self-signed certs without a CA + #- REDIS_SSL_CA_CERT=/certs/redis/ca.crt # optional: CA cert to verify the server + #- REDIS_SSL_CERT=/certs/redis/client.crt # optional: client cert (only if server requires client auth) + #- REDIS_SSL_KEY=/certs/redis/client.key # optional: client key (only if server requires client auth) # Logging - DISPATCHARR_LOG_LEVEL=info @@ -95,6 +107,7 @@ services: condition: service_started volumes: - ./data:/data + #- ./certs:/certs:ro # TLS certificates (optional) extra_hosts: - "host.docker.internal:host-gateway" entrypoint: ["/app/docker/entrypoint.celery.sh"] @@ -108,21 +121,30 @@ services: # Must match the web service port for DVR recording and internal API calls - DISPATCHARR_PORT=9191 - # PostgreSQL Connection + # PostgreSQL — must match web service settings - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + # PostgreSQL TLS — must match web service + #- POSTGRES_SSL=true + #- POSTGRES_SSL_MODE=verify-full + #- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt + #- POSTGRES_SSL_CERT=/certs/postgres/client.crt + #- POSTGRES_SSL_KEY=/certs/postgres/client.key - # Redis Connection + # Redis — must match web service settings - 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 + #- REDIS_USER=your_redis_username + # Redis TLS — must match web service + #- REDIS_SSL=true + #- REDIS_SSL_VERIFY=true + #- REDIS_SSL_CA_CERT=/certs/redis/ca.crt + #- REDIS_SSL_CERT=/certs/redis/client.crt + #- REDIS_SSL_KEY=/certs/redis/client.key # Logging - DISPATCHARR_LOG_LEVEL=info diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index 7a69f430..d7a43255 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -36,6 +36,23 @@ if [ "$USE_LEGACY_NUMPY" = "true" ]; then fi fi +# Fix TLS client key permissions for PostgreSQL (same issue as web entrypoint). +# libpq requires 0600 but Docker Desktop mounts files as 0777. +if [ -n "${POSTGRES_SSL_KEY:-}" ] && [ -f "$POSTGRES_SSL_KEY" ]; then + _key_perms=$(stat -c '%a' "$POSTGRES_SSL_KEY" 2>/dev/null) + if [ "$_key_perms" != "600" ] && [ "$_key_perms" != "640" ]; then + _fixed_key="/data/.pg-client-celery.key" + cp "$POSTGRES_SSL_KEY" "$_fixed_key" + chmod 600 "$_fixed_key" + # Match ownership to the user running Celery if running as root + if [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ]; then + chown "${PUID}:${PGID:-$PUID}" "$_fixed_key" + fi + export POSTGRES_SSL_KEY="$_fixed_key" + echo "Fixed PostgreSQL client key permissions (${_key_perms} → 600)" + fi +fi + # Wait for migrations to complete # Uses 'migrate --check' which exits 0 only when all migrations are applied, # and exits 1 on unapplied migrations OR connection errors (safe either way) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0de0afd5..7365a300 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -112,6 +112,14 @@ variables=( CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) +# TLS variables are optional — only propagate when set to avoid noisy warnings +for _tls_var in POSTGRES_SSL POSTGRES_SSL_MODE POSTGRES_SSL_CA_CERT POSTGRES_SSL_CERT POSTGRES_SSL_KEY \ + REDIS_SSL REDIS_SSL_VERIFY REDIS_SSL_CA_CERT REDIS_SSL_CERT REDIS_SSL_KEY; do + if [ -n "${!_tls_var+x}" ]; then + variables+=("$_tls_var") + fi +done + # Truncate files before rewriting > /etc/profile.d/dispatcharr.sh @@ -229,6 +237,28 @@ if [ "$USE_LEGACY_NUMPY" = "true" ]; then fi fi +# Fix TLS client key permissions for PostgreSQL. +# libpq requires the client key to be 0600 or stricter. Volume-mounted files +# from Windows/macOS hosts often have 0755 permissions, so copy the key to a +# writable location with correct permissions if needed. +if [ -n "${POSTGRES_SSL_KEY:-}" ] && [ -f "$POSTGRES_SSL_KEY" ]; then + _key_perms=$(stat -c '%a' "$POSTGRES_SSL_KEY" 2>/dev/null) + if [ "$_key_perms" != "600" ] && [ "$_key_perms" != "640" ]; then + _fixed_key="/data/.pg-client.key" + cp "$POSTGRES_SSL_KEY" "$_fixed_key" + chmod 600 "$_fixed_key" + if [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ]; then + chown "${PUID}:${PGID}" "$_fixed_key" + fi + export POSTGRES_SSL_KEY="$_fixed_key" + # Update /etc/environment so login shells see the fixed path + sed -i "/^POSTGRES_SSL_KEY=/d" /etc/environment + echo "POSTGRES_SSL_KEY='$_fixed_key'" >> /etc/environment + sed -i "s|export POSTGRES_SSL_KEY=.*|export POSTGRES_SSL_KEY='$_fixed_key'|" /etc/profile.d/dispatcharr.sh + echo "Fixed PostgreSQL client key permissions (${_key_perms} → 600)" + fi +fi + # Run Django commands as non-root user to prevent permission issues su - "$POSTGRES_USER" -c "cd /app && python manage.py migrate --noinput" su - "$POSTGRES_USER" -c "cd /app && python manage.py collectstatic --noinput" diff --git a/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx new file mode 100644 index 00000000..f8c9614a --- /dev/null +++ b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { + Badge, + Group, + Paper, + SimpleGrid, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import useSettingsStore from '../../../store/settings.jsx'; + +const TlsOption = ({ label, description, tooltip, badgeText, badgeColor }) => ( +