"""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. # , 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//... 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 ) — 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', }, }, }