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