mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
874f5fce6a
commit
b30a24e2fb
16 changed files with 540 additions and 48 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue