Compare commits

..
2 Commits
24 changed files with 1369 additions and 18 deletions
+44 -7
View File
@@ -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). |
+64
View File
@@ -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.
+69
View File
@@ -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).
+60
View File
@@ -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) |
+36 -2
View File
@@ -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 |
+60
View File
@@ -0,0 +1,60 @@
# REGISTRE-BRIQUES — moitié technique (générée)
> 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`.
>
> Périmètre (T-A.10) : **.24 (G9) et .27 (J5040) uniquement**. Le xpenology **.25 est hors périmètre** :
> il est décrit dans `INVENTAIRE-STATEFUL.md` et ses briques sont **à mourir en Phase 6**.
>
> Couverture backup : g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5)
<!-- GÉNÉRÉ PAR scripts/11-registry.sh — ne pas éditer à la main. ts=2026-10-12 14:17 UTC ; 21 conteneurs -->
| Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) |
|---|---|---|---|---|---|---|---|---|---|
| g9 | `collabora` | nextcloud / collabora | `collabora/code:26.04.5.1.1` | running | oui | false | stateful | oui | — |
| g9 | `couchdb` | g9-queue / couchdb | `couchdb:3.3` | running | oui | false | stateful | NON | — |
| g9 | `edge` | edge / edge | `caddy:2` | running | oui | true | stateless | oui | — |
| g9 | `element` | matrix / element | `vectorim/element-web:latest` | running | non (latest) | false | stateful | NON | — |
| g9 | `gitea` | gitea / gitea | `gitea/gitea:1.27.3` | running | oui | false | stateful | oui | git.folzocorp.fr |
| g9 | `gitea-db` | gitea / gitea-db | `postgres:16` | running | oui | false | stateful | oui | — |
| g9 | `jellyfin` | jellyfin / jellyfin | `jellyfin/jellyfin:10.9` | running | oui | false | stateful | oui | media.folzocorp.fr |
| g9 | `n8n` | g9-queue / n8n | `docker.n8n.io/n8nio/n8n:latest` | running | non (latest) | false | stateful | NON | n8n.folzocorp.fr |
| g9 | `nextcloud` | nextcloud / nextcloud | `nextcloud:35.0.1` | running | oui | false | stateful | oui | cloud.folzocorp.fr |
| g9 | `nextcloud_db` | nextcloud / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
| g9 | `nextcloud_redis` | nextcloud / redis | `redis:7` | running | oui | false | stateful | oui | — |
| g9 | `obsidian` | g9-queue / obsidian | `lscr.io/linuxserver/obsidian:latest` | running | non (latest) | false | stateful | NON | — |
| g9 | `photoprism` | photoprism / photoprism | `photoprism/photoprism:260919` | running | oui | false | stateful | oui | photos.folzocorp.fr |
| g9 | `photoprism_db` | photoprism / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
| g9 | `synapse` | matrix / synapse | `matrixdotorg/synapse:latest` | running | non (latest) | false | stateful | NON | matrix.folzocorp.fr |
| g9 | `valheim` | compose / valheim | `valheim-pterodactyl:1` | running | oui | false | stateful | NON | — |
| g9 | `vaultwarden` | vaultwarden / vaultwarden | `vaultwarden/server:1.37.4` | running | oui | false | stateful | oui | pass.folzocorp.fr |
| g9 | `watchtower` | g9-queue / watchtower | `45b54f6dc152` | running | oui (digest) | true | stateless | NON | — |
| j5040 | `adguard` | adguard / adguard | `aba9e3bf0613` | running | oui (digest) | true | stateless | oui | — |
| j5040 | `caddy` | edge-j5040 / caddy | `502996b5b60a` | running | oui (digest) | true | stateless | oui | — |
| j5040 | `sweet_feynman` | - / - | `437a490f7afc` | running | oui (digest) | ABSENT | stateless | NON | — |
## Constats automatiques (détection de dérive)
- g9/valheim: conteneur réel non déclaré dans le kit (compose)
- g9/n8n: image non épinglée (docker.n8n.io/n8nio/n8n:latest) — S11.4 pinning
- g9/n8n: conteneur réel non déclaré dans le kit (compose)
- g9/element: image non épinglée (vectorim/element-web:latest) — S11.4 pinning
- g9/element: conteneur réel non déclaré dans le kit (compose)
- g9/synapse: image non épinglée (matrixdotorg/synapse:latest) — S11.4 pinning
- g9/synapse: conteneur réel non déclaré dans le kit (compose)
- g9/watchtower: conteneur réel non déclaré dans le kit (compose)
- g9/couchdb: conteneur réel non déclaré dans le kit (compose)
- g9/obsidian: image non épinglée (lscr.io/linuxserver/obsidian:latest) — S11.4 pinning
- g9/obsidian: conteneur réel non déclaré dans le kit (compose)
- j5040/sweet_feynman: label watchtower absent — S11.4 sélectivité
- j5040/sweet_feynman: conteneur réel non déclaré dans le kit (compose)
## Utiliser ce fichier
- `./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.
+87
View File
@@ -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/<brique>/<compte>` | `infra/gitea/g9ops`, `infra/n8n/api`, `infra/synapse/admin` |
| Adultes | `famille/<pseudo>/<brique>` | `famille/effi/nextcloud`, `famille/effi/matrix` |
| Enfants | `enfants/<pseudo>/<brique>` (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/<pseudo>/` ;
- `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/<pseudo>/nextcloud` | `docker exec nextcloud php occ user:set-password <uid>` (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/<uid>`), token admin via login ; déactivation propre = `POST /_synapse/admin/v1/deactivate/<uid>` | 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 <uid> --password <nouveau>`
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 <synapse> register_new_matrix_user -a -u <admin>`, **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).
+72
View File
@@ -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/<vg>/<lv> /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*
+43
View File
@@ -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).
+23
View File
@@ -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.
+25
View File
@@ -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 <synapse> register_new_matrix_user -a -u <admin>` → **retirer** le secret → restart | retirer la ligne + restart |
| Création de compte refusée | `PUT /_synapse/admin/v2/users/@<uid>:192.168.1.24` (token admin) | créer avec `admin:false` ; inscription publique reste **coupée** (voulu) | `POST /_synapse/admin/v1/deactivate/<uid>` (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).
+27
View File
@@ -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 <uid>` | 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).
+29
View File
@@ -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).
+29
View File
@@ -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 <watchtower@digest> --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`.
+27
View File
@@ -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.
+7
View File
@@ -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` |
+15 -9
View File
@@ -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."
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# 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 :
# ./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.
set -euo pipefail
KIT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$KIT/REGISTRE-BRIQUES.md"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
HOSTS="${HOSTS:-g9:folzo@192.168.1.24 j5040:folzo@192.168.1.27}"
RESTIC_COVERAGE="${RESTIC_COVERAGE:-g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5)}"
MODE="generate"
ALERT=0
for a in "$@"; do
if [[ "$a" == "--check" ]]; then MODE="check"; fi
if [[ "$a" == "--alert" ]]; then ALERT=1; fi
done
export SSH_KEY
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
declared_json="$(python3 "$KIT/scripts/registry_inputs.py" declared)"
caddy_json="$(python3 "$KIT/scripts/registry_inputs.py" caddy)"
echo "$caddy_json" > "$TMP/caddy.json"
rows_all="$TMP/rows.jsonl"
findings_all="$TMP/findings.txt"
: > "$rows_all"
: > "$findings_all"
for entry in $HOSTS; do
machine="${entry%%:*}"
host="${entry#*:}"
mip="${host#*@}"
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$SSH_KEY" "$host" \
'docker ps -a --format json' > "$TMP/$machine.ps.json" 2>"$TMP/$machine.err" || {
echo "[registry] SSH/docker indisponible sur $machine ($host)" >&2
echo "$machine: inaccessible (SSH ou docker)" >> "$findings_all"
continue
}
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$SSH_KEY" "$host" \
'docker ps --format "{{.Names}}\t{{.Ports}}"' > "$TMP/$machine.ports.tsv" 2>/dev/null || : > "$TMP/$machine.ports.tsv"
python3 "$KIT/scripts/registry_inputs.py" endpoints "$TMP/$machine.ports.tsv" "$TMP/caddy.json" "$mip" > "$TMP/$machine.ep.json"
python3 "$KIT/scripts/registry_parse.py" "$machine" "$(cat "$TMP/$machine.ep.json")" "$declared_json" \
< "$TMP/$machine.ps.json" > "$TMP/$machine.rows.json" 2>"$TMP/$machine.findings"
python3 -c 'import json,sys
for r in json.load(open(sys.argv[1])):
print(json.dumps(r, ensure_ascii=False))' "$TMP/$machine.rows.json" >> "$rows_all"
sed -n 's/^#findings# //p' "$TMP/$machine.findings" | python3 -c 'import json,sys
for line in sys.stdin:
line=line.strip()
if line:
for f in json.loads(line): print(f)' >> "$findings_all"
done
nb_conteneurs="$(wc -l < "$rows_all" | tr -d ' ')"
nb_findings="$(grep -c . "$findings_all" || true)"
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"<!-- GÉNÉRÉ PAR scripts/11-registry.sh — ne pas éditer à la main. ts={ts} ; {sys.argv[2]} conteneurs -->")
print()
print("| Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) |")
print("|---|---|---|---|---|---|---|---|---|---|")
for r in rows:
print(f"| {r['machine']} | `{r['name']}` | {r['project']} / {r['service']} | `{r['image']}` | {r['state']} | {r['pinned']} | {r['wt']} | {r['stateful']} | {r['kit']} | {r['endpoint'] or '—'} |")
PY
)"
render="$(cat <<EOF
# REGISTRE-BRIQUES — moitié technique (générée)
> 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\`.
>
> Périmètre (T-A.10) : **.24 (G9) et .27 (J5040) uniquement**. Le xpenology **.25 est hors périmètre** :
> il est décrit dans \`INVENTAIRE-STATEFUL.md\` et ses briques sont **à mourir en Phase 6**.
>
> Couverture backup : $RESTIC_COVERAGE
$generated
## Constats automatiques (détection de dérive)
$(if [[ -s "$findings_all" ]]; then sed 's/^/- /' "$findings_all"; else echo "- aucun"; fi)
## Utiliser ce fichier
- \`./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
)"
if [[ "$MODE" == "check" ]]; then
if [[ ! -f "$OUT" ]]; then
echo "[registry] REGISTRE-BRIQUES.md absent — générer d'abord" >&2
exit 2
fi
base="$(git -C "$KIT" show HEAD:g9-migration/REGISTRE-BRIQUES.md 2>/dev/null || git -C "$KIT" show HEAD:REGISTRE-BRIQUES.md 2>/dev/null || true)"
body_new="$(printf '%s' "$render" | sed '1,4d')"
body_old="$(printf '%s' "$base" | sed '1,4d')"
if [[ -z "$body_old" ]]; then
echo "[registry] pas de version commitée de comparaison" >&2
exit 2
fi
if [[ "$(printf '%s' "$body_new" | md5sum)" == "$(printf '%s' "$body_old" | md5sum)" ]]; then
echo "[registry] aucune dérive ($nb_conteneurs conteneurs, $nb_findings constats)"
exit 0
fi
echo "[registry] DÉRIVE détectée :"
printf '%s\n' "$nb_findings constats"
if [[ "$ALERT" == "1" ]]; then
source "$KIT/scripts/lib-notify.sh"
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"10-registry","brique":"registre","host":"g9","check":"registry-drift","http_code":0,"severite":"warn","message":sys.argv[2],"runbook":"RUNBOOKS/registre.md"},ensure_ascii=False))' "$ts" "$nb_conteneurs conteneurs, $nb_findings constats automatiques, diff registre/réel à lire dans REGISTRE-BRIQUES.md")"
notify_json "$payload"
fi
exit 1
fi
printf '%s\n' "$render" > "$OUT"
echo "[registry] $OUT généré : $nb_conteneurs conteneurs, $nb_findings constats automatiques"
+165
View File
@@ -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/tcp/$host/$port" 2>/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"
+56
View File
@@ -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()
+64
View File
@@ -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()
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# lib-notify.sh — notification Matrix en payload JSON structuré (C-AP2).
# 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 '<json>' (ou notify_simple <source> <brique> <sev> <msg>)
# 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" b64
b64="$(printf '%s' "$payload" | base64 -w0)"
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$G9_SSH_KEY" "$G9_SSH" \
"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="$3"
:
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Helpers for 10-registry.sh — lit le kit (lecture seule) et fournit les entrées du registre.
declared <kit_dir> -> JSON [{project, service, image}] depuis compose/*/docker-compose.yml
caddy <kit_dir> -> JSON {port: sous-domaine} depuis compose/*/Caddyfile
endpoints <ports_tsv> <caddy_json> -> JSON {conteneur: sous-domaine}
Aucun conteneur n'est touché.
"""
import json
import os
import re
import sys
def declared(kit):
out = []
cdir = os.path.join(kit, "compose")
if not os.path.isdir(cdir):
return out
for project in sorted(os.listdir(cdir)):
f = os.path.join(cdir, project, "docker-compose.yml")
if not os.path.isfile(f):
continue
try:
import yaml
data = yaml.safe_load(open(f)) or {}
except Exception:
data = {}
services = data.get("services") or {}
for service, spec in services.items():
image = "-"
if isinstance(spec, dict):
image = spec.get("image", "-") or "-"
out.append({"project": project, "service": service, "image": image})
return out
def caddy(kit):
"""Liste [{site, ip, port, source}] — clé = (ip, port), donc pas de collision entre machines."""
entries = []
cdir = os.path.join(kit, "compose")
if not os.path.isdir(cdir):
return entries
for project in sorted(os.listdir(cdir)):
cf = os.path.join(cdir, project, "Caddyfile")
if not os.path.isfile(cf):
continue
site = None
for line in open(cf):
s = line.strip()
if s.startswith("#"):
continue
m = re.match(r"^((?:[a-z0-9*-]+\.[a-z0-9.-]+\s+)*[a-z0-9*-]+\.[a-z0-9.-]+)\s*\{$", s)
if m:
hosts = m.group(1).split()
site = next((h for h in hosts if "folzocorp.fr" in h), None)
continue
m = re.search(r"reverse_proxy\s+(\S+):(\d+)", s)
if m and site:
ip, port = m.group(1), m.group(2)
if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
entries.append({"site": site, "ip": ip, "port": port, "source": project})
return entries
def endpoints(ports_tsv, caddy_json, machine_ip):
entries = json.loads(open(caddy_json).read()) if os.path.isfile(caddy_json) else []
cmap = {(e["ip"], e["port"]): e["site"] for e in entries}
out = {}
for line in open(ports_tsv):
parts = line.rstrip("\n").split("\t")
if len(parts) < 2:
continue
name, ports = parts[0], parts[1]
found = ""
for m in re.finditer(r":(\d+)->", ports):
site = cmap.get((machine_ip, m.group(1)))
if site:
found = site
break
out[name] = found
return out
def main():
cmd = sys.argv[1]
kit = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if cmd == "declared":
print(json.dumps(declared(kit)))
elif cmd == "caddy":
print(json.dumps(caddy(kit)))
elif cmd == "endpoints":
print(json.dumps(endpoints(sys.argv[2], sys.argv[3], sys.argv[4])))
else:
sys.exit("usage: registry_inputs.py declared|caddy|endpoints")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Parse `docker ps -a --format json` (stdin) + kit compose declarations, emit markdown rows.
Read-only helper for 10-registry.sh. No container is touched.
"""
import json
import re
import sys
STATEFUL_HINTS = ("mariadb", "postgres", "redis", "influx", "couchdb", "db", "vault", "data")
LABEL_WT = "com.centurylinklabs.watchtower.enable"
LABEL_PROJECT = "com.docker.compose.project"
LABEL_SERVICE = "com.docker.compose.service"
def is_digest_id(image):
return bool(re.fullmatch(r"[0-9a-f]{12,64}", image))
def kit_matches(kit_image, real_image):
"""Le kit épingle par digest : `name@sha256:<hex>` == réel `<hex court>` si préfixe."""
if not kit_image or kit_image == "-":
return True
if kit_image == real_image:
return True
if "@sha256:" in kit_image and is_digest_id(real_image):
digest = kit_image.split("@sha256:", 1)[1]
return digest.startswith(real_image)
return False
def is_stateful(name, image, labels):
if labels.get(LABEL_WT) == "false":
return True
low = f"{name} {image}".lower()
return any(h in low for h in STATEFUL_HINTS)
def main():
machine = sys.argv[1]
endpoints = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
declared = json.loads(sys.argv[3]) if len(sys.argv) > 3 else []
declared_keys = {(d["project"], d["service"]) for d in declared}
declared_images = {f'{d["project"]}/{d["service"]}': d["image"] for d in declared}
rows = []
findings = []
raw = sys.stdin.read().strip()
if not raw:
print("[]")
print("#findings# []", file=sys.stderr)
return
for line in raw.splitlines():
if not line.strip():
continue
c = json.loads(line)
name = c.get("Names", c.get("Name", "?"))
image = c.get("Image", "?")
state = c.get("State", "?")
labels = {}
for kv in str(c.get("Labels", "")).split(","):
if "=" in kv:
k, v = kv.split("=", 1)
labels[k] = v
project = labels.get(LABEL_PROJECT, "-")
service = labels.get(LABEL_SERVICE, "-")
wt = labels.get(LABEL_WT)
wt_label = "true" if wt == "true" else ("false" if wt == "false" else "ABSENT")
pinned = "non (latest)" if image.endswith(":latest") else "oui"
if is_digest_id(image):
pinned = "oui (digest)"
key = f"{project}/{service}"
declared_state = "oui" if (project, service) in declared_keys else "NON"
drift_img = ""
if declared_state == "oui" and not kit_matches(declared_images.get(key), image):
drift_img = f"image kit={declared_images.get(key)} != réel={image}"
endpoint = endpoints.get(f"{name}", "")
rows.append({
"machine": machine,
"name": name,
"project": project,
"service": service,
"image": image,
"state": state,
"pinned": pinned,
"wt": wt_label,
"stateful": "stateful" if is_stateful(name, image, labels) else "stateless",
"kit": declared_state,
"endpoint": endpoint,
})
if pinned.startswith("non"):
findings.append(f"{machine}/{name}: image non épinglée ({image}) — S11.4 pinning")
if wt_label == "ABSENT":
findings.append(f"{machine}/{name}: label watchtower absent — S11.4 sélectivité")
if declared_state == "NON":
findings.append(f"{machine}/{name}: conteneur réel non déclaré dans le kit (compose)")
if drift_img:
findings.append(f"{machine}/{name}: {drift_img} — dérive kit/réel")
if state not in ("running",):
findings.append(f"{machine}/{name}: état {state}")
rows.sort(key=lambda r: (r["machine"], r["name"]))
print(json.dumps(rows, ensure_ascii=False))
print("#findings# " + json.dumps(findings, ensure_ascii=False), file=sys.stderr)
if __name__ == "__main__":
main()