Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92248c821 | ||
|
|
3fc9ae52c9 | ||
|
|
f974203973 | ||
|
|
7e5f15577a | ||
|
|
70e9476420 | ||
|
|
2e0ae9a91c | ||
|
|
66c356848b | ||
|
|
8f584a8672 |
@@ -4,9 +4,9 @@ Lorsque tu interroges le serveur, ne lances que des commandes en LECTURE SEULE (
|
||||
|
||||
## Accès
|
||||
|
||||
- **SSH**: `ssh cloud@sans.pub`
|
||||
- **SSH**: `ssh zuck@sans.pub` (alias `~/.ssh/config` : `Host sans.pub`) — sudo sans mot de passe. Note : ce document mentionnait auparavant un utilisateur `cloud`, à vérifier/clarifier (voir `install-serveur.md`, le compte créé est `zuck`).
|
||||
- **CapRover**: https://captain.cloud.sans.pub
|
||||
- **Utilisateur**: cloud (gestion Docker/Caprover)
|
||||
- **Gitea**: `git.sans.pub` (port SSH 2242, `User git` dans `~/.ssh/config`)
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -78,6 +78,66 @@ Aucun service en erreur connu (vérifié 2026-02-19). `mail` tourne correctement
|
||||
|
||||
**Prérequis** : définir `CAPROVER_PASSWORD` dans `.env` (voir `.envrc`)
|
||||
|
||||
### CLI CapRover (alternative à l'API brute)
|
||||
|
||||
Installé localement via `fnm` (Node géré sans toucher au système) :
|
||||
```bash
|
||||
curl -fsSL https://fnm.vercel.app/install | bash # une seule fois
|
||||
fnm install --lts && fnm use lts-latest
|
||||
npm install -g caprover
|
||||
caprover login # à faire soi-même dans un vrai terminal (TTY requis pour le mot de passe)
|
||||
```
|
||||
Ensuite : `caprover list` (machines connectées), `caprover api -n <nom-machine> -t /chemin/api -m GET -d '{}'` pour des appels génériques (toujours passer `-d` même vide, sinon prompt interactif qui plante sans TTY).
|
||||
|
||||
Endpoints utiles :
|
||||
- `GET /user/apps/appDefinitions` → liste toutes les apps avec `envVars`, `volumes`, `ports`, etc.
|
||||
- `POST /user/apps/appDefinitions/update` (payload : `appName` + les champs à conserver/modifier) → met à jour la config d'une app **sans redéployer l'image** (mais redémarre le service)
|
||||
|
||||
⚠️ **`appDefinitions/update` remplace, ne fusionne pas** : un champ absent du payload est réinitialisé à sa valeur par défaut (vécu en prod le 2026-09-23 : `containerHttpPort` oublié → réinitialisé à `80` au lieu de `3000` → 502 sur git.sans.pub pendant plusieurs minutes, cf `incidents/2026-09-14-gitea-cryptominer.md`). **Toujours faire un `GET /user/apps/appDefinitions` juste avant et renvoyer l'objet complet**, en ne modifiant que le(s) champ(s) voulu(s).
|
||||
|
||||
## Gitea — détails techniques
|
||||
|
||||
- **Containers Docker** (Swarm) : `srv-captain--gitea.1.*` (gitea/gitea:1.27.3, mis à jour le 2026-09-23) + `srv-captain--gitea-db.1.*` (mysql:5.7)
|
||||
- **Volume de données** : `captain--gitea-data` → monté sur `/data` dans le container
|
||||
- Dépôts bare : `/data/git/repositories/<owner>/<repo>.git`
|
||||
- Config app : `/data/gitea/conf/app.ini` (contient des secrets en clair : `INTERNAL_TOKEN`, `LFS_JWT_SECRET`, mot de passe MySQL — jamais versionner ce fichier)
|
||||
- `.gitconfig` global (compte système utilisé par Gitea pour exécuter git) : `/data/gitea/home/.gitconfig`
|
||||
- Logs applicatifs : `/data/gitea/log/gitea.log` (rotation quotidienne en `.gz`)
|
||||
- **Réseau** : container Gitea sur `captain-overlay-network`, IP interne observée `10.0.1.75`. Le reverse-proxy CapRover (`captain-nginx`) apparaît côté Gitea sous l'IP `10.0.1.105` pour toutes les requêtes publiques (pas d'IP client réelle sans config `X-Forwarded-For`/trusted proxies).
|
||||
- **Connexion à la base MySQL depuis le host/container** : passer par `-h 127.0.0.1` (TCP), pas par défaut socket local (`localhost`) — le grant MySQL de l'utilisateur `gitea` ne couvre que `%`/TCP, pas `localhost`.
|
||||
- **Variables d'env CapRover de l'app `gitea`** (vérifié via `docker service inspect srv-captain--gitea`) : `DB_HOST`, `DB_PASSWD`, `DB_TYPE`, `DB_USER`, `RUN_MODE`. ⚠️ Aucune n'est au format `GITEA__<section>__<clé>` attendu par l'image officielle pour réinjecter dans `app.ini` au démarrage — elles sont donc **inertes** pour Gitea (c'est `app.ini` dans le volume qui fait foi). En cas de rotation de secret (mot de passe DB notamment), mettre à jour `app.ini` + redémarrer suffit fonctionnellement ; penser quand même à resynchroniser `DB_PASSWD` côté CapRover pour ne pas laisser une copie obsolète du secret.
|
||||
- **⚠️ Voir `incidents/2026-09-14-gitea-cryptominer.md`** : l'API interne Gitea (`/api/internal/manager/add-logger`) a été exploitée pour injecter des `packObjectsHook` malveillants dans `.gitconfig`. Toujours vérifier ce fichier ne contient pas d'entrées `[uploadpack] packObjectsHook` inattendues.
|
||||
|
||||
## Backup
|
||||
|
||||
- **Système actuel (depuis le 2026-10-03)** : restic 0.19.1 (`/usr/local/bin/restic`) → OVH Swift, conteneur `sans-pub-restic` (même projet OVH que l'ancien backup, région GRA)
|
||||
- Script : `/root/restic/backup.sh` (source versionnée : `backup/restic-backup.sh` dans ce dépôt), cron `/etc/cron.d/restic-backup` à 03:30
|
||||
- Config : `/root/restic/env` (identifiants OVH) + `/root/restic/password` (mot de passe du dépôt) — `600`, **jamais afficher**. Le mot de passe doit aussi être dans Vaultwarden, sinon le backup est inutilisable si le serveur disparaît
|
||||
- Contenu : dumps SQL de toutes les bases (`/var/backups/sans-pub-db/`, détection auto MySQL/MariaDB/PostgreSQL), volumes Docker (hors registres, previews et logs Nextcloud), `/captain/data` (hors registry), `/home/cloud/apps_data`, `/root/backup`, `/etc`. Les fichiers bruts d'une base sont exclus **seulement si son dump a réussi** (sinon sauvegardés en repli)
|
||||
- Particularités des bases : `limesurvey-mariadb-db` n'accepte root qu'en TCP (`-h127.0.0.1`, le mot de passe de `root@localhost` est différent) ; `writefreely-db` a une table `mysql.proc` incompatible (dump sans `--routines`). Le script essaie automatiquement ces variantes
|
||||
- Premier snapshot complet : 2026-10-03 (~53 Gio, ~45 Gio stockés) ; restauration testée (`restic dump` + comparaison SHA256)
|
||||
- ⚠️ Les scripts lancés par `/etc/cron.d/` n'ont que `PATH=/usr/bin:/bin` : d'où le `export PATH=...` en tête des scripts (restic dans `/usr/local/bin` → backups du 4 et 5 octobre 2026 en échec). Tester un script cron avec `systemd-run -E PATH=/usr/bin:/bin <script>`
|
||||
- Rétention : 7 quotidiens, 4 hebdos, 6 mensuels ; `prune` + `check` (5 % des données) le dimanche
|
||||
- Log : `/var/log/restic-backup.log` ; mail à `pierre@sans.pub` en cas d'échec (via SMTP de poste.io `mail.sans.pub:465`, donc **pas d'alerte si le serveur entier tombe**)
|
||||
- Consulter : `ssh sans.pub 'sudo bash -c ". /root/restic/env && restic snapshots"'`
|
||||
- **Ancien système (arrêté)** : Duplicity (`tecnativa/duplicity`) configuré dans `/root/backup/` sur le serveur (`docker-compose.yaml` + `config.json`, nov. 2020) → OVH Public Cloud Archive `backup-principal` (données) + Swift `backup-principal-metadata` (manifestes), région GRA. Conteneur et image absents du serveur au 2026-10-03 : plus aucun backup automatique. Ces deux fichiers contiennent des secrets (identifiants OVH, passphrase de chiffrement) — ne jamais les afficher ni les versionner.
|
||||
- Suites (Vaultwarden, alerte externe, rotation des identifiants OVH) : voir `plan-migration.md`, phase 0.
|
||||
|
||||
## Mail (poste.io) — certificat TLS
|
||||
|
||||
- poste.io (`analogic/poste.io:2.4.11`) est derrière le nginx de CapRover : son renouvellement Let's Encrypt intégré ne fonctionne pas (certificat resté expiré du 2021-02-06 au 2026-10-03)
|
||||
- Solution (2026-10-03) : `/root/mail-cert-sync.sh` (source : `mail/mail-cert-sync.sh`), cron `/etc/cron.d/mail-cert-sync` à 04:15. Si le certificat `mail.sans.pub` renouvelé par CapRover (`/captain/data/letencrypt/etc/live/mail.sans.pub/`) diffère de `/data/ssl/server.crt` de poste.io : copie + `21-certificate.sh` + rechargement Dovecot (SIGHUP) + redémarrage Haraka (quelques secondes). Log : `/var/log/mail-cert-sync.log`
|
||||
- Le certificat ne couvre que **`mail.sans.pub`** : configurer les clients mail avec ce nom (pas `smtp.sans.pub`, qui donnerait une erreur de nom)
|
||||
- Vérifier : `echo | openssl s_client -connect mail.sans.pub:993 2>/dev/null | openssl x509 -noout -enddate`
|
||||
|
||||
## Nextcloud — détails techniques
|
||||
|
||||
- **Cron** : service CapRover `nextcloud-cron` (image perso `img-captain-nextcloud-cron:4`, PHP 8.1, mai 2023 — Nextcloud lui-même tourne en PHP 8.3) qui monte le même volume `nextcloud-data` et lance `cron.php` toutes les 5 min. Mode `backgroundjobs_mode = cron`.
|
||||
- Vérifier : `occ config:app:get core lastcron` (timestamp Unix, doit dater de < 10 min)
|
||||
- ⚠️ Panne du 2026-08-09 au 2026-10-03 : le conteneur, non redémarré lors du redémarrage Docker du 2026-02-16, avait perdu la résolution DNS du réseau overlay (`getaddrinfo for srv-captain--nextcloud-db failed`). Corrigé par redémarrage via `appDefinitions/update` (définition complète renvoyée). Si ça se reproduit : même remède.
|
||||
- **Rétention** (depuis le 2026-10-03) : `trashbin_retention_obligation` et `versions_retention_obligation` = `auto, 30` (suppression au plus tard après 30 jours ; avant, `auto` sans quota = jamais supprimé → corbeille de 28 Go)
|
||||
- `occ` : `docker exec -u www-data <conteneur srv-captain--nextcloud.1> php occ ...`
|
||||
|
||||
## Suivi des tâches
|
||||
|
||||
Voir `todo.txt` (format todo.txt standard)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# Backup quotidien sans.pub : dumps des bases + restic vers OVH Swift (conteneur sans-pub-restic)
|
||||
# Installé dans /root/restic/backup.sh, lancé par /etc/cron.d/restic-backup
|
||||
# cron ne fournit que /usr/bin:/bin ; restic est dans /usr/local/bin
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
set -uo pipefail
|
||||
|
||||
LOG=/var/log/restic-backup.log
|
||||
DUMP_DIR=/var/backups/sans-pub-db
|
||||
VOLUMES=/var/lib/docker/volumes
|
||||
MAIL_TO=pierre@sans.pub
|
||||
ERRORS=()
|
||||
|
||||
exec 9>/run/restic-backup.lock
|
||||
flock -n 9 || { echo "$(date -Is) déjà en cours, abandon" >> "$LOG"; exit 1; }
|
||||
exec >> "$LOG" 2>&1
|
||||
echo "===== $(date -Is) début"
|
||||
|
||||
. /root/restic/env
|
||||
|
||||
notify_failure() {
|
||||
local pass
|
||||
pass=$(sed -n 's/^ *SMTP_PASS: *//p' /root/backup/docker-compose.yaml)
|
||||
printf 'From: system@sans.pub\r\nTo: %s\r\nSubject: [sans.pub] ECHEC backup restic\r\n\r\n%s\r\n\r\nVoir %s sur le serveur.\r\n' \
|
||||
"$MAIL_TO" "$(printf '%s\n' "${ERRORS[@]}")" "$LOG" |
|
||||
curl -sS --url smtps://mail.sans.pub:465 --mail-from system@sans.pub --mail-rcpt "$MAIL_TO" \
|
||||
--user "system@sans.pub:$pass" --upload-file - || echo "envoi du mail d'alerte impossible"
|
||||
}
|
||||
|
||||
# 1. Dumps logiques des bases (cohérents, contrairement aux fichiers bruts)
|
||||
# Les fichiers bruts d'une base ne sont exclus du backup que si son dump a réussi.
|
||||
umask 077
|
||||
DB_VOLUMES_DUMPED=()
|
||||
rm -rf "$DUMP_DIR.new" && mkdir -p "$DUMP_DIR.new"
|
||||
for c in $(docker ps --format '{{.Names}}'); do
|
||||
name=${c#srv-captain--}; name=${name%%.*}
|
||||
envs=$(docker inspect "$c" --format '{{range .Config.Env}}{{println .}}{{end}}' | cut -d= -f1)
|
||||
attempts=()
|
||||
if grep -qx MYSQL_ROOT_PASSWORD <<<"$envs"; then
|
||||
# Variantes essayées dans l'ordre : socket puis TCP (certaines bases n'acceptent root@% qu'en TCP,
|
||||
# ex. limesurvey), puis sans --routines (mysql.proc incompatible, ex. writefreely-db)
|
||||
base='export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysqldump -uroot --all-databases --single-transaction --triggers'
|
||||
attempts=("$base --routines" "$base --routines -h127.0.0.1" "$base" "$base -h127.0.0.1")
|
||||
elif grep -qx POSTGRES_USER <<<"$envs"; then
|
||||
attempts=('exec pg_dumpall -U "$POSTGRES_USER"')
|
||||
else
|
||||
continue
|
||||
fi
|
||||
out="$DUMP_DIR.new/$name.sql.gz"
|
||||
ok=
|
||||
for cmd in "${attempts[@]}"; do
|
||||
if docker exec "$c" sh -c "$cmd" 2>"$DUMP_DIR.new/$name.err" | gzip -6 > "$out" \
|
||||
&& [ "$(zcat "$out" 2>/dev/null | head -c 1 | wc -c)" -gt 0 ]; then
|
||||
ok=1; break
|
||||
fi
|
||||
done
|
||||
if [ -n "$ok" ]; then
|
||||
rm -f "$DUMP_DIR.new/$name.err"
|
||||
echo "dump OK $name ($(du -h "$out" | cut -f1))"
|
||||
for v in $(docker inspect "$c" --format '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{end}}{{end}}'); do
|
||||
DB_VOLUMES_DUMPED+=(--exclude "$VOLUMES/$v")
|
||||
done
|
||||
else
|
||||
ERRORS+=("dump ÉCHEC $name (fichiers bruts sauvegardés à la place) : $(tail -c 300 "$DUMP_DIR.new/$name.err")")
|
||||
fi
|
||||
done
|
||||
rm -rf "$DUMP_DIR" && mv "$DUMP_DIR.new" "$DUMP_DIR"
|
||||
|
||||
# 2. Sauvegarde restic
|
||||
# Exclus : fichiers bruts des bases dumpées avec succès, registres Docker (reconstructibles), caches, logs
|
||||
EXCLUDES=(
|
||||
"${DB_VOLUMES_DUMPED[@]}"
|
||||
--exclude "$VOLUMES/ffd465a8ae19be68cdfcd27ee8d5acfddf8d9269ad8e95dbbaf4562dcaacbeef"
|
||||
--exclude "$VOLUMES/edc80279a0f6294d1fb8efb4955b2a23bd861f394a5532120c29d6149f36490e"
|
||||
--exclude "/captain/data/registry"
|
||||
--exclude "$VOLUMES/*/_data/data/appdata_*/preview"
|
||||
--exclude "$VOLUMES/captain--bitwarden-data/_data/icon_cache"
|
||||
--exclude "$VOLUMES/*/_data/data/nextcloud.log*"
|
||||
--exclude "$VOLUMES/*/_data/data/audit.log*"
|
||||
)
|
||||
nice -n 10 ionice -c2 -n7 restic backup --tag quotidien --exclude-caches "${EXCLUDES[@]}" \
|
||||
"$DUMP_DIR" "$VOLUMES" /captain/data /home/cloud/apps_data /root/backup /etc \
|
||||
|| ERRORS+=("restic backup a échoué (code $?)")
|
||||
|
||||
# 3. Rétention ; purge et vérification le dimanche
|
||||
restic forget --tag quotidien --keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
|
||||
|| ERRORS+=("restic forget a échoué")
|
||||
if [ "$(date +%u)" = 7 ]; then
|
||||
restic prune || ERRORS+=("restic prune a échoué")
|
||||
restic check --read-data-subset=5% || ERRORS+=("restic check a échoué")
|
||||
fi
|
||||
|
||||
if [ ${#ERRORS[@]} -gt 0 ]; then
|
||||
printf 'ERREUR: %s\n' "${ERRORS[@]}"
|
||||
notify_failure
|
||||
echo "===== $(date -Is) fin AVEC ERREURS"
|
||||
exit 1
|
||||
fi
|
||||
echo "===== $(date -Is) fin OK"
|
||||
@@ -0,0 +1,159 @@
|
||||
# Incident : mineur de cryptomonnaie via Gitea (packObjectsHook)
|
||||
|
||||
- **Statut** : contenu, secrets serveur rotés et Gitea mis à jour vers 1.27.3 (2026-09-23). Reste : mots de passe utilisateurs/tokens/webhooks.
|
||||
- **Date de compromission** : 2026-09-14 ~15:16-15:17
|
||||
- **Date de détection** : 2026-09-22 (via un `git clone` cassé)
|
||||
- **Impact** : exécution de code sur le serveur hébergeant Gitea (et potentiellement l'hôte partagé, ~40 services CapRover) ; minage de cryptomonnaie (Monero) actif pendant ~8 jours
|
||||
|
||||
## Découverte
|
||||
|
||||
Un `git clone` sur `git.sans.pub` échouait systématiquement (SSH et HTTPS) avec :
|
||||
|
||||
```
|
||||
remote: wget: server returned error: HTTP/1.1 404 Not Found
|
||||
remote: curl: (22) The requested URL returned error: 404
|
||||
fatal: fin de fichier prématurée
|
||||
fatal: fetch-pack : sortie d'index de pack invalide
|
||||
```
|
||||
|
||||
Le problème touchait **tous les dépôts**, pas un seul — signe d'un problème serveur global plutôt qu'un souci de repo.
|
||||
|
||||
## Analyse
|
||||
|
||||
`GIT_TRACE_PACKET=1` a montré que le serveur, juste après avoir annoncé `packfile`, envoyait sur le canal d'erreur (sideband 2) le texte `wget: ... 404` / `curl: ... 404` puis coupait la connexion — donc un script serveur s'exécute pendant la génération du pack, avant même l'envoi des données.
|
||||
|
||||
Cause trouvée dans `/data/gitea/home/.gitconfig` (dans le volume Docker `captain--gitea-data`) : **13 lignes `[uploadpack] packObjectsHook = sh /data/gitea/home/p0_XXXXXXXX.sh` injectées**, chacune maquillée en fin de ligne par un faux commentaire imitant une entrée de log Gitea :
|
||||
|
||||
```
|
||||
;#router: completed POST /api/internal/manager/add-logger for 10.0.1.105:XXXXX, 200 OK ...
|
||||
```
|
||||
|
||||
Cette technique fait croire, à la lecture rapide du fichier, qu'il s'agit d'une ligne de log et non de config active.
|
||||
|
||||
### Vecteur d'entrée (hypothèse la plus probable)
|
||||
|
||||
L'endpoint interne Gitea `/api/internal/manager/add-logger` (normalement réservé à un usage interne, protégé par `INTERNAL_TOKEN`) semble avoir été atteignable depuis l'extérieur via le reverse-proxy public (l'IP source `10.0.1.105` observée dans les logs correspond au conteneur `captain-nginx`, donc toutes les requêtes publiques passent par cette IP côté Gitea — la requête malveillante est arrivée par le chemin HTTP public normal). Ceci est cohérent avec des vulnérabilités connues de Gitea permettant l'injection de config git via cette API interne mal exposée.
|
||||
|
||||
**Décision** : pas de restriction réseau supplémentaire sur `/api/internal/*` au niveau du reverse-proxy. L'authentification par `INTERNAL_TOKEN` est le bon périmètre de sécurité pour cet endpoint (pas l'adresse IP source) ; ajouter un blocage réseau serait redondant. Le vecteur réel reste donc **la compromission du `INTERNAL_TOKEN` lui-même** (fuite non identifiée), pas son exposition réseau — token désormais roté (voir plus bas), ce qui referme ce point sans action supplémentaire.
|
||||
|
||||
**À vérifier/confirmer** : version exacte de Gitea au moment de l'attaque (`gitea/gitea:1.25.4`), CVE correspondant à une éventuelle fuite/faiblesse du `INTERNAL_TOKEN`.
|
||||
|
||||
## Charge malveillante
|
||||
|
||||
Les scripts injectés (`p0_c9cd952f.sh`, `p0_9759db49.sh`) :
|
||||
|
||||
1. Téléchargent un binaire déguisé : `https://sunnyeye.solarvest.my/health.bin` → sauvegardé sous `/data/gitea/.sys_health_s3`
|
||||
2. Écrivent une config **XMRig** (JSON encodé en base64 dans le script) pointant vers des pools Monero (`gulf.moneroocean.stream`, `pool.hashvault.pro`) avec une adresse de wallet dédiée
|
||||
3. Assurent la persistance :
|
||||
- via `crontab` (`*/3 * * * *` + `@reboot`) si `cron`/`crond` est disponible
|
||||
- sinon via une boucle `sh -c 'while sleep 180; do <watchdog>; done'` détachée (`nohup`)
|
||||
4. Se camouflent sous des noms plausibles : `.sys_health_s3`, `.gitea_cron_health`, `.wp_s2_cron`, `.gitea_watchdog.pid`
|
||||
|
||||
Le mineur tournait en continu depuis le 2026-09-14 (le conteneur Gitea n'avait pas redémarré depuis), consommant ~298% CPU / ~2.4 Go RAM au moment de la détection. Il a cessé de fonctionner correctement quand le domaine `sunnyeye.solarvest.my` s'est mis à répondre 404 — c'est cet échec de téléchargement qui a cassé les clones et permis la détection.
|
||||
|
||||
## Remédiation effectuée (2026-09-22)
|
||||
|
||||
- [x] Process du mineur et du watchdog tués (`kill -9` sur les PID hôte)
|
||||
- [x] Fichiers de persistance supprimés : `.sys_health_s3`, `.sys_health_s3.json`, `.sys_health_s3.log`, `.gitea_cron_health`, `.gitea_watchdog.pid`
|
||||
- [x] `.gitconfig` nettoyé des 13 lignes `packObjectsHook` injectées (backup conservé : `/data/gitea/home/.gitconfig.bak-20260922234958`)
|
||||
- [x] Clone git re-testé et fonctionnel
|
||||
- [x] Audit de la base Gitea (voir ci-dessous) : aucune autre persistance trouvée
|
||||
- [x] **(2026-09-23, oubli corrigé)** Les scripts malveillants eux-mêmes (`p0_c9cd952f.sh`, `p0_9759db49.sh`) et un fichier sonde (`.s3w`) étaient toujours présents sur le disque (`/data/gitea/home/`) — seule la référence dans `.gitconfig` avait été nettoyée le 09-22, pas les fichiers. Repéré en creusant une alerte Gitea sans rapport ("hooks broken" sur `elevenbook`, finalement bénigne — hash identique à un repo sain, juste pas repoussé depuis 2023). Fichiers supprimés, recherche exhaustive des IOC sur tout le volume confirmée vide, aucun process malveillant actif, `.gitconfig` toujours propre (pas de réinfection).
|
||||
|
||||
## Audit Gitea (base de données) — résultat
|
||||
|
||||
Aucune anomalie détectée au-delà de l'injection `packObjectsHook` :
|
||||
|
||||
| Élément vérifié | Résultat |
|
||||
|---|---|
|
||||
| Comptes utilisateurs | 3 comptes (`pierre` admin, `Sans.pub`, `Duogeeks`), tous antérieurs à l'incident |
|
||||
| Clés SSH publiques | 3 clés, aucune ajout suspect autour du 09-14 |
|
||||
| Tokens d'accès personnels | 4 tokens, tous antérieurs et cohérents avec l'usage habituel |
|
||||
| Webhooks | 4 webhooks de déploiement CapRover, tous de 2020-2021 |
|
||||
| Sources de login externes | 1 seule, préexistante (2020) |
|
||||
| Collaborateurs sur les repos | Rien d'ajouté récemment |
|
||||
| Organisations | Aucune organisation suspecte |
|
||||
|
||||
**Conclusion** : l'attaque semble limitée à l'exécution de commande via le hook git (cryptojacking), sans prise de compte ni persistance au niveau applicatif Gitea. Mais l'attaquant avait un accès en lecture au système de fichiers du conteneur, donc à `app.ini` (secrets en clair) — voir rotation ci-dessous.
|
||||
|
||||
## Rotation des secrets — effectuée le 2026-09-23
|
||||
|
||||
Confirmé via `docker service inspect` puis via l'API CapRover (double vérification) : les variables d'env CapRover de l'app `gitea` sont `RUN_MODE`, `DB_TYPE`, `DB_HOST`, `DB_USER`, `DB_PASSWD` — aucune au format `GITEA__<section>__<clé>` attendu par l'image officielle pour être réinjectée dans `app.ini`. Elles sont donc **inertes** pour Gitea (c'est `app.ini` dans le volume qui fait foi). Conséquence : `INTERNAL_TOKEN`/`LFS_JWT_SECRET` n'ont pas de variable CapRover à mettre à jour ; seul `DB_PASSWD` avait une copie dormante à resynchroniser.
|
||||
|
||||
Effectué :
|
||||
- [x] `INTERNAL_TOKEN` régénéré (`gitea generate secret INTERNAL_TOKEN`) dans `app.ini`
|
||||
- [x] `LFS_JWT_SECRET` régénéré (`gitea generate secret LFS_JWT_SECRET`) dans `app.ini`
|
||||
- [x] Mot de passe MySQL de l'utilisateur `gitea`@`%` changé (`ALTER USER ... IDENTIFIED BY ...`)
|
||||
- [x] `app.ini` mis à jour avec les 3 nouvelles valeurs (backup : `app.ini.bak-20260923004549`)
|
||||
- [x] Service Gitea redémarré (`docker service update --force srv-captain--gitea`), reconnexion DB confirmée dans les logs, aucune erreur
|
||||
- [x] Clone git testé et fonctionnel après rotation
|
||||
- [x] Variable CapRover `DB_PASSWD` resynchronisée via l'API (`/user/apps/appDefinitions/update`), vérifiée par comparaison de hash
|
||||
|
||||
Outillage utilisé : CLI `caprover` (installé localement via `fnm` + `npm install -g caprover`, authentifié avec `caprover login` — voir section CapRover CLI dans `CLAUDE.md`).
|
||||
|
||||
## Mise à jour Gitea 1.25.4 → 1.27.3 — effectuée le 2026-09-23
|
||||
|
||||
Corrige 15 advisories de sécurité publiées le 2026-08-29 (dont au moins 2 RCE en sévérité haute : bypass d'approbation sur les runners self-hosted, RCE via rendu externe de markup). Aucune ne correspond exactement au vecteur de cet incident (API interne), mais l'upgrade était largement justifiée.
|
||||
|
||||
Effectué :
|
||||
- [x] Backup complet avant upgrade : dump SQL (`mysqldump`, 114 tables) + tarball du volume de données complet (2,1 Go), dans `/home/zuck/backups/gitea-upgrade-20260923005619/` sur le serveur
|
||||
- [x] Déploiement de `gitea/gitea:1.27.3` via l'API CapRover (`POST /user/apps/appData/gitea` avec `captainDefinitionContent`)
|
||||
- [x] Démarrage vérifié sans erreur de migration, `Git version: 2.54.0`
|
||||
|
||||
### Incident secondaire causé pendant l'upgrade (auto-résolu le jour même)
|
||||
|
||||
La mise à jour CapRover de `DB_PASSWD` faite plus tôt (rotation des secrets, cf section dédiée) avait été envoyée avec un payload **incomplet** — le champ `containerHttpPort` n'était pas inclus. CapRover l'a réinitialisé à sa valeur par défaut (`80`) au lieu de conserver `3000` (le port réel utilisé par l'image officielle Gitea). Conséquence : **`git.sans.pub` a répondu en 502 pendant plusieurs minutes** (visible dans les logs nginx avec du trafic réel externe), le temps de diagnostiquer (`connect() failed (111: Connection refused)` vers `10.0.1.x:80`) et de renvoyer un payload complet avec `containerHttpPort: 3000` restauré.
|
||||
|
||||
**Leçon** : toute mise à jour via `POST /user/apps/appDefinitions/update` doit **toujours renvoyer l'intégralité des champs de la définition actuelle** (récupérés via un `GET /user/apps/appDefinitions` juste avant), pas seulement le(s) champ(s) qu'on veut changer — l'API ne fait pas de merge partiel, elle remplace.
|
||||
|
||||
## Vérification de non-réinfection — 2026-10-03
|
||||
|
||||
Contrôle en lecture seule, ~10 jours après le nettoyage. **Aucun signe de nouvelle compromission.**
|
||||
|
||||
- `.gitconfig` propre (aucun `packObjectsHook`/`fsmonitor`/`sshCommand`/`hooksPath`, ni faux commentaire `;#router:`) ; seules occurrences dans le backup `.bak-20260922234958`
|
||||
- Aucun fichier IOC (`p0_*.sh`, `.sys_health*`, `.wp_s2*`, `.s3w`…) dans le conteneur, sur l'hôte ni dans `/var/lib/docker/volumes`
|
||||
- Processus du conteneur : uniquement `s6`, `gitea web`, `sshd` ; crontab Alpine par défaut
|
||||
- Hooks des 22 dépôts identiques entre eux et générés par Gitea (`# AUTO GENERATED BY GITEA`), aucun modifié depuis le nettoyage ; `config` des dépôts sans clé suspecte
|
||||
- Logs Gitea (conservés depuis le 2026-09-26) : aucun appel `add-logger`, seuls des appels internes légitimes (`/api/internal/ssh/.../update` depuis `10.0.0.2`), aucune mention `solarvest`/`moneroocean`/`hashvault`/`xmrig`
|
||||
- Hôte : load average 0,56, aucun processus gourmand, aucune connexion vers des ports de pools ; crontabs système inchangées depuis 2021-2022
|
||||
|
||||
Limites : les logs du 23 au 25/09 ont été perdus par rotation ; vérification ciblée sur ce mode opératoire, ne remplace pas le scan antirootkit ci-dessous.
|
||||
|
||||
## Mouvement latéral et intégrité de l'hôte — 2026-10-03
|
||||
|
||||
Contrôle en lecture seule. **Aucun signe de mouvement latéral ni de compromission de l'hôte.**
|
||||
|
||||
Surface d'évasion depuis Gitea :
|
||||
- Conteneur Gitea non privilégié, sans `CapAdd`, sans PID/réseau hôte, seul montage = son volume de données → pas d'accès direct à l'hôte
|
||||
- Seul conteneur montant `docker.sock` : `captain-captain` (CapRover, normal)
|
||||
|
||||
Autres conteneurs :
|
||||
- Aucun processus suspect dans les ~40 conteneurs (faux positifs vérifiés : `p0f` dans `mail` = fingerprinting poste.io ; `jetty` dans `scenari`)
|
||||
- Aucun conteneur gourmand en CPU (max ~2 %)
|
||||
- Aucun fichier IOC ni chaîne IOC (`solarvest`, `moneroocean`, `hashvault`, `packObjectsHook`) dans les volumes, `/captain` ou les couches inscriptibles ; aucun fichier récent dans les `/tmp`, `/var/tmp`, `/dev/shm` des conteneurs
|
||||
- Redis : `une-espace-redis` protégé par mot de passe ; `peertube-redis` **sans authentification** (joignable depuis le réseau overlay, donc depuis Gitea pendant la compromission) mais config intacte (`dir=/data`, `dbfilename=dump.rdb`, pas de `slaveof`, pas de module chargé)
|
||||
|
||||
Hôte :
|
||||
- `dpkg --verify` : aucun binaire/bibliothèque de paquet modifié (seule différence : `zoneinfo/Europe/Paris`, bénin)
|
||||
- Aucun fichier modifié depuis le 2026-09-01 dans `/etc`, `/bin`, `/sbin`, `/usr/bin`, `/usr/sbin`, `/usr/local`, systemd ; aucun binaire setuid modifié depuis 2025 ; pas de `/etc/ld.so.preload`
|
||||
- Aucun processus caché (comparaison `/proc` vs `ps`)
|
||||
- Comptes : `root`, `zuck`, `cloud`, `gemini` (serveur Gemini `agate`, 2023, jamais connecté), `postgres` ; `authorized_keys` : `zuck` (1 clé, modifié le 2026-09-22 pendant la remédiation), `cloud` (1 clé, 2023), `admin` (ancien utilisateur LDAP YunoHost UID 1007, 1 clé `pierre@pierre` de 2020, utilisateur non résolu par `getent` donc inutilisable en SSH) — aucune clé récente ; sudoers inchangés depuis 2021
|
||||
- SSH : `PasswordAuthentication no`, `PermitRootLogin no` ; connexions acceptées uniquement depuis `88.179.22.122` et `92.88.171.59` (à confirmer : IP mobile ?)
|
||||
- Crontabs système inchangées ; pare-feu `INPUT DROP` avec liste blanche (les ports publiés par Docker contournent `INPUT` : 3000, 996, 1001, 2242 exposés via l'ingress Swarm)
|
||||
|
||||
## Reste à faire
|
||||
|
||||
- [ ] Changer les mots de passe des 3 comptes Gitea (pierre, Sans.pub, Duogeeks) par précaution
|
||||
- [ ] Révoquer et régénérer les 4 tokens d'accès personnels (LML, Piaire CI/Deploy, Piaire, Laptop .npmrc) — penser à mettre à jour partout où ils sont utilisés
|
||||
- [ ] Régénérer les 4 tokens de webhook CapRover (déploiement tmp-fc, dechets-agglo-muretain, etherpad, domi-marche) et mettre à jour les URLs de webhook correspondantes dans Gitea
|
||||
- [x] Vérifier l'intégrité des autres services sur le même hôte (mouvement latéral) — fait le 2026-10-03, rien trouvé (voir section dédiée)
|
||||
- [x] Vérification d'intégrité des binaires système sur l'hôte — fait le 2026-10-03 via `dpkg --verify`, rien trouvé
|
||||
- [ ] Scan antirootkit outillé (rkhunter/chkrootkit) — aucun outil installé ; nécessite une installation (dépôts Debian 9 archivés) ou un scan hors ligne
|
||||
- [ ] Revoir si `DOMAIN = localhost` dans `app.ini` (`[server]`) est correct ou un reliquat de config
|
||||
|
||||
## Indicateurs de compromission (IOC)
|
||||
|
||||
- Domaine : `sunnyeye.solarvest.my`
|
||||
- Fichiers : `p0_*.sh`, `.sys_health_s3`, `.sys_health_s3.json`, `.sys_health_s3.log`, `.gitea_cron_health`, `.gitea_watchdog.pid`, `.wp_s2_cron`, `.wp_s2_wd.pid`, `.s3w`
|
||||
- Chemins ciblés pour la persistance (dans cet ordre) : `/data/gitea`, `/data/git`, `/home/git`, `/var/tmp`, `/dev/shm`, `/tmp`, `$HOME`
|
||||
- Pattern de commentaire d'obfuscation dans les configs git : `;#router: completed POST /api/internal/manager/add-logger for <ip>, 200 OK ...`
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# Synchronise le certificat Let's Encrypt de mail.sans.pub (renouvelé par CapRover)
|
||||
# vers poste.io, qui ne sait pas le renouveler lui-même derrière le nginx de CapRover.
|
||||
# Installé dans /root/mail-cert-sync.sh, lancé chaque jour par /etc/cron.d/mail-cert-sync
|
||||
# cron ne fournit que /usr/bin:/bin
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
set -euo pipefail
|
||||
|
||||
LIVE=/captain/data/letencrypt/etc/live/mail.sans.pub
|
||||
SSL=/var/lib/docker/volumes/captain--mail-data/_data/ssl
|
||||
LOG=/var/log/mail-cert-sync.log
|
||||
exec >> "$LOG" 2>&1
|
||||
|
||||
# Déjà à jour ?
|
||||
if cmp -s "$LIVE/cert.pem" "$SSL/server.crt"; then
|
||||
exit 0
|
||||
fi
|
||||
openssl x509 -in "$LIVE/cert.pem" -noout -checkend 86400 \
|
||||
|| { echo "$(date -Is) certificat CapRover expiré ou invalide, rien fait"; exit 1; }
|
||||
|
||||
echo "$(date -Is) nouveau certificat : $(openssl x509 -in "$LIVE/cert.pem" -noout -enddate)"
|
||||
stamp=$(date +%Y%m%d%H%M%S)
|
||||
for f in server.crt server.key ca.crt; do
|
||||
[ -f "$SSL/$f" ] && cp -p "$SSL/$f" "$SSL/$f.bak-$stamp"
|
||||
done
|
||||
install -m 644 -o mail -g mail "$LIVE/cert.pem" "$SSL/server.crt"
|
||||
install -m 644 -o mail -g mail "$LIVE/chain.pem" "$SSL/ca.crt"
|
||||
install -m 600 -o mail -g mail "$LIVE/privkey.pem" "$SSL/server.key"
|
||||
# Garder seulement les 3 dernières sauvegardes de chaque fichier
|
||||
for f in server.crt server.key ca.crt; do
|
||||
ls -1t "$SSL/$f".bak-* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
done
|
||||
|
||||
# Distribution dans le conteneur (script d'init de poste.io) puis rechargement des services TLS
|
||||
M=$(docker ps -q -f name=srv-captain--mail)
|
||||
docker exec "$M" bash /etc/cont-init.d/21-certificate.sh
|
||||
docker exec "$M" s6-svc -h /var/run/s6/services/dovecot # IMAP/POP : relecture à chaud
|
||||
docker exec "$M" s6-svc -t /var/run/s6/services/haraka-smtp # SMTP 25/465 : redémarrage (quelques s)
|
||||
docker exec "$M" s6-svc -t /var/run/s6/services/haraka-submission # SMTP 587
|
||||
echo "$(date -Is) certificat installé et services rechargés"
|
||||
@@ -0,0 +1,191 @@
|
||||
# Plan de migration du serveur sans.pub
|
||||
|
||||
- **Créé le** : 2026-10-03
|
||||
- **Statut** : brouillon — décisions D1 à D4 à trancher avant de commencer
|
||||
- **Remplace** : la chaîne « Debian 9 → 12, puis Docker 27+, puis CapRover 1.14 » de l'ancien `todo.txt`
|
||||
|
||||
## 1. État des lieux (relevé du 2026-10-03)
|
||||
|
||||
| Élément | État | Problème |
|
||||
|---|---|---|
|
||||
| OS | Debian 9, noyau 4.9, uptime ~2300 jours | Fin de support, plus de correctifs de sécurité |
|
||||
| Docker | 19.03.12 (Swarm, 1 nœud) | Trop ancien pour CapRover ≥ 1.14 (API 1.43 requise) |
|
||||
| CapRover | 1.11.1 | Bloqué par Docker |
|
||||
| Disque | 1 seul disque de 931 Go, LVM, **sans RAID** | Panne disque = perte totale |
|
||||
| Backup | Duplicity (`/root/backup/`, nov. 2020) vers OVH — **ne tourne plus** : conteneur absent, image supprimée | Voir D4 |
|
||||
| Résidus | YunoHost (LDAP, postfix/dovecot installés, `/etc/yunohost`), PostgreSQL 9.6 sur l'hôte (vide), `/home/admin/Kaamelott` (31 Go) | Hôte encombré, surface d'attaque |
|
||||
| Matériel | 4 cœurs, 23 Go RAM, IP `109.238.11.111` (Ikoula) | — |
|
||||
|
||||
### Données à sauvegarder (~140 Go utiles)
|
||||
|
||||
| Données | Taille |
|
||||
|---|---|
|
||||
| `captain--nextcloud-data` | 50 Go |
|
||||
| `captain--photoprism-data` | 23 Go |
|
||||
| `captain--une-espace-data` | 16 Go |
|
||||
| `captain--etherpad-db-data` | 3 Go |
|
||||
| `captain--gitea-data` | 2,1 Go |
|
||||
| `captain--peertube-peertube-data` | 1,7 Go |
|
||||
| `captain--mail-data` | 1,4 Go |
|
||||
| Autres volumes (bases, cryptpad, bitwarden, privatebin…) | ~3 Go |
|
||||
| `/captain/data` hors registry (certificats, config CapRover) | ~1 Go |
|
||||
| `/home/cloud/apps_data` (configs writefreely, rss-bridge) | < 10 Mo |
|
||||
| `/home/admin/Kaamelott` | 31 Go (à garder ?) |
|
||||
|
||||
À **ne pas** sauvegarder (reconstructible) : registres Docker (`/captain/data/registry` 17 Go, volumes anonymes de `docker-registry` 28 + 4,5 Go), `overlay2`, logs.
|
||||
|
||||
### Services (47) et images obsolètes
|
||||
|
||||
Bases en fin de vie : `mysql:5.7` (gitea-db), `postgres:10` (peertube-db), `mariadb:10.5.3` (nextcloud-db, une-espace-db), `mariadb:10.7` (writefreely-db), `redis:6` (peertube-redis).
|
||||
|
||||
Applications très en retard : PeerTube 3.4.1, PhotoPrism 2021, LimeSurvey 5.2.4, CryptPad 5.2.1 (image non officielle), Scenari 5.0, Miniflux 2.0.44, Vaultwarden 1.32, poste.io 2.4.11, WriteFreely 0.12.
|
||||
|
||||
Images personnalisées construites par CapRover (registre interne `:996`) : domi-marche, ethercalc-db, etherpad, jeu-laser, limesurvey-mariadb-db, lmo, monica, monica-db, nextcloud-cron, ots-db, pierre-martin, sans-pub-site, une-espace-cron, une-espace-redis, zoe-cm1-site. **Il faut retrouver leurs sources** (dépôts Gitea + webhooks) avant de quitter CapRover.
|
||||
|
||||
## 2. Décisions à prendre
|
||||
|
||||
### D1 — Mise à jour sur place ou nouveau serveur ? → **Recommandé : nouveau serveur**
|
||||
|
||||
Le scénario « tout éteindre, backup, tout mettre à jour, tout relancer » sur la machine actuelle implique quatre montées de version Debian enchaînées (9 → 10 → 11 → 12 → 13) sur un système qui n'a pas redémarré depuis six ans, avec des résidus YunoHost. Il implique aussi une longue coupure de tous les services, et aucun retour arrière si une étape casse.
|
||||
|
||||
Avec un nouveau serveur (Debian 13 neuf, idéalement avec 2 disques en RAID 1) :
|
||||
- l'ancien serveur reste allumé et sert de retour arrière jusqu'à la fin ;
|
||||
- on migre **service par service**, chacun avec une courte coupure ;
|
||||
- on part d'un hôte propre, ce qui est cohérent avec l'incident de septembre (on ne garde rien de potentiellement touché).
|
||||
|
||||
Coût : un second serveur pendant quelques semaines. Point d'attention : **la nouvelle IP** impacte le mail (PTR, SPF, réputation) → voir phase 5.
|
||||
|
||||
### D2 — Garder CapRover ? → **Recommandé : non, Docker Compose + Caddy, versionné dans ce dépôt**
|
||||
|
||||
| | CapRover | Compose + Caddy |
|
||||
|---|---|---|
|
||||
| Config | Dans la base CapRover, modifiable via UI/API | Fichiers texte versionnés dans git, relisibles, diffables |
|
||||
| Pièges connus | `appDefinitions/update` qui remplace au lieu de fusionner (incident du 2026-09-23), modifs `docker service` invisibles | Aucun intermédiaire : ce qui est dans le fichier est ce qui tourne |
|
||||
| Swarm | Obligatoire (inutile sur 1 nœud) | Inutile |
|
||||
| TLS | certbot intégré | Automatique avec Caddy |
|
||||
| Déploiement depuis Gitea | Webhooks intégrés | À remplacer (Gitea Actions + `docker compose pull && up -d`, ou build local) |
|
||||
| Backup/restauration | Indirecte (volumes + config CapRover) | Directe : dépôt git + volumes |
|
||||
| Interface web | Oui | Non (option : Dockge ou Portainer si besoin d'une UI) |
|
||||
|
||||
Si une interface de type PaaS reste indispensable, les alternatives modernes à CapRover sont Coolify et Dokploy. Elles réintroduisent cependant la même couche opaque.
|
||||
|
||||
### D3 — Quels services garder ?
|
||||
|
||||
À passer en revue (garder / supprimer / archiver) avant la migration — chaque service supprimé, c'est une migration en moins. Candidats à l'examen : ethercalc, reqbin, scenari, monica, photoprism, jeu-laser, lmo, une-fibre (redirection), limesurvey, cryptpad, `/home/admin/Kaamelott`.
|
||||
|
||||
### D4 — Le backup OVH existant (retrouvé le 2026-10-03)
|
||||
|
||||
Configuration dans `/root/backup/` sur le serveur, créée en novembre 2020 :
|
||||
- `docker-compose.yaml` : image `tecnativa/duplicity` (Duplicity + cron interne), sauvegarde de `/var/lib/docker/volumes`, `/captain/data` et `/home/cloud/apps_data`, chiffrée (`PASSPHRASE`), rapport par mail à `pierre@sans.pub`
|
||||
- `config.json` : double destination OVH (projet Public Cloud, région GRA) — données dans le conteneur **Public Cloud Archive** `backup-principal` (`pca://`), manifestes/signatures dans le conteneur **Swift** `backup-principal-metadata`
|
||||
|
||||
État constaté :
|
||||
- **Aucun conteneur de backup ne tourne** et l'image `tecnativa/duplicity` n'est plus présente sur le serveur → les backups sont arrêtés depuis une date inconnue
|
||||
- `restored-data/` vide : aucune restauration n'a jamais été testée
|
||||
- `config.json` contient les identifiants OVH en clair et est **lisible par tous** (`644`) → passer en `600`
|
||||
- La passphrase Duplicity est dans `docker-compose.yaml` : **sans elle, les archives existantes sont illisibles** — à mettre dans Vaultwarden
|
||||
- Public Cloud Archive (stockage froid) : la restauration impose un délai de désarchivage (plusieurs heures) — peu adapté à une migration
|
||||
- Vérifier côté console OVH que l'offre Public Cloud Archive (Swift) est toujours active et facturée — OVH pousse désormais ses offres S3 (Object Storage, Cold Archive)
|
||||
|
||||
À faire : lister les deux conteneurs OVH pour connaître la date du dernier backup réussi et son volume.
|
||||
|
||||
## 3. Phases
|
||||
|
||||
### Phase 0 — Backup fiable (à faire tout de suite, indépendamment de la migration)
|
||||
|
||||
Objectif : un backup chiffré, automatique, **restauration testée**, sur OVH Object Storage.
|
||||
|
||||
- [x] (2026-10-03) restic 0.19.1 installé, dépôt `swift:sans-pub-restic:/` créé avec les identifiants OVH existants (Swift standard, pas archive)
|
||||
- [x] (2026-10-03) Script `backup/restic-backup.sh` : dumps de toutes les bases + volumes + `/captain/data` + `/etc`, rétention 7/4/6, `prune`/`check` hebdo, mail si échec — détails dans `CLAUDE.md` § Backup
|
||||
- [x] (2026-10-03) Cron quotidien 03:30 ; premier backup complet (~53 Gio) réussi, restauration de fichiers testée depuis OVH
|
||||
- [ ] Copier `/root/restic/password` dans Vaultwarden (+ la passphrase Duplicity et les identifiants OVH)
|
||||
- [ ] Alerte « homme mort » externe (ex : healthchecks.io) : l'alerte mail actuelle passe par le serveur lui-même
|
||||
- [ ] Régénérer les identifiants OVH (exposés en clair dans une session Claude le 2026-10-03) puis mettre à jour `/root/restic/env` ; `chmod 600 /root/backup/config.json`
|
||||
- [ ] (Optionnel) Lister les conteneurs OVH de l'ancien backup Duplicity pour connaître la date du dernier backup, puis les supprimer pour ne plus les payer
|
||||
- [ ] (Optionnel) Inclure `/home/admin/Kaamelott` (31 Go, exclu pour l'instant)
|
||||
- [ ] **Test de restauration** : restaurer un volume et un dump de base sur une autre machine, et vérifier que le service démarre avec
|
||||
- [ ] Conserver le mot de passe restic **hors du serveur** (Vaultwarden + copie papier) — sans lui le backup est inutilisable
|
||||
|
||||
Les sauvegardes « à chaud » (bases dumpées, fichiers copiés en marche) suffisent pour le quotidien. Pour la bascule de chaque service, on arrêtera le service avant la sauvegarde finale (phase 3).
|
||||
|
||||
### Phase 1 — Préparer la nouvelle infrastructure
|
||||
|
||||
- [ ] Commander le serveur (2 disques RAID 1 si possible, ≥ 1 To, ≥ 32 Go RAM vu la croissance)
|
||||
- [ ] Debian 13, durcissement SSH (clé seule, pas de root), pare-feu (nftables), mises à jour automatiques de sécurité (`unattended-upgrades`)
|
||||
- [ ] Docker Engine récent (dépôt officiel) + plugin Compose
|
||||
- [ ] Arborescence dans ce dépôt : `stacks/<service>/compose.yml` + `.env.example` ; secrets réels dans `.env` non versionnés (ou chiffrés avec sops/age)
|
||||
- [ ] Caddy en reverse proxy (un réseau Docker par stack, seul Caddy est connecté à tous)
|
||||
- [ ] Backup restic installé **dès le départ** sur le nouveau serveur
|
||||
- [ ] Supervision minimale (uptime externe + espace disque + échec de backup)
|
||||
|
||||
### Phase 2 — Inventaire détaillé des services
|
||||
|
||||
Pour chaque service conservé (D3) : image cible, volumes, variables d'environnement (via `GET /user/apps/appDefinitions`), domaines, dépendances (base, redis), source des images personnalisées, procédure de montée de version.
|
||||
|
||||
- [ ] Exporter les définitions CapRover complètes (`appDefinitions`) dans ce dépôt (sans les secrets)
|
||||
- [ ] Retrouver le dépôt source de chaque image `img-captain-*`
|
||||
|
||||
### Phase 3 — Migration service par service
|
||||
|
||||
Pour chaque service :
|
||||
1. Baisser le TTL DNS du domaine (300 s) quelques jours avant
|
||||
2. Déployer la stack sur le nouveau serveur, à vide, et tester
|
||||
3. Arrêter le service sur l'ancien serveur (via CapRover : instances → 0)
|
||||
4. Dump final de la base + copie des volumes (rsync de l'ancien vers le nouveau)
|
||||
5. Restaurer ; pour les bases EOL, **restaurer le dump dans la nouvelle version** plutôt que réutiliser les fichiers bruts
|
||||
6. Basculer le DNS, tester, surveiller 24-48 h
|
||||
7. Laisser l'ancien service arrêté mais intact (retour arrière = rallumer + remettre le DNS)
|
||||
|
||||
Ordre proposé (du moins risqué au plus critique) :
|
||||
1. Sites statiques et outils sans données : sans-pub-site, pierre-martin, zoe-cm1-site, it-tools, cyberchef, adminer, une-fibre
|
||||
2. Petits services avec données : privatebin, ots, rss + rss-bridge, writefreely, ethercalc, etherpad, cryptpad
|
||||
3. Gitea (+ remplacement des webhooks de déploiement CapRover)
|
||||
4. Services lourds avec montée de version importante : peertube (3.4 → 8 : suivre les notes de version majeure par majeure), limesurvey, photoprism, scenari, monica
|
||||
5. Nextcloud et une-espace (gros volumes)
|
||||
6. Vaultwarden (critique : tester export/import avant)
|
||||
7. Mail en dernier (voir phase 5)
|
||||
|
||||
### Phase 4 — Montée de version des bases
|
||||
|
||||
| Base | Actuelle | Cible | Méthode |
|
||||
|---|---|---|---|
|
||||
| gitea-db | MySQL 5.7 | MySQL 8.4 LTS (ou MariaDB 11.4) | dump/restore |
|
||||
| etherpad-db | MySQL 8.0.33 | MySQL 8.4 | dump/restore |
|
||||
| nextcloud-db, une-espace-db | MariaDB 10.5.3 | MariaDB 11.4 LTS | dump/restore |
|
||||
| writefreely-db | MariaDB 10.7 | MariaDB 11.4 | dump/restore |
|
||||
| peertube-db | PostgreSQL 10 | PostgreSQL 17 | `pg_dump` / restore |
|
||||
| rss-db | PostgreSQL 15.2 | PostgreSQL 17 | `pg_dump` / restore |
|
||||
| peertube-redis | Redis 6 | Valkey 8 / Redis 7 | données jetables (cache/queue), mot de passe à ajouter |
|
||||
|
||||
### Phase 5 — Mail (poste.io)
|
||||
|
||||
Le changement d'IP est le principal risque :
|
||||
- [ ] Demander le **PTR (reverse DNS)** de la nouvelle IP vers `sans.pub` avant la bascule
|
||||
- [ ] Vérifier que la nouvelle IP n'est pas sur liste noire (Spamhaus, etc.)
|
||||
- [ ] Mettre à jour SPF (`ip4:`), garder la même clé DKIM
|
||||
- [ ] Bascule : arrêter mail sur l'ancien serveur → rsync final de `captain--mail-data` → démarrer → changer A/MX
|
||||
- [ ] Les mails entrants pendant la bascule sont remis plus tard par les serveurs expéditeurs (file d'attente SMTP de plusieurs jours)
|
||||
|
||||
### Phase 6 — Décommissionnement
|
||||
|
||||
- [ ] Toutes les stacks migrées et stables depuis ≥ 2 semaines
|
||||
- [ ] Backup final complet de l'ancien serveur (archive froide sur OVH)
|
||||
- [ ] Résiliation de l'ancien serveur
|
||||
- [ ] Mise à jour de `CLAUDE.md` (nouvelle archi, plus de CapRover, nouvelles commandes)
|
||||
|
||||
## 4. Problèmes découverts en route (2026-10-03)
|
||||
|
||||
- [x] Certificat TLS de poste.io expiré depuis le 2021-02-06 — corrigé le 2026-10-03 en réutilisant le certificat CapRover, synchronisation quotidienne (voir `CLAUDE.md` § Mail). Dans la nouvelle archi, prévoir le certificat du mail dès le départ (Caddy ne sert pas les ports mail)
|
||||
- [ ] `writefreely-db` (MariaDB 10.7) : table système `mysql.proc` incompatible → lancer `mariadb-upgrade` (ou corriger lors de la migration par dump/restore)
|
||||
- [ ] `limesurvey-mariadb-db` : `root@localhost` et `root@%` ont des mots de passe différents → à harmoniser (ou ignorer : réglé à la migration)
|
||||
- [x] Cron Nextcloud en panne du 2026-08-09 au 2026-10-03 (DNS overlay perdu) — corrigé, voir `CLAUDE.md`
|
||||
|
||||
## 5. Risques
|
||||
|
||||
| Risque | Parade |
|
||||
|---|---|
|
||||
| Pas de backup avant de commencer | Phase 0 obligatoire, restauration testée |
|
||||
| Source d'une image personnalisée introuvable | Exporter l'image depuis le registre interne (`docker save`) en dernier recours |
|
||||
| PeerTube 3.4 → 8 casse | Garder l'ancien en lecture seule, migrer par étapes de version majeure |
|
||||
| Mail en spam après changement d'IP | PTR + liste noire vérifiés avant, bascule mail en dernier |
|
||||
| Secrets compromis lors de l'incident de septembre recopiés tels quels | Profiter de la migration pour régénérer les secrets applicatifs |
|
||||
Reference in New Issue
Block a user