Services Traefik En bref Niveau : intermédiaire Durée : 30 minutes Prérequis : socle système et Docker en place Niveau de test : reconstitué Traefik, c'est quoi ? Traefik est un reverse proxy conçu pour Docker. Il reçoit tout le trafic web d'une VM, lit les étiquettes (labels) posées sur les conteneurs, et en déduit seul vers quelle application envoyer chaque requête. Il obtient aussi les certificats HTTPS et les renouvelle automatiquement. Élément Rôle Point d'entrée web (port 80) Reçoit le HTTP et redirige vers HTTPS ; sert au défi de validation Let's Encrypt Point d'entrée websecure (port 443) Reçoit le HTTPS et applique le certificat correspondant au nom demandé Fournisseur Docker Surveille les conteneurs et crée les routes à partir de leurs labels Résolveur de certificats Demande et renouvelle les certificats Let's Encrypt Visiteur → cloud.penhoat.example:443 → Traefik → conteneur Nextcloud Concrètement, ajouter une application ne demande jamais de modifier Traefik : il suffit que son conteneur soit sur le réseau proxy et porte les bons labels. Traefik le détecte à chaud, sans redémarrage. Prérequis Le socle est en place : Docker installé, réseau proxy créé, ports 80 et 443 ouverts dans le pare-feu. Un enregistrement DNS pointe chaque nom du client vers l'adresse publique de sa VM. Une entrée générique *.penhoat.example suffit. Le port 80 est joignable depuis Internet : Let's Encrypt s'en sert pour vérifier que le domaine appartient bien au client. Le domaine est réel. penhoat.example étant fictif, aucun certificat réel ne peut lui être délivré. Pour reproduire ce TP, remplacer le domaine par un domaine dont on dispose. Pendant les essais, activer le mode « staging » de Let's Encrypt (variable traefik_acme_staging). Les certificats délivrés ne sont pas reconnus par les navigateurs, mais le serveur de test n'impose pas les mêmes limites de demandes que le serveur réel, qui bloque un domaine après quelques échecs répétés. Arborescence du rôle roles/traefik/ ├── defaults/main.yml ├── handlers/main.yml ├── tasks/main.yml └── templates/ ├── docker-compose.yml.j2 ├── logrotate-traefik.j2 └── traefik.yml.j2 Sur la VM, le rôle crée : /opt/nebuleuse/traefik/ ├── docker-compose.yml ├── traefik.yml # configuration statique └── acme/acme.json # certificats (créé par Traefik, accès réservé à root) /var/log/traefik/access.log # journal d'accès, lu par Fail2ban Variables # roles/traefik/defaults/main.yml # Version de Traefik. Choisir une version récente : avec les versions récentes de Docker, # une version trop ancienne de Traefik ne parvient plus à lire l'API Docker. traefik_version: "v3.6" # Dossier de travail sur la VM. traefik_dir: /opt/nebuleuse/traefik # Dossier du journal d'accès (monté dans le conteneur, lu par Fail2ban). traefik_log_dir: /var/log/traefik # Adresse e-mail déclarée auprès de Let's Encrypt (définie dans group_vars/all). traefik_acme_email: "{{ nebuleuse_acme_email }}" # true : utilise le serveur de test de Let's Encrypt (certificats non reconnus, sans limites strictes). traefik_acme_staging: false # Réseau Docker partagé avec les applications (défini dans group_vars/all). traefik_network: "{{ docker_proxy_network }}" # Noms de domaine que les applications doivent joindre à l'intérieur de la VM. # Voir la section « Joindre le SSO depuis un conteneur ». traefik_network_aliases: - "{{ client_subdomains.sso }}.{{ client_domain }}" La configuration statique La configuration statique décrit Traefik lui-même : ses ports, ses sources de configuration, ses certificats. Elle n'est lue qu'au démarrage. # roles/traefik/templates/traefik.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. global: # Pas de vérification de mise à jour ni de télémétrie : la VM n'a pas à contacter ces services. checkNewVersion: false sendAnonymousUsage: false log: level: INFO # Journal d'accès : une ligne JSON par requête. Fail2ban le lit pour repérer les attaques. accessLog: filePath: /var/log/traefik/access.log format: json providers: docker: # Un conteneur n'est publié que s'il porte le label "traefik.enable=true". exposedByDefault: false # Réseau utilisé pour joindre les conteneurs. network: {{ traefik_network }} entryPoints: web: address: ":80" http: # Toute requête HTTP est redirigée vers HTTPS. redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" http: tls: # Tout routeur sur ce point d'entrée reçoit automatiquement un certificat # pour le nom de sa règle Host(...). certResolver: letsencrypt certificatesResolvers: letsencrypt: acme: email: {{ traefik_acme_email }} # Emplacement des certificats : volume persistant, pour ne pas les redemander à chaque redémarrage. storage: /acme/acme.json {% if traefik_acme_staging %} # Serveur de test de Let's Encrypt. caServer: https://acme-staging-v02.api.letsencrypt.org/directory {% endif %} # Validation par HTTP : Let's Encrypt vérifie le domaine sur le port 80. httpChallenge: entryPoint: web Cette configuration obtient un certificat par nom de domaine. Pour un certificat unique couvrant tous les sous-domaines (certificat « wildcard »), il faut passer par la validation DNS, qui demande les identifiants de l'API du fournisseur de noms de domaine. C'est plus pratique, mais cela ajoute un secret par client. Le fichier Docker Compose # roles/traefik/templates/docker-compose.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. services: traefik: image: traefik:{{ traefik_version }} container_name: {{ client_name }}_traefik restart: unless-stopped # Interdit au processus de gagner de nouveaux privilèges. security_opt: - no-new-privileges:true # Seul conteneur de la VM à publier des ports vers Internet. ports: - "80:80" - "443:443" volumes: # Lecture de la liste des conteneurs. L'accès est en lecture seule, mais il donne # tout de même une vue étendue sur Docker : voir « Pour aller plus loin ». - /var/run/docker.sock:/var/run/docker.sock:ro - {{ traefik_dir }}/traefik.yml:/etc/traefik/traefik.yml:ro - {{ traefik_dir }}/acme:/acme - {{ traefik_log_dir }}:/var/log/traefik networks: {{ traefik_network }}: # Noms que les autres conteneurs peuvent joindre directement, sans sortir de la VM. aliases: {% for alias in traefik_network_aliases %} - {{ alias }} {% endfor %} networks: {{ traefik_network }}: # Réseau créé par le rôle docker-engine : Compose ne doit pas le recréer. external: true Rotation du journal d'accès Un journal qui grossit sans limite finit par remplir le disque. Docker fait tourner les journaux de ses conteneurs, mais pas les fichiers que Traefik écrit lui-même. Une règle logrotate s'en charge. # roles/traefik/templates/logrotate-traefik.j2 {{ traefik_log_dir }}/access.log { # Un fichier par jour, sept jours conservés. daily rotate 7 compress delaycompress missingok notifempty # Copie puis vide le fichier : Traefik continue d'écrire dans le même fichier ouvert. copytruncate } Tâches # roles/traefik/tasks/main.yml - name: Créer les dossiers de Traefik ansible.builtin.file: path: "{{ item.path }}" state: directory mode: "{{ item.mode }}" loop: - { path: "{{ traefik_dir }}", mode: "0755" } # Le dossier des certificats est réservé à root : il contient les clés privées. - { path: "{{ traefik_dir }}/acme", mode: "0700" } - { path: "{{ traefik_log_dir }}", mode: "0755" } - name: Déposer la configuration statique de Traefik ansible.builtin.template: src: traefik.yml.j2 dest: "{{ traefik_dir }}/traefik.yml" mode: "0644" # Un changement de configuration statique demande un redémarrage. notify: Redémarrer traefik - name: Déposer le fichier Docker Compose de Traefik ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ traefik_dir }}/docker-compose.yml" mode: "0644" - name: Déposer la règle de rotation du journal d'accès ansible.builtin.template: src: logrotate-traefik.j2 dest: /etc/logrotate.d/traefik mode: "0644" - name: Démarrer Traefik community.docker.docker_compose_v2: project_src: "{{ traefik_dir }}" # "present" crée ou met à jour les conteneurs, sans toucher à ceux déjà conformes. state: present - name: Attendre que Traefik écoute sur le port 443 # Le module s'exécute sur la VM : "127.0.0.1" désigne donc la VM elle-même. ansible.builtin.wait_for: host: 127.0.0.1 port: 443 timeout: 60 # roles/traefik/handlers/main.yml - name: Redémarrer traefik community.docker.docker_compose_v2: project_src: "{{ traefik_dir }}" state: restarted Comment une application se déclare Traefik n'a aucune liste d'applications : chaque conteneur se décrit lui-même avec des labels. Toutes les pages d'application suivent ce modèle, ici pour Nextcloud. # Extrait d'un docker-compose.yml.j2 d'application (exemple : Nextcloud) labels: # Publier ce conteneur (obligatoire, car exposedByDefault vaut false). - "traefik.enable=true" # Réseau à utiliser si le conteneur est sur plusieurs réseaux. - "traefik.docker.network={{ docker_proxy_network }}" # Règle de routage : les requêtes pour ce nom vont vers ce conteneur. # Le nom du routeur (nextcloud-alpha) doit être unique sur la VM. - "traefik.http.routers.nextcloud-{{ client_name }}.rule=Host(`{{ client_subdomains.nextcloud }}.{{ client_domain }}`)" # Point d'entrée HTTPS : le certificat est demandé automatiquement. - "traefik.http.routers.nextcloud-{{ client_name }}.entrypoints=websecure" # Port d'écoute de l'application À L'INTÉRIEUR du conteneur. - "traefik.http.services.nextcloud-{{ client_name }}.loadbalancer.server.port=80" Joindre le SSO depuis un conteneur Une application doit parfois contacter le SSO de la VM, par exemple pour échanger un code de connexion contre un jeton. Elle le fait avec son adresse publique, sso.penhoat.example. Or ce nom pointe vers l'adresse publique de la VM, et un conteneur qui cherche à la joindre fait un détour par l'extérieur : le chemin fonctionne ou non selon la façon dont l'hébergeur attache l'adresse publique à la machine (adresse portée directement par la VM ou traduite par une passerelle). Le plus fiable est d'éviter ce détour. Le rôle donne à Traefik un alias de réseau : sur le réseau proxy, le nom sso.penhoat.example est résolu vers le conteneur Traefik lui-même. L'application joint donc Traefik directement, sur le port 443, avec le même nom et le même certificat. Le résultat est le même pour l'utilisateur, sans dépendre de l'adresse publique. # Extrait de docker-compose.yml.j2 (généré par le rôle) networks: proxy: aliases: - sso.penhoat.example Seul le nom du SSO a besoin de cet alias, car c'est le seul que les applications appellent entre elles. La variable traefik_network_aliases permet d'en ajouter d'autres si une application doit en joindre une autre. Vérification # Sur la VM : Traefik est démarré et publie les ports 80 et 443. docker ps --filter name=traefik --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' # La redirection HTTP vers HTTPS répond "301". curl -sI http://127.0.0.1 | head -n 1 # Un nom sans application répond "404" : Traefik écoute, mais n'a aucune route pour lui. curl -skI https://127.0.0.1 | head -n 1 # Journal de démarrage : aucune erreur de certificat attendue. docker logs --tail 30 alpha_traefik # Journal d'accès : le champ ClientHost doit contenir l'adresse du visiteur, pas celle de Docker. sudo tail -n 3 /var/log/traefik/access.log | jq '{ClientHost, RequestHost, DownstreamStatus}' Une fois une application déployée, le certificat apparaît avec : # Affiche l'émetteur et les dates de validité du certificat de l'application. echo | openssl s_client -connect cloud.penhoat.example:443 -servername cloud.penhoat.example 2>/dev/null | openssl x509 -noout -issuer -dates Pour aller plus loin Protéger l'accès au socket Docker. Même en lecture seule, le socket expose beaucoup d'informations sur la machine. Un intermédiaire comme docker-socket-proxy limite Traefik aux seules requêtes nécessaires. Certificats wildcard par validation DNS, pour ne pas dépendre de l'accès au port 80. En-têtes de sécurité (HSTS, X-Content-Type-Options) ajoutés par un middleware commun à toutes les applications. Pièges Limites de Let's Encrypt : des demandes répétées en échec bloquent temporairement un domaine. Tester avec traefik_acme_staging: true, puis repasser à false en supprimant le contenu du dossier acme pour obtenir les vrais certificats. DNS pas encore propagé : le défi échoue et le journal indique que le domaine ne pointe pas vers la VM. Vérifier l'enregistrement avant de redémarrer Traefik. Application inaccessible, erreur 404 : le label traefik.enable=true ou la règle Host(...) est absente ou erronée. Le routeur n'existe pas, donc Traefik ne sait pas où envoyer la requête. Application inaccessible, erreur 502 ou 504 : Traefik trouve la route mais n'atteint pas le conteneur. Causes habituelles : mauvais port dans le label loadbalancer.server.port, conteneur absent du réseau proxy, ou conteneur sur plusieurs réseaux sans label traefik.docker.network. Traefik ne détecte plus aucun conteneur : avec les versions récentes de Docker, une version trop ancienne de Traefik n'arrive plus à lire l'API Docker. Le journal mentionne une version d'API trop ancienne. Mettre à jour traefik_version. Adresse du visiteur remplacée par celle de la passerelle Docker : le journal d'accès montre l'adresse interne de Docker au lieu de celle du visiteur, ce qui rend Fail2ban inefficace. Le cas se rencontre surtout avec les connexions IPv6 ou lorsqu'un intermédiaire est placé devant la VM. Le contrôle se fait avec la dernière commande de vérification, depuis une adresse extérieure. Noms de routeur en double : deux conteneurs qui déclarent le même nom de routeur se contredisent, et seul l'un des deux fonctionne. Le nom du client dans le nom du routeur évite les collisions entre projets. Modification de traefik.yml sans effet : la configuration statique n'est lue qu'au démarrage. Le handler redémarre Traefik quand le fichier change ; les labels, eux, sont pris en compte à chaud. Page suivante : Authentik (SSO). Authentik En bref Niveau : avancé Durée : 45 minutes Prérequis : Traefik en place Niveau de test : reconstitué Authentik, c'est quoi ? Authentik est un fournisseur d'identité : il détient les comptes des utilisateurs et dit aux applications qui est connecté. Une application qui lui délègue la connexion n'a plus de mots de passe à gérer : l'utilisateur se connecte une fois sur Authentik, puis accède à toutes les applications de la VM. Le protocole utilisé est OpenID Connect (OIDC). Élément Rôle Serveur Authentik Interface web, API et pages de connexion Worker Authentik Tâches de fond (envoi d'e-mails, nettoyage, synchronisations) PostgreSQL Base de données d'Authentik (comptes, configuration) Provider OIDC Définit comment une application se connecte (identifiant, secret, adresses de retour) Application Objet Authentik qui rattache un provider à une application et à ses règles d'accès Utilisateur → Application → redirection vers Authentik → connexion → retour à l'application avec un jeton Concrètement, chaque application commandée par un client a besoin d'un provider et d'une application dans Authentik, créés avec les bons réglages. Faire ces réglages à la main dans l'interface est long et source d'erreurs. Le rôle les fait donc par l'API d'Authentik, avec un jeu de tâches réutilisable que toutes les pages d'application appellent. Prérequis Traefik est en place et publie sso.penhoat.example (page précédente). Quatre secrets existent dans le Vault du client : vault_authentik_secret_key, vault_authentik_db_password, vault_authentik_bootstrap_password et vault_authentik_bootstrap_token. Les deux derniers complètent la liste de la page « Inventaire et variables ». curl est installé sur la VM (fait par le socle). Le mot de passe et le jeton d'amorçage ne sont lus qu'au tout premier démarrage d'Authentik : ils créent le compte administrateur akadmin et son jeton d'API. Les modifier ensuite dans le Vault ne change rien dans Authentik. Arborescence du rôle roles/authentik/ ├── defaults/main.yml ├── tasks/ │ ├── main.yml # déploiement, attente, découverte des objets communs │ ├── oidc_app.yml # création d'un provider et d'une application OIDC (réutilisable) │ └── first_user.yml # création du premier compte du client └── templates/ └── docker-compose.yml.j2 Variables # roles/authentik/defaults/main.yml # Version d'Authentik : remplacer par la dernière version stable (voir les notes de version). # Depuis la version 2025.10, Redis n'est plus nécessaire. Sur une version plus ancienne, # un conteneur Redis doit être ajouté au fichier Docker Compose. authentik_version: "2025.10" # Dossier de travail sur la VM. authentik_dir: /opt/nebuleuse/authentik # Nom de domaine d'Authentik et adresse de son API. authentik_host: "{{ client_subdomains.sso }}.{{ client_domain }}" authentik_api: "https://{{ authentik_host }}/api/v3" # Secrets : définis dans le Vault, jamais en clair ici. authentik_secret_key: "{{ vault_authentik_secret_key }}" authentik_db_password: "{{ vault_authentik_db_password }}" authentik_bootstrap_password: "{{ vault_authentik_bootstrap_password }}" authentik_bootstrap_token: "{{ vault_authentik_bootstrap_token }}" # Réseau Docker partagé avec Traefik. authentik_network: "{{ docker_proxy_network }}" # Flux d'autorisation utilisé par les applications. # "explicit-consent" : l'utilisateur valide un écran de consentement à la première connexion. # Il existe aussi une variante "implicit-consent", sans écran de validation. authentik_authorization_flow_slug: default-provider-authorization-explicit-consent authentik_invalidation_flow_slug: default-provider-invalidation-flow # Fichier d'en-tête contenant le jeton d'API (lu par curl, jamais passé en argument). authentik_api_header_file: /root/.authentik-api-header # Base de toutes les commandes curl qui appellent l'API. # -sS : silencieux, mais affiche les erreurs # -k : n'exige pas de certificat reconnu (nécessaire en mode "staging") # --fail-with-body : échoue sur une erreur HTTP, en affichant la réponse du serveur # --resolve : résout le nom d'Authentik vers la VM elle-même, sans passer par le DNS # -H @fichier : lit l'en-tête d'authentification dans un fichier authentik_curl_base: - curl - -sS - -k - --fail-with-body - --resolve - "{{ authentik_host }}:443:127.0.0.1" - -H - "@{{ authentik_api_header_file }}" Le fichier Docker Compose # roles/authentik/templates/docker-compose.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. # Il contient des secrets : accès réservé à root. # Variables communes au serveur et au worker (ancre YAML réutilisée plus bas). x-authentik-env: &authentik_env AUTHENTIK_SECRET_KEY: "{{ authentik_secret_key }}" AUTHENTIK_POSTGRESQL__HOST: db AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: "{{ authentik_db_password }}" # Lus uniquement au premier démarrage : créent le compte "akadmin" et son jeton d'API. AUTHENTIK_BOOTSTRAP_PASSWORD: "{{ authentik_bootstrap_password }}" AUTHENTIK_BOOTSTRAP_TOKEN: "{{ authentik_bootstrap_token }}" AUTHENTIK_BOOTSTRAP_EMAIL: "{{ client_admin_email }}" services: server: image: ghcr.io/goauthentik/server:{{ authentik_version }} container_name: {{ client_name }}_authentik_server restart: unless-stopped command: server environment: *authentik_env volumes: - {{ authentik_dir }}/media:/media - {{ authentik_dir }}/custom-templates:/templates depends_on: db: condition: service_healthy networks: - {{ authentik_network }} - internal labels: - "traefik.enable=true" - "traefik.docker.network={{ authentik_network }}" - "traefik.http.routers.authentik-{{ client_name }}.rule=Host(`{{ authentik_host }}`)" - "traefik.http.routers.authentik-{{ client_name }}.entrypoints=websecure" # Port HTTP interne d'Authentik. - "traefik.http.services.authentik-{{ client_name }}.loadbalancer.server.port=9000" worker: image: ghcr.io/goauthentik/server:{{ authentik_version }} container_name: {{ client_name }}_authentik_worker restart: unless-stopped command: worker environment: *authentik_env volumes: - {{ authentik_dir }}/media:/media - {{ authentik_dir }}/custom-templates:/templates depends_on: db: condition: service_healthy # Le worker n'a pas besoin d'être joignable : réseau interne uniquement. networks: - internal db: image: postgres:16-alpine container_name: {{ client_name }}_authentik_db restart: unless-stopped environment: POSTGRES_USER: authentik POSTGRES_DB: authentik POSTGRES_PASSWORD: "{{ authentik_db_password }}" volumes: - {{ authentik_dir }}/db-data:/var/lib/postgresql/data # Les autres services attendent que la base soit prête avant de démarrer. healthcheck: test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"] interval: 5s timeout: 5s retries: 10 # La base n'est jamais exposée sur le réseau partagé avec Traefik. networks: - internal networks: {{ authentik_network }}: external: true internal: {} Seul le serveur est sur le réseau partagé proxy. La base de données et le worker restent sur un réseau privé au projet : aucune autre application de la VM ne peut atteindre la base d'Authentik. Déploiement et découverte des objets communs La première partie du rôle déploie les conteneurs, attend que l'API réponde, puis retrouve les objets d'Authentik dont tous les providers ont besoin : les deux flux, les mappings de scope, la clé de signature et la règle d'accès. # roles/authentik/tasks/main.yml - name: Créer les dossiers d'Authentik ansible.builtin.file: path: "{{ item }}" state: directory mode: "0755" loop: - "{{ authentik_dir }}" - "{{ authentik_dir }}/media" - "{{ authentik_dir }}/custom-templates" - "{{ authentik_dir }}/db-data" - name: Déposer le fichier Docker Compose d'Authentik ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ authentik_dir }}/docker-compose.yml" # Le fichier contient des secrets : root uniquement. mode: "0600" no_log: true - name: Démarrer Authentik community.docker.docker_compose_v2: project_src: "{{ authentik_dir }}" state: present # Le premier démarrage applique les migrations de la base : cela peut durer plusieurs minutes. - name: Attendre que l'API d'Authentik soit prête ansible.builtin.command: argv: - curl - -fsS - -k - --resolve - "{{ authentik_host }}:443:127.0.0.1" - "https://{{ authentik_host }}/-/health/live/" register: authentik_ready until: authentik_ready.rc == 0 retries: 60 delay: 5 changed_when: false # Le jeton est écrit dans un fichier d'en-tête plutôt que passé en argument à curl : # il n'apparaît ni dans la liste des processus, ni dans les journaux d'Ansible. - name: Déposer le fichier d'en-tête d'authentification pour l'API ansible.builtin.copy: dest: "{{ authentik_api_header_file }}" content: "Authorization: Bearer {{ authentik_bootstrap_token }}\n" owner: root group: root mode: "0600" no_log: true # --- Découverte des objets communs à tous les providers --- - name: Trouver le flux d'autorisation ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/flows/instances/?slug=' ~ authentik_authorization_flow_slug] }}" register: authentik_authz_flow_raw changed_when: false - name: Trouver le flux d'invalidation ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/flows/instances/?slug=' ~ authentik_invalidation_flow_slug] }}" register: authentik_invalidation_flow_raw changed_when: false - name: Lister les mappings de scope ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/propertymappings/provider/scope/?page_size=200'] }}" register: authentik_scope_mappings_raw changed_when: false - name: Trouver la clé de signature par défaut ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/crypto/certificatekeypairs/?name=authentik%20Self-signed%20Certificate'] }}" register: authentik_signing_key_raw changed_when: false no_log: true # la réponse peut contenir des éléments de la clé - name: Retenir les identifiants des objets trouvés ansible.builtin.set_fact: authentik_authz_flow_pk: "{{ (authentik_authz_flow_raw.stdout | from_json).results[0].pk }}" authentik_invalidation_flow_pk: "{{ (authentik_invalidation_flow_raw.stdout | from_json).results[0].pk }}" # Clé qui signe les jetons : sans elle, Authentik signe avec le secret client (HS256). authentik_signing_key_pk: "{{ (authentik_signing_key_raw.stdout | from_json).results[0].pk }}" # Les trois mappings standard : sans eux, l'application reçoit un jeton sans identité. authentik_scope_mapping_pks: >- {{ (authentik_scope_mappings_raw.stdout | from_json).results | selectattr('scope_name', 'in', ['openid', 'profile', 'email']) | map(attribute='pk') | list }} # --- Règle d'accès : "toute personne connectée peut utiliser l'application" --- - name: Chercher la règle d'accès Nébuleuse ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/policies/expression/?name=nebuleuse-allow-all'] }}" register: authentik_policy_raw changed_when: false - name: Créer la règle d'accès si elle est absente ansible.builtin.command: argv: >- {{ authentik_curl_base + ['-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', ({'name': 'nebuleuse-allow-all', 'expression': 'return True'} | to_json), authentik_api ~ '/policies/expression/'] }} register: authentik_policy_created when: (authentik_policy_raw.stdout | from_json).results | length == 0 - name: Retenir l'identifiant de la règle d'accès ansible.builtin.set_fact: authentik_allow_policy_pk: >- {{ (authentik_policy_created.stdout | from_json).pk if authentik_policy_created is not skipped else (authentik_policy_raw.stdout | from_json).results[0].pk }} Les commandes curl sont lancées avec argv (une liste d'arguments) plutôt qu'avec une chaîne : il n'y a ainsi aucun guillemet à échapper dans le JSON. Dans ce déploiement, le module ansible.builtin.uri n'a pas été retenu : curl s'est montré plus prévisible, notamment avec --resolve et les certificats de test. Créer une connexion OIDC pour une application Ce fichier de tâches est le cœur de l'intégration. Il est appelé une fois par application, avec trois paramètres : le nom, l'identifiant court (le « slug ») et les adresses de retour autorisées. Il crée le provider, l'application, lie la règle d'accès, puis retourne l'identifiant client, le secret et l'adresse de l'émetteur. # roles/authentik/tasks/oidc_app.yml # # Paramètres attendus : # oidc_app_name : nom affiché dans Authentik (ex. "Nextcloud") # oidc_app_slug : identifiant court, utilisé dans les adresses (ex. "nextcloud") # oidc_redirect_uris : liste d'adresses de retour autorisées # [{ matching_mode: strict, url: "https://..." }] # oidc_sub_mode : (facultatif) identifiant d'utilisateur transmis à l'application # # Résultats : # oidc_client_id, oidc_client_secret, oidc_issuer # 1. Le provider : décrit comment l'application se connecte. - name: "{{ oidc_app_name }} : chercher le provider" ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/providers/oauth2/?name=' ~ oidc_app_slug] }}" register: oidc_provider_lookup changed_when: false no_log: true # la réponse contient le secret client - name: "{{ oidc_app_name }} : créer le provider s'il est absent" ansible.builtin.command: argv: >- {{ authentik_curl_base + ['-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', (oidc_provider_payload | to_json), authentik_api ~ '/providers/oauth2/'] }} vars: oidc_provider_payload: name: "{{ oidc_app_slug }}" # Flux d'AUTORISATION : pas le flux d'authentification, sinon l'utilisateur boucle sur la connexion. authorization_flow: "{{ authentik_authz_flow_pk }}" invalidation_flow: "{{ authentik_invalidation_flow_pk }}" # "confidential" : l'application garde un secret côté serveur. client_type: confidential # Signature des jetons en RS256, attendue par la plupart des applications (Immich, par exemple). signing_key: "{{ authentik_signing_key_pk }}" # Identifiant transmis à l'application pour désigner l'utilisateur (voir la section Pièges). sub_mode: "{{ oidc_sub_mode | default('hashed_user_id') }}" # Adresses de retour : "strict" exige une correspondance exacte, caractère pour caractère. redirect_uris: "{{ oidc_redirect_uris }}" # Les mappings openid, profile et email : sans eux, "scopes demandés non configurés". property_mappings: "{{ authentik_scope_mapping_pks }}" register: oidc_provider_created when: (oidc_provider_lookup.stdout | from_json).results | length == 0 no_log: true - name: "{{ oidc_app_name }} : retenir le provider" ansible.builtin.set_fact: oidc_provider: >- {{ (oidc_provider_created.stdout | from_json) if oidc_provider_created is not skipped else (oidc_provider_lookup.stdout | from_json).results[0] }} no_log: true # 2. L'application : rattache le provider à un nom et à des règles d'accès. - name: "{{ oidc_app_name }} : chercher l'application" ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/core/applications/?slug=' ~ oidc_app_slug] }}" register: oidc_app_lookup changed_when: false - name: "{{ oidc_app_name }} : créer l'application si elle est absente" ansible.builtin.command: argv: >- {{ authentik_curl_base + ['-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', (oidc_app_payload | to_json), authentik_api ~ '/core/applications/'] }} vars: oidc_app_payload: name: "{{ oidc_app_name }}" slug: "{{ oidc_app_slug }}" provider: "{{ oidc_provider.pk }}" policy_engine_mode: any register: oidc_app_created when: (oidc_app_lookup.stdout | from_json).results | length == 0 - name: "{{ oidc_app_name }} : retenir l'application" ansible.builtin.set_fact: oidc_app_pk: >- {{ (oidc_app_created.stdout | from_json).pk if oidc_app_created is not skipped else (oidc_app_lookup.stdout | from_json).results[0].pk }} # 3. La règle d'accès : sans elle, la connexion se termine par "Accès refusé". # Elle n'est liée qu'à la création de l'application, pour ne pas multiplier les liens. - name: "{{ oidc_app_name }} : lier la règle d'accès à l'application" ansible.builtin.command: argv: >- {{ authentik_curl_base + ['-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', (oidc_binding_payload | to_json), authentik_api ~ '/policies/bindings/'] }} vars: oidc_binding_payload: target: "{{ oidc_app_pk }}" policy: "{{ authentik_allow_policy_pk }}" order: 0 when: oidc_app_created is not skipped # 4. Les valeurs que l'application doit connaître. - name: "{{ oidc_app_name }} : retenir les valeurs de connexion" ansible.builtin.set_fact: oidc_client_id: "{{ oidc_provider.client_id }}" oidc_client_secret: "{{ oidc_provider.client_secret }}" # Adresse de l'émetteur : l'application y trouve la découverte automatique de la configuration. oidc_issuer: "https://{{ authentik_host }}/application/o/{{ oidc_app_slug }}/" no_log: true Une page d'application appelle ce fichier de tâches ainsi : # roles/nextcloud/tasks/main.yml (extrait) - name: Créer la connexion SSO de Nextcloud ansible.builtin.include_tasks: ../../authentik/tasks/oidc_app.yml vars: oidc_app_name: Nextcloud oidc_app_slug: nextcloud oidc_redirect_uris: - matching_mode: strict url: "https://{{ client_subdomains.nextcloud }}.{{ client_domain }}/apps/user_oidc/code" Les adresses OIDC dont l'application a besoin sont toutes construites à partir de l'adresse de l'émetteur : Usage Adresse Découverte automatique https://sso.penhoat.example/application/o//.well-known/openid-configuration Autorisation https://sso.penhoat.example/application/o/authorize/ Jeton https://sso.penhoat.example/application/o/token/ Informations sur l'utilisateur https://sso.penhoat.example/application/o/userinfo/ Déconnexion https://sso.penhoat.example/application/o//end-session/ Le premier compte du client Le compte akadmin sert à l'exploitation par Nébuleuse. Le client a besoin de son propre compte, créé à partir de client_admin_email. Son mot de passe n'est volontairement pas défini ici : il est choisi par le client, par un lien de réinitialisation envoyé par le support. # roles/authentik/tasks/first_user.yml - name: Chercher le compte du client ansible.builtin.command: argv: "{{ authentik_curl_base + [authentik_api ~ '/core/users/?username=' ~ client_admin_email] }}" register: authentik_user_lookup changed_when: false - name: Créer le compte du client s'il est absent ansible.builtin.command: argv: >- {{ authentik_curl_base + ['-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', (authentik_user_payload | to_json), authentik_api ~ '/core/users/'] }} vars: authentik_user_payload: username: "{{ client_admin_email }}" name: "Administrateur {{ client_name }}" email: "{{ client_admin_email }}" is_active: true when: (authentik_user_lookup.stdout | from_json).results | length == 0 Dans main.yml, ce fichier est inclus à la fin du rôle : # roles/authentik/tasks/main.yml (fin du fichier) - name: Créer le premier compte du client ansible.builtin.include_tasks: first_user.yml Vérification # Sur la VM : les trois conteneurs sont démarrés et la base est « healthy ». docker ps --filter name=authentik --format '{{.Names}}\t{{.Status}}' # L'API répond et reconnaît le jeton : doit afficher l'utilisateur "akadmin". curl -sSk --resolve sso.penhoat.example:443:127.0.0.1 \ -H @/root/.authentik-api-header \ https://sso.penhoat.example/api/v3/core/users/me/ | jq '.user.username' # Une connexion OIDC existe et la découverte automatique répond (exemple : Nextcloud). curl -sSk --resolve sso.penhoat.example:443:127.0.0.1 \ https://sso.penhoat.example/application/o/nextcloud/.well-known/openid-configuration | jq '.issuer' Dans l'interface, on se connecte sur https://sso.penhoat.example/if/admin/ avec akadmin et le mot de passe d'amorçage. Les rubriques Applications et Providers doivent lister chaque application commandée. Pièges Flux d'authentification à la place du flux d'autorisation : l'utilisateur se reconnecte en boucle au lieu d'être renvoyé vers l'application. Le provider doit utiliser un flux dont le slug commence par default-provider-authorization-. Mappings de scope absents : l'erreur « Application requested scopes not configured » apparaît. Les trois mappings openid, profile et email doivent être attachés au provider. « Accès refusé » après une connexion réussie : dans le déploiement qui sert de base à ce TP, une application sans règle liée refusait l'accès. La règle return True est liée à chaque application pour l'autoriser à toute personne connectée. sub_mode inadapté : c'est l'identifiant de l'utilisateur transmis à l'application. La valeur par défaut hashed_user_id convient à la plupart des applications. Une application qui attend un UUID (PaperMerge, par exemple) échoue avec une erreur de format : utiliser user_uuid. Les valeurs possibles sont hashed_user_id, user_id, user_uuid, user_username, user_email et user_upn. Utilisateur introuvable côté application : certaines applications font correspondre l'utilisateur d'Authentik à un compte qu'elles ont déjà. Le nom d'utilisateur et l'adresse e-mail doivent alors être identiques de part et d'autre, et la connexion doit se faire avec ce compte, pas avec akadmin. Adresse de retour différente : l'adresse déclarée dans Authentik et celle utilisée par l'application doivent être identiques, y compris pour un / final. L'erreur affichée mentionne une URI de redirection invalide. Compter avec .count : la liste des résultats d'Authentik est dans .results, et le nombre d'éléments dans .pagination.count. Un .count direct provoque l'erreur « 'dict object' has no attribute 'count' ». Le rôle compte avec .results | length. Jeton visible dans la liste des processus ou dans les journaux : le passer en argument de curl l'expose. D'où le fichier d'en-tête en mode 0600 et les no_log: true. Modifier un provider existant : le rôle ne met pas à jour un provider déjà créé. Si les adresses de retour changent, il faut les modifier dans l'interface, ou supprimer le provider pour qu'il soit recréé. Premier démarrage long : les migrations de la base peuvent prendre plusieurs minutes. Le rôle attend jusqu'à cinq minutes avant d'abandonner. Version de Redis : sur une version d'Authentik antérieure à 2025.10, le serveur et le worker exigent un conteneur Redis, absent du fichier Compose de cette page. Jetons signés avec le secret client (HS256) : sans clé de signature sur le provider, Authentik signe avec le secret client. Une application qui attend du RS256 refuse alors le jeton. Le rôle associe donc la clé « authentik Self-signed Certificate » à chaque provider. Pour aller plus loin Remplacer le jeton d'amorçage par un compte de service dédié, aux droits limités à la gestion des providers, puis supprimer le jeton d'amorçage une fois le déploiement terminé. Configurer l'envoi d'e-mails (SMTP) pour que les liens de réinitialisation et les notifications partent directement d'Authentik. Déclarer la configuration par « blueprints » : Authentik sait charger des fichiers de description, une alternative à l'API qui offre une vraie mise à jour déclarative. Page suivante : Nextcloud. Nextcloud En bref Niveau : intermédiaire Durée : 45 minutes Prérequis : Traefik et Authentik en place (pages précédentes) Niveau de test : reconstitué Nextcloud, c'est quoi ? Nextcloud est une plateforme de fichiers, d'agenda et de contacts auto-hébergée : l'équivalent libre d'un espace de stockage en ligne, avec synchronisation sur ordinateur et téléphone. C'est l'application phare du catalogue, et celle qui sert ici de modèle aux autres : un fichier Docker Compose, des labels Traefik, une connexion SSO créée par le rôle Authentik. Élément Rôle Conteneur Nextcloud Serveur web (Apache et PHP) : interface, API, synchronisation Conteneur cron Même image, exécute les tâches de fond toutes les cinq minutes PostgreSQL Base de données (comptes, partages, métadonnées) Redis Cache et verrouillage des fichiers Application user_oidc Module Nextcloud qui délègue la connexion à Authentik Utilisateur → Traefik → Nextcloud → redirection vers Authentik → retour connecté Concrètement, le rôle fait cinq choses dans l'ordre : déposer le fichier Compose, démarrer les conteneurs, attendre la fin de l'installation automatique, créer la connexion SSO dans Authentik, puis déclarer cette connexion dans Nextcloud avec la commande occ. Cette page traite le déploiement automatisé d'une instance Nextcloud par Nébuleuse. L'installation manuelle et les commandes occ d'usage courant sont décrites dans le livre Nextcloud de la base de connaissance. Prérequis Traefik publie cloud.penhoat.example (variable client_subdomains.nextcloud) et Authentik répond sur sso.penhoat.example. nextcloud figure dans nebuleuse_apps et dans nebuleuse_catalogue. Deux secrets existent dans le Vault du client : vault_nextcloud_db_password et vault_nextcloud_admin_password (page « Inventaire et variables »). Arborescence du rôle roles/nextcloud/ ├── defaults/main.yml ├── tasks/main.yml └── templates/ └── docker-compose.yml.j2 Variables # roles/nextcloud/defaults/main.yml # Version majeure de Nextcloud : remplacer par la dernière version stable. # Nextcloud ne permet pas de sauter une version majeure lors d'une mise à jour. nextcloud_version: "32" # Dossier de travail sur la VM. nextcloud_dir: /opt/nebuleuse/nextcloud # Nom de domaine de l'application. nextcloud_host: "{{ client_subdomains.nextcloud }}.{{ client_domain }}" # Secrets : définis dans le Vault, jamais en clair ici. nextcloud_db_password: "{{ vault_nextcloud_db_password }}" nextcloud_admin_password: "{{ vault_nextcloud_admin_password }}" # Compte administrateur local, réservé à l'exploitation. Les utilisateurs du client # se connectent par le SSO. nextcloud_admin_user: nebuleuse-admin # Réseaux d'où Nextcloud accepte l'en-tête X-Forwarded-For. Ici : les réseaux privés de Docker, # où seul Traefik peut joindre l'application. nextcloud_trusted_proxies: "172.16.0.0/12" # Limites PHP : taille maximale d'un fichier envoyé et mémoire d'un processus. nextcloud_upload_limit: 10G nextcloud_memory_limit: 1G # Réseau Docker partagé avec Traefik. nextcloud_network: "{{ docker_proxy_network }}" # Nom du fournisseur OIDC déclaré dans Nextcloud (apparaît sur le bouton de connexion). nextcloud_oidc_provider_name: Authentik Le fichier Docker Compose # roles/nextcloud/templates/docker-compose.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. # Il contient des secrets : accès réservé à root. # Variables communes au serveur et au conteneur cron (ancre YAML réutilisée plus bas). x-nextcloud-env: &nextcloud_env # Base de données. POSTGRES_HOST: db POSTGRES_DB: nextcloud POSTGRES_USER: nextcloud POSTGRES_PASSWORD: "{{ nextcloud_db_password }}" # Cache et verrouillage. REDIS_HOST: redis # Lues uniquement à la première installation : créent le compte administrateur local. NEXTCLOUD_ADMIN_USER: "{{ nextcloud_admin_user }}" NEXTCLOUD_ADMIN_PASSWORD: "{{ nextcloud_admin_password }}" NEXTCLOUD_TRUSTED_DOMAINS: "{{ nextcloud_host }}" # Nextcloud est derrière Traefik : il doit construire ses adresses en HTTPS # et faire confiance à l'en-tête X-Forwarded-For envoyé par le proxy. OVERWRITEPROTOCOL: https OVERWRITEHOST: "{{ nextcloud_host }}" OVERWRITECLIURL: "https://{{ nextcloud_host }}" TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}" PHP_UPLOAD_LIMIT: "{{ nextcloud_upload_limit }}" PHP_MEMORY_LIMIT: "{{ nextcloud_memory_limit }}" services: app: image: nextcloud:{{ nextcloud_version }}-apache container_name: {{ client_name }}_nextcloud restart: unless-stopped environment: *nextcloud_env volumes: # Code, configuration et données des utilisateurs. - {{ nextcloud_dir }}/html:/var/www/html depends_on: - db - redis networks: - {{ nextcloud_network }} - internal labels: - "traefik.enable=true" - "traefik.docker.network={{ nextcloud_network }}" - "traefik.http.routers.nextcloud-{{ client_name }}.rule=Host(`{{ nextcloud_host }}`)" - "traefik.http.routers.nextcloud-{{ client_name }}.entrypoints=websecure" - "traefik.http.services.nextcloud-{{ client_name }}.loadbalancer.server.port=80" # Redirige les adresses de découverte CalDAV et CardDAV vers le service DAV de Nextcloud. # "$$" : un "$" littéral dans un fichier Compose. - "traefik.http.routers.nextcloud-{{ client_name }}.middlewares=nextcloud-dav-{{ client_name }}" - "traefik.http.middlewares.nextcloud-dav-{{ client_name }}.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav" - "traefik.http.middlewares.nextcloud-dav-{{ client_name }}.redirectregex.replacement=https://$${1}/remote.php/dav" - "traefik.http.middlewares.nextcloud-dav-{{ client_name }}.redirectregex.permanent=true" cron: image: nextcloud:{{ nextcloud_version }}-apache container_name: {{ client_name }}_nextcloud_cron restart: unless-stopped # Script fourni par l'image : lance "cron.php" toutes les cinq minutes. entrypoint: /cron.sh environment: *nextcloud_env volumes: - {{ nextcloud_dir }}/html:/var/www/html depends_on: - db - redis # Pas besoin d'être joignable : réseau interne uniquement. networks: - internal db: image: postgres:16-alpine container_name: {{ client_name }}_nextcloud_db restart: unless-stopped environment: POSTGRES_USER: nextcloud POSTGRES_DB: nextcloud POSTGRES_PASSWORD: "{{ nextcloud_db_password }}" volumes: - {{ nextcloud_dir }}/db-data:/var/lib/postgresql/data networks: - internal redis: image: redis:7-alpine container_name: {{ client_name }}_nextcloud_redis restart: unless-stopped networks: - internal networks: {{ nextcloud_network }}: external: true internal: {} Seul le serveur Nextcloud est sur le réseau proxy. La base, Redis et le conteneur cron restent sur un réseau privé au projet. Redis n'a donc pas de mot de passe : aucun autre conteneur de la VM ne peut l'atteindre. Déploiement # roles/nextcloud/tasks/main.yml - name: Créer les dossiers de Nextcloud ansible.builtin.file: path: "{{ item }}" state: directory mode: "0755" loop: - "{{ nextcloud_dir }}" - "{{ nextcloud_dir }}/db-data" # Le dossier "html" est créé par Docker : il appartient ensuite à l'utilisateur www-data # du conteneur (UID 33). Ne pas le modifier avec chown. - name: Déposer le fichier Docker Compose de Nextcloud ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ nextcloud_dir }}/docker-compose.yml" # Le fichier contient des secrets : root uniquement. mode: "0600" no_log: true - name: Démarrer Nextcloud community.docker.docker_compose_v2: project_src: "{{ nextcloud_dir }}" state: present # Au premier démarrage, l'image installe Nextcloud toute seule (plusieurs minutes). # "occ status" ne renvoie "installed": true qu'une fois l'installation terminée. - name: Attendre la fin de l'installation de Nextcloud ansible.builtin.command: argv: - docker - exec - --user - www-data - "{{ client_name }}_nextcloud" - php - occ - status - --output=json register: nextcloud_status # On retire les espaces pour ne pas dépendre de la mise en forme du JSON. until: >- nextcloud_status.rc == 0 and '"installed":true' in (nextcloud_status.stdout | replace(' ', '')) retries: 60 delay: 10 changed_when: false # Réglages complémentaires, rejouables sans effet de bord. - name: Régler le fonctionnement de Nextcloud ansible.builtin.command: argv: >- {{ ['docker', 'exec', '--user', 'www-data', client_name ~ '_nextcloud', 'php', 'occ', 'config:system:set'] + item }} loop: # Région par défaut des numéros de téléphone. - ['default_phone_region', '--value=FR'] # Fenêtre de maintenance : les tâches lourdes tournent à partir de 1 h (UTC). - ['maintenance_window_start', '--type=integer', '--value=1'] changed_when: false - name: Utiliser le conteneur cron pour les tâches de fond ansible.builtin.command: argv: [docker, exec, --user, www-data, "{{ client_name }}_nextcloud", php, occ, background:cron] changed_when: false Connexion SSO Cette partie du rôle appelle le fichier de tâches de la page Authentik, puis déclare le résultat dans Nextcloud. L'adresse de retour /apps/user_oidc/code est celle de l'application user_oidc. # roles/nextcloud/tasks/main.yml (suite) - name: Créer la connexion SSO de Nextcloud dans Authentik ansible.builtin.include_tasks: ../../authentik/tasks/oidc_app.yml vars: oidc_app_name: Nextcloud oidc_app_slug: nextcloud oidc_redirect_uris: - matching_mode: strict url: "https://{{ nextcloud_host }}/apps/user_oidc/code" - name: Lister les applications Nextcloud installées ansible.builtin.command: argv: [docker, exec, --user, www-data, "{{ client_name }}_nextcloud", php, occ, app:list, --output=json] register: nextcloud_apps_list changed_when: false - name: Installer l'application user_oidc si elle est absente ansible.builtin.command: argv: [docker, exec, --user, www-data, "{{ client_name }}_nextcloud", php, occ, app:install, user_oidc] when: "'user_oidc' not in nextcloud_apps_list.stdout" # La commande crée le fournisseur, ou le met à jour s'il existe déjà. # --unique-uid=0 : l'identifiant Nextcloud est le nom d'utilisateur, pas un hachage. # --mapping-uid=... : champ du jeton utilisé comme identifiant. - name: Déclarer Authentik comme fournisseur de connexion ansible.builtin.command: argv: - docker - exec - --user - www-data - "{{ client_name }}_nextcloud" - php - occ - user_oidc:provider - "{{ nextcloud_oidc_provider_name }}" - "--clientid={{ oidc_client_id }}" - "--clientsecret={{ oidc_client_secret }}" - "--discoveryuri={{ oidc_issuer }}.well-known/openid-configuration" - "--unique-uid=0" - "--mapping-uid=preferred_username" - "--scope=openid email profile" changed_when: false no_log: true Vérification # Sur la VM : quatre conteneurs "Up". docker ps --filter name=nextcloud --format '{{.Names}}\t{{.Status}}' # Nextcloud est installé et en version attendue. docker exec --user www-data alpha_nextcloud php occ status # L'adresse publique répond et annonce une instance installée. curl -sS https://cloud.penhoat.example/status.php | jq '{installed, maintenance, version}' # Le fournisseur OIDC est déclaré. docker exec --user www-data alpha_nextcloud php occ user_oidc:provider # La redirection CalDAV fonctionne : l'adresse "Location" doit finir par /remote.php/dav. curl -sSI https://cloud.penhoat.example/.well-known/caldav | grep -i '^location' Dans le navigateur, la page https://cloud.penhoat.example doit proposer le bouton « Authentik ». Après connexion, l'espace fichiers du compte s'affiche. Le compte local reste accessible par https://cloud.penhoat.example/login?direct=1. Pièges Variables d'installation sans effet après coup : NEXTCLOUD_ADMIN_*, les paramètres de base et les domaines approuvés ne sont lus qu'à la première installation. Pour les changer ensuite, il faut passer par occ (par exemple config:system:set trusted_domains 1 --value=...). « Accès via un domaine non approuvé » : le nom utilisé n'est pas dans trusted_domains. Tous les noms par lesquels l'application est atteinte doivent y figurer. Adresses en http:// ou boucle de redirection : OVERWRITEPROTOCOL, OVERWRITEHOST ou TRUSTED_PROXIES manquent. Les journaux d'accès ne montrent alors que l'adresse de Traefik. Utilisateurs créés avec un identifiant haché : sans --unique-uid=0, Nextcloud nomme les comptes d'après une empreinte, illisible dans les partages et les journaux. Erreur sur l'adresse de retour : l'adresse déclarée dans Authentik doit être exactement https:///apps/user_oidc/code. Premier utilisateur SSO sans droits d'administration : le compte du client est créé à sa première connexion, comme simple utilisateur. Pour le promouvoir : occ group:adduser admin . Tâches de fond jamais exécutées : sans conteneur cron, le mode « AJAX » ne s'exécute que lorsqu'un utilisateur ouvre l'interface. Le conteneur cron et la commande background:cron règlent ce point. Gros fichiers refusés : Traefik ne limite pas la taille, mais PHP oui. Les variables PHP_UPLOAD_LIMIT et PHP_MEMORY_LIMIT fixent les limites ; les clients de synchronisation découpent déjà les envois. Mise à jour de version : Nextcloud n'accepte de monter que d'une version majeure à la fois. Passer de 31 à 33 impose de passer par 32. Secret client visible brièvement : --clientsecret est passé en argument de docker exec et apparaît pendant quelques secondes dans la liste des processus de la VM, accessible à root seulement. Avec no_log: true, il n'apparaît pas dans les journaux d'Ansible. Nextcloud ne joint pas le SSO : l'erreur survient à l'échange du code de connexion. Le mécanisme d'alias réseau de Traefik, décrit dans sa page, évite ce détour par l'adresse publique. Page suivante : Immich. Immich En bref Niveau : avancé Durée : 45 minutes Prérequis : Traefik et Authentik en place ; 4 Go de mémoire sur la VM Niveau de test : reconstitué Immich, c'est quoi ? Immich est une galerie de photos et de vidéos auto-hébergée : sauvegarde automatique depuis le téléphone, albums partagés, recherche par visage, par lieu ou par contenu. C'est l'alternative libre aux services de photos en ligne. Il se compose de plusieurs conteneurs qui travaillent ensemble. Élément Rôle Serveur Immich Interface web, API, envoi et traitement des fichiers Machine learning Reconnaissance de visages et recherche par contenu PostgreSQL (image Immich) Base de données, avec les extensions de recherche vectorielle Redis (Valkey) File des tâches de fond et cache Fichier immich.json Configuration déclarative, dont la connexion SSO Téléphone ou navigateur → Traefik → Serveur Immich → Machine learning, PostgreSQL, Redis Concrètement, la page suit le même modèle que Nextcloud : un fichier Compose et des labels Traefik. Deux différences. Le SSO est déclaré dans un fichier de configuration lu au démarrage, et non par une commande après coup. Et le premier compte administrateur est créé par le rôle, pour éviter qu'un inconnu s'inscrive le premier. Prérequis Traefik publie photos.penhoat.example (variable client_subdomains.immich) et Authentik répond sur sso.penhoat.example. immich figure dans nebuleuse_apps et dans nebuleuse_catalogue. Deux secrets existent dans le Vault du client : vault_immich_db_password (déjà prévu) et vault_immich_admin_password (à ajouter). La VM dispose d'au moins 4 Go de mémoire : la reconnaissance de visages est gourmande. vault_immich_admin_password complète la liste de la page « Inventaire et variables ». Valeur d'exemple : FICTIF-admin-motdepasse-6. Arborescence du rôle roles/immich/ ├── defaults/main.yml ├── tasks/main.yml └── templates/ ├── docker-compose.yml.j2 └── immich.json.j2 Variables # roles/immich/defaults/main.yml # Version d'Immich : "v2" suit la version majeure 2. Immich publie souvent des changements # incompatibles : lire les notes de version avant toute montée de version. immich_version: "v2" # Image PostgreSQL d'Immich. Elle embarque les extensions de recherche vectorielle. # Le tag change à chaque version : le reprendre dans le docker-compose.yml officiel # de la version d'Immich choisie. immich_postgres_image: "ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0" # Dossiers de travail sur la VM. immich_dir: /opt/nebuleuse/immich # Bibliothèque : photos et vidéos des utilisateurs. C'est le dossier qui grossit. immich_library_dir: "{{ immich_dir }}/library" # Nom de domaine de l'application. immich_host: "{{ client_subdomains.immich }}.{{ client_domain }}" # Secrets : définis dans le Vault, jamais en clair ici. immich_db_password: "{{ vault_immich_db_password }}" immich_admin_password: "{{ vault_immich_admin_password }}" # Réseaux d'où Immich accepte l'en-tête X-Forwarded-For (réseaux privés de Docker). immich_trusted_proxies: "172.16.0.0/12" # Réseau Docker partagé avec Traefik. immich_network: "{{ docker_proxy_network }}" Le fichier Docker Compose # roles/immich/templates/docker-compose.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. # Il contient des secrets : accès réservé à root. # Variables communes au serveur (ancre YAML : un seul endroit à modifier). x-immich-env: &immich_env DB_HOSTNAME: database DB_USERNAME: immich DB_DATABASE_NAME: immich DB_PASSWORD: "{{ immich_db_password }}" REDIS_HOSTNAME: redis # Active la lecture de la configuration déclarative. IMMICH_CONFIG_FILE: /config/immich.json IMMICH_TRUSTED_PROXIES: "{{ immich_trusted_proxies }}" TZ: Europe/Paris services: server: image: ghcr.io/immich-app/immich-server:{{ immich_version }} container_name: {{ client_name }}_immich_server restart: unless-stopped environment: *immich_env volumes: # Bibliothèque des utilisateurs. - {{ immich_library_dir }}:/data # Configuration déclarative, en lecture seule. - {{ immich_dir }}/immich.json:/config/immich.json:ro - /etc/localtime:/etc/localtime:ro depends_on: - redis - database networks: - {{ immich_network }} - internal labels: - "traefik.enable=true" - "traefik.docker.network={{ immich_network }}" - "traefik.http.routers.immich-{{ client_name }}.rule=Host(`{{ immich_host }}`)" - "traefik.http.routers.immich-{{ client_name }}.entrypoints=websecure" # Port d'écoute du serveur Immich. - "traefik.http.services.immich-{{ client_name }}.loadbalancer.server.port=2283" machine-learning: image: ghcr.io/immich-app/immich-machine-learning:{{ immich_version }} container_name: {{ client_name }}_immich_ml restart: unless-stopped volumes: # Modèles téléchargés au premier usage : évite de les retélécharger à chaque démarrage. - {{ immich_dir }}/model-cache:/cache # Joint uniquement par le serveur, sur le réseau privé. networks: - internal redis: image: docker.io/valkey/valkey:8-bookworm container_name: {{ client_name }}_immich_redis restart: unless-stopped networks: - internal database: image: {{ immich_postgres_image }} container_name: {{ client_name }}_immich_db restart: unless-stopped environment: POSTGRES_USER: immich POSTGRES_DB: immich POSTGRES_PASSWORD: "{{ immich_db_password }}" # Détecte la corruption silencieuse des données. POSTGRES_INITDB_ARGS: "--data-checksums" volumes: # La base doit être sur un disque local, jamais sur un partage réseau. - {{ immich_dir }}/db-data:/var/lib/postgresql/data networks: - internal networks: {{ immich_network }}: external: true internal: {} Comme pour Authentik et Nextcloud, seul le serveur est sur le réseau proxy. Le service de machine learning, Redis et la base ne sont joignables que depuis le projet. La configuration déclarative Immich se configure dans son interface d'administration, mais accepte aussi un fichier JSON lu au démarrage. Ce fichier rend la configuration reproductible pour chaque client. En contrepartie, une fois le fichier actif, les réglages qu'il définit ne sont plus modifiables dans l'interface. # roles/immich/templates/immich.json.j2 { "server": { "externalDomain": "https://{{ immich_host }}" }, "passwordLogin": { "enabled": true }, "oauth": { "enabled": true, "issuerUrl": "{{ oidc_issuer }}", "clientId": "{{ oidc_client_id }}", "clientSecret": "{{ oidc_client_secret }}", "scope": "openid email profile", "signingAlgorithm": "RS256", "buttonText": "Se connecter avec Authentik", "autoRegister": true, "autoLaunch": false, "storageLabelClaim": "preferred_username" } } Le format JSON n'accepte pas les commentaires : les réglages sont donc expliqués ci-dessous. Le fichier contient le secret client du SSO, d'où le mode 0600 à son dépôt. Réglage Effet server.externalDomain Adresse publique, utilisée dans les liens de partage passwordLogin.enabled Garde la connexion par mot de passe : le compte administrateur local reste utilisable à côté du SSO oauth.issuerUrl Adresse de l'émetteur : Immich y trouve la découverte automatique de la configuration oauth.signingAlgorithm Algorithme de signature des jetons attendu (voir les pièges) oauth.autoRegister Crée le compte Immich à la première connexion par le SSO oauth.autoLaunch Si true, la page de connexion redirige directement vers le SSO oauth.storageLabelClaim Champ du jeton utilisé comme étiquette de stockage de l'utilisateur Déploiement # roles/immich/tasks/main.yml - name: Créer les dossiers d'Immich ansible.builtin.file: path: "{{ item }}" state: directory mode: "0755" loop: - "{{ immich_dir }}" - "{{ immich_library_dir }}" - "{{ immich_dir }}/model-cache" - "{{ immich_dir }}/db-data" # La connexion SSO est créée avant le fichier de configuration : elle fournit # l'identifiant et le secret qui y sont inscrits. - name: Créer la connexion SSO d'Immich dans Authentik ansible.builtin.include_tasks: ../../authentik/tasks/oidc_app.yml vars: oidc_app_name: Immich oidc_app_slug: immich oidc_redirect_uris: # Connexion depuis le navigateur. - matching_mode: strict url: "https://{{ immich_host }}/auth/login" # Association d'un compte SSO depuis les réglages de l'utilisateur. - matching_mode: strict url: "https://{{ immich_host }}/user-settings" # Application mobile. - matching_mode: strict url: "app.immich:///oauth-callback" - name: Déposer la configuration d'Immich ansible.builtin.template: src: immich.json.j2 dest: "{{ immich_dir }}/immich.json" # Le fichier contient le secret client : root uniquement. mode: "0600" register: immich_config no_log: true - name: Déposer le fichier Docker Compose d'Immich ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ immich_dir }}/docker-compose.yml" mode: "0600" no_log: true - name: Démarrer Immich community.docker.docker_compose_v2: project_src: "{{ immich_dir }}" state: present # Immich ne relit son fichier de configuration qu'au démarrage. - name: Redémarrer Immich si la configuration a changé community.docker.docker_compose_v2: project_src: "{{ immich_dir }}" state: restarted when: immich_config.changed # Le premier démarrage applique les migrations de la base : cela peut durer quelques minutes. - name: Attendre que l'API d'Immich réponde ansible.builtin.command: argv: - curl - -fsS - -k - --resolve - "{{ immich_host }}:443:127.0.0.1" - "https://{{ immich_host }}/api/server/ping" register: immich_ping until: immich_ping.rc == 0 retries: 60 delay: 5 changed_when: false - name: Savoir si Immich a déjà un administrateur ansible.builtin.command: argv: - curl - -sS - -k - --resolve - "{{ immich_host }}:443:127.0.0.1" - "https://{{ immich_host }}/api/server/config" register: immich_server_config changed_when: false # Le premier compte créé dans Immich devient administrateur : le rôle le crée # avant que quiconque puisse s'inscrire. - name: Créer le compte administrateur du client ansible.builtin.command: argv: >- {{ ['curl', '-sS', '-k', '--fail-with-body', '--resolve', immich_host ~ ':443:127.0.0.1', '-X', 'POST', '-H', 'Content-Type: application/json', '--data-binary', (immich_admin_payload | to_json), 'https://' ~ immich_host ~ '/api/auth/admin-sign-up'] }} vars: immich_admin_payload: email: "{{ client_admin_email }}" password: "{{ immich_admin_password }}" name: "Administrateur {{ client_name }}" when: not (immich_server_config.stdout | from_json).isInitialized no_log: true Vérification # Sur la VM : quatre conteneurs "Up". docker ps --filter name=immich --format '{{.Names}}\t{{.Status}}' # L'API répond : doit afficher {"res":"pong"}. curl -sS https://photos.penhoat.example/api/server/ping # L'instance est initialisée et le SSO est activé. curl -sS https://photos.penhoat.example/api/server/config | jq '{isInitialized, oauthButtonText}' # La découverte de l'émetteur répond et indique l'algorithme de signature (attendu : RS256). curl -sS https://sso.penhoat.example/application/o/immich/.well-known/openid-configuration \ | jq '{issuer, id_token_signing_alg_values_supported}' Dans le navigateur, la page https://photos.penhoat.example doit afficher le formulaire de connexion avec le bouton « Se connecter avec Authentik ». La connexion SSO avec l'adresse e-mail du client doit ouvrir le compte administrateur créé par le rôle, sans en créer un second. Pièges Le premier compte créé devient administrateur : si Immich est exposé avant la création du compte, la première personne qui s'inscrit, ou qui se connecte par le SSO, prend ce rôle. Le rôle crée donc l'administrateur dès le démarrage. Réglages verrouillés dans l'interface : avec le fichier immich.json, les rubriques qu'il définit sont en lecture seule. Toute modification passe par le fichier, puis un redémarrage. Algorithme de signature : Immich attend des jetons signés en RS256. Authentik ne signe en RS256 que si le provider porte une clé de signature. C'est pourquoi la page Authentik associe la clé de signature par défaut à chaque provider. Trois adresses de retour : le navigateur, les réglages utilisateur et l'application mobile en utilisent chacun une. Sans la troisième, la connexion fonctionne dans le navigateur mais échoue sur le téléphone. Image PostgreSQL : le tag doit correspondre à la version d'Immich. Une image PostgreSQL ordinaire ne contient pas les extensions de recherche, et le serveur ne démarre pas. Un changement de version majeure de PostgreSQL impose une migration, pas un simple changement de tag. Base sur un partage réseau : PostgreSQL y est lent et peut se corrompre. Le dossier db-data reste sur un disque local. Mémoire et premier lancement : le service de machine learning télécharge ses modèles au premier usage et consomme plusieurs Go. Les premières tâches de reconnaissance sont longues, sans que ce soit une panne. La bibliothèque grossit vite : elle croît avec chaque photo et vidéo envoyées. La surveillance de l'espace disque et la sauvegarde du dossier library et de la base sont à prévoir, la sauvegarde de la base seule ne suffit pas. Montées de version : Immich publie régulièrement des changements incompatibles. Il faut lire les notes de version et sauvegarder avant de changer immich_version. Compte SSO et compte existant : Immich rattache la connexion SSO au compte qui porte la même adresse e-mail. L'adresse du compte d'Authentik doit donc être identique à celle du compte Immich. Immich ne joint pas le SSO : la découverte de l'émetteur échoue depuis le conteneur. Le mécanisme d'alias réseau de Traefik, décrit dans sa page, évite ce détour par l'adresse publique. Page suivante : Vaultwarden. Vaultwarden En bref Niveau : intermédiaire Durée : 30 minutes Prérequis : Traefik en place Niveau de test : reconstitué Vaultwarden, c'est quoi ? Vaultwarden est un gestionnaire de mots de passe auto-hébergé, compatible avec les applications et extensions Bitwarden. Chaque utilisateur y range ses identifiants dans un coffre chiffré, accessible depuis le navigateur, l'ordinateur et le téléphone. Un seul conteneur suffit : le serveur et sa base de données SQLite. Élément Rôle Conteneur Vaultwarden Serveur, interface web et API compatible Bitwarden Dossier data Base SQLite, pièces jointes, clés et journal Journal vaultwarden.log Lu par Fail2ban pour bloquer les échecs de connexion Navigateur ou application → Traefik → Vaultwarden Concrètement, Vaultwarden est l'application la plus simple du catalogue à déployer, et la seule qui ne passe pas par le SSO. Le coffre est chiffré avec le mot de passe principal de l'utilisateur, qui ne quitte jamais son appareil. Ce mot de passe ne peut pas être délégué à Authentik : le serveur ne pourrait plus rien déchiffrer sans lui. Le rôle ne crée donc pas de connexion SSO pour Vaultwarden. Certaines versions proposent une connexion SSO optionnelle, non activée ici. Prérequis Traefik publie vault.penhoat.example (variable client_subdomains.vaultwarden). vaultwarden figure dans nebuleuse_apps et dans nebuleuse_catalogue. Le secret vault_vaultwarden_admin_token existe dans le Vault du client (page « Inventaire et variables »). Arborescence du rôle roles/vaultwarden/ ├── defaults/main.yml ├── tasks/main.yml └── templates/ └── docker-compose.yml.j2 Variables # roles/vaultwarden/defaults/main.yml # Version de Vaultwarden : remplacer par la dernière version stable. vaultwarden_version: "1.34.3" # Dossier de travail sur la VM. vaultwarden_dir: /opt/nebuleuse/vaultwarden # Nom de domaine de l'application. vaultwarden_host: "{{ client_subdomains.vaultwarden }}.{{ client_domain }}" # Inscriptions : fermées en fonctionnement normal. À ouvrir temporairement pour créer # les premiers comptes (voir la section « Créer les premiers comptes »). vaultwarden_signups_allowed: false # Seules les adresses de ce domaine peuvent s'inscrire quand les inscriptions sont ouvertes. vaultwarden_signups_domain: "{{ client_domain }}" # Panneau d'administration (/admin) : désactivé par défaut. Il donne accès à tous les comptes. vaultwarden_admin_enabled: false # Jeton du panneau d'administration, stocké dans le Vault sous forme de hachage Argon2. vaultwarden_admin_token: "{{ vault_vaultwarden_admin_token }}" # Réseau Docker partagé avec Traefik. vaultwarden_network: "{{ docker_proxy_network }}" Le fichier Docker Compose # roles/vaultwarden/templates/docker-compose.yml.j2 # Fichier géré par Ansible : toute modification manuelle sera écrasée. # Il peut contenir un jeton d'administration : accès réservé à root. services: vaultwarden: image: vaultwarden/server:{{ vaultwarden_version }} container_name: {{ client_name }}_vaultwarden restart: unless-stopped environment: # Adresse publique : utilisée dans les liens des e-mails et par les extensions. DOMAIN: "https://{{ vaultwarden_host }}" SIGNUPS_ALLOWED: "{{ vaultwarden_signups_allowed | lower }}" SIGNUPS_DOMAINS_WHITELIST: "{{ vaultwarden_signups_domain }}" # Les comptes existants peuvent inviter d'autres personnes (organisations). INVITATIONS_ALLOWED: "true" # Journal dans un fichier : c'est lui que lit Fail2ban. LOG_FILE: /data/vaultwarden.log LOG_LEVEL: warn TZ: Europe/Paris {% if vaultwarden_admin_enabled %} # Un hachage Argon2 contient des "$", qui doivent être doublés dans un fichier Compose. ADMIN_TOKEN: '{{ vaultwarden_admin_token | replace("$", "$$") }}' {% endif %} volumes: - {{ vaultwarden_dir }}/data:/data networks: - {{ vaultwarden_network }} labels: - "traefik.enable=true" - "traefik.docker.network={{ vaultwarden_network }}" - "traefik.http.routers.vaultwarden-{{ client_name }}.rule=Host(`{{ vaultwarden_host }}`)" - "traefik.http.routers.vaultwarden-{{ client_name }}.entrypoints=websecure" # Port d'écoute de Vaultwarden dans le conteneur. - "traefik.http.services.vaultwarden-{{ client_name }}.loadbalancer.server.port=80" networks: {{ vaultwarden_network }}: external: true Vaultwarden lit l'adresse du visiteur dans l'en-tête X-Real-IP, que Traefik renseigne. Les échecs de connexion de son journal portent donc la vraie adresse du visiteur, ce qui permet à Fail2ban de la bloquer. Déploiement # roles/vaultwarden/tasks/main.yml - name: Créer les dossiers de Vaultwarden ansible.builtin.file: path: "{{ item }}" state: directory mode: "0755" loop: - "{{ vaultwarden_dir }}" - "{{ vaultwarden_dir }}/data" - name: Déposer le fichier Docker Compose de Vaultwarden ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ vaultwarden_dir }}/docker-compose.yml" # Le fichier peut contenir un jeton : root uniquement. mode: "0600" no_log: true - name: Démarrer Vaultwarden community.docker.docker_compose_v2: project_src: "{{ vaultwarden_dir }}" state: present - name: Attendre que Vaultwarden réponde ansible.builtin.command: argv: - curl - -fsS - -k - --resolve - "{{ vaultwarden_host }}:443:127.0.0.1" - "https://{{ vaultwarden_host }}/alive" register: vaultwarden_alive until: vaultwarden_alive.rc == 0 retries: 30 delay: 5 changed_when: false Créer les premiers comptes Les inscriptions sont fermées par défaut : personne ne peut créer de compte sur l'adresse publique. Pour intégrer un client, le support ouvre les inscriptions le temps de créer les premiers comptes, puis les referme. # 1. Ouvrir les inscriptions pour ce client, pour les adresses de son domaine seulement. ansible-playbook site.yml --limit client-alpha --tags vaultwarden -e vaultwarden_signups_allowed=true # 2. Le client crée son compte sur https://vault.penhoat.example (mot de passe principal choisi par lui). # 3. Refermer les inscriptions : relancer sans la variable. ansible-playbook site.yml --limit client-alpha --tags vaultwarden Les comptes suivants sont créés par invitation depuis une organisation, sans rouvrir les inscriptions. Vérification # Sur la VM : le conteneur est démarré. docker ps --filter name=vaultwarden --format '{{.Names}}\t{{.Status}}' # L'adresse publique répond : doit afficher une date ISO. curl -sS https://vault.penhoat.example/alive # Le fichier de journal existe (créé au premier événement). ls -l /opt/nebuleuse/vaultwarden/data/vaultwarden.log Dans le navigateur, https://vault.penhoat.example affiche la page de connexion. Si les inscriptions sont fermées, le bouton « Créer un compte » est absent. Pièges Inscriptions laissées ouvertes : une instance publique avec les inscriptions ouvertes accepte n'importe quelle adresse du domaine autorisé, sans vérification. La variable vaultwarden_signups_allowed doit revenir à false dès les comptes créés. Jeton d'administration en clair : le jeton doit être stocké sous forme de hachage Argon2 dans le Vault, pas en clair. Dans le fichier Compose, chaque $ doit être doublé, ce que fait le modèle avec replace. Panneau d'administration exposé : il donne accès à tous les comptes. Il reste désactivé tant que vaultwarden_admin_enabled n'est pas passé à true, et ne doit l'être que le temps d'une intervention. Mot de passe principal perdu : le serveur ne peut ni le retrouver ni le réinitialiser, car il ne le connaît pas. Sans lui, le coffre est perdu. Prévenir le client à l'ouverture de son compte. Extensions qui n'atteignent pas le serveur : l'adresse du serveur doit être saisie en entier, https://vault.penhoat.example, et le certificat doit être reconnu. Les certificats de test de Let's Encrypt sont refusés par les applications. Journal absent pour Fail2ban : sans LOG_FILE, Vaultwarden écrit sur la sortie standard et le filtre ne trouve rien. Le chemin hôte du journal est /opt/nebuleuse/vaultwarden/data/vaultwarden.log. Sauvegarde : tout le contenu est dans le dossier data. Une copie du dossier à froid, ou une copie de la base avec sqlite3 ... ".backup", suffit. Une copie à chaud du seul fichier de base peut être corrompue. Page suivante : Fail2ban. Fail2ban En bref Niveau : avancé Durée : 45 minutes Prérequis : Traefik et au moins une application en place Niveau de test : reconstitué Fail2ban, c'est quoi ? Fail2ban lit des fichiers de journaux, repère les adresses qui multiplient les échecs (mots de passe erronés, pages interdites) et les bloque quelques instants dans le pare-feu. Il ne remplace pas les mots de passe solides : il réduit le bruit des attaques automatiques et ralentit les tentatives de devinette. Élément Rôle Filtre ( filter.d) Expression régulière qui reconnaît un échec dans une ligne de journal Jail ( jail.d) Associe un filtre, un fichier de journal, un seuil et une durée de blocage Action de blocage Ajoute une règle dans le pare-feu pour l'adresse fautive Chaîne DOCKER-USER Point où les règles s'appliquent aux conteneurs Visiteur fautif → journal Traefik ou Vaultwarden → filtre → jail → règle dans DOCKER-USER → adresse bloquée Concrètement, le rôle installe Fail2ban sur la VM et y dépose deux filtres et deux jails : un pour les erreurs d'authentification vues par Traefik, un pour les échecs de connexion à Vaultwarden. Les jails portent le préfixe nebuleuse-. Les principes de Fail2ban (filtres, jails, commandes de base) sont détaillés dans le livre Fail2Ban de la base de connaissance. Cette page ne traite que l'intégration au socle Nébuleuse. Prérequis Le socle est en place : Docker a créé la chaîne DOCKER-USER. Traefik écrit son journal d'accès en JSON dans /var/log/traefik/access.log (page Traefik). Pour la jail Vaultwarden : le journal /opt/nebuleuse/vaultwarden/data/vaultwarden.log existe (page Vaultwarden). Pourquoi DOCKER-USER ? Docker publie les ports des conteneurs en les redirigeant avant le pare-feu de la machine. Une règle de blocage placée dans la chaîne habituelle ( INPUT) ne verrait jamais le trafic destiné à Traefik. La chaîne DOCKER-USER, elle, est traversée avant les règles de Docker, et Docker n'y touche jamais : c'est le bon endroit pour bloquer une adresse. Arborescence du rôle roles/fail2ban/ ├── defaults/main.yml ├── handlers/main.yml ├── tasks/main.yml └── templates/ ├── filter-nebuleuse-traefik.conf.j2 ├── filter-nebuleuse-vaultwarden.conf.j2 └── jail-nebuleuse.conf.j2 Variables # roles/fail2ban/defaults/main.yml # Adresses jamais bloquées : la VM elle-même et le réseau d'administration. # Ajouter ici l'adresse fixe du client si elle est connue. fail2ban_ignore_ips: - 127.0.0.1/8 - "{{ admin_network_cidr }}" # Jail Traefik : trop d'erreurs 401 ou 403 en peu de temps. fail2ban_traefik_maxretry: 30 fail2ban_traefik_findtime: 10m fail2ban_traefik_bantime: 1h # Jail Vaultwarden : échecs de connexion. fail2ban_vaultwarden_maxretry: 5 fail2ban_vaultwarden_findtime: 10m fail2ban_vaultwarden_bantime: 1h # Chaîne du pare-feu où les blocages sont ajoutés. fail2ban_chain: DOCKER-USER Les filtres Le journal d'accès de Traefik contient une ligne JSON par requête. Les champs sont triés par ordre alphabétique : ClientHost (l'adresse du visiteur) précède donc toujours DownstreamStatus (le code de réponse). # roles/fail2ban/templates/filter-nebuleuse-traefik.conf.j2 # Fichier géré par Ansible. [Definition] # Une ligne est un échec quand la réponse est 401 (non authentifié) ou 403 (interdit). failregex = ^.*"ClientHost":"".*"DownstreamStatus":(?:401|403).*$ # Format de la date dans le journal JSON. datepattern = "time":"%%Y-%%m-%%dT%%H:%%M:%%S ignoreregex = # roles/fail2ban/templates/filter-nebuleuse-vaultwarden.conf.j2 # Fichier géré par Ansible. [Definition] # Mot de passe erroné, ou jeton d'administration erroné. désigne l'adresse du visiteur. failregex = ^.*Username or password is incorrect\. Try again\. IP: \. Username:.*$ ^.*Invalid admin token\. IP: .*$ ignoreregex = Les jails # roles/fail2ban/templates/jail-nebuleuse.conf.j2 # Fichier géré par Ansible. [DEFAULT] ignoreip = {{ fail2ban_ignore_ips | join(' ') }} # Blocage par iptables, dans la chaîne traversée par le trafic des conteneurs. banaction = iptables-multiport chain = {{ fail2ban_chain }} port = http,https [nebuleuse-traefik] enabled = true filter = nebuleuse-traefik logpath = /var/log/traefik/access.log maxretry = {{ fail2ban_traefik_maxretry }} findtime = {{ fail2ban_traefik_findtime }} bantime = {{ fail2ban_traefik_bantime }} {% if 'vaultwarden' in nebuleuse_apps %} [nebuleuse-vaultwarden] enabled = true filter = nebuleuse-vaultwarden logpath = /opt/nebuleuse/vaultwarden/data/vaultwarden.log maxretry = {{ fail2ban_vaultwarden_maxretry }} findtime = {{ fail2ban_vaultwarden_findtime }} bantime = {{ fail2ban_vaultwarden_bantime }} {% endif %} Tâches # roles/fail2ban/tasks/main.yml - name: Installer Fail2ban et iptables ansible.builtin.apt: name: - fail2ban - iptables # absent d'une Debian 12 minimale state: present - name: Déposer les filtres ansible.builtin.template: src: "filter-{{ item }}.conf.j2" dest: "/etc/fail2ban/filter.d/{{ item }}.conf" mode: "0644" loop: - nebuleuse-traefik - nebuleuse-vaultwarden notify: Recharger fail2ban - name: Déposer les jails ansible.builtin.template: src: jail-nebuleuse.conf.j2 dest: /etc/fail2ban/jail.d/nebuleuse.conf mode: "0644" notify: Recharger fail2ban - name: Activer et démarrer Fail2ban ansible.builtin.service: name: fail2ban state: started enabled: true # roles/fail2ban/handlers/main.yml - name: Recharger fail2ban ansible.builtin.service: name: fail2ban state: restarted Vérification # Contrôle que le filtre reconnaît bien des lignes du journal (aucune erreur attendue). sudo fail2ban-regex /var/log/traefik/access.log /etc/fail2ban/filter.d/nebuleuse-traefik.conf # Les jails sont actives. sudo fail2ban-client status # Détail d'une jail : compteurs d'échecs et adresses bloquées. sudo fail2ban-client status nebuleuse-traefik # La chaîne DOCKER-USER contient les règles de Fail2ban. sudo iptables -L DOCKER-USER -n Pour provoquer un blocage, depuis une machine qui n'est pas dans ignoreip, envoyer plus de requêtes refusées que le seuil : # 35 requêtes avec un mauvais mot de passe : la réponse est 401 à chaque fois. for i in $(seq 35); do curl -s -o /dev/null -u test:test https://cloud.penhoat.example/remote.php/dav done # L'adresse de la machine de test doit apparaître dans "Banned IP list". sudo fail2ban-client status nebuleuse-traefik # Débloquer l'adresse après le test. sudo fail2ban-client set nebuleuse-traefik unbanip Pièges Fichier de journal absent : une jail dont le fichier n'existe pas ne démarre pas. D'où la place de Fail2ban en dernier dans site.yml. Blocage sans effet : une action qui insère les règles dans INPUT ne voit pas le trafic des conteneurs. L'action doit viser la chaîne DOCKER-USER. Adresse de Traefik bloquée à la place du visiteur : si l'adresse lue dans le journal est celle du proxy et non celle du visiteur, toute la VM est coupée. Le champ ClientHost doit contenir l'adresse réelle ; l'adresse du réseau d'administration est exclue par ignoreip. Clients de synchronisation bloqués : une application de synchronisation (Nextcloud, par exemple) provoque normalement quelques réponses 401. Le seuil de 30 en dix minutes laisse de la marge. Un seuil trop bas bloque un utilisateur légitime. Blocages perdus après un redémarrage de Docker : Docker recrée la chaîne DOCKER-USER et efface les règles. Fail2ban doit être redémarré après Docker pour réinstaller les blocages en cours. Expression régulière qui ne reconnaît rien : fail2ban-regex est l'outil de contrôle. Si la date n'est pas reconnue, la ligne est ignorée, même si l'expression est bonne. Se bloquer soi-même : en cas de test depuis le poste d'administration, l'adresse peut être bloquée à son tour. fail2ban-client set unbanip la libère. Page suivante : Déploiement d'un client.