Absorber alpinux-infra : un seul dépôt pour la machine

Il y avait deux dépôts d'infrastructure et aucune frontière entre eux. La
seule distinction défendable — « documentation » d'un côté, « fichiers de
configuration » de l'autre — ne tenait plus : infra avait fini par contenir
plus de documentation qu'owni, pendant qu'owni décrivait du concret (IP,
bases, comptes).

Tout vient donc ici : conf/ (vhosts de référence), dns/ (export de zone),
services/ (units systemd), scripts/ (sauvegarde) et docs/ (courrier sortant,
certificats, sauvegardes, et le déploiement par service).

Dans ce sens plutôt que l'inverse parce qu'« owni » nomme la machine, là où
« infra » ne dit pas de quoi il s'agit — et c'est le nom que Cédric emploie
spontanément, y compris pour le dossier de secrets.

Les renvois croisés entre les deux dépôts deviennent des liens internes : un
lien vers un dépôt qu'on s'apprête à archiver aurait pourri en silence.

Reste une incohérence, signalée plutôt que corrigée à la hâte :
docs/admin.md, static.md, wiki.md, dynamic.md et proxy-calendar.md décrivent
le déploiement de sites, pas la machine. Ils ont la même place ici que celle
que la messagerie n'avait pas — ils devraient rejoindre leurs dépôts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFbwnJurBwTs7x7t93ecku
This commit is contained in:
Cédrix 2026-09-27 12:55:45 +02:00
parent abbef2304c
commit 89cc919906
19 changed files with 1377 additions and 19 deletions

View file

@ -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) | | `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) | | `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) | | `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 ### 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 ## 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 d'owni. SPF, DKIM, DMARC, les plafonds de Postfix, les boîtes du service et
les pièges qui vont avec : 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 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. 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 ## Sauvegardes
Les sept bases sont tirées chaque nuit vers le poste par 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 pièges, dont le worker de la messagerie qui réexpédierait une campagne — est
écrite à côté : é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, 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. `/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"

View file

@ -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
<VirtualHost *:80>
ServerName admin.alpinux.org
Redirect permanent / https://admin.alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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
</VirtualHost>

View file

@ -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)
<VirtualHost *:80>
ServerName alpinux.org
ServerAlias www.alpinux.org
Redirect permanent / https://alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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 ───────────────────────────────────────────
<Directory /var/www/clients/client1/web1/web>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
DirectoryIndex index.html
</Directory>
# 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
</VirtualHost>

View file

@ -0,0 +1,33 @@
# Apache vhost pour dynamic.alpinux.org
# L'app Flask tourne derrière Gunicorn sur 127.0.0.1:5001
<VirtualHost *:80>
ServerName dynamic.alpinux.org
Redirect permanent / https://dynamic.alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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
</VirtualHost>

View file

@ -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.
<VirtualHost *:80>
ServerName static.alpinux.org
Redirect permanent / https://static.alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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
<Directory /var/www/clients/client1/web17/web>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
</Directory>
# 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
</VirtualHost>

View file

@ -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.
<VirtualHost *:80>
ServerName wiki.alpinux.org
Redirect permanent / https://wiki.alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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 ────────────────────────────────────
<Directory /var/www/clients/client1/web2/web/wiki-static>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
DirectoryIndex index.html
# Cache long pour les assets versionnés MkDocs
<FilesMatch "\.(css|js|woff2?|png|svg|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Pas de cache sur le HTML (contenu mis à jour)
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
</Directory>
# 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
</VirtualHost>

32
dns/alpinux.org.zone Normal file
View file

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

33
docs/admin.md Normal file
View file

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

186
docs/certificats.md Normal file
View file

@ -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/<domaine>_ecc/`) et dépose une
copie dans `/var/www/clients/<client>/<web>/ssl/<domaine>-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/<domaine>/` 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.

141
docs/courrier-sortant.md Normal file
View file

@ -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+<référence>@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: <bounce+…@alpinux.org>` à 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+<ref>@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`.

31
docs/dynamic.md Normal file
View file

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

69
docs/proxy-calendar.md Normal file
View file

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

233
docs/sauvegardes.md Normal file
View file

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

156
docs/static.md Normal file
View file

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

23
docs/wiki.md Normal file
View file

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

95
scripts/sauvegarder-owni.sh Executable file
View file

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

View file

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

View file

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

View file

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