web frontal = reverse proxy + WAF ; le dorsal porte les sites

Le frontal derive ses vhosts des expositions publiques (meme derivation que l edge),
filtre par ModSecurity v3 + OWASP CRS (DetectionOnly d abord, eprouve), refuse les noms
inconnus, et ne sert plus rien lui-meme. Les sites statiques passent au dorsal, relaye
par l edge et par le frontal. Sondes : frontal (nouvelle), sites-servis suit le dorsal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 16:48:42 -04:00
parent 2903d75831
commit 3fe7e3cea3
21 changed files with 401 additions and 166 deletions

View file

@ -122,12 +122,11 @@
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. | | `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. | | `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. | | `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
| `serveur_web_dorsal` | ingress | 80 | tcp | edge | clair | Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge). | | `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. | | `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
| `serveur_web_frontal` | ingress | 80 | tcp | edge | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). | | `serveur_web_frontal` | ingress | 80 | tcp | externe | clair | HTTP public, relaye a travers le WAF — et le defi HTTP d'ACME ; renverra vers HTTPS quand le certificat sera la. |
| `serveur_web_frontal` | ingress | 80 | tcp | externe | clair | HTTP public — le premier contact d'un navigateur, et le defi HTTP d'ACME ; renvoie vers HTTPS. | | `serveur_web_frontal` | ingress | 443 | tcp | externe | tls-requis | HTTPS public — les sites et services que le locataire publie sur l'Internet (attend son certificat Let's Encrypt). |
| `serveur_web_frontal` | ingress | 443 | tcp | externe | tls-requis | HTTPS public — les sites et services que le locataire publie sur l'Internet. | | `serveur_web_frontal` | egress | 80 | tcp | serveur_web_dorsal | clair | Relais vers le web dorsal, qui porte les sites et les applications publiques. |
| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. |
## Synthèse chiffrement ## Synthèse chiffrement
@ -135,6 +134,6 @@
- **n-a** : 8 flux - **n-a** : 8 flux
- **ssh** : 8 flux - **ssh** : 8 flux
- **starttls** : 6 flux - **starttls** : 6 flux
- **tls** : 11 flux - **tls** : 10 flux
- **tls-cible** : 2 flux - **tls-cible** : 2 flux
- **tls-requis** : 36 flux - **tls-requis** : 36 flux

View file

@ -1,9 +1,13 @@
# serveur_web_dorsal # serveur_web_dorsal
Hébergement de **webapps dynamiques** (runtime + service systemd), derrière l'edge. **Le serveur qui porte les sites et les applications web du locataire** : webapps
Deuxième brique de la **plateforme webapp** de Set-OPS : **100 % natif** — venv/paquets dynamiques (runtime + service systemd) et, depuis le 2026-09-29, sites **statiques**
+ systemd + nginx, **zéro conteneur**. Codifié depuis le spike du 2026-07-05 (repris du web frontal). **100 % natif** — venv/paquets + systemd + nginx, **zéro
(monregistraire : FastAPI + uvicorn + SQLite). conteneur**. Codifié depuis le spike du 2026-07-05 (monregistraire : FastAPI + uvicorn +
SQLite ; site Alliance Boréale).
Il est relayé par **deux portes** : l'**edge** pour les noms internes (l'administration),
le **web frontal** (reverse proxy + WAF) pour les noms publics (l'Internet).
## Patron ## Patron
Pour chaque app déclarée dans `serveur_web_dorsal_apps` : Pour chaque app déclarée dans `serveur_web_dorsal_apps` :
@ -30,6 +34,21 @@ serveur_web_dorsal_apps:
``` ```
Et une app `groupe: serveur_web_dorsal, hote: <noeud>, port: 80, expose: [registraire.<domaine>]`. Et une app `groupe: serveur_web_dorsal, hote: <noeud>, port: 80, expose: [registraire.<domaine>]`.
## Sites statiques
Pour chaque site déclaré dans `serveur_web_dorsal_sites` : `git clone`/`pull` →
`/srv/web/<nom>`, puis un vhost nginx servant `<sous_dossier>` (ex. `public/`), avec
en-têtes de sécurité + CSP (défaut : `self` only, aucun tiers), gzip, cache.
```yaml
serveur_web_dorsal_sites:
- nom: alliance
server_name: "alliance-boreale.chezlepro.ca" # le FQDN que le plan expose
depot: "https://forge.{{ domaine_interne }}/alliance/site-alliance-boreale.git"
branche: main
sous_dossier: public
```
Et dans `applications.yml` : `groupe: serveur_web_dorsal, hote: <noeud>, port: 80,
expose: [<FQDN>]`. Sonde `sites-servis` : un GET par `server_name`.
## BD ## BD
Les apps à état **local** (SQLite) : `data_dir` persistant. Les apps à **BD réseau** Les apps à état **local** (SQLite) : `data_dir` persistant. Les apps à **BD réseau**
(PostgreSQL) : déclarer la base dans `bases-donnees.yml` et passer le DSN via `env` (PostgreSQL) : déclarer la base dans `bases-donnees.yml` et passer le DSN via `env`
@ -41,4 +60,5 @@ paquets de runtime (le patron systemd + nginx + git reste identique).
## Validation ## Validation
`ansible-lint` profil production + `--syntax-check`. Éprouvé sur web-frontal-01 `ansible-lint` profil production + `--syntax-check`. Éprouvé sur web-frontal-01
(monregistraire servi via l'edge, HTTPS step-ca, API fonctionnelle). (monregistraire servi via l'edge, HTTPS step-ca, API fonctionnelle) — avant que le
frontal ne devienne le reverse proxy public.

View file

@ -29,3 +29,30 @@ serveur_web_dorsal_apps: []
# `/api/...` est parfaitement saine sans rien servir a `/`. Ce qui reste exclu : les 5xx # `/api/...` est parfaitement saine sans rien servir a `/`. Ce qui reste exclu : les 5xx
# et l'absence de reponse, c'est-a-dire l'app coincee ou morte. # et l'absence de reponse, c'est-a-dire l'app coincee ou morte.
serveur_web_dorsal_sonde_codes: "HTTP/1.1 200,HTTP/1.1 302,HTTP/1.1 401,HTTP/1.1 403,HTTP/1.1 404" serveur_web_dorsal_sonde_codes: "HTTP/1.1 200,HTTP/1.1 302,HTTP/1.1 401,HTTP/1.1 403,HTTP/1.1 404"
# --- LES SITES STATIQUES (2026-09-29, repris du web frontal) -------------------------------
#
# LE DORSAL PORTE LES SITES ; LE FRONTAL NE SERT RIEN LUI-MEME. Le web frontal est le reverse
# proxy et le WAF devant les services web publics du locataire ; ce qui est servi — les
# applications comme les sites statiques — vit ici. Un site statique : 100 % natif, nginx,
# le contenu cloné d'un depot git souverain.
serveur_web_dorsal_sites_racine: "/srv/web"
# En-tetes de securite par defaut (sobriete + vie privee : tout local, pas de tiers).
serveur_web_dorsal_csp_defaut: >-
default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self';
object-src 'none'; base-uri 'self'; frame-ancestors 'self'
# Liste des sites statiques servis par ce noeud. Chaque entree :
# nom identifiant court (dossier + vhost)
# server_name FQDN servi — celui que le plan EXPOSE (applications.yml -> edge ou frontal)
# depot URL du depot git du site (souverain, ex. Forgejo)
# branche branche a servir (defaut: main)
# sous_dossier sous-dossier du depot a servir comme racine (ex: public)
# csp Content-Security-Policy specifique (optionnel)
serveur_web_dorsal_sites: []
# LE PORT DES VHOSTS LOCAUX DES SITES. En clair sur cette machine : c'est le frontal (ou
# l'edge, pour un nom interne) qui les recoit et les relaie. La sonde mesure ce qu'il ira
# chercher — si le frontal est vert et le site muet, la panne est ici.
serveur_web_dorsal_sites_port: 80

View file

@ -2,12 +2,14 @@
# Flux reseau du dorsal web (propriete du role). Voir docs/flux-conception.md. # Flux reseau du dorsal web (propriete du role). Voir docs/flux-conception.md.
# L'app ecoute en LOCALHOST (pas de flux inter-noeud) ; le nginx local la fronte. # L'app ecoute en LOCALHOST (pas de flux inter-noeud) ; le nginx local la fronte.
flux: flux:
# LE DORSAL EST RELAYE PAR DEUX PORTES (2026-09-29) : l'edge pour les noms internes
# (l'administration), le web frontal pour les noms publics (l'Internet, a travers le WAF).
- sens: ingress - sens: ingress
port: 80 port: 80
protocole: tcp protocole: tcp
pair: edge pair: [edge, serveur_web_frontal]
chiffrement: clair chiffrement: clair
raison: "Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge)." raison: "Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics)."
- sens: egress - sens: egress
port: 443 port: 443
protocole: tcp protocole: tcp

View file

@ -28,3 +28,10 @@ sondes:
jamais en « failed » — elle se coince : le processus vit, systemd la dit active, le jamais en « failed » — elle se coince : le processus vit, systemd la dit active, le
port est ouvert, et plus une requete n''aboutit. Le rapport d''unites en echec ne port est ouvert, et plus une requete n''aboutit. Le rapport d''unites en echec ne
verra jamais ca.' verra jamais ca.'
# SUR LE DORSAL DEPUIS LE 2026-09-29 : c'est lui qui porte les sites ; le frontal les relaie.
- nom: sites-servis
ttl: 5400
raison: 'Chacun des sites statiques declares se sert-il encore ? Le contenu vient
d''un depot git : une branche renommee, un sous-dossier deplace ou un clone vide
laissent nginx parfaitement vert avec un site perime, vide, ou en 404. Seul un GET
sur le `server_name` de chaque site le dit.'

View file

@ -107,6 +107,49 @@
label: "{{ item.nom }}" label: "{{ item.nom }}"
when: not ansible_check_mode when: not ansible_check_mode
# LES SITES STATIQUES (2026-09-29) — repris du web frontal, qui ne sert plus rien lui-meme.
- name: Creer la racine des sites statiques
ansible.builtin.file:
path: "{{ serveur_web_dorsal_sites_racine }}"
state: directory
owner: root
group: root
mode: "0755"
when: serveur_web_dorsal_sites | length > 0
- name: Cloner / mettre a jour le depot de chaque site (clone superficiel, contenu courant)
ansible.builtin.git:
repo: "{{ item.depot }}"
dest: "{{ serveur_web_dorsal_sites_racine }}/{{ item.nom }}"
version: "{{ item.branche | default('main') }}"
depth: 1
force: true
loop: "{{ serveur_web_dorsal_sites }}"
loop_control:
label: "{{ item.nom }}"
- name: Deployer le vhost de chaque site
ansible.builtin.template:
src: site.conf.j2
dest: "/etc/nginx/sites-available/site-{{ item.nom }}"
owner: root
group: root
mode: "0644"
loop: "{{ serveur_web_dorsal_sites }}"
loop_control:
label: "{{ item.nom }}"
notify: Recharger nginx serveur_web_dorsal
- name: Activer le vhost de chaque site
ansible.builtin.file:
src: "/etc/nginx/sites-available/site-{{ item.nom }}"
dest: "/etc/nginx/sites-enabled/site-{{ item.nom }}"
state: link
loop: "{{ serveur_web_dorsal_sites }}"
loop_control:
label: "{{ item.nom }}"
notify: Recharger nginx serveur_web_dorsal
# LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. `client_sante` la fait tourner et # LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. `client_sante` la fait tourner et
# pousse le resultat ; les greffons standard, eux, sont poses par le porteur. # pousse le resultat ; les greffons standard, eux, sont poses par le porteur.
# #
@ -127,3 +170,14 @@
owner: root owner: root
group: root group: root
mode: "0750" mode: "0750"
# LE CONTENU D'UN SITE N'EST PAS DANS CE ROLE : il arrive par git depuis une forge. Une
# branche renommee, un sous-dossier deplace ou un clone vide laissent nginx parfaitement
# vert. Seul un GET par `server_name` le dit.
- name: Deposer la sonde « sites-servis »
ansible.builtin.template:
src: sonde-sites-servis.sh.j2
dest: /usr/local/lib/setops/sondes/sites-servis.sh
owner: root
group: root
mode: "0750"

View file

@ -1,15 +1,15 @@
# Site statique {{ item.nom }} — genere par Set-OPS (role serveur_web_frontal). Ne pas editer. # Site statique {{ item.nom }} — genere par Set-OPS (role serveur_web_dorsal). Ne pas editer.
# Sert derriere l'edge (TLS termine a l'edge). Racine = depot git / sous-dossier. # Sert derriere l'edge (TLS termine a l'edge). Racine = depot git / sous-dossier.
server { server {
listen 80; listen 80;
server_name {{ item.server_name }}; server_name {{ item.server_name }};
root {{ serveur_web_frontal_racine_base }}/{{ item.nom }}{{ ('/' ~ item.sous_dossier) if (item.sous_dossier | default('')) else '' }}; root {{ serveur_web_dorsal_sites_racine }}/{{ item.nom }}{{ ('/' ~ item.sous_dossier) if (item.sous_dossier | default('')) else '' }};
index index.html; index index.html;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "{{ (item.csp | default(serveur_web_frontal_csp_defaut)) | trim }}" always; add_header Content-Security-Policy "{{ (item.csp | default(serveur_web_dorsal_csp_defaut)) | trim }}" always;
gzip on; gzip on;
gzip_types text/css application/javascript text/html image/svg+xml; gzip_types text/css application/javascript text/html image/svg+xml;

View file

@ -4,7 +4,7 @@
# la sequence `{#`, que Jinja lirait comme un debut de commentaire. Le shell en a # la sequence `{#`, que Jinja lirait comme un debut de commentaire. Le shell en a
# besoin ; c'est donc Jinja qui cede. Meme geste que `client_sante/setops-sante.sh.j2` # besoin ; c'est donc Jinja qui cede. Meme geste que `client_sante/setops-sante.sh.j2`
# et `serveur_nginx/sonde-edge.sh.j2`.) # et `serveur_nginx/sonde-edge.sh.j2`.)
# GENERE par Set-OPS (role serveur_web_frontal). Ne pas editer a la main. # GENERE par Set-OPS (role serveur_web_dorsal). Ne pas editer a la main.
# #
# SONDE « sites-servis » — chacun des sites declares se sert-il encore ? # SONDE « sites-servis » — chacun des sites declares se sert-il encore ?
# #
@ -18,9 +18,9 @@
# mesurerait le serveur par defaut, c'est-a-dire n'importe quoi d'autre. # mesurerait le serveur par defaut, c'est-a-dire n'importe quoi d'autre.
set -uo pipefail set -uo pipefail
PORT={{ serveur_web_frontal_sonde_port }} PORT={{ serveur_web_dorsal_sites_port }}
noms=({% for s in serveur_web_frontal_sites | default([]) %}'{{ s.server_name }}' {% endfor %}) noms=({% for s in serveur_web_dorsal_sites | default([]) %}'{{ s.server_name }}' {% endfor %})
etiquettes=({% for s in serveur_web_frontal_sites | default([]) %}'{{ s.nom }}' {% endfor %}) etiquettes=({% for s in serveur_web_dorsal_sites | default([]) %}'{{ s.nom }}' {% endfor %})
if (( ${#noms[@]} == 0 )); then if (( ${#noms[@]} == 0 )); then
# PAS UN VERT MUET. Un hebergeur sans site declare est un etat legitime — le dire vaut # PAS UN VERT MUET. Un hebergeur sans site declare est un etat legitime — le dire vaut

View file

@ -1,37 +1,34 @@
# serveur_web_frontal # serveur_web_frontal
Hébergement de **sites web statiques** (nginx), derrière l'edge. Première brique de **Reverse proxy et WAF devant les services web PUBLICS du locataire.** C'est la porte
la **plateforme webapp** de Set-OPS : **100 % natif** — pas de conteneur, pas de publique : la frontière redirige l'adresse publique que le site attribue au locataire
runtime, pas de base de données. Le contenu vient d'un **dépôt git souverain** (80/443) vers lui ; il filtre et relaie. **Il ne sert rien lui-même** — les sites et les
(Forgejo). Codifié depuis le spike du 2026-07-05 (site Alliance Boréale). applications vivent sur le web **dorsal** (`serveur_web_dorsal`).
L'**edge** (`serveur_nginx`) est l'autre porte : les services **internes** (`*.internal`),
pour la zone d'administration. Les deux ne se remplacent pas.
## Ce que le rôle fait ## Ce que le rôle fait
Pour chaque site déclaré dans `serveur_web_frontal_sites` : 1. nginx + **ModSecurity v3** (`libnginx-mod-http-modsecurity`) + **OWASP CRS**
1. `git clone`/`pull` du dépôt → `/srv/web/<nom>` ; (`modsecurity-crs`), tous paquets Debian, tout libre ;
2. vhost nginx servant `<sous_dossier>` (ex. `public/`), avec en-têtes de sécurité 2. compose le WAF (`/etc/nginx/setops-waf/main.conf`) : moteur, mode, CRS et ses
+ CSP (défaut : `self` only, aucun tiers, aucun pistage), gzip, cache, `try_files`. exclusions — l'inclusion livrée par Debian ne charge que le moteur ;
3. un vhost par **exposition publique** : les FQDN que `applications.yml` expose dans un
domaine dont `plan/domaines.yml` désigne ce frontal (`edge: serveur_web_frontal`) —
la même dérivation que l'edge ;
4. un serveur par défaut qui **refuse tout nom inconnu** (`return 444`) ;
5. la sonde `frontal` : nginx actif et configuration valide, WAF chargé et CRS présent,
chaque exposition publique servie à travers lui.
Exposé via `applications.yml` (champ `expose`) → l'edge proxie en HTTPS (TLS step-ca). ## Le WAF
Les SAN du cert edge et le plancher /etc/hosts sont **auto-dérivés du plan**. `serveur_web_frontal_waf_mode: DetectionOnly` d'abord : le CRS journalise ce qu'il aurait
bloqué (`/var/log/nginx/modsec_audit.log`) sans rien refuser. On lit, on ajuste les
exclusions pour les applications réelles, **puis** `On`. Éprouvé le 2026-09-29 (Debian 13,
nginx 1.26, CRS 3.3.7) : une injection SQL est journalisée (942100) en détection, refusée
(403) en blocage ; une requête saine passe dans les deux cas.
## Déclaration (group_vars du groupe serveur_web_frontal) ## TLS
```yaml En clair (80) tant que le certificat **Let's Encrypt** des noms publics n'est pas en place.
serveur_web_frontal_sites:
- nom: alliance
server_name: "alliance-boreale.{{ domaine_interne }}"
depot: "https://forge.{{ domaine_interne }}/alliance/site-alliance-boreale.git"
branche: main
sous_dossier: public
# csp: "..." # optionnel, sinon serveur_web_frontal_csp_defaut
```
Et dans `applications.yml` : une app `groupe: serveur_web_frontal, hote: <noeud>, port: 80,
expose: [alliance-boreale.<domaine>]`.
## Portée
Statique seulement (le **frontal**). Les apps **dynamiques** (runtime + service
systemd + BD via resoudre_base) relèveront d'un rôle *dorsal* à venir — même éthos :
natif, systemd, zéro conteneur.
## Validation ## Validation
`ansible-lint` profil production + `--syntax-check`. Éprouvé sur web-frontal-01 `ansible-lint` profil production + `--syntax-check` ; sonde `frontal`.
(site servi via l'edge, HTTPS step-ca vérifié, CSP en place).

View file

@ -1,29 +1,40 @@
--- ---
# ========================================================================= # =========================================================================
# serveur_web_frontal — hebergement de sites web STATIQUES (nginx), derriere l'edge. # serveur_web_frontal — REVERSE PROXY + WAF devant les services web PUBLICS du locataire.
# Premiere brique de la « plateforme webapp » : 100% natif (pas de conteneur, #
# pas de runtime, pas de BD). Le contenu vient d'un depot git souverain. # L'EDGE ET LE FRONTAL SONT DEUX PORTES (2026-09-29). L'edge (`serveur_nginx`) expose les
# Les apps DYNAMIQUES (service systemd + BD) viendront via un r_le dorsal. # services INTERNES (`*.internal`) a la zone d'administration. Le frontal expose ce que le
# Codifie depuis le spike du 2026-07-05 (site Alliance Boreale). # locataire publie sur l'Internet : la frontiere redirige l'adresse publique du locataire
# (80/443) vers lui, il filtre (WAF) et relaie vers le service — le plus souvent le web
# DORSAL, qui porte les sites et les applications. Le frontal ne sert rien lui-meme.
#
# SES VHOSTS NE SE DECLARENT PAS ICI : ils derivent des expositions dont le domaine PUBLIC
# le designe (`plan/domaines.yml`, champ `edge: serveur_web_frontal`) — la meme derivation
# que l'edge (`expositions_des_applications`), filtree sur ce groupe.
# ========================================================================= # =========================================================================
serveur_web_frontal_racine_base: "/srv/web" serveur_web_frontal_paquets:
- nginx
- libnginx-mod-http-modsecurity # le connecteur nginx de ModSecurity v3
- modsecurity-crs # OWASP Core Rule Set
# En-tetes de securite par defaut (sobriete + vie privee : tout local, pas de tiers). serveur_web_frontal_groupe: "serveur_web_frontal"
serveur_web_frontal_csp_defaut: >- serveur_web_frontal_publier_expositions: true
default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self';
object-src 'none'; base-uri 'self'; frame-ancestors 'self'
# Liste des sites statiques servis par ce noeud. Chaque entree : # --- LE WAF --------------------------------------------------------------------------
# nom identifiant court (dossier + vhost) #
# server_name FQDN servi (doit etre expose via applications.yml -> edge) # `DetectionOnly` D'ABORD : le CRS journalise ce qu'il aurait bloque
# depot URL du depot git du site (souverain, ex. Forgejo) # (`/var/log/nginx/modsec_audit.log`) sans rien refuser. On lit, on ajuste les exclusions
# branche branche a servir (defaut: main) # pour les applications reelles, PUIS on passe a `On`. Un WAF bloquant pose a l'aveugle
# sous_dossier sous-dossier du depot a servir comme racine (ex: public) # casse les formulaires legitimes, et on finit par le desactiver.
# csp Content-Security-Policy specifique (optionnel) #
serveur_web_frontal_sites: [] # Eprouve le 2026-09-29 sur le vrai systeme (Debian 13, nginx 1.26, CRS 3.3.7) : une
# injection SQL en requete rend 200 et est journalisee (942100, 949110) en detection ;
# 403 en blocage ; une requete saine passe dans les deux cas.
serveur_web_frontal_waf_mode: "DetectionOnly" # ou "On"
serveur_web_frontal_waf_dir: "/etc/nginx/setops-waf"
# LE PORT DES VHOSTS LOCAUX. Les sites statiques ecoutent en clair sur cette machine ; # Taille maximale d'une requete relayee (televersements).
# c'est l'edge qui termine le TLS devant. La sonde mesure donc ce que l'edge ira chercher, serveur_web_frontal_taille_max_corps: "16m"
# pas ce que le visiteur voit — et c'est le bon endroit : si l'edge est vert et le site
# muet, la panne est ici. # Un amont declare sur un de ces ports se parle en TLS (comme a l'edge).
serveur_web_frontal_sonde_port: 80 serveur_web_frontal_ports_tls: [443, 8443]

View file

@ -1,31 +1,26 @@
--- ---
# Flux reseau du frontal web (propriete du role). Voir docs/flux-conception.md. # Flux reseau du frontal web (propriete du role). Voir docs/flux-conception.md.
#
# LE WEB FRONTAL EST LA PORTE PUBLIQUE DU LOCATAIRE (2026-09-29). L'edge expose les services
# INTERNES a l'administration ; ce qui est publie sur l'Internet l'est ici, par l'adresse
# publique que le site attribue au locataire (redirection a la frontiere). Il filtre (WAF)
# et relaie : il ne sert rien lui-meme, et ne recoit plus rien de l'edge.
flux: flux:
- sens: ingress
port: 80
protocole: tcp
pair: edge
chiffrement: clair
raison: "Contenu statique servi au navigateur via l'edge (TLS termine a l'edge)."
# LE WEB FRONTAL EST LA PORTE PUBLIQUE DU LOCATAIRE (2026-09-29). L'edge expose les services
# INTERNES a l'administration ; ce qui est publie sur l'Internet l'est ici, par l'adresse
# publique que le site attribue au locataire (redirection a la frontiere). Le 443 attend son
# TLS (Let's Encrypt) : la redirection est prete avant lui.
- sens: ingress - sens: ingress
port: 80 port: 80
protocole: tcp protocole: tcp
pair: externe pair: externe
chiffrement: clair chiffrement: clair
raison: "HTTP public — le premier contact d'un navigateur, et le defi HTTP d'ACME ; renvoie vers HTTPS." raison: "HTTP public, relaye a travers le WAF — et le defi HTTP d'ACME ; renverra vers HTTPS quand le certificat sera la."
- sens: ingress - sens: ingress
port: 443 port: 443
protocole: tcp protocole: tcp
pair: externe pair: externe
chiffrement: tls-requis chiffrement: tls-requis
raison: "HTTPS public — les sites et services que le locataire publie sur l'Internet." raison: "HTTPS public — les sites et services que le locataire publie sur l'Internet (attend son certificat Let's Encrypt)."
- sens: egress - sens: egress
port: 443 port: 80
protocole: tcp protocole: tcp
pair: flotte pair: serveur_web_dorsal
chiffrement: tls chiffrement: clair
raison: "git clone/pull du depot du site (Forgejo souverain) au deploiement." raison: "Relais vers le web dorsal, qui porte les sites et les applications publiques."

View file

@ -1,33 +1,14 @@
--- ---
# Supervision derivee du role. Voir docs/supervision-conception.md. # Supervision derivee du role. Voir docs/supervision-conception.md.
# #
# CE QUE `serveur_nginx` NE PEUT PAS DIRE. L'edge a sa sonde, et elle repond « nginx sert # LE FRONTAL EST LA PORTE PUBLIQUE : s'il tombe, tout ce que le locataire publie tombe avec
# encore ». Ici la question est autre : CHACUN des sites declares se sert-il vraiment ? # lui, pendant que les services derriere restent verts. Et un frontal peut tres bien
# # relayer SANS FILTRER — module WAF non charge, regles absentes — en restant « en ligne ».
# LA PANNE PROPRE A CE ROLE EST SILENCIEUSE, et elle vient du fait que le contenu n'est # `sites-servis` a suivi les sites sur le dorsal le 2026-09-29.
# pas dans le role : il arrive par `git clone`/`pull` depuis une forge. Trois facons de
# perdre un site sans que nginx bronche :
#
# 1. LE DEPOT A CHANGE DE BRANCHE OU DE NOM — le pull echoue, le contenu reste celui de
# la derniere fois qui a marche. Le site est en ligne, et vieux.
# 2. LE SOUS-DOSSIER SERVI A BOUGE (`public/` renomme en amont) — nginx rend 404 ou 403
# sur un repertoire qui existe mais ne contient plus d'index.
# 3. LE CLONE EST VIDE — un depot neuf, ou une reconstruction qui n'a rien ramene. Meme
# lecture que la forge du site le 2026-09-14 : tout repond, il n'y a rien dedans.
#
# Dans les trois cas l'edge est vert, le vhost existe, le port ecoute. Seul un GET sur le
# `server_name` du site dit la verite.
#
# UNE SEULE SONDE POUR TOUS LES SITES, parce qu'il n'y a qu'une CAUSE D'ACTION : aller
# voir cet hebergeur. Elle nomme celui qui manque — c'est ce dont on a besoin pour agir,
# et une sonde par site ferait N services a definir pour un seul geste.
#
# TTL de 5400 s pour un porteur qui passe aux 15 min : trois passages manques avant la
# peremption. Le silence alerte autant que l'echec.
sondes: sondes:
- nom: sites-servis - nom: frontal
ttl: 5400 ttl: 5400
raison: 'Chacun des sites statiques declares se sert-il encore ? Le contenu vient raison: 'Le reverse proxy public tient-il, son WAF (ModSecurity + OWASP CRS) est-il
d''un depot git : une branche renommee, un sous-dossier deplace ou un clone vide vraiment dans le chemin, et chaque exposition publique repond-elle a travers lui ?
laissent nginx parfaitement vert avec un site perime, vide, ou en 404. Seul un GET Un frontal qui relaie sans filtrer reste vert ; un frontal tombe rend tout le public
sur le `server_name` de chaque site le dit.' injoignable pendant que les services derriere restent sains.'

View file

@ -1,66 +1,103 @@
--- ---
- name: Installer nginx + git (git requis pour cloner les depots des sites) # serveur_web_frontal — reverse proxy + WAF des services web PUBLICS. Voir defaults/main.yml.
- name: Installer nginx, ModSecurity et l'OWASP CRS
ansible.builtin.apt: ansible.builtin.apt:
name: name: "{{ serveur_web_frontal_paquets }}"
- nginx
- git
state: present state: present
update_cache: true update_cache: true
cache_valid_time: 3600 cache_valid_time: 3600
- name: Retirer le vhost nginx par defaut - name: Retirer le vhost nginx par defaut de Debian
ansible.builtin.file: ansible.builtin.file:
path: /etc/nginx/sites-enabled/default path: /etc/nginx/sites-enabled/default
state: absent state: absent
notify: Recharger nginx serveur_web_frontal notify: Recharger nginx serveur_web_frontal
- name: Creer la racine des sites - name: Deployer le durcissement de base nginx
ansible.builtin.template:
src: durcissement.conf.j2
dest: /etc/nginx/conf.d/99-setops-frontal.conf
owner: root
group: root
mode: "0644"
notify: Recharger nginx serveur_web_frontal
- name: Creer le repertoire du WAF
ansible.builtin.file: ansible.builtin.file:
path: "{{ serveur_web_frontal_racine_base }}" path: "{{ serveur_web_frontal_waf_dir }}"
state: directory state: directory
owner: root owner: root
group: root group: root
mode: "0755" mode: "0755"
- name: Cloner / mettre a jour le depot de chaque site (clone superficiel, contenu courant) - name: Deployer la composition du WAF (ModSecurity + OWASP CRS)
ansible.builtin.git:
repo: "{{ item.depot }}"
dest: "{{ serveur_web_frontal_racine_base }}/{{ item.nom }}"
version: "{{ item.branche | default('main') }}"
depth: 1
force: true
loop: "{{ serveur_web_frontal_sites }}"
loop_control:
label: "{{ item.nom }}"
- name: Deployer le vhost de chaque site
ansible.builtin.template: ansible.builtin.template:
src: site.conf.j2 src: waf.conf.j2
dest: "/etc/nginx/sites-available/{{ item.nom }}" dest: "{{ serveur_web_frontal_waf_dir }}/main.conf"
owner: root owner: root
group: root group: root
mode: "0644" mode: "0644"
loop: "{{ serveur_web_frontal_sites }}"
loop_control:
label: "{{ item.nom }}"
notify: Recharger nginx serveur_web_frontal notify: Recharger nginx serveur_web_frontal
- name: Activer le vhost de chaque site # LES REGISTRES DU PLAN, COMME A L'EDGE : on releve d'abord ce qui existe, puis on charge.
# `domaines.yml` peut manquer (un plan sans domaine public) : les expositions sont alors vides.
- name: Quels registres du plan existent vraiment ?
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ setops_plan_dir }}/applications.yml"
- "{{ setops_plan_dir }}/domaines.yml"
delegate_to: localhost
become: false
register: serveur_web_frontal_registres
when: serveur_web_frontal_publier_expositions | bool
- name: Charger les registres applications et domaines
ansible.builtin.include_vars:
file: "{{ item.item }}"
loop: "{{ serveur_web_frontal_registres.results | default([]) }}"
loop_control:
label: "{{ item.item | basename }}"
when:
- serveur_web_frontal_publier_expositions | bool
- item.stat.exists | default(false)
- name: Lister les expositions publiques destinees a ce frontal
ansible.builtin.set_fact:
serveur_web_frontal_expositions: >-
{{ {'applications': applications | default({})}
| expositions_des_applications({'domaines_publics': domaines_publics | default({})}, serveur_web_frontal_groupe) }}
when: serveur_web_frontal_publier_expositions | bool
- name: Deployer les vhosts du frontal (derives des expositions publiques)
ansible.builtin.template:
src: frontal.conf.j2
dest: /etc/nginx/sites-available/frontal.conf
owner: root
group: root
mode: "0644"
notify: Recharger nginx serveur_web_frontal
- name: Activer les vhosts du frontal
ansible.builtin.file: ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ item.nom }}" src: /etc/nginx/sites-available/frontal.conf
dest: "/etc/nginx/sites-enabled/{{ item.nom }}" dest: /etc/nginx/sites-enabled/frontal.conf
state: link state: link
loop: "{{ serveur_web_frontal_sites }}"
loop_control:
label: "{{ item.nom }}"
notify: Recharger nginx serveur_web_frontal notify: Recharger nginx serveur_web_frontal
# LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. `client_sante` la fait tourner et - name: Valider la configuration nginx avant tout rechargement
# pousse le resultat ; les greffons standard, eux, sont poses par le porteur. ansible.builtin.command: nginx -t
# changed_when: false
# LE CONTENU DE CE ROLE N'EST PAS DANS CE ROLE : il arrive par git depuis une forge. Une when: not ansible_check_mode
# branche renommee, un sous-dossier deplace ou un clone vide laissent nginx parfaitement
# vert. Seul un GET par `server_name` le dit. - name: Activer et demarrer nginx
ansible.builtin.systemd:
name: nginx
enabled: true
state: started
when: not ansible_check_mode
# LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE.
- name: Assurer le repertoire des sondes de supervision - name: Assurer le repertoire des sondes de supervision
ansible.builtin.file: ansible.builtin.file:
path: /usr/local/lib/setops/sondes path: /usr/local/lib/setops/sondes
@ -69,10 +106,10 @@
group: root group: root
mode: "0755" mode: "0755"
- name: Deposer la sonde « sites-servis » - name: Deposer la sonde « frontal »
ansible.builtin.template: ansible.builtin.template:
src: sonde-sites-servis.sh.j2 src: sonde-frontal.sh.j2
dest: /usr/local/lib/setops/sondes/sites-servis.sh dest: /usr/local/lib/setops/sondes/frontal.sh
owner: root owner: root
group: root group: root
mode: "0750" mode: "0750"

View file

@ -0,0 +1,3 @@
# Gere par Set-OPS (role serveur_web_frontal). Ne pas editer a la main.
server_tokens off;
client_max_body_size {{ serveur_web_frontal_taille_max_corps }};

View file

@ -0,0 +1,41 @@
# Gere par Set-OPS (role serveur_web_frontal). Ne pas editer a la main.
#
# LE SERVEUR PAR DEFAUT REFUSE TOUT NOM INCONNU (meme regle que l'edge) : sans lui, nginx
# confie un nom inconnu au premier vhost, et un balayage de l'adresse publique recevrait un
# service qu'il n'a pas nomme.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
{% for expo in serveur_web_frontal_expositions | default([]) %}
{% if expo.hote in hostvars and hostvars[expo.hote].ansible_host is defined and expo.port %}
{% set tls = (expo.port | int) in serveur_web_frontal_ports_tls %}
# {{ expo.fqdn }} -> {{ expo.hote }}:{{ expo.port }} ({{ expo.application }})
# En clair en attendant son certificat Let's Encrypt.
server {
listen 80;
listen [::]:80;
server_name {{ expo.fqdn }};
modsecurity on;
modsecurity_rules_file {{ serveur_web_frontal_waf_dir }}/main.conf;
location / {
proxy_pass {{ 'https' if tls else 'http' }}://{{ hostvars[expo.hote].ansible_host }}:{{ expo.port }};
{% if tls %}
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_ssl_name {{ expo.hote }}.{{ domaine_interne }};
proxy_ssl_server_name on;
{% endif %}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
{% endif %}
{% endfor %}

View file

@ -0,0 +1,40 @@
#!/bin/bash
# GENERE par Set-OPS (role serveur_web_frontal). Ne pas editer a la main.
#
# SONDE « frontal » — le reverse proxy public tient, son WAF est charge, et chaque
# exposition publique repond a travers lui.
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `serveur_web_frontal_waf_mode` (une valeur que le moteur
# refuse fait echouer `nginx -t`).
set -uo pipefail
MODE='{{ serveur_web_frontal_waf_mode }}'
EXPOSITIONS="{{ serveur_web_frontal_expositions | default([]) | map(attribute='fqdn') | unique | sort | join(' ') }}"
systemctl is-active --quiet nginx || { echo "Le frontal (nginx) n'est pas actif : les services publics sont injoignables."; exit 2; }
nginx -t >/dev/null 2>&1 \
|| { echo "Le frontal sert, mais sa configuration sur disque est INVALIDE : le prochain demarrage echouera."; exit 2; }
# LE WAF EST-IL VRAIMENT DANS LE CHEMIN ? Un module non charge, ou des regles absentes,
# laissent passer tout le trafic sans rien dire : le frontal reste « vert ».
conf=$(nginx -T 2>/dev/null)
grep -q "ngx_http_modsecurity_module" <<<"${conf}" \
|| { echo "Le module ModSecurity n'est pas charge : le frontal relaie SANS filtrer."; exit 2; }
grep -q "modsecurity-crs/rules" '{{ serveur_web_frontal_waf_dir }}/main.conf' 2>/dev/null \
|| { echo "Les regles OWASP CRS ne sont pas chargees : le WAF n'a rien a appliquer."; exit 2; }
servies=0; en_defaut=()
for n in ${EXPOSITIONS}; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -H "Host: ${n}" http://127.0.0.1/ 2>/dev/null)
if [[ "${code}" =~ ^[234][0-9][0-9]$ ]]; then servies=$((servies + 1)); else en_defaut+=("${n}=${code:-000}"); fi
done
if [[ -n "${en_defaut[*]:-}" ]]; then
echo "Le frontal repond, mais des expositions publiques ne sont PAS servies : ${en_defaut[*]} (000 = silence, 5xx = amont en defaut). WAF ${MODE}."
exit 2
fi
if [[ -z "${EXPOSITIONS}" ]]; then
echo "Frontal pret (WAF CRS en ${MODE}) : aucune exposition publique declaree pour l'instant."
exit 0
fi
echo "Frontal : ${servies} exposition(s) publique(s) servie(s), WAF CRS en ${MODE}."

View file

@ -0,0 +1,12 @@
# Gere par Set-OPS (role serveur_web_frontal). Ne pas editer a la main.
#
# ModSecurity v3 + OWASP CRS. L'inclusion livree par Debian (`modsecurity_includes.conf`)
# ne charge QUE le moteur, pas les regles : on compose ici, dans l'ordre que le CRS exige
# — reglages du moteur, puis son mode, puis la configuration du CRS, ses exclusions
# d'avant, ses regles, ses exclusions d'apres.
Include /etc/nginx/modsecurity.conf
SecRuleEngine {{ serveur_web_frontal_waf_mode }}
Include /etc/modsecurity/crs/crs-setup.conf
Include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
Include /usr/share/modsecurity-crs/rules/*.conf
Include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf

View file

@ -9,8 +9,11 @@
graph LR graph LR
R["<b>postfix</b>"] R["<b>postfix</b>"]
E0["externe"] -->|"25 · starttls"| R E0["externe"] -->|"25 · starttls"| R
E1["flotte"] -->|"587 · starttls"| R E1["externe"] -->|"465 · tls-requis"| R
E2["smtp"] -->|"25 · starttls"| R E2["externe"] -->|"587 · starttls"| R
E3["flotte"] -->|"465 · tls-requis"| R
E4["flotte"] -->|"587 · starttls"| R
E5["smtp"] -->|"25 · starttls"| R
R -.->|"sonde « file-courriel »"| ICINGA[["Icinga"]] R -.->|"sonde « file-courriel »"| ICINGA[["Icinga"]]
``` ```
@ -19,7 +22,8 @@ graph LR
| port | depuis | chiffrement | pourquoi | | port | depuis | chiffrement | pourquoi |
|---|---|---|---| |---|---|---|---|
| `25` | `externe`, `client_smtp` | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). | | `25` | `externe`, `client_smtp` | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
| `587` | `flotte` | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. | | `587` | `flotte`, `externe` | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
| `465` | `flotte`, `externe` | tls-requis | Soumission authentifiée en TLS direct (submissions, RFC 8314) : clients de courriel des utilisateurs. |
## Ce qu'il rend à la supervision ## Ce qu'il rend à la supervision

View file

@ -9,20 +9,23 @@
graph LR graph LR
R["<b>web_dorsal</b>"] R["<b>web_dorsal</b>"]
E0["edge"] -->|"80 · clair"| R E0["edge"] -->|"80 · clair"| R
E1["web_frontal"] -->|"80 · clair"| R
R -.->|"sonde « apps-servies »"| ICINGA[["Icinga"]] R -.->|"sonde « apps-servies »"| ICINGA[["Icinga"]]
R -.->|"sonde « sites-servis »"| ICINGA[["Icinga"]]
``` ```
## Qui lui parle ## Qui lui parle
| port | depuis | chiffrement | pourquoi | | port | depuis | chiffrement | pourquoi |
|---|---|---|---| |---|---|---|---|
| `80` | `edge` | clair | Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge). | | `80` | `edge`, `serveur_web_frontal` | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
## Ce qu'il rend à la supervision ## Ce qu'il rend à la supervision
| sonde | TTL | ce qu'elle voit | | sonde | TTL | ce qu'elle voit |
|---|---|---| |---|---|---|
| `apps-servies` | 5400 s | Chacune des webapps declarees repond-elle encore ? Une app ne tombe presque jamais en « failed » — elle se coince : le processus vit, systemd la dit active, le port est ouvert, et plus une requete n'aboutit. Le rapport d'unites en echec ne verra jamais ca. | | `apps-servies` | 5400 s | Chacune des webapps declarees repond-elle encore ? Une app ne tombe presque jamais en « failed » — elle se coince : le processus vit, systemd la dit active, le port est ouvert, et plus une requete n'aboutit. Le rapport d'unites en echec ne verra jamais ca. |
| `sites-servis` | 5400 s | Chacun des sites statiques declares se sert-il encore ? Le contenu vient d'un depot git : une branche renommee, un sous-dossier deplace ou un clone vide laissent nginx parfaitement vert avec un site perime, vide, ou en 404. Seul un GET sur le `server_name` de chaque site le dit. |
## Ce qu'il expose en séries ## Ce qu'il expose en séries

View file

@ -8,21 +8,23 @@
```mermaid ```mermaid
graph LR graph LR
R["<b>web_frontal</b>"] R["<b>web_frontal</b>"]
E0["edge"] -->|"80 · clair"| R E0["externe"] -->|"443 · tls-requis"| R
R -.->|"sonde « sites-servis »"| ICINGA[["Icinga"]] E1["externe"] -->|"80 · clair"| R
R -.->|"sonde « frontal »"| ICINGA[["Icinga"]]
``` ```
## Qui lui parle ## Qui lui parle
| port | depuis | chiffrement | pourquoi | | port | depuis | chiffrement | pourquoi |
|---|---|---|---| |---|---|---|---|
| `80` | `edge` | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). | | `80` | `externe` | clair | HTTP public, relaye a travers le WAF — et le defi HTTP d'ACME ; renverra vers HTTPS quand le certificat sera la. |
| `443` | `externe` | tls-requis | HTTPS public — les sites et services que le locataire publie sur l'Internet (attend son certificat Let's Encrypt). |
## Ce qu'il rend à la supervision ## Ce qu'il rend à la supervision
| sonde | TTL | ce qu'elle voit | | sonde | TTL | ce qu'elle voit |
|---|---|---| |---|---|---|
| `sites-servis` | 5400 s | Chacun des sites statiques declares se sert-il encore ? Le contenu vient d'un depot git : une branche renommee, un sous-dossier deplace ou un clone vide laissent nginx parfaitement vert avec un site perime, vide, ou en 404. Seul un GET sur le `server_name` de chaque site le dit. | | `frontal` | 5400 s | Le reverse proxy public tient-il, son WAF (ModSecurity + OWASP CRS) est-il vraiment dans le chemin, et chaque exposition publique repond-elle a travers lui ? Un frontal qui relaie sans filtrer reste vert ; un frontal tombe rend tout le public injoignable pendant que les services derriere restent sains. |
## Ce qu'il expose en séries ## Ce qu'il expose en séries

View file

@ -61,7 +61,7 @@ Chaque fiche est construite depuis ce que le rôle **déclare lui-même** — `m
| [`serveur_ops`](Rôle-serveur_ops) | 1 | 2 | — | | [`serveur_ops`](Rôle-serveur_ops) | 1 | 2 | — |
| [`serveur_ops_site`](Rôle-serveur_ops_site) | — | 3 | — | | [`serveur_ops_site`](Rôle-serveur_ops_site) | — | 3 | — |
| [`serveur_ops_tenant`](Rôle-serveur_ops_tenant) | 1 | 1 | — | | [`serveur_ops_tenant`](Rôle-serveur_ops_tenant) | 1 | 1 | — |
| [`serveur_postfix`](Rôle-serveur_postfix) | 2 | 1 | — | | [`serveur_postfix`](Rôle-serveur_postfix) | 3 | 1 | — |
| [`serveur_postgresql`](Rôle-serveur_postgresql) | 2 | 1 | oui | | [`serveur_postgresql`](Rôle-serveur_postgresql) | 2 | 1 | oui |
| [`serveur_powerdns`](Rôle-serveur_powerdns) | 4 | 1 | — | | [`serveur_powerdns`](Rôle-serveur_powerdns) | 4 | 1 | — |
| [`serveur_prometheus`](Rôle-serveur_prometheus) | 1 | 1 | — | | [`serveur_prometheus`](Rôle-serveur_prometheus) | 1 | 1 | — |
@ -70,8 +70,8 @@ Chaque fiche est construite depuis ce que le rôle **déclare lui-même** — `m
| [`serveur_resolveur_site`](Rôle-serveur_resolveur_site) | 2 | 1 | — | | [`serveur_resolveur_site`](Rôle-serveur_resolveur_site) | 2 | 1 | — |
| [`serveur_rspamd`](Rôle-serveur_rspamd) | 2 | 1 | — | | [`serveur_rspamd`](Rôle-serveur_rspamd) | 2 | 1 | — |
| [`serveur_step_ca`](Rôle-serveur_step_ca) | 1 | 1 | — | | [`serveur_step_ca`](Rôle-serveur_step_ca) | 1 | 1 | — |
| [`serveur_web_dorsal`](Rôle-serveur_web_dorsal) | 1 | 1 | — | | [`serveur_web_dorsal`](Rôle-serveur_web_dorsal) | 1 | 2 | — |
| [`serveur_web_frontal`](Rôle-serveur_web_frontal) | 1 | 1 | — | | [`serveur_web_frontal`](Rôle-serveur_web_frontal) | 2 | 1 | — |
| [`ssh_baseline`](Rôle-ssh_baseline) | — | — | — | | [`ssh_baseline`](Rôle-ssh_baseline) | — | — | — |
| [`ssh_hardening`](Rôle-ssh_hardening) | — | — | — | | [`ssh_hardening`](Rôle-ssh_hardening) | — | — | — |
| [`sudo_ansible`](Rôle-sudo_ansible) | — | — | — | | [`sudo_ansible`](Rôle-sudo_ansible) | — | — | — |