Compare commits
35
Commits
366c5ca7a7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06713702ff | ||
|
|
431ba70f25 | ||
|
|
8b97089bfa | ||
|
|
7fab09409c | ||
|
|
c7385be393 | ||
|
|
f9fde1e622 | ||
|
|
9eae93be0b | ||
|
|
aeee208c04 | ||
|
|
4f1c267404 | ||
|
|
4a3c460b57 | ||
|
|
e27ac1ea90 | ||
|
|
79197d13cb | ||
|
|
67b5b68c18 | ||
|
|
70265acddd | ||
|
|
f13fc2472a | ||
|
|
3f3f14302d | ||
|
|
d61eb0f67b | ||
|
|
76a348449e | ||
|
|
3fcfa4459b | ||
|
|
b98fbf76b4 | ||
|
|
3b162dea50 | ||
|
|
a955bcd4c2 | ||
|
|
4b26049b8f | ||
|
|
ca64eaaf77 | ||
|
|
2f98c32da1 | ||
|
|
a5b2909722 | ||
|
|
6ee136b35d | ||
|
|
d4f68cb8f7 | ||
|
|
71f1ec33fd | ||
|
|
a3a198027d | ||
|
|
6516d67d0b | ||
|
|
0b95d24046 | ||
|
|
e2dc0176ff | ||
|
|
ffe3f2ac91 | ||
|
|
e6295cffc2 |
@@ -12,3 +12,6 @@ g9-migration/configs/restic.env
|
||||
|
||||
# Artefacts playwright-cli (snapshots navigateur)
|
||||
.playwright-cli/
|
||||
|
||||
# Config WireGuard générée (clés privées — G9)
|
||||
**/wireguard/wg/
|
||||
|
||||
+76
-10
@@ -127,7 +127,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
||||
| 5 | **WireGuard** | Accès distant chiffré | J5040 | GPL-2.0 | Système (noyau), très stable | Clés exportables |
|
||||
| 6 | **Web Station** | Sites web (dd/lola.folzocorp…) | J5040 | — (contenu) | Statique | Fichiers, exportables |
|
||||
| 7 | **Beszel** | Monitoring léger + alertes | J5040 | MIT | Mature, maintenu (henrygd) | Base SQLite, exportable |
|
||||
| 8 | **Matrix (Synapse)** | **Messagerie** (remplace WhatsApp) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
|
||||
| 8 | **Matrix (Synapse)** | **Messagerie du foyer** (complément de WhatsApp, **pas de remplacement**) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
|
||||
| 9 | **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 | Mature (Jitsi Meet), maintenu | N/A (appels éphémères) |
|
||||
| 10 | **\*arr** (Prowlarr + Sonarr + Radarr) — **1 stack** | Gestion **séries/films** (indexers + queue) | J5040 | GPL-3.0 | Très mature (la stack \*arr), maintenu | Bases SQLite exportables, config |
|
||||
| 11 | **qBittorrent** | **Téléchargement** (torrent/HTTP) | J5040 | GPL-2.0 | Très mature, maintenu | File de tâches exportable |
|
||||
@@ -189,7 +189,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
||||
| Brique | Maturité | Ressource (machine cible) | Pourquoi CELLE-ci (vs alternatives) |
|
||||
|--------|----------|---------------------------|--------------------------------------|
|
||||
| **Caddy** | Caddy 2, très stable | ~50 Mo (J5040) | **TLS auto** (Let's Encrypt) sans script. Plus simple que Traefik/Nginx pour un usage familial. |
|
||||
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Remplace WhatsApp** (chiffré, fédéré, OSS). Alternative : Element = le client. |
|
||||
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Messagerie du foyer** (chiffré, fédéré, OSS) — **complément** de WhatsApp, **pas un remplacement**. Alternative : Element = le client. |
|
||||
| **Jitsi** | Mature (Jitsi Meet) | ~1-2 Go (J5040) | **Visio** OSS, s'intègre à Matrix (Element). |
|
||||
| **PhotoPrism** | Mature, AGPL | ~2-3 Go (G9) | **Lecture in-place** (ne déplace pas les originaux) + IA (visages, recherche sémantique) + partage. Plus sobre qu'Immich (qui désorganise). |
|
||||
| **Jellyfin** | Très mature, AGPL | ~1-2 Go (G9) | **Vidéo + musique** OSS, transcodage, multi-appareils. Standard de fait. |
|
||||
@@ -411,25 +411,53 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
||||
|
||||
## S8 — Communication (Matrix + Jitsi + assistant vocal)
|
||||
|
||||
> **Statut** : ✅ **Tranché** (05/10/2026) — **Matrix + Jitsi + Element** (remplace WhatsApp) + **assistant vocal** via Home Assistant.
|
||||
> **Statut** : ✅ **Tranché** (05/10/2026), **révisé 12/10/2026** — **Matrix + Jitsi + Element** = **complément du foyer** (messagerie familiale + canal d'alerte + assistant), **pas un remplacement de WhatsApp**. WhatsApp reste la messagerie principale (famille, amis, travail) — réalité d'usage mesurée, voir `g9-migration/BRIDGE-WHATSAPP.md`.
|
||||
|
||||
### 8.1 La stack communication
|
||||
|
||||
| Brique | Rôle | Machine | Licence |
|
||||
|--------|------|---------|---------|
|
||||
| **Matrix (Synapse)** | **Messagerie** (remplace WhatsApp) | J5040 | Apache-2.0 |
|
||||
| **Matrix (Synapse)** | **Messagerie du foyer** + **canal d'alerte ops** (complément de WhatsApp) | J5040 | Apache-2.0 |
|
||||
| **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 |
|
||||
| **Element** | **Client** (Android) | Téléphone | GPL-3.0 |
|
||||
| **Element** | **Client** (Android) — 2 adultes d'abord, ados en phase 2 | Téléphone | GPL-3.0 |
|
||||
| **Home Assistant** | **Assistant vocal** (enceintes Google) | Freebox | Apache-2.0 |
|
||||
|
||||
### 8.2 L'usage famille (résilience = usage)
|
||||
|
||||
| Utilisateur | Appareil | Usage quotidien |
|
||||
|-------------|----------|-----------------|
|
||||
| **Famille** | Téléphone (Element) | **Messagerie** (remplace WhatsApp) |
|
||||
| **Famille** | Téléphone (Element) | **Messagerie du foyer** (salon famille) — **WhatsApp garde** les échanges extérieurs (amis, travail, famille élargie) |
|
||||
| **Famille** | Téléphone + TV | **Visio** (Jitsi, via Element) |
|
||||
| **Moi** | Téléphone + PC | **Salon ops** (alertes backup / sondes / dérive registre) |
|
||||
| **Moi** | Enceintes Google ×4-5 | **Assistant vocal** (Home Assistant) → peut déclencher n8n, contrôler Frigate |
|
||||
|
||||
**Décision 12/10/2026 (statut du canal)** : Matrix + Jitsi + Element = **complément du foyer** —
|
||||
salon famille, salon ops, salon Effi. **WhatsApp n'est pas remplacé** et reste la messagerie
|
||||
principale (famille élargie, amis, travail) : c'est un constat d'usage mesuré, pas un choix
|
||||
technologique. Element s'installe d'abord sur les **2 appareils adultes** ; les 4 ados viennent en
|
||||
**phase 2**, déclenchée par le **GO famille** explicite de l'utilisateur. Grille de décision :
|
||||
**S14.5**.
|
||||
|
||||
**Les 4 interdictions** (applicables dès maintenant, vérifiables) :
|
||||
|
||||
1. **Interdiction** de présenter Matrix comme un remplacement de WhatsApp, et d'inciter la famille
|
||||
à quitter WhatsApp pour Matrix (WhatsApp garde les échanges extérieurs).
|
||||
2. **Interdiction** de faire transiter les **alertes ops** par WhatsApp : le canal d'alerte est
|
||||
**Matrix uniquement** (pas d'e-mail, pas de SMS, pas de WhatsApp).
|
||||
3. **Interdiction** d'**automatiser l'envoi** de messages WhatsApp (pont `mautrix-whatsapp` :
|
||||
**étudié, non déployé**, numéro familial **interdit** — voir `g9-migration/BRIDGE-WHATSAPP.md`).
|
||||
4. **Interdiction** d'équiper les **4 ados** d'Element (et de comptes enfants Matrix) **sans GO
|
||||
famille** explicite, et sans coffre/2FA posé pour eux (C-FM1).
|
||||
|
||||
Ces 4 interdictions sont **contrôlables** : interdiction 1 = vocabulaire du dossier, contrôlée par
|
||||
la commande suivante dont la sortie doit être **vide** (0 ligne) :
|
||||
`grep -in "remplace whatsapp" DOSSIER-SOFTWARE.md g9-migration/*.md g9-migration/RUNBOOKS/*.md GUIDE-MATRIX.md | grep -vi "pas\|complément\|interdiction\|ne présente"`
|
||||
— les lignes qui mentionnent WhatsApp le font **en négation** (S3.1 #8, S3.3, S8 statut, S8.2
|
||||
décision + cette liste, S13.3 C.1, S14.5, changelog, `journal.md` = historique) ;
|
||||
interdiction 2 = le canal d'alerte est Matrix (3
|
||||
notifications types mesurées dans le salon ops) ; interdiction 3 = 0 flux n8n vers WhatsApp
|
||||
(inventaire n8n) ; interdiction 4 = 0 compte ado Matrix (Synapse mesuré : 4 comptes, aucun ado).
|
||||
|
||||
### 8.3 Le bot hermes ↔ Matrix
|
||||
|
||||
> **hermes** (Effi, sur PC IA) est **connecté à Matrix** : la famille peut **parler à Effi** dans un salon Matrix. C'est l'**assistant de famille** (projet second cerveau, phase 3).
|
||||
@@ -461,7 +489,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
||||
| Élément | Détail |
|
||||
|---------|--------|
|
||||
| **Domaine** | **folzocorp.fr** (principal) + **sous-domaines** par service |
|
||||
| **Sous-domaines** | `cloud.folzocorp.fr` (Nextcloud) · `photos.folzocorp.fr` (PhotoPrism) · `media.folzocorp.fr` (Jellyfin) · `matrix.folzocorp.fr` (Matrix) · `git.folzocorp.fr` (Gitea) · `vault.folzocorp.fr` (Vaultwarden) · `ai.folzocorp.fr` (OpenWebUI) |
|
||||
| **Sous-domaines** | `cloud.folzocorp.fr` (Nextcloud) · `prisme.folzocorp.fr` (**PhotoPrism**) · `photos.folzocorp.fr` (**Synology Photos** — LAN-only, **temporaire**, disparaît avec le xpenology en Phase 6) · `media.folzocorp.fr` (Jellyfin) · `matrix.folzocorp.fr` (Matrix) · `git.folzocorp.fr` (Gitea) · `pass.folzocorp.fr` (Vaultwarden) · `n8n.folzocorp.fr` (n8n) · `ai.folzocorp.fr` (OpenWebUI) |
|
||||
| **TLS** | **Caddy** génère + renouvelle **automatiquement** les certificats Let's Encrypt (pas de script). |
|
||||
| **Exposition** | **Seulement** les services **nécessaires** à l'extérieur passent par Caddy. Le **reste** est **LAN-only** (accès via **WireGuard**). |
|
||||
|
||||
@@ -555,7 +583,9 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
||||
| **Docker Compose** | **Déclaration** des services (fichiers `docker-compose.yml`) | toutes | Apache-2.0 |
|
||||
| **Gitea** | **Versioning** des `docker-compose.yml` + config (source de vérité) | G9 | MIT |
|
||||
| **Watchtower** | **Mises à jour** automatiques **sélectives** (stateless auto / stateful exclu + manuel) | J5040 + G9 + PC IA | Apache-2.0 |
|
||||
| **Beszel** | **Monitoring** léger + historique + **alertes** | J5040 (hub) + agents | MIT |
|
||||
| **Beszel** | **Monitoring** léger + historique + **alertes** (métriques + état conteneurs) | J5040 (hub) + agents | MIT |
|
||||
| **Registre** (`11-registry.sh` → `REGISTRE-BRIQUES.md`) | **Inventaire généré** (images épinglées, labels, endpoints, état) + **détection de dérive** par `git diff` | pilot → .24/.27 (lecture seule) | MIT (kit) |
|
||||
| **Sondes applicatives** (`12-probes.sh`) | **`up ≠ utilisable`** : sonde la **fonction** (occ status, discovery, healthz, login) + **alertes JSON** + **1 digest/jour** | G9 (cron) | MIT (kit) |
|
||||
| **n8n** | **Alertes** + **automatisation** (workflows) | G9 | ⚠️ fair-code |
|
||||
|
||||
### 11.2 Pourquoi pas Portainer / PaaS
|
||||
@@ -741,7 +771,7 @@ services:
|
||||
|
||||
| # | Action logicielle | Dépendance | Critère de succès |
|
||||
|---|-------------------|------------|-------------------|
|
||||
| C.1 | **Matrix + Jitsi + Element** (remplace WhatsApp) | Phase B | Famille messagerie + visio en local |
|
||||
| C.1 | **Matrix + Jitsi + Element** (complément du foyer, **pas** un remplacement de WhatsApp) | Phase B | Famille messagerie + visio en local |
|
||||
| C.2 | **Home Assistant** (enceintes Google) + **Frigate** (caméras) | Phase B | Assistant vocal + vision caméras |
|
||||
| C.3 | **Bot hermes ↔ Matrix** (Effi famille) | Phase B + 2ᵉ cerveau | Effi répond dans Matrix |
|
||||
| C.4 | **Enfants** : AdGuard profils + Family Link | Phase B | Enfants protégés (DNS + parental) |
|
||||
@@ -827,7 +857,7 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
|
||||
| Fédéré / auto-hébergé | ✅ | ❌ | 🟡 |
|
||||
| **Verdict** | **✅ Recommandé** | ❌ (GAFAM) | 🟡 (pas d'auto-hébergement facile) |
|
||||
|
||||
> **Recommandation** : **Matrix (Synapse) + Jitsi + Element** (remplace WhatsApp).
|
||||
> **Recommandation** : **Matrix (Synapse) + Jitsi + Element** en **complément du foyer** (messagerie familiale + alertes + assistant). **Révisé 12/10/2026** : WhatsApp **n'est pas remplaçable** dans notre usage (famille, amis, travail) → il reste la messagerie principale ; Element couvre le **foyer** et le **canal d'alerte**. Le pont `mautrix-whatsapp` est **étudié, non déployé** : numéro familial **interdit**, risque de bannissement, coût de session (QR / 12 jours). Détail : `g9-migration/BRIDGE-WHATSAPP.md`.
|
||||
|
||||
### 14.6 MDM enfants
|
||||
|
||||
@@ -840,6 +870,39 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
|
||||
|
||||
> **Recommandation** : **Google Family Link** (maintenant, usage parental) + **MDM OSS** (MicroMDM/Android Enterprise) en **option future**.
|
||||
|
||||
### 14.7 Coffre famille (décision 12/10/2026)
|
||||
|
||||
| Critère | **Vaultwarden unique (6 personnes)** ✅ | Nextcloud Passwords |
|
||||
|---------|----------------------------------------|---------------------|
|
||||
| Dépendance | ✅ indépendant des autres briques | ❌ le coffre vit dans la brique qu'il protège (Nextcloud HS = plus les accès pour réparer Nextcloud) |
|
||||
| Reset admin d'un compte enfant | ✅ sans effet sur le coffre | ❌ clés dérivées du mot de passe → coffre potentiellement illisible |
|
||||
| Modèle familial (organisations, emergency access) | ✅ | ❌ |
|
||||
| Coût pour 6 (import, backup, reset, formation) | ✅ 1 chemin | ❌ 2 chemins (registre doublé) |
|
||||
| **Verdict** | **✅ Retenu** | ❌ Non retenu (pilote adulte possible, non déclenché) |
|
||||
|
||||
### 14.8 Identité (décision 12/10/2026, ordre validé)
|
||||
|
||||
| Étape | Décision | Statut |
|
||||
|---|---|---|
|
||||
| 1 | **Registre d'identités** (moitié générée + moitié humaine) — répond à « quel compte a servi ? » | ✅ posé (`g9-migration/REGISTRE-IDENTITES.md`) |
|
||||
| 2 | **Étude exposition internet** avant tout IdP | ✅ étude écrite, 5 décisions à rendre |
|
||||
| 3 | **Authelia** en cible, pilote 3 briques (Synapse, Gitea, Jellyfin), **opt-in + break-glass** | 🟡 après étude + Phase 5 |
|
||||
| — | Authentik / Keycloak / Samba AD / FreeIPA / Nextcloud-IdP | ❌ écartés (poids, sur-ingénierie, SPOF sur la brique la plus stateful) |
|
||||
|
||||
### 14.9 Suivi applicatif (décision 12/10/2026)
|
||||
|
||||
| Critère | **Sondes + registre + Diun** ✅ | Grafana/Prometheus (+cAdvisor) | Netdata | Uptime Kuma |
|
||||
|---|---|---|---|---|
|
||||
| Couvre `up ≠ utilisable` | ✅ (occ, discovery, healthz, login) | ❌ (métriques) | 🟡 | ✅ |
|
||||
| Briques ajoutées | **0** (scripts du kit) | 4 | 1 (lourd) | 1 (stateful) |
|
||||
| Chevauchement avec Beszel | **complémentaire** | redondant (InfluxDB déjà écarté) | redondant | partiel |
|
||||
| Charge de maintenance | faible (cron + 1 digest/jour) | élevée | moyenne | moyenne + fatigue d'alertes |
|
||||
| **Verdict** | **✅ Retenu** | ❌ | ❌ | 🟡 reporté (déclencheur écrit, Phase 6 + 2 semaines d'usage) |
|
||||
|
||||
### 14.10 Autonomie de l'assistante IA (décision 12/10/2026)
|
||||
|
||||
> **Paliers G11** : L0 lecture seule → L1 propose → L2 exécute le **stateless documenté** → L3 stateful **sur GO explicite**. Portes chiffrées (≥ 90 % de propositions pertinentes sur 30 jours ; sinistre simulé sans perte). Interdits permanents : destructif, suppression de données, réseau (G6), wipe, création de privilège, lecture de secrets hors coffre. Détail : `g9-migration/README.md` (charte G11).
|
||||
|
||||
---
|
||||
|
||||
## S15 — Critères d'acceptance (AC1–AC10)
|
||||
@@ -867,6 +930,9 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
|
||||
|
||||
| Date | Version | Changement |
|
||||
|------|---------|------------|
|
||||
| 12/10/2026 | **v1.8** | **Identités mesurées + trou Synapse réparé + exposition durcie** : balayage lecture seule (`15-`/`16-identity-sweep`) → **Gitea 2FA = 0/4 et le bot `g9ops` est admin**, **Synapse admin = 0**, Nextcloud 8 comptes + providers 2FA installés, n8n 1 owner, PhotoPrism 3 comptes, **CouchDB = 401 (faux positif « sans auth » corrigé)** ; **1ᵉʳ admin Synapse créé et vérifié** (`26-synapse-admin-repair.sh` : `register` → mxid aléatoire `password_hash` NULL, **`HMAC incorrect`** sur la voie signée, puis **bcrypt en base + `docker restart` + `PUT /_synapse/admin/v2/users`** → login **OK** + API admin **OK** ; snapshot sqlite avant, compte temporaire désactivé, 0 purge ; `27-synapse-family-users.sh` outille les 6 comptes famille `admin:false`) ; **pare-feu durci sur GO** : SSH **LAN-only sur .24 et .27**, **443 LAN-only sur .24**, **plus aucune règle `Anywhere`** sur le G9, Tailscale coupé sur le pilot ; **dumps logiques** des 3 bases dans le périmètre restic + **rejouabilité Gitea prouvée** (116 tables) ; **hub Beszel hors-G9** sur J5040 (digest, ufw LAN-only, 200) ; **31 critères clôturés côté IA** avec distinction explicite « prouvé par mesure » / « geste humain » (`RESTE-HUMAIN.md`). Artéfacts : `scripts/{25,26,27}-*.sh`, §7 et §7.1 de `REGISTRE-IDENTITES.md`, `RUNBOOKS/matrix.md` réécrit. |
|
||||
| 12/10/2026 | **v1.7** | **Mesures Phase 4.5 (l'existant est maintenant prouvé, pas seulement décidé)** : registre **22 conteneurs / 7 constats** et définitions compose **entrées dans le dépôt** (`matrix/`, `g9-queue/`, `valheim/`, `diun/` vivaient hors git) ; **dérive prouvée de bout en bout** (`--check --alert` → payload JSON reçu → revert → « aucune dérive ») ; **chaîne d'alerte vérifiée** : 5 payloads JSON conformes dans le salon ops + 3 notifications types avec event_ids (backup OK, backup KO provoqué, sonde KO) ; **Diun déployé** (digest `crazymax/diun@sha256:e324b793…`, notifie sans appliquer, `docker inspect` avant/après identique) ; **`prisme.folzocorp.fr` = 200** (route posée sur le J5040, Let's Encrypt DNS-01) — `photos.` sert encore PhotoPrism, web DSM `.25` = 000 ; **backup** : 5 snapshots Freebox, coffres + `gitea/db` + `nextcloud/db` + **505 836 entrées photos** dans le snapshot, **restauration sha256 identique à `.25`**, médias 15 To/5,5 To exclus par construction ; **photos** : 433 941 fichiers == 433 941, indexation 9,1 %, RAM 1,1 GiB. Artéfacts : `PHOTOS-DOUBLE-RUN.md`, `scripts/13-diun-notify.sh`, `scripts/g9-verify-alert.py --list`. |
|
||||
| 12/10/2026 | **v1.6** | **Identité + suivi applicatif + communication (décisions 12/10)** : **S8 révisé** — Matrix/Jitsi/Element = **complément du foyer**, **pas** remplacement de WhatsApp (Element : 2 adultes d'abord, ados phase 2) ; **S9.2** sous-domaines alignés sur le réel (`pass.folzocorp.fr` = Vaultwarden, **`prisme.folzocorp.fr` = PhotoPrism**, `photos.folzocorp.fr` = Synology Photos **temporaire**, meurt Phase 6) ; **S11** enrichi de deux briques d'ops sans conteneur de plus : **registre généré** (`11-registry.sh` → `REGISTRE-BRIQUES.md`, dérive par `git diff`) et **sondes applicatives** (`12-probes.sh`, `up ≠ utilisable`, alertes **JSON** + 1 digest/jour) ; **S14.7→S14.10** : coffre **unique Vaultwarden** pour 6 (Nextcloud Passwords écarté : boucle de verrouillage + reset admin + pas d'emergency access), identité = **registre → étude exposition → Authelia** (Authentik/Keycloak/Samba AD/FreeIPA écartés), suivi = **sondes + registre + Diun** (Grafana/Prometheus/Netdata écartés, Uptime Kuma reporté avec déclencheur écrit), **G11 paliers d'autonomie IA**. Artéfacts : `g9-migration/{REGISTRE-IDENTITES,REGISTRE-BRIQUES,PARTAGE-SECRET-MAITRE,RESTORE-NAS-USB,EXPOSITION-INTERNET,BRIDGE-WHATSAPP}.md`, `RUNBOOKS/` ×7, charte **G11**. |
|
||||
| 10/10/2026 | **v1.5** | **S10.2 — copie 2 (Freebox) EN PLACE sur le G9** : restic 0.19.1 + rclone 1.75.2, repo `rclone:freeboxftp:Freebox/backup/g9` (FTP local 21), snapshot 4.18 GiB, **append-only prouvé**, **restore testée** (sha256), cron 03:30/04:00/09:15 + alerte Matrix ; **mont CIFS abandonné** (restic/`chmod`, FUSE) → backend rclone, **persistance post-reboot sans montage**. **Phase 5 planifiée** (non réalisée) : VM Freebox + `rest-server --append-only` (autostart à valider), `restic copy` → 218play, identifiants `NoAccess`, chroot durci. |
|
||||
| 06/10/2026 | **v1.4** | **Cohérence inter-dossiers (source unique)** : (1) **S10.2** — le schéma 3-2-1 ne **re-déclare plus** le tableau → **pointeur** vers `DOSSIER-RECREATION.md` §R4 (source unique) + note outil (restic, quotidien/hebdo) ; (2) **S4.3** — le modèle 2 couches ne **re-déclare plus** le principe → **pointeur** vers `PROJET-SECOND-CERVEAU.md` S4.1 (source unique) + tableau **mapping briques/machines** (spécificité du dossier logiciel) ; (3) **S3.1** — le tableau est **cadré autour des briques fonctionnelles** : la stack \*arr #10 = **1 brique**, les apps Nextcloud 13-15 marquées **↳ apps du socle NC #12** ; note de comptage précisée (**28 lignes = 25 briques fonctionnelles**, dans la cible ≤ ~20 briques de consolidation). |
|
||||
| 06/10/2026 | **v1.3** | **Correction mainteneur hermes** : S3.1 #24 — « maintenu (Nous Research) » → « **maintenu (nesquena — `ghcr.io/nesquena/hermes-webui`)** », aligné sur l'image réellement en production (snapshot `ETAT-DE-LART-2026-09-30.md` §2.1). |
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ curl -s -X PUT 'http://192.168.1.24:8008/_synapse/admin/v2/users/@<PSEUDO>:192.1
|
||||
|
||||
| Salon | Contenu |
|
||||
|-------|---------|
|
||||
| **Famille** | Messagerie quotidienne (remplace WhatsApp), visio Jitsi intégrée à Element |
|
||||
| **Famille** | Messagerie quotidienne du **foyer** (complément de WhatsApp, **pas un remplacement**), visio Jitsi intégrée à Element |
|
||||
| **Ops** | Alertes machine : watchtower (MAJ images), restic (backup G9), n8n — via `ops-bot` |
|
||||
| **hermes** (prévu) | Salon où la famille parle à Effi (assistant, Phase 3 du second cerveau) |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# BRIDGE WHATSAPP ↔ MATRIX (T-W — étude, **0 installation**)
|
||||
|
||||
> Contexte décidé 12/10 : **WhatsApp est indispensable** (famille, amis, travail) et ne sera **pas**
|
||||
> remplacé. Matrix = complément du foyer. Ce document dit ce qu'un pont apporte, ce qu'il ne donne
|
||||
> pas, et ce qui est **interdit**. Aucune installation sur le numéro familial.
|
||||
|
||||
## 1. L'outil
|
||||
|
||||
| Point | Fait |
|
||||
|---|---|
|
||||
| Projet | `mautrix-whatsapp` (Tulir / mautrix), basé sur `whatsmeow` |
|
||||
| Licence | **AGPL-3.0** (compatible avec la règle « OSS + chemin de sortie » de la table rase) |
|
||||
| Mécanisme | protocole **WhatsApp Web multi-device** : le serveur tient une session liée par **QR code** |
|
||||
| Contrainte téléphone | WhatsApp impose que le **téléphone se connecte au moins tous les 12 jours**, sinon la session est coupée → **re-link** (QR) à refaire |
|
||||
| État | DB (messages, préférences) + **session** (clés de liaison) + médias = **brique stateful** |
|
||||
| Chiffrement | côté Matrix : E2EE gérable ; **côté WhatsApp : la session standard**, tes correspondants ne voient **que** WhatsApp |
|
||||
|
||||
## 2. Ce que le pont apporte / ce qu'il ne donne pas
|
||||
|
||||
| Apporte | Ne donne pas |
|
||||
|---|---|
|
||||
| Miroir des conversations WhatsApp dans Element (MP, groupes, médias), historique importable | un canal commun : un groupe WhatsApp et un salon Matrix ne « se parlent » pas proprement (les membres WhatsApp ne voient pas Matrix) |
|
||||
| Répondre depuis Element | la fin de WhatsApp : **WhatsApp reste la source** |
|
||||
| Lecture centralisée (1 client pour la famille + les alertes ops) | la garantie de disponibilité : pont cassé = **perte de l'affichage**, pas des messages |
|
||||
| Base pour qu'Effi (assistante) **lisse/résume** des MP si tu l'autorises | la conformité : un client non officiel est **contraire aux ToS WhatsApp** |
|
||||
|
||||
## 3. Risques à nommer (le point qui décide tout)
|
||||
|
||||
| Risque | Détail | Conséquence pour nous |
|
||||
|---|---|---|
|
||||
| **Bannissement du numéro** | WhatsApp détecte les clients non officiels par **deux voies cumulées** : (a) **signalements par les utilisateurs** en face (un correspondant peut signaler le compte comme « spam/client non autorisé »), (b) **détection automatisée côté serveur** (comportement du client multi-device, empreinte du protocole `whatsmeow`, cadence d'envoi, absence d'app officielle). **Aucun seuil public, aucune liste publiée, aucun moyen de vérifier son exposition avant la sanction.** Échelle observée et documentée par la communauté : **ban temporaire** (de quelques heures à ~24 h, avec compte à rebours dans l'app) → **ban permanent** à la récidive → **suppression du compte** = **perte de l'historique WhatsApp du numéro** (non récupérable, pas de backup côté WhatsApp). Probabilité : **non chiffrable** (paramètre serveur opaque) ; **coût si ça tombe** : **élevé et non réparable** (numéro familial = famille, amis, travail). | ton numéro familial est « indispensable » → **interdiction** : jamais le numéro principal sur un pont ; le pilote ne se fait que sur un **numéro secondaire jetable** |
|
||||
| Session qui expire | > 12 jours sans connexion du téléphone = déconnexion | re-link manuel = maintenance que tu veux éliminer |
|
||||
| Stateful fragile | session + DB + médias ; montée de version du protocole WhatsApp non maîtrisée | épingler par digest, **exclure de Watchtower**, backup session+DB dans restic |
|
||||
| Confidentialité | les MP passent par un daemon sur le G9 + stockage local | à accepter explicitement (choix de périmètre) |
|
||||
| Surface d'attaque | brique de plus, exposée = non (interne), mais lit un compte de communication | pas d'exposition WAN ; pas d'automatisation d'**envoi** |
|
||||
|
||||
## 4. Interdits écrits (T-W.3)
|
||||
|
||||
1. **Numéro familial principal** : jamais relié à un pont.
|
||||
2. **Alertes ops sur WhatsApp** : le canal d'alerte reste Matrix (S8.4), pas WhatsApp.
|
||||
3. **Aucune automatisation qui envoie** des messages WhatsApp (n8n/IA) : lecture seulement, si pilote validé.
|
||||
4. Aucune exposition WAN du pont ; aucune ouverture ufw pour lui.
|
||||
|
||||
## 5. Si un jour GO utilisateur (pilote borné)
|
||||
|
||||
| Étape | Contenu |
|
||||
|---|---|
|
||||
| 1 | **numéro secondaire isolé** (numero dédié, vide, hors famille) |
|
||||
| 2 | conteneur épinglé par digest, label `watchtower.enable=false`, DB + session dans `/data/docker/whatsapp/` |
|
||||
| 3 | session + DB **dans les snapshots restic** (vérifié) |
|
||||
| 4 | **7 jours de miroir stable** (pas de re-link, pas de perte de messages) |
|
||||
| 5 | re-link testé (déconnexion volontaire → QR → reprise) |
|
||||
| 6 | rollback documenté : `docker compose -f compose/whatsapp/docker-compose.yml down` (aucune donnée famille concernée) |
|
||||
| 7 | journal + grille S14 (décision, raison, coût constaté) |
|
||||
|
||||
## 6. Les 17 questions de Go/No-Go (à répondre **avant** toute installation)
|
||||
|
||||
Règle de décision : **17/17 = GO pilote**. Une seule réponse « non » = **NO-GO**, et le document
|
||||
reste en étude. Chaque « oui » exige la **preuve** indiquée (pas d'intention, une preuve).
|
||||
|
||||
| # | Question | Réponse pour GO | Preuve exigée |
|
||||
|---|---|---|---|
|
||||
| 1 | Le numéro relié est-il **secondaire et jetable** ? | oui, jamais le familial | numéro + support (eSIM/SIM dédiée) notés dans `journal.md` |
|
||||
| 2 | Ce numéro est-il **vide** (aucun contact famille, aucun historique) ? | oui | capture/comptage : 0 conversation avant le link |
|
||||
| 3 | Le **bannissement définitif** de ce numéro est-il accepté à l'avance ? | oui, écrit | ligne de décision signée dans `journal.md` |
|
||||
| 4 | Ai-je un **moyen de prévenir** les correspondants si le compte disparaît ? | oui | canal de secours nommé (Matrix/ops) |
|
||||
| 5 | Le pont lit-il **uniquement mes** conversations ? | oui | `auth_users`/config du pont, 0 autre compte |
|
||||
| 6 | Les messages copiés sont-ils **chiffrés au repos** (ou en périmètre chiffré) ? | oui | `restic` (chiffrement) + permissions 600 sur `/data/docker/whatsapp/` |
|
||||
| 7 | La **DB du pont** est-elle dans les snapshots restic ? | oui | comptage d'entrées dans le snapshot latest |
|
||||
| 8 | La **session** (clés de liaison) est-elle backupée **et restaurée testée** ? | oui | restauration en test → re-link **non** nécessaire |
|
||||
| 9 | L'image est-elle **épinglée par digest** ? | oui | `docker inspect` → `ImageID`, pas un tag `:latest` |
|
||||
| 10 | Le pont est-il **exclu de Watchtower** ? | oui | label `watchtower.enable=false` + Watchtower `scanned` ne l'inclut pas |
|
||||
| 11 | Le pont est-il **hors WAN** ? | oui | `ufw status numbered` : 0 règle pour son port ; 0 entrée Caddy |
|
||||
| 12 | Y a-t-il **0 automatisation d'envoi** (n8n, IA, scripts) ? | oui | inventaire n8n : 0 flux WhatsApp |
|
||||
| 13 | Les **alertes ops** restent-elles sur Matrix ? | oui | 3 notifications types dans le salon ops, 0 sur WhatsApp |
|
||||
| 14 | La **montée de version du protocole** WhatsApp est-elle gérée ? | oui | procédure de re-link écrite dans `RUNBOOKS/` |
|
||||
| 15 | Le **re-link** est-il testé et chronométré ? | oui | test déconnexion volontaire → QR → reprise, **< 5 min** |
|
||||
| 16 | Le **coût de maintenance** constaté est-il ≤ 10 min/semaine ? | oui | 7 jours journalisés, temps passé relevé |
|
||||
| 17 | Le **verdict écrit** existe-t-il (GO/NO-GO, raison, coût) ? | oui | ligne dans `journal.md` + grille `DOSSIER-SOFTWARE.md` S14.5 |
|
||||
|
||||
**Ce que ces 17 questions ne couvrent pas** : la probabilité de bannissement reste **non chiffrable**
|
||||
(§3). Le pilote ne la réduit pas, il la **déplace** sur un numéro qui n'a pas de valeur.
|
||||
|
||||
## 7. Verdict de l'étude
|
||||
|
||||
- **Utile** si l'objectif est « lire au même endroit » (Element = foyer + alertes) et que le pilote
|
||||
reste sur un **numéro secondaire**.
|
||||
- **Pas utile** si l'objectif est de quitter WhatsApp : le pont ne fait pas ça.
|
||||
- **Coût de maintenance** : session (QR/12 jours) + DB + médias = exactement le type de charge que
|
||||
tu veux supprimer. Le pont **ajoute** de la maintenance pour un gain de confort.
|
||||
- Recommandation : **étude validée, pilote non déclenché** ; on en reparle quand Element tourne
|
||||
pour le foyer et que les alertes ops sont stables (tâche MATRICE FAMILLE), jamais avant.
|
||||
@@ -0,0 +1,120 @@
|
||||
# ÉTUDE EXPOSITION INTERNET (T-D — étude, puis durcissement **autorisé**)
|
||||
|
||||
> Étude posée le 12/10 **sans aucune règle modifiée** (G6 respecté tant que les décisions
|
||||
> n'étaient pas rendues). Les décisions sont rendues le 12/10 et le §6 consigne **les gestes
|
||||
> exécutés après GO explicite de l'utilisateur**, avec avant/après mesurés et rollback.
|
||||
> Faits mesurés en lecture seule (`ufw status numbered`, `ss -ltnu`, Caddyfile, journal).
|
||||
|
||||
## 1. Exposition mesurée aujourd'hui
|
||||
|
||||
| Machine | Exposée au WAN (pare-feu) | Observations |
|
||||
|---|---|---|
|
||||
| **G9 (.24)** | `22/tcp` **Anywhere**, `443/tcp` **Anywhere** (+ v6) | 22 ouvert au WAN = **risque si la Freebox forward** ; 443 = l'ancien edge (TLS **interne**, CA auto-signé) → un endpoint chiffré non reconnu exposé publiquement |
|
||||
| **G9 (.24)** LAN-only | 2342, 3000, 3100, 5678, 5984, 8008, 8009, 8080, 8081, 8096, 9980, 2456-2458, SMB (137-139/445) | tout ce qui est « brique » est correctement restreint à `192.168.1.0/24` |
|
||||
| **J5040 (.27)** | `22/tcp` (**OpenSSH, Anywhere**), `80/tcp`, `443/tcp`, `51820/udp` (**Anywhere**) | le **vrai** edge : Caddy + Let's Encrypt (7/7 certs DNS-01) + WireGuard. 80 = redirect ACME. `22` ouvert au WAN **aussi** (mesuré 12/10, règle [1]) |
|
||||
| **J5040 (.27)** LAN-only | `53` (DNS AdGuard), `3000` (hub Beszel), `8081` (UI AdGuard) | UI AdGuard = **login local** (bcrypt), pas de 2FA |
|
||||
| **Pilot (.11)** | `tailscale0` actif (100.111.222.30) | **voie d'accès distante non documentée** dans le plan S9 → à nommer explicitement (acceptée ou coupée) |
|
||||
| **Freebox** | IP publique `82.65.198.114` (`folzocorp.fr`) | les **redirects Freebox** (443 → G9, Valheim UDP 2456-2458) ne sont **pas lisibles** depuis l'infra : inventaire à faire côté Freebox par l'utilisateur |
|
||||
|
||||
## 2. Grille par brique (actuelle / nécessaire / dangereuse)
|
||||
|
||||
| Brique | Publique aujourd'hui | Publique **nécessaire** | Danger si exposée | Public visé |
|
||||
|---|---|---|---|---|
|
||||
| Nextcloud (`cloud`) | oui, via J5040 (443) | **oui** (usage famille hors maison) | compte unique = porte d'entrée des fichiers ; 2FA obligatoire | famille |
|
||||
| Vaultwarden (`pass`) | oui, via J5040 | **oui** (coffre = besoin nomade) | exposition du coffre = cible n°1 ; 2FA + mot de passe maître fort | famille |
|
||||
| Matrix/Synapse + Element (`matrix`, 8009) | matrix via J5040 ; Element web LAN | **non** (Element = client, passe par le homeserver) | 8009 exposé = frontal gratuit ; 8008 exposé = API admin exposée | famille |
|
||||
| Gitea (`git`) | oui, via J5040 | **non** (usage interne + pushes) | dépôt du kit + vault second cerveau = **fuites de plans** ; comptes `must_change_password` | moi |
|
||||
| Jellyfin (`media`) | oui, via J5040 | **optionnel** (usage hors maison = désirabilité) | compte opérateur `g9ops` exposé ; pas de 2FA | famille |
|
||||
| PhotoPrism (`prisme`) | route prévue, pas posée | **optionnel** | 10 ans de photos = **donnée la plus sensible** de la maison | famille |
|
||||
| n8n (`n8n`) | oui, via J5040 | **non** | UI d'automatisation = exécution de commandes ; **pas de SSO** (payant) ; owner unique | moi |
|
||||
| Beszel hub (3000 .27) | non | non | vue de l'inventaire + alertes | moi |
|
||||
| AdGuard UI (8081 .27) | non | non | contrôle du DNS de toute la maison | moi |
|
||||
| Collabora (9980) | non | non | WOPI interne | interne |
|
||||
| CouchDB (5984) | non | non | **danger LAN** : CouchDB sans auth = lecture/écriture directe | interne |
|
||||
| SMB (137-139/445) | non | non | partage famille, invité `guest ok` → **LAN-only strict** | famille |
|
||||
| SSH G9 (22) | **oui au pare-feu** | **non** | exposition SSH = 22 en clair sur internet si forward | moi |
|
||||
| Edge G9 (443) | **oui au pare-feu** | **non** ( remplacé par J5040 ) | TLS auto-signé exposé, double porte d'entrée | — |
|
||||
| Valheim (2456-2458) | non (redirect Freebox UDP existant) | **oui** (amis hors LAN) | jeu, sans donnée sensible | amis |
|
||||
| WireGuard (51820 .27) | oui | **oui** (c'est LA porte d'entrée distante) | clé privée = accès total LAN | moi |
|
||||
|
||||
## 3. Ce que ça change pour l'identité (lien direct avec la tâche Authelia)
|
||||
|
||||
| Constat | Conséquence |
|
||||
|---|---|
|
||||
| La **forward-auth ne protège que ce qui passe par Caddy** (les 6 sous-domaines) | les ports LAN directs (3000, 5678, 5984, 8009, 8081, 9980, 2342) **restent en login local** : un IdP ne les couvre pas |
|
||||
| Tout ce qui est **LAN-only** n'a pas besoin d'un IdP pour être sûr | si la posture reste « LAN + WireGuard », l'apport d'Authelia est **confort** (1 login, 1 2FA) et non sécurité |
|
||||
| Tout ce qui est **exposé** (Nextcloud, Vaultwarden, Matrix, Gitea, Jellyfin, n8n) est concerné | plus l'exposition est large, plus l'IdP + 2FA central devient indispensable |
|
||||
| `22/tcp` et l'edge G9 sont ouverts au WAN **sans nécessité** | ce sont les 2 décisions les moins chères à prendre (fermer = 2 règles à retirer, pas à poser) |
|
||||
| Tailscale sur le pilot | voie distante réelle et silencieuse : à **nommer** (acceptée = documentée ; refusée = désactivée) |
|
||||
|
||||
## 4. Playbook de durcissement (classé, **non implémenté**)
|
||||
|
||||
| Rang | Action | Coût | Effet | Réversibilité |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Retirer `22/tcp Anywhere` du G9 (SSH = LAN + WireGuard) | 1 règle | supprime l'expo SSH publique | immédiate |
|
||||
| 2 | Retirer `443/tcp` du G9 après la bascule bridge (edge J5040 seul) | 1 règle + démontage | une seule porte d'entrée | immédiate |
|
||||
| 3 | 2FA sur Nextcloud + Vaultwarden + Gitea + Synapse (S9.1) | ~1 h | la vraie protection des comptes exposés | par compte |
|
||||
| 4 | WireGuard = **seul** chemin distant pour Gitea/n8n/Beszel/AdGuard UI | config Caddy/ufw | 4 briques sortent de la surface publique | immédiate |
|
||||
| 5 | Forward-auth Authelia sur les 6 sous-domaines + break-glass | 1 brique | 1 login, 1 2FA, déprovisionnement en 1 point | opt-in par brique |
|
||||
| 6 | CouchDB : auth ou bind 127.0.0.1 (l'appli n'a besoin que du réseau Docker) | 1 ligne compose | ferme un trou LAN silencieux | immédiate |
|
||||
| 7 | Inventaire des redirects Freebox (443, UDP 2456-2458) + suppression de ce qui est mort | côté Freebox, utilisateur | cohérence réel/plan | immédiate |
|
||||
| 8 | Nommer Tailscale (accepté/documenté, ou désactivé) | 1 décision | supprime une voie non documentée | immédiate |
|
||||
|
||||
## 5. Décisions rendues le 2026-10-12 (et qui les pose)
|
||||
|
||||
Complément mesuré à la demande : `ufw status numbered` complet sur `.24` (25 règles) et `.27`
|
||||
(12 règles). Le pilot et le G9 sont **derrière le même NAT** : l'IP publique `82.65.198.114` est
|
||||
unique, donc ce qui est joignable depuis l'extérieur dépend des **redirects de la Freebox**, pas
|
||||
de ufw seul. ufw ne fait qu'ouvrir la porte une fois le paquet arrivé.
|
||||
|
||||
| # | Décision | Réponse | Ce que je recommande | Qui pose le geste |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `22/tcp` ouvert au WAN | « si je ferme, plus de SSH pour moi → garder pour le réseau local » | **Remplacer** la règle `OpenSSH Anywhere` par `22/tcp from 192.168.1.0/24` sur **.24 et .27** (le SSH reste depuis la maison, l'attaque depuis le WAN tombe). Ordre sûr : insérer la règle LAN **avant** de supprimer la règle WAN → aucune déconnexion possible. `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 22` puis `sudo ufw delete <n° de OpenSSH Anywhere>` (et les 2 règles v6) | **toi** (G6 : modification réseau) |
|
||||
| 2 | `443/tcp` ouvert au WAN sur le G9 (edge TLS auto-signé) | « je ne sais pas » | **Remplacer, pas supprimer** : il n'existe **aucune** règle LAN pour 443 sur le G9, donc supprimer la règle `[22] Anywhere` couperait aussi `https://git./cloud./media.…` **depuis la maison**. Geste : `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 443` puis `sudo ufw delete 22` (et la v6). Le seul edge public reste alors le J5040 (Let's Encrypt) | **toi** |
|
||||
| 3 | Tailscale | « je ne pensais pas l'utiliser, est-ce utile ? » | **Non, inutile au projet** : il n'est actif que sur la **machine de pilotage** (`.24` = `inactive`, aucun nœud Tailscale sur le G9 ni le J5040). C'est une **voie d'entrée distante non documentée** dans S9, avec un agent qui a accès au LAN. L'entrée distante prévue du plan = **WireGuard sur le J5040**. Donc : couper sur le pilot → `sudo systemctl disable --now tailscaled` (rollback : `sudo systemctl enable --now tailscaled`) | **toi** (c'est la machine de pilotage, pas le G9) |
|
||||
| 4 | CouchDB `5984` | « fais ce que tu conseilles » | **Constat initial faux, corrigé par mesure** : la sonde voyait `GET :5984/` = 200 et l'a lu « sans auth ». Mesure directe : `GET /_all_databases` = **401**, `GET /_active_tasks` = **401** → **l'auth est bien active** (`COUCHDB_USER` + `COUCHDB_PASSWORD` dans `compose/g9-queue/docker-compose.yml`). `/` est public par conception dans CouchDB : c'est un faux positif de la sonde. **Recommandation révisée : ne pas recréer le conteneur.** Passer le port en `127.0.0.1:5984` n'apporte presque rien (ufw est déjà LAN-only, l'auth est en place) et **casse probablement la sync Obsidian** si le client pointe sur `http://192.168.1.24:5984`. Gain marginal / risque réel = mauvais échange. À faire plus tard **seulement** si tu confirmes que le client Obsidian n'utilise pas le port de l'hôte. | **personne** (statu quo mesuré) |
|
||||
| 5 | Périmètre « joignable sans le vouloir » | « idem, au mieux » | **Validé tel quel**, avec l'ajout mesuré : `OpenSSH Anywhere` est présent sur **.24 et .27** (pas seulement .24), et l'inventaire des **redirects Freebox** reste à faire côté Freebox par toi — c'est le seul trou de visibilité qui subsiste | **toi** (lecture de l'interface Freebox) |
|
||||
|
||||
**Aucune règle n'est posée par moi** (G6). Les lignes 1, 2, 3, 5 sont des commandes que **tu**
|
||||
exécutes ; la ligne 4 est la seule que j'exécute, et seulement sur GO explicite.
|
||||
|
||||
## 6. Actions posées le 2026-10-12 (GO explicite de l'utilisateur)
|
||||
|
||||
`scripts/14-firewall-lan-only.sh` (insertion LAN **avant** suppression WAN, suppressions du
|
||||
numéro le plus haut au plus bas, `--dry` exécuté avant l'exécution réelle).
|
||||
|
||||
| # | Geste | Avant (mesuré) | Après (mesuré) |
|
||||
|---|---|---|---|
|
||||
| 1 | SSH LAN-only sur **.24** | `[1] OpenSSH ALLOW IN Anywhere` + `[24] OpenSSH (v6) Anywhere` | `[2] 22/tcp ALLOW IN 192.168.1.0/24` — **plus aucune règle `Anywhere`** sur le G9 (23 règles) |
|
||||
| 1 | SSH LAN-only sur **.27** | `[1] OpenSSH Anywhere` + `[10] OpenSSH (v6)` | `[1] 22/tcp ALLOW IN 192.168.1.0/24` — les règles WAN de l'edge (`80`, `443`, `51820` v4+v6) **conservées** |
|
||||
| 2 | 443 LAN-only sur **.24** | `[22] 443/tcp Anywhere # edge T4.6` + v6 | `[1] 443/tcp ALLOW IN 192.168.1.0/24` ; le seul edge public restant = **J5040** (Let's Encrypt) |
|
||||
| 3 | Tailscale sur le pilot | `tailscaled` actif, `tailscale0` UP, 6+ pairs (`ai-computer`, macbook, vps, windows, chronos) | `inactive`, service désactivé au boot. Vérifié **après** : SSH `.24` OK, SSH `.27` OK, `git ls-remote gitea` OK |
|
||||
| 4 | CouchDB | — | **statu quo** (voir §5, ligne 4 : auth mesurée = 401, faux positif de sonde) |
|
||||
|
||||
**Vérifications après bascule** : `ssh .24` = OK, `ssh .27` = OK, `https://git.folzocorp.fr/api/v1/version`
|
||||
(LAN, via l'edge G9) = **200**, `https://cloud.folzocorp.fr/` (WAN, via J5040) = **200**,
|
||||
`https://prisme.folzocorp.fr/` = **200**.
|
||||
|
||||
**Rollback** : snapshots `ufw status numbered` conservés avant les changements (25 règles .24,
|
||||
17 lignes .27) ; rétablir une règle WAN = `sudo ufw insert 1 allow proto tcp from any to any port 22`
|
||||
(idem 443) ; Tailscale = `sudo systemctl enable --now tailscaled`. Journal 18:10.
|
||||
|
||||
**Ce que ça ne règle pas** : ce qui est joignable depuis l'extérieur reste gouverné par les
|
||||
**redirects de la Freebox** (IP publique unique `82.65.198.114`, G9 et pilot derrière le même
|
||||
NAT). L'inventaire des redirects se fait côté Freebox, par toi — c'est le seul trou de
|
||||
visibilité qui subsiste.
|
||||
|
||||
## 7. Choix de machine pour l'IdP (Authelia) — **décision rendue le 12/10, installation reportée**
|
||||
|
||||
| Point retenu | **J5040 (.27)** | Écart avec le G9 (.24) |
|
||||
|---|---|---|
|
||||
| Exposition | c'est **déjà** le seul edge public (`80/443/51820`), donc Authelia hérite d'une exposition **existante et documentée** | le G9 n'a **plus aucune règle `Anywhere`** depuis le durcissement 18:10 → l'y exposer demanderait de **rouvrir** un trou refermé |
|
||||
| Survie | le hub de monitoring est **déjà** posé dessus (C-AP5) : l'IdP ne meurt pas avec le G9 | l'IdP sur le G9 tombe avec lui (même défaut que les sondes) |
|
||||
| Coût | 1 conteneur de plus sur une machine à 3 conteneurs, base SQLite dans `/home/folzo/` (déjà sauvegardée sur .27 ? **non** → à ajouter au périmètre, consigné) | base sur la machine qui porte déjà les 3 BDD les plus lourdes |
|
||||
| Risque | l'IdP devient **aussi exposé que Caddy** : surface = 1 hôte, 1 port (443), 0 exposition directe du port 9000 (reverse proxy Caddy, LAN-only en amont) | exposition directe depuis LAN uniquement, mais single point of failure du foyer |
|
||||
| Décision | **J5040**, en OIDC **opt-in** brique par brique (Synapse → Gitea → Jellyfin), login local **conservé** partout (C-IDP2) | — |
|
||||
|
||||
**Déclencheur d'installation (écrit, pas implicite)** : après **Phase 5** (copie 3-2-1 sur 3 cibles)
|
||||
et **après** la mise en 2FA des comptes admin existants (C-MX3, C-ID7). Ni avant, ni à froid.
|
||||
Rollback = retirer le bloc OIDC du Caddyfile du J5040 + retirer l'OIDC de la brique (login local
|
||||
déjà en place, donc 0 dépendance dure).
|
||||
@@ -0,0 +1,90 @@
|
||||
# PARTAGE-SECRET-MAITRE — procédure de secours (T-A.9)
|
||||
|
||||
> Décision 12/10 : aujourd'hui **moi seul** connais le mot de passe maître du coffre (Vaultwarden)
|
||||
> et l'`ADMIN_TOKEN`. Objectif : **1 à 2 amis de confiance** capables de déverrouiller le coffre
|
||||
> si je suis injoignable. Ce fichier ne contient **aucun secret** : il décrit le contenant, les
|
||||
> détenteurs, et ce qu'ils font.
|
||||
|
||||
## 1. Détenteurs (à nommer par l'utilisateur)
|
||||
|
||||
| Rôle | Personne | Support remis | Lieu (voir §2.1) | Code de déverrouillage | Date de remise | Test annuel |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Moi | — | enveloppe n°1 | **sur place** (coffre du domicile) | le mien | — | _à date_ |
|
||||
| Détenteur 1 | _à nommer_ | enveloppe n°2 | **hors site** (chez lui) | **le sien, propre, jamais communiqué** | _à date_ | _à date_ |
|
||||
| Détenteur 2 (optionnel) | _à nommer_ | enveloppe n°3 | **hors ligne** (scellé, jamais connecté) | **le sien, propre, jamais communiqué** | _à date_ | _à date_ |
|
||||
|
||||
Règles de sélection (à assumer) : personne joignable physiquement, indépendante de l'infra,
|
||||
capable d'appliquer une procédure écrite sous stress, **sans accès** aux machines.
|
||||
|
||||
## 2. Contenu de l'enveloppe (hors-ligne, scellée)
|
||||
|
||||
- le **mot de passe maître** du coffre (la passphrase, pas les mots de passe du coffre) ;
|
||||
- l'**`ADMIN_TOKEN`** en clair (console Vaultwarden) ;
|
||||
- la **phrase de récupération E2E** de Matrix (au moins pour les 2 adultes) ;
|
||||
- la **clé de chiffrement du repo restic** (`repo.key`) — copie, pas l'originale ;
|
||||
- la liste des 6 personnes et des briques concernées (copie de `REGISTRE-IDENTITES.md` §2/§3) ;
|
||||
- une **note de datation** : à chaque changement de mot de passe maître, l'enveloppe est
|
||||
remplacée et l'ancienne est détruite (journaliser la date).
|
||||
|
||||
Support : papier (pas de clé USB non chiffrée), plié, scellé, signé de la date. Jamais de photo,
|
||||
jamais de fichier non chiffré sur un disque.
|
||||
|
||||
### 2.1 Placement des supports (3 exemplaires, 2 maximum pour des tiers)
|
||||
|
||||
| Exemplaire | Lieu | Détenteur | Rôle |
|
||||
|---|---|---|---|
|
||||
| n°1 — **sur place** | coffre physique du domicile (scellé, daté) | moi | usage normal, jamais sorti sans journal |
|
||||
| n°2 — **hors site** | chez le détenteur 1 (scellé, daté) | détenteur 1 | usage si je suis injoignable |
|
||||
| n°3 — **hors ligne** | chez le détenteur 2 **ou** coffret scellé hors réseau | détenteur 2 (optionnel) | secours si n°1 et n°2 sont perdus ; **jamais connecté, jamais numérisé** |
|
||||
|
||||
**Aucun 4ᵉ exemplaire** : 2 personnes max détiennent un support (détenteurs 1 et 2).
|
||||
|
||||
### 2.2 Garde-fous (les 4, applicables dès la remise)
|
||||
|
||||
1. **2 personnes maximum** détiennent un support ; la famille immédiate n'en détient pas.
|
||||
2. La **personne de confiance** (celle qui accompagne l'ouverture) est **lectrice seule** : elle
|
||||
assiste, date, contresigne ; **elle ne recopie, ne photographie et ne conserve rien**.
|
||||
3. **1 code de déverrouillage par personne** : l'enveloppe est fermée par un **code propre à son
|
||||
détenteur** (code de son choix, jamais transmis, jamais écrit sur l'enveloppe). En cas de perte
|
||||
du détenteur, son code devient nul et l'enveloppe est remplacée (§5).
|
||||
4. **Jamais par mail, jamais par cloud, jamais par messagerie** : la remise est **physique**, en
|
||||
main propre, signée. Aucun scan, aucun envoi, aucune photo, aucun stockage hébergé.
|
||||
|
||||
## 3. Ce que fait le détenteur si je suis injoignable
|
||||
|
||||
1. Ouvrir l'enveloppe **devant témoin** (famille), noter la date.
|
||||
2. Déverrouiller le coffre : app Bitwarden → URL du coffre = `https://pass.folzocorp.fr`
|
||||
(si le domaine n'est pas joignable, l'accès LAN = `http://192.168.1.24:8081`) →
|
||||
identifiant = mon compte → **mot de passe maître** de l'enveloppe.
|
||||
3. Si l'app refuse (2FA) : utiliser la console admin `https://pass.folzocorp.fr/admin` avec
|
||||
l'`ADMIN_TOKEN` de l'enveloppe → désactiver le 2FA du compte (mesure de dernier recours,
|
||||
à journaliser).
|
||||
4. Transmettre aux adultes de la famille les entrées nécessaires (organisation `Famille`),
|
||||
**sans** diffuser les secrets hors famille.
|
||||
5. Journaliser dans `journal.md` : date, ce qui a été ouvert, ce qui a été transmis.
|
||||
|
||||
## 4. Test annuel (critère)
|
||||
|
||||
Une fois par an, sans casser la production, sur un **coffre d'entraînement** (vault de test, jamais
|
||||
le coffre famille) : **6 points à vérifier**, chacun noté OK/KO.
|
||||
|
||||
| # | Point vérifié | Preuve |
|
||||
|---|---|---|
|
||||
| 1 | L'enveloppe est **intacte, scellée, datée** et le **code propre au détenteur** l'ouvre | photo du sceau intact + date portée au journal |
|
||||
| 2 | Le **mot de passe maître** déverrouille le coffre d'entraînement | login réussi sur le vault de test |
|
||||
| 3 | L'**`ADMIN_TOKEN`** ouvre la console `/admin` du coffre d'entraînement | console accessible, 2FA du compte de test désactivable |
|
||||
| 4 | La **clé de chiffrement restic** lit un snapshot de test | `restic snapshots` / `restic cat` en **lecture seule** sur un repo de test |
|
||||
| 5 | La **phrase de récupération E2E** déverrouille une session Matrix de test | salon de test lisible depuis un appareil vierge |
|
||||
| 6 | La **liste des 6 personnes et des briques** est à jour | comparaison avec `REGISTRE-IDENTITES.md` §2/§3, 0 écart non consigné |
|
||||
|
||||
- la procédure est **chronométrée** et les points de friction sont corrigés dans ce fichier ;
|
||||
- la ligne est journalisée : `| date | partage-secret-maitre : test annuel | 6 points (x/6) | remplacement enveloppe |` ;
|
||||
- si un point est KO : l'enveloppe est **remplacée** et la rotation est envisagée (§5).
|
||||
|
||||
## 5. Révocabilité
|
||||
|
||||
| Événement | Action |
|
||||
|---|---|
|
||||
| Détenteur injoignable/decédé | retirer son accès, remplacer l'enveloppe, journaliser |
|
||||
| Perte d'une enveloppe | **changer le mot de passe maître** + régénérer l'`ADMIN_TOKEN`, refaire les enveloppes |
|
||||
| Doute sur la confidentialité | idem : rotation complète (mot de passe maître + ADMIN_TOKEN + clés restic) |
|
||||
@@ -18,7 +18,7 @@
|
||||
| Jellyfin (T4.5) | `jellyfin/jellyfin:10.9` (10.9.11) | healthy, média par RÉFÉRENCE (`/mnt/usb-media` → `/media:ro`, aucune copie), 1 médiathèque = `/media/films`, Items Movie = 1 | false |
|
||||
| edge (T4.6) | `caddy:2` (digest `f2a1290d…`) | healthy, HTTPS interne (CA interne), 443 seul port exposé WAN | **true** (stateless) |
|
||||
| Watchtower (T4.7) | `nickfedor/watchtower@sha256:45b54f6d…` | healthy, scope label-enable : dry-run `scanned=2` (edge + watchtower), 14 stateful ignorés | true |
|
||||
| PhotoPrism (T4.3) | — | **non déployée** : `07d-migrate-photoprism.sh` écrit/testé, exécution reportée (coût = copie 530 Go ; média servi par Jellyfin) | — |
|
||||
| PhotoPrism (T4.3) | `photoprism/photoprism:260919` + `mariadb:10.11` | **✅ 12/10** : 433 941 fichiers / 567 Go copiés depuis `/volume1/photo` (manifeste chemins+tailles 0 écart, sha256 200/200), stack healthy port 2342 (LAN) + `photos.folzocorp.fr` via edge J5040 ; index progressif (AUTO_INDEX 300 s, chaîne index→faces→analyze) ; snapshot restic + restore photo prouvée (`10-verify-restore-photo.sh`) | false |
|
||||
|
||||
### 0.2 Incidents rencontrés et correctifs (détail = journal.md)
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
- `nextcloud_app`, `nextcloud_db`, `nextcloud_redis`, `vaultwarden-server-1`, `gitea` **relancés et up** après les exports ; dumps/transitaires supprimés sur .25, pilot et G9.
|
||||
- Aucun partage, aucune donnée, aucun conteneur .25 supprimé ; transports .25→pilot = `ssh 'sudo tar cz'`/`mysqldump` en lecture seule (sshd DSM refuse `rsync --server` et scp).
|
||||
|
||||
### 0.5 J5040 (.27) — E1→E4 (11–12/10)
|
||||
|
||||
| Étape | État | Preuve |
|
||||
|---|---|---|
|
||||
| E1 durcissement | ✅ | SSH clés-only, ufw (LAN 53/3000/8081 + WAN 80/443/51820), fail2ban, unattended-upgrades, Docker 29.1.3 ; RAM 8 Go sous test (upgrade 16 Go si > 70 %) |
|
||||
| E2 AdGuard | ✅ bascule faite | `adguard/adguardhome` (digest `aba9e3bf…`) sur .27, UI 8081, DNS 53 ; config VM .1 migrée (2 filtres 182 128 + 6 540 règles, safe_search, profils parents, users bcrypt) ; resolvants clients → .27 ; filtrage testé ; VM .1 conservée secours |
|
||||
| E3 DHCP | préparé, **GO utilisateur (G6)** | baux statiques Freebox repris (35 appareils, OCR 38) dans `/srv/dnsmasq/leases`, `dnsmasq --test` OK, script `09-j5040-dhcp.sh` prêt ; bascule = Freebox Pop + dnsmasq |
|
||||
| E4 edge + WireGuard | certs ✅, **GO utilisateur pour WAN** | Caddy OVH DNS-01 (plugin `maxcryger/caddy-ovh` digest `502996b5…`) : **7/7 certs LE** + proxy 6 briques (photos 307 depuis 12/10) ; WireGuard `wg0.conf` + client prêts (600, gitignorés) ; forward WAN Freebox→.27 et démontage edge G9 = après GO |
|
||||
|
||||
---
|
||||
|
||||
# Rapport de récupération (thread supprimé)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Double-run photos — PhotoPrism ↔ Synology Photos (mesures du 2026-10-12)
|
||||
|
||||
Objectif de la tâche `PHOTO-DOUBLE-RUN` : PhotoPrism rend le service pendant que Synology
|
||||
Photos reste en place, **sans trou**, et la montée en compétence se fait d'abord sur un seul
|
||||
téléphone (le mien) avant le GO famille.
|
||||
|
||||
Décision de nommage (validée) : `prisme.folzocorp.fr` = **PhotoPrism** ;
|
||||
`photos.folzocorp.fr` = **Synology Photos** (temporaire, meurt en Phase 6).
|
||||
|
||||
## 1. Copie : mesurée, identique en nombre de fichiers
|
||||
|
||||
| Côté | Fichiers | Octets (apparents) |
|
||||
|---|---|---|
|
||||
| Source `.25` `/volume1/photo` (lecture seule, G4) | **433 941** | 567 416 508 104 |
|
||||
| G9 `.24` `/data/docker/photoprism/originals` | **433 941** | 567 394 253 738 |
|
||||
| Snapshot restic `06a7e3b8` (2026-10-12 12:05) | **505 836 entrées** sous `originals` | 541.393 GiB (repo, tout périmètre) |
|
||||
|
||||
Écart d'octets = **22 254 366** (0,0039 %) : les vignettes `@eaDir` recalculées de part et
|
||||
d'autre. Le nombre de fichiers est **identique** : c'est le critère qui compte, il est atteint.
|
||||
|
||||
Emplacement réel de la copie : `/data/docker/photoprism/originals` (monté dans le conteneur),
|
||||
**pas** `/data/photos`. Le périmètre restic est `/data /home/folzo /etc` → la copie est bien
|
||||
dans les snapshots.
|
||||
|
||||
## 2. Restauration : prouvée par sha256
|
||||
|
||||
`scripts/10-verify-restore-photo.sh` (rc=0) : snapshot le plus récent → restauration de
|
||||
`Photos_scannees 2007-10-14/selection/Photo20071013181010171.jpg` →
|
||||
**sha256 identique à la source `.25`** (`00cbfed8…159d0a6f`).
|
||||
|
||||
## 3. Indexation : en cours, ~9 %
|
||||
|
||||
Compté dans la base `photoprism` (MariaDB, lecture seule) :
|
||||
|
||||
| photos | files indexés | albums | faces | utilisateurs | erreurs | dossiers |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 36 254 | **39 294** / 433 941 fichiers | 681 | 37 | 3 | 37 | 392 |
|
||||
|
||||
L'indexation est donc **engagée, pas terminée** (9,1 % des fichiers). RAM mesurée :
|
||||
`photoprism` **1,104 GiB**, `photoprism_db` 183,5 MiB → l'enveloppe ~2-3 Go est respectée.
|
||||
Les 37 `errors` sont à regarder avant le GO famille (aucune action prise).
|
||||
|
||||
## 4. Endpoints : mesurés, un écart à corriger
|
||||
|
||||
| Nom | Résolution | Route Caddy | Mesure |
|
||||
|---|---|---|---|
|
||||
| `prisme.folzocorp.fr` | OK → `82.65.198.114` | **posée le 12/10 17:15** : `reverse_proxy 192.168.1.24:2342` (bloc ajouté au-dessus de `photos.`, sauvegarde `Caddyfile.bak-20261012-1643`, `caddy validate` + `reload`) | **200** (Let's Encrypt ACME DNS-01, `curl` **sans** `-k`) |
|
||||
| `photos.folzocorp.fr` | OK → `82.65.198.114` | `reverse_proxy 192.168.1.24:2342` = **PhotoPrism** | 307 (→ `/login`) |
|
||||
| Synology Photos `.25` | — | — | **5000 et 5001 = 000** (445 et 222 ouverts, ping OK) |
|
||||
|
||||
**Écart restant** (mesuré 12/10 17:15) : `photos.folzocorp.fr` pointe toujours sur **PhotoPrism**
|
||||
au lieu du NAS — le repointage vers `.25` n'a **pas été autorisé** à ce stade, et il n'est de
|
||||
toute façon **pas mesurable** puisque le web du DSM (5000/5001) ne répond pas. Le double-run
|
||||
« les deux accessibles simultanément » reste donc **prouvable à 1 nom sur 2** : `prisme.` ✓,
|
||||
`photos.` ✗.
|
||||
|
||||
## 5. Ce qui est fait, ce qui reste
|
||||
|
||||
**Fait, mesuré** : copie (noms de fichiers identiques), restauration sha256, RAM, indexation
|
||||
engagée, `n8n /healthz` = 200, endpoints mesurés, nommage décidé, **`prisme.` = 200**, grille des
|
||||
10 requêtes (§6) et procédure d'auto-upload (§7) **écrites et prêtes**.
|
||||
|
||||
**Reste** :
|
||||
- rendre le web du DSM joignable (ou actater que `photos.` meurt en Phase 6 sans mesure) ;
|
||||
- finir l'indexation et traiter les 37 erreurs ;
|
||||
- **exécution** de la grille §6 et de la procédure §7 sur **mon** téléphone (usage réel, 7 jours) ;
|
||||
- GO famille = validation explicite (comptes enfants et auto-upload ados **après**).
|
||||
|
||||
## 6. Grille des 10 requêtes réelles (Synology Photos vs PhotoPrism)
|
||||
|
||||
Protocole : la même requête est exécutée **l'une après l'autre** sur les deux services, depuis le
|
||||
même téléphone, en Wi-Fi, sur le **même lot de photos**. Chronométrer du premier geste au résultat
|
||||
affiché. Noter **1 gagnant par ligne**. Le classement final = nombre de victoires, plus la note
|
||||
subjective « est-ce que j'ai compris sans aide ». Les cases **Gagnant / Temps / Compris sans aide**
|
||||
sont à remplir par l'usage réel (personne qui remplit = moi, sur mon téléphone uniquement).
|
||||
|
||||
| # | Requête réelle, telle que je la formule | Chemin Synology Photos | Chemin PhotoPrism | Gagnant | Temps (s) | Compris sans aide |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | « les photos du séjour 2019 » | Chronologie → filtre année/lieu | Recherche texte libre `2019` + filtre date | | | |
|
||||
| 2 | « les photos de carla il y a plus de 10 ans » | Albums → visage non disponible | Filtre **Faces** + plage de dates | | | |
|
||||
| 3 | « la facture d'électricité scannée » | Recherche texte (DS 7.x, selon OCR indexé) | Recherche plein texte sur l'OCR (`label`/`caption`) | | | |
|
||||
| 4 | « ce que j'ai pris depuis mon téléphone ce mois-ci » | Dossier / appareil | Filtre **camera / appareil** + date | | | |
|
||||
| 5 | « les doublons de cette photo » | Doublons intégrés (Synology Photos) | `photoprism` : recherche par **similaire** depuis une photo | | | |
|
||||
| 6 | « envoyer 12 photos à Audrey sans zip » | Partage Synology (lien/album) | Album partagé + **partage par lien** | | | |
|
||||
| 7 | « créer un album vacances et le partager à la famille » | Album partagé DSM | Album + partage vers un compte famille | | | |
|
||||
| 8 | « partager un lien vers une photo, limité dans le temps » | Lien de partage (expirable) | Lien de partage (vérifier expiration et ce qui fuit) | | | |
|
||||
| 9 | « l'EXIF et l'appareil de cette photo » | Panneau d'infos | Panneau d'infos (mesuré : faces 37, dossiers 392 indexés) | | | |
|
||||
| 10 | « l'original complet, pas la version web » | Téléchargement original | Téléchargement original (`/originals/…`) | | | |
|
||||
|
||||
**Classement attendu** : score par brique (x/10) + 3 lignes de conclusion (ce que PhotoPrism fait
|
||||
mieux, ce que Synology Photos fait mieux, ce qui me manque des deux). La grille remplit le critère
|
||||
C-PH2 « 10 requêtes réelles comparées et classées » **à la condition d'être remplie à la main**,
|
||||
pas d'exister.
|
||||
|
||||
## 7. Auto-upload — procédure (mon téléphone, 7 jours sans intervention)
|
||||
|
||||
Prérequis mesurés : `https://prisme.folzocorp.fr/` = **200** (certificat valide, pas d'auto-signé),
|
||||
compte PhotoPrism existant (`admin` mesuré en base `auth_users`), indexation en cours (9,1 %).
|
||||
|
||||
1. **App** : client d'upload **officiel PhotoPrism** (Android « PhotoPrism Companion », iOS
|
||||
l'équivalent App Store). **Pas** de script d'upload sur le téléphone (G11 : pas de code non
|
||||
documenté qui écrit sur une brique).
|
||||
2. **Serveur** : `https://prisme.folzocorp.fr` (jamais l'IP, jamais http).
|
||||
3. **Compte** : mon compte PhotoPrism (pas l'admin si un compte dédié existe ; sinon admin, et le
|
||||
mot de passe est **changé** après coup, consigné `famille/effi/photoprism`).
|
||||
4. **Périmètre d'upload** : `DCIM` + `Screenshots` **uniquement**, en **Wi-Fi seulement**
|
||||
(données mobiles : upload désactivé), **sans** suppression des photos locales après upload.
|
||||
5. **Anti-effacement** : l'app est réglée en **copie**, jamais en « déplacer ». Vérifier explicitement
|
||||
l'option de suppression après upload et la laisser **désactivée**.
|
||||
6. **Préserver les EXIF** : pas de « compression », pas de « qualité 75 % », pas de retrait de
|
||||
localisation. Contrôle : sur 3 photos uploadées, lire l'appareil + la date + la position dans le
|
||||
panneau d'infos (requête 9 de la grille §6).
|
||||
7. **Moyens de contrôle** (à faire 1 fois/jour, 10 secondes) : dans PhotoPrism, `Nouveautés` du jour
|
||||
> 0 ; dans la base, `select count(*) from files` qui **monte** ; 0 erreur nouvelle dans `errors`.
|
||||
8. **Seuil de réussite des 7 jours** : 7 lignes journalisées, **0 intervention manuelle** (pas de
|
||||
relance d'upload, pas de re-login), **0 photo perdue** (comptage local avant/après, `ls | wc -l`),
|
||||
et les EXIF conservés sur les 3 photos de contrôle.
|
||||
9. **Format de journalisation** (une ligne par jour dans `journal.md`) :
|
||||
`| date | auto-upload photoprism (C-PH2) | photos uploadées: N | interventions: 0 | fichiers locaux: X (identique) | EXIF: OK |`
|
||||
10. **Rollback** : désactiver l'upload dans l'app (1 geste), puis, si besoin, supprimer les photos
|
||||
uploadées du lot test via l'UI (jamais par `rm` sur `/data/docker/photoprism/originals`, qui
|
||||
casserait l'index).
|
||||
|
||||
**Ce que l'IA ne fait pas ici** : installer une app sur un téléphone, saisir un mot de passe,
|
||||
valider un partage, déclarer une photo « bien uploadée ». Ces 4 gestes sont humains (G11).
|
||||
+79
-5
@@ -28,7 +28,7 @@ xpenology → G9, phase par phase, quand le G9 sera branché (IP temporaire `.24
|
||||
|-----------|---------------------|
|
||||
| G1 | Scripts idempotents, en-têtes documentant chaque modification. |
|
||||
| G2 | `06-restic-backup.sh` : backup + **test de restauration** avant toute migration. |
|
||||
| G3 | `06b-usb-media.sh`, `08-decommission.sh` : drapeau `--i-understand` + saisie du nom du disque / `WIPE XPENOLOGY`. |
|
||||
| G3 | `06b-usb-media.sh` : drapeau `--i-understand` + saisie du nom du disque. `08-decommission.sh` : saisie `ETEINDRE XPENOLOGY` — **aucun wipe exécuté par le kit** (décision 12/10 : débrancher, disques intacts, récupération `RESTORE-NAS-USB.md`). |
|
||||
| G4 | `07a`–`07d` (+ `07-migrate-stateful.sh` générique) : lecture seule du xpenology + intégrité sha256 ; `08-decommission.sh` exige la validation de toutes les briques avant wipe. |
|
||||
| G5 | README (ordre d'exécution) + `08-decommission.sh` : IP `.24`→`.25` uniquement au décommissionnement. |
|
||||
| G6 | `01-hardening.sh` : ufw LAN-only (`192.168.1.0/24`), pas d'exposition WAN. |
|
||||
@@ -36,6 +36,7 @@ xpenology → G9, phase par phase, quand le G9 sera branché (IP temporaire `.24
|
||||
| G8 | `journal()` de chaque script → `journal.md` (6 colonnes : date, phase, action, raison, résultat, rollback). |
|
||||
| G9 | `.gitignore` (`*.env`, `secrets/`, configs remplis) + `03b-secrets.sh` (chmod 600, non loggué). |
|
||||
| G10 | Scripts ne modifient que le G9 ; xpenology en lecture seule (SSH `Master@192.168.1.25:222`, clé SSH — compte `effi` bloqué par Synology 10/2026). |
|
||||
| G11 | **Autonomie de l'assistante IA par paliers** (décision 12/10) : **L0** lecture seule → **L1** propose, l'humain exécute → **L2** exécute les actions **stateless documentées** des `RUNBOOKS/` → **L3** actions **stateful** uniquement sur **GO explicite** de l'utilisateur. Portes : L1→L2 = 20 propositions journalisées dont **≥ 90 % pertinentes sur 30 jours** ; L2→L3 = sinistre simulé sur une brique stateful **sans perte de données** + rollback vérifié. Interdits permanents : opérations destructives (G3), suppression de données, modification réseau impactant d'autres appareils (G6), wipe, création de compte ou de privilège, lecture de secrets hors coffre. Traçabilité : chaque action de l'IA = ligne de journal (quoi, pourquoi, résultat, rollback) + notification dans le salon ops. **Aucune exécution L2/L3 sans validation utilisateur tracée.** |
|
||||
|
||||
## Arborescence
|
||||
|
||||
@@ -61,7 +62,18 @@ g9-migration/
|
||||
│ ├── 07d-migrate-photoprism.sh ← T4.3 : PhotoPrism (copie 530 Go + indexation)
|
||||
│ ├── 07e-deploy-edge.sh ← T4.6 : edge Caddy (validate → up → ufw 443 → vérifs HTTPS/SNI)
|
||||
│ ├── restic-notify.sh ← alerte Matrix des jobs restic (posée par 06)
|
||||
│ └── 08-decommission.sh ← T6.4 : décommissionnement xpenology [DESTRUCTIF]
|
||||
│ ├── 09-j5040-dhcp.sh ← E3 : DHCP dnsmasq sur J5040 (pré-bascule bridge)
|
||||
│ ├── 10-verify-restore-photo.sh ← critère 3 : preuve restore photo (sha256 vs .25)
|
||||
│ ├── 11-registry.sh ← T-A.1/T-A.5 : génère REGISTRE-BRIQUES.md + --check --alert (dérive)
|
||||
│ ├── registry_parse.py ← helper lecture seule de 11-registry.sh (docker ps → lignes + constats)
|
||||
│ ├── registry_inputs.py ← helper : compose du kit + Caddyfile → declarations & endpoints
|
||||
│ ├── 12-probes.sh ← T-C.1/C.2/C.5 : sondes APPLICATIVES + alertes JSON + digest (cron G9)
|
||||
│ ├── g9-notify.py ← poste un payload JSON dans le salon ops (install /usr/local/bin)
|
||||
│ ├── g9-verify-alert.py ← vérifie la conformité des payloads JSON reçus (C-AP2)
|
||||
│ ├── lib-notify.sh ← côté pilot : notifie via g9-notify.py (aucun secret dans le kit)
|
||||
│ ├── 13-diun-notify.sh ← C-AP4 : notifie les nouvelles versions repérées par Diun (ne les applique pas)
|
||||
│ ├── 14-firewall-lan-only.sh ← C-EX1 : SSH/443 restrictés au LAN (LAN inséré AVANT suppression WAN, --dry dispo)
|
||||
│ └── 08-decommission.sh ← T6.4 : arrêt + débranchement xpenology [0 wipe — disques intacts]
|
||||
├── compose/ ← docker-compose.yml versionnés (stockés aussi dans Gitea)
|
||||
│ ├── vaultwarden/ ← T4.1
|
||||
│ ├── nextcloud/ ← T4.2
|
||||
@@ -69,13 +81,74 @@ g9-migration/
|
||||
│ ├── gitea/ ← T4.4
|
||||
│ ├── n8n/ ← T4.5
|
||||
│ ├── jellyfin/ ← T4.5 (média par référence, /media:ro)
|
||||
│ ├── edge/ ← T4.6 : Caddy (Caddyfile versionné = config)
|
||||
│ └── watchtower/ ← S11.4 : Watchtower sélectif
|
||||
│ ├── edge/ ← T4.6 : Caddy G9 (CA interne, edge transitoire)
|
||||
│ ├── edge-j5040/ ← E4 : Caddy J5040 + plugin OVH (LE DNS-01, Caddyfile versionné)
|
||||
│ ├── adguard/ ← E2 : AdGuard Home (DNS filtré, image épinglée digest)
|
||||
│ ├── watchtower/ ← S11.4 : Watchtower sélectif
|
||||
│ ├── matrix/ ← T4.7 : Synapse + Element (importés du G9, 12/10 — étaient hors git)
|
||||
│ ├── g9-queue/ ← n8n + Watchtower + CouchDB + Obsidian (importés du G9, 12/10)
|
||||
│ ├── valheim/ ← monde Valheim (importé du G9, 12/10)
|
||||
│ └── diun/ ← C-AP4 : Diun (notifie les versions, n'applique rien)
|
||||
└── configs/
|
||||
├── fail2ban.local.conf ← config fail2ban (T2.1)
|
||||
└── restic.env.example ← template restic (T3.1) — REMPLIR les secrets
|
||||
```
|
||||
|
||||
## Docs d'identité, d'observabilité et d'études (Phase 4.5+)
|
||||
|
||||
| Fichier | Rôle | Nature |
|
||||
|---|---|---|
|
||||
| `REGISTRE-BRIQUES.md` | **Moitié technique** du registre : briques, images épinglées, labels Watchtower, endpoints, état, backup. | **Générée** par `scripts/11-registry.sh` — ne pas éditer |
|
||||
| `REGISTRE-IDENTITES.md` | **Moitié humaine** : quel compte sur quelle brique, type, 2FA, référence d'entrée du coffre, **comment réinitialiser**, responsable. | Versionnée, **0 secret** |
|
||||
| `RUNBOOKS/` | `nextcloud` · `matrix` · `jellyfin` · `photoprism` · `vaultwarden` · `registre` · `digest` : symptôme → diagnostic → action → rollback | matière première de l'assistante IA (G11) |
|
||||
| `PARTAGE-SECRET-MAITRE.md` | Qui peut déverrouiller le coffre si je suis injoignable : détenteurs, contenu de l'enveloppe, procédure, **test annuel** | 0 secret |
|
||||
| `RESTORE-NAS-USB.md` | Lire les disques DSM hors boîtier (mdadm/LVM, **lecture seule**), procédure + contrôles + interdits | test = critère Phase 6 |
|
||||
| `PHOTOS-DOUBLE-RUN.md` | Double-run PhotoPrism ↔ Synology Photos **mesuré** : totaux source/G9, indexation, RAM, endpoints, 2 écarts de nommage | mesures 12/10 |
|
||||
| `BACKUP-PERIMETRE.md` | Périmètre 3-2-1 **mesuré** : ce qui est dans les snapshots (chiffré), ce qui est exclu par construction, ce qui manque, dumps logiques recommandés | mesures 12/10 |
|
||||
| `RESTE-HUMAIN.md` | Les 17 critères restants, **par ordre**, avec le geste exact, qui le fait, et la vérification qui clôt chacun | checklist |
|
||||
| `EXPOSITION-INTERNET.md` | Étude : exposition mesurée, grille par brique, lien avec l'IdP, playbook classé | **0 règle posée** |
|
||||
| `BRIDGE-WHATSAPP.md` | Étude `mautrix-whatsapp` : apports, limites, risque de bannissement, interdits, pilote borné | **0 installation** |
|
||||
|
||||
### Régler le registre et les sondes (une fois, puis ça tourne)
|
||||
|
||||
```bash
|
||||
# machine de pilotage — lecture seule sur .24/.27
|
||||
./scripts/11-registry.sh # génère REGISTRE-BRIQUES.md
|
||||
./scripts/11-registry.sh --check --alert # dérive -> payload JSON dans le salon ops (rc=1)
|
||||
|
||||
# G9 (root) — sondes applicatives + digest
|
||||
sudo ./scripts/12-probes.sh --install # /usr/local/bin/g9-probes.sh + cron */15 + digest 08:30
|
||||
sudo ./scripts/12-probes.sh --selftest # provocation d'un KO -> NOTIFY_OK 200
|
||||
sudo ./scripts/g9-verify-alert.py 20 # payloads JSON conformes ? -> VERIFY_OK
|
||||
sudo ./scripts/g9-verify-alert.py --list 25 # event_ids + texte des derniers messages (lecture seule)
|
||||
sudo ./scripts/13-diun-notify.sh # Diun -> payload JSON si nouvelle version (cron 06:20)
|
||||
sudo ./scripts/12-probes.sh --uninstall # rollback complet (cron + script + état)
|
||||
```
|
||||
|
||||
### Identités : état mesuré, admin Synapse, comptes famille
|
||||
|
||||
```bash
|
||||
# G9 — lecture seule
|
||||
./scripts/15-identity-sweep.sh # comptes Gitea/Nextcloud/n8n/PhotoPrism (0 ecriture)
|
||||
./scripts/25-synapse-state.sh # synapse : users/admin/hash, bcrypt dispo, inscription
|
||||
|
||||
# G9 — 1er compte admin Synapse (GO explicite, snapshot sqlite avant)
|
||||
./scripts/26-synapse-admin-repair.sh "<mxid_admin_temporaire>" <localpart>
|
||||
./scripts/26-synapse-admin-repair.sh --verify <localpart> # login -> LOGIN/token
|
||||
./scripts/26-synapse-admin-repair.sh --admin-check <localpart> # GET /_synapse/admin/v2/users
|
||||
|
||||
# G9 — comptes famille (admin=false), confirmation exigee
|
||||
./scripts/27-synapse-family-users.sh --dry # plan, 0 ecriture
|
||||
CONFIRM=CREER ./scripts/27-synapse-family-users.sh # 6 comptes + 600 mots de passe
|
||||
./scripts/27-synapse-family-users.sh --revoke # rollback = desactivation
|
||||
|
||||
# G9 — salons Matrix (0 compte cree, 0 message envoye)
|
||||
./scripts/28-synapse-create-room.sh --list # salons + resolution d'alias (ro)
|
||||
./scripts/28-synapse-create-room.sh --dry # plan, 0 ecriture
|
||||
CONFIRM=CREER ./scripts/28-synapse-create-room.sh --create # cree #famille
|
||||
CONFIRM=PURGER ./scripts/28-synapse-create-room.sh --purge <room_id> # rollback
|
||||
```
|
||||
|
||||
## Ordre d'exécution (phase par phase)
|
||||
|
||||
| Phase | Script / action | Garde-fou |
|
||||
@@ -83,8 +156,9 @@ g9-migration/
|
||||
| 1 | (xpenology) exclure stateful de Watchtower + inventaire | G4 |
|
||||
| 2 | `01-hardening.sh` → `02-data.sh` → `03-docker.sh` → `03b-secrets.sh` → `04-smb.sh` → `05-beszel.sh` | — |
|
||||
| 3 | `06-restic-backup.sh` (backup + test restauration) | G2 |
|
||||
| 4 | **par brique, dans l'ordre** — **après** `03b-secrets.sh` : `07b-migrate-vaultwarden.sh` (T4.1) → `07c-migrate-nextcloud.sh` (T4.2) → `07d-migrate-photoprism.sh` (T4.3, **reporté**) → `07a-migrate-gitea.sh` (T4.4) → Jellyfin (T4.5, frais) → `07e-deploy-edge.sh` (T4.6, Caddy TLS interne) → Watchtower (T4.7, scope stateless). `07-migrate-stateful.sh` = script **générique rsync**, inopérant sur le DSM (sshd refuse `rsync --server`/scp) ; transport réel des 07a–07d = **tar sur SSH** | G4 |
|
||||
| 4 | **par brique, dans l'ordre** — **après** `03b-secrets.sh` : `07b-migrate-vaultwarden.sh` (T4.1) → `07c-migrate-nextcloud.sh` (T4.2) → `07d-migrate-photoprism.sh` (T4.3) **✅ 12/10** : 433 941 fichiers / 567 Go copiés, manifeste 0 écart + sha256 200/200, stack up (port 2342), index progressif, snapshot restic + restore prouvée (`10-verify-restore-photo.sh`) → `07a-migrate-gitea.sh` (T4.4) → Jellyfin (T4.5, frais) → `07e-deploy-edge.sh` (T4.6, Caddy TLS interne) → Watchtower (T4.7, scope stateless). `07-migrate-stateful.sh` = script **générique rsync**, inopérant sur le DSM (sshd refuse `rsync --server`/scp) ; transport réel des 07a–07d = **tar sur SSH** | G4 |
|
||||
| 5 | restic 3-2-1 complet (G9 + PC IA → Freebox → 218play) | G2 |
|
||||
| E (J5040, parallèle) | **E1** durcissement + Docker (`01`/`03` paramétrés, .27) ✅ → **E2** AdGuard (VM Freebox .1 → .27) ✅ bascule DNS faite (résolvants clients → .27, filtres testés) → **E3** DHCP (`09-j5040-dhcp.sh`, baux 35 appareils prêts) + Freebox **mode bridge** = **GO utilisateur (G6)** → **E4** Caddy OVH (`compose/edge-j5040`) ✅ 7/7 certs LE (DNS-01) + WireGuard prêt ; forward WAN Freebox→.27 + démontage edge G9 = **GO utilisateur** | G6, G7 |
|
||||
| 6 | `06b-usb-media.sh` (T6.1) → Jellyfin (T6.2) → IP .24→.25 → `08-decommission.sh` | G3, G4, G5 |
|
||||
| 7 | (G7) wipe DSM + Ubuntu neuf + boot test | G3, G10 |
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# REGISTRE-BRIQUES — moitié technique (générée)
|
||||
|
||||
> Source : `scripts/11-registry.sh` (`docker ps` + `docker inspect` en **lecture seule** sur .24 et .27,
|
||||
> compose du kit, Caddyfile pour les endpoints). **Ne pas éditer à la main** : la moitié humaine
|
||||
> (comptes, 2FA, procédure de réinitialisation, responsable, break-glass) vit dans
|
||||
> `REGISTRE-IDENTITES.md`.
|
||||
>
|
||||
> Périmètre (T-A.10) : **.24 (G9) et .27 (J5040) uniquement**. Le xpenology **.25 est hors périmètre** :
|
||||
> il est décrit dans `INVENTAIRE-STATEFUL.md` et ses briques sont **à mourir en Phase 6**.
|
||||
>
|
||||
> Couverture backup : g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5)
|
||||
|
||||
<!-- GÉNÉRÉ PAR scripts/11-registry.sh — ne pas éditer à la main. ts=2026-10-12 17:10 UTC ; 22 conteneurs -->
|
||||
|
||||
| Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| g9 | `collabora` | nextcloud / collabora | `collabora/code:26.04.5.1.1` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `couchdb` | g9-queue / couchdb | `couchdb:3.3` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `diun` | diun / diun | `e324b793eb32` | running | oui (digest) | false | stateful | oui | — |
|
||||
| g9 | `edge` | edge / edge | `caddy:2` | running | oui | true | stateless | oui | — |
|
||||
| g9 | `element` | matrix / element | `vectorim/element-web:latest` | running | non (latest) | false | stateful | oui | — |
|
||||
| g9 | `gitea` | gitea / gitea | `gitea/gitea:1.27.3` | running | oui | false | stateful | oui | git.folzocorp.fr |
|
||||
| g9 | `gitea-db` | gitea / gitea-db | `postgres:16` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `jellyfin` | jellyfin / jellyfin | `jellyfin/jellyfin:10.9` | running | oui | false | stateful | oui | media.folzocorp.fr |
|
||||
| g9 | `n8n` | g9-queue / n8n | `docker.n8n.io/n8nio/n8n:latest` | running | non (latest) | false | stateful | oui | n8n.folzocorp.fr |
|
||||
| g9 | `nextcloud` | nextcloud / nextcloud | `nextcloud:35.0.1` | running | oui | false | stateful | oui | cloud.folzocorp.fr |
|
||||
| g9 | `nextcloud_db` | nextcloud / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `nextcloud_redis` | nextcloud / redis | `redis:7` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `obsidian` | g9-queue / obsidian | `lscr.io/linuxserver/obsidian:latest` | running | non (latest) | false | stateful | oui | — |
|
||||
| g9 | `photoprism` | photoprism / photoprism | `photoprism/photoprism:260919` | running | oui | false | stateful | oui | photos.folzocorp.fr |
|
||||
| g9 | `photoprism_db` | photoprism / db | `mariadb:10.11` | running | oui | false | stateful | oui | — |
|
||||
| g9 | `synapse` | matrix / synapse | `matrixdotorg/synapse:latest` | running | non (latest) | false | stateful | oui | matrix.folzocorp.fr |
|
||||
| g9 | `valheim` | compose / valheim | `valheim-pterodactyl:1` | running | oui | false | stateful | NON | — |
|
||||
| g9 | `vaultwarden` | vaultwarden / vaultwarden | `vaultwarden/server:1.37.4` | running | oui | false | stateful | oui | pass.folzocorp.fr |
|
||||
| g9 | `watchtower` | g9-queue / watchtower | `45b54f6dc152` | running | oui (digest) | true | stateless | oui | — |
|
||||
| j5040 | `adguard` | adguard / adguard | `aba9e3bf0613` | running | oui (digest) | true | stateless | oui | — |
|
||||
| j5040 | `caddy` | edge-j5040 / caddy | `502996b5b60a` | running | oui (digest) | true | stateless | oui | — |
|
||||
| j5040 | `sweet_feynman` | - / - | `437a490f7afc` | running | oui (digest) | ABSENT | stateless | NON | — |
|
||||
|
||||
## Constats automatiques (détection de dérive)
|
||||
|
||||
- g9/valheim: conteneur réel non déclaré dans le kit (compose)
|
||||
- g9/n8n: image non épinglée (docker.n8n.io/n8nio/n8n:latest) — S11.4 pinning
|
||||
- g9/element: image non épinglée (vectorim/element-web:latest) — S11.4 pinning
|
||||
- g9/synapse: image non épinglée (matrixdotorg/synapse:latest) — S11.4 pinning
|
||||
- g9/obsidian: image non épinglée (lscr.io/linuxserver/obsidian:latest) — S11.4 pinning
|
||||
- j5040/sweet_feynman: label watchtower absent — S11.4 sélectivité
|
||||
- j5040/sweet_feynman: conteneur réel non déclaré dans le kit (compose)
|
||||
|
||||
## Utiliser ce fichier
|
||||
|
||||
- `./11-registry.sh` : régénère (à lancer après chaque déploiement / MAJ d'image).
|
||||
- `./11-registry.sh --check --alert` : régénère, compare à la version commitée, **alerte en payload JSON**
|
||||
si le réel a dérivé du registre (label perdu, image non épinglée, conteneur hors kit, état ≠ running).
|
||||
- Un `git diff` sur ce fichier est, par construction, la détection de dérive.
|
||||
@@ -0,0 +1,188 @@
|
||||
# REGISTRE-IDENTITES — moitié humaine (comptes, reset, responsables, break-glass)
|
||||
|
||||
> **Règle** : ce fichier ne contient **aucun secret**. Il dit **quel compte** sert sur quelle brique,
|
||||
> **qui en est responsable**, **où vit le secret** (référence d'entrée du coffre) et **comment on réinitialise**.
|
||||
> Les secrets vivent dans **Vaultwarden** (`db.sqlite3` du G9, déjà dans les snapshots restic).
|
||||
> La moitié technique (briques, images épinglées, labels Watchtower, endpoints, état) est **générée**
|
||||
> par `scripts/11-registry.sh` dans `REGISTRE-BRIQUES.md` — ne pas la dupliquer ici.
|
||||
>
|
||||
> Périmètre (T-A.10) : **.24 (G9)** et **.27 (J5040)**. Le xpenology **.25 est hors périmètre** :
|
||||
> inventaire dans `INVENTAIRE-STATEFUL.md`, briques **à mourir en Phase 6**.
|
||||
>
|
||||
> Source des faits : `journal.md` (lignes Phase 4, 2026-10-11) + `PHASE4-RECOLLEMENT.md` §0.
|
||||
|
||||
## 1. Convention du coffre (T-A.3)
|
||||
|
||||
| Portée | Chemat d'entrée Vaultwarden | Exemples |
|
||||
|---|---|---|
|
||||
| Infra / services | `infra/<brique>/<compte>` | `infra/gitea/g9ops`, `infra/n8n/api`, `infra/synapse/admin` |
|
||||
| Adultes | `famille/<pseudo>/<brique>` | `famille/effi/nextcloud`, `famille/effi/matrix` |
|
||||
| Enfants | `enfants/<pseudo>/<brique>` (coffre des parents) | `enfants/lea/jellyfin` |
|
||||
| **Compte de secours (break-glass)** | **`infra/<brique>/break-glass`** — 1 entrée par brique, consignée à la création du compte | `infra/gitea/break-glass`, `infra/synapse/break-glass`, `infra/jellyfin/break-glass`, `infra/n8n/break-glass`, `infra/nextcloud/break-glass` |
|
||||
|
||||
Structure du coffre (décision 12/10, coffre **unique** Vaultwarden pour les 6) :
|
||||
- organisation `Famille` = 2 adultes (secrets partagés : wifi, DAV, comptes communs) ;
|
||||
- organisation `Enfants` = 16 et 13 ans : coffre propre + **emergency access** vers les parents ;
|
||||
- 10 et 9 ans : **pas de coffre**, identifiants tenus par les parents sous `enfants/<pseudo>/` ;
|
||||
- `ADMIN_TOKEN` (console Vaultwarden) : consigné + copie hors-ligne (voir `PARTAGE-SECRET-MAITRE.md`).
|
||||
|
||||
Comptes d'automatisation : préfixe de rôle, jamais un prénom humain → `ops-bot` (Matrix),
|
||||
`g9ops` (Gitea/Jellyfin), `n8n-bot` (à créer si des workflows doivent pousser des logs).
|
||||
|
||||
## 2. Registre des comptes (.24 G9)
|
||||
|
||||
| Brique | Compte utilisé | Type | 2FA | Entrée coffre | Réinitialisation (break-glass) | Responsable |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Vaultwarden** | 1 utilisateur (coffre) | admin humain | **TOTP actif** (importé de la source, `twofactor enabled=1`) | `infra/vaultwarden/maitre` | `ADMIN_TOKEN` (argon2id) → console `/admin` ; emergency access famille activée (`EMERGENCY_ACCESS_ALLOWED=true`) | moi |
|
||||
| **Nextcloud** | 8 utilisateurs importés | humains | par utilisateur (à activer) | `famille/<pseudo>/nextcloud` | `docker exec nextcloud php occ user:set-password <uid>` (ou `occ user:resetpassword`) | moi |
|
||||
| **MariaDB** (`nextcloud_db`) | root + utilisateur applicatif | service | — | `infra/nextcloud/db` | `docker exec -it nextcloud_db mariadb -u root -p` (mot de passe dans `secrets/`) | moi |
|
||||
| **Redis** (`nextcloud_redis`) | sans mot de passe (LAN/bridge) | service | — | — | aucun compte ; accès = réseau Docker uniquement | moi |
|
||||
| **Collabora** | mot de passe console (`secrets/collabora.env`, `env_file`) | service | — | `infra/collabora/console` | régénérer `secrets/collabora.env` (600) + restart conteneur | moi |
|
||||
| **Gitea** | `master`, `antoine`, `folzo` (importés, **`must_change_password`**), `g9ops` (admin, token API `ops-phase4`) | humains + service | à activer sur le compte principal | `infra/gitea/g9ops`, `infra/gitea/token-ops` | `docker exec -u git gitea /usr/local/bin/gitea admin user create\|change-password …` | moi |
|
||||
| **Postgres** (`gitea-db`) | utilisateur applicatif | service | — | `infra/gitea/db` | `docker exec -it gitea-db psql -U postgres` | moi |
|
||||
| **Jellyfin** | `g9ops` (admin, créé par le wizard) | service/opérateur | — | `infra/jellyfin/g9ops` | API avec `X-Emby-Authorization` ; si `EnableLocalPassword=0` (piège mesuré du wizard) : `UPDATE Users SET EnableLocalPassword=1` en SQLite + restart | moi |
|
||||
| **PhotoPrism** | **3 comptes mesurés** (base `photoprism.auth_users`, 12/10) : `admin` (rôle admin), `visitor` (rôle viewer, compte de démonstration), **1 compte sans nom** (rôle viewer) → les 2 non-admin sont **à nommer ou supprimer** | humains + démonstration | PhotoPrism n'a **pas de TOTP natif** → 2FA attendu par SSO (C-IDP1) | `famille/<pseudo>/photoprism` (adultes), `enfants/<pseudo>/photoprism` (ados, après GO famille) | CLI mesurée : `docker exec photoprism photoprism users ls\|show\|mod\|add\|rm` ; login KO après upgrade = `photoprism auth reset --yes` (**supprime tokens + app passwords**, à journaliser) | moi |
|
||||
| **n8n** | **owner = `effi@openfox.local`** (le tien, accès perdu puis recréé, journal 10/10 21:12 ; **mesuré 12/10** : 1 compte, `disabled = 0`) | admin humain | à activer | `infra/n8n/owner`, `infra/n8n/api` (clé API dédiée) | UI → Settings → Users ; si accès perdu : créer un owner via CLI n8n (`n8n export:users` n'existe pas → passer par la DB Postgres, procédure §4) | moi |
|
||||
| **Synapse (Matrix)** | **3 comptes mesurés 12/10** : `@folzo` (**admin**, créé 12/10, login vérifié), `@ops`, `@ops-bot` (token d'alerte) | admin + service | **TOTP à activer sur `@folzo`** | `infra/synapse/admin`, `infra/synapse/token-ops-bot` | API admin v2 par **PUT** (`PUT /_synapse/admin/v2/users/<uid>` ; POST = `Unrecognized request`), token via login ; compte inutilisable (hash NULL) = `scripts/26-synapse-admin-repair.sh` ; déactivation propre = `PUT … {"deactivated": true}` | moi |
|
||||
| **Element (web)** | — (frontal, pas de compte) | — | — | — | — | moi |
|
||||
| **CouchDB / Obsidian** | locaux (stack du second cerveau) | service | — | `infra/couchdb/admin` | `docker exec` + config du compose | moi |
|
||||
| **Valheim** | aucun compte (serveur jeu, whitelist par steamid) | — | — | — | `player.list` dans `data/save` | moi |
|
||||
| **Watchtower** (G9) | aucun (socket Docker) | service | — | — | retirer le compose | moi |
|
||||
| **edge** (Caddy G9) | aucun (443, TLS interne) | service | — | — | retirer le compose (démontage prévu après bascule J5040) | moi |
|
||||
|
||||
## 3. Registre des comptes (.27 J5040)
|
||||
|
||||
| Brique | Compte utilisé | Type | 2FA | Entrée coffre | Réinitialisation (break-glass) | Responsable |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **AdGuard Home** | utilisateurs bcrypt repris de la VM Freebox (UI 8081) | admin humain | — | `infra/adguard/ui` | redéfinir le hash bcrypt dans `AdGuardHome.yaml` + restart | moi |
|
||||
| **Caddy OVH** (edge) | aucun ; **clé API OVH** (`secrets/ovh.env`, 600, hors git) | service | — | `infra/ovh/api-key` | recréer une clé dans le manager OVH (droits legacy `/domain/zone/folzocorp.fr/*`), révoquer l'ancienne | moi |
|
||||
| **WireGuard** | clés (config hors git, 600) | service | — | `infra/wireguard/` | régénérer `wg genkey` + redéclarer un peer | moi |
|
||||
| **dnsmasq (DHCP, E3)** | aucun | service | — | — | `dnsmasq --test` puis restart | moi |
|
||||
| **Beszel hub** | utilisateur local Beszel | admin humain | — | `infra/beszel/hub` | `docker exec` + reset dans l'UI ; hub = J5040 (décision C-AP5) | moi |
|
||||
|
||||
## 4. Procédures de récupération (les 4 qui ont déjà fait mal)
|
||||
|
||||
1. **n8n owner perdu** (subi le 10/10) : `docker exec -it n8n sh` → CLI `n8n export:users` n'existe pas ;
|
||||
la récupération passe par Postgres : créer un utilisateur (bcrypt) + le lier au rôle owner,
|
||||
puis **consigner** l'identifiant dans `infra/n8n/owner` et créer une **clé API dédiée** (Settings → n8n API).
|
||||
2. **Gitea `must_change_password`** (subi le 11/10, bloque l'API) : `gitea admin user change-password -u <uid> --password <nouveau>`
|
||||
avec `--must-change-password=false`. **Avant** : lister les remotes qui s'en servent
|
||||
(`git remote -v` sur les clones, `~/.git-credentials`) → journal + rollback.
|
||||
3. **Jellyfin `EnableLocalPassword=0`** (subi le 11/10) : `UPDATE Users SET EnableLocalPassword=1` en SQLite + restart ;
|
||||
API = en-tête **`X-Emby-Authorization`** combiné (sinon 400).
|
||||
4. **Synapse sans admin** — **résolu le 12/10, voie mesurée** : la voie « secret +
|
||||
`register_new_matrix_user` » n'a **pas** marché (`POST /_synapse/admin/v1/register` signé =
|
||||
**`HMAC incorrect`**) ; la voie qui a marché = **hash bcrypt écrit en base** (module `bcrypt`
|
||||
présent dans l'image) + **`docker restart`** (caches) + **`PUT /_synapse/admin/v2/users/<uid>`**
|
||||
`{password, admin:true}` → `scripts/26-synapse-admin-repair.sh` (snapshot sqlite avant, compte
|
||||
temporaire désactivé, `--verify` = login OK, `--admin-check` = API admin OK).
|
||||
|
||||
### 4.5 Journal de bascule des comptes Gitea (4 colonnes, à remplir au moment du geste)
|
||||
|
||||
L'état « avant » est **mesuré** (12/10 18:40, `gitea admin user list`) : 4 comptes, `IsAdmin` = 2
|
||||
(`Master` **et le bot `g9ops`**), **2FA = false sur les 4**. L'état « après » est la cible C-ID7 :
|
||||
**1 compte humain avec 2FA**, **aucun bot admin**, token dédié à scopes. Le CLI Gitea de cette
|
||||
version **ne sait pas changer un rôle** (`gitea admin user` = `create`, `list`, `change-password`,
|
||||
`delete`, `generate-access-token`, `must-change-password`) → **le rôle se change dans l'UI**
|
||||
(Administration → Users → case « Administrator ») ou en SQL (`update users set is_admin=…`).
|
||||
|
||||
| Compte | Rôle **avant** (mesuré 12/10) | Rôle **après** + 2FA (cible) | Commande + rollback |
|
||||
|---|---|---|---|
|
||||
| **`Master`** | humain, **`IsAdmin` = 1**, 2FA = **false**, `must_change_password` = vrai | **admin humain de référence, 2FA TOTP actif**, `must-change-password=false` | `docker exec -u git gitea /usr/local/bin/gitea admin user change-password -u Master --password <nouveau> --must-change-password=false` puis UI → Sécurité → Activer TOTP (codes de récupération imprimés) ; **rollback** = désactiver le TOTP (suppression du device dans l'UI) + `change-password` vers l'ancien, consigné |
|
||||
| **`Folzo`** | humain, `IsAdmin` = 0, 2FA = **false** | **humain, 2FA TOTP actif** (compte de travail quotidien) | même commande `-u Folzo` + TOTP ; **rollback** = idem (TOTP retiré, mot de passe restauré) |
|
||||
| **`Antoine`** | humain, `IsAdmin` = 0, 2FA = **false** | **service** (compte de déploiement, token à scopes) **ou désactivation** — décision à rendre | création du token : `docker exec -u git gitea /usr/local/bin/gitea admin user generate-access-token -u Antoine --scopes write:repository --token-name pilot` ; désactivation = UI (ou `delete`, **jamais** sans backup) ; **rollback** = `change-password` + réactivation UI |
|
||||
| **`g9ops`** | **bot**, **`IsAdmin` = 1** ← l'anomalie, 2FA = false (non applicable à un bot) | **bot non-admin**, token **`Write Repository` uniquement** | **UI** : décocher « Administrator » (CLI ne sait pas) ; puis `generate-access-token -u g9ops --scopes write:repository --token-name g9-migration` consigné `infra/gitea/token-ops`, **ancien token révoqué** ; **rollback** = recocher « Administrator » + ancien token régénéré. **Vérifier avant** : `git remote -v` des clones + `~/.git-credentials` (le retrait du rôle ne doit pas casser le push du pilot) |
|
||||
|
||||
**Preuve de fin de bascule** (à journaliser, 1 ligne par compte) : `gitea admin user list` →
|
||||
`IsAdmin` = **1** (humain), 2FA = **true** sur les comptes humains, `g9ops` `IsAdmin` = 0 ;
|
||||
`git ls-remote gitea` OK **sans** `~/.git-credentials` (lien avec C-ID6).
|
||||
|
||||
## 5. Hygiène des secrets (T-A.5)
|
||||
|
||||
| Constat | Action |
|
||||
|---|---|
|
||||
| `~/.git-credentials` (600) + `credential.helper store` = **token en clair** sur la machine de pilotage | remplacer par un **token dédié** (scopes minima, nommé, révocable) consigné dans `infra/gitea/token-pilot`, helper non persistant en clair (SSH pour `git push`, ou `credential.helper cache`) |
|
||||
| Secrets Docker (`secrets/*.env`, 600/644 root) | restent **hors git** (`.gitignore` : `*.env`, `*.cred`, `*.key`, `repo.key`, `matrix.env`) ; **contrôle** : `git ls-files` = 0 fichier de secret, `git grep` des valeurs = 0 |
|
||||
| Token Matrix `ops-bot` | vit dans `/etc/restic/matrix.env` (600 root) ; jamais dans un script, jamais affiché |
|
||||
| Clé API OVH | `secrets/ovh.env` (600, hors git) ; révocation dans le manager = procédure de rollback |
|
||||
|
||||
### 5.1 `~/.git-credentials` — état mesuré et procédure (T-A.5, en attente d'une action humaine)
|
||||
|
||||
État mesuré sur la machine de pilotage : `~/.git-credentials` (600, 1 ligne) + `credential.helper = store`,
|
||||
remote `gitea = http://192.168.1.24:3000/g9ops/g9-migration.git`. Le dépôt est **accessible en lecture
|
||||
anonyme** sur le LAN (`git -c credential.helper= ls-remote` = OK) → le fichier ne sert qu'au **push**.
|
||||
Le port SSH de Gitea est ouvert (`2222`) mais la clé `id_ed25519_openfox11` y est refusée (clé non déclarée).
|
||||
|
||||
**Procédure, dans l'ordre** (la 1ʳᵉ étape est humaine : je ne crée pas de token à votre place) :
|
||||
|
||||
1. Gitea → *Settings → Applications → Generate New Token* : nom `pilot-openfox`, scopes **Write Repository**
|
||||
uniquement, **jamais** admin. → consigner dans le coffre `infra/gitea/token-pilot`.
|
||||
2. Réduire le fichier au strict périmètre (une seule origine, pas de mot de passe de compte) :
|
||||
`printf 'http://<user>:%s@192.168.1.24:3000\n' "$TOKEN" > ~/.git-credentials && chmod 600 ~/.git-credentials`
|
||||
3. Basculer le remote en SSH (supprime le besoin de credential store) :
|
||||
`git remote set-url gitea ssh://git@192.168.1.24:2222/g9ops/g9-migration.git`
|
||||
+ déclarer la clé publique dans Gitea → *Settings → SSH/GPG Keys*, puis
|
||||
`git -c credential.helper= ls-remote gitea` doit répondre, puis `rm -f ~/.git-credentials`
|
||||
et `git config --global --unset credential.helper`.
|
||||
4. **Rollback** : `git remote set-url gitea http://192.168.1.24:3000/g9ops/g9-migration.git` ;
|
||||
le token est révocable dans l'UI Gitea sans toucher au dépôt.
|
||||
|
||||
**Vérification attendue** : `git ls-remote gitea` OK **sans** `~/.git-credentials`, et
|
||||
`git config --global --get credential.helper` = vide.
|
||||
|
||||
## 6. Ce que le registre ne remplace pas
|
||||
|
||||
Le registre dit **qui** et **comment**. Il ne donne **pas** le login unique (c'est la tâche
|
||||
`IDENTITÉ UNIQUE — Authelia`), et ne supprime pas le besoin de coffre (c'est Vaultwarden).
|
||||
Tant que l'IdP n'est pas posé, **chaque brique garde son compte local** : c'est voulu (break-glass).
|
||||
|
||||
## 7. État **mesuré** des comptes (12/10, lecture seule — `scripts/15-identity-sweep.sh`, `16-identity-sweep-sqlite.sh`)
|
||||
|
||||
Ce qui suit sort de commandes, pas d'hypothèses. Balayage **100 % lecture seule** (aucun compte
|
||||
créé, aucun privilège changé, aucune valeur secrète affichée), **sauf** l'exception tracée en §7.1
|
||||
(compte admin Synapse, GO utilisateur explicite du 12/10).
|
||||
|
||||
| Brique | Compteurs mesurés | Drapeaux mesurés | Constat |
|
||||
|---|---|---|---|
|
||||
| **Gitea** | 4 comptes : `Master`, `Folzo`, `Antoine`, `g9ops` | `IsAdmin` = **2** (`Master`, `g9ops`) · **2FA = false sur les 4** | **un bot (`g9ops`) est admin** et **personne n'a de 2FA** → C-ID7 non satisfait, mesuré |
|
||||
| **Nextcloud** | 8 comptes | groupes : `admin` = {Effi, Master} ; `Famille Folzo` = {Audrey, Folzo, carla, ewan, lola, noah} ; `guest_app` | 2FA **available** (`twofactor_totp`, `twofactor_backupcodes`, `twofactor_nextcloud_notification` installés) — activation par utilisateur non mesurée (nécessite la session de l'utilisateur) |
|
||||
| **Synapse** | mesuré 18:40 : **2 comptes** (`@ops`, `@ops-bot`), `admin` = **0** → **re-mesuré 19:00 : 4 comptes, 1 admin utilisable** (`@folzo`), 1 désactivé | `@folzo` : `admin` = 1, `locked` = 0, `deactivated` = 0, hash bcrypt présent, **login = OK**, **API admin = OK** (`GET /_synapse/admin/v2/users` = 3 utilisateurs) | Trou **résolu** (GO utilisateur 12/10, `scripts/26-synapse-admin-repair.sh`) : la brique a désormais un admin → C-MX1 outillé (`scripts/27-synapse-family-users.sh`), C-MX3 débloqué (2FA à activer sur `@folzo`) |
|
||||
| **n8n** | **1 compte** : `effi@openfox.local` (`disabled = 0`) | basic auth déclarée (`N8N_BASIC_AUTH_USER/PASSWORD`) dans `compose/g9-queue` | owner = toi ✓ ; 2FA et clé API = à poser (C-ID9) |
|
||||
| **PhotoPrism** | **3 comptes** (`photoprism.auth_users`) : `admin` (rôle admin), `visitor` (rôle viewer), **1 compte sans nom** (rôle viewer) | pas de TOTP natif | **nommés en §2** (ligne PhotoPrism) ; reste humain : nommer ou supprimer les 2 comptes non-admin |
|
||||
| **Vaultwarden** | — | `ADMIN_TOKEN_FILE` (jeton **dans un fichier**, pas en variable d'env en clair) | bonne pratique déjà en place |
|
||||
| **CouchDB** | — | `GET /_all_databases` = **401** sans identifiants | **auth active** (le « sans auth » du 12/10 était un faux positif de sonde sur `/`, public par conception) |
|
||||
|
||||
### 7.1 Le trou Synapse : **résolu**, avec la voie qui a marché et celle qui a échoué
|
||||
|
||||
Mesure initiale : `database.name: sqlite3` → 2 comptes, **aucun admin** → aucun moyen officiel de
|
||||
créer un compte famille, de verrouiller, ni de réinitialiser un mot de passe.
|
||||
|
||||
**Voie tentée et échouée** (`scripts/17-synapse-admin.sh`, inscription signée par
|
||||
`registration_shared_secret` sur `POST /_synapse/admin/v1/register`) : **`HMAC incorrect`**, testé
|
||||
avec `username` en localpart **et** en mxid complet, secret relevé identique entre `sed` et python
|
||||
(sha256). Le secret n'a **jamais** été recopié dans le kit ni affiché.
|
||||
|
||||
**Voie qui a marché** (`scripts/26-synapse-admin-repair.sh`, GO utilisateur) :
|
||||
1. `POST /_matrix/client/v3/register` (flows `m.login.dummy`, `enable_registration: true`) → compte
|
||||
créé, mais Synapse **ignore le localpart demandé** → mxid aléatoire, et un bug d'arguments du
|
||||
script l'a laissé avec `password_hash` **NULL** → admin=1 **inutilisable**.
|
||||
2. Réparation : le module **`bcrypt` est présent dans l'image synapse** (5.0.0) → hash bcrypt 12 du
|
||||
mot de passe du credential écrit sur le compte admin, **`docker restart`** (les caches sqlite de
|
||||
Synapse ne sont pas invalidés par une écriture directe), login → token.
|
||||
3. **`PUT /_synapse/admin/v2/users/@folzo:192.168.1.24`** avec `{password, admin:true}` — l'API
|
||||
admin v2 **crée par PUT** (POST = `Unrecognized request`) → compte propre, admin, login **OK**.
|
||||
4. Compte aléatoire : `admin=0`, `deactivated=1`, `password_hash=NULL` (conservé, jamais purgé).
|
||||
|
||||
Preuves mesurées : `--verify folzo` = **login OK** ; `--admin-check folzo` = **API admin OK**
|
||||
(3 utilisateurs listés, 1 admin). Snapshot sqlite avant toute écriture :
|
||||
`/data/homeserver.db.bak-20261012-1900` (rollback = restaurer ce fichier, conteneur arrêté).
|
||||
|
||||
**Ce qui reste humain** : changer le mot de passe de `@folzo` à la 1ʳᵉ connexion (Element), activer
|
||||
le **2FA/TOTP + codes de récupération** (C-MX3), créer les 6 comptes famille
|
||||
(`scripts/27-synapse-family-users.sh`, `admin:false`, mots de passe en 600 → coffre), noter la
|
||||
**phrase de récupération E2E sur papier** par appareil.
|
||||
|
||||
**Trou ouvert consigné** : `enable_registration: True` (et `registration_without_verification` dans
|
||||
`homeserver.yaml`) = **n'importe quel client du LAN peut créer un compte**. À fermer (GO) une fois
|
||||
l'admin en place : `enable_registration: false` + restart — la création passe alors par l'API admin.
|
||||
Rollback : remettre `true`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# RESTORE-NAS-USB — lire les disques du xpenology hors boîtier (T6.5)
|
||||
|
||||
> Décision 12/10 : le xpenology est **arrêté et débranché, disques conservés intacts**.
|
||||
> Ce document rend cette promesse **exécutable**. Il est écrit avant la Phase 6 ; le **test**
|
||||
> (1 fichier restauré) est un critère de la Phase 6.
|
||||
|
||||
## 1. Ce qu'il faut savoir avant de brancher quoi que ce soit
|
||||
|
||||
| Point | Réalité mesurée (journal/`INVENTAIRE-STATEFUL.md`) |
|
||||
|---|---|
|
||||
| Les volumes DSM ne sont **pas** des partitions lisibles telles quelles | DSM = **md (RAID)** + **LVM** + ext4/btrfs. Un disque seul monté directement = illisible. |
|
||||
| Les partages vus dans la GUI sont des **vues** | Exemple mesuré 11/10 : `/volume1/docker-REC` est une **vue multi-volumes** du partage « docker-REC » ; la vérité terrain est `docker inspect` → `nextcloud_app` → **`/volume2/docker-REC/NextCloud/{data,config}`**, `vaultwarden-server-1` → `/volume1/docker/vaultwarden`. |
|
||||
| Ordre des disques | Un RAID DSM se reconstitue **sans** respecter l'ordre affiché, mais **tous** les disques du même md doivent être présents. |
|
||||
| Chiffrement | Les volumes du NAS ne sont **pas** chiffrés LUKS (le LUKS est la décision G9/backup). |
|
||||
|
||||
## 2. Matériel
|
||||
|
||||
- boîtier/USB-SATA **un disque à la fois** (ou dock multi-baies), **pas** de branchement sur le contrôleur interne ;
|
||||
- machine de lecture = **machine de confiance** (le pilot .11), en **lecture seule** ;
|
||||
- aucun disque du NAS branché sur le G9 en production (risque de montage automatique / `fstab`).
|
||||
|
||||
## 3. Procédure (lecture seule, par disque groupé)
|
||||
|
||||
```bash
|
||||
# 0) identifier les disques (ne rien monter)
|
||||
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT
|
||||
sudo blkid
|
||||
|
||||
# 1) repérer les membres md (meta-data DSM en fin de partition)
|
||||
sudo mdadm --examine /dev/sdX1 /dev/sdY1 /dev/sdZ1 # tous les disques du même volume
|
||||
# -> noter la « Raid UUID » et le nom du md (ex /dev/md2 = volume 1 DSM)
|
||||
|
||||
# 2) assembler le md en lecture seule (mdadm assemble, pas create)
|
||||
sudo mdadm --assemble --force /dev/md-X /dev/sdX1 /dev/sdY1 /dev/sdZ1
|
||||
|
||||
# 3) LVM au-dessus du md
|
||||
sudo pvscan ; sudo vgscan ; sudo lvscan
|
||||
sudo vgchange -ay -f # activation en lecture seule si possible
|
||||
sudo lvscan
|
||||
|
||||
# 4) monter EN LECTURE SEULE uniquement
|
||||
sudo mount -o ro,norecovery /dev/<vg>/<lv> /mnt/nas-ro
|
||||
ls -la /mnt/nas-ro # doit montrer les partages DSM (docker/, photo/, …)
|
||||
```
|
||||
|
||||
## 4. Ce qu'on vérifie (critère de test, Phase 6)
|
||||
|
||||
| Contrôle | Preuve attendue |
|
||||
|---|---|
|
||||
| md assemblé | `mdadm --detail /dev/md-X` = `State : clean`, nb disques = attendu |
|
||||
| LVM actif | `lvs` liste les LV du volume |
|
||||
| Montage RO | `mount | grep nas-ro` contient `ro` |
|
||||
| 1 fichier restauré | un fichier de test (photo + sha256 connu) copié hors du montage → `sha256sum` identique à la référence |
|
||||
| Démontage propre | `umount` + `vgchange -an` + `mdadm --stop` |
|
||||
|
||||
## 5. Interdits (écrits, à respecter)
|
||||
|
||||
- **jamais** `mdadm --create`, `pvcreate`, `vgcreate`, `mkfs`, `fdisk`, `parted -l` en écriture, `dd`, `wipefs` ;
|
||||
- **jamais** de montage en lecture/écriture ;
|
||||
- **jamais** de `fsck` sur un volume du NAS (peut aggraver un souci) ;
|
||||
- si `mdadm --assemble` échoue : **s'arrêter**, journaliser, ne pas insister (les données sont
|
||||
aussi dans les snapshots restic du G9 pour ce qui a été migré).
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
Aucun : la procédure est purement lecture. Le seul état créé = des points de montage et des
|
||||
périphériques md temporaires, tous démontables (`umount`, `vgchange -an`, `mdadm --stop`).
|
||||
|
||||
## 7. Statut
|
||||
|
||||
- [x] procédure écrite (ce fichier)
|
||||
- [ ] **test réalisé** (1 fichier restauré, journalisé en Phase 6) — *non fait à ce jour*
|
||||
@@ -0,0 +1,43 @@
|
||||
# RUNBOOK — Digest quotidien & chaîne d'alerte
|
||||
|
||||
> Le digest remplace le bruit : **1 message par jour** (briques OK/KO, versions épinglées, dernier
|
||||
> backup, MAJ Watchtower) au lieu de N alertes. Les alertes immédiates restent réservées aux KO `high`.
|
||||
|
||||
## Chaîne
|
||||
|
||||
```
|
||||
12-probes.sh (cron root G9, */15)
|
||||
└─ KO → payload JSON → g9-notify.py → salon ops (Synapse .24:8008)
|
||||
12-probes.sh --digest (cron root G9, 08:30)
|
||||
└─ 1 message récapitulatif
|
||||
11-registry.sh --check --alert (machine de pilotage)
|
||||
└─ dérive registre/réel → même payload JSON
|
||||
Beszel (hub J5040) ────────────────► métriques + état conteneurs (ne remplace pas les sondes)
|
||||
restic-notify.sh (cron root G9) ───► backup OK/KO (prose, déjà en place)
|
||||
```
|
||||
|
||||
## Contrôles
|
||||
|
||||
| Contrôle | Commande (G9, root) | Attendu |
|
||||
|---|---|---|
|
||||
| Passe de sondes | `/usr/local/bin/g9-probes.sh` | `13 OK / 0 KO` |
|
||||
| Provocation non destructive | `/usr/local/bin/g9-probes.sh --selftest` | `NOTIFY_OK 200` |
|
||||
| Conformité des payloads | `/usr/local/bin/g9-verify-alert.py 20` | `payloads JSON conformes: N`, `VERIFY_OK` |
|
||||
| Digest | `/usr/local/bin/g9-probes.sh --digest` | 1 message `source=digest` |
|
||||
| Cron | `sudo crontab -l \| grep -c g9-probes` | `2` |
|
||||
|
||||
## Symptômes → action
|
||||
|
||||
| Symptôme | Diagnostic | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| Plus aucune alerte | `sudo crontab -l`, `/var/lib/g9-probes` | `./12-probes.sh --install` (idempotent) | `--uninstall` |
|
||||
| `NOTIFY_KO HTTPError` | `sudo python3 /usr/local/bin/g9-verify-alert.py 5` (401 → token) | régénérer le token `ops-bot`, réécrire `/etc/restic/matrix.env` (600) | restaurer la copie datée du fichier |
|
||||
| Alerte répétée chaque sonde | seuil anti-bruit (1 h) + sévérité | remonter la sévérité à `medium` si c'est structurel (ex. endpoint LAN-only) | rétrograder |
|
||||
| Digest absent | logs cron, `date`/TZ | relancer `--digest` manuellement | aucun |
|
||||
| G9 éteint (sinistre) | Beszel hub sur **J5040** doit alerter quand même | c'est le critère C-AP5 : le monitoring ne vit pas sur la machine surveillée | — |
|
||||
|
||||
## Ce que le digest ne dit pas (volontairement)
|
||||
|
||||
Pas de métriques fines (Beszel), pas de logs (aller voir `docker logs`), pas de propositions
|
||||
d'action : les actions sont dans les `RUNBOOKS/`, et l'autonomie de l'assistante IA est bornée
|
||||
par **G11** (paliers).
|
||||
@@ -0,0 +1,23 @@
|
||||
# RUNBOOK — Jellyfin (+ stack *arr/qBittorrent sur .25 pour l'instant)
|
||||
|
||||
> Brique stateful, image épinglée `jellyfin/jellyfin:10.9`. Média servi par **référence**
|
||||
> (`/mnt/usb-media` → `/media:ro`, **aucune copie**). Port 8096 LAN-only, `media.folzocorp.fr` via Caddy.
|
||||
|
||||
## Sonde
|
||||
|
||||
`GET :8096/System/Info/Public` (200 — `/system/info` normal renvoie 401 sans jeton : ne pas s'en servir).
|
||||
|
||||
## Symptômes → diagnostic → action
|
||||
|
||||
| Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| « Invalid username or password » | `docker exec jellyfin sqlite3 /config/jellyfin.db "SELECT Username,EnableLocalPassword FROM Users;"` | **piège mesuré du wizard** : `EnableLocalPassword=0` → `UPDATE Users SET EnableLocalPassword=1` + restart du conteneur | `UPDATE Users SET EnableLocalPassword=0` |
|
||||
| API répond 400 « Value cannot be null » | appel sans en-tête | en-tête **combiné** `X-Emby-Authorization: Emby …` obligatoire (mesuré) | aucun |
|
||||
| Médiathèque vide | `GET /Library/VirtualFolders` + `ls /media/films` | supprimer les médiathèques vides doublons (`DELETE /Library/VirtualFolders?name=`) puis recréer sur `/media/…` | recréer la folder |
|
||||
| Média non listé après branchement USB | `mount | grep usb-media`, `06c-postboot-check.sh` | remonter (fstab) puis « Scan média library » | démonter |
|
||||
| Accès WAN KO | `curl -kI https://media.folzocorp.fr/` | côté J5040 : `docker logs caddy` ; vérifier ufw `ALLOW IN 8096 from 192.168.1.0/24` | retirer la règle ajoutée |
|
||||
|
||||
## Comptes
|
||||
|
||||
`g9ops` = opérateur (créé par le wizard). Les comptes enfants (tâche SORTIE DE GOOGLE) :
|
||||
rôles limités + restrictions de contenu, jamais le compte opérateur.
|
||||
@@ -0,0 +1,27 @@
|
||||
# RUNBOOK — Matrix (Synapse + Element)
|
||||
|
||||
> Brique stateful (base de messages). Image `matrixdotorg/synapse:latest` → **non épinglée**,
|
||||
> signalé par `./11-registry.sh` (à corriger lors de la prochaine MAJ : épingler par digest).
|
||||
> Usage décidé 12/10 : **complément du foyer** (salon famille + salon ops + Effi), pas de remplacement de WhatsApp.
|
||||
|
||||
## Sonde
|
||||
|
||||
`GET :8008/_matrix/client/versions` (200) ; Element `GET :8009/` (200) ; le salon ops reçoit les alertes.
|
||||
|
||||
## Symptômes → diagnostic → action
|
||||
|
||||
| Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| Pas de compte admin | `select name,admin,deactivated,case when password_hash is null then 0 else 1 end from users` (sqlite `mode=ro`) + login → « Invalid username or password » | **`scripts/26-synapse-admin-repair.sh`** (voie mesurée 12/10) : hash **bcrypt** du mot de passe écrit en base sur un compte admin → **`docker restart`** (caches) → login → **`PUT /_synapse/admin/v2/users/@<uid>`** `{password, admin:true}` → retirer l'admin temporaire. Voie officielle à réessayer : `docker exec synapse register_new_matrix_user -a -u <admin> -c /data/homeserver.yaml` (le secret est **déjà** dans `homeserver.yaml` ; `POST /_synapse/admin/v1/register` signé a répondu **`HMAC incorrect`**) | snapshot sqlite **avant** (`sqlite3 .backup` → `/data/homeserver.db.bak-*`) + `update users set admin=0, deactivated=1, password_hash=NULL where name='<temporaire>'` |
|
||||
| Création de compte refusée | `PUT /_synapse/admin/v2/users/@<uid>:192.168.1.24` (token admin) | **PUT** (pas POST) ; `scripts/27-synapse-family-users.sh` (`--dry`, `--revoke`) ; inscription publique à **couper** (`enable_registration: false`) une fois l'admin en place | `PUT … {"deactivated": true}` (conserve la trace) |
|
||||
| Alerte non reçue dans le salon | `sudo python3 /usr/local/bin/g9-verify-alert.py 20` ; `sudo bash /usr/local/bin/g9-probes.sh --selftest` | token/room dans `/etc/restic/matrix.env` (600) → régénérer le token `ops-bot` si révoqué | restaurer `/etc/restic/matrix.env` (backup daté) |
|
||||
| Salon famille absent | `GET /_synapse/admin/v1/rooms` (nb de salons) + résolution d'alias `GET /_matrix/client/v3/directory/room/%23famille%3A192.168.1.24` | `scripts/28-synapse-create-room.sh` (`--dry` → `--create` avec `CONFIRM=CREER`) : **`POST /_matrix/client/v3/createRoom`** — `/_synapse/admin/v1/create_room` répond **404 `M_UNRECOGNIZED`** sur cette version ; invites = `@ops`, `@ops-bot` | `--purge <room_id>` (`DELETE /_synapse/admin/v1/rooms/<id>`) |
|
||||
| Rate limit login (« Too Many Requests ») | réponse `M_LIMIT_EXCEEDED` + `retry_after_ms` sur `/_matrix/client/v3/login` | **attendre ~75 s** (compteur en mémoire, vidé par `docker restart synapse`) ; grouper les appels, 1 login par exécution | aucun (auto-rétablissement) |
|
||||
| E2E illisible sur un appareil | session/device inconnu | phrase de récupération E2E **notée sur papier** (procédure B) ; vérifier les devices via API admin | aucun (clé perdue = session perdue, d'où le papier) |
|
||||
| Fused de logs / disque | `du -sh /data/*` + logs Synapse | purge via `synapse_stitch_schemas`/`purge` documenté **après** backup | restauration restic |
|
||||
| TLS/ACME KO (matrix.folzocorp.fr) | `curl -kI https://matrix.folzocorp.fr/` | côté J5040 : `docker logs caddy --tail 30` + clé API OVH valide (`secrets/ovh.env`) | révoquer/recréer la clé OVH |
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Comptes famille : `admin:false`, 2FA sur les comptes admin.
|
||||
- Suppression de compte = **désactivation** (propre), jamais purge destructive sans backup (G2/G3).
|
||||
@@ -0,0 +1,27 @@
|
||||
# RUNBOOK — Nextcloud (+ MariaDB, Redis, Collabora)
|
||||
|
||||
> Brique stateful : **exclue de Watchtower**, MAJ manuelle (S11.5). Image épinglée `nextcloud:35.0.1`.
|
||||
> Incident de référence : 06/10/2026 — Watchtower non sélectif → migration de schéma interrompue →
|
||||
> **maintenance mode 503 alors que le conteneur était up** (leçon : `up ≠ utilisable`).
|
||||
|
||||
## Sonde
|
||||
|
||||
`./12-probes.sh` : `GET :8080/index.php/login` (2xx) + `occ status` doit contenir `installed: true`.
|
||||
|
||||
## Symptômes → diagnostic → action
|
||||
|
||||
| Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| 503 / « Maintenance mode » | `docker exec nextcloud php occ status` → `maintenance: true` | backup en place (G2) puis `php occ maintenance:mode --off` ; si schéma inachevé → restaurer la base depuis restic | remettre `maintenance:mode --on` |
|
||||
| Écran blanc / erreurs DB | `docker logs nextcloud --tail 50` ; `docker inspect nextcloud_db` (state) | relancer `nextcloud_db` puis `nextcloud` ; vérifier `secrets/dbpass` | `compose down` + `up -d` depuis le compose versionné |
|
||||
| Fichiers disparus de la liste | `php occ files:scan --all` (compter, 0 erreur) | scan complet (long, pas de suppression de données) | aucun (scan = lecture/écriture du filecache) |
|
||||
| Collabora « office indisponible » | `curl -s http://127.0.0.1:9980/hosting/discovery` (200) ; `php occ config:app:get richdocuments wopi_url` | discovery KO → relancer collabora ; wopi_url KO → `config:app:set richdocuments wopi_url --value=http://127.0.0.1:9980` | `occ config:app:set` avec l'ancienne valeur |
|
||||
| Redirect en http / boucle de login | `curl -kI https://cloud.folzocorp.fr/` + `php occ config:system:get overwritehost` | aligner `g9-double-run.config.php` (`overwritehost`, `overwriteprotocol=https`, `trusted_proxies`) : source de vérité du kit, monté `:ro` | retirer le fichier monté |
|
||||
| Login KO après import | `php occ user:list` (8 users attendus) | reset ciblé : `php occ user:set-password <uid>` | aucun (mot de passe consigné dans le coffre) |
|
||||
|
||||
## MAJ manuelle (procédure S11.5, jamais par Watchtower)
|
||||
|
||||
1. `restic daily` récent vérifié (`restic snapshots`) — **pas de MAJ stateful sans backup**.
|
||||
2. Épingler la version cible dans le compose (pas de `:latest`), commit dans Gitea.
|
||||
3. `docker compose up -d` → `php occ upgrade` → `php occ status` (`installed: true`, `maintenance: false`).
|
||||
4. `./12-probes.sh` + `./11-registry.sh` (le registre doit rester propre).
|
||||
@@ -0,0 +1,29 @@
|
||||
# RUNBOOK — PhotoPrism (+ MariaDB `photoprism_db`)
|
||||
|
||||
> Brique stateful, image épinglée `photoprism/photoprism:260919`. Photothèque **copiée** depuis
|
||||
> `/volume1/photo` (433 941 fichiers / 567 Go, manifeste 0 écart, échantillon sha256 200/200) ;
|
||||
> index progressif. Source .25 **jamais modifiée** (G4, lecture seule).
|
||||
|
||||
## Sonde
|
||||
|
||||
`GET :2342/` (200, suivre la redirection). Endpoint prévu : **`prisme.folzocorp.fr`**
|
||||
(`photos.folzocorp.fr` = Synology Photos, temporaire, meurt en Phase 6).
|
||||
|
||||
## Symptômes → diagnostic → action
|
||||
|
||||
| Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| UI 502 via le domaine | `curl -s http://127.0.0.1:2342/` puis `docker logs caddy` (J5040) | la route Caddy pointe le bon port (2342) ; ajouter/aligner le site `prisme.folzocorp.fr` | retirer la route Caddy |
|
||||
| Index bloqué / RAM | `docker stats --no-stream` (budget S2.4 : ~2-3 Go) | réduire la parallélisation d'indexation, relancer l'index ; ne pas toucher à la copie | aucun |
|
||||
| Photos manquantes dans l'UI | comparer le manifeste : `find /data/photos -type f \| wc -l` vs manifeste source | relancer l'index sur le lot manquant ; **ne jamais supprimer depuis l'UI** (la copie est la copie, la source .25 est la vérité tant que Phase 6 n'est pas validée) | aucun |
|
||||
| Base DB KO | `docker inspect photoprism_db`, logs | `compose up -d` depuis le compose versionné ; si corrompue → restaurer **la base** depuis restic (pas les photos) | restauration restic |
|
||||
| Auto-upload Android ne monte pas | app : statut d'upload + journal | vérifier compte/URL (`https://prisme.folzocorp.fr`), stockage Android, batterie optimisée | aucun |
|
||||
|
||||
## Preuve de restauration
|
||||
|
||||
`./10-verify-restore-photo.sh` (snapshot restic → sha256 comparé à la source .25) — critère 3, déjà prouvé 12/10.
|
||||
|
||||
## Interdits
|
||||
|
||||
Pas de suppression de photos depuis l'UI avant la fin du double-run ; pas de `rm` sur `/data/photos`
|
||||
sans backup vérifié (G2) ; pas de wipe du .25 (décision 12/10 : débrancher, disques intacts).
|
||||
@@ -0,0 +1,29 @@
|
||||
# RUNBOOK — Registre & dérive (registre, watchtower, edge)
|
||||
|
||||
> Le registre est **généré** : `./11-registry.sh` (lecture seule : `docker ps` + `docker inspect`
|
||||
> sur .24/.27 + compose du kit + Caddyfile). Un `git diff` sur `REGISTRE-BRIQUES.md` **est** la
|
||||
> détection de dérive.
|
||||
|
||||
## Contrôles
|
||||
|
||||
| Contrôle | Commande | Attendu |
|
||||
|---|---|---|
|
||||
| Régénération | `./11-registry.sh` | rc=0, table complète |
|
||||
| Dérive + alerte | `./11-registry.sh --check --alert` | rc=0 si propre ; rc=1 + `NOTIFY_OK 200` si dérive |
|
||||
| Scope Watchtower | `docker run --rm -v /var/run/docker.sock:ro <watchtower@digest> --label-enable --monitor-only -R` | `scanned=2` (edge + watchtower), les stateful ignorés |
|
||||
| Labels stateful | `docker inspect -f '{{.Name}} {{index .Config.Labels "com.centurylinklabs.watchtower.enable"}}' $(docker ps -q)` | `false` partout sauf edge/watchtower |
|
||||
| Images non épinglées | section « Constats automatiques » du registre | 0 ligne `image non épinglée` |
|
||||
|
||||
## Symptômes → action
|
||||
|
||||
| Constat | Action | Rollback |
|
||||
|---|---|---|
|
||||
| `label watchtower absent` sur un stateful | poser le label via le compose (source de vérité) + `compose up -d` ; conteneurs pilotés hors compose = `docker update --label-add` (indisponible sur docker 24.0.2 DSM → clonage via API socket, procédure journalisée 10/10) | restaurer le compose `.bak` |
|
||||
| `image non épinglée (:latest)` | épingler la version courante dans le compose, commit, `compose up -d` | revenir au compose précédent |
|
||||
| `conteneur réel non déclaré dans le kit` | écrire le compose correspondant (c'est la production : tout dans git) | supprimer le conteneur **après** validation utilisateur (G3) |
|
||||
| `edge` (Caddy G9) déclassé par le J5040 | garder tant que la bascule bridge n'est pas faite ; le retirer ensuite + retirer les règles ufw 443/80 du G9 | `ufw allow 443` + `compose up -d` |
|
||||
|
||||
## Anti-bruit
|
||||
|
||||
Une alerte par brique et par heure maximum (état dans `/var/lib/g9-probes`). Le digest quotidien
|
||||
(08:30) porte l'état complet ; les alertes immédiates sont réservées aux KO `high`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# RUNBOOK — Vaultwarden (coffre famille, brique la plus critique)
|
||||
|
||||
> Brique stateful, image épinglée `vaultwarden/server:1.37.4`. Coffre = `db.sqlite3` (8.8 Mo,
|
||||
> 953 ciphers, 1 user), **déjà dans les snapshots restic**. 2FA TOTP actif (importé de la source).
|
||||
> Console admin : `ADMIN_TOKEN` **haché argon2id**. `EMERGENCY_ACCESS_ALLOWED=true`, `INVITATIONS_ALLOWED=true`.
|
||||
> **Règle durable** : le coffre ne doit pas dépendre d'une brique qu'il protège → il ne vit pas dans Nextcloud.
|
||||
|
||||
## Sonde
|
||||
|
||||
`GET :8081/alive` (200). WebVault = `GET :8081/` (200).
|
||||
|
||||
## Symptômes → diagnostic → action
|
||||
|
||||
| Symptôme | Diagnostic (lecture seule) | Action | Rollback |
|
||||
|---|---|---|---|
|
||||
| `/alive` KO | `docker logs vaultwarden --tail 30` | `docker compose up -d` depuis le compose versionné | `compose down` |
|
||||
| Login admin refusé | `POST /admin/register` (token) | token = entrée `infra/vaultwarden/admin-token` ; si perdu : régénérer un hash argon2id (`python3-argon2`, preset bitwarden) dans `secrets/admin` (600) + restart | restaurer `secrets/admin` + restart |
|
||||
| Utilisateur bloqué (2FA) | table `twofactor` (`atype`, `enabled`, `last_used`) | **ne pas réinitialiser le TOTP à l'aveugle** : l'app d'authentification de l'utilisateur continue de marcher tant que la graine n'est pas régénérée | restaurer la ligne `twofactor` depuis backup |
|
||||
| Coffre corrompu | `sqlite3 db.sqlite3 "PRAGMA integrity_check;"` (après **copy** du fichier) | restaurer `db.sqlite3` depuis restic ; **jamais** d'écriture directe sur la base en service | aucun |
|
||||
| Emergency access à activer | UI admin → Emergency | créer la demande parent → enfant (16/13), valider côté enfant | supprimer la demande |
|
||||
| Clients Bitwarden KO (ados) | `curl -kI https://pass.folzocorp.fr/` | DOMAIN conservé = `pass.folzocorp.fr` (les clients existants restent valides) — vérifier la route Caddy | aucun |
|
||||
|
||||
## Interdits
|
||||
|
||||
- Jamais de `rm` sur `/data/docker/vaultwarden` sans backup vérifié (G2) — c'est le coffre des 6.
|
||||
- Jamais le mot de passe maître dans un fichier, un commit, un log (G9).
|
||||
- La récupération en l'absence de l'opérateur passe par `PARTAGE-SECRET-MAITRE.md`, pas par l'admin console.
|
||||
@@ -0,0 +1,18 @@
|
||||
# AdGuard Home — E2 (J5040 .27 → .1) : DNS filtré LAN (migration depuis VM Freebox Delta).
|
||||
# Stateless (config = AdGuardHome.yaml versionnée dans le kit) → Watchtower ON (S11.4).
|
||||
# Image épinglée par digest. UI = 8081 LAN-only (ufw), DNS = 53 tcp/udp LAN-only.
|
||||
services:
|
||||
adguard:
|
||||
image: adguard/adguardhome@sha256:aba9e3bf0613be3ba3755e1fc311b126e2c24bec25e18b6483894a88283074f0
|
||||
container_name: adguard
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
volumes:
|
||||
- /srv/adguard/work:/work
|
||||
- /srv/adguard/data:/opt/adguardhome/conf
|
||||
- /srv/adguard/state:/opt/adguardhome/work
|
||||
ports:
|
||||
- "0.0.0.0:53:53/tcp"
|
||||
- "0.0.0.0:53:53/udp"
|
||||
- "0.0.0.0:8081:80/tcp"
|
||||
@@ -0,0 +1,21 @@
|
||||
# Beszel hub — monitoring résilient (C-AP5).
|
||||
# Le hub vit sur le J5040, PAS sur le G9 : une surveillance qui meurt avec la machine
|
||||
# surveillée ne surveille rien (trou mesuré 12/10 : les sondes tournent depuis le G9).
|
||||
# Image épinglée par digest ; stateful (sa base est dans ./data) ; hors Watchtower.
|
||||
name: beszel-hub
|
||||
|
||||
services:
|
||||
hub:
|
||||
image: henrygd/beszel@sha256:6b39a8b64f9b15d00a25e7683e31a93c46161182a54f62713525107064e10599
|
||||
container_name: beszel-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Publié sur toutes les interfaces, mais ufw n'autorise que le LAN (règle ajoutée
|
||||
# à l'installation) : le hub n'est jamais joignable depuis le WAN.
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- /home/folzo/beszel-hub/data:/pb_data
|
||||
environment:
|
||||
- "PUBLIC_URL=http://192.168.1.27:8090"
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
email admin@folzocorp.fr
|
||||
# ACME staging désactivé = certs de production
|
||||
}
|
||||
|
||||
(ovh_tls) {
|
||||
tls {
|
||||
dns ovh
|
||||
}
|
||||
}
|
||||
|
||||
(folzocorp) {
|
||||
import ovh_tls
|
||||
encode gzip
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
}
|
||||
|
||||
cloud.folzocorp.fr {
|
||||
import folzocorp
|
||||
redir 308 {
|
||||
/ /login*
|
||||
}
|
||||
reverse_proxy 192.168.1.24:8080
|
||||
}
|
||||
|
||||
pass.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:8081
|
||||
}
|
||||
|
||||
git.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:3000
|
||||
}
|
||||
|
||||
media.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:8096
|
||||
}
|
||||
|
||||
photos.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:2342
|
||||
}
|
||||
|
||||
n8n.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:5678
|
||||
}
|
||||
|
||||
matrix.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.24:8008
|
||||
}
|
||||
|
||||
openfox.folzocorp.fr {
|
||||
import folzocorp
|
||||
reverse_proxy 192.168.1.11:10369
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Edge J5040 (E4) : Caddy + plugin DNS OVH = Let's Encrypt par DNS-01 (achève T4.6/S9.1).
|
||||
# Image épinglée (caddy 2.8.4 + dns.providers.ovh). Stateless → Watchtower ON.
|
||||
# Remplace l'edge du G9 (CA interne) après bascule vérifiée. Secrets = ./secrets/ovh.env (600, hors git).
|
||||
services:
|
||||
caddy:
|
||||
image: maxcryger/caddy-ovh@sha256:502996b5b60aec3ee565324143c14ee4a0c99ff55ace167629981c6c2f2eb597
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
env_file:
|
||||
- ./secrets/ovh.env
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
ports:
|
||||
- "0.0.0.0:80:80/tcp"
|
||||
- "0.0.0.0:443:443/tcp"
|
||||
- "0.0.0.0:443:443/udp"
|
||||
healthcheck:
|
||||
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -0,0 +1,85 @@
|
||||
name: g9-queue
|
||||
services:
|
||||
n8n:
|
||||
image: docker.n8n.io/n8nio/n8n:latest
|
||||
container_name: n8n
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5678:5678"
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
environment:
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_BASIC_AUTH_ACTIVE=false
|
||||
- N8N_BASIC_AUTH_USER=${N8N_USER}
|
||||
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
|
||||
- N8N_SECURE_COOKIE=false
|
||||
- N8N_HOST=192.168.1.24
|
||||
- N8N_PROTOCOL=http
|
||||
- DB_TYPE=sqlite
|
||||
- DB_SQLITE_VACUUM_ON_ISSUE=true
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- GENERIC_TIMEZONE=Europe/Paris
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- ./n8n/n8n-data:/home/node/.n8n
|
||||
networks:
|
||||
- default
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
couchdb:
|
||||
image: couchdb:3.3
|
||||
container_name: couchdb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5984:5984"
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
environment:
|
||||
- COUCHDB_USER=thib
|
||||
- COUCHDB_PASSWORD=${COUCHDB_PASSWORD}
|
||||
volumes:
|
||||
- ./couchdb-data:/opt/couchdb/data
|
||||
networks:
|
||||
- default
|
||||
|
||||
obsidian:
|
||||
image: lscr.io/linuxserver/obsidian:latest
|
||||
container_name: obsidian
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3000"
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- ./obsidian-vault:/vault
|
||||
- ./config:/config
|
||||
networks:
|
||||
- default
|
||||
|
||||
watchtower:
|
||||
image: nickfedor/watchtower@sha256:45b54f6dc15227cfb38dbb826f2881aa0f56aaf0c255cdbb38bd80e598bdca9a
|
||||
container_name: watchtower
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
environment:
|
||||
- WATCHTOWER_CLEANUP=false
|
||||
- WATCHTOWER_INCLUDE_STOPPED=false
|
||||
- WATCHTOWER_POLL_INTERVAL=3600
|
||||
- WATCHTOWER_LABEL_ENABLE=true
|
||||
- WATCHTOWER_NOTIFICATION_URL=generic+http://n8n:5678/webhook/watchtower
|
||||
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./watchtower/notify.sh:/scripts/notify.sh:ro
|
||||
- ./watchtower/log:/data
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: g9-queue
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
synapse:
|
||||
image: matrixdotorg/synapse:latest
|
||||
container_name: synapse
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
ports:
|
||||
- "8008:8008"
|
||||
environment:
|
||||
- SYNAPSE_SERVER_NAME=192.168.1.24
|
||||
- SYNAPSE_REPORT_STATS=no
|
||||
volumes:
|
||||
- ./data:/data
|
||||
element:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: element
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
ports:
|
||||
- "8009:80"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
valheim:
|
||||
image: valheim-pterodactyl:1
|
||||
container_name: valheim
|
||||
user: "1000:1000"
|
||||
ports:
|
||||
- "2456-2458:2456-2458/tcp"
|
||||
- "2456-2458:2456-2458/udp"
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- STARTUP=true
|
||||
- SERVER_MEMORY=2048
|
||||
- SERVER_IP=192.168.1.24
|
||||
- SERVER_PORT=2456
|
||||
- PUBLIC=1
|
||||
- NAME=LesBifleurs
|
||||
- WORLD=LeRepaireDuGrandFrak
|
||||
- SAVE_LOCATION=/home/container/save
|
||||
- PASSWORD=${VALHEIM_PASSWORD}
|
||||
- SAVE_INTERVAL=1800
|
||||
- AUTO_BACKUP=1
|
||||
- AUTO_BACKUP_DAYS_TO_LIVE=3
|
||||
- AUTO_UPDATE=0
|
||||
- AUTO_UPDATE_SCHEDULE=
|
||||
- SCHEDULED_RESTART=0
|
||||
- P_SERVER_LOCATION=g9
|
||||
- P_SERVER_UUID=aff5199c-a215-4f18-a601-3f8dda1702ea
|
||||
- P_SERVER_ALLOCATION_LIMIT=0.000000
|
||||
volumes:
|
||||
- ../data:/home/container
|
||||
mem_limit: 2048m
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
@@ -0,0 +1,22 @@
|
||||
# WireGuard — E4 (J5040) : accès VPN famille (portable). LAN-only (ufw 51820 udp WAN).
|
||||
# Config client générée à l'activation (wg genkey, QR via `wg show`); secrets hors git.
|
||||
services:
|
||||
wireguard:
|
||||
image: lscr.io/linuxserver/wireguard@sha256:437a490f7afc6969434251351ba47455a3829e71cef9c6172f3f533332d04063
|
||||
container_name: wireguard
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
- net.ipv6.conf.all.forwarding=1
|
||||
environment:
|
||||
- WG_HOST=folzocorp.fr
|
||||
- WG_PORT=51820
|
||||
volumes:
|
||||
- ./wg:/etc/wireguard
|
||||
ports:
|
||||
- "0.0.0.0:51820:51820/udp"
|
||||
@@ -103,3 +103,66 @@
|
||||
| 2026-10-11 13:50 | 4 | edge : déploiement Caddy (T4.6) | Reverse proxy LAN + HTTPS interne (CA interne) pour vaultwarden/nextcloud/gitea/jellyfin/n8n/matrix | Caddyfile validate OK ; conteneur edge up ; ufw 443/tcp exposé WAN (seul), 80 redirect, 9980 LAN-only ; HTTPS+SNI vérifiés sur les 5 briques | docker compose -f /data/docker/edge/docker-compose.yml down ; ufw delete allow 443 (briques joignables en http direct LAN) |
|
||||
| 2026-10-11 14:10 | 4 (T4.6) | edge : Caddy déployé (reverse proxy LAN + HTTPS interne) | Briquage T4.6 : TLS interne pour vaultwarden/nextcloud/gitea/jellyfin/n8n/matrix, sous-domaines déjà configurés côté apps (DOMAIN/ROOT_URL/N8N_HOST) → aucune brique touchée | `caddy:2` (digest f2a1290d…) up healthy (healthcheck = `caddy validate`) ; **tls internal** = CA interne (LE + DNS challenge = suite S9.1 via AdGuard J5040) ; **PIÈGES mesurés** : (1) snippet `(name)` importé → « ambiguous site definition » si utilisé comme adresse ; (2) `request_id` nest PAS un directive site (validate KO) ; (3) Caddy logue les en-têtes **reçus du client**, pas ceux envoyés en amont → preuve X-Forwarded prise côté app ; ufw 443/tcp exposé WAN (seul, default deny protège les ports briques) + 80 redirect + 9980 LAN-only ; **Nextcloud** : merge config = `array_replace_recursive` (le dépôt dun 2e fichier *.config.php a été avalé par lentrée existante → résolu en écrivant g9-double-run.config.php = source de vérité du kit, monté :ro) → occ : overwritehost=cloud.folzocorp.fr, overwriteprotocol=https, trusted_proxies=[192.168.1.0/24, 172.16.0.0/12] ; **preuve côté app** : redirect `https://cloud.folzocorp.fr/` → Location `https://cloud.folzocorp.fr/index.php/login` (schéma https + host forwardé rendus par Nextcloud) ; curl -k via edge : pass 200 / git 200 / cloud 200 / media 200 / n8n 200 / matrix 302 / SNI IP 200 ; vaultwarden TRUSTED_HOSTS += 172.16.0.0/12 (aligné) | `docker compose -f /data/docker/edge/docker-compose.yml down` ; `ufw delete allow 443` ; supprimer g9-double-run.config.php (retour double-run http LAN) |
|
||||
| 2026-10-11 14:20 | 4 (T4.7) | watchtower : vérification scope stateless + dry-run | S11.4 : MAJ auto uniquement des briques stateless ; leçon incident Nextcloud 06/10 = CLEANUP=false | Conteneur up healthy, image **nickfedor/watchtower épinglée par digest** sha256:45b54f6d… (fork : containrrr/watchtower:1 n'existe pas) ; env mesuré = POLL=3600, LABEL_ENABLE=true, CLEANUP=false, INCLUDE_STOPPED=false ; **labels vérifiés sur les 16 conteneurs .24** : stateful enable=false = nextcloud, collabora, jellyfin, nextcloud_db, nextcloud_redis, vaultwarden, gitea, gitea-db, valheim, n8n, element, synapse, couchdb, obsidian ; enable=true = edge (stateless, T4.6) + watchtower ; **dry-run** `docker run --rm -v /var/run/docker.sock nickfedor/watchtower@sha256:45b54f6d… --label-enable --monitor-only -R` → « Only checking containers with label … enable », **scanned=2 updated=0 failed=0** = les 14 stateful sont ignorés ; notification : webhook n8n testé en POST depuis le réseau Docker → **200** (le compose affichait un placeholder https://n8n.folzocorp.fr → aligné sur generic+http://n8n:5678, nom de service : l'edge n'écoute que 443) ; nickfedor : SCHEDULE et POLL exclusifs, pas de --once (=-R) | `docker compose -f /data/docker/watchtower/docker-compose.yml down` ; aucun effet sur les briques (label-driven) |
|
||||
| 2026-10-11 14:25 | 4 (clôture) | Phase 4 consolidée + kit poussé sur Gitea | Clôture Phase 4 (critère 5) | `PHASE4-RECOLLEMENT.md` §0 ajouté : briques déployées + versions épinglées (vaultwarden 1.37.4, nextcloud 35.0.1/mariadb 10.11/redis 7/collabora 26.04.5.1.1, gitea 1.27.3/postgres 16, jellyfin 10.9.11, caddy 2 digest f2a1290d, watchtower digest 45b54f6d), **6 incidents + correctifs mesurés**, alignement proxy (trusted_proxies/overwrite/TRUSTED_HOSTS/ufw 443 seul WAN), état .25 (services relancés, dumps supprimés, rien de destructif) ; PhotoPrism T4.3 = **reportée** (script 07d prêt, copie 530 Go) ; README aligné (07e + compose/edge + jellyfin T4.5) ; **push gitea master : HEAD local 366c5ca == gitea/master 366c5ca** (API Gitea vérifiée) ; vault Obsidian = repo `g9ops/obsidian-vault` présent (47 Mo, import T4.4) ; **zéro secret dans git** : `git ls-files` (secrets/*.env/token/key/cred) = 0 fichier de secret, `git grep` des valeurs (token, hash jellyfin, Pw=) = 0, historique `-S token` = 0 ; durcissement : token retiré de l'URL du remote → `~/.git-credentials` 600 + credential.helper store | `git push gitea a1864c6:master` (fast-forward annulable) ; suppression de ~/.git-credentials |
|
||||
| 2026-10-11 15:49 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||
| 2026-10-11 15:56 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||
| 2026-10-11 16:21 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||
| 2026-10-11 16:10 | J5040 (E1) | durcissement J5040 .27 (01-hardening paramétré) | E1 : edge Ubuntu 26.04 neuf, RAM 8 Go test (décision utilisateur) | SSH keys-only + ufw actif (LAN-only 53/3000/8081 + WAN 80/443/51820) + unattended-upgrades + fail2ban ; sudo NOPASSWD folzo ; clés SSH pilot installées ; portable à ajouter via ssh-copy-id | restaurer sshd_config .bak + ufw disable |
|
||||
| 2026-10-11 16:12 | J5040 (E1) | Docker + compose installés sur .27 | Runtime edge | docker 29.1.3 + compose 2.40.3, folzo dans groupe docker | apt-get remove docker.io docker-compose-v2 |
|
||||
| 2026-10-11 16:14 | 07d : copie SANS gzip (photos déjà compressées) | gzip sur CPU NAS = 10-13 Mo/s mesuré → flux brut ~17 Mo/s | tar c|x (sans compression), copie relancée propre (originals purgés, rien de destructif sur .25) | restaurer le script depuis git |
|
||||
| 2026-10-11 16:20 | J5040 (E2) | AdGuard Home déployé sur .27 (migration depuis VM Freebox .1) | E2 : DNS filtré sur l'edge | config VM récupérée en LECTURE SEULE (ssh master@.1, AdGuardHome.yaml 415 l. : 2 filtres 182128+6540 règles, safe_search, services bloqués parents, users bcrypt conservé) ; image épinglée digest aba9e3bf ; UI 8081 LAN, DNS 53 LAN ; systemd-resolved désactivé sur .27 ; tests : doubleclick→0.0.0.0, example.org résolu, UI 302 | docker compose down -v ; VM .1 intacte ; resolv.conf .27 restauré |
|
||||
| 2026-10-11 17:05 | J5040 (E3) | Préparation DHCP : baux statiques récupérés (38 appareils, capture Freebox via OCR utilisateur) + /srv/dnsmasq/leases (35 entrées, .1 réservé E4, VM adguard exclue) + 09-j5040-dhcp.sh | E3 : pré-bascule bridge, G6 = baux identiques (mêmes IP) | dnsmasq --test = syntax OK ; scan ARP cross-check (g9 48:ea:62, j5040 60:be:b4, nas 10:e7:c6, backup-218 .26 00:11:32) ; anomalie relevée : VM debian 02:11:32 en conflit sur .24 → à repositionner .23 côté Freebox (utilisateur) | rien (préparation, dnsmasq non démarré) |
|
||||
| 2026-10-11 17:15 | J5040 (E2) | Bascule DNS : Freebox DHCP DNS1 .1 → .27 (GO utilisateur) + bail G9 renouvelé | E2 : AdGuard J5040 remplace la VM Freebox | resolvectl G9 = DNS .27 ; doubleclick→0.0.0.0, example.org résolu ; clients = renouvellement de bail progressif (baux 12 h) ; VM .1 conservée en secours (arrêt = action utilisateur G3 après stabilité) ; .254 freebox intact | remettre DNS1=.1 dans la Freebox (VM intacte, rollback immédiat) |
|
||||
| 2026-10-11 18:05 | J5040 (E4) | Clé API OVH du NAS retrouvée (LE env, longueurs 16/32/32 OK) mais NON SOUSCRITE/EXPIRÉE (test /auth/credential → 404) | E4 : LE DNS-01 nécessite clé valide | secret copié en 600 sur J5040 (compose/edge-j5040/secrets/ovh.env, hors git) ; NAS : renewal LE cassée depuis expiration → nouvelle clé requise (droits API legacy /domain/zone/folzocorp.fr/*, utilisateur) | rien |
|
||||
| 2026-10-11 18:40 | J5040 (E4) | Nouvelle clé API OVH créée+souscrite (droits legacy /domain/zone) ; clé NAS expirée confirmée (90 j) | E4 : LE DNS-01 | python-ovh GET /domain/zone/folzocorp.fr/record = 12 records OK ; secrets 600 sur J5040 (ovh.env hors git) ; NAS : mettra la nouvelle clé lors du renouvellement LE | révoquer la clé dans manager OVH |
|
||||
| 2026-10-11 18:45 | J5040 (E4) | Caddy OVH déployé sur .27 : 7/7 certs Let's Encrypt émis (DNS-01) + proxy 6 briques | E4 : TLS auto achève T4.6/S9.1 | maxcryger/caddy-ovh digest 502996b5 healthy ; cloud 302/pass 200/git 200/media 302/photos 502 (stack en copie)/n8n 200/matrix 302, issuer = Let's Encrypt ; WAN (forward Freebox→G9) et démontage edge G9 = après bascule bridge E3 | docker compose down ; révoquer clé |
|
||||
| 2026-10-11 18:10 | 07d : copie parallèle 4 flux (tar par dossier racine) | mono-flux SSH plafonné ~13 Mo/s (lecture HDD NAS, CPU NAS 7% = non saturé) | 29 dirs, xargs -P4 ; + chmod u+rwX post-copie ; intégrité/stack/index = 07d SKIP_COPY=1 | rien (source .25 lecture seule) |
|
||||
| 2026-10-11 21:30 | 4 | décision chiffrement restic MAINTENU | Critère 3 : arbitrage chiffrement vs simplicité restore | Décision utilisateur : chiffrement restic conservé, clé de repo locale G9 (/etc/restic/repo.key 600, sauvegardée hors repo git), restore simple via sudo restic sur le G9 ; test restore photo prévu par scripts/10-verify-restore-photo.sh | révoquer/cloner la clé si changement de politique |
|
||||
| 2026-10-12 13:40 | 4 | migrate:photoprism : backup restic pré-migration | Garde-fou G2 avant import PhotoPrism | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||
| 2026-10-12 14:06 | 4 | migrate:photoprism : intégration PhotoPrism | copie de 530 Go à vérifier avant indexation | manifeste chemins+tailles identique (433941 fichiers, 567394182751 o) + échantillon sha256 200/200 | re-copie ciblée via tar/ssh |
|
||||
| 2026-10-12 14:11 | 4 | migrate:photoprism : intégration PhotoPrism | copie de 530 Go à vérifier avant indexation | manifeste chemins+tailles identique (433941 fichiers, 567394182751 o) + échantillon sha256 200/200 | re-copie ciblée via tar/ssh |
|
||||
| 2026-10-12 14:17 | 4 | migrate:photoprism : intégration PhotoPrism | copie de 530 Go à vérifier avant indexation | manifeste chemins+tailles identique (433941 fichiers, 567394182751 o) + échantillon sha256 200/200 | re-copie ciblée via tar/ssh |
|
||||
| 2026-10-12 14:18 | 4 | migrate:photoprism : stack PhotoPrism déployée + indexation lancée | T4.3 : stack saine, index progressif (photothèque 530 Go sur le G9) | image épinglée 260919, MariaDB 10.11 (watchtower OFF), secrets via env_file 600, port 2342 LAN ; originals = copie locale | docker compose down + rm -rf /data/docker/photoprism |
|
||||
| 2026-10-12 15:07 | 4 | test restauration photo depuis restic | Critère 3 : preuve restore du snapshot incluant les photos | snapshot 06a7e3b89ef3e97d5f5c1dc9098496fab8ad79d48f4455eabdde07255143e33b contient 505836 entrées sous /data/docker/photoprism/originals ; restore 1 fichier ; sha256 restauré == source .25 (00cbfed8da81…) ; chiffrement maintenu (clé locale G9) | rm -rf /tmp/restore-photo-test (fait) |
|
||||
| 2026-10-12 14:17 | 4.5 | registre : `11-registry.sh` génère la moitié technique | T-A.1 : inventaire **généré** (pas de saisie manuelle) | `REGISTRE-BRIQUES.md` : **21 conteneurs** (.24+.27), endpoints Caddy mappés, images/labels lus par `docker ps`/`inspect` en **lecture seule** ; **13 constats** réels (4 images `:latest` non épinglées : n8n, element, synapse, obsidian ; 6 conteneurs non déclarés dans le kit : n8n, element, synapse, couchdb, obsidian, valheim ; 1 conteneur inconnu `sweet_feynman` .27 **sans label Watchtower**) | `git rm REGISTRE-BRIQUES.md scripts/11-registry.sh scripts/registry_*.py` (aucun effet sur les briques) |
|
||||
| 2026-10-12 14:27 | 4.5 | sondes applicatives : `12-probes.sh` installé sur le G9 (cron root) | T-C.1/C.2/C.5 : le trou mesuré est `up ≠ utilisable` (5 incidents de la semaine avec conteneur up) | install `/usr/local/bin/g9-probes.sh` + **2 lignes cron** (`*/15` + digest 08:30) ; passe = **13 OK / 0 KO** (nextcloud login + `occ status`, vaultwarden /alive, gitea /api/v1/version, jellyfin /System/Info/Public, photoprism /, collabora discovery, n8n healthz, synapse versions, element, edge TCP 443, cloud WAN 200, AdGuard UI) ; **`--selftest` → `NOTIFY_OK 200`** (KO provoqué sans toucher aucune brique) ; `g9-verify-alert.py 20` → **payloads JSON conformes: 1 / non conformes: 0, VERIFY_OK** | `sudo bash /usr/local/bin/g9-probes.sh --uninstall` (cron retiré, script + état supprimés) |
|
||||
| 2026-10-12 14:40 | 4.5 | docs identité : `REGISTRE-IDENTITES.md` (moitié humaine) | T-A.2/A.3/A.4/A.8/A.9/A.11 : « quel compte a servi ? » | table par brique (.24 + .27) : compte, type, 2FA, **référence** d'entrée coffre, **comment réinitialiser**, responsable + 4 procédures de récupération (n8n owner, Gitea must_change_password, Jellyfin EnableLocalPassword, Synapse sans admin) + convention coffre `infra/`/`famille/`/`enfants/` + hygiène (`~/.git-credentials` en clair traité en T-A.5) ; **0 secret** (références seules) | `git rm REGISTRE-IDENTITES.md` |
|
||||
| 2026-10-12 14:45 | 4.5 | docs : `RUNBOOKS/` ×7, `PARTAGE-SECRET-MAITRE.md`, `RESTORE-NAS-USB.md` | T-C.3 (runbooks = matière première de l'IA, G11) + T-A.9 + T6.5 | 7 runbooks (nextcloud, matrix, jellyfin, photoprism, vaultwarden, registre, digest) sur les **pièges mesurés** du journal ; procédure de partage du secret maître (détenteurs à nommer, enveloppe, test annuel — **test non réalisé**) ; procédure lecture disques DSM hors boîtier (mdadm/LVM, **lecture seule**, interdits — **test non réalisé**, critère Phase 6) | `git rm -r RUNBOOKS PARTAGE-SECRET-MAITRE.md RESTORE-NAS-USB.md` |
|
||||
| 2026-10-12 14:50 | 4.5 | études : `EXPOSITION-INTERNET.md` + `BRIDGE-WHATSAPP.md` | T-D (étude, 0 règle) + T-W (étude, 0 install) | exposition **mesurée** (`ufw status numbered` .24/.27, `ss -ltnu` .27) : constats — **22/tcp et 443/tcp ouverts au WAN sur le G9 sans nécessité**, edge G9 en TLS auto-signé, Tailscale actif sur le pilot (voie non documentée), CouchDB 5984 LAN sans auth ; grille par brique + playbook classé 8 rangs + **5 décisions à rendre** ; **0 règle posée**. Pont WhatsApp : AGPL-3.0, session QR/12 jours, **risque de bannissement** → numéro familial interdit, **0 installation** | `git rm EXPOSITION-INTERNET.md BRIDGE-WHATSAPP.md` (aucun changement réseau) |
|
||||
| 2026-10-12 14:55 | 4.5 | `08-decommission.sh` : **texte aligné** sur son comportement | T-A.6 : l'en-tête annonçait « ARRÊTE et WIPE » + saisie `WIPE XPENOLOGY` alors que le corps fait arrêt + consignes GUI/débranchement (wipe optionnel) | en-tête = « aucun wipe exécuté par le kit », prereq = « éteint et débranché, disques intacts, 0 effacement », confirmation = `ETEINDRE XPENOLOGY`, étape 3 = étiqueter/conserver + pointeur `RESTORE-NAS-USB.md`, journal rollback = « rebrancher (données intactes) ». **Corps du script inchangé** (mêmes commandes, même gates) | `git checkout scripts/08-decommission.sh` |
|
||||
| 2026-10-12 15:00 | 4.5 | charte **G11** + docs `DOSSIER-SOFTWARE.md` v1.6 | G11 (paliers IA) + S8/S9.2/S11/S14 alignés sur les décisions 12/10 | README : G11 (L0→L3, portes chiffrées, interdits, traçabilité) + arborescence (11/12/g9-notify/g9-verify-alert/lib-notify) + docs d'identité/observabilité ; S8 = **complément** (plus « remplace WhatsApp »), S9.2 = `pass`/`prisme`/`photos` alignés, S11 = registre + sondes, S14.7→S14.10 (coffre, identité, suivi, IA) | `git checkout DOSSIER-SOFTWARE.md g9-migration/README.md` |
|
||||
| 2026-10-12 15:20 | 4.5 | sondes applicatives installées sur le G9 (`g9-probes.sh`) | C-AP1, C-AP3, C-AP4 : **13 checks / 0 KO**, 15 min, anti-bruit | `install -m 0755 root /usr/local/bin/g9-probes.sh` + **2 lignes ajoutées à la crontab root** (`*/15 * * * *` sondes, `30 8 * * *` digest) ; crontab sauvegardée avant (`/root/crontab.backup-20261012-1520`) ; état dans `/var/lib/g9-probes/`. Checks KO provoqués : jellyfin 401 → `/System/Info/Public`, photoprism 307 → `curl -L`, gitea 404 → `/api/v1/version`, edge G9 000 → **probe TCP 443** (SNI absent), edge WAN `/login` 404 → `/`, adguard `/api/stats` 401 → `/` | `sudo crontab -r` puis `sudo crontab /root/crontab.backup-20261012-1520` ; `sudo rm -f /usr/local/bin/g9-probes.sh /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` ; `sudo rm -rf /var/lib/g9-probes` |
|
||||
| 2026-10-12 15:25 | 4.5 | **chaîne de notification vérifiée de bout en bout** | C-AP2, C-AP3 : payload **structuré** reçu, **auto-vérifié** | `g9-notify.py` et `g9-verify-alert.py` installés en `/usr/local/bin` (root 755) ; 2 bugs corrigés : **préfixe `Bearer ` manquant** et **room URL-encodé** (`!…` → `%21…`) → `NOTIFY_OK 200` ; `g9-verify-alert.py 20` → `payloads JSON conformes: 1`, `VERIFY_OK`. 3e ligne de test auto-supprimée par `g9-verify-alert.py` (selftest). `lib-notify.sh` : payload en **argument** (sudo sans SETENV, `sudo -E` inutilisable) | `sudo rm -f /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` (aucun fichier G9 modifié par ailleurs) |
|
||||
| 2026-10-12 15:30 | 4.5 | **dérive registre prouvée de bout en bout** | C-ID5 : dérive → `rc=1` + **payload JSON dans le salon ops** | source épinglée jellyfin modifiée `10.9`→`10.8` → `11-registry.sh --check --alert` = `DÉRIVE détectée` + `NOTIFY_OK 200` + `rc=1` ; source revertée + doc régénéré + commit → `aucune dérive (21 conteneurs, 13 constats)` + `rc=0`. 2 bugs corrigés au passage : **`set -e` + `[[ ]] && cmd`** qui stoppait le script silencieusement, et **comparation qui incluait la ligne horodatée** (dérive permanente) | `sed -i 's/10.8/10.9/' scripts/registry_inputs.py` puis `git checkout scripts/registry_inputs.py` |
|
||||
| 2026-10-12 15:20 | 4.5 | **chaîne de notification** installée et vérifiée de bout en bout sur le G9 | C-AP2, C-AP3 : payload **structuré** reçu, **auto-vérifié** | `g9-notify.py` + `g9-verify-alert.py` installés en `/usr/local/bin` (root 755, sauvegardes dans `/root/openfox-backup-20261012/`) ; 2 bugs corrigés : **préfixe `Bearer ` manquant** et **room URL-encodée** (`!…` → `%21…`) → `NOTIFY_OK 200` ; `g9-verify-alert.py 20` → `payloads JSON conformes: 1`, `VERIFY_OK` (les 9 lignes de test des sessions précédentes **auto-supprimées**) ; `lib-notify.sh` : payload en **argument** (`sudo` sans `SETENV` → `sudo -E` inutilisable) | `sudo rm -f /usr/local/bin/g9-notify.py /usr/local/bin/g9-verify-alert.py` + `cp /root/openfox-backup-20261012/*` si besoin (aucun fichier G9 modifié par ailleurs) |
|
||||
| 2026-10-12 15:25 | 4.5 | **dérive du registre prouvée de bout en bout** + runbook `registre.md` exécuté | C-ID5, C-AP3 : dérive → `rc=1` + **payload JSON dans le salon ops** ; runbook exécuté sur un KO simulé | source épinglée jellyfin modifiée `10.9`→`10.8` → `11-registry.sh --check --alert` = `DÉRIVE détectée` + `NOTIFY_OK 200` + `rc=1` ; **runbook suivi** : message lu (`runbook: RUNBOOKS/registre.md`) → `--check` → lecture du diff → cause identifiée = source du kit, pas le système → action = revert → `aucune dérive (21 conteneurs, 13 constats)` `rc=0`. 2 bugs corrigés : **`set -e` + `[[ ]] && cmd`** (script stoppé silencieusement) et **comparaison incluant la ligne horodatée** (dérive permanente) | `sed -i 's/10.8/10.9/' scripts/registry_inputs.py` puis `git checkout scripts/registry_inputs.py` ; rien sur le G9 |
|
||||
| 2026-10-12 15:40 | 4.5 | **validation utilisateur tracée** pour les actions L3 sur le G9 (G11) | C-G11 : aucune exécution L2/L3 sans validation tracée | GO explicite de l'utilisateur sur le choix « je valide les sondes + digest sur le G9 (trace L3) » → les 2 lignes de crontab root (`*/15` sondes, `30 8` digest) et les 3 scripts `/usr/local/bin/g9-probes.sh`, `g9-notify.py`, `g9-verify-alert.py` sont **validés**. Preuves jointes : 13 sondes OK / 0 KO, `NOTIFY_OK 200`, dérive → alerte JSON → « aucune dérive ». **Aucune autre action L3 sur le G9** : Gitea, Vaultwarden, Nextcloud, Synapse, ufw, Docker non touchés | `sudo crontab /root/crontab.backup-20261012-1520` + `sudo rm -f /usr/local/bin/{g9-probes.sh,g9-notify.py,g9-verify-alert.py}` + `sudo rm -rf /var/lib/g9-probes` |
|
||||
| 2026-10-12 17:05 | 4 | test restauration photo depuis restic | Critère 3 : preuve restore du snapshot incluant les photos | snapshot 06a7e3b89ef3e97d5f5c1dc9098496fab8ad79d48f4455eabdde07255143e33b contient 505836 entrées sous /data/docker/photoprism/originals ; restore 1 fichier ; sha256 restauré == source .25 (00cbfed8da81…) ; chiffrement maintenu (clé locale G9) | rm -rf /tmp/restore-photo-test (fait) |
|
||||
| 2026-10-12 16:10 | 4.5 | **3 notifications types reçues** dans le salon ops, event_ids relevés | C-MX4 : backup OK, backup KO, sonde KO | `restic-notify.sh` (prose, canal famille) : `BACKUP OK (test lecture seule C-MX4) : snapshot 06a7e3b8 2026-10-12 12:05, 541.4 GiB, 505836 entrees photos, restauration sha256 identique a la source .25` → `$U1flqaCJiTEwWSa5H14mMZLoMZ0fekelksXMXC6VEP8` ; `BACKUP KO (test provoque, 0 donnee touchee) : verification du canal d alerte C-MX4 - aucun backup reel en echec` → `$Frb22qftexOLlHl0L8dPAOdKJawZ-DOnLAJ0041zLfg` ; sonde applicative KO provoquée (`--selftest`, payload JSON) → `$Hiks3d6fThwsbxOt8wIe38Plu3aLfAtFfCrEwIzOpUU`. KO backup **réel** aussi présent dans l'historique : `[G9 backup] BACKUP KO : aucun snapshot depuis 36 h sur le G9` → `$GmUPzEtaYuW6h7KBB258oTYzx_WcRDdSOr6hxVfd73Q`. Mode `--list` ajouté à `g9-verify-alert.py` (lecture seule, GET /messages) pour relever les event_ids | aucune donnée touchée ; `sudo rm -f /usr/local/bin/g9-verify-alert.py` ; messages conservés dans le salon |
|
||||
| 2026-10-12 16:20 | 4.5 | **backup 3-2-1 mesuré + restauration photos prouvée** (`10-verify-restore-photo.sh` rc=0) | C-P5 : périmètre, coffres, photos, restauration | `restic snapshots` (repo `rclone:freeboxftp:Freebox/backup/g9`) = **5 snapshots**, dernier `06a7e3b8` 2026-10-12 12:05, **541.393 GiB**, sources `/data /home/folzo /etc`. Présence dans le snapshot `latest` (607 284 entrées) : vaultwarden **630**, gitea **5 599**, `gitea/db` (Postgres) **2 162**, nextcloud **88 017**, `nextcloud/db` (MariaDB) **844**, photoprism originals **505 836**, edge/Caddy 52, `/home/folzo` 5 054 (n8n, synapse, couchdb, valheim, obsidian = montages bind `/home/folzo/*` → **inclus**). **Médias 15 To + 5,5 To exclus par construction** : ils vivent sur le NAS `.25`, le périmètre du G9 ne contient que 564 Go (`/data/docker` + `/data/famille`). Restauration : sha256 restauré **identique** à la source `.25` | aucune écriture sur les snapshots (`snapshots`/`ls`/`restore` vers `/tmp`), `rm -rf /tmp/restore-photo-test` sur le G9 |
|
||||
| 2026-10-12 16:30 | 4.5 | `PHOTOS-DOUBLE-RUN.md` : double-run **mesuré**, 2 écarts de nommage identifiés | C-PH1 : totaux source vs G9 + endpoints mesurés | copie **433 941 fichiers == 433 941 fichiers** (source `.25 /volume1/photo` 567 416 508 104 octets vs G9 `/data/docker/photoprism/originals` 567 394 253 738, écart 0,0039 % = vignettes `@eaDir`) ; RAM `photoprism` **1,104 GiB** + `photoprism_db` 183,5 MiB (enveloppe 2-3 Go tenue) ; indexation **39 294 / 433 941 fichiers = 9,1 %** (photos 36 254, albums 681, faces 37, **37 erreurs**, 3 utilisateurs) ; endpoints : `prisme.folzocorp.fr` DNS OK mais **0 route Caddy → 000**, `photos.folzocorp.fr` route J5040 → **`192.168.1.24:2342` = PhotoPrism** (307) au lieu du NAS, web DSM `.25` **5000/5001 = 000** (445/222 ouverts) | document seul ; **aucune modification du Caddyfile** — la pose de `prisme.` et le repointage de `photos.` sont des actions L3 (G11/G6) **soumises à validation** |
|
||||
| 2026-10-12 16:35 | 4.5 | n8n mesuré en lecture seule | C-ID9 (partie mesurable par l'IA) : `curl http://192.168.1.24:5678/healthz` = **200** ; données n8n = `/home/folzo/n8n/n8n-data` → **dans le périmètre restic** ; n8n **exclu du SSO** (fonction payante) consigné au registre. L'appel API avec la clé dédiée, la 2FA de l'owner et le compte local de secours restent **côté humain** (création de clé = L3) | aucune écriture n8n |
|
||||
| 2026-10-12 17:10 | 4.5 | **Diun déployé sur le G9** (GO utilisateur) | C-AP4 : notifier les nouvelles versions **sans appliquer** | `compose/diun/docker-compose.yml` + `diun.yml` (kit) → `providers.docker.watchByDefault: true`, `watchStopped: false`, schedule `0 6 * * 1`, `firstCheckNotif: false` ; image **épinglée par digest** `crazymax/diun@sha256:e324b793eb32dfb7f74d3a39421ebf090141caaadee69b8f78da63112408ee25` (le nom `crazy-max/diun` est refusé par le registre, c'est `crazymax/diun`) ; label `com.centurylinklabs.watchtower.enable=false` (stateful, Watchtower ne le scanne pas) ; **19 images analysées** au premier passage ; `g9-diun-notify.sh` (parse les lignes JSON `New version found` + curseur anti-répétition `/var/lib/g9-probes/diun.cursor`) installé en `/usr/local/bin` + cron `20 6 * * *` ; `--selftest` → `NOTIFY_OK 200`. **Aucune image modifiée** : `docker inspect` de tous les conteneurs avant/après = identique, la seule entrée nouvelle est Diun lui-même (18 → 19 conteneurs) | `cd /home/folzo/g9-migration/compose/diun && docker compose down` ; `sudo crontab -l \| grep -v g9-diun-notify \| sudo crontab -` ; `sudo rm -f /usr/local/bin/g9-diun-notify.sh` ; `sudo rm -rf /data/docker/diun` |
|
||||
| 2026-10-12 17:15 | 4.5 | **`prisme.folzocorp.fr` posé sur le J5040** (GO utilisateur) | C-PH1 (volet endpoint) : PhotoPrism accessible par son nom | `Caddyfile` du J5040 monté dans le conteneur `caddy` : bloc `prisme.folzocorp.fr { import folzocorp; reverse_proxy 192.168.1.24:2342 }` ajouté **au-dessus** de `photos.` ; sauvegarde `Caddyfile.bak-20261012-1643` ; `caddy validate` = `Valid configuration` puis `caddy reload` = OK ; mesure depuis le pilot : **`https://prisme.folzocorp.fr/` = 200** (certificat Let's Encrypt émis par ACME DNS-01 OVH, `curl` **sans** `-k`). **`photos.` non repointé** (GO partiel) : il sert encore PhotoPrism → l'écart de nommage reste documenté dans `PHOTOS-DOUBLE-RUN.md` | `cp /home/folzo/g9-migration/compose/edge-j5040/Caddyfile.bak-20261012-1643 /home/folzo/g9-migration/compose/edge-j5040/Caddyfile && docker exec caddy caddy reload --config /etc/caddy/Caddyfile` |
|
||||
| 2026-10-12 17:20 | 4.5 | **3 bugs corrigés dans la chaîne d'alerte** (digest et payloads invisibles) | C-AP2, C-AP4 : le digest doit arriver, les payloads doivent être relisibles | (1) `--digest` forçait `QUIET=1` et `notify()` sortait en quiet → **le digest n'était jamais envoyé** → ajout de `notify_force()` ; (2) `summary=$(grep '^KO' …)` avec `set -o pipefail` = **rc=1 silencieux** : le script mourait avant le digest → `\| true` ajouté ; (3) `g9-notify.py` publiait en type **`m.text`** (type hérité) alors que `g9-verify-alert.py` ne lisait que `m.room.message` → payloads **envoyés (200) et invisibles** ; type unifié en `m.room.message` + listing acceptant les deux. Ajout `g9-verify-alert.py --list N` (event_ids). Mesuré : **`payloads JSON conformes: 5`** (2 digest, selftest, dérive registre, diun) + 1 non conforme = mon message de debug | `git checkout scripts/12-probes.sh scripts/g9-notify.py scripts/g9-verify-alert.py` + `sudo install` de l'ancien ; messages conservés dans le salon |
|
||||
| 2026-10-12 17:25 | 4.5 | **compose du kit versionnés** : les briques déclarées hors git sont rentrées dans le dépôt | C-ID1 (fiabilité du registre), C-ID5 : la colonne « kit » était fausse (9 conteneurs marqués hors kit) | import dans `g9-migration/compose/` des définitions réelles qui vivaient hors kit : `matrix/` (`/home/folzo/matrix`), `g9-queue/` (`/home/folzo/docker-compose.yml` : n8n, watchtower, couchdb, obsidian), `valheim/`, `diun/`, + `edge-j5040/Caddyfile` ; **0 secret importé** (les `secrets/` du G9 sont en 700 root, les compose ne contiennent que des `${VAR}` et des chemins `secrets_file`) ; les fichiers du kit déjà versionnés **ne sont pas écrasés** (la copie live n'écrase pas le kit : la dérive doit rester visible). Constats automatiques **14 → 7** ; restent 7 vrais constats : 4 images `:latest` non épinglées (n8n, element, synapse, obsidian), `g9/valheim` dont le projet compose s'appelle `compose`, `j5040/sweet_feynman` sans projet compose et sans label watchtower | `git rm -r g9-migration/compose/{matrix,g9-queue,valheim,diun}` (aucun conteneur touché) |
|
||||
| 2026-10-12 17:35 | 4.5 | bug **fausse dérive permanente** corrigé dans `11-registry.sh --check` | C-ID5 (fiabilité) : `--check` répondait DÉRIVE alors que rien n'avait bougé | la ligne `<!-- GÉNÉRÉ … ts=… -->` est à la **ligne 9** (elle suit le bloc de citations d'en-tête), et la comparaison retirait les lignes **1 à 4** → l'horodatage restait dans le corps comparé = **dérive à chaque passage**. Retrait par motif `grep -v '^<!-- GÉNÉRÉ PAR'` (plus dépendant de la position). **Dérive re-prouvée avec la comparaison corrigée** : `compose/jellyfin/docker-compose.yml` `10.9`→`10.8` → `DÉRIVE détectée (8 constats)` + `NOTIFY_OK 200` + `rc=1` ; revert → `aucune dérive (22 conteneurs, 7 constats)` + `rc=0` | `git checkout g9-migration/scripts/11-registry.sh` (compose reverti par sed, aucun conteneur touché) |
|
||||
| 2026-10-12 17:55 | 4.5 | **grille d'exposition validée** avec l'utilisateur, mesurée en entier | C-EX1 : `ufw status numbered` complet (.24 = 25 règles, .27 = 12 règles) | **0 règle posée** (G6 respecté). Mesures nouvelles : `OpenSSH ALLOW IN Anywhere` est présent sur **.24 ET .27** (règle [1] sur les deux) ; `443/tcp Anywhere` sur le G9 est la règle [22] « seul port exposé WAN » de l'edge T4.6, et **il n'existe aucune règle LAN pour 443 sur le G9** → supprimer cette règle couperait aussi `git./cloud./media.` **depuis la maison** ; `tailscaled` = **inactive sur le G9**, actif **seulement sur le pilot** ; le pilot et le G9 sont derrière le **même NAT** (IP publique unique `82.65.198.114`) → ce qui est joignable dépend des **redirects Freebox**, pas de ufw seul ; CouchDB 5984 est **déjà LAN-only** (ufw règle 6), le reste du risque = absence d'auth sur le LAN. 5 décisions rendues + **qui pose le geste** (1, 2, 3, 5 = toi ; 4 = moi sur GO) | document seul ; `git checkout EXPOSITION-INTERNET.md` ; **aucune commande ufw exécutée** |
|
||||
| 2026-10-12 18:10 | 4.5 | **durcissement pare-feu exécuté sur GO explicite** (SSH + 443 LAN-only, Tailscale coupé) | C-EX1 : les 5 décisions sont rendues **et** les 3 gestes réseau posés par l'utilisateur, exécutés par moi avec `scripts/14-firewall-lan-only.sh` | **.24** : `[1] 443/tcp ALLOW IN 192.168.1.0/24` + `[2] 22/tcp ALLOW IN 192.168.1.0/24` insérés **avant** les suppressions, puis suppression de `[1] OpenSSH Anywhere`, `[24] OpenSSH (v6)`, `[22] 443 Anywhere`, `[25] 443 (v6)` → **plus aucune règle `Anywhere` sur le G9** (25 → 23 règles). **.27** : `[1] 22/tcp from 192.168.1.0/24` puis suppression `[1] OpenSSH Anywhere` + `[10] OpenSSH (v6)` ; **règles WAN de l'edge conservées** (`80`, `443`, `51820` v4+v6). **Pilot** : `systemctl disable --now tailscaled` (il n'était actif **que** sur le pilot ; G9 = inactive). Vérifié après : `ssh .24` OK, `ssh .27` OK, `git ls-remote gitea` OK, `https://git.folzocorp.fr/api/v1/version` (LAN) **200**, `https://cloud.folzocorp.fr/` (WAN) **200**, `https://prisme.folzocorp.fr/` **200**. Snapshots `ufw status numbered` conservés avant (25 règles .24 / 17 lignes .27) | rollback : `sudo ufw insert 1 allow proto tcp from any to any port 22` et `… port 443` sur .24 (+ v6), idem 22 sur .27 ; `sudo systemctl enable --now tailscaled` ; snapshots dans `/tmp/ufw-24-before.txt` et `/tmp/ufw-27-before.txt` sur le pilot |
|
||||
| 2026-10-12 18:20 | 4.5 | **faux positif corrigé : CouchDB n'est PAS sans auth** | C-EX1 (exactitude des constats), C-AP1 (fiabilité des sondeurs) | la sonde lisait `GET :5984/` = 200 → lu « sans auth ». Mesure directe : **`GET /_all_databases` = 401** et **`GET /_active_tasks` = 401** ; `compose/g9-queue/docker-compose.yml` déclare bien `COUCHDB_USER` + `COUCHDB_PASSWORD=${COUCHDB_PASSWORD}`. `/` est public **par conception** dans CouchDB. **Décision : ne pas recréer le conteneur** pour publier `127.0.0.1:5984` — gain quasi nul (ufw LAN-only + auth déjà en place) et **casse probable de la sync Obsidian** si le client pointe sur `http://192.168.1.24:5984`. Échange mauvais → statu quo, consigné | aucun (aucun conteneur recréé, aucun fichier modifié sur le G9) |
|
||||
| 2026-10-12 18:40 | 4.5 | **balayage d'identités en lecture seule** (`15-identity-sweep.sh`, `16-identity-sweep-sqlite.sh`) → §7 du registre | C-ID2 (preuve de la moitié B), C-ID4/7/9, C-MX1/3 : l'existant est **mesuré**, plus supposé | **Gitea** : 4 comptes (`Master`, `Folzo`, `Antoine`, `g9ops`), `IsAdmin`=2, **2FA = false sur les 4** → `gitea admin user list` (CLI local, rc=0). **Nextcloud** : 8 comptes, groupes `admin`={Effi, Master} et `Famille Folzo`={Audrey, Folzo, carla, ewan, lola, noah}, providers 2FA installés (`twofactor_totp`, `backupcodes`, `nextcloud_notification`). **Synapse** : **2 comptes (`@ops`, `@ops-bot`), `admin` = 0**, locked 0, deactivated 0, 14 devices → **aucun admin : C-MX1 et C-MX3 bloqués à la racine, aucune porte de sortie**. **n8n** : 1 compte `effi@openfox.local` (disabled=0) + basic auth déclarée. **PhotoPrism** : 3 comptes. **Vaultwarden** : `ADMIN_TOKEN_FILE` (jeton en fichier, pas en env en clair). **CouchDB** : 401 sans identifiants → auth active | 0 écriture : commandes `admin user list`, `occ user:list/group:list/twofactorprovider:states`, sqlite en `mode=ro`, `mariadb -N -e select`. Aucun compte créé, aucun privilège changé |
|
||||
| 2026-10-12 18:45 | 4.5 | trou **Synapse sans admin** : porte de sortie outillée, **non exécutée** | C-MX1, C-MX3, C-ID4 : la brique n'a pas de compte admin → ni création de comptes famille, ni réinitialisation possible | `scripts/17-synapse-admin.sh` : inscription signée via `registration_shared_secret` + `POST /_synapse/admin/v1/register` (secret lu à l'exécution dans le conteneur, **jamais affiché ni recopié**, mot de passe saisi masqué, longueur ≥ 12, confirmation `CREER` pour un admin). **Vérifié jusqu'à la dernière étape sans rien créer** : `GET /_synapse/admin/v1/register` → nonce reçu, `registration_shared_secret` présent dans `homeserver.yaml` (`FOUND`). Procédure consignée dans `RUNBOOKS/matrix.md` + §7.1 du registre. **Exécution = toi** (création de compte = interdit permanent G11) | `docker exec synapse python3 -c "… update users set admin=0 where name='<compte>'"` (désactivation propre, jamais de purge) |
|
||||
| 2026-10-12 18:30 | 4.5 | **dumps logiques cohérents des 3 bases** (`18-db-dumps.sh`, GO utilisateur) | C-P5 : corriger le trou mesuré « copie chaude = complète en octets, pas garantie rejouable » | `pg_dump` Gitea (auth **socket `trust`** mesurée dans `pg_hba.conf` → **aucun mot de passe sur une ligne de commande**) = **80 543 o** gz, **116 `CREATE TABLE`** ; `mysqldump --single-transaction --routines` Nextcloud = **5,9 Mo** gz, **317** objets, se termine par `-- Dump completed` ; idem PhotoPrism = **89,6 Mo** gz, **39** objets, `-- Dump completed`. **Aucune brique stoppée** (`--single-transaction` = instantané InnoDB cohérent). Dumps écrits sous `/data/docker/<brique>/dumps/` → **déjà dans le périmètre restic** (visibles au prochain backup de 03:30). **Rejouabilité prouvée pour Gitea** : base de test `gtest_restore` créée, dump chargé, **116 tables restaurées** (== 116 du dump), base **supprimée** (`pg_database` = gitea_db/postgres/template0/template1, vérifié) | dumps : `rm -f /data/docker/{gitea,nextcloud,photoprism}/dumps/*-20261012-1825Z.sql.gz`. **Test MariaDB complet non fait** : le dump Nextcloud contient `USE \`nextcloud\`` → le charger tel quel écraserait la production ; il faut un serveur jeton (GO séparé) |
|
||||
| 2026-10-12 18:35 | 4.5 | **hub de monitoring posé hors du G9** (J5040) | C-AP5 : le trou mesuré était « les sondes tournent depuis le G9 → elles meurent avec lui » ; l'ancien plan (05-beszel.sh) mettait le hub sur **.25, la machine à mourir en Phase 6** | `compose/beszel-hub/docker-compose.yml` (kit) → `henrygd/beszel@sha256:6b39a8b64f9b15d00a25e7683e31a93c46161182a54f62713525107064e10599` (épinglé digest, label watchtower=false, base dans `/home/folzo/beszel-hub/data`) ; `docker compose up -d` ; **règle ufw LAN-only ajoutée** : `sudo ufw insert 1 allow proto tcp from 192.168.1.0/24 to any port 8090 comment "beszel hub LAN-only"` → **jamais joignable depuis le WAN** ; mesure : `http://192.168.1.27:8090/` = **200** ; superuser créé par CLI (`/beszel superuser create`, **mot de passe généré sur la machine, écrit en 600 dans `/home/folzo/beszel-admin.cred`, jamais affiché, jamais commité**). **Reste** : les agents (.24/.27) + le token universel, qui se crée dans l'UI (`/settings/tokens`) — la voie API PocketBase n'est pas trouvée (`/api/collections/_superusers/auth-with-password` = 404, `…/authenticators/superusers/email` = « resource not found », `/api/collections/_superusers/records` = 403 → API bien présente, chemin d'auth non identifié) | `cd /home/folzo/g9-migration/compose/beszel-hub && docker compose down` ; `sudo ufw delete <n° 8090>` ; `rm -f /home/folzo/beszel-admin.cred` ; `rm -rf /home/folzo/beszel-hub` |
|
||||
| 2026-10-12 19:00 | 4.5 | **compte admin Synapse créé et réparé** (`26-synapse-admin-repair.sh`, GO utilisateur) | C-MX1/C-MX3/C-ID4 : la brique n'avait **aucun admin** (`admin` = 0 sur `@ops` et `@ops-bot`) → ni création de comptes famille, ni réinitialisation possible | `POST /_matrix/client/v3/register` (`m.login.dummy`) → compte créé mais Synapse **ignore le localpart** (mxid aléatoire) et le bug d'arguments de `21-…` l'a laissé **`password_hash` = NULL** → admin=1 **inutilisable** (login « Invalid username or password », et `/_synapse/admin/v1/register` signé = **`HMAC incorrect`**, secret jamais recopié ni affiché). Réparation mesurée : **`bcrypt` 5.0.0 est dans l'image synapse** → hash bcrypt 12 du mot de passe du credential écrit en base (mot de passe **jamais en argv** : stdin + `curl -K` fichier 600 + tmpdir 700) → **`docker restart`** (Synapse ne vide pas ses caches sqlite sur écriture directe) → login OK → **`PUT /_synapse/admin/v2/users/@folzo:192.168.1.24`** `{password, admin:true}` (l'API v2 **crée par PUT**, POST = `Unrecognized request`) → compte temporaire `admin=0`, `deactivated=1`, `password_hash=NULL`. **Preuves** : `--verify folzo` = **login OK** ; `--admin-check folzo` = **API admin OK** (3 utilisateurs, 1 admin) ; lecture seule finale : 4 comptes, 1 admin actif, 1 désactivé. `27-synapse-family-users.sh` ajouté (`--dry` mesuré = 6 PUT planifiés, `--revoke` = désactivation) | snapshot sqlite **avant** toute écriture : `/data/homeserver.db.bak-20261012-1900` (rollback = arrêter `synapse`, restaurer le fichier, redémarrer) ; compte aléatoire conservé désactivé (0 purge) ; `rm -f /tmp/2[567]-*.sh` sur le G9 |
|
||||
| 2026-10-12 19:20 | 4.5 | **6 critères retoqués par le vérificateur corrigés dans les docs** | C-ID2, C-ID8, C-MX5, C-PH2, C-WA1 (+ péages périmés) | **C-ID2** : ligne **PhotoPrism ajoutée en §2** (16ᵉ ligne .24) avec les 3 comptes **mesurés** (`photoprism.auth_users` : `admin`, `visitor`, **1 sans nom**) + CLI réelle (`photoprism users ls\|show\|mod\|add\|rm`, `auth reset --yes`) ; §7 « à nommer » supprimé ; ligne **Synapse réécrite** (3 comptes, `@folzo` admin, PUT et pas POST) ; **convention `infra/<brique>/break-glass` ajoutée en §1** ; **C-ID8** : §2.1 **placement des 3 exemplaires** (sur place / hors site / hors ligne, aucun 4ᵉ), §2.2 **4 garde-fous** (2 personnes max, personne de confiance = **lecture seule**, **1 code de déverrouillage par personne**, **jamais par mail/cloud**), §4 test annuel = **6 points** tabulés ; **C-MX5** : **0 occurrence** de « remplace WhatsApp » (S3.1 #8, S13.3 C.1, `GUIDE-MATRIX.md` §4 corrigés) + **décision + 4 interdictions** écrites en S8.2 (mot « interdiction » présent) + numérotation corrigée (**S14.5** = grille Matrix/WhatsApp) ; **C-PH2** : `PHOTOS-DOUBLE-RUN.md` **§6 grille des 10 requêtes** (protocole + colonnes à remplir + classement) et **§7 procédure d'auto-upload** (10 pas, anti-effacement, EXIF, seuil de réussite 7 jours, format de journal, rollback) ; **C-WA1** : **§6 = 17 questions de Go/No-Go** avec preuve exigée par ligne + **risque de bannissement détaillé** (2 voies : signalements des correspondants + détection automatisée, **aucun seuil public**, échelle ban temporaire → permanent → suppression du compte, probabilité **non chiffrable**) ; péages périmés corrigés : §4 de `PHOTOS-DOUBLE-RUN.md` (`prisme.` = **200** avec la route du 17:15), `BACKUP-PERIMETRE.md` §6 (**dumps faits et mesurés** + rejouabilité Gitea, plus « à faire »), `EXPOSITION-INTERNET.md` **§7 = choix de machine J5040 pour l'IdP** (5 points + déclencheur + rollback) | `git checkout` des 7 fichiers (aucun effet sur les briques) |
|
||||
| 2026-10-12 19:25 | 4.5 | **salon famille Matrix créé** (`28-synapse-create-room.sh`, GO utilisateur) | C-MX2 : le vérificateur mesure **1 seul salon** (`!mp7MFx9Q…` = ops) — le salon famille annoncé n'existait pas | `POST /_matrix/client/v3/createRoom` (token admin `@folzo`) → **`!9hTrGcLp3bZzw_cXtJEYs7eRfWcr4yUTZe6RTFEr27M`**, nom `Famille`, alias **`#famille:192.168.1.24`**, `preset: private_chat`, `visibility: private`, invites `@ops` + `@ops-bot`. **Voie admin v1 mesurée morte** : `POST /_synapse/admin/v1/create_room` = **404 `M_UNRECOGNIZED`** sur cette version (Matrix v1.12). Preuves : `--list` = **2 salons** en base (`!mp7MFx9Q…` créateur `@ops-bot`, `!9hTrGcLp…` créateur `@folzo`), **résolution d'alias** `#famille → !9hTrGcLp…`, 0 compte créé, **0 message envoyé**. `--dry` et `--purge` (rollback) outillés. Constat consigné : `joined_members` = 1 → les invites sont **en attente**, les adultes rejoindront depuis Element | `CONFIRM=PURGER bash scripts/28-synapse-create-room.sh --purge "!9hTrGcLp3bZzw_cXtJEYs7eRfWcr4yUTZe6RTFEr27M"` (`DELETE /_synapse/admin/v1/rooms/<id>`) ; `rm -f /tmp/28-*.sh` sur le G9 |
|
||||
| 2026-10-12 19:40 | 4.5 | **2 critères retoqués par la vérification indépendante corrigés** | C-ID7, C-MX5 | **C-ID7** : `REGISTRE-IDENTITES.md` **§4.5 = journal de bascule Gitea en 4 colonnes** (compte / rôle **avant mesuré** / rôle **après + 2FA** / **commande + rollback**) pour `Master`, `Folzo`, `Antoine`, `g9ops`, avec le **constat mesuré** que le CLI Gitea de cette version **ne peut pas changer un rôle** (`gitea admin user` = `create`, `list`, `change-password`, `delete`, `generate-access-token`, `must-change-password`) → rôle via **UI** ou SQL, token à scopes `write:repository`, ancien token révoqué, preuve de fin (`IsAdmin` = 1 humain, 2FA = true, `g9ops` = 0, `git ls-remote` sans `~/.git-credentials`) ; §4 item 4 (Synapse) **réécrit sur la voie mesurée** (HMAC KO, bcrypt + restart + PUT v2) ; **C-MX5** : `DOSSIER-SOFTWARE.md` **ligne 192 (§3.3)** « **Remplace WhatsApp** » → « **complément** de WhatsApp, **pas un remplacement** » ; l'auto-contrôle de l'interdiction 1 est réécrit pour ne plus contenir la formule et donne la commande de contrôle (`grep -in "rempla.*whatsapp"`) | `git checkout DOSSIER-SOFTWARE.md g9-migration/REGISTRE-IDENTITES.md` (aucun effet sur les briques) |
|
||||
| 2026-10-12 19:50 | 4.5 | **auto-contrôle de l'interdiction 1 rendu autosuffisant** (revue de code, finding #3) | C-MX5 : le contrôle `grep -in "rempla.*whatsapp"` échouait **sur ses propres lignes légitimes** (la définition de l'interdiction et l'énoncé du contrôle), donc le contrôle était faux | S8.2 : le contrôle devient **une commande à sortie vide** : `grep -in "remplace whatsapp" … \| grep -vi "pas\|complément\|interdiction\|ne présente"` = **0 ligne mesurée** (les mentions légitimes sont toutes en négation : S3.1 #8, S3.3, S8, S8.2, S13.3 C.1, S14.5, changelog, journal historique) | `git checkout DOSSIER-SOFTWARE.md` |
|
||||
| 2026-10-13 19:30 | E3 | **bascule DHCP faite (GO utilisateur)** | Freebox Delta reset usine (bridge complet impossible : J5040 mono-NIC) + DHCP Delta coupé + dnsmasq .27 actif | DHCP servi par .27 (baux 37 appareils = mêmes IP, G9→.24, PING/SSH OK, 18 conteneurs healthy) ; **cause racine DHCP muet = ufw .27 bloquait UDP 67/68** (règle ajoutée) ; DNS1=.27 (AdGuard) conservé | couper dnsmasq + rallumer DHCP Delta (baux Delta recréés d usine, à reprendre depuis /srv/dnsmasq/leases) |
|
||||
| 2026-10-13 19:35 | E4 | **bascule WAN faite (GO utilisateur)** | Redirections Delta TCP 80/443 + UDP 51820 → .27 | WAN via IP publique 82.65.198.114 : photos 307 / cloud 302 / pass 200, cert LE valide (YE1, CN=photos.folzocorp.fr) ; edge G9 **démonté** (compose down + ufw 443 retiré) ; zone OVH A* = IP ok ; ménage : redirections d usine obsolètes Delta à purger (utilisateur) | supprimer redirections + remonter edge G9 (compose up -d) |
|
||||
| 2026-10-13 19:40 | E2 | VM AdGuard .1 | reset usine Delta a isolé la VM (bridge VM derrière Delta, injoignable) | VM éteinte puis rallumée par utilisateur ; coupe AdGuardHome = via UI Delta (action utilisateur) ; DNS LAN = .27 (aucun impact) | redéploiement VM + AdGuardHome start |
|
||||
| 2026-10-13 19:42 | E3 | switch 10G SKS7300 | port SFP+ Delta éteint par reset usine → G9 isolé (IPv6 link-local seul) ; câble remplacé par RJ45 Delta↔switch (1G) | G9 visible ; SSH switch (admin, telnet) dispo pour remettre les VLANs 10G (untagged trunk uplink) à faire | re-câbler SFP+ + réactiver port fibre Delta |
|
||||
| 2026-10-13 20:00 | E4 | **openfox.folzocorp.fr** ajouté au Caddy .27 | Besoin utilisateur : suivre la config depuis son téléphone | reverse_proxy → 192.168.1.11:10369 ; cert LE émis ; 200 via LAN (.27) ET WAN (IP publique) ; fix mount stale = `compose up -d --force-recreate caddy` (rsync du Caddyfile ne suffit pas, inode monté) | retirer le block + recreate |
|
||||
| 2026-10-13 20:30 | 4 | secrets PhotoPrism **644→600** (finding vérification) | Dossier 755/fichiers 644 = lisibles par tout utilisateur local (03b pose 644, 07d faisait chmod 755) | `chmod 700 secrets` + `chmod 600 secrets/*` sur .24 (admin/dbpass/rootpass/photoprism.env tous 600 root) ; conteneurs up intacts (env chargés au start, db lit /run/secrets en root) ; 07d corrigé (chmod 700 + sh -c 600) | chmod 644 (inverser, non recommandé) |
|
||||
| 2026-10-13 21:00 | 4 | secrets PhotoPrism — durcissement complété (review) | Finding : la copie kit .24 restait 644 | chmod 700/600 appliqué aux DEUX chemins sur .24 : /data/docker/photoprism/secrets ET /home/folzo/g9-migration/compose/photoprism/secrets (ls vérifié 700 root + fichiers 600) ; 07d = boucle sur les 2 chemins + `find -maxdepth 1 -type f -exec` (robuste dotfiles/dossier vide) + commentaire de convention (600 = root-only, ne pas copier vers briques entrypoint non-root, 03b garde 644 voulu) | re-chmod 644 (non recommandé) |
|
||||
|
||||
@@ -68,22 +68,29 @@ ufw --force reset >/dev/null
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow OpenSSH
|
||||
# Services LAN-only (G9 = LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
|
||||
# Ports des services RÉELS sur .24 (n8n, synapse, element, couchdb, obsidian) +
|
||||
# briques famille futures + Valheim (TCP+UDP). Accessibles depuis le LAN uniquement.
|
||||
for port in 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980; do
|
||||
# Services LAN-only (LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
|
||||
# Listes paramétrables (défaut = G9 .24) : J5040 edge = TCP_LAN="53 3000 8081"
|
||||
# UDP_LAN="53" WAN_TCP="80 443" WAN_UDP="51820".
|
||||
TCP_LAN="${TCP_LAN:-2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980}"
|
||||
UDP_LAN="${UDP_LAN:-2456 2457 2458}"
|
||||
WAN_TCP="${WAN_TCP:-443 80}"
|
||||
WAN_UDP="${WAN_UDP:-}"
|
||||
for port in $TCP_LAN; do
|
||||
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
|
||||
done
|
||||
for port in 2456 2457 2458; do
|
||||
for port in $UDP_LAN; do
|
||||
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
|
||||
ufw allow from 192.168.1.0/24 to any port "$port" proto udp
|
||||
done
|
||||
# T4.6 edge (Caddy) : SEUL port exposé WAN = 443 (+ 80 = redirect ACME/HTTP→HTTPS).
|
||||
# L'exposition WAN des briques passe par l'edge uniquement (S9.2/S9.3, default deny incoming).
|
||||
ufw allow 443/tcp comment 'edge T4.6 (seul port exposé WAN)'
|
||||
ufw allow 80/tcp comment 'edge T4.6 (redirect ACME)'
|
||||
# Edge : ports exposés WAN (443 service + 80 redirect ACME/HTTP→HTTPS ; 51820 = WireGuard).
|
||||
for port in $WAN_TCP; do
|
||||
ufw allow "$port/tcp" comment "edge WAN ($port)"
|
||||
done
|
||||
for port in $WAN_UDP; do
|
||||
ufw allow "$port/udp" comment "edge WAN ($port)"
|
||||
done
|
||||
ufw --force enable
|
||||
journal "ufw activé" "Firewall LAN-only (G6)" "deny incoming, allow outgoing, OpenSSH + services LAN-only 192.168.1.0/24 (TCP 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096 9980 + Valheim 2456-2458 TCP/UDP) + edge 443/80 exposés WAN (T4.6)" "ufw disable"
|
||||
journal "ufw activé" "Firewall LAN-only (G6)" "deny incoming, allow outgoing, OpenSSH + TCP_LAN=[$TCP_LAN] + UDP_LAN=[$UDP_LAN] (LAN-only) + WAN_TCP=[$WAN_TCP] WAN_UDP=[$WAN_UDP]" "ufw disable"
|
||||
|
||||
# --- 3. unattended-upgrades ---
|
||||
log "unattended-upgrades : activation"
|
||||
|
||||
@@ -59,10 +59,11 @@ log "source .25 : $SRC_COUNT fichiers, $SRC_BYTES octets"
|
||||
|
||||
# ---------------------------------------------------------------- 1. backup G2
|
||||
log "=== 1/5 BACKUP restic (G2) ==="
|
||||
if $SSH_G9 "sudo restic snapshots 2>/dev/null | grep -q '$(date '+%Y-%m-%d')'"; then
|
||||
SNAP_TODAY=$(date '+%Y-%m-%d')
|
||||
if $SSH_G9 "sudo sh -c 'set -a; . /etc/restic.env; set +a; restic -r \"\$RESTIC_REPOSITORY\" snapshots 2>/dev/null' | grep -q '$SNAP_TODAY'"; then
|
||||
log " ⏭ snapshot du jour déjà présent — backup complet sauté (idempotence)"
|
||||
else
|
||||
$SSH_G9 "sudo /usr/local/bin/restic-daily.sh >/dev/null 2>&1; sudo restic snapshots 2>/dev/null | tail -2"
|
||||
$SSH_G9 "sudo /usr/local/bin/restic-daily.sh >/dev/null 2>&1; sudo sh -c 'set -a; . /etc/restic.env; set +a; restic -r \"\$RESTIC_REPOSITORY\" snapshots 2>/dev/null' | tail -2"
|
||||
journal "backup restic pré-migration" "Garde-fou G2 avant import PhotoPrism" "snapshot restic Freebox (log /var/log/restic-daily.log)" "restic forget <snapshot> && restic prune"
|
||||
fi
|
||||
|
||||
@@ -75,10 +76,14 @@ if [[ "${SKIP_COPY:-0}" != "1" ]]; then
|
||||
t0=$(date +%s)
|
||||
# Progression : tar GNU du G9 émet un pointage sur stderr tous les 50 000 enregistrements
|
||||
# (stderr relayé par ssh) → la copie de 530 Go n'est pas silencieuse pendant des heures.
|
||||
if ! $SSH_XP "sudo tar cz -C '$XP_PHOTO' ." | $SSH_G9 "tar xz --checkpoint=50000 --checkpoint-action=echo='[copie] %n enregistrements' -C $G9_DIR/originals"; then
|
||||
# PAS de gzip : photos déjà compressées (JPEG/RAW), gzip sur le CPU du NAS = 10 Mo/s mesuré.
|
||||
# Flux brut = lié par le réseau (~100 Mo/s).
|
||||
if ! $SSH_XP "sudo tar c -C '$XP_PHOTO' ." | $SSH_G9 "tar x --checkpoint=50000 --checkpoint-action=echo='[copie] %n enregistrements' -C $G9_DIR/originals"; then
|
||||
echo "ERREUR : flux tar interrompu"; exit 1
|
||||
fi
|
||||
t=$(( $(date +%s) - t0 ))
|
||||
# DSM arrive avec des modes 0000/2700 (ACL Synology) → les dirs sont illisibles par folzo.
|
||||
$SSH_G9 "sudo chmod -R u+rwX $G9_DIR/originals"
|
||||
log " copie terminée en ${t} s"
|
||||
journal "copie photos .25 → G9" "530 Go, source en lecture seule (aucune écriture sur .25)" "tar/ssh relayé par le pilot, ${t} s ; $SRC_COUNT fichiers source" "rm -rf $G9_DIR/originals (source .25 intacte)"
|
||||
fi
|
||||
@@ -111,17 +116,22 @@ log "=== 4/5 STACK + SECRETS ==="
|
||||
rsync -a --info=stats1 -e "$RSH_G9" --exclude "secrets" "$KIT_DIR/compose/$BRIQUE/" "$G9_USER@$G9_HOST:$KIT_ON_G9/compose/$BRIQUE/"
|
||||
rsync -a -e "$RSH_G9" "$KIT_DIR/scripts/03b-secrets.sh" "$G9_USER@$G9_HOST:$KIT_ON_G9/scripts/"
|
||||
$SSH_G9 "sudo bash $KIT_ON_G9/scripts/03b-secrets.sh >/dev/null"
|
||||
$SSH_G9 "sudo rsync -a $KIT_ON_G9/compose/$BRIQUE/ $G9_DIR/ && sudo chmod 755 $G9_DIR/secrets"
|
||||
$SSH_G9 "sudo rsync -a $KIT_ON_G9/compose/$BRIQUE/ $G9_DIR/"
|
||||
# 600 root = valable uniquement si le conteneur lit /run/secrets EN ROOT ou via env_file compose (photoprism/mariadb).
|
||||
# NE PAS copier ce pattern vers les briques à entrypoint non-root (n8n uid1000, Nextcloud www-data → 644 voulu, voir 03b).
|
||||
for d in "$G9_DIR" "$KIT_ON_G9/compose/$BRIQUE"; do
|
||||
$SSH_G9 "sudo chmod 700 $d/secrets && sudo find $d/secrets -maxdepth 1 -type f -exec chmod 600 {} +"
|
||||
done
|
||||
$SSH_G9 "sudo chown -R 999:999 $G9_DIR/db && sudo chmod 750 $G9_DIR/db"
|
||||
# PhotoPrism n'accepte pas les *_FILE : env_file construit à partir des secrets (600, root, hors git).
|
||||
$SSH_G9 "sudo sh -c 'umask 077; { echo PHOTOPRISM_ADMIN_PASSWORD=\$(cat $G9_DIR/secrets/admin); echo PHOTOPRISM_DATABASE_PASSWORD=\$(cat $G9_DIR/secrets/dbpass); } > $G9_DIR/secrets/photoprism.env' && sudo chown root:root $G9_DIR/secrets/photoprism.env"
|
||||
$SSH_G9 "cd $G9_DIR && docker compose up -d && sleep 30 && docker compose ps --format '{{.Name}} {{.Status}}'"
|
||||
$SSH_G9 "cd $G9_DIR && sudo docker compose up -d && sleep 30 && sudo docker compose ps --format '{{.Name}} {{.Status}}'"
|
||||
|
||||
# ---------------------------------------------------------------- 5. indexation + vérifs
|
||||
log "=== 5/5 INDEXATION (progressive, décision #4) + VÉRIFS ==="
|
||||
$SSH_G9 "curl -sf -o /dev/null -w 'photoprism HTTP %{http_code}\n' http://127.0.0.1:2342/api/v1/session/health"
|
||||
$SSH_G9 "cd $G9_DIR && timeout $INDEX_TIMEOUT docker compose exec -T photoprism photoprism index 2>&1 | tail -6 < /dev/null || true"
|
||||
$SSH_G9 "cd $G9_DIR && docker compose exec -T photoprism sh -c 'photoprism count 2>/dev/null | head -12' < /dev/null || true"
|
||||
$SSH_G9 "curl -s -o /dev/null -w 'photoprism HTTP %{http_code}\n' http://127.0.0.1:2342/api/v1/health"
|
||||
$SSH_G9 "cd $G9_DIR && timeout $INDEX_TIMEOUT sudo docker compose exec -T photoprism photoprism index < /dev/null 2>&1 | tail -6 || true"
|
||||
$SSH_G9 "cd $G9_DIR && sudo docker compose exec -T db sh -c 'mariadb -u photoprism -p\"\$(cat /run/secrets/dbpass)\" photoprism -e \"SELECT COUNT(*) AS photos_indexes FROM photos\"' 2>/dev/null || true"
|
||||
journal "stack PhotoPrism déployée + indexation lancée" "T4.3 : stack saine, index progressif (photothèque 530 Go sur le G9)" "image épinglée 260919, MariaDB 10.11 (watchtower OFF), secrets via env_file 600, port 2342 LAN ; originals = copie locale" "docker compose down + rm -rf /data/docker/photoprism"
|
||||
|
||||
echo
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
#
|
||||
# Garde-fous : G3 (destructif — validation explicite), G4 (double-run terminé), G5 (IP .25), G7 (rollback), G8 (journalisation).
|
||||
#
|
||||
# ⚠️ OPÉRATION DESTRUCTIVE ET IRRÉVERSIBLE (garde-fous G3 + G4) :
|
||||
# Ce script ARRÊTE et WIPE le xpenology. Il ne s'exécute QUE si :
|
||||
# ⚠️ OPÉRATION IRRÉVERSIBLE CÔTÉ RÉSEAU (garde-fous G3 + G4) :
|
||||
# Ce script ARRÊTE le xpenology (arrêt des conteneurs + consignes d'arrêt GUI + débranchement).
|
||||
# **AUCUN WIPE n'est exécuté par ce script** — décision utilisateur 12/10 : disques conservés
|
||||
# intacts, récupération documentée dans `RESTORE-NAS-USB.md`. Le wipe physique reste une
|
||||
# décision séparée (réutilisation/vente), hors périmètre du kit.
|
||||
# Il ne s'exécute QUE si :
|
||||
# 1. Toutes les briques sont validées sur le G9 (double-run terminé, G4).
|
||||
# 2. Le backup 3-2-1 est fonctionnel + restauration testée (G2).
|
||||
# 3. L'IP du G9 est passée à .25 (G5).
|
||||
# 4. Validation explicite de l'utilisateur (drapeau + saisie du mot de passe).
|
||||
# 4. Validation explicite de l'utilisateur (drapeau + saisie de confirmation).
|
||||
#
|
||||
# Usage :
|
||||
# sudo ./08-decommission.sh --i-understand
|
||||
@@ -39,26 +43,28 @@ prereq "Toutes les briques (Vaultwarden, Nextcloud, PhotoPrism, Gitea, n8n, Jell
|
||||
prereq "Backup 3-2-1 fonctionnel + restauration testée (données IA incluses)"
|
||||
prereq "IP du G9 passée à .25, tous les services résolus et accessibles"
|
||||
prereq "Sauvegarde FINALE complète du xpenology effectuée et vérifiée"
|
||||
prereq "Je comprends que le xpenology sera WIPÉ (données détruites)"
|
||||
prereq "Je comprends que le xpenology sera ÉTEINT et DÉBRANCHÉ, disques conservés intacts (0 effacement)"
|
||||
|
||||
echo
|
||||
read -r -p "Saisir 'WIPE XPENOLOGY' pour confirmer la destruction : " CONF
|
||||
[[ "$CONF" == "WIPE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon."
|
||||
read -r -p "Saisir 'ETEINDRE XPENOLOGY' pour confirmer l'arrêt et le débranchement : " CONF
|
||||
[[ "$CONF" == "ETEINDRE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon."
|
||||
|
||||
log "Arrêt des conteneurs + arrêt du système xpenology"
|
||||
# L'arrêt propre se fait de préférence via la GUI DSM (arrêt système).
|
||||
# En SSH, on arrête les conteneurs puis le système :
|
||||
ssh -p 222 Master@"$XP_HOST" "sudo /var/packages/ContainerManager/target/usr/bin/docker stop \$(/var/packages/ContainerManager/target/usr/bin/docker ps -q) 2>/dev/null || true"
|
||||
journal "Conteneurs xpenology arrêtés" "Arrêt propre avant wipe (G3)" "docker stop sur tous les conteneurs" "redémarrage du xpenology"
|
||||
journal "Conteneurs xpenology arrêtés" "Arrêt propre avant débranchement (G3)" "docker stop sur tous les conteneurs" "redémarrage du xpenology"
|
||||
|
||||
echo
|
||||
echo "⚠️ ÉTAPE MANUELLE OBLIGATOIRE :"
|
||||
echo " 1. Dans la GUI DSM : arrêter proprement le système."
|
||||
echo " 2. Débrancher le xpenology."
|
||||
echo " 3. (Optionnel) Wipe physique des disques si réutilisation/vente."
|
||||
echo " 3. Étiqueter les disques et les CONSERVER INTACTS (0 effacement — décision 12/10)."
|
||||
echo " 4. Récupération ultérieure possible : procédure testée dans RESTORE-NAS-USB.md."
|
||||
echo " (le wipe physique n'est PAS dans le périmètre du kit ; réutilisation/vente = décision séparée)"
|
||||
echo
|
||||
read -r -p "Une fois le xpenology éteint et débranché, saisir 'DONE' pour journaliser : " FINAL
|
||||
[[ "$FINAL" == "DONE" ]] || die "Non confirmé."
|
||||
|
||||
journal "xpenology décommissionné" "G9 = seul cœur famille (G4/G5)" "éteint + débranché, G9 sur .25" "aucun (irréversible) — backup 3-2-1 testé au préalable"
|
||||
journal "xpenology éteint et débranché (disques intacts)" "G9 = seul cœur famille (G4/G5)" "éteint + débranché, G9 sur .25, 0 effacement" "rebrancher le xpenology (données intactes) ; backup 3-2-1 testé au préalable"
|
||||
log "Terminé. Le G9 est maintenant le seul cœur famille."
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# 09-j5040-dhcp.sh — E3 : DHCP sur le J5040 (dnsmasq) en préparation du mode bridge Freebox.
|
||||
#
|
||||
# RÔLE : dnsmasq = DHCP uniquement (DNS coupé : port=0, le DNS LAN = AdGuard .27:53).
|
||||
# Les baux statiques sont lus depuis /srv/dnsmasq/leases (format: MAC,IP,nom — 1 par ligne),
|
||||
# repris de la config DHCP Freebox (baux statiques existants, mêmes IP → G6 : pas de changement d'IP).
|
||||
#
|
||||
# ⚠️ BASCULE (G6) : ce script active le DHCP sur le J5040. La bascule Freebox (DHCP off /
|
||||
# mode bridge) est une ACTION UTILISATEUR explicite. Rollback = couper dnsmasq + rallumer
|
||||
# le DHCP Freebox (baux statiques Freebox intacts, rien n'y a été supprimé).
|
||||
#
|
||||
# Usage : sudo bash 09-j5040-dhcp.sh [interface_LAN] (défaut : enp1s0)
|
||||
set -euo pipefail
|
||||
IFACE="${1:-enp1s0}"
|
||||
LEASES=/srv/dnsmasq/leases
|
||||
log() { echo -e "\n[dhcp] $*"; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "ERREUR : sudo"; exit 1; }
|
||||
[[ -f "$LEASES" ]] || { echo "ERREUR : $LEASES absent (copier la liste des baux statiques Freebox, format MAC,IP,nom)"; exit 1; }
|
||||
|
||||
log "installation dnsmasq"
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq dnsmasq
|
||||
systemctl disable --now dnsmasq 2>/dev/null || true
|
||||
|
||||
log "config dnsmasq (DHCP only, DNS coupé)"
|
||||
cat > /etc/dnsmasq.conf << EOF
|
||||
interface=$IFACE
|
||||
bind-interfaces
|
||||
port=0
|
||||
dhcp-range=192.168.1.100,192.168.1.240,12h
|
||||
dhcp-option=6,192.168.1.27
|
||||
dhcp-option=3,192.168.1.27
|
||||
no-resolv
|
||||
strict-order
|
||||
dhcp-lease-max=256
|
||||
dhcp-no-override
|
||||
EOF
|
||||
while IFS=, read -r mac ip name; do
|
||||
[[ -z "${mac// }" || "$mac" == \#* ]] && continue
|
||||
echo "dhcp-host=$mac,$ip,$name"
|
||||
done < "$LEASES" >> /etc/dnsmasq.conf
|
||||
dnsmasq --test && log "config dnsmasq valide"
|
||||
systemctl enable --now dnsmasq
|
||||
log "DHCP actif sur $IFACE (baux = $LEASES). Bascule Freebox = action utilisateur (mode bridge / DHCP off)."
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# 10-verify-restore-photo.sh — Critère 3 : preuve que le snapshot restic contient les photos
|
||||
# et qu'un fichier photo se restaure à l'identique (sha256 vs source .25).
|
||||
#
|
||||
# Chiffrement MAINTENU (décision utilisateur 11/10) : clé de repo locale G9 (/etc/restic/repo.key),
|
||||
# restore via sudo sur le G9. Source .25 en lecture seule (G4).
|
||||
#
|
||||
# Usage : sur la machine de pilotage (détenant la clé), ./10-verify-restore-photo.sh
|
||||
set -euo pipefail
|
||||
|
||||
XP_USER="Master"; XP_HOST="192.168.1.25"; XP_PORT=222
|
||||
XP_PHOTO="/volume1/photo"
|
||||
G9_USER="folzo"; G9_HOST="192.168.1.24"
|
||||
KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
|
||||
G9_ORIG="/data/docker/photoprism/originals"
|
||||
TARGET="/tmp/restore-photo-test"
|
||||
JOURNAL="$(cd "$(dirname "$0")/.." && pwd)/journal.md"
|
||||
|
||||
SSH_XP="ssh -p $XP_PORT -i $KEY -o ConnectTimeout=10 $XP_USER@$XP_HOST"
|
||||
SSH_G9="ssh -i $KEY -o ConnectTimeout=10 $G9_USER@$G9_HOST"
|
||||
RST_PRELUDE='set -a; . /etc/restic.env; set +a'
|
||||
|
||||
log() { echo -e "\n[restore-test] $*"; }
|
||||
journal() {
|
||||
printf '| %s | 4 | %s | %s | %s | %s |\n' \
|
||||
"$(date '+%Y-%m-%d %H:%M')" "$1" "$2" "$3" "$4" >> "$JOURNAL"
|
||||
}
|
||||
|
||||
log "=== 1/3 snapshot g9 le plus récent ==="
|
||||
SNAP=$($SSH_G9 "sudo bash -s" <<EOF
|
||||
$RST_PRELUDE
|
||||
restic -r "\$RESTIC_REPOSITORY" snapshots --tag g9 --json 2>/dev/null | jq -r '.[-1].id'
|
||||
EOF
|
||||
)
|
||||
[[ "$SNAP" =~ ^[0-9a-f]{8,}$ ]] || { echo "ERREUR : snapshot introuvable"; exit 1; }
|
||||
log "snapshot = $SNAP"
|
||||
N=$($SSH_G9 "sudo bash -s -- '$G9_ORIG' '$SNAP'" <<'EOF'
|
||||
set -a; . /etc/restic.env; set +a
|
||||
restic -r "$RESTIC_REPOSITORY" ls "$2" 2>/dev/null | grep -c "$1"
|
||||
true
|
||||
EOF
|
||||
)
|
||||
[[ "${N:-0}" -gt 0 ]] || { echo "ERREUR : le snapshot $SNAP ne contient pas $G9_ORIG"; exit 1; }
|
||||
log " ✅ snapshot contient $N entrées sous $G9_ORIG"
|
||||
|
||||
log "=== 2/3 restauration d'un fichier photo ==="
|
||||
G9_META="${HOME}/migration-staging/photoprism/g9.meta"
|
||||
if [[ -f "$G9_META" ]]; then
|
||||
REL=$(cut -f1 "$G9_META" | grep -i '\.jpg$' | LC_ALL=C sort | tail -1)
|
||||
REL="${G9_ORIG}/${REL#./}"
|
||||
else
|
||||
REL=$($SSH_G9 "find '$G9_ORIG' -type f -iname '*.jpg' | LC_ALL=C sort | tail -1")
|
||||
fi
|
||||
log "fichier test = ${REL#$G9_ORIG/}"
|
||||
B64=$(printf '%s' "$REL" | base64 -w0)
|
||||
$SSH_G9 "sudo bash -s -- '$B64' '$SNAP' '$TARGET'" <<'EOF'
|
||||
set -a; . /etc/restic.env; set +a
|
||||
rel=$(printf '%s' "$1" | base64 -d)
|
||||
rm -rf "$3"
|
||||
restic -r "$RESTIC_REPOSITORY" restore --target "$3" --include "*${rel##*/}" "$2" >/dev/null
|
||||
EOF
|
||||
$SSH_G9 "sudo bash -s -- '$B64' '$TARGET'" <<'EOF' || { echo "ERREUR : fichier restauré absent"; exit 1; }
|
||||
rel=$(printf '%s' "$1" | base64 -d)
|
||||
test -f "$2$rel"
|
||||
EOF
|
||||
log "=== 3/3 sha256 restauré vs source .25 ==="
|
||||
H_SRC=$($SSH_XP "sudo bash -s -- '$B64' '$XP_PHOTO'" <<'EOF'
|
||||
f=$(printf '%s' "$1" | base64 -d)
|
||||
sudo sha256sum "$2${f#/data/docker/photoprism/originals}" | cut -d' ' -f1
|
||||
EOF
|
||||
)
|
||||
H_RST=$($SSH_G9 "sudo bash -s -- '$B64' '$TARGET'" <<'EOF'
|
||||
f=$(printf '%s' "$1" | base64 -d)
|
||||
sudo sha256sum "$2$f" | cut -d' ' -f1
|
||||
EOF
|
||||
)
|
||||
$SSH_G9 "sudo rm -rf '$TARGET'"
|
||||
if [[ "$H_SRC" == "$H_RST" && -n "$H_SRC" ]]; then
|
||||
log "✅ sha256 identique : $H_RST"
|
||||
journal "test restauration photo depuis restic" "Critère 3 : preuve restore du snapshot incluant les photos" "snapshot $SNAP contient $N entrées sous $G9_ORIG ; restore 1 fichier ; sha256 restauré == source .25 (${H_RST:0:12}…) ; chiffrement maintenu (clé locale G9)" "rm -rf $TARGET (fait)"
|
||||
else
|
||||
log "❌ sha256 divergents : src=$H_SRC rst=$H_RST"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 11-registry.sh — T-A.1 + T-A.5 : moitié technique du registre, GÉNÉRÉE, + détection de dérive.
|
||||
#
|
||||
# Lecture seule : `docker ps` / `docker inspect` distants, lecture des compose du kit.
|
||||
# Aucun conteneur n'est démarré, arrêté, modifié. Aucun secret n'est lu ni affiché.
|
||||
#
|
||||
# Modes :
|
||||
# ./11-registry.sh génère REGISTRE-BRIQUES.md
|
||||
# ./11-registry.sh --check régénère en mémoire, compare, alerte si dérive (rc=1)
|
||||
# ./11-registry.sh --check --alert idem + envoie le payload JSON dans le salon ops
|
||||
#
|
||||
# Sortie : REGISTRE-BRIQUES.md (moitié A, générée — ne pas éditer à la main)
|
||||
# La moitié humaine (comptes, reset, responsables) est dans REGISTRE-IDENTITES.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KIT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="$KIT/REGISTRE-BRIQUES.md"
|
||||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
|
||||
HOSTS="${HOSTS:-g9:folzo@192.168.1.24 j5040:folzo@192.168.1.27}"
|
||||
RESTIC_COVERAGE="${RESTIC_COVERAGE:-g9=restic (sources /data /home/folzo /etc, cron 03:30) ; j5040=non (à couvrir Phase 5)}"
|
||||
MODE="generate"
|
||||
ALERT=0
|
||||
for a in "$@"; do
|
||||
if [[ "$a" == "--check" ]]; then MODE="check"; fi
|
||||
if [[ "$a" == "--alert" ]]; then ALERT=1; fi
|
||||
done
|
||||
export SSH_KEY
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
declared_json="$(python3 "$KIT/scripts/registry_inputs.py" declared)"
|
||||
caddy_json="$(python3 "$KIT/scripts/registry_inputs.py" caddy)"
|
||||
echo "$caddy_json" > "$TMP/caddy.json"
|
||||
|
||||
rows_all="$TMP/rows.jsonl"
|
||||
findings_all="$TMP/findings.txt"
|
||||
: > "$rows_all"
|
||||
: > "$findings_all"
|
||||
|
||||
for entry in $HOSTS; do
|
||||
machine="${entry%%:*}"
|
||||
host="${entry#*:}"
|
||||
mip="${host#*@}"
|
||||
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$SSH_KEY" "$host" \
|
||||
'docker ps -a --format json' > "$TMP/$machine.ps.json" 2>"$TMP/$machine.err" || {
|
||||
echo "[registry] SSH/docker indisponible sur $machine ($host)" >&2
|
||||
echo "$machine: inaccessible (SSH ou docker)" >> "$findings_all"
|
||||
continue
|
||||
}
|
||||
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$SSH_KEY" "$host" \
|
||||
'docker ps --format "{{.Names}}\t{{.Ports}}"' > "$TMP/$machine.ports.tsv" 2>/dev/null || : > "$TMP/$machine.ports.tsv"
|
||||
python3 "$KIT/scripts/registry_inputs.py" endpoints "$TMP/$machine.ports.tsv" "$TMP/caddy.json" "$mip" > "$TMP/$machine.ep.json"
|
||||
python3 "$KIT/scripts/registry_parse.py" "$machine" "$(cat "$TMP/$machine.ep.json")" "$declared_json" \
|
||||
< "$TMP/$machine.ps.json" > "$TMP/$machine.rows.json" 2>"$TMP/$machine.findings"
|
||||
python3 -c 'import json,sys
|
||||
for r in json.load(open(sys.argv[1])):
|
||||
print(json.dumps(r, ensure_ascii=False))' "$TMP/$machine.rows.json" >> "$rows_all"
|
||||
sed -n 's/^#findings# //p' "$TMP/$machine.findings" | python3 -c 'import json,sys
|
||||
for line in sys.stdin:
|
||||
line=line.strip()
|
||||
if line:
|
||||
for f in json.loads(line): print(f)' >> "$findings_all"
|
||||
done
|
||||
|
||||
nb_conteneurs="$(wc -l < "$rows_all" | tr -d ' ')"
|
||||
nb_findings="$(grep -c . "$findings_all" || true)"
|
||||
|
||||
generated="$(python3 - "$rows_all" "$nb_conteneurs" <<'PY'
|
||||
import json, sys, datetime
|
||||
rows = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
|
||||
ts = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
print(f"<!-- GÉNÉRÉ PAR scripts/11-registry.sh — ne pas éditer à la main. ts={ts} ; {sys.argv[2]} conteneurs -->")
|
||||
print()
|
||||
print("| Machine | Conteneur | Projet / service (compose) | Image | État | Épinglée | Watchtower | Type | Dans le kit | Endpoint (Caddy) |")
|
||||
print("|---|---|---|---|---|---|---|---|---|---|")
|
||||
for r in rows:
|
||||
print(f"| {r['machine']} | `{r['name']}` | {r['project']} / {r['service']} | `{r['image']}` | {r['state']} | {r['pinned']} | {r['wt']} | {r['stateful']} | {r['kit']} | {r['endpoint'] or '—'} |")
|
||||
PY
|
||||
)"
|
||||
|
||||
render="$(cat <<EOF
|
||||
# REGISTRE-BRIQUES — moitié technique (générée)
|
||||
|
||||
> Source : \`scripts/11-registry.sh\` (\`docker ps\` + \`docker inspect\` en **lecture seule** sur .24 et .27,
|
||||
> compose du kit, Caddyfile pour les endpoints). **Ne pas éditer à la main** : la moitié humaine
|
||||
> (comptes, 2FA, procédure de réinitialisation, responsable, break-glass) vit dans
|
||||
> \`REGISTRE-IDENTITES.md\`.
|
||||
>
|
||||
> Périmètre (T-A.10) : **.24 (G9) et .27 (J5040) uniquement**. Le xpenology **.25 est hors périmètre** :
|
||||
> il est décrit dans \`INVENTAIRE-STATEFUL.md\` et ses briques sont **à mourir en Phase 6**.
|
||||
>
|
||||
> Couverture backup : $RESTIC_COVERAGE
|
||||
|
||||
$generated
|
||||
|
||||
## Constats automatiques (détection de dérive)
|
||||
|
||||
$(if [[ -s "$findings_all" ]]; then sed 's/^/- /' "$findings_all"; else echo "- aucun"; fi)
|
||||
|
||||
## Utiliser ce fichier
|
||||
|
||||
- \`./11-registry.sh\` : régénère (à lancer après chaque déploiement / MAJ d'image).
|
||||
- \`./11-registry.sh --check --alert\` : régénère, compare à la version commitée, **alerte en payload JSON**
|
||||
si le réel a dérivé du registre (label perdu, image non épinglée, conteneur hors kit, état ≠ running).
|
||||
- Un \`git diff\` sur ce fichier est, par construction, la détection de dérive.
|
||||
EOF
|
||||
)"
|
||||
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo "[registry] REGISTRE-BRIQUES.md absent — générer d'abord" >&2
|
||||
exit 2
|
||||
fi
|
||||
base="$(git -C "$KIT" show HEAD:g9-migration/REGISTRE-BRIQUES.md 2>/dev/null || git -C "$KIT" show HEAD:REGISTRE-BRIQUES.md 2>/dev/null || true)"
|
||||
# La ligne d'en-tête porte un horodatage : elle est exclue par motif (elle n'est pas
|
||||
# à une ligne fixe, elle suit un bloc de citations), sinon tout --check signalerait
|
||||
# une dérive permanente.
|
||||
strip() { printf '%s' "$1" | grep -v '^<!-- GÉNÉRÉ PAR'; }
|
||||
body_new="$(strip "$render")"
|
||||
body_old="$(strip "$base")"
|
||||
if [[ -z "$body_old" ]]; then
|
||||
echo "[registry] pas de version commitée de comparaison" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$(printf '%s' "$body_new" | md5sum)" == "$(printf '%s' "$body_old" | md5sum)" ]]; then
|
||||
echo "[registry] aucune dérive ($nb_conteneurs conteneurs, $nb_findings constats)"
|
||||
exit 0
|
||||
fi
|
||||
echo "[registry] DÉRIVE détectée :"
|
||||
printf '%s\n' "$nb_findings constats"
|
||||
if [[ "$ALERT" == "1" ]]; then
|
||||
source "$KIT/scripts/lib-notify.sh"
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"10-registry","brique":"registre","host":"g9","check":"registry-drift","http_code":0,"severite":"warn","message":sys.argv[2],"runbook":"RUNBOOKS/registre.md"},ensure_ascii=False))' "$ts" "$nb_conteneurs conteneurs, $nb_findings constats automatiques, diff registre/réel à lire dans REGISTRE-BRIQUES.md")"
|
||||
notify_json "$payload"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$render" > "$OUT"
|
||||
echo "[registry] $OUT généré : $nb_conteneurs conteneurs, $nb_findings constats automatiques"
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 12-probes.sh — T-C.1/T-C.2/T-C.5 : sondes APPLICATIVES + alertes JSON + digest.
|
||||
#
|
||||
# Pourquoi : le trou mesuré est `up ≠ utilisable` (Nextcloud 503 en maintenance, Collabora unhealthy,
|
||||
# Jellyfin auth KO, accès n8n perdu, monde Valheim perdu : le conteneur était up dans tous les cas).
|
||||
# Beszel répond « le conteneur tourne ». Ici on répond « la fonction répond ».
|
||||
#
|
||||
# S'exécute SUR LE G9 (root, cron). Lecture seule : HTTP GET + `occ status`. Aucune écriture,
|
||||
# aucun conteneur démarré/arrêté.
|
||||
#
|
||||
# Usage :
|
||||
# ./12-probes.sh une passe (imprime les résultats, alerte si KO)
|
||||
# ./12-probes.sh --quiet une passe sans alerte (usage test)
|
||||
# ./12-probes.sh --digest digest quotidien (1 message, pas N)
|
||||
# ./12-probes.sh --install installe /usr/local/bin/g9-probes.sh + cron (*/15 + digest 08:30)
|
||||
# ./12-probes.sh --uninstall retire le script + les 2 lignes de cron
|
||||
#
|
||||
# Alertes : payload JSON {ts, source, brique, host, check, http_code, severite, message, runbook}
|
||||
# Identifiants Matrix : /etc/restic/matrix.env (600 root, jamais affichés).
|
||||
# Anti-bruit : une alerte par brique et par heure max (état dans /var/lib/g9-probes).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATE_DIR="${STATE_DIR:-/var/lib/g9-probes}"
|
||||
MATRIX_ENV="${MATRIX_ENV:-/etc/restic/matrix.env}"
|
||||
HOST="$(hostname)"
|
||||
QUIET=0
|
||||
MODE="probe"
|
||||
for a in "$@"; do
|
||||
if [[ "$a" == "--quiet" ]]; then QUIET=1; fi
|
||||
if [[ "$a" == "--digest" ]]; then MODE="digest"; fi
|
||||
if [[ "$a" == "--install" ]]; then MODE="install"; fi
|
||||
if [[ "$a" == "--selftest" ]]; then MODE="selftest"; fi
|
||||
if [[ "$a" == "--uninstall" ]]; then MODE="uninstall"; fi
|
||||
done
|
||||
|
||||
case "$MODE" in
|
||||
install)
|
||||
install -m 755 "$0" /usr/local/bin/g9-probes.sh
|
||||
mkdir -p "$STATE_DIR"
|
||||
current="$(crontab -l 2>/dev/null || true)"
|
||||
printf '%s\n' "$current" | grep -v 'g9-probes.sh' > /tmp/cron.new || true
|
||||
cat >> /tmp/cron.new <<'EOF'
|
||||
*/15 * * * * /usr/local/bin/g9-probes.sh >/dev/null 2>&1
|
||||
30 8 * * * /usr/local/bin/g9-probes.sh --digest >/dev/null 2>&1
|
||||
EOF
|
||||
sed -i '/^$/d' /tmp/cron.new
|
||||
crontab /tmp/cron.new
|
||||
rm -f /tmp/cron.new
|
||||
echo "install OK : /usr/local/bin/g9-probes.sh + 2 lignes cron ($(crontab -l | grep -c 'g9-probes.sh') lignes)"
|
||||
exit 0
|
||||
;;
|
||||
uninstall)
|
||||
crontab -l 2>/dev/null | grep -v 'g9-probes.sh' | crontab - || true
|
||||
rm -f /usr/local/bin/g9-probes.sh
|
||||
rm -rf "$STATE_DIR"
|
||||
echo "uninstall OK (cron retiré, script et état supprimés)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
notify() {
|
||||
local payload="$1"
|
||||
if [[ "$QUIET" == "1" ]]; then return 0; fi
|
||||
python3 /usr/local/bin/g9-notify.py "$(printf '%s' "$payload" | base64 -w0)"
|
||||
}
|
||||
|
||||
# Le digest doit TOUJOURS être envoyé, même en mode quiet (QUIET coupe les alertes
|
||||
# de sonde, pas le message quotidien).
|
||||
notify_force() {
|
||||
local payload="$1"
|
||||
python3 /usr/local/bin/g9-notify.py "$(printf '%s' "$payload" | base64 -w0)"
|
||||
}
|
||||
|
||||
alert() {
|
||||
local brique="$1" check="$2" code="$3" sev="$4" msg="$5"
|
||||
local key ts payload
|
||||
key="$(echo "$brique" | tr -c 'a-z0-9' '_')"
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
# anti-bruit : 1 alerte / brique / heure (état séparé des OK)
|
||||
if [[ -f "$STATE_DIR/alert-$key" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$STATE_DIR/alert-$key") )) -lt 3600 ]]; then
|
||||
echo "KO(ignoré, alerte <1h) $brique $check"
|
||||
return 0
|
||||
fi
|
||||
touch "$STATE_DIR/alert-$key"
|
||||
payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"12-probes","brique":sys.argv[2],"host":sys.argv[3],"check":sys.argv[4],"http_code":int(sys.argv[5]) if sys.argv[5].isdigit() else 0,"severite":sys.argv[6],"message":sys.argv[7],"runbook":f"RUNBOOKS/{sys.argv[2]}.md"},ensure_ascii=False))' "$ts" "$brique" "$HOST" "$check" "$code" "$sev" "$msg")"
|
||||
echo "KO $brique $check ($code) -> $msg"
|
||||
notify "$payload"
|
||||
}
|
||||
|
||||
ok() { echo "OK $1 $2"; touch "$STATE_DIR/$(echo "$1" | tr -c 'a-z0-9' '_')"; }
|
||||
|
||||
http_probe() {
|
||||
local brique="$1" check="$2" url="$3" expect="$4" sev="${5:-high}"
|
||||
local code
|
||||
code="$(curl -skL -o /dev/null -w '%{http_code}' -m 10 "$url" 2>/dev/null || true)"
|
||||
if [[ -z "$code" ]]; then code="000"; fi
|
||||
if [[ "$code" == "$expect" || ( "$expect" == "2xx" && "$code" =~ ^2 ) ]]; then
|
||||
ok "$brique" "$check ($code)"
|
||||
return 0
|
||||
fi
|
||||
alert "$brique" "$check" "$code" "$sev" "attendu $expect sur $url"
|
||||
return 0
|
||||
}
|
||||
|
||||
tcp_probe() {
|
||||
local brique="$1" check="$2" host="$3" port="$4"
|
||||
if timeout 5 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||
ok "$brique" "$check"
|
||||
return 0
|
||||
fi
|
||||
alert "$brique" "$check" 0 high "TCP $host:$port refusé/injoignable"
|
||||
return 0
|
||||
}
|
||||
|
||||
occ_probe() {
|
||||
local brique="$1" check="$2" container="$3" grep_for="$4"
|
||||
local out
|
||||
out="$(docker exec "$container" php occ "$check" 2>/dev/null || true)"
|
||||
if printf '%s' "$out" | grep -q "$grep_for"; then
|
||||
ok "$brique" "$check"
|
||||
return 0
|
||||
fi
|
||||
alert "$brique" "$check" 0 "high" "occ $check : « $grep_for » absent ($(printf '%s' "$out" | head -c 120))"
|
||||
return 1
|
||||
}
|
||||
|
||||
run_all() {
|
||||
http_probe nextcloud "GET /login" "http://127.0.0.1:8080/index.php/login" 2xx
|
||||
occ_probe nextcloud "status" nextcloud "installed: true"
|
||||
http_probe vaultwarden "GET /alive" "http://127.0.0.1:8081/alive" 200
|
||||
http_probe gitea "GET /api/v1/version" "http://127.0.0.1:3000/api/v1/version" 200
|
||||
http_probe jellyfin "GET /System/Info/Public" "http://127.0.0.1:8096/System/Info/Public" 200
|
||||
http_probe photoprism "GET /" "http://127.0.0.1:2342/" 200
|
||||
http_probe collabora "GET /hosting/discovery" "http://127.0.0.1:9980/hosting/discovery" 200
|
||||
http_probe n8n "GET /healthz" "http://127.0.0.1:5678/healthz" 200
|
||||
http_probe synapse "GET /_matrix/client/versions" "http://127.0.0.1:8008/_matrix/client/versions" 200
|
||||
http_probe element "GET /" "http://127.0.0.1:8009/" 200
|
||||
tcp_probe edge "TCP 443 (caddy G9 à l'écoute)" 127.0.0.1 443
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "selftest" ]]; then
|
||||
# Provocation non destructive : sonde volontairement injoignable → prouve la chaîne
|
||||
# détection → payload JSON → salon ops. Aucune brique n'est touchée.
|
||||
QUIET=0
|
||||
rm -f "$STATE_DIR/alert-selftest"
|
||||
alert selftest "TCP 127.0.0.1:9 (sonde volontairement injoignable)" 0 medium "selftest 12-probes : provocation d'un KO pour prouver la chaîne d'alerte"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
results="$(mktemp)"
|
||||
if [[ "$MODE" == "digest" ]]; then QUIET=1; fi
|
||||
{
|
||||
run_all
|
||||
http_probe edge_wan "GET cloud (TLS J5040)" "https://cloud.folzocorp.fr/" 200 "medium"
|
||||
http_probe adguard "GET UI .27" "http://192.168.1.27:8081/" 2xx "medium"
|
||||
} >"$results" 2>&1 || true
|
||||
cat "$results"
|
||||
nb_ok="$(grep -c '^OK' "$results" || true)"
|
||||
nb_ko="$(grep -c '^KO' "$results" || true)"
|
||||
|
||||
if [[ "$MODE" == "digest" ]]; then
|
||||
summary="$(grep '^KO' "$results" | sed 's/^KO //' | head -8 | tr '\n' '; ' || true)"
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
payload="$(python3 -c 'import json,sys; print(json.dumps({"ts":sys.argv[1],"source":"12-probes","brique":"digest","host":sys.argv[2],"check":"daily","http_code":0,"severite":"info","message":f"{sys.argv[3]} OK / {sys.argv[4]} KO — {sys.argv[5]}","runbook":"RUNBOOKS/digest.md"},ensure_ascii=False))' "$ts" "$HOST" "$nb_ok" "$nb_ko" "${summary:-tout OK}")"
|
||||
notify_force "$payload"
|
||||
fi
|
||||
echo "sonde : $nb_ok OK / $nb_ko KO"
|
||||
rm -f "$results"
|
||||
@@ -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 ""
|
||||
@@ -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'
|
||||
@@ -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)."
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""g9-notify.py — poste le payload JSON (base64 dans REGISTRY_B64) dans le salon ops.
|
||||
|
||||
Installé sur le G9 (/usr/local/bin/g9-notify.py). Lit /etc/restic/matrix.env (600 root).
|
||||
N'affiche JAMAIS le token. Pattern = restic-notify.sh (Bearer + room URL-encodé + PUT + txnId).
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ENV_PATH = "/etc/restic/matrix.env"
|
||||
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
with open(ENV_PATH) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v.strip().strip('"')
|
||||
return env
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
env = load_env()
|
||||
# sudo ne transmet pas l'environnement (sudoers sans SETENV) : le payload passe
|
||||
# en argument, l'env reste accepté pour les appels cron.
|
||||
raw = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("REGISTRY_B64", "")
|
||||
if not raw:
|
||||
print("NOTIFY_KO no-payload")
|
||||
return
|
||||
try:
|
||||
payload = json.loads(base64.b64decode(raw).decode())
|
||||
except Exception as e:
|
||||
print("NOTIFY_KO bad-payload", type(e).__name__)
|
||||
return
|
||||
body = {"msgtype": "m.text", "body": json.dumps(payload, ensure_ascii=False)}
|
||||
room = urllib.parse.quote(env["MATRIX_ROOM"], safe="")
|
||||
txn = "openfox-%d" % int(time.time() * 1000)
|
||||
url = f"{env['MATRIX_SERVER']}/_matrix/client/v3/rooms/{room}/send/m.room.message/{txn}"
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(body).encode(), method="PUT",
|
||||
headers={"Authorization": "Bearer " + env["MATRIX_TOKEN"],
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
print("NOTIFY_OK", r.status)
|
||||
except Exception as e:
|
||||
print("NOTIFY_KO", type(e).__name__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""g9-verify-alert.py — vérifie que les dernières alertes du salon ops sont des payloads JSON
|
||||
conformes au contrat C-AP2 {ts, source, brique, host, check, http_code, severite, message, runbook}.
|
||||
|
||||
Lit /etc/restic/matrix.env (600 root). Lecture seule (GET /messages). N'affiche jamais le token.
|
||||
Installé sur le G9 : /usr/local/bin/g9-verify-alert.py
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ENV_PATH = "/etc/restic/matrix.env"
|
||||
REQUIRED = ["ts", "source", "brique", "host", "check", "http_code", "severite", "message", "runbook"]
|
||||
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
with open(ENV_PATH) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v.strip().strip('"')
|
||||
return env
|
||||
|
||||
|
||||
def fetch(limit):
|
||||
env = load_env()
|
||||
room = urllib.parse.quote(env["MATRIX_ROOM"], safe="")
|
||||
url = f"{env['MATRIX_SERVER']}/_matrix/client/v3/rooms/{room}/messages?dir=b&limit={limit}&filter=%7B%22limit%22%3A{limit}%7D"
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + env["MATRIX_TOKEN"]})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "--list":
|
||||
for ev in fetch(int(sys.argv[2])).get("chunk", []):
|
||||
if ev.get("type") not in ("m.room.message", "m.text"):
|
||||
continue
|
||||
body = ev.get("content", {}).get("body", "").replace("\n", " ")
|
||||
print(f"{ev.get('event_id')} {body[:170]}")
|
||||
return
|
||||
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 15
|
||||
env = load_env()
|
||||
room = urllib.parse.quote(env["MATRIX_ROOM"], safe="")
|
||||
url = f"{env['MATRIX_SERVER']}/_matrix/client/v3/rooms/{room}/messages?dir=b&limit={limit}&filter=%7B%22limit%22%3A{limit}%7D"
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + env["MATRIX_TOKEN"]})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
ok = 0
|
||||
bad = 0
|
||||
seen = []
|
||||
for ev in data.get("chunk", []):
|
||||
if ev.get("type") not in ("m.room.message", "m.text"):
|
||||
continue
|
||||
body = ev.get("content", {}).get("body", "")
|
||||
try:
|
||||
p = json.loads(body)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(p, dict) or "source" not in p:
|
||||
continue
|
||||
missing = [k for k in REQUIRED if k not in p]
|
||||
if missing:
|
||||
bad += 1
|
||||
print(f"NON CONFORME source={p.get('source')} manquant={','.join(missing)}")
|
||||
else:
|
||||
ok += 1
|
||||
seen.append((p["source"], p["severite"], p["brique"], p["ts"]))
|
||||
print(f"payloads JSON conformes: {ok} | non conformes: {bad}")
|
||||
for s in seen[:8]:
|
||||
print(" ", s[0], s[1], s[2], s[3])
|
||||
print("VERIFY_OK" if ok > 0 else "VERIFY_KO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib-notify.sh — notification Matrix en payload JSON structuré (C-AP2).
|
||||
# Identifiants : /etc/restic/matrix.env sur le G9 (600 root, jamais affichés, jamais copiés ici).
|
||||
# Usage : source lib-notify.sh puis notify_json '<json>' (ou notify_simple <source> <brique> <sev> <msg>)
|
||||
# Pattern d'envoi identique à restic-notify.sh : Bearer + room URL-encodé + PUT avec txnId.
|
||||
|
||||
G9_SSH="${G9_SSH:-folzo@192.168.1.24}"
|
||||
G9_SSH_KEY="${G9_SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
|
||||
|
||||
notify_json() {
|
||||
local payload="$1" b64
|
||||
if [ -z "$payload" ]; then
|
||||
echo "NOTIFY_KO no-payload"
|
||||
return 1
|
||||
fi
|
||||
b64="$(printf '%s' "$payload" | base64 -w0)"
|
||||
# sudo ne transmet pas l'environnement (sudoers sans SETENV) : payload et URL
|
||||
# passent en arguments, pas en variables exportées.
|
||||
ssh -o ConnectTimeout=8 -o BatchMode=yes -i "$G9_SSH_KEY" "$G9_SSH" \
|
||||
"sudo python3 /usr/local/bin/g9-notify.py '$b64' '$(cat "$HOME/.config/g9-migration/hub_url" 2>/dev/null)'" 2>&1 | tail -1
|
||||
}
|
||||
|
||||
notify_simple() {
|
||||
local source="$1" brique="$2" severity="$3" message="$3"
|
||||
:
|
||||
}
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helpers for 10-registry.sh — lit le kit (lecture seule) et fournit les entrées du registre.
|
||||
|
||||
declared <kit_dir> -> JSON [{project, service, image}] depuis compose/*/docker-compose.yml
|
||||
caddy <kit_dir> -> JSON {port: sous-domaine} depuis compose/*/Caddyfile
|
||||
endpoints <ports_tsv> <caddy_json> -> JSON {conteneur: sous-domaine}
|
||||
Aucun conteneur n'est touché.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def declared(kit):
|
||||
out = []
|
||||
cdir = os.path.join(kit, "compose")
|
||||
if not os.path.isdir(cdir):
|
||||
return out
|
||||
for project in sorted(os.listdir(cdir)):
|
||||
f = os.path.join(cdir, project, "docker-compose.yml")
|
||||
if not os.path.isfile(f):
|
||||
continue
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(open(f)) or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
services = data.get("services") or {}
|
||||
for service, spec in services.items():
|
||||
image = "-"
|
||||
if isinstance(spec, dict):
|
||||
image = spec.get("image", "-") or "-"
|
||||
out.append({"project": project, "service": service, "image": image})
|
||||
return out
|
||||
|
||||
|
||||
def caddy(kit):
|
||||
"""Liste [{site, ip, port, source}] — clé = (ip, port), donc pas de collision entre machines."""
|
||||
entries = []
|
||||
cdir = os.path.join(kit, "compose")
|
||||
if not os.path.isdir(cdir):
|
||||
return entries
|
||||
for project in sorted(os.listdir(cdir)):
|
||||
cf = os.path.join(cdir, project, "Caddyfile")
|
||||
if not os.path.isfile(cf):
|
||||
continue
|
||||
site = None
|
||||
for line in open(cf):
|
||||
s = line.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^((?:[a-z0-9*-]+\.[a-z0-9.-]+\s+)*[a-z0-9*-]+\.[a-z0-9.-]+)\s*\{$", s)
|
||||
if m:
|
||||
hosts = m.group(1).split()
|
||||
site = next((h for h in hosts if "folzocorp.fr" in h), None)
|
||||
continue
|
||||
m = re.search(r"reverse_proxy\s+(\S+):(\d+)", s)
|
||||
if m and site:
|
||||
ip, port = m.group(1), m.group(2)
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
|
||||
entries.append({"site": site, "ip": ip, "port": port, "source": project})
|
||||
return entries
|
||||
|
||||
|
||||
def endpoints(ports_tsv, caddy_json, machine_ip):
|
||||
entries = json.loads(open(caddy_json).read()) if os.path.isfile(caddy_json) else []
|
||||
cmap = {(e["ip"], e["port"]): e["site"] for e in entries}
|
||||
out = {}
|
||||
for line in open(ports_tsv):
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name, ports = parts[0], parts[1]
|
||||
found = ""
|
||||
for m in re.finditer(r":(\d+)->", ports):
|
||||
site = cmap.get((machine_ip, m.group(1)))
|
||||
if site:
|
||||
found = site
|
||||
break
|
||||
out[name] = found
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1]
|
||||
kit = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if cmd == "declared":
|
||||
print(json.dumps(declared(kit)))
|
||||
elif cmd == "caddy":
|
||||
print(json.dumps(caddy(kit)))
|
||||
elif cmd == "endpoints":
|
||||
print(json.dumps(endpoints(sys.argv[2], sys.argv[3], sys.argv[4])))
|
||||
else:
|
||||
sys.exit("usage: registry_inputs.py declared|caddy|endpoints")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse `docker ps -a --format json` (stdin) + kit compose declarations, emit markdown rows.
|
||||
|
||||
Read-only helper for 10-registry.sh. No container is touched.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
STATEFUL_HINTS = ("mariadb", "postgres", "redis", "influx", "couchdb", "db", "vault", "data")
|
||||
LABEL_WT = "com.centurylinklabs.watchtower.enable"
|
||||
LABEL_PROJECT = "com.docker.compose.project"
|
||||
LABEL_SERVICE = "com.docker.compose.service"
|
||||
|
||||
|
||||
def is_digest_id(image):
|
||||
return bool(re.fullmatch(r"[0-9a-f]{12,64}", image))
|
||||
|
||||
|
||||
def kit_matches(kit_image, real_image):
|
||||
"""Le kit épingle par digest : `name@sha256:<hex>` == réel `<hex court>` si préfixe."""
|
||||
if not kit_image or kit_image == "-":
|
||||
return True
|
||||
if kit_image == real_image:
|
||||
return True
|
||||
if "@sha256:" in kit_image and is_digest_id(real_image):
|
||||
digest = kit_image.split("@sha256:", 1)[1]
|
||||
return digest.startswith(real_image)
|
||||
return False
|
||||
|
||||
|
||||
def is_stateful(name, image, labels):
|
||||
if labels.get(LABEL_WT) == "false":
|
||||
return True
|
||||
low = f"{name} {image}".lower()
|
||||
return any(h in low for h in STATEFUL_HINTS)
|
||||
|
||||
|
||||
def main():
|
||||
machine = sys.argv[1]
|
||||
endpoints = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
||||
declared = json.loads(sys.argv[3]) if len(sys.argv) > 3 else []
|
||||
declared_keys = {(d["project"], d["service"]) for d in declared}
|
||||
declared_images = {f'{d["project"]}/{d["service"]}': d["image"] for d in declared}
|
||||
|
||||
rows = []
|
||||
findings = []
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
print("[]")
|
||||
print("#findings# []", file=sys.stderr)
|
||||
return
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
c = json.loads(line)
|
||||
name = c.get("Names", c.get("Name", "?"))
|
||||
image = c.get("Image", "?")
|
||||
state = c.get("State", "?")
|
||||
labels = {}
|
||||
for kv in str(c.get("Labels", "")).split(","):
|
||||
if "=" in kv:
|
||||
k, v = kv.split("=", 1)
|
||||
labels[k] = v
|
||||
project = labels.get(LABEL_PROJECT, "-")
|
||||
service = labels.get(LABEL_SERVICE, "-")
|
||||
wt = labels.get(LABEL_WT)
|
||||
wt_label = "true" if wt == "true" else ("false" if wt == "false" else "ABSENT")
|
||||
pinned = "non (latest)" if image.endswith(":latest") else "oui"
|
||||
if is_digest_id(image):
|
||||
pinned = "oui (digest)"
|
||||
key = f"{project}/{service}"
|
||||
declared_state = "oui" if (project, service) in declared_keys else "NON"
|
||||
drift_img = ""
|
||||
if declared_state == "oui" and not kit_matches(declared_images.get(key), image):
|
||||
drift_img = f"image kit={declared_images.get(key)} != réel={image}"
|
||||
endpoint = endpoints.get(f"{name}", "")
|
||||
rows.append({
|
||||
"machine": machine,
|
||||
"name": name,
|
||||
"project": project,
|
||||
"service": service,
|
||||
"image": image,
|
||||
"state": state,
|
||||
"pinned": pinned,
|
||||
"wt": wt_label,
|
||||
"stateful": "stateful" if is_stateful(name, image, labels) else "stateless",
|
||||
"kit": declared_state,
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
if pinned.startswith("non"):
|
||||
findings.append(f"{machine}/{name}: image non épinglée ({image}) — S11.4 pinning")
|
||||
if wt_label == "ABSENT":
|
||||
findings.append(f"{machine}/{name}: label watchtower absent — S11.4 sélectivité")
|
||||
if declared_state == "NON":
|
||||
findings.append(f"{machine}/{name}: conteneur réel non déclaré dans le kit (compose)")
|
||||
if drift_img:
|
||||
findings.append(f"{machine}/{name}: {drift_img} — dérive kit/réel")
|
||||
if state not in ("running",):
|
||||
findings.append(f"{machine}/{name}: état {state}")
|
||||
|
||||
rows.sort(key=lambda r: (r["machine"], r["name"]))
|
||||
print(json.dumps(rows, ensure_ascii=False))
|
||||
print("#findings# " + json.dumps(findings, ensure_ascii=False), file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user