Phase 4 : kit G9 (scripts + compose) + Gitea T4.4 backend Postgres

- compose/gitea : SQLite -> Postgres (source = paquet App Center DSM, gitea_db 13 repos/3 users), image épinglée 1.27.3, push-to-create
- scripts/07a-migrate-gitea.sh : export pg_dump (MVCC, .25 lecture seule) + tar/ssh (sshd DSM refuse rsync/scp) + manifestes sha256
- scripts/03b-secrets.sh : secrets gitea ajoutés
- journal.md : entrées Phase 4
This commit is contained in:
Effi
2026-10-11 07:45:00 +02:00
commit c246427cf5
39 changed files with 6131 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# 01-hardening.sh — T2.1 : Durcissement Ubuntu Server 26.04 du G9
# Idempotent. Exécuter en root (sudo).
#
# Garde-fous : G6 (réseau LAN-only), G7 (rollback), G8 (journalisation).
#
# Fait :
# - SSH : clés uniquement (Pas de mot de passe), port par défaut 22
# - ufw : actif, par défaut deny incoming / allow outgoing
# - unattended-upgrades : activé
# - fail2ban : actif
#
# Rollback :
# - Re-activer PasswordAuthentication yes dans /etc/ssh/sshd_config
# - ufw disable ; restaurer les règles sauvegardées avant le reset
# (`cp /etc/ufw/before.rules.bak.<epoch> /etc/ufw/before.rules`, idem user.rules, puis `ufw reload`)
# - systemctl disable --now fail2ban unattended-upgrades
#
# ⚠️ Maintenance : la liste de ports ouverts (TCP/UDP, LAN-only) est **en dur** dans ce script
# et doit rester **alignée sur `compose/*/docker-compose.yml`** (source des ports publiés).
# Ajouter une brique = ajouter son port ici, sinon le service sera injoignable depuis la LAN.
set -euo pipefail
log() { echo -e "\n[hardening] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | hardening : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ $EUID -eq 0 ]] || { echo "ERREUR : exécuter en root (sudo)"; exit 1; }
# --- 1. SSH : clés uniquement ---
log "SSH : désactivation de l'authentification par mot de passe"
SSHD=/etc/ssh/sshd_config
# Guard anti-lockout (G1) : couper PasswordAuthentication sans clé installée = porte SSH fermée.
RUN_USER="${SUDO_USER:-$USER}"
RUN_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)"
if ! grep -qsE '^(ssh-|ecdsa-|sk-)' "$RUN_HOME/.ssh/authorized_keys" 2>/dev/null; then
echo "ERREUR : aucune clé publique dans $RUN_HOME/.ssh/authorized_keys pour '$RUN_USER'."
echo "Installe d'abord une clé (ssh-copy-id), puis relance. Rien n'a été modifié."
exit 1
fi
cp -n "$SSHD" "$SSHD.bak.$(date +%s)" 2>/dev/null || true
sed -ri 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' "$SSHD"
sed -ri 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/' "$SSHD"
sed -ri 's/^#?X11Forwarding.*/X11Forwarding no/' "$SSHD"
# Drop-ins sshd_config.d : la PREMIÈRE occurrence gagne (Include en tête de sshd_config)
# → désactiver le mot de passe aussi dans les drop-ins (ex. 50-cloud-init.conf).
for dropin in "$SSHD".d/*.conf; do
[[ -f "$dropin" ]] || continue
cp -n "$dropin" "$dropin.bak.$(date +%s)" 2>/dev/null || true
sed -ri 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' "$dropin"
done
systemctl restart ssh
journal "SSH keys-only activé" "Durcissement SSH (G6)" "PasswordAuthentication no, PermitRootLogin prohibit-password" "Re-activer PasswordAuthentication yes dans sshd_config"
# --- 2. ufw ---
log "ufw : activation (deny incoming / allow outgoing)"
# G8 : les règles existantes sont sauvegardées AVANT le reset (ufw --force reset est destructif).
ufw_bak="/etc/ufw/before.rules.bak.$(date +%s)"
cp -a /etc/ufw/before.rules "$ufw_bak" 2>/dev/null || true
cp -a /etc/ufw/user.rules "/etc/ufw/user.rules.bak.$(date +%s)" 2>/dev/null || true
log "règles ufw sauvegardées : $ufw_bak"
ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
# Services LAN-only (G9 = LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
# Ports des services RÉELS sur .24 (n8n, synapse, element, couchdb, obsidian) +
# briques famille futures + Valheim (TCP+UDP). Accessibles depuis le LAN uniquement.
for port in 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096; do
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
done
for port in 2456 2457 2458; do
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
ufw allow from 192.168.1.0/24 to any port "$port" proto udp
done
ufw --force enable
journal "ufw activé" "Firewall LAN-only (G6)" "deny incoming, allow outgoing, OpenSSH + services LAN-only 192.168.1.0/24 (TCP 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 + Valheim 2456-2458 TCP/UDP)" "ufw disable"
# --- 3. unattended-upgrades ---
log "unattended-upgrades : activation"
apt-get update -qq
apt-get install -y -qq unattended-upgrades
dpkg-reconfigure -f noninteractive unattended-upgrades
systemctl enable --now unattended-upgrades
journal "unattended-upgrades activé" "Mises à jour de sécurité auto (G7)" "installé + activé" "systemctl disable --now unattended-upgrades"
# --- 4. fail2ban ---
log "fail2ban : installation + activation"
apt-get install -y -qq fail2ban
cp -n "$(dirname "$0")/../configs/fail2ban.local.conf" /etc/fail2ban/jail.local
systemctl enable --now fail2ban
journal "fail2ban activé" "Anti brute-force SSH (G7)" "installé + activé" "systemctl disable --now fail2ban"
log "Terminé. Vérif : ss -tlnp | grep :22, ufw status, systemctl is-active fail2ban unattended-upgrades"
echo "AVANT de couper ton accès SSH, ouvre une 2e session et teste 'ssh <user>@G9' avec ta clé."
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# 02-data.sh — T2.2 (Option 0, validée 10/10/2026) : espace données /data sur le G9
#
# Décision tracée : LUKS abandonné sur le G9 (mono-disque Samsung 990 Pro 2 To,
# données familiales non critiques, protection = ufw LAN-only + backups restic
# chiffrés — voir DOSSIER-SOFTWARE S9/S10, changelog 10/10/2026).
# Remplace 02-disk-luks.sh (aucune opération destructive : ni partitionnement,
# ni LUKS, ni réinstallation).
#
# Garde-fous : G1 (idempotent, non destructif), G7 (rollback = rmdir), G8.
#
# Fait :
# /data (root du volume données, sur le LV racine existant)
# /data/famille (partage SMB — T2.4)
# /data/docker (données conteneurs si besoin)
#
# Rollback : rmdir /data/famille /data/docker /data (dossiers vides uniquement)
set -euo pipefail
log() { echo -e "\n[data] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | data : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ $EUID -eq 0 ]] || { echo "ERREUR : exécuter en root (sudo)"; exit 1; }
OWNER="${SUDO_USER:-folzo}"
log "Création de /data (dossier sur le LV racine — pas de partition, pas de LUKS)"
mkdir -p /data /data/famille /data/docker
chown "$OWNER":"$OWNER" /data /data/famille /data/docker
chmod 755 /data
chmod 2775 /data/famille
chmod 2775 /data/docker
df -h /data
journal "Espace /data créé (Option 0)" "Données famille sur mono-disque 990 Pro, LUKS abandonné (protection = ufw LAN + restic chiffré)" "/data, /data/famille, /data/docker (owner $OWNER, 2775)" "rmdir /data/famille /data/docker /data"
log "Terminé. Vérif : df -h /data, ls -ld /data /data/famille"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# 03-docker.sh — T2.3 : Docker + docker compose + groupe docker
# Idempotent. Exécuter en root.
#
# Garde-fous : G7 (rollback), G8 (journalisation).
#
# Rollback : apt-get remove docker.io docker-compose-v2 ; groupe docker supprimé manuellement.
set -euo pipefail
log() { echo -e "\n[docker] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | docker : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ $EUID -eq 0 ]] || { echo "ERREUR : exécuter en root (sudo)"; exit 1; }
log "Installation Docker + compose"
apt-get update -qq
apt-get install -y -qq docker.io docker-compose-v2
systemctl enable --now docker
# Utilisateur courant dans le groupe docker (évite sudo pour docker)
USER="${SUDO_USER:-$(whoami)}"
if [[ "$USER" != "root" ]]; then
usermod -aG docker "$USER"
log "Utilisateur '$USER' ajouté au groupe docker (reconnexion SSH requise)"
fi
journal "Docker + compose installés" "Runtime conteneurs du G9" "$USER dans le groupe docker" "apt-get remove docker.io docker-compose-v2"
log "Terminé. Vérif : docker version, docker compose version, docker run --rm hello-world"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# 03b-secrets.sh — T2.3b : bootstrap des fichiers secrets des briques famille
# Idempotent (ne remplace JAMAIS un secret déjà existant). Exécuter en root.
#
# Garde-fous : G9 (secrets hors git, non loggués), G7 (rollback), G8 (journalisation).
#
# Pourquoi : les compose montent ./secrets:/run/secrets:ro et lisent *_PASSWORD_FILE.
# Rien d'autre ne crée ces fichiers → sans ce script, le premier 'compose up'
# crash-loop sur secret manquant. À exécuter AVANT 07-migrate-stateful.sh (étape 3).
#
# Génère (chmod 644 root:root — voir la note du code : les entrypoints conteneurs ne sont
# pas root, 600 les rendrait illisibles ; le dossier reste hors git, G9 respecté) pour chaque brique :
# nextcloud : dbpass, rootpass
# photoprism : admin, dbpass, rootpass
# vaultwarden : admin
# n8n : dbpass
# gitea : dbpass (backend Postgres — voir compose/gitea)
# (jellyfin, watchtower : pas de secret fichier)
#
# ⚠️ QUELS CREDENTIELS GAGNENT (piège de migration) :
# - INIT FRAÎCHE : ce sont ces secrets générés qui sont utilisés (MARIADB_*/POSTGRES_*
# les seedent au 1er démarrage de la base).
# - IMPORT STATEFUL (07-migrate-stateful.sh) : les env MARIADB_*/POSTGRES_* sont IGNORÉS
# (ils ne seedent qu'une base vierge). La base importée garde les CREDENTIELS
# D'ORIGINE du xpenology, et la config app importée (config.php, storage…) est
# cohérente avec elle. → Les secrets générés ici sont donc INUTILES (dead weight)
# pour une brique importée ; ils ne servent que si la base doit être re-initialisée.
#
# NB : les secrets sont HORS GIT (G9). Ne jamais les logguer ni les commiter.
set -euo pipefail
log() { echo -e "\n[secrets] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | secrets : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
COMPOSE_ROOT="$(dirname "$SCRIPT_DIR")/compose"
# Génère un secret aléatoire (24 octets base64) dans $1, sans écraser s'il existe.
gen_secret() {
local file="$1"
if [[ -f "$file" ]]; then
log " ⏭ existe déjà : $file (non modifié)"
return 0
fi
umask 077
openssl rand -base64 24 > "$file"
# 644 (root:root) : les entrypoints conteneurs qui lisent /run/secrets/* ne sont
# PAS root (n8n = uid 1000, Nextcloud = www-data) → 600 root = illisible = crash-loop.
# Le dossier reste hors git (G9) ; 644 = compromis lisible par les services.
chmod 644 "$file"
log " ✅ généré : $file"
}
# briques : "brique:s1 s2 s3;..."
declare -A SECRET_MAP=(
[nextcloud]="dbpass rootpass"
[photoprism]="admin dbpass rootpass"
[vaultwarden]="admin"
[n8n]="dbpass"
[gitea]="dbpass"
)
for brique in nextcloud photoprism vaultwarden n8n gitea; do
COMPOSE_DIR="$COMPOSE_ROOT/$brique"
[[ -d "$COMPOSE_DIR" ]] || { echo "ERREUR : $COMPOSE_DIR absent"; exit 1; }
SECRETS_DIR="$COMPOSE_DIR/secrets"
mkdir -p "$SECRETS_DIR"
log "Brique $brique → $SECRETS_DIR"
for s in ${SECRET_MAP[$brique]}; do
gen_secret "$SECRETS_DIR/$s"
done
done
journal "Secrets bootstrap OK" "Briques stateful nécessitent des fichiers secrets" "nextcloud, photoprism, vaultwarden, n8n (chmod 644 root:root, dossier hors git)" "suppression des fichiers secrets générés"
log "Terminé. Vérif : ls -l compose/*/secrets/ (644 root:root, non versionnés — 644 voulu : lus par l'uid du conteneur)."
echo "⚠️ Ces secrets sont HORS GIT (G9). Ne pas les commiter ni les logguer."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# 04-smb.sh — T2.4 : Partage SMB (données famille) sur le G9
# Idempotent. Exécuter en root.
#
# Garde-fous : G6 (réseau LAN-only), G9 (aucun secret loggué/commité),
# G7 (rollback), G8 (journalisation).
#
# Modèle retenu (10/10/2026 — zéro maintenance par périphérique) :
# accès INVITÉ lecture/écriture (« map to guest »), réservé au LAN (ufw 137-139/445).
# Les écritures sont forcées sur l'utilisateur du G9 (force user) → permissions
# simples, aucun compte à créer par appareil. Un appareil qui exige des
# identifiants accepte n'importe quel utilisateur/mot de passe (mappé en invité).
#
# Partage : /data/famille → //G9/famille
# Rollback : systemctl disable --now smbd nmbd ; suppression du [famille] dans
# /etc/samba/smb.conf ; ufw delete des règles SMB 137/138/139/445
set -euo pipefail
log() { echo -e "\n[smb] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | smb : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ $EUID -eq 0 ]] || { echo "ERREUR : exécuter en root (sudo)"; exit 1; }
log "Installation Samba"
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq samba
OWNER="${SUDO_USER:-folzo}"
# Dossier partagé
mkdir -p /data/famille
chown "$OWNER":"$OWNER" /data/famille
chmod 2775 /data/famille
# Global : accès invité (idempotent)
if ! grep -qiE '^map to guest' /etc/samba/smb.conf; then
sed -ri 's/^\[global\]/[global]\n map to guest = Bad User/' /etc/samba/smb.conf
fi
# Config du partage (idempotent : on n'ajoute [famille] que s'il est absent)
if grep -q '^\[famille\]' /etc/samba/smb.conf; then
log "Partage [famille] déjà présent — on le laisse tel quel (idempotent)."
else
cat >> /etc/samba/smb.conf <<EOF
[famille]
path = /data/famille
browseable = yes
read only = no
guest ok = yes
force user = $OWNER
create mask = 0664
directory mask = 2775
EOF
fi
# Firewall : SMB réservé au LAN (G6) — idempotent
ufw allow from 192.168.1.0/24 to any port 445 proto tcp 2>/dev/null || true
ufw allow from 192.168.1.0/24 to any port 139 proto tcp 2>/dev/null || true
ufw allow from 192.168.1.0/24 to any port 137 proto udp 2>/dev/null || true
ufw allow from 192.168.1.0/24 to any port 138 proto udp 2>/dev/null || true
# Ubuntu >= 24 : « disable netbios = yes » par défaut → nmbd ne démarre pas
# (découverte par nom impossible). L'activer (réservé LAN, filtré par ufw).
sed -ri "s/^([[:space:]]*)disable netbios.*/\1disable netbios = No/I" /etc/samba/smb.conf
systemctl enable --now smbd nmbd
systemctl restart nmbd
journal "Samba installé, partage [famille] (invité LAN)" "Accès SMB données famille sans maintenance par appareil" "partage [famille] → /data/famille, guest ok, force user $OWNER, ufw SMB LAN-only" "systemctl disable --now smbd nmbd ; suppression [famille] ; ufw delete règles SMB"
log "Terminé. Vérif : testparm -s ; smbclient -L //localhost -N ; depuis un poste LAN : \\\\192.168.1.24\\famille"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# 05-beszel.sh — T2.5 : Agent Beszel sur le G9
# Idempotent. Exécuter en root.
#
# Garde-fous : G7 (rollback), G8 (journalisation), G9 (le token n'est JAMAIS
# loggué ni commité — passé par l'environnement, écrit uniquement dans l'unit
# systemd root-only).
#
# Hub : http://192.168.1.25:8090 (monitoring.folzocorp.fr) — container hub sur .25.
#
# Prérequis : BESZEL_TOKEN = token universel du hub (créé dans le hub :
# /settings/tokens → « nouveau token »). BESZEL_KEY = clé publique du hub
# (affichée dans « Add System ») pour la méthode SSH.
#
# Usage :
# sudo BESZEL_TOKEN=<token> bash 05-beszel.sh
#
# Rollback : /tmp/install-agent.sh -u ; ou systemctl disable --now beszel-agent
set -euo pipefail
log() { echo -e "\n[beszel] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 2 | beszel : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ $EUID -eq 0 ]] || { echo "ERREUR : exécuter en root (sudo)"; exit 1; }
BESZEL_HUB="${BESZEL_HUB:-http://192.168.1.25:8090}"
BESZEL_KEY="${BESZEL_KEY:-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINo6KvTbvXbilsNLqHX+EOZKuZGgpNcfx+c+mbyivBlv}"
BESZEL_VERSION="${BESZEL_VERSION:-v0.21.0}"
[[ -n "${BESZEL_TOKEN:-}" ]] || { echo "ERREUR : BESZEL_TOKEN requis (token universel du hub, /settings/tokens). Rien n'a été installé."; exit 1; }
if systemctl is-active beszel-agent >/dev/null 2>&1; then
log "Agent beszel-agent déjà actif — idempotent, rien à faire."
journal "Agent Beszel déjà actif" "Monitoring du G9" "hub $BESZEL_HUB" "install-agent.sh -u"
exit 0
fi
log "Installation de l'agent Beszel $BESZEL_VERSION (hub: $BESZEL_HUB) — token non loggué"
curl -fsSL https://get.beszel.dev -o /tmp/install-agent.sh
chmod +x /tmp/install-agent.sh
/tmp/install-agent.sh -k "$BESZEL_KEY" -t "$BESZEL_TOKEN" -url "$BESZEL_HUB" -v "$BESZEL_VERSION" --auto-update false
unset BESZEL_TOKEN
journal "Agent Beszel installé ($BESZEL_VERSION)" "Monitoring du G9 (T2.5)" "hub $BESZEL_HUB, agent actif (token non loggué)" "systemctl disable --now beszel-agent ; /tmp/install-agent.sh -u"
log "Terminé. Vérif : systemctl is-active beszel-agent ; journalctl -u beszel-agent -n 5 ; le G9 apparaît dans le hub."
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
#
# 06-restic-backup.sh — T3.1 : restic G9 → Freebox (chiffré, quotidien) + test restauration
# Idempotent. Exécuter en root : sudo bash 06-restic-backup.sh
#
# Transport retenu (10/10/2026, après tests) : **FTP local de la Freebox (port 21)** via le
# backend rclone de restic (`RESTIC_REPOSITORY=rclone:freeboxftp:Freebox/backup/g9`).
#
# Transports testés/écartés + limitations append-only : **source unique = RESTORE-G9.md §6**.
#
# Prérequis :
# - /etc/restic.env (600, root) rempli + /etc/restic/repo.key (600) = mot de passe du repo
# - /root/.config/rclone/rclone.conf (600) = RCLONE_CONFIG : remote [freeboxftp] type=ftp
# host=192.168.1.254 port=21 user=freebox (+ [freebox] type=smb en secours)
# - Serveur FTP de la Freebox activé (Freebox OS → Paramètres → Mode avancé → FTP)
#
# Fait :
# 1. Installe restic (binaire officiel épinglé + sha256) et rclone (backend smb/ftp)
# 2. Vérifie la connectivité FTP vers la Freebox
# 3. Initialise le repo (si absent)
# 4. Premier backup de RESTIC_SOURCES, conteneurs stateful arrêtés (cohérence des bases)
# 5. Append-only 5m APRÈS le premier backup (limitation : enforcement côté client)
# 6. TEST DE RESTAURATION (AC5) sur un fichier réel + comparaison sha256
# 7. Cron : backup 03:30 (stop → backup → start) + prune 04:00 + health 09:15 + alerte Matrix
#
# Rollback : systemctl stop des jobs (crontab -r lignes restic) ; apt-get remove rclone ;
# rm /usr/local/bin/restic ; suppression du repo distant (destructif, manuel).
set -euo pipefail
log() { echo -e "\n[restic] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 3 | restic : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "${G9_JOURNAL:-/root/g9-migration/journal.md}"
}
ENV_FILE=/etc/restic.env
[[ -f "$ENV_FILE" ]] || { echo "ERREUR : $ENV_FILE absent. Copier configs/restic.env.example et remplir."; exit 1; }
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a # set -a : exporter, sinon restic/rclone ne voient pas les variables
[[ -n "${RESTIC_REPOSITORY:-}" && -n "${RESTIC_PASSWORD_FILE:-}" ]] || { echo "ERREUR : RESTIC_REPOSITORY / RESTIC_PASSWORD_FILE vides dans $ENV_FILE"; exit 1; }
[[ -f "$RESTIC_PASSWORD_FILE" ]] || { echo "ERREUR : $RESTIC_PASSWORD_FILE introuvable (mot de passe du repo)"; exit 1; }
chmod 600 "$ENV_FILE" "$RESTIC_PASSWORD_FILE"
[[ -n "${RCLONE_CONFIG:-}" && -f "$RCLONE_CONFIG" ]] || { echo "ERREUR : config rclone absente ($RCLONE_CONFIG)"; exit 1; }
chmod 600 "$RCLONE_CONFIG"
RCLONE_BIN=/usr/local/bin/rclone
RESTIC_BIN=/usr/local/bin/restic
STATEFUL="${RESTIC_STATEFUL:-valheim n8n synapse couchdb obsidian}"
AOPT=()
if [[ "${RESTIC_RCLONE_APPENDONLY:-0}" == "1" ]]; then AOPT=(-o "rclone.args=serve restic --stdio --append-only"); fi
log "Installation restic (officiel, épinglé) + rclone"
apt-get update -qq >/dev/null 2>&1 || true
apt-get install -y -qq unzip bzip2 curl jq >/dev/null
RESTIC_VERSION="${RESTIC_VERSION:-0.19.1}"
RESTIC_SHA256="${RESTIC_SHA256:-f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c}"
if ! $RESTIC_BIN version 2>/dev/null | grep -q "$RESTIC_VERSION"; then
tmpd=$(mktemp -d)
( cd "$tmpd" && curl -fsSL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" \
&& printf '%s restic.bz2\n' "$RESTIC_SHA256" | sha256sum -c - \
&& bzip2 -d restic.bz2 && install -m 755 restic "$RESTIC_BIN" ) || { echo "ERREUR : sha256 restic invalide"; exit 1; }
rm -rf "$tmpd"
fi
RCLONE_VERSION="${RCLONE_VERSION:-1.75.2}"
RCLONE_SHA256="${RCLONE_SHA256:-349ac8fba6ff65d6247043f1750cdcb518ec5d500ef91463a10d37c0ccdf3702}"
if ! $RCLONE_BIN version 2>/dev/null | grep -q "v${RCLONE_VERSION}"; then
tmpd=$(mktemp -d)
( cd "$tmpd" \
&& curl -fsSL -o r.zip "https://downloads.rclone.org/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip" \
&& printf '%s r.zip\n' "$RCLONE_SHA256" | sha256sum -c - \
&& unzip -q r.zip && install -m 755 rclone-*-linux-amd64/rclone "$RCLONE_BIN" ) \
|| { echo "ERREUR : sha256 rclone invalide (v${RCLONE_VERSION})"; exit 1; }
rm -rf "$tmpd"
fi
log "restic $($RESTIC_BIN version | head -1) / rclone $($RCLONE_BIN version | head -1)"
$RESTIC_BIN options 2>&1 | grep -q 'rclone.args' || { echo "ERREUR : backend rclone indisponible dans restic"; exit 1; }
EXCLUDES_DEST="${RESTIC_EXCLUDE_FILE:-/etc/restic/excludes.txt}"
if [[ ! -f "$EXCLUDES_DEST" ]]; then
cp "$(dirname "$0")/../configs/excludes.txt.example" "$EXCLUDES_DEST"
chmod 644 "$EXCLUDES_DEST"
log "Installé : $EXCLUDES_DEST (depuis configs/excludes.txt.example)"
fi
log "Connectivité FTP vers la Freebox (${RESTIC_RCLONE_REMOTE:-freeboxftp:})"
$RCLONE_BIN --timeout 30s lsf "${RESTIC_RCLONE_REMOTE:-freeboxftp:}${RESTIC_RCLONE_PATH:-Freebox/backup}" >/dev/null \
|| { echo "ERREUR : FTP Freebox injoignable (serveur FTP activé ? hôte/port dans $RCLONE_CONFIG ?)"; exit 1; }
log "Initialisation du repo : $RESTIC_REPOSITORY"
if ! $RESTIC_BIN -r "$RESTIC_REPOSITORY" snapshots >/dev/null 2>&1; then
$RESTIC_BIN -r "$RESTIC_REPOSITORY" init
$RESTIC_BIN -r "$RESTIC_REPOSITORY" snapshots >/dev/null || { echo "ERREUR : repo illisible après init"; exit 1; }
journal "Repo restic initialisé" "Backup chiffré du G9 (FTP/rclone)" "repo $RESTIC_REPOSITORY, config+clé relues après écriture" "suppression du repo distant (destructif, manuel)"
else
log "Repo déjà initialisé."
fi
if [[ "$($RESTIC_BIN -r "$RESTIC_REPOSITORY" snapshots 2>/dev/null | grep -cE '^[0-9a-f]{8}' || true)" == "0" ]]; then
log "Premier backup de $RESTIC_SOURCES — conteneurs stateful arrêtés (cohérence des bases)"
for c in $STATEFUL; do docker stop -t 20 "$c" >/dev/null 2>&1 && log "stop $c"; done
set +e
$RESTIC_BIN -r "$RESTIC_REPOSITORY" "${AOPT[@]}" backup --exclude-file "${RESTIC_EXCLUDE_FILE:-/etc/restic/excludes.txt}" --tag "${RESTIC_TAG:-g9}" $RESTIC_SOURCES
RC=$?
set -e
for c in $STATEFUL; do docker start "$c" >/dev/null 2>&1 && log "start $c"; done
[[ $RC -eq 0 ]] || { journal "Premier backup ÉCHEC" "Backup chiffré du G9" "rc=$RC" "re-exécuter le script"; echo "ERREUR : backup rc=$RC"; exit 1; }
journal "Premier backup OK" "Backup chiffré du G9 (G2)" "snapshot créé (sources $RESTIC_SOURCES), conteneurs stoppés puis relancés" "restic forget du snapshot + prune"
else
log "Snapshots déjà présents : premier backup sauté."
fi
log "Append-only (après le 1er backup) : rclone serve restic --append-only"
# restic >= 0.18 n'a plus de sous-commande `set` ; l'append-only s'obtient par le backend rclone
# (`rclone serve restic --stdio --append-only` refuse la suppression des objets du repo).
# Limitation assumée : enforcement par le process rclone local (le G9), pas par la Freebox.
APPENDONLY_ARGS=(-o "rclone.args=serve restic --stdio --append-only")
SCRATCH=/tmp/restic-appendonly-test
SCRATCH_REPO="${SCRATCH_REPO:-rclone:${RESTIC_RCLONE_REMOTE}Freebox/backup/_appendonly-test}"
mkdir -p "$SCRATCH"
echo "appendonly-proof-$(date -Is)" > "$SCRATCH/canary.txt"
set +e
$RESTIC_BIN -r "$SCRATCH_REPO" init >/dev/null 2>&1
$RESTIC_BIN -r "$SCRATCH_REPO" backup "$SCRATCH" >/dev/null 2>&1
$RESTIC_BIN -r "$SCRATCH_REPO" "${APPENDONLY_ARGS[@]}" forget --prune --keep-last 1 --force > "$SCRATCH/forget.log" 2>&1
RC_FORGET=$?
$RESTIC_BIN -r "$SCRATCH_REPO" snapshots >/dev/null 2>&1
RC_SNAP=$?
set -e
if [[ $RC_FORGET -ne 0 && $RC_SNAP -eq 0 ]]; then
journal "Append-only vérifié" "Anti-ransomware (G2)" "forget --prune REFUSÉ par rclone --append-only (rc=$RC_FORGET), snapshots toujours lisibles ; jobs backup utilisés avec cet argument" "retirer --append-only de RESTIC_RCLONE_APPENDONLY"
log "Append-only vérifié : suppression refusée (rc=$RC_FORGET), repo intact"
else
journal "Append-only NON vérifié" "Anti-ransomware (G2)" "rc_forget=$RC_FORGET rc_snap=$RC_SNAP — comportement inattendu" "voir journal"
log "AVERTISSEMENT : append-only non confirmé (rc_forget=$RC_FORGET)"
fi
rm -rf "$SCRATCH"
$RCLONE_BIN purge "$SCRATCH_REPO" >/dev/null 2>&1 || true
log "TEST DE RESTAURATION (AC5)"
RESTORE_DIR=$(mktemp -d /restore-test-XXXXXXXX)
# Fichier test = premier fichier > 1k des sources (déterministe ; `restic ls` n'expose pas
# la taille dans une colonne exploitable par awk, la branche `ls` était morte).
TEST_FILE=$(find $RESTIC_SOURCES -type f -size +1k 2>/dev/null | head -n 1 || true)
[[ -n "$TEST_FILE" ]] || { echo "ERREUR : aucun fichier test trouvé"; exit 1; }
RESTIC_PATH="$TEST_FILE"
$RESTIC_BIN -r "$RESTIC_REPOSITORY" restore latest --target "$RESTORE_DIR" --include "$RESTIC_PATH"
SRC_SHA=$(sha256sum "$TEST_FILE" | cut -d' ' -f1)
DST_SHA=$(sha256sum "$RESTORE_DIR/$RESTIC_PATH" 2>/dev/null | cut -d' ' -f1)
if [[ -f "$RESTORE_DIR/$RESTIC_PATH" ]] && cmp -s "$TEST_FILE" "$RESTORE_DIR/$RESTIC_PATH" && [[ "$SRC_SHA" == "$DST_SHA" ]]; then
log "Restauration testée OK : $TEST_FILE (sha256 ${SRC_SHA:0:12}… identique)"
journal "Test restauration OK" "Vérification G2 (restauration testée)" "$TEST_FILE restauré depuis la Freebox, sha256 identique" "—"
else
log "ÉCHEC restauration : $TEST_FILE (src ${SRC_SHA:0:12}… / dst ${DST_SHA:0:12}…)"
journal "Test restauration ÉCHEC" "Vérification G2" "fichier restauré différent de $TEST_FILE" "re-brancher le backup"
rm -rf "$RESTORE_DIR"; exit 1
fi
rm -rf "$RESTORE_DIR"
log "Jobs quotidiens + alerte Matrix"
install -m 755 "$(dirname "$0")/restic-notify.sh" /usr/local/bin/restic-notify.sh
cat > /usr/local/bin/restic-daily.sh <<'EOS'
#!/usr/bin/env bash
set -euo pipefail
set -a; source /etc/restic.env; set +a
RESTIC_BIN=/usr/local/bin/restic
RCLONE_BIN=/usr/local/bin/rclone
STATEFUL="${RESTIC_STATEFUL:-valheim n8n synapse couchdb obsidian}"
AOPT=()
if [[ "${RESTIC_RCLONE_APPENDONLY:-0}" == "1" ]]; then AOPT=(-o "rclone.args=serve restic --stdio --append-only"); fi
if ! $RCLONE_BIN --timeout 30s lsf "${RESTIC_RCLONE_REMOTE:-freeboxftp:}${RESTIC_RCLONE_PATH:-Freebox/backup}" >/dev/null 2>&1; then
/usr/local/bin/restic-notify.sh "BACKUP KO : FTP Freebox injoignable depuis le G9"
exit 1
fi
$RESTIC_BIN -r "$RESTIC_REPOSITORY" unlock >/dev/null 2>&1 || true
for c in $STATEFUL; do docker stop -t 20 "$c" >/dev/null 2>&1 || true; done
set +e
$RESTIC_BIN -r "$RESTIC_REPOSITORY" "${AOPT[@]}" backup --exclude-file "${RESTIC_EXCLUDE_FILE:-/etc/restic/excludes.txt}" --tag "${RESTIC_TAG:-g9}" $RESTIC_SOURCES
RC=$?
set -e
for c in $STATEFUL; do docker start "$c" >/dev/null 2>&1 || true; done
if [[ $RC -ne 0 ]]; then
/usr/local/bin/restic-notify.sh "BACKUP KO : restic a échoué (rc=$RC) sur le G9"
exit $RC
fi
EOS
chmod 755 /usr/local/bin/restic-daily.sh
cat > /usr/local/bin/restic-prune.sh <<'EOS'
#!/usr/bin/env bash
set -euo pipefail
set -a; source /etc/restic.env; set +a
RESTIC_BIN=/usr/local/bin/restic
$RESTIC_BIN -r "$RESTIC_REPOSITORY" unlock >/dev/null 2>&1 || true
AOPT=()
if [[ "${RESTIC_RCLONE_APPENDONLY:-0}" == "1" ]]; then AOPT=(-o "rclone.args=serve restic --stdio --append-only"); fi
RETAIN=(--keep-daily "${RESTIC_RETAIN_DAILY:-7}" --keep-weekly "${RESTIC_RETAIN_WEEKLY:-4}" \
--keep-monthly "${RESTIC_RETAIN_MONTHLY:-12}" --keep-yearly "${RESTIC_RETAIN_YEARLY:-5}")
# Le refus d'append-only est le comportement ATTENDU : log seul, pas d'alerte (sinon alerte
# quotidienne = fatigue du canal où arrivent les vrais « BACKUP KO »).
if ! $RESTIC_BIN -r "$RESTIC_REPOSITORY" "${AOPT[@]}" forget --prune "${RETAIN[@]}"; then
echo "$(date -Is) append-only : suppression des objets refusée (attendu), purge sans --append-only"
if ! $RESTIC_BIN -r "$RESTIC_REPOSITORY" forget --prune "${RETAIN[@]}"; then
/usr/local/bin/restic-notify.sh "PURGE KO : la purge a échoué même sans --append-only (rétention bloquée)" || true
exit 1
fi
fi
EOS
chmod 755 /usr/local/bin/restic-prune.sh
cat > /usr/local/bin/restic-health.sh <<'EOS'
#!/usr/bin/env bash
# Le dernier snapshot doit dater de moins de 26 h.
set -euo pipefail
set -a; source /etc/restic.env; set +a
RESTIC_BIN=/usr/local/bin/restic
LAST=$($RESTIC_BIN -r "$RESTIC_REPOSITORY" snapshots --json 2>/dev/null | grep -o '"time":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
AGE=$(( $(date +%s) - $(date -d "${LAST:-1970-01-01}" +%s) ))
if [[ $AGE -gt $((26*3600)) ]]; then
/usr/local/bin/restic-notify.sh "BACKUP KO : aucun snapshot depuis $((AGE/3600)) h sur le G9"
echo "KO: dernier snapshot = ${LAST:-aucun}"
exit 1
fi
echo "OK: dernier snapshot = $LAST"
EOS
chmod 755 /usr/local/bin/restic-health.sh
BACKUP_LINE="30 3 * * * /usr/local/bin/restic-daily.sh >> /var/log/restic-daily.log 2>&1"
PRUNE_LINE="0 4 * * * /usr/local/bin/restic-prune.sh >> /var/log/restic-prune.log 2>&1"
HEALTH_LINE="15 9 * * * /usr/local/bin/restic-health.sh >> /var/log/restic-health.log 2>&1"
cron_tmp=$(mktemp)
{ crontab -l 2>/dev/null | grep -vE 'restic-daily\.sh|restic-prune\.sh|restic-health\.sh' || true; echo "$BACKUP_LINE"; echo "$PRUNE_LINE"; echo "$HEALTH_LINE"; } > "$cron_tmp"
if [[ "$(grep -c 'restic-' "$cron_tmp")" -ne 3 ]]; then
rm -f "$cron_tmp"
echo "ERREUR : crontab refusé (3 lignes attendues) — crontab existant laissé intact"
exit 1
fi
crontab "$cron_tmp"
rm -f "$cron_tmp"
journal "Cron restic programmé" "Backup quotidien + visibilité" "backup 03:30 (stop/backup/start) + prune 04:00 + health 09:15" "suppression des 3 lignes crontab"
log "Terminé. Vérif : $RESTIC_BIN -r $RESTIC_REPOSITORY snapshots ; check ; stats"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# 06b-usb-media.sh — T6.1 : montage des disques USB média → /mnt/usb-media
# Prérequis MANUEL avant Jellyfin (T6.2). Exécuter en root.
#
# Garde-fous : G3 (formatage = validation explicite), G7 (rollback), G8 (journalisation).
#
# Pourquoi ce script est interactif : les disques USB (15 To + 5,5 To) ont des
# noms de device variables (sdb, sdc, sdd…) selon l'ordre de branchement. On ne
# peut PAS les détecter automatiquement en toute sécurité → l'utilisateur
# sélectionne les device à formater/monter.
#
# Fait :
# 1. Crée /mnt/usb-media
# 2. Affiche les disques USB détectés (lsblk)
# 3. Demande le device de CHAQUE disque média
# 4. (Si vierge) propose de formater en ext4 — garde-fou G3
# 5. Ajoute les entrées fstab (nofail) + montage
#
# ⚠️ OPÉRATION POTENTIELLEMENT DESTRUCTIVE (formatage) → bloqué sans --i-understand.
set -euo pipefail
log() { echo -e "\n[usb-media] $*"; }
die() { echo -e "\n[ERREUR] $*" >&2; exit 1; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 6 | usb-media : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
MOUNT_ROOT=/mnt/usb-media
mkdir -p "$MOUNT_ROOT"
log "Disques détectés :"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MODEL | grep -vE 'rom|loop' || true
echo
read -r -p "Nom du 1er disque média (ex. sdb) : " DISK1
read -r -p "Nom du 2e disque média (ex. sdc) : " DISK2
[[ -n "$DISK1" && -n "$DISK2" ]] || die "Deux disques requis."
for DISK in "$DISK1" "$DISK2"; do
DEV="/dev/${DISK}"
[[ -b "$DEV" ]] || die "Device introuvable : $DEV"
if lsblk -no MOUNTPOINT "$DEV" | grep -q .; then
die "$DEV a déjà des partitions montées. Abandon."
fi
FSTYPE=$(blkid -o value -s TYPE "$DEV" 2>/dev/null || true)
if [[ -z "$FSTYPE" ]]; then
[[ "${1:-}" == "--i-understand" ]] || die "$DEV est vierge (formatage requis). Relancer avec --i-understand."
read -r -p "CONFIRMER le formatage ext4 de $DEV (efface tout) : saisir $DEV : " CONF
[[ "$CONF" == "$DEV" ]] || die "Non confirmé. Abandon."
mkfs.ext4 -F "$DEV"
FSTYPE=ext4
fi
# Le fstab doit porter le VRAI type de fs (pas ext4 en dur) : les disques média
# sont souvent NTFS/exFAT. Un mauvais type + nofail = montage silencieux qui échoue.
case "$FSTYPE" in
ext4|xfs|btrfs|ntfs|exfat|vfat) ;;
*) die "$DEV : système de fichiers '$FSTYPE' non supporté pour le montage média. Abandon." ;;
esac
LABEL=$(echo "$DISK" | tr -d '/')
UUID=$(blkid -s UUID -o value "$DEV")
MP="$MOUNT_ROOT/$LABEL"
mkdir -p "$MP"
# fsck (pass 2) ne sait vérifier que ext2/3/4 → 0 pour ntfs/exfat/vfat/xfs.
case "$FSTYPE" in
ext2|ext3|ext4) FSCK=2 ;;
*) FSCK=0 ;;
esac
if ! grep -qE "^[^#]* $MP " /etc/fstab; then
echo "UUID=${UUID} $MP $FSTYPE defaults,noatime,nofail 0 $FSCK" >> /etc/fstab
fi
mount "$MP"
log "Monté : $DEV → $MP ($FSTYPE)"
done
journal "USB média montés" "Jellyfin (T6.2) lit les médias" "$MOUNT_ROOT (disques $DISK1, $DISK2)" "suppression des entrées fstab + umount"
log "Terminé. Jellyfin (T6.2) peut maintenant monter $MOUNT_ROOT."
echo "⚠️ Redémarrer pour valider le montage au boot (fstab nofail)."
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 06c-postboot-check.sh — vérifie la persistance du backup restic après reboot du G9.
# Lecture seule : ne modifie rien. Exécuter en root : sudo bash 06c-postboot-check.sh
set -uo pipefail
ok=0; ko=0
say() { printf '%-46s %s\n' "$1" "$2"; }
chk() { if eval "$2" >/dev/null 2>&1; then say "$1" "OK"; ok=$((ok+1)); else say "$1" "KO"; ko=$((ko+1)); fi; }
chk "binaire restic (/usr/local/bin/restic)" "test -x /usr/local/bin/restic"
chk "binaire rclone (/usr/local/bin/rclone)" "test -x /usr/local/bin/rclone"
chk "/etc/restic.env présent et 600" "test \$(stat -c %a /etc/restic.env) = 600"
chk "clé repo /etc/restic/repo.key 600" "test \$(stat -c %a /etc/restic/repo.key) = 600"
chk "config rclone 600" "test \$(stat -c %a /root/.config/rclone/rclone.conf) = 600"
chk "aucun montage CIFS requis (fstab)" "! grep -q cifs /etc/fstab"
chk "cron root : 3 lignes restic" "test \$(crontab -l | grep -c 'restic-') = 3"
chk "job quotidien exécutable" "test -x /usr/local/bin/restic-daily.sh"
chk "job prune exécutable" "test -x /usr/local/bin/restic-prune.sh"
chk "job health exécutable" "test -x /usr/local/bin/restic-health.sh"
chk "job notify exécutable" "test -x /usr/local/bin/restic-notify.sh"
chk "jq présent (payload de l'alerte)" "command -v jq"
set -a; . /etc/restic.env; set +a
chk "repo accessible (restic snapshots)" "restic -r \"\$RESTIC_REPOSITORY\" snapshots"
chk "repo intègre (restic check)" "restic -r \"\$RESTIC_REPOSITORY\" check --read-data-subset=1%"
chk "dernier snapshot < 26 h" "/usr/local/bin/restic-health.sh"
chk "fichier d'exclusions présent" "test -s \"${RESTIC_EXCLUDE_FILE:-/etc/restic/excludes.txt}\""
# Liste stateful lue depuis /etc/restic.env (source unique, pas de liste en dur ici).
RUNNING=$(docker ps --format '{{.Names}}')
stateful_up=0; stateful_total=0
for c in ${RESTIC_STATEFUL:-}; do
stateful_total=$((stateful_total+1))
grep -qx "$c" <<<"$RUNNING" && stateful_up=$((stateful_up+1))
done
chk "conteneurs stateful relancés (${stateful_up}/${stateful_total})" "test $stateful_total -gt 0 -a $stateful_up -eq $stateful_total"
echo
echo "persistance post-reboot : $ok OK / $ko KO"
[[ $ko -eq 0 ]] || exit 1
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
#
# 07-migrate-stateful.sh — T4.x : export/import des stateful (xpenology → G9)
#
# Garde-fous : G4 (double-run, xpenology = source de vérité, lecture seule), G7 (rollback), G8 (journalisation).
#
# Principe (garde-fou G4 — double-run) :
# 1. EXPORT des données depuis le xpenology (source de vérité) → copie sur le G9.
# 2. VÉRIFICATION D'INTÉGRITÉ fichier par fichier (manifeste sha256 trié, diff).
# 3. Le xpenology n'est JAMAIS modifié/supprimé ici — lecture seule.
# 4. Chaque brique est validée sur le G9 AVANT que le xpenology soit décommissionné (T6.4).
#
# Usage :
# ./07-migrate-stateful.sh <brique>
# briques : vaultwarden | nextcloud | photoprism | gitea | n8n
#
# Prérequis :
# - Accès SSH au xpenology : ssh -p 222 Master@192.168.1.25 (clé SSH + sudo NOPASSWD —
# compte `effi` bloqué par Synology depuis 10/2026, voir journal T1.1-b).
# - Sur le G9 : /data monté, Docker opérationnel.
# - Secrets bootstrap (03b-secrets.sh) déjà exécuté.
#
# ⚠️ COHÉRENCE DES BASES (G4) : le rsync d'une base VIVANTE (MariaDB/Postgres en
# service sur .25) produit une copie incohérente et un manifeste sha256 qui
# change pendant le transfert. Le script STOPPE donc les conteneurs source
# (XP_CONTAINERS) avant l'export et les REDÉMARRE juste après — le double-run
# G4 est respecté (la source reprend du service, rien n'est supprimé).
#
# ⚠️ CREDENTIELS DB : l'import est une COPIE de la base existante. Les env
# MARIADB_*/POSTGRES_* (seedés par 03b-secrets.sh) sont IGNORÉS sur une base
# déjà initialisée → la base importée garde les CREDENTIELS D'ORIGINE du
# xpenology, et la config app importée est cohérente avec elle. Le service
# G9 démarre donc avec les identifiants d'origine, PAS les secrets générés.
#
# ⚠️⚠️ À CONFIRMER PENDANT L'INVENTAIRE T1.2 ⚠️⚠️
# Les chemins sources xpenology (XP_MAP) sont des HYPOTHÈSES. Les conteneurs
# stateful ont été créés via la GUI DSM (snapshot ETAT-DE-LART-2026-10-06 §1),
# donc leur arborescence réelle n'est pas garantie. AVANT d'exécuter, vérifier
# sur le xpenology que chaque chemin source existe et contient les bonnes données,
# puis ajuster XP_MAP si besoin. Le script refuse de migrer un chemin source absent.
#
# Rollback : la migration est une COPIE → rien n'est perdu. Sur le G9, supprimer
# le conteneur + les sous-dossiers importés dans compose/<brique>/ si l'import est inacceptable.
set -euo pipefail
log() { echo -e "\n[migrate] $*"; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 4 | migrate:%s : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$BRIQUE" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
BRIQUE="${1:-}"
[[ -n "$BRIQUE" ]] || { echo "Usage : ./07-migrate-stateful.sh <vaultwarden|nextcloud|photoprism|gitea|n8n>"; exit 1; }
# --- Coordonnées xpenology (lecture seule) ---
XP_USER="Master"
XP_HOST="192.168.1.25"
XP_PORT=222
XP_DOCKER="/var/packages/ContainerManager/target/usr/bin/docker"
XP_SSH="ssh -p $XP_PORT -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $XP_USER@$XP_HOST"
# Pré-test rapide : connexion SSH + rsync présent sur le xpenology (échec franc avant transfert).
if ! $XP_SSH "command -v rsync >/dev/null 2>&1" 2>/dev/null; then
echo "ERREUR : connexion SSH impossible vers $XP_USER@$XP_HOST:$XP_PORT ou rsync absent du NAS."
echo "Vérifiez les accès SSH et la présence de rsync côté xpenology avant de relancer."
exit 1
fi
# --- Racine des compose G9 ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
COMPOSE_ROOT="$(dirname "$SCRIPT_DIR")/compose"
COMPOSE_DIR="$COMPOSE_ROOT/$BRIQUE"
[[ -d "$COMPOSE_DIR" ]] || { echo "ERREUR : $COMPOSE_DIR absent"; exit 1; }
# --- Mapping source (xpenology) → sous-dossier compose (G9) ---
# Format par brique : "xp_dir:g9_subdir;xp_dir:g9_subdir;..."
# g9_subdir est relatif à compose/<brique>/ (il correspond aux volumes relatifs ./data, ./db, ...).
# ⚠️ Chemins sources = HYPOTHÈSES à confirmer à l'inventaire T1.2.
declare -A XP_MAP=(
[vaultwarden]="/volume1/docker/vaultwarden:data"
[nextcloud]="/volume1/docker/nextcloud/data:data;/volume1/docker/nextcloud/config:config;/volume1/docker/nextcloud_db/db:db"
[photoprism]="/volume1/docker/photoprism/storage:storage;/volume1/docker/photoprism_db/db:db"
[gitea]="/volume1/docker/gitea:data"
[n8n]="/volume1/docker/n8n/data:data;/volume1/docker/n8n_db/db:db"
)
# Conteneurs SOURCE à stopper sur .25 pendant l'export (cohérence des bases),
# redémarrés immédiatement après (double-run G4). Noms = inventaire T1.1-a
# (conteneurs-xpenology.csv) — à ajuster si le nom réel diffère.
declare -A XP_CONTAINERS=(
[vaultwarden]="vaultwarden-server-1"
[nextcloud]="nextcloud_app nextcloud_db"
[photoprism]="photoprism photoprism_db"
[gitea]="gitea"
[n8n]="n8n"
)
# Photoprism lit les originaux IN-PLACE (pas de migration) : /data/famille/photos doit
# déjà être en place sur le G9 (T2.2/T2.4). Seuls storage + db sont migrés.
if [[ "$BRIQUE" == "photoprism" ]]; then
log "⚠️ PhotoPrism : les originaux sont lus IN-PLACE sur /data/famille/photos (non migrés)."
log " Vérifiez que /data/famille/photos est bien en place sur le G9 avant 'compose up'."
fi
XP_MAP_LINE="${XP_MAP[$BRIQUE]:-}"
[[ -n "$XP_MAP_LINE" ]] || { echo "ERREUR : mapping inconnu pour $BRIQUE (compléter XP_MAP)"; exit 1; }
# Décomposer le mapping en paires (xp_dir, g9_subdir)
IFS=';' read -ra PAIRS <<< "$XP_MAP_LINE"
XP_DIRS=()
G9_SUBS=()
for pair in "${PAIRS[@]}"; do
[[ -n "$pair" ]] || continue
XP_DIRS+=("${pair%%:*}")
G9_SUBS+=("${pair##*:}")
done
log "=== ÉTAPE 1/3 : VÉRIFICATION DES CHEMINS SOURCES (xpenology, lecture seule) ==="
MISSING=0
for i in "${!XP_DIRS[@]}"; do
if $XP_SSH "test -d '${XP_DIRS[$i]}'" 2>/dev/null; then
log " ✅ source présente : ${XP_DIRS[$i]} → compose/$BRIQUE/${G9_SUBS[$i]}/"
else
log " ❌ source ABSENTE : ${XP_DIRS[$i]} (attendu → compose/$BRIQUE/${G9_SUBS[$i]}/)"
MISSING=1
fi
done
if [[ $MISSING -eq 1 ]]; then
echo
echo "ERREUR : au moins un chemin source xpenology est absent."
echo "Les chemins de XP_MAP sont des hypothèses (conteneurs créés via GUI DSM)."
echo "Vérifiez l'arborescence réelle sur le xpenology (inventaire T1.2) et corrigez XP_MAP."
exit 1
fi
log "=== ÉTAPE 2/3 : TRANSFERT (rsync, chiffré via SSH) + INTÉGRITÉ fichier par fichier ==="
# --- Stop de la source (cohérence des bases vivantes) ---
STOPPED=()
for c in ${XP_CONTAINERS[$BRIQUE]:-}; do
if $XP_SSH "sudo $XP_DOCKER ps -q -f name=^${c}\$" 2>/dev/null | grep -q .; then
if $XP_SSH "sudo $XP_DOCKER stop $c" >/dev/null 2>&1; then
STOPPED+=("$c")
log " ⏸ source stoppée : $c"
else
log " ⚠️ échec du stop de $c — export potentiellement incohérent, À VÉRIFIER."
fi
else
log " ⚠️ conteneur '$c' non trouvé running sur .25 — vérifier l'inventaire (cohérence non garantie)."
fi
done
start_source() {
for c in "${STOPPED[@]:-}"; do
$XP_SSH "sudo $XP_DOCKER start $c" >/dev/null 2>&1 && log " ▶ source redémarrée : $c" || log " ⚠️ ÉCHEC du redémarrage de $c — INTERVENIR SUR .25."
done
}
trap start_source EXIT
TOTAL_FILES=0
for i in "${!XP_DIRS[@]}"; do
XP_DIR="${XP_DIRS[$i]}"
G9_SUB="${G9_SUBS[$i]}"
G9_DIR="$COMPOSE_DIR/$G9_SUB"
mkdir -p "$G9_DIR"
log "Transfert : $XP_DIR/ → $G9_DIR/"
rsync -a --info=progress2 -e "ssh -p $XP_PORT -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600" "$XP_USER@$XP_HOST:$XP_DIR/" "$G9_DIR/"
# Manifeste sha256 trié, chemins RELATIFS (identiques des deux côtés) → diff fiable.
log "Intégrité : manifeste sha256 (chemins relatifs, triés) pour $G9_SUB"
REMOTE_MANIFEST=$(mktemp)
LOCAL_MANIFEST=$(mktemp)
$XP_SSH "cd '$XP_DIR' && find . -type f -exec sha256sum {} + 2>/dev/null | sort" > "$REMOTE_MANIFEST"
( cd "$G9_DIR" && find . -type f -exec sha256sum {} + 2>/dev/null | sort ) > "$LOCAL_MANIFEST"
R_COUNT=$(wc -l < "$REMOTE_MANIFEST")
L_COUNT=$(wc -l < "$LOCAL_MANIFEST")
TOTAL_FILES=$(( TOTAL_FILES + R_COUNT ))
log " fichiers : source=$R_COUNT local=$L_COUNT"
if diff -u "$REMOTE_MANIFEST" "$LOCAL_MANIFEST" > "${REMOTE_MANIFEST}.diff"; then
log " ✅ intégrité OK ($G9_SUB)"
else
log " ❌ ÉCHEC intégrité ($G9_SUB) — différences :"
head -n 50 "${REMOTE_MANIFEST}.diff"
rm -f "$REMOTE_MANIFEST" "$LOCAL_MANIFEST" "${REMOTE_MANIFEST}.diff"
journal "import ÉCHEC intégrité sur $G9_SUB" "Vérification G4 (intégrité)" "manifeste sha256 différent" "supprimer le sous-dossier importé, re-transférer"
echo "ERREUR : l'intégrité a échoué sur $G9_SUB. Ne pas lancer le service. Re-vérifier le transfert."
exit 1
fi
rm -f "$REMOTE_MANIFEST" "$LOCAL_MANIFEST" "${REMOTE_MANIFEST}.diff"
done
log "Intégrité globale OK : $TOTAL_FILES fichiers vérifiés."
journal "export+import OK" "Migration G4 (copie, xpenology non modifié)" "$TOTAL_FILES fichiers, manifeste sha256 identique" "suppression du conteneur + sous-dossiers importés"
# --- Redémarrage de la source AVANT de toucher au G9 (double-run G4) ---
start_source
log "=== ÉTAPE 3/3 : LANCEMENT DU SERVICE SUR LE G9 ==="
log "docker compose up -d dans $COMPOSE_DIR"
docker compose -f "$COMPOSE_DIR/docker-compose.yml" up -d
log "Vérification du service (healthcheck)"
sleep 10
docker compose -f "$COMPOSE_DIR/docker-compose.yml" ps
log "Terminé. VALIDER MANUELLEMENT le service (login, 1 fichier, 1 recherche) AVANT de passer à la brique suivante."
journal "service lancé sur le G9" "Double-run G4" "compose up -d, healthcheck à valider" "docker compose down + suppression des données importées"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
#
# 07a-migrate-gitea.sh — T4.4 : migration Gitea (xpenology .25 → G9 .24)
#
# Garde-fous : G4 (double-run, .25 = source de vérité, LECTURE SEULE), G2 (backup restic
# déclenché avant import), G7 (rollback), G8 (journalisation), G9 (aucun secret loggué).
#
# SOURCE RÉELLE (mesurée le 2026-10-11, voir C-T4-INV) : le Gitea familial est le **paquet
# App Center DSM « gitea » v1.27.3** (compte sc-gitea), PAS le conteneur compose :
# - DB : postgres du conteneur partagé `postgres16` (127.0.0.1:5433), base `gitea_db`,
# rôle `gitea_folzo` → 13 repositories, 3 users
# - dépôts : /volume2/gitea/gitea-repositories (155 Mo)
# - LFS : /volume2/gitea/lfs
# - appdata : /volume2/@appdata/gitea/data (avatars, attachments, jwt, sessions, indexers)
# - conf : /volume2/@appdata/gitea/conf.ini (ROOT = /volume2/gitea/gitea-repositories)
# Le conteneur `gitea` de DSM (compose, ROOT = /data/git/repositories VIDE) partage la MÊME base
# postgres : second frontend, dépôts vides → il n'est PAS la source.
#
# COHÉRENCE (G4) : pg_dump = instantané cohérent (MVCC) → AUCUN stop, AUCUNE écriture,
# AUCUNE suppression sur .25. Les dépôts git (objets) sont quiets.
#
# TRANSIT : **tar sur SSH** (.25 → stage .11 → .24). Mesuré le 2026-10-11 : le sshd de DSM
# refuse `rsync --server` (« Permission denied, please try again. ») et scp (subsystem sftp
# internal-sftp) → `subsystem request failed`. Seuls `ssh 'sudo tar cz'` et `ssh 'sudo cat'`
# fonctionnent. Le stage local est un cache, purgé à la fin.
#
# Usage (machine de pilotage détenant les clés SSH, ex. PC IA .11) : ./07a-migrate-gitea.sh
# Prérequis : clés SSH Master@.25:222 + folzo@.24 ; 03b-secrets.sh exécuté sur le G9.
#
# Rollback : migration = COPIE. G9 : `docker compose -f /data/docker/gitea/docker-compose.yml
# down` + suppression de /data/docker/gitea. .25 : rien (intact).
set -euo pipefail
XP_USER="Master"; XP_HOST="192.168.1.25"; XP_PORT=222
XP_DOCKER="/var/packages/ContainerManager/target/usr/bin/docker"
G9_USER="folzo"; G9_HOST="192.168.1.24"
KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
BRIQUE="gitea"
G9_DIR="/data/docker/$BRIQUE"
KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
KIT_ON_G9="/home/folzo/g9-migration"
JOURNAL="$KIT_DIR/journal.md"
STAGE="${STAGE:-$HOME/migration-staging/$BRIQUE}"
SSH_XP="ssh -p $XP_PORT -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $XP_USER@$XP_HOST"
SSH_G9="ssh -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $G9_USER@$G9_HOST"
RSH_XP="ssh -p $XP_PORT -i $KEY"
RSH_G9="ssh -i $KEY"
log() { echo -e "\n[gitea] $*"; }
journal() {
printf '| %s | 4 | migrate:%s : %s | %s | %s | %s |\n' \
"$(date '+%Y-%m-%d %H:%M')" "$BRIQUE" "$1" "$2" "$3" "$4" >> "$JOURNAL"
}
manifest_in() { ( cd "$1" && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort ); }
# ---------------------------------------------------------------- 0. préflight
log "=== 0/5 PRÉFLIGHT ==="
$SSH_XP "command -v rsync >/dev/null && echo XP_OK" >/dev/null || { echo "ERREUR : SSH/rsync .25 indisponibles"; exit 1; }
$SSH_G9 "command -v rsync >/dev/null && command -v docker >/dev/null && echo G9_OK" >/dev/null || { echo "ERREUR : SSH/rsync/docker .24 indisponibles"; exit 1; }
$SSH_XP "command -v tar >/dev/null && sudo tar --version >/dev/null" || { echo "ERREUR : SSH/tar(sudo) indisponibles sur .25"; exit 1; }
for d in /volume2/gitea/gitea-repositories /volume2/gitea/lfs /volume2/@appdata/gitea/data; do
$SSH_XP "sudo test -d '$d'" >/dev/null || { echo "ERREUR : source absente sur .25 : $d"; exit 1; }
done
$SSH_G9 "test -d $KIT_ON_G9/compose && command -v rsync >/dev/null" || {
echo "ERREUR : kit absent sur le G9 ($KIT_ON_G9) — synchroniser le kit avant de migrer."; exit 1; }
mkdir -p "$STAGE"
# ---------------------------------------------------------------- 1. backup G2
log "=== 1/5 BACKUP restic (G2) ==="
$SSH_G9 "sudo /usr/local/bin/restic-daily.sh >/dev/null 2>&1; sudo restic snapshots 2>/dev/null | tail -2"
journal "backup restic pré-migration" "Garde-fou G2 avant import Gitea" "snapshot restic Freebox (voir logs /var/log/restic-daily.log)" "restic forget <snapshot> && restic prune"
# ---------------------------------------------------------------- 2. export .25
log "=== 2/5 EXPORT pg_dump depuis .25 (lecture seule, MVCC, sans stop) ==="
STAMP="$(date '+%Y-%m-%d-%H%M')"
DUMP_REMOTE="/tmp/gitea_db_$STAMP.dump"
$SSH_XP "sudo $XP_DOCKER exec postgres16 pg_dump -Fc -Z3 -U gitea_folzo gitea_db > $DUMP_REMOTE && sudo chmod 644 $DUMP_REMOTE"
DUMP_SHA=$($SSH_XP "sha256sum $DUMP_REMOTE | cut -d' ' -f1")
DUMP_SIZE=$($SSH_XP "stat -c%s $DUMP_REMOTE")
log "dump ${DUMP_REMOTE##*/} : $DUMP_SIZE o, sha256 ${DUMP_SHA:0:16}…"
$SSH_XP "sudo cat $DUMP_REMOTE" > "$STAGE/${DUMP_REMOTE##*/}"
[[ "$(sha256sum "$STAGE/${DUMP_REMOTE##*/}" | cut -d' ' -f1)" == "$DUMP_SHA" ]] || { echo "ERREUR : dump corrompu au stage"; exit 1; }
$SSH_XP "sudo rm -f $DUMP_REMOTE"
# ---------------------------------------------------------------- 3. transfert + intégrité
log "=== 3/5 TRANSFERT (tar sur SSH : .25 → stage → .24) + INTÉGRITÉ sha256 ==="
copy_pair() { # $1 = src .25, $2 = nom du sous-dossier G9
local src="$1" name="$2"
local stage="$STAGE/$name"
mkdir -p "$stage"
log " hop 1 (tar/ssh) : $src/ → stage/$name/"
$SSH_XP "sudo tar cz -C '$src' ." | tar xz -C "$stage"
log " intégrité $name (source vs stage) :"
local r l rc=0
r=$(mktemp); l=$(mktemp)
$SSH_XP "cd '$src' && sudo find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort" > "$r"
manifest_in "$stage" > "$l"
diff -u "$r" "$l" > "$r.diff" || rc=1
if [[ $rc -ne 0 ]]; then
log " ❌ ÉCHEC intégrité $name — 20 premières différences :"; head -20 "$r.diff"
rm -f "$r" "$l" "$r.diff"
journal "import ÉCHEC intégrité ($name)" "Vérification G4" "manifeste sha256 source ≠ stage" "purger stage/$name et re-exécuter"
echo "ERREUR : intégrité $name — arrêt avant le G9."; exit 1
fi
log " ✅ $(wc -l < "$r") fichiers identiques (source == stage)"
rm -f "$r" "$l" "$r.diff"
log " hop 2 (rsync) : stage/$name/ → .24:$G9_DIR/$name/"
$SSH_G9 "mkdir -p $G9_DIR/$name"
rsync -a --info=stats1 -e "$RSH_G9" "$stage/" "$G9_USER@$G9_HOST:$G9_DIR/$name/"
local g rc2=0
g=$(mktemp)
manifest_in "$stage" > "$r"; $SSH_G9 "cd '$G9_DIR/$name' && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort" > "$g"
diff -u "$r" "$g" > "$g.diff" || rc2=1
if [[ $rc2 -ne 0 ]]; then
log " ❌ ÉCHEC intégrité $name sur le G9 :"; head -20 "$g.diff"
rm -f "$r" "$g" "$g.diff"
journal "import ÉCHEC intégrité G9 ($name)" "Vérification G4" "manifeste stage ≠ G9" "supprimer $G9_DIR/$name et re-transférer"
echo "ERREUR : intégrité G9 $name — arrêt."; exit 1
fi
log " ✅ $name : $(wc -l < "$g") fichiers identiques sur le G9"
rm -f "$r" "$g" "$g.diff"
}
copy_pair /volume2/gitea/gitea-repositories repositories
copy_pair /volume2/gitea/lfs lfs
copy_pair /volume2/@appdata/gitea/data data/gitea
log " dump → G9"
$SSH_G9 "mkdir -p $G9_DIR/backup"
rsync -a --info=stats1 -e "$RSH_G9" "$STAGE/${DUMP_REMOTE##*/}" "$G9_USER@$G9_HOST:$G9_DIR/backup/"
[[ "$($SSH_G9 "sha256sum $G9_DIR/backup/${DUMP_REMOTE##*/} | cut -d' ' -f1")" == "$DUMP_SHA" ]] || { echo "ERREUR : sha256 dump ≠ source"; exit 1; }
log " ✅ dump restauré sur le G9, sha256 identique (${DUMP_SHA:0:16}…)"
rm -rf "$STAGE"
# ---------------------------------------------------------------- 4. déploiement + restore
log "=== 4/5 DÉPLOIEMENT compose + RESTORE DB sur le G9 ==="
# Le kit vit sur le G9 dans /home/folzo/g9-migration : les secrets y sont générés par
# 03b-secrets.sh (644 root, hors git), puis le stack est installé sous /data/docker/<brique>
# (source couverte par le repo restic — C-COMMON).
KIT_ON_G9="/home/folzo/g9-migration"
rsync -a --info=stats1 -e "$RSH_G9" --exclude "secrets" "$KIT_DIR/compose/$BRIQUE/" "$G9_USER@$G9_HOST:$KIT_ON_G9/compose/$BRIQUE/"
rsync -a -e "$RSH_G9" "$KIT_DIR/scripts/03b-secrets.sh" "$G9_USER@$G9_HOST:$KIT_ON_G9/scripts/"
$SSH_G9 "sudo bash $KIT_ON_G9/scripts/03b-secrets.sh >/dev/null && sudo ls -l $KIT_ON_G9/compose/$BRIQUE/secrets/"
# secrets/ est root:root 700 (03b-secrets tourne en root) → copie du stack en sudo,
# puis secrets relus en 755/644 (lisibles par l'uid conteneur, dossier hors git — G9).
$SSH_G9 "sudo mkdir -p $G9_DIR && sudo rsync -a $KIT_ON_G9/compose/$BRIQUE/ $G9_DIR/ && sudo chmod 755 $G9_DIR/secrets && sudo chmod 644 $G9_DIR/secrets/dbpass"
$SSH_G9 "cd $G9_DIR && sudo sh -c 'umask 077 && printf \"GITEA__database__PASSWD=%s\n\" \"\$(cat secrets/dbpass)\" > secrets/gitea.env' && sudo chmod 644 secrets/gitea.env"
$SSH_G9 "cd $G9_DIR && docker compose up -d gitea-db && sleep 15 && docker compose ps --format '{{.Name}} {{.Status}}'"
log "restore pg_dump → gitea_db (rôle gitea_folzo, base vierge)"
# Le dump n'est pas monté dans le conteneur postgres → il entre par stdin (format custom).
$SSH_G9 "cd $G9_DIR && docker compose exec -T gitea-db pg_restore -U gitea_folzo -d gitea_db < backup/$(basename "$DUMP_REMOTE") 2>&1 | tail -8"
$SSH_G9 "cd $G9_DIR && docker compose exec -T gitea-db psql -U gitea_folzo -d gitea_db -t -A -c \"select 'repositories='||count(*) from repository; select 'users='||count(*) from \\\"user\\\";\""
$SSH_G9 "sudo chown -R 1000:1000 $G9_DIR/repositories $G9_DIR/lfs $G9_DIR/data && sudo chmod -R u+rwX $G9_DIR/repositories $G9_DIR/lfs $G9_DIR/data"
$SSH_G9 "cd $G9_DIR && docker compose up -d && sleep 20 && docker compose ps --format '{{.Name}} {{.Status}}'"
# ---------------------------------------------------------------- 5. vérification
log "=== 5/5 VÉRIFICATION ==="
$SSH_G9 "curl -sf -o /dev/null -w 'healthz HTTP %{http_code}\n' http://127.0.0.1:3000/api/healthz"
$SSH_G9 "cd $G9_DIR && docker compose exec -T gitea-db psql -U gitea_folzo -d gitea_db -t -A -c \"select owner_name||'/'||lower_name from repository order by 1;\""
log "source .25 (double-run G4) :"
$SSH_XP "sudo $XP_DOCKER ps --format '{{.Names}} {{.Status}}' | grep -E '^gitea '"
journal "Gitea migré + lancé sur le G9" "T4.4 (double-run G4, .25 lecture seule)" "pg_dump ${DUMP_SHA:0:12}… restauré ; dépôts+LFS+appdata sha256 identiques ; healthz ; .25 intact" "docker compose -f $G9_DIR/docker-compose.yml down + rm -rf $G9_DIR (G9 seul)"
log "Terminé. VALIDATION MANUELLE : git ls-remote ≥ 3 repos, clone/push, repo g9-migration."
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# 08-decommission.sh — T6.4 : Décommissionnement du xpenology
#
# Garde-fous : G3 (destructif — validation explicite), G4 (double-run terminé), G5 (IP .25), G7 (rollback), G8 (journalisation).
#
# ⚠️ OPÉRATION DESTRUCTIVE ET IRRÉVERSIBLE (garde-fous G3 + G4) :
# Ce script ARRÊTE et WIPE le xpenology. Il ne s'exécute QUE si :
# 1. Toutes les briques sont validées sur le G9 (double-run terminé, G4).
# 2. Le backup 3-2-1 est fonctionnel + restauration testée (G2).
# 3. L'IP du G9 est passée à .25 (G5).
# 4. Validation explicite de l'utilisateur (drapeau + saisie du mot de passe).
#
# Usage :
# sudo ./08-decommission.sh --i-understand
#
# Le script EXIGE de cocher chaque prérequis avant de continuer.
set -euo pipefail
log() { echo -e "\n[decom] $*"; }
die() { echo -e "\n[ERREUR] $*" >&2; exit 1; }
journal() {
local action="$1" raison="$2" resultat="$3" rollback="$4"
printf '| %s | 6 | decommission : %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$(dirname "$0")/../journal.md"
}
[[ "${1:-}" == "--i-understand" ]] || die "Drapeau --i-understand requis (opération irréversible)."
XP_HOST="192.168.1.25"
log "=== PRÉREQUIS (cocher chacun : y/n) ==="
prereq() {
local label="$1"
read -r -p " [ ] $label — confirmé ? (y/N) : " a
[[ "$a" == "y" || "$a" == "Y" ]] || die "Prérequis non confirmé : $label"
}
prereq "Toutes les briques (Vaultwarden, Nextcloud, PhotoPrism, Gitea, n8n, Jellyfin) validées sur le G9"
prereq "Backup 3-2-1 fonctionnel + restauration testée (données IA incluses)"
prereq "IP du G9 passée à .25, tous les services résolus et accessibles"
prereq "Sauvegarde FINALE complète du xpenology effectuée et vérifiée"
prereq "Je comprends que le xpenology sera WIPÉ (données détruites)"
echo
read -r -p "Saisir 'WIPE XPENOLOGY' pour confirmer la destruction : " CONF
[[ "$CONF" == "WIPE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon."
log "Arrêt des conteneurs + arrêt du système xpenology"
# L'arrêt propre se fait de préférence via la GUI DSM (arrêt système).
# En SSH, on arrête les conteneurs puis le système :
ssh -p 222 Master@"$XP_HOST" "sudo /var/packages/ContainerManager/target/usr/bin/docker stop \$(/var/packages/ContainerManager/target/usr/bin/docker ps -q) 2>/dev/null || true"
journal "Conteneurs xpenology arrêtés" "Arrêt propre avant wipe (G3)" "docker stop sur tous les conteneurs" "redémarrage du xpenology"
echo
echo "⚠️ ÉTAPE MANUELLE OBLIGATOIRE :"
echo " 1. Dans la GUI DSM : arrêter proprement le système."
echo " 2. Débrancher le xpenology."
echo " 3. (Optionnel) Wipe physique des disques si réutilisation/vente."
echo
read -r -p "Une fois le xpenology éteint et débranché, saisir 'DONE' pour journaliser : " FINAL
[[ "$FINAL" == "DONE" ]] || die "Non confirmé."
journal "xpenology décommissionné" "G9 = seul cœur famille (G4/G5)" "éteint + débranché, G9 sur .25" "aucun (irréversible) — backup 3-2-1 testé au préalable"
log "Terminé. Le G9 est maintenant le seul cœur famille."
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Alerte ops (Matrix en direct via Synapse ; fallback webhook n8n).
# Secrets lus dans /etc/restic/matrix.env (600 root), jamais affichés (G9).
set -uo pipefail
[[ -f /etc/restic/matrix.env ]] && { set -a; . /etc/restic/matrix.env; set +a; }
[[ -f /etc/restic.env ]] && { set -a; . /etc/restic.env; set +a; }
MSG="${1:?message}"
TXN="restic-g9-$(date +%s%N)"
CLEAN=$(printf '%s' "$MSG" | tr -d '"\\')
have_jq=0
command -v jq >/dev/null 2>&1 && have_jq=1
json_text() {
if [[ $have_jq -eq 1 ]]; then jq -cn --arg b "$1" '{msgtype:"m.text", body:$b}'
else printf '{"msgtype":"m.text","body":"%s"}' "$1"; fi
}
json_webhook() {
if [[ $have_jq -eq 1 ]]; then jq -cn --arg m "$1" '{event:"restic-g9", host:"g9", message:$m}'
else printf '{"event":"restic-g9","host":"g9","message":"%s"}' "$1"; fi
}
if [[ -n "${MATRIX_TOKEN:-}" && -n "${MATRIX_ROOM:-}" ]]; then
BODY=$(json_text "[G9 backup] ${CLEAN}")
ROOM_ENC=$(printf '%s' "$MATRIX_ROOM" | sed 's/ /%20/g')
if curl -fsS -m 20 -X PUT \
"${MATRIX_SERVER}/_matrix/client/v3/rooms/${ROOM_ENC}/send/m.room.message/${TXN}" \
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
-H 'Content-Type: application/json' \
-d "$BODY" >/dev/null; then
exit 0
fi
fi
if [[ -n "${RESTIC_NOTIFY_WEBHOOK:-}" ]]; then
BODY=$(json_webhook "${CLEAN}")
if curl -fsS -m 20 -X POST "$RESTIC_NOTIFY_WEBHOOK" \
-H 'Content-Type: application/json' -d "$BODY" >/dev/null; then
exit 0
fi
fi
echo "notify : ni Matrix ni webhook joignable" >&2
exit 1