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 | 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

View file

@ -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: <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
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.

View file

@ -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

View file

@ -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

View file

@ -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.'

View file

@ -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"

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.
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;

View file

@ -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

View file

@ -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/<nom>` ;
2. vhost nginx servant `<sous_dossier>` (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: <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.
## 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`.

View file

@ -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]

View file

@ -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."

View file

@ -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.'

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:
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"

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
R["<b>postfix</b>"]
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

View file

@ -9,20 +9,23 @@
graph LR
R["<b>web_dorsal</b>"]
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

View file

@ -8,21 +8,23 @@
```mermaid
graph LR
R["<b>web_frontal</b>"]
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

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_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) | — | — | — |