Compare commits

...
20 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
41 changed files with 2066 additions and 57 deletions
+32 -3
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 | | 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 | | 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 | | 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) | | 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 | | 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 | | 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) | | 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. | | **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). | | **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). | | **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. | | **Jellyfin** | Très mature, AGPL | ~1-2 Go (G9) | **Vidéo + musique** OSS, transcodage, multi-appareils. Standard de fait. |
@@ -431,6 +431,33 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
| **Moi** | Téléphone + PC | **Salon ops** (alertes backup / sondes / dérive registre) | | **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 | | **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 ### 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). > **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).
@@ -744,7 +771,7 @@ services:
| # | Action logicielle | Dépendance | Critère de succès | | # | 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.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.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) | | C.4 | **Enfants** : AdGuard profils + Family Link | Phase B | Enfants protégés (DNS + parental) |
@@ -903,6 +930,8 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
| Date | Version | Changement | | 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**. | | 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. | | 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.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). |
+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 | | 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` | | **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) | | **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.
+30 -2
View File
@@ -28,7 +28,7 @@
| Risque | Détail | Conséquence pour nous | | Risque | Détail | Conséquence pour nous |
|---|---|---| |---|---|---|
| **Bannissement du numéro** | WhatsApp détecte les clients non officiels ; le risque est **réel et non nul** | ton numéro familial est « indispensable » → **interdiction** : jamais le numéro principal sur un pont | | **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 | | 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 | | 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) | | Confidentialité | les MP passent par un daemon sur le G9 + stockage local | à accepter explicitement (choix de périmètre) |
@@ -53,7 +53,35 @@
| 6 | rollback documenté : `docker compose -f compose/whatsapp/docker-compose.yml down` (aucune donnée famille concernée) | | 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é) | | 7 | journal + grille S14 (décision, raison, coût constaté) |
## 6. Verdict de l'étude ## 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 - **Utile** si l'objectif est « lire au même endroit » (Element = foyer + alertes) et que le pilote
reste sur un **numéro secondaire**. reste sur un **numéro secondaire**.
+62 -11
View File
@@ -1,7 +1,9 @@
# ÉTUDE EXPOSITION INTERNET (T-D — étude, **aucune règle posée**) # ÉTUDE EXPOSITION INTERNET (T-D — étude, puis durcissement **autorisé**)
> Périmètre : documenter, pas agir. **0 règle ufw modifiée, 0 changement réseau** (G6). > Étude posée le 12/10 **sans aucune règle modifiée** (G6 respecté tant que les décisions
> Faits mesurés le 12/10/2026 en lecture seule (`ufw status numbered`, `ss -ltnu`, Caddyfile, journal). > 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 ## 1. Exposition mesurée aujourd'hui
@@ -9,7 +11,7 @@
|---|---|---| |---|---|---|
| **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)** | `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` | | **G9 (.24)** LAN-only | 2342, 3000, 3100, 5678, 5984, 8008, 8009, 8080, 8081, 8096, 9980, 2456-2458, SMB (137-139/445) | tout ce qui est « brique » est correctement restreint à `192.168.1.0/24` |
| **J5040 (.27)** | `80/tcp`, `443/tcp`, `51820/udp` (**Anywhere**) | le **vrai** edge : Caddy + Let's Encrypt (7/7 certs DNS-01) + WireGuard. 80 = redirect ACME | | **J5040 (.27)** | `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 | | **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) | | **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 | | **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 |
@@ -58,12 +60,61 @@
| 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 | | 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 | | 8 | Nommer Tailscale (accepté/documenté, ou désactivé) | 1 décision | supprime une voie non documentée | immédiate |
## 5. Décisions à rendre (ta main) ## 5. Décisions rendues le 2026-10-12 (et qui les pose)
1. Fermer `22` WAN sur le G9 ? (rang 1) Complément mesuré à la demande : `ufw status numbered` complet sur `.24` (25 règles) et `.27`
2. Démonter l'edge G9 à la bascule bridge ? (rang 2) (12 règles). Le pilot et le G9 sont **derrière le même NAT** : l'IP publique `82.65.198.114` est
3. Garder Gitea et n8n exposés, ou les réserver à WireGuard ? (rang 4) unique, donc ce qui est joignable depuis l'extérieur dépend des **redirects de la Freebox**, pas
4. Tailscale : documenté et assumé, ou coupé ? (rang 8) de ufw seul. ufw ne fait qu'ouvrir la porte une fois le paquet arrivé.
5. PhotoPrism et Jellyfin exposés hors maison, oui/non ? (grille)
**Tant que ces 5 réponses ne sont pas données, aucune règle n'est posée** (G6). | # | 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).
+42 -12
View File
@@ -7,10 +7,11 @@
## 1. Détenteurs (à nommer par l'utilisateur) ## 1. Détenteurs (à nommer par l'utilisateur)
| Rôle | Personne | Support remis | Date de remise | Test annuel | | Rôle | Personne | Support remis | Lieu (voir §2.1) | Code de déverrouillage | Date de remise | Test annuel |
|---|---|---|---|---| |---|---|---|---|---|---|---|
| Détenteur 1 | _à nommer_ | enveloppe scellée n°1 | _à date_ | _à date_ | | Moi | — | enveloppe n°1 | **sur place** (coffre du domicile) | le mien | — | _à date_ |
| Détenteur 2 (optionnel) | _à nommer_ | enveloppe scellée n°2 | _à date_ | _à 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, 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. capable d'appliquer une procédure écrite sous stress, **sans accès** aux machines.
@@ -25,8 +26,29 @@ capable d'appliquer une procédure écrite sous stress, **sans accès** aux mach
- une **note de datation** : à chaque changement de mot de passe maître, l'enveloppe est - 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). remplacée et l'ancienne est détruite (journaliser la date).
Support : papier (pas de clé USB non chiffrée), plié, scellé, signé de la date. Deux copies Support : papier (pas de clé USB non chiffrée), plié, scellé, signé de la date. Jamais de photo,
max. Jamais de photo, jamais de fichier non chiffré sur un disque. 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 ## 3. Ce que fait le détenteur si je suis injoignable
@@ -43,13 +65,21 @@ max. Jamais de photo, jamais de fichier non chiffré sur un disque.
## 4. Test annuel (critère) ## 4. Test annuel (critère)
Une fois par an, sans casser la production : 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.
- le détenteur ouvre l'enveloppe (ou une copie d'entraînement identique) ; | # | Point vérifié | Preuve |
- il déverrouille un **coffre d'entraînement** (vault de test, pas le coffre famille) avec les |---|---|---|
éléments de l'enveloppe ; | 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 |
- la procédure est chronométrée et les points de friction sont corrigés dans ce fichier ; | 2 | Le **mot de passe maître** déverrouille le coffre d'entraînement | login réussi sur le vault de test |
- la ligne est journalisée : `| date | partage-secret-maitre : test annuel | résultat | remplacement enveloppe |`. | 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é ## 5. Révocabilité
+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).
+36 -1
View File
@@ -71,6 +71,8 @@ g9-migration/
│ ├── g9-notify.py ← poste un payload JSON dans le salon ops (install /usr/local/bin) │ ├── 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) │ ├── 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) │ ├── 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] │ └── 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) ├── compose/ ← docker-compose.yml versionnés (stockés aussi dans Gitea)
│ ├── vaultwarden/ ← T4.1 │ ├── vaultwarden/ ← T4.1
@@ -82,7 +84,11 @@ g9-migration/
│ ├── edge/ ← T4.6 : Caddy G9 (CA interne, edge transitoire) │ ├── edge/ ← T4.6 : Caddy G9 (CA interne, edge transitoire)
│ ├── edge-j5040/ ← E4 : Caddy J5040 + plugin OVH (LE DNS-01, Caddyfile versionné) │ ├── edge-j5040/ ← E4 : Caddy J5040 + plugin OVH (LE DNS-01, Caddyfile versionné)
│ ├── adguard/ ← E2 : AdGuard Home (DNS filtré, image épinglée digest) │ ├── adguard/ ← E2 : AdGuard Home (DNS filtré, image épinglée digest)
│ └── watchtower/ ← S11.4 : Watchtower sélectif │ ├── 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/ └── configs/
├── fail2ban.local.conf ← config fail2ban (T2.1) ├── fail2ban.local.conf ← config fail2ban (T2.1)
└── restic.env.example ← template restic (T3.1) — REMPLIR les secrets └── restic.env.example ← template restic (T3.1) — REMPLIR les secrets
@@ -97,6 +103,9 @@ g9-migration/
| `RUNBOOKS/` | `nextcloud` · `matrix` · `jellyfin` · `photoprism` · `vaultwarden` · `registre` · `digest` : symptôme → diagnostic → action → rollback | matière première de l'assistante IA (G11) | | `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 | | `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 | | `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** | | `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** | | `BRIDGE-WHATSAPP.md` | Étude `mautrix-whatsapp` : apports, limites, risque de bannissement, interdits, pilote borné | **0 installation** |
@@ -111,9 +120,35 @@ g9-migration/
sudo ./scripts/12-probes.sh --install # /usr/local/bin/g9-probes.sh + cron */15 + digest 08:30 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/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 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) 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) ## Ordre d'exécution (phase par phase)
| Phase | Script / action | Garde-fou | | Phase | Script / action | Garde-fou |
+8 -13
View File
@@ -10,28 +10,29 @@
> >
> Couverture backup : g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5) > Couverture backup : g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5)
<!-- GÉNÉRÉ PAR scripts/11-registry.sh — ne pas éditer à la main. ts=2026-10-12 14:48 UTC ; 21 conteneurs --> <!-- 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) | | 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 | `collabora` | nextcloud / collabora | `collabora/code:26.04.5.1.1` | running | oui | false | stateful | oui | — |
| g9 | `couchdb` | g9-queue / couchdb | `couchdb:3.3` | running | oui | false | stateful | NON | — | | g9 | `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 | `edge` | edge / edge | `caddy:2` | running | oui | true | stateless | oui | — |
| g9 | `element` | matrix / element | `vectorim/element-web:latest` | running | non (latest) | false | stateful | NON | — | | g9 | `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` | 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 | `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 | `jellyfin` | jellyfin / jellyfin | `jellyfin/jellyfin:10.9` | running | oui | false | stateful | oui | media.folzocorp.fr |
| g9 | `n8n` | g9-queue / n8n | `docker.n8n.io/n8nio/n8n:latest` | running | non (latest) | false | stateful | NON | n8n.folzocorp.fr | | g9 | `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` | 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_db` | nextcloud / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
| g9 | `nextcloud_redis` | nextcloud / redis | `redis:7` | running | oui | false | stateful | oui | — | | g9 | `nextcloud_redis` | nextcloud / redis | `redis:7` | running | oui | false | stateful | oui | — |
| g9 | `obsidian` | g9-queue / obsidian | `lscr.io/linuxserver/obsidian:latest` | running | non (latest) | false | stateful | NON | — | | g9 | `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` | 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 | `photoprism_db` | photoprism / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
| g9 | `synapse` | matrix / synapse | `matrixdotorg/synapse:latest` | running | non (latest) | false | stateful | NON | matrix.folzocorp.fr | | g9 | `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 | `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 | `vaultwarden` | vaultwarden / vaultwarden | `vaultwarden/server:1.37.4` | running | oui | false | stateful | oui | pass.folzocorp.fr |
| g9 | `watchtower` | g9-queue / watchtower | `45b54f6dc152` | running | oui (digest) | true | stateless | NON | — | | g9 | `watchtower` | g9-queue / watchtower | `45b54f6dc152` | running | oui (digest) | true | stateless | oui | — |
| j5040 | `adguard` | adguard / adguard | `aba9e3bf0613` | 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 | `caddy` | edge-j5040 / caddy | `502996b5b60a` | running | oui (digest) | true | stateless | oui | — |
| j5040 | `sweet_feynman` | - / - | `437a490f7afc` | running | oui (digest) | ABSENT | stateless | NON | — | | j5040 | `sweet_feynman` | - / - | `437a490f7afc` | running | oui (digest) | ABSENT | stateless | NON | — |
@@ -40,15 +41,9 @@
- g9/valheim: conteneur réel non déclaré dans le kit (compose) - g9/valheim: conteneur réel non déclaré dans le kit (compose)
- g9/n8n: image non épinglée (docker.n8n.io/n8nio/n8n:latest) — S11.4 pinning - g9/n8n: image non épinglée (docker.n8n.io/n8nio/n8n:latest) — S11.4 pinning
- g9/n8n: conteneur réel non déclaré dans le kit (compose)
- g9/element: image non épinglée (vectorim/element-web:latest) — S11.4 pinning - g9/element: image non épinglée (vectorim/element-web:latest) — S11.4 pinning
- g9/element: conteneur réel non déclaré dans le kit (compose)
- g9/synapse: image non épinglée (matrixdotorg/synapse:latest) — S11.4 pinning - g9/synapse: image non épinglée (matrixdotorg/synapse:latest) — S11.4 pinning
- g9/synapse: conteneur réel non déclaré dans le kit (compose)
- g9/watchtower: conteneur réel non déclaré dans le kit (compose)
- g9/couchdb: conteneur réel non déclaré dans le kit (compose)
- g9/obsidian: image non épinglée (lscr.io/linuxserver/obsidian:latest) — S11.4 pinning - g9/obsidian: image non épinglée (lscr.io/linuxserver/obsidian:latest) — S11.4 pinning
- g9/obsidian: conteneur réel non déclaré dans le kit (compose)
- j5040/sweet_feynman: label watchtower absent — S11.4 sélectivité - j5040/sweet_feynman: label watchtower absent — S11.4 sélectivité
- j5040/sweet_feynman: conteneur réel non déclaré dans le kit (compose) - j5040/sweet_feynman: conteneur réel non déclaré dans le kit (compose)
+105 -4
View File
@@ -18,6 +18,7 @@
| Infra / services | `infra/<brique>/<compte>` | `infra/gitea/g9ops`, `infra/n8n/api`, `infra/synapse/admin` | | Infra / services | `infra/<brique>/<compte>` | `infra/gitea/g9ops`, `infra/n8n/api`, `infra/synapse/admin` |
| Adultes | `famille/<pseudo>/<brique>` | `famille/effi/nextcloud`, `famille/effi/matrix` | | Adultes | `famille/<pseudo>/<brique>` | `famille/effi/nextcloud`, `famille/effi/matrix` |
| Enfants | `enfants/<pseudo>/<brique>` (coffre des parents) | `enfants/lea/jellyfin` | | 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) : 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 `Famille` = 2 adultes (secrets partagés : wifi, DAV, comptes communs) ;
@@ -40,8 +41,9 @@ Comptes d'automatisation : préfixe de rôle, jamais un prénom humain → `ops-
| **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 | | **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 | | **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 | | **Jellyfin** | `g9ops` (admin, créé par le wizard) | service/opérateur | — | `infra/jellyfin/g9ops` | API avec `X-Emby-Authorization` ; si `EnableLocalPassword=0` (piège mesuré du wizard) : `UPDATE Users SET EnableLocalPassword=1` en SQLite + restart | moi |
| **n8n** | **owner = mon compte** (accès perdu puis recréé, journal 10/10 21:12) | admin humain | à activer | `infra/n8n/owner`, `infra/n8n/api` (clé API dédiée) | UI → Settings → Users ; si accès perdu : créer un owner via CLI n8n (`n8n export:users` n'existe pas → passer par la DB Postgres, procédure §4) | moi | | **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 |
| **Synapse (Matrix)** | admin (créé via `registration_shared_secret`) + `ops-bot` (token) | admin + service | **TOTP à activer** | `infra/synapse/admin`, `infra/synapse/token-ops-bot` | API admin v2 (`PUT /_synapse/admin/v2/users/<uid>`), token admin via login ; déactivation propre = `POST /_synapse/admin/v1/deactivate/<uid>` | moi | | **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 | | **Element (web)** | — (frontal, pas de compte) | — | — | — | — | moi |
| **CouchDB / Obsidian** | locaux (stack du second cerveau) | service | — | `infra/couchdb/admin` | `docker exec` + config du compose | 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 | | **Valheim** | aucun compte (serveur jeu, whitelist par steamid) | — | — | — | `player.list` dans `data/save` | moi |
@@ -68,8 +70,32 @@ Comptes d'automatisation : préfixe de rôle, jamais un prénom humain → `ops-
(`git remote -v` sur les clones, `~/.git-credentials`) → journal + rollback. (`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 ; 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). API = en-tête **`X-Emby-Authorization`** combiné (sinon 400).
4. **Synapse sans admin** : ajouter `registration_shared_secret` dans `homeserver.yaml` (sudo), restart, 4. **Synapse sans admin** — **résolu le 12/10, voie mesurée** : la voie « secret +
`docker exec <synapse> register_new_matrix_user -a -u <admin>`, **retirer** le secret, restart. `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) ## 5. Hygiène des secrets (T-A.5)
@@ -80,8 +106,83 @@ Comptes d'automatisation : préfixe de rôle, jamais un prénom humain → `ops-
| Token Matrix `ops-bot` | vit dans `/etc/restic/matrix.env` (600 root) ; jamais dans un script, jamais affiché | | 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 | | 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 ## 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 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). `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). 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.
+4 -2
View File
@@ -12,9 +12,11 @@
| Symptôme | Diagnostic (lecture seule) | Action | Rollback | | Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|---|---|---|---| |---|---|---|---|
| Pas de compte admin | login admin → 403 | ajouter `registration_shared_secret` (sudo, `homeserver.yaml`) → restart → `docker exec <synapse> register_new_matrix_user -a -u <admin>` → **retirer** le secret → restart | retirer la ligne + restart | | 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) | créer avec `admin:false` ; inscription publique reste **coupée** (voulu) | `POST /_synapse/admin/v1/deactivate/<uid>` (conserve la trace) | | 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é) | | 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) | | 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 | | 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 | | 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 |
@@ -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"
@@ -55,3 +55,8 @@ matrix.folzocorp.fr {
import folzocorp import folzocorp
reverse_proxy 192.168.1.24:8008 reverse_proxy 192.168.1.24:8008
} }
openfox.folzocorp.fr {
import folzocorp
reverse_proxy 192.168.1.11:10369
}
@@ -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"
+32
View File
@@ -134,3 +134,35 @@
| 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: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: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: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é) |
@@ -116,7 +116,12 @@ 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 --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/" 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 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" $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). # 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 "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"
+6 -2
View File
@@ -115,8 +115,12 @@ if [[ "$MODE" == "check" ]]; then
exit 2 exit 2
fi 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)" base="$(git -C "$KIT" show HEAD:g9-migration/REGISTRE-BRIQUES.md 2>/dev/null || git -C "$KIT" show HEAD:REGISTRE-BRIQUES.md 2>/dev/null || true)"
body_new="$(printf '%s' "$render" | sed '1,4d')" # La ligne d'en-tête porte un horodatage : elle est exclue par motif (elle n'est pas
body_old="$(printf '%s' "$base" | sed '1,4d')" # à 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 if [[ -z "$body_old" ]]; then
echo "[registry] pas de version commitée de comparaison" >&2 echo "[registry] pas de version commitée de comparaison" >&2
exit 2 exit 2
+10 -3
View File
@@ -65,7 +65,14 @@ mkdir -p "$STATE_DIR"
notify() { notify() {
local payload="$1" local payload="$1"
if [[ "$QUIET" == "1" ]]; then return 0; fi if [[ "$QUIET" == "1" ]]; then return 0; fi
REGISTRY_B64="$(printf '%s' "$payload" | base64 -w0)" python3 /usr/local/bin/g9-notify.py 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() { alert() {
@@ -156,10 +163,10 @@ nb_ok="$(grep -c '^OK' "$results" || true)"
nb_ko="$(grep -c '^KO' "$results" || true)" nb_ko="$(grep -c '^KO' "$results" || true)"
if [[ "$MODE" == "digest" ]]; then if [[ "$MODE" == "digest" ]]; then
summary="$(grep '^KO' "$results" | sed 's/^KO //' | head -8 | tr '\n' '; ')" summary="$(grep '^KO' "$results" | sed 's/^KO //' | head -8 | tr '\n' '; ' || true)"
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" 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}")" payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"12-probes","brique":"digest","host":sys.argv[2],"check":"daily","http_code":0,"severite":"info","message":f"{sys.argv[3]} OK / {sys.argv[4]} KO — {sys.argv[5]}","runbook":"RUNBOOKS/digest.md"},ensure_ascii=False))' "$ts" "$HOST" "$nb_ok" "$nb_ko" "${summary:-tout OK}")"
notify "$payload" notify_force "$payload"
fi fi
echo "sonde : $nb_ok OK / $nb_ko KO" echo "sonde : $nb_ok OK / $nb_ko KO"
rm -f "$results" 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
+5 -1
View File
@@ -27,8 +27,12 @@ def load_env():
def main(): def main():
import sys
env = load_env() env = load_env()
raw = os.environ.get("REGISTRY_B64", "") # 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: if not raw:
print("NOTIFY_KO no-payload") print("NOTIFY_KO no-payload")
return return
+17 -1
View File
@@ -26,7 +26,23 @@ def load_env():
return env 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(): 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 limit = int(sys.argv[1]) if len(sys.argv) > 1 else 15
env = load_env() env = load_env()
room = urllib.parse.quote(env["MATRIX_ROOM"], safe="") room = urllib.parse.quote(env["MATRIX_ROOM"], safe="")
@@ -38,7 +54,7 @@ def main():
bad = 0 bad = 0
seen = [] seen = []
for ev in data.get("chunk", []): for ev in data.get("chunk", []):
if ev.get("type") != "m.room.message": if ev.get("type") not in ("m.room.message", "m.text"):
continue continue
body = ev.get("content", {}).get("body", "") body = ev.get("content", {}).get("body", "")
try: try: