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
This commit is contained in:
None 2026-03-21 17:24:09 -05:00
parent 874f5fce6a
commit b30a24e2fb
16 changed files with 540 additions and 48 deletions

View file

@ -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

View file

@ -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")

View file

@ -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", "")),
},
}
)

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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 }) => (
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{label}
</Text>
<Tooltip label={tooltip}>
<Badge color={badgeColor} variant="light" size="sm">
{badgeText}
</Badge>
</Tooltip>
</Group>
<Text size="xs" c="dimmed">
{description}
</Text>
</div>
);
const TlsServiceCard = ({ serviceName, children }) => (
<Paper p="md" withBorder h="100%">
<Stack gap="sm">
<Text fw={600}>{serviceName}</Text>
{children}
</Stack>
</Paper>
);
const RedisStatus = ({ tls }) => {
const enabled = tls?.enabled ?? false;
const verify = tls?.verify ?? true;
const mtls = tls?.mtls ?? false;
let verifyColor = 'gray';
let verifyTooltip = 'Verification is not active — TLS is disabled';
if (enabled) {
verifyColor = verify ? 'green' : 'yellow';
verifyTooltip = verify
? "The server's identity is verified using a trusted certificate"
: 'The connection is encrypted but the server identity is not verified';
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
return (
<TlsServiceCard serviceName="Redis">
<TlsOption
label="Encryption"
description="Encrypt traffic between Dispatcharr and Redis."
tooltip={
enabled
? 'The connection is encrypted'
: 'The connection is not encrypted'
}
badgeText={enabled ? 'Enabled' : 'Disabled'}
badgeColor={enabled ? 'green' : 'gray'}
/>
<TlsOption
label="Server Verification"
description="Verify the Redis server's identity using a CA certificate."
tooltip={verifyTooltip}
badgeText={verify && enabled ? 'On' : 'Off'}
badgeColor={verifyColor}
/>
<TlsOption
label="Mutual TLS"
description="Authenticate Dispatcharr to Redis using a client certificate."
tooltip={mtlsTooltip}
badgeText={mtls && enabled ? 'Active' : 'Inactive'}
badgeColor={mtlsColor}
/>
</TlsServiceCard>
);
};
const PostgresStatus = ({ tls }) => {
const enabled = tls?.enabled ?? false;
const sslMode = tls?.ssl_mode;
const mtls = tls?.mtls ?? false;
let modeColor = 'gray';
let modeTooltip = 'Verification mode is not active — TLS is disabled';
let modeBadge = 'Off';
if (enabled && sslMode) {
modeBadge = sslMode;
if (sslMode === 'verify-full') {
modeColor = 'green';
modeTooltip = 'Server certificate and hostname are both verified';
} else if (sslMode === 'verify-ca') {
modeColor = 'yellow';
modeTooltip =
'Server certificate is verified, but hostname is not checked';
} else {
modeColor = 'yellow';
modeTooltip = 'Connection is encrypted but the server is not verified';
}
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
return (
<TlsServiceCard serviceName="PostgreSQL">
<TlsOption
label="Encryption"
description="Encrypt traffic between Dispatcharr and PostgreSQL."
tooltip={
enabled
? 'The connection is encrypted'
: 'The connection is not encrypted'
}
badgeText={enabled ? 'Enabled' : 'Disabled'}
badgeColor={enabled ? 'green' : 'gray'}
/>
<TlsOption
label="Verification Mode"
description="How strictly to verify the PostgreSQL server's identity."
tooltip={modeTooltip}
badgeText={modeBadge}
badgeColor={modeColor}
/>
<TlsOption
label="Mutual TLS"
description="Authenticate Dispatcharr to PostgreSQL using a client certificate."
tooltip={mtlsTooltip}
badgeText={mtls && enabled ? 'Active' : 'Inactive'}
badgeColor={mtlsColor}
/>
</TlsServiceCard>
);
};
const ConnectionSecurityPanel = React.memo(() => {
const environment = useSettingsStore((s) => s.environment);
const isModular = environment.env_mode === 'modular';
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
{isModular
? 'Encrypt connections to Redis and PostgreSQL using environment variables in the docker compose file.'
: 'Connection encryption is only available in modular deployment mode with external services.'}
</Text>
{isModular && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RedisStatus tls={environment.redis_tls} />
<PostgresStatus tls={environment.postgres_tls} />
</SimpleGrid>
)}
</Stack>
);
});
export default ConnectionSecurityPanel;

View file

@ -5,7 +5,16 @@ import {
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
import {
Alert,
Button,
Divider,
Flex,
NumberInput,
Stack,
Text,
} from '@mantine/core';
import ConnectionSecurityPanel from './ConnectionSecurityPanel.jsx';
import { useForm } from '@mantine/form';
import { getSystemSettingsFormInitialValues } from '../../../utils/forms/settings/SystemSettingsFormUtils.js';
@ -68,6 +77,8 @@ const SystemSettingsForm = React.memo(({ active }) => {
max={1000}
step={10}
/>
<Divider my="md" label="Connection Security" labelPosition="left" />
<ConnectionSecurityPanel />
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
onClick={form.onSubmit(onSubmit)}

View file

@ -16,6 +16,12 @@ vi.mock('../../../../utils/forms/settings/SystemSettingsFormUtils.js', () => ({
getSystemSettingsFormInitialValues: vi.fn(),
}));
vi.mock('../ConnectionSecurityPanel.jsx', () => ({
default: () => (
<div data-testid="connection-security-panel">ConnectionSecurityPanel</div>
),
}));
// Mantine form
vi.mock('@mantine/form', () => ({
useForm: vi.fn(),
@ -47,6 +53,7 @@ vi.mock('@mantine/core', () => ({
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Divider: () => <hr />,
}));
//

View file

@ -14,7 +14,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
},
version: {
version: '',
@ -33,7 +33,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
@ -87,7 +87,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
});
});

View file

@ -8,7 +8,7 @@ const useSettingsStore = create((set, get) => ({
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
},
version: {
version: '',
@ -40,7 +40,7 @@ const useSettingsStore = create((set, get) => ({
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
},
};

View file

@ -34,10 +34,49 @@ def _flush_non_celery_keys(client):
logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)")
def _build_ssl_kwargs():
"""Build Redis SSL kwargs from environment variables.
Validates that any configured certificate file paths exist on disk
and logs a summary of the active TLS configuration.
"""
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
)
cert_paths = {
"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", ""),
}
for env_var, path in cert_paths.items():
if path and not os.path.isfile(path):
logger.error(
f"Redis TLS: {env_var}={path!r} — file not found. "
f"Check that the certificate file exists and the volume is mounted correctly."
)
sys.exit(1)
if cert_paths["REDIS_SSL_CA_CERT"]:
ssl_kwargs["ssl_ca_certs"] = cert_paths["REDIS_SSL_CA_CERT"]
if cert_paths["REDIS_SSL_CERT"]:
ssl_kwargs["ssl_certfile"] = cert_paths["REDIS_SSL_CERT"]
if cert_paths["REDIS_SSL_KEY"]:
ssl_kwargs["ssl_keyfile"] = cert_paths["REDIS_SSL_KEY"]
logger.info("Redis TLS: enabled")
return ssl_kwargs
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
ssl_kwargs = _build_ssl_kwargs()
logger.info(f"Waiting for Redis at {host}:{port}/{db}...")
@ -50,7 +89,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='',
password=password if password else None,
username=username if username else None,
socket_timeout=2,
socket_connect_timeout=2
socket_connect_timeout=2,
**ssl_kwargs
)
redis_client.ping()
# Clear stale state on startup. In AIO mode, every service restarts
@ -66,14 +106,16 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='',
return True
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e:
retry_count += 1
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs 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 False
logger.info(f"⏳ Redis not available yet, retrying in {retry_interval}s... ({retry_count}/{max_retries})")
time.sleep(retry_interval)
except Exception as e:
logger.error(f"❌ Unexpected error connecting to Redis: {e}")
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs else ""
logger.error(f"❌ Unexpected error connecting to Redis: {e}{_tls_hint}")
return False
return False