commit b592ab669f6eff874f04271487f4dfd22669e172 Author: denis defolie Date: Mon Jul 20 11:05:07 2026 +0200 First commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..779f99a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1c291b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,129 @@ +# Variables d'environnement de l'émetteur Django et des scripts associés. +# +# Copié une seule fois vers .env par install.sh (jamais committé, cf. +# .gitignore) — jamais régénéré automatiquement ensuite pour ne pas écraser +# des valeurs déjà personnalisées ; DJANGO_SECRET_KEY est généré aléatoirement +# à la création, FAIL2BAN_CLIENT_PATH est résolu et mis à jour à chaque +# exécution de install.sh. +# +# Lu par systemd (EnvironmentFile=.env dans generic/emitter/*.service) ET +# directement par Django via python-dotenv (config/settings/base.py), pour +# que `manage.py ...` en dev/CLI lise les mêmes valeurs sans avoir à les +# exporter manuellement. + +DJANGO_SECRET_KEY=changeme + +# Domaine public du tableau de bord derrière nginx (reverse proxy TLS) — +# laisser vide si le dashboard n'est accessible qu'en local/VPN, sans nginx +# devant. "sudo make install-nginx" lit cette valeur pour déployer le +# vhost, obtenir le certificat Let's Encrypt, et met à jour +# DJANGO_ALLOWED_HOSTS/DJANGO_CSRF_TRUSTED_ORIGINS/DJANGO_SECURE_SSL +# ci-dessous automatiquement — ne pas les éditer à la main si vous +# utilisez cette commande, elle les écrase à chaque exécution. +DASHBOARD_DOMAIN= + +# Page publique /communaute/ (master uniquement, désactivée par défaut) : +# formulaire de demande d'inscription à la communauté, jamais de jeton +# émis sans validation manuelle dans /admin/. Nécessite DEFAULT_FROM_EMAIL +# ci-dessous pour que l'email de validation puisse partir. +COMMUNITY_ENROLLMENT_ENABLED=false +DEFAULT_FROM_EMAIL= +# Optionnel : reçoit une notification (mail_admins) à chaque nouvelle +# demande d'inscription, en plus de sa visibilité dans /admin/. Vide = +# pas de notification envoyée (la demande reste quand même visible). +ADMIN_EMAIL= + +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost +# Domaines complets (avec schéma) à autoriser en CSRF derrière un reverse +# proxy TLS (ex. nginx, voir generic/nginx/) — ex. https://dashboard.example.org +DJANGO_CSRF_TRUSTED_ORIGINS= + +# Base de données : "sqlite" (défaut), "mariadb" (voir "sudo ./install.sh +# install-mariadb") ou "postgresql" (provisionnement manuel du serveur/de +# la base pour l'instant, pas de sous-commande install.sh dédiée — driver +# emitter/requirements-postgresql.txt à installer à la main dans le venv). +# DB_* n'a d'effet qu'avec DB_ENGINE=mariadb ou postgresql. DB_PORT par +# défaut ci-dessous (3306) est celui de mariadb : à changer en 5432 pour +# postgresql (ou laisser vide, config/settings/production.py retombe déjà +# sur 5432 si DB_PORT est absent). +DB_ENGINE=sqlite +DB_NAME=fail2ban_emitter +DB_USER=fail2ban_emitter +DB_PASSWORD=banishing +DB_HOST=127.0.0.1 +DB_PORT=3306 + +MQTT_BROKER_HOST=127.0.0.1 +MQTT_BROKER_PORT=1883 +MQTT_BROKER_USERNAME=emitter +MQTT_BROKER_PASSWORD=banishing + +# IP du second listener mosquitto local (en plus de 127.0.0.1), sur le +# réseau VPN de confiance de CE VPS — permet le développement/test de +# l'émetteur depuis un poste distant connecté au même VPN. Propre à chaque +# noeud (chaque VPS a sa propre IP VPN, ex. tun0) : jamais une valeur +# partagée entre noeuds. Laisser vide si ce VPS n'a pas de VPN ou si l'accès +# distant au broker local n'est pas souhaité : install.sh (deploy_mosquitto) +# désactive alors ce second listener (loopback seul) au lieu de deviner une +# IP ou de planter au démarrage de mosquitto. +MQTT_VPN_LISTENER_IP= + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 + +# À passer à "true" une fois le reverse proxy TLS en place (voir +# generic/nginx/fail2ban-emitter.example.conf) — le forcer sans proxy TLS +# devant l'émetteur casserait tout (boucle de redirection, cookies rejetés). +DJANGO_SECURE_SSL=false + +# Résolu automatiquement par install.sh (command -v fail2ban-client) ; doit +# rester cohérent avec le chemin réel de fail2ban-client sur ce système. +FAIL2BAN_CLIENT_PATH=/usr/bin/fail2ban-client + +# Interface réseau WAN de CE noeud (generic/firewall/firewall-launcher.sh, +# lu par firewall.service via EnvironmentFile=) — ajouté une seule fois par +# install.sh (détection : interface de la route par défaut), jamais +# réécrit ensuite. Corriger ici si erroné (ip -o link show), en particulier +# sur une interface au nommage prévisible (enp1s0, ens18...) plutôt que +# l'ancien défaut historique "eth0". +ETH_IF=eth0 + +# À définir UNIQUEMENT si ce noeud est directement sur un réseau local +# (ex. derrière une box résidentielle, ETH_IF = aussi l'interface LAN) — +# exempte ce sous-réseau du filtre anti-spoof de firewall-launcher.sh, +# qui sinon droppe silencieusement le trafic local légitime (NAT +# loopback/hairpin de la box, autre appareil du LAN) en le confondant +# avec une IP source usurpée. Laisser vide sur un VPS à IP publique +# dédiée (comportement inchangé). +LAN_NET= + +# AbuseIPDB (REPORT_ABUSE, Phase 4, manage.py publish_command REPORT_ABUSE +# , master uniquement) — laisser ABUSEIPDB_ENABLED=false (défaut) ou +# ABUSEIPDB_API_KEY vide tant que vous ne voulez pas soumettre de vrais +# rapports publics : la commande tombe alors en dry-run, journalise ce +# qu'elle aurait envoyé sans rien transmettre. Clé obtenue sur +# https://www.abuseipdb.com/account/api. +ABUSEIPDB_ENABLED=false +ABUSEIPDB_API_KEY= + +# Relais vers le broker master (Phase 2.b/4, mTLS) — voir +# scripts/generate-node-cert.sh. MQTT_MASTER_CLIENT_CERT/KEY et +# MQTT_MASTER_LISTENER_CERT/KEY ont des valeurs par défaut dans +# config/settings/base.py (emitter/master-tls/*.{crt,key}) ; les redéfinir +# ici seulement si les certificats vivent ailleurs. +MQTT_MASTER_HOST=mqtt.domain.net +MQTT_MASTER_PORT=8883 +MQTT_MASTER_NODE_NAME= + +# Lien vers le dashboard du master, affiché dans la sidebar de CE noeud +# (ex. https://banishing.domain.net, sans slash final) — laisser vide sur +# le master lui-même. Un seul lien hiérarchique (client -> master), pas de +# liens croisés entre noeuds (essayé puis abandonné, ne passe pas à +# l'échelle avec beaucoup de clients abonnés). +MASTER_DASHBOARD_URL= + +# Largeur de la sidebar du dashboard (CSS, ex. "220px" ou "16rem") — à +# élargir si le roster (Phase 3) fait grandir le bloc "Noeuds" au point +# que les libellés/alias soient tronqués. Laisser vide pour garder la +# valeur par défaut du CSS (--sidebar-w, main.css). +DASHBOARD_SIDEBAR_WIDTH= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ac6c820 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# Normalisation des fins de ligne : tout le dépôt en LF +* text=auto eol=lf + +*.py text eol=lf diff=python +*.sh text eol=lf +*.service text eol=lf +*.conf text eol=lf +*.local text eol=lf +*.md text eol=lf diff=markdown +*.json text eol=lf + +# Fichiers binaires +*.png binary +*.jpg binary +*.mmdb binary + +# Classification linguist : configurations fail2ban/firewall +generic/fail2ban/**/*.conf linguist-language=INI +generic/fail2ban/**/*.local linguist-language=INI +generic/firewall/*.conf linguist-language=Shell +generic/firewall/*.service linguist-language=INI +LICENSE linguist-detectable=false + +# Bibliothèque tierce vendorisée (Leaflet) : ne pas reformater/normaliser, +# ne pas compter dans les stats de langage du dépôt +emitter/banevents/static/banevents/vendor/** -text linguist-vendored diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..838d52f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: Lint + +# pyright (typage Python) + shellcheck (syntaxe des scripts shell), voir +# ROADMAP.md Phase 5. Réutilise `make lint` tel quel plutôt que de +# dupliquer sa logique ici — un seul endroit à maintenir pour le venv, +# les dépendances de dev et les commandes réellement exécutées. +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + # shellcheck est déjà présent sur les images ubuntu-latest de + # GitHub Actions — seul pyright manque (make lint l'attend sur le + # PATH, comme en local). + - name: Installer pyright + run: pip install pyright + + - name: make lint + run: make lint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1c950c --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Secrets / identifiants — ne jamais committer +.env +.env.* +!.env.example +*.pem +*.key +*.crt +*.csr +*.p12 +*.pfx +mqtt.conf +*_passwd +mosquitto_passwd* +secrets.conf +*credentials* + +# Base GeoIP (téléchargée à part, licence MaxMind) +*.mmdb + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.mypy_cache/ +.pyright/ + +# Django +db.sqlite3 +staticfiles/ +media/ +local_settings.py + +# Dump de données (make dump-db) — contient des IP bannies, comptes admin +# réels, jamais à committer +emitter/db-dump-*.json + +# Node / front +node_modules/ + +# Documentation mkdocs générée (make docs) — reconstruite à la demande, +# jamais committée +site/ + +# Éditeurs / OS +.vscode/ +.idea/ +*.swp +.DS_Store + +# État d'outils d'assistance au développement (hors périmètre du projet) +.claude/ +ROADMAP.md +CONTEXT.md + +# Logs locaux +*.log +# ... sauf les fixtures de test versionnées +!tests/**/*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f488259 --- /dev/null +++ b/Makefile @@ -0,0 +1,99 @@ +# Makefile racine — Fail2banActionBanishment +# NB : les lignes de recette Make DOIVENT être indentées par une tabulation +# (contrainte de l'outil Make lui-même, seule exception à la règle "4 espaces" +# du projet, cf. ROADMAP.md Phase 0). + +.PHONY: install uninstall status test lint docs docs-serve run worker mqtt master-client master-listen deploy join-token join dump-db install-master install-mariadb install-nginx install-supervisor uninstall-supervisor help + +EMITTER_DIR := emitter +EMITTER_VENV := $(EMITTER_DIR)/.venv +EMITTER_PYTHON := $(EMITTER_VENV)/bin/python + +# Crée le venv de l'émetteur et installe ses dépendances s'il n'existe pas +# encore (évite le "pas de virtual env" quand run/worker/test/mqtt sont +# lancés sans avoir suivi la procédure manuelle du README). +$(EMITTER_VENV)/bin/activate: $(EMITTER_DIR)/requirements.txt + python3 -m venv $(EMITTER_VENV) + $(EMITTER_PYTHON) -m pip install --quiet --upgrade pip + $(EMITTER_PYTHON) -m pip install --quiet -r $(EMITTER_DIR)/requirements.txt + touch $(EMITTER_VENV)/bin/activate + +help: ## Affiche cette aide + @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' + +install: ## Déploie le noeud (paquets, configuration, services) — nécessite root + sudo ./install.sh install + +uninstall: ## Arrête et désactive les services installés (ne supprime pas les fichiers déployés) + sudo systemctl disable --now fail2ban.service firewall.service mosquitto.service + +status: ## Vérifie l'état des services (firewall, fail2ban, mosquitto) + sudo ./install.sh status + +test: $(EMITTER_VENV)/bin/activate ## Vérifie les filtres fail2ban custom + tests Django (émetteur) + ./scripts/test-fail2ban-filters.sh + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py test + +lint: $(EMITTER_VENV)/bin/activate ## Vérifie le typage Python (pyright) et la syntaxe des scripts shell (shellcheck) — nécessite pyright/shellcheck sur le PATH (voir README) + $(EMITTER_PYTHON) -m pip install --quiet -r $(EMITTER_DIR)/requirements-dev.txt + pyright + shellcheck install.sh scripts/*.sh generic/firewall/firewall-launcher.sh + +docs: $(EMITTER_VENV)/bin/activate ## Génère le site de documentation mkdocs dans site/ (fr/en) — prévisualiser avec 'make docs-serve' + $(EMITTER_PYTHON) -m pip install --quiet -r requirements-docs.txt + $(EMITTER_PYTHON) -m mkdocs build + +docs-serve: $(EMITTER_VENV)/bin/activate ## Sert la documentation en local avec rechargement à chaud (http://127.0.0.1:8001/) + $(EMITTER_PYTHON) -m pip install --quiet -r requirements-docs.txt + $(EMITTER_PYTHON) -m mkdocs serve -a 127.0.0.1:8001 + +run: $(EMITTER_VENV)/bin/activate ## Lance l'émetteur Django en mode développement (dev server ; MQTT/Celery à lancer à part, voir make mqtt/worker) + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py migrate + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py runserver + +mqtt: $(EMITTER_VENV)/bin/activate ## Lance l'ingestion MQTT de l'émetteur (manage.py mqtt_listen) + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py mqtt_listen + +worker: $(EMITTER_VENV)/bin/activate ## Lance le worker Celery (géolocalisation asynchrone des IP bannies) + cd $(EMITTER_DIR) && .venv/bin/celery -A config worker -l info + +master-client: $(EMITTER_VENV)/bin/activate ## Lance le relais vers le broker master (Phase 2.b, mTLS — voir MQTT_MASTER_* dans .env) + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py master_client + +master-listen: $(EMITTER_VENV)/bin/activate ## Lance l'ingestion côté master (Phase 3, tous les noeuds — cert master-internal) + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py master_listen + +deploy: install ## Alias de production pour install, sur le VPS cible + +join-token: $(EMITTER_VENV)/bin/activate ## Émet un jeton d'auto-inscription pour un nouveau noeud (MASTER uniquement) — usage: make join-token NODE= [TTL=] + @if [ -z "$(NODE)" ]; then echo "Usage: make join-token NODE= [TTL=]" >&2; exit 1; fi + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py create_join_token $(NODE) $(if $(TTL),--ttl-minutes $(TTL)) + +join: ## Auto-inscrit CE noeud auprès du master, avec le jeton reçu via 'make join-token' côté master — usage: make join MASTER= NODE= TOKEN= — nécessite root + @if [ -z "$(MASTER)" ] || [ -z "$(NODE)" ] || [ -z "$(TOKEN)" ]; then \ + echo "Usage: make join MASTER= NODE= TOKEN=" >&2; exit 1; \ + fi + sudo ./install.sh join --master $(MASTER) --node $(NODE) --token $(TOKEN) + +dump-db: $(EMITTER_VENV)/bin/activate ## Dump JSON portable de la base (Django dumpdata) en vue d'un portage vers mariadb/postgresql — recharger ensuite avec manage.py loaddata après bascule DB_ENGINE + $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py dumpdata --natural-foreign --natural-primary \ + -e contenttypes -e auth.permission -e sessions \ + -o $(EMITTER_DIR)/db-dump-$$(date +%Y%m%d-%H%M%S).json + @echo "Dump créé dans $(EMITTER_DIR)/. Après bascule DB_ENGINE (make install-mariadb, ou" + @echo "DB_ENGINE=postgresql dans .env + migrate), recharger avec :" + @echo " $(EMITTER_PYTHON) $(EMITTER_DIR)/manage.py loaddata .json" + +install-master: ## Déploie le broker master (CA privée, ACL, ingestion multi-noeuds) — opt-in, nécessite root + sudo ./install.sh install-master + +install-mariadb: ## Bascule la base de données de SQLite vers MariaDB (base/utilisateur créés) — opt-in, nécessite root, relancer 'make install' ensuite + sudo ./install.sh install-mariadb + +install-nginx: ## Déploie nginx + certificat Let's Encrypt devant le tableau de bord (voir DASHBOARD_DOMAIN dans .env) — nécessite root + sudo ./install.sh install-nginx + +install-supervisor: ## Bascule les services de l'émetteur de systemd vers supervisor (option, VPS) + sudo ./install.sh install-supervisor + +uninstall-supervisor: ## Revient à systemd (arrête supervisor, réactive les services systemd) + sudo ./install.sh uninstall-supervisor diff --git a/README-EN.md b/README-EN.md new file mode 100644 index 0000000..d4d259b --- /dev/null +++ b/README-EN.md @@ -0,0 +1,197 @@ +# Fail2banActionBanishment + +Distributed malicious-host banishing system built on **fail2ban** and +**iptables**, with event propagation over **MQTT**. + +Each node detects and bans locally (fail2ban + iptables), publishes the event +to a local Mosquitto broker, then relays it to a private **master** broker. +The master centralizes the decision (multi-node correlation, global recidive +detection, shared reputation) and republishes the actions to be executed to +all subscribed nodes. + +Target machine: **Debian 13 VPS**. + +Full documentation (installation, detailed production deployment, +token-based node enrollment, MQTT topics and commands reference): +`make docs-serve` then , or `make docs` to +generate the static site into `site/`. See also [README.md](README.md) +for the French version. + +## Architecture (overview) + +``` +[node] fail2ban/iptables → local mosquitto (1883) → master mosquitto (8883, TLS) + │ + decision / correlation + │ +[node] ◄──────────────── MQTT action (BAN, SYNC_BAN, UNBAN, ...) ◄────────── +``` + +## Use cases + +The master/nodes model doesn't assume any ownership relationship between +the protected servers — only that they share a common decision authority. +That makes the architecture relevant beyond a single operator running +their own VPSes: + +- **A set of sites** (several VPSes run by the same operator, each hosting + one or more services): an IP attacking one site gets banned everywhere + via `SYNC_BAN`, without manual per-site monitoring. +- **A community** (several independent administrators, each responsible + for their own server, who agree to pool their detections): every member + keeps their own node and their own local fail2ban — only ban + propagation is shared through the common master. No member gets SSH + access to another member's server. +- **An IT services company** managing several clients' sites: a + centralized master gives an aggregated view (jail/country counters, + per-node history, per-client aliases) without mixing client data — + each node only sees its own events, the master sees everything. + +In all three cases, a new server joins the set without ever handing out +SSH access to the others: a single-use token-based enrollment is enough +(`make join-token` / `make join`, detailed below). + +## Repository layout + +- `generic/fail2ban/` — jails, filters and the MQTT action (`f2b-mqtt-action-banisher`) +- `generic/firewall/` — iptables launcher script (`firewall-launcher.sh`), per-service rules +- `generic/mosquitto/` — local MQTT broker (`127.0.0.1:1883`, no persistence) and master broker (`mosquitto-master.conf`, port 8883, mTLS) +- `generic/emitter/` — systemd service templates (web/ASGI, MQTT ingestion, Celery worker, master relay) +- `generic/supervisor/` — `.example.conf` templates (optional alternative to systemd, `make install-supervisor`) +- `generic/nginx/` — `.example.conf` TLS reverse-proxy template for the dashboard (not deployed automatically) +- `emitter/` — Django emitter (`banevents` app): MQTT ingestion (`manage.py mqtt_listen`), master relay (`manage.py master_client`), `BanEvent` model, dashboard + map + history, geolocation (Celery) +- `.env.example` — configuration template (copied to `.env` by `install.sh`, never committed) +- `install.sh` — idempotent deployment (packages, config files, systemd services, including the emitter); wrapped by the `make` targets below +- `scripts/test-fail2ban-filters.sh` — custom filter checks via `fail2ban-regex` +- `scripts/master-ca-init.sh`, `scripts/generate-node-cert.sh` — private CA and mTLS client certs for the master broker (manual flow, see token-based enrollment below for the recommended flow) +- `Makefile` — every entry point (`make help` for the full list) +- `docs/` — mkdocs documentation (installation, production deployment, reference) + +## Installation (Debian 13 VPS) + +Deployment model: **no copy into `/opt`**. The repo cloned into a dedicated +user's `$HOME` (e.g. `banisher`) *is* the deployment — venv, SQLite +database, `staticfiles/` and `.env` all live inside that checkout, owned by +that user. `install.sh` detects this user from the repo's actual owner (no +hardcoded name): + +```sh +useradd -m -s /bin/bash banisher # once, if the account doesn't exist yet +su - banisher -c 'git clone ~/Fail2banMqttActionBanisher' +cd ~banisher/Fail2banMqttActionBanisher +sudo make install +sudo make status +``` + +`make install` (= `sudo ./install.sh install`): +- installs/checks a `NOPASSWD:ALL` sudoers entry for that user + (`/etc/sudoers.d/-full-access`, created once if missing) — this + account should therefore be **dedicated to this project**, not shared with + other uses; +- copies `.env.example` to `.env` if missing (never regenerated afterward, + `DJANGO_SECRET_KEY` generated randomly) — edit `.env` for real credentials + (MQTT password for the `emitter` account, master domain, ...); +- creates the emitter's Python venv (`emitter/.venv`), applies migrations + and `collectstatic`, all run as the deployment user (never root); +- enables four systemd services: `fail2ban-emitter-web` (ASGI/Daphne + dashboard, `127.0.0.1:8050`), `fail2ban-emitter-mqtt` + (`manage.py mqtt_listen`), `fail2ban-emitter-worker` (Celery worker, + geolocation) and `fail2ban-emitter-master-client` (`manage.py + master_client`, relay/execution of the master's commands — won't + actually connect until a node certificate has been obtained, see + below). + +Also edit `/etc/fail2ban/mqtt.conf` (real MQTT credentials, copied from +`generic/fail2ban/mqtt.conf.example` if it doesn't exist yet). + +To update the emitter after a `git pull`, just rerun `sudo make install`: +dependencies/migrations are reapplied and all four services restarted. + +Additional deployment options (detailed in the [deployment +documentation](docs/deployment.en.md), `make docs-serve` for the +browsable version): +- **master** (`sudo make install-master`) — public mTLS MQTT broker, + private CA, multi-node ingestion; +- **token-based node enrollment** (`make join-token` on the master, then + `sudo make join` on the new node) — recommended way to attach a node to + a master, without ever copying a certificate by hand; +- **nginx** (`sudo make install-nginx`) — TLS reverse proxy (Let's + Encrypt) in front of the dashboard, for public access; +- **supervisor** (`sudo make install-supervisor` / `sudo make + uninstall-supervisor`) — optional alternative to systemd; +- **MariaDB** (`sudo make install-mariadb`) — switches the database from + SQLite (default) to a dedicated local MariaDB server. + +## Django emitter (development) + +Requires a reachable Redis (Django Channels channel layer, e.g. `docker run +-p 6379:6379 redis`). + +```sh +make run # dashboard + map at http://127.0.0.1:8000/ (real-time WebSocket included) +make mqtt # in a second terminal: MQTT ingestion +make worker # in a third terminal: asynchronous geolocation +``` + +Environment variables (`MQTT_BROKER_HOST`, `MQTT_BROKER_USERNAME`, +`MQTT_BROKER_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, ...): copy +`.env.example` to `.env` at the repo root and edit it — loaded +automatically by Django (`python-dotenv`), nothing to export by hand. +Geolocation uses the free ip-api.com fallback API until a local GeoLite2 +(MaxMind) database is configured. + +## Translations (fr/en) + +French is the source language of the template code; English is a +translation maintained in `emitter/locale/en/LC_MESSAGES/django.po`. A +selector in the dashboard's sidebar switches the active language +(`django_language` cookie). + +After adding/changing a `{% trans %}`/`{% blocktrans %}` in a template: + +```sh +cd emitter +.venv/bin/python manage.py makemessages -l en --no-location --ignore ".venv" +# edit locale/en/LC_MESSAGES/django.po (fill in the missing msgstr entries) +.venv/bin/python manage.py compilemessages -l en --ignore ".venv" +``` + +`--ignore ".venv"`: without it, `compilemessages` also recompiles (slowly, +with no useful effect) every one of Django's own bundled translations found +inside the venv, since it lives under `emitter/` — harmless but pointless. + +## Master broker and node enrollment + +See the [deployment documentation](docs/deployment.en.md) for the full +detail: master provisioning, the enrollment security model (single-use +token, private key that never leaves the node, token automatically given +back on a transient failure), troubleshooting. In short: + +```sh +# on the master +make join-token NODE=my-node + +# on the new node +sudo make join MASTER=https://master-dashboard NODE=my-node TOKEN= +sudo make install +``` + +Master-side ingestion (`manage.py master_listen`) systematically +republishes a `SYNC_BAN` for every ingested ban, and correlates +independent bans across nodes to trigger an automatic escalation — see +the [reference](docs/reference.en.md) for the full list of master +commands and MQTT topics. + +## Development constraints + +- Source code in English, comments in French +- Indentation: 4 spaces, never tabs, see `.editorconfig` + (exception: Makefile recipes, which must be tab-indented — a constraint of + Make itself) +- Python type-checked with `pyright` (`pyrightconfig.json`), shell scripts + with `shellcheck` — `make lint`, also run in CI +- JavaScript: ES6 classes + +## License + +AGPL-3.0 — see [LICENSE](LICENSE). diff --git a/README.md b/README.md new file mode 100644 index 0000000..75e581f --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# Fail2banActionBanishment + +Système distribué de bannissement d'hôtes malveillants basé sur **fail2ban** et +**iptables**, avec propagation des événements par **MQTT**. + +Chaque noeud détecte et bannit localement (fail2ban + iptables), publie +l'événement sur un broker Mosquitto local, puis relaie l'information vers un +broker **master** privé. Le master centralise la décision (corrélation +multi-noeuds, récidive globale, réputation partagée) et republie les actions à +exécuter vers l'ensemble des noeuds abonnés. + +Machine cible : **VPS Debian 13**. + +Documentation complète (installation, déploiement en production détaillé, +auto-inscription des noeuds, référence des topics MQTT et des commandes) : +`make docs-serve` puis , ou `make docs` pour générer +le site statique dans `site/`. Voir aussi [README-EN.md](README-EN.md) pour +la version anglaise. + +## Architecture (résumé) + +``` +[noeud] fail2ban/iptables → mosquitto local (1883) → mosquitto master (8883, TLS) + │ + décision / corrélation + │ +[noeud] ◄──────────────── action MQTT (BAN, SYNC_BAN, UNBAN, ...) ◄────────── +``` + +## Cas d'usage + +Le modèle master/noeuds ne suppose aucun lien d'appartenance entre les +serveurs protégés — seulement qu'ils partagent une même autorité de +décision. Ça rend l'architecture pertinente au-delà d'un opérateur unique +sur ses propres VPS : + +- **Un ensemble de sites** (plusieurs VPS d'un même opérateur, chacun + hébergeant un ou plusieurs services) : une IP qui attaque un site est + bannie partout via `SYNC_BAN`, sans surveillance manuelle site par site. +- **Une communauté** (plusieurs administrateurs indépendants, chacun + responsable de son propre serveur, qui acceptent de mutualiser leurs + détections) : chaque membre garde son propre noeud et son propre + fail2ban local — seule la propagation des bans est partagée via le + master commun. Aucun accès aux serveurs des autres membres n'est requis + ni accordé. +- **Une société de services informatiques** qui gère les sites de + plusieurs clients : un master centralisé donne une vue agrégée + (compteurs jail/pays, historique par noeud, alias par client) sans + mélanger les données de client à client — chaque noeud ne voit que ses + propres événements, le master voit tout. + +Dans les trois cas, un nouveau serveur rejoint l'ensemble sans jamais +donner accès SSH aux autres : une auto-inscription par jeton à usage +unique suffit (`make join-token` / `make join`, détaillé plus bas). + +## Contenu du dépôt + +- `generic/fail2ban/` — jails, filtres et action MQTT (`f2b-mqtt-action-banisher`) +- `generic/firewall/` — script iptables (`firewall-launcher.sh`), règles par service +- `generic/mosquitto/` — broker MQTT local (`127.0.0.1:1883`, sans persistance) et broker master (`mosquitto-master.conf`, port 8883, mTLS) +- `generic/emitter/` — gabarits des services systemd (web/ASGI, ingestion MQTT, worker Celery, relais master) +- `generic/supervisor/` — gabarits `.example.conf` (alternative optionnelle à systemd, `make install-supervisor`) +- `generic/nginx/` — gabarit `.example.conf` de reverse proxy TLS devant le tableau de bord (non déployé automatiquement) +- `emitter/` — émetteur Django (app `banevents`) : ingestion MQTT (`manage.py mqtt_listen`), relais vers le master (`manage.py master_client`), modèle `BanEvent`, tableau de bord + carte + historique, géolocalisation (Celery) +- `.env.example` — modèle de configuration (copié vers `.env` par `install.sh`, jamais committé) +- `install.sh` — déploiement idempotent (paquets, fichiers de conf, services systemd, y compris l'émetteur) ; wrappé par les cibles `make` ci-dessous +- `scripts/test-fail2ban-filters.sh` — vérification des filtres custom via `fail2ban-regex` +- `scripts/master-ca-init.sh`, `scripts/generate-node-cert.sh` — CA privée et certificats clients mTLS pour le broker master (flux manuel, voir auto-inscription plus bas pour le flux recommandé) +- `Makefile` — tous les points d'entrée (`make help` pour la liste complète) +- `docs/` — documentation mkdocs (installation, déploiement en production, référence) + +## Installation (VPS Debian 13) + +Modèle de déploiement : **pas de copie vers `/opt`**. Le dépôt cloné dans le +`$HOME` d'un utilisateur dédié (ex. `banisher`) *est* le déploiement — venv, +base SQLite, `staticfiles/` et `.env` vivent tous dans ce checkout, possédés +par cet utilisateur. `install.sh` détecte cet utilisateur à partir du +propriétaire du dépôt (pas de nom codé en dur) : + +```sh +useradd -m -s /bin/bash banisher # une seule fois, si le compte n'existe pas +su - banisher -c 'git clone ~/Fail2banMqttActionBanisher' +cd ~banisher/Fail2banMqttActionBanisher +sudo make install +sudo make status +``` + +`make install` (= `sudo ./install.sh install`) : +- installe/vérifie un sudoers `NOPASSWD:ALL` pour cet utilisateur + (`/etc/sudoers.d/-full-access`, créé une seule fois s'il est absent) + — ce compte doit donc être **dédié à ce projet**, pas un compte partagé + avec d'autres usages ; +- copie `.env.example` vers `.env` s'il est absent (jamais régénéré ensuite, + `DJANGO_SECRET_KEY` généré aléatoirement) — éditer `.env` pour les vrais + identifiants (mot de passe MQTT du compte `emitter`, domaine du master, ...) ; +- crée le venv Python de l'émetteur (`emitter/.venv`), applique les + migrations et `collectstatic`, tous exécutés en tant que l'utilisateur de + déploiement (jamais root) ; +- active quatre services systemd : `fail2ban-emitter-web` (tableau de bord + ASGI/Daphne, `127.0.0.1:8050`), `fail2ban-emitter-mqtt` + (`manage.py mqtt_listen`), `fail2ban-emitter-worker` (worker Celery, + géolocalisation) et `fail2ban-emitter-master-client` (`manage.py + master_client`, relais/exécution des commandes du master — ne se + connectera réellement qu'une fois un certificat de noeud obtenu, voir + plus bas). + +Éditer aussi `/etc/fail2ban/mqtt.conf` (identifiants MQTT réels, copié depuis +`generic/fail2ban/mqtt.conf.example` s'il n'existe pas encore). + +Pour mettre à jour l'émetteur après un `git pull`, relancer simplement +`sudo make install` : les dépendances/migrations sont réappliquées et les +quatre services redémarrés. + +Options de déploiement supplémentaires (détaillées dans la [documentation +de déploiement](docs/deployment.md), `make docs-serve` pour la version +navigable) : +- **master** (`sudo make install-master`) — broker MQTT public en mTLS, + CA privée, ingestion multi-noeuds ; +- **auto-inscription d'un noeud** (`make join-token` côté master, puis + `sudo make join` côté nouveau noeud) — recommandé pour rattacher un + noeud à un master, sans jamais copier de certificat à la main ; +- **nginx** (`sudo make install-nginx`) — reverse proxy TLS (Let's + Encrypt) devant le tableau de bord, pour un accès public ; +- **supervisor** (`sudo make install-supervisor` / `sudo make + uninstall-supervisor`) — alternative optionnelle à systemd ; +- **MariaDB** (`sudo make install-mariadb`) — bascule la base de données + de SQLite (défaut) vers un serveur MariaDB local dédié. + +## Émetteur Django (développement) + +Nécessite un Redis accessible (channel layer Django Channels, ex. `docker run +-p 6379:6379 redis`). + +```sh +make run # tableau de bord + carte sur http://127.0.0.1:8000/ (WebSocket temps réel inclus) +make mqtt # dans un second terminal : ingestion MQTT +make worker # dans un troisième terminal : géolocalisation asynchrone +``` + +Variables d'environnement (`MQTT_BROKER_HOST`, `MQTT_BROKER_USERNAME`, +`MQTT_BROKER_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, ...) : copier +`.env.example` vers `.env` à la racine du dépôt et l'éditer — chargé +automatiquement par Django (`python-dotenv`) sans rien à exporter à la main. +La géolocalisation utilise l'API externe gratuite ip-api.com en attendant une +base GeoLite2 (MaxMind) locale. + +## Traductions (fr/en) + +Le français est la langue source du code des templates ; l'anglais est une +traduction maintenue dans `emitter/locale/en/LC_MESSAGES/django.po`. Un +sélecteur dans la sidebar du tableau de bord bascule la langue active +(cookie `django_language`). + +Après avoir ajouté/modifié un `{% trans %}`/`{% blocktrans %}` dans un +template : + +```sh +cd emitter +.venv/bin/python manage.py makemessages -l en --no-location --ignore ".venv" +# éditer locale/en/LC_MESSAGES/django.po (remplir les msgstr manquants) +.venv/bin/python manage.py compilemessages -l en --ignore ".venv" +``` + +`--ignore ".venv"` : sans ça, `compilemessages` recompile aussi (lentement, +sans effet utile) toutes les traductions internes de Django trouvées dans le +venv, puisque celui-ci vit sous `emitter/` — inoffensif mais inutile. + +## Broker master et auto-inscription des noeuds + +Voir la [documentation de déploiement](docs/deployment.md) pour le détail +complet : provisionnement du master, modèle de sécurité de +l'auto-inscription (jeton à usage unique, clé privée qui ne quitte jamais +le noeud, jeton rendu automatiquement en cas d'échec transitoire), +dépannage. En résumé : + +```sh +# sur le master +make join-token NODE=mon-noeud + +# sur le nouveau noeud +sudo make join MASTER=https://dashboard-du-master NODE=mon-noeud TOKEN= +sudo make install +``` + +L'ingestion côté master (`manage.py master_listen`) republie +systématiquement un `SYNC_BAN` pour chaque ban ingéré, et corrèle les bans +indépendants sur plusieurs noeuds pour déclencher une escalade +automatique — voir la [référence](docs/reference.md) pour la liste +complète des commandes du master et des topics MQTT. + +## Contraintes de développement + +- Code source en anglais, commentaires en français +- Indentation : 4 espaces, jamais de tabulation, voir `.editorconfig` + (exception : les recettes du `Makefile`, qui doivent être indentées par une + tabulation — contrainte de l'outil Make lui-même) +- Python vérifié avec `pyright` (`pyrightconfig.json`), scripts shell avec + `shellcheck` — `make lint`, aussi exécuté en CI +- JavaScript : classes ES6 + +## Licence + +AGPL-3.0 — voir [LICENSE](LICENSE). diff --git a/docs/deployment.en.md b/docs/deployment.en.md new file mode 100644 index 0000000..0de7299 --- /dev/null +++ b/docs/deployment.en.md @@ -0,0 +1,189 @@ +# Production deployment + +This page details deploying a master + nodes set in production, beyond +the single isolated node covered in [Installation](installation.en.md). + +## Overview + +Three ways to use this project, from simplest to most complete — the +same base install (`sudo make install`) is the foundation for all three: + +- **Solo**: `sudo make install`, alone. This server detects and bans + locally, without centralizing anything elsewhere. Enough to protect a + single VPS. The relay service to a master + (`fail2ban-emitter-master-client`) still runs, but silently keeps + retrying in a loop as long as no master is configured (no harm done, + `Restart=on-failure`). +- **Solo + master**: `sudo make install` **then** `sudo make + install-master`, on the **same** server. This VPS protects its own + services (like solo mode) and also serves as a master, ready to take on + other nodes later — useful for starting out alone today without + closing the door on a second server tomorrow. +- **Master + nodes** (several VPSes): one server in solo+master mode, and + one or more other servers, each installed solo and then attached to + that master via token-based enrollment (below). The master itself + stays a node like any other (it detects and bans locally in addition + to centralizing the decision) — going from "solo+master on its own" to + "master + nodes" therefore requires no reinstall of the master, just + adding nodes to it over time. + +Recommended order for moving to several nodes: + +1. Deploy the master (once — solo+master if this same server should also + protect itself, which is the common case). +2. On the master, issue a token for each new node. +3. On each new node, enroll using that token. + +## Deploying the master + +```sh +sudo make install-master +``` + +Requires a first `sudo make install` already done on this same machine. +Provisions: + +- a dedicated **private CA** (`scripts/master-ca-init.sh`), distinct from + the Let's Encrypt certificate that serves the broker's TLS identity — + it only signs nodes' client certificates (mTLS authentication); +- the public Mosquitto listener (port 8883, TLS, `MQTT_MASTER_DOMAIN` in + `.env`) with per-node ACLs; +- master-side ingestion (`fail2ban-emitter-master-listen`, runs with its + own mTLS identity `master-internal`, generated automatically). + +`MQTT_MASTER_DOMAIN` must point to a real public domain (DNS already set +up) — the broker obtains its Let's Encrypt certificate for that name. + +## Token-based node enrollment (recommended) + +### The problem + +Adding a node by hand would require generating a key + certificate on the +master, then copying those files to the new node (`scp`), renaming them +correctly, and editing its `.env` — several manual, cross-machine steps, +and the private key would travel over the network (never a good +practice). + +### The model + +The master issues a **single-use token**, short-lived (1h by default). +The new node uses that token to enroll itself: + +- The node generates its **own private key locally** and never sends it + — only a CSR (signing request) goes to the master. +- The token is never stored in plaintext on the master: only its SHA-256 + hash is persisted (same logic as a password hash). +- The token is **atomically marked used** as soon as it's validated, and + **automatically given back** if a later step fails (inconsistent CSR, + signing failure) — a transient hiccup doesn't force issuing an + entirely new token. +- The CN of the received CSR must match the node name declared when the + token was issued — a stolen token can't be used to claim a different + name. +- Any error returned to the client is **deliberately generic** + ("invalid or expired token"), regardless of the actual cause — the + detail (unknown, expired, or already-used token, inconsistent CN, + signing failure) only goes into the master's logs, so as to give no + hint to a third party probing the endpoint. +- The transport is the master's HTTPS dashboard (already publicly + exposed, nginx + Let's Encrypt): no new port or service to open, and it + solves the chicken-and-egg problem (a node can't use mTLS MQTT to + obtain... its first mTLS certificate). + +### Step by step + +On the **master**: + +```sh +make join-token NODE=node-name +``` + +Prints the plaintext token **only once**, along with the full command to +run on the new node (lost token: simply issue a new one, the previous +one stays valid independently until it expires). + +On the **new node** (having already gone through `sudo make install`): + +```sh +sudo make join MASTER=https:// NODE=node-name TOKEN= +sudo make install +``` + +The second `make install` applies the received configuration +(`MQTT_MASTER_NODE_NAME` in `.env`, certificate in place) and restarts +the services. + +### What happens behind the scenes + +``` +[new node] [master] + generates key + CSR (local, never sent) + POST /api/join/ {node_name, token, csr} ──► + validates the token (hash, expiry, single use) + checks the CSR's CN == node_name + signs the CSR with the private CA + adds the ACL entitlement for this node + restarts mosquitto + {cert, ca_cert} ◄──── + writes cert/ca_cert, updates .env +``` + +### Troubleshooting + +- **"Invalid or expired token"** — the message is deliberately generic. + Possible causes: token already used (run `make join-token` again to + issue a new one), token expired (1h default, adjustable via + `make join-token NODE=... TTL=`), or a typo in the node name + (must be identical between token issuance and `make join`). +- The new node must be able to reach the master over HTTPS (443) — a + firewall blocking outbound traffic would prevent enrollment. + +## Databases + +SQLite is the default, nothing to configure. Two alternatives: + +```sh +sudo make install-mariadb # provisions a local MariaDB server + dedicated database/user +sudo make install # re-run afterward to apply migrations on the new database +``` + +PostgreSQL is supported at the configuration level (`DB_ENGINE=postgresql` +in `.env`, driver `emitter/requirements-postgresql.txt`) but without an +automated provisioning subcommand for now — server and database must be +created manually before switching `DB_ENGINE`. + +Migrating data from one backend to another (engine-agnostic dump via +`manage.py dumpdata`/`loaddata`): + +```sh +make dump-db # writes emitter/db-dump-.json (git-ignored, contains real data) +# ... switch DB_ENGINE, migrate ... +emitter/.venv/bin/python emitter/manage.py loaddata .json +``` + +## nginx reverse proxy + public TLS + +```sh +sudo make install-nginx # reads DASHBOARD_DOMAIN from .env +``` + +Obtains a Let's Encrypt certificate and deploys an nginx vhost in front of +the dashboard (WebSocket included) — needed for public access; without +it, the dashboard is only reachable locally/over VPN. + +## Alternative to systemd: supervisor + +```sh +sudo make install-supervisor # switches the emitter's 4 services to supervisor +sudo make uninstall-supervisor # rollback (re-enables systemd) +``` + +## Updating + +```sh +git pull +sudo make install +``` + +Reapplies dependencies/migrations and restarts the affected services, on +any node (including the master). diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..7d33e0a --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,193 @@ +# Déploiement en production + +Cette page détaille le déploiement d'un ensemble master + noeuds en +production, au-delà du premier noeud isolé couvert par +[Installation](installation.md). + +## Vue d'ensemble + +Trois façons d'utiliser ce projet, du plus simple au plus complet — la +même installation de base (`sudo make install`) sert de socle aux trois : + +- **Solo** : `sudo make install`, seul. Ce serveur détecte et bannit + localement, sans rien centraliser ailleurs. Suffisant pour protéger un + unique VPS. Le service de relais vers un master + (`fail2ban-emitter-master-client`) tourne quand même, mais réessaie + silencieusement en boucle tant qu'aucun master n'est configuré (aucune + gêne, `Restart=on-failure`). +- **Solo + master** : `sudo make install` **puis** `sudo make + install-master`, sur le **même** serveur. Ce VPS protège ses propres + services (comme en mode solo) et sert en plus de master, prêt à + accueillir d'autres noeuds plus tard — utile pour démarrer seul + aujourd'hui sans fermer la porte à un second serveur demain. +- **Master + noeuds** (plusieurs VPS) : un serveur en mode solo+master, + et un ou plusieurs autres serveurs, chacun installé en solo puis + rattaché à ce master via l'auto-inscription par jeton (ci-dessous). Le + master lui-même reste un noeud comme les autres (il détecte et bannit + localement en plus de centraliser la décision) — passer de "solo+master + tout seul" à "master + noeuds" ne demande donc aucune réinstallation du + master, seulement d'y ajouter des noeuds au fur et à mesure. + +Ordre recommandé pour passer à plusieurs noeuds : + +1. Déployer le master (une seule fois — solo+master si ce même serveur + doit aussi se protéger lui-même, ce qui est le cas courant). +2. Sur le master, émettre un jeton pour chaque nouveau noeud. +3. Sur chaque nouveau noeud, s'auto-inscrire avec ce jeton. + +## Déployer le master + +```sh +sudo make install-master +``` + +Nécessite un premier `sudo make install` déjà effectué sur cette même +machine. Provisionne : + +- une **CA privée** dédiée (`scripts/master-ca-init.sh`), distincte du + certificat Let's Encrypt qui sert l'identité TLS du broker — elle ne + sert qu'à signer les certificats clients des noeuds (authentification + mTLS) ; +- le listener Mosquitto public (port 8883, TLS, `MQTT_MASTER_DOMAIN` dans + `.env`) avec ACL par noeud ; +- l'ingestion côté master (`fail2ban-emitter-master-listen`, tourne avec + sa propre identité mTLS `master-internal`, générée automatiquement). + +`MQTT_MASTER_DOMAIN` doit pointer vers un domaine public réel (DNS déjà en +place) — le broker obtient son certificat Let's Encrypt à ce nom. + +## Auto-inscription d'un noeud (recommandé) + +### Le problème + +Ajouter un noeud à la main demanderait de générer une clé + un certificat +sur le master, puis de copier ces fichiers vers le nouveau noeud (`scp`), +de les renommer correctement, et d'éditer son `.env` — plusieurs étapes +manuelles, cross-machine, et la clé privée transiterait par le réseau +(jamais une bonne pratique). + +### Le modèle + +Le master émet un **jeton à usage unique**, à courte durée de vie (1h par +défaut). Le nouveau noeud utilise ce jeton pour s'inscrire lui-même : + +- Le noeud génère sa **propre clé privée localement** et ne l'envoie + jamais — seule une CSR (demande de signature) part vers le master. +- Le jeton n'est jamais stocké en clair côté master : seul son hash + SHA-256 est persisté (même logique qu'un hachage de mot de passe). +- Le jeton est **marqué utilisé de façon atomique** dès sa validation, et + **rendu automatiquement** si une étape suivante échoue (CSR + incohérente, échec de signature) — un accroc transitoire ne force pas + à réémettre un jeton entièrement nouveau. +- Le CN de la CSR reçue doit correspondre au nom de noeud déclaré au + moment de l'émission du jeton — un jeton volé ne permet pas de réclamer + un autre nom. +- Toute erreur renvoyée au client est **volontairement générique** + ("jeton invalide ou expiré"), quelle que soit la cause réelle — le + détail (jeton inconnu, expiré, déjà utilisé, CN incohérent, échec de + signature) part uniquement dans les journaux du master, pour ne donner + aucun indice à un tiers qui sonderait l'endpoint. +- Le transport est le tableau de bord HTTPS du master (déjà exposé + publiquement, nginx + Let's Encrypt) : aucun nouveau port ni service à + ouvrir, et ça résout le problème d'oeuf-et-poule (un noeud ne peut pas + utiliser mTLS MQTT pour obtenir... son premier certificat mTLS). + +### Étape par étape + +Sur le **master** : + +```sh +make join-token NODE=nom-du-noeud +``` + +Affiche le jeton en clair **une seule fois**, avec la commande complète à +lancer sur le nouveau noeud (jeton perdu : simplement en émettre un +nouveau, celui-ci reste valide indépendamment jusqu'à expiration). + +Sur le **nouveau noeud** (déjà passé par `sudo make install` au +préalable) : + +```sh +sudo make join MASTER=https:// NODE=nom-du-noeud TOKEN= +sudo make install +``` + +Le second `make install` applique la configuration reçue +(`MQTT_MASTER_NODE_NAME` dans `.env`, certificat en place) et redémarre +les services. + +### Ce qui se passe en coulisse + +``` +[nouveau noeud] [master] + génère clé + CSR (locale, jamais transmise) + POST /api/join/ {node_name, token, csr} ──► + vérifie le jeton (hash, expiration, usage unique) + vérifie le CN de la CSR == node_name + signe la CSR via la CA privée + ajoute l'entitlement ACL pour ce noeud + redémarre mosquitto + {cert, ca_cert} ◄──── + écrit cert/ca_cert, met à jour .env +``` + +### Dépannage + +- **"Jeton invalide ou expiré"** — le message est volontairement + générique. Causes possibles : jeton déjà utilisé (relancer + `make join-token` pour en émettre un nouveau), jeton expiré (durée par + défaut 1h, ajustable via `make join-token NODE=... TTL=`), ou + faute de frappe dans le nom de noeud (doit être identique entre + l'émission du jeton et `make join`). +- Le nouveau noeud doit pouvoir joindre le master en HTTPS (443) — un + pare-feu qui bloquerait la sortie empêcherait l'auto-inscription. + +## Bases de données + +SQLite est le défaut, sans rien à configurer. Deux alternatives : + +```sh +sudo make install-mariadb # provisionne un serveur MariaDB local + base/utilisateur dédiés +sudo make install # à relancer ensuite pour appliquer les migrations sur la nouvelle base +``` + +PostgreSQL est supporté côté configuration (`DB_ENGINE=postgresql` dans +`.env`, driver `emitter/requirements-postgresql.txt`) mais sans +sous-commande de provisionnement automatique pour l'instant — serveur et +base à créer manuellement avant de basculer `DB_ENGINE`. + +Portage d'une base vers une autre (dump indépendant du moteur, via +`manage.py dumpdata`/`loaddata`) : + +```sh +make dump-db # écrit emitter/db-dump-.json (exclu de git, contient des données réelles) +# ... bascule DB_ENGINE, migrate ... +emitter/.venv/bin/python emitter/manage.py loaddata .json +``` + +## Reverse proxy nginx + TLS public + +```sh +sudo make install-nginx # lit DASHBOARD_DOMAIN dans .env +``` + +Obtient un certificat Let's Encrypt et déploie un vhost nginx devant le +tableau de bord (WebSocket inclus) — nécessaire pour un accès public ; +sans ça, le tableau de bord n'est accessible qu'en local/VPN. + +## Alternative à systemd : supervisor + +```sh +sudo make install-supervisor # bascule les 4 services de l'émetteur vers supervisor +sudo make uninstall-supervisor # retour arrière (réactive systemd) +``` + +## Mise à jour + +```sh +git pull +sudo make install +``` + +Réapplique dépendances/migrations et redémarre les services concernés, +sur n'importe quel noeud (y compris le master). diff --git a/docs/index.en.md b/docs/index.en.md new file mode 100644 index 0000000..e95eb96 --- /dev/null +++ b/docs/index.en.md @@ -0,0 +1,60 @@ +# Fail2banActionBanisher + +Distributed malicious-host banishing system built on **fail2ban** and +**iptables**, with event propagation over **MQTT**. + +Each node detects and bans locally (fail2ban + iptables), publishes the +event to a local Mosquitto broker, then relays it to a private **master** +broker (mTLS). The master centralizes the decision (multi-node +correlation, global recidive detection, shared reputation) and +republishes the actions to be executed to all subscribed nodes. + +Target machine: **Debian 13 VPS**. + +## Architecture (overview) + +``` +[node] fail2ban/iptables → local mosquitto (1883) → master mosquitto (8883, TLS) + │ + decision / correlation + │ +[node] ◄──────────────── MQTT action (BAN, SYNC_BAN, UNBAN, ...) ◄────────── +``` + +A Django dashboard (real-time, WebSocket) runs on every node and on the +master, with an aggregated multi-node view on the master side. + +## Use cases + +The master/nodes model doesn't assume any ownership relationship between +the protected servers — only that they share a common decision authority. +That makes the architecture relevant beyond a single operator running +their own VPSes: + +- **A set of sites** (several VPSes run by the same operator, each + hosting one or more services): an IP attacking one site gets banned + everywhere via `SYNC_BAN`, without manual per-site monitoring. +- **A community** (several independent administrators, each responsible + for their own server, who agree to pool their detections): every + member keeps their own node and their own local fail2ban — only ban + propagation is shared through the common master. No member gets SSH + access to another member's server. +- **An IT services company** managing several clients' sites: a + centralized master gives an aggregated view (jail/country counters, + per-node history, per-client aliases) without mixing client data — + each node only sees its own events, the master sees everything. + +In all three cases, a new server joins the set without ever handing out +SSH access to the others: a single-use [token-based +enrollment](deployment.en.md#token-based-node-enrollment-recommended) is +enough — particularly relevant for the community and services-company +cases, where the servers belong to different parties. + +## Where to go next + +- [Installation](installation.en.md) — development setup, first + production node. +- [Production deployment](deployment.en.md) — master, token-based node + enrollment, databases, reverse proxy, alternatives to systemd. +- [Reference](reference.en.md) — MQTT topics, master commands, custom + fail2ban jails. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e9104b4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,62 @@ +# Fail2banActionBanishment + +Système distribué de bannissement d'hôtes malveillants basé sur **fail2ban** +et **iptables**, avec propagation des événements par **MQTT**. + +Chaque noeud détecte et bannit localement (fail2ban + iptables), publie +l'événement sur un broker Mosquitto local, puis relaie l'information vers un +broker **master** privé (mTLS). Le master centralise la décision +(corrélation multi-noeuds, récidive globale, réputation partagée) et +republie les actions à exécuter vers l'ensemble des noeuds abonnés. + +Machine cible : **VPS Debian 13**. + +## Architecture (résumé) + +``` +[noeud] fail2ban/iptables → mosquitto local (1883) → mosquitto master (8883, TLS) + │ + décision / corrélation + │ +[noeud] ◄──────────────── action MQTT (BAN, SYNC_BAN, UNBAN, ...) ◄────────── +``` + +Un tableau de bord Django (temps réel, WebSocket) tourne sur chaque noeud et +sur le master, avec une vue agrégée multi-noeuds côté master. + +## Cas d'usage + +Le modèle master/noeuds ne suppose aucun lien d'appartenance entre les +serveurs protégés — seulement qu'ils partagent une même autorité de +décision. Ça rend l'architecture pertinente au-delà d'un opérateur unique +sur ses propres VPS : + +- **Un ensemble de sites** (plusieurs VPS d'un même opérateur, chacun + hébergeant un ou plusieurs services) : une IP qui attaque un site est + bannie partout via `SYNC_BAN`, sans surveillance manuelle site par site. +- **Une communauté** (plusieurs administrateurs indépendants, chacun + responsable de son propre serveur, qui acceptent de mutualiser leurs + détections) : chaque membre garde son propre noeud et son propre + fail2ban local — seule la propagation des bans est partagée via le + master commun. Aucun accès aux serveurs des autres membres n'est requis + ni accordé. +- **Une société de services informatiques** qui gère les sites de + plusieurs clients : un master centralisé donne une vue agrégée + (compteurs jail/pays, historique par noeud, alias par client) sans + mélanger les données de client à client — chaque noeud ne voit que ses + propres événements, le master voit tout. + +Dans les trois cas, un nouveau serveur rejoint l'ensemble sans jamais +donner accès SSH aux autres : une [auto-inscription par jeton à usage +unique](deployment.md#auto-inscription-dun-noeud-recommandé) suffit — +pertinent en particulier pour la communauté et la société de services, où +les serveurs appartiennent à des tiers différents. + +## Pour aller plus loin + +- [Installation](installation.md) — mise en route en développement, premier + noeud en production. +- [Déploiement en production](deployment.md) — master, auto-inscription + d'un noeud, bases de données, reverse proxy, alternatives à systemd. +- [Référence](reference.md) — topics MQTT, commandes du master, jails + fail2ban personnalisées. diff --git a/docs/installation.en.md b/docs/installation.en.md new file mode 100644 index 0000000..633bca9 --- /dev/null +++ b/docs/installation.en.md @@ -0,0 +1,73 @@ +# Installation + +## Prerequisites + +- **Debian 13** VPS, root access (or an account with `sudo`). +- The repository cloned into the `$HOME` of a **non-root** user dedicated + to this project (e.g. `banisher`) — `install.sh`/`make install` + determine this user from the repository's actual owner + (`stat -c '%U' `), never hardcoded. Trade-off accepted: the user + gets full `sudo` access (`NOPASSWD:ALL`), to be reserved for an account + strictly dedicated to this project. + +```sh +git clone ~/Fail2banMqttActionBanisher +cd ~/Fail2banMqttActionBanisher +``` + +## Development (without real fail2ban/iptables) + +Requires a reachable Redis (Django Channels channel layer), e.g. +`docker run -p 6379:6379 redis`. + +```sh +make run # dashboard + map, http://127.0.0.1:8000/ +make mqtt # in a second terminal: MQTT ingestion +make worker # in a third terminal: async geolocation +``` + +These targets automatically create the Python venv (`emitter/.venv`) and +install dependencies on first run. + +Environment variables (`MQTT_BROKER_HOST`, `MQTT_BROKER_USERNAME`, +`MQTT_BROKER_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, ...): copy +`.env.example` to `.env` at the repo root and edit it — loaded +automatically by Django (`python-dotenv`), nothing to export by hand. + +## First production node + +!!! warning "Must be adapted before `make install`" + `generic/firewall/firewall-filters.conf` and the files under + `generic/fail2ban/jail.d/` come from the reference server + (`miraceti-vps1`, a master) and contain rules specific to it + (OpenVPN, Coturn, WireGuard, Portainer, a hardcoded public IP, + `DOCKER-USER`...). On a plain node, keep only what's actually + exposed (typically SSH + 80/443) — the unneeded sections reference + interfaces (`$VPN_IF`, `$WG_IF`) that don't exist on a node, which + produces silent `iptables` errors (the script keeps going, but the + intended rule is never applied). Also check `ETH_IF` in + `generic/firewall/firewall-launcher.sh` (real interface name: + `ip -o link show`), and disable `ufw` if present (having both + `iptables` rulesets active at once breaks one of them in + unpredictable ways). + +```sh +sudo make install +sudo make status +``` + +Deploys and enables: system packages (fail2ban, iptables, mosquitto, +Redis), firewall, custom fail2ban jails, local Mosquitto broker, and the +Django emitter's four services (web, MQTT ingestion, Celery worker, +master relay). Idempotent: re-running `sudo make install` after a +`git pull` reapplies dependencies/migrations and restarts the affected +services. + +Edit `/etc/fail2ban/mqtt.conf` (real MQTT credentials, copied from +`generic/fail2ban/mqtt.conf.example` if it doesn't exist yet) before the +first deployment. + +A freshly installed node already works standalone (local detection and +banning) ; the relay to a master (`fail2ban-emitter-master-client`) will +keep retrying in a loop until a node certificate has been obtained — see +[Production deployment](deployment.en.md) for what's next. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..9a1662e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,74 @@ +# Installation + +## Prérequis + +- VPS **Debian 13**, accès root (ou un compte avec `sudo`). +- Le dépôt cloné dans le `$HOME` d'un utilisateur **non-root** dédié à ce + projet (ex. `banisher`) — `install.sh`/`make install` déterminent cet + utilisateur à partir du propriétaire réel du dépôt + (`stat -c '%U' `), jamais codé en dur. Contrepartie assumée : + l'utilisateur reçoit un accès `sudo` complet (`NOPASSWD:ALL`), à + réserver à un compte strictement dédié à ce projet. + +```sh +git clone ~/Fail2banMqttActionBanisher +cd ~/Fail2banMqttActionBanisher +``` + +## Développement (sans fail2ban/iptables réels) + +Nécessite un Redis accessible (channel layer Django Channels), par exemple +`docker run -p 6379:6379 redis`. + +```sh +make run # tableau de bord + carte, http://127.0.0.1:8000/ +make mqtt # dans un second terminal : ingestion MQTT +make worker # dans un troisième terminal : géolocalisation asynchrone +``` + +Ces cibles créent automatiquement le venv Python (`emitter/.venv`) et +installent les dépendances au premier lancement. + +Variables d'environnement (`MQTT_BROKER_HOST`, `MQTT_BROKER_USERNAME`, +`MQTT_BROKER_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, ...) : copier +`.env.example` vers `.env` à la racine du dépôt et l'éditer — chargé +automatiquement par Django (`python-dotenv`), rien à exporter à la main. + +## Premier noeud en production + +!!! warning "À adapter impérativement avant `make install`" + `generic/firewall/firewall-filters.conf` et les fichiers de + `generic/fail2ban/jail.d/` proviennent du serveur de référence + (`miraceti-vps1`, un master) et contiennent des règles qui lui sont + propres (OpenVPN, Coturn, WireGuard, Portainer, IP publique codée en + dur, `DOCKER-USER`...). Sur un simple noeud, ne garder que ce qui est + réellement exposé (typiquement SSH + 80/443) — les sections + superflues réfèrent des interfaces (`$VPN_IF`, `$WG_IF`) absentes + d'un noeud, ce qui produit des erreurs `iptables` silencieuses (le + script continue, mais la règle voulue n'est jamais posée). Vérifier + aussi `ETH_IF` dans `generic/firewall/firewall-launcher.sh` (nom + réel de l'interface : `ip -o link show`) et désactiver `ufw` s'il est + présent (coexister avec les règles `iptables` posées ici casserait + l'un des deux jeux de règles de façon imprévisible). + +```sh +sudo make install +sudo make status +``` + +Déploie et active : paquets système (fail2ban, iptables, mosquitto, +Redis), pare-feu, jails fail2ban personnalisées, broker Mosquitto local, +et les quatre services de l'émetteur Django (web, ingestion MQTT, worker +Celery, relais vers le master). Idempotent : relancer `sudo make install` +après un `git pull` réapplique dépendances/migrations et redémarre les +services concernés. + +Éditer `/etc/fail2ban/mqtt.conf` (identifiants MQTT réels, copié depuis +`generic/fail2ban/mqtt.conf.example` s'il n'existe pas encore) avant le +premier déploiement. + +Un noeud fraîchement installé fonctionne déjà en autonome (détection et +ban locaux) ; le relais vers un master (`fail2ban-emitter-master-client`) +tentera de se connecter en boucle tant qu'aucun certificat de noeud n'a +été obtenu — voir [Déploiement en production](deployment.md) pour la +suite. diff --git a/docs/reference.en.md b/docs/reference.en.md new file mode 100644 index 0000000..7bad73e --- /dev/null +++ b/docs/reference.en.md @@ -0,0 +1,61 @@ +# Reference + +## MQTT topics + +| Topic | Direction | Role | +|---|---|---| +| `fail2ban//ban` | node → master | ban/notice event published by this node | +| `fail2ban//heartbeat` | node → master | periodic liveness proof (independent of any ban activity) | +| `fail2ban//ack` | node → master | acknowledgement after executing a command received from the master | +| `fail2ban//action` | master → node | command addressed specifically to this node | +| `fail2ban/broadcast/action` | master → all nodes | command broadcast to all subscribed nodes | +| `fail2ban/broadcast/roster` | master → all nodes | list of known nodes + global jail/country counters, periodic | + +`` in the topic segment is the mTLS client certificate's CN +(enforced by the broker's ACL) — the node's internal identifier +(`uuid.getnode()`) travels in the message body, not in the topic. + +## Master commands + +| Command | Effect on the receiving node | Trigger | +|---|---|---| +| `SYNC_BAN` | Full ban (all ports) via a dedicated jail | Automatic: the master systematically publishes a `SYNC_BAN` for every ban it ingests | +| `ESCALATE` | **Permanent** ban via a dedicated jail | Automatic: the master correlates — an IP independently banned on several distinct nodes within 24h triggers an escalation | +| `BAN_ALLPORTS` | Full ban (all ports), same mechanism as `SYNC_BAN` | Manual (targeted human decision) | +| `WHITELIST` | Added to `ignoreip` on all active jails + unbanned — in-memory, not persistent | Manual | +| `RATE_LIMIT` | Throttle (10 req/s, burst 20) instead of a full block | Manual — edge case, likely false positives | +| `NOTIFY_ONLY` | No network action, just a visible dashboard notification | Manual — observation mode | +| `REPORT_ABUSE` | Report to AbuseIPDB — **not broadcast to nodes**, submitted directly by the master (one report per IP is enough) | Manual, dry-run until `ABUSEIPDB_ENABLED`/`ABUSEIPDB_API_KEY` are configured | + +Manual commands are triggered on the master, by hand: + +```sh +emitter/.venv/bin/python emitter/manage.py publish_command WHITELIST 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command BAN_ALLPORTS 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command RATE_LIMIT 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command REPORT_ABUSE 203.0.113.5 +``` + +Geolocation of banned IPs is automatic (asynchronous, on ingestion of +each event); to retry the ones left without coordinates (API failure, +private IP): + +```sh +emitter/.venv/bin/python emitter/manage.py retry_geolocation # all ungeolocated events +emitter/.venv/bin/python emitter/manage.py retry_geolocation --ip # a specific IP +``` + +## Custom fail2ban jails + +| Jail | Role | +|---|---| +| `emitter-admin-auth` | Dashboard `/admin/` lockout (django-axes) → network ban | +| `master-sync` | Receiving a `SYNC_BAN`/`BAN_ALLPORTS` — never triggered by a log, only by manual injection from the master relay | +| `master-ratelimit` | Receiving a `RATE_LIMIT` | +| `master-escalate` | Receiving an `ESCALATE` — permanent ban | + +`emitter-admin-auth` follows a reusable template +(`generic/fail2ban/jail.d/app-auth.conf.template` + +`generic/fail2ban/filter.d/app-auth.conf`) to protect any other +application exposed on the same server the same way, not just this +dashboard. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..d8d4123 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,61 @@ +# Référence + +## Topics MQTT + +| Topic | Sens | Rôle | +|---|---|---| +| `fail2ban//ban` | noeud → master | événement de ban/notice publié par ce noeud | +| `fail2ban//heartbeat` | noeud → master | preuve de vie périodique (indépendante de toute activité de ban) | +| `fail2ban//ack` | noeud → master | accusé de réception après exécution d'une commande reçue du master | +| `fail2ban//action` | master → noeud | commande adressée spécifiquement à ce noeud | +| `fail2ban/broadcast/action` | master → tous les noeuds | commande diffusée à tous les noeuds abonnés | +| `fail2ban/broadcast/roster` | master → tous les noeuds | liste des noeuds connus + compteurs jail/pays globaux, périodique | + +`` dans le segment de topic est le CN du certificat client mTLS +(imposé par l'ACL du broker) — l'identifiant interne du noeud +(`uuid.getnode()`) voyage dans le corps du message, pas dans le topic. + +## Commandes du master + +| Commande | Effet sur le noeud qui la reçoit | Déclenchement | +|---|---|---| +| `SYNC_BAN` | Ban total (toutes ports) via une jail dédiée | Automatique : le master publie systématiquement un `SYNC_BAN` pour chaque ban qu'il ingère | +| `ESCALATE` | Ban **permanent** via une jail dédiée | Automatique : le master corrèle — une IP bannie indépendamment sur plusieurs noeuds distincts en 24h déclenche une escalade | +| `BAN_ALLPORTS` | Ban total (toutes ports), même mécanisme que `SYNC_BAN` | Manuel (décision humaine ciblée) | +| `WHITELIST` | Ajout à `ignoreip` sur toutes les jails actives + débannit — en mémoire, non persistant | Manuel | +| `RATE_LIMIT` | Throttle (10 req/s, burst 20) au lieu d'un blocage total | Manuel — cas limite, faux positifs probables | +| `NOTIFY_ONLY` | Aucune action réseau, juste une notification visible sur le tableau de bord | Manuel — mode observation | +| `REPORT_ABUSE` | Signalement à AbuseIPDB — **pas diffusé aux noeuds**, soumis directement par le master (un rapport par IP suffit) | Manuel, dry-run tant que `ABUSEIPDB_ENABLED`/`ABUSEIPDB_API_KEY` ne sont pas configurés | + +Les commandes manuelles se déclenchent sur le master, à la main : + +```sh +emitter/.venv/bin/python emitter/manage.py publish_command WHITELIST 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command BAN_ALLPORTS 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command RATE_LIMIT 203.0.113.5 +emitter/.venv/bin/python emitter/manage.py publish_command REPORT_ABUSE 203.0.113.5 +``` + +La géolocalisation des IP bannies est automatique (asynchrone, dès +l'ingestion d'un événement) ; pour relancer celles restées sans +coordonnées (échec d'API, IP privée) : + +```sh +emitter/.venv/bin/python emitter/manage.py retry_geolocation # tous les événements non géolocalisés +emitter/.venv/bin/python emitter/manage.py retry_geolocation --ip # une IP précise +``` + +## Jails fail2ban personnalisées + +| Jail | Rôle | +|---|---| +| `emitter-admin-auth` | Verrouillage `/admin/` du tableau de bord (django-axes) → ban réseau | +| `master-sync` | Réception d'un `SYNC_BAN`/`BAN_ALLPORTS` — jamais déclenchée par un log, seulement par injection manuelle depuis le relais master | +| `master-ratelimit` | Réception d'un `RATE_LIMIT` | +| `master-escalate` | Réception d'un `ESCALATE` — ban permanent | + +`emitter-admin-auth` suit un gabarit réutilisable +(`generic/fail2ban/jail.d/app-auth.conf.template` + +`generic/fail2ban/filter.d/app-auth.conf`) pour protéger de la même façon +n'importe quelle autre application exposée sur le même serveur, pas +seulement ce tableau de bord. diff --git a/emitter/banevents/__init__.py b/emitter/banevents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emitter/banevents/abuseipdb.py b/emitter/banevents/abuseipdb.py new file mode 100644 index 0000000..aedc873 --- /dev/null +++ b/emitter/banevents/abuseipdb.py @@ -0,0 +1,28 @@ +"""Soumission d'un rapport à AbuseIPDB (Phase 4, REPORT_ABUSE) — fonction +pure, sans effet de bord hors l'appel réseau, appelée directement par +management/commands/publish_command.py (jamais diffusée en MQTT, voir sa +docstring pour le pourquoi : un rapport par IP suffit, le diffuser à +chaque noeud produirait des doublons vus comme du bruit par le service). +""" +import requests +from django.conf import settings + +ABUSEIPDB_REPORT_URL = 'https://api.abuseipdb.com/api/v2/report' + + +def submit_report(ip: str, categories: str, comment: str) -> tuple[bool, str]: + """POST vers l'API v2 AbuseIPDB. Retourne (succès, detail — message + d'erreur si échec), jamais d'exception laissée remonter — même forme + que master_client.py::_banip_via_jail, pour un traitement uniforme + côté appelant.""" + try: + response = requests.post( + ABUSEIPDB_REPORT_URL, + headers={'Key': settings.ABUSEIPDB_API_KEY, 'Accept': 'application/json'}, + data={'ip': ip, 'categories': categories, 'comment': comment}, + timeout=10, + ) + response.raise_for_status() + except requests.RequestException as e: + return False, str(e) + return True, '' diff --git a/emitter/banevents/admin.py b/emitter/banevents/admin.py new file mode 100644 index 0000000..27fa671 --- /dev/null +++ b/emitter/banevents/admin.py @@ -0,0 +1,97 @@ +import datetime + +from django.contrib import admin, messages +from django.core.mail import send_mail +from django.db.models import QuerySet +from django.http import HttpRequest +from django.utils import timezone + +from .enrollment import issue_join_token, join_command +from .models import BanEvent, EnrollmentRequest, JoinToken, NodeRegistry +from .views import NODE_OFFLINE_THRESHOLD_SECONDS + + +@admin.register(BanEvent) +class BanEventAdmin(admin.ModelAdmin): + list_display = ('received_at', 'node', 'jail_name', 'action', 'ip_address', 'bantime') + list_filter = ('jail_name', 'action', 'node') + search_fields = ('ip_address', 'node', 'jail_name') + ordering = ('-received_at',) + + +@admin.register(NodeRegistry) +class NodeRegistryAdmin(admin.ModelAdmin): + """Seule UI d'édition d'alias/pays par noeud — pas de page dédiée. + node_id reste en premier et hors list_editable (Django exige que la + première colonne serve de lien vers la page de modification).""" + list_display = ('node_id', 'alias', 'country', 'dashboard_url', 'is_online', 'last_seen', 'first_seen') + list_editable = ('alias', 'country', 'dashboard_url') + search_fields = ('node_id', 'alias') + ordering = ('node_id',) + + @admin.display(boolean=True, description='En ligne') + def is_online(self, obj: NodeRegistry) -> bool: + threshold = timezone.now() - datetime.timedelta(seconds=NODE_OFFLINE_THRESHOLD_SECONDS) + return obj.last_seen >= threshold + + +@admin.register(JoinToken) +class JoinTokenAdmin(admin.ModelAdmin): + """Visibilité des jetons émis (manage.py create_join_token) — jamais + créés/édités ici, token_hash n'a d'ailleurs aucun intérêt à être + affiché (c'est un hash, le jeton en clair n'est montré qu'une fois, + en ligne de commande, jamais persisté).""" + list_display = ('node_name', 'created_at', 'expires_at', 'used_at', 'status') + search_fields = ('node_name',) + ordering = ('-created_at',) + + @admin.display(description='Statut') + def status(self, obj: JoinToken) -> str: + if obj.used_at: + return 'Utilisé' + if timezone.now() > obj.expires_at: + return 'Expiré' + return 'Actif' + + +@admin.register(EnrollmentRequest) +class EnrollmentRequestAdmin(admin.ModelAdmin): + """Demandes soumises via la page publique /communaute/ — jamais de + jeton émis/envoyé sans passer par l'action approve_and_send_token + ci-dessous, déclenchée à la main.""" + list_display = ('email', 'node_name', 'country', 'status', 'created_at', 'processed_at') + list_filter = ('status',) + search_fields = ('email', 'node_name') + ordering = ('-created_at',) + actions = ['approve_and_send_token', 'reject'] + + @admin.action(description="Approuver et envoyer le jeton d'inscription par email") + def approve_and_send_token(self, request: HttpRequest, queryset: QuerySet[EnrollmentRequest]) -> None: + approved = 0 + for enrollment in queryset.filter(status=EnrollmentRequest.STATUS_PENDING): + token, _join_token = issue_join_token(enrollment.node_name) + send_mail( + subject='Fail2banActionBanisher — inscription acceptée', + message=( + f"Votre demande pour rejoindre la communauté (noeud '{enrollment.node_name}') " + 'a été acceptée.\n\n' + 'Sur votre serveur, après avoir cloné le dépôt et configuré .env :\n\n' + f' {join_command(enrollment.node_name, token)}\n' + ' sudo make install\n\n' + 'Ce jeton est à usage unique et expire dans 60 minutes.' + ), + from_email=None, # DEFAULT_FROM_EMAIL + recipient_list=[enrollment.email], + ) + enrollment.status = EnrollmentRequest.STATUS_APPROVED + enrollment.processed_at = timezone.now() + enrollment.save(update_fields=['status', 'processed_at']) + approved += 1 + self.message_user(request, f'{approved} demande(s) approuvée(s), jeton envoyé par email.', messages.SUCCESS) + + @admin.action(description='Rejeter') + def reject(self, request: HttpRequest, queryset: QuerySet[EnrollmentRequest]) -> None: + updated = queryset.filter(status=EnrollmentRequest.STATUS_PENDING).update( + status=EnrollmentRequest.STATUS_REJECTED, processed_at=timezone.now(), + ) + self.message_user(request, f'{updated} demande(s) rejetée(s).', messages.SUCCESS) diff --git a/emitter/banevents/apps.py b/emitter/banevents/apps.py new file mode 100644 index 0000000..e201069 --- /dev/null +++ b/emitter/banevents/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BaneventsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'banevents' + + def ready(self) -> None: + from . import signals # noqa: F401 diff --git a/emitter/banevents/consumers.py b/emitter/banevents/consumers.py new file mode 100644 index 0000000..7746114 --- /dev/null +++ b/emitter/banevents/consumers.py @@ -0,0 +1,26 @@ +import json +from typing import Any + +from channels.generic.websocket import AsyncWebsocketConsumer + +GROUP_NAME = 'banevents' + + +class BanEventConsumer(AsyncWebsocketConsumer): + """Pousse chaque nouveau BanEvent aux clients connectés au tableau de bord.""" + + async def connect(self) -> None: + await self.channel_layer.group_add(GROUP_NAME, self.channel_name) + await self.accept() + + async def disconnect(self, close_code: int) -> None: + await self.channel_layer.group_discard(GROUP_NAME, self.channel_name) + + async def ban_event(self, event: dict[str, Any]) -> None: + await self.send(text_data=json.dumps({'kind': 'ban', **event['payload']})) + + async def command_notification(self, event: dict[str, Any]) -> None: + """Issue d'une commande reçue du master (banevents.management. + commands.master_client) — succès/échec d'un SYNC_BAN, ou commande + non gérée. Même group_send que ban_event, discriminé par `kind`.""" + await self.send(text_data=json.dumps({'kind': 'command', **event['payload']})) diff --git a/emitter/banevents/enrollment.py b/emitter/banevents/enrollment.py new file mode 100644 index 0000000..6e039e9 --- /dev/null +++ b/emitter/banevents/enrollment.py @@ -0,0 +1,35 @@ +"""Émission de jetons d'auto-inscription (flux "kubeadm join", voir +ROADMAP.md et banevents/views.py::join_node) — factorisé pour être +appelé à la fois par `manage.py create_join_token` (CLI, décision +humaine directe) et par `EnrollmentRequestAdmin.approve_and_send_token` +(validation d'une demande soumise via /communaute/) : un seul endroit qui +touche le hachage/l'expiration du jeton. +""" +import datetime +import hashlib +import os +import secrets + +from django.utils import timezone + +from .models import JoinToken + +DEFAULT_TTL_MINUTES = 60 + + +def issue_join_token(node_name: str, ttl_minutes: int = DEFAULT_TTL_MINUTES) -> tuple[str, JoinToken]: + """Génère un jeton en clair (jamais persisté tel quel) + l'objet + JoinToken correspondant (seul le hash SHA-256 est stocké).""" + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest() + expires_at = timezone.now() + datetime.timedelta(minutes=ttl_minutes) + join_token = JoinToken.objects.create(node_name=node_name, token_hash=token_hash, expires_at=expires_at) + return token, join_token + + +def join_command(node_name: str, token: str) -> str: + """Commande complète à communiquer (CLI ou email) — DASHBOARD_DOMAIN + déjà chargé dans os.environ par python-dotenv (cf. settings/base.py).""" + master_url = os.environ.get('DASHBOARD_DOMAIN', '') + master_url = f'https://{master_url}' if master_url else '' + return f'sudo make join MASTER={master_url} NODE={node_name} TOKEN={token}' diff --git a/emitter/banevents/forms.py b/emitter/banevents/forms.py new file mode 100644 index 0000000..045ff5f --- /dev/null +++ b/emitter/banevents/forms.py @@ -0,0 +1,24 @@ +from django import forms + +from .models import EnrollmentRequest + + +class EnrollmentRequestForm(forms.ModelForm): + """Formulaire public de /communaute/. `website` n'est pas un champ du + modèle (absent de Meta.fields, jamais sauvegardé) — honeypot anti-spam + classique : caché par CSS côté template, un humain ne le remplit + jamais, un bot qui remplit tout aveuglément si. Rempli => la vue + ignore silencieusement la soumission, sans message d'erreur (ne pas + indiquer au bot qu'il a été détecté).""" + + website = forms.CharField(required=False, widget=forms.HiddenInput) + + class Meta: + model = EnrollmentRequest + fields = ['email', 'node_name', 'country', 'message'] + widgets = { + 'message': forms.Textarea(attrs={'rows': 4}), + } + + def is_spam(self) -> bool: + return bool(self.cleaned_data.get('website')) diff --git a/emitter/banevents/ingestion.py b/emitter/banevents/ingestion.py new file mode 100644 index 0000000..a648f3d --- /dev/null +++ b/emitter/banevents/ingestion.py @@ -0,0 +1,79 @@ +"""Logique d'ingestion partagée entre mqtt_listen (broker local, un seul +noeud) et master_listen (broker master, tous les noeuds) : les deux +persistent dans le même modèle BanEvent, donnant une vue multi-noeuds +unifiée via le filtre par noeud déjà présent sur le tableau de bord.""" +import datetime +from typing import Any + +from django.utils import timezone + +from .models import BanEvent, EnrollmentRequest, NodeRegistry + + +def clean_tag(value: Any) -> str: + """Normalise un tag fail2ban optionnel : "-" (défaut Init de l'action, + pour les jails sans port/protocole propre, ex. recidive) devient vide.""" + value = str(value or '').strip() + return '' if value == '-' else value + + +def save_ban_event(payload: dict[str, Any], alias_hint: str = '') -> BanEvent: + event_time = None + raw_time = payload.get('time') + if raw_time: + try: + event_time = timezone.make_aware( + datetime.datetime.fromtimestamp(float(raw_time)), timezone.get_default_timezone() + ) + except (TypeError, ValueError, OSError): + event_time = None + + bantime = payload.get('bantime') + try: + bantime = int(float(bantime)) if bantime is not None else None + except (TypeError, ValueError): + bantime = None + + node = str(payload.get('node', '')) + # filter().update() plutôt que update_or_create() : une seule requête + # sur le chemin chaud (noeud déjà connu, la quasi-totalité du trafic), + # là où update_or_create() fait toujours un SELECT ... FOR UPDATE (no-op + # silencieux sur SQLite, qui ne supporte pas ce verrou) + un UPDATE. + # Contourne .save()/les signaux Django — sans conséquence aujourd'hui + # (NodeRegistry n'a aucun signal), à revoir si un jour l'un lui en ajoute. + if not NodeRegistry.objects.filter(node_id=node).update(last_seen=timezone.now()): + # alias_hint (CN du certificat client, cf. master_listen.py:: + # handle_ban) : uniquement à la création, jamais pour écraser un + # alias déjà édité à la main dans /admin/ — get_or_create() ne + # touche 'defaults' que si la ligne est effectivement créée. + defaults = {'alias': alias_hint} + # Requête EnrollmentRequest.country volontairement isolée dans + # cette branche (jamais exécutée sur le chemin chaud, cf. + # commentaire ci-dessus sur filter().update()) : un noeud rejoint + # via /communaute/ a laissé le pays de son serveur dans sa + # demande approuvée — .node_name y correspond au CN présenté ici + # (alias_hint), pas de lien direct en base entre les deux tables. + if alias_hint: + enrollment = ( + EnrollmentRequest.objects.filter( + node_name=alias_hint, status=EnrollmentRequest.STATUS_APPROVED, country__gt='', + ) + .order_by('-processed_at') + .first() + ) + if enrollment: + defaults['country'] = enrollment.country + NodeRegistry.objects.get_or_create(node_id=node, defaults=defaults) + + return BanEvent.objects.create( + node=node, + action=str(payload.get('action', '')), + jail_name=str(payload.get('name', '')), + ip_address=payload['ip'], + port=clean_tag(payload.get('port')), + protocol=clean_tag(payload.get('protocol')), + bantime=bantime, + reason=str(payload.get('reason', '')), + event_time=event_time, + raw_payload=payload, + ) diff --git a/emitter/banevents/management/__init__.py b/emitter/banevents/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emitter/banevents/management/commands/__init__.py b/emitter/banevents/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emitter/banevents/management/commands/create_join_token.py b/emitter/banevents/management/commands/create_join_token.py new file mode 100644 index 0000000..3d805ea --- /dev/null +++ b/emitter/banevents/management/commands/create_join_token.py @@ -0,0 +1,40 @@ +"""Émet un jeton d'auto-inscription à usage unique pour un nouveau noeud +(flux "kubeadm join", voir ROADMAP.md et banevents/views.py::join_node). +À lancer sur le MASTER uniquement — le jeton n'a de sens que contre +l'endpoint /api/join/ de cette même instance. + +Usage : + manage.py create_join_token [--ttl-minutes 60] + +Le jeton n'est affiché QU'UNE SEULE FOIS ici, en clair — seul son hash est +persisté (JoinToken.token_hash). Impossible de le retrouver après coup : +en cas de perte, en émettre un nouveau (le précédent reste valide jusqu'à +son expiration ou son utilisation, aucun conflit). +""" +from typing import Any + +from django.core.management.base import BaseCommand + +from banevents.enrollment import DEFAULT_TTL_MINUTES, issue_join_token, join_command + + +class Command(BaseCommand): + help = "Émet un jeton d'auto-inscription à usage unique pour un nouveau noeud." + + def add_arguments(self, parser: Any) -> None: + parser.add_argument('node_name') + parser.add_argument('--ttl-minutes', type=int, default=DEFAULT_TTL_MINUTES) + + def handle(self, *args: Any, **options: Any) -> None: + node_name = options['node_name'] + ttl_minutes = options['ttl_minutes'] + + token, _ = issue_join_token(node_name, ttl_minutes) + + self.stdout.write(self.style.SUCCESS( + f"Jeton émis pour '{node_name}' (expire dans {ttl_minutes} min, usage unique) :" + )) + self.stdout.write('') + self.stdout.write(f' {join_command(node_name, token)}') + self.stdout.write('') + self.stdout.write('À lancer sur le nouveau noeud, puis "sudo make install" comme d\'habitude.') diff --git a/emitter/banevents/management/commands/master_client.py b/emitter/banevents/management/commands/master_client.py new file mode 100644 index 0000000..47d7676 --- /dev/null +++ b/emitter/banevents/management/commands/master_client.py @@ -0,0 +1,439 @@ +"""Client MQTT vers le broker master (Phase 2.b/4). + +Relaie chaque événement fail2ban publié sur le broker local (même topic +que banevents.mqtt_listen, fail2ban/+/jail) vers le broker master en mTLS, +sous fail2ban//ban. S'abonne en retour aux commandes +du master (fail2ban//action et +fail2ban/broadcast/action) et aux mises à jour du roster (Phase 3, voir +banevents.management.commands.master_listen). + +Commandes reconnues (command_handlers ci-dessous) : SYNC_BAN, BAN_ALLPORTS, +WHITELIST, NOTIFY_ONLY, RATE_LIMIT, ESCALATE. Pour en ajouter une : écrire +une méthode `execute_(self, command: dict) -> None` qui termine +toujours par un appel à `self.report_outcome(cmd, ip, status, +detail='')`, puis l'ajouter au dict `self.command_handlers` dans +`handle()`. Toute commande reçue sans handler enregistré est journalisée +sans action. + +Trois formes possibles pour une commande Phase 4 — choisir AVANT +d'écrire du code (voir ROADMAP.md, Phase 4, "procédure d'ajout") : +1. Réaction automatique décidée par le master (comme SYNC_BAN/ESCALATE) → + un handler ici + une règle `correlation_rule_` dans + master_listen.py. +2. Décision manuelle diffusée à tous les noeuds (comme + WHITELIST/BAN_ALLPORTS/RATE_LIMIT) → un handler ici + ajout aux + MANUAL_COMMANDS de publish_command.py. +3. Action master-only, jamais exécutée par un noeud (comme REPORT_ABUSE) + → PAS de handler ici du tout, branchement direct dans + publish_command.py. + +Ce relais est un process indépendant de mqtt_listen : les deux s'abonnent +séparément au même topic local, chacun avec sa propre connexion/identité. +""" +import ipaddress +import json +import os +import ssl +import subprocess +import threading +import uuid +from typing import Any + +import paho.mqtt.client as mqtt +import redis +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from banevents.consumers import GROUP_NAME +from banevents.models import NodeRegistry +from banevents.stats import ROSTER_REDIS_DB, ROSTER_REDIS_KEY, ROSTER_TTL_SECONDS + +MASTER_SYNC_JAIL = 'master-sync' +MASTER_RATELIMIT_JAIL = 'master-ratelimit' +MASTER_ESCALATE_JAIL = 'master-escalate' +# Intervalle de heartbeat (Phase 3, supervision) : assez court pour qu'une +# coupure soit détectée en quelques minutes côté dashboard (seuil "hors +# ligne" = 3x cet intervalle, cf. views.py), assez long pour rester +# négligeable en trafic MQTT. +HEARTBEAT_INTERVAL_SECONDS = 60 + + +class Command(BaseCommand): + help = 'Relaie les événements fail2ban locaux vers le broker master et applique ses commandes (SYNC_BAN).' + + def handle(self, *args: Any, **options: Any) -> None: + if not settings.MQTT_MASTER_NODE_NAME: + raise CommandError( + 'MQTT_MASTER_NODE_NAME est vide. Doit être identique au nom ' + 'passé à scripts/generate-node-cert.sh (le CN du certificat ' + "client) : c'est ce que le master utilisera pour restreindre " + 'ce noeud via ACL (fail2ban//...).' + ) + self.node_topic_prefix = f'fail2ban/{settings.MQTT_MASTER_NODE_NAME}' + # Identité fail2ban locale (uuid.getnode(), posée par + # f2b_mqtt_action_banisher.py dans chaque payload) — distincte de + # MQTT_MASTER_NODE_NAME (l'alias/CN mTLS) : sert uniquement à + # reconnaître "ce SYNC_BAN vient de ce noeud", pas à s'authentifier. + self.local_node_id = str(uuid.getnode()) + # Communiqué au master via le heartbeat (voir send_heartbeat) pour + # alimenter automatiquement NodeRegistry.dashboard_url côté master + # (master_listen.py::handle_heartbeat) — os.environ direct, pas un + # Django setting : DASHBOARD_DOMAIN est propre à CE noeud (vide sur + # un noeud sans tableau de bord public exposé), même lecture directe + # que enrollment.py::join_command. + dashboard_domain = os.environ.get('DASHBOARD_DOMAIN', '') + self.dashboard_url = f'https://{dashboard_domain}' if dashboard_domain else '' + self.heartbeat_timer: threading.Timer | None = None + self.command_handlers = { + 'SYNC_BAN': self.execute_sync_ban, + 'BAN_ALLPORTS': self.execute_ban_allports, + 'WHITELIST': self.execute_whitelist, + 'NOTIFY_ONLY': self.execute_notify_only, + 'RATE_LIMIT': self.execute_rate_limit, + 'ESCALATE': self.execute_escalate, + } + # Cache du roster (Phase 3, cf. master_listen.py) — DB dédiée + # (db=2) : db=0 déjà pris par Channels (CHANNEL_LAYERS), db=1 par + # Celery (CELERY_BROKER_URL). + self.redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=ROSTER_REDIS_DB) + + self.local_client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-relay-local-{uuid.uuid4().hex[:8]}' + ) + self.local_client.username_pw_set(settings.MQTT_BROKER_USERNAME, settings.MQTT_BROKER_PASSWORD) + self.local_client.on_connect = self.on_local_connect + self.local_client.on_message = self.on_local_message + + self.master_client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-relay-master-{uuid.uuid4().hex[:8]}' + ) + # ca_certs=None : le magasin de CA système (défaut Python) sait déjà + # vérifier le certificat serveur Let's Encrypt du master. Notre CA + # privée (scripts/master-ca-init.sh) ne sert qu'à ce que LE MASTER + # vérifie NOTRE certificat client (mTLS) — elle n'a rien à voir avec + # la vérification du certificat serveur par CE client, et la fournir + # ici écrase le magasin par défaut au lieu de s'y ajouter, faisant + # échouer la validation du certificat serveur (constaté : + # SSLCertVerificationError "unable to get local issuer certificate"). + self.master_client.tls_set( + certfile=settings.MQTT_MASTER_CLIENT_CERT, + keyfile=settings.MQTT_MASTER_CLIENT_KEY, + tls_version=ssl.PROTOCOL_TLSv1_2, + ) + self.master_client.on_connect = self.on_master_connect + self.master_client.on_message = self.on_master_message + # Callback dédié (pas on_master_message) : le roster est un flux + # séparé des commandes (action), pas la peine de faire cohabiter + # deux formats de message dans un seul handler générique. + self.master_client.message_callback_add('fail2ban/broadcast/roster', self.on_roster_message) + + self.stdout.write(f'Connexion locale à {settings.MQTT_BROKER_HOST}:{settings.MQTT_BROKER_PORT}') + self.local_client.connect(settings.MQTT_BROKER_HOST, settings.MQTT_BROKER_PORT, keepalive=60) + self.local_client.loop_start() + + # connect_async + retry_first_connection : si le master est injoignable + # au démarrage (réseau, certificat pas encore en place, ...), + # loop_forever() réessaie tout seul au lieu de faire planter toute la + # commande (y compris le relais local, qui fonctionnerait pourtant + # très bien sans lui). Sans retry_first_connection, un échec de LA + # toute première tentative fait remonter l'exception au lieu d'être + # réessayé (constaté : ConnectionRefusedError non rattrapée). + self.stdout.write(f'Connexion master à {settings.MQTT_MASTER_HOST}:{settings.MQTT_MASTER_PORT}') + self.master_client.connect_async(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=60) + self.master_client.reconnect_delay_set(min_delay=1, max_delay=30) + self.master_client.loop_forever(retry_first_connection=True) + + def on_local_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + properties: Any = None, + ) -> None: + self.stdout.write(self.style.SUCCESS(f'Connecté au broker local ({reason_code})')) + client.subscribe(settings.MQTT_TOPIC_SUBSCRIBE) + + def on_local_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(message.payload.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + self.stderr.write(self.style.ERROR(f'Message local invalide : {e}')) + return + + # Le topic doit correspondre au CN du certificat mTLS de ce process + # (MQTT_MASTER_NODE_NAME), jamais au "node" interne du payload + # (uuid.getnode() côté fail2ban, un simple identifiant matériel) : le + # master ACL chaque connexion sur son identité de certificat, donc + # un topic qui ne correspond pas à ce CN serait rejeté. + topic = f'{self.node_topic_prefix}/ban' + self.master_client.publish(topic, json.dumps(payload), qos=1) + self.stdout.write(f'Relayé vers le master : {topic} {payload.get("action")} {payload.get("ip")}') + + def on_master_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + properties: Any = None, + ) -> None: + self.stdout.write(self.style.SUCCESS(f'Connecté au master ({reason_code})')) + client.subscribe(f'{self.node_topic_prefix}/action') + client.subscribe('fail2ban/broadcast/action') + client.subscribe('fail2ban/broadcast/roster') + # on_master_connect peut se redéclencher à chaque reconnexion : + # annuler toute chaîne de timer précédente pour ne pas en empiler + # plusieurs en parallèle (heartbeats en double). + if self.heartbeat_timer is not None: + self.heartbeat_timer.cancel() + self.send_heartbeat() + + def send_heartbeat(self) -> None: + # Le segment de topic (fail2ban//heartbeat) + # est l'alias/CN mTLS, imposé par l'ACL (pattern write + # fail2ban/%u/heartbeat) — jamais le même identifiant que + # NodeRegistry.node_id (uuid.getnode() brut, cf. commentaire plus + # haut sur ces deux identités distinctes). L'id brut voyage donc + # dans le payload, exactement comme /ban le fait déjà. + self.master_client.publish( + f'{self.node_topic_prefix}/heartbeat', + json.dumps({'node': self.local_node_id, 'dashboard_url': self.dashboard_url}), + qos=0, + ) + self.heartbeat_timer = threading.Timer(HEARTBEAT_INTERVAL_SECONDS, self.send_heartbeat) + self.heartbeat_timer.daemon = True + self.heartbeat_timer.start() + + def report_outcome(self, cmd: str, ip: str, status: str, detail: str = '') -> None: + """Diffuse l'issue d'une commande reçue du master vers ses deux + destinations : accusé de réception MQTT vers le master + (fail2ban//ack) et notification WebSocket vers ce dashboard + (même canal que les BanEvent, cf. signals.py) — un SYNC_BAN réussi + finit par apparaître indirectement via le BanEvent qu'il déclenche, + mais un échec ou une commande non gérée ne laissaient jusqu'ici + aucune trace visible hors journalctl. Point d'entrée unique pour + toute nouvelle commande : chaque execute_ doit terminer par un + appel ici plutôt que de publier séparément vers les deux canaux.""" + payload = {'cmd': cmd, 'ip': ip, 'status': status, 'detail': detail} + self.master_client.publish(f'{self.node_topic_prefix}/ack', json.dumps(payload), qos=1) + channel_layer = get_channel_layer() + if channel_layer is not None: + async_to_sync(channel_layer.group_send)( + GROUP_NAME, {'type': 'command.notification', 'payload': payload}, + ) + + def on_master_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + try: + command = json.loads(message.payload.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + self.stderr.write(self.style.ERROR(f'Commande master invalide : {e}')) + return + + cmd = command.get('cmd') + handler = self.command_handlers.get(cmd) + if handler is not None: + handler(command) + else: + # Phase 4 restante (ESCALATE, RATE_LIMIT, REPORT_ABUSE, + # REQUEST_GEOLOCATE) : pas de handler enregistré, journalisée + # seulement. + self.stdout.write(self.style.WARNING(f'Commande reçue du master (non gérée) : {command}')) + self.report_outcome(str(cmd or '?'), '', 'unhandled') + + def _banip_via_jail(self, jail: str, ip: str) -> tuple[bool, str]: + """Bannit `ip` via la jail `jail` (`fail2ban-client set + banip `) — primitive partagée par toute commande qui se + résume à "bannir cette IP dans une jail dédiée à injection + manuelle" (master-sync/SYNC_BAN, master-sync/BAN_ALLPORTS, + master-ratelimit/RATE_LIMIT, master-escalate/ESCALATE) : seuls le + nom de la jail et l'origine de la décision changent. Retourne + (succès, detail — message d'erreur si échec).""" + try: + result = subprocess.run( + ['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'banip', ip], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError) as e: + return False, str(e) + if result.returncode == 0: + return True, '' + return False, result.stderr.strip() or result.stdout.strip() + + def _list_active_jails(self) -> list[str]: + """Parse la sortie de `fail2ban-client status` ("Jail list: a, b, c") + — pas d'option JSON native côté fail2ban-client pour cette info.""" + try: + result = subprocess.run( + ['sudo', settings.FAIL2BAN_CLIENT_PATH, 'status'], + capture_output=True, text=True, timeout=10, check=True, + ) + except (subprocess.SubprocessError, OSError, subprocess.CalledProcessError) as e: + self.stderr.write(self.style.ERROR(f'Impossible de lister les jails actives : {e}')) + return [] + for line in result.stdout.splitlines(): + if 'Jail list:' in line: + return [j.strip() for j in line.split(':', 1)[1].split(',') if j.strip()] + return [] + + def execute_sync_ban(self, command: dict[str, Any]) -> None: + if str(command.get('origin_node', '')) == self.local_node_id: + self.stdout.write(f'SYNC_BAN ignoré (origine = ce noeud) : {command.get("ip")}') + return + + try: + ip = str(ipaddress.ip_address(command.get('ip', ''))) + except (ValueError, TypeError): + self.stderr.write(self.style.ERROR(f'SYNC_BAN : IP invalide reçue : {command.get("ip")!r}')) + self.report_outcome('SYNC_BAN', str(command.get('ip', '')), 'error', 'IP invalide') + return + + ok, detail = self._banip_via_jail(MASTER_SYNC_JAIL, ip) + if ok: + self.stdout.write(self.style.SUCCESS(f'SYNC_BAN appliqué : {ip} (jail {MASTER_SYNC_JAIL})')) + self.report_outcome('SYNC_BAN', ip, 'ok') + else: + self.stderr.write(self.style.ERROR(f'SYNC_BAN échec pour {ip} : {detail}')) + self.report_outcome('SYNC_BAN', ip, 'error', detail) + + def execute_ban_allports(self, command: dict[str, Any]) -> None: + """Décision ciblée explicite (pas une synchro auto : pas de check + origin_node — déclenchée à la main via publish_command.py, doit + s'appliquer sur tous les noeuds qui la reçoivent, y compris + l'origine si jamais elle en porte une).""" + try: + ip = str(ipaddress.ip_address(command.get('ip', ''))) + except (ValueError, TypeError): + self.stderr.write(self.style.ERROR(f'BAN_ALLPORTS : IP invalide reçue : {command.get("ip")!r}')) + self.report_outcome('BAN_ALLPORTS', str(command.get('ip', '')), 'error', 'IP invalide') + return + + ok, detail = self._banip_via_jail(MASTER_SYNC_JAIL, ip) + if ok: + self.stdout.write(self.style.SUCCESS(f'BAN_ALLPORTS appliqué : {ip} (jail {MASTER_SYNC_JAIL})')) + self.report_outcome('BAN_ALLPORTS', ip, 'ok') + else: + self.stderr.write(self.style.ERROR(f'BAN_ALLPORTS échec pour {ip} : {detail}')) + self.report_outcome('BAN_ALLPORTS', ip, 'error', detail) + + def execute_whitelist(self, command: dict[str, Any]) -> None: + """Ajoute l'IP à ignoreip sur toutes les jails actives + la + débannit si elle l'était. Limitation connue : `addignoreip` est un + réglage en mémoire, non persistant — perdu au prochain redémarrage + de fail2ban.service (install.sh ne touche pas jail.local donc un + `install.sh install` ne l'efface pas, mais un restart manuel du + service, oui). Persister ça proprement (fichier dédié non écrasé + par install.sh, relu au démarrage) reste à faire séparément.""" + try: + ip = str(ipaddress.ip_address(command.get('ip', ''))) + except (ValueError, TypeError): + self.stderr.write(self.style.ERROR(f'WHITELIST : IP invalide reçue : {command.get("ip")!r}')) + self.report_outcome('WHITELIST', str(command.get('ip', '')), 'error', 'IP invalide') + return + + jails = self._list_active_jails() + if not jails: + self.report_outcome('WHITELIST', ip, 'error', 'aucune jail active trouvée') + return + for jail in jails: + subprocess.run( + ['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'addignoreip', ip], + capture_output=True, text=True, timeout=10, + ) + # Échec ignoré : l'IP peut légitimement ne pas être bannie + # dans cette jail précise. + subprocess.run( + ['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'unbanip', ip], + capture_output=True, text=True, timeout=10, + ) + self.stdout.write(self.style.SUCCESS(f'WHITELIST appliqué : {ip} ({len(jails)} jails)')) + self.report_outcome('WHITELIST', ip, 'ok') + + def execute_notify_only(self, command: dict[str, Any]) -> None: + """Aucune action système — la visibilité "alerte admin" demandée + pour cette commande (cf. ROADMAP.md) est déjà couverte par le + toast que report_outcome déclenche côté dashboard.""" + ip = str(command.get('ip', '')) + self.stdout.write(f'NOTIFY_ONLY reçu : {ip}') + self.report_outcome('NOTIFY_ONLY', ip, 'ok') + + def execute_rate_limit(self, command: dict[str, Any]) -> None: + """Décision ciblée explicite (manuelle, via publish_command.py) : + throttle au lieu d'un blocage total, jail dédiée master-ratelimit + (banaction f2b-iptables-hashlimit).""" + try: + ip = str(ipaddress.ip_address(command.get('ip', ''))) + except (ValueError, TypeError): + self.stderr.write(self.style.ERROR(f'RATE_LIMIT : IP invalide reçue : {command.get("ip")!r}')) + self.report_outcome('RATE_LIMIT', str(command.get('ip', '')), 'error', 'IP invalide') + return + + ok, detail = self._banip_via_jail(MASTER_RATELIMIT_JAIL, ip) + if ok: + self.stdout.write(self.style.SUCCESS(f'RATE_LIMIT appliqué : {ip} (jail {MASTER_RATELIMIT_JAIL})')) + self.report_outcome('RATE_LIMIT', ip, 'ok') + else: + self.stderr.write(self.style.ERROR(f'RATE_LIMIT échec pour {ip} : {detail}')) + self.report_outcome('RATE_LIMIT', ip, 'error', detail) + + def execute_escalate(self, command: dict[str, Any]) -> None: + """Auto-publiée par master_listen.py (correlation_rule_escalate) + quand une IP est bannie indépendamment sur plusieurs noeuds + distincts. Contrairement à SYNC_BAN, pas de check origin_node : + doit s'appliquer aussi sur le(s) noeud(s) d'origine (leur ban + initial n'est pas permanent, master-escalate est une jail + séparée, pas de conflit à réappliquer).""" + try: + ip = str(ipaddress.ip_address(command.get('ip', ''))) + except (ValueError, TypeError): + self.stderr.write(self.style.ERROR(f'ESCALATE : IP invalide reçue : {command.get("ip")!r}')) + self.report_outcome('ESCALATE', str(command.get('ip', '')), 'error', 'IP invalide') + return + + ok, detail = self._banip_via_jail(MASTER_ESCALATE_JAIL, ip) + if ok: + self.stdout.write(self.style.SUCCESS(f'ESCALATE appliqué : {ip} (jail {MASTER_ESCALATE_JAIL}, permanent)')) + self.report_outcome('ESCALATE', ip, 'ok') + else: + self.stderr.write(self.style.ERROR(f'ESCALATE échec pour {ip} : {detail}')) + self.report_outcome('ESCALATE', ip, 'error', detail) + + def on_roster_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + """Roster périodique publié par master_listen.py (Phase 3) : les + noeuds connus se propagent dans NodeRegistry (déjà le modèle lu par + la sidebar — un client voit ainsi les autres noeuds, pas que + lui-même) ; les stats globales jail/pays (pas des lignes de table, + des agrégats transitoires) vont en cache Redis, lu par views.py + avec repli sur le calcul local si absent/périmé.""" + try: + roster = json.loads(message.payload.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + self.stderr.write(self.style.ERROR(f'Roster invalide : {e}')) + return + + for node in roster.get('nodes', []): + node_id = node.get('node_id') + if not node_id: + continue + # filter().update() plutôt que update_or_create() : last_seen a + # auto_now=True, qui écraserait la vraie valeur du master par + # "maintenant" si on passait par .save() (déclenché en interne + # par update_or_create) — .update() fait une requête SQL directe, + # sans repasser par la logique auto_now du champ. + fields = { + 'alias': node.get('alias', ''), + 'country': node.get('country', ''), + 'dashboard_url': node.get('dashboard_url', ''), + } + if node.get('last_seen'): + fields['last_seen'] = node['last_seen'] + if not NodeRegistry.objects.filter(node_id=node_id).update(**fields): + NodeRegistry.objects.get_or_create(node_id=node_id, defaults=fields) + + self.redis_client.set( + ROSTER_REDIS_KEY, + json.dumps({'top_jails': roster.get('top_jails', []), 'top_countries': roster.get('top_countries', [])}), + ex=ROSTER_TTL_SECONDS, + ) diff --git a/emitter/banevents/management/commands/master_listen.py b/emitter/banevents/management/commands/master_listen.py new file mode 100644 index 0000000..a509956 --- /dev/null +++ b/emitter/banevents/management/commands/master_listen.py @@ -0,0 +1,237 @@ +"""Client MQTT côté master (Phase 3/4, minimal) : écoute fail2ban/+/ban +(tous les noeuds connectés) en mTLS, persiste chaque événement dans le +même modèle BanEvent que mqtt_listen — dashboard multi-noeuds unifié via +le filtre par noeud déjà présent, sans rien dupliquer — et applique un +registre de règles de corrélation (self.correlation_rules, construit +dans handle()) à chaque BAN reçu : SYNC_BAN (propagation systématique à +tous les noeuds) et ESCALATE (ban permanent si l'IP est bannie +indépendamment sur plusieurs noeuds distincts, cf. +correlation_rule_escalate). Pour ajouter une future règle +auto-déclenchée : écrire une méthode `correlation_rule_(self, +client, payload) -> None` et l'ajouter à self.correlation_rules dans +handle() — voir ROADMAP.md (Phase 4, "procédure d'ajout") pour les deux +autres formes possibles (commande manuelle diffusée, action master-only) +selon la nature de la nouvelle commande. + +Publie aussi périodiquement un "roster" (fail2ban/broadcast/roster, Phase +3) : la liste des noeuds connus (NodeRegistry) + les compteurs jail/pays +globaux — sans ça, la sidebar d'un noeud client n'aurait jamais que SES +propres données (aucun autre process que celui-ci ne maintient +NodeRegistry à jour pour plus d'un noeud). master_client.py de chaque +noeud s'y abonne et republie localement (NodeRegistry + cache Redis, voir +banevents/stats.py). + +Identité mTLS dédiée (MQTT_MASTER_LISTENER_CERT/KEY, CN "master-internal" +par convention), distincte de celle de master_client : ce process doit à +la fois LIRE tous les noeuds (fail2ban/+/ban) et ÉCRIRE sur le canal de +diffusion — deux droits plus larges que ceux d'un noeud pris +individuellement, donc son propre certificat (voir master-acl.conf). +""" +import datetime +import json +import ssl +import threading +import uuid +from typing import Any + +import paho.mqtt.client as mqtt +from django.conf import settings +from django.core.management.base import BaseCommand +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone + +from banevents.ingestion import save_ban_event +from banevents.models import BanEvent, NodeRegistry +from banevents.stats import top_jails_and_countries + +MASTER_BAN_TOPIC = 'fail2ban/+/ban' +MASTER_HEARTBEAT_TOPIC = 'fail2ban/+/heartbeat' +MASTER_ACK_TOPIC = 'fail2ban/+/ack' +BROADCAST_ACTION_TOPIC = 'fail2ban/broadcast/action' +BROADCAST_ROSTER_TOPIC = 'fail2ban/broadcast/roster' +# Même ordre de grandeur que HEARTBEAT_INTERVAL_SECONDS (master_client.py) — +# pas besoin d'être plus réactif, la sidebar n'a rien de temps réel critique. +ROSTER_INTERVAL_SECONDS = 60 +# ESCALATE (Phase 4) : seuil de corrélation cross-node. +ESCALATE_NODE_THRESHOLD = 2 +ESCALATE_WINDOW_HOURS = 24 +# Jails "réactives" (déclenchées par une commande, pas par une détection +# locale indépendante) — exclues du comptage ESCALATE, sinon la cascade +# SYNC_BAN habituelle (un ban propagé rebondit une fois avant de +# s'arrêter, cf. ROADMAP.md) se compterait elle-même comme une 2e +# détection indépendante. +REACTIVE_JAIL_NAMES = {'master-sync', 'master-ratelimit', 'master-escalate'} + + +class Command(BaseCommand): + help = ( + "Écoute fail2ban/+/ban sur le broker master, enregistre chaque événement, " + "et publie un SYNC_BAN en diffusion générale pour chaque BAN reçu." + ) + + def handle(self, *args: Any, **options: Any) -> None: + self.roster_timer: threading.Timer | None = None + self.correlation_rules = [self.correlation_rule_sync_ban, self.correlation_rule_escalate] + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-listen-{uuid.uuid4().hex[:8]}' + ) + # ca_certs=None : voir master_client.py — le magasin de CA système + # sait déjà vérifier le certificat serveur Let's Encrypt du master. + client.tls_set( + certfile=settings.MQTT_MASTER_LISTENER_CERT, + keyfile=settings.MQTT_MASTER_LISTENER_KEY, + tls_version=ssl.PROTOCOL_TLSv1_2, + ) + client.on_connect = self.on_connect + client.on_message = self.on_message + + self.stdout.write(f'Connexion master à {settings.MQTT_MASTER_HOST}:{settings.MQTT_MASTER_PORT}') + client.connect_async(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=60) + client.reconnect_delay_set(min_delay=1, max_delay=30) + client.loop_forever(retry_first_connection=True) + + def on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + properties: Any = None, + ) -> None: + self.stdout.write(self.style.SUCCESS(f'Connecté au master ({reason_code})')) + client.subscribe(MASTER_BAN_TOPIC) + client.subscribe(MASTER_HEARTBEAT_TOPIC) + client.subscribe(MASTER_ACK_TOPIC) + # on_connect peut se redéclencher à chaque reconnexion : annuler + # tout timer précédent pour ne pas empiler plusieurs chaînes en + # parallèle (rosters en double), même précaution que le heartbeat + # de master_client.py. + if self.roster_timer is not None: + self.roster_timer.cancel() + self.publish_roster(client) + + def on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + # Topics séparés par suffixe (fail2ban//ban|heartbeat|ack), + # jamais un wildcard multi-niveau fail2ban/+/+ — reste explicite, + # cohérent avec le reste de l'ACL (master-acl.conf). + topic_kind = message.topic.rsplit('/', 1)[-1] + if topic_kind == 'ban': + self.handle_ban(client, message) + elif topic_kind == 'heartbeat': + self.handle_heartbeat(message) + elif topic_kind == 'ack': + self.handle_ack(message) + + def handle_ban(self, client: mqtt.Client, message: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(message.payload.decode('utf-8')) + # Segment du topic (fail2ban//ban) = CN du certificat client + # = node_name choisi à l'inscription (sign-node-csr.sh) — sert + # uniquement de suggestion d'alias à la création d'un NodeRegistry + # inédit (voir save_ban_event) ; payload['node'] (uuid.getnode()) + # reste le vrai node_id, ces deux identifiants ne coïncident pas. + alias_hint = message.topic.split('/')[1] + save_ban_event(payload, alias_hint=alias_hint) + except Exception as e: + self.stderr.write(self.style.ERROR(f'Message master invalide ({message.topic}) : {e}')) + return + self.stdout.write( + f'Événement master enregistré : noeud={payload.get("node")} ' + f'{payload.get("name")} {payload.get("action")} {payload.get("ip")}' + ) + + if payload.get('action') == 'BAN': + for rule in self.correlation_rules: + rule(client, payload) + + def handle_heartbeat(self, message: mqtt.MQTTMessage) -> None: + # Le segment de topic (fail2ban//heartbeat) est le CN du + # certificat client (MQTT_MASTER_NODE_NAME), PAS le même + # identifiant que NodeRegistry.node_id (uuid.getnode() brut, cf. + # payload des BanEvent) — l'id brut voyage donc dans le payload, + # comme pour /ban. + try: + payload = json.loads(message.payload.decode('utf-8')) + node_id = str(payload['node']) + except (json.JSONDecodeError, UnicodeDecodeError, KeyError) as e: + self.stderr.write(self.style.ERROR(f'Heartbeat invalide ({message.topic}) : {e}')) + return + # .update() plutôt que get_or_create() : un noeud pas encore connu + # de NodeRegistry (jamais banni depuis son déploiement) est un + # no-op silencieux ici — il apparaîtra dès son premier ban, pas la + # peine de le créer juste pour un heartbeat. + update_fields: dict[str, Any] = {'last_seen': timezone.now()} + # dashboard_url (DASHBOARD_DOMAIN de CE noeud, cf. master_client.py:: + # send_heartbeat) : resynchronisé à CHAQUE heartbeat, pas seulement à + # la création — contrairement à alias/country (des labels choisis + # une fois), c'est une donnée technique qui doit refléter le .env + # réel du noeud. Absent/vide (noeud sans tableau de bord public + # exposé) => clé omise, ne jamais écraser une valeur déjà connue + # avec du vide. + dashboard_url = payload.get('dashboard_url') + if dashboard_url: + update_fields['dashboard_url'] = dashboard_url + NodeRegistry.objects.filter(node_id=node_id).update(**update_fields) + + def handle_ack(self, message: mqtt.MQTTMessage) -> None: + node_id = message.topic.split('/')[1] + try: + payload = json.loads(message.payload.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + self.stderr.write(self.style.ERROR(f'ACK invalide ({message.topic}) : {e}')) + return + self.stdout.write( + f'ACK reçu de {node_id} : {payload.get("cmd")} {payload.get("ip")} -> ' + f'{payload.get("status")} {payload.get("detail", "")}'.rstrip() + ) + + def publish_roster(self, client: mqtt.Client) -> None: + nodes = list( + NodeRegistry.objects.all().values('node_id', 'alias', 'country', 'dashboard_url', 'last_seen') + ) + roster = {'nodes': nodes, **top_jails_and_countries()} + client.publish(BROADCAST_ROSTER_TOPIC, json.dumps(roster, cls=DjangoJSONEncoder), qos=0) + self.roster_timer = threading.Timer(ROSTER_INTERVAL_SECONDS, self.publish_roster, args=[client]) + self.roster_timer.daemon = True + self.roster_timer.start() + + def correlation_rule_sync_ban(self, client: mqtt.Client, payload: dict[str, Any]) -> None: + command = { + 'cmd': 'SYNC_BAN', + 'ip': payload.get('ip'), + 'ttl': payload.get('bantime'), + 'reason': payload.get('name', ''), + 'origin_node': payload.get('node', ''), + } + client.publish(BROADCAST_ACTION_TOPIC, json.dumps(command), qos=1) + self.stdout.write(f'SYNC_BAN publié : {command}') + + def correlation_rule_escalate(self, client: mqtt.Client, payload: dict[str, Any]) -> None: + """Publie ESCALATE (ban permanent, jail master-escalate côté + noeuds) si `ip` a été bannie de façon INDÉPENDANTE (hors jails + REACTIVE_JAIL_NAMES — sinon la cascade SYNC_BAN habituelle se + compterait elle-même) sur au moins ESCALATE_NODE_THRESHOLD noeuds + distincts dans les dernières ESCALATE_WINDOW_HOURS heures.""" + ip = payload.get('ip') + if not ip: + return + + window_start = timezone.now() - datetime.timedelta(hours=ESCALATE_WINDOW_HOURS) + distinct_nodes = ( + BanEvent.objects.filter(ip_address=ip, action='BAN', received_at__gte=window_start) + .exclude(jail_name__in=REACTIVE_JAIL_NAMES) + .values('node').distinct().count() + ) + if distinct_nodes < ESCALATE_NODE_THRESHOLD: + return + + command = { + 'cmd': 'ESCALATE', + 'ip': ip, + 'reason': payload.get('name', ''), + 'origin_node': payload.get('node', ''), + } + client.publish(BROADCAST_ACTION_TOPIC, json.dumps(command), qos=1) + self.stdout.write(self.style.WARNING( + f'ESCALATE publié ({distinct_nodes} noeuds indépendants en {ESCALATE_WINDOW_HOURS}h) : {command}' + )) diff --git a/emitter/banevents/management/commands/mqtt_listen.py b/emitter/banevents/management/commands/mqtt_listen.py new file mode 100644 index 0000000..d68312e --- /dev/null +++ b/emitter/banevents/management/commands/mqtt_listen.py @@ -0,0 +1,54 @@ +"""Client MQTT local : ingère les événements fail2ban publiés par +f2b-mqtt-action-banisher et les persiste en base (modèle BanEvent).""" +import json +import uuid +from typing import Any + +import paho.mqtt.client as mqtt +from django.conf import settings +from django.core.management.base import BaseCommand + +from banevents.ingestion import save_ban_event + + +class Command(BaseCommand): + help = "Écoute le broker MQTT local et enregistre chaque événement fail2ban reçu." + + def handle(self, *args: Any, **options: Any) -> None: + # client_id unique par process : deux instances partageant le même id + # MQTT (deux déploiements, ou un test local pointé sur le même + # broker) s'éjectent mutuellement en boucle sinon (constaté en + # prod : une instance de dev oubliée connectée via le VPN et le + # service systemd du VPS se disputaient le même id). + client_id = f'emitter-mqtt-listen-{uuid.uuid4().hex[:8]}' + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) + client.username_pw_set(settings.MQTT_BROKER_USERNAME, settings.MQTT_BROKER_PASSWORD) + client.on_connect = self.on_connect + client.on_message = self.on_message + + self.stdout.write( + f'Connexion à {settings.MQTT_BROKER_HOST}:{settings.MQTT_BROKER_PORT} ' + f'(topic {settings.MQTT_TOPIC_SUBSCRIBE})' + ) + client.connect(settings.MQTT_BROKER_HOST, settings.MQTT_BROKER_PORT, keepalive=60) + client.loop_forever() + + def on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + properties: Any = None, + ) -> None: + self.stdout.write(self.style.SUCCESS(f'Connecté au broker ({reason_code})')) + client.subscribe(settings.MQTT_TOPIC_SUBSCRIBE) + + def on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(message.payload.decode('utf-8')) + save_ban_event(payload) + except Exception as e: + self.stderr.write(self.style.ERROR(f'Message MQTT invalide ({message.topic}) : {e}')) + return + self.stdout.write(f'Événement enregistré : {payload.get("name")} {payload.get("action")} {payload.get("ip")}') diff --git a/emitter/banevents/management/commands/publish_command.py b/emitter/banevents/management/commands/publish_command.py new file mode 100644 index 0000000..38a0a20 --- /dev/null +++ b/emitter/banevents/management/commands/publish_command.py @@ -0,0 +1,115 @@ +"""Déclenche à la main une commande sans émission automatique (Phase 4) : +contrairement à SYNC_BAN/ESCALATE (toujours publiées par master_listen.py +via self.correlation_rules), WHITELIST/BAN_ALLPORTS/NOTIFY_ONLY/RATE_LIMIT +correspondent à une décision humaine ("je sais que cette IP est +particulière"), pas à une règle automatique — pas de logique de +corrélation à construire pour ça. Diffusées à tous les noeuds abonnés via +fail2ban/broadcast/action, exécutées par master_client.py::execute_. + +REPORT_ABUSE fait exception : PAS de diffusion MQTT. Diffuser à chaque +noeud produirait un rapport en double par noeud connecté pour le même +incident (vu comme du bruit par AbuseIPDB, et demanderait une clé API par +noeud). Ce process tourne déjà sur le master, avec accès à l'historique +BanEvent complet (tous noeuds) pour construire un commentaire pertinent — +il soumet donc le rapport lui-même (banevents/abuseipdb.py), sans passer +par MQTT ni par master_client.py. + +Usage (à lancer sur le master, où vit le certificat master-internal, seule +identité autorisée en écriture sur fail2ban/broadcast/action) : + manage.py publish_command WHITELIST 203.0.113.5 + manage.py publish_command BAN_ALLPORTS 203.0.113.5 + manage.py publish_command NOTIFY_ONLY 203.0.113.5 + manage.py publish_command RATE_LIMIT 203.0.113.5 + manage.py publish_command REPORT_ABUSE 203.0.113.5 --comment "texte" +""" +import json +import ssl +import time +import uuid +from typing import Any + +import paho.mqtt.client as mqtt +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from banevents.abuseipdb import submit_report +from banevents.models import BanEvent + +BROADCAST_ACTION_TOPIC = 'fail2ban/broadcast/action' +# Commandes diffusées en MQTT à tous les noeuds — cf. master_client.py +# command_handlers pour la liste des commandes qu'un noeud sait exécuter. +MANUAL_COMMANDS = ('WHITELIST', 'BAN_ALLPORTS', 'NOTIFY_ONLY', 'RATE_LIMIT') +DEFAULT_ABUSEIPDB_CATEGORIES = '18' # Brute-Force + + +class Command(BaseCommand): + help = 'Publie une commande manuelle (WHITELIST/BAN_ALLPORTS/NOTIFY_ONLY/RATE_LIMIT/REPORT_ABUSE).' + + def add_arguments(self, parser: Any) -> None: + parser.add_argument('cmd', choices=(*MANUAL_COMMANDS, 'REPORT_ABUSE')) + parser.add_argument('ip') + parser.add_argument('--categories', default=DEFAULT_ABUSEIPDB_CATEGORIES, help='REPORT_ABUSE uniquement') + parser.add_argument('--comment', default='', help='REPORT_ABUSE uniquement') + + def handle(self, *args: Any, **options: Any) -> None: + cmd = options['cmd'] + ip = options['ip'] + + if cmd == 'REPORT_ABUSE': + self.handle_report_abuse(ip, options['categories'], options['comment']) + return + + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=f'publish-command-{uuid.uuid4().hex[:8]}' + ) + # Même identité que master_listen.py (master-internal) — seule + # scopée en écriture sur fail2ban/broadcast/action, cf. + # master-acl.conf. + client.tls_set( + certfile=settings.MQTT_MASTER_LISTENER_CERT, + keyfile=settings.MQTT_MASTER_LISTENER_KEY, + tls_version=ssl.PROTOCOL_TLSv1_2, + ) + client.connect(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=10) + client.loop_start() + + payload = {'cmd': cmd, 'ip': ip, 'origin_node': 'manual', 'reason': 'manual'} + info = client.publish(BROADCAST_ACTION_TOPIC, json.dumps(payload), qos=1) + info.wait_for_publish(timeout=10) + + client.loop_stop() + client.disconnect() + + if not info.is_published(): + raise CommandError(f'Échec de publication de {cmd} pour {ip} (PUBACK non reçu).') + self.stdout.write(self.style.SUCCESS(f'{cmd} publié pour {ip} sur {BROADCAST_ACTION_TOPIC}')) + # Laisse le temps au disconnect propre de partir avant que le + # process ne se termine (paho ferme le socket en tâche de fond). + time.sleep(0.2) + + def handle_report_abuse(self, ip: str, categories: str, comment: str) -> None: + comment = comment or self._default_comment(ip) + + if not settings.ABUSEIPDB_ENABLED or not settings.ABUSEIPDB_API_KEY: + self.stdout.write(self.style.WARNING( + f'[dry-run] AbuseIPDB désactivé (ABUSEIPDB_ENABLED/ABUSEIPDB_API_KEY, voir .env) — ' + f'rien envoyé. Aurait signalé {ip} (catégories {categories}) : {comment}' + )) + return + + ok, detail = submit_report(ip, categories, comment) + if ok: + self.stdout.write(self.style.SUCCESS(f'{ip} signalé à AbuseIPDB (catégories {categories}).')) + else: + raise CommandError(f'Échec du signalement AbuseIPDB pour {ip} : {detail}') + + def _default_comment(self, ip: str) -> str: + """Commentaire par défaut construit depuis l'historique BanEvent + de cette IP — le master voit tous les noeuds, contrairement à un + rapport qui partirait d'un seul d'entre eux.""" + events = BanEvent.objects.filter(ip_address=ip, action='BAN').order_by('-received_at')[:5] + jails = sorted({e.jail_name for e in events if e.jail_name}) + nodes = sorted({e.node for e in events if e.node}) + if not jails: + return f'Fail2Ban: banned IP {ip}.' + return f'Fail2Ban: banned for {", ".join(jails)} on {len(nodes)} node(s).' diff --git a/emitter/banevents/management/commands/retry_geolocation.py b/emitter/banevents/management/commands/retry_geolocation.py new file mode 100644 index 0000000..d5b294d --- /dev/null +++ b/emitter/banevents/management/commands/retry_geolocation.py @@ -0,0 +1,39 @@ +"""Relance la géolocalisation pour des BanEvent (Phase 4 — repurposing de +REQUEST_GEOLOCATE). La géoloc est déjà automatique et centralisée à +l'ingestion (signal post_save sur BanEvent, cf. signals.py, déclenché pour +tout événement y compris ceux ingérés par master_listen depuis n'importe +quel noeud) : un aller-retour MQTT "demande au noeud sa géoloc" n'aurait +rien à demander, le noeud fail2ban ne calcule rien lui-même. Cette +commande se contente de rejouer les échecs (ip-api.com en rate limit, IP +privée/invalide, ...) via le même Celery task, sans nouvelle logique. + +Usage : + manage.py retry_geolocation # tous les BanEvent sans coordonnées + manage.py retry_geolocation --ip 203.0.113.5 # une IP précise, même déjà tentée +""" +from typing import Any + +from django.core.management.base import BaseCommand + +from banevents.models import BanEvent +from banevents.tasks import geolocate_ip + + +class Command(BaseCommand): + help = 'Relance la géolocalisation (Celery) pour les BanEvent sans coordonnées, ou une IP précise.' + + def add_arguments(self, parser: Any) -> None: + parser.add_argument('--ip', default='', help='Ne relancer que pour cette IP (même si déjà géolocalisée)') + + def handle(self, *args: Any, **options: Any) -> None: + qs = BanEvent.objects.all() + if options['ip']: + qs = qs.filter(ip_address=options['ip']) + else: + qs = qs.filter(geolocated_at__isnull=True) + + count = 0 + for event_id in qs.values_list('id', flat=True): + geolocate_ip.delay(event_id) + count += 1 + self.stdout.write(self.style.SUCCESS(f'{count} géolocalisation(s) relancée(s).')) diff --git a/emitter/banevents/migrations/0001_initial.py b/emitter/banevents/migrations/0001_initial.py new file mode 100644 index 0000000..578cc96 --- /dev/null +++ b/emitter/banevents/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.7 on 2026-07-14 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='BanEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('node', models.CharField(max_length=64)), + ('action', models.CharField(max_length=32)), + ('jail_name', models.CharField(max_length=64)), + ('ip_address', models.GenericIPAddressField()), + ('port', models.PositiveIntegerField(blank=True, null=True)), + ('protocol', models.CharField(blank=True, max_length=16)), + ('bantime', models.IntegerField(blank=True, null=True)), + ('reason', models.CharField(blank=True, max_length=255)), + ('event_time', models.DateTimeField(blank=True, null=True)), + ('received_at', models.DateTimeField(auto_now_add=True)), + ('raw_payload', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['-received_at'], + }, + ), + ] diff --git a/emitter/banevents/migrations/0002_banevent_city_banevent_country_banevent_country_code_and_more.py b/emitter/banevents/migrations/0002_banevent_city_banevent_country_banevent_country_code_and_more.py new file mode 100644 index 0000000..5f442c0 --- /dev/null +++ b/emitter/banevents/migrations/0002_banevent_city_banevent_country_banevent_country_code_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.7 on 2026-07-14 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='banevent', + name='city', + field=models.CharField(blank=True, max_length=128), + ), + migrations.AddField( + model_name='banevent', + name='country', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='banevent', + name='country_code', + field=models.CharField(blank=True, max_length=2), + ), + migrations.AddField( + model_name='banevent', + name='geolocated_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='banevent', + name='latitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='banevent', + name='longitude', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/emitter/banevents/migrations/0003_alter_banevent_port.py b/emitter/banevents/migrations/0003_alter_banevent_port.py new file mode 100644 index 0000000..4489d19 --- /dev/null +++ b/emitter/banevents/migrations/0003_alter_banevent_port.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-07-15 08:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0002_banevent_city_banevent_country_banevent_country_code_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='banevent', + name='port', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/emitter/banevents/migrations/0004_noderegistry_alter_banevent_country_and_more.py b/emitter/banevents/migrations/0004_noderegistry_alter_banevent_country_and_more.py new file mode 100644 index 0000000..a1d2097 --- /dev/null +++ b/emitter/banevents/migrations/0004_noderegistry_alter_banevent_country_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 6.0.7 on 2026-07-17 16:04 + +from django.db import migrations, models + + +def backfill_node_registry(apps, schema_editor): + """Sans ça, NodeRegistry reste vide au déploiement jusqu'à ce que + chaque noeud republie un événement — le filtre par noeud du dashboard + (masqué s'il n'y a qu'une seule valeur) disparaîtrait pour les noeuds + déjà connus dans BanEvent, régression par rapport à l'ancien + _distinct_nodes() qui interrogeait BanEvent directement.""" + BanEvent = apps.get_model('banevents', 'BanEvent') + NodeRegistry = apps.get_model('banevents', 'NodeRegistry') + for node_id in BanEvent.objects.order_by().values_list('node', flat=True).distinct(): + if node_id: + NodeRegistry.objects.get_or_create(node_id=node_id) + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0003_alter_banevent_port'), + ] + + operations = [ + migrations.CreateModel( + name='NodeRegistry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('node_id', models.CharField(max_length=64, unique=True)), + ('alias', models.CharField(blank=True, max_length=100)), + ('country', models.CharField(blank=True, max_length=100)), + ('first_seen', models.DateTimeField(auto_now_add=True)), + ('last_seen', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['node_id'], + }, + ), + migrations.AlterField( + model_name='banevent', + name='country', + field=models.CharField(blank=True, db_index=True, max_length=64), + ), + migrations.AlterField( + model_name='banevent', + name='jail_name', + field=models.CharField(db_index=True, max_length=64), + ), + migrations.RunPython(backfill_node_registry, migrations.RunPython.noop), + ] diff --git a/emitter/banevents/migrations/0005_noderegistry_dashboard_url.py b/emitter/banevents/migrations/0005_noderegistry_dashboard_url.py new file mode 100644 index 0000000..6b16484 --- /dev/null +++ b/emitter/banevents/migrations/0005_noderegistry_dashboard_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-07-18 06:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0004_noderegistry_alter_banevent_country_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='noderegistry', + name='dashboard_url', + field=models.URLField(blank=True, max_length=255), + ), + ] diff --git a/emitter/banevents/migrations/0006_jointoken.py b/emitter/banevents/migrations/0006_jointoken.py new file mode 100644 index 0000000..5474023 --- /dev/null +++ b/emitter/banevents/migrations/0006_jointoken.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.7 on 2026-07-18 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0005_noderegistry_dashboard_url'), + ] + + operations = [ + migrations.CreateModel( + name='JoinToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('node_name', models.CharField(max_length=64)), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/emitter/banevents/migrations/0007_enrollmentrequest.py b/emitter/banevents/migrations/0007_enrollmentrequest.py new file mode 100644 index 0000000..701492c --- /dev/null +++ b/emitter/banevents/migrations/0007_enrollmentrequest.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-07-18 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0006_jointoken'), + ] + + operations = [ + migrations.CreateModel( + name='EnrollmentRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('node_name', models.CharField(max_length=64)), + ('message', models.TextField(blank=True)), + ('status', models.CharField(choices=[('pending', 'En attente'), ('approved', 'Approuvée'), ('rejected', 'Rejetée')], default='pending', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/emitter/banevents/migrations/0008_enrollmentrequest_country.py b/emitter/banevents/migrations/0008_enrollmentrequest_country.py new file mode 100644 index 0000000..20c31f9 --- /dev/null +++ b/emitter/banevents/migrations/0008_enrollmentrequest_country.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-07-20 07:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banevents', '0007_enrollmentrequest'), + ] + + operations = [ + migrations.AddField( + model_name='enrollmentrequest', + name='country', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/emitter/banevents/migrations/__init__.py b/emitter/banevents/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emitter/banevents/models.py b/emitter/banevents/models.py new file mode 100644 index 0000000..558c8d7 --- /dev/null +++ b/emitter/banevents/models.py @@ -0,0 +1,151 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils import timezone + + +class BanEvent(models.Model): + """Un événement de ban/notice reçu via MQTT depuis un noeud fail2ban.""" + + node = models.CharField(max_length=64) + action = models.CharField(max_length=32) + jail_name = models.CharField(max_length=64, db_index=True) + ip_address = models.GenericIPAddressField() + # Chaîne, pas un entier : fail2ban accepte des noms de service + # ("http,https") ou des listes de ports ("8883,8884") pour ce champ. + port = models.CharField(max_length=64, blank=True, default='') + protocol = models.CharField(max_length=16, blank=True) + bantime = models.IntegerField(null=True, blank=True) + reason = models.CharField(max_length=255, blank=True) + event_time = models.DateTimeField(null=True, blank=True) + received_at = models.DateTimeField(auto_now_add=True) + raw_payload = models.JSONField(default=dict, blank=True) + + latitude = models.FloatField(null=True, blank=True) + longitude = models.FloatField(null=True, blank=True) + country = models.CharField(max_length=64, blank=True, db_index=True) + country_code = models.CharField(max_length=2, blank=True) + city = models.CharField(max_length=128, blank=True) + geolocated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-received_at'] + + if TYPE_CHECKING: + # Attaché dynamiquement par views.py::_attach_display_node (alias + # résolu depuis NodeRegistry), pas un champ persisté — annotation + # purement pour le typage, aucun effet à l'exécution. + display_node: str + + def __str__(self) -> str: + return f'{self.jail_name} {self.action} {self.ip_address}' + + +class NodeRegistry(models.Model): + """Métadonnées par noeud (alias, pays du VPS), en plus du `node` brut + (uuid.getnode() stringifié) porté par chaque BanEvent. Alimenté + automatiquement par banevents.ingestion.save_ban_event dès qu'un noeud + inédit publie un événement ; alias/country restent vides jusqu'à édition + manuelle via /admin/ (seule UI d'édition, pas de page dédiée).""" + + node_id = models.CharField(max_length=64, unique=True) + alias = models.CharField(max_length=100, blank=True) + # Pays du VPS/noeud lui-même (saisi à la main) — à ne pas confondre avec + # BanEvent.country, le pays géolocalisé de l'IP *bannie*, un concept + # différent qui existe déjà indépendamment de ce modèle. + country = models.CharField(max_length=100, blank=True) + # URL de base du dashboard de CE noeud (ex. https://banishing.example.org, + # sans slash final), saisie à la main via /admin/ — republiée à tous les + # noeuds par le roster (master_listen.py) pour que le bloc "Noeuds" de + # la sidebar (n'importe où il y a plus d'un noeud connu) puisse pointer + # vers chacun sans maillage N×N codé en dur (cf. MASTER_DASHBOARD_URL, + # qui reste le seul lien client -> master). + dashboard_url = models.URLField(max_length=255, blank=True) + first_seen = models.DateTimeField(auto_now_add=True) + last_seen = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['node_id'] + + if TYPE_CHECKING: + # Attaché dynamiquement par views.py::_node_choices (calculé depuis + # last_seen, pas persisté) — annotation purement pour le typage. + is_online: bool + + def __str__(self) -> str: + return self.alias or self.node_id + + @property + def display_name(self) -> str: + return self.alias or self.node_id + + +class JoinToken(models.Model): + """Jeton à usage unique pour l'auto-inscription d'un noeud (flux + "kubeadm join", voir manage.py create_join_token et + banevents/views.py::join_node). Seul le hash est stocké — jamais le + jeton en clair, même logique qu'un hachage de mot de passe : une fuite + de la base ne rend pas les jetons utilisables.""" + + node_name = models.CharField(max_length=64) + token_hash = models.CharField(max_length=64, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self) -> str: + if self.used_at: + status = 'utilisé' + elif timezone.now() > self.expires_at: + status = 'expiré' + else: + status = 'actif' + return f'{self.node_name} ({status})' + + +class EnrollmentRequest(models.Model): + """Demande d'inscription à la communauté, soumise via la page publique + /communaute/ (désactivée par défaut, cf. COMMUNITY_ENROLLMENT_ENABLED). + Ne crée jamais directement d'entrée NodeRegistry : le node_id réel + (uuid.getnode() du futur noeud) n'existe pas encore à ce stade, il + n'apparaît qu'au premier événement/heartbeat une fois le noeud + effectivement rattaché — NodeRegistry reste le résultat final du + parcours, pas une action immédiate de cette demande. Validée à la main + dans /admin/ (voir EnrollmentRequestAdmin.approve_and_send_token) : + jamais de jeton émis/envoyé sans décision humaine explicite.""" + + STATUS_PENDING = 'pending' + STATUS_APPROVED = 'approved' + STATUS_REJECTED = 'rejected' + STATUS_CHOICES = [ + (STATUS_PENDING, 'En attente'), + (STATUS_APPROVED, 'Approuvée'), + (STATUS_REJECTED, 'Rejetée'), + ] + + email = models.EmailField() + node_name = models.CharField(max_length=64) + # Pays du VPS/serveur candidat (pas de l'IP d'un attaquant) — signalé + # comme utile en pratique : les data-centers de pays différents ne + # font pas face au même trafic malveillant, une information qu'on ne + # peut pas déduire automatiquement à l'inscription. Propagé vers + # NodeRegistry.country à la création (ingestion.save_ban_event), sur + # le même principe que node_name -> NodeRegistry.alias. + country = models.CharField(max_length=100, blank=True) + message = models.TextField(blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING) + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self) -> str: + # dict(...)[...] plutôt que get_status_display() : cette méthode + # auto-générée par Django (choices=) n'est visible de pyright + # qu'avec le plugin mypy de django-stubs, non supporté ici (même + # limitation que Model.pk vs .id, voir CONTEXT.md/ROADMAP.md). + return f'{self.email} ({self.node_name}) — {dict(self.STATUS_CHOICES)[self.status]}' diff --git a/emitter/banevents/routing.py b/emitter/banevents/routing.py new file mode 100644 index 0000000..e176cc4 --- /dev/null +++ b/emitter/banevents/routing.py @@ -0,0 +1,12 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + # re_path() réutilisé pour du routage ASGI (pattern documenté par + # Channels lui-même) — django-stubs type `view` pour des vues WSGI, + # pas des consumers ASGI : décalage entre les stubs des deux paquets, + # sans rapport avec un vrai bug (fonctionne, testé en conditions + # réelles toute la journée, cf. ROADMAP.md). + re_path(r'^ws/banevents/$', consumers.BanEventConsumer.as_asgi()), # type: ignore[reportCallIssue, reportArgumentType] +] diff --git a/emitter/banevents/serializers.py b/emitter/banevents/serializers.py new file mode 100644 index 0000000..b7c69cc --- /dev/null +++ b/emitter/banevents/serializers.py @@ -0,0 +1,30 @@ +from typing import Any + +from .models import BanEvent + + +def event_payload(instance: BanEvent, node_alias: str | None = None) -> dict[str, Any]: + """Représentation JSON-sérialisable d'un BanEvent, partagée par le + WebSocket temps réel et le bootstrap initial du tableau de bord. + + node_alias : alias lisible du noeud (NodeRegistry.display_name), + résolu par l'appelant plutôt qu'ici pour ne pas cacher une requête DB + par événement (voir views.py, qui résout tous les alias en une seule + requête avant d'appeler cette fonction en boucle).""" + return { + 'id': instance.pk, + 'received_at': instance.received_at.isoformat(), + 'node': instance.node, + 'node_alias': node_alias or instance.node, + 'jail_name': instance.jail_name, + 'action': instance.action, + 'ip_address': instance.ip_address, + 'port': instance.port, + 'protocol': instance.protocol, + 'bantime': instance.bantime, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'country': instance.country, + 'country_code': instance.country_code, + 'city': instance.city, + } diff --git a/emitter/banevents/signals.py b/emitter/banevents/signals.py new file mode 100644 index 0000000..05aebb9 --- /dev/null +++ b/emitter/banevents/signals.py @@ -0,0 +1,56 @@ +"""Diffuse chaque BanEvent (création et mise à jour géoloc) aux clients +WebSocket via le channel layer, et déclenche la géolocalisation asynchrone +à la création. Journalise aussi les verrouillages django-axes (protection +/admin/, cf. AXES_* dans config/settings/base.py) pour que fail2ban +(generic/fail2ban/jail.d/emitter-admin-auth.conf) puisse réagir.""" +import logging +from typing import Any + +from asgiref.sync import async_to_sync +from axes.signals import user_locked_out +from channels.layers import get_channel_layer +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.http import HttpRequest + +from .consumers import GROUP_NAME +from .models import BanEvent, NodeRegistry +from .serializers import event_payload +from .tasks import geolocate_ip + +fail2ban_axes_logger = logging.getLogger('fail2ban.axes') + + +@receiver(post_save, sender=BanEvent) +def broadcast_ban_event(sender: type[BanEvent], instance: BanEvent, created: bool, **kwargs: Any) -> None: + channel_layer = get_channel_layer() + if channel_layer is not None: + # Se déclenche deux fois par event (création, puis mise à jour + # géoloc par geolocate_ip ci-dessous) : lookup unique indexé, sans + # conséquence à tourner deux fois (résultat identique). + node_alias = NodeRegistry.objects.filter(node_id=instance.node).values_list('alias', flat=True).first() + async_to_sync(channel_layer.group_send)( + GROUP_NAME, + {'type': 'ban.event', 'payload': event_payload(instance, node_alias)}, + ) + + if created: + geolocate_ip.delay(instance.pk) + + +@receiver(user_locked_out) +def log_axes_lockout(sender: Any, request: HttpRequest, username: str | None, ip_address: str, **kwargs: Any) -> None: + """Un seul point de vérité pour le seuil : axes décide (AXES_FAILURE_LIMIT) + qu'il y a un problème, cette ligne fait réagir fail2ban (ban réseau + + propagation SYNC_BAN aux autres noeuds, comme toute autre jail — cf. + jail.local [DEFAULT] action). maxretry=1 côté jail : chaque ligne ici + EST déjà la décision de verrouillage, pas la peine de recompter. + + Format de message imposé par filter.d/app-auth.conf (générique, + réutilisable par toute appli du VPS — pas seulement ce projet) : + "Verrouillage après échecs répétés depuis (...)", + = "émetteur" ici, un seul mot (\\S+ côté filtre).""" + fail2ban_axes_logger.warning( + 'Verrouillage émetteur après échecs répétés depuis %s (utilisateur tenté : %s)', + ip_address, username or '?', + ) diff --git a/emitter/banevents/static/admin/css/emitter_admin.css b/emitter/banevents/static/admin/css/emitter_admin.css new file mode 100644 index 0000000..90a4f19 --- /dev/null +++ b/emitter/banevents/static/admin/css/emitter_admin.css @@ -0,0 +1,199 @@ +/* ============================================================ + Emitter — Django Admin Theme Override + Thème sombre, couleurs depuis main.css + ============================================================ */ + +/* --- Variables Django admin 4.x ----------------------------- */ +:root { + --primary : #00d4aa; + --secondary : #00a884; + --accent : #00d4aa; + --primary-fg : #0d1117; + + --body-fg : #e6edf3; + --body-bg : #0d1117; + --body-quiet-color : #8b949e; + --body-loud-color : #e6edf3; + + --header-color : #e6edf3; + --header-branding-color: #00d4aa; + --header-bg : #161b22; + --header-link-color : #e6edf3; + + --breadcrumbs-fg : #8b949e; + --breadcrumbs-link-fg : #00d4aa; + --breadcrumbs-bg : #161b22; + + --link-fg : #00d4aa; + --link-hover-color : #00a884; + --link-selected-fg : #00d4aa; + + --hairline-color : #1f2a36; + --border-color : #1f2a36; + + --error-fg : #f85149; + + --message-success-bg : rgba(63,185,80,.15); + --message-warning-bg : rgba(210,153,34,.15); + --message-error-bg : rgba(248,81,73,.15); + + --darkened-bg : #161b22; + --selected-bg : #21262d; + --selected-row : #2d333b; + + --button-fg : #0d1117; + --button-bg : #00d4aa; + --button-hover-bg : #00a884; + --default-button-fg : #0d1117; + --default-button-bg : #00d4aa; + --default-button-hover-bg: #00a884; + --close-button-bg : #21262d; + --close-button-hover-bg : #2d333b; + + --object-tools-fg : #0d1117; + --object-tools-bg : #00d4aa; + --object-tools-hover-bg : #00a884; + + --font-family : 'Segoe UI', system-ui, -apple-system, sans-serif; +} + +/* --- Global ------------------------------------------------- */ +html, body { font-family: var(--font-family); background: var(--body-bg); color: var(--body-fg); } +a { color: var(--link-fg); } +a:hover { color: var(--link-hover-color); } + +/* --- Header ------------------------------------------------- */ +#header { + background : var(--header-bg); + color : var(--header-color); + border-bottom: 1px solid #1f2a36; +} +#header a:link, #header a:visited { color: var(--header-color); } +#branding h1, #branding h1 a:link, #branding h1 a:visited { + color: var(--accent); + font-weight: 700; +} +#user-tools a { color: #8b949e; } +#user-tools a:hover { color: var(--accent); } + +/* --- Breadcrumbs -------------------------------------------- */ +div.breadcrumbs { + background : var(--breadcrumbs-bg); + color : var(--breadcrumbs-fg); + border-bottom: 1px solid #1f2a36; +} +div.breadcrumbs a { color: var(--accent); } +div.breadcrumbs a:hover { color: var(--link-hover-color); } + +/* --- Sidebar / nav ------------------------------------------ */ +#nav-sidebar { background: #161b22; border-right: 1px solid #1f2a36; } +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { color: var(--accent); } + +/* --- Content area ------------------------------------------- */ +#content { background: var(--body-bg); } +#content-main { background: var(--body-bg); } +.colMS #content-main { border-right: 1px solid #1f2a36; } + +/* --- Module headers (blocs gris-bleu → sombre+accent) ------- */ +.module caption, +.inline-group h2, +fieldset.module h2, +div.submit-row { + background : #161b22; + color : var(--body-fg); + border-bottom: 1px solid #1f2a36; +} +.module { background: #161b22; border: 1px solid #1f2a36; } +fieldset.module { background: #161b22; border: 1px solid #1f2a36; } + +/* --- Tables ------------------------------------------------- */ +#result_list thead th { background: #161b22; color: var(--body-quiet-color); border-bottom: 1px solid #1f2a36; } +#result_list tr.row1 { background: #0d1117; } +#result_list tr.row2 { background: #111820; } +#result_list tr:hover td { background: #21262d !important; } +#result_list td, #result_list th { border-right-color: #1f2a36; } +table { border-color: #1f2a36; } +td, th { border-color: #1f2a36; } + +/* --- Filtres (colonne droite) -------------------------------- */ +#changelist-filter { background: #161b22; border-left: 1px solid #1f2a36; } +#changelist-filter h2 { background: #161b22; color: var(--accent); border-bottom: 1px solid #1f2a36; } +#changelist-filter h3 { color: var(--body-quiet-color); border-bottom: 1px solid #1f2a36; } +#changelist-filter li.selected a { color: var(--accent); font-weight: 600; } +#changelist-filter a { color: var(--body-fg); } +#changelist-filter a:hover { color: var(--accent); } + +/* --- Formulaires -------------------------------------------- */ +input, select, textarea { + background : #21262d; + color : var(--body-fg); + border : 1px solid #2d3748; + border-radius: 4px; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); + outline : none; + box-shadow : 0 0 0 2px rgba(0,212,170,.2); +} +.form-row { border-bottom-color: #1f2a36; } +.aligned label { color: var(--body-quiet-color); } +.help { color: #8b949e; } + +/* --- Boutons ------------------------------------------------ */ +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background : var(--button-bg); + color : var(--button-fg); + border : none; + border-radius: 4px; + font-weight : 600; +} +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); + color : var(--button-fg); +} +.button.default, input[type=submit].default { + background: var(--accent); + color : #0d1117; +} +a.deletelink { background: #7f1d1d; color: #fca5a5; border-radius: 4px; padding: 4px 8px; } +a.deletelink:hover { background: #991b1b; color: #fca5a5; } + +/* --- Object tools (haut des formulaires) ------------------- */ +ul.object-tools li a { background: #21262d; color: var(--accent); border: 1px solid #1f2a36; } +ul.object-tools li a:hover { background: #2d333b; } +ul.object-tools li a.addlink { background: var(--accent); color: #0d1117; border: none; } +ul.object-tools li a.addlink:hover { background: var(--link-hover-color); } + +/* --- Pagination -------------------------------------------- */ +.paginator a, .paginator span { + background : #21262d; + color : var(--body-fg); + border : 1px solid #1f2a36; + border-radius: 3px; +} +.paginator a:hover { background: #2d333b; } +.paginator .this-page { background: var(--accent); color: #0d1117; border-color: var(--accent); } + +/* --- Messages (success/warning/error) ---------------------- */ +.messagelist li { border-radius: 4px; padding: 10px 14px; } +.messagelist li.success { background: rgba(63,185,80,.15); color: #3fb950; border: 1px solid rgba(63,185,80,.3); } +.messagelist li.warning { background: rgba(210,153,34,.15); color: #d29922; border: 1px solid rgba(210,153,34,.3); } +.messagelist li.error { background: rgba(248,81,73,.15); color: #f85149; border: 1px solid rgba(248,81,73,.3); } + +/* --- Dashboard (index admin) -------------------------------- */ +#content-main .module h2 { color: var(--body-fg); } +.dashboard #content { background: var(--body-bg); } + +/* --- Inlines ------------------------------------------------ */ +.inline-group { border: 1px solid #1f2a36; } +.inline-group thead tr { background: #161b22; } +.inline-related h3 { background: #161b22; border: 1px solid #1f2a36; color: var(--accent); } + +/* --- Misc --------------------------------------------------- */ +.errornote { background: rgba(248,81,73,.1); color: #f85149; border: 1px solid rgba(248,81,73,.4); } +.errorlist li { color: #f85149; } +.selector { background: #161b22; border: 1px solid #1f2a36; } +.selector select { background: #0d1117; color: var(--body-fg); border: none; } +.selector-available h2, .selector-chosen h2 { background: #161b22; color: var(--body-quiet-color); } +.selector-filter input { background: #21262d; color: var(--body-fg); border-color: #2d3748; } diff --git a/emitter/banevents/static/banevents/css/main.css b/emitter/banevents/static/banevents/css/main.css new file mode 100644 index 0000000..803a5b4 --- /dev/null +++ b/emitter/banevents/static/banevents/css/main.css @@ -0,0 +1,760 @@ +/* ============================================================ + Feuille de style principale — main + Thème sombre, zéro dépendance externe + ============================================================ */ + +/* --- Variables -------------------------------------------- */ +:root { + --bg-base : #0d1117; + --bg-surface : #1c2128; + --bg-elevated : #2d333b; + --bg-hover : #373e47; + + --accent : #00d4aa; + --accent-dim : #00a884; + --accent-glow : rgba(0,212,170,.15); + + --danger : #f85149; + --warning : #e3b341; + --info : #58a6ff; + --success : #56d364; + + --text-primary : #f0f6fc; + --text-secondary:#b1bac4; + --text-muted : #768390; + + --border : #30363d; + + --radius-sm : 6px; + --radius-md : 10px; + --radius-lg : 16px; + + --shadow-sm : 0 1px 3px rgba(0,0,0,.4); + --shadow-md : 0 4px 16px rgba(0,0,0,.5); + --shadow-lg : 0 8px 32px rgba(0,0,0,.6); + + --sidebar-w : 220px; + --font : 'Segoe UI', system-ui, -apple-system, sans-serif; +} + +/* --- Reset ------------------------------------------------- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 15px; scroll-behavior: smooth; } +body { + font-family : var(--font); + background : var(--bg-base); + color : var(--text-primary); + min-height : 100vh; + display : flex; + flex-direction : row; + line-height : 1.6; +} +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-dim); } +img { display: block; } +button { font-family: var(--font); cursor: pointer; } + +/* --- Sidebar ---------------------------------------------- */ +.vc-sidebar { + position : fixed; + top: 0; left: 0; bottom: 0; + width : var(--sidebar-w); + background : var(--bg-surface); + border-right : 1px solid var(--border); + display : flex; + flex-direction : column; + z-index : 1000; + overflow-y : auto; +} +.vc-brand { + display : flex; + flex-direction : column; + align-items : flex-start; + gap : 2px; + font-size : 1.1rem; + font-weight : 700; + color : var(--accent); + padding : 20px 16px 16px; + border-bottom : 1px solid var(--border); + white-space : nowrap; +} +.vc-brand:hover { color: var(--accent); } +.vc-brand small { + display : block; + font-size : 0.7rem; + font-weight : 400; + color : var(--text-muted); +} + +.vc-sidebar-nav { + display : flex; + flex-direction : column; + gap : 2px; + padding : 12px 8px; + flex : 1; +} +.vc-nav-link { + display : flex; + align-items : center; + gap : 10px; + padding : 9px 12px; + border-radius : var(--radius-sm); + color : var(--text-secondary); + font-size : .9rem; + transition : background .15s, color .15s; +} +.vc-nav-link:hover, +.vc-nav-link--active { + background : var(--bg-hover); + color : var(--text-primary); +} +.vc-nav-link--active { + color : var(--accent); +} +.vc-nav-link { position: relative; } +button.vc-nav-link { + width : 100%; + background : transparent; + border : none; + cursor : pointer; + text-align : left; + font-family : inherit; +} +.vc-nav-alert-badge { + margin-left : auto; + display : inline-flex; + align-items : center; + justify-content: center; + min-width : 18px; + height : 18px; + padding : 0 5px; + border-radius : 9px; + font-size : .7rem; + font-weight : 700; + background : var(--danger, #f85149); + color : #fff; + line-height : 1; +} + +.vc-sidebar-bottom { + border-top : 1px solid var(--border); + padding : 12px 8px; + display : flex; + flex-direction : column; + gap : 4px; +} +.vc-sidebar-user { + display : flex; + align-items : center; + gap : 8px; + padding : 8px 12px; + border-radius : var(--radius-sm); + color : var(--text-secondary); + font-size : .88rem; +} +.vc-logout-btn { + display : flex; + align-items : center; + gap : 8px; + width : 100%; + padding : 8px 12px; + background : transparent; + border : none; + border-radius : var(--radius-sm); + color : var(--danger); + font-size : .88rem; + text-align : left; + transition : background .12s; +} +.vc-logout-btn:hover { background: rgba(248,81,73,.1); } + +/* --- Navbar pagination ------------------------------------ */ +.vc-nav-page { + display : none; + flex-direction : column; + gap : 2px; +} +.vc-nav-page--active { + display : flex; + animation : vc-page-in .18s ease; +} +@keyframes vc-page-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} +.vc-nav-pager { + display : flex; + align-items : center; + justify-content: center; + gap : 6px; + padding : 5px 0 2px; +} +.vc-pager-btn { + background : transparent; + border : none; + color : var(--text-muted); + font-size : 1.15rem; + line-height : 1; + padding : 1px 5px; + border-radius : var(--radius-sm); + cursor : pointer; + transition : color .12s, background .12s; +} +.vc-pager-btn:hover:not(:disabled) { + color : var(--text-primary); + background : var(--bg-hover); +} +.vc-pager-btn:disabled { opacity: .25; cursor: default; } +.vc-pager-dots { + display : flex; + gap : 5px; + align-items : center; +} +.vc-pager-dot { + width : 5px; + height : 5px; + border-radius : 50%; + background : var(--text-muted); + cursor : pointer; + transition : background .15s, transform .15s; +} +.vc-pager-dot:hover { background: var(--text-secondary); } +.vc-pager-dot--active { + background : var(--accent); + transform : scale(1.4); +} + +/* --- Sidebar collapse (desktop) --------------------------- */ +.vc-sidebar { + transition : transform .25s ease; +} +.vc-sidebar--collapsed { + transform : translateX(-100%); +} +.vc-sidebar-collapse-btn { + display : flex; + align-items : center; + justify-content: center; + width : 100%; + padding : 7px 12px; + background : transparent; + border : none; + border-top : 1px solid var(--border); + color : var(--text-muted); + font-size : .8rem; + gap : 6px; + cursor : pointer; + transition : color .15s, background .15s; + font-family : inherit; +} +.vc-sidebar-collapse-btn:hover { color: var(--text-primary); background: var(--bg-hover); } + +/* Bouton flottant pour ré-ouvrir le sidebar (desktop) */ +.vc-sidebar-open-btn { + display : none; + position : fixed; + top : 12px; + left : 8px; + z-index : 1100; + background : var(--bg-surface); + border : 1px solid var(--border); + border-radius : var(--radius-sm); + padding : 6px 8px; + color : var(--text-primary); + cursor : pointer; +} +.vc-sidebar-open-btn:hover { background: var(--bg-hover); } +.vc-main-wrap { transition: margin-left .25s ease; } + +/* --- Hamburger (mobile) ----------------------------------- */ +.vc-sidebar-toggle { + display : none; + position : fixed; + top : 12px; + left : 12px; + z-index : 1100; + background : var(--bg-surface); + border : 1px solid var(--border); + border-radius : var(--radius-sm); + padding : 6px 8px; + color : var(--text-primary); +} + +/* --- Layout wrapper --------------------------------------- */ +.vc-main-wrap { + flex : 1; + display : flex; + flex-direction : column; + min-height : 100vh; + min-width : 0; +} +.vc-main-wrap--with-sidebar { + margin-left : var(--sidebar-w); +} + +/* --- Main / layout ---------------------------------------- */ +.vc-main { flex: 1; } +.vc-container { max-width: 1280px; margin: 0 auto; padding: 32px 24px; } +.vc-container--narrow { max-width: 900px; } +.vc-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } + +/* --- En-tête de page -------------------------------------- */ +.vc-page-header { + display : flex; + align-items : center; + justify-content: space-between; + margin-bottom : 28px; + gap : 16px; + flex-wrap : wrap; +} +.vc-page-header h2 { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 10px; } +.vc-page-header h2 svg { color: var(--accent); } +.vc-header-actions { display: flex; gap: 8px; } + +/* --- Cartes ----------------------------------------------- */ +.vc-card { + background : var(--bg-surface); + border : 1px solid var(--border); + border-radius : var(--radius-lg); + padding : 24px; + margin-bottom : 20px; +} +.vc-card-title { + font-size : 1rem; + font-weight : 600; + margin-bottom : 16px; + display : flex; + align-items : center; + gap : 8px; +} +.vc-card-title svg { color: var(--accent); } + +/* --- Boutons ---------------------------------------------- */ +.vc-btn { + display : inline-flex; + align-items : center; + justify-content: center; + gap : 7px; + padding : 8px 18px; + border-radius : var(--radius-sm); + font-size : .9rem; + font-weight : 500; + line-height : 1.4; + border : 1px solid transparent; + transition : all .15s; + white-space : nowrap; + text-decoration: none; +} +.vc-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; } +.vc-btn--primary { background: var(--accent); color: #0d1117; border-color: var(--accent); } +.vc-btn--primary:hover { background: var(--accent-dim); border-color: var(--accent-dim); color: #0d1117; } +.vc-btn--ghost { background: var(--bg-surface); color: var(--text-primary); border-color: #6e7681; } +.vc-btn--ghost:hover { background: var(--bg-hover); color: var(--text-primary); border-color: #8b949e; } +.vc-btn--danger { background: transparent; color: var(--danger); border-color: var(--danger); } +.vc-btn--danger:hover { background: var(--danger); color: #fff; } +.vc-btn--large { padding: 12px 28px; font-size: 1rem; } +.vc-btn--block { width: 100%; } +.vc-btn--sm { padding: 5px 10px; font-size: .82rem; } + +/* --- Formulaires ------------------------------------------ */ +.vc-form-group { margin-bottom: 18px; } +.vc-form-group label { + display : flex; + align-items : center; + gap : 6px; + font-size : .85rem; + font-weight : 500; + color : var(--text-secondary); + margin-bottom : 6px; +} +.vc-form-group input[type="text"], +.vc-form-group input[type="email"], +.vc-form-group input[type="password"], +.vc-form-group input[type="number"], +.vc-form-group textarea, +.vc-form-group select { + width : 100%; + padding : 9px 12px; + background : var(--bg-elevated); + border : 1px solid var(--border); + border-radius : var(--radius-sm); + color : var(--text-primary); + font-size : .9rem; + font-family : var(--font); + transition : border-color .15s, box-shadow .15s; +} +.vc-form-group input:focus, +.vc-form-group textarea:focus, +.vc-form-group select:focus { + outline : none; + border-color : var(--accent); + box-shadow : 0 0 0 3px var(--accent-glow); +} +.vc-form-group input::placeholder, +.vc-form-group textarea::placeholder { color: var(--text-muted); } +.vc-field-error { color: var(--danger); font-size: .82rem; margin-top: 4px; } +.vc-field-hint { color: var(--text-muted); font-size: .8rem; margin-top: 4px; } +.vc-form-actions { + display : flex; + gap : 10px; + justify-content: flex-end; + padding-top : 16px; + border-top : 1px solid var(--border); + margin-top : 20px; +} + +/* --- Badges ----------------------------------------------- */ +.vc-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 20px; + font-size: .78rem; + font-weight: 600; +} +.vc-badge--success { background: rgba(63,185,80,.15); color: var(--success); } +.vc-badge--danger { background: rgba(248,81,73,.15); color: var(--danger); } +.vc-badge--info { background: rgba(56,139,253,.15); color: var(--info); } +.vc-badge--warning { background: rgba(210,153,34,.15); color: var(--warning); } + +/* --- Messages système ------------------------------------- */ +.vc-messages { + position : fixed; + top : 12px; + right : 16px; + z-index : 2000; + display : flex; + flex-direction : column; + gap : 8px; + max-width : 360px; +} +.vc-alert { + display : flex; + align-items : center; + justify-content: space-between; + gap : 12px; + padding : 12px 16px; + border-radius : var(--radius-md); + background : var(--bg-elevated); + border-left : 4px solid var(--accent); + box-shadow : var(--shadow-md); + font-size : .88rem; +} +.vc-alert--error { border-color: var(--danger); } +.vc-alert--warning { border-color: var(--warning); } +.vc-alert--info { border-color: var(--info); } +.vc-alert button { background: none; border: none; color: var(--text-muted); font-size: .9rem; padding: 2px; flex-shrink: 0; } +.vc-alert button:hover { color: var(--text-primary); } + +/* --- Authentification ------------------------------------- */ +.vc-auth-wrap { + min-height : 100vh; + display : flex; + align-items : center; + justify-content: center; + padding : 32px 16px; + background : radial-gradient(ellipse at 60% 30%, rgba(0,212,170,.06) 0%, transparent 70%); +} +.vc-auth-card { + width : 100%; + max-width : 420px; + background : var(--bg-surface); + border : 1px solid var(--border); + border-radius : var(--radius-lg); + padding : 40px 36px; + box-shadow : var(--shadow-lg); +} +.vc-auth-header { text-align: center; margin-bottom: 28px; } +.vc-auth-icon { + width : 56px; height: 56px; + border-radius : 50%; + background : var(--accent-glow); + border : 1px solid var(--accent); + display : flex; + align-items : center; + justify-content: center; + margin : 0 auto 16px; + color : var(--accent); +} +.vc-auth-header h1 { font-size: 1.6rem; margin-bottom: 6px; } +.vc-auth-header p { color: var(--text-secondary); font-size: .88rem; } + +/* --- Table générique -------------------------------------- */ +.vc-table { width: 100%; border-collapse: collapse; font-size: .9rem; } +.vc-table th { + text-align: left; padding: 10px 12px; + border-bottom: 2px solid var(--border); + color: var(--text-secondary); font-weight: 600; font-size: .8rem; + text-transform: uppercase; letter-spacing: .05em; white-space: nowrap; +} +.vc-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.vc-table tr:last-child td { border-bottom: none; } +.vc-table tr:hover td { background: var(--bg-elevated); } + +/* --- État vide -------------------------------------------- */ +.vc-empty-state { text-align: center; padding: 72px 24px; } +.vc-empty-state svg { color: var(--bg-elevated); display: block; margin: 0 auto 16px; } +.vc-empty-state h3 { color: var(--text-secondary); margin-bottom: 8px; } +.vc-empty-state p { color: var(--text-muted); margin-bottom: 16px; } + +/* --- Dashboard grille ------------------------------------- */ +.vc-dashboard-grid { + display : grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows : 120px; + gap : 12px; + padding : 24px; + align-items : start; +} +.vc-widget { + background : var(--bg-surface); + border : 1px solid var(--border); + border-radius : var(--radius-lg); + overflow : hidden; + display : flex; + flex-direction : column; + transition : border-color .2s, box-shadow .2s; + height : 100%; +} +.vc-widget:hover { border-color: var(--accent); box-shadow: 0 4px 20px rgba(0,212,170,.08); } +.vc-widget--dragging { + opacity : .5; + border-color : var(--accent); +} +.vc-widget--drag-over { + border-color : var(--accent); + box-shadow : 0 0 0 2px var(--accent-glow); +} +.vc-widget[draggable="true"] { cursor: grab; } +.vc-widget[draggable="true"]:active { cursor: grabbing; } +.vc-widget-header { + display : flex; + align-items : center; + justify-content: space-between; + padding : 10px 14px; + border-bottom : 1px solid var(--border); + font-size : .85rem; + font-weight : 600; + flex-shrink : 0; + gap : 8px; +} +.vc-widget-header svg { color: var(--accent); flex-shrink: 0; } +.vc-widget-body { + padding : 14px; + flex : 1; + overflow : hidden; +} + +/* Widget : texte / boîte */ +.vc-widget-text { + font-size : .9rem; + line-height : 1.7; + color : var(--text-secondary); + word-break : break-word; +} + +/* Widget : Grafana iframe */ +.vc-widget-iframe { + width : 100%; + height : 100%; + border : none; + display : block; +} +.vc-widget-body--iframe { + padding : 0; +} + +/* Widget : placeholder (caméra / device) */ +.vc-widget-placeholder { + height : 100%; + display : flex; + flex-direction : column; + align-items : center; + justify-content: center; + gap : 8px; + color : var(--text-muted); + font-size : .82rem; +} +.vc-widget-placeholder svg { color: var(--bg-elevated); } + +/* Widget : grille imbriquée */ +.vc-widget-grid { + display : grid; + grid-template-columns: 1fr 1fr; + gap : 8px; + height : 100%; +} +.vc-widget-grid-cell { + background : var(--bg-elevated); + border-radius : var(--radius-sm); + display : flex; + align-items : center; + justify-content: center; + color : var(--text-muted); + font-size : .8rem; +} + +/* --- Footer ----------------------------------------------- */ +.vc-footer { + background : var(--bg-surface); + border-top : 1px solid var(--border); + padding : 12px 24px; + font-size : .8rem; + color : var(--text-muted); + display : flex; + align-items : center; + gap : 12px; +} +.vc-footer a { color: var(--text-muted); } +.vc-footer a:hover { color: var(--accent); } + +/* --- Texte utilitaire ------------------------------------- */ +.vc-text-muted { color: var(--text-muted); font-size: .88rem; display: flex; align-items: center; gap: 6px; } +.vc-section-title { font-size: 1rem; font-weight: 600; color: var(--text-secondary); margin: 0 0 16px; display: flex; align-items: center; gap: 8px; } +.vc-section-title svg { color: var(--accent); } + +/* --- Sélecteur de langue (Phase 5) -------------------------- */ +.vc-language-switcher { + padding: 10px 12px; + border-top: 1px solid var(--border); + margin-top: auto; +} +.vc-language-switcher select { + width: 100%; + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-size: .85rem; +} + +/* --- Stats système sidebar --------------------------------- */ +.vc-sidebar-stats { + padding : 10px 12px 8px; + border-bottom : 1px solid var(--border); + display : flex; + flex-direction : column; + gap : 6px; +} +.vc-stat-row { + display : flex; + align-items : center; + gap : 7px; + color : var(--text-muted); + cursor : default; +} +.vc-stat-icon { + width : 13px; + height : 13px; + flex-shrink : 0; + color : var(--text-secondary); +} +.vc-stat-track { + flex : 1; + height : 3px; + background : var(--bg-elevated); + border-radius : 2px; + overflow : hidden; +} +.vc-stat-fill { + height : 100%; + width : 0%; + border-radius : 2px; + transition : width .6s ease; +} +.vc-stat-fill--cpu { background: #6366f1; } +.vc-stat-fill--mem { background: #f97316; } +.vc-stat-fill--disk { background: #eab308; } +.vc-stat-fill--warn { background: #ef4444; } +.vc-stat-pct { + width : 30px; + text-align : right; + font-size : 10px; + font-variant-numeric: tabular-nums; + color : var(--text-muted); + flex-shrink : 0; +} + +/* Compteurs top-jail/top-pays : réutilisent le mécanisme track+fill+pct + ci-dessus (barre relative au plus grand compte de la liste, pas un + pourcentage réel), avec un libellé texte à la place de l'icône — + catégories nominales sans ordre naturel, donc UNE seule couleur par + widget plutôt qu'une rotation par ligne (cf. skill dataviz : ne pas + double-encoder la longueur de barre avec une teinte différente par + catégorie quand rien ne justifie un ordre). */ +.vc-stat-label { + width : 84px; + flex-shrink : 0; + overflow : hidden; + text-overflow : ellipsis; + white-space : nowrap; + font-size : 11px; +} +.vc-stat-label--link { color: var(--text-secondary); text-decoration: none; } +.vc-stat-label--link:hover { color: var(--accent); text-decoration: underline; } +.vc-stat-fill--jail { background: var(--accent); } +.vc-stat-fill--country { background: var(--info); } +.vc-sidebar-stats-title { + font-size : .68rem; + text-transform : uppercase; + letter-spacing : .05em; + color : var(--text-muted); + margin-top : 2px; +} +.vc-sidebar-stats-title:first-child { margin-top: 0; } +/* Plafonne le bloc "Noeuds" à ~6 lignes avec défilement propre à lui — + sans ça, beaucoup de noeuds (roster, Phase 3) repoussent indéfiniment + "Top jails"/"Top pays attaquants" plus bas, jusqu'à devoir scroller + toute la sidebar (nav comprise) pour les atteindre. */ +.vc-node-list { + display : flex; + flex-direction : column; + gap : 6px; + max-height : 150px; + overflow-y : auto; +} +.vc-stat-dot { + width : 7px; + height : 7px; + border-radius : 50%; + flex-shrink : 0; +} +.vc-stat-dot--online { background: var(--success); } +.vc-stat-dot--offline { background: var(--danger); } +.vc-stat-time { + margin-left : auto; + font-size : 10px; + color : var(--text-muted); + flex-shrink : 0; +} + +/* --- Responsive ------------------------------------------- */ +@media (max-width: 1200px) { + .vc-dashboard-grid { + grid-template-columns: repeat(8, 1fr); + } +} +@media (max-width: 768px) { + .vc-sidebar { + transform : translateX(-100%); + transition : transform .25s ease; + } + .vc-sidebar--open { + transform : translateX(0); + } + .vc-sidebar-toggle { display: flex; align-items: center; } + .vc-sidebar-collapse-btn { display: none; } + .vc-sidebar-open-btn { display: none !important; } + .vc-main-wrap--with-sidebar { margin-left: 0; } + .vc-grid-2col { grid-template-columns: 1fr; } + .vc-container { padding: 16px; } + .vc-auth-card { padding: 28px 20px; } + .vc-page-header { flex-direction: column; align-items: flex-start; } + .vc-dashboard-grid { + grid-template-columns: repeat(4, 1fr); + padding: 12px; + gap: 8px; + } +} diff --git a/emitter/banevents/static/banevents/js/dashboard.js b/emitter/banevents/static/banevents/js/dashboard.js new file mode 100644 index 0000000..98c32d6 --- /dev/null +++ b/emitter/banevents/static/banevents/js/dashboard.js @@ -0,0 +1,44 @@ +document.addEventListener('DOMContentLoaded', () => { + const selectedNode = document.body.dataset.selectedNode || ''; + const matchesFilter = (banEvent) => !selectedNode || banEvent.node === selectedNode; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const stream = new BanEventStream(`${protocol}//${window.location.host}/ws/banevents/`); + + const notifications = new NotificationCenter('#banevents-messages'); + stream.onEvent((message) => { + if (message.kind === 'command') { + notifications.show(message); + } + }); + + const table = new BanEventTable('#banevents-table-body', '#banevents-empty-state'); + stream.onEvent((banEvent) => { + if (banEvent.kind === 'ban' && matchesFilter(banEvent)) { + table.prependRow(banEvent); + } + }); + + const mapElement = document.getElementById('banevents-map'); + if (mapElement) { + const map = new BanEventMap('banevents-map'); + stream.onEvent((banEvent) => { + if (banEvent.kind === 'ban' && matchesFilter(banEvent)) { + map.addMarker(banEvent); + } + }); + + const initialDataElement = document.getElementById('banevents-initial-data'); + if (initialDataElement) { + const initialEvents = JSON.parse(initialDataElement.textContent); + initialEvents.forEach((banEvent) => map.addMarker(banEvent)); + } + + const resetButton = document.getElementById('banevents-map-reset'); + if (resetButton) { + resetButton.addEventListener('click', () => map.resetView()); + } + } + + stream.connect(); +}); diff --git a/emitter/banevents/static/banevents/js/history.js b/emitter/banevents/static/banevents/js/history.js new file mode 100644 index 0000000..0d50ffa --- /dev/null +++ b/emitter/banevents/static/banevents/js/history.js @@ -0,0 +1,19 @@ +document.addEventListener('DOMContentLoaded', () => { + const mapElement = document.getElementById('banevents-map'); + if (!mapElement) { + return; + } + + const map = new BanEventMap('banevents-map'); + + const initialDataElement = document.getElementById('banevents-initial-data'); + if (initialDataElement) { + const initialEvents = JSON.parse(initialDataElement.textContent); + initialEvents.forEach((banEvent) => map.addMarker(banEvent)); + } + + const resetButton = document.getElementById('banevents-map-reset'); + if (resetButton) { + resetButton.addEventListener('click', () => map.resetView()); + } +}); diff --git a/emitter/banevents/static/banevents/js/map.js b/emitter/banevents/static/banevents/js/map.js new file mode 100644 index 0000000..61fc614 --- /dev/null +++ b/emitter/banevents/static/banevents/js/map.js @@ -0,0 +1,42 @@ +/* Carte Leaflet : un marqueur par BanEvent géolocalisé. Les événements + * sans latitude/longitude (géolocalisation pas encore terminée) sont + * ignorés jusqu'à la mise à jour poussée par la tâche Celery. */ +class BanEventMap { + static DEFAULT_CENTER = [20, 0]; + static DEFAULT_ZOOM = 2; + + constructor(elementId) { + this.map = L.map(elementId).setView(BanEventMap.DEFAULT_CENTER, BanEventMap.DEFAULT_ZOOM); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 18, + }).addTo(this.map); + this.markers = L.layerGroup().addTo(this.map); + this.markersByEventId = new Map(); + } + + resetView() { + this.map.setView(BanEventMap.DEFAULT_CENTER, BanEventMap.DEFAULT_ZOOM); + } + + addMarker(banEvent) { + if (banEvent.latitude == null || banEvent.longitude == null) { + return; + } + this.removeMarker(banEvent.id); + + const marker = L.marker([banEvent.latitude, banEvent.longitude]); + const location = [banEvent.city, banEvent.country].filter(Boolean).join(', '); + marker.bindPopup(`${banEvent.ip_address}
${banEvent.jail_name} — ${banEvent.action}
${location}`); + marker.addTo(this.markers); + this.markersByEventId.set(banEvent.id, marker); + } + + removeMarker(eventId) { + const existing = this.markersByEventId.get(eventId); + if (existing) { + this.markers.removeLayer(existing); + this.markersByEventId.delete(eventId); + } + } +} diff --git a/emitter/banevents/static/banevents/js/notifications.js b/emitter/banevents/static/banevents/js/notifications.js new file mode 100644 index 0000000..910702b --- /dev/null +++ b/emitter/banevents/static/banevents/js/notifications.js @@ -0,0 +1,50 @@ +/* Notifications transitoires (.vc-messages/.vc-alert, main.css) pour les + * commandes reçues du master (banevents.consumers.command_notification) — + * un SYNC_BAN réussi apparaît déjà indirectement via la ligne de table + * qu'il déclenche ; ceci couvre les échecs et commandes non gérées, qui + * autrement ne laissent aucune trace visible hors journalctl. */ +const ALERT_CLASSES = { + ok: '', + error: 'vc-alert--error', + unhandled: 'vc-alert--warning', +}; +const AUTO_DISMISS_MS = 8000; + +// Chaînes traduites (Phase 5) posées par dashboard.html (#banevents-i18n, +// json_script-style) — jamais codées en dur ici, pour rester cohérentes +// avec la langue active côté serveur (fr/en). +const I18N_EL = document.getElementById('banevents-i18n'); +const I18N = I18N_EL ? JSON.parse(I18N_EL.textContent) : { + unhandled: 'Commande reçue du master (non gérée)', ok: 'appliqué', error: 'échec', close: 'Fermer', +}; + +function commandNotificationText(notification) { + const { cmd, ip, status, detail } = notification; + if (status === 'unhandled') { + return `${I18N.unhandled} : ${cmd}`; + } + const outcome = status === 'ok' ? I18N.ok : `${I18N.error}${detail ? ` (${detail})` : ''}`; + return `${cmd} ${ip} → ${outcome}`; +} + +class NotificationCenter { + constructor(containerSelector) { + this.container = document.querySelector(containerSelector); + } + + show(notification) { + if (!this.container) { + return; + } + const alertClass = ALERT_CLASSES[notification.status] || ''; + const alert = document.createElement('div'); + alert.className = `vc-alert ${alertClass}`.trim(); + alert.innerHTML = ` + ${commandNotificationText(notification)} + + `; + alert.querySelector('button').addEventListener('click', () => alert.remove()); + this.container.appendChild(alert); + setTimeout(() => alert.remove(), AUTO_DISMISS_MS); + } +} diff --git a/emitter/banevents/static/banevents/js/stream.js b/emitter/banevents/static/banevents/js/stream.js new file mode 100644 index 0000000..ebef58c --- /dev/null +++ b/emitter/banevents/static/banevents/js/stream.js @@ -0,0 +1,26 @@ +/* Connexion WebSocket unique au tableau de bord, redistribuée aux écouteurs + * (tableau, carte) via onEvent(). Reconnexion automatique sur coupure. */ +class BanEventStream { + constructor(url) { + this.url = url; + this.listeners = []; + this.socket = null; + } + + onEvent(callback) { + this.listeners.push(callback); + } + + connect() { + this.socket = new WebSocket(this.url); + this.socket.addEventListener('message', (event) => { + const banEvent = JSON.parse(event.data); + this.listeners.forEach((callback) => callback(banEvent)); + }); + this.socket.addEventListener('close', () => this.scheduleReconnect()); + } + + scheduleReconnect() { + setTimeout(() => this.connect(), 3000); + } +} diff --git a/emitter/banevents/static/banevents/js/table.js b/emitter/banevents/static/banevents/js/table.js new file mode 100644 index 0000000..145608c --- /dev/null +++ b/emitter/banevents/static/banevents/js/table.js @@ -0,0 +1,38 @@ +/* Ajoute chaque BanEvent reçu en tête du tableau du tableau de bord. */ +const ACTION_BADGE_CLASSES = { + BAN: 'vc-badge--danger', + UNBAN: 'vc-badge--success', +}; + +function actionBadgeClass(action) { + return ACTION_BADGE_CLASSES[action] || 'vc-badge--info'; +} + +class BanEventTable { + constructor(tableBodySelector, emptyStateSelector) { + this.tableBody = document.querySelector(tableBodySelector); + this.emptyState = document.querySelector(emptyStateSelector); + } + + prependRow(banEvent) { + if (this.emptyState) { + this.emptyState.remove(); + this.emptyState = null; + } + if (!this.tableBody) { + return; + } + const row = document.createElement('tr'); + row.innerHTML = ` + ${banEvent.received_at} + ${banEvent.node_alias} + ${banEvent.jail_name} + ${banEvent.action} + ${banEvent.ip_address} + ${banEvent.port || '—'}/${banEvent.protocol || '—'} + ${banEvent.bantime ?? '—'} + ${banEvent.city || banEvent.country || '—'} + `; + this.tableBody.prepend(row); + } +} diff --git a/emitter/banevents/static/banevents/vendor/leaflet/images/layers-2x.png b/emitter/banevents/static/banevents/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/emitter/banevents/static/banevents/vendor/leaflet/images/layers-2x.png differ diff --git a/emitter/banevents/static/banevents/vendor/leaflet/images/layers.png b/emitter/banevents/static/banevents/vendor/leaflet/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/emitter/banevents/static/banevents/vendor/leaflet/images/layers.png differ diff --git a/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon-2x.png b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon-2x.png differ diff --git a/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon.png b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-icon.png differ diff --git a/emitter/banevents/static/banevents/vendor/leaflet/images/marker-shadow.png b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/emitter/banevents/static/banevents/vendor/leaflet/images/marker-shadow.png differ diff --git a/emitter/banevents/static/banevents/vendor/leaflet/leaflet.css b/emitter/banevents/static/banevents/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/emitter/banevents/static/banevents/vendor/leaflet/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/emitter/banevents/static/banevents/vendor/leaflet/leaflet.js b/emitter/banevents/static/banevents/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/emitter/banevents/static/banevents/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 list[dict]: + """Ajoute `pct` = largeur de barre relative au plus grand compte de la + liste — pas un vrai pourcentage (la somme ne fait pas 100), juste une + échelle visuelle pour la barre du widget sidebar.""" + max_count = max((row['count'] for row in rows), default=0) + for row in rows: + row['pct'] = round(row['count'] / max_count * 100) if max_count else 0 + return rows + + +def top_jails_and_countries() -> dict[str, list[dict]]: + """Compteurs globaux (toutes dates, tous noeuds connus de CE process) + — sur le master, "tous les noeuds" ; sur un client, seulement lui-même + (d'où le roster, qui republie la version calculée côté master).""" + top_jails = list( + BanEvent.objects.values('jail_name').annotate(count=Count('id')).order_by('-count')[:TOP_N] + ) + top_countries = list( + BanEvent.objects.exclude(country='') + .values('country').annotate(count=Count('id')).order_by('-count')[:TOP_N] + ) + return {'top_jails': with_bar_pct(top_jails), 'top_countries': with_bar_pct(top_countries)} diff --git a/emitter/banevents/tasks.py b/emitter/banevents/tasks.py new file mode 100644 index 0000000..3c594a0 --- /dev/null +++ b/emitter/banevents/tasks.py @@ -0,0 +1,40 @@ +"""Géolocalisation asynchrone des IP bannies (hors du chemin d'ingestion MQTT). + +Fallback API externe (ip-api.com, sans clé) tant qu'aucune base GeoLite2 +locale (MaxMind) n'est configurée — voir ROADMAP.md, Phase 2.a. +""" +import requests +from celery import shared_task +from django.utils import timezone + +from .models import BanEvent + +GEOLOCATION_API_URL = 'http://ip-api.com/json/{ip}' + + +@shared_task(bind=True, max_retries=3, default_retry_delay=10) +def geolocate_ip(self, ban_event_id: int) -> None: + try: + event = BanEvent.objects.get(pk=ban_event_id) + except BanEvent.DoesNotExist: + return + + try: + response = requests.get(GEOLOCATION_API_URL.format(ip=event.ip_address), timeout=5) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + raise self.retry(exc=e) + + if data.get('status') != 'success': + return + + event.latitude = data.get('lat') + event.longitude = data.get('lon') + event.country = data.get('country', '') + event.country_code = data.get('countryCode', '') + event.city = data.get('city', '') + event.geolocated_at = timezone.now() + event.save(update_fields=[ + 'latitude', 'longitude', 'country', 'country_code', 'city', 'geolocated_at', + ]) diff --git a/emitter/banevents/templates/admin/base_site.html b/emitter/banevents/templates/admin/base_site.html new file mode 100644 index 0000000..a42e62f --- /dev/null +++ b/emitter/banevents/templates/admin/base_site.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} +{% load static %} + +{% comment %} +Étend admin/base.html directement (le VRAI parent), pas +admin/base_site.html : puisque banevents précède django.contrib.admin +dans INSTALLED_APPS (nécessaire pour que CE fichier soit trouvé plutôt +que celui de Django), un {% extends "admin/base_site.html" %} ici +retomberait sur ce même fichier et bouclerait à l'infini. Les blocs +title/branding sont donc recopiés depuis le base_site.html par défaut +de Django plutôt qu'hérités. +{% endcomment %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block branding %} + +{% if user.is_anonymous %} + {% include "admin/color_theme_toggle.html" %} +{% endif %} +{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} diff --git a/emitter/banevents/templates/banevents/_event_row.html b/emitter/banevents/templates/banevents/_event_row.html new file mode 100644 index 0000000..1e99902 --- /dev/null +++ b/emitter/banevents/templates/banevents/_event_row.html @@ -0,0 +1,10 @@ + + {{ event.received_at|date:"c" }} + {{ event.display_node }} + {{ event.jail_name }} + {{ event.action }} + {{ event.ip_address }} + {{ event.port|default:"—" }}/{{ event.protocol|default:"—" }} + {{ event.bantime|default:"—" }} + {{ event.city|default:event.country|default:"—" }} + diff --git a/emitter/banevents/templates/banevents/_master_nav.html b/emitter/banevents/templates/banevents/_master_nav.html new file mode 100644 index 0000000..a2ecc89 --- /dev/null +++ b/emitter/banevents/templates/banevents/_master_nav.html @@ -0,0 +1,5 @@ +{% load i18n %} +{% if master_dashboard_url %} +↗ Master — {% trans "Direct" %} +↗ Master — {% trans "Historique" %} +{% endif %} diff --git a/emitter/banevents/templates/banevents/_sidebar_stats.html b/emitter/banevents/templates/banevents/_sidebar_stats.html new file mode 100644 index 0000000..507e39b --- /dev/null +++ b/emitter/banevents/templates/banevents/_sidebar_stats.html @@ -0,0 +1,65 @@ +{% load i18n %} +{% if nodes|length > 1 or top_jails or top_countries %} +
+ {% if nodes|length > 1 %} + {% comment %} + Heartbeat (last_seen) n'est mis à jour que par master_listen, qui ne + tourne que sur le master : sur un noeud simple, NodeRegistry ne contient + jamais que lui-même — ce bloc n'a de sens que là où plusieurs noeuds + sont effectivement suivis. + {% endcomment %} +
{% trans "Noeuds" %}
+
+ {% for node in nodes %} +
+ + {% if node.dashboard_url %} + {{ node.display_name }} + {% else %} + {{ node.display_name }} + {% endif %} + {{ node.last_seen|timesince }} +
+ {% endfor %} +
+ {% endif %} + {% if top_jails %} +
{% trans "Top jails" %}
+ {% for row in top_jails %} +
+ {{ row.jail_name }} + + {{ row.count }} +
+ {% endfor %} + {% endif %} + {% if top_countries %} +
{% trans "Top pays attaquants" %}
+ {% for row in top_countries %} +
+ {{ row.country }} + + {{ row.count }} +
+ {% endfor %} + {% endif %} +
+{% endif %} + +{% comment %} +Sélecteur de langue (Phase 5) — POST vers la vue set_language intégrée +de Django (config/urls.py, path("i18n/...")), qui pose le cookie +django_language lu par LocaleMiddleware à chaque requête suivante. +Auto-soumis au changement, pas de bouton "Valider" séparé. +{% endcomment %} +
+ {% csrf_token %} + + +
diff --git a/emitter/banevents/templates/banevents/community.html b/emitter/banevents/templates/banevents/community.html new file mode 100644 index 0000000..7cb79eb --- /dev/null +++ b/emitter/banevents/templates/banevents/community.html @@ -0,0 +1,76 @@ +{% load i18n %} + + + + + Fail2banActionBanisher — {% trans "Rejoindre la communauté" %} + + + +
+
+
+
+

{% trans "Rejoindre la communauté" %}

+ {% trans "Tableau de bord" %} +
+ + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + +
+
{% trans "Le but" %}
+

{% blocktrans %}Fail2banActionBanisher est un système distribué de bannissement d'hôtes malveillants (fail2ban + iptables), avec propagation des événements par MQTT. Un serveur "master" centralise la décision (corrélation multi-noeuds, récidive globale) et republie les actions à exécuter vers l'ensemble des noeuds abonnés.{% endblocktrans %}

+

{% blocktrans %}Rejoindre la communauté ne suppose aucun lien d'appartenance entre les serveurs : chaque membre garde son propre noeud et son propre fail2ban local, seule la propagation des bans est partagée via ce master commun. Aucun accès aux serveurs des autres membres n'est requis ni accordé.{% endblocktrans %}

+
+ +
+
{% trans "Le fonctionnement" %}
+

{% blocktrans %}Votre serveur détecte et bannit localement (fail2ban + iptables), publie l'événement sur un broker Mosquitto local, puis le relaie vers ce master en mTLS. En retour, le master republie les bans décidés (propagation, escalade en cas de récidive détectée sur plusieurs noeuds, ...) vers votre serveur.{% endblocktrans %}

+
+ +
+
{% trans "L'installation" %}
+
    +
  1. {% blocktrans %}Cloner le dépôt sur votre serveur (VPS Debian 13), dans le $HOME d'un utilisateur dédié.{% endblocktrans %}
  2. +
  3. {% blocktrans %}Copier .env.example vers .env et l'éditer.{% endblocktrans %}
  4. +
  5. {% blocktrans %}Auto-inscription : après acceptation de votre demande ci-dessous, vous recevrez un email avec une commande make join prête à l'emploi — la clé privée de votre serveur est générée localement et ne quitte jamais votre machine.{% endblocktrans %}
  6. +
  7. sudo make install
  8. +
+
+ +
+
{% trans "Demander à rejoindre" %}
+

{% trans "Votre demande sera examinée avant qu'un jeton d'inscription ne vous soit envoyé par email." %}

+
+ {% csrf_token %} + {{ form.website }} +
+ + {{ form.email }} +
+
+ + {{ form.node_name }} +
+
+ + {{ form.country }} +
+
+ + {{ form.message }} +
+ +
+
+
+
+
+ + diff --git a/emitter/banevents/templates/banevents/dashboard.html b/emitter/banevents/templates/banevents/dashboard.html new file mode 100644 index 0000000..045377b --- /dev/null +++ b/emitter/banevents/templates/banevents/dashboard.html @@ -0,0 +1,105 @@ +{% load i18n %} + + + + + Fail2banActionBanisher — {% trans "Tableau de bord" %} + + + + + +
+ + +
+
+
+
+

{% trans "Événements de bannissement" %}

+ {% if filterable_nodes|length > 1 %} +
+ +
+ {% endif %} +
+ +
+
+ {% trans "Carte" %} + +
+
+
+ +
+ + + + + + + + + + + + + + + {% for event in events %} + {% include "banevents/_event_row.html" %} + {% endfor %} + +
{% trans "Reçu le" %}{% trans "Noeud" %}{% trans "Jail" %}{% trans "Action" %}IP{% trans "Port/Protocole" %}{% trans "Durée du ban" %}{% trans "Localisation" %}
+ {% if not events %} +
+

{% trans "Aucun événement pour le moment" %}

+

{% trans "En attente d'un ban à diffuser en direct via WebSocket (manage.py mqtt_listen doit tourner)." %}

+
+ {% endif %} +
+
+
+
+ + + {% comment %} + notifications.js n'est pas rendu côté serveur : les quelques mots + qu'il affiche (statut d'une commande reçue du master) passent par ce + bloc JSON plutôt que d'être codés en dur en français dans le .js. + {% endcomment %} + {% trans "Commande reçue du master (non gérée)" as js_i18n_unhandled %} + {% trans "appliqué" as js_i18n_ok %} + {% trans "échec" as js_i18n_error %} + {% trans "Fermer" as js_i18n_close %} + + + + + + + + + diff --git a/emitter/banevents/templates/banevents/history.html b/emitter/banevents/templates/banevents/history.html new file mode 100644 index 0000000..94850a2 --- /dev/null +++ b/emitter/banevents/templates/banevents/history.html @@ -0,0 +1,103 @@ +{% load i18n %} + + + + + Fail2banActionBanisher — {% trans "Historique" %} + + + + + + + +
+
+
+
+

{% trans "Historique des bannissements" %}

+
+ +
+
+
+ + +
+
+ + +
+ {% if filterable_nodes|length > 1 %} +
+ + +
+ {% endif %} + + {% trans "Réinitialiser" %} +
+
+ +
+
+ {% trans "Carte" %} + +
+
+
+ +
+ + + + + + + + + + + + + + + {% for event in events %} + {% include "banevents/_event_row.html" %} + {% endfor %} + +
{% trans "Reçu le" %}{% trans "Noeud" %}{% trans "Jail" %}{% trans "Action" %}IP{% trans "Port/Protocole" %}{% trans "Durée du ban" %}{% trans "Localisation" %}
+ {% if not events %} +
+

{% trans "Aucun événement sur cette période" %}

+ {% url 'banevents:history' as reset_url %} +

{% blocktrans %}Ajuste les dates ou le noeud ci-dessus, ou réinitialise le filtre.{% endblocktrans %}

+
+ {% endif %} +
+
+
+
+ + + + + + + diff --git a/emitter/banevents/tests.py b/emitter/banevents/tests.py new file mode 100644 index 0000000..c36de9d --- /dev/null +++ b/emitter/banevents/tests.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from .ingestion import save_ban_event +from .models import NodeRegistry + + +class NodeRegistryIngestionTests(TestCase): + def test_creates_node_registry_row_for_new_node(self): + save_ban_event({'node': '12345', 'action': 'BAN', 'name': 'sshd', 'ip': '203.0.113.1'}) + + node = NodeRegistry.objects.get(node_id='12345') + self.assertEqual(node.alias, '') + self.assertEqual(node.display_name, '12345') + + def test_second_event_updates_last_seen_without_duplicating(self): + save_ban_event({'node': '12345', 'action': 'BAN', 'name': 'sshd', 'ip': '203.0.113.1'}) + first_seen = NodeRegistry.objects.get(node_id='12345').last_seen + + save_ban_event({'node': '12345', 'action': 'UNBAN', 'name': 'sshd', 'ip': '203.0.113.1'}) + + self.assertEqual(NodeRegistry.objects.filter(node_id='12345').count(), 1) + self.assertGreaterEqual(NodeRegistry.objects.get(node_id='12345').last_seen, first_seen) + + def test_display_name_prefers_alias(self): + node = NodeRegistry.objects.create(node_id='12345', alias='miraceti-vps1') + self.assertEqual(node.display_name, 'miraceti-vps1') diff --git a/emitter/banevents/urls.py b/emitter/banevents/urls.py new file mode 100644 index 0000000..3b4ffc8 --- /dev/null +++ b/emitter/banevents/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +app_name = 'banevents' + +urlpatterns = [ + path('', views.dashboard, name='dashboard'), + path('historique/', views.history, name='history'), + path('api/join/', views.join_node, name='join_node'), + path('communaute/', views.community_landing, name='community'), +] diff --git a/emitter/banevents/views.py b/emitter/banevents/views.py new file mode 100644 index 0000000..23d1c50 --- /dev/null +++ b/emitter/banevents/views.py @@ -0,0 +1,293 @@ +import datetime +import hashlib +import json +import logging +import subprocess +import tempfile +from pathlib import Path + +import redis +from django.conf import settings +from django.contrib import messages +from django.core.mail import mail_admins +from django.core.serializers.json import DjangoJSONEncoder +from django.http import Http404, HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from ipware import get_client_ip + +from .forms import EnrollmentRequestForm +from .models import BanEvent, JoinToken, NodeRegistry +from .serializers import event_payload +from .stats import ROSTER_REDIS_DB, ROSTER_REDIS_KEY, top_jails_and_countries + +join_logger = logging.getLogger('banevents.join') +SCRIPTS_DIR = Path(settings.BASE_DIR).parent / 'scripts' + +HISTORY_MAX_EVENTS = 500 +# 3x l'intervalle de heartbeat (HEARTBEAT_INTERVAL_SECONDS dans +# master_client.py) : tolère un accroc réseau/reconnexion sans faux +# positif "hors ligne". +NODE_OFFLINE_THRESHOLD_SECONDS = 180 + + +def _node_choices() -> list[NodeRegistry]: + """Attache `is_online` (calculé, pas un champ modèle) à chaque noeud — + un noeud pas encore redéployé avec le heartbeat (Phase 3) apparaîtra + "hors ligne" même s'il fonctionne, jusqu'à son prochain déploiement.""" + threshold = timezone.now() - datetime.timedelta(seconds=NODE_OFFLINE_THRESHOLD_SECONDS) + nodes = list(NodeRegistry.objects.all()) + for node in nodes: + node.is_online = node.last_seen >= threshold + return nodes + + +def _filterable_nodes(nodes: list[NodeRegistry]) -> list[NodeRegistry]: + """Sous-ensemble de `nodes` ayant au moins un BanEvent en local — + depuis le roster (Phase 3), NodeRegistry liste TOUS les noeuds connus + du master, y compris ceux dont ce process-ci n'a ingéré aucun + événement (un noeud client ne voit localement que ses propres bans). + Le menu de filtre ne doit proposer que des choix qui renvoient + vraiment quelque chose — la liste "Noeuds" de la sidebar, elle, + continue d'afficher tout le monde (c'est son rôle).""" + local_node_ids = set(BanEvent.objects.order_by().values_list('node', flat=True).distinct()) + return [node for node in nodes if node.node_id in local_node_ids] + + +def _attach_display_node(events: list[BanEvent], nodes: list[NodeRegistry]) -> None: + """Résout l'alias de chaque event en une seule requête (déjà chargée + dans `nodes`) plutôt qu'un lookup par event — attaché comme attribut + Python, lu à la fois par le template (event.display_node) et par + event_payload() pour le JSON de bootstrap.""" + alias_map = {node.node_id: node.display_name for node in nodes} + for event in events: + event.display_node = str(alias_map.get(event.node, event.node)) + + +def _sidebar_stats() -> dict[str, list[dict]]: + """Compteurs jail/pays pour le widget de la sidebar — volontairement + pas filtrés par le ?node=/plage de dates de la page courante, cf. + plan : c'est un résumé permanent, pas du contenu de page. + + Essaie d'abord le roster reçu du master (Phase 3, cache Redis alimenté + par master_client.py) : sur un noeud client, les BanEvent locaux ne + couvrent que ce noeud, la vue globale vient forcément d'ailleurs. Repli + sur le calcul local si absent/périmé — vrai sur le master lui-même + (qui republie ce qu'il vient de calculer localement, donc aucune + différence pratique) ou sur un noeud pas encore redéployé avec le + roster.""" + try: + cached = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=ROSTER_REDIS_DB).get( + ROSTER_REDIS_KEY + ) + except redis.RedisError: + cached = None + # isinstance, pas juste `if cached:` : redis-py type .get() de façon + # générique (client sync ET async partagent la signature), pyright ne + # peut pas savoir statiquement que CE client est synchrone — ce client + # ne renvoie jamais un awaitable en pratique. + if isinstance(cached, (str, bytes)): + return json.loads(cached) + return top_jails_and_countries() + + +def dashboard(request: HttpRequest) -> HttpResponse: + selected_node = request.GET.get('node', '') + + events_qs = BanEvent.objects.all() + if selected_node: + events_qs = events_qs.filter(node=selected_node) + events = list(events_qs[:100]) + + nodes = _node_choices() + _attach_display_node(events, nodes) + + events_json = json.dumps( + [event_payload(event, event.display_node) for event in events], cls=DjangoJSONEncoder + ) + return render(request, 'banevents/dashboard.html', { + 'events': events, + 'events_json': events_json, + 'nodes': nodes, + 'filterable_nodes': _filterable_nodes(nodes), + 'selected_node': selected_node, + 'master_dashboard_url': settings.MASTER_DASHBOARD_URL, + 'sidebar_width': settings.DASHBOARD_SIDEBAR_WIDTH, + 'community_enrollment_enabled': settings.COMMUNITY_ENROLLMENT_ENABLED, + **_sidebar_stats(), + }) + + +def _join_client_ip(request: HttpRequest) -> str: + """Même résolution d'IP réelle qu'axes (nginx en unique reverse-proxy, + cf. AXES_IPWARE_PROXY_COUNT/AXES_IPWARE_META_PRECEDENCE_ORDER dans + config/settings/base.py et son historique de bugs) — réutilisée ici + uniquement pour l'audit (logs), pas pour une décision de sécurité.""" + ip, _ = get_client_ip( + request, + proxy_count=settings.AXES_IPWARE_PROXY_COUNT, + request_header_order=settings.AXES_IPWARE_META_PRECEDENCE_ORDER, + ) + return ip or '?' + + +def _join_error(request: HttpRequest, node_name: str, reason: str) -> JsonResponse: + """Message volontairement générique côté client quelle que soit la + cause réelle (jeton inconnu, expiré, déjà utilisé, CN invalide, échec + de signature) — ne jamais donner d'indice à un tiers qui sonderait cet + endpoint (énumération de noms de noeuds, etc.). Le détail part + uniquement dans les logs serveur.""" + join_logger.warning('Échec join pour %s depuis %s : %s', node_name, _join_client_ip(request), reason) + return JsonResponse({'error': 'Jeton invalide ou expiré.'}, status=403) + + +@csrf_exempt +@require_POST +def join_node(request: HttpRequest) -> JsonResponse: + """Auto-inscription d'un noeud par jeton à usage unique (flux + "kubeadm join", voir manage.py create_join_token et ROADMAP.md). + Appel machine-à-machine (sudo make join côté noeud), pas de + session navigateur — csrf_exempt. Aucune jail fail2ban dédiée : les + jetons ont 256 bits d'entropie (secrets.token_urlsafe(32)), un + brute-force est déjà impraticable sans compteur d'échecs, contrairement + à /admin/ (mots de passe, entropie humaine faible, d'où django-axes).""" + try: + payload = json.loads(request.body) + node_name = str(payload['node_name']) + token = str(payload['token']) + csr_pem = str(payload['csr']) + except (json.JSONDecodeError, KeyError, TypeError, UnicodeDecodeError): + return _join_error(request, '?', 'requête JSON invalide') + + token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest() + now = timezone.now() + # update() atomique : une seule ligne affectée garantit qu'aucune + # requête concurrente n'a déjà consommé ce jeton (protection contre + # une double utilisation en cas de rejeu/course). Si tout ce qui suit + # échoue (CN incohérent, signature en erreur), le jeton est "rendu" + # (used_at remis à NULL, cf. plus bas) plutôt que brûlé pour de bon — + # un échec transitoire côté serveur ne doit pas forcer à réémettre un + # jeton entièrement nouveau alors que le client n'y est pour rien. + updated = JoinToken.objects.filter( + token_hash=token_hash, node_name=node_name, used_at__isnull=True, expires_at__gt=now, + ).update(used_at=now) + if updated != 1: + return _join_error(request, node_name, 'jeton inconnu, expiré ou déjà utilisé') + + with tempfile.TemporaryDirectory() as tmp_dir: + csr_path = Path(tmp_dir) / 'node.csr' + csr_path.write_text(csr_pem) + + # CN de la CSR doit correspondre au node_name déclaré : défense en + # profondeur, un jeton (donc un node_name) ne permet pas de faire + # signer un certificat pour un AUTRE nom. -nameopt oneline,-space_eq + # force un format de sortie stable ("subject=CN=xxx", sans espace + # autour du "="), indépendant de la version d'openssl installée. + subject = subprocess.run( + ['openssl', 'req', '-in', str(csr_path), '-noout', '-subject', '-nameopt', 'oneline,-space_eq'], + capture_output=True, text=True, timeout=10, + ) + if subject.returncode != 0 or subject.stdout.strip() != f'subject=CN={node_name}': + JoinToken.objects.filter(token_hash=token_hash).update(used_at=None) + return _join_error(request, node_name, f'CSR illisible ou CN incohérent ({subject.stdout.strip()!r})') + + result = subprocess.run( + ['sudo', str(SCRIPTS_DIR / 'sign-node-csr.sh'), node_name, str(csr_path)], + capture_output=True, text=True, timeout=15, + ) + if result.returncode != 0: + JoinToken.objects.filter(token_hash=token_hash).update(used_at=None) + return _join_error(request, node_name, f'échec de signature : {result.stderr.strip()}') + + # ca.crt appartient à root:mosquitto, mode 640 (cf. master-ca-init.sh) — + # le user de déploiement (celui qui fait tourner Daphne) n'est pas dans + # ce groupe, une lecture directe échoue (PermissionError, repéré en + # conditions réelles). sudo cat, comme pour la signature elle-même : + # même accès déjà en place (NOPASSWD:ALL), pas de nouveau droit à ouvrir. + ca_cert_result = subprocess.run( + ['sudo', 'cat', str(Path(settings.MQTT_MASTER_CA_DIR) / 'ca.crt')], + capture_output=True, text=True, timeout=10, + ) + ca_cert_pem = ca_cert_result.stdout if ca_cert_result.returncode == 0 else '' + + join_logger.info('Noeud %s inscrit depuis %s', node_name, _join_client_ip(request)) + return JsonResponse({'cert': result.stdout, 'ca_cert': ca_cert_pem}, status=201) + + +def history(request: HttpRequest) -> HttpResponse: + selected_node = request.GET.get('node', '') + from_date = request.GET.get('from', '') + to_date = request.GET.get('to', '') + + events_qs = BanEvent.objects.all() + if selected_node: + events_qs = events_qs.filter(node=selected_node) + if from_date: + events_qs = events_qs.filter(received_at__date__gte=from_date) + if to_date: + events_qs = events_qs.filter(received_at__date__lte=to_date) + events = list(events_qs[:HISTORY_MAX_EVENTS]) + + nodes = _node_choices() + _attach_display_node(events, nodes) + + events_json = json.dumps( + [event_payload(event, event.display_node) for event in events], cls=DjangoJSONEncoder + ) + return render(request, 'banevents/history.html', { + 'events': events, + 'events_json': events_json, + 'nodes': nodes, + 'filterable_nodes': _filterable_nodes(nodes), + 'selected_node': selected_node, + 'from_date': from_date, + 'to_date': to_date, + 'master_dashboard_url': settings.MASTER_DASHBOARD_URL, + 'sidebar_width': settings.DASHBOARD_SIDEBAR_WIDTH, + 'community_enrollment_enabled': settings.COMMUNITY_ENROLLMENT_ENABLED, + **_sidebar_stats(), + }) + + +def community_landing(request: HttpRequest) -> HttpResponse: + """Page publique expliquant le projet et proposant de rejoindre la + communauté (master uniquement, désactivée par défaut — voir + COMMUNITY_ENROLLMENT_ENABLED). Ne crée jamais de jeton ni d'accès + direct : la soumission place juste une EnrollmentRequest en attente, + validée à la main dans /admin/ (voir + EnrollmentRequestAdmin.approve_and_send_token).""" + if not settings.COMMUNITY_ENROLLMENT_ENABLED: + raise Http404 + + if request.method == 'POST': + form = EnrollmentRequestForm(request.POST) + if form.is_valid(): + if form.is_spam(): + # Ignoré silencieusement : ne pas révéler à un bot qu'il a + # été détecté (pas d'erreur, pas d'entrée créée). + return redirect('banevents:community') + enrollment = form.save() + try: + mail_admins( + 'Nouvelle demande d\'inscription à la communauté', + f"Email : {enrollment.email}\n" + f"Noeud souhaité : {enrollment.node_name}\n" + f"Message : {enrollment.message or '(vide)'}\n\n" + f"À traiter dans /admin/banevents/enrollmentrequest/", + ) + except Exception: + # Best-effort : la demande est déjà enregistrée (visible dans + # /admin/ de toute façon) — un échec d'envoi ne doit jamais + # faire perdre la soumission de l'utilisateur. + join_logger.exception('Échec de notification admin pour une nouvelle EnrollmentRequest') + messages.success(request, _( + 'Votre demande est enregistrée. Vous recevrez un email si elle est acceptée.' + )) + return redirect('banevents:community') + else: + form = EnrollmentRequestForm() + + return render(request, 'banevents/community.html', {'form': form}) diff --git a/emitter/config/__init__.py b/emitter/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/emitter/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/emitter/config/asgi.py b/emitter/config/asgi.py new file mode 100644 index 0000000..cbc7bff --- /dev/null +++ b/emitter/config/asgi.py @@ -0,0 +1,16 @@ +"""ASGI config: sert le HTTP classique (Django) et les WebSockets (Channels).""" +import os + +import django +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() + +from banevents.routing import websocket_urlpatterns # noqa: E402 + +application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': URLRouter(websocket_urlpatterns), +}) diff --git a/emitter/config/celery.py b/emitter/config/celery.py new file mode 100644 index 0000000..a6960e7 --- /dev/null +++ b/emitter/config/celery.py @@ -0,0 +1,10 @@ +"""App Celery : tâches asynchrones (géolocalisation) hors du chemin d'ingestion MQTT.""" +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') + +app = Celery('config') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/emitter/config/settings/__init__.py b/emitter/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emitter/config/settings/base.py b/emitter/config/settings/base.py new file mode 100644 index 0000000..2793323 --- /dev/null +++ b/emitter/config/settings/base.py @@ -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. + # , 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', + }, + }, +} diff --git a/emitter/config/settings/dev.py b/emitter/config/settings/dev.py new file mode 100644 index 0000000..518d3da --- /dev/null +++ b/emitter/config/settings/dev.py @@ -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 diff --git a/emitter/config/settings/production.py b/emitter/config/settings/production.py new file mode 100644 index 0000000..9493303 --- /dev/null +++ b/emitter/config/settings/production.py @@ -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') diff --git a/emitter/config/urls.py b/emitter/config/urls.py new file mode 100644 index 0000000..d3751da --- /dev/null +++ b/emitter/config/urls.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('admin/', admin.site.urls), + # Vue set_language (POST) utilisée par le sélecteur de langue de la + # sidebar (_sidebar_stats.html) — pose le cookie django_language, lu + # par LocaleMiddleware (config/settings/base.py) à chaque requête. + path('i18n/', include('django.conf.urls.i18n')), + path('', include('banevents.urls')), +] diff --git a/emitter/config/wsgi.py b/emitter/config/wsgi.py new file mode 100644 index 0000000..af33ec8 --- /dev/null +++ b/emitter/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') + +application = get_wsgi_application() diff --git a/emitter/locale/en/LC_MESSAGES/django.mo b/emitter/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..e0c286a Binary files /dev/null and b/emitter/locale/en/LC_MESSAGES/django.mo differ diff --git a/emitter/locale/en/LC_MESSAGES/django.po b/emitter/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..4846b75 --- /dev/null +++ b/emitter/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,241 @@ +# English translation catalog for the banevents dashboard UI (Phase 5). +# Regenerate the msgid list with `manage.py makemessages -l en`, keep the +# msgstr values below in sync by hand, then `manage.py compilemessages`. +msgid "" +msgstr "" +"Project-Id-Version: Fail2banActionBanisher\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-18 17:34+0000\n" +"PO-Revision-Date: 2026-07-18 17:34+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "Django site admin" +msgstr "Django site admin" + +msgid "Django administration" +msgstr "Django administration" + +msgid "Direct" +msgstr "Live" + +msgid "Historique" +msgstr "History" + +msgid "Noeuds" +msgstr "Nodes" + +msgid "en ligne" +msgstr "online" + +msgid "hors ligne" +msgstr "offline" + +msgid "dernière activité" +msgstr "last activity" + +msgid "Top jails" +msgstr "Top jails" + +msgid "Top pays attaquants" +msgstr "Top attacking countries" + +msgid "Langue" +msgstr "Language" + +msgid "Rejoindre la communauté" +msgstr "Join the community" + +msgid "Tableau de bord" +msgstr "Dashboard" + +msgid "Le but" +msgstr "The purpose" + +msgid "" +"Fail2banActionBanisher est un système distribué de bannissement d'hôtes " +"malveillants (fail2ban + iptables), avec propagation des événements par " +"MQTT. Un serveur \"master\" centralise la décision (corrélation multi-" +"noeuds, récidive globale) et republie les actions à exécuter vers l'ensemble " +"des noeuds abonnés." +msgstr "" +"Fail2banActionBanisher is a distributed malicious-host banishing system " +"(fail2ban + iptables), with event propagation over MQTT. A \"master\" " +"server centralizes the decision (multi-node correlation, global recidive " +"detection) and republishes the actions to be executed to all subscribed " +"nodes." + +msgid "" +"Rejoindre la communauté ne suppose aucun lien d'appartenance entre les " +"serveurs : chaque membre garde son propre noeud et son propre fail2ban " +"local, seule la propagation des bans est partagée via ce master commun. " +"Aucun accès aux serveurs des autres membres n'est requis ni accordé." +msgstr "" +"Joining the community doesn't assume any ownership relationship between " +"servers: every member keeps their own node and their own local fail2ban — " +"only ban propagation is shared through this common master. No member gets " +"access to another member's servers." + +msgid "Le fonctionnement" +msgstr "How it works" + +msgid "" +"Votre serveur détecte et bannit localement (fail2ban + iptables), publie " +"l'événement sur un broker Mosquitto local, puis le relaie vers ce master en " +"mTLS. En retour, le master republie les bans décidés (propagation, escalade " +"en cas de récidive détectée sur plusieurs noeuds, ...) vers votre serveur." +msgstr "" +"Your server detects and bans locally (fail2ban + iptables), publishes the " +"event to a local Mosquitto broker, then relays it to this master over " +"mTLS. In return, the master republishes decided bans (propagation, " +"escalation if recidive is detected across several nodes, ...) to your " +"server." + +msgid "L'installation" +msgstr "Installation" + +msgid "" +"Cloner le dépôt sur votre serveur (VPS Debian 13), dans le $HOME d'un utilisateur dédié." +msgstr "" +"Clone the repository on your server (Debian 13 VPS), into a dedicated " +"user's $HOME." + +msgid "Copier .env.example vers .env et l'éditer." +msgstr "Copy .env.example to .env and edit it." + +msgid "" +"Auto-inscription : après acceptation de votre demande ci-dessous, vous " +"recevrez un email avec une commande make join prête à l'emploi " +"— la clé privée de votre serveur est générée localement et ne quitte jamais " +"votre machine." +msgstr "" +"Token-based enrollment: once your request below is accepted, you'll " +"receive an email with a ready-to-use make join command — your " +"server's private key is generated locally and never leaves your machine." + +msgid "Demander à rejoindre" +msgstr "Request to join" + +msgid "" +"Votre demande sera examinée avant qu'un jeton d'inscription ne vous soit " +"envoyé par email." +msgstr "" +"Your request will be reviewed before an enrollment token is sent to you by " +"email." + +msgid "Email" +msgstr "Email" + +msgid "Nom de noeud souhaité" +msgstr "Desired node name" + +msgid "Pays du serveur (optionnel)" +msgstr "Server country (optional)" + +msgid "Message de présentation (optionnel)" +msgstr "Introduction message (optional)" + +msgid "Envoyer la demande" +msgstr "Send request" + +msgid "Bannissements (direct)" +msgstr "Bans (live)" + +msgid "Administration" +msgstr "Administration" + +msgid "Événements de bannissement" +msgstr "Ban events" + +msgid "Tous les noeuds" +msgstr "All nodes" + +msgid "(hors ligne)" +msgstr "(offline)" + +msgid "Carte" +msgstr "Map" + +msgid "Réinitialiser la vue" +msgstr "Reset view" + +msgid "Reçu le" +msgstr "Received" + +msgid "Noeud" +msgstr "Node" + +msgid "Jail" +msgstr "Jail" + +msgid "Action" +msgstr "Action" + +msgid "Port/Protocole" +msgstr "Port/Protocol" + +msgid "Durée du ban" +msgstr "Ban duration" + +msgid "Localisation" +msgstr "Location" + +msgid "Aucun événement pour le moment" +msgstr "No events yet" + +msgid "" +"En attente d'un ban à diffuser en direct via WebSocket (manage.py " +"mqtt_listen doit tourner)." +msgstr "" +"Waiting for a ban to stream live via WebSocket (manage.py mqtt_listen must " +"be running)." + +msgid "Commande reçue du master (non gérée)" +msgstr "Command received from master (unhandled)" + +msgid "appliqué" +msgstr "applied" + +msgid "échec" +msgstr "failed" + +msgid "Fermer" +msgstr "Close" + +msgid "Historique des bannissements" +msgstr "Ban history" + +msgid "Depuis" +msgstr "From" + +msgid "Jusqu'à" +msgstr "To" + +msgid "Filtrer" +msgstr "Filter" + +msgid "Réinitialiser" +msgstr "Reset" + +msgid "Aucun événement sur cette période" +msgstr "No events for this period" + +#, python-format +msgid "" +"Ajuste les dates ou le noeud ci-dessus, ou réinitialise le filtre." +msgstr "" +"Adjust the dates or node above, or reset the " +"filter." + +msgid "" +"Votre demande est enregistrée. Vous recevrez un email si elle est acceptée." +msgstr "" +"Your request has been recorded. You will receive an email if it is " +"accepted." diff --git a/emitter/manage.py b/emitter/manage.py new file mode 100755 index 0000000..d732f57 --- /dev/null +++ b/emitter/manage.py @@ -0,0 +1,23 @@ +#!.venv/bin/python +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/emitter/requirements-dev.txt b/emitter/requirements-dev.txt new file mode 100644 index 0000000..51a41fb --- /dev/null +++ b/emitter/requirements-dev.txt @@ -0,0 +1,11 @@ +# Dépendances de développement uniquement (typage statique) — jamais +# nécessaires à l'exécution, seulement à `make lint` (pyright). Sans +# elles, pyright ne peut pas résoudre les attributs dynamiques de Django +# (Model.objects, BaseCommand.style.SUCCESS/ERROR, ...) — ces paquets +# fournissent des stubs statiques (.pyi), pas de plugin mypy requis : +# pyright ne supporte de toute façon pas le plugin mypy de django-stubs, +# seuls ses fichiers de stubs (objects, Style, etc.) sont exploitables ici. +# Version alignée sur la plage Django de requirements.txt (>=5.0,<7) — +# le versionnage de django-stubs suit celui de Django par convention. +django-stubs>=5.0,<7 +celery-types>=0.26,<1 diff --git a/emitter/requirements-mariadb.txt b/emitter/requirements-mariadb.txt new file mode 100644 index 0000000..626e869 --- /dev/null +++ b/emitter/requirements-mariadb.txt @@ -0,0 +1,6 @@ +# Dépendance optionnelle pour DB_ENGINE=mariadb (voir .env.example et +# "sudo ./install.sh install-mariadb"). PyMySQL est un driver pur Python +# (pymysql.install_as_MySQLdb(), cf. config/settings/production.py) : pas de +# mysqlclient, pas de dépendance de compilation (libmysqlclient-dev) côté +# paquets système. +PyMySQL>=1,<2 diff --git a/emitter/requirements-postgresql.txt b/emitter/requirements-postgresql.txt new file mode 100644 index 0000000..8f473a0 --- /dev/null +++ b/emitter/requirements-postgresql.txt @@ -0,0 +1,6 @@ +# Dépendance optionnelle pour DB_ENGINE=postgresql (voir .env.example et +# config/settings/production.py). psycopg[binary] : driver officiel +# PostgreSQL pour Django, wheels précompilées — pas de dépendance de +# compilation C (libpq-dev) côté paquets système, même raisonnement que +# PyMySQL pour DB_ENGINE=mariadb (requirements-mariadb.txt). +psycopg[binary]>=3,<4 diff --git a/emitter/requirements.txt b/emitter/requirements.txt new file mode 100644 index 0000000..1dcb9d1 --- /dev/null +++ b/emitter/requirements.txt @@ -0,0 +1,12 @@ +Django>=5.0,<7 +paho-mqtt>=2,<3 +channels>=4,<5 +channels_redis>=4,<5 +daphne>=4,<5 +redis>=5,<6 +celery>=5,<6 +requests>=2,<3 +whitenoise>=6,<7 +python-dotenv>=1,<2 +django-axes[ipware]>=8,<9 +django-sendmail-backend>=0.1,<1 diff --git a/generic/emitter/fail2ban-emitter-master-client.service b/generic/emitter/fail2ban-emitter-master-client.service new file mode 100644 index 0000000..40708ad --- /dev/null +++ b/generic/emitter/fail2ban-emitter-master-client.service @@ -0,0 +1,32 @@ +[Unit] +Description=Fail2banActionBanisher - émetteur : relais vers le master (master_client, SYNC_BAN) +After=network.target mosquitto.service +Requires=mosquitto.service + +[Service] +Type=simple +User=__EMITTER_USER__ +Group=__EMITTER_GROUP__ +WorkingDirectory=__EMITTER_DIR__ +EnvironmentFile=__ENV_FILE__ +Environment=DJANGO_SETTINGS_MODULE=config.settings.production +Environment=PYTHONUNBUFFERED=1 +ExecStart=__EMITTER_DIR__/.venv/bin/python manage.py master_client +Restart=on-failure +RestartSec=5 + +# Pas de NoNewPrivileges=true ici, contrairement aux autres services de +# l'émetteur : ce process a structurellement besoin d'escalader via sudo +# (execute_sync_ban -> `sudo fail2ban-client set master-sync banip `, +# cf. master_client.py). NoNewPrivileges bloque tout mécanisme de gain de +# privilège au niveau execve, y compris le setuid de sudo lui-même — sudo +# échoue alors avec "the 'no new privileges' flag is set", quels que soient +# les droits sudoers. Repéré seulement en conditions réelles (le premier +# vrai SYNC_BAN reçu d'un autre noeud) : les tests précédents mockaient +# subprocess.run, donc n'exerçaient jamais cette interaction avec systemd. +ProtectSystem=strict +PrivateTmp=true +ReadWritePaths=__EMITTER_DIR__ + +[Install] +WantedBy=multi-user.target diff --git a/generic/emitter/fail2ban-emitter-master-listen.service b/generic/emitter/fail2ban-emitter-master-listen.service new file mode 100644 index 0000000..4225879 --- /dev/null +++ b/generic/emitter/fail2ban-emitter-master-listen.service @@ -0,0 +1,24 @@ +[Unit] +Description=Fail2banActionBanisher - émetteur : ingestion master (master_listen, tous les noeuds) +After=network.target mosquitto.service +Requires=mosquitto.service + +[Service] +Type=simple +User=__EMITTER_USER__ +Group=__EMITTER_GROUP__ +WorkingDirectory=__EMITTER_DIR__ +EnvironmentFile=__ENV_FILE__ +Environment=DJANGO_SETTINGS_MODULE=config.settings.production +Environment=PYTHONUNBUFFERED=1 +ExecStart=__EMITTER_DIR__/.venv/bin/python manage.py master_listen +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +PrivateTmp=true +ReadWritePaths=__EMITTER_DIR__ + +[Install] +WantedBy=multi-user.target diff --git a/generic/emitter/fail2ban-emitter-mqtt.service b/generic/emitter/fail2ban-emitter-mqtt.service new file mode 100644 index 0000000..10061fb --- /dev/null +++ b/generic/emitter/fail2ban-emitter-mqtt.service @@ -0,0 +1,24 @@ +[Unit] +Description=Fail2banActionBanisher - émetteur : ingestion MQTT (mqtt_listen) +After=network.target mosquitto.service +Requires=mosquitto.service + +[Service] +Type=simple +User=__EMITTER_USER__ +Group=__EMITTER_GROUP__ +WorkingDirectory=__EMITTER_DIR__ +EnvironmentFile=__ENV_FILE__ +Environment=DJANGO_SETTINGS_MODULE=config.settings.production +Environment=PYTHONUNBUFFERED=1 +ExecStart=__EMITTER_DIR__/.venv/bin/python manage.py mqtt_listen +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +PrivateTmp=true +ReadWritePaths=__EMITTER_DIR__ + +[Install] +WantedBy=multi-user.target diff --git a/generic/emitter/fail2ban-emitter-web.service b/generic/emitter/fail2ban-emitter-web.service new file mode 100644 index 0000000..f61a9c8 --- /dev/null +++ b/generic/emitter/fail2ban-emitter-web.service @@ -0,0 +1,62 @@ +[Unit] +Description=Fail2banActionBanisher - émetteur Django (ASGI/Daphne) +After=network.target redis-server.service +Requires=redis-server.service + +[Service] +Type=simple +User=__EMITTER_USER__ +Group=__EMITTER_GROUP__ +WorkingDirectory=__EMITTER_DIR__ +EnvironmentFile=__ENV_FILE__ +Environment=DJANGO_SETTINGS_MODULE=config.settings.production +Environment=PYTHONUNBUFFERED=1 +# Loopback uniquement par défaut : éditer le bind ici pour un accès distant +# (ex. IP du VPN de confiance), pas d'exposition publique directe prévue. +ExecStart=__EMITTER_DIR__/.venv/bin/daphne -b 127.0.0.1 -p 8050 config.asgi:application +Restart=on-failure +RestartSec=5 + +# Pas de NoNewPrivileges=true ici (retiré 2026-07-18) : depuis l'endpoint +# /api/join/ (banevents/views.py::join_node, auto-inscription des noeuds +# par jeton), ce process a structurellement besoin d'escalader via sudo +# (sudo scripts/sign-node-csr.sh ...). Même piège déjà rencontré sur +# fail2ban-emitter-master-client.service : NoNewPrivileges bloque tout +# mécanisme de gain de privilège au niveau execve, y compris le setuid de +# sudo lui-même — sudo échoue alors avec "the 'no new privileges' flag is +# set", quels que soient les droits sudoers. Repéré en conditions réelles +# (premier vrai test de /api/join/), pas en lecture de code. +ProtectSystem=strict +PrivateTmp=true +# /etc/mosquitto/master-ca et master-acl.conf : sign-node-csr.sh (appelé +# via sudo par /api/join/) écrit un fichier .srl à côté de ca.crt +# (-CAcreateserial d'openssl) ET ajoute l'entitlement ACL du nouveau +# noeud — sudo ne crée pas de nouveau mount namespace, le sudo'd child +# hérite du même ProtectSystem=strict que ce process, donc ces deux +# chemins doivent être listés ici aussi, pas seulement __EMITTER_DIR__. +# Repéré en conditions réelles (premier vrai test de /api/join/, échec +# silencieux : openssl/l'ajout ACL échouaient sans qu'aucun message ne +# remonte jusqu'au subprocess.run() de la vue Django). +# +# Préfixe "-" obligatoire sur les deux chemins mosquitto : CE service +# tourne sur TOUS les noeuds (unité générique), pas seulement le master — +# /etc/mosquitto/master-ca et master-acl.conf n'existent QUE sur le +# master (déployés par install-master). ReadWritePaths= exige que le +# chemin existe déjà au moment de construire le mount namespace ; sans +# "-", le service refuse de démarrer PARTOUT ailleurs que sur le master +# ("Failed to set up mount namespacing: ... No such file or directory", +# status 226/NAMESPACE) — cassé en conditions réelles sur un vrai noeud +# (linuxtarn-vps1) le jour même du déploiement. "-" en tête dit à systemd +# d'ignorer silencieusement l'entrée si le chemin est absent. +# /var/spool/exim4 et /var/log/exim4 : django-sendmail-backend invoque +# /usr/sbin/sendmail directement (voir config/settings/base.py) pour +# l'email d'inscription à la communauté (banevents/views.py:: +# community_landing, mail_admins) — même piège que ci-dessus, repéré en +# conditions réelles : "Cannot open main log file ... Permission denied" +# + "Failed to create spool file ... Read-only file system". Préfixe "-" +# : un noeud sans exim4 installé ne doit pas empêcher ce service de +# démarrer. +ReadWritePaths=__EMITTER_DIR__ -/etc/mosquitto/master-ca -/etc/mosquitto/master-acl.conf -/var/spool/exim4 -/var/log/exim4 + +[Install] +WantedBy=multi-user.target diff --git a/generic/emitter/fail2ban-emitter-worker.service b/generic/emitter/fail2ban-emitter-worker.service new file mode 100644 index 0000000..81984fb --- /dev/null +++ b/generic/emitter/fail2ban-emitter-worker.service @@ -0,0 +1,24 @@ +[Unit] +Description=Fail2banActionBanisher - émetteur : worker Celery (géolocalisation) +After=network.target redis-server.service +Requires=redis-server.service + +[Service] +Type=simple +User=__EMITTER_USER__ +Group=__EMITTER_GROUP__ +WorkingDirectory=__EMITTER_DIR__ +EnvironmentFile=__ENV_FILE__ +Environment=DJANGO_SETTINGS_MODULE=config.settings.production +Environment=PYTHONUNBUFFERED=1 +ExecStart=__EMITTER_DIR__/.venv/bin/celery -A config worker -l info +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +PrivateTmp=true +ReadWritePaths=__EMITTER_DIR__ + +[Install] +WantedBy=multi-user.target diff --git a/generic/emitter/logrotate-admin-auth.conf b/generic/emitter/logrotate-admin-auth.conf new file mode 100644 index 0000000..51a8af2 --- /dev/null +++ b/generic/emitter/logrotate-admin-auth.conf @@ -0,0 +1,18 @@ +# Log dédié aux verrouillages django-axes (emitter/logs/admin-auth.log, +# cf. LOGGING dans config/settings/base.py et +# generic/fail2ban/jail.d/emitter-admin-auth.conf) — aucun logrotate.d +# système ne le couvre (chemin custom sous le home du user de déploiement, +# pas /var/log). copytruncate, pas le rename+recreate par défaut : le +# logging.FileHandler de Python garde son descripteur de fichier ouvert +# tant que le process (Daphne) tourne, il ne le rouvrirait jamais tout +# seul après un rename — copytruncate tronque le fichier EN PLACE (même +# inode), pas besoin de redémarrer le service pour rotation. +__EMITTER_DIR__/logs/admin-auth.log { + weekly + rotate 6 + compress + delaycompress + missingok + notifempty + copytruncate +} diff --git a/generic/fail2ban/action.d/f2b-iptables-allports.conf b/generic/fail2ban/action.d/f2b-iptables-allports.conf new file mode 100644 index 0000000..39a404e --- /dev/null +++ b/generic/fail2ban/action.d/f2b-iptables-allports.conf @@ -0,0 +1,20 @@ +[Definition] + +actionstart = iptables -N fail2ban- + iptables -A fail2ban- -j RETURN + iptables -I -p -j fail2ban- + +actionstop = iptables -D -p -j fail2ban- || true + iptables -F fail2ban- || true + iptables -X fail2ban- || true + +actioncheck = iptables -n -L | grep -q fail2ban- + +actionban = iptables -I fail2ban- 1 -s -j DROP + +actionunban = iptables -D fail2ban- -s -j DROP + +[Init] +name = default +protocol = tcp +chain = INPUT diff --git a/generic/fail2ban/action.d/f2b-iptables-hashlimit.conf b/generic/fail2ban/action.d/f2b-iptables-hashlimit.conf new file mode 100644 index 0000000..81e0f6c --- /dev/null +++ b/generic/fail2ban/action.d/f2b-iptables-hashlimit.conf @@ -0,0 +1,30 @@ +[Definition] +# Throttle (RATE_LIMIT, Phase 4) plutôt qu'un blocage total : au lieu de +# DROP toutes les connexions de , ne DROP que celles au-delà du débit +# autorisé. --hashlimit-mode srcip fait déjà le bucketing par IP source +# dans une table hashlimit partagée (f2b-) : pas besoin d'un nom de +# table distinct par IP bannie, une seule table suffit pour toute la jail. + +actionstart = iptables -N fail2ban- + iptables -A fail2ban- -j RETURN + iptables -I -p -j fail2ban- + +actionstop = iptables -D -p -j fail2ban- || true + iptables -F fail2ban- || true + iptables -X fail2ban- || true + +actioncheck = iptables -n -L | grep -q fail2ban- + +# actionunban doit reprendre EXACTEMENT la même spec de règle que +# actionban (seul -I vs -D change) : iptables -D échoue silencieusement +# ("Bad rule") si les deux ne correspondent pas au caractère près. +actionban = iptables -I fail2ban- 1 -s -m hashlimit --hashlimit-above --hashlimit-burst --hashlimit-mode srcip --hashlimit-name f2b- -j DROP + +actionunban = iptables -D fail2ban- -s -m hashlimit --hashlimit-above --hashlimit-burst --hashlimit-mode srcip --hashlimit-name f2b- -j DROP + +[Init] +name = default +protocol = tcp +chain = INPUT +hashlimit_rate = 10/sec +hashlimit_burst = 20 diff --git a/generic/fail2ban/action.d/f2b-iptables-multiport.conf b/generic/fail2ban/action.d/f2b-iptables-multiport.conf new file mode 100644 index 0000000..e249978 --- /dev/null +++ b/generic/fail2ban/action.d/f2b-iptables-multiport.conf @@ -0,0 +1,22 @@ +[Definition] + +actionstart = iptables -N fail2ban- + iptables -A fail2ban- -j RETURN + iptables -I -p -m multiport --dports -j fail2ban- + +# Correction : était un tag invalide dans la version originale +actionstop = iptables -D -p -m multiport --dports -j fail2ban- || true + iptables -F fail2ban- || true + iptables -X fail2ban- || true + +actioncheck = iptables -n -L | grep -q fail2ban- + +actionban = iptables -I fail2ban- 1 -s -j DROP + +actionunban = iptables -D fail2ban- -s -j DROP + +[Init] +name = default +port = ssh +protocol = tcp +chain = INPUT diff --git a/generic/fail2ban/action.d/f2b-mqtt-action-banisher.conf b/generic/fail2ban/action.d/f2b-mqtt-action-banisher.conf new file mode 100644 index 0000000..44170e5 --- /dev/null +++ b/generic/fail2ban/action.d/f2b-mqtt-action-banisher.conf @@ -0,0 +1,19 @@ +[Definition] +actionstart = +actioncheck = +actionstop = + +# = nom du jail qui a déclenché l'action +# / = valeurs du jail si passées explicitement (voir jail.local), +# sinon les défauts "-" du [Init] ci-dessous (ex. jails +# recidive, qui ne ciblent pas un port/protocole précis) +actionban = /etc/fail2ban/action.d/f2b_mqtt_action_banisher.py \ + "{\"action\":\"BAN\",\"ip\":\"\",\"time\":\"