First commit

This commit is contained in:
2026-07-20 11:05:07 +02:00
commit b592ab669f
159 changed files with 10294 additions and 0 deletions
View File
+373
View File
@@ -0,0 +1,373 @@
"""Django settings shared by every environment (dev, production)."""
import os
from pathlib import Path
from django.core.exceptions import DisallowedHost
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# .env vit à la racine du dépôt (BASE_DIR/../.env), pas dans emitter/ — voir
# .env.example. Chargé ici pour que `manage.py ...` lise les mêmes valeurs
# que systemd (EnvironmentFile), sans avoir à les exporter à la main en dev.
# N'écrase jamais une variable déjà présente dans l'environnement (override=False
# par défaut) : sous systemd, EnvironmentFile reste la source de vérité.
load_dotenv(BASE_DIR.parent / '.env')
INSTALLED_APPS = [
'daphne',
# banevents AVANT django.contrib.admin : override du thème admin
# (templates/admin/base_site.html) — avec APP_DIRS=True, la résolution
# de template prend le premier app_dirs match dans l'ordre
# d'INSTALLED_APPS, donc notre template ne serait jamais atteint si
# django.contrib.admin passait en premier (piège documenté par Django
# lui-même pour toute personnalisation de l'admin).
'banevents',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'axes',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
# Doit se trouver après SessionMiddleware et avant CommonMiddleware
# (exigé par Django) : lit la langue depuis le cookie de session, puis
# l'en-tête Accept-Language, avant que CommonMiddleware ne traite la
# requête. Bascule fr/en du dashboard (Phase 5) — voir set_language
# dans config/urls.py et le sélecteur dans _sidebar_stats.html.
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# Doit rester en dernier (exigé par django-axes).
'axes.middleware.AxesMiddleware',
]
# django-axes en premier (exigé) : verrouille après trop d'échecs de
# connexion admin, cf. banevents/signals.py (receiver user_locked_out) qui
# journalise l'événement pour fail2ban — voir generic/fail2ban/jail.d/
# emitter-admin-auth.conf. AXES ne bannit rien au réseau lui-même, juste
# l'application ; le ban réseau + la propagation aux autres noeuds
# viennent de fail2ban comme pour toutes les autres jails.
AUTHENTICATION_BACKENDS = [
'axes.backends.AxesBackend',
'django.contrib.auth.backends.ModelBackend',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# LANGUAGE_CODE/LANGUAGES dans les templates (ex.
# <html lang="{{ LANGUAGE_CODE }}">, sélecteur de langue
# dans _sidebar_stats.html) — Phase 5.
'django.template.context_processors.i18n',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
ASGI_APPLICATION = 'config.asgi.application'
REDIS_HOST = os.environ.get('REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.environ.get('REDIS_PORT', '6379'))
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [(REDIS_HOST, REDIS_PORT)],
},
},
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# fr (défaut, langue source des templates) / en (traduction, voir
# locale/en/LC_MESSAGES/django.po, généré par makemessages -l en et
# compilé par compilemessages — make lint/test ne le font pas
# automatiquement, cf. README).
LANGUAGES = [
('fr', 'Français'),
('en', 'English'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
# Celery — DB Redis distincte de celle des Channels (0), pour ne pas mélanger
# les deux usages du même serveur Redis.
CELERY_BROKER_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/1'
CELERY_RESULT_BACKEND = f'redis://{REDIS_HOST}:{REDIS_PORT}/1'
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = TIME_ZONE
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'banevents' / 'static']
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Client MQTT local (management command banevents/mqtt_listen) — même broker
# local que celui utilisé par f2b-mqtt-action-banisher (generic/mosquitto/),
# mais avec un compte dédié à l'émetteur Django.
#MQTT_BROKER_HOST = os.environ.get('MQTT_BROKER_HOST', '127.0.0.1')
MQTT_BROKER_HOST = os.environ.get('MQTT_BROKER_HOST', '192.168.250.1')
MQTT_BROKER_PORT = int(os.environ.get('MQTT_BROKER_PORT', '1883'))
MQTT_BROKER_USERNAME = os.environ.get('MQTT_BROKER_USERNAME', 'emitter')
MQTT_BROKER_PASSWORD = os.environ.get('MQTT_BROKER_PASSWORD', 'banishing')
MQTT_TOPIC_SUBSCRIBE = os.environ.get('MQTT_TOPIC_SUBSCRIBE', 'fail2ban/+/jail')
# Client MQTT vers le broker master (Phase 2.b, management command
# banevents/master_client) — mTLS : le certificat client (signé par la CA
# privée du master, cf. scripts/generate-node-cert.sh) sert d'identité,
# pas de mot de passe. Par défaut, cherche les fichiers dans
# emitter/master-tls/ (jamais commité, cf. .gitignore).
# Pas de MQTT_MASTER_CA_CERT : le certificat SERVEUR du master est signé
# Let's Encrypt, vérifié par le magasin de CA système par défaut. La CA
# privée du master ne sert qu'à ce que LUI vérifie NOTRE certificat client —
# elle n'a pas sa place ici (cf. master_client.py).
MQTT_MASTER_HOST = os.environ.get('MQTT_MASTER_HOST', 'mqtt.linuxtarn.org')
MQTT_MASTER_PORT = int(os.environ.get('MQTT_MASTER_PORT', '8883'))
MQTT_MASTER_CLIENT_CERT = os.environ.get('MQTT_MASTER_CLIENT_CERT', str(BASE_DIR / 'master-tls' / 'node.crt'))
MQTT_MASTER_CLIENT_KEY = os.environ.get('MQTT_MASTER_CLIENT_KEY', str(BASE_DIR / 'master-tls' / 'node.key'))
# Doit être identique au nom passé à scripts/generate-node-cert.sh (le CN du
# certificat client) : le master ACL ce noeud sur fail2ban/<ce nom>/... via
# use_identity_as_username, donc un nom qui ne correspond pas au CN se fera
# rejeter toute publication/abonnement par l'ACL.
MQTT_MASTER_NODE_NAME = os.environ.get('MQTT_MASTER_NODE_NAME', 'testclient')
# Client MQTT côté master (Phase 3, management command
# banevents/master_listen) — identité mTLS DISTINCTE de MQTT_MASTER_CLIENT_*
# ci-dessus : ce process doit lire fail2ban/+/ban (tous les noeuds), un
# droit plus large que celui de n'importe quel noeud pris individuellement,
# donc son propre certificat (CN "master-internal" par convention, voir
# generic/mosquitto/master-acl.conf) plutôt que de réutiliser l'identité
# d'un noeud.
MQTT_MASTER_LISTENER_CERT = os.environ.get('MQTT_MASTER_LISTENER_CERT', str(BASE_DIR / 'master-tls' / 'listener.crt'))
MQTT_MASTER_LISTENER_KEY = os.environ.get('MQTT_MASTER_LISTENER_KEY', str(BASE_DIR / 'master-tls' / 'listener.key'))
# CA privée du master (scripts/master-ca-init.sh) — master uniquement,
# lue par banevents/views.py::join_node pour renvoyer ca.crt au noeud qui
# s'auto-inscrit (flux par jeton, voir ROADMAP.md). Même défaut que
# master-ca-init.sh/generate-node-cert.sh/sign-node-csr.sh : à garder
# cohérent si jamais CA_DIR est un jour personnalisé sur ce VPS.
MQTT_MASTER_CA_DIR = os.environ.get('MQTT_MASTER_CA_DIR', '/etc/mosquitto/master-ca')
# Page publique /communaute/ (master uniquement) : désactivée par défaut
# — quiconque peut y déposer une demande d'inscription (email, nom de
# noeud souhaité), mais aucun jeton n'est émis/envoyé sans validation
# manuelle dans /admin/ (voir banevents/admin.py::EnrollmentRequestAdmin).
COMMUNITY_ENROLLMENT_ENABLED = os.environ.get('COMMUNITY_ENROLLMENT_ENABLED', 'false').lower() == 'true'
# Email (validation d'une demande d'inscription, cf. ci-dessus) — backend
# sendmail (django-sendmail-backend), pas le backend SMTP standard de
# Django : ce dernier exige un socket TCP en écoute (ex. 127.0.0.1:25),
# absent sur un VPS dont le MTA local (exim4) n'est configuré qu'en
# remise directe (invocation du binaire /usr/sbin/sendmail, vérifié
# fonctionnel en conditions réelles, sans dépendre du daemon exim4.service
# — qui peut très bien être arrêté/en échec sans que ça n'empêche l'envoi
# via ce backend). Aucune garantie de délivrabilité vers une adresse
# EXTERNE sans relais SMTP (smarthost) configuré par ailleurs.
EMAIL_BACKEND = 'django_sendmail_backend.backends.EmailBackend'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '')
# ADMINS alimente mail_admins() — notification à l'admin qu'une nouvelle
# EnrollmentRequest attend une décision dans /admin/ (banevents/views.py::
# community_landing), en plus de l'email envoyé au demandeur une fois
# validée. ADMIN_EMAIL vide => ADMINS reste vide => mail_admins() ne fait
# rien (pas d'erreur), la demande reste visible dans /admin/ de toute
# façon — cette notification est un confort, pas la source de vérité.
_admin_email = os.environ.get('ADMIN_EMAIL', '')
ADMINS = [('admin', _admin_email)] if _admin_email else []
# Chemin absolu de fail2ban-client, utilisé par master_client pour exécuter
# un SYNC_BAN reçu (sudo fail2ban-client set master-sync banip <ip>) — doit
# correspondre exactement au chemin autorisé dans le sudoers déployé par
# install.sh (generic/emitter/sudoers-master-sync), sudo ne fait pas de
# résolution de $PATH.
FAIL2BAN_CLIENT_PATH = os.environ.get('FAIL2BAN_CLIENT_PATH', '/usr/bin/fail2ban-client')
# AbuseIPDB (REPORT_ABUSE, Phase 4, banevents/abuseipdb.py) — désactivé
# par défaut : ABUSEIPDB_ENABLED=false ou ABUSEIPDB_API_KEY vide font
# tomber publish_command.py en dry-run (rien envoyé, juste journalisé).
# Jamais de rapport public réel sans configuration explicite dans .env.
ABUSEIPDB_API_KEY = os.environ.get('ABUSEIPDB_API_KEY', '')
ABUSEIPDB_ENABLED = os.environ.get('ABUSEIPDB_ENABLED', 'false').lower() == 'true'
# Lien vers le dashboard du master (sidebar) — vide sur le master
# lui-même (pas de lien vers soi-même), renseigné sur les noeuds clients.
# Un seul lien hiérarchique (client -> master), pas un maillage entre
# noeuds (tentative de liens croisés N x N essayée puis abandonnée —
# ne passe pas à l'échelle avec beaucoup de clients abonnés).
MASTER_DASHBOARD_URL = os.environ.get('MASTER_DASHBOARD_URL', '').rstrip('/')
# Largeur de la sidebar (CSS, ex. "220px" ou "16rem") — la valeur par
# défaut (--sidebar-w dans main.css) convient à peu de noeuds/alias courts ;
# ajustable sans toucher au CSS si le roster (Phase 3) fait grandir le
# bloc "Noeuds" au point que les libellés soient tronqués.
# `or` plutôt que get(key, default) : .env.example laisse la variable
# présente mais vide (convention de ce fichier), qui doit retomber sur le
# défaut au même titre qu'une variable absente.
DASHBOARD_SIDEBAR_WIDTH = os.environ.get('DASHBOARD_SIDEBAR_WIDTH') or '220px'
# django-axes (protection anti-brute-force sur /admin/, cf. AUTHENTICATION_
# BACKENDS ci-dessus) — verrouille par IP seule (pas IP+utilisateur) pour
# rester cohérent avec le modèle fail2ban : une IP qui abuse doit être
# bloquée quel que soit le compte visé, pas juste ce compte-là.
AXES_FAILURE_LIMIT = 5
AXES_LOCKOUT_PARAMETERS = ['ip_address']
AXES_COOLOFF_TIME = 1 # heures — verrouillage applicatif ; le vrai blocage
# réseau (fail2ban, cf. plus bas) dure bien plus longtemps (24h).
# Le dashboard est derrière nginx (generic/nginx/fail2ban-emitter.example.conf,
# qui pose déjà X-Real-IP/X-Forwarded-For) : sans ceci, axes verrait
# 127.0.0.1 (nginx) sur chaque requête au lieu de la vraie IP cliente.
# 0, pas 1 : nginx est l'unique proxy et un vrai client n'envoie jamais son
# propre X-Forwarded-For, donc $proxy_add_x_forwarded_for (gabarit nginx)
# ne contient qu'UNE entrée (le $remote_addr observé par nginx lui-même).
# python-ipware exige (nb d'entrées - 1) >= proxy_count : avec 1 entrée et
# proxy_count=1, la validation échoue systématiquement et l'IP résolue
# vaut None (vérifié en conditions réelles : ligne de log
# "... depuis None ..." malgré 5 échecs de connexion réels). proxy_count=0
# prend ip_list[-1], donc TOUJOURS le dernier maillon ajouté par nginx —
# fiable même si le client falsifie son propre X-Forwarded-For en amont.
AXES_IPWARE_PROXY_COUNT = 0
# axes ignore X-Forwarded-For par défaut (AXES_IPWARE_META_PRECEDENCE_ORDER
# vaut ('REMOTE_ADDR',) tant que ce n'est pas explicite — protection contre
# le spoofing par un déploiement mal configuré) : sans ceci, chaque requête
# est vue avec l'IP de nginx (127.0.0.1), jamais la vraie IP cliente.
AXES_IPWARE_META_PRECEDENCE_ORDER = ('HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR')
# Log dédié pour fail2ban (generic/fail2ban/jail.d/emitter-admin-auth.conf)
# — à l'intérieur du checkout (ReadWritePaths=__EMITTER_DIR__ dans
# fail2ban-emitter-web.service couvre déjà tout l'arbre, pas besoin
# d'étendre le sandboxing systemd pour un nouveau chemin /var/log/...).
# Le receiver banevents/signals.py écrit une ligne stable et contrôlée par
# nous sur le logger 'fail2ban.axes', pas les logs internes d'axes (le
# format exact de la librairie n'est pas un contrat, il pourrait changer
# entre versions).
LOG_DIR = BASE_DIR / 'logs'
# install.sh (deploy_emitter) crée déjà ce dossier en prod avec le bon
# propriétaire avant le premier manage.py, mais un checkout local (dev,
# CI) n'a jamais lancé install.sh : sans ce mkdir, django.setup() plante
# dès l'import des settings (FileHandler ne crée pas son dossier parent).
LOG_DIR.mkdir(parents=True, exist_ok=True)
def _skip_disallowed_host(record):
"""Coupe l'email admin (mail_admins, cf. handler ci-dessous) pour les
DisallowedHost bruit constant sur un serveur à IP publique (scanners
qui tapent l'IP brute plutôt que DASHBOARD_DOMAIN), sans intérêt
actionnable contrairement aux autres erreurs 500. Django rejette déjà
correctement la requête (ALLOWED_HOSTS) ; seule la notification est
supprimée, pas la protection elle-même. Recette officielle Django, voir
https://docs.djangoproject.com/en/stable/howto/error-reporting/#filtering-error-reports
"""
if record.exc_info:
exc_type = record.exc_info[0]
if exc_type is not None and issubclass(exc_type, DisallowedHost):
return False
return True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
# Redéfini ici (identique à DEFAULT_LOGGING de Django) : dictConfig
# ne fusionne pas entre l'appel interne de Django et celui-ci pour
# notre propre LOGGING, un nom de filtre référencé plus bas sans
# être défini dans CE dict lèverait "Unable to configure filter".
'require_debug_false': {'()': 'django.utils.log.RequireDebugFalse'},
'skip_disallowed_host': {
'()': 'django.utils.log.CallbackFilter',
'callback': _skip_disallowed_host,
},
},
'formatters': {
# TIME_ZONE='UTC' ci-dessus force tout le process en UTC
# (Settings.__init__ de Django appelle time.tzset() dès le
# chargement des settings) — %(asctime)s (time.localtime() sous
# le capot) produit donc des heures UTC, mais SANS le dire.
# fail2ban (process séparé, non affecté par Django) lit l'horloge
# système réelle du VPS (ex. Europe/Berlin, CEST = UTC+2) : un
# timestamp UTC non marqué paraît alors "vieux de 2h", ce qui l'a
# fait ignorer silencieusement une ligne de verrouillage fraîche
# juste après un restart de fail2ban.service ("Ignoring all log
# entries older than 3600s" — repéré en re-testant après la
# généralisation du filtre app-auth). Suffixe "Z" explicite
# (ISO 8601 UTC, reconnu par la détection de date de fail2ban) :
# supprime toute ambiguïté, quel que soit le fuseau du VPS.
'fail2ban': {'format': '%(asctime)s.%(msecs)03dZ %(message)s', 'datefmt': '%Y-%m-%dT%H:%M:%S'},
},
'handlers': {
'fail2ban_axes': {
'class': 'logging.FileHandler',
'filename': str(LOG_DIR / 'admin-auth.log'),
'formatter': 'fail2ban',
},
# Redéfinit le handler par défaut de Django (même class/level que
# DEFAULT_LOGGING) pour n'y ajouter que le filtre ci-dessus — une
# config LOGGING partielle remplace entièrement une clé du même nom
# plutôt que de la fusionner, il faut donc la réécrire au complet.
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false', 'skip_disallowed_host'],
'class': 'django.utils.log.AdminEmailHandler',
},
# Requis par le logger 'django' ci-dessous (même piège que
# require_debug_false : un handler référencé doit être défini dans
# CE dict, jamais hérité de l'appel dictConfig() interne de
# Django) — conserve la visibilité des erreurs dans journalctl,
# inchangée par rapport à avant ce correctif.
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'fail2ban.axes': {
'handlers': ['fail2ban_axes'],
'level': 'WARNING',
'propagate': False,
},
# Redéfini explicitement (même comportement que DEFAULT_LOGGING de
# Django : handlers=['console', 'mail_admins'], level='INFO') pour
# forcer le rattachement à NOTRE instance de mail_admins ci-dessus.
# Sans ça, le logger 'django' garde en mémoire la référence vers
# l'instance créée par le tout premier dictConfig() interne de
# Django (DEFAULT_LOGGING, jamais filtrée) : les objets logger
# Python conservent leurs handlers par référence directe, pas par
# nom, un dictConfig() ultérieur qui ne re-déclare pas le logger
# ne le fait donc jamais pointer vers le nouveau handler — piège
# vécu : le filtre ci-dessus n'avait justement aucun effet tant
# que ce logger n'était pas explicitement redéclaré ici.
'django': {
'handlers': ['console', 'mail_admins'],
'level': 'INFO',
},
},
}
+17
View File
@@ -0,0 +1,17 @@
"""Settings de développement local : SQLite, DEBUG actif, hôtes ouverts."""
from .base import * # noqa: F401,F403
SECRET_KEY = 'django-insecure-dev-only-not-for-production'
DEBUG = True
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Pas de verrouillage gênant en dev local — fail2ban ne tourne de toute
# façon pas ici (cf. AXES_* dans base.py, pensé pour la prod derrière nginx).
AXES_ENABLED = False
+77
View File
@@ -0,0 +1,77 @@
"""Settings de production : secrets et hôtes fournis par l'environnement."""
import os
from .base import * # noqa: F401,F403
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
DEBUG = False
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o]
# DB_ENGINE=mariadb (opt-in, voir "sudo ./install.sh install-mariadb") bascule
# vers MariaDB/MySQL via PyMySQL (pas de mysqlclient : évite une dépendance de
# compilation C côté paquets système) ; sqlite reste le défaut, sans rien à
# installer/configurer en plus.
#
# DB_ENGINE=postgresql (opt-in) : contrairement à mariadb, pas de
# sous-commande install.sh dédiée pour l'instant — provisionner le serveur
# PostgreSQL/la base/l'utilisateur reste manuel, puis renseigner DB_*
# ci-dessous dans .env. Driver requirements-postgresql.txt
# (psycopg[binary], pas de compilation C nécessaire non plus) à installer
# à la main dans le venv (pip install -r requirements-postgresql.txt)
# avant de basculer DB_ENGINE.
DB_ENGINE = os.environ.get('DB_ENGINE', 'sqlite')
if DB_ENGINE == 'mariadb':
import pymysql
pymysql.install_as_MySQLdb()
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'fail2ban_emitter'),
'USER': os.environ.get('DB_USER', 'fail2ban_emitter'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '3306'),
}
}
elif DB_ENGINE == 'postgresql':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'fail2ban_emitter'),
'USER': os.environ.get('DB_USER', 'fail2ban_emitter'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.environ.get('DJANGO_DB_PATH', str(BASE_DIR / 'db.sqlite3')),
}
}
STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
},
}
MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE
# Pas de TLS géré ici (pas de reverse proxy devant l'émetteur pour l'instant) :
# forcer le HTTPS casserait l'accès (redirection en boucle, cookies rejetés).
# À activer (DJANGO_SECURE_SSL=true) une fois un reverse proxy TLS en place.
DJANGO_SECURE_SSL = os.environ.get('DJANGO_SECURE_SSL', 'false').lower() == 'true'
SECURE_SSL_REDIRECT = DJANGO_SECURE_SSL
SESSION_COOKIE_SECURE = DJANGO_SECURE_SSL
CSRF_COOKIE_SECURE = DJANGO_SECURE_SSL
# Daphne ne voit que la connexion interne en clair depuis nginx (proxy_pass
# http://127.0.0.1:8050) : sans ceci, request.is_secure() est toujours faux
# côté Django, qui redirige alors indéfiniment (SECURE_SSL_REDIRECT actif
# mais jamais satisfait) — le gabarit generic/nginx/fail2ban-emitter.example.conf
# pose bien X-Forwarded-Proto, encore faut-il que Django lui fasse confiance.
if DJANGO_SECURE_SSL:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')