diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 44ed5a9..3812b87 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 | 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_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_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 — 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. | -| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. | +| `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 | 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` | egress | 80 | tcp | serveur_web_dorsal | clair | Relais vers le web dorsal, qui porte les sites et les applications publiques. | ## Synthèse chiffrement @@ -135,6 +134,6 @@ - **n-a** : 8 flux - **ssh** : 8 flux - **starttls** : 6 flux -- **tls** : 11 flux +- **tls** : 10 flux - **tls-cible** : 2 flux - **tls-requis** : 36 flux diff --git a/roles/serveur_web_dorsal/README.md b/roles/serveur_web_dorsal/README.md index 4e63ec0..10a7341 100644 --- a/roles/serveur_web_dorsal/README.md +++ b/roles/serveur_web_dorsal/README.md @@ -1,9 +1,13 @@ # serveur_web_dorsal -Hébergement de **webapps dynamiques** (runtime + service systemd), derrière l'edge. -Deuxième brique de la **plateforme webapp** de Set-OPS : **100 % natif** — venv/paquets -+ systemd + nginx, **zéro conteneur**. Codifié depuis le spike du 2026-07-05 -(monregistraire : FastAPI + uvicorn + SQLite). +**Le serveur qui porte les sites et les applications web du locataire** : webapps +dynamiques (runtime + service systemd) et, depuis le 2026-09-29, sites **statiques** +(repris du web frontal). **100 % natif** — venv/paquets + systemd + nginx, **zéro +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 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: , port: 80, expose: [registraire.]`. +## Sites statiques +Pour chaque site déclaré dans `serveur_web_dorsal_sites` : `git clone`/`pull` → +`/srv/web/`, puis un vhost nginx servant `` (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: , port: 80, +expose: []`. Sonde `sites-servis` : un GET par `server_name`. + ## BD 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` @@ -41,4 +60,5 @@ paquets de runtime (le patron systemd + nginx + git reste identique). ## Validation `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. diff --git a/roles/serveur_web_dorsal/defaults/main.yml b/roles/serveur_web_dorsal/defaults/main.yml index 76762a9..d28a688 100644 --- a/roles/serveur_web_dorsal/defaults/main.yml +++ b/roles/serveur_web_dorsal/defaults/main.yml @@ -29,3 +29,30 @@ serveur_web_dorsal_apps: [] # `/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. 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 diff --git a/roles/serveur_web_dorsal/meta/flux.yml b/roles/serveur_web_dorsal/meta/flux.yml index 2769943..e0021b6 100644 --- a/roles/serveur_web_dorsal/meta/flux.yml +++ b/roles/serveur_web_dorsal/meta/flux.yml @@ -2,12 +2,14 @@ # 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. 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 port: 80 protocole: tcp - pair: edge + pair: [edge, serveur_web_frontal] 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 port: 443 protocole: tcp diff --git a/roles/serveur_web_dorsal/meta/supervision.yml b/roles/serveur_web_dorsal/meta/supervision.yml index 28bbdf3..eae5181 100644 --- a/roles/serveur_web_dorsal/meta/supervision.yml +++ b/roles/serveur_web_dorsal/meta/supervision.yml @@ -28,3 +28,10 @@ sondes: 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.' + # 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.' diff --git a/roles/serveur_web_dorsal/tasks/main.yml b/roles/serveur_web_dorsal/tasks/main.yml index 3a20a98..ebc56c2 100644 --- a/roles/serveur_web_dorsal/tasks/main.yml +++ b/roles/serveur_web_dorsal/tasks/main.yml @@ -107,6 +107,49 @@ label: "{{ item.nom }}" 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 # pousse le resultat ; les greffons standard, eux, sont poses par le porteur. # @@ -127,3 +170,14 @@ owner: root group: root 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" diff --git a/roles/serveur_web_frontal/templates/site.conf.j2 b/roles/serveur_web_dorsal/templates/site.conf.j2 similarity index 86% rename from roles/serveur_web_frontal/templates/site.conf.j2 rename to roles/serveur_web_dorsal/templates/site.conf.j2 index f69ad4e..e725cee 100644 --- a/roles/serveur_web_frontal/templates/site.conf.j2 +++ b/roles/serveur_web_dorsal/templates/site.conf.j2 @@ -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. server { listen 80; 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; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" 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_types text/css application/javascript text/html image/svg+xml; diff --git a/roles/serveur_web_frontal/templates/sonde-sites-servis.sh.j2 b/roles/serveur_web_dorsal/templates/sonde-sites-servis.sh.j2 similarity index 88% rename from roles/serveur_web_frontal/templates/sonde-sites-servis.sh.j2 rename to roles/serveur_web_dorsal/templates/sonde-sites-servis.sh.j2 index 701718c..e06e006 100644 --- a/roles/serveur_web_frontal/templates/sonde-sites-servis.sh.j2 +++ b/roles/serveur_web_dorsal/templates/sonde-sites-servis.sh.j2 @@ -4,7 +4,7 @@ # 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` # 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 ? # @@ -18,9 +18,9 @@ # mesurerait le serveur par defaut, c'est-a-dire n'importe quoi d'autre. set -uo pipefail -PORT={{ serveur_web_frontal_sonde_port }} -noms=({% for s in serveur_web_frontal_sites | default([]) %}'{{ s.server_name }}' {% endfor %}) -etiquettes=({% for s in serveur_web_frontal_sites | default([]) %}'{{ s.nom }}' {% endfor %}) +PORT={{ serveur_web_dorsal_sites_port }} +noms=({% for s in serveur_web_dorsal_sites | default([]) %}'{{ s.server_name }}' {% endfor %}) +etiquettes=({% for s in serveur_web_dorsal_sites | default([]) %}'{{ s.nom }}' {% endfor %}) if (( ${#noms[@]} == 0 )); then # PAS UN VERT MUET. Un hebergeur sans site declare est un etat legitime — le dire vaut diff --git a/roles/serveur_web_frontal/README.md b/roles/serveur_web_frontal/README.md index 6a26f1e..a43181c 100644 --- a/roles/serveur_web_frontal/README.md +++ b/roles/serveur_web_frontal/README.md @@ -1,37 +1,34 @@ # serveur_web_frontal -Hébergement de **sites web statiques** (nginx), derrière l'edge. Première brique de -la **plateforme webapp** de Set-OPS : **100 % natif** — pas de conteneur, pas de -runtime, pas de base de données. Le contenu vient d'un **dépôt git souverain** -(Forgejo). Codifié depuis le spike du 2026-07-05 (site Alliance Boréale). +**Reverse proxy et WAF devant les services web PUBLICS du locataire.** C'est la porte +publique : la frontière redirige l'adresse publique que le site attribue au locataire +(80/443) vers lui ; il filtre et relaie. **Il ne sert rien lui-même** — les sites et les +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 -Pour chaque site déclaré dans `serveur_web_frontal_sites` : -1. `git clone`/`pull` du dépôt → `/srv/web/` ; -2. vhost nginx servant `` (ex. `public/`), avec en-têtes de sécurité - + CSP (défaut : `self` only, aucun tiers, aucun pistage), gzip, cache, `try_files`. +1. nginx + **ModSecurity v3** (`libnginx-mod-http-modsecurity`) + **OWASP CRS** + (`modsecurity-crs`), tous paquets Debian, tout libre ; +2. compose le WAF (`/etc/nginx/setops-waf/main.conf`) : moteur, mode, CRS et ses + 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). -Les SAN du cert edge et le plancher /etc/hosts sont **auto-dérivés du plan**. +## Le WAF +`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) -```yaml -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: , port: 80, -expose: [alliance-boreale.]`. - -## 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. +## TLS +En clair (80) tant que le certificat **Let's Encrypt** des noms publics n'est pas en place. ## Validation -`ansible-lint` profil production + `--syntax-check`. Éprouvé sur web-frontal-01 -(site servi via l'edge, HTTPS step-ca vérifié, CSP en place). +`ansible-lint` profil production + `--syntax-check` ; sonde `frontal`. diff --git a/roles/serveur_web_frontal/defaults/main.yml b/roles/serveur_web_frontal/defaults/main.yml index 19d085c..da83d0c 100644 --- a/roles/serveur_web_frontal/defaults/main.yml +++ b/roles/serveur_web_frontal/defaults/main.yml @@ -1,29 +1,40 @@ --- # ========================================================================= -# serveur_web_frontal — hebergement de sites web STATIQUES (nginx), derriere l'edge. -# 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. -# Les apps DYNAMIQUES (service systemd + BD) viendront via un r_le dorsal. -# Codifie depuis le spike du 2026-07-05 (site Alliance Boreale). +# serveur_web_frontal — REVERSE PROXY + WAF devant les services web PUBLICS du locataire. +# +# L'EDGE ET LE FRONTAL SONT DEUX PORTES (2026-09-29). L'edge (`serveur_nginx`) expose les +# services INTERNES (`*.internal`) a la zone d'administration. Le frontal expose ce que le +# 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_csp_defaut: >- - default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; - object-src 'none'; base-uri 'self'; frame-ancestors 'self' +serveur_web_frontal_groupe: "serveur_web_frontal" +serveur_web_frontal_publier_expositions: true -# Liste des sites statiques servis par ce noeud. Chaque entree : -# nom identifiant court (dossier + vhost) -# server_name FQDN servi (doit etre expose via applications.yml -> edge) -# 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_frontal_sites: [] +# --- LE WAF -------------------------------------------------------------------------- +# +# `DetectionOnly` D'ABORD : le CRS journalise ce qu'il aurait bloque +# (`/var/log/nginx/modsec_audit.log`) sans rien refuser. On lit, on ajuste les exclusions +# pour les applications reelles, PUIS on passe a `On`. Un WAF bloquant pose a l'aveugle +# casse les formulaires legitimes, et on finit par le desactiver. +# +# 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 ; -# c'est l'edge qui termine le TLS devant. La sonde mesure donc ce que l'edge ira chercher, -# 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. -serveur_web_frontal_sonde_port: 80 +# Taille maximale d'une requete relayee (televersements). +serveur_web_frontal_taille_max_corps: "16m" + +# Un amont declare sur un de ces ports se parle en TLS (comme a l'edge). +serveur_web_frontal_ports_tls: [443, 8443] diff --git a/roles/serveur_web_frontal/meta/flux.yml b/roles/serveur_web_frontal/meta/flux.yml index 4d789a0..14bbb48 100644 --- a/roles/serveur_web_frontal/meta/flux.yml +++ b/roles/serveur_web_frontal/meta/flux.yml @@ -1,31 +1,26 @@ --- # 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: - - 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 port: 80 protocole: tcp pair: externe 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 port: 443 protocole: tcp pair: externe 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 - port: 443 + port: 80 protocole: tcp - pair: flotte - chiffrement: tls - raison: "git clone/pull du depot du site (Forgejo souverain) au deploiement." + pair: serveur_web_dorsal + chiffrement: clair + raison: "Relais vers le web dorsal, qui porte les sites et les applications publiques." diff --git a/roles/serveur_web_frontal/meta/supervision.yml b/roles/serveur_web_frontal/meta/supervision.yml index f13a853..6a33f5a 100644 --- a/roles/serveur_web_frontal/meta/supervision.yml +++ b/roles/serveur_web_frontal/meta/supervision.yml @@ -1,33 +1,14 @@ --- # 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 -# encore ». Ici la question est autre : CHACUN des sites declares se sert-il vraiment ? -# -# LA PANNE PROPRE A CE ROLE EST SILENCIEUSE, et elle vient du fait que le contenu n'est -# 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. +# LE FRONTAL EST LA PORTE PUBLIQUE : s'il tombe, tout ce que le locataire publie tombe avec +# 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 ». +# `sites-servis` a suivi les sites sur le dorsal le 2026-09-29. sondes: - - nom: sites-servis + - nom: frontal 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.' + raison: '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.' diff --git a/roles/serveur_web_frontal/tasks/main.yml b/roles/serveur_web_frontal/tasks/main.yml index 9e60a0c..b7aadd6 100644 --- a/roles/serveur_web_frontal/tasks/main.yml +++ b/roles/serveur_web_frontal/tasks/main.yml @@ -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: - name: - - nginx - - git + name: "{{ serveur_web_frontal_paquets }}" state: present update_cache: true cache_valid_time: 3600 -- name: Retirer le vhost nginx par defaut +- name: Retirer le vhost nginx par defaut de Debian ansible.builtin.file: path: /etc/nginx/sites-enabled/default state: absent 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: - path: "{{ serveur_web_frontal_racine_base }}" + path: "{{ serveur_web_frontal_waf_dir }}" state: directory owner: root group: root mode: "0755" -- name: Cloner / mettre a jour le depot de chaque site (clone superficiel, contenu courant) - 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 +- name: Deployer la composition du WAF (ModSecurity + OWASP CRS) ansible.builtin.template: - src: site.conf.j2 - dest: "/etc/nginx/sites-available/{{ item.nom }}" + src: waf.conf.j2 + dest: "{{ serveur_web_frontal_waf_dir }}/main.conf" owner: root group: root mode: "0644" - loop: "{{ serveur_web_frontal_sites }}" - loop_control: - label: "{{ item.nom }}" 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: - src: "/etc/nginx/sites-available/{{ item.nom }}" - dest: "/etc/nginx/sites-enabled/{{ item.nom }}" + src: /etc/nginx/sites-available/frontal.conf + dest: /etc/nginx/sites-enabled/frontal.conf state: link - loop: "{{ serveur_web_frontal_sites }}" - loop_control: - label: "{{ item.nom }}" notify: Recharger nginx serveur_web_frontal -# 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. -# -# LE CONTENU DE CE ROLE 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: Valider la configuration nginx avant tout rechargement + ansible.builtin.command: nginx -t + changed_when: false + when: not ansible_check_mode + +- 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 ansible.builtin.file: path: /usr/local/lib/setops/sondes @@ -69,10 +106,10 @@ group: root mode: "0755" -- name: Deposer la sonde « sites-servis » +- name: Deposer la sonde « frontal » ansible.builtin.template: - src: sonde-sites-servis.sh.j2 - dest: /usr/local/lib/setops/sondes/sites-servis.sh + src: sonde-frontal.sh.j2 + dest: /usr/local/lib/setops/sondes/frontal.sh owner: root group: root mode: "0750" diff --git a/roles/serveur_web_frontal/templates/durcissement.conf.j2 b/roles/serveur_web_frontal/templates/durcissement.conf.j2 new file mode 100644 index 0000000..5049673 --- /dev/null +++ b/roles/serveur_web_frontal/templates/durcissement.conf.j2 @@ -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 }}; diff --git a/roles/serveur_web_frontal/templates/frontal.conf.j2 b/roles/serveur_web_frontal/templates/frontal.conf.j2 new file mode 100644 index 0000000..b212f83 --- /dev/null +++ b/roles/serveur_web_frontal/templates/frontal.conf.j2 @@ -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 %} diff --git a/roles/serveur_web_frontal/templates/sonde-frontal.sh.j2 b/roles/serveur_web_frontal/templates/sonde-frontal.sh.j2 new file mode 100644 index 0000000..6664200 --- /dev/null +++ b/roles/serveur_web_frontal/templates/sonde-frontal.sh.j2 @@ -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}." diff --git a/roles/serveur_web_frontal/templates/waf.conf.j2 b/roles/serveur_web_frontal/templates/waf.conf.j2 new file mode 100644 index 0000000..fb0bb07 --- /dev/null +++ b/roles/serveur_web_frontal/templates/waf.conf.j2 @@ -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 diff --git a/wiki/Rôle-serveur_postfix.md b/wiki/Rôle-serveur_postfix.md index 9aaf29d..c3a413a 100644 --- a/wiki/Rôle-serveur_postfix.md +++ b/wiki/Rôle-serveur_postfix.md @@ -9,8 +9,11 @@ graph LR R["postfix"] E0["externe"] -->|"25 · starttls"| R - E1["flotte"] -->|"587 · starttls"| R - E2["smtp"] -->|"25 · starttls"| R + E1["externe"] -->|"465 · tls-requis"| 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"]] ``` @@ -19,7 +22,8 @@ graph LR | port | depuis | chiffrement | pourquoi | |---|---|---|---| | `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 diff --git a/wiki/Rôle-serveur_web_dorsal.md b/wiki/Rôle-serveur_web_dorsal.md index 312665a..ac42286 100644 --- a/wiki/Rôle-serveur_web_dorsal.md +++ b/wiki/Rôle-serveur_web_dorsal.md @@ -9,20 +9,23 @@ graph LR R["web_dorsal"] E0["edge"] -->|"80 · clair"| R + E1["web_frontal"] -->|"80 · clair"| R R -.->|"sonde « apps-servies »"| ICINGA[["Icinga"]] + R -.->|"sonde « sites-servis »"| ICINGA[["Icinga"]] ``` ## Qui lui parle | 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 | 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. | +| `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 diff --git a/wiki/Rôle-serveur_web_frontal.md b/wiki/Rôle-serveur_web_frontal.md index 0d8b2d5..effab58 100644 --- a/wiki/Rôle-serveur_web_frontal.md +++ b/wiki/Rôle-serveur_web_frontal.md @@ -8,21 +8,23 @@ ```mermaid graph LR R["web_frontal"] - E0["edge"] -->|"80 · clair"| R - R -.->|"sonde « sites-servis »"| ICINGA[["Icinga"]] + E0["externe"] -->|"443 · tls-requis"| R + E1["externe"] -->|"80 · clair"| R + R -.->|"sonde « frontal »"| ICINGA[["Icinga"]] ``` ## Qui lui parle | 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 | 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 diff --git a/wiki/Rôles.md b/wiki/Rôles.md index 0968b00..ec421f2 100644 --- a/wiki/Rôles.md +++ b/wiki/Rôles.md @@ -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_site`](Rôle-serveur_ops_site) | — | 3 | — | | [`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_powerdns`](Rôle-serveur_powerdns) | 4 | 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_rspamd`](Rôle-serveur_rspamd) | 2 | 1 | — | | [`serveur_step_ca`](Rôle-serveur_step_ca) | 1 | 1 | — | -| [`serveur_web_dorsal`](Rôle-serveur_web_dorsal) | 1 | 1 | — | -| [`serveur_web_frontal`](Rôle-serveur_web_frontal) | 1 | 1 | — | +| [`serveur_web_dorsal`](Rôle-serveur_web_dorsal) | 1 | 2 | — | +| [`serveur_web_frontal`](Rôle-serveur_web_frontal) | 2 | 1 | — | | [`ssh_baseline`](Rôle-ssh_baseline) | — | — | — | | [`ssh_hardening`](Rôle-ssh_hardening) | — | — | — | | [`sudo_ansible`](Rôle-sudo_ansible) | — | — | — |