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:
parent
abbef2304c
commit
89cc919906
19 changed files with 1377 additions and 19 deletions
80
README.md
80
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) |
|
| `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"
|
|
||||||
|
|
|
||||||
36
conf/admin.alpinux.org.vhost.conf
Normal file
36
conf/admin.alpinux.org.vhost.conf
Normal 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>
|
||||||
67
conf/alpinux.org.vhost.conf
Normal file
67
conf/alpinux.org.vhost.conf
Normal 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>
|
||||||
33
conf/dynamic.alpinux.org.vhost.conf
Normal file
33
conf/dynamic.alpinux.org.vhost.conf
Normal 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>
|
||||||
40
conf/static.alpinux.org.vhost.conf
Normal file
40
conf/static.alpinux.org.vhost.conf
Normal 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>
|
||||||
66
conf/wiki.alpinux.org.vhost.conf
Normal file
66
conf/wiki.alpinux.org.vhost.conf
Normal 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
32
dns/alpinux.org.zone
Normal 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
33
docs/admin.md
Normal 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
186
docs/certificats.md
Normal 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
141
docs/courrier-sortant.md
Normal 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
31
docs/dynamic.md
Normal 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
69
docs/proxy-calendar.md
Normal 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
233
docs/sauvegardes.md
Normal 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
156
docs/static.md
Normal 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
23
docs/wiki.md
Normal 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
95
scripts/sauvegarder-owni.sh
Executable 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
|
||||||
24
services/alpinux-admin.service
Normal file
24
services/alpinux-admin.service
Normal 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
|
||||||
24
services/dynamic.alpinux.org.service
Normal file
24
services/dynamic.alpinux.org.service
Normal 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
|
||||||
27
services/static-cdn.service
Normal file
27
services/static-cdn.service
Normal 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
|
||||||
Loading…
Reference in a new issue