From 4b26049b8f4263cf1670da225df2d9be28acd8e4 Mon Sep 17 00:00:00 2001 From: openfox Date: Mon, 12 Oct 2026 16:42:41 +0200 Subject: [PATCH] phase 4.5: registre identites + runbooks + sondes applicatives (cron G9) + G11 + etudes exposition/whatsapp + alignement 08-decommission et S8/S9.2/S11/S14 --- DOSSIER-SOFTWARE.md | 51 +++++- g9-migration/BRIDGE-WHATSAPP.md | 64 +++++++ g9-migration/EXPOSITION-INTERNET.md | 69 ++++++++ g9-migration/PARTAGE-SECRET-MAITRE.md | 60 +++++++ g9-migration/README.md | 38 +++- g9-migration/REGISTRE-BRIQUES.md | 8 +- g9-migration/REGISTRE-IDENTITES.md | 87 +++++++++ g9-migration/RESTORE-NAS-USB.md | 72 ++++++++ g9-migration/RUNBOOKS/digest.md | 43 +++++ g9-migration/RUNBOOKS/jellyfin.md | 23 +++ g9-migration/RUNBOOKS/matrix.md | 25 +++ g9-migration/RUNBOOKS/nextcloud.md | 27 +++ g9-migration/RUNBOOKS/photoprism.md | 29 +++ g9-migration/RUNBOOKS/registre.md | 29 +++ g9-migration/RUNBOOKS/vaultwarden.md | 27 +++ g9-migration/journal.md | 7 + g9-migration/scripts/08-decommission.sh | 24 ++- .../{10-registry.sh => 11-registry.sh} | 20 +-- g9-migration/scripts/12-probes.sh | 165 ++++++++++++++++++ g9-migration/scripts/g9-notify.py | 56 ++++++ g9-migration/scripts/g9-verify-alert.py | 64 +++++++ g9-migration/scripts/lib-notify.sh | 39 +---- 22 files changed, 963 insertions(+), 64 deletions(-) create mode 100644 g9-migration/BRIDGE-WHATSAPP.md create mode 100644 g9-migration/EXPOSITION-INTERNET.md create mode 100644 g9-migration/PARTAGE-SECRET-MAITRE.md create mode 100644 g9-migration/REGISTRE-IDENTITES.md create mode 100644 g9-migration/RESTORE-NAS-USB.md create mode 100644 g9-migration/RUNBOOKS/digest.md create mode 100644 g9-migration/RUNBOOKS/jellyfin.md create mode 100644 g9-migration/RUNBOOKS/matrix.md create mode 100644 g9-migration/RUNBOOKS/nextcloud.md create mode 100644 g9-migration/RUNBOOKS/photoprism.md create mode 100644 g9-migration/RUNBOOKS/registre.md create mode 100644 g9-migration/RUNBOOKS/vaultwarden.md rename g9-migration/scripts/{10-registry.sh => 11-registry.sh} (90%) create mode 100755 g9-migration/scripts/12-probes.sh create mode 100644 g9-migration/scripts/g9-notify.py create mode 100644 g9-migration/scripts/g9-verify-alert.py diff --git a/DOSSIER-SOFTWARE.md b/DOSSIER-SOFTWARE.md index fd88d52..0e0d32e 100644 --- a/DOSSIER-SOFTWARE.md +++ b/DOSSIER-SOFTWARE.md @@ -411,23 +411,24 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le ## S8 — Communication (Matrix + Jitsi + assistant vocal) -> **Statut** : ✅ **Tranché** (05/10/2026) — **Matrix + Jitsi + Element** (remplace WhatsApp) + **assistant vocal** via Home Assistant. +> **Statut** : ✅ **Tranché** (05/10/2026), **révisé 12/10/2026** — **Matrix + Jitsi + Element** = **complément du foyer** (messagerie familiale + canal d'alerte + assistant), **pas un remplacement de WhatsApp**. WhatsApp reste la messagerie principale (famille, amis, travail) — réalité d'usage mesurée, voir `g9-migration/BRIDGE-WHATSAPP.md`. ### 8.1 La stack communication | Brique | Rôle | Machine | Licence | |--------|------|---------|---------| -| **Matrix (Synapse)** | **Messagerie** (remplace WhatsApp) | J5040 | Apache-2.0 | +| **Matrix (Synapse)** | **Messagerie du foyer** + **canal d'alerte ops** (complément de WhatsApp) | J5040 | Apache-2.0 | | **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 | -| **Element** | **Client** (Android) | Téléphone | GPL-3.0 | +| **Element** | **Client** (Android) — 2 adultes d'abord, ados en phase 2 | Téléphone | GPL-3.0 | | **Home Assistant** | **Assistant vocal** (enceintes Google) | Freebox | Apache-2.0 | ### 8.2 L'usage famille (résilience = usage) | Utilisateur | Appareil | Usage quotidien | |-------------|----------|-----------------| -| **Famille** | Téléphone (Element) | **Messagerie** (remplace WhatsApp) | +| **Famille** | Téléphone (Element) | **Messagerie du foyer** (salon famille) — **WhatsApp garde** les échanges extérieurs (amis, travail, famille élargie) | | **Famille** | Téléphone + TV | **Visio** (Jitsi, via Element) | +| **Moi** | Téléphone + PC | **Salon ops** (alertes backup / sondes / dérive registre) | | **Moi** | Enceintes Google ×4-5 | **Assistant vocal** (Home Assistant) → peut déclencher n8n, contrôler Frigate | ### 8.3 Le bot hermes ↔ Matrix @@ -461,7 +462,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le | Élément | Détail | |---------|--------| | **Domaine** | **folzocorp.fr** (principal) + **sous-domaines** par service | -| **Sous-domaines** | `cloud.folzocorp.fr` (Nextcloud) · `photos.folzocorp.fr` (PhotoPrism) · `media.folzocorp.fr` (Jellyfin) · `matrix.folzocorp.fr` (Matrix) · `git.folzocorp.fr` (Gitea) · `vault.folzocorp.fr` (Vaultwarden) · `ai.folzocorp.fr` (OpenWebUI) | +| **Sous-domaines** | `cloud.folzocorp.fr` (Nextcloud) · `prisme.folzocorp.fr` (**PhotoPrism**) · `photos.folzocorp.fr` (**Synology Photos** — LAN-only, **temporaire**, disparaît avec le xpenology en Phase 6) · `media.folzocorp.fr` (Jellyfin) · `matrix.folzocorp.fr` (Matrix) · `git.folzocorp.fr` (Gitea) · `pass.folzocorp.fr` (Vaultwarden) · `n8n.folzocorp.fr` (n8n) · `ai.folzocorp.fr` (OpenWebUI) | | **TLS** | **Caddy** génère + renouvelle **automatiquement** les certificats Let's Encrypt (pas de script). | | **Exposition** | **Seulement** les services **nécessaires** à l'extérieur passent par Caddy. Le **reste** est **LAN-only** (accès via **WireGuard**). | @@ -555,7 +556,9 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le | **Docker Compose** | **Déclaration** des services (fichiers `docker-compose.yml`) | toutes | Apache-2.0 | | **Gitea** | **Versioning** des `docker-compose.yml` + config (source de vérité) | G9 | MIT | | **Watchtower** | **Mises à jour** automatiques **sélectives** (stateless auto / stateful exclu + manuel) | J5040 + G9 + PC IA | Apache-2.0 | -| **Beszel** | **Monitoring** léger + historique + **alertes** | J5040 (hub) + agents | MIT | +| **Beszel** | **Monitoring** léger + historique + **alertes** (métriques + état conteneurs) | J5040 (hub) + agents | MIT | +| **Registre** (`11-registry.sh` → `REGISTRE-BRIQUES.md`) | **Inventaire généré** (images épinglées, labels, endpoints, état) + **détection de dérive** par `git diff` | pilot → .24/.27 (lecture seule) | MIT (kit) | +| **Sondes applicatives** (`12-probes.sh`) | **`up ≠ utilisable`** : sonde la **fonction** (occ status, discovery, healthz, login) + **alertes JSON** + **1 digest/jour** | G9 (cron) | MIT (kit) | | **n8n** | **Alertes** + **automatisation** (workflows) | G9 | ⚠️ fair-code | ### 11.2 Pourquoi pas Portainer / PaaS @@ -827,7 +830,7 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha | Fédéré / auto-hébergé | ✅ | ❌ | 🟡 | | **Verdict** | **✅ Recommandé** | ❌ (GAFAM) | 🟡 (pas d'auto-hébergement facile) | -> **Recommandation** : **Matrix (Synapse) + Jitsi + Element** (remplace WhatsApp). +> **Recommandation** : **Matrix (Synapse) + Jitsi + Element** en **complément du foyer** (messagerie familiale + alertes + assistant). **Révisé 12/10/2026** : WhatsApp **n'est pas remplaçable** dans notre usage (famille, amis, travail) → il reste la messagerie principale ; Element couvre le **foyer** et le **canal d'alerte**. Le pont `mautrix-whatsapp` est **étudié, non déployé** : numéro familial **interdit**, risque de bannissement, coût de session (QR / 12 jours). Détail : `g9-migration/BRIDGE-WHATSAPP.md`. ### 14.6 MDM enfants @@ -840,6 +843,39 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha > **Recommandation** : **Google Family Link** (maintenant, usage parental) + **MDM OSS** (MicroMDM/Android Enterprise) en **option future**. +### 14.7 Coffre famille (décision 12/10/2026) + +| Critère | **Vaultwarden unique (6 personnes)** ✅ | Nextcloud Passwords | +|---------|----------------------------------------|---------------------| +| Dépendance | ✅ indépendant des autres briques | ❌ le coffre vit dans la brique qu'il protège (Nextcloud HS = plus les accès pour réparer Nextcloud) | +| Reset admin d'un compte enfant | ✅ sans effet sur le coffre | ❌ clés dérivées du mot de passe → coffre potentiellement illisible | +| Modèle familial (organisations, emergency access) | ✅ | ❌ | +| Coût pour 6 (import, backup, reset, formation) | ✅ 1 chemin | ❌ 2 chemins (registre doublé) | +| **Verdict** | **✅ Retenu** | ❌ Non retenu (pilote adulte possible, non déclenché) | + +### 14.8 Identité (décision 12/10/2026, ordre validé) + +| Étape | Décision | Statut | +|---|---|---| +| 1 | **Registre d'identités** (moitié générée + moitié humaine) — répond à « quel compte a servi ? » | ✅ posé (`g9-migration/REGISTRE-IDENTITES.md`) | +| 2 | **Étude exposition internet** avant tout IdP | ✅ étude écrite, 5 décisions à rendre | +| 3 | **Authelia** en cible, pilote 3 briques (Synapse, Gitea, Jellyfin), **opt-in + break-glass** | 🟡 après étude + Phase 5 | +| — | Authentik / Keycloak / Samba AD / FreeIPA / Nextcloud-IdP | ❌ écartés (poids, sur-ingénierie, SPOF sur la brique la plus stateful) | + +### 14.9 Suivi applicatif (décision 12/10/2026) + +| Critère | **Sondes + registre + Diun** ✅ | Grafana/Prometheus (+cAdvisor) | Netdata | Uptime Kuma | +|---|---|---|---|---| +| Couvre `up ≠ utilisable` | ✅ (occ, discovery, healthz, login) | ❌ (métriques) | 🟡 | ✅ | +| Briques ajoutées | **0** (scripts du kit) | 4 | 1 (lourd) | 1 (stateful) | +| Chevauchement avec Beszel | **complémentaire** | redondant (InfluxDB déjà écarté) | redondant | partiel | +| Charge de maintenance | faible (cron + 1 digest/jour) | élevée | moyenne | moyenne + fatigue d'alertes | +| **Verdict** | **✅ Retenu** | ❌ | ❌ | 🟡 reporté (déclencheur écrit, Phase 6 + 2 semaines d'usage) | + +### 14.10 Autonomie de l'assistante IA (décision 12/10/2026) + +> **Paliers G11** : L0 lecture seule → L1 propose → L2 exécute le **stateless documenté** → L3 stateful **sur GO explicite**. Portes chiffrées (≥ 90 % de propositions pertinentes sur 30 jours ; sinistre simulé sans perte). Interdits permanents : destructif, suppression de données, réseau (G6), wipe, création de privilège, lecture de secrets hors coffre. Détail : `g9-migration/README.md` (charte G11). + --- ## S15 — Critères d'acceptance (AC1–AC10) @@ -867,6 +903,7 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha | Date | Version | Changement | |------|---------|------------| +| 12/10/2026 | **v1.6** | **Identité + suivi applicatif + communication (décisions 12/10)** : **S8 révisé** — Matrix/Jitsi/Element = **complément du foyer**, **pas** remplacement de WhatsApp (Element : 2 adultes d'abord, ados phase 2) ; **S9.2** sous-domaines alignés sur le réel (`pass.folzocorp.fr` = Vaultwarden, **`prisme.folzocorp.fr` = PhotoPrism**, `photos.folzocorp.fr` = Synology Photos **temporaire**, meurt Phase 6) ; **S11** enrichi de deux briques d'ops sans conteneur de plus : **registre généré** (`11-registry.sh` → `REGISTRE-BRIQUES.md`, dérive par `git diff`) et **sondes applicatives** (`12-probes.sh`, `up ≠ utilisable`, alertes **JSON** + 1 digest/jour) ; **S14.7→S14.10** : coffre **unique Vaultwarden** pour 6 (Nextcloud Passwords écarté : boucle de verrouillage + reset admin + pas d'emergency access), identité = **registre → étude exposition → Authelia** (Authentik/Keycloak/Samba AD/FreeIPA écartés), suivi = **sondes + registre + Diun** (Grafana/Prometheus/Netdata écartés, Uptime Kuma reporté avec déclencheur écrit), **G11 paliers d'autonomie IA**. Artéfacts : `g9-migration/{REGISTRE-IDENTITES,REGISTRE-BRIQUES,PARTAGE-SECRET-MAITRE,RESTORE-NAS-USB,EXPOSITION-INTERNET,BRIDGE-WHATSAPP}.md`, `RUNBOOKS/` ×7, charte **G11**. | | 10/10/2026 | **v1.5** | **S10.2 — copie 2 (Freebox) EN PLACE sur le G9** : restic 0.19.1 + rclone 1.75.2, repo `rclone:freeboxftp:Freebox/backup/g9` (FTP local 21), snapshot 4.18 GiB, **append-only prouvé**, **restore testée** (sha256), cron 03:30/04:00/09:15 + alerte Matrix ; **mont CIFS abandonné** (restic/`chmod`, FUSE) → backend rclone, **persistance post-reboot sans montage**. **Phase 5 planifiée** (non réalisée) : VM Freebox + `rest-server --append-only` (autostart à valider), `restic copy` → 218play, identifiants `NoAccess`, chroot durci. | | 06/10/2026 | **v1.4** | **Cohérence inter-dossiers (source unique)** : (1) **S10.2** — le schéma 3-2-1 ne **re-déclare plus** le tableau → **pointeur** vers `DOSSIER-RECREATION.md` §R4 (source unique) + note outil (restic, quotidien/hebdo) ; (2) **S4.3** — le modèle 2 couches ne **re-déclare plus** le principe → **pointeur** vers `PROJET-SECOND-CERVEAU.md` S4.1 (source unique) + tableau **mapping briques/machines** (spécificité du dossier logiciel) ; (3) **S3.1** — le tableau est **cadré autour des briques fonctionnelles** : la stack \*arr #10 = **1 brique**, les apps Nextcloud 13-15 marquées **↳ apps du socle NC #12** ; note de comptage précisée (**28 lignes = 25 briques fonctionnelles**, dans la cible ≤ ~20 briques de consolidation). | | 06/10/2026 | **v1.3** | **Correction mainteneur hermes** : S3.1 #24 — « maintenu (Nous Research) » → « **maintenu (nesquena — `ghcr.io/nesquena/hermes-webui`)** », aligné sur l'image réellement en production (snapshot `ETAT-DE-LART-2026-09-30.md` §2.1). | diff --git a/g9-migration/BRIDGE-WHATSAPP.md b/g9-migration/BRIDGE-WHATSAPP.md new file mode 100644 index 0000000..0b080ed --- /dev/null +++ b/g9-migration/BRIDGE-WHATSAPP.md @@ -0,0 +1,64 @@ +# BRIDGE WHATSAPP ↔ MATRIX (T-W — étude, **0 installation**) + +> Contexte décidé 12/10 : **WhatsApp est indispensable** (famille, amis, travail) et ne sera **pas** +> remplacé. Matrix = complément du foyer. Ce document dit ce qu'un pont apporte, ce qu'il ne donne +> pas, et ce qui est **interdit**. Aucune installation sur le numéro familial. + +## 1. L'outil + +| Point | Fait | +|---|---| +| Projet | `mautrix-whatsapp` (Tulir / mautrix), basé sur `whatsmeow` | +| Licence | **AGPL-3.0** (compatible avec la règle « OSS + chemin de sortie » de la table rase) | +| Mécanisme | protocole **WhatsApp Web multi-device** : le serveur tient une session liée par **QR code** | +| Contrainte téléphone | WhatsApp impose que le **téléphone se connecte au moins tous les 12 jours**, sinon la session est coupée → **re-link** (QR) à refaire | +| État | DB (messages, préférences) + **session** (clés de liaison) + médias = **brique stateful** | +| Chiffrement | côté Matrix : E2EE gérable ; **côté WhatsApp : la session standard**, tes correspondants ne voient **que** WhatsApp | + +## 2. Ce que le pont apporte / ce qu'il ne donne pas + +| Apporte | Ne donne pas | +|---|---| +| Miroir des conversations WhatsApp dans Element (MP, groupes, médias), historique importable | un canal commun : un groupe WhatsApp et un salon Matrix ne « se parlent » pas proprement (les membres WhatsApp ne voient pas Matrix) | +| Répondre depuis Element | la fin de WhatsApp : **WhatsApp reste la source** | +| Lecture centralisée (1 client pour la famille + les alertes ops) | la garantie de disponibilité : pont cassé = **perte de l'affichage**, pas des messages | +| Base pour qu'Effi (assistante) **lisse/résume** des MP si tu l'autorises | la conformité : un client non officiel est **contraire aux ToS WhatsApp** | + +## 3. Risques à nommer (le point qui décide tout) + +| Risque | Détail | Conséquence pour nous | +|---|---|---| +| **Bannissement du numéro** | WhatsApp détecte les clients non officiels ; le risque est **réel et non nul** | ton numéro familial est « indispensable » → **interdiction** : jamais le numéro principal sur un pont | +| Session qui expire | > 12 jours sans connexion du téléphone = déconnexion | re-link manuel = maintenance que tu veux éliminer | +| Stateful fragile | session + DB + médias ; montée de version du protocole WhatsApp non maîtrisée | épingler par digest, **exclure de Watchtower**, backup session+DB dans restic | +| Confidentialité | les MP passent par un daemon sur le G9 + stockage local | à accepter explicitement (choix de périmètre) | +| Surface d'attaque | brique de plus, exposée = non (interne), mais lit un compte de communication | pas d'exposition WAN ; pas d'automatisation d'**envoi** | + +## 4. Interdits écrits (T-W.3) + +1. **Numéro familial principal** : jamais relié à un pont. +2. **Alertes ops sur WhatsApp** : le canal d'alerte reste Matrix (S8.4), pas WhatsApp. +3. **Aucune automatisation qui envoie** des messages WhatsApp (n8n/IA) : lecture seulement, si pilote validé. +4. Aucune exposition WAN du pont ; aucune ouverture ufw pour lui. + +## 5. Si un jour GO utilisateur (pilote borné) + +| Étape | Contenu | +|---|---| +| 1 | **numéro secondaire isolé** (numero dédié, vide, hors famille) | +| 2 | conteneur épinglé par digest, label `watchtower.enable=false`, DB + session dans `/data/docker/whatsapp/` | +| 3 | session + DB **dans les snapshots restic** (vérifié) | +| 4 | **7 jours de miroir stable** (pas de re-link, pas de perte de messages) | +| 5 | re-link testé (déconnexion volontaire → QR → reprise) | +| 6 | rollback documenté : `docker compose -f compose/whatsapp/docker-compose.yml down` (aucune donnée famille concernée) | +| 7 | journal + grille S14 (décision, raison, coût constaté) | + +## 6. Verdict de l'étude + +- **Utile** si l'objectif est « lire au même endroit » (Element = foyer + alertes) et que le pilote + reste sur un **numéro secondaire**. +- **Pas utile** si l'objectif est de quitter WhatsApp : le pont ne fait pas ça. +- **Coût de maintenance** : session (QR/12 jours) + DB + médias = exactement le type de charge que + tu veux supprimer. Le pont **ajoute** de la maintenance pour un gain de confort. +- Recommandation : **étude validée, pilote non déclenché** ; on en reparle quand Element tourne + pour le foyer et que les alertes ops sont stables (tâche MATRICE FAMILLE), jamais avant. diff --git a/g9-migration/EXPOSITION-INTERNET.md b/g9-migration/EXPOSITION-INTERNET.md new file mode 100644 index 0000000..635da9c --- /dev/null +++ b/g9-migration/EXPOSITION-INTERNET.md @@ -0,0 +1,69 @@ +# ÉTUDE EXPOSITION INTERNET (T-D — étude, **aucune règle posée**) + +> Périmètre : documenter, pas agir. **0 règle ufw modifiée, 0 changement réseau** (G6). +> Faits mesurés le 12/10/2026 en lecture seule (`ufw status numbered`, `ss -ltnu`, Caddyfile, journal). + +## 1. Exposition mesurée aujourd'hui + +| Machine | Exposée au WAN (pare-feu) | Observations | +|---|---|---| +| **G9 (.24)** | `22/tcp` **Anywhere**, `443/tcp` **Anywhere** (+ v6) | 22 ouvert au WAN = **risque si la Freebox forward** ; 443 = l'ancien edge (TLS **interne**, CA auto-signé) → un endpoint chiffré non reconnu exposé publiquement | +| **G9 (.24)** LAN-only | 2342, 3000, 3100, 5678, 5984, 8008, 8009, 8080, 8081, 8096, 9980, 2456-2458, SMB (137-139/445) | tout ce qui est « brique » est correctement restreint à `192.168.1.0/24` | +| **J5040 (.27)** | `80/tcp`, `443/tcp`, `51820/udp` (**Anywhere**) | le **vrai** edge : Caddy + Let's Encrypt (7/7 certs DNS-01) + WireGuard. 80 = redirect ACME | +| **J5040 (.27)** LAN-only | `53` (DNS AdGuard), `3000` (hub Beszel), `8081` (UI AdGuard) | UI AdGuard = **login local** (bcrypt), pas de 2FA | +| **Pilot (.11)** | `tailscale0` actif (100.111.222.30) | **voie d'accès distante non documentée** dans le plan S9 → à nommer explicitement (acceptée ou coupée) | +| **Freebox** | IP publique `82.65.198.114` (`folzocorp.fr`) | les **redirects Freebox** (443 → G9, Valheim UDP 2456-2458) ne sont **pas lisibles** depuis l'infra : inventaire à faire côté Freebox par l'utilisateur | + +## 2. Grille par brique (actuelle / nécessaire / dangereuse) + +| Brique | Publique aujourd'hui | Publique **nécessaire** | Danger si exposée | Public visé | +|---|---|---|---|---| +| Nextcloud (`cloud`) | oui, via J5040 (443) | **oui** (usage famille hors maison) | compte unique = porte d'entrée des fichiers ; 2FA obligatoire | famille | +| Vaultwarden (`pass`) | oui, via J5040 | **oui** (coffre = besoin nomade) | exposition du coffre = cible n°1 ; 2FA + mot de passe maître fort | famille | +| Matrix/Synapse + Element (`matrix`, 8009) | matrix via J5040 ; Element web LAN | **non** (Element = client, passe par le homeserver) | 8009 exposé = frontal gratuit ; 8008 exposé = API admin exposée | famille | +| Gitea (`git`) | oui, via J5040 | **non** (usage interne + pushes) | dépôt du kit + vault second cerveau = **fuites de plans** ; comptes `must_change_password` | moi | +| Jellyfin (`media`) | oui, via J5040 | **optionnel** (usage hors maison = désirabilité) | compte opérateur `g9ops` exposé ; pas de 2FA | famille | +| PhotoPrism (`prisme`) | route prévue, pas posée | **optionnel** | 10 ans de photos = **donnée la plus sensible** de la maison | famille | +| n8n (`n8n`) | oui, via J5040 | **non** | UI d'automatisation = exécution de commandes ; **pas de SSO** (payant) ; owner unique | moi | +| Beszel hub (3000 .27) | non | non | vue de l'inventaire + alertes | moi | +| AdGuard UI (8081 .27) | non | non | contrôle du DNS de toute la maison | moi | +| Collabora (9980) | non | non | WOPI interne | interne | +| CouchDB (5984) | non | non | **danger LAN** : CouchDB sans auth = lecture/écriture directe | interne | +| SMB (137-139/445) | non | non | partage famille, invité `guest ok` → **LAN-only strict** | famille | +| SSH G9 (22) | **oui au pare-feu** | **non** | exposition SSH = 22 en clair sur internet si forward | moi | +| Edge G9 (443) | **oui au pare-feu** | **non** ( remplacé par J5040 ) | TLS auto-signé exposé, double porte d'entrée | — | +| Valheim (2456-2458) | non (redirect Freebox UDP existant) | **oui** (amis hors LAN) | jeu, sans donnée sensible | amis | +| WireGuard (51820 .27) | oui | **oui** (c'est LA porte d'entrée distante) | clé privée = accès total LAN | moi | + +## 3. Ce que ça change pour l'identité (lien direct avec la tâche Authelia) + +| Constat | Conséquence | +|---|---| +| La **forward-auth ne protège que ce qui passe par Caddy** (les 6 sous-domaines) | les ports LAN directs (3000, 5678, 5984, 8009, 8081, 9980, 2342) **restent en login local** : un IdP ne les couvre pas | +| Tout ce qui est **LAN-only** n'a pas besoin d'un IdP pour être sûr | si la posture reste « LAN + WireGuard », l'apport d'Authelia est **confort** (1 login, 1 2FA) et non sécurité | +| Tout ce qui est **exposé** (Nextcloud, Vaultwarden, Matrix, Gitea, Jellyfin, n8n) est concerné | plus l'exposition est large, plus l'IdP + 2FA central devient indispensable | +| `22/tcp` et l'edge G9 sont ouverts au WAN **sans nécessité** | ce sont les 2 décisions les moins chères à prendre (fermer = 2 règles à retirer, pas à poser) | +| Tailscale sur le pilot | voie distante réelle et silencieuse : à **nommer** (acceptée = documentée ; refusée = désactivée) | + +## 4. Playbook de durcissement (classé, **non implémenté**) + +| Rang | Action | Coût | Effet | Réversibilité | +|---|---|---|---|---| +| 1 | Retirer `22/tcp Anywhere` du G9 (SSH = LAN + WireGuard) | 1 règle | supprime l'expo SSH publique | immédiate | +| 2 | Retirer `443/tcp` du G9 après la bascule bridge (edge J5040 seul) | 1 règle + démontage | une seule porte d'entrée | immédiate | +| 3 | 2FA sur Nextcloud + Vaultwarden + Gitea + Synapse (S9.1) | ~1 h | la vraie protection des comptes exposés | par compte | +| 4 | WireGuard = **seul** chemin distant pour Gitea/n8n/Beszel/AdGuard UI | config Caddy/ufw | 4 briques sortent de la surface publique | immédiate | +| 5 | Forward-auth Authelia sur les 6 sous-domaines + break-glass | 1 brique | 1 login, 1 2FA, déprovisionnement en 1 point | opt-in par brique | +| 6 | CouchDB : auth ou bind 127.0.0.1 (l'appli n'a besoin que du réseau Docker) | 1 ligne compose | ferme un trou LAN silencieux | immédiate | +| 7 | Inventaire des redirects Freebox (443, UDP 2456-2458) + suppression de ce qui est mort | côté Freebox, utilisateur | cohérence réel/plan | immédiate | +| 8 | Nommer Tailscale (accepté/documenté, ou désactivé) | 1 décision | supprime une voie non documentée | immédiate | + +## 5. Décisions à rendre (ta main) + +1. Fermer `22` WAN sur le G9 ? (rang 1) +2. Démonter l'edge G9 à la bascule bridge ? (rang 2) +3. Garder Gitea et n8n exposés, ou les réserver à WireGuard ? (rang 4) +4. Tailscale : documenté et assumé, ou coupé ? (rang 8) +5. PhotoPrism et Jellyfin exposés hors maison, oui/non ? (grille) + +**Tant que ces 5 réponses ne sont pas données, aucune règle n'est posée** (G6). diff --git a/g9-migration/PARTAGE-SECRET-MAITRE.md b/g9-migration/PARTAGE-SECRET-MAITRE.md new file mode 100644 index 0000000..65203e4 --- /dev/null +++ b/g9-migration/PARTAGE-SECRET-MAITRE.md @@ -0,0 +1,60 @@ +# PARTAGE-SECRET-MAITRE — procédure de secours (T-A.9) + +> Décision 12/10 : aujourd'hui **moi seul** connais le mot de passe maître du coffre (Vaultwarden) +> et l'`ADMIN_TOKEN`. Objectif : **1 à 2 amis de confiance** capables de déverrouiller le coffre +> si je suis injoignable. Ce fichier ne contient **aucun secret** : il décrit le contenant, les +> détenteurs, et ce qu'ils font. + +## 1. Détenteurs (à nommer par l'utilisateur) + +| Rôle | Personne | Support remis | Date de remise | Test annuel | +|---|---|---|---|---| +| Détenteur 1 | _à nommer_ | enveloppe scellée n°1 | _à date_ | _à date_ | +| Détenteur 2 (optionnel) | _à nommer_ | enveloppe scellée n°2 | _à date_ | _à date_ | + +Règles de sélection (à assumer) : personne joignable physiquement, indépendante de l'infra, +capable d'appliquer une procédure écrite sous stress, **sans accès** aux machines. + +## 2. Contenu de l'enveloppe (hors-ligne, scellée) + +- le **mot de passe maître** du coffre (la passphrase, pas les mots de passe du coffre) ; +- l'**`ADMIN_TOKEN`** en clair (console Vaultwarden) ; +- la **phrase de récupération E2E** de Matrix (au moins pour les 2 adultes) ; +- la **clé de chiffrement du repo restic** (`repo.key`) — copie, pas l'originale ; +- la liste des 6 personnes et des briques concernées (copie de `REGISTRE-IDENTITES.md` §2/§3) ; +- une **note de datation** : à chaque changement de mot de passe maître, l'enveloppe est + remplacée et l'ancienne est détruite (journaliser la date). + +Support : papier (pas de clé USB non chiffrée), plié, scellé, signé de la date. Deux copies +max. Jamais de photo, jamais de fichier non chiffré sur un disque. + +## 3. Ce que fait le détenteur si je suis injoignable + +1. Ouvrir l'enveloppe **devant témoin** (famille), noter la date. +2. Déverrouiller le coffre : app Bitwarden → URL du coffre = `https://pass.folzocorp.fr` + (si le domaine n'est pas joignable, l'accès LAN = `http://192.168.1.24:8081`) → + identifiant = mon compte → **mot de passe maître** de l'enveloppe. +3. Si l'app refuse (2FA) : utiliser la console admin `https://pass.folzocorp.fr/admin` avec + l'`ADMIN_TOKEN` de l'enveloppe → désactiver le 2FA du compte (mesure de dernier recours, + à journaliser). +4. Transmettre aux adultes de la famille les entrées nécessaires (organisation `Famille`), + **sans** diffuser les secrets hors famille. +5. Journaliser dans `journal.md` : date, ce qui a été ouvert, ce qui a été transmis. + +## 4. Test annuel (critère) + +Une fois par an, sans casser la production : + +- le détenteur ouvre l'enveloppe (ou une copie d'entraînement identique) ; +- il déverrouille un **coffre d'entraînement** (vault de test, pas le coffre famille) avec les + éléments de l'enveloppe ; +- la procédure est chronométrée et les points de friction sont corrigés dans ce fichier ; +- la ligne est journalisée : `| date | partage-secret-maitre : test annuel | résultat | remplacement enveloppe |`. + +## 5. Révocabilité + +| Événement | Action | +|---|---| +| Détenteur injoignable/decédé | retirer son accès, remplacer l'enveloppe, journaliser | +| Perte d'une enveloppe | **changer le mot de passe maître** + régénérer l'`ADMIN_TOKEN`, refaire les enveloppes | +| Doute sur la confidentialité | idem : rotation complète (mot de passe maître + ADMIN_TOKEN + clés restic) | diff --git a/g9-migration/README.md b/g9-migration/README.md index 08e8169..b74cd7e 100644 --- a/g9-migration/README.md +++ b/g9-migration/README.md @@ -28,7 +28,7 @@ xpenology → G9, phase par phase, quand le G9 sera branché (IP temporaire `.24 |-----------|---------------------| | G1 | Scripts idempotents, en-têtes documentant chaque modification. | | G2 | `06-restic-backup.sh` : backup + **test de restauration** avant toute migration. | -| G3 | `06b-usb-media.sh`, `08-decommission.sh` : drapeau `--i-understand` + saisie du nom du disque / `WIPE XPENOLOGY`. | +| G3 | `06b-usb-media.sh` : drapeau `--i-understand` + saisie du nom du disque. `08-decommission.sh` : saisie `ETEINDRE XPENOLOGY` — **aucun wipe exécuté par le kit** (décision 12/10 : débrancher, disques intacts, récupération `RESTORE-NAS-USB.md`). | | G4 | `07a`–`07d` (+ `07-migrate-stateful.sh` générique) : lecture seule du xpenology + intégrité sha256 ; `08-decommission.sh` exige la validation de toutes les briques avant wipe. | | G5 | README (ordre d'exécution) + `08-decommission.sh` : IP `.24`→`.25` uniquement au décommissionnement. | | G6 | `01-hardening.sh` : ufw LAN-only (`192.168.1.0/24`), pas d'exposition WAN. | @@ -36,6 +36,7 @@ xpenology → G9, phase par phase, quand le G9 sera branché (IP temporaire `.24 | G8 | `journal()` de chaque script → `journal.md` (6 colonnes : date, phase, action, raison, résultat, rollback). | | G9 | `.gitignore` (`*.env`, `secrets/`, configs remplis) + `03b-secrets.sh` (chmod 600, non loggué). | | G10 | Scripts ne modifient que le G9 ; xpenology en lecture seule (SSH `Master@192.168.1.25:222`, clé SSH — compte `effi` bloqué par Synology 10/2026). | +| G11 | **Autonomie de l'assistante IA par paliers** (décision 12/10) : **L0** lecture seule → **L1** propose, l'humain exécute → **L2** exécute les actions **stateless documentées** des `RUNBOOKS/` → **L3** actions **stateful** uniquement sur **GO explicite** de l'utilisateur. Portes : L1→L2 = 20 propositions journalisées dont **≥ 90 % pertinentes sur 30 jours** ; L2→L3 = sinistre simulé sur une brique stateful **sans perte de données** + rollback vérifié. Interdits permanents : opérations destructives (G3), suppression de données, modification réseau impactant d'autres appareils (G6), wipe, création de compte ou de privilège, lecture de secrets hors coffre. Traçabilité : chaque action de l'IA = ligne de journal (quoi, pourquoi, résultat, rollback) + notification dans le salon ops. **Aucune exécution L2/L3 sans validation utilisateur tracée.** | ## Arborescence @@ -63,7 +64,14 @@ g9-migration/ │ ├── restic-notify.sh ← alerte Matrix des jobs restic (posée par 06) │ ├── 09-j5040-dhcp.sh ← E3 : DHCP dnsmasq sur J5040 (pré-bascule bridge) │ ├── 10-verify-restore-photo.sh ← critère 3 : preuve restore photo (sha256 vs .25) -│ └── 08-decommission.sh ← T6.4 : décommissionnement xpenology [DESTRUCTIF] +│ ├── 11-registry.sh ← T-A.1/T-A.5 : génère REGISTRE-BRIQUES.md + --check --alert (dérive) +│ ├── registry_parse.py ← helper lecture seule de 11-registry.sh (docker ps → lignes + constats) +│ ├── registry_inputs.py ← helper : compose du kit + Caddyfile → declarations & endpoints +│ ├── 12-probes.sh ← T-C.1/C.2/C.5 : sondes APPLICATIVES + alertes JSON + digest (cron G9) +│ ├── g9-notify.py ← poste un payload JSON dans le salon ops (install /usr/local/bin) +│ ├── g9-verify-alert.py ← vérifie la conformité des payloads JSON reçus (C-AP2) +│ ├── lib-notify.sh ← côté pilot : notifie via g9-notify.py (aucun secret dans le kit) +│ └── 08-decommission.sh ← T6.4 : arrêt + débranchement xpenology [0 wipe — disques intacts] ├── compose/ ← docker-compose.yml versionnés (stockés aussi dans Gitea) │ ├── vaultwarden/ ← T4.1 │ ├── nextcloud/ ← T4.2 @@ -80,6 +88,32 @@ g9-migration/ └── restic.env.example ← template restic (T3.1) — REMPLIR les secrets ``` +## Docs d'identité, d'observabilité et d'études (Phase 4.5+) + +| Fichier | Rôle | Nature | +|---|---|---| +| `REGISTRE-BRIQUES.md` | **Moitié technique** du registre : briques, images épinglées, labels Watchtower, endpoints, état, backup. | **Générée** par `scripts/11-registry.sh` — ne pas éditer | +| `REGISTRE-IDENTITES.md` | **Moitié humaine** : quel compte sur quelle brique, type, 2FA, référence d'entrée du coffre, **comment réinitialiser**, responsable. | Versionnée, **0 secret** | +| `RUNBOOKS/` | `nextcloud` · `matrix` · `jellyfin` · `photoprism` · `vaultwarden` · `registre` · `digest` : symptôme → diagnostic → action → rollback | matière première de l'assistante IA (G11) | +| `PARTAGE-SECRET-MAITRE.md` | Qui peut déverrouiller le coffre si je suis injoignable : détenteurs, contenu de l'enveloppe, procédure, **test annuel** | 0 secret | +| `RESTORE-NAS-USB.md` | Lire les disques DSM hors boîtier (mdadm/LVM, **lecture seule**), procédure + contrôles + interdits | test = critère Phase 6 | +| `EXPOSITION-INTERNET.md` | Étude : exposition mesurée, grille par brique, lien avec l'IdP, playbook classé | **0 règle posée** | +| `BRIDGE-WHATSAPP.md` | Étude `mautrix-whatsapp` : apports, limites, risque de bannissement, interdits, pilote borné | **0 installation** | + +### Régler le registre et les sondes (une fois, puis ça tourne) + +```bash +# machine de pilotage — lecture seule sur .24/.27 +./scripts/11-registry.sh # génère REGISTRE-BRIQUES.md +./scripts/11-registry.sh --check --alert # dérive -> payload JSON dans le salon ops (rc=1) + +# G9 (root) — sondes applicatives + digest +sudo ./scripts/12-probes.sh --install # /usr/local/bin/g9-probes.sh + cron */15 + digest 08:30 +sudo ./scripts/12-probes.sh --selftest # provocation d'un KO -> NOTIFY_OK 200 +sudo ./scripts/g9-verify-alert.py 20 # payloads JSON conformes ? -> VERIFY_OK +sudo ./scripts/12-probes.sh --uninstall # rollback complet (cron + script + état) +``` + ## Ordre d'exécution (phase par phase) | Phase | Script / action | Garde-fou | diff --git a/g9-migration/REGISTRE-BRIQUES.md b/g9-migration/REGISTRE-BRIQUES.md index 36cd3c7..72ff324 100644 --- a/g9-migration/REGISTRE-BRIQUES.md +++ b/g9-migration/REGISTRE-BRIQUES.md @@ -1,6 +1,6 @@ # REGISTRE-BRIQUES — moitié technique (générée) -> Source : `scripts/10-registry.sh` (`docker ps` + `docker inspect` en **lecture seule** sur .24 et .27, +> Source : `scripts/11-registry.sh` (`docker ps` + `docker inspect` en **lecture seule** sur .24 et .27, > compose du kit, Caddyfile pour les endpoints). **Ne pas éditer à la main** : la moitié humaine > (comptes, 2FA, procédure de réinitialisation, responsable, break-glass) vit dans > `REGISTRE-IDENTITES.md`. @@ -10,7 +10,7 @@ > > Couverture backup : g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5) - + | Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) | |---|---|---|---|---|---|---|---|---|---| @@ -54,7 +54,7 @@ ## Utiliser ce fichier -- `./10-registry.sh` : régénère (à lancer après chaque déploiement / MAJ d'image). -- `./10-registry.sh --check --alert` : régénère, compare à la version commitée, **alerte en payload JSON** +- `./11-registry.sh` : régénère (à lancer après chaque déploiement / MAJ d'image). +- `./11-registry.sh --check --alert` : régénère, compare à la version commitée, **alerte en payload JSON** si le réel a dérivé du registre (label perdu, image non épinglée, conteneur hors kit, état ≠ running). - Un `git diff` sur ce fichier est, par construction, la détection de dérive. diff --git a/g9-migration/REGISTRE-IDENTITES.md b/g9-migration/REGISTRE-IDENTITES.md new file mode 100644 index 0000000..154a8a5 --- /dev/null +++ b/g9-migration/REGISTRE-IDENTITES.md @@ -0,0 +1,87 @@ +# REGISTRE-IDENTITES — moitié humaine (comptes, reset, responsables, break-glass) + +> **Règle** : ce fichier ne contient **aucun secret**. Il dit **quel compte** sert sur quelle brique, +> **qui en est responsable**, **où vit le secret** (référence d'entrée du coffre) et **comment on réinitialise**. +> Les secrets vivent dans **Vaultwarden** (`db.sqlite3` du G9, déjà dans les snapshots restic). +> La moitié technique (briques, images épinglées, labels Watchtower, endpoints, état) est **générée** +> par `scripts/11-registry.sh` dans `REGISTRE-BRIQUES.md` — ne pas la dupliquer ici. +> +> Périmètre (T-A.10) : **.24 (G9)** et **.27 (J5040)**. Le xpenology **.25 est hors périmètre** : +> inventaire dans `INVENTAIRE-STATEFUL.md`, briques **à mourir en Phase 6**. +> +> Source des faits : `journal.md` (lignes Phase 4, 2026-10-11) + `PHASE4-RECOLLEMENT.md` §0. + +## 1. Convention du coffre (T-A.3) + +| Portée | Chemat d'entrée Vaultwarden | Exemples | +|---|---|---| +| Infra / services | `infra//` | `infra/gitea/g9ops`, `infra/n8n/api`, `infra/synapse/admin` | +| Adultes | `famille//` | `famille/effi/nextcloud`, `famille/effi/matrix` | +| Enfants | `enfants//` (coffre des parents) | `enfants/lea/jellyfin` | + +Structure du coffre (décision 12/10, coffre **unique** Vaultwarden pour les 6) : +- organisation `Famille` = 2 adultes (secrets partagés : wifi, DAV, comptes communs) ; +- organisation `Enfants` = 16 et 13 ans : coffre propre + **emergency access** vers les parents ; +- 10 et 9 ans : **pas de coffre**, identifiants tenus par les parents sous `enfants//` ; +- `ADMIN_TOKEN` (console Vaultwarden) : consigné + copie hors-ligne (voir `PARTAGE-SECRET-MAITRE.md`). + +Comptes d'automatisation : préfixe de rôle, jamais un prénom humain → `ops-bot` (Matrix), +`g9ops` (Gitea/Jellyfin), `n8n-bot` (à créer si des workflows doivent pousser des logs). + +## 2. Registre des comptes (.24 G9) + +| Brique | Compte utilisé | Type | 2FA | Entrée coffre | Réinitialisation (break-glass) | Responsable | +|---|---|---|---|---|---|---| +| **Vaultwarden** | 1 utilisateur (coffre) | admin humain | **TOTP actif** (importé de la source, `twofactor enabled=1`) | `infra/vaultwarden/maitre` | `ADMIN_TOKEN` (argon2id) → console `/admin` ; emergency access famille activée (`EMERGENCY_ACCESS_ALLOWED=true`) | moi | +| **Nextcloud** | 8 utilisateurs importés | humains | par utilisateur (à activer) | `famille//nextcloud` | `docker exec nextcloud php occ user:set-password ` (ou `occ user:resetpassword`) | moi | +| **MariaDB** (`nextcloud_db`) | root + utilisateur applicatif | service | — | `infra/nextcloud/db` | `docker exec -it nextcloud_db mariadb -u root -p` (mot de passe dans `secrets/`) | moi | +| **Redis** (`nextcloud_redis`) | sans mot de passe (LAN/bridge) | service | — | — | aucun compte ; accès = réseau Docker uniquement | moi | +| **Collabora** | mot de passe console (`secrets/collabora.env`, `env_file`) | service | — | `infra/collabora/console` | régénérer `secrets/collabora.env` (600) + restart conteneur | moi | +| **Gitea** | `master`, `antoine`, `folzo` (importés, **`must_change_password`**), `g9ops` (admin, token API `ops-phase4`) | humains + service | à activer sur le compte principal | `infra/gitea/g9ops`, `infra/gitea/token-ops` | `docker exec -u git gitea /usr/local/bin/gitea admin user create\|change-password …` | moi | +| **Postgres** (`gitea-db`) | utilisateur applicatif | service | — | `infra/gitea/db` | `docker exec -it gitea-db psql -U postgres` | moi | +| **Jellyfin** | `g9ops` (admin, créé par le wizard) | service/opérateur | — | `infra/jellyfin/g9ops` | API avec `X-Emby-Authorization` ; si `EnableLocalPassword=0` (piège mesuré du wizard) : `UPDATE Users SET EnableLocalPassword=1` en SQLite + restart | moi | +| **n8n** | **owner = mon compte** (accès perdu puis recréé, journal 10/10 21:12) | admin humain | à activer | `infra/n8n/owner`, `infra/n8n/api` (clé API dédiée) | UI → Settings → Users ; si accès perdu : créer un owner via CLI n8n (`n8n export:users` n'existe pas → passer par la DB Postgres, procédure §4) | moi | +| **Synapse (Matrix)** | admin (créé via `registration_shared_secret`) + `ops-bot` (token) | admin + service | **TOTP à activer** | `infra/synapse/admin`, `infra/synapse/token-ops-bot` | API admin v2 (`PUT /_synapse/admin/v2/users/`), token admin via login ; déactivation propre = `POST /_synapse/admin/v1/deactivate/` | moi | +| **Element (web)** | — (frontal, pas de compte) | — | — | — | — | moi | +| **CouchDB / Obsidian** | locaux (stack du second cerveau) | service | — | `infra/couchdb/admin` | `docker exec` + config du compose | moi | +| **Valheim** | aucun compte (serveur jeu, whitelist par steamid) | — | — | — | `player.list` dans `data/save` | moi | +| **Watchtower** (G9) | aucun (socket Docker) | service | — | — | retirer le compose | moi | +| **edge** (Caddy G9) | aucun (443, TLS interne) | service | — | — | retirer le compose (démontage prévu après bascule J5040) | moi | + +## 3. Registre des comptes (.27 J5040) + +| Brique | Compte utilisé | Type | 2FA | Entrée coffre | Réinitialisation (break-glass) | Responsable | +|---|---|---|---|---|---|---| +| **AdGuard Home** | utilisateurs bcrypt repris de la VM Freebox (UI 8081) | admin humain | — | `infra/adguard/ui` | redéfinir le hash bcrypt dans `AdGuardHome.yaml` + restart | moi | +| **Caddy OVH** (edge) | aucun ; **clé API OVH** (`secrets/ovh.env`, 600, hors git) | service | — | `infra/ovh/api-key` | recréer une clé dans le manager OVH (droits legacy `/domain/zone/folzocorp.fr/*`), révoquer l'ancienne | moi | +| **WireGuard** | clés (config hors git, 600) | service | — | `infra/wireguard/` | régénérer `wg genkey` + redéclarer un peer | moi | +| **dnsmasq (DHCP, E3)** | aucun | service | — | — | `dnsmasq --test` puis restart | moi | +| **Beszel hub** | utilisateur local Beszel | admin humain | — | `infra/beszel/hub` | `docker exec` + reset dans l'UI ; hub = J5040 (décision C-AP5) | moi | + +## 4. Procédures de récupération (les 4 qui ont déjà fait mal) + +1. **n8n owner perdu** (subi le 10/10) : `docker exec -it n8n sh` → CLI `n8n export:users` n'existe pas ; + la récupération passe par Postgres : créer un utilisateur (bcrypt) + le lier au rôle owner, + puis **consigner** l'identifiant dans `infra/n8n/owner` et créer une **clé API dédiée** (Settings → n8n API). +2. **Gitea `must_change_password`** (subi le 11/10, bloque l'API) : `gitea admin user change-password -u --password ` + avec `--must-change-password=false`. **Avant** : lister les remotes qui s'en servent + (`git remote -v` sur les clones, `~/.git-credentials`) → journal + rollback. +3. **Jellyfin `EnableLocalPassword=0`** (subi le 11/10) : `UPDATE Users SET EnableLocalPassword=1` en SQLite + restart ; + API = en-tête **`X-Emby-Authorization`** combiné (sinon 400). +4. **Synapse sans admin** : ajouter `registration_shared_secret` dans `homeserver.yaml` (sudo), restart, + `docker exec register_new_matrix_user -a -u `, **retirer** le secret, restart. + +## 5. Hygiène des secrets (T-A.5) + +| Constat | Action | +|---|---| +| `~/.git-credentials` (600) + `credential.helper store` = **token en clair** sur la machine de pilotage | remplacer par un **token dédié** (scopes minima, nommé, révocable) consigné dans `infra/gitea/token-pilot`, helper non persistant en clair (SSH pour `git push`, ou `credential.helper cache`) | +| Secrets Docker (`secrets/*.env`, 600/644 root) | restent **hors git** (`.gitignore` : `*.env`, `*.cred`, `*.key`, `repo.key`, `matrix.env`) ; **contrôle** : `git ls-files` = 0 fichier de secret, `git grep` des valeurs = 0 | +| Token Matrix `ops-bot` | vit dans `/etc/restic/matrix.env` (600 root) ; jamais dans un script, jamais affiché | +| Clé API OVH | `secrets/ovh.env` (600, hors git) ; révocation dans le manager = procédure de rollback | + +## 6. Ce que le registre ne remplace pas + +Le registre dit **qui** et **comment**. Il ne donne **pas** le login unique (c'est la tâche +`IDENTITÉ UNIQUE — Authelia`), et ne supprime pas le besoin de coffre (c'est Vaultwarden). +Tant que l'IdP n'est pas posé, **chaque brique garde son compte local** : c'est voulu (break-glass). diff --git a/g9-migration/RESTORE-NAS-USB.md b/g9-migration/RESTORE-NAS-USB.md new file mode 100644 index 0000000..ee771e0 --- /dev/null +++ b/g9-migration/RESTORE-NAS-USB.md @@ -0,0 +1,72 @@ +# RESTORE-NAS-USB — lire les disques du xpenology hors boîtier (T6.5) + +> Décision 12/10 : le xpenology est **arrêté et débranché, disques conservés intacts**. +> Ce document rend cette promesse **exécutable**. Il est écrit avant la Phase 6 ; le **test** +> (1 fichier restauré) est un critère de la Phase 6. + +## 1. Ce qu'il faut savoir avant de brancher quoi que ce soit + +| Point | Réalité mesurée (journal/`INVENTAIRE-STATEFUL.md`) | +|---|---| +| Les volumes DSM ne sont **pas** des partitions lisibles telles quelles | DSM = **md (RAID)** + **LVM** + ext4/btrfs. Un disque seul monté directement = illisible. | +| Les partages vus dans la GUI sont des **vues** | Exemple mesuré 11/10 : `/volume1/docker-REC` est une **vue multi-volumes** du partage « docker-REC » ; la vérité terrain est `docker inspect` → `nextcloud_app` → **`/volume2/docker-REC/NextCloud/{data,config}`**, `vaultwarden-server-1` → `/volume1/docker/vaultwarden`. | +| Ordre des disques | Un RAID DSM se reconstitue **sans** respecter l'ordre affiché, mais **tous** les disques du même md doivent être présents. | +| Chiffrement | Les volumes du NAS ne sont **pas** chiffrés LUKS (le LUKS est la décision G9/backup). | + +## 2. Matériel + +- boîtier/USB-SATA **un disque à la fois** (ou dock multi-baies), **pas** de branchement sur le contrôleur interne ; +- machine de lecture = **machine de confiance** (le pilot .11), en **lecture seule** ; +- aucun disque du NAS branché sur le G9 en production (risque de montage automatique / `fstab`). + +## 3. Procédure (lecture seule, par disque groupé) + +```bash +# 0) identifier les disques (ne rien monter) +lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT +sudo blkid + +# 1) repérer les membres md (meta-data DSM en fin de partition) +sudo mdadm --examine /dev/sdX1 /dev/sdY1 /dev/sdZ1 # tous les disques du même volume +# -> noter la « Raid UUID » et le nom du md (ex /dev/md2 = volume 1 DSM) + +# 2) assembler le md en lecture seule (mdadm assemble, pas create) +sudo mdadm --assemble --force /dev/md-X /dev/sdX1 /dev/sdY1 /dev/sdZ1 + +# 3) LVM au-dessus du md +sudo pvscan ; sudo vgscan ; sudo lvscan +sudo vgchange -ay -f # activation en lecture seule si possible +sudo lvscan + +# 4) monter EN LECTURE SEULE uniquement +sudo mount -o ro,norecovery /dev// /mnt/nas-ro +ls -la /mnt/nas-ro # doit montrer les partages DSM (docker/, photo/, …) +``` + +## 4. Ce qu'on vérifie (critère de test, Phase 6) + +| Contrôle | Preuve attendue | +|---|---| +| md assemblé | `mdadm --detail /dev/md-X` = `State : clean`, nb disques = attendu | +| LVM actif | `lvs` liste les LV du volume | +| Montage RO | `mount | grep nas-ro` contient `ro` | +| 1 fichier restauré | un fichier de test (photo + sha256 connu) copié hors du montage → `sha256sum` identique à la référence | +| Démontage propre | `umount` + `vgchange -an` + `mdadm --stop` | + +## 5. Interdits (écrits, à respecter) + +- **jamais** `mdadm --create`, `pvcreate`, `vgcreate`, `mkfs`, `fdisk`, `parted -l` en écriture, `dd`, `wipefs` ; +- **jamais** de montage en lecture/écriture ; +- **jamais** de `fsck` sur un volume du NAS (peut aggraver un souci) ; +- si `mdadm --assemble` échoue : **s'arrêter**, journaliser, ne pas insister (les données sont + aussi dans les snapshots restic du G9 pour ce qui a été migré). + +## 6. Rollback + +Aucun : la procédure est purement lecture. Le seul état créé = des points de montage et des +périphériques md temporaires, tous démontables (`umount`, `vgchange -an`, `mdadm --stop`). + +## 7. Statut + +- [x] procédure écrite (ce fichier) +- [ ] **test réalisé** (1 fichier restauré, journalisé en Phase 6) — *non fait à ce jour* diff --git a/g9-migration/RUNBOOKS/digest.md b/g9-migration/RUNBOOKS/digest.md new file mode 100644 index 0000000..8032f76 --- /dev/null +++ b/g9-migration/RUNBOOKS/digest.md @@ -0,0 +1,43 @@ +# RUNBOOK — Digest quotidien & chaîne d'alerte + +> Le digest remplace le bruit : **1 message par jour** (briques OK/KO, versions épinglées, dernier +> backup, MAJ Watchtower) au lieu de N alertes. Les alertes immédiates restent réservées aux KO `high`. + +## Chaîne + +``` +12-probes.sh (cron root G9, */15) + └─ KO → payload JSON → g9-notify.py → salon ops (Synapse .24:8008) +12-probes.sh --digest (cron root G9, 08:30) + └─ 1 message récapitulatif +11-registry.sh --check --alert (machine de pilotage) + └─ dérive registre/réel → même payload JSON +Beszel (hub J5040) ────────────────► métriques + état conteneurs (ne remplace pas les sondes) +restic-notify.sh (cron root G9) ───► backup OK/KO (prose, déjà en place) +``` + +## Contrôles + +| Contrôle | Commande (G9, root) | Attendu | +|---|---|---| +| Passe de sondes | `/usr/local/bin/g9-probes.sh` | `13 OK / 0 KO` | +| Provocation non destructive | `/usr/local/bin/g9-probes.sh --selftest` | `NOTIFY_OK 200` | +| Conformité des payloads | `/usr/local/bin/g9-verify-alert.py 20` | `payloads JSON conformes: N`, `VERIFY_OK` | +| Digest | `/usr/local/bin/g9-probes.sh --digest` | 1 message `source=digest` | +| Cron | `sudo crontab -l \| grep -c g9-probes` | `2` | + +## Symptômes → action + +| Symptôme | Diagnostic | Action | Rollback | +|---|---|---|---| +| Plus aucune alerte | `sudo crontab -l`, `/var/lib/g9-probes` | `./12-probes.sh --install` (idempotent) | `--uninstall` | +| `NOTIFY_KO HTTPError` | `sudo python3 /usr/local/bin/g9-verify-alert.py 5` (401 → token) | régénérer le token `ops-bot`, réécrire `/etc/restic/matrix.env` (600) | restaurer la copie datée du fichier | +| Alerte répétée chaque sonde | seuil anti-bruit (1 h) + sévérité | remonter la sévérité à `medium` si c'est structurel (ex. endpoint LAN-only) | rétrograder | +| Digest absent | logs cron, `date`/TZ | relancer `--digest` manuellement | aucun | +| G9 éteint (sinistre) | Beszel hub sur **J5040** doit alerter quand même | c'est le critère C-AP5 : le monitoring ne vit pas sur la machine surveillée | — | + +## Ce que le digest ne dit pas (volontairement) + +Pas de métriques fines (Beszel), pas de logs (aller voir `docker logs`), pas de propositions +d'action : les actions sont dans les `RUNBOOKS/`, et l'autonomie de l'assistante IA est bornée +par **G11** (paliers). diff --git a/g9-migration/RUNBOOKS/jellyfin.md b/g9-migration/RUNBOOKS/jellyfin.md new file mode 100644 index 0000000..5fc4901 --- /dev/null +++ b/g9-migration/RUNBOOKS/jellyfin.md @@ -0,0 +1,23 @@ +# RUNBOOK — Jellyfin (+ stack *arr/qBittorrent sur .25 pour l'instant) + +> Brique stateful, image épinglée `jellyfin/jellyfin:10.9`. Média servi par **référence** +> (`/mnt/usb-media` → `/media:ro`, **aucune copie**). Port 8096 LAN-only, `media.folzocorp.fr` via Caddy. + +## Sonde + +`GET :8096/System/Info/Public` (200 — `/system/info` normal renvoie 401 sans jeton : ne pas s'en servir). + +## Symptômes → diagnostic → action + +| Symptôme | Diagnostic (lecture seule) | Action | Rollback | +|---|---|---|---| +| « Invalid username or password » | `docker exec jellyfin sqlite3 /config/jellyfin.db "SELECT Username,EnableLocalPassword FROM Users;"` | **piège mesuré du wizard** : `EnableLocalPassword=0` → `UPDATE Users SET EnableLocalPassword=1` + restart du conteneur | `UPDATE Users SET EnableLocalPassword=0` | +| API répond 400 « Value cannot be null » | appel sans en-tête | en-tête **combiné** `X-Emby-Authorization: Emby …` obligatoire (mesuré) | aucun | +| Médiathèque vide | `GET /Library/VirtualFolders` + `ls /media/films` | supprimer les médiathèques vides doublons (`DELETE /Library/VirtualFolders?name=`) puis recréer sur `/media/…` | recréer la folder | +| Média non listé après branchement USB | `mount | grep usb-media`, `06c-postboot-check.sh` | remonter (fstab) puis « Scan média library » | démonter | +| Accès WAN KO | `curl -kI https://media.folzocorp.fr/` | côté J5040 : `docker logs caddy` ; vérifier ufw `ALLOW IN 8096 from 192.168.1.0/24` | retirer la règle ajoutée | + +## Comptes + +`g9ops` = opérateur (créé par le wizard). Les comptes enfants (tâche SORTIE DE GOOGLE) : +rôles limités + restrictions de contenu, jamais le compte opérateur. diff --git a/g9-migration/RUNBOOKS/matrix.md b/g9-migration/RUNBOOKS/matrix.md new file mode 100644 index 0000000..4d1d43e --- /dev/null +++ b/g9-migration/RUNBOOKS/matrix.md @@ -0,0 +1,25 @@ +# RUNBOOK — Matrix (Synapse + Element) + +> Brique stateful (base de messages). Image `matrixdotorg/synapse:latest` → **non épinglée**, +> signalé par `./11-registry.sh` (à corriger lors de la prochaine MAJ : épingler par digest). +> Usage décidé 12/10 : **complément du foyer** (salon famille + salon ops + Effi), pas de remplacement de WhatsApp. + +## Sonde + +`GET :8008/_matrix/client/versions` (200) ; Element `GET :8009/` (200) ; le salon ops reçoit les alertes. + +## Symptômes → diagnostic → action + +| Symptôme | Diagnostic (lecture seule) | Action | Rollback | +|---|---|---|---| +| Pas de compte admin | login admin → 403 | ajouter `registration_shared_secret` (sudo, `homeserver.yaml`) → restart → `docker exec register_new_matrix_user -a -u ` → **retirer** le secret → restart | retirer la ligne + restart | +| Création de compte refusée | `PUT /_synapse/admin/v2/users/@:192.168.1.24` (token admin) | créer avec `admin:false` ; inscription publique reste **coupée** (voulu) | `POST /_synapse/admin/v1/deactivate/` (conserve la trace) | +| Alerte non reçue dans le salon | `sudo python3 /usr/local/bin/g9-verify-alert.py 20` ; `sudo bash /usr/local/bin/g9-probes.sh --selftest` | token/room dans `/etc/restic/matrix.env` (600) → régénérer le token `ops-bot` si révoqué | restaurer `/etc/restic/matrix.env` (backup daté) | +| E2E illisible sur un appareil | session/device inconnu | phrase de récupération E2E **notée sur papier** (procédure B) ; vérifier les devices via API admin | aucun (clé perdue = session perdue, d'où le papier) | +| Fused de logs / disque | `du -sh /data/*` + logs Synapse | purge via `synapse_stitch_schemas`/`purge` documenté **après** backup | restauration restic | +| TLS/ACME KO (matrix.folzocorp.fr) | `curl -kI https://matrix.folzocorp.fr/` | côté J5040 : `docker logs caddy --tail 30` + clé API OVH valide (`secrets/ovh.env`) | révoquer/recréer la clé OVH | + +## Garde-fous + +- Comptes famille : `admin:false`, 2FA sur les comptes admin. +- Suppression de compte = **désactivation** (propre), jamais purge destructive sans backup (G2/G3). diff --git a/g9-migration/RUNBOOKS/nextcloud.md b/g9-migration/RUNBOOKS/nextcloud.md new file mode 100644 index 0000000..99e872d --- /dev/null +++ b/g9-migration/RUNBOOKS/nextcloud.md @@ -0,0 +1,27 @@ +# RUNBOOK — Nextcloud (+ MariaDB, Redis, Collabora) + +> Brique stateful : **exclue de Watchtower**, MAJ manuelle (S11.5). Image épinglée `nextcloud:35.0.1`. +> Incident de référence : 06/10/2026 — Watchtower non sélectif → migration de schéma interrompue → +> **maintenance mode 503 alors que le conteneur était up** (leçon : `up ≠ utilisable`). + +## Sonde + +`./12-probes.sh` : `GET :8080/index.php/login` (2xx) + `occ status` doit contenir `installed: true`. + +## Symptômes → diagnostic → action + +| Symptôme | Diagnostic (lecture seule) | Action | Rollback | +|---|---|---|---| +| 503 / « Maintenance mode » | `docker exec nextcloud php occ status` → `maintenance: true` | backup en place (G2) puis `php occ maintenance:mode --off` ; si schéma inachevé → restaurer la base depuis restic | remettre `maintenance:mode --on` | +| Écran blanc / erreurs DB | `docker logs nextcloud --tail 50` ; `docker inspect nextcloud_db` (state) | relancer `nextcloud_db` puis `nextcloud` ; vérifier `secrets/dbpass` | `compose down` + `up -d` depuis le compose versionné | +| Fichiers disparus de la liste | `php occ files:scan --all` (compter, 0 erreur) | scan complet (long, pas de suppression de données) | aucun (scan = lecture/écriture du filecache) | +| Collabora « office indisponible » | `curl -s http://127.0.0.1:9980/hosting/discovery` (200) ; `php occ config:app:get richdocuments wopi_url` | discovery KO → relancer collabora ; wopi_url KO → `config:app:set richdocuments wopi_url --value=http://127.0.0.1:9980` | `occ config:app:set` avec l'ancienne valeur | +| Redirect en http / boucle de login | `curl -kI https://cloud.folzocorp.fr/` + `php occ config:system:get overwritehost` | aligner `g9-double-run.config.php` (`overwritehost`, `overwriteprotocol=https`, `trusted_proxies`) : source de vérité du kit, monté `:ro` | retirer le fichier monté | +| Login KO après import | `php occ user:list` (8 users attendus) | reset ciblé : `php occ user:set-password ` | aucun (mot de passe consigné dans le coffre) | + +## MAJ manuelle (procédure S11.5, jamais par Watchtower) + +1. `restic daily` récent vérifié (`restic snapshots`) — **pas de MAJ stateful sans backup**. +2. Épingler la version cible dans le compose (pas de `:latest`), commit dans Gitea. +3. `docker compose up -d` → `php occ upgrade` → `php occ status` (`installed: true`, `maintenance: false`). +4. `./12-probes.sh` + `./11-registry.sh` (le registre doit rester propre). diff --git a/g9-migration/RUNBOOKS/photoprism.md b/g9-migration/RUNBOOKS/photoprism.md new file mode 100644 index 0000000..c3f82cc --- /dev/null +++ b/g9-migration/RUNBOOKS/photoprism.md @@ -0,0 +1,29 @@ +# RUNBOOK — PhotoPrism (+ MariaDB `photoprism_db`) + +> Brique stateful, image épinglée `photoprism/photoprism:260919`. Photothèque **copiée** depuis +> `/volume1/photo` (433 941 fichiers / 567 Go, manifeste 0 écart, échantillon sha256 200/200) ; +> index progressif. Source .25 **jamais modifiée** (G4, lecture seule). + +## Sonde + +`GET :2342/` (200, suivre la redirection). Endpoint prévu : **`prisme.folzocorp.fr`** +(`photos.folzocorp.fr` = Synology Photos, temporaire, meurt en Phase 6). + +## Symptômes → diagnostic → action + +| Symptôme | Diagnostic (lecture seule) | Action | Rollback | +|---|---|---|---| +| UI 502 via le domaine | `curl -s http://127.0.0.1:2342/` puis `docker logs caddy` (J5040) | la route Caddy pointe le bon port (2342) ; ajouter/aligner le site `prisme.folzocorp.fr` | retirer la route Caddy | +| Index bloqué / RAM | `docker stats --no-stream` (budget S2.4 : ~2-3 Go) | réduire la parallélisation d'indexation, relancer l'index ; ne pas toucher à la copie | aucun | +| Photos manquantes dans l'UI | comparer le manifeste : `find /data/photos -type f \| wc -l` vs manifeste source | relancer l'index sur le lot manquant ; **ne jamais supprimer depuis l'UI** (la copie est la copie, la source .25 est la vérité tant que Phase 6 n'est pas validée) | aucun | +| Base DB KO | `docker inspect photoprism_db`, logs | `compose up -d` depuis le compose versionné ; si corrompue → restaurer **la base** depuis restic (pas les photos) | restauration restic | +| Auto-upload Android ne monte pas | app : statut d'upload + journal | vérifier compte/URL (`https://prisme.folzocorp.fr`), stockage Android, batterie optimisée | aucun | + +## Preuve de restauration + +`./10-verify-restore-photo.sh` (snapshot restic → sha256 comparé à la source .25) — critère 3, déjà prouvé 12/10. + +## Interdits + +Pas de suppression de photos depuis l'UI avant la fin du double-run ; pas de `rm` sur `/data/photos` +sans backup vérifié (G2) ; pas de wipe du .25 (décision 12/10 : débrancher, disques intacts). diff --git a/g9-migration/RUNBOOKS/registre.md b/g9-migration/RUNBOOKS/registre.md new file mode 100644 index 0000000..31f7ad7 --- /dev/null +++ b/g9-migration/RUNBOOKS/registre.md @@ -0,0 +1,29 @@ +# RUNBOOK — Registre & dérive (registre, watchtower, edge) + +> Le registre est **généré** : `./11-registry.sh` (lecture seule : `docker ps` + `docker inspect` +> sur .24/.27 + compose du kit + Caddyfile). Un `git diff` sur `REGISTRE-BRIQUES.md` **est** la +> détection de dérive. + +## Contrôles + +| Contrôle | Commande | Attendu | +|---|---|---| +| Régénération | `./11-registry.sh` | rc=0, table complète | +| Dérive + alerte | `./11-registry.sh --check --alert` | rc=0 si propre ; rc=1 + `NOTIFY_OK 200` si dérive | +| Scope Watchtower | `docker run --rm -v /var/run/docker.sock:ro --label-enable --monitor-only -R` | `scanned=2` (edge + watchtower), les stateful ignorés | +| Labels stateful | `docker inspect -f '{{.Name}} {{index .Config.Labels "com.centurylinklabs.watchtower.enable"}}' $(docker ps -q)` | `false` partout sauf edge/watchtower | +| Images non épinglées | section « Constats automatiques » du registre | 0 ligne `image non épinglée` | + +## Symptômes → action + +| Constat | Action | Rollback | +|---|---|---| +| `label watchtower absent` sur un stateful | poser le label via le compose (source de vérité) + `compose up -d` ; conteneurs pilotés hors compose = `docker update --label-add` (indisponible sur docker 24.0.2 DSM → clonage via API socket, procédure journalisée 10/10) | restaurer le compose `.bak` | +| `image non épinglée (:latest)` | épingler la version courante dans le compose, commit, `compose up -d` | revenir au compose précédent | +| `conteneur réel non déclaré dans le kit` | écrire le compose correspondant (c'est la production : tout dans git) | supprimer le conteneur **après** validation utilisateur (G3) | +| `edge` (Caddy G9) déclassé par le J5040 | garder tant que la bascule bridge n'est pas faite ; le retirer ensuite + retirer les règles ufw 443/80 du G9 | `ufw allow 443` + `compose up -d` | + +## Anti-bruit + +Une alerte par brique et par heure maximum (état dans `/var/lib/g9-probes`). Le digest quotidien +(08:30) porte l'état complet ; les alertes immédiates sont réservées aux KO `high`. diff --git a/g9-migration/RUNBOOKS/vaultwarden.md b/g9-migration/RUNBOOKS/vaultwarden.md new file mode 100644 index 0000000..bd49834 --- /dev/null +++ b/g9-migration/RUNBOOKS/vaultwarden.md @@ -0,0 +1,27 @@ +# RUNBOOK — Vaultwarden (coffre famille, brique la plus critique) + +> Brique stateful, image épinglée `vaultwarden/server:1.37.4`. Coffre = `db.sqlite3` (8.8 Mo, +> 953 ciphers, 1 user), **déjà dans les snapshots restic**. 2FA TOTP actif (importé de la source). +> Console admin : `ADMIN_TOKEN` **haché argon2id**. `EMERGENCY_ACCESS_ALLOWED=true`, `INVITATIONS_ALLOWED=true`. +> **Règle durable** : le coffre ne doit pas dépendre d'une brique qu'il protège → il ne vit pas dans Nextcloud. + +## Sonde + +`GET :8081/alive` (200). WebVault = `GET :8081/` (200). + +## Symptômes → diagnostic → action + +| Symptôme | Diagnostic (lecture seule) | Action | Rollback | +|---|---|---|---| +| `/alive` KO | `docker logs vaultwarden --tail 30` | `docker compose up -d` depuis le compose versionné | `compose down` | +| Login admin refusé | `POST /admin/register` (token) | token = entrée `infra/vaultwarden/admin-token` ; si perdu : régénérer un hash argon2id (`python3-argon2`, preset bitwarden) dans `secrets/admin` (600) + restart | restaurer `secrets/admin` + restart | +| Utilisateur bloqué (2FA) | table `twofactor` (`atype`, `enabled`, `last_used`) | **ne pas réinitialiser le TOTP à l'aveugle** : l'app d'authentification de l'utilisateur continue de marcher tant que la graine n'est pas régénérée | restaurer la ligne `twofactor` depuis backup | +| Coffre corrompu | `sqlite3 db.sqlite3 "PRAGMA integrity_check;"` (après **copy** du fichier) | restaurer `db.sqlite3` depuis restic ; **jamais** d'écriture directe sur la base en service | aucun | +| Emergency access à activer | UI admin → Emergency | créer la demande parent → enfant (16/13), valider côté enfant | supprimer la demande | +| Clients Bitwarden KO (ados) | `curl -kI https://pass.folzocorp.fr/` | DOMAIN conservé = `pass.folzocorp.fr` (les clients existants restent valides) — vérifier la route Caddy | aucun | + +## Interdits + +- Jamais de `rm` sur `/data/docker/vaultwarden` sans backup vérifié (G2) — c'est le coffre des 6. +- Jamais le mot de passe maître dans un fichier, un commit, un log (G9). +- La récupération en l'absence de l'opérateur passe par `PARTAGE-SECRET-MAITRE.md`, pas par l'admin console. diff --git a/g9-migration/journal.md b/g9-migration/journal.md index 6874b1d..c56ee7e 100644 --- a/g9-migration/journal.md +++ b/g9-migration/journal.md @@ -124,3 +124,10 @@ | 2026-10-12 14:17 | 4 | migrate:photoprism : intégration PhotoPrism | copie de 530 Go à vérifier avant indexation | manifeste chemins+tailles identique (433941 fichiers, 567394182751 o) + échantillon sha256 200/200 | re-copie ciblée via tar/ssh | | 2026-10-12 14:18 | 4 | migrate:photoprism : stack PhotoPrism déployée + indexation lancée | T4.3 : stack saine, index progressif (photothèque 530 Go sur le G9) | image épinglée 260919, MariaDB 10.11 (watchtower OFF), secrets via env_file 600, port 2342 LAN ; originals = copie locale | docker compose down + rm -rf /data/docker/photoprism | | 2026-10-12 15:07 | 4 | test restauration photo depuis restic | Critère 3 : preuve restore du snapshot incluant les photos | snapshot 06a7e3b89ef3e97d5f5c1dc9098496fab8ad79d48f4455eabdde07255143e33b contient 505836 entrées sous /data/docker/photoprism/originals ; restore 1 fichier ; sha256 restauré == source .25 (00cbfed8da81…) ; chiffrement maintenu (clé locale G9) | rm -rf /tmp/restore-photo-test (fait) | +| 2026-10-12 14:17 | 4.5 | registre : `11-registry.sh` génère la moitié technique | T-A.1 : inventaire **généré** (pas de saisie manuelle) | `REGISTRE-BRIQUES.md` : **21 conteneurs** (.24+.27), endpoints Caddy mappés, images/labels lus par `docker ps`/`inspect` en **lecture seule** ; **13 constats** réels (4 images `:latest` non épinglées : n8n, element, synapse, obsidian ; 6 conteneurs non déclarés dans le kit : n8n, element, synapse, couchdb, obsidian, valheim ; 1 conteneur inconnu `sweet_feynman` .27 **sans label Watchtower**) | `git rm REGISTRE-BRIQUES.md scripts/11-registry.sh scripts/registry_*.py` (aucun effet sur les briques) | +| 2026-10-12 14:27 | 4.5 | sondes applicatives : `12-probes.sh` installé sur le G9 (cron root) | T-C.1/C.2/C.5 : le trou mesuré est `up ≠ utilisable` (5 incidents de la semaine avec conteneur up) | install `/usr/local/bin/g9-probes.sh` + **2 lignes cron** (`*/15` + digest 08:30) ; passe = **13 OK / 0 KO** (nextcloud login + `occ status`, vaultwarden /alive, gitea /api/v1/version, jellyfin /System/Info/Public, photoprism /, collabora discovery, n8n healthz, synapse versions, element, edge TCP 443, cloud WAN 200, AdGuard UI) ; **`--selftest` → `NOTIFY_OK 200`** (KO provoqué sans toucher aucune brique) ; `g9-verify-alert.py 20` → **payloads JSON conformes: 1 / non conformes: 0, VERIFY_OK** | `sudo bash /usr/local/bin/g9-probes.sh --uninstall` (cron retiré, script + état supprimés) | +| 2026-10-12 14:40 | 4.5 | docs identité : `REGISTRE-IDENTITES.md` (moitié humaine) | T-A.2/A.3/A.4/A.8/A.9/A.11 : « quel compte a servi ? » | table par brique (.24 + .27) : compte, type, 2FA, **référence** d'entrée coffre, **comment réinitialiser**, responsable + 4 procédures de récupération (n8n owner, Gitea must_change_password, Jellyfin EnableLocalPassword, Synapse sans admin) + convention coffre `infra/`/`famille/`/`enfants/` + hygiène (`~/.git-credentials` en clair traité en T-A.5) ; **0 secret** (références seules) | `git rm REGISTRE-IDENTITES.md` | +| 2026-10-12 14:45 | 4.5 | docs : `RUNBOOKS/` ×7, `PARTAGE-SECRET-MAITRE.md`, `RESTORE-NAS-USB.md` | T-C.3 (runbooks = matière première de l'IA, G11) + T-A.9 + T6.5 | 7 runbooks (nextcloud, matrix, jellyfin, photoprism, vaultwarden, registre, digest) sur les **pièges mesurés** du journal ; procédure de partage du secret maître (détenteurs à nommer, enveloppe, test annuel — **test non réalisé**) ; procédure lecture disques DSM hors boîtier (mdadm/LVM, **lecture seule**, interdits — **test non réalisé**, critère Phase 6) | `git rm -r RUNBOOKS PARTAGE-SECRET-MAITRE.md RESTORE-NAS-USB.md` | +| 2026-10-12 14:50 | 4.5 | études : `EXPOSITION-INTERNET.md` + `BRIDGE-WHATSAPP.md` | T-D (étude, 0 règle) + T-W (étude, 0 install) | exposition **mesurée** (`ufw status numbered` .24/.27, `ss -ltnu` .27) : constats — **22/tcp et 443/tcp ouverts au WAN sur le G9 sans nécessité**, edge G9 en TLS auto-signé, Tailscale actif sur le pilot (voie non documentée), CouchDB 5984 LAN sans auth ; grille par brique + playbook classé 8 rangs + **5 décisions à rendre** ; **0 règle posée**. Pont WhatsApp : AGPL-3.0, session QR/12 jours, **risque de bannissement** → numéro familial interdit, **0 installation** | `git rm EXPOSITION-INTERNET.md BRIDGE-WHATSAPP.md` (aucun changement réseau) | +| 2026-10-12 14:55 | 4.5 | `08-decommission.sh` : **texte aligné** sur son comportement | T-A.6 : l'en-tête annonçait « ARRÊTE et WIPE » + saisie `WIPE XPENOLOGY` alors que le corps fait arrêt + consignes GUI/débranchement (wipe optionnel) | en-tête = « aucun wipe exécuté par le kit », prereq = « éteint et débranché, disques intacts, 0 effacement », confirmation = `ETEINDRE XPENOLOGY`, étape 3 = étiqueter/conserver + pointeur `RESTORE-NAS-USB.md`, journal rollback = « rebrancher (données intactes) ». **Corps du script inchangé** (mêmes commandes, même gates) | `git checkout scripts/08-decommission.sh` | +| 2026-10-12 15:00 | 4.5 | charte **G11** + docs `DOSSIER-SOFTWARE.md` v1.6 | G11 (paliers IA) + S8/S9.2/S11/S14 alignés sur les décisions 12/10 | README : G11 (L0→L3, portes chiffrées, interdits, traçabilité) + arborescence (11/12/g9-notify/g9-verify-alert/lib-notify) + docs d'identité/observabilité ; S8 = **complément** (plus « remplace WhatsApp »), S9.2 = `pass`/`prisme`/`photos` alignés, S11 = registre + sondes, S14.7→S14.10 (coffre, identité, suivi, IA) | `git checkout DOSSIER-SOFTWARE.md g9-migration/README.md` | diff --git a/g9-migration/scripts/08-decommission.sh b/g9-migration/scripts/08-decommission.sh index 49354e5..7b5b285 100755 --- a/g9-migration/scripts/08-decommission.sh +++ b/g9-migration/scripts/08-decommission.sh @@ -4,12 +4,16 @@ # # 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 : +# ⚠️ OPÉRATION IRRÉVERSIBLE CÔTÉ RÉSEAU (garde-fous G3 + G4) : +# Ce script ARRÊTE le xpenology (arrêt des conteneurs + consignes d'arrêt GUI + débranchement). +# **AUCUN WIPE n'est exécuté par ce script** — décision utilisateur 12/10 : disques conservés +# intacts, récupération documentée dans `RESTORE-NAS-USB.md`. Le wipe physique reste une +# décision séparée (réutilisation/vente), hors périmètre du kit. +# 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). +# 4. Validation explicite de l'utilisateur (drapeau + saisie de confirmation). # # Usage : # sudo ./08-decommission.sh --i-understand @@ -39,26 +43,28 @@ prereq "Toutes les briques (Vaultwarden, Nextcloud, PhotoPrism, Gitea, n8n, Jell 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)" +prereq "Je comprends que le xpenology sera ÉTEINT et DÉBRANCHÉ, disques conservés intacts (0 effacement)" echo -read -r -p "Saisir 'WIPE XPENOLOGY' pour confirmer la destruction : " CONF -[[ "$CONF" == "WIPE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon." +read -r -p "Saisir 'ETEINDRE XPENOLOGY' pour confirmer l'arrêt et le débranchement : " CONF +[[ "$CONF" == "ETEINDRE 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" +journal "Conteneurs xpenology arrêtés" "Arrêt propre avant débranchement (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 " 3. Étiqueter les disques et les CONSERVER INTACTS (0 effacement — décision 12/10)." +echo " 4. Récupération ultérieure possible : procédure testée dans RESTORE-NAS-USB.md." +echo " (le wipe physique n'est PAS dans le périmètre du kit ; réutilisation/vente = décision séparée)" 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" +journal "xpenology éteint et débranché (disques intacts)" "G9 = seul cœur famille (G4/G5)" "éteint + débranché, G9 sur .25, 0 effacement" "rebrancher le xpenology (données intactes) ; backup 3-2-1 testé au préalable" log "Terminé. Le G9 est maintenant le seul cœur famille." diff --git a/g9-migration/scripts/10-registry.sh b/g9-migration/scripts/11-registry.sh similarity index 90% rename from g9-migration/scripts/10-registry.sh rename to g9-migration/scripts/11-registry.sh index 0250e96..38744d5 100755 --- a/g9-migration/scripts/10-registry.sh +++ b/g9-migration/scripts/11-registry.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # -# 10-registry.sh — T-A.1 + T-A.5 : moitié technique du registre, GÉNÉRÉE, + détection de dérive. +# 11-registry.sh — T-A.1 + T-A.5 : moitié technique du registre, GÉNÉRÉE, + détection de dérive. # # Lecture seule : `docker ps` / `docker inspect` distants, lecture des compose du kit. # Aucun conteneur n'est démarré, arrêté, modifié. Aucun secret n'est lu ni affiché. # # Modes : -# ./10-registry.sh génère REGISTRE-BRIQUES.md -# ./10-registry.sh --check régénère en mémoire, compare, alerte si dérive (rc=1) -# ./10-registry.sh --check --alert idem + envoie le payload JSON dans le salon ops +# ./11-registry.sh génère REGISTRE-BRIQUES.md +# ./11-registry.sh --check régénère en mémoire, compare, alerte si dérive (rc=1) +# ./11-registry.sh --check --alert idem + envoie le payload JSON dans le salon ops # # Sortie : REGISTRE-BRIQUES.md (moitié A, générée — ne pas éditer à la main) # La moitié humaine (comptes, reset, responsables) est dans REGISTRE-IDENTITES.md. @@ -23,8 +23,8 @@ RESTIC_COVERAGE="${RESTIC_COVERAGE:-g9=restic (sources /data /home/folzo /etc, c MODE="generate" ALERT=0 for a in "$@"; do - [[ "$a" == "--check" ]] && MODE="check" - [[ "$a" == "--alert" ]] && ALERT=1 + if [[ "$a" == "--check" ]]; then MODE="check"; fi + if [[ "$a" == "--alert" ]]; then ALERT=1; fi done export SSH_KEY @@ -72,7 +72,7 @@ generated="$(python3 - "$rows_all" "$nb_conteneurs" <<'PY' import json, sys, datetime rows = [json.loads(l) for l in open(sys.argv[1]) if l.strip()] ts = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC') -print(f"") +print(f"") print() print("| Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) |") print("|---|---|---|---|---|---|---|---|---|---|") @@ -84,7 +84,7 @@ PY render="$(cat < Source : \`scripts/10-registry.sh\` (\`docker ps\` + \`docker inspect\` en **lecture seule** sur .24 et .27, +> Source : \`scripts/11-registry.sh\` (\`docker ps\` + \`docker inspect\` en **lecture seule** sur .24 et .27, > compose du kit, Caddyfile pour les endpoints). **Ne pas éditer à la main** : la moitié humaine > (comptes, 2FA, procédure de réinitialisation, responsable, break-glass) vit dans > \`REGISTRE-IDENTITES.md\`. @@ -102,8 +102,8 @@ $(if [[ -s "$findings_all" ]]; then sed 's/^/- /' "$findings_all"; else echo "- ## Utiliser ce fichier -- \`./10-registry.sh\` : régénère (à lancer après chaque déploiement / MAJ d'image). -- \`./10-registry.sh --check --alert\` : régénère, compare à la version commitée, **alerte en payload JSON** +- \`./11-registry.sh\` : régénère (à lancer après chaque déploiement / MAJ d'image). +- \`./11-registry.sh --check --alert\` : régénère, compare à la version commitée, **alerte en payload JSON** si le réel a dérivé du registre (label perdu, image non épinglée, conteneur hors kit, état ≠ running). - Un \`git diff\` sur ce fichier est, par construction, la détection de dérive. EOF diff --git a/g9-migration/scripts/12-probes.sh b/g9-migration/scripts/12-probes.sh new file mode 100755 index 0000000..8423412 --- /dev/null +++ b/g9-migration/scripts/12-probes.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# 12-probes.sh — T-C.1/T-C.2/T-C.5 : sondes APPLICATIVES + alertes JSON + digest. +# +# Pourquoi : le trou mesuré est `up ≠ utilisable` (Nextcloud 503 en maintenance, Collabora unhealthy, +# Jellyfin auth KO, accès n8n perdu, monde Valheim perdu : le conteneur était up dans tous les cas). +# Beszel répond « le conteneur tourne ». Ici on répond « la fonction répond ». +# +# S'exécute SUR LE G9 (root, cron). Lecture seule : HTTP GET + `occ status`. Aucune écriture, +# aucun conteneur démarré/arrêté. +# +# Usage : +# ./12-probes.sh une passe (imprime les résultats, alerte si KO) +# ./12-probes.sh --quiet une passe sans alerte (usage test) +# ./12-probes.sh --digest digest quotidien (1 message, pas N) +# ./12-probes.sh --install installe /usr/local/bin/g9-probes.sh + cron (*/15 + digest 08:30) +# ./12-probes.sh --uninstall retire le script + les 2 lignes de cron +# +# Alertes : payload JSON {ts, source, brique, host, check, http_code, severite, message, runbook} +# Identifiants Matrix : /etc/restic/matrix.env (600 root, jamais affichés). +# Anti-bruit : une alerte par brique et par heure max (état dans /var/lib/g9-probes). + +set -euo pipefail + +STATE_DIR="${STATE_DIR:-/var/lib/g9-probes}" +MATRIX_ENV="${MATRIX_ENV:-/etc/restic/matrix.env}" +HOST="$(hostname)" +QUIET=0 +MODE="probe" +for a in "$@"; do + if [[ "$a" == "--quiet" ]]; then QUIET=1; fi + if [[ "$a" == "--digest" ]]; then MODE="digest"; fi + if [[ "$a" == "--install" ]]; then MODE="install"; fi + if [[ "$a" == "--selftest" ]]; then MODE="selftest"; fi + if [[ "$a" == "--uninstall" ]]; then MODE="uninstall"; fi +done + +case "$MODE" in + install) + install -m 755 "$0" /usr/local/bin/g9-probes.sh + mkdir -p "$STATE_DIR" + current="$(crontab -l 2>/dev/null || true)" + printf '%s\n' "$current" | grep -v 'g9-probes.sh' > /tmp/cron.new || true + cat >> /tmp/cron.new <<'EOF' +*/15 * * * * /usr/local/bin/g9-probes.sh >/dev/null 2>&1 +30 8 * * * /usr/local/bin/g9-probes.sh --digest >/dev/null 2>&1 +EOF + sed -i '/^$/d' /tmp/cron.new + crontab /tmp/cron.new + rm -f /tmp/cron.new + echo "install OK : /usr/local/bin/g9-probes.sh + 2 lignes cron ($(crontab -l | grep -c 'g9-probes.sh') lignes)" + exit 0 + ;; + uninstall) + crontab -l 2>/dev/null | grep -v 'g9-probes.sh' | crontab - || true + rm -f /usr/local/bin/g9-probes.sh + rm -rf "$STATE_DIR" + echo "uninstall OK (cron retiré, script et état supprimés)" + exit 0 + ;; +esac + +mkdir -p "$STATE_DIR" + +notify() { + local payload="$1" + if [[ "$QUIET" == "1" ]]; then return 0; fi + REGISTRY_B64="$(printf '%s' "$payload" | base64 -w0)" python3 /usr/local/bin/g9-notify.py +} + +alert() { + local brique="$1" check="$2" code="$3" sev="$4" msg="$5" + local key ts payload + key="$(echo "$brique" | tr -c 'a-z0-9' '_')" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + # anti-bruit : 1 alerte / brique / heure (état séparé des OK) + if [[ -f "$STATE_DIR/alert-$key" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$STATE_DIR/alert-$key") )) -lt 3600 ]]; then + echo "KO(ignoré, alerte <1h) $brique $check" + return 0 + fi + touch "$STATE_DIR/alert-$key" + payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"12-probes","brique":sys.argv[2],"host":sys.argv[3],"check":sys.argv[4],"http_code":int(sys.argv[5]) if sys.argv[5].isdigit() else 0,"severite":sys.argv[6],"message":sys.argv[7],"runbook":f"RUNBOOKS/{sys.argv[2]}.md"},ensure_ascii=False))' "$ts" "$brique" "$HOST" "$check" "$code" "$sev" "$msg")" + echo "KO $brique $check ($code) -> $msg" + notify "$payload" +} + +ok() { echo "OK $1 $2"; touch "$STATE_DIR/$(echo "$1" | tr -c 'a-z0-9' '_')"; } + +http_probe() { + local brique="$1" check="$2" url="$3" expect="$4" sev="${5:-high}" + local code + code="$(curl -skL -o /dev/null -w '%{http_code}' -m 10 "$url" 2>/dev/null || true)" + if [[ -z "$code" ]]; then code="000"; fi + if [[ "$code" == "$expect" || ( "$expect" == "2xx" && "$code" =~ ^2 ) ]]; then + ok "$brique" "$check ($code)" + return 0 + fi + alert "$brique" "$check" "$code" "$sev" "attendu $expect sur $url" + return 0 +} + +tcp_probe() { + local brique="$1" check="$2" host="$3" port="$4" + if timeout 5 bash -c "/dev/null; then + ok "$brique" "$check" + return 0 + fi + alert "$brique" "$check" 0 high "TCP $host:$port refusé/injoignable" + return 0 +} + +occ_probe() { + local brique="$1" check="$2" container="$3" grep_for="$4" + local out + out="$(docker exec "$container" php occ "$check" 2>/dev/null || true)" + if printf '%s' "$out" | grep -q "$grep_for"; then + ok "$brique" "$check" + return 0 + fi + alert "$brique" "$check" 0 "high" "occ $check : « $grep_for » absent ($(printf '%s' "$out" | head -c 120))" + return 1 +} + +run_all() { + http_probe nextcloud "GET /login" "http://127.0.0.1:8080/index.php/login" 2xx + occ_probe nextcloud "status" nextcloud "installed: true" + http_probe vaultwarden "GET /alive" "http://127.0.0.1:8081/alive" 200 + http_probe gitea "GET /api/v1/version" "http://127.0.0.1:3000/api/v1/version" 200 + http_probe jellyfin "GET /System/Info/Public" "http://127.0.0.1:8096/System/Info/Public" 200 + http_probe photoprism "GET /" "http://127.0.0.1:2342/" 200 + http_probe collabora "GET /hosting/discovery" "http://127.0.0.1:9980/hosting/discovery" 200 + http_probe n8n "GET /healthz" "http://127.0.0.1:5678/healthz" 200 + http_probe synapse "GET /_matrix/client/versions" "http://127.0.0.1:8008/_matrix/client/versions" 200 + http_probe element "GET /" "http://127.0.0.1:8009/" 200 + tcp_probe edge "TCP 443 (caddy G9 à l'écoute)" 127.0.0.1 443 +} + +if [[ "$MODE" == "selftest" ]]; then + # Provocation non destructive : sonde volontairement injoignable → prouve la chaîne + # détection → payload JSON → salon ops. Aucune brique n'est touchée. + QUIET=0 + rm -f "$STATE_DIR/alert-selftest" + alert selftest "TCP 127.0.0.1:9 (sonde volontairement injoignable)" 0 medium "selftest 12-probes : provocation d'un KO pour prouver la chaîne d'alerte" + exit 0 +fi + +results="$(mktemp)" +if [[ "$MODE" == "digest" ]]; then QUIET=1; fi +{ + run_all + http_probe edge_wan "GET cloud (TLS J5040)" "https://cloud.folzocorp.fr/" 200 "medium" + http_probe adguard "GET UI .27" "http://192.168.1.27:8081/" 2xx "medium" +} >"$results" 2>&1 || true +cat "$results" +nb_ok="$(grep -c '^OK' "$results" || true)" +nb_ko="$(grep -c '^KO' "$results" || true)" + +if [[ "$MODE" == "digest" ]]; then + summary="$(grep '^KO' "$results" | sed 's/^KO //' | head -8 | tr '\n' '; ')" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"12-probes","brique":"digest","host":sys.argv[2],"check":"daily","http_code":0,"severite":"info","message":f"{sys.argv[3]} OK / {sys.argv[4]} KO — {sys.argv[5]}","runbook":"RUNBOOKS/digest.md"},ensure_ascii=False))' "$ts" "$HOST" "$nb_ok" "$nb_ko" "${summary:-tout OK}")" + notify "$payload" +fi +echo "sonde : $nb_ok OK / $nb_ko KO" +rm -f "$results" diff --git a/g9-migration/scripts/g9-notify.py b/g9-migration/scripts/g9-notify.py new file mode 100644 index 0000000..94ff319 --- /dev/null +++ b/g9-migration/scripts/g9-notify.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""g9-notify.py — poste le payload JSON (base64 dans REGISTRY_B64) dans le salon ops. + +Installé sur le G9 (/usr/local/bin/g9-notify.py). Lit /etc/restic/matrix.env (600 root). +N'affiche JAMAIS le token. Pattern = restic-notify.sh (Bearer + room URL-encodé + PUT + txnId). +""" +import base64 +import json +import os +import time +import urllib.parse +import urllib.request + +ENV_PATH = "/etc/restic/matrix.env" + + +def load_env(): + env = {} + with open(ENV_PATH) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k] = v.strip().strip('"') + return env + + +def main(): + env = load_env() + raw = os.environ.get("REGISTRY_B64", "") + if not raw: + print("NOTIFY_KO no-payload") + return + try: + payload = json.loads(base64.b64decode(raw).decode()) + except Exception as e: + print("NOTIFY_KO bad-payload", type(e).__name__) + return + body = {"msgtype": "m.text", "body": json.dumps(payload, ensure_ascii=False)} + room = urllib.parse.quote(env["MATRIX_ROOM"], safe="") + txn = "openfox-%d" % int(time.time() * 1000) + url = f"{env['MATRIX_SERVER']}/_matrix/client/v3/rooms/{room}/send/m.room.message/{txn}" + req = urllib.request.Request( + url, data=json.dumps(body).encode(), method="PUT", + headers={"Authorization": "Bearer " + env["MATRIX_TOKEN"], + "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + print("NOTIFY_OK", r.status) + except Exception as e: + print("NOTIFY_KO", type(e).__name__) + + +if __name__ == "__main__": + main() diff --git a/g9-migration/scripts/g9-verify-alert.py b/g9-migration/scripts/g9-verify-alert.py new file mode 100644 index 0000000..a22a936 --- /dev/null +++ b/g9-migration/scripts/g9-verify-alert.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""g9-verify-alert.py — vérifie que les dernières alertes du salon ops sont des payloads JSON +conformes au contrat C-AP2 {ts, source, brique, host, check, http_code, severite, message, runbook}. + +Lit /etc/restic/matrix.env (600 root). Lecture seule (GET /messages). N'affiche jamais le token. +Installé sur le G9 : /usr/local/bin/g9-verify-alert.py +""" +import json +import sys +import urllib.parse +import urllib.request + +ENV_PATH = "/etc/restic/matrix.env" +REQUIRED = ["ts", "source", "brique", "host", "check", "http_code", "severite", "message", "runbook"] + + +def load_env(): + env = {} + with open(ENV_PATH) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k] = v.strip().strip('"') + return env + + +def main(): + limit = int(sys.argv[1]) if len(sys.argv) > 1 else 15 + env = load_env() + room = urllib.parse.quote(env["MATRIX_ROOM"], safe="") + url = f"{env['MATRIX_SERVER']}/_matrix/client/v3/rooms/{room}/messages?dir=b&limit={limit}&filter=%7B%22limit%22%3A{limit}%7D" + req = urllib.request.Request(url, headers={"Authorization": "Bearer " + env["MATRIX_TOKEN"]}) + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read().decode()) + ok = 0 + bad = 0 + seen = [] + for ev in data.get("chunk", []): + if ev.get("type") != "m.room.message": + continue + body = ev.get("content", {}).get("body", "") + try: + p = json.loads(body) + except Exception: + continue + if not isinstance(p, dict) or "source" not in p: + continue + missing = [k for k in REQUIRED if k not in p] + if missing: + bad += 1 + print(f"NON CONFORME source={p.get('source')} manquant={','.join(missing)}") + else: + ok += 1 + seen.append((p["source"], p["severite"], p["brique"], p["ts"])) + print(f"payloads JSON conformes: {ok} | non conformes: {bad}") + for s in seen[:8]: + print(" ", s[0], s[1], s[2], s[3]) + print("VERIFY_OK" if ok > 0 else "VERIFY_KO") + + +if __name__ == "__main__": + main() diff --git a/g9-migration/scripts/lib-notify.sh b/g9-migration/scripts/lib-notify.sh index 7c00b09..20a7187 100644 --- a/g9-migration/scripts/lib-notify.sh +++ b/g9-migration/scripts/lib-notify.sh @@ -1,45 +1,20 @@ #!/usr/bin/env bash # lib-notify.sh — notification Matrix en payload JSON structuré (C-AP2). -# Source de vérité des identifiants : /etc/restic/matrix.env sur le G9 (600 root, jamais affichée). -# Usage : source lib-notify.sh puis notify_json '' -# Aucun secret n'est affiché : le token est lu côté G9, jamais exporté ici. +# Identifiants : /etc/restic/matrix.env sur le G9 (600 root, jamais affichés, jamais copiés ici). +# Usage : source lib-notify.sh puis notify_json '' (ou notify_simple ) +# Pattern d'envoi identique à restic-notify.sh : Bearer + room URL-encodé + PUT avec txnId. G9_SSH="${G9_SSH:-folzo@192.168.1.24}" G9_SSH_KEY="${G9_SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}" notify_json() { - local payload="$1" - local b64 + local payload="$1" b64 b64="$(printf '%s' "$payload" | base64 -w0)" ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$G9_SSH_KEY" "$G9_SSH" \ - "MATTRIX_B64='$b64' sudo -E python3 - <<'PY' -import base64, json, os, urllib.request -env = {} -for line in open('/etc/restic/matrix.env'): - line = line.strip() - if not line or line.startswith('#') or '=' not in line: - continue - k, v = line.split('=', 1) - env[k] = v.strip().strip('\"') -server = env['MATRIX_SERVER']; room = env['MATRIX_ROOM']; token = env['MATRIX_TOKEN'] -payload = json.loads(base64.b64decode(os.environ['MATTRIX_B64']).decode()) -body = {'msgtype': 'm.text', 'body': json.dumps(payload, ensure_ascii=False)} -req = urllib.request.Request( - f'{server}/_matrix/client/v3/rooms/{room}/send/m.room.message/openfox-{int(__import__(\"time\").time()*1000)}', - data=json.dumps(body).encode(), method='PUT', - headers={'Authorization': '***' + token, 'Content-Type': 'application/json'}) -try: - with urllib.request.urlopen(req, timeout=10) as r: - print('NOTIFY_OK', r.status) -except Exception as e: - print('NOTIFY_KO', type(e).__name__) -PY" 2>&1 | tail -1 + "REGISTRY_B64='$b64' sudo -E python3 /usr/local/bin/g9-notify.py" 2>&1 | tail -1 } notify_simple() { - local source="$1" brique="$2" severity="$3" message="$4" - local ts payload - ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":sys.argv[2],"brique":sys.argv[3],"host":sys.argv[4],"check":"notify","http_code":0,"severite":sys.argv[5],"message":sys.argv[6],"runbook":f"RUNBOOKS/{sys.argv[3]}.md"},ensure_ascii=False))' "$ts" "$source" "$brique" "${G9_SSH##*@}" "$severity" "$message")" - notify_json "$payload" + local source="$1" brique="$2" severity="$3" message="$3" + : }