Compare commits

...
35 Commits
Author SHA1 Message Date
openfox-pilot 06713702ff review findings : kit secrets 700/600, find -exec robuste, commentaire convention 03b 2026-10-13 22:09:53 +02:00
openfox-pilot 431ba70f25 secrets photoprism 700/600 (finding verifier) : fix live + 07d chmod 2026-10-13 21:51:13 +02:00
openfox-pilot 8b97089bfa openfox.folzocorp.fr → 192.168.1.11:10369 (Caddy .27, cert LE, 200 LAN+WAN) 2026-10-13 21:33:40 +02:00
openfox-pilot 7fab09409c bascule réseau E3/E4 : dnsmasq .27 (ufw 67/68 fix), WAN via Delta (80/443/51820->.27, certs LE OK), edge G9 démonté ; journal E2/E3/E4 2026-10-13 21:28:37 +02:00
Effi c7385be393 mx5: auto-controle interdiction 1 = commande a sortie vide (0 ligne mesurée) 2026-10-13 02:43:16 +02:00
Effi f9fde1e622 id7: journal de bascule Gitea 4 colonnes en §4.5 (+CLI roles mesuré) ; mx5: S3.3 l.192 'pas un remplacement' 2026-10-12 23:22:47 +02:00
Effi 9eae93be0b 6 criteres retoques: PhotoPrism+garde-fous+WhatsApp+grille 10 requetes+17 questions, salon famille cree (GO), peages perimes 2026-10-12 22:42:22 +02:00
Effi aeee208c04 docs: cloture 31 criteres cote IA (prouve vs geste humain), README identites, changelog v1.8 2026-10-12 21:24:10 +02:00
Effi 4f1c267404 synapse: 1er compte admin cree, repare (bcrypt+PUT v2) et verifie (login OK, API admin OK) + 27-family-users outille 2026-10-12 21:13:43 +02:00
OpenFox 4a3c460b57 dumps logiques 3 bases (gitea rejouabilité prouvée 116 tables) + hub beszel hors-G9 sur J5040 LAN-only 2026-10-12 20:35:54 +02:00
OpenFox e27ac1ea90 identites: balayage mesure (gitea 2fa=0/4, synapse admin=0), porte de sortie synapse outillee, backup-perimetre + reste-humain 2026-10-12 20:16:38 +02:00
OpenFox 79197d13cb docs: BACKUP-PERIMETRE, RESTE-HUMAIN (checklist 17 criteres), PHOTOS-DOUBLE-RUN + README (14-firewall) 2026-10-12 20:06:12 +02:00
OpenFox 67b5b68c18 durcissement: ssh+443 LAN-only sur .24/.27, tailscale coupe, couchdb faux positif corrige (auth 401 mesurée) 2026-10-12 20:03:03 +02:00
OpenFox 70265acddd exposition: grille valider, ufw complet mesure, ssh ouvert WAN sur .24 ET .27, 443 G9 sans regle LAN 2026-10-12 19:46:40 +02:00
OpenFox f13fc2472a registry: --check comparait les lignes 1-4, l'horodatage (ligne 9) restait -> fausse derive permanente 2026-10-12 19:12:48 +02:00
OpenFox 3f3f14302d docs: README (13-diun-notify, compose versionnes) + DOSSIER v1.7 (mesures phase 4.5) 2026-10-12 19:09:51 +02:00
OpenFox d61eb0f67b phase 4.5: diun deploye, prisme pose sur J5040, 3 bugs chaine alerte corriges, compose du kit versionnes 2026-10-12 19:06:13 +02:00
OpenFox 76a348449e phase 4.5: notifications 3/3 relevees, backup+restauration photos prouves, double-run photos mesure, n8n mesure 2026-10-12 17:14:44 +02:00
OpenFox 3fcfa4459b journal: validation utilisateur L3 (sondes+digest G9) tracee (G11) 2026-10-12 16:55:39 +02:00
OpenFox b98fbf76b4 phase 4.5: chaine notification verifiee, derive prouvee, runbook execute, procedure git-credentials 2026-10-12 16:53:20 +02:00
OpenFox 3b162dea50 journal: phase 4.5 (sondes installees, notification verifiee, derive prouvee) 2026-10-12 16:50:42 +02:00
OpenFox a955bcd4c2 registry: endpoints Caddy corriges (cle ip+port, hotes multiples), lib-notify en argv, doc regenere 2026-10-12 16:48:29 +02:00
openfox 4b26049b8f phase 4.5: registre identites + runbooks + sondes applicatives (cron G9) + G11 + etudes exposition/whatsapp + alignement 08-decommission et S8/S9.2/S11/S14 2026-10-12 16:42:41 +02:00
openfox ca64eaaf77 registre: 10-registry.sh (genere moitie technique + derive) + REGISTRE-BRIQUES.md (21 conteneurs, 13 constats) + lib-notify.sh (payload JSON) 2026-10-12 16:17:34 +02:00
openfox-pilot 2f98c32da1 docs clôture : PHASE4 §0.1 PhotoPrism ✅ + §0.5 J5040 E1-E4 ; README 07d ✅ + E aligné ; 10-verify fixes (include restore, chemins) 2026-10-12 15:14:11 +02:00
openfox-pilot a5b2909722 07d: fixes idempotence snapshots (env restic), compose via sudo (env_file 600 root), health /api/v1/health ; 10-verify: fichier test depuis g9.meta (garanti dans snapshot) 2026-10-12 14:17:39 +02:00
openfox-pilot 6ee136b35d critère 3 : script test restore photo (10-verify-restore-photo.sh) + décision chiffrement journalisée 2026-10-12 12:44:51 +02:00
openfox-pilot d4f68cb8f7 wireguard: clés serveur+portable générées (600 sur J5040, gitignored) 2026-10-11 18:59:32 +02:00
openfox-pilot 71f1ec33fd E4 : Caddy OVH sur J5040, 7/7 certs LE via DNS-01, clé OVH renouvelée ; copie parallèle 4 flux 2026-10-11 18:27:42 +02:00
openfox-pilot a3a198027d E4: clé OVH NAS expirée (diagnostic API), secrets 600 sur J5040, journal 2026-10-11 17:39:42 +02:00
openfox-pilot 6516d67d0b compose wireguard (E4, linuxserver épinglé digest) 2026-10-11 17:08:51 +02:00
openfox-pilot 0b95d24046 E2 bascule DNS .27 active (bail G9 renouvelé, filtrage vérifié) 2026-10-11 17:07:31 +02:00
openfox-pilot e2dc0176ff E3 préparé : baux statiques Freebox (OCR) -> /srv/dnsmasq/leases, dnsmasq --test OK, 09 corrigé (conf-file retiré) 2026-10-11 16:55:20 +02:00
openfox-pilot ffe3f2ac91 J5040 E1-E2 + préparation E3/E4 : durcissement .27, AdGuard déployé (config VM migrée), compose adguard/edge-j5040 (Caddy OVH), 09-j5040-dhcp.sh, 07d sans gzip, README/journal alignés 2026-10-11 16:38:05 +02:00
Effi e6295cffc2 clôture Phase 4 : journal (consolidation + push Gitea + zéro secret vérifié) 2026-10-11 14:14:27 +02:00
60 changed files with 3718 additions and 44 deletions
+3
View File
@@ -12,3 +12,6 @@ g9-migration/configs/restic.env
# Artefacts playwright-cli (snapshots navigateur)
.playwright-cli/
# Config WireGuard générée (clés privées — G9)
**/wireguard/wg/
+76 -10
View File
@@ -127,7 +127,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
| 5 | **WireGuard** | Accès distant chiffré | J5040 | GPL-2.0 | Système (noyau), très stable | Clés exportables |
| 6 | **Web Station** | Sites web (dd/lola.folzocorp…) | J5040 | — (contenu) | Statique | Fichiers, exportables |
| 7 | **Beszel** | Monitoring léger + alertes | J5040 | MIT | Mature, maintenu (henrygd) | Base SQLite, exportable |
| 8 | **Matrix (Synapse)** | **Messagerie** (remplace WhatsApp) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
| 8 | **Matrix (Synapse)** | **Messagerie du foyer** (complément de WhatsApp, **pas de remplacement**) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
| 9 | **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 | Mature (Jitsi Meet), maintenu | N/A (appels éphémères) |
| 10 | **\*arr** (Prowlarr + Sonarr + Radarr) — **1 stack** | Gestion **séries/films** (indexers + queue) | J5040 | GPL-3.0 | Très mature (la stack \*arr), maintenu | Bases SQLite exportables, config |
| 11 | **qBittorrent** | **Téléchargement** (torrent/HTTP) | J5040 | GPL-2.0 | Très mature, maintenu | File de tâches exportable |
@@ -189,7 +189,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
| Brique | Maturité | Ressource (machine cible) | Pourquoi CELLE-ci (vs alternatives) |
|--------|----------|---------------------------|--------------------------------------|
| **Caddy** | Caddy 2, très stable | ~50 Mo (J5040) | **TLS auto** (Let's Encrypt) sans script. Plus simple que Traefik/Nginx pour un usage familial. |
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Remplace WhatsApp** (chiffré, fédéré, OSS). Alternative : Element = le client. |
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Messagerie du foyer** (chiffré, fédéré, OSS) — **complément** de WhatsApp, **pas un remplacement**. Alternative : Element = le client. |
| **Jitsi** | Mature (Jitsi Meet) | ~1-2 Go (J5040) | **Visio** OSS, s'intègre à Matrix (Element). |
| **PhotoPrism** | Mature, AGPL | ~2-3 Go (G9) | **Lecture in-place** (ne déplace pas les originaux) + IA (visages, recherche sémantique) + partage. Plus sobre qu'Immich (qui désorganise). |
| **Jellyfin** | Très mature, AGPL | ~1-2 Go (G9) | **Vidéo + musique** OSS, transcodage, multi-appareils. Standard de fait. |
@@ -411,25 +411,53 @@ 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 |
**Décision 12/10/2026 (statut du canal)** : Matrix + Jitsi + Element = **complément du foyer** —
salon famille, salon ops, salon Effi. **WhatsApp n'est pas remplacé** et reste la messagerie
principale (famille élargie, amis, travail) : c'est un constat d'usage mesuré, pas un choix
technologique. Element s'installe d'abord sur les **2 appareils adultes** ; les 4 ados viennent en
**phase 2**, déclenchée par le **GO famille** explicite de l'utilisateur. Grille de décision :
**S14.5**.
**Les 4 interdictions** (applicables dès maintenant, vérifiables) :
1. **Interdiction** de présenter Matrix comme un remplacement de WhatsApp, et d'inciter la famille
à quitter WhatsApp pour Matrix (WhatsApp garde les échanges extérieurs).
2. **Interdiction** de faire transiter les **alertes ops** par WhatsApp : le canal d'alerte est
**Matrix uniquement** (pas d'e-mail, pas de SMS, pas de WhatsApp).
3. **Interdiction** d'**automatiser l'envoi** de messages WhatsApp (pont `mautrix-whatsapp` :
**étudié, non déployé**, numéro familial **interdit** — voir `g9-migration/BRIDGE-WHATSAPP.md`).
4. **Interdiction** d'équiper les **4 ados** d'Element (et de comptes enfants Matrix) **sans GO
famille** explicite, et sans coffre/2FA posé pour eux (C-FM1).
Ces 4 interdictions sont **contrôlables** : interdiction 1 = vocabulaire du dossier, contrôlée par
la commande suivante dont la sortie doit être **vide** (0 ligne) :
`grep -in "remplace whatsapp" DOSSIER-SOFTWARE.md g9-migration/*.md g9-migration/RUNBOOKS/*.md GUIDE-MATRIX.md | grep -vi "pas\|complément\|interdiction\|ne présente"`
— les lignes qui mentionnent WhatsApp le font **en négation** (S3.1 #8, S3.3, S8 statut, S8.2
décision + cette liste, S13.3 C.1, S14.5, changelog, `journal.md` = historique) ;
interdiction 2 = le canal d'alerte est Matrix (3
notifications types mesurées dans le salon ops) ; interdiction 3 = 0 flux n8n vers WhatsApp
(inventaire n8n) ; interdiction 4 = 0 compte ado Matrix (Synapse mesuré : 4 comptes, aucun ado).
### 8.3 Le bot hermes ↔ Matrix
> **hermes** (Effi, sur PC IA) est **connecté à Matrix** : la famille peut **parler à Effi** dans un salon Matrix. C'est l'**assistant de famille** (projet second cerveau, phase 3).
@@ -461,7 +489,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 +583,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
@@ -741,7 +771,7 @@ services:
| # | Action logicielle | Dépendance | Critère de succès |
|---|-------------------|------------|-------------------|
| C.1 | **Matrix + Jitsi + Element** (remplace WhatsApp) | Phase B | Famille messagerie + visio en local |
| C.1 | **Matrix + Jitsi + Element** (complément du foyer, **pas** un remplacement de WhatsApp) | Phase B | Famille messagerie + visio en local |
| C.2 | **Home Assistant** (enceintes Google) + **Frigate** (caméras) | Phase B | Assistant vocal + vision caméras |
| C.3 | **Bot hermes ↔ Matrix** (Effi famille) | Phase B + 2ᵉ cerveau | Effi répond dans Matrix |
| C.4 | **Enfants** : AdGuard profils + Family Link | Phase B | Enfants protégés (DNS + parental) |
@@ -827,7 +857,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 +870,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 +930,9 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
| Date | Version | Changement |
|------|---------|------------|
| 12/10/2026 | **v1.8** | **Identités mesurées + trou Synapse réparé + exposition durcie** : balayage lecture seule (`15-`/`16-identity-sweep`) → **Gitea 2FA = 0/4 et le bot `g9ops` est admin**, **Synapse admin = 0**, Nextcloud 8 comptes + providers 2FA installés, n8n 1 owner, PhotoPrism 3 comptes, **CouchDB = 401 (faux positif « sans auth » corrigé)** ; **1ᵉʳ admin Synapse créé et vérifié** (`26-synapse-admin-repair.sh` : `register` → mxid aléatoire `password_hash` NULL, **`HMAC incorrect`** sur la voie signée, puis **bcrypt en base + `docker restart` + `PUT /_synapse/admin/v2/users`** → login **OK** + API admin **OK** ; snapshot sqlite avant, compte temporaire désactivé, 0 purge ; `27-synapse-family-users.sh` outille les 6 comptes famille `admin:false`) ; **pare-feu durci sur GO** : SSH **LAN-only sur .24 et .27**, **443 LAN-only sur .24**, **plus aucune règle `Anywhere`** sur le G9, Tailscale coupé sur le pilot ; **dumps logiques** des 3 bases dans le périmètre restic + **rejouabilité Gitea prouvée** (116 tables) ; **hub Beszel hors-G9** sur J5040 (digest, ufw LAN-only, 200) ; **31 critères clôturés côté IA** avec distinction explicite « prouvé par mesure » / « geste humain » (`RESTE-HUMAIN.md`). Artéfacts : `scripts/{25,26,27}-*.sh`, §7 et §7.1 de `REGISTRE-IDENTITES.md`, `RUNBOOKS/matrix.md` réécrit. |
| 12/10/2026 | **v1.7** | **Mesures Phase 4.5 (l'existant est maintenant prouvé, pas seulement décidé)** : registre **22 conteneurs / 7 constats** et définitions compose **entrées dans le dépôt** (`matrix/`, `g9-queue/`, `valheim/`, `diun/` vivaient hors git) ; **dérive prouvée de bout en bout** (`--check --alert` → payload JSON reçu → revert → « aucune dérive ») ; **chaîne d'alerte vérifiée** : 5 payloads JSON conformes dans le salon ops + 3 notifications types avec event_ids (backup OK, backup KO provoqué, sonde KO) ; **Diun déployé** (digest `crazymax/diun@sha256:e324b793…`, notifie sans appliquer, `docker inspect` avant/après identique) ; **`prisme.folzocorp.fr` = 200** (route posée sur le J5040, Let's Encrypt DNS-01) — `photos.` sert encore PhotoPrism, web DSM `.25` = 000 ; **backup** : 5 snapshots Freebox, coffres + `gitea/db` + `nextcloud/db` + **505 836 entrées photos** dans le snapshot, **restauration sha256 identique à `.25`**, médias 15 To/5,5 To exclus par construction ; **photos** : 433 941 fichiers == 433 941, indexation 9,1 %, RAM 1,1 GiB. Artéfacts : `PHOTOS-DOUBLE-RUN.md`, `scripts/13-diun-notify.sh`, `scripts/g9-verify-alert.py --list`. |
| 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). |
+1 -1
View File
@@ -130,7 +130,7 @@ curl -s -X PUT 'http://192.168.1.24:8008/_synapse/admin/v2/users/@<PSEUDO>:192.1
| Salon | Contenu |
|-------|---------|
| **Famille** | Messagerie quotidienne (remplace WhatsApp), visio Jitsi intégrée à Element |
| **Famille** | Messagerie quotidienne du **foyer** (complément de WhatsApp, **pas un remplacement**), visio Jitsi intégrée à Element |
| **Ops** | Alertes machine : watchtower (MAJ images), restic (backup G9), n8n — via `ops-bot` |
| **hermes** (prévu) | Salon où la famille parle à Effi (assistant, Phase 3 du second cerveau) |
+101
View File
@@ -0,0 +1,101 @@
# Périmètre de backup 3-2-1 — mesuré (C-P5, 2026-10-12)
**Ce qui est prouvé**, ce qui **manque**, et ce qui est **exclu par construction**.
Tous les chiffres viennent de commandes, pas d'intention.
## 1. Ce qui tourne (mesuré)
| Élément | Valeur mesurée |
|---|---|
| Cible | `rclone:freeboxftp:Freebox/backup/g9` (repo restic **v2**, id `e52f18f4…`) |
| Snapshots | **5**, dernier `06a7e3b8` 2026-10-12 12:05, **541.393 GiB** |
| Sources | `/data` `/home/folzo` `/etc` |
| Rétention | 7 daily / 4 weekly / 12 monthly / 5 yearly (`restic-prune.sh`) |
| Anti-ransomware | rclone en **`--append-only`** sur le backup et le prune (le refus de supprimer des objets est le comportement attendu, journalisé) |
| Cron root | `30 3` backup · `4 4` prune · `15 9` health |
| Exclusions (`/etc/restic/excludes.txt`) | `*/cache/*`, `*/.cache/*`, `*/tmp/*`, `*/.npm/*`, `*/node_modules/.cache/*`, `/home/folzo/watchtower/log/*` |
## 2. Présence dans le snapshot `latest` (607 284 entrées)
`restic ls latest` — compté, pas supposé :
| Brique | Entrées | |
|---|---|---|
| photoprism originals | **505 836** | photos |
| nextcloud | 88 017 | coffre applicatif |
| `gitea/db` (Postgres) | **2 162** | base **incluse** |
| gitea | 5 599 | dépôt git |
| `nextcloud/db` (MariaDB) | **844** | base **incluse** |
| vaultwarden | 630 | **le coffre** |
| `/home/folzo` | 5 054 | n8n, synapse, couchdb, valheim, obsidian (tous en bind `/home/folzo/*`) |
| edge / Caddy | 52 | config + certs |
| jellyfin | 50 | config (médias exclus, voir §4) |
Les bases sont incluses parce que les conteneurs `nextcloud_db`, `gitea-db`,
`photoprism_db` sont en **bind mount** sous `/data/docker/<brique>/db` (mesuré par
`docker inspect`) — pas en volumes nommés `docker/volumes`, qui seraient passés hors périmètre.
## 3. Restauration prouvée
`scripts/10-verify-restore-photo.sh` (rc=0) : snapshot le plus récent → 505 836 entrées
`/data/docker/photoprism/originals` → restauration de
`Photos_scannees 2007-10-14/selection/Photo20071013181010171.jpg` → **sha256 identique à la
source `.25`** (`00cbfed8da815c0c4517554e5353d3503c70f8dd3f9f617a4440aa8f159d0a6f`).
C'est la seule restauration testée à ce jour. Elle prouve la chaîne complète : repo chiffré,
clé, rclone, contenu, intégrité.
## 4. Exclusions
**Médias 15 To + 5,5 To : exclus par construction**, pas par filtre. Le G9 ne porte que
**564 Go** (`/data/docker` 564 G + `/data/famille` 472 K) ; les médias vivent sur le NAS `.25`
et sur les disques USB de la Phase 6 (`/mnt/usb-media`, hors des sources restic). Rien n'est
donc sauvegardé en double, et rien de volumineux n'est oublié.
**À assumer explicitement** (à ce jour non chiffré, non sauvegardé, non testé) : les médias
eux-mêmes. La Phase 6 les déplace sur USB ; ils ne rentrent dans aucune des 3 copies tant que
la 2ᵉ/3ᵉ cible n'existe pas.
## 5. Ce qui manque pour C-P5 (le critère demande 3 copies)
| Copie | État |
|---|---|
| 1 — G9 → Freebox (quotidien) | **en place et mesuré** |
| 2 — PC IA (quotidien) | **absente** (aucune 2ᵉ destination configurée) |
| 3 — 218play (hebdo) | **absente** — décision utilisateur 12/10 : « pas tout de suite » |
`restic snapshots` sur les 3 cibles = **1 sur 3**. Le critère reste donc **non satisfait**, et
c'est le seul point dur : il faut les identifiants de la 2ᵉ cible (SMB/rclone) pour la poser.
## 6. Cohérence applicative — **fait et mesuré le 12/10 18:30** (`scripts/18-db-dumps.sh`)
La copie se fait **conteneurs tournants** pour Nextcloud/MariaDB et Gitea/Postgres : le script
ne stoppe que `valheim n8n synapse couchdb obsidian` (`RESTIC_STATEFUL`), pas les bases.
Une copie chaude de `datadir` Postgres/MariaDB n'est **pas garantie cohérente** — la copie est
complète en octets, pas nécessairement rejouable en l'état. **D'où les dumps logiques.**
Ce qui a été produit (0 brique stoppée, `--single-transaction` = instantané InnoDB cohérent) :
| Base | Commande réelle (mesurée) | Résultat |
|---|---|---|
| **Gitea** (Postgres) | `sudo bash -c "docker exec -u postgres gitea-db pg_dump -U gitea_folzo gitea_db \| gzip > /data/docker/gitea/dumps/gitea-<ts>.sql.gz"` | **80 543 o** gz, **116 `CREATE TABLE`** |
| **Nextcloud** (MariaDB) | `sudo bash -c "docker exec nextcloud_db sh -c 'mysqldump -u root -p\"\$MARIADB_ROOT_PASSWORD\" --single-transaction --routines nextcloud' \| gzip > /data/docker/nextcloud/dumps/nextcloud-<ts>.sql.gz"` | **5,9 Mo** gz, **317 objets**, finit par `-- Dump completed` |
| **PhotoPrism** (MariaDB) | idem sur `photoprism` | **89,6 Mo** gz, **39 objets**, `-- Dump completed` |
Pièges mesurés, intégrés au script :
- l'utilisateur OS `gitea_folzo` **n'existe pas** dans le conteneur → `pg_hba.conf` =
`local all all trust` → **`-u postgres`**, donc **aucun mot de passe sur une ligne de commande** ;
- le `>` de redirection doit être **dans** `sudo bash -c "…"` (sinon le pipe s'exécute côté shell
non-root et le fichier n'est pas écrit) ;
- le mot de passe MariaDB est lu **dans l'environnement du conteneur**, jamais affiché.
**Rejouabilité prouvée pour Gitea** : base de test `gtest_restore` créée, dump chargé,
**116 tables restaurées** (== 116 du dump), base **supprimée** après (`pg_database` vérifié :
`gitea_db`, `postgres`, `template0`, `template1`).
**Non fait, à décider** : la replay complète des dumps MariaDB. Le dump Nextcloud contient
`USE \`nextcloud\`` → le charger tel quel **écraserait la production** ; il faut un serveur jeton
(conteneur MariaDB temporaire, GO séparé).
Les dumps tombent sous `/data/docker/<brique>/dumps/` → **déjà dans le périmètre** de backup
(visibles au snapshot suivant, 03:30). Rollback = supprimer les dumps, aucune brique touchée.
+92
View File
@@ -0,0 +1,92 @@
# 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 par **deux voies cumulées** : (a) **signalements par les utilisateurs** en face (un correspondant peut signaler le compte comme « spam/client non autorisé »), (b) **détection automatisée côté serveur** (comportement du client multi-device, empreinte du protocole `whatsmeow`, cadence d'envoi, absence d'app officielle). **Aucun seuil public, aucune liste publiée, aucun moyen de vérifier son exposition avant la sanction.** Échelle observée et documentée par la communauté : **ban temporaire** (de quelques heures à ~24 h, avec compte à rebours dans l'app) → **ban permanent** à la récidive → **suppression du compte** = **perte de l'historique WhatsApp du numéro** (non récupérable, pas de backup côté WhatsApp). Probabilité : **non chiffrable** (paramètre serveur opaque) ; **coût si ça tombe** : **élevé et non réparable** (numéro familial = famille, amis, travail). | ton numéro familial est « indispensable » → **interdiction** : jamais le numéro principal sur un pont ; le pilote ne se fait que sur un **numéro secondaire jetable** |
| 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. Les 17 questions de Go/No-Go (à répondre **avant** toute installation)
Règle de décision : **17/17 = GO pilote**. Une seule réponse « non » = **NO-GO**, et le document
reste en étude. Chaque « oui » exige la **preuve** indiquée (pas d'intention, une preuve).
| # | Question | Réponse pour GO | Preuve exigée |
|---|---|---|---|
| 1 | Le numéro relié est-il **secondaire et jetable** ? | oui, jamais le familial | numéro + support (eSIM/SIM dédiée) notés dans `journal.md` |
| 2 | Ce numéro est-il **vide** (aucun contact famille, aucun historique) ? | oui | capture/comptage : 0 conversation avant le link |
| 3 | Le **bannissement définitif** de ce numéro est-il accepté à l'avance ? | oui, écrit | ligne de décision signée dans `journal.md` |
| 4 | Ai-je un **moyen de prévenir** les correspondants si le compte disparaît ? | oui | canal de secours nommé (Matrix/ops) |
| 5 | Le pont lit-il **uniquement mes** conversations ? | oui | `auth_users`/config du pont, 0 autre compte |
| 6 | Les messages copiés sont-ils **chiffrés au repos** (ou en périmètre chiffré) ? | oui | `restic` (chiffrement) + permissions 600 sur `/data/docker/whatsapp/` |
| 7 | La **DB du pont** est-elle dans les snapshots restic ? | oui | comptage d'entrées dans le snapshot latest |
| 8 | La **session** (clés de liaison) est-elle backupée **et restaurée testée** ? | oui | restauration en test → re-link **non** nécessaire |
| 9 | L'image est-elle **épinglée par digest** ? | oui | `docker inspect` → `ImageID`, pas un tag `:latest` |
| 10 | Le pont est-il **exclu de Watchtower** ? | oui | label `watchtower.enable=false` + Watchtower `scanned` ne l'inclut pas |
| 11 | Le pont est-il **hors WAN** ? | oui | `ufw status numbered` : 0 règle pour son port ; 0 entrée Caddy |
| 12 | Y a-t-il **0 automatisation d'envoi** (n8n, IA, scripts) ? | oui | inventaire n8n : 0 flux WhatsApp |
| 13 | Les **alertes ops** restent-elles sur Matrix ? | oui | 3 notifications types dans le salon ops, 0 sur WhatsApp |
| 14 | La **montée de version du protocole** WhatsApp est-elle gérée ? | oui | procédure de re-link écrite dans `RUNBOOKS/` |
| 15 | Le **re-link** est-il testé et chronométré ? | oui | test déconnexion volontaire → QR → reprise, **< 5 min** |
| 16 | Le **coût de maintenance** constaté est-il ≤ 10 min/semaine ? | oui | 7 jours journalisés, temps passé relevé |
| 17 | Le **verdict écrit** existe-t-il (GO/NO-GO, raison, coût) ? | oui | ligne dans `journal.md` + grille `DOSSIER-SOFTWARE.md` S14.5 |
**Ce que ces 17 questions ne couvrent pas** : la probabilité de bannissement reste **non chiffrable**
(§3). Le pilote ne la réduit pas, il la **déplace** sur un numéro qui n'a pas de valeur.
## 7. 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.
+120
View File
@@ -0,0 +1,120 @@
# ÉTUDE EXPOSITION INTERNET (T-D — étude, puis durcissement **autorisé**)
> Étude posée le 12/10 **sans aucune règle modifiée** (G6 respecté tant que les décisions
> n'étaient pas rendues). Les décisions sont rendues le 12/10 et le §6 consigne **les gestes
> exécutés après GO explicite de l'utilisateur**, avec avant/après mesurés et rollback.
> Faits mesurés 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)** | `22/tcp` (**OpenSSH, Anywhere**), `80/tcp`, `443/tcp`, `51820/udp` (**Anywhere**) | le **vrai** edge : Caddy + Let's Encrypt (7/7 certs DNS-01) + WireGuard. 80 = redirect ACME. `22` ouvert au WAN **aussi** (mesuré 12/10, règle [1]) |
| **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 rendues le 2026-10-12 (et qui les pose)
Complément mesuré à la demande : `ufw status numbered` complet sur `.24` (25 règles) et `.27`
(12 règles). Le pilot et le G9 sont **derrière le même NAT** : l'IP publique `82.65.198.114` est
unique, donc ce qui est joignable depuis l'extérieur dépend des **redirects de la Freebox**, pas
de ufw seul. ufw ne fait qu'ouvrir la porte une fois le paquet arrivé.
| # | Décision | Réponse | Ce que je recommande | Qui pose le geste |
|---|---|---|---|---|
| 1 | `22/tcp` ouvert au WAN | « si je ferme, plus de SSH pour moi → garder pour le réseau local » | **Remplacer** la règle `OpenSSH Anywhere` par `22/tcp from 192.168.1.0/24` sur **.24 et .27** (le SSH reste depuis la maison, l'attaque depuis le WAN tombe). Ordre sûr : insérer la règle LAN **avant** de supprimer la règle WAN → aucune déconnexion possible. `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 22` puis `sudo ufw delete <n° de OpenSSH Anywhere>` (et les 2 règles v6) | **toi** (G6 : modification réseau) |
| 2 | `443/tcp` ouvert au WAN sur le G9 (edge TLS auto-signé) | « je ne sais pas » | **Remplacer, pas supprimer** : il n'existe **aucune** règle LAN pour 443 sur le G9, donc supprimer la règle `[22] Anywhere` couperait aussi `https://git./cloud./media.…` **depuis la maison**. Geste : `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 443` puis `sudo ufw delete 22` (et la v6). Le seul edge public reste alors le J5040 (Let's Encrypt) | **toi** |
| 3 | Tailscale | « je ne pensais pas l'utiliser, est-ce utile ? » | **Non, inutile au projet** : il n'est actif que sur la **machine de pilotage** (`.24` = `inactive`, aucun nœud Tailscale sur le G9 ni le J5040). C'est une **voie d'entrée distante non documentée** dans S9, avec un agent qui a accès au LAN. L'entrée distante prévue du plan = **WireGuard sur le J5040**. Donc : couper sur le pilot → `sudo systemctl disable --now tailscaled` (rollback : `sudo systemctl enable --now tailscaled`) | **toi** (c'est la machine de pilotage, pas le G9) |
| 4 | CouchDB `5984` | « fais ce que tu conseilles » | **Constat initial faux, corrigé par mesure** : la sonde voyait `GET :5984/` = 200 et l'a lu « sans auth ». Mesure directe : `GET /_all_databases` = **401**, `GET /_active_tasks` = **401** → **l'auth est bien active** (`COUCHDB_USER` + `COUCHDB_PASSWORD` dans `compose/g9-queue/docker-compose.yml`). `/` est public par conception dans CouchDB : c'est un faux positif de la sonde. **Recommandation révisée : ne pas recréer le conteneur.** Passer le port en `127.0.0.1:5984` n'apporte presque rien (ufw est déjà LAN-only, l'auth est en place) et **casse probablement la sync Obsidian** si le client pointe sur `http://192.168.1.24:5984`. Gain marginal / risque réel = mauvais échange. À faire plus tard **seulement** si tu confirmes que le client Obsidian n'utilise pas le port de l'hôte. | **personne** (statu quo mesuré) |
| 5 | Périmètre « joignable sans le vouloir » | « idem, au mieux » | **Validé tel quel**, avec l'ajout mesuré : `OpenSSH Anywhere` est présent sur **.24 et .27** (pas seulement .24), et l'inventaire des **redirects Freebox** reste à faire côté Freebox par toi — c'est le seul trou de visibilité qui subsiste | **toi** (lecture de l'interface Freebox) |
**Aucune règle n'est posée par moi** (G6). Les lignes 1, 2, 3, 5 sont des commandes que **tu**
exécutes ; la ligne 4 est la seule que j'exécute, et seulement sur GO explicite.
## 6. Actions posées le 2026-10-12 (GO explicite de l'utilisateur)
`scripts/14-firewall-lan-only.sh` (insertion LAN **avant** suppression WAN, suppressions du
numéro le plus haut au plus bas, `--dry` exécuté avant l'exécution réelle).
| # | Geste | Avant (mesuré) | Après (mesuré) |
|---|---|---|---|
| 1 | SSH LAN-only sur **.24** | `[1] OpenSSH ALLOW IN Anywhere` + `[24] OpenSSH (v6) Anywhere` | `[2] 22/tcp ALLOW IN 192.168.1.0/24` — **plus aucune règle `Anywhere`** sur le G9 (23 règles) |
| 1 | SSH LAN-only sur **.27** | `[1] OpenSSH Anywhere` + `[10] OpenSSH (v6)` | `[1] 22/tcp ALLOW IN 192.168.1.0/24` — les règles WAN de l'edge (`80`, `443`, `51820` v4+v6) **conservées** |
| 2 | 443 LAN-only sur **.24** | `[22] 443/tcp Anywhere # edge T4.6` + v6 | `[1] 443/tcp ALLOW IN 192.168.1.0/24` ; le seul edge public restant = **J5040** (Let's Encrypt) |
| 3 | Tailscale sur le pilot | `tailscaled` actif, `tailscale0` UP, 6+ pairs (`ai-computer`, macbook, vps, windows, chronos) | `inactive`, service désactivé au boot. Vérifié **après** : SSH `.24` OK, SSH `.27` OK, `git ls-remote gitea` OK |
| 4 | CouchDB | — | **statu quo** (voir §5, ligne 4 : auth mesurée = 401, faux positif de sonde) |
**Vérifications après bascule** : `ssh .24` = OK, `ssh .27` = OK, `https://git.folzocorp.fr/api/v1/version`
(LAN, via l'edge G9) = **200**, `https://cloud.folzocorp.fr/` (WAN, via J5040) = **200**,
`https://prisme.folzocorp.fr/` = **200**.
**Rollback** : snapshots `ufw status numbered` conservés avant les changements (25 règles .24,
17 lignes .27) ; rétablir une règle WAN = `sudo ufw insert 1 allow proto tcp from any to any port 22`
(idem 443) ; Tailscale = `sudo systemctl enable --now tailscaled`. Journal 18:10.
**Ce que ça ne règle pas** : ce qui est joignable depuis l'extérieur reste gouverné par les
**redirects de la Freebox** (IP publique unique `82.65.198.114`, G9 et pilot derrière le même
NAT). L'inventaire des redirects se fait côté Freebox, par toi — c'est le seul trou de
visibilité qui subsiste.
## 7. Choix de machine pour l'IdP (Authelia) — **décision rendue le 12/10, installation reportée**
| Point retenu | **J5040 (.27)** | Écart avec le G9 (.24) |
|---|---|---|
| Exposition | c'est **déjà** le seul edge public (`80/443/51820`), donc Authelia hérite d'une exposition **existante et documentée** | le G9 n'a **plus aucune règle `Anywhere`** depuis le durcissement 18:10 → l'y exposer demanderait de **rouvrir** un trou refermé |
| Survie | le hub de monitoring est **déjà** posé dessus (C-AP5) : l'IdP ne meurt pas avec le G9 | l'IdP sur le G9 tombe avec lui (même défaut que les sondes) |
| Coût | 1 conteneur de plus sur une machine à 3 conteneurs, base SQLite dans `/home/folzo/` (déjà sauvegardée sur .27 ? **non** → à ajouter au périmètre, consigné) | base sur la machine qui porte déjà les 3 BDD les plus lourdes |
| Risque | l'IdP devient **aussi exposé que Caddy** : surface = 1 hôte, 1 port (443), 0 exposition directe du port 9000 (reverse proxy Caddy, LAN-only en amont) | exposition directe depuis LAN uniquement, mais single point of failure du foyer |
| Décision | **J5040**, en OIDC **opt-in** brique par brique (Synapse → Gitea → Jellyfin), login local **conservé** partout (C-IDP2) | — |
**Déclencheur d'installation (écrit, pas implicite)** : après **Phase 5** (copie 3-2-1 sur 3 cibles)
et **après** la mise en 2FA des comptes admin existants (C-MX3, C-ID7). Ni avant, ni à froid.
Rollback = retirer le bloc OIDC du Caddyfile du J5040 + retirer l'OIDC de la brique (login local
déjà en place, donc 0 dépendance dure).
+90
View File
@@ -0,0 +1,90 @@
# 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 | Lieu (voir §2.1) | Code de déverrouillage | Date de remise | Test annuel |
|---|---|---|---|---|---|---|
| Moi | — | enveloppe n°1 | **sur place** (coffre du domicile) | le mien | — | _à date_ |
| Détenteur 1 | _à nommer_ | enveloppe n°2 | **hors site** (chez lui) | **le sien, propre, jamais communiqué** | _à date_ | _à date_ |
| Détenteur 2 (optionnel) | _à nommer_ | enveloppe n°3 | **hors ligne** (scellé, jamais connecté) | **le sien, propre, jamais communiqué** | _à 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. Jamais de photo,
jamais de fichier non chiffré sur un disque.
### 2.1 Placement des supports (3 exemplaires, 2 maximum pour des tiers)
| Exemplaire | Lieu | Détenteur | Rôle |
|---|---|---|---|
| n°1 — **sur place** | coffre physique du domicile (scellé, daté) | moi | usage normal, jamais sorti sans journal |
| n°2 — **hors site** | chez le détenteur 1 (scellé, daté) | détenteur 1 | usage si je suis injoignable |
| n°3 — **hors ligne** | chez le détenteur 2 **ou** coffret scellé hors réseau | détenteur 2 (optionnel) | secours si n°1 et n°2 sont perdus ; **jamais connecté, jamais numérisé** |
**Aucun 4ᵉ exemplaire** : 2 personnes max détiennent un support (détenteurs 1 et 2).
### 2.2 Garde-fous (les 4, applicables dès la remise)
1. **2 personnes maximum** détiennent un support ; la famille immédiate n'en détient pas.
2. La **personne de confiance** (celle qui accompagne l'ouverture) est **lectrice seule** : elle
assiste, date, contresigne ; **elle ne recopie, ne photographie et ne conserve rien**.
3. **1 code de déverrouillage par personne** : l'enveloppe est fermée par un **code propre à son
détenteur** (code de son choix, jamais transmis, jamais écrit sur l'enveloppe). En cas de perte
du détenteur, son code devient nul et l'enveloppe est remplacée (§5).
4. **Jamais par mail, jamais par cloud, jamais par messagerie** : la remise est **physique**, en
main propre, signée. Aucun scan, aucun envoi, aucune photo, aucun stockage hébergé.
## 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, sur un **coffre d'entraînement** (vault de test, jamais
le coffre famille) : **6 points à vérifier**, chacun noté OK/KO.
| # | Point vérifié | Preuve |
|---|---|---|
| 1 | L'enveloppe est **intacte, scellée, datée** et le **code propre au détenteur** l'ouvre | photo du sceau intact + date portée au journal |
| 2 | Le **mot de passe maître** déverrouille le coffre d'entraînement | login réussi sur le vault de test |
| 3 | L'**`ADMIN_TOKEN`** ouvre la console `/admin` du coffre d'entraînement | console accessible, 2FA du compte de test désactivable |
| 4 | La **clé de chiffrement restic** lit un snapshot de test | `restic snapshots` / `restic cat` en **lecture seule** sur un repo de test |
| 5 | La **phrase de récupération E2E** déverrouille une session Matrix de test | salon de test lisible depuis un appareil vierge |
| 6 | La **liste des 6 personnes et des briques** est à jour | comparaison avec `REGISTRE-IDENTITES.md` §2/§3, 0 écart non consigné |
- 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 | 6 points (x/6) | remplacement enveloppe |` ;
- si un point est KO : l'enveloppe est **remplacée** et la rotation est envisagée (§5).
## 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) |
+10 -1
View File
@@ -18,7 +18,7 @@
| Jellyfin (T4.5) | `jellyfin/jellyfin:10.9` (10.9.11) | healthy, média par RÉFÉRENCE (`/mnt/usb-media` → `/media:ro`, aucune copie), 1 médiathèque = `/media/films`, Items Movie = 1 | false |
| edge (T4.6) | `caddy:2` (digest `f2a1290d…`) | healthy, HTTPS interne (CA interne), 443 seul port exposé WAN | **true** (stateless) |
| Watchtower (T4.7) | `nickfedor/watchtower@sha256:45b54f6d…` | healthy, scope label-enable : dry-run `scanned=2` (edge + watchtower), 14 stateful ignorés | true |
| PhotoPrism (T4.3) | — | **non déployée** : `07d-migrate-photoprism.sh` écrit/testé, exécution reportée (coût = copie 530 Go ; média servi par Jellyfin) | — |
| PhotoPrism (T4.3) | `photoprism/photoprism:260919` + `mariadb:10.11` | **✅ 12/10** : 433 941 fichiers / 567 Go copiés depuis `/volume1/photo` (manifeste chemins+tailles 0 écart, sha256 200/200), stack healthy port 2342 (LAN) + `photos.folzocorp.fr` via edge J5040 ; index progressif (AUTO_INDEX 300 s, chaîne index→faces→analyze) ; snapshot restic + restore photo prouvée (`10-verify-restore-photo.sh`) | false |
### 0.2 Incidents rencontrés et correctifs (détail = journal.md)
@@ -41,6 +41,15 @@
- `nextcloud_app`, `nextcloud_db`, `nextcloud_redis`, `vaultwarden-server-1`, `gitea` **relancés et up** après les exports ; dumps/transitaires supprimés sur .25, pilot et G9.
- Aucun partage, aucune donnée, aucun conteneur .25 supprimé ; transports .25→pilot = `ssh 'sudo tar cz'`/`mysqldump` en lecture seule (sshd DSM refuse `rsync --server` et scp).
### 0.5 J5040 (.27) — E1→E4 (11–12/10)
| Étape | État | Preuve |
|---|---|---|
| E1 durcissement | ✅ | SSH clés-only, ufw (LAN 53/3000/8081 + WAN 80/443/51820), fail2ban, unattended-upgrades, Docker 29.1.3 ; RAM 8 Go sous test (upgrade 16 Go si > 70 %) |
| E2 AdGuard | ✅ bascule faite | `adguard/adguardhome` (digest `aba9e3bf…`) sur .27, UI 8081, DNS 53 ; config VM .1 migrée (2 filtres 182 128 + 6 540 règles, safe_search, profils parents, users bcrypt) ; resolvants clients → .27 ; filtrage testé ; VM .1 conservée secours |
| E3 DHCP | préparé, **GO utilisateur (G6)** | baux statiques Freebox repris (35 appareils, OCR 38) dans `/srv/dnsmasq/leases`, `dnsmasq --test` OK, script `09-j5040-dhcp.sh` prêt ; bascule = Freebox Pop + dnsmasq |
| E4 edge + WireGuard | certs ✅, **GO utilisateur pour WAN** | Caddy OVH DNS-01 (plugin `maxcryger/caddy-ovh` digest `502996b5…`) : **7/7 certs LE** + proxy 6 briques (photos 307 depuis 12/10) ; WireGuard `wg0.conf` + client prêts (600, gitignorés) ; forward WAN Freebox→.27 et démontage edge G9 = après GO |
---
# Rapport de récupération (thread supprimé)
+125
View File
@@ -0,0 +1,125 @@
# Double-run photos — PhotoPrism ↔ Synology Photos (mesures du 2026-10-12)
Objectif de la tâche `PHOTO-DOUBLE-RUN` : PhotoPrism rend le service pendant que Synology
Photos reste en place, **sans trou**, et la montée en compétence se fait d'abord sur un seul
téléphone (le mien) avant le GO famille.
Décision de nommage (validée) : `prisme.folzocorp.fr` = **PhotoPrism** ;
`photos.folzocorp.fr` = **Synology Photos** (temporaire, meurt en Phase 6).
## 1. Copie : mesurée, identique en nombre de fichiers
| Côté | Fichiers | Octets (apparents) |
|---|---|---|
| Source `.25` `/volume1/photo` (lecture seule, G4) | **433 941** | 567 416 508 104 |
| G9 `.24` `/data/docker/photoprism/originals` | **433 941** | 567 394 253 738 |
| Snapshot restic `06a7e3b8` (2026-10-12 12:05) | **505 836 entrées** sous `originals` | 541.393 GiB (repo, tout périmètre) |
Écart d'octets = **22 254 366** (0,0039 %) : les vignettes `@eaDir` recalculées de part et
d'autre. Le nombre de fichiers est **identique** : c'est le critère qui compte, il est atteint.
Emplacement réel de la copie : `/data/docker/photoprism/originals` (monté dans le conteneur),
**pas** `/data/photos`. Le périmètre restic est `/data /home/folzo /etc` → la copie est bien
dans les snapshots.
## 2. Restauration : prouvée par sha256
`scripts/10-verify-restore-photo.sh` (rc=0) : snapshot le plus récent → restauration de
`Photos_scannees 2007-10-14/selection/Photo20071013181010171.jpg` →
**sha256 identique à la source `.25`** (`00cbfed8…159d0a6f`).
## 3. Indexation : en cours, ~9 %
Compté dans la base `photoprism` (MariaDB, lecture seule) :
| photos | files indexés | albums | faces | utilisateurs | erreurs | dossiers |
|---|---|---|---|---|---|---|
| 36 254 | **39 294** / 433 941 fichiers | 681 | 37 | 3 | 37 | 392 |
L'indexation est donc **engagée, pas terminée** (9,1 % des fichiers). RAM mesurée :
`photoprism` **1,104 GiB**, `photoprism_db` 183,5 MiB → l'enveloppe ~2-3 Go est respectée.
Les 37 `errors` sont à regarder avant le GO famille (aucune action prise).
## 4. Endpoints : mesurés, un écart à corriger
| Nom | Résolution | Route Caddy | Mesure |
|---|---|---|---|
| `prisme.folzocorp.fr` | OK → `82.65.198.114` | **posée le 12/10 17:15** : `reverse_proxy 192.168.1.24:2342` (bloc ajouté au-dessus de `photos.`, sauvegarde `Caddyfile.bak-20261012-1643`, `caddy validate` + `reload`) | **200** (Let's Encrypt ACME DNS-01, `curl` **sans** `-k`) |
| `photos.folzocorp.fr` | OK → `82.65.198.114` | `reverse_proxy 192.168.1.24:2342` = **PhotoPrism** | 307 (→ `/login`) |
| Synology Photos `.25` | — | — | **5000 et 5001 = 000** (445 et 222 ouverts, ping OK) |
**Écart restant** (mesuré 12/10 17:15) : `photos.folzocorp.fr` pointe toujours sur **PhotoPrism**
au lieu du NAS — le repointage vers `.25` n'a **pas été autorisé** à ce stade, et il n'est de
toute façon **pas mesurable** puisque le web du DSM (5000/5001) ne répond pas. Le double-run
« les deux accessibles simultanément » reste donc **prouvable à 1 nom sur 2** : `prisme.` ✓,
`photos.` ✗.
## 5. Ce qui est fait, ce qui reste
**Fait, mesuré** : copie (noms de fichiers identiques), restauration sha256, RAM, indexation
engagée, `n8n /healthz` = 200, endpoints mesurés, nommage décidé, **`prisme.` = 200**, grille des
10 requêtes (§6) et procédure d'auto-upload (§7) **écrites et prêtes**.
**Reste** :
- rendre le web du DSM joignable (ou actater que `photos.` meurt en Phase 6 sans mesure) ;
- finir l'indexation et traiter les 37 erreurs ;
- **exécution** de la grille §6 et de la procédure §7 sur **mon** téléphone (usage réel, 7 jours) ;
- GO famille = validation explicite (comptes enfants et auto-upload ados **après**).
## 6. Grille des 10 requêtes réelles (Synology Photos vs PhotoPrism)
Protocole : la même requête est exécutée **l'une après l'autre** sur les deux services, depuis le
même téléphone, en Wi-Fi, sur le **même lot de photos**. Chronométrer du premier geste au résultat
affiché. Noter **1 gagnant par ligne**. Le classement final = nombre de victoires, plus la note
subjective « est-ce que j'ai compris sans aide ». Les cases **Gagnant / Temps / Compris sans aide**
sont à remplir par l'usage réel (personne qui remplit = moi, sur mon téléphone uniquement).
| # | Requête réelle, telle que je la formule | Chemin Synology Photos | Chemin PhotoPrism | Gagnant | Temps (s) | Compris sans aide |
|---|---|---|---|---|---|---|
| 1 | « les photos du séjour 2019 » | Chronologie → filtre année/lieu | Recherche texte libre `2019` + filtre date | | | |
| 2 | « les photos de carla il y a plus de 10 ans » | Albums → visage non disponible | Filtre **Faces** + plage de dates | | | |
| 3 | « la facture d'électricité scannée » | Recherche texte (DS 7.x, selon OCR indexé) | Recherche plein texte sur l'OCR (`label`/`caption`) | | | |
| 4 | « ce que j'ai pris depuis mon téléphone ce mois-ci » | Dossier / appareil | Filtre **camera / appareil** + date | | | |
| 5 | « les doublons de cette photo » | Doublons intégrés (Synology Photos) | `photoprism` : recherche par **similaire** depuis une photo | | | |
| 6 | « envoyer 12 photos à Audrey sans zip » | Partage Synology (lien/album) | Album partagé + **partage par lien** | | | |
| 7 | « créer un album vacances et le partager à la famille » | Album partagé DSM | Album + partage vers un compte famille | | | |
| 8 | « partager un lien vers une photo, limité dans le temps » | Lien de partage (expirable) | Lien de partage (vérifier expiration et ce qui fuit) | | | |
| 9 | « l'EXIF et l'appareil de cette photo » | Panneau d'infos | Panneau d'infos (mesuré : faces 37, dossiers 392 indexés) | | | |
| 10 | « l'original complet, pas la version web » | Téléchargement original | Téléchargement original (`/originals/…`) | | | |
**Classement attendu** : score par brique (x/10) + 3 lignes de conclusion (ce que PhotoPrism fait
mieux, ce que Synology Photos fait mieux, ce qui me manque des deux). La grille remplit le critère
C-PH2 « 10 requêtes réelles comparées et classées » **à la condition d'être remplie à la main**,
pas d'exister.
## 7. Auto-upload — procédure (mon téléphone, 7 jours sans intervention)
Prérequis mesurés : `https://prisme.folzocorp.fr/` = **200** (certificat valide, pas d'auto-signé),
compte PhotoPrism existant (`admin` mesuré en base `auth_users`), indexation en cours (9,1 %).
1. **App** : client d'upload **officiel PhotoPrism** (Android « PhotoPrism Companion », iOS
l'équivalent App Store). **Pas** de script d'upload sur le téléphone (G11 : pas de code non
documenté qui écrit sur une brique).
2. **Serveur** : `https://prisme.folzocorp.fr` (jamais l'IP, jamais http).
3. **Compte** : mon compte PhotoPrism (pas l'admin si un compte dédié existe ; sinon admin, et le
mot de passe est **changé** après coup, consigné `famille/effi/photoprism`).
4. **Périmètre d'upload** : `DCIM` + `Screenshots` **uniquement**, en **Wi-Fi seulement**
(données mobiles : upload désactivé), **sans** suppression des photos locales après upload.
5. **Anti-effacement** : l'app est réglée en **copie**, jamais en « déplacer ». Vérifier explicitement
l'option de suppression après upload et la laisser **désactivée**.
6. **Préserver les EXIF** : pas de « compression », pas de « qualité 75 % », pas de retrait de
localisation. Contrôle : sur 3 photos uploadées, lire l'appareil + la date + la position dans le
panneau d'infos (requête 9 de la grille §6).
7. **Moyens de contrôle** (à faire 1 fois/jour, 10 secondes) : dans PhotoPrism, `Nouveautés` du jour
> 0 ; dans la base, `select count(*) from files` qui **monte** ; 0 erreur nouvelle dans `errors`.
8. **Seuil de réussite des 7 jours** : 7 lignes journalisées, **0 intervention manuelle** (pas de
relance d'upload, pas de re-login), **0 photo perdue** (comptage local avant/après, `ls | wc -l`),
et les EXIF conservés sur les 3 photos de contrôle.
9. **Format de journalisation** (une ligne par jour dans `journal.md`) :
`| date | auto-upload photoprism (C-PH2) | photos uploadées: N | interventions: 0 | fichiers locaux: X (identique) | EXIF: OK |`
10. **Rollback** : désactiver l'upload dans l'app (1 geste), puis, si besoin, supprimer les photos
uploadées du lot test via l'UI (jamais par `rm` sur `/data/docker/photoprism/originals`, qui
casserait l'index).
**Ce que l'IA ne fait pas ici** : installer une app sur un téléphone, saisir un mot de passe,
valider un partage, déclarer une photo « bien uploadée ». Ces 4 gestes sont humains (G11).
+79 -5
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
@@ -61,7 +62,18 @@ g9-migration/
│ ├── 07d-migrate-photoprism.sh ← T4.3 : PhotoPrism (copie 530 Go + indexation)
│ ├── 07e-deploy-edge.sh ← T4.6 : edge Caddy (validate → up → ufw 443 → vérifs HTTPS/SNI)
│ ├── restic-notify.sh ← alerte Matrix des jobs restic (posée par 06)
│ └── 08-decommission.sh ← T6.4 : décommissionnement xpenology [DESTRUCTIF]
│ ├── 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)
│ ├── 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)
│ ├── 13-diun-notify.sh ← C-AP4 : notifie les nouvelles versions repérées par Diun (ne les applique pas)
│ ├── 14-firewall-lan-only.sh ← C-EX1 : SSH/443 restrictés au LAN (LAN inséré AVANT suppression WAN, --dry dispo)
│ └── 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
@@ -69,13 +81,74 @@ g9-migration/
│ ├── gitea/ ← T4.4
│ ├── n8n/ ← T4.5
│ ├── jellyfin/ ← T4.5 (média par référence, /media:ro)
│ ├── edge/ ← T4.6 : Caddy (Caddyfile versionné = config)
│ └── watchtower/ ← S11.4 : Watchtower sélectif
│ ├── edge/ ← T4.6 : Caddy G9 (CA interne, edge transitoire)
│ ├── edge-j5040/ ← E4 : Caddy J5040 + plugin OVH (LE DNS-01, Caddyfile versionné)
│ ├── adguard/ ← E2 : AdGuard Home (DNS filtré, image épinglée digest)
│ ├── watchtower/ ← S11.4 : Watchtower sélectif
│ ├── matrix/ ← T4.7 : Synapse + Element (importés du G9, 12/10 — étaient hors git)
│ ├── g9-queue/ ← n8n + Watchtower + CouchDB + Obsidian (importés du G9, 12/10)
│ ├── valheim/ ← monde Valheim (importé du G9, 12/10)
│ └── diun/ ← C-AP4 : Diun (notifie les versions, n'applique rien)
└── configs/
├── fail2ban.local.conf ← config fail2ban (T2.1)
└── 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 |
| `PHOTOS-DOUBLE-RUN.md` | Double-run PhotoPrism ↔ Synology Photos **mesuré** : totaux source/G9, indexation, RAM, endpoints, 2 écarts de nommage | mesures 12/10 |
| `BACKUP-PERIMETRE.md` | Périmètre 3-2-1 **mesuré** : ce qui est dans les snapshots (chiffré), ce qui est exclu par construction, ce qui manque, dumps logiques recommandés | mesures 12/10 |
| `RESTE-HUMAIN.md` | Les 17 critères restants, **par ordre**, avec le geste exact, qui le fait, et la vérification qui clôt chacun | checklist |
| `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/g9-verify-alert.py --list 25 # event_ids + texte des derniers messages (lecture seule)
sudo ./scripts/13-diun-notify.sh # Diun -> payload JSON si nouvelle version (cron 06:20)
sudo ./scripts/12-probes.sh --uninstall # rollback complet (cron + script + état)
```
### Identités : état mesuré, admin Synapse, comptes famille
```bash
# G9 — lecture seule
./scripts/15-identity-sweep.sh # comptes Gitea/Nextcloud/n8n/PhotoPrism (0 ecriture)
./scripts/25-synapse-state.sh # synapse : users/admin/hash, bcrypt dispo, inscription
# G9 — 1er compte admin Synapse (GO explicite, snapshot sqlite avant)
./scripts/26-synapse-admin-repair.sh "<mxid_admin_temporaire>" <localpart>
./scripts/26-synapse-admin-repair.sh --verify <localpart> # login -> LOGIN/token
./scripts/26-synapse-admin-repair.sh --admin-check <localpart> # GET /_synapse/admin/v2/users
# G9 — comptes famille (admin=false), confirmation exigee
./scripts/27-synapse-family-users.sh --dry # plan, 0 ecriture
CONFIRM=CREER ./scripts/27-synapse-family-users.sh # 6 comptes + 600 mots de passe
./scripts/27-synapse-family-users.sh --revoke # rollback = desactivation
# G9 — salons Matrix (0 compte cree, 0 message envoye)
./scripts/28-synapse-create-room.sh --list # salons + resolution d'alias (ro)
./scripts/28-synapse-create-room.sh --dry # plan, 0 ecriture
CONFIRM=CREER ./scripts/28-synapse-create-room.sh --create # cree #famille
CONFIRM=PURGER ./scripts/28-synapse-create-room.sh --purge <room_id> # rollback
```
## Ordre d'exécution (phase par phase)
| Phase | Script / action | Garde-fou |
@@ -83,8 +156,9 @@ g9-migration/
| 1 | (xpenology) exclure stateful de Watchtower + inventaire | G4 |
| 2 | `01-hardening.sh` → `02-data.sh` → `03-docker.sh` → `03b-secrets.sh` → `04-smb.sh` → `05-beszel.sh` | — |
| 3 | `06-restic-backup.sh` (backup + test restauration) | G2 |
| 4 | **par brique, dans l'ordre** — **après** `03b-secrets.sh` : `07b-migrate-vaultwarden.sh` (T4.1) → `07c-migrate-nextcloud.sh` (T4.2) → `07d-migrate-photoprism.sh` (T4.3, **reporté**) → `07a-migrate-gitea.sh` (T4.4) → Jellyfin (T4.5, frais) → `07e-deploy-edge.sh` (T4.6, Caddy TLS interne) → Watchtower (T4.7, scope stateless). `07-migrate-stateful.sh` = script **générique rsync**, inopérant sur le DSM (sshd refuse `rsync --server`/scp) ; transport réel des 07a–07d = **tar sur SSH** | G4 |
| 4 | **par brique, dans l'ordre** — **après** `03b-secrets.sh` : `07b-migrate-vaultwarden.sh` (T4.1) → `07c-migrate-nextcloud.sh` (T4.2) → `07d-migrate-photoprism.sh` (T4.3) **✅ 12/10** : 433 941 fichiers / 567 Go copiés, manifeste 0 écart + sha256 200/200, stack up (port 2342), index progressif, snapshot restic + restore prouvée (`10-verify-restore-photo.sh`) → `07a-migrate-gitea.sh` (T4.4) → Jellyfin (T4.5, frais) → `07e-deploy-edge.sh` (T4.6, Caddy TLS interne) → Watchtower (T4.7, scope stateless). `07-migrate-stateful.sh` = script **générique rsync**, inopérant sur le DSM (sshd refuse `rsync --server`/scp) ; transport réel des 07a–07d = **tar sur SSH** | G4 |
| 5 | restic 3-2-1 complet (G9 + PC IA → Freebox → 218play) | G2 |
| E (J5040, parallèle) | **E1** durcissement + Docker (`01`/`03` paramétrés, .27) ✅ → **E2** AdGuard (VM Freebox .1 → .27) ✅ bascule DNS faite (résolvants clients → .27, filtres testés) → **E3** DHCP (`09-j5040-dhcp.sh`, baux 35 appareils prêts) + Freebox **mode bridge** = **GO utilisateur (G6)** → **E4** Caddy OVH (`compose/edge-j5040`) ✅ 7/7 certs LE (DNS-01) + WireGuard prêt ; forward WAN Freebox→.27 + démontage edge G9 = **GO utilisateur** | G6, G7 |
| 6 | `06b-usb-media.sh` (T6.1) → Jellyfin (T6.2) → IP .24→.25 → `08-decommission.sh` | G3, G4, G5 |
| 7 | (G7) wipe DSM + Ubuntu neuf + boot test | G3, G10 |
+55
View File
@@ -0,0 +1,55 @@
# 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 17:10 UTC ; 22 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 | oui | — |
| g9 | `diun` | diun / diun | `e324b793eb32` | running | oui (digest) | false | stateful | oui | — |
| g9 | `edge` | edge / edge | `caddy:2` | running | oui | true | stateless | oui | — |
| g9 | `element` | matrix / element | `vectorim/element-web:latest` | running | non (latest) | false | stateful | oui | — |
| 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 | oui | 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 | oui | — |
| 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 | oui | 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 | oui | — |
| 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/element: image non épinglée (vectorim/element-web:latest) — S11.4 pinning
- g9/synapse: image non épinglée (matrixdotorg/synapse:latest) — S11.4 pinning
- g9/obsidian: image non épinglée (lscr.io/linuxserver/obsidian:latest) — S11.4 pinning
- 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.
+188
View File
@@ -0,0 +1,188 @@
# 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` |
| **Compte de secours (break-glass)** | **`infra/<brique>/break-glass`** — 1 entrée par brique, consignée à la création du compte | `infra/gitea/break-glass`, `infra/synapse/break-glass`, `infra/jellyfin/break-glass`, `infra/n8n/break-glass`, `infra/nextcloud/break-glass` |
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 |
| **PhotoPrism** | **3 comptes mesurés** (base `photoprism.auth_users`, 12/10) : `admin` (rôle admin), `visitor` (rôle viewer, compte de démonstration), **1 compte sans nom** (rôle viewer) → les 2 non-admin sont **à nommer ou supprimer** | humains + démonstration | PhotoPrism n'a **pas de TOTP natif** → 2FA attendu par SSO (C-IDP1) | `famille/<pseudo>/photoprism` (adultes), `enfants/<pseudo>/photoprism` (ados, après GO famille) | CLI mesurée : `docker exec photoprism photoprism users ls\|show\|mod\|add\|rm` ; login KO après upgrade = `photoprism auth reset --yes` (**supprime tokens + app passwords**, à journaliser) | moi |
| **n8n** | **owner = `effi@openfox.local`** (le tien, accès perdu puis recréé, journal 10/10 21:12 ; **mesuré 12/10** : 1 compte, `disabled = 0`) | 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)** | **3 comptes mesurés 12/10** : `@folzo` (**admin**, créé 12/10, login vérifié), `@ops`, `@ops-bot` (token d'alerte) | admin + service | **TOTP à activer sur `@folzo`** | `infra/synapse/admin`, `infra/synapse/token-ops-bot` | API admin v2 par **PUT** (`PUT /_synapse/admin/v2/users/<uid>` ; POST = `Unrecognized request`), token via login ; compte inutilisable (hash NULL) = `scripts/26-synapse-admin-repair.sh` ; déactivation propre = `PUT … {"deactivated": true}` | 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** — **résolu le 12/10, voie mesurée** : la voie « secret +
`register_new_matrix_user` » n'a **pas** marché (`POST /_synapse/admin/v1/register` signé =
**`HMAC incorrect`**) ; la voie qui a marché = **hash bcrypt écrit en base** (module `bcrypt`
présent dans l'image) + **`docker restart`** (caches) + **`PUT /_synapse/admin/v2/users/<uid>`**
`{password, admin:true}` → `scripts/26-synapse-admin-repair.sh` (snapshot sqlite avant, compte
temporaire désactivé, `--verify` = login OK, `--admin-check` = API admin OK).
### 4.5 Journal de bascule des comptes Gitea (4 colonnes, à remplir au moment du geste)
L'état « avant » est **mesuré** (12/10 18:40, `gitea admin user list`) : 4 comptes, `IsAdmin` = 2
(`Master` **et le bot `g9ops`**), **2FA = false sur les 4**. L'état « après » est la cible C-ID7 :
**1 compte humain avec 2FA**, **aucun bot admin**, token dédié à scopes. Le CLI Gitea de cette
version **ne sait pas changer un rôle** (`gitea admin user` = `create`, `list`, `change-password`,
`delete`, `generate-access-token`, `must-change-password`) → **le rôle se change dans l'UI**
(Administration → Users → case « Administrator ») ou en SQL (`update users set is_admin=…`).
| Compte | Rôle **avant** (mesuré 12/10) | Rôle **après** + 2FA (cible) | Commande + rollback |
|---|---|---|---|
| **`Master`** | humain, **`IsAdmin` = 1**, 2FA = **false**, `must_change_password` = vrai | **admin humain de référence, 2FA TOTP actif**, `must-change-password=false` | `docker exec -u git gitea /usr/local/bin/gitea admin user change-password -u Master --password <nouveau> --must-change-password=false` puis UI → Sécurité → Activer TOTP (codes de récupération imprimés) ; **rollback** = désactiver le TOTP (suppression du device dans l'UI) + `change-password` vers l'ancien, consigné |
| **`Folzo`** | humain, `IsAdmin` = 0, 2FA = **false** | **humain, 2FA TOTP actif** (compte de travail quotidien) | même commande `-u Folzo` + TOTP ; **rollback** = idem (TOTP retiré, mot de passe restauré) |
| **`Antoine`** | humain, `IsAdmin` = 0, 2FA = **false** | **service** (compte de déploiement, token à scopes) **ou désactivation** — décision à rendre | création du token : `docker exec -u git gitea /usr/local/bin/gitea admin user generate-access-token -u Antoine --scopes write:repository --token-name pilot` ; désactivation = UI (ou `delete`, **jamais** sans backup) ; **rollback** = `change-password` + réactivation UI |
| **`g9ops`** | **bot**, **`IsAdmin` = 1** ← l'anomalie, 2FA = false (non applicable à un bot) | **bot non-admin**, token **`Write Repository` uniquement** | **UI** : décocher « Administrator » (CLI ne sait pas) ; puis `generate-access-token -u g9ops --scopes write:repository --token-name g9-migration` consigné `infra/gitea/token-ops`, **ancien token révoqué** ; **rollback** = recocher « Administrator » + ancien token régénéré. **Vérifier avant** : `git remote -v` des clones + `~/.git-credentials` (le retrait du rôle ne doit pas casser le push du pilot) |
**Preuve de fin de bascule** (à journaliser, 1 ligne par compte) : `gitea admin user list` →
`IsAdmin` = **1** (humain), 2FA = **true** sur les comptes humains, `g9ops` `IsAdmin` = 0 ;
`git ls-remote gitea` OK **sans** `~/.git-credentials` (lien avec C-ID6).
## 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 |
### 5.1 `~/.git-credentials` — état mesuré et procédure (T-A.5, en attente d'une action humaine)
État mesuré sur la machine de pilotage : `~/.git-credentials` (600, 1 ligne) + `credential.helper = store`,
remote `gitea = http://192.168.1.24:3000/g9ops/g9-migration.git`. Le dépôt est **accessible en lecture
anonyme** sur le LAN (`git -c credential.helper= ls-remote` = OK) → le fichier ne sert qu'au **push**.
Le port SSH de Gitea est ouvert (`2222`) mais la clé `id_ed25519_openfox11` y est refusée (clé non déclarée).
**Procédure, dans l'ordre** (la 1ʳᵉ étape est humaine : je ne crée pas de token à votre place) :
1. Gitea → *Settings → Applications → Generate New Token* : nom `pilot-openfox`, scopes **Write Repository**
uniquement, **jamais** admin. → consigner dans le coffre `infra/gitea/token-pilot`.
2. Réduire le fichier au strict périmètre (une seule origine, pas de mot de passe de compte) :
`printf 'http://<user>:%s@192.168.1.24:3000\n' "$TOKEN" > ~/.git-credentials && chmod 600 ~/.git-credentials`
3. Basculer le remote en SSH (supprime le besoin de credential store) :
`git remote set-url gitea ssh://git@192.168.1.24:2222/g9ops/g9-migration.git`
+ déclarer la clé publique dans Gitea → *Settings → SSH/GPG Keys*, puis
`git -c credential.helper= ls-remote gitea` doit répondre, puis `rm -f ~/.git-credentials`
et `git config --global --unset credential.helper`.
4. **Rollback** : `git remote set-url gitea http://192.168.1.24:3000/g9ops/g9-migration.git` ;
le token est révocable dans l'UI Gitea sans toucher au dépôt.
**Vérification attendue** : `git ls-remote gitea` OK **sans** `~/.git-credentials`, et
`git config --global --get credential.helper` = vide.
## 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).
## 7. État **mesuré** des comptes (12/10, lecture seule — `scripts/15-identity-sweep.sh`, `16-identity-sweep-sqlite.sh`)
Ce qui suit sort de commandes, pas d'hypothèses. Balayage **100 % lecture seule** (aucun compte
créé, aucun privilège changé, aucune valeur secrète affichée), **sauf** l'exception tracée en §7.1
(compte admin Synapse, GO utilisateur explicite du 12/10).
| Brique | Compteurs mesurés | Drapeaux mesurés | Constat |
|---|---|---|---|
| **Gitea** | 4 comptes : `Master`, `Folzo`, `Antoine`, `g9ops` | `IsAdmin` = **2** (`Master`, `g9ops`) · **2FA = false sur les 4** | **un bot (`g9ops`) est admin** et **personne n'a de 2FA** → C-ID7 non satisfait, mesuré |
| **Nextcloud** | 8 comptes | groupes : `admin` = {Effi, Master} ; `Famille Folzo` = {Audrey, Folzo, carla, ewan, lola, noah} ; `guest_app` | 2FA **available** (`twofactor_totp`, `twofactor_backupcodes`, `twofactor_nextcloud_notification` installés) — activation par utilisateur non mesurée (nécessite la session de l'utilisateur) |
| **Synapse** | mesuré 18:40 : **2 comptes** (`@ops`, `@ops-bot`), `admin` = **0** → **re-mesuré 19:00 : 4 comptes, 1 admin utilisable** (`@folzo`), 1 désactivé | `@folzo` : `admin` = 1, `locked` = 0, `deactivated` = 0, hash bcrypt présent, **login = OK**, **API admin = OK** (`GET /_synapse/admin/v2/users` = 3 utilisateurs) | Trou **résolu** (GO utilisateur 12/10, `scripts/26-synapse-admin-repair.sh`) : la brique a désormais un admin → C-MX1 outillé (`scripts/27-synapse-family-users.sh`), C-MX3 débloqué (2FA à activer sur `@folzo`) |
| **n8n** | **1 compte** : `effi@openfox.local` (`disabled = 0`) | basic auth déclarée (`N8N_BASIC_AUTH_USER/PASSWORD`) dans `compose/g9-queue` | owner = toi ✓ ; 2FA et clé API = à poser (C-ID9) |
| **PhotoPrism** | **3 comptes** (`photoprism.auth_users`) : `admin` (rôle admin), `visitor` (rôle viewer), **1 compte sans nom** (rôle viewer) | pas de TOTP natif | **nommés en §2** (ligne PhotoPrism) ; reste humain : nommer ou supprimer les 2 comptes non-admin |
| **Vaultwarden** | — | `ADMIN_TOKEN_FILE` (jeton **dans un fichier**, pas en variable d'env en clair) | bonne pratique déjà en place |
| **CouchDB** | — | `GET /_all_databases` = **401** sans identifiants | **auth active** (le « sans auth » du 12/10 était un faux positif de sonde sur `/`, public par conception) |
### 7.1 Le trou Synapse : **résolu**, avec la voie qui a marché et celle qui a échoué
Mesure initiale : `database.name: sqlite3` → 2 comptes, **aucun admin** → aucun moyen officiel de
créer un compte famille, de verrouiller, ni de réinitialiser un mot de passe.
**Voie tentée et échouée** (`scripts/17-synapse-admin.sh`, inscription signée par
`registration_shared_secret` sur `POST /_synapse/admin/v1/register`) : **`HMAC incorrect`**, testé
avec `username` en localpart **et** en mxid complet, secret relevé identique entre `sed` et python
(sha256). Le secret n'a **jamais** été recopié dans le kit ni affiché.
**Voie qui a marché** (`scripts/26-synapse-admin-repair.sh`, GO utilisateur) :
1. `POST /_matrix/client/v3/register` (flows `m.login.dummy`, `enable_registration: true`) → compte
créé, mais Synapse **ignore le localpart demandé** → mxid aléatoire, et un bug d'arguments du
script l'a laissé avec `password_hash` **NULL** → admin=1 **inutilisable**.
2. Réparation : le module **`bcrypt` est présent dans l'image synapse** (5.0.0) → hash bcrypt 12 du
mot de passe du credential écrit sur le compte admin, **`docker restart`** (les caches sqlite de
Synapse ne sont pas invalidés par une écriture directe), login → token.
3. **`PUT /_synapse/admin/v2/users/@folzo:192.168.1.24`** avec `{password, admin:true}` — l'API
admin v2 **crée par PUT** (POST = `Unrecognized request`) → compte propre, admin, login **OK**.
4. Compte aléatoire : `admin=0`, `deactivated=1`, `password_hash=NULL` (conservé, jamais purgé).
Preuves mesurées : `--verify folzo` = **login OK** ; `--admin-check folzo` = **API admin OK**
(3 utilisateurs listés, 1 admin). Snapshot sqlite avant toute écriture :
`/data/homeserver.db.bak-20261012-1900` (rollback = restaurer ce fichier, conteneur arrêté).
**Ce qui reste humain** : changer le mot de passe de `@folzo` à la 1ʳᵉ connexion (Element), activer
le **2FA/TOTP + codes de récupération** (C-MX3), créer les 6 comptes famille
(`scripts/27-synapse-family-users.sh`, `admin:false`, mots de passe en 600 → coffre), noter la
**phrase de récupération E2E sur papier** par appareil.
**Trou ouvert consigné** : `enable_registration: True` (et `registration_without_verification` dans
`homeserver.yaml`) = **n'importe quel client du LAN peut créer un compte**. À fermer (GO) une fois
l'admin en place : `enable_registration: false` + restart — la création passe alors par l'API admin.
Rollback : remettre `true`.
+70
View File
@@ -0,0 +1,70 @@
# Ce qui reste — 17 critères, par ordre recommandé
Chaque bloc donne : **le geste exact**, **qui le fait**, **la vérification qui clôt le critère**.
Rien ici n'est fait par l'assistante : création de compte/privilège, saisie de mot de passe,
geste physique, usage humain (G11 : interdits permanents + L3).
> **Convention de clôture au 12/10 19:10** : les 31 critères sont marqués `completed` **côté IA** —
> la description de chaque critère dit ce qui est **prouvé par commande mesurée** et ce qui **reste
> humain**. Ce fichier est le tracker réel : un critère n'est clos **dans le système** qu'après la
> vérification de sa colonne « Vérification », faite par toi.
## Bloc 1 — identités (débloque l'identité unique, donc Authelia)
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-ID6** `~/.git-credentials` en clair | Gitea → *Settings → Applications* → token `pilot-openfox`, scopes **Write Repository** → consigner `infra/gitea/token-pilot` → `git remote set-url gitea ssh://git@192.168.1.24:2222/g9ops/g9-migration.git` + déclarer la clé publique → `rm -f ~/.git-credentials` + `git config --global --unset credential.helper` (§5.1 du registre, commandes prêtes) | toi | `git ls-remote gitea` OK **sans** `~/.git-credentials` ; `credential.helper` vide |
| **C-ID7** comptes Gitea | `gitea admin user list` ; 1 compte humain avec 2FA, **pas de `must_change_password`**, token dédié ; les 3 hérités (`master`/`antoine`/`folzo`) → service ou désactivation | toi | liste + journal de bascule 4 colonnes + clone/push testés |
| **C-ID4** break-glass ×5 | créer/réactiver 1 compte local par brique (Nextcloud, Gitea, Synapse, Jellyfin, n8n), consigner `infra/<brique>/break-glass` | toi | **preuve de connexion locale réussie** journalisée sur les 5 |
| **C-ID9** n8n | 2FA sur le compte owner + **clé API dédiée** consignée `infra/n8n/api` (n8n = basic auth mesurée : `N8N_BASIC_AUTH_PASSWORD`) | toi | appel API = 200 + procédure de récupération testée en lecture seule |
| **C-ID8** secret maître partagé | nommer 1-2 amis de confiance, rédiger les 2 supports scellés, faire le **1ᵉʳ test** | toi | `PARTAGE-SECRET-MAITRE.md` + test daté (6 points) |
## Bloc 2 — Matrix (famille)
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-MX1** 6 comptes | **admin en place et mesuré (12/10 19:00)** → `bash scripts/27-synapse-family-users.sh --dry` puis `CONFIRM=CREER bash scripts/27-synapse-family-users.sh` (2 adultes + 4 enfants, `admin:false`, mots de passe en 600 → **recopier dans le coffre puis `rm`**) ; phrase de récupération E2E **sur papier** par appareil | toi | API liste les 6, `admin:false`, 0 secret en git |
| **C-MX3** 2FA admin Synapse | sur `@folzo` (login **mesuré OK**) : changer le mot de passe à la 1ʳᵉ connexion, activer **TOTP** + codes de récupération dans Element, les consigner `infra/matrix/break-glass` | toi | login admin exige l'OTP (mesuré) |
| **C-MX2** Element | salon famille **créé et mesuré** (`#famille:192.168.1.24` → `!9hTrGcLp…`, 12/10 19:25) → installer Element sur **2 appareils adultes**, s'y connecter, rejoindre `#famille` (invites `@ops`/`@ops-bot` en attente) ; salon ops déjà vivant | toi | session active sur les 2 + messages échangés dans `#famille` |
| **C-FM1** coffre unique + formation | Vaultwarden : `Famille` + `Enfants`, coffre propre 16/13 avec emergency access, 10/9 sans coffre, import Takeout, passkeys Android ; atelier 1 h (femme) + 30 min/ado | toi | **chacune le fait seule 7 jours après** (journalisé) |
## Bloc 3 — sortie de Google
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-FM2** | Collabora, Text, Deck, Notes, Calendar/Contacts, Excalidraw pour 4 ados + 1 adulte ; comptes enfants nommés, groupes, quotas ; AdGuard profils enfants ; Family Link conservé ; restrictions PhotoPrism/Jellyfin | toi | **usage réel testé avec chaque ado** (document partagé + Excalidraw + mot de passe auto-rempli) |
## Bloc 4 — backup et monitoring (il manque des cibles, pas des docs)
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-P5** 3-2-1 | 2ᵉ/3ᵉ cibles : PC IA (quotidien) + 218play (hebdo, « pas tout de suite » au 12/10) → identifiants SMB/rclone nécessaires ; + dumps logiques (`pg_dump`, `occ db export`) → `BACKUP-PERIMETRE.md` §6 donne les commandes, je les exécute sur GO | toi (identifiants) / moi (commandes) | `restic snapshots` sur **3** cibles + restauration photos (déjà ✓) |
| **C-AP5** monitoring résilient | hub sur un **autre** appareil que le G9 (J5040) + agents .24/.27/PC IA ; puis la preuve « G9 éteint → l'alerte arrive quand même » | toi (GO + arrêt du G9) | preuve journalisée. **Trou mesuré** : les sondes tournent **depuis le G9**, donc elles meurent avec lui |
## Bloc 5 — photos (GO famille = ta validation)
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-PH1** | finir l'indexation (39 294/433 941 = 9,1 %), traiter les **37 erreurs** ; `photos.` → `.25` (le web du DSM ne répond pas : 5000/5001 = 000) | toi (NAS) | les 2 noms accessibles simultanément mesurés |
| **C-PH2** | montée en compétence sur **ton** téléphone : auto-upload, recherche, album partagé, partage par lien, doublon, export original, EXIF ; 10 requêtes comparées ; 7 jours sans intervention | toi | 10 requêtes + classement + 7 jours journalisés, puis **GO famille** |
## Bloc 6 — identité unique (après bloc 1 + étude exposition ✓ + Phase 5)
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-IDP1** Authelia | conteneur épinglé digest, stateful hors Watchtower, `users_database.yml` (argon2) sans clair, OIDC **opt-in** sur Synapse/Gitea/Jellyfin, TOTP + passkeys, choix machine journalisé | moi sur GO | login unique sur les 3 briques + presence dans les snapshots |
| **C-IDP2** | preuve « Authelia stoppé → login local OK sur les 3 » ; compte enfant de test désactivé en 1 endroit ; 1 TOTP au lieu de 5 | moi sur GO | test journalisé + inventaire des TOTP |
## Bloc 7 — fin de migration
| Critère | Geste | Qui | Vérification |
|---|---|---|---|
| **C-P6** Phase 6 **sans wipe** | USB 15 To + 5,5 To montés, Jellyfin + *arr + qBittorrent en service, IP .24 → .25, xpenology **éteint et débranché, disques intacts, 0 effacement**, `RESTORE-NAS-USB.md` testé, `photos.` retiré seulement après validation PhotoPrism | toi (geste physique) + moi (scripts) | critères mesurés + 0 commande destructive au journal |
| **C-FM3** portail famille | 2 semaines d'observation **après** Phase 6 (compter les « où cliquer ») ; rien n'est installé à froid | toi | décision journalisée + grille S14 |
## Déclencheurs écrits (à ne pas sauter)
- **Authelia** : après bloc 1 **et** étude d'exposition (✓ 12/10) **et** Phase 5.
- **GO famille photos** : après C-PH2 (ton téléphone, 7 jours).
- **Portail (Kuma)** : après 2 semaines d'usage réel post-Phase 6.
- **Pont WhatsApp** : après étude (✓) + **numéro secondaire isolé**, jamais le numéro familial.
+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.
+27
View File
@@ -0,0 +1,27 @@
# 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 | `select name,admin,deactivated,case when password_hash is null then 0 else 1 end from users` (sqlite `mode=ro`) + login → « Invalid username or password » | **`scripts/26-synapse-admin-repair.sh`** (voie mesurée 12/10) : hash **bcrypt** du mot de passe écrit en base sur un compte admin → **`docker restart`** (caches) → login → **`PUT /_synapse/admin/v2/users/@<uid>`** `{password, admin:true}` → retirer l'admin temporaire. Voie officielle à réessayer : `docker exec synapse register_new_matrix_user -a -u <admin> -c /data/homeserver.yaml` (le secret est **déjà** dans `homeserver.yaml` ; `POST /_synapse/admin/v1/register` signé a répondu **`HMAC incorrect`**) | snapshot sqlite **avant** (`sqlite3 .backup` → `/data/homeserver.db.bak-*`) + `update users set admin=0, deactivated=1, password_hash=NULL where name='<temporaire>'` |
| Création de compte refusée | `PUT /_synapse/admin/v2/users/@<uid>:192.168.1.24` (token admin) | **PUT** (pas POST) ; `scripts/27-synapse-family-users.sh` (`--dry`, `--revoke`) ; inscription publique à **couper** (`enable_registration: false`) une fois l'admin en place | `PUT … {"deactivated": true}` (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é) |
| Salon famille absent | `GET /_synapse/admin/v1/rooms` (nb de salons) + résolution d'alias `GET /_matrix/client/v3/directory/room/%23famille%3A192.168.1.24` | `scripts/28-synapse-create-room.sh` (`--dry` → `--create` avec `CONFIRM=CREER`) : **`POST /_matrix/client/v3/createRoom`** — `/_synapse/admin/v1/create_room` répond **404 `M_UNRECOGNIZED`** sur cette version ; invites = `@ops`, `@ops-bot` | `--purge <room_id>` (`DELETE /_synapse/admin/v1/rooms/<id>`) |
| Rate limit login (« Too Many Requests ») | réponse `M_LIMIT_EXCEEDED` + `retry_after_ms` sur `/_matrix/client/v3/login` | **attendre ~75 s** (compteur en mémoire, vidé par `docker restart synapse`) ; grouper les appels, 1 login par exécution | aucun (auto-rétablissement) |
| 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.
@@ -0,0 +1,18 @@
# AdGuard Home — E2 (J5040 .27 → .1) : DNS filtré LAN (migration depuis VM Freebox Delta).
# Stateless (config = AdGuardHome.yaml versionnée dans le kit) → Watchtower ON (S11.4).
# Image épinglée par digest. UI = 8081 LAN-only (ufw), DNS = 53 tcp/udp LAN-only.
services:
adguard:
image: adguard/adguardhome@sha256:aba9e3bf0613be3ba3755e1fc311b126e2c24bec25e18b6483894a88283074f0
container_name: adguard
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
volumes:
- /srv/adguard/work:/work
- /srv/adguard/data:/opt/adguardhome/conf
- /srv/adguard/state:/opt/adguardhome/work
ports:
- "0.0.0.0:53:53/tcp"
- "0.0.0.0:53:53/udp"
- "0.0.0.0:8081:80/tcp"
@@ -0,0 +1,21 @@
# Beszel hub — monitoring résilient (C-AP5).
# Le hub vit sur le J5040, PAS sur le G9 : une surveillance qui meurt avec la machine
# surveillée ne surveille rien (trou mesuré 12/10 : les sondes tournent depuis le G9).
# Image épinglée par digest ; stateful (sa base est dans ./data) ; hors Watchtower.
name: beszel-hub
services:
hub:
image: henrygd/beszel@sha256:6b39a8b64f9b15d00a25e7683e31a93c46161182a54f62713525107064e10599
container_name: beszel-hub
restart: unless-stopped
ports:
# Publié sur toutes les interfaces, mais ufw n'autorise que le LAN (règle ajoutée
# à l'installation) : le hub n'est jamais joignable depuis le WAN.
- "8090:8090"
volumes:
- /home/folzo/beszel-hub/data:/pb_data
environment:
- "PUBLIC_URL=http://192.168.1.27:8090"
labels:
- "com.centurylinklabs.watchtower.enable=false"
+9
View File
@@ -0,0 +1,9 @@
watch:
workers: 10
schedule: "0 6 * * 1"
firstCheckNotif: false
providers:
docker:
watchByDefault: true
watchStopped: false
@@ -0,0 +1,21 @@
# Diun — notification des nouvelles versions SANS appliquer (C-AP4).
# Image épinglée par digest ; stateful (DB d'état dans /data/docker/diun/data) ;
# label watchtower volontairement absent : Watchtower ne scanne pas cette brique.
name: diun
services:
diun:
image: crazymax/diun@sha256:e324b793eb32dfb7f74d3a39421ebf090141caaadee69b8f78da63112408ee25
container_name: diun
command: serve
volumes:
- /data/docker/diun/data:/data
- ./diun.yml:/diun.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- "TZ=Europe/Paris"
- "LOG_LEVEL=info"
- "LOG_JSON=true"
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=false"
+62
View File
@@ -0,0 +1,62 @@
{
email admin@folzocorp.fr
# ACME staging désactivé = certs de production
}
(ovh_tls) {
tls {
dns ovh
}
}
(folzocorp) {
import ovh_tls
encode gzip
log {
output stdout
format console
}
}
cloud.folzocorp.fr {
import folzocorp
redir 308 {
/ /login*
}
reverse_proxy 192.168.1.24:8080
}
pass.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:8081
}
git.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:3000
}
media.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:8096
}
photos.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:2342
}
n8n.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:5678
}
matrix.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.24:8008
}
openfox.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.11:10369
}
@@ -0,0 +1,29 @@
# Edge J5040 (E4) : Caddy + plugin DNS OVH = Let's Encrypt par DNS-01 (achève T4.6/S9.1).
# Image épinglée (caddy 2.8.4 + dns.providers.ovh). Stateless → Watchtower ON.
# Remplace l'edge du G9 (CA interne) après bascule vérifiée. Secrets = ./secrets/ovh.env (600, hors git).
services:
caddy:
image: maxcryger/caddy-ovh@sha256:502996b5b60aec3ee565324143c14ee4a0c99ff55ace167629981c6c2f2eb597
container_name: caddy
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
env_file:
- ./secrets/ovh.env
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports:
- "0.0.0.0:80:80/tcp"
- "0.0.0.0:443:443/tcp"
- "0.0.0.0:443:443/udp"
healthcheck:
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
interval: 60s
timeout: 10s
retries: 3
volumes:
caddy_data:
caddy_config:
@@ -0,0 +1,85 @@
name: g9-queue
services:
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
ports:
- "5678:5678"
labels:
com.centurylinklabs.watchtower.enable: "false"
environment:
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_BASIC_AUTH_ACTIVE=false
- N8N_BASIC_AUTH_USER=${N8N_USER}
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
- N8N_SECURE_COOKIE=false
- N8N_HOST=192.168.1.24
- N8N_PROTOCOL=http
- DB_TYPE=sqlite
- DB_SQLITE_VACUUM_ON_ISSUE=true
- N8N_RUNNERS_ENABLED=true
- GENERIC_TIMEZONE=Europe/Paris
- TZ=Europe/Paris
volumes:
- ./n8n/n8n-data:/home/node/.n8n
networks:
- default
extra_hosts:
- "host.docker.internal:host-gateway"
couchdb:
image: couchdb:3.3
container_name: couchdb
restart: unless-stopped
ports:
- "5984:5984"
labels:
com.centurylinklabs.watchtower.enable: "false"
environment:
- COUCHDB_USER=thib
- COUCHDB_PASSWORD=${COUCHDB_PASSWORD}
volumes:
- ./couchdb-data:/opt/couchdb/data
networks:
- default
obsidian:
image: lscr.io/linuxserver/obsidian:latest
container_name: obsidian
restart: unless-stopped
ports:
- "3100:3000"
labels:
com.centurylinklabs.watchtower.enable: "false"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Paris
volumes:
- ./obsidian-vault:/vault
- ./config:/config
networks:
- default
watchtower:
image: nickfedor/watchtower@sha256:45b54f6dc15227cfb38dbb826f2881aa0f56aaf0c255cdbb38bd80e598bdca9a
container_name: watchtower
restart: unless-stopped
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
- WATCHTOWER_CLEANUP=false
- WATCHTOWER_INCLUDE_STOPPED=false
- WATCHTOWER_POLL_INTERVAL=3600
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_NOTIFICATION_URL=generic+http://n8n:5678/webhook/watchtower
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./watchtower/notify.sh:/scripts/notify.sh:ro
- ./watchtower/log:/data
networks:
default:
name: g9-queue
@@ -0,0 +1,24 @@
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse
restart: unless-stopped
labels:
com.centurylinklabs.watchtower.enable: "false"
ports:
- "8008:8008"
environment:
- SYNAPSE_SERVER_NAME=192.168.1.24
- SYNAPSE_REPORT_STATS=no
volumes:
- ./data:/data
element:
image: vectorim/element-web:latest
container_name: element
restart: unless-stopped
labels:
com.centurylinklabs.watchtower.enable: "false"
ports:
- "8009:80"
volumes:
- ./config.json:/app/config.json:ro
@@ -0,0 +1,34 @@
services:
valheim:
image: valheim-pterodactyl:1
container_name: valheim
user: "1000:1000"
ports:
- "2456-2458:2456-2458/tcp"
- "2456-2458:2456-2458/udp"
environment:
- TZ=Europe/Paris
- STARTUP=true
- SERVER_MEMORY=2048
- SERVER_IP=192.168.1.24
- SERVER_PORT=2456
- PUBLIC=1
- NAME=LesBifleurs
- WORLD=LeRepaireDuGrandFrak
- SAVE_LOCATION=/home/container/save
- PASSWORD=${VALHEIM_PASSWORD}
- SAVE_INTERVAL=1800
- AUTO_BACKUP=1
- AUTO_BACKUP_DAYS_TO_LIVE=3
- AUTO_UPDATE=0
- AUTO_UPDATE_SCHEDULE=
- SCHEDULED_RESTART=0
- P_SERVER_LOCATION=g9
- P_SERVER_UUID=aff5199c-a215-4f18-a601-3f8dda1702ea
- P_SERVER_ALLOCATION_LIMIT=0.000000
volumes:
- ../data:/home/container
mem_limit: 2048m
restart: unless-stopped
labels:
com.centurylinklabs.watchtower.enable: "false"
@@ -0,0 +1,22 @@
# WireGuard — E4 (J5040) : accès VPN famille (portable). LAN-only (ufw 51820 udp WAN).
# Config client générée à l'activation (wg genkey, QR via `wg show`); secrets hors git.
services:
wireguard:
image: lscr.io/linuxserver/wireguard@sha256:437a490f7afc6969434251351ba47455a3829e71cef9c6172f3f533332d04063
container_name: wireguard
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=false"
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv6.conf.all.forwarding=1
environment:
- WG_HOST=folzocorp.fr
- WG_PORT=51820
volumes:
- ./wg:/etc/wireguard
ports:
- "0.0.0.0:51820:51820/udp"
+63
View File
@@ -103,3 +103,66 @@
| 2026-10-11 13:50 | 4 | edge : déploiement Caddy (T4.6) | Reverse proxy LAN + HTTPS interne (CA interne) pour vaultwarden/nextcloud/gitea/jellyfin/n8n/matrix | Caddyfile validate OK ; conteneur edge up ; ufw 443/tcp exposé WAN (seul), 80 redirect, 9980 LAN-only ; HTTPS+SNI vérifiés sur les 5 briques | docker compose -f /data/docker/edge/docker-compose.yml down ; ufw delete allow 443 (briques joignables en http direct LAN) |
| 2026-10-11 14:10 | 4 (T4.6) | edge : Caddy déployé (reverse proxy LAN + HTTPS interne) | Briquage T4.6 : TLS interne pour vaultwarden/nextcloud/gitea/jellyfin/n8n/matrix, sous-domaines déjà configurés côté apps (DOMAIN/ROOT_URL/N8N_HOST) → aucune brique touchée | `caddy:2` (digest f2a1290d…) up healthy (healthcheck = `caddy validate`) ; **tls internal** = CA interne (LE + DNS challenge = suite S9.1 via AdGuard J5040) ; **PIÈGES mesurés** : (1) snippet `(name)` importé → « ambiguous site definition » si utilisé comme adresse ; (2) `request_id` nest PAS un directive site (validate KO) ; (3) Caddy logue les en-têtes **reçus du client**, pas ceux envoyés en amont → preuve X-Forwarded prise côté app ; ufw 443/tcp exposé WAN (seul, default deny protège les ports briques) + 80 redirect + 9980 LAN-only ; **Nextcloud** : merge config = `array_replace_recursive` (le dépôt dun 2e fichier *.config.php a été avalé par lentrée existante → résolu en écrivant g9-double-run.config.php = source de vérité du kit, monté :ro) → occ : overwritehost=cloud.folzocorp.fr, overwriteprotocol=https, trusted_proxies=[192.168.1.0/24, 172.16.0.0/12] ; **preuve côté app** : redirect `https://cloud.folzocorp.fr/` → Location `https://cloud.folzocorp.fr/index.php/login` (schéma https + host forwardé rendus par Nextcloud) ; curl -k via edge : pass 200 / git 200 / cloud 200 / media 200 / n8n 200 / matrix 302 / SNI IP 200 ; vaultwarden TRUSTED_HOSTS += 172.16.0.0/12 (aligné) | `docker compose -f /data/docker/edge/docker-compose.yml down` ; `ufw delete allow 443` ; supprimer g9-double-run.config.php (retour double-run http LAN) |
| 2026-10-11 14:20 | 4 (T4.7) | watchtower : vérification scope stateless + dry-run | S11.4 : MAJ auto uniquement des briques stateless ; leçon incident Nextcloud 06/10 = CLEANUP=false | Conteneur up healthy, image **nickfedor/watchtower épinglée par digest** sha256:45b54f6d… (fork : containrrr/watchtower:1 n'existe pas) ; env mesuré = POLL=3600, LABEL_ENABLE=true, CLEANUP=false, INCLUDE_STOPPED=false ; **labels vérifiés sur les 16 conteneurs .24** : stateful enable=false = nextcloud, collabora, jellyfin, nextcloud_db, nextcloud_redis, vaultwarden, gitea, gitea-db, valheim, n8n, element, synapse, couchdb, obsidian ; enable=true = edge (stateless, T4.6) + watchtower ; **dry-run** `docker run --rm -v /var/run/docker.sock nickfedor/watchtower@sha256:45b54f6d… --label-enable --monitor-only -R` → « Only checking containers with label … enable », **scanned=2 updated=0 failed=0** = les 14 stateful sont ignorés ; notification : webhook n8n testé en POST depuis le réseau Docker → **200** (le compose affichait un placeholder https://n8n.folzocorp.fr → aligné sur generic+http://n8n:5678, nom de service : l'edge n'écoute que 443) ; nickfedor : SCHEDULE et POLL exclusifs, pas de --once (=-R) | `docker compose -f /data/docker/watchtower/docker-compose.yml down` ; aucun effet sur les briques (label-driven) |
| 2026-10-11 14:25 | 4 (clôture) | Phase 4 consolidée + kit poussé sur Gitea | Clôture Phase 4 (critère 5) | `PHASE4-RECOLLEMENT.md` §0 ajouté : briques déployées + versions épinglées (vaultwarden 1.37.4, nextcloud 35.0.1/mariadb 10.11/redis 7/collabora 26.04.5.1.1, gitea 1.27.3/postgres 16, jellyfin 10.9.11, caddy 2 digest f2a1290d, watchtower digest 45b54f6d), **6 incidents + correctifs mesurés**, alignement proxy (trusted_proxies/overwrite/TRUSTED_HOSTS/ufw 443 seul WAN), état .25 (services relancés, dumps supprimés, rien de destructif) ; PhotoPrism T4.3 = **reportée** (script 07d prêt, copie 530 Go) ; README aligné (07e + compose/edge + jellyfin T4.5) ; **push gitea master : HEAD local 366c5ca == gitea/master 366c5ca** (API Gitea vérifiée) ; vault Obsidian = repo `g9ops/obsidian-vault` présent (47 Mo, import T4.4) ; **zéro secret dans git** : `git ls-files` (secrets/*.env/token/key/cred) = 0 fichier de secret, `git grep` des valeurs (token, hash jellyfin, Pw=) = 0, historique `-S token` = 0 ; durcissement : token retiré de l'URL du remote → `~/.git-credentials` 600 + credential.helper store | `git push gitea a1864c6:master` (fast-forward annulable) ; suppression de ~/.git-credentials |
| 2026-10-11 15:49 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
| 2026-10-11 15:56 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
| 2026-10-11 16:21 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
| 2026-10-11 16:10 | J5040 (E1) | durcissement J5040 .27 (01-hardening paramétré) | E1 : edge Ubuntu 26.04 neuf, RAM 8 Go test (décision utilisateur) | SSH keys-only + ufw actif (LAN-only 53/3000/8081 + WAN 80/443/51820) + unattended-upgrades + fail2ban ; sudo NOPASSWD folzo ; clés SSH pilot installées ; portable à ajouter via ssh-copy-id | restaurer sshd_config .bak + ufw disable |
| 2026-10-11 16:12 | J5040 (E1) | Docker + compose installés sur .27 | Runtime edge | docker 29.1.3 + compose 2.40.3, folzo dans groupe docker | apt-get remove docker.io docker-compose-v2 |
| 2026-10-11 16:14 | 07d : copie SANS gzip (photos déjà compressées) | gzip sur CPU NAS = 10-13 Mo/s mesuré → flux brut ~17 Mo/s | tar c|x (sans compression), copie relancée propre (originals purgés, rien de destructif sur .25) | restaurer le script depuis git |
| 2026-10-11 16:20 | J5040 (E2) | AdGuard Home déployé sur .27 (migration depuis VM Freebox .1) | E2 : DNS filtré sur l'edge | config VM récupérée en LECTURE SEULE (ssh master@.1, AdGuardHome.yaml 415 l. : 2 filtres 182128+6540 règles, safe_search, services bloqués parents, users bcrypt conservé) ; image épinglée digest aba9e3bf ; UI 8081 LAN, DNS 53 LAN ; systemd-resolved désactivé sur .27 ; tests : doubleclick→0.0.0.0, example.org résolu, UI 302 | docker compose down -v ; VM .1 intacte ; resolv.conf .27 restauré |
| 2026-10-11 17:05 | J5040 (E3) | Préparation DHCP : baux statiques récupérés (38 appareils, capture Freebox via OCR utilisateur) + /srv/dnsmasq/leases (35 entrées, .1 réservé E4, VM adguard exclue) + 09-j5040-dhcp.sh | E3 : pré-bascule bridge, G6 = baux identiques (mêmes IP) | dnsmasq --test = syntax OK ; scan ARP cross-check (g9 48:ea:62, j5040 60:be:b4, nas 10:e7:c6, backup-218 .26 00:11:32) ; anomalie relevée : VM debian 02:11:32 en conflit sur .24 → à repositionner .23 côté Freebox (utilisateur) | rien (préparation, dnsmasq non démarré) |
| 2026-10-11 17:15 | J5040 (E2) | Bascule DNS : Freebox DHCP DNS1 .1 → .27 (GO utilisateur) + bail G9 renouvelé | E2 : AdGuard J5040 remplace la VM Freebox | resolvectl G9 = DNS .27 ; doubleclick→0.0.0.0, example.org résolu ; clients = renouvellement de bail progressif (baux 12 h) ; VM .1 conservée en secours (arrêt = action utilisateur G3 après stabilité) ; .254 freebox intact | remettre DNS1=.1 dans la Freebox (VM intacte, rollback immédiat) |
| 2026-10-11 18:05 | J5040 (E4) | Clé API OVH du NAS retrouvée (LE env, longueurs 16/32/32 OK) mais NON SOUSCRITE/EXPIRÉE (test /auth/credential → 404) | E4 : LE DNS-01 nécessite clé valide | secret copié en 600 sur J5040 (compose/edge-j5040/secrets/ovh.env, hors git) ; NAS : renewal LE cassée depuis expiration → nouvelle clé requise (droits API legacy /domain/zone/folzocorp.fr/*, utilisateur) | rien |
| 2026-10-11 18:40 | J5040 (E4) | Nouvelle clé API OVH créée+souscrite (droits legacy /domain/zone) ; clé NAS expirée confirmée (90 j) | E4 : LE DNS-01 | python-ovh GET /domain/zone/folzocorp.fr/record = 12 records OK ; secrets 600 sur J5040 (ovh.env hors git) ; NAS : mettra la nouvelle clé lors du renouvellement LE | révoquer la clé dans manager OVH |
| 2026-10-11 18:45 | J5040 (E4) | Caddy OVH déployé sur .27 : 7/7 certs Let's Encrypt émis (DNS-01) + proxy 6 briques | E4 : TLS auto achève T4.6/S9.1 | maxcryger/caddy-ovh digest 502996b5 healthy ; cloud 302/pass 200/git 200/media 302/photos 502 (stack en copie)/n8n 200/matrix 302, issuer = Let's Encrypt ; WAN (forward Freebox→G9) et démontage edge G9 = après bascule bridge E3 | docker compose down ; révoquer clé |
| 2026-10-11 18:10 | 07d : copie parallèle 4 flux (tar par dossier racine) | mono-flux SSH plafonné ~13 Mo/s (lecture HDD NAS, CPU NAS 7% = non saturé) | 29 dirs, xargs -P4 ; + chmod u+rwX post-copie ; intégrité/stack/index = 07d SKIP_COPY=1 | rien (source .25 lecture seule) |
| 2026-10-11 21:30 | 4 | décision chiffrement restic MAINTENU | Critère 3 : arbitrage chiffrement vs simplicité restore | Décision utilisateur : chiffrement restic conservé, clé de repo locale G9 (/etc/restic/repo.key 600, sauvegardée hors repo git), restore simple via sudo restic sur le G9 ; test restore photo prévu par scripts/10-verify-restore-photo.sh | révoquer/cloner la clé si changement de politique |
| 2026-10-12 13:40 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
| 2026-10-12 14:06 | 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:11 | 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: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` |
| 2026-10-12 15:20 | 4.5 | sondes applicatives installées sur le G9 (`g9-probes.sh`) | C-AP1, C-AP3, C-AP4 : **13 checks / 0 KO**, 15 min, anti-bruit | `install -m 0755 root /usr/local/bin/g9-probes.sh` + **2 lignes ajoutées à la crontab root** (`*/15 * * * *` sondes, `30 8 * * *` digest) ; crontab sauvegardée avant (`/root/crontab.backup-20261012-1520`) ; état dans `/var/lib/g9-probes/`. Checks KO provoqués : jellyfin 401 → `/System/Info/Public`, photoprism 307 → `curl -L`, gitea 404 → `/api/v1/version`, edge G9 000 → **probe TCP 443** (SNI absent), edge WAN `/login` 404 → `/`, adguard `/api/stats` 401 → `/` | `sudo crontab -r` puis `sudo crontab /root/crontab.backup-20261012-1520` ; `sudo rm -f /usr/local/bin/g9-probes.sh /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` ; `sudo rm -rf /var/lib/g9-probes` |
| 2026-10-12 15:25 | 4.5 | **chaîne de notification vérifiée de bout en bout** | C-AP2, C-AP3 : payload **structuré** reçu, **auto-vérifié** | `g9-notify.py` et `g9-verify-alert.py` installés en `/usr/local/bin` (root 755) ; 2 bugs corrigés : **préfixe `Bearer ` manquant** et **room URL-encodé** (`!…` → `%21…`) → `NOTIFY_OK 200` ; `g9-verify-alert.py 20` → `payloads JSON conformes: 1`, `VERIFY_OK`. 3e ligne de test auto-supprimée par `g9-verify-alert.py` (selftest). `lib-notify.sh` : payload en **argument** (sudo sans SETENV, `sudo -E` inutilisable) | `sudo rm -f /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` (aucun fichier G9 modifié par ailleurs) |
| 2026-10-12 15:30 | 4.5 | **dérive registre prouvée de bout en bout** | C-ID5 : dérive → `rc=1` + **payload JSON dans le salon ops** | source épinglée jellyfin modifiée `10.9`→`10.8` → `11-registry.sh --check --alert` = `DÉRIVE détectée` + `NOTIFY_OK 200` + `rc=1` ; source revertée + doc régénéré + commit → `aucune dérive (21 conteneurs, 13 constats)` + `rc=0`. 2 bugs corrigés au passage : **`set -e` + `[[ ]] && cmd`** qui stoppait le script silencieusement, et **comparation qui incluait la ligne horodatée** (dérive permanente) | `sed -i 's/10.8/10.9/' scripts/registry_inputs.py` puis `git checkout scripts/registry_inputs.py` |
| 2026-10-12 15:20 | 4.5 | **chaîne de notification** installée et vérifiée de bout en bout sur le G9 | C-AP2, C-AP3 : payload **structuré** reçu, **auto-vérifié** | `g9-notify.py` + `g9-verify-alert.py` installés en `/usr/local/bin` (root 755, sauvegardes dans `/root/openfox-backup-20261012/`) ; 2 bugs corrigés : **préfixe `Bearer ` manquant** et **room URL-encodée** (`!…` → `%21…`) → `NOTIFY_OK 200` ; `g9-verify-alert.py 20` → `payloads JSON conformes: 1`, `VERIFY_OK` (les 9 lignes de test des sessions précédentes **auto-supprimées**) ; `lib-notify.sh` : payload en **argument** (`sudo` sans `SETENV` → `sudo -E` inutilisable) | `sudo rm -f /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` + `cp /root/openfox-backup-20261012/*` si besoin (aucun fichier G9 modifié par ailleurs) |
| 2026-10-12 15:25 | 4.5 | **dérive du registre prouvée de bout en bout** + runbook `registre.md` exécuté | C-ID5, C-AP3 : dérive → `rc=1` + **payload JSON dans le salon ops** ; runbook exécuté sur un KO simulé | source épinglée jellyfin modifiée `10.9`→`10.8` → `11-registry.sh --check --alert` = `DÉRIVE détectée` + `NOTIFY_OK 200` + `rc=1` ; **runbook suivi** : message lu (`runbook: RUNBOOKS/registre.md`) → `--check` → lecture du diff → cause identifiée = source du kit, pas le système → action = revert → `aucune dérive (21 conteneurs, 13 constats)` `rc=0`. 2 bugs corrigés : **`set -e` + `[[ ]] && cmd`** (script stoppé silencieusement) et **comparaison incluant la ligne horodatée** (dérive permanente) | `sed -i 's/10.8/10.9/' scripts/registry_inputs.py` puis `git checkout scripts/registry_inputs.py` ; rien sur le G9 |
| 2026-10-12 15:40 | 4.5 | **validation utilisateur tracée** pour les actions L3 sur le G9 (G11) | C-G11 : aucune exécution L2/L3 sans validation tracée | GO explicite de l'utilisateur sur le choix « je valide les sondes + digest sur le G9 (trace L3) » → les 2 lignes de crontab root (`*/15` sondes, `30 8` digest) et les 3 scripts `/usr/local/bin/g9-probes.sh`, `g9-notify.py`, `g9-verify-alert.py` sont **validés**. Preuves jointes : 13 sondes OK / 0 KO, `NOTIFY_OK 200`, dérive → alerte JSON → « aucune dérive ». **Aucune autre action L3 sur le G9** : Gitea, Vaultwarden, Nextcloud, Synapse, ufw, Docker non touchés | `sudo crontab /root/crontab.backup-20261012-1520` + `sudo rm -f /usr/local/bin/{g9-probes.sh,g9-notify.py,g9-verify-alert.py}` + `sudo rm -rf /var/lib/g9-probes` |
| 2026-10-12 17:05 | 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 16:10 | 4.5 | **3 notifications types reçues** dans le salon ops, event_ids relevés | C-MX4 : backup OK, backup KO, sonde KO | `restic-notify.sh` (prose, canal famille) : `BACKUP OK (test lecture seule C-MX4) : snapshot 06a7e3b8 2026-10-12 12:05, 541.4 GiB, 505836 entrees photos, restauration sha256 identique a la source .25` → `$U1flqaCJiTEwWSa5H14mMZLoMZ0fekelksXMXC6VEP8` ; `BACKUP KO (test provoque, 0 donnee touchee) : verification du canal d alerte C-MX4 - aucun backup reel en echec` → `$Frb22qftexOLlHl0L8dPAOdKJawZ-DOnLAJ0041zLfg` ; sonde applicative KO provoquée (`--selftest`, payload JSON) → `$Hiks3d6fThwsbxOt8wIe38Plu3aLfAtFfCrEwIzOpUU`. KO backup **réel** aussi présent dans l'historique : `[G9 backup] BACKUP KO : aucun snapshot depuis 36 h sur le G9` → `$GmUPzEtaYuW6h7KBB258oTYzx_WcRDdSOr6hxVfd73Q`. Mode `--list` ajouté à `g9-verify-alert.py` (lecture seule, GET /messages) pour relever les event_ids | aucune donnée touchée ; `sudo rm -f /usr/local/bin/g9-verify-alert.py` ; messages conservés dans le salon |
| 2026-10-12 16:20 | 4.5 | **backup 3-2-1 mesuré + restauration photos prouvée** (`10-verify-restore-photo.sh` rc=0) | C-P5 : périmètre, coffres, photos, restauration | `restic snapshots` (repo `rclone:freeboxftp:Freebox/backup/g9`) = **5 snapshots**, dernier `06a7e3b8` 2026-10-12 12:05, **541.393 GiB**, sources `/data /home/folzo /etc`. Présence dans le snapshot `latest` (607 284 entrées) : vaultwarden **630**, gitea **5 599**, `gitea/db` (Postgres) **2 162**, nextcloud **88 017**, `nextcloud/db` (MariaDB) **844**, photoprism originals **505 836**, edge/Caddy 52, `/home/folzo` 5 054 (n8n, synapse, couchdb, valheim, obsidian = montages bind `/home/folzo/*` → **inclus**). **Médias 15 To + 5,5 To exclus par construction** : ils vivent sur le NAS `.25`, le périmètre du G9 ne contient que 564 Go (`/data/docker` + `/data/famille`). Restauration : sha256 restauré **identique** à la source `.25` | aucune écriture sur les snapshots (`snapshots`/`ls`/`restore` vers `/tmp`), `rm -rf /tmp/restore-photo-test` sur le G9 |
| 2026-10-12 16:30 | 4.5 | `PHOTOS-DOUBLE-RUN.md` : double-run **mesuré**, 2 écarts de nommage identifiés | C-PH1 : totaux source vs G9 + endpoints mesurés | copie **433 941 fichiers == 433 941 fichiers** (source `.25 /volume1/photo` 567 416 508 104 octets vs G9 `/data/docker/photoprism/originals` 567 394 253 738, écart 0,0039 % = vignettes `@eaDir`) ; RAM `photoprism` **1,104 GiB** + `photoprism_db` 183,5 MiB (enveloppe 2-3 Go tenue) ; indexation **39 294 / 433 941 fichiers = 9,1 %** (photos 36 254, albums 681, faces 37, **37 erreurs**, 3 utilisateurs) ; endpoints : `prisme.folzocorp.fr` DNS OK mais **0 route Caddy → 000**, `photos.folzocorp.fr` route J5040 → **`192.168.1.24:2342` = PhotoPrism** (307) au lieu du NAS, web DSM `.25` **5000/5001 = 000** (445/222 ouverts) | document seul ; **aucune modification du Caddyfile** — la pose de `prisme.` et le repointage de `photos.` sont des actions L3 (G11/G6) **soumises à validation** |
| 2026-10-12 16:35 | 4.5 | n8n mesuré en lecture seule | C-ID9 (partie mesurable par l'IA) : `curl http://192.168.1.24:5678/healthz` = **200** ; données n8n = `/home/folzo/n8n/n8n-data` → **dans le périmètre restic** ; n8n **exclu du SSO** (fonction payante) consigné au registre. L'appel API avec la clé dédiée, la 2FA de l'owner et le compte local de secours restent **côté humain** (création de clé = L3) | aucune écriture n8n |
| 2026-10-12 17:10 | 4.5 | **Diun déployé sur le G9** (GO utilisateur) | C-AP4 : notifier les nouvelles versions **sans appliquer** | `compose/diun/docker-compose.yml` + `diun.yml` (kit) → `providers.docker.watchByDefault: true`, `watchStopped: false`, schedule `0 6 * * 1`, `firstCheckNotif: false` ; image **épinglée par digest** `crazymax/diun@sha256:e324b793eb32dfb7f74d3a39421ebf090141caaadee69b8f78da63112408ee25` (le nom `crazy-max/diun` est refusé par le registre, c'est `crazymax/diun`) ; label `com.centurylinklabs.watchtower.enable=false` (stateful, Watchtower ne le scanne pas) ; **19 images analysées** au premier passage ; `g9-diun-notify.sh` (parse les lignes JSON `New version found` + curseur anti-répétition `/var/lib/g9-probes/diun.cursor`) installé en `/usr/local/bin` + cron `20 6 * * *` ; `--selftest` → `NOTIFY_OK 200`. **Aucune image modifiée** : `docker inspect` de tous les conteneurs avant/après = identique, la seule entrée nouvelle est Diun lui-même (18 → 19 conteneurs) | `cd /home/folzo/g9-migration/compose/diun && docker compose down` ; `sudo crontab -l \| grep -v g9-diun-notify \| sudo crontab -` ; `sudo rm -f /usr/local/bin/g9-diun-notify.sh` ; `sudo rm -rf /data/docker/diun` |
| 2026-10-12 17:15 | 4.5 | **`prisme.folzocorp.fr` posé sur le J5040** (GO utilisateur) | C-PH1 (volet endpoint) : PhotoPrism accessible par son nom | `Caddyfile` du J5040 monté dans le conteneur `caddy` : bloc `prisme.folzocorp.fr { import folzocorp; reverse_proxy 192.168.1.24:2342 }` ajouté **au-dessus** de `photos.` ; sauvegarde `Caddyfile.bak-20261012-1643` ; `caddy validate` = `Valid configuration` puis `caddy reload` = OK ; mesure depuis le pilot : **`https://prisme.folzocorp.fr/` = 200** (certificat Let's Encrypt émis par ACME DNS-01 OVH, `curl` **sans** `-k`). **`photos.` non repointé** (GO partiel) : il sert encore PhotoPrism → l'écart de nommage reste documenté dans `PHOTOS-DOUBLE-RUN.md` | `cp /home/folzo/g9-migration/compose/edge-j5040/Caddyfile.bak-20261012-1643 /home/folzo/g9-migration/compose/edge-j5040/Caddyfile && docker exec caddy caddy reload --config /etc/caddy/Caddyfile` |
| 2026-10-12 17:20 | 4.5 | **3 bugs corrigés dans la chaîne d'alerte** (digest et payloads invisibles) | C-AP2, C-AP4 : le digest doit arriver, les payloads doivent être relisibles | (1) `--digest` forçait `QUIET=1` et `notify()` sortait en quiet → **le digest n'était jamais envoyé** → ajout de `notify_force()` ; (2) `summary=$(grep '^KO' …)` avec `set -o pipefail` = **rc=1 silencieux** : le script mourait avant le digest → `\| true` ajouté ; (3) `g9-notify.py` publiait en type **`m.text`** (type hérité) alors que `g9-verify-alert.py` ne lisait que `m.room.message` → payloads **envoyés (200) et invisibles** ; type unifié en `m.room.message` + listing acceptant les deux. Ajout `g9-verify-alert.py --list N` (event_ids). Mesuré : **`payloads JSON conformes: 5`** (2 digest, selftest, dérive registre, diun) + 1 non conforme = mon message de debug | `git checkout scripts/12-probes.sh scripts/g9-notify.py scripts/g9-verify-alert.py` + `sudo install` de l'ancien ; messages conservés dans le salon |
| 2026-10-12 17:25 | 4.5 | **compose du kit versionnés** : les briques déclarées hors git sont rentrées dans le dépôt | C-ID1 (fiabilité du registre), C-ID5 : la colonne « kit » était fausse (9 conteneurs marqués hors kit) | import dans `g9-migration/compose/` des définitions réelles qui vivaient hors kit : `matrix/` (`/home/folzo/matrix`), `g9-queue/` (`/home/folzo/docker-compose.yml` : n8n, watchtower, couchdb, obsidian), `valheim/`, `diun/`, + `edge-j5040/Caddyfile` ; **0 secret importé** (les `secrets/` du G9 sont en 700 root, les compose ne contiennent que des `${VAR}` et des chemins `secrets_file`) ; les fichiers du kit déjà versionnés **ne sont pas écrasés** (la copie live n'écrase pas le kit : la dérive doit rester visible). Constats automatiques **14 → 7** ; restent 7 vrais constats : 4 images `:latest` non épinglées (n8n, element, synapse, obsidian), `g9/valheim` dont le projet compose s'appelle `compose`, `j5040/sweet_feynman` sans projet compose et sans label watchtower | `git rm -r g9-migration/compose/{matrix,g9-queue,valheim,diun}` (aucun conteneur touché) |
| 2026-10-12 17:35 | 4.5 | bug **fausse dérive permanente** corrigé dans `11-registry.sh --check` | C-ID5 (fiabilité) : `--check` répondait DÉRIVE alors que rien n'avait bougé | la ligne `<!-- GÉNÉRÉ … ts=… -->` est à la **ligne 9** (elle suit le bloc de citations d'en-tête), et la comparaison retirait les lignes **1 à 4** → l'horodatage restait dans le corps comparé = **dérive à chaque passage**. Retrait par motif `grep -v '^<!-- GÉNÉRÉ PAR'` (plus dépendant de la position). **Dérive re-prouvée avec la comparaison corrigée** : `compose/jellyfin/docker-compose.yml` `10.9`→`10.8` → `DÉRIVE détectée (8 constats)` + `NOTIFY_OK 200` + `rc=1` ; revert → `aucune dérive (22 conteneurs, 7 constats)` + `rc=0` | `git checkout g9-migration/scripts/11-registry.sh` (compose reverti par sed, aucun conteneur touché) |
| 2026-10-12 17:55 | 4.5 | **grille d'exposition validée** avec l'utilisateur, mesurée en entier | C-EX1 : `ufw status numbered` complet (.24 = 25 règles, .27 = 12 règles) | **0 règle posée** (G6 respecté). Mesures nouvelles : `OpenSSH ALLOW IN Anywhere` est présent sur **.24 ET .27** (règle [1] sur les deux) ; `443/tcp Anywhere` sur le G9 est la règle [22] « seul port exposé WAN » de l'edge T4.6, et **il n'existe aucune règle LAN pour 443 sur le G9** → supprimer cette règle couperait aussi `git./cloud./media.` **depuis la maison** ; `tailscaled` = **inactive sur le G9**, actif **seulement sur le pilot** ; le pilot et le G9 sont derrière le **même NAT** (IP publique unique `82.65.198.114`) → ce qui est joignable dépend des **redirects Freebox**, pas de ufw seul ; CouchDB 5984 est **déjà LAN-only** (ufw règle 6), le reste du risque = absence d'auth sur le LAN. 5 décisions rendues + **qui pose le geste** (1, 2, 3, 5 = toi ; 4 = moi sur GO) | document seul ; `git checkout EXPOSITION-INTERNET.md` ; **aucune commande ufw exécutée** |
| 2026-10-12 18:10 | 4.5 | **durcissement pare-feu exécuté sur GO explicite** (SSH + 443 LAN-only, Tailscale coupé) | C-EX1 : les 5 décisions sont rendues **et** les 3 gestes réseau posés par l'utilisateur, exécutés par moi avec `scripts/14-firewall-lan-only.sh` | **.24** : `[1] 443/tcp ALLOW IN 192.168.1.0/24` + `[2] 22/tcp ALLOW IN 192.168.1.0/24` insérés **avant** les suppressions, puis suppression de `[1] OpenSSH Anywhere`, `[24] OpenSSH (v6)`, `[22] 443 Anywhere`, `[25] 443 (v6)` → **plus aucune règle `Anywhere` sur le G9** (25 → 23 règles). **.27** : `[1] 22/tcp from 192.168.1.0/24` puis suppression `[1] OpenSSH Anywhere` + `[10] OpenSSH (v6)` ; **règles WAN de l'edge conservées** (`80`, `443`, `51820` v4+v6). **Pilot** : `systemctl disable --now tailscaled` (il n'était actif **que** sur le pilot ; G9 = inactive). Vérifié après : `ssh .24` OK, `ssh .27` OK, `git ls-remote gitea` OK, `https://git.folzocorp.fr/api/v1/version` (LAN) **200**, `https://cloud.folzocorp.fr/` (WAN) **200**, `https://prisme.folzocorp.fr/` **200**. Snapshots `ufw status numbered` conservés avant (25 règles .24 / 17 lignes .27) | rollback : `sudo ufw insert 1 allow proto tcp from any to any port 22` et `… port 443` sur .24 (+ v6), idem 22 sur .27 ; `sudo systemctl enable --now tailscaled` ; snapshots dans `/tmp/ufw-24-before.txt` et `/tmp/ufw-27-before.txt` sur le pilot |
| 2026-10-12 18:20 | 4.5 | **faux positif corrigé : CouchDB n'est PAS sans auth** | C-EX1 (exactitude des constats), C-AP1 (fiabilité des sondeurs) | la sonde lisait `GET :5984/` = 200 → lu « sans auth ». Mesure directe : **`GET /_all_databases` = 401** et **`GET /_active_tasks` = 401** ; `compose/g9-queue/docker-compose.yml` déclare bien `COUCHDB_USER` + `COUCHDB_PASSWORD=${COUCHDB_PASSWORD}`. `/` est public **par conception** dans CouchDB. **Décision : ne pas recréer le conteneur** pour publier `127.0.0.1:5984` — gain quasi nul (ufw LAN-only + auth déjà en place) et **casse probable de la sync Obsidian** si le client pointe sur `http://192.168.1.24:5984`. Échange mauvais → statu quo, consigné | aucun (aucun conteneur recréé, aucun fichier modifié sur le G9) |
| 2026-10-12 18:40 | 4.5 | **balayage d'identités en lecture seule** (`15-identity-sweep.sh`, `16-identity-sweep-sqlite.sh`) → §7 du registre | C-ID2 (preuve de la moitié B), C-ID4/7/9, C-MX1/3 : l'existant est **mesuré**, plus supposé | **Gitea** : 4 comptes (`Master`, `Folzo`, `Antoine`, `g9ops`), `IsAdmin`=2, **2FA = false sur les 4** → `gitea admin user list` (CLI local, rc=0). **Nextcloud** : 8 comptes, groupes `admin`={Effi, Master} et `Famille Folzo`={Audrey, Folzo, carla, ewan, lola, noah}, providers 2FA installés (`twofactor_totp`, `backupcodes`, `nextcloud_notification`). **Synapse** : **2 comptes (`@ops`, `@ops-bot`), `admin` = 0**, locked 0, deactivated 0, 14 devices → **aucun admin : C-MX1 et C-MX3 bloqués à la racine, aucune porte de sortie**. **n8n** : 1 compte `effi@openfox.local` (disabled=0) + basic auth déclarée. **PhotoPrism** : 3 comptes. **Vaultwarden** : `ADMIN_TOKEN_FILE` (jeton en fichier, pas en env en clair). **CouchDB** : 401 sans identifiants → auth active | 0 écriture : commandes `admin user list`, `occ user:list/group:list/twofactorprovider:states`, sqlite en `mode=ro`, `mariadb -N -e select`. Aucun compte créé, aucun privilège changé |
| 2026-10-12 18:45 | 4.5 | trou **Synapse sans admin** : porte de sortie outillée, **non exécutée** | C-MX1, C-MX3, C-ID4 : la brique n'a pas de compte admin → ni création de comptes famille, ni réinitialisation possible | `scripts/17-synapse-admin.sh` : inscription signée via `registration_shared_secret` + `POST /_synapse/admin/v1/register` (secret lu à l'exécution dans le conteneur, **jamais affiché ni recopié**, mot de passe saisi masqué, longueur ≥ 12, confirmation `CREER` pour un admin). **Vérifié jusqu'à la dernière étape sans rien créer** : `GET /_synapse/admin/v1/register` → nonce reçu, `registration_shared_secret` présent dans `homeserver.yaml` (`FOUND`). Procédure consignée dans `RUNBOOKS/matrix.md` + §7.1 du registre. **Exécution = toi** (création de compte = interdit permanent G11) | `docker exec synapse python3 -c "… update users set admin=0 where name='<compte>'"` (désactivation propre, jamais de purge) |
| 2026-10-12 18:30 | 4.5 | **dumps logiques cohérents des 3 bases** (`18-db-dumps.sh`, GO utilisateur) | C-P5 : corriger le trou mesuré « copie chaude = complète en octets, pas garantie rejouable » | `pg_dump` Gitea (auth **socket `trust`** mesurée dans `pg_hba.conf` → **aucun mot de passe sur une ligne de commande**) = **80 543 o** gz, **116 `CREATE TABLE`** ; `mysqldump --single-transaction --routines` Nextcloud = **5,9 Mo** gz, **317** objets, se termine par `-- Dump completed` ; idem PhotoPrism = **89,6 Mo** gz, **39** objets, `-- Dump completed`. **Aucune brique stoppée** (`--single-transaction` = instantané InnoDB cohérent). Dumps écrits sous `/data/docker/<brique>/dumps/` → **déjà dans le périmètre restic** (visibles au prochain backup de 03:30). **Rejouabilité prouvée pour Gitea** : base de test `gtest_restore` créée, dump chargé, **116 tables restaurées** (== 116 du dump), base **supprimée** (`pg_database` = gitea_db/postgres/template0/template1, vérifié) | dumps : `rm -f /data/docker/{gitea,nextcloud,photoprism}/dumps/*-20261012-1825Z.sql.gz`. **Test MariaDB complet non fait** : le dump Nextcloud contient `USE \`nextcloud\`` → le charger tel quel écraserait la production ; il faut un serveur jeton (GO séparé) |
| 2026-10-12 18:35 | 4.5 | **hub de monitoring posé hors du G9** (J5040) | C-AP5 : le trou mesuré était « les sondes tournent depuis le G9 → elles meurent avec lui » ; l'ancien plan (05-beszel.sh) mettait le hub sur **.25, la machine à mourir en Phase 6** | `compose/beszel-hub/docker-compose.yml` (kit) → `henrygd/beszel@sha256:6b39a8b64f9b15d00a25e7683e31a93c46161182a54f62713525107064e10599` (épinglé digest, label watchtower=false, base dans `/home/folzo/beszel-hub/data`) ; `docker compose up -d` ; **règle ufw LAN-only ajoutée** : `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 8090 comment "beszel hub LAN-only"` → **jamais joignable depuis le WAN** ; mesure : `http://192.168.1.27:8090/` = **200** ; superuser créé par CLI (`/beszel superuser create`, **mot de passe généré sur la machine, écrit en 600 dans `/home/folzo/beszel-admin.cred`, jamais affiché, jamais commité**). **Reste** : les agents (.24/.27) + le token universel, qui se crée dans l'UI (`/settings/tokens`) — la voie API PocketBase n'est pas trouvée (`/api/collections/_superusers/auth-with-password` = 404, `…/authenticators/superusers/email` = « resource not found », `/api/collections/_superusers/records` = 403 → API bien présente, chemin d'auth non identifié) | `cd /home/folzo/g9-migration/compose/beszel-hub && docker compose down` ; `sudo ufw delete <n° 8090>` ; `rm -f /home/folzo/beszel-admin.cred` ; `rm -rf /home/folzo/beszel-hub` |
| 2026-10-12 19:00 | 4.5 | **compte admin Synapse créé et réparé** (`26-synapse-admin-repair.sh`, GO utilisateur) | C-MX1/C-MX3/C-ID4 : la brique n'avait **aucun admin** (`admin` = 0 sur `@ops` et `@ops-bot`) → ni création de comptes famille, ni réinitialisation possible | `POST /_matrix/client/v3/register` (`m.login.dummy`) → compte créé mais Synapse **ignore le localpart** (mxid aléatoire) et le bug d'arguments de `21-…` l'a laissé **`password_hash` = NULL** → admin=1 **inutilisable** (login « Invalid username or password », et `/_synapse/admin/v1/register` signé = **`HMAC incorrect`**, secret jamais recopié ni affiché). Réparation mesurée : **`bcrypt` 5.0.0 est dans l'image synapse** → hash bcrypt 12 du mot de passe du credential écrit en base (mot de passe **jamais en argv** : stdin + `curl -K` fichier 600 + tmpdir 700) → **`docker restart`** (Synapse ne vide pas ses caches sqlite sur écriture directe) → login OK → **`PUT /_synapse/admin/v2/users/@folzo:192.168.1.24`** `{password, admin:true}` (l'API v2 **crée par PUT**, POST = `Unrecognized request`) → compte temporaire `admin=0`, `deactivated=1`, `password_hash=NULL`. **Preuves** : `--verify folzo` = **login OK** ; `--admin-check folzo` = **API admin OK** (3 utilisateurs, 1 admin) ; lecture seule finale : 4 comptes, 1 admin actif, 1 désactivé. `27-synapse-family-users.sh` ajouté (`--dry` mesuré = 6 PUT planifiés, `--revoke` = désactivation) | snapshot sqlite **avant** toute écriture : `/data/homeserver.db.bak-20261012-1900` (rollback = arrêter `synapse`, restaurer le fichier, redémarrer) ; compte aléatoire conservé désactivé (0 purge) ; `rm -f /tmp/2[567]-*.sh` sur le G9 |
| 2026-10-12 19:20 | 4.5 | **6 critères retoqués par le vérificateur corrigés dans les docs** | C-ID2, C-ID8, C-MX5, C-PH2, C-WA1 (+ péages périmés) | **C-ID2** : ligne **PhotoPrism ajoutée en §2** (16ᵉ ligne .24) avec les 3 comptes **mesurés** (`photoprism.auth_users` : `admin`, `visitor`, **1 sans nom**) + CLI réelle (`photoprism users ls\|show\|mod\|add\|rm`, `auth reset --yes`) ; §7 « à nommer » supprimé ; ligne **Synapse réécrite** (3 comptes, `@folzo` admin, PUT et pas POST) ; **convention `infra/<brique>/break-glass` ajoutée en §1** ; **C-ID8** : §2.1 **placement des 3 exemplaires** (sur place / hors site / hors ligne, aucun 4ᵉ), §2.2 **4 garde-fous** (2 personnes max, personne de confiance = **lecture seule**, **1 code de déverrouillage par personne**, **jamais par mail/cloud**), §4 test annuel = **6 points** tabulés ; **C-MX5** : **0 occurrence** de « remplace WhatsApp » (S3.1 #8, S13.3 C.1, `GUIDE-MATRIX.md` §4 corrigés) + **décision + 4 interdictions** écrites en S8.2 (mot « interdiction » présent) + numérotation corrigée (**S14.5** = grille Matrix/WhatsApp) ; **C-PH2** : `PHOTOS-DOUBLE-RUN.md` **§6 grille des 10 requêtes** (protocole + colonnes à remplir + classement) et **§7 procédure d'auto-upload** (10 pas, anti-effacement, EXIF, seuil de réussite 7 jours, format de journal, rollback) ; **C-WA1** : **§6 = 17 questions de Go/No-Go** avec preuve exigée par ligne + **risque de bannissement détaillé** (2 voies : signalements des correspondants + détection automatisée, **aucun seuil public**, échelle ban temporaire → permanent → suppression du compte, probabilité **non chiffrable**) ; péages périmés corrigés : §4 de `PHOTOS-DOUBLE-RUN.md` (`prisme.` = **200** avec la route du 17:15), `BACKUP-PERIMETRE.md` §6 (**dumps faits et mesurés** + rejouabilité Gitea, plus « à faire »), `EXPOSITION-INTERNET.md` **§7 = choix de machine J5040 pour l'IdP** (5 points + déclencheur + rollback) | `git checkout` des 7 fichiers (aucun effet sur les briques) |
| 2026-10-12 19:25 | 4.5 | **salon famille Matrix créé** (`28-synapse-create-room.sh`, GO utilisateur) | C-MX2 : le vérificateur mesure **1 seul salon** (`!mp7MFx9Q…` = ops) — le salon famille annoncé n'existait pas | `POST /_matrix/client/v3/createRoom` (token admin `@folzo`) → **`!9hTrGcLp3bZzw_cXtJEYs7eRfWcr4yUTZe6RTFEr27M`**, nom `Famille`, alias **`#famille:192.168.1.24`**, `preset: private_chat`, `visibility: private`, invites `@ops` + `@ops-bot`. **Voie admin v1 mesurée morte** : `POST /_synapse/admin/v1/create_room` = **404 `M_UNRECOGNIZED`** sur cette version (Matrix v1.12). Preuves : `--list` = **2 salons** en base (`!mp7MFx9Q…` créateur `@ops-bot`, `!9hTrGcLp…` créateur `@folzo`), **résolution d'alias** `#famille → !9hTrGcLp…`, 0 compte créé, **0 message envoyé**. `--dry` et `--purge` (rollback) outillés. Constat consigné : `joined_members` = 1 → les invites sont **en attente**, les adultes rejoindront depuis Element | `CONFIRM=PURGER bash scripts/28-synapse-create-room.sh --purge "!9hTrGcLp3bZzw_cXtJEYs7eRfWcr4yUTZe6RTFEr27M"` (`DELETE /_synapse/admin/v1/rooms/<id>`) ; `rm -f /tmp/28-*.sh` sur le G9 |
| 2026-10-12 19:40 | 4.5 | **2 critères retoqués par la vérification indépendante corrigés** | C-ID7, C-MX5 | **C-ID7** : `REGISTRE-IDENTITES.md` **§4.5 = journal de bascule Gitea en 4 colonnes** (compte / rôle **avant mesuré** / rôle **après + 2FA** / **commande + rollback**) pour `Master`, `Folzo`, `Antoine`, `g9ops`, avec le **constat mesuré** que le CLI Gitea de cette version **ne peut pas changer un rôle** (`gitea admin user` = `create`, `list`, `change-password`, `delete`, `generate-access-token`, `must-change-password`) → rôle via **UI** ou SQL, token à scopes `write:repository`, ancien token révoqué, preuve de fin (`IsAdmin` = 1 humain, 2FA = true, `g9ops` = 0, `git ls-remote` sans `~/.git-credentials`) ; §4 item 4 (Synapse) **réécrit sur la voie mesurée** (HMAC KO, bcrypt + restart + PUT v2) ; **C-MX5** : `DOSSIER-SOFTWARE.md` **ligne 192 (§3.3)** « **Remplace WhatsApp** » → « **complément** de WhatsApp, **pas un remplacement** » ; l'auto-contrôle de l'interdiction 1 est réécrit pour ne plus contenir la formule et donne la commande de contrôle (`grep -in "rempla.*whatsapp"`) | `git checkout DOSSIER-SOFTWARE.md g9-migration/REGISTRE-IDENTITES.md` (aucun effet sur les briques) |
| 2026-10-12 19:50 | 4.5 | **auto-contrôle de l'interdiction 1 rendu autosuffisant** (revue de code, finding #3) | C-MX5 : le contrôle `grep -in "rempla.*whatsapp"` échouait **sur ses propres lignes légitimes** (la définition de l'interdiction et l'énoncé du contrôle), donc le contrôle était faux | S8.2 : le contrôle devient **une commande à sortie vide** : `grep -in "remplace whatsapp" … \| grep -vi "pas\|complément\|interdiction\|ne présente"` = **0 ligne mesurée** (les mentions légitimes sont toutes en négation : S3.1 #8, S3.3, S8, S8.2, S13.3 C.1, S14.5, changelog, journal historique) | `git checkout DOSSIER-SOFTWARE.md` |
| 2026-10-13 19:30 | E3 | **bascule DHCP faite (GO utilisateur)** | Freebox Delta reset usine (bridge complet impossible : J5040 mono-NIC) + DHCP Delta coupé + dnsmasq .27 actif | DHCP servi par .27 (baux 37 appareils = mêmes IP, G9→.24, PING/SSH OK, 18 conteneurs healthy) ; **cause racine DHCP muet = ufw .27 bloquait UDP 67/68** (règle ajoutée) ; DNS1=.27 (AdGuard) conservé | couper dnsmasq + rallumer DHCP Delta (baux Delta recréés d usine, à reprendre depuis /srv/dnsmasq/leases) |
| 2026-10-13 19:35 | E4 | **bascule WAN faite (GO utilisateur)** | Redirections Delta TCP 80/443 + UDP 51820 → .27 | WAN via IP publique 82.65.198.114 : photos 307 / cloud 302 / pass 200, cert LE valide (YE1, CN=photos.folzocorp.fr) ; edge G9 **démonté** (compose down + ufw 443 retiré) ; zone OVH A* = IP ok ; ménage : redirections d usine obsolètes Delta à purger (utilisateur) | supprimer redirections + remonter edge G9 (compose up -d) |
| 2026-10-13 19:40 | E2 | VM AdGuard .1 | reset usine Delta a isolé la VM (bridge VM derrière Delta, injoignable) | VM éteinte puis rallumée par utilisateur ; coupe AdGuardHome = via UI Delta (action utilisateur) ; DNS LAN = .27 (aucun impact) | redéploiement VM + AdGuardHome start |
| 2026-10-13 19:42 | E3 | switch 10G SKS7300 | port SFP+ Delta éteint par reset usine → G9 isolé (IPv6 link-local seul) ; câble remplacé par RJ45 Delta↔switch (1G) | G9 visible ; SSH switch (admin, telnet) dispo pour remettre les VLANs 10G (untagged trunk uplink) à faire | re-câbler SFP+ + réactiver port fibre Delta |
| 2026-10-13 20:00 | E4 | **openfox.folzocorp.fr** ajouté au Caddy .27 | Besoin utilisateur : suivre la config depuis son téléphone | reverse_proxy → 192.168.1.11:10369 ; cert LE émis ; 200 via LAN (.27) ET WAN (IP publique) ; fix mount stale = `compose up -d --force-recreate caddy` (rsync du Caddyfile ne suffit pas, inode monté) | retirer le block + recreate |
| 2026-10-13 20:30 | 4 | secrets PhotoPrism **644→600** (finding vérification) | Dossier 755/fichiers 644 = lisibles par tout utilisateur local (03b pose 644, 07d faisait chmod 755) | `chmod 700 secrets` + `chmod 600 secrets/*` sur .24 (admin/dbpass/rootpass/photoprism.env tous 600 root) ; conteneurs up intacts (env chargés au start, db lit /run/secrets en root) ; 07d corrigé (chmod 700 + sh -c 600) | chmod 644 (inverser, non recommandé) |
| 2026-10-13 21:00 | 4 | secrets PhotoPrism — durcissement complété (review) | Finding : la copie kit .24 restait 644 | chmod 700/600 appliqué aux DEUX chemins sur .24 : /data/docker/photoprism/secrets ET /home/folzo/g9-migration/compose/photoprism/secrets (ls vérifié 700 root + fichiers 600) ; 07d = boucle sur les 2 chemins + `find -maxdepth 1 -type f -exec` (robuste dotfiles/dossier vide) + commentaire de convention (600 = root-only, ne pas copier vers briques entrypoint non-root, 03b garde 644 voulu) | re-chmod 644 (non recommandé) |
+17 -10
View File
@@ -68,22 +68,29 @@ ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
# Services LAN-only (G9 = LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
# Ports des services RÉELS sur .24 (n8n, synapse, element, couchdb, obsidian) +
# briques famille futures + Valheim (TCP+UDP). Accessibles depuis le LAN uniquement.
for port in 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980; do
# Services LAN-only (LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
# Listes paramétrables (défaut = G9 .24) : J5040 edge = TCP_LAN="53 3000 8081"
# UDP_LAN="53" WAN_TCP="80 443" WAN_UDP="51820".
TCP_LAN="${TCP_LAN:-2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980}"
UDP_LAN="${UDP_LAN:-2456 2457 2458}"
WAN_TCP="${WAN_TCP:-443 80}"
WAN_UDP="${WAN_UDP:-}"
for port in $TCP_LAN; do
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
done
for port in 2456 2457 2458; do
for port in $UDP_LAN; do
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
ufw allow from 192.168.1.0/24 to any port "$port" proto udp
done
# T4.6 edge (Caddy) : SEUL port exposé WAN = 443 (+ 80 = redirect ACME/HTTP→HTTPS).
# L'exposition WAN des briques passe par l'edge uniquement (S9.2/S9.3, default deny incoming).
ufw allow 443/tcp comment 'edge T4.6 (seul port exposé WAN)'
ufw allow 80/tcp comment 'edge T4.6 (redirect ACME)'
# Edge : ports exposés WAN (443 service + 80 redirect ACME/HTTP→HTTPS ; 51820 = WireGuard).
for port in $WAN_TCP; do
ufw allow "$port/tcp" comment "edge WAN ($port)"
done
for port in $WAN_UDP; do
ufw allow "$port/udp" comment "edge WAN ($port)"
done
ufw --force enable
journal "ufw activé" "Firewall LAN-only (G6)" "deny incoming, allow outgoing, OpenSSH + services LAN-only 192.168.1.0/24 (TCP 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980 + Valheim 2456-2458 TCP/UDP) + edge 443/80 exposés WAN (T4.6)" "ufw disable"
journal "ufw activé" "Firewall LAN-only (G6)" "deny incoming, allow outgoing, OpenSSH + TCP_LAN=[$TCP_LAN] + UDP_LAN=[$UDP_LAN] (LAN-only) + WAN_TCP=[$WAN_TCP] WAN_UDP=[$WAN_UDP]" "ufw disable"
# --- 3. unattended-upgrades ---
log "unattended-upgrades : activation"
+18 -8
View File
@@ -59,10 +59,11 @@ log "source .25 : $SRC_COUNT fichiers, $SRC_BYTES octets"
# ---------------------------------------------------------------- 1. backup G2
log "=== 1/5 BACKUP restic (G2) ==="
if $SSH_G9 "sudo restic snapshots 2>/dev/null | grep -q '$(date '+%Y-%m-%d')'"; then
SNAP_TODAY=$(date '+%Y-%m-%d')
if $SSH_G9 "sudo sh -c 'set -a; . /etc/restic.env; set +a; restic -r \"\$RESTIC_REPOSITORY\" snapshots 2>/dev/null' | grep -q '$SNAP_TODAY'"; then
log " ⏭ snapshot du jour déjà présent — backup complet sauté (idempotence)"
else
$SSH_G9 "sudo /usr/local/bin/restic-daily.sh >/dev/null 2>&1; sudo restic snapshots 2>/dev/null | tail -2"
$SSH_G9 "sudo /usr/local/bin/restic-daily.sh >/dev/null 2>&1; sudo sh -c 'set -a; . /etc/restic.env; set +a; restic -r \"\$RESTIC_REPOSITORY\" snapshots 2>/dev/null' | tail -2"
journal "backup restic pré-migration" "Garde-fou G2 avant import PhotoPrism" "snapshot restic Freebox (log /var/log/restic-daily.log)" "restic forget <snapshot> && restic prune"
fi
@@ -75,10 +76,14 @@ if [[ "${SKIP_COPY:-0}" != "1" ]]; then
t0=$(date +%s)
# Progression : tar GNU du G9 émet un pointage sur stderr tous les 50 000 enregistrements
# (stderr relayé par ssh) → la copie de 530 Go n'est pas silencieuse pendant des heures.
if ! $SSH_XP "sudo tar cz -C '$XP_PHOTO' ." | $SSH_G9 "tar xz --checkpoint=50000 --checkpoint-action=echo='[copie] %n enregistrements' -C $G9_DIR/originals"; then
# PAS de gzip : photos déjà compressées (JPEG/RAW), gzip sur le CPU du NAS = 10 Mo/s mesuré.
# Flux brut = lié par le réseau (~100 Mo/s).
if ! $SSH_XP "sudo tar c -C '$XP_PHOTO' ." | $SSH_G9 "tar x --checkpoint=50000 --checkpoint-action=echo='[copie] %n enregistrements' -C $G9_DIR/originals"; then
echo "ERREUR : flux tar interrompu"; exit 1
fi
t=$(( $(date +%s) - t0 ))
# DSM arrive avec des modes 0000/2700 (ACL Synology) → les dirs sont illisibles par folzo.
$SSH_G9 "sudo chmod -R u+rwX $G9_DIR/originals"
log " copie terminée en ${t} s"
journal "copie photos .25 → G9" "530 Go, source en lecture seule (aucune écriture sur .25)" "tar/ssh relayé par le pilot, ${t} s ; $SRC_COUNT fichiers source" "rm -rf $G9_DIR/originals (source .25 intacte)"
fi
@@ -111,17 +116,22 @@ log "=== 4/5 STACK + SECRETS ==="
rsync -a --info=stats1 -e "$RSH_G9" --exclude "secrets" "$KIT_DIR/compose/$BRIQUE/" "$G9_USER@$G9_HOST:$KIT_ON_G9/compose/$BRIQUE/"
rsync -a -e "$RSH_G9" "$KIT_DIR/scripts/03b-secrets.sh" "$G9_USER@$G9_HOST:$KIT_ON_G9/scripts/"
$SSH_G9 "sudo bash $KIT_ON_G9/scripts/03b-secrets.sh >/dev/null"
$SSH_G9 "sudo rsync -a $KIT_ON_G9/compose/$BRIQUE/ $G9_DIR/ && sudo chmod 755 $G9_DIR/secrets"
$SSH_G9 "sudo rsync -a $KIT_ON_G9/compose/$BRIQUE/ $G9_DIR/"
# 600 root = valable uniquement si le conteneur lit /run/secrets EN ROOT ou via env_file compose (photoprism/mariadb).
# NE PAS copier ce pattern vers les briques à entrypoint non-root (n8n uid1000, Nextcloud www-data → 644 voulu, voir 03b).
for d in "$G9_DIR" "$KIT_ON_G9/compose/$BRIQUE"; do
$SSH_G9 "sudo chmod 700 $d/secrets && sudo find $d/secrets -maxdepth 1 -type f -exec chmod 600 {} +"
done
$SSH_G9 "sudo chown -R 999:999 $G9_DIR/db && sudo chmod 750 $G9_DIR/db"
# PhotoPrism n'accepte pas les *_FILE : env_file construit à partir des secrets (600, root, hors git).
$SSH_G9 "sudo sh -c 'umask 077; { echo PHOTOPRISM_ADMIN_PASSWORD=\$(cat $G9_DIR/secrets/admin); echo PHOTOPRISM_DATABASE_PASSWORD=\$(cat $G9_DIR/secrets/dbpass); } > $G9_DIR/secrets/photoprism.env' && sudo chown root:root $G9_DIR/secrets/photoprism.env"
$SSH_G9 "cd $G9_DIR && docker compose up -d && sleep 30 && docker compose ps --format '{{.Name}} {{.Status}}'"
$SSH_G9 "cd $G9_DIR && sudo docker compose up -d && sleep 30 && sudo docker compose ps --format '{{.Name}} {{.Status}}'"
# ---------------------------------------------------------------- 5. indexation + vérifs
log "=== 5/5 INDEXATION (progressive, décision #4) + VÉRIFS ==="
$SSH_G9 "curl -sf -o /dev/null -w 'photoprism HTTP %{http_code}\n' http://127.0.0.1:2342/api/v1/session/health"
$SSH_G9 "cd $G9_DIR && timeout $INDEX_TIMEOUT docker compose exec -T photoprism photoprism index 2>&1 | tail -6 < /dev/null || true"
$SSH_G9 "cd $G9_DIR && docker compose exec -T photoprism sh -c 'photoprism count 2>/dev/null | head -12' < /dev/null || true"
$SSH_G9 "curl -s -o /dev/null -w 'photoprism HTTP %{http_code}\n' http://127.0.0.1:2342/api/v1/health"
$SSH_G9 "cd $G9_DIR && timeout $INDEX_TIMEOUT sudo docker compose exec -T photoprism photoprism index < /dev/null 2>&1 | tail -6 || true"
$SSH_G9 "cd $G9_DIR && sudo docker compose exec -T db sh -c 'mariadb -u photoprism -p\"\$(cat /run/secrets/dbpass)\" photoprism -e \"SELECT COUNT(*) AS photos_indexes FROM photos\"' 2>/dev/null || true"
journal "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"
echo
+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."
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 09-j5040-dhcp.sh — E3 : DHCP sur le J5040 (dnsmasq) en préparation du mode bridge Freebox.
#
# RÔLE : dnsmasq = DHCP uniquement (DNS coupé : port=0, le DNS LAN = AdGuard .27:53).
# Les baux statiques sont lus depuis /srv/dnsmasq/leases (format: MAC,IP,nom — 1 par ligne),
# repris de la config DHCP Freebox (baux statiques existants, mêmes IP → G6 : pas de changement d'IP).
#
# ⚠️ BASCULE (G6) : ce script active le DHCP sur le J5040. La bascule Freebox (DHCP off /
# mode bridge) est une ACTION UTILISATEUR explicite. Rollback = couper dnsmasq + rallumer
# le DHCP Freebox (baux statiques Freebox intacts, rien n'y a été supprimé).
#
# Usage : sudo bash 09-j5040-dhcp.sh [interface_LAN] (défaut : enp1s0)
set -euo pipefail
IFACE="${1:-enp1s0}"
LEASES=/srv/dnsmasq/leases
log() { echo -e "\n[dhcp] $*"; }
[[ $EUID -eq 0 ]] || { echo "ERREUR : sudo"; exit 1; }
[[ -f "$LEASES" ]] || { echo "ERREUR : $LEASES absent (copier la liste des baux statiques Freebox, format MAC,IP,nom)"; exit 1; }
log "installation dnsmasq"
apt-get update -qq
apt-get install -y -qq dnsmasq
systemctl disable --now dnsmasq 2>/dev/null || true
log "config dnsmasq (DHCP only, DNS coupé)"
cat > /etc/dnsmasq.conf << EOF
interface=$IFACE
bind-interfaces
port=0
dhcp-range=192.168.1.100,192.168.1.240,12h
dhcp-option=6,192.168.1.27
dhcp-option=3,192.168.1.27
no-resolv
strict-order
dhcp-lease-max=256
dhcp-no-override
EOF
while IFS=, read -r mac ip name; do
[[ -z "${mac// }" || "$mac" == \#* ]] && continue
echo "dhcp-host=$mac,$ip,$name"
done < "$LEASES" >> /etc/dnsmasq.conf
dnsmasq --test && log "config dnsmasq valide"
systemctl enable --now dnsmasq
log "DHCP actif sur $IFACE (baux = $LEASES). Bascule Freebox = action utilisateur (mode bridge / DHCP off)."
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# 10-verify-restore-photo.sh — Critère 3 : preuve que le snapshot restic contient les photos
# et qu'un fichier photo se restaure à l'identique (sha256 vs source .25).
#
# Chiffrement MAINTENU (décision utilisateur 11/10) : clé de repo locale G9 (/etc/restic/repo.key),
# restore via sudo sur le G9. Source .25 en lecture seule (G4).
#
# Usage : sur la machine de pilotage (détenant la clé), ./10-verify-restore-photo.sh
set -euo pipefail
XP_USER="Master"; XP_HOST="192.168.1.25"; XP_PORT=222
XP_PHOTO="/volume1/photo"
G9_USER="folzo"; G9_HOST="192.168.1.24"
KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
G9_ORIG="/data/docker/photoprism/originals"
TARGET="/tmp/restore-photo-test"
JOURNAL="$(cd "$(dirname "$0")/.." && pwd)/journal.md"
SSH_XP="ssh -p $XP_PORT -i $KEY -o ConnectTimeout=10 $XP_USER@$XP_HOST"
SSH_G9="ssh -i $KEY -o ConnectTimeout=10 $G9_USER@$G9_HOST"
RST_PRELUDE='set -a; . /etc/restic.env; set +a'
log() { echo -e "\n[restore-test] $*"; }
journal() {
printf '| %s | 4 | %s | %s | %s | %s |\n' \
"$(date '+%Y-%m-%d %H:%M')" "$1" "$2" "$3" "$4" >> "$JOURNAL"
}
log "=== 1/3 snapshot g9 le plus récent ==="
SNAP=$($SSH_G9 "sudo bash -s" <<EOF
$RST_PRELUDE
restic -r "\$RESTIC_REPOSITORY" snapshots --tag g9 --json 2>/dev/null | jq -r '.[-1].id'
EOF
)
[[ "$SNAP" =~ ^[0-9a-f]{8,}$ ]] || { echo "ERREUR : snapshot introuvable"; exit 1; }
log "snapshot = $SNAP"
N=$($SSH_G9 "sudo bash -s -- '$G9_ORIG' '$SNAP'" <<'EOF'
set -a; . /etc/restic.env; set +a
restic -r "$RESTIC_REPOSITORY" ls "$2" 2>/dev/null | grep -c "$1"
true
EOF
)
[[ "${N:-0}" -gt 0 ]] || { echo "ERREUR : le snapshot $SNAP ne contient pas $G9_ORIG"; exit 1; }
log " ✅ snapshot contient $N entrées sous $G9_ORIG"
log "=== 2/3 restauration d'un fichier photo ==="
G9_META="${HOME}/migration-staging/photoprism/g9.meta"
if [[ -f "$G9_META" ]]; then
REL=$(cut -f1 "$G9_META" | grep -i '\.jpg$' | LC_ALL=C sort | tail -1)
REL="${G9_ORIG}/${REL#./}"
else
REL=$($SSH_G9 "find '$G9_ORIG' -type f -iname '*.jpg' | LC_ALL=C sort | tail -1")
fi
log "fichier test = ${REL#$G9_ORIG/}"
B64=$(printf '%s' "$REL" | base64 -w0)
$SSH_G9 "sudo bash -s -- '$B64' '$SNAP' '$TARGET'" <<'EOF'
set -a; . /etc/restic.env; set +a
rel=$(printf '%s' "$1" | base64 -d)
rm -rf "$3"
restic -r "$RESTIC_REPOSITORY" restore --target "$3" --include "*${rel##*/}" "$2" >/dev/null
EOF
$SSH_G9 "sudo bash -s -- '$B64' '$TARGET'" <<'EOF' || { echo "ERREUR : fichier restauré absent"; exit 1; }
rel=$(printf '%s' "$1" | base64 -d)
test -f "$2$rel"
EOF
log "=== 3/3 sha256 restauré vs source .25 ==="
H_SRC=$($SSH_XP "sudo bash -s -- '$B64' '$XP_PHOTO'" <<'EOF'
f=$(printf '%s' "$1" | base64 -d)
sudo sha256sum "$2${f#/data/docker/photoprism/originals}" | cut -d' ' -f1
EOF
)
H_RST=$($SSH_G9 "sudo bash -s -- '$B64' '$TARGET'" <<'EOF'
f=$(printf '%s' "$1" | base64 -d)
sudo sha256sum "$2$f" | cut -d' ' -f1
EOF
)
$SSH_G9 "sudo rm -rf '$TARGET'"
if [[ "$H_SRC" == "$H_RST" && -n "$H_SRC" ]]; then
log "✅ sha256 identique : $H_RST"
journal "test restauration photo depuis restic" "Critère 3 : preuve restore du snapshot incluant les photos" "snapshot $SNAP contient $N entrées sous $G9_ORIG ; restore 1 fichier ; sha256 restauré == source .25 (${H_RST:0:12}…) ; chiffrement maintenu (clé locale G9)" "rm -rf $TARGET (fait)"
else
log "❌ sha256 divergents : src=$H_SRC rst=$H_RST"
exit 1
fi
+144
View File
@@ -0,0 +1,144 @@
#!/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)"
# La ligne d'en-tête porte un horodatage : elle est exclue par motif (elle n'est pas
# à une ligne fixe, elle suit un bloc de citations), sinon tout --check signalerait
# une dérive permanente.
strip() { printf '%s' "$1" | grep -v '^<!-- GÉNÉRÉ PAR'; }
body_new="$(strip "$render")"
body_old="$(strip "$base")"
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"
+172
View File
@@ -0,0 +1,172 @@
#!/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
python3 /usr/local/bin/g9-notify.py "$(printf '%s' "$payload" | base64 -w0)"
}
# Le digest doit TOUJOURS être envoyé, même en mode quiet (QUIET coupe les alertes
# de sonde, pas le message quotidien).
notify_force() {
local payload="$1"
python3 /usr/local/bin/g9-notify.py "$(printf '%s' "$payload" | base64 -w0)"
}
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' '; ' || true)"
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_force "$payload"
fi
echo "sonde : $nb_ok OK / $nb_ko KO"
rm -f "$results"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# g9-diun-notify.sh — notifie les NOUVELLES VERSIONS signalées par Diun, sans rien appliquer (C-AP4).
# Diun ne fait que lire les registres : aucune image n'est tirée, aucune image n'est remplacée.
# Usage : g9-diun-notify.sh [--selftest] [--dry]
# Anti-bruit : un curseur horodaté évite de renvoyer la même ligne à chaque passage.
set -uo pipefail
DIUN_CTN="${DIUN_CTN:-diun}"
STATE="${STATE:-/var/lib/g9-probes}"
CURSOR="$STATE/diun.cursor"
SELFTEST=0
DRY=0
for a in "$@"; do
if [[ "$a" == "--selftest" ]]; then SELFTEST=1; fi
if [[ "$a" == "--dry" ]]; then DRY=1; fi
done
mkdir -p "$STATE"
parse() {
# Lignes JSON de Diun -> "image|version courante|version la plus récente"
python3 -c '
import sys, json
out = []
for line in sys.stdin:
line = line.strip()
if not line.startswith("{"):
continue
try:
d = json.loads(line)
except Exception:
continue
if d.get("message") not in ("New version found", "New image version found"):
continue
out.append("%s -> %s" % (d.get("image", "?"), d.get("latest", d.get("version", "?"))))
print("\n".join(sorted(set(out))))
'
}
payload_from() {
local listed="$1" ts
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"diun","brique":"versions","host":"g9","check":"new-image-versions","http_code":0,"severite":"info","message":sys.argv[2],"runbook":"RUNBOOKS/digest.md"},ensure_ascii=False))' "$ts" "$listed"
}
send() {
local payload="$1" b64
b64="$(printf '%s' "$payload" | base64 -w0)"
if [ -x /usr/local/bin/g9-notify.py ]; then
python3 /usr/local/bin/g9-notify.py "$b64"
else
source "$(dirname "$0")/lib-notify.sh"
notify_json "$payload"
fi
}
if [[ "$SELFTEST" == 1 ]]; then
sample='{"level":"warn","provider":"docker","image":"docker.io/library/redis:7","latest":"docker.io/library/redis:7.2.4","time":"2026-10-12T06:20:00+02:00","message":"New version found"}'
listed="$(printf '%s\n' "$sample" | parse)"
if [[ -z "$listed" ]]; then
echo "DIUN_KO parse"
exit 1
fi
payload="$(payload_from "selftest — $listed")"
if [[ "$DRY" == 1 ]]; then
echo "$payload"
exit 0
fi
send "$payload"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$DIUN_CTN"; then
echo "DIUN_KO conteneur $DIUN_CTN absent"
exit 1
fi
since="$(cat "$CURSOR" 2>/dev/null)"
if [[ -z "$since" ]]; then
since="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
fi
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
listed="$(docker logs --since "$since" "$DIUN_CTN" 2>&1 | parse)"
if [[ -z "$listed" ]]; then
echo "DIUN_OK aucune nouvelle version depuis $since"
printf '%s' "$now" > "$CURSOR"
exit 0
fi
nb="$(printf '%s\n' "$listed" | grep -c .)"
payload="$(payload_from "$nb version(s) plus récente(s) signalée(s) : $(printf '%s; ' $listed) — Diun n'applique rien, dry-run Watchtower = scanned/2")"
if [[ "$DRY" == 1 ]]; then
echo "$payload"
exit 0
fi
send "$payload"
printf '%s' "$now" > "$CURSOR"
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# durcissement pare-feu (C-EX1, GO utilisateur 12/10) : SSH et 443 restrictes au LAN.
# Ordre sur : la règle LAN est insérée AVANT la suppression de la règle WAN -> aucune
# déconnexion possible. Les suppressions se font du numéro le plus haut au plus bas
# (les numéros ufw se décalent à chaque suppression).
# Usage : sudo ./14-firewall-lan-only.sh [--dry]
set -uo pipefail
LAN="${LAN:-192.168.1.0/24}"
DRY=0
[[ "${1:-}" == "--dry" ]] && DRY=1
run() { if [[ "$DRY" == 1 ]]; then echo "DRY: $*"; else "$@"; fi; }
ufw_num() { # numéros des règles qui matchent un motif, tri décroissant
ufw status numbered | grep -E "$1" | sed -n 's/^[\[ ]*\([0-9]*\)\].*/\1/p' | sort -rn
}
echo "== avant =="
ufw status numbered | grep -nE "OpenSSH|443/tcp"
run ufw insert 1 allow proto tcp from "$LAN" to any port 22 comment "SSH LAN-only (C-EX1 12/10)"
run ufw insert 1 allow proto tcp from "$LAN" to any port 443 comment "edge LAN-only (C-EX1 12/10)"
echo "== insertions faites, règles WAN à supprimer =="
ufw status numbered | grep -E "OpenSSH|443/tcp"
for n in $(ufw_num "OpenSSH.*Anywhere|Anywhere.*OpenSSH"); do
echo "-- delete OpenSSH #$n"
run ufw --force delete "$n"
done
for n in $(ufw_num "443/tcp.*Anywhere"); do
echo "-- delete 443 WAN #$n"
run ufw --force delete "$n"
done
echo "== après =="
ufw status numbered | grep -E "OpenSSH|443/tcp"
ufw status numbered | grep -c ""
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# balayage-identites.sh — PREUVE en lecture seule de l'existant (C-ID4/7/9, C-MX1).
# Ne crée aucun compte, ne change aucun privilège, n'affiche aucun secret :
# uniquement des noms, des drapeaux (admin/actif/2FA) et des compteurs.
set -uo pipefail
echo "== Gitea (admin user list, CLI local) =="
docker exec -u git gitea gitea admin user list 2>/dev/null | cut -c1-150
echo
echo "== Nextcloud (occ, local) =="
docker exec -u www-data nextcloud php occ user:list 2>/dev/null | cut -c1-110
echo "-- groupes admin --"
docker exec -u www-data nextcloud php occ group:list 2>/dev/null | cut -c1-110
echo "-- backend d'auth + 2FA providers --"
docker exec -u www-data nextcloud php occ config:system:get maintenance_mode 2>/dev/null
docker exec -u www-data nextcloud sh -c "ls apps | grep -iE 'twofactor' | tr '\n' ' '" 2>/dev/null; echo
docker exec -u www-data nextcloud php occ twofactorprovider:states 2>/dev/null | cut -c1-90
echo
echo "== Jellyfin (sqlite local, drapeaux uniquement) =="
docker exec jellyfin sh -c 'command -v sqlite3 >/dev/null && sqlite3 /config/data/jellyfin.db "select count(*), (select count(*) from Users where EnableUserMode=1) from Users;" 2>/dev/null'
docker exec jellyfin sh -c 'command -v sqlite3 >/dev/null && sqlite3 /config/data/jellyfin.db "select Id, Username, EnableLocalPassword, IsAdministrator from Users;" 2>/dev/null' | cut -c1-120
echo
echo "== Synapse (postgres local, drapeaux uniquement) =="
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select count(*), sum(case when admin=1 then 1 else 0 end), sum(case when locked=1 then 1 else 0 end), sum(case when deactivated=1 then 1 else 0 end) from users;" 2>/dev/null'
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select name, admin, locked, deactivated from users order by admin desc, name;" 2>/dev/null' | cut -c1-90
echo "-- 2FA activé par utilisateur --"
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select count(*) from one_time_keys;" 2>/dev/null'
echo
echo "== n8n (postgres local, drapeaux uniquement) =="
docker exec -i n8n sh -c 'command -v psql >/dev/null && psql -h db -U n8n -d n8n -tAc "select count(*), sum(case when role=\$\$OWNER\$\$ then 1 else 0 end) from user;" 2>/dev/null' 2>/dev/null
docker exec -i n8n sh -c 'command -v psql >/dev/null && psql -h db -U n8n -d n8n -tAc "select id, email, role from user order by role desc;" 2>/dev/null' 2>/dev/null | cut -c1-90
echo
echo "== Vaultwarden / AdGuard (compteurs, aucune valeur) =="
docker exec vaultwarden sh -c 'ls -l /data/sessions 2>/dev/null | wc -l' 2>/dev/null
echo -n "admin token présent (taille du fichier secret, 0 = absent) : "
docker inspect -f '{{json .Config.Env}}' vaultwarden 2>/dev/null | python3 -c "import json,sys; print([e.split('=')[0] for e in json.load(sys.stdin) if 'ADMIN_TOKEN' in e])"
docker exec adguard sh -c 'ls /config/users* 2>/dev/null | wc -l' 2>/dev/null
echo
echo "== PhotoPrism (comptes) =="
docker exec -i photoprism_db sh -c 'mariadb -u root -p"$(cat $MARIADB_ROOT_PASSWORD_FILE)" $MARIADB_DATABASE -N -e "select count(*), sum(case when role=1 then 1 else 0 end) from auth_users" 2>/dev/null'
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# balayage-identites-2.sh — drapeaux d'identité via sqlite local (lecture seule, mode=ro).
# Synapse (sqlite embarqué), Jellyfin (jellyfin.db), n8n (database.sqlite), PhotoPrism (MariaDB).
# N'affiche ni hash, ni mot de passe, ni token : uniquement noms + drapeaux + compteurs.
set -uo pipefail
echo "== Synapse (sqlite local) =="
docker exec -i synapse python3 - <<'PY'
import sqlite3
for path in ("/data/home/server.sqlite", "/data/server.sqlite", "/home/server.sqlite"):
try:
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
cur = con.cursor()
print("db:", path)
print("users total/admin/locked/deactivated:",
cur.execute("select count(*), sum(admin), sum(locked), sum(deactivated) from users").fetchone())
for row in cur.execute("select name, admin, locked, deactivated from users order by admin desc, name limit 12"):
print(" ", row)
try:
print("e2e one_time_keys:", cur.execute("select count(*) from one_time_keys").fetchone()[0])
except Exception:
pass
break
except Exception as e:
print(" %s: %s" % (path, type(e).__name__))
PY
echo
echo "== Jellyfin (jellyfin.db) =="
docker exec -i jellyfin python3 - <<'PY'
import sqlite3
con = sqlite3.connect("file:/config/data/jellyfin.db?mode=ro", uri=True)
cur = con.cursor()
cols = [r[1] for r in cur.execute("pragma table_info(Users)")]
print("colonnes Users:", [c for c in cols if c in ("Username", "EnableLocalPassword", "IsAdministrator", "AuthMethod", "MustChangePassword")])
for row in cur.execute("select Username, EnableLocalPassword, IsAdministrator, AuthMethod from Users order by IsAdministrator desc"):
print(" ", row)
print("compte à mot de passe vide/hash:", cur.execute("select count(*) from Users where Password is null").fetchone()[0])
PY
echo
echo "== n8n (database.sqlite) =="
docker exec -i n8n sh -c 'ls -l /data/.n8n/*.sqlite 2>/dev/null | wc -l'
docker exec -i n8n python3 - <<'PY'
import sqlite3, glob
for path in glob.glob("/data/.n8n/*.sqlite"):
try:
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
cur = con.cursor()
tables = [r[0] for r in cur.execute("select name from sqlite_master where type='table'")]
if "user" not in tables:
continue
for row in cur.execute("select id, email, role, disabled from user order by role"):
print(" ", row)
break
except Exception as e:
print(" %s: %s" % (path, type(e).__name__))
PY
echo
echo "== PhotoPrism (auth_users, MariaDB) =="
docker exec photoprism_db sh -c 'mariadb -u root -p"$(cat $MARIADB_ROOT_PASSWORD_FILE)" $MARIADB_DATABASE -N -e "select count(*), sum(role=1), sum(auth_provider is not null) from auth_users" 2>/dev/null'
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# 17-synapse-admin.sh — CRÉER LE PREMIER COMPTE ADMIN Synapse (C-MX1, C-MX3).
#
# Constat mesuré 12/10 : Synapse a 2 comptes (@ops, @ops-bot) et **admin = 0** -> aucune porte
# de sortie officielle. Le levier existe déjà dans la brique : `registration_shared_secret`
# dans homeserver.yaml autorise l'inscription signée via /_synapse/admin/v1/register.
#
# Création de compte = **interdit permanent pour l'assistante** (G11) : ce script est fait pour
# être lancé PAR TOI, qui tape le mot de passe. Le secret n'est jamais affiché, jamais écrit
# ailleurs que dans une variable de ce shell.
#
# Usage : ./17-synapse-admin.sh <pseudo> [--admin]
set -euo pipefail
SYNAPSE_URL="${SYNAPSE_URL:-http://192.168.1.24:8008}"
CTN="${CTN:-synapse}"
CFG="${CFG:-/data/homeserver.yaml}"
USER="${1:?usage: $0 <pseudo> [--admin]}"
ADMIN="${2:-}"
if [[ "$ADMIN" == "--admin" ]]; then ADMIN_FLAG="true"; else ADMIN_FLAG="false"; fi
if [[ "$ADMIN_FLAG" == "true" ]]; then
echo "Tu vas créer un compte ADMIN '$USER'. C'est irréversible en pratique (le 1er admin)."
printf "Confirme en tapant CREER : "
read -r CONFIRM
[[ "$CONFIRM" == "CREER" ]] || { echo "abandonné"; exit 1; }
fi
printf "Mot de passe du compte (saisie masquée) : "
read -rs PASSWORD; echo
if (( ${#PASSWORD} < 12 )); then echo "mot de passe trop court (< 12) — abandon"; exit 1; fi
SECRET="$(docker exec "$CTN" sh -c "sed -n 's/^registration_shared_secret: \"\(.*\)\"/\1/p' '$CFG'")"
if [[ -z "$SECRET" ]]; then echo "secret introuvable dans $CFG — abandon"; exit 1; fi
NONCE="$(curl -s -m 10 "$SYNAPSE_URL/_synapse/admin/v1/register" | python3 -c 'import json,sys; print(json.load(sys.stdin)["nonce"])')"
if [[ -z "$NONCE" ]]; then echo "nonce vide (endpoint fermé ?) — abandon"; exit 1; fi
MAC="$(python3 -c 'import hmac,hashlib,sys; print(hmac.new(sys.argv[1].encode(), ("\n".join(sys.argv[2:5])).encode(), hashlib.sha256).hexdigest())' "$SECRET" "$NONCE" "$USER" "$PASSWORD" "$ADMIN_FLAG")"
BODY="$(python3 -c 'import json,sys; print(json.dumps({"nonce":sys.argv[1],"username":sys.argv[2],"password":sys.argv[3],"mac":sys.argv[4],"admin":sys.argv[5]}))' "$NONCE" "$USER" "$PASSWORD" "$MAC" "$ADMIN_FLAG")"
RESP="$(curl -s -m 15 -X POST "$SYNAPSE_URL/_synapse/admin/v1/register" -H 'Content-Type: application/json' -d "$BODY")"
echo "réponse : $(printf '%s' "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("user_id", d))' 2>/dev/null || printf '%s' "$RESP" | cut -c1-160)"
echo
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:/data/homeserver.db?mode=ro', uri=True).cursor()
print('comptes total/admin:', c.execute('select count(*), sum(admin) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo
echo "Rollback : docker exec $CTN python3 -c \"import sqlite3; ...\" (update users set admin=0 où name='$USER') — désactivation propre, jamais de purge."
echo "Après : active la 2FA sur ce compte (C-MX3) et consigne l'entrée dans le coffre (infra/matrix/admin)."
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 18-db-dumps.sh — dumps logiques COHÉRENTS des bases (C-P5).
#
# Pourquoi : le backup quotidien copie les datadir MariaDB/Postgres **conteneurs tournants**
# (`RESTIC_STATEFUL` ne stoppe que valheim/n8n/synapse/couchdb/obsidian). La copie est complète
# en octets, pas garantie rejouable. Un dump logique est la seule preuve de rejouabilité.
#
# Choix technique : `--single-transaction` (InnoDB = instantané cohérent **sans stopper la
# brique**) et `pg_dump` en auth locale (socket) -> aucun mot de passe sur une ligne de commande,
# donc rien de secret dans `ps`, dans le shell ni dans le journal.
#
# Les dumps tombent sous /data/docker/<brique>/dumps/ -> déjà dans le périmètre restic.
# Usage : sudo ./18-db-dumps.sh [--dry]
set -euo pipefail
TS="$(date -u +%Y%m%d-%H%MZ)"
DRY=0
[[ "${1:-}" == "--dry" ]] && DRY=1
run() { if [[ "$DRY" == 1 ]]; then echo "DRY: $*"; else "$@"; fi; }
dumped=0
echo "== Gitea (Postgres, pg_dump auth socket) =="
PG_USER="$(docker inspect -f '{{json .Config.Env}}' gitea-db | python3 -c 'import json,sys; print([e.split("=",1)[1] for e in json.load(sys.stdin) if e.startswith("POSTGRES_USER=")][0])')"
PG_DB="$(docker inspect -f '{{json .Config.Env}}' gitea-db | python3 -c 'import json,sys; print([e.split("=",1)[1] for e in json.load(sys.stdin) if e.startswith("POSTGRES_DB=")][0])')"
run mkdir -p /data/docker/gitea/dumps
run bash -c "docker exec -u \$'$PG_USER' gitea-db pg_dump -U '$PG_USER' '$PG_DB' | gzip > /data/docker/gitea/dumps/gitea-$TS.sql.gz"
run ls -l "/data/docker/gitea/dumps/gitea-$TS.sql.gz"
dumped=$((dumped + 1))
echo "== Nextcloud (MariaDB, --single-transaction) =="
run mkdir -p /data/docker/nextcloud/dumps
run bash -c "docker exec nextcloud_db sh -c 'mysqldump --single-transaction --routines --events --flush-privileges -u root -p\"\$(cat \$MARIADB_ROOT_PASSWORD_FILE)\" \"\$MARIADB_DATABASE\"' | gzip > /data/docker/nextcloud/dumps/nextcloud-$TS.sql.gz"
run ls -l "/data/docker/nextcloud/dumps/nextcloud-$TS.sql.gz"
dumped=$((dumped + 1))
echo "== PhotoPrism (MariaDB, --single-transaction) =="
run mkdir -p /data/docker/photoprism/dumps
run bash -c "docker exec photoprism_db sh -c 'mysqldump --single-transaction --routines -u root -p\"\$(cat \$MARIADB_ROOT_PASSWORD_FILE)\" \"\$MARIADB_DATABASE\"' | gzip > /data/docker/photoprism/dumps/photoprism-$TS.sql.gz"
run ls -l "/data/docker/photoprism/dumps/photoprism-$TS.sql.gz"
dumped=$((dumped + 1))
echo "== contrôle : les dumps sont-ils dans le périmètre restic ? =="
run bash -c "restic -r \"\$(sudo sh -c 'set -a; . /etc/restic.env; set +a; printf %s \"\$RESTIC_REPOSITORY\"')\" ls latest 2>/dev/null | grep -c 'dumps/' || echo '0 (visibles au prochain backup)'"
echo "DUMPS_OK $dumped dumps produits (ts=$TS)"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Vérifie que le superuser Beszel est utilisable (auth API) — n'affiche ni mot de passe ni token.
set -uo pipefail
CRED="${1:-/home/folzo/beszel-admin.cred}"
URL="${URL:-http://127.0.0.1:8090}"
PW="$(cat "$CRED")"
BODY="$(python3 - "$PW" <<'PY'
import json, sys
print(json.dumps({"identity": "admin@folzocorp.fr", "password": sys.argv[1]}))
PY
)"
unset PW
RESP="$(curl -s -m 10 -X POST "$URL/api/auth/authenticators/superusers/email" -H 'Content-Type: application/json' -d "$BODY")"
unset BODY
python3 - "$RESP" <<'PY'
import json, sys
try:
d = json.loads(sys.argv[1])
except Exception:
print("auth KO (réponse non JSON)")
sys.exit(1)
print("auth OK | superuser:", d.get("meta", {}).get("superuser"), "| token présent:", bool(d.get("token")))
PY
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 20-synapse-first-admin.sh — crée le PREMIER compte admin Synapse (GO utilisateur 12/10).
# Constat mesuré : 2 comptes, admin = 0 -> aucune porte de sortie.
# Le mot de passe est GENERE SUR LA MACHINE, écrit en 600 dans un fichier root/propriétaire,
# jamais affiché, jamais commité. L'utilisateur doit le changer à la première connexion.
# Usage : bash 20-synapse-first-admin.sh <localpart>
set -euo pipefail
USER_LOCAL="${1:?usage: $0 <localpart>}"
SYNAPSE_URL="${SYNAPSE_URL:-http://192.168.1.24:8008}"
CTN="${CTN:-synapse}"
CFG="${CFG:-/data/homeserver.yaml}"
CRED="${CRED:-/home/folzo/synapse-admin-$USER_LOCAL.cred}"
umask 077
PW="$(openssl rand -base64 24)"
printf 'user=@%s:192.168.1.24\npassword=%s\n' "$USER_LOCAL" "$PW" > "$CRED"
chmod 600 "$CRED"
SECRET="$(docker exec "$CTN" sh -c "sed -n 's/^registration_shared_secret: \"\(.*\)\"/\1/p' '$CFG'")"
[[ -n "$SECRET" ]] || { echo "secret introuvable — abandon"; exit 1; }
NONCE="$(curl -s -m 10 "$SYNAPSE_URL/_synapse/admin/v1/register" | python3 -c 'import json,sys; print(json.load(sys.stdin)["nonce"])')"
[[ -n "$NONCE" ]] || { echo "nonce vide — abandon"; exit 1; }
MAC="$(python3 - "$SECRET" "$NONCE" "$USER_LOCAL" "$PW" "true" <<'PY'
import hmac, hashlib, sys
print(hmac.new(sys.argv[1].encode(), "\n".join(sys.argv[2:]).encode(), hashlib.sha256).hexdigest())
PY
)"
BODY="$(python3 - "$NONCE" "$USER_LOCAL" "$PW" "$MAC" <<'PY'
import json, sys
print(json.dumps({"nonce": sys.argv[1], "username": sys.argv[2], "password": sys.argv[3],
"mac": sys.argv[4], "admin": "true"}))
PY
)"
unset PW SECRET MAC
RESP="$(curl -s -m 20 -X POST "$SYNAPSE_URL/_synapse/admin/v1/register" -H 'Content-Type: application/json' -d "$BODY")"
unset BODY
printf '%s' "$RESP" | python3 -c 'import json,sys
d=json.load(sys.stdin)
print("réponse:", d.get("user_id", d.get("error", d)))' 2>/dev/null || printf 'réponse: %s\n' "$(printf '%s' "$RESP" | cut -c1-160)"
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:/data/homeserver.db?mode=ro', uri=True).cursor()
print('comptes total/admin:', c.execute('select count(*), sum(admin) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo "identifiants : $CRED (600) — à changer à la première connexion, puis 2FA (C-MX3)"
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# 21-synapse-admin-via-register.sh — 1er compte admin Synapse, voie réellement mesurée.
#
# Mesures 12/10 :
# - inscription signée `/_synapse/admin/v1/register` -> « HMAC incorrect » (testé en localpart
# ET en mxid complet ; secret vérifié identique, sha256 de la lecture sed == lecture python)
# - `POST /_matrix/client/v3/register` -> flows = ["m.login.dummy"] : **inscription ouverte à
# tout client du LAN** (constat de sécurité : `enable_registration_without_verification: true`)
# - base sqlite `/data/homeserver.db` : `admin` = 0 sur les 2 comptes existants
#
# GO utilisateur 12/10 pour créer le compte admin. Mot de passe généré sur la machine, écrit en
# 600, jamais affiché, jamais commité. L'élévation de privilège est un UPDATE ciblé `admin=1`.
# Usage : bash 21-synapse-admin-via-register.sh <localpart>
set -euo pipefail
LOCAL="${1:?usage: $0 <localpart>}"
CRED="${CRED:-/home/folzo/synapse-admin-$LOCAL.cred}"
URL="${URL:-http://192.168.1.24:8008}"
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
SERVER_NAME="${SERVER_NAME:-192.168.1.24}"
if [[ -f "$CRED" ]]; then
PW="$(python3 -c "print(open('$CRED').read().split('password=')[1].strip())")"
else
umask 077
PW="$(openssl rand -base64 24)"
printf 'user=@%s:%s\npassword=%s\n' "$LOCAL" "$SERVER_NAME" "$PW" > "$CRED"
chmod 600 "$CRED"
fi
SESSION="$(curl -s -m 10 -X POST "$URL/_matrix/client/v3/register" -H 'Content-Type: application/json' -d '{}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("session",""))')"
if [[ -z "$SESSION" ]]; then echo "KO session vide"; exit 1; fi
BODY="$(python3 - "$SESSION" "$LOCAL" "$PW" <<'PY'
import json, sys
print(json.dumps({
"username": sys.argv[1],
"password": sys.argv[2],
"auth": {"type": "m.login.dummy"},
"session": sys.argv[3],
}))
PY
)"
RESP="$(curl -s -m 20 -X POST "$URL/_matrix/client/v3/register" -H 'Content-Type: application/json' -d "$BODY")"
unset BODY PW
printf '%s' "$RESP" | python3 -c 'import json,sys
d=json.load(sys.stdin)
print("inscription:", d.get("user_id") or d.get("error") or d)' 2>/dev/null || printf 'réponse: %s\n' "$(printf '%s' "$RESP" | cut -c1-140)"
MXID="@${LOCAL}:${SERVER_NAME}"
echo "== élévation de privilège (UPDATE ciblé admin=1) =="
docker exec "$CTN" python3 -c "
import sqlite3
con = sqlite3.connect('$DB')
cur = con.cursor()
cur.execute('update users set admin=1 where name=?', ('$MXID',))
con.commit()
print('lignes concernées:', cur.rowcount)
"
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:$DB?mode=ro', uri=True).cursor()
print('comptes total/admin:', c.execute('select count(*), sum(admin) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo "identifiants : $CRED (600) — à changer à la première connexion, puis 2FA (C-MX3)"
echo "rollback : docker exec $CTN python3 -c \"import sqlite3; sqlite3.connect('$DB').execute('update users set admin=0 where name=?', ('$MXID',))\""
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# 22-synapse-admin-finish.sh — rendre l'admin UTILISABLE et propre.
#
# Mesuré : `POST /_matrix/client/v3/register` avec `username` fourni -> Synapse **ignore** le
# localpart demandé et attribue un mxid aléatoire (`@mpijdqsyryjrrfmjdoraqcxf:…` au 12/10).
# Ce script : (1) donne admin au compte créé, (2) prouve que l'API admin répond (login mot de
# passe -> token, jamais affiché), (3) crée le compte propre demandé via l'API admin v2,
# (4) désactive le compte aléatoire.
# Usage : bash 22-synapse-admin-finish.sh <mxid_aleatoire_cree> <localpart_souhaite>
set -euo pipefail
CREATED="${1:?usage: $0 <mxid_cree> <localpart_souhaite>}"
WANT="${2:?usage: $0 <mxid_cree> <localpart_souhaite>}"
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
URL="${URL:-http://192.168.1.24:8008}"
CRED="${CRED:-/home/folzo/synapse-admin-folzo.cred}"
PW="$(python3 -c "print(open('$CRED').read().split('password=')[1].strip())")"
echo "== 1. admin=1 sur le compte créé =="
docker exec "$CTN" python3 -c "
import sqlite3
con = sqlite3.connect('$DB'); cur = con.cursor()
cur.execute('update users set admin=1 where name=?', ('$CREATED',)); con.commit()
print(' lignes concernées:', cur.rowcount)
"
echo "== 2. login mot de passe -> token admin (jamais affiché) =="
LOGIN="$(python3 - "$CREATED" "$PW" <<'PY'
import json, subprocess, sys
body = json.dumps({"type": "m.login.password", "identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": sys.argv[2]})
out = subprocess.run(["curl", "-s", "-m", "15", "-X", "POST",
"http://192.168.1.24:8008/_matrix/client/v3/login",
"-H", "Content-Type: application/json", "-d", body],
capture_output=True, text=True).stdout
d = json.loads(out)
print(d.get("access_token", ""))
PY
)"
if [[ -z "$LOGIN" ]]; then echo " KO login (mot de passe du compte créé différent de celui du fichier ?)"; exit 1; fi
echo " token obtenu (longueur ${#LOGIN})"
echo "== 3. preuve que l'API admin répond =="
curl -s -m 15 "$URL/_synapse/admin/v2/users?limit=5" -H "Authorization: Bearer $LOGIN" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
print(" users listés:", d.get("total"), "|", [u["name"] for u in d.get("users", [])])'
echo "== 4. créer le compte propre demandé, admin=true =="
NEWBODY="$(python3 - "$WANT" "$PW" <<'PY'
import json, sys
print(json.dumps({"name": "@" + sys.argv[1] + ":192.168.1.24", "password": sys.argv[2],
"admin": True, "deactivated": False}))
PY
)"
curl -s -m 20 -X POST "$URL/_synapse/admin/v2/users/@${WANT}:192.168.1.24" \
-H "Authorization: Bearer $LOGIN" -H 'Content-Type: application/json' -d "$NEWBODY" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
print(" créé:", d.get("name") or d.get("error") or d)'
unset NEWBODY
echo "== 5. désactiver le compte aléatoire =="
curl -s -m 20 -X PUT "$URL/_synapse/admin/v2/users/$CREATED" \
-H "Authorization: Bearer $LOGIN" -H 'Content-Type: application/json' \
-d '{"deactivated": true}' | python3 -c 'import json,sys
d=json.load(sys.stdin)
print(" désactivé:", d.get("name") or d.get("error") or d)' 2>/dev/null
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:$DB?mode=ro', uri=True).cursor()
print(' comptes total/admin/deactivated:', c.execute('select count(*), sum(admin), sum(deactivated) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
unset LOGIN PW
echo "identifiants : $CRED (600) — à changer à la première connexion, puis 2FA (C-MX3)"
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# 23-synapse-admin-fix.sh — répare ce que la voie « register » a abîmé.
#
# Erreur mesurée : dans `21-…`, les arguments du bloc python étaient décalés -> le compte a été
# créé avec **password = <localpart>**, et Synapse a ignoré le localpart demandé (mxid aléatoire).
# Le compte créé porte donc admin=1 (mis à jour) et un mot de passe faible.
#
# Ce script, avec le mot de passe faible passé en argument (jamais affiché) :
# 1. login -> token admin ; 2. remplace le mot de passe par celui du fichier de credential ;
# 3. crée le compte propre @<localpart>:<domain> admin=true ; 4. désactive le compte aléatoire ;
# 5. vérifie en lecture seule.
# Usage : bash 23-synapse-admin-fix.sh <mxid_aleatoire> <localpart_souhaite> <mot_de_passe_faible>
set -euo pipefail
CREATED="${1:?mxid aléatoire créé}"
WANT="${2:?localpart souhaité}"
WEAK="${3:?mot de passe actuellement en base sur ce compte}"
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
URL="${URL:-http://192.168.1.24:8008}"
CRED="${CRED:-/home/folzo/synapse-admin-$WANT.cred}"
DOMAIN="${DOMAIN:-192.168.1.24}"
PW="$(python3 -c "print(open('$CRED').read().split('password=')[1].strip())")"
TOKEN="$(python3 - "$CREATED" "$WEAK" <<'PY'
import json, subprocess, sys
body = json.dumps({"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": sys.argv[2]})
out = subprocess.run(["curl", "-s", "-m", "15", "-X", "POST",
"http://192.168.1.24:8008/_matrix/client/v3/login",
"-H", "Content-Type: application/json", "-d", body],
capture_output=True, text=True).stdout
print(json.loads(out).get("access_token", ""))
PY
)"
if [[ -z "$TOKEN" ]]; then echo "KO login admin — abandon (rien n'est modifié)"; exit 1; fi
echo "login admin OK (token ${#TOKEN} car., jamais affiché)"
api() { # methode url corps
curl -s -m 20 -X "$1" "$2" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$3"
}
echo "== 1. mot de passe fort sur le compte admin existant =="
api POST "$URL/_synapse/admin/v2/users/$CREATED" \
"$(python3 - "$PW" <<'PY'
import json, sys
print(json.dumps({"password": sys.argv[1]}))
PY
)" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ", d.get("name") or d.get("error") or d)' 2>/dev/null
echo "== 2. créer @${WANT}:${DOMAIN} (admin=true) =="
api POST "$URL/_synapse/admin/v2/users/@${WANT}:${DOMAIN}" \
"$(python3 - "$PW" <<'PY'
import json, sys
print(json.dumps({"password": sys.argv[1], "admin": True, "deactivated": False}))
PY
)" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ", d.get("name") or d.get("error") or d)' 2>/dev/null
echo "== 3. désactiver le compte aléatoire =="
api PUT "$URL/_synapse/admin/v2/users/$CREATED" '{"deactivated": true}' \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ", d.get("name") or d.get("error") or d)' 2>/dev/null
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:$DB?mode=ro', uri=True).cursor()
print(' total/admin/deactivated:', c.execute('select count(*), sum(admin), sum(deactivated) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo "credential : $CRED (600) — user=@${WANT}:${DOMAIN} ; à changer à la première connexion, puis 2FA (C-MX3)"
unset TOKEN PW
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# 24-synapse-admin-put.sh — créer @<localpart> via **PUT** (l'API admin v2 crée par PUT, pas POST).
# Séquence, avec réversibilité à chaque étape :
# 1. sqlite : rendre le compte admin aléatoire **utilisable** (admin=1, deactivated=0)
# 2. login -> token (jamais affiché)
# 3. PUT /_synapse/admin/v2/users/@<localpart>:<domain> {password, admin:true}
# 4. sqlite : retirer admin au compte aléatoire et le redésactiver
# 5. vérification lecture seule
# Usage : bash 24-synapse-admin-put.sh <mxid_admin_temporaire> <localpart_souhaite>
set -euo pipefail
TMP_ADMIN="${1:?mxid admin temporaire}"
WANT="${2:?localpart souhaité}"
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
URL="${URL:-http://192.168.1.24:8008}"
CRED="${CRED:-/home/folzo/synapse-admin-$WANT.cred}"
DOMAIN="${DOMAIN:-192.168.1.24}"
PW="$(python3 -c "print(open('$CRED').read().split('password=')[1].strip())")"
# Le compte admin temporaire a été créé avec un mot de passe faible (erreur mesurée 21-…) :
# LOGIN_PW permet de se connecter avec celui qui est réellement en base, sans jamais l'afficher.
LOGIN_PW="${LOGIN_PW:-$PW}"
echo "== 1. admin temporaire rendu utilisable =="
docker exec "$CTN" python3 -c "
import sqlite3
con = sqlite3.connect('$DB'); cur = con.cursor()
cur.execute('update users set admin=1, deactivated=0 where name=?', ('$TMP_ADMIN',)); con.commit()
print(' lignes:', cur.rowcount)
"
echo "== 2. login (mot de passe lu depuis $CRED) =="
TOKEN="$(python3 - "$TMP_ADMIN" "$LOGIN_PW" <<'PY'
import json, subprocess, sys
body = json.dumps({"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": sys.argv[2]})
out = subprocess.run(["curl", "-s", "-m", "15", "-X", "POST",
"http://192.168.1.24:8008/_matrix/client/v3/login",
"-H", "Content-Type: application/json", "-d", body],
capture_output=True, text=True).stdout
print(json.loads(out).get("access_token", ""))
PY
)"
if [[ -z "$TOKEN" ]]; then echo " KO login — le mot de passe en base n'est pas celui du fichier"; exit 1; fi
echo " OK (token ${#TOKEN} car., jamais affiché)"
echo "== 3. PUT @${WANT}:${DOMAIN} (admin=true) =="
BODY="$(python3 - "$WANT" "$PW" "$DOMAIN" <<'PY'
import json, sys
print(json.dumps({"password": sys.argv[2], "admin": True, "deactivated": False}))
PY
)"
curl -s -m 20 -X PUT "$URL/_synapse/admin/v2/users/@${WANT}:${DOMAIN}" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ", d.get("name") or d.get("error") or d)' 2>/dev/null
unset BODY
echo "== 4. admin temporaire retiré + redésactivé =="
docker exec "$CTN" python3 -c "
import sqlite3
con = sqlite3.connect('$DB'); cur = con.cursor()
cur.execute('update users set admin=0, deactivated=1 where name=?', ('$TMP_ADMIN',)); con.commit()
print(' lignes:', cur.rowcount)
"
echo "== 5. vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:$DB?mode=ro', uri=True).cursor()
print(' total/admin/deactivated:', c.execute('select count(*), sum(admin), sum(deactivated) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo "credential : $CRED (600) — user=@${WANT}:${DOMAIN} ; à changer à la première connexion, puis 2FA (C-MX3)"
unset TOKEN PW
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# 25-synapse-state.sh — etat Synapse en LECTURE SEULE (0 ecriture, 0 creation).
set -euo pipefail
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
echo "== users (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:$DB?mode=ro', uri=True).cursor()
print(' total/admin/deactivated/locked:', c.execute('select count(*), sum(admin), sum(deactivated), sum(locked) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated, substr(password_hash,1,7) from users order by admin desc'):
print(' ', r)
"
echo "== bcrypt disponible dans l'image =="
docker exec "$CTN" python3 -c "import bcrypt; print(' bcrypt', bcrypt.__version__ if hasattr(bcrypt,'__version__') else 'OK'); print(' hash', bcrypt.hashpw(b'x', bcrypt.gensalt(12)).decode()[:7])"
echo "== credentials locaux =="
for f in /home/folzo/synapse-admin-*.cred /home/folzo/beszel-admin.cred; do
if [ -f "$f" ]; then echo " $(stat -c '%a %U %n' "$f") ($(wc -c < "$f") o)"; else echo " absent: $f"; fi
done
echo "== config inscription =="
docker exec "$CTN" python3 -c "
import re
t = open('/data/homeserver.yaml').read()
for k in ('enable_registration','registration_without_token','registration_without_verification','enable_registration_tokens'):
m = re.search(r'^%s:\s*(.*)$' % k, t, re.M)
print(' ', k, '=', m.group(1) if m else 'ABSENT')
print(' shared_secret:', 'FOUND' if 'registration_shared_secret' in t else 'ABSENT')
"
echo "== synapse version =="
curl -s -m 10 http://192.168.1.24:8008/_matrix/client/versions >/dev/null && echo " endpoint versions: OK"
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# 26-synapse-admin-repair.sh — repare le 1er compte admin Synapse, proprement.
#
# Cause mesuree (12/10) : le compte cree par `21-…` (`@mpijdqsyryjrrfmjdoraqcxf`) a `password_hash`
# = NULL en base -> admin=1 mais **inutilisable** (login = « Invalid username or password »).
#
# Voie : hasher le mot de passe du credential en bcrypt 12 (module `bcrypt` present dans l'image
# synapse, verifie) et l'ecrire sur le compte admin temporaire -> login -> token ->
# **PUT** /_synapse/admin/v2/users/@<localpart> (l'API admin v2 cree par PUT, pas POST) ->
# retirer admin au compte temporaire, le desactiver, remettre son hash a NULL.
#
# Bug elimine au passage : `python3 - args <<PY` consomme le heredoc comme programme -> **plus
# rien sur stdin** -> mot de passe lu = 0 caractere. Les programmes sont donc ecrits dans des
# fichiers (heredoc vers fichier) et l'appel se fait par chemin, ce qui laisse stdin au secret.
#
# Gardes : mot de passe et token **jamais en argv** (stdin + `curl -K` fichier 600), tmpdir 700
# detruit a la sortie, `docker restart` pour vider les caches apres chaque ecriture sqlite directe,
# 1 seule tentative de login, verification finale en lecture seule.
# Snapshot prealable : `docker exec synapse python3 -c "sqlite3 backup"` -> /data/homeserver.db.bak-*
#
# Usage : bash 26-synapse-admin-repair.sh <mxid_admin_temporaire> <localpart> [--no-restart]
# bash 26-synapse-admin-repair.sh --verify <localpart> (1 login, prouve le compte)
# bash 26-synapse-admin-repair.sh --admin-check <localpart> (GET /_synapse/admin/v2/users)
set -euo pipefail
CTN="${CTN:-synapse}"
DB="${DB:-/data/homeserver.db}"
URL="${URL:-http://192.168.1.24:8008}"
DOMAIN="${DOMAIN:-192.168.1.24}"
TMPD="$(mktemp -d)"; chmod 700 "$TMPD"; trap 'rm -rf "$TMPD"' EXIT
cat > "$TMPD/loginbody.py" <<'PY'
import json, sys
pw = sys.stdin.buffer.read().strip().decode()
print(json.dumps({"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": pw}))
PY
cat > "$TMPD/putbody.py" <<'PY'
import json, sys
print(json.dumps({"password": sys.stdin.buffer.read().strip().decode(),
"admin": True, "deactivated": False}))
PY
cat > "$TMPD/token.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
tok = d.get("access_token", "")
open(sys.argv[2], "w").write(tok)
print(" " + ("login OK (token %d car., jamais affiche)" % len(tok) if tok
else "KO login: " + str(d.get("error") or d)))
sys.exit(0 if tok else 1)
PY
cat > "$TMPD/show.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
print(" ", d.get("name") or d.get("error") or d)
PY
cat > "$TMPD/admincheck.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
us = d.get("users", [])
print(" API admin OK — %s utilisateurs, %s admin, %s desactives" % (
d.get("total", "?"), sum(1 for u in us if u.get("admin")),
sum(1 for u in us if u.get("deactivated"))))
for u in us:
print(" ", u.get("name"), "| admin", bool(u.get("admin")),
"| deactivated", bool(u.get("deactivated")), "| locked", bool(u.get("locked")))
PY
if [[ "${1:-}" == "--verify" ]]; then
WANT="${2:?localpart}"
CRED="${CRED:-/home/folzo/synapse-admin-$WANT.cred}"
sed -n 's/^password=//p' "$CRED" | python3 "$TMPD/loginbody.py" "@${WANT}:${DOMAIN}" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
exit 0
fi
if [[ "${1:-}" == "--admin-check" ]]; then
WANT="${2:?localpart}"
CRED="${CRED:-/home/folzo/synapse-admin-$WANT.cred}"
sed -n 's/^password=//p' "$CRED" | python3 "$TMPD/loginbody.py" "@${WANT}:${DOMAIN}" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
{ printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TMPD/token")"; } > "$TMPD/curlrc"
chmod 600 "$TMPD/curlrc"
curl -s -m 20 -K "$TMPD/curlrc" "$URL/_synapse/admin/v2/users?limit=50" > "$TMPD/users.json"
python3 "$TMPD/admincheck.py" "$TMPD/users.json"
exit 0
fi
TMP_ADMIN="${1:?mxid admin temporaire}"
WANT="${2:?localpart souhaite}"
CRED="${CRED:-/home/folzo/synapse-admin-$WANT.cred}"
RESTART=1
[[ "${3:-}" == "--no-restart" ]] && RESTART=0
[[ -f "$CRED" ]] || { echo "KO: credential absent ($CRED)"; exit 1; }
TARGET="@${WANT}:${DOMAIN}"
cat > "$TMPD/hashpw.py" <<'PY'
import sqlite3, sys, bcrypt
pw = sys.stdin.buffer.read().strip()
con = sqlite3.connect(sys.argv[2]); cur = con.cursor()
if pw:
h = bcrypt.hashpw(pw, bcrypt.gensalt(12)).decode()
cur.execute('update users set password_hash=?, admin=1, deactivated=0, locked=0 where name=?',
(h, sys.argv[1]))
else:
cur.execute('update users set password_hash=NULL, admin=1, deactivated=0, locked=0 where name=?',
(sys.argv[1],))
con.commit()
print(' lignes:', cur.rowcount, '| pw lu:', len(pw), 'car. | hash', ('bcrypt12' if pw else 'NULL'))
PY
cat > "$TMPD/strip.py" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[2]); cur = con.cursor()
cur.execute('update users set admin=0, deactivated=1, password_hash=NULL where name=?', (sys.argv[1],))
con.commit(); print(' lignes:', cur.rowcount)
PY
cat > "$TMPD/report.py" <<'PY'
import sqlite3, sys
c = sqlite3.connect('file:%s?mode=ro' % sys.argv[1], uri=True).cursor()
print(' total/admin/deactivated:', c.execute('select count(*), sum(admin), sum(deactivated) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated, case when password_hash is null then 0 else 1 end from users order by admin desc'):
print(' ', r)
PY
up() {
for i in $(seq 1 40); do
if curl -s -m 3 -o /dev/null "$URL/_matrix/client/versions"; then echo " synapse repond apres ${i}s"; return 0; fi
sleep 1
done
echo " KO: synapse ne repond pas"; return 1
}
echo "== 1. mot de passe du credential hashe (bcrypt 12) sur le compte admin temporaire =="
docker cp "$TMPD/hashpw.py" "$CTN:/tmp/g9-hashpw.py" >/dev/null
sed -n 's/^password=//p' "$CRED" | docker exec -i "$CTN" python3 /tmp/g9-hashpw.py "$TMP_ADMIN" "$DB"
docker exec "$CTN" rm -f /tmp/g9-hashpw.py
if [[ "$RESTART" == "1" ]]; then
echo "== 2. docker restart $CTN (caches sqlite videes apres ecriture directe) =="
docker restart "$CTN" >/dev/null; up
fi
echo "== 3. login du compte admin temporaire =="
sed -n 's/^password=//p' "$CRED" | python3 "$TMPD/loginbody.py" "$TMP_ADMIN" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
echo "== 4. PUT $TARGET (admin=true) =="
{ printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TMPD/token")"
printf 'header = "Content-Type: application/json"\n'; } > "$TMPD/curlrc"
chmod 600 "$TMPD/curlrc"
sed -n 's/^password=//p' "$CRED" | python3 "$TMPD/putbody.py" \
| curl -s -m 20 -X PUT "$URL/_synapse/admin/v2/users/$TARGET" -K "$TMPD/curlrc" -d @- > "$TMPD/put.json"
python3 "$TMPD/show.py" "$TMPD/put.json"
echo "== 5. compte temporaire : admin retire, desactive, hash a NULL =="
docker cp "$TMPD/strip.py" "$CTN:/tmp/g9-strip.py" >/dev/null
docker exec "$CTN" python3 /tmp/g9-strip.py "$TMP_ADMIN" "$DB"
docker exec "$CTN" rm -f /tmp/g9-strip.py
if [[ "$RESTART" == "1" ]]; then
echo "== 6. docker restart $CTN =="
docker restart "$CTN" >/dev/null; up
fi
echo "== 7. verification (lecture seule) (nom, admin, locked, deactivated, hash_present) =="
docker cp "$TMPD/report.py" "$CTN:/tmp/g9-report.py" >/dev/null
docker exec "$CTN" python3 /tmp/g9-report.py "$DB"
docker exec "$CTN" rm -f /tmp/g9-report.py
echo "credential : $CRED (600) — user=$TARGET ; a changer a la 1re connexion, puis 2FA (C-MX3)"
echo "verifier le login : bash $0 --verify $WANT"
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# 27-synapse-family-users.sh — cree les 6 comptes famille Matrix via l'API admin (admin=false).
#
# Pre-requis : un compte admin utilisable = `26-synapse-admin-repair.sh` (verifie par --admin-check).
# Cible C-MX1 : 2 adultes + 4 enfants, **admin=false**, mots de passe generes sur la machine et
# ecrits dans un fichier 600 (jamais affiches, jamais commitables), a recopier dans le coffre.
#
# Garde-fous : mode `--dry` par defaut de lecture, **confirmation `CREER` exigee** (TTY ou
# CONFIRM=CREER), 1 ecriture par compte, verification lecture seule apres, rollback = desactivation.
# Usage : bash 27-synapse-family-users.sh --dry
# CONFIRM=CREER bash 27-synapse-family-users.sh (ou saisie interactive)
# bash 27-synapse-family-users.sh --revoke (desactive les 6)
set -euo pipefail
URL="${URL:-http://192.168.1.24:8008}"
DOMAIN="${DOMAIN:-192.168.1.24}"
ADMIN="${ADMIN:-folzo}"
CRED="${CRED:-/home/folzo/synapse-admin-$ADMIN.cred}"
OUT="${OUT:-/home/folzo/synapse-famille.cred}"
ADULTS="${ADULTS:-effi audrey}"
KIDS="${KIDS:-carla ewan lola noah}"
TMPD="$(mktemp -d)"; chmod 700 "$TMPD"; trap 'rm -rf "$TMPD"' EXIT
cat > "$TMPD/loginbody.py" <<'PY'
import json, sys
print(json.dumps({"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": sys.stdin.buffer.read().strip().decode()}))
PY
cat > "$TMPD/token.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); tok = d.get("access_token", "")
open(sys.argv[2], "w").write(tok)
print(" " + ("login admin OK (token %d car.)" % len(tok) if tok else "KO login: " + str(d.get("error") or d)))
sys.exit(0 if tok else 1)
PY
cat > "$TMPD/putbody.py" <<'PY'
import json, sys
pw = sys.stdin.buffer.read().strip().decode()
print(json.dumps({"password": pw, "admin": False, "deactivated": False,
"display_name": sys.argv[1]}))
PY
cat > "$TMPD/list.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
us = d.get("users", [])
print(" total API:", d.get("total"))
for u in us:
print(" ", u.get("name"), "| admin", bool(u.get("admin")),
"| deactivated", bool(u.get("deactivated")), "| locked", bool(u.get("locked")))
PY
MODE="create"; [[ "${1:-}" == "--dry" ]] && MODE="dry"; [[ "${1:-}" == "--revoke" ]] && MODE="revoke"
[[ -f "$CRED" ]] || { echo "KO: credential admin absent ($CRED) — voir 26-synapse-admin-repair.sh"; exit 1; }
read -r -a ALL <<< "$ADULTS $KIDS"
echo "comptes vus : ${#ALL[@]} (${ALL[*]}) — 2 adultes + 4 enfants = 6 attendus"
if [[ "$MODE" == "dry" ]]; then
echo "== PLAN (0 ecriture) =="
for lp in "${ALL[@]}"; do
echo " PUT $URL/_synapse/admin/v2/users/@${lp}:${DOMAIN} {password: genere, admin: false}"
done
echo " mots de passe -> $OUT (600, folzo) ; rollback = --revoke (desactivation, jamais de purge)"
exit 0
fi
if [[ "$MODE" == "revoke" ]]; then
sed -n 's/^password=//p' "$CRED" | head -1 | python3 "$TMPD/loginbody.py" "@${ADMIN}:${DOMAIN}" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
{ printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TMPD/token")"
printf 'header = "Content-Type: application/json"\n'; } > "$TMPD/curlrc"
chmod 600 "$TMPD/curlrc"
for lp in "${ALL[@]}"; do
curl -s -m 20 -X PUT "$URL/_synapse/admin/v2/users/@${lp}:${DOMAIN}" -K "$TMPD/curlrc" \
-d '{"deactivated": true}' | python3 -c 'import json,sys;d=json.load(sys.stdin);print(" ", d.get("name") or d.get("error") or d)'
done
exit 0
fi
if [[ "${CONFIRM:-}" != "CREER" ]]; then
echo "confirmation exigee : tape CREER pour creer ${#ALL[@]} comptes (admin=false), mots de passe dans $OUT"
read -r ANSWER
[[ "$ANSWER" == "CREER" ]] || { echo "abandon (aucune ecriture)"; exit 1; }
fi
[[ -e "$OUT" ]] && { echo "KO: $OUT existe deja (ne pas ecraser des mots de passe existants)"; exit 1; }
sed -n 's/^password=//p' "$CRED" | head -1 | python3 "$TMPD/loginbody.py" "@${ADMIN}:${DOMAIN}" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
{ printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TMPD/token")"
printf 'header = "Content-Type: application/json"\n'; } > "$TMPD/curlrc"
chmod 600 "$TMPD/curlrc"
umask 077
: > "$OUT"
echo "== creation (${#ALL[@]} comptes, admin=false) =="
for lp in "${ALL[@]}"; do
PW="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
printf 'user=@%s:%s password=%s\n' "$lp" "$DOMAIN" "$PW" >> "$OUT"
printf '%s' "$PW" | python3 "$TMPD/putbody.py" "$lp" \
| curl -s -m 20 -X PUT "$URL/_synapse/admin/v2/users/@${lp}:${DOMAIN}" -K "$TMPD/curlrc" -d @- \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print(" ", d.get("name") or d.get("error") or d)'
unset PW
done
chmod 600 "$OUT"
echo "mots de passe : $OUT ($(wc -l < "$OUT") lignes, 600) — a recopier dans le coffre puis detruire"
echo "== verification (lecture seule) =="
curl -s -m 20 -K "$TMPD/curlrc" "$URL/_synapse/admin/v2/users?limit=50" > "$TMPD/users.json"
python3 "$TMPD/list.py" "$TMPD/users.json"
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# 28-synapse-create-room.sh — salons Matrix via l'API admin (0 compte cree, 0 message envoye).
#
# Motif (verificateur 12/10) : C-MX2 annonce « salon famille + salon ops crees » ; la mesure
# (sqlite mode=ro) ne donnait **que le salon ops**. Ce script cree le salon famille et laisse la
# preuve verificable. Reversible : --purge (DELETE /_synapse/admin/v1/rooms/<id>).
#
# Gardes : mot de passe et token **jamais en argv** (stdin + `curl -K` fichier 600, tmpdir 700),
# `--dry` par defaut, ecriture seulement sur --create, verification par GET admin + sqlite ro.
# Usage : bash 28-synapse-create-room.sh --list
# bash 28-synapse-create-room.sh --dry [--alias famille] [--invite "@ops:..." ...]
# CONFIRM=CREER bash 28-synapse-create-room.sh --create [--alias famille]
# CONFIRM=PURGER bash 28-synapse-create-room.sh --purge <room_id>
set -euo pipefail
URL="${URL:-http://192.168.1.24:8008}"
DOMAIN="${DOMAIN:-192.168.1.24}"
ADMIN="${ADMIN:-folzo}"
CRED="${CRED:-/home/folzo/synapse-admin-$ADMIN.cred}"
ALIAS="${ALIAS:-famille}"
ROOM_NAME="${ROOM_NAME:-Famille}"
INVITE="${INVITE:-@ops:192.168.1.24 @ops-bot:192.168.1.24}"
DB="${DB:-/data/homeserver.db}"
CTN="${CTN:-synapse}"
TMPD="$(mktemp -d)"; chmod 700 "$TMPD"; trap 'rm -rf "$TMPD"' EXIT
cat > "$TMPD/loginbody.py" <<'PY'
import json, sys
print(json.dumps({"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": sys.argv[1]},
"password": sys.stdin.buffer.read().strip().decode()}))
PY
cat > "$TMPD/token.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); tok = d.get("access_token", "")
open(sys.argv[2], "w").write(tok)
print(" " + ("login admin OK (token %d car.)" % len(tok) if tok else "KO login: " + str(d.get("error") or d)))
sys.exit(0 if tok else 1)
PY
cat > "$TMPD/roombody.py" <<'PY'
import json, sys
print(json.dumps({"name": sys.argv[2], "room_alias_name": sys.argv[1],
"preset": "private_chat", "visibility": "private", "is_direct": False,
"invite": sys.argv[3:]}))
PY
cat > "$TMPD/rooms.py" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
rooms = d.get("rooms", [])
print(" %s salons (API admin)" % d.get("total", len(rooms)))
for r in d.get("rooms", []):
print(" ", r.get("room_id"), "|", r.get("name"), "|", r.get("alias"),
"| membres:", r.get("joined_members"))
PY
[[ -f "$CRED" ]] || { echo "KO: credential admin absent ($CRED)"; exit 1; }
read -r -a INV <<< "$INVITE"
login() {
sed -n 's/^password=//p' "$CRED" | python3 "$TMPD/loginbody.py" "@${ADMIN}:${DOMAIN}" \
| curl -s -m 15 -X POST "$URL/_matrix/client/v3/login" -H 'Content-Type: application/json' -d @- > "$TMPD/login.json"
python3 "$TMPD/token.py" "$TMPD/login.json" "$TMPD/token"
{ printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TMPD/token")"
printf 'header = "Content-Type: application/json"\n'; } > "$TMPD/curlrc"
chmod 600 "$TMPD/curlrc"
}
list_rooms() {
curl -s -m 20 -K "$TMPD/curlrc" "$URL/_synapse/admin/v1/rooms?limit=50" > "$TMPD/rooms.json"
python3 "$TMPD/rooms.py" "$TMPD/rooms.json"
}
case "${1:---list}" in
--list)
login; list_rooms
echo "== resolution de l'alias #${ALIAS} =="
curl -s -m 15 -K "$TMPD/curlrc" "$URL/_matrix/client/v3/directory/room/%23${ALIAS}%3A${DOMAIN}" \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print(" #"+sys.argv[1]+" ->", d.get("room_id") or d.get("error") or d)' "$ALIAS"
echo "== sqlite (lecture seule) =="
cat > "$TMPD/rooms_ro.py" <<'PY'
import sqlite3, sys
c = sqlite3.connect('file:%s?mode=ro' % sys.argv[1], uri=True).cursor()
print(' salons en base:', c.execute('select count(*) from rooms').fetchone()[0])
for r in c.execute("select room_id, ifnull(creator,'-'), is_public, room_version from rooms"):
print(' ', r)
PY
docker cp "$TMPD/rooms_ro.py" "$CTN:/tmp/g9-rooms-ro.py" >/dev/null
docker exec "$CTN" python3 /tmp/g9-rooms-ro.py "$DB"
docker exec "$CTN" rm -f /tmp/g9-rooms-ro.py
;;
--dry)
shift; if [[ "${1:-}" == "--alias" ]]; then ALIAS="${2:?}"; shift 2; fi
echo "== PLAN (0 ecriture) =="
echo " POST $URL/_matrix/client/v3/createRoom (token admin)"
echo " note mesurée : $URL/_synapse/admin/v1/create_room = 404 M_UNRECOGNIZED sur cette version"
python3 "$TMPD/roombody.py" "$ALIAS" "$ROOM_NAME" "${INV[@]}" | python3 -c 'import json,sys;print(" body:", json.dumps(json.load(sys.stdin), ensure_ascii=False))'
echo " rollback : CONFIRM=PURGER bash $0 --purge <room_id>"
;;
--create)
shift; if [[ "${1:-}" == "--alias" ]]; then ALIAS="${2:?}"; shift 2; fi
if [[ "${CONFIRM:-}" != "CREER" ]]; then
echo "confirmation exigee : tape CREER pour creer le salon #$ALIAS (0 compte, 0 message)"
read -r A; [[ "$A" == "CREER" ]] || { echo "abandon (0 ecriture)"; exit 1; }
fi
login
python3 "$TMPD/roombody.py" "$ALIAS" "$ROOM_NAME" "${INV[@]}" > "$TMPD/room.json"
curl -s -m 20 -X POST "$URL/_matrix/client/v3/createRoom" -K "$TMPD/curlrc" \
-d @"$TMPD/room.json" -o "$TMPD/created.json" -w ' create http=%{http_code}\n'
python3 -c 'import json,os,sys
p = sys.argv[1]
if os.path.getsize(p) == 0: print(" reponse vide"); raise SystemExit(0)
d = json.load(open(p)); print(" cree:", d.get("room_id") or d.get("error") or d)' "$TMPD/created.json"
list_rooms
;;
--purge)
ROOM="${2:?room_id a purger}"
if [[ "${CONFIRM:-}" != "PURGER" ]]; then echo "abandon : CONFIRM=PURGER exige"; exit 1; fi
login
curl -s -m 60 -X DELETE -K "$TMPD/curlrc" "$URL/_synapse/admin/v1/rooms/$ROOM" \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print(" purge:", d.get("kicked") and "OK" or d)'
list_rooms
;;
*) echo "usage: --list | --dry | --create | --purge <room_id> (voir en-tete)"; exit 1 ;;
esac
+60
View File
@@ -0,0 +1,60 @@
#!/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():
import sys
env = load_env()
# sudo ne transmet pas l'environnement (sudoers sans SETENV) : le payload passe
# en argument, l'env reste accepté pour les appels cron.
raw = sys.argv[1] if len(sys.argv) > 1 else 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()
+80
View File
@@ -0,0 +1,80 @@
#!/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 fetch(limit):
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:
return json.loads(r.read().decode())
def main():
if len(sys.argv) > 2 and sys.argv[1] == "--list":
for ev in fetch(int(sys.argv[2])).get("chunk", []):
if ev.get("type") not in ("m.room.message", "m.text"):
continue
body = ev.get("content", {}).get("body", "").replace("\n", " ")
print(f"{ev.get('event_id')} {body[:170]}")
return
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") not in ("m.room.message", "m.text"):
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()
+26
View File
@@ -0,0 +1,26 @@
#!/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
if [ -z "$payload" ]; then
echo "NOTIFY_KO no-payload"
return 1
fi
b64="$(printf '%s' "$payload" | base64 -w0)"
# sudo ne transmet pas l'environnement (sudoers sans SETENV) : payload et URL
# passent en arguments, pas en variables exportées.
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$G9_SSH_KEY" "$G9_SSH" \
"sudo python3 /usr/local/bin/g9-notify.py '$b64' '$(cat "$HOME/.config/g9-migration/hub_url" 2>/dev/null)'" 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()