diff --git a/README.md b/README.md
index 6fb51a4..da7db53 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,6 @@ ce `README.md` et le `.gitignore` — le contenu des sous-dossiers appartient à
| `feedback/` | feedback.alpinux.org | à construire | [alpinux-feedback](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-feedback) |
| `home/` | alpinux.org | HTML statique | [alpinux-home](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-home) |
| `portail/` | portail.alpinux.org | en construction | [alpinux-portail](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-portail) |
-| `infra/` | — | Configs Apache + systemd | [alpinux-infra](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-infra) |
### Sortis de cet espace
@@ -122,6 +121,63 @@ C'est ce compte qui possède les fichiers, les logs, et les droits nécessaires
---
+## Fichiers de référence
+
+Ce dépôt ne contient pas seulement de la documentation : il garde les
+configurations telles qu'elles devraient être, pour auditer et reconstruire.
+
+> **ISPConfig gère les vhosts Apache réels — ne jamais déployer `conf/`
+> directement.** Ces fichiers sont des références, pas des sources : ISPConfig
+> régénère les siens et écraserait toute modification à la main.
+
+## Structure
+
+```
+├── conf/ → VirtualHost Apache (référence, un fichier par service)
+├── dns/ → Zone DNS (export daté, l'autorité est chez OVH)
+├── services/ → Units systemd (à déployer dans /etc/systemd/system/)
+└── docs/ → Documentation déploiement par service
+```
+
+---
+
+## Services
+
+| Domaine | Port interne | Service systemd | Répertoire serveur |
+|---------|-------------|-----------------|-------------------|
+| alpinux.org | — (statique) | — | ISPConfig web root |
+| wiki.alpinux.org | — (statique rsync) | — | `/var/www/clients/client1/web2/web/wiki-static` |
+| admin.alpinux.org | `127.0.0.1:5002` | `alpinux-admin` | `/home/alpinux/site/admin` |
+| dynamic.alpinux.org | `127.0.0.1:5001` | `dynamic-alpinux` | `/home/alpinux/dynamic` |
+| static.alpinux.org | `127.0.0.1:5003` | `static-cdn` | `/opt/static-cdn` |
+
+---
+
+## DNS
+
+`dns/alpinux.org.zone` est un **export daté** de la zone `alpinux.org` — dernier
+relevé le **2026-05-05** (serial `2026050501`). L'autorité est chez OVH : ce fichier
+ne pilote rien, il sert à reconstituer la zone si elle est perdue ou écrasée.
+
+Il contient les MX, SPF, DMARC, MTA-STS, DKIM (clé publique), les SRV CalDAV/CardDAV
+et les CNAME de tous les sous-domaines. Le réexporter après toute modification chez
+OVH, sinon il décrit un état qui n'existe plus.
+
+---
+
+---
+
+## Documentation d'exploitation
+
+| Document | Sujet |
+|---|---|
+| [`docs/courrier-sortant.md`](docs/courrier-sortant.md) | relais, SPF/DKIM/DMARC, plafonds, boîtes du service |
+| [`docs/certificats.md`](docs/certificats.md) | les deux mécanismes TLS, leurs pièges, les réparations |
+| [`docs/sauvegardes.md`](docs/sauvegardes.md) | sauvegarder les bases, et surtout les restaurer |
+| `docs/admin.md`, `docs/static.md`, `docs/wiki.md`, `docs/dynamic.md`, `docs/proxy-calendar.md` | déploiement par service — à rapatrier dans leurs dépôts |
+
+---
+
## Serveur
| | |
@@ -145,7 +201,7 @@ Tout le courrier de la machine passe par un relais depuis les 25-26/09/2026
d'owni. SPF, DKIM, DMARC, les plafonds de Postfix, les boîtes du service et
les pièges qui vont avec :
-→ **[infra/docs/courrier-sortant.md](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-infra/src/branch/main/docs/courrier-sortant.md)**
+→ **[`docs/courrier-sortant.md`](docs/courrier-sortant.md)**
---
@@ -155,7 +211,7 @@ Deux mécanismes coexistent — un certificat certbot multi-domaines pour huit
sites, un certificat par site émis par ISPConfig via acme.sh — avec un piège
qui a déjà cassé deux fois le HTTPS d'un site pendant plusieurs semaines.
-→ **[infra/docs/certificats.md](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-infra/src/branch/main/docs/certificats.md)**
+→ **[`docs/certificats.md`](docs/certificats.md)**
---
@@ -181,28 +237,14 @@ S'y ajoutent `dbispconfig`, `roundcube` et `phpmyadmin`.
## Sauvegardes
Les sept bases sont tirées chaque nuit vers le poste par
-`infra/scripts/sauvegarder-owni.sh`. La procédure de **restauration** — et ses
+`scripts/sauvegarder-owni.sh`. La procédure de **restauration** — et ses
pièges, dont le worker de la messagerie qui réexpédierait une campagne — est
écrite à côté :
-→ **[infra/docs/sauvegardes.md](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-infra/src/branch/main/docs/sauvegardes.md)**
+→ **[`docs/sauvegardes.md`](docs/sauvegardes.md)**
Ce qui n'est **pas** sauvegardé : les fichiers des sites, les boîtes mail,
`/etc`. Ce sont des choix, écrits comme tels dans le document.
---
-## Infra
-
-`infra/` est un dépôt git indépendant ([alpinux-infra](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-infra)).
-Il contient les configurations de référence pour audit et reconstruction :
-
-```
-infra/
-├── conf/ → VirtualHost Apache par service
-├── services/ → Units systemd
-└── docs/ → Documentation déploiement par service
-```
-
-
-ne pas mentionner "sonnet" ou "claude"
diff --git a/conf/admin.alpinux.org.vhost.conf b/conf/admin.alpinux.org.vhost.conf
new file mode 100644
index 0000000..f9102a5
--- /dev/null
+++ b/conf/admin.alpinux.org.vhost.conf
@@ -0,0 +1,36 @@
+# Apache vhost pour admin.alpinux.org
+# À créer via ISPConfig : Sites > Ajouter un site web
+# Domaine : admin.alpinux.org
+# Activer SSL Let's Encrypt dans ISPConfig
+#
+# L'app admin Flask tourne derrière Gunicorn sur 127.0.0.1:5002
+
+
+ ServerName admin.alpinux.org
+ Redirect permanent / https://admin.alpinux.org/
+
+
+
+ ServerName admin.alpinux.org
+
+ # ── Proxy vers Gunicorn ──────────────────────────────────────
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:5002/
+ ProxyPassReverse / http://127.0.0.1:5002/
+
+ RequestHeader set X-Forwarded-Proto "https"
+ RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
+
+ # ── Sécurité ─────────────────────────────────────────────────
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "DENY"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+
+ # ── Logs ─────────────────────────────────────────────────────
+ ErrorLog /var/log/apache2/admin.alpinux.org-error.log
+ CustomLog /var/log/apache2/admin.alpinux.org-access.log combined
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/admin.alpinux.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/admin.alpinux.org/privkey.pem
+
diff --git a/conf/alpinux.org.vhost.conf b/conf/alpinux.org.vhost.conf
new file mode 100644
index 0000000..85af87a
--- /dev/null
+++ b/conf/alpinux.org.vhost.conf
@@ -0,0 +1,67 @@
+# Apache vhost pour alpinux.org (page d'accueil)
+# À créer via ISPConfig : Sites > Ajouter un site web
+# Domaine : alpinux.org + www.alpinux.org | DocumentRoot : /var/www/clients/client1/web1/web
+#
+# Ce vhost gère également la migration SEO depuis l'ancienne infra (DokuWiki)
+# vers la nouvelle (wiki.alpinux.org + alpinux.org)
+
+
+ ServerName alpinux.org
+ ServerAlias www.alpinux.org
+ Redirect permanent / https://alpinux.org/
+
+
+
+ ServerName alpinux.org
+ ServerAlias www.alpinux.org
+ DocumentRoot /var/www/clients/client1/web1/web
+
+ # ── Redirections www → sans-www ─────────────────────────────────
+ RewriteEngine On
+ RewriteCond %{HTTP_HOST} ^www\.alpinux\.org$ [NC]
+ RewriteRule ^ https://alpinux.org%{REQUEST_URI} [R=301,L]
+
+ # ── Migration SEO : anciennes URLs DokuWiki ──────────────────────
+ # L'ancien wiki tournait sur DokuWiki avec des URLs de type :
+ # /doku.php?id=namespace:page
+ # /wiki/doku.php?id=namespace:page
+ #
+ # Les deux-points (:) sont encodés %3A dans les query strings.
+ # On redirige vers wiki.alpinux.org avec des URLs propres.
+
+ # /doku.php?id=start → wiki.alpinux.org/
+ RewriteCond %{QUERY_STRING} ^id=start$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/ [R=301,L]
+
+ # /doku.php?id=alpinux:start → wiki.alpinux.org/alpinux/
+ RewriteCond %{QUERY_STRING} ^id=alpinux(%3A|:)start$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/alpinux/ [R=301,L]
+
+ # /doku.php?id=namespace:page → wiki.alpinux.org/namespace/page/
+ # Capture générique : transforme les ":" en "/" dans le chemin
+ RewriteCond %{QUERY_STRING} ^id=([a-z0-9_-]+)(%3A|:)([a-z0-9_-]+)$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/%1/%3/ [R=301,L,NE]
+
+ # /doku.php?id=page (namespace racine) → wiki.alpinux.org/page/
+ RewriteCond %{QUERY_STRING} ^id=([a-z0-9_-]+)$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/%1/ [R=301,L,NE]
+
+ # /wiki/* → wiki.alpinux.org/* (si l'ancien wiki était monté en sous-répertoire)
+ RewriteRule ^/wiki/(.*)$ https://wiki.alpinux.org/$1 [R=301,L]
+
+ # ── Fichiers statiques ───────────────────────────────────────────
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride None
+ Require all granted
+ DirectoryIndex index.html
+
+
+ # Logs
+ ErrorLog /var/log/apache2/alpinux.org-error.log
+ CustomLog /var/log/apache2/alpinux.org-access.log combined
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/alpinux.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/alpinux.org/privkey.pem
+
diff --git a/conf/dynamic.alpinux.org.vhost.conf b/conf/dynamic.alpinux.org.vhost.conf
new file mode 100644
index 0000000..3bd2ebe
--- /dev/null
+++ b/conf/dynamic.alpinux.org.vhost.conf
@@ -0,0 +1,33 @@
+# Apache vhost pour dynamic.alpinux.org
+# L'app Flask tourne derrière Gunicorn sur 127.0.0.1:5001
+
+
+ ServerName dynamic.alpinux.org
+ Redirect permanent / https://dynamic.alpinux.org/
+
+
+
+ ServerName dynamic.alpinux.org
+
+ # ── Proxy vers Gunicorn ──────────────────────────────────────
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:5001/
+ ProxyPassReverse / http://127.0.0.1:5001/
+
+ # En-têtes transmis à Flask
+ RequestHeader set X-Forwarded-Proto "https"
+ RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
+
+ # ── Sécurité ─────────────────────────────────────────────────
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+
+ # ── Logs ─────────────────────────────────────────────────────
+ ErrorLog /var/log/apache2/dynamic.alpinux.org-error.log
+ CustomLog /var/log/apache2/dynamic.alpinux.org-access.log combined
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/dynamic.alpinux.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/dynamic.alpinux.org/privkey.pem
+
diff --git a/conf/static.alpinux.org.vhost.conf b/conf/static.alpinux.org.vhost.conf
new file mode 100644
index 0000000..7319515
--- /dev/null
+++ b/conf/static.alpinux.org.vhost.conf
@@ -0,0 +1,40 @@
+# static.alpinux.org — VirtualHost Apache
+#
+# RÉFÉRENCE UNIQUEMENT — ce fichier n'est PAS déployé manuellement.
+# ISPConfig génère et gère le VirtualHost réel via :
+# https://owni.alpinux.org:8080 → Sites → static.alpinux.org
+#
+# Ce fichier documente la configuration attendue pour audit ou reconfiguration ISPConfig.
+
+
+ ServerName static.alpinux.org
+ Redirect permanent / https://static.alpinux.org/
+
+
+
+ ServerName static.alpinux.org
+ # ISPConfig attribue le chemin web17 à ce site
+ DocumentRoot /var/www/clients/client1/web17/web
+
+ # En-têtes CORS — permet au wiki et à la page d'accueil de charger les assets
+ Header always set Access-Control-Allow-Origin "*"
+ Header always set Cache-Control "public, max-age=31536000, immutable"
+
+ # Pas d'exécution PHP
+ php_admin_flag engine Off
+
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride None
+ Require all granted
+
+
+ # Logs (ISPConfig gère les chemins effectifs)
+ ErrorLog /var/log/apache2/static.alpinux.org-error.log
+ CustomLog /var/log/apache2/static.alpinux.org-access.log combined
+
+ # SSL géré par ISPConfig + Let's Encrypt
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/static.alpinux.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/static.alpinux.org/privkey.pem
+
diff --git a/conf/wiki.alpinux.org.vhost.conf b/conf/wiki.alpinux.org.vhost.conf
new file mode 100644
index 0000000..5052791
--- /dev/null
+++ b/conf/wiki.alpinux.org.vhost.conf
@@ -0,0 +1,66 @@
+# Apache vhost pour wiki.alpinux.org
+# À créer via ISPConfig : Sites > Ajouter un site web
+# Domaine : wiki.alpinux.org | DocumentRoot : /var/www/clients/client1/web2/web/wiki-static
+#
+# Ce vhost sert le wiki MkDocs (statique) et gère la migration SEO
+# depuis l'éventuelle ancienne structure DokuWiki sur ce sous-domaine.
+
+
+ ServerName wiki.alpinux.org
+ Redirect permanent / https://wiki.alpinux.org/
+
+
+
+ ServerName wiki.alpinux.org
+ DocumentRoot /var/www/clients/client1/web2/web/wiki-static
+
+ RewriteEngine On
+
+ # ── Migration SEO : anciennes URLs DokuWiki sur ce sous-domaine ──
+ # Si l'ancien DokuWiki était hébergé ici avant la migration MkDocs
+
+ # /doku.php?id=start → /
+ RewriteCond %{QUERY_STRING} ^id=start$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/ [R=301,L]
+
+ # /doku.php?id=alpinux:start → /alpinux/
+ RewriteCond %{QUERY_STRING} ^id=alpinux(%3A|:)start$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/alpinux/ [R=301,L]
+
+ # /doku.php?id=namespace:page → /namespace/page/
+ RewriteCond %{QUERY_STRING} ^id=([a-z0-9_-]+)(%3A|:)([a-z0-9_-]+)$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/%1/%3/ [R=301,L,NE]
+
+ # /doku.php?id=page → /page/
+ RewriteCond %{QUERY_STRING} ^id=([a-z0-9_-]+)$ [NC]
+ RewriteRule ^/doku\.php$ https://wiki.alpinux.org/%1/ [R=301,L,NE]
+
+ # URLs sans slash final → avec slash (cohérence MkDocs)
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_URI} !/$
+ RewriteRule ^(.+)$ $1/ [R=301,L]
+
+ # ── Fichiers statiques MkDocs ────────────────────────────────────
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride None
+ Require all granted
+ DirectoryIndex index.html
+ # Cache long pour les assets versionnés MkDocs
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+ # Pas de cache sur le HTML (contenu mis à jour)
+
+ Header set Cache-Control "public, max-age=3600"
+
+
+
+ # Logs
+ ErrorLog /var/log/apache2/wiki.alpinux.org-error.log
+ CustomLog /var/log/apache2/wiki.alpinux.org-access.log combined
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/wiki.alpinux.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/wiki.alpinux.org/privkey.pem
+
diff --git a/dns/alpinux.org.zone b/dns/alpinux.org.zone
new file mode 100644
index 0000000..743dbc7
--- /dev/null
+++ b/dns/alpinux.org.zone
@@ -0,0 +1,32 @@
+$TTL 60
+@ IN SOA dns.ovh.net. tech.ovh.net. (2026050501 86400 3600 3600000 86400)
+ IN NS dns.ovh.net.
+ IN NS ns.ovh.net.
+ IN MX 10 owni.alpinux.org.
+ IN A 51.91.79.148
+ IN AAAA 2001:41d0:404:200::3f85
+ IN TXT "v=spf1 mx a -all"
+_caldavs._tcp IN SRV 0 1 443 alpinux.yourownnet.fr.
+_caldavs._tcp IN TXT "path=/remote.php/dav/"
+_carddavs._tcp IN SRV 0 1 443 alpinux.yourownnet.fr.
+_carddavs._tcp IN TXT "path=/remote.php/dav/"
+_dmarc IN TXT "v=DMARC1;p=quarantine;pct=100;rua=mailto:postmaster@alpinux.org;sp=quarantine;aspf=r;"
+_mta-sts IN TXT "v=STSv1; id=20260502"
+admin IN CNAME owni.alpinux.org.
+alpid IN CNAME owni.alpinux.org.
+autoconfig IN CNAME owni.alpinux.org.
+cloud IN CNAME owni.alpinux.org.
+default._domainkey IN TXT ( "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArFLSyolwo0KUHCqhb7owCUw1kx/taxCK/gWhqVD3HFeBJbqtli5pckvXp16/Iv54k3m/1Vz+f60q2F1phCPXBlS0fzV8e8Z7DNCLa3PIlbPVbXGleuxXRy6it/3JE+Nt00Zu2Fr4PwgxOV6YaJSxcvXbW2gGjlzhxMJYN/723icBw2ZJbZLqqB4VEMj27W4T0+AtTHk4LQ+S" "eS6lNin+HCljz5bbZjFJkFePnAixTAgy18A0qxHyQbmDx9OLYftRoqwHDcpcRTh4e0a9MEUIdz+te/kKvHm3bsgkKuQbCTHlpDjg7ZD3uhpedrwBJHlJDCSlD5LX9mU1eOcxroIDbwIDAQAB;t=s;" )
+dolibarr IN CNAME owni.alpinux.org.
+dynamic IN CNAME www.alpinux.org.
+feedback IN CNAME www.alpinux.org.
+gitea IN CNAME owni.alpinux.org.
+installparty IN CNAME owni.alpinux.org.
+mta-sts IN CNAME owni.alpinux.org.
+owni IN A 51.91.79.148
+owni IN AAAA 2001:41d0:404:200::3f85
+portail IN CNAME www.alpinux.org.
+static IN CNAME www.alpinux.org.
+wiki IN CNAME owni.alpinux.org.
+www IN A 51.91.79.148
+www IN AAAA 2001:41d0:404:200::3f85
diff --git a/docs/admin.md b/docs/admin.md
new file mode 100644
index 0000000..9d5dd06
--- /dev/null
+++ b/docs/admin.md
@@ -0,0 +1,33 @@
+# admin.alpinux.org
+
+Interface d'administration Alpinux — Flask + AlpID SSO.
+
+## Déploiement
+
+```bash
+cd org.alpinux.owni/admin
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+sudo systemctl restart alpinux-admin
+```
+
+## Variables d'environnement (`/opt/alpinux-admin/.env`)
+
+| Variable | Obligatoire | Description |
+|----------|-------------|-------------|
+| `SECRET_KEY` | oui | Clé Flask (`python3 -c "import secrets; print(secrets.token_hex(32))"`) |
+| `ALPID_CLIENT_ID` | oui | Client Keycloak |
+| `ALPID_CLIENT_SECRET` | oui | Secret Keycloak |
+| `ALPID_DISCOVERY_URL` | oui | `https://alpid.alpinux.org/realms/master/.well-known/openid-configuration` |
+| `ADMIN_GROUPS` | non | Groupes autorisés (défaut : `admins`) |
+| `ADMIN_EMAILS` | non | Emails autorisés (fallback si `groups` absent du token) |
+
+## Apache — directives proxy
+
+```apache
+RequestHeader set X-Forwarded-Proto "https"
+ProxyPreserveHost On
+ProxyPass / http://127.0.0.1:5001/
+ProxyPassReverse / http://127.0.0.1:5001/
+```
diff --git a/docs/certificats.md b/docs/certificats.md
new file mode 100644
index 0000000..1a903f5
--- /dev/null
+++ b/docs/certificats.md
@@ -0,0 +1,186 @@
+# Certificats TLS sur owni
+
+État relevé le 27/09/2026. Deux mécanismes coexistent, et ce n'est pas un
+désordre : c'est une histoire. Ce document dit lequel gère quoi, où chercher
+quand un site passe en « connexion non sécurisée », et ce qui a déjà cassé.
+
+## Qui gère quoi
+
+### certbot — un certificat multi-domaines pour huit sites
+
+`/etc/letsencrypt/live/alpid.alpinux.org/` porte **un seul certificat couvrant
+dix noms** :
+
+```
+alpid.alpinux.org dolibarr.alpinux.org owni.alpinux.org
+alpinux.org gitea.alpinux.org wiki.alpinux.org
+autoconfig.alpinux.org installparty.alpinux.org www.alpinux.org
+cloud.alpinux.org
+```
+
+Huit vhosts le servent directement. Renouvellement : `certbot renew` chaque
+nuit à 3 h 00, par `cronwrap` (donc visible sur `admin.alpinux.org`).
+
+> **Dix noms sur les cent autorisés : le nombre n'est pas un problème, le
+> couplage l'est.** Un seul domaine dont le DNS casse ou dont la validation
+> échoue empêche le renouvellement des neuf autres. Et un nom couvert par un
+> SAN devient un cas particulier que les outils traitent mal — voir l'incident
+> `cloud` plus bas.
+
+### ISPConfig et acme.sh — un certificat par site
+
+ISPConfig émet par acme.sh (`/root/.acme.sh/_ecc/`) et dépose une
+copie dans `/var/www/clients///ssl/-le.crt`. C'est cette
+copie que le vhost lit, jamais l'original.
+
+Concernés : `admin`, `messagerie`, `portail`, `static`.
+
+## Le piège, et il a déjà mordu deux fois
+
+**ISPConfig ne rafraîchit sa copie que lorsqu'il *crée* le certificat.** Un
+renouvellement par certbot passe inaperçu : le vhost continue de servir
+l'ancien fichier jusqu'à son expiration, puis le site tombe. Re-sauvegarder le
+site dans l'interface ne suffit pas à déclencher la copie.
+
+| Quand | Quoi | Vu le |
+|---|---|---|
+| 1er août 2026 | `static` et `portail` perdent leur HTTPS | 5 septembre |
+| 4 août 2026 | `cloud` perd son HTTPS | 27 septembre |
+
+Cinq semaines, puis sept. C'est le délai qu'il faut pour que quelqu'un
+s'aperçoive qu'un site ne répond plus.
+
+## Le dispositif qui l'évite aujourd'hui
+
+1. **Le hook** `/etc/letsencrypt/renewal-hooks/deploy/ispconfig-cert.sh`
+ s'exécute après chaque renouvellement certbot. Il itère sur
+ `$RENEWED_DOMAINS` — **tous** les noms du certificat, SAN compris —, cherche
+ `*/ssl/${domain}-le.crt` sous `/var/www/clients` et le met à jour. C'est le
+ cas nominal.
+2. **Le filet** `/usr/local/sbin/owni-certs-sync.sh`, chaque nuit à 4 h 20,
+ rattrape ce que le hook n'a pas traité : hook absent lors d'un
+ renouvellement passé, copie écrasée par ISPConfig, certificat posé à la
+ main. Mode audit par défaut, `--fix` pour corriger, alerte à 21 jours.
+3. **La surveillance** : depuis le 27/09/2026 ce script passe par `cronwrap`,
+ donc son état remonte sur `admin.alpinux.org`. Auparavant il alertait par
+ `MAILTO=root`, c'est-à-dire nulle part — l'expiration de `cloud` a été
+ signalée chaque nuit pendant 54 jours sans que personne la voie.
+
+> Retenir de cette histoire : **une alerte qui n'atteint personne n'est pas une
+> alerte.** Le script fonctionnait parfaitement ; c'est sa sortie qui se
+> perdait.
+
+## Diagnostiquer
+
+### La seule méthode qui ne dépend pas du mécanisme
+
+Ce que voit un visiteur, quel que soit l'outil qui a émis le certificat :
+
+```sh
+for d in messagerie.alpinux.org alpinux.org wiki.alpinux.org \
+ gitea.alpinux.org cloud.alpinux.org; do
+ fin=$(echo | openssl s_client -servername $d -connect $d:443 2>/dev/null \
+ | openssl x509 -noout -enddate | cut -d= -f2)
+ echo "$d : $(( ($(date -d "$fin" +%s) - $(date +%s)) / 86400 )) jours"
+done
+```
+
+Un `echo | openssl s_client` qui ne rend rien = TLS cassé, donc site
+inaccessible en HTTPS.
+
+### L'audit complet, sur le serveur
+
+```sh
+sudo /usr/local/sbin/owni-certs-sync.sh # audit, n'écrit rien
+sudo /usr/local/sbin/owni-certs-sync.sh --fix # corrige ce qu'il peut
+```
+
+Il rend `0` si tout est cohérent, `1` s'il subsiste une anomalie. Sa sortie
+distingue :
+
+- `ok` — à jour ;
+- `ORPHELIN` (majuscules) — **expiré**, c'est une panne ;
+- `orphelin` (minuscules) — inconnu de certbot mais valide, simple
+ avertissement ;
+- `inutilisé` — un certificat existe et aucun service ne le lit.
+
+### Quel certificat un vhost lit-il vraiment
+
+```sh
+for f in /etc/apache2/sites-enabled/*.vhost; do
+ cert=$(sudo awk '/SSLCertificateFile/ {print $2; exit}' "$f")
+ [ -n "$cert" ] && printf "%-34s %s\n" "$(basename "$f")" "$cert"
+done
+```
+
+### Ce que couvre un certificat multi-domaines
+
+```sh
+sudo openssl x509 -noout -text -in /etc/letsencrypt/live/alpid.alpinux.org/fullchain.pem \
+ | grep -A1 "Subject Alternative Name"
+```
+
+## Réparer un site dont le certificat a expiré
+
+Cas rencontré le 27/09/2026 avec `cloud.alpinux.org`.
+
+**1. Établir ce qui manque vraiment.** Le nom est-il couvert par un certificat
+valide existant ? S'il est dans le SAN, il n'y a rien à émettre — seulement à
+recopier.
+
+**2. Recopier le certificat valide à l'emplacement que le vhost attend**, en
+gardant l'ancien plutôt qu'en l'écrasant :
+
+```sh
+D=/var/www/clients/client1/web19/ssl
+L=/etc/letsencrypt/live/alpid.alpinux.org
+
+sudo cp -a $D/cloud.alpinux.org-le.crt $D/cloud.alpinux.org-le.crt.expire-AAAAMMJJ
+sudo cp -a $D/cloud.alpinux.org-le.key $D/cloud.alpinux.org-le.key.expire-AAAAMMJJ
+sudo cp $L/fullchain.pem $D/cloud.alpinux.org-le.crt
+sudo cp $L/privkey.pem $D/cloud.alpinux.org-le.key
+sudo chmod 644 $D/cloud.alpinux.org-le.crt
+sudo chmod 600 $D/cloud.alpinux.org-le.key
+
+sudo apache2ctl configtest && sudo systemctl reload apache2
+```
+
+**3. Vérifier de l'extérieur**, pas seulement que la commande a rendu la main.
+
+Une fois le fichier en place, **le hook le maintiendra** : il cherche
+`*/ssl/${domain}-le.crt` et ne peut mettre à jour que ce qui existe. C'est
+pourquoi cette réparation n'est pas un pansement.
+
+**Alternative propre**, si le domaine mérite son propre certificat : le
+réémettre depuis ISPConfig (*Sites → le site → SSL → Let's Encrypt*, décocher,
+enregistrer, recocher, enregistrer).
+
+## Ce qui reste en suspens
+
+**Neuf certificats ISPConfig ne servent à rien.** Chaque site couvert par le
+SAN possède en plus un certificat à son nom, généré et jamais lu :
+
+```
+inutilisé wiki.alpinux.org, gitea.alpinux.org, alpid.alpinux.org,
+ dolibarr.alpinux.org, installparty.alpinux.org,
+ autoconfig.alpinux.org, alpinux.org, owni.alpinux.org, cloud…
+```
+
+Rien n'est cassé de ce fait, et les supprimer demanderait de vérifier un par
+un qu'aucun service — Postfix, Dovecot, pure-ftpd — ne les lit. Le script les
+signale précisément pour éviter qu'on cesse de mettre à jour un fichier qui
+sert ailleurs.
+
+**`owni-certs-sync.sh` raisonne par nom de dossier, pas par SAN.** Il cherche
+`/etc/letsencrypt/live//` et classe « orphelin » tout nom qui n'en a
+pas — même couvert par le SAN d'un autre. C'est pourquoi il a su *signaler*
+l'expiration de `cloud` sans pouvoir la *réparer*. Lui apprendre à lire les
+SAN le rendrait autonome sur ce cas.
+
+## Le script n'est pas versionné
+
+`/usr/local/sbin/owni-certs-sync.sh` et le hook n'existent **que sur la
+machine**. Choix assumé : ce sont des rustines autour d'un comportement
+d'ISPConfig, et les versionner reviendrait à les pérenniser. Mais cela veut
+dire qu'un serveur reconstruit les perdra, avec la connaissance qu'ils
+portent — d'où ce document, qui doit suffire à les réécrire.
diff --git a/docs/courrier-sortant.md b/docs/courrier-sortant.md
new file mode 100644
index 0000000..56c9487
--- /dev/null
+++ b/docs/courrier-sortant.md
@@ -0,0 +1,141 @@
+# Courrier sortant d'owni
+
+Ce qui vaut pour **tout** le courrier de la machine, quel que soit le service
+qui l'émet — messagerie, Dolibarr, Gitea, avis système. Ce qui est propre à
+l'application de publipostage est dans son dépôt,
+[alpinux.messagerie](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.messagerie) :
+en-têtes de liste, classement des rebonds, masquage des adresses, hygiène de
+liste.
+
+État au 27/09/2026.
+
+## Tout sort par un relais
+
+**Depuis les 25-26 septembre 2026**, Postfix ne remet plus directement :
+
+```
+relayhost = [mail.acemail.fr]:587
+smtp_sasl_auth_enable = yes
+```
+
+| | |
+|---|---|
+| IP qui parle aux destinataires | `176.9.125.188` (le relais) |
+| IP de la machine | `51.91.79.148` — n'émet plus |
+| Compte de soumission | `messagerie@alpinux.org` |
+
+Vérifié dans les logs : les messages de campagne sortent bien par
+`mail.acemail.fr[176.9.125.188]:587`.
+
+### Trois conséquences
+
+**La réputation d'expéditeur n'est plus la nôtre seule.** Un rejet de plus
+compte contre une IP partagée avec les autres clients de l'hébergeur. Cela
+rend l'hygiène de liste plus impérative, pas moins.
+
+**Le blocage Microsoft sur `51.91.79.148` n'a plus d'objet.** Cette IP
+n'émet plus ; la demande de délistage a été abandonnée pour cette raison.
+
+**Un plafond d'envoi s'ajoute**, celui du relais, et il n'est pas connu. Le
+franchir ne produirait aucune erreur applicative : les messages
+s'accumuleraient en `status=deferred` dans la file de Postfix, avec un code
+`4.x.x` venu du relais.
+
+### L'enveloppe doit survivre au relais
+
+La messagerie utilise des adresses de retour variables —
+`bounce+@alpinux.org` — pour savoir quel envoi a rebondi. Un relais
+authentifié réécrit volontiers l'enveloppe, soit pour la faire correspondre au
+compte SASL, soit en SRS.
+
+**Vérifié le 27/09/2026 : celui-ci la préserve.** Un message parti par le
+relais portait bien `Return-Path: ` à l'arrivée.
+
+> À revérifier après **tout** changement chez l'hébergeur du relais. La panne
+> serait silencieuse : aucune erreur, aucun log, simplement plus un seul
+> rebond relevé — et une liste qui se dégrade sans que rien ne le signale.
+>
+> Le symptôme à guetter en base : des rebonds qui arrivent tous **orphelins**,
+> non rattachés à leur envoi.
+
+## DNS du courrier
+
+```
+SPF v=spf1 a mx include:_spf.acemail.fr -all
+DKIM sélecteur « default », 2048 bits, signé par owni avant le relais
+DMARC v=DMARC1; p=quarantine; pct=100; rua=mailto:postmaster@alpinux.org; aspf=r
+MX 10 owni.alpinux.org
+```
+
+`_spf.acemail.fr` autorise `ip4:176.9.125.188` : le relais est donc couvert.
+
+**DKIM est signé par owni avant la remise au relais.** C'est lui qui aligne
+DMARC si SPF venait à échouer — par exemple si l'hébergeur changeait d'IP sans
+mettre son SPF à jour.
+
+### Ne pas rouvrir l'IPv6 sortante sans précaution
+
+L'envoi est forcé en IPv4 (`-o inet_protocols=ipv4` sur le transport `smtp` de
+`master.cf`). L'IPv6 a été ouverte le 21/09/2026 et refermée le 22 : en
+vingt-quatre heures, `2001:41d0:404:200::3f85` s'est fait lister par Spamhaus
+(CSS et XBL) et Gandi rejetait en `554`. Une IP neuve qui se met à émettre des
+campagnes est exactement le profil que cible la liste CSS.
+
+Postfix **ne se replie pas** en IPv4 quand un MX rejette en dur : le message
+rebondit sèchement.
+
+## Limites de débit
+
+| Où | Réglage | Protège de |
+|---|---|---|
+| Postfix, soumission | `smtpd_client_message_rate_limit` = 400/h | un compte compromis |
+| Postfix, sortie | `smtp_destination_rate_delay` = 1 s | le `450` des MX distants |
+| Relais, en aval | **inconnue** | ce qu'on ignore |
+
+Le compteur de Postfix est **partagé** par tous les services de la machine —
+portail, Gitea, Dolibarr, messagerie. Un service qui s'emballe pénalise les
+autres.
+
+Profil réel mesuré le 24/09/2026 : une campagne de **254 messages en 10
+minutes**, soit environ 25 par minute. C'est ce débit instantané qu'il faut
+confronter aux limites du relais, pas une moyenne horaire.
+
+## Les boîtes du service
+
+`alpinux.org` est un **domaine virtuel** (`virtual_mailbox_domains` en MySQL,
+livraison LMTP vers Dovecot) : `/etc/aliases` ne s'y applique pas. Une adresse
+qui doit recevoir a besoin d'une vraie boîte.
+
+| Boîte | Reçoit |
+|---|---|
+| `bounce@alpinux.org` | les rapports de non-remise, via VERP |
+| `postmaster@alpinux.org` | rapports DMARC, plaintes FBL, courrier d'opérateur |
+| `abuse@alpinux.org` | alias de `postmaster@` (RFC 2142) |
+| `messagerie@alpinux.org` | expédie, et reçoit les réponses humaines |
+
+`recipient_delimiter = +` est actif : tout ce qui arrive sur
+`bounce+[@alpinux.org` tombe dans `bounce@`, l'adresse complète restant
+lisible dans `Delivered-To`. **Le VERP en dépend entièrement.**
+
+## Vérifier
+
+```sh
+# Par où sort le courrier
+postconf -h relayhost sender_canonical_maps smtp_generic_maps recipient_delimiter
+
+# Ce que les destinataires voient réellement
+dig +short TXT alpinux.org | grep spf
+dig +short TXT _dmarc.alpinux.org
+dig +short TXT _spf.acemail.fr
+
+# Les remises récentes, et par quel relais
+sudo grep "relay=" /var/log/mail.log | grep -oE "relay=[^,]*" | sort | uniq -c | sort -rn | head
+
+# Les messages différés : le symptôme d'un plafond atteint
+sudo grep "status=deferred" /var/log/mail.log | tail -5
+```
+
+Le miroir le plus utile reste **les rapports DMARC** : les logs disent ce qui
+est parti, les rapports disent ce qui est *arrivé*, vu d'en face — y compris ce
+qui est parti sans qu'on le sache. Ils sont dépouillés par la messagerie et
+consultables sur `/postmaster`.
diff --git a/docs/dynamic.md b/docs/dynamic.md
new file mode 100644
index 0000000..bb64e2d
--- /dev/null
+++ b/docs/dynamic.md
@@ -0,0 +1,31 @@
+# dynamic.alpinux.org
+
+Quiz et jeux — Flask + AlpID SSO.
+
+## Déploiement
+
+```bash
+cd org.alpinux.owni/dynamic
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+sudo systemctl restart dynamic.alpinux.org
+```
+
+## Variables d'environnement
+
+| Variable | Obligatoire | Description |
+|----------|-------------|-------------|
+| `SECRET_KEY` | oui | Clé Flask |
+| `ALPID_CLIENT_ID` | oui | Client Keycloak |
+| `ALPID_CLIENT_SECRET` | oui | Secret Keycloak |
+| `ALPID_DISCOVERY_URL` | oui | `https://alpid.alpinux.org/realms/master/.well-known/openid-configuration` |
+
+## Apache — directives proxy
+
+```apache
+RequestHeader set X-Forwarded-Proto "https"
+ProxyPreserveHost On
+ProxyPass / http://127.0.0.1:5000/
+ProxyPassReverse / http://127.0.0.1:5000/
+```
diff --git a/docs/proxy-calendar.md b/docs/proxy-calendar.md
new file mode 100644
index 0000000..dbaf5c1
--- /dev/null
+++ b/docs/proxy-calendar.md
@@ -0,0 +1,69 @@
+# Proxy cache calendrier public — `alpinux.org/public-calendars/`
+
+## Rôle
+
+Ce composant expose le calendrier Nextcloud d'Alpinux en iCal public, avec mise en cache locale pour éviter de solliciter le serveur Nextcloud à chaque requête.
+
+## Localisation
+
+| Élément | Chemin |
+|---|---|
+| Script PHP | `/var/www/clients/client1/web11/web/public-calendars/index.php` |
+| Cache ICS | `/var/www/clients/client1/web11/web/public-calendars/{token}.ics` |
+| Lien symlink | `/var/www/alpinux.org/web/` → même racine |
+
+## Flux de données
+
+```
+Visiteur / JS page d'accueil
+ │
+ ▼
+GET https://alpinux.org/public-calendars/{token}
+ │
+ ▼
+index.php
+ ├── Cache valide (< 1 h) ? ──▶ sert le fichier .ics local
+ └── Cache expiré ou absent ──▶ fetch Nextcloud DAV
+ │
+ ▼
+ https://alpinux.yourownnet.fr/remote.php/dav/public-calendars/{token}?export
+ │
+ ▼
+ Écrit {token}.ics ──▶ sert le fichier
+```
+
+## TTL du cache
+
+**3 600 secondes (1 heure).** Passé ce délai, la prochaine requête déclenche un nouveau fetch Nextcloud et écrase le fichier.
+
+## Modes d'accès
+
+| URL | Comportement |
+|---|---|
+| `/public-calendars/{token}` | Flux iCal brut (usage principal) |
+| `/public-calendars/{token}?p=webcal` | Redirige vers `webcal://…` pour abonnement depuis une appli calendrier |
+| `/public-calendars/{token}?p=html` | Aperçu HTML des 8 prochains événements (dans les 4 mois) |
+
+## Invalidation manuelle du cache
+
+Lorsqu'un événement est ajouté ou modifié dans Nextcloud et doit apparaître immédiatement (sans attendre 1 heure) :
+
+```bash
+sudo rm /var/www/clients/client1/web11/web/public-calendars/n5BWPYsxw7FCYozM.ics
+```
+
+La prochaine requête HTTP sur `/public-calendars/n5BWPYsxw7FCYozM` régénère le cache automatiquement.
+
+## Token du calendrier Alpinux
+
+| Calendrier | Token |
+|---|---|
+| Alpinux évènements (`president@alpinux.org`) | `n5BWPYsxw7FCYozM` |
+
+## Source Nextcloud
+
+Le calendrier est géré dans l'interface Nextcloud de l'association. L'événement doit impérativement être placé dans le calendrier **« Alpinux évènements »** pour apparaître dans le flux public. Un événement dans un autre agenda (personnel, secondaire) ne sera pas exporté.
+
+## Relation avec la page d'accueil
+
+`alpinux.org/index.html` charge le flux via `fetch('/public-calendars/n5BWPYsxw7FCYozM')` côté client (JS) pour alimenter la grille planning et les cartes « Prochains rendez-vous ». Voir `home/index.html` pour le code de parsing iCal.
diff --git a/docs/sauvegardes.md b/docs/sauvegardes.md
new file mode 100644
index 0000000..7cfe61f
--- /dev/null
+++ b/docs/sauvegardes.md
@@ -0,0 +1,233 @@
+# Sauvegardes d'owni — faire, et surtout défaire
+
+Une sauvegarde qu'on n'a jamais restaurée est une hypothèse. Ce document dit
+autant comment récupérer que comment sauvegarder, et la première partie utile
+est la seconde.
+
+## Ce qui est sauvegardé
+
+`scripts/sauvegarder-owni.sh`, lancé **depuis le poste**, tire chaque nuit les
+sept bases de la machine :
+
+| Base | Service |
+|---|---|
+| `c1_messagerie_db` | messagerie.alpinux.org |
+| `c1dolibarr` | fichier des adhérents |
+| `c1gitea` | tous les dépôts |
+| `c1alpid` | le SSO Keycloak |
+| `c1_installparty` | installparty.alpinux.org |
+| `c1_evenements` | événements |
+| `dbispconfig` | la configuration d'ISPConfig elle-même |
+
+Environ **1,5 Mo compressés** pour l'ensemble. Rangées par jour dans
+`~/Sauvegardes/owni/AAAA-MM-JJ/`, gardées 30 jours.
+
+Tiré depuis le poste et non poussé depuis owni : une machine compromise ne
+doit pas pouvoir atteindre les sauvegardes de ce qu'elle héberge.
+
+### Ce qui n'est pas sauvegardé
+
+- **Les fichiers des sites.** Le code est dans Gitea, et Gitea est sur cette
+ même machine — ce qui ne protège de rien. Les dépôts sont clonés sur le
+ poste, ce qui protège un peu mieux.
+- **Les boîtes mail** (`/var/vmail`, 1,3 Go) : les rebonds et le courrier
+ postmaster, dont l'essentiel est déjà repris en base.
+- **`/etc`**, donc la configuration système hors ISPConfig.
+
+Ces trois points sont des choix, pas des oublis. À reconsidérer le jour où
+l'un d'eux manquera.
+
+## Lancer
+
+```sh
+cd ~/Projects/org.alpinux.owni/infra
+./scripts/sauvegarder-owni.sh --essai # montre, n'écrit rien
+./scripts/sauvegarder-owni.sh
+```
+
+En cron, sur le poste — `crontab -e` :
+
+```cron
+30 3 * * * cd $HOME/Projects/org.alpinux.owni/infra && ./scripts/sauvegarder-owni.sh >> $HOME/Sauvegardes/owni/journal.txt 2>&1
+```
+
+Le poste doit être allumé à 3 h 30. S'il ne l'est pas, la nuit est sautée :
+regarder `journal.txt` de temps en temps, ou lancer le script à la main après
+une longue absence.
+
+> **Ces fichiers contiennent des données personnelles** — 258 adresses
+> d'abonnés et le fichier des adhérents. Le dossier est en `700`, les fichiers
+> en `600`. Sur un poste portable, envisager un chiffrement du disque ou des
+> archives.
+
+---
+
+# Restaurer
+
+## Avant tout : regarder ce qu'on va restaurer
+
+Ne jamais restaurer une sauvegarde sans l'avoir ouverte. Une sauvegarde
+tronquée écrase des données saines.
+
+```sh
+S=~/Sauvegardes/owni/2026-09-27/c1_messagerie_db_2026-09-27.sql.gz
+
+zcat "$S" | tail -2 # doit finir par « Dump completed on … »
+zcat "$S" | grep -c "INSERT INTO"
+zcat "$S" | grep -oE "^CREATE TABLE \`[a-z_]+\`" | wc -l
+```
+
+Le script refuse déjà d'enregistrer un dump sans sa ligne finale, mais
+l'habitude de vérifier vaut mieux que la confiance.
+
+## Essayer d'abord à côté, jamais directement
+
+**La bonne méthode** : restaurer dans une base temporaire, regarder, puis
+basculer. On ne remplace jamais une base en production par un fichier qu'on
+n'a pas inspecté.
+
+```sh
+scp ~/Sauvegardes/owni/2026-09-27/c1_messagerie_db_2026-09-27.sql.gz \
+ abonnelc@owni.alpinux.org:/tmp/
+
+ssh abonnelc@owni.alpinux.org
+sudo mysql -e "CREATE DATABASE essai_restauration CHARACTER SET utf8mb4;"
+zcat /tmp/c1_messagerie_db_2026-09-27.sql.gz | sudo mysql essai_restauration
+
+# Est-ce bien ce qu'on croit ?
+sudo mysql -t essai_restauration -e "
+ SELECT COUNT(*) AS abonnes FROM abonnes;
+ SELECT COUNT(*) AS campagnes FROM campagnes;
+ SELECT MAX(creee_le) AS derniere FROM campagnes;"
+```
+
+Comparer avec la production **avant** de décider quoi que ce soit :
+
+```sh
+sudo mysql -t c1_messagerie_db -e "
+ SELECT COUNT(*) AS abonnes FROM abonnes;
+ SELECT COUNT(*) AS campagnes FROM campagnes;"
+```
+
+Une fois satisfait, effacer l'essai : `sudo mysql -e "DROP DATABASE essai_restauration;"`
+
+## Restaurer pour de bon
+
+### Le piège de la messagerie : arrêter le worker d'abord
+
+`bin/worker-envoi.php` tourne **chaque minute**. Restaurer la base pendant
+qu'il tourne, c'est lui rendre une file d'envois déjà partis — et
+**réexpédier une campagne à 250 personnes**.
+
+```sh
+# 1. Couper le cron de la messagerie
+sudo mv /etc/cron.d/messagerie /root/messagerie.cron.suspendu
+# Vérifier qu'aucun worker n'est en cours
+pgrep -af worker-envoi.php || echo "aucun worker en cours"
+
+# 2. Mettre la base de côté plutôt que l'écraser
+sudo mysqldump --single-transaction c1_messagerie_db \
+ | gzip -c > /root/avant-restauration-$(date +%Y%m%d-%H%M).sql.gz
+
+# 3. Restaurer
+zcat /tmp/c1_messagerie_db_2026-09-27.sql.gz | sudo mysql c1_messagerie_db
+
+# 4. Vérifier avant de rouvrir les vannes
+sudo mysql -t c1_messagerie_db -e "
+ SELECT statut, COUNT(*) FROM envois GROUP BY statut;
+ SELECT COUNT(*) FROM abonnes WHERE statut='actif';"
+
+# 5. Remettre le cron — et seulement alors
+sudo mv /root/messagerie.cron.suspendu /etc/cron.d/messagerie
+```
+
+Entre 1 et 5, le site reste consultable : c'est l'envoi qui est suspendu, pas
+l'application.
+
+> Attention au nom du fichier dans `/etc/cron.d/` : **un point dans le nom et
+> il est ignoré en silence**. `messagerie` fonctionne, `messagerie.cron` non.
+> C'est pourquoi on le déplace vers `/root/` plutôt que de le renommer sur
+> place.
+
+### Les autres services : arrêter avant, redémarrer après
+
+```sh
+# Gitea
+sudo systemctl stop gitea
+zcat /tmp/c1gitea_*.sql.gz | sudo mysql c1gitea
+sudo systemctl start gitea
+
+# Keycloak (le SSO) — plus personne ne peut se connecter nulle part pendant
+sudo systemctl stop keycloak
+zcat /tmp/c1alpid_*.sql.gz | sudo mysql c1alpid
+sudo systemctl start keycloak
+```
+
+Restaurer `c1gitea` sans arrêter Gitea laisse le service avec un état en
+mémoire qui ne correspond plus à la base : au mieux des erreurs, au pire des
+écritures par-dessus la restauration.
+
+### Ne restaurer qu'une table
+
+Le cas le plus fréquent — on a vidé une table par erreur, le reste est bon.
+
+```sh
+# Extraire la seule table qui compte
+zcat c1_messagerie_db_2026-09-27.sql.gz \
+ | sed -n '/^-- Table structure for table `abonnes`/,/^-- Table structure for table `abonnements`/p' \
+ > /tmp/abonnes.sql
+
+grep -c "INSERT INTO" /tmp/abonnes.sql # regarder avant d'appliquer
+sudo mysql c1_messagerie_db < /tmp/abonnes.sql
+```
+
+Le `sed` va d'une table à la suivante dans l'ordre du dump : vérifier que la
+table nommée en second est bien celle qui suit, sinon on emporte trop ou trop
+peu.
+
+### `dbispconfig` : en dernier recours seulement
+
+La configuration d'ISPConfig décrit les sites, les bases, les boîtes mail et
+les certificats. La restaurer remet **toute la machine** dans un état
+antérieur, y compris des sites créés depuis. À ne faire que pour reconstruire
+un serveur perdu, jamais pour corriger un détail.
+
+## Après une restauration
+
+- **Vérifier ce qui compte**, pas seulement que la commande a rendu la main :
+ compter les lignes des tables principales, ouvrir une page, envoyer un
+ message de test.
+- **La messagerie** : vérifier que la file d'envois ne contient pas de vieux
+ messages en attente avant de rouvrir le cron.
+- **Noter ce qui s'est passé** — dans le ticket, ou ici. Une restauration est
+ rare ; ce qu'on y apprend se perd si personne ne l'écrit.
+
+## Ce qui a été vérifié, et ce qui ne l'est pas
+
+**Le 27/09/2026, la sauvegarde de `c1_messagerie_db` a été restaurée pour de
+vrai** — dans une base séparée, sur un MariaDB 11 de développement. Elle est
+donc lisible et complète :
+
+| | |
+|---|---|
+| tables | 21 |
+| abonnés | 258, dont 238 actifs |
+| campagnes | 48 |
+| envois | 255 |
+| rebonds | 35 |
+
+Ce sont les chiffres de la production. Le dump n'est pas une intention : il
+remonte.
+
+**Ce qui reste non vérifié :**
+
+- la restauration **sur owni même**, avec l'arrêt du cron et la bascule
+ décrits plus haut. C'est la partie où l'on peut casser quelque chose, et
+ c'est donc celle qui mériterait un essai un jour de calme ;
+- les six autres bases, dont la sauvegarde a réussi mais qu'on n'a pas
+ rechargées ;
+- le comportement du cron nocturne dans la durée — poste éteint, réseau
+ coupé, disque plein.
+
+Voir le ticket
+[#1](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-owni/issues/1).
diff --git a/docs/static.md b/docs/static.md
new file mode 100644
index 0000000..7c8a954
--- /dev/null
+++ b/docs/static.md
@@ -0,0 +1,156 @@
+# infra/static — static.alpinux.org
+
+Configuration de référence pour `static.alpinux.org` (CDN assets Alpinux + tableau de bord).
+
+## Fichiers
+
+| Fichier | Description |
+|---------|-------------|
+| `static.alpinux.org.vhost.conf` | Configuration Apache — **référence audit, non déployé manuellement** |
+| `static-cdn.service` | Systemd unit pour l'app Flask (tableau de bord) |
+
+## Gestion via ISPConfig
+
+Le VirtualHost est **créé et géré par ISPConfig** (`https://owni.alpinux.org:8080`).
+
+Pour recréer ou reconfigurer le site :
+1. *Sites → Ajouter un site web* — domaine `static.alpinux.org`
+2. Onglet *SSL* → activer **Let's Encrypt**
+3. PHP désactivé (onglet Options avancées)
+
+## Tableau de bord Flask (app/)
+
+L'app Flask (`static/app/`) sert la page d'accueil de `static.alpinux.org` après authentification AlpID.
+
+### Déploiement
+
+Depuis le poste de développement :
+
+```bash
+cd org.alpinux.owni/static
+scripts/deploy-app.sh
+```
+
+Le script :
+- rsync `app/` → `/opt/static-cdn/` sur le serveur (hors web root)
+- crée le venv Python et installe les dépendances
+- installe et démarre `static-cdn.service` via systemd
+
+Créer le fichier `/opt/static-cdn/.env` sur le serveur après le premier déploiement :
+
+```bash
+ssh alpinux.org
+nano /opt/static-cdn/.env
+sudo systemctl restart static-cdn
+```
+
+### Apache — directives proxy
+
+Ajouter dans ISPConfig → Sites → `static.alpinux.org` → onglet **Options** → champ
+**Directives Apache personnalisées SSL** :
+
+```apache
+# Fichiers CDN publics — Apache sert directement depuis DocumentRoot
+ProxyPass /logo/ !
+ProxyPass /wiki/ !
+ProxyPass /error/ !
+ProxyPass /favicon.ico !
+ProxyPass /robots.txt !
+
+# Tableau de bord → Flask sur 127.0.0.1:5003
+RequestHeader set X-Forwarded-Proto "https"
+ProxyPreserveHost On
+ProxyPass / http://127.0.0.1:5003/
+ProxyPassReverse / http://127.0.0.1:5003/
+```
+
+Note : `/stats/` n'est **pas** exclu — il est servi par Flask avec authentification AlpID.
+
+`RequestHeader set X-Forwarded-Proto "https"` est indispensable : sans lui, Flask génère
+le `redirect_uri` en HTTP et Keycloak refuse le callback.
+
+### Variables d'environnement (`/opt/static-cdn/.env`)
+
+| Variable | Obligatoire | Valeur / Description |
+|----------|-------------|----------------------|
+| `SECRET_KEY` | oui | Chaîne aléatoire longue (`python3 -c "import secrets; print(secrets.token_hex(32))"`) |
+| `ALPID_CLIENT_ID` | oui | `static-cdn` (client créé dans Keycloak) |
+| `ALPID_CLIENT_SECRET` | oui | Secret généré par Keycloak |
+| `ALPID_DISCOVERY_URL` | oui | `https://alpid.alpinux.org/realms/master/.well-known/openid-configuration` |
+| `ADMIN_GROUPS` | non | Groupes Keycloak autorisés (défaut : `admins`) — nécessite le claim `groups` activé côté Keycloak |
+| `ADMIN_EMAILS` | non | Emails autorisés séparés par virgule — fallback si `groups` n'est pas dans le token |
+| `ASSETS_ROOT` | oui | `/var/www/clients/client1/web17/web` |
+| `STATS_FILE` | non | `/opt/static-cdn/goaccess.html` — rapport HTML GoAccess |
+| `STATS_JSON` | non | `/opt/static-cdn/goaccess.json` — statistiques par fichier (badges "vues" dans browse) |
+
+### Keycloak — realm et client
+
+- **Realm** : `master` (seul realm actif sur cette instance AlpID)
+- **Client ID** : `static-cdn`
+- **Redirect URI** : `https://static.alpinux.org/auth/callback`
+- **Scopes demandés** : `openid profile email` — le scope `groups` n'est **pas** activé
+ sur ce client ; l'autorisation se fait via `ADMIN_EMAILS` ou, si les deux sont absents,
+ tous les utilisateurs AlpID sont acceptés
+
+### Logique d'autorisation (priorité décroissante)
+
+1. Claim `groups` présent dans le token → vérification contre `ADMIN_GROUPS`
+2. `ADMIN_EMAILS` défini dans `.env` → l'email du compte doit figurer dans la liste
+3. Ni l'un ni l'autre → tout utilisateur authentifié via AlpID est accepté
+
+Pour restreindre à un seul administrateur :
+```
+ADMIN_EMAILS=cedric.alpinux@acemail.fr
+```
+
+## Chemin web root
+
+ISPConfig attribue `/var/www/clients/client1/web17/web` au site `static.alpinux.org`.
+Appartient à `web17:client1` (`drwx--x---`) — `sudo rsync` requis pour y écrire.
+
+### ACL pour l'app Flask
+
+Le service tourne en tant qu'`abonnelc` qui n'est ni `web17` ni dans `client1`.
+Une ACL POSIX lui donne les droits de lecture nécessaires :
+
+```bash
+sudo setfacl -R -m u:abonnelc:rx /var/www/clients/client1/web17/web
+sudo setfacl -d -m u:abonnelc:rx /var/www/clients/client1/web17/web
+```
+
+ISPConfig peut réinitialiser les permissions du web root lors d'une modification du site —
+relancer ces deux commandes si le tableau de bord retourne une `PermissionError`.
+
+## Statistiques GoAccess
+
+### Génération manuelle
+
+```bash
+sudo goaccess /var/log/ispconfig/httpd/static.alpinux.org/access.log \
+ --log-format=COMBINED --no-global-config \
+ --output=/opt/static-cdn/goaccess.html
+sudo goaccess /var/log/ispconfig/httpd/static.alpinux.org/access.log \
+ --log-format=COMBINED --no-global-config \
+ --output=/opt/static-cdn/goaccess.json
+sudo chown abonnelc: /opt/static-cdn/goaccess.html /opt/static-cdn/goaccess.json
+```
+
+### Cron quotidien (root)
+
+```cron
+0 4 * * * goaccess /var/log/ispconfig/httpd/static.alpinux.org/access.log \
+ --log-format=COMBINED --no-global-config \
+ --output=/opt/static-cdn/goaccess.html && \
+ goaccess /var/log/ispconfig/httpd/static.alpinux.org/access.log \
+ --log-format=COMBINED --no-global-config \
+ --output=/opt/static-cdn/goaccess.json && \
+ chown abonnelc: /opt/static-cdn/goaccess.html /opt/static-cdn/goaccess.json
+```
+
+Le rapport JSON alimente les **badges "Vues"** dans le navigateur de fichiers (browse).
+Sans ce fichier, les badges sont masqués.
+
+## Accès rsync (assets CDN)
+
+Les assets sont synchronisés via `static/scripts/push-assets.sh` / `pull-assets.sh`.
+Le dossier `app/` est exclu du rsync (déployé séparément, non servi depuis le web root).
diff --git a/docs/wiki.md b/docs/wiki.md
new file mode 100644
index 0000000..49e9d1a
--- /dev/null
+++ b/docs/wiki.md
@@ -0,0 +1,23 @@
+# wiki.alpinux.org
+
+Documentation publique Alpinux — MkDocs Material.
+
+## Déploiement
+
+```bash
+cd org.alpinux.owni/wiki
+mkdocs build --strict
+rsync -rlcz --delete site/ alpinux.org:/var/www/wiki.alpinux.org/web/
+```
+
+## Développement local
+
+```bash
+pip install mkdocs-material
+mkdocs serve # http://localhost:8000
+```
+
+## Notes
+
+- Pas de service systemd — site statique servi directement par Apache
+- Les docs publiques sont dans `wiki/docs/technique/`
diff --git a/scripts/sauvegarder-owni.sh b/scripts/sauvegarder-owni.sh
new file mode 100755
index 0000000..ff00ab2
--- /dev/null
+++ b/scripts/sauvegarder-owni.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+# Sauvegarde des bases d'owni, tirée depuis le poste.
+#
+# scripts/sauvegarder-owni.sh # sauvegarde
+# scripts/sauvegarder-owni.sh --essai # montre ce qui serait fait
+#
+# Tiré depuis le poste et non poussé depuis owni : une machine compromise ne
+# doit pas pouvoir atteindre les sauvegardes de ce qu'elle héberge. C'est le
+# poste qui va chercher, owni n'a aucun accès en retour.
+#
+# Les dumps sont faits ici plutôt que repris de /var/backup : ISPConfig n'en
+# produit qu'un sur six (seule la messagerie y est réglée), et n'en garde
+# qu'une copie. Ce script prend les six et garde ce qu'on lui dit.
+#
+# Ce qu'il ne fait pas : les fichiers des sites. Le code est dans Gitea, les
+# pièces jointes et les boîtes mail sont un autre sujet — voir docs/sauvegardes.md.
+
+set -u -o pipefail
+
+DEST="${SAUVEGARDE_DEST:-$HOME/Sauvegardes/owni}"
+GARDE="${SAUVEGARDE_GARDE:-30}" # jours de rétention
+SSH_OPTS=(-o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=20
+ -i "$HOME/.ssh/id_rsa_owni.alpinux.org")
+HOTE="abonnelc@owni.alpinux.org"
+ESSAI=0
+[ "${1:-}" = "--essai" ] && ESSAI=1
+
+# Les bases applicatives. dbispconfig est à part : c'est la configuration
+# d'ISPConfig, sans laquelle les sites ne se reconstruisent pas à l'identique.
+BASES=(c1_messagerie_db c1dolibarr c1gitea c1alpid c1_installparty c1_evenements dbispconfig)
+
+jour=$(date +%Y-%m-%d)
+cible="$DEST/$jour"
+
+echo "Sauvegarde d'owni vers $cible"
+[ "$ESSAI" = 1 ] && echo "(essai : rien ne sera écrit)"
+
+if [ "$ESSAI" = 0 ]; then
+ mkdir -p "$cible" || { echo "impossible de créer $cible" >&2; exit 1; }
+ chmod 700 "$DEST" "$cible"
+fi
+
+# Une base qui échoue ne doit pas interrompre les autres : mieux vaut cinq
+# sauvegardes sur six qu'aucune, et le compte final dit ce qui manque.
+faites=0
+ratees=()
+
+for base in "${BASES[@]}"; do
+ fichier="$cible/${base}_${jour}.sql.gz"
+ printf ' %-20s ' "$base"
+
+ if [ "$ESSAI" = 1 ]; then
+ echo "→ ${fichier/#$HOME/\~}"
+ continue
+ fi
+
+ # --single-transaction : pas de verrou sur les tables InnoDB, le site
+ # continue de répondre pendant le dump.
+ if ssh "${SSH_OPTS[@]}" "$HOTE" \
+ "sudo mysqldump --no-tablespaces --single-transaction --quick '$base'" \
+ 2>/dev/null | gzip -c > "$fichier"; then
+ taille=$(du -h "$fichier" | cut -f1)
+ # Un dump vide ou tronqué se reconnaît à sa dernière ligne.
+ if zcat "$fichier" 2>/dev/null | tail -2 | grep -q "Dump completed"; then
+ echo "ok $taille"
+ faites=$((faites + 1))
+ else
+ echo "INCOMPLET ($taille) — dump tronqué"
+ ratees+=("$base")
+ fi
+ else
+ echo "ÉCHEC"
+ ratees+=("$base")
+ rm -f "$fichier"
+ fi
+done
+
+[ "$ESSAI" = 1 ] && exit 0
+
+chmod 600 "$cible"/*.sql.gz 2>/dev/null
+
+# Rotation : on efface les jours entiers passés la rétention. Faite après, et
+# seulement si la sauvegarde du jour a réussi — sinon on effacerait les
+# anciennes sans en avoir de nouvelle.
+if [ "$faites" -gt 0 ]; then
+ find "$DEST" -maxdepth 1 -type d -name '20*-*-*' -mtime "+$GARDE" \
+ -exec rm -rf {} + 2>/dev/null
+fi
+
+echo
+echo "$faites base(s) sauvegardée(s) sur ${#BASES[@]}, rétention $GARDE jours."
+if [ ${#ratees[@]} -gt 0 ]; then
+ echo "Échec : ${ratees[*]}" >&2
+ exit 1
+fi
diff --git a/services/alpinux-admin.service b/services/alpinux-admin.service
new file mode 100644
index 0000000..16a92ac
--- /dev/null
+++ b/services/alpinux-admin.service
@@ -0,0 +1,24 @@
+# Systemd unit pour l'app d'administration Alpinux
+# Copier dans /etc/systemd/system/alpinux-admin.service
+# puis : sudo systemctl enable --now alpinux-admin
+
+[Unit]
+Description=Alpinux Admin — admin.alpinux.org (Flask + Gunicorn)
+After=network.target
+
+[Service]
+User=alpinux
+Group=alpinux
+WorkingDirectory=/home/alpinux/site/admin
+EnvironmentFile=/etc/alpinux-admin/config.env
+ExecStart=/home/alpinux/site/admin/venv/bin/gunicorn \
+ --workers 1 \
+ --bind 127.0.0.1:5002 \
+ --access-logfile /var/log/alpinux-admin/access.log \
+ --error-logfile /var/log/alpinux-admin/error.log \
+ app:app
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/services/dynamic.alpinux.org.service b/services/dynamic.alpinux.org.service
new file mode 100644
index 0000000..02fbbde
--- /dev/null
+++ b/services/dynamic.alpinux.org.service
@@ -0,0 +1,24 @@
+# Systemd unit pour l'app Flask dynamic.alpinux.org
+# Copier dans /etc/systemd/system/dynamic-alpinux.service
+# puis : sudo systemctl enable --now dynamic-alpinux
+
+[Unit]
+Description=Alpinux Dynamic — Quiz interactifs (Flask + Gunicorn)
+After=network.target
+
+[Service]
+User=alpinux
+Group=alpinux
+WorkingDirectory=/home/alpinux/dynamic
+EnvironmentFile=/etc/dynamic-alpinux/config.env
+ExecStart=/home/alpinux/dynamic/venv/bin/gunicorn \
+ --workers 2 \
+ --bind 127.0.0.1:5001 \
+ --access-logfile /var/log/dynamic-alpinux/access.log \
+ --error-logfile /var/log/dynamic-alpinux/error.log \
+ app:app
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/services/static-cdn.service b/services/static-cdn.service
new file mode 100644
index 0000000..6d2bc8d
--- /dev/null
+++ b/services/static-cdn.service
@@ -0,0 +1,27 @@
+# Systemd unit pour l'app Flask static.alpinux.org (tableau de bord CDN)
+# Déployé via : static/scripts/deploy-app.sh
+# Opérations manuelles :
+# sudo systemctl enable --now static-cdn
+# sudo systemctl restart static-cdn
+
+[Unit]
+Description=Alpinux Static CDN — Tableau de bord (Flask + Gunicorn)
+After=network.target
+
+[Service]
+User=static-cdn
+Group=static-cdn
+WorkingDirectory=/opt/static-cdn
+EnvironmentFile=/opt/static-cdn/.env
+ExecStart=/opt/static-cdn/venv/bin/gunicorn \
+ --workers 2 \
+ --timeout 120 \
+ --bind 127.0.0.1:5003 \
+ --access-logfile /var/log/static-cdn/access.log \
+ --error-logfile /var/log/static-cdn/error.log \
+ app:app
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
]