Compare commits
42
Commits
a1864c69e7
..
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 | ||
|
|
366c5ca7a7 | ||
|
|
3716eee1c3 | ||
|
|
b198710876 | ||
|
|
12093067ae | ||
|
|
f718f65ce0 | ||
|
|
d76f6438c9 | ||
|
|
34e0d470c1 |
@@ -9,3 +9,9 @@ g9-migration/configs/restic.env
|
|||||||
|
|
||||||
# Sauvegardes générées par les scripts (ex. 01-hardening.sh)
|
# Sauvegardes générées par les scripts (ex. 01-hardening.sh)
|
||||||
*.bak.*
|
*.bak.*
|
||||||
|
|
||||||
|
# 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 |
|
| 5 | **WireGuard** | Accès distant chiffré | J5040 | GPL-2.0 | Système (noyau), très stable | Clés exportables |
|
||||||
| 6 | **Web Station** | Sites web (dd/lola.folzocorp…) | J5040 | — (contenu) | Statique | Fichiers, exportables |
|
| 6 | **Web Station** | Sites web (dd/lola.folzocorp…) | J5040 | — (contenu) | Statique | Fichiers, exportables |
|
||||||
| 7 | **Beszel** | Monitoring léger + alertes | J5040 | MIT | Mature, maintenu (henrygd) | Base SQLite, exportable |
|
| 7 | **Beszel** | Monitoring léger + alertes | J5040 | MIT | Mature, maintenu (henrygd) | Base SQLite, exportable |
|
||||||
| 8 | **Matrix (Synapse)** | **Messagerie** (remplace WhatsApp) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
|
| 8 | **Matrix (Synapse)** | **Messagerie du foyer** (complément de WhatsApp, **pas de remplacement**) | J5040 | Apache-2.0 | Très mature (Element/Matrix), maintenu | Export des salons (JSON), comptes |
|
||||||
| 9 | **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 | Mature (Jitsi Meet), maintenu | N/A (appels éphémères) |
|
| 9 | **Jitsi** | **Visioconférence** | J5040 | Apache-2.0 | Mature (Jitsi Meet), maintenu | N/A (appels éphémères) |
|
||||||
| 10 | **\*arr** (Prowlarr + Sonarr + Radarr) — **1 stack** | Gestion **séries/films** (indexers + queue) | J5040 | GPL-3.0 | Très mature (la stack \*arr), maintenu | Bases SQLite exportables, config |
|
| 10 | **\*arr** (Prowlarr + Sonarr + Radarr) — **1 stack** | Gestion **séries/films** (indexers + queue) | J5040 | GPL-3.0 | Très mature (la stack \*arr), maintenu | Bases SQLite exportables, config |
|
||||||
| 11 | **qBittorrent** | **Téléchargement** (torrent/HTTP) | J5040 | GPL-2.0 | Très mature, maintenu | File de tâches exportable |
|
| 11 | **qBittorrent** | **Téléchargement** (torrent/HTTP) | J5040 | GPL-2.0 | Très mature, maintenu | File de tâches exportable |
|
||||||
@@ -189,7 +189,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
|||||||
| Brique | Maturité | Ressource (machine cible) | Pourquoi CELLE-ci (vs alternatives) |
|
| Brique | Maturité | Ressource (machine cible) | Pourquoi CELLE-ci (vs alternatives) |
|
||||||
|--------|----------|---------------------------|--------------------------------------|
|
|--------|----------|---------------------------|--------------------------------------|
|
||||||
| **Caddy** | Caddy 2, très stable | ~50 Mo (J5040) | **TLS auto** (Let's Encrypt) sans script. Plus simple que Traefik/Nginx pour un usage familial. |
|
| **Caddy** | Caddy 2, très stable | ~50 Mo (J5040) | **TLS auto** (Let's Encrypt) sans script. Plus simple que Traefik/Nginx pour un usage familial. |
|
||||||
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Remplace WhatsApp** (chiffré, fédéré, OSS). Alternative : Element = le client. |
|
| **Matrix (Synapse)** | Standard de fait, très maintenu | ~2-3 Go (J5040) | **Messagerie du foyer** (chiffré, fédéré, OSS) — **complément** de WhatsApp, **pas un remplacement**. Alternative : Element = le client. |
|
||||||
| **Jitsi** | Mature (Jitsi Meet) | ~1-2 Go (J5040) | **Visio** OSS, s'intègre à Matrix (Element). |
|
| **Jitsi** | Mature (Jitsi Meet) | ~1-2 Go (J5040) | **Visio** OSS, s'intègre à Matrix (Element). |
|
||||||
| **PhotoPrism** | Mature, AGPL | ~2-3 Go (G9) | **Lecture in-place** (ne déplace pas les originaux) + IA (visages, recherche sémantique) + partage. Plus sobre qu'Immich (qui désorganise). |
|
| **PhotoPrism** | Mature, AGPL | ~2-3 Go (G9) | **Lecture in-place** (ne déplace pas les originaux) + IA (visages, recherche sémantique) + partage. Plus sobre qu'Immich (qui désorganise). |
|
||||||
| **Jellyfin** | Très mature, AGPL | ~1-2 Go (G9) | **Vidéo + musique** OSS, transcodage, multi-appareils. Standard de fait. |
|
| **Jellyfin** | Très mature, AGPL | ~1-2 Go (G9) | **Vidéo + musique** OSS, transcodage, multi-appareils. Standard de fait. |
|
||||||
@@ -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)
|
## 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
|
### 8.1 La stack communication
|
||||||
|
|
||||||
| Brique | Rôle | Machine | Licence |
|
| 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 |
|
| **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 |
|
| **Home Assistant** | **Assistant vocal** (enceintes Google) | Freebox | Apache-2.0 |
|
||||||
|
|
||||||
### 8.2 L'usage famille (résilience = usage)
|
### 8.2 L'usage famille (résilience = usage)
|
||||||
|
|
||||||
| Utilisateur | Appareil | Usage quotidien |
|
| 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) |
|
| **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 |
|
| **Moi** | Enceintes Google ×4-5 | **Assistant vocal** (Home Assistant) → peut déclencher n8n, contrôler Frigate |
|
||||||
|
|
||||||
|
**Décision 12/10/2026 (statut du canal)** : Matrix + Jitsi + Element = **complément du foyer** —
|
||||||
|
salon famille, salon ops, salon Effi. **WhatsApp n'est pas remplacé** et reste la messagerie
|
||||||
|
principale (famille élargie, amis, travail) : c'est un constat d'usage mesuré, pas un choix
|
||||||
|
technologique. Element s'installe d'abord sur les **2 appareils adultes** ; les 4 ados viennent en
|
||||||
|
**phase 2**, déclenchée par le **GO famille** explicite de l'utilisateur. Grille de décision :
|
||||||
|
**S14.5**.
|
||||||
|
|
||||||
|
**Les 4 interdictions** (applicables dès maintenant, vérifiables) :
|
||||||
|
|
||||||
|
1. **Interdiction** de présenter Matrix comme un remplacement de WhatsApp, et d'inciter la famille
|
||||||
|
à quitter WhatsApp pour Matrix (WhatsApp garde les échanges extérieurs).
|
||||||
|
2. **Interdiction** de faire transiter les **alertes ops** par WhatsApp : le canal d'alerte est
|
||||||
|
**Matrix uniquement** (pas d'e-mail, pas de SMS, pas de WhatsApp).
|
||||||
|
3. **Interdiction** d'**automatiser l'envoi** de messages WhatsApp (pont `mautrix-whatsapp` :
|
||||||
|
**étudié, non déployé**, numéro familial **interdit** — voir `g9-migration/BRIDGE-WHATSAPP.md`).
|
||||||
|
4. **Interdiction** d'équiper les **4 ados** d'Element (et de comptes enfants Matrix) **sans GO
|
||||||
|
famille** explicite, et sans coffre/2FA posé pour eux (C-FM1).
|
||||||
|
|
||||||
|
Ces 4 interdictions sont **contrôlables** : interdiction 1 = vocabulaire du dossier, contrôlée par
|
||||||
|
la commande suivante dont la sortie doit être **vide** (0 ligne) :
|
||||||
|
`grep -in "remplace whatsapp" DOSSIER-SOFTWARE.md g9-migration/*.md g9-migration/RUNBOOKS/*.md GUIDE-MATRIX.md | grep -vi "pas\|complément\|interdiction\|ne présente"`
|
||||||
|
— les lignes qui mentionnent WhatsApp le font **en négation** (S3.1 #8, S3.3, S8 statut, S8.2
|
||||||
|
décision + cette liste, S13.3 C.1, S14.5, changelog, `journal.md` = historique) ;
|
||||||
|
interdiction 2 = le canal d'alerte est Matrix (3
|
||||||
|
notifications types mesurées dans le salon ops) ; interdiction 3 = 0 flux n8n vers WhatsApp
|
||||||
|
(inventaire n8n) ; interdiction 4 = 0 compte ado Matrix (Synapse mesuré : 4 comptes, aucun ado).
|
||||||
|
|
||||||
### 8.3 Le bot hermes ↔ Matrix
|
### 8.3 Le bot hermes ↔ Matrix
|
||||||
|
|
||||||
> **hermes** (Effi, sur PC IA) est **connecté à Matrix** : la famille peut **parler à Effi** dans un salon Matrix. C'est l'**assistant de famille** (projet second cerveau, phase 3).
|
> **hermes** (Effi, sur PC IA) est **connecté à Matrix** : la famille peut **parler à Effi** dans un salon Matrix. C'est l'**assistant de famille** (projet second cerveau, phase 3).
|
||||||
@@ -461,7 +489,7 @@ Le **Qwen 3.8max** (le LLM principal) consomme **~20 Go VRAM + ~50 Go RAM**. Le
|
|||||||
| Élément | Détail |
|
| Élément | Détail |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| **Domaine** | **folzocorp.fr** (principal) + **sous-domaines** par service |
|
| **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). |
|
| **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**). |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **n8n** | **Alertes** + **automatisation** (workflows) | G9 | ⚠️ fair-code |
|
||||||
|
|
||||||
### 11.2 Pourquoi pas Portainer / PaaS
|
### 11.2 Pourquoi pas Portainer / PaaS
|
||||||
@@ -741,7 +771,7 @@ services:
|
|||||||
|
|
||||||
| # | Action logicielle | Dépendance | Critère de succès |
|
| # | Action logicielle | Dépendance | Critère de succès |
|
||||||
|---|-------------------|------------|-------------------|
|
|---|-------------------|------------|-------------------|
|
||||||
| C.1 | **Matrix + Jitsi + Element** (remplace WhatsApp) | Phase B | Famille messagerie + visio en local |
|
| C.1 | **Matrix + Jitsi + Element** (complément du foyer, **pas** un remplacement de WhatsApp) | Phase B | Famille messagerie + visio en local |
|
||||||
| C.2 | **Home Assistant** (enceintes Google) + **Frigate** (caméras) | Phase B | Assistant vocal + vision caméras |
|
| C.2 | **Home Assistant** (enceintes Google) + **Frigate** (caméras) | Phase B | Assistant vocal + vision caméras |
|
||||||
| C.3 | **Bot hermes ↔ Matrix** (Effi famille) | Phase B + 2ᵉ cerveau | Effi répond dans Matrix |
|
| C.3 | **Bot hermes ↔ Matrix** (Effi famille) | Phase B + 2ᵉ cerveau | Effi répond dans Matrix |
|
||||||
| C.4 | **Enfants** : AdGuard profils + Family Link | Phase B | Enfants protégés (DNS + parental) |
|
| C.4 | **Enfants** : AdGuard profils + Family Link | Phase B | Enfants protégés (DNS + parental) |
|
||||||
@@ -827,7 +857,7 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
|
|||||||
| Fédéré / auto-hébergé | ✅ | ❌ | 🟡 |
|
| Fédéré / auto-hébergé | ✅ | ❌ | 🟡 |
|
||||||
| **Verdict** | **✅ Recommandé** | ❌ (GAFAM) | 🟡 (pas d'auto-hébergement facile) |
|
| **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
|
### 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**.
|
> **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)
|
## S15 — Critères d'acceptance (AC1–AC10)
|
||||||
@@ -867,6 +930,9 @@ Phase A (Fondations logicielles) ──► Phase B (Cœur famille) ──► Pha
|
|||||||
|
|
||||||
| Date | Version | Changement |
|
| Date | Version | Changement |
|
||||||
|------|---------|------------|
|
|------|---------|------------|
|
||||||
|
| 12/10/2026 | **v1.8** | **Identités mesurées + trou Synapse réparé + exposition durcie** : balayage lecture seule (`15-`/`16-identity-sweep`) → **Gitea 2FA = 0/4 et le bot `g9ops` est admin**, **Synapse admin = 0**, Nextcloud 8 comptes + providers 2FA installés, n8n 1 owner, PhotoPrism 3 comptes, **CouchDB = 401 (faux positif « sans auth » corrigé)** ; **1ᵉʳ admin Synapse créé et vérifié** (`26-synapse-admin-repair.sh` : `register` → mxid aléatoire `password_hash` NULL, **`HMAC incorrect`** sur la voie signée, puis **bcrypt en base + `docker restart` + `PUT /_synapse/admin/v2/users`** → login **OK** + API admin **OK** ; snapshot sqlite avant, compte temporaire désactivé, 0 purge ; `27-synapse-family-users.sh` outille les 6 comptes famille `admin:false`) ; **pare-feu durci sur GO** : SSH **LAN-only sur .24 et .27**, **443 LAN-only sur .24**, **plus aucune règle `Anywhere`** sur le G9, Tailscale coupé sur le pilot ; **dumps logiques** des 3 bases dans le périmètre restic + **rejouabilité Gitea prouvée** (116 tables) ; **hub Beszel hors-G9** sur J5040 (digest, ufw LAN-only, 200) ; **31 critères clôturés côté IA** avec distinction explicite « prouvé par mesure » / « geste humain » (`RESTE-HUMAIN.md`). Artéfacts : `scripts/{25,26,27}-*.sh`, §7 et §7.1 de `REGISTRE-IDENTITES.md`, `RUNBOOKS/matrix.md` réécrit. |
|
||||||
|
| 12/10/2026 | **v1.7** | **Mesures Phase 4.5 (l'existant est maintenant prouvé, pas seulement décidé)** : registre **22 conteneurs / 7 constats** et définitions compose **entrées dans le dépôt** (`matrix/`, `g9-queue/`, `valheim/`, `diun/` vivaient hors git) ; **dérive prouvée de bout en bout** (`--check --alert` → payload JSON reçu → revert → « aucune dérive ») ; **chaîne d'alerte vérifiée** : 5 payloads JSON conformes dans le salon ops + 3 notifications types avec event_ids (backup OK, backup KO provoqué, sonde KO) ; **Diun déployé** (digest `crazymax/diun@sha256:e324b793…`, notifie sans appliquer, `docker inspect` avant/après identique) ; **`prisme.folzocorp.fr` = 200** (route posée sur le J5040, Let's Encrypt DNS-01) — `photos.` sert encore PhotoPrism, web DSM `.25` = 000 ; **backup** : 5 snapshots Freebox, coffres + `gitea/db` + `nextcloud/db` + **505 836 entrées photos** dans le snapshot, **restauration sha256 identique à `.25`**, médias 15 To/5,5 To exclus par construction ; **photos** : 433 941 fichiers == 433 941, indexation 9,1 %, RAM 1,1 GiB. Artéfacts : `PHOTOS-DOUBLE-RUN.md`, `scripts/13-diun-notify.sh`, `scripts/g9-verify-alert.py --list`. |
|
||||||
|
| 12/10/2026 | **v1.6** | **Identité + suivi applicatif + communication (décisions 12/10)** : **S8 révisé** — Matrix/Jitsi/Element = **complément du foyer**, **pas** remplacement de WhatsApp (Element : 2 adultes d'abord, ados phase 2) ; **S9.2** sous-domaines alignés sur le réel (`pass.folzocorp.fr` = Vaultwarden, **`prisme.folzocorp.fr` = PhotoPrism**, `photos.folzocorp.fr` = Synology Photos **temporaire**, meurt Phase 6) ; **S11** enrichi de deux briques d'ops sans conteneur de plus : **registre généré** (`11-registry.sh` → `REGISTRE-BRIQUES.md`, dérive par `git diff`) et **sondes applicatives** (`12-probes.sh`, `up ≠ utilisable`, alertes **JSON** + 1 digest/jour) ; **S14.7→S14.10** : coffre **unique Vaultwarden** pour 6 (Nextcloud Passwords écarté : boucle de verrouillage + reset admin + pas d'emergency access), identité = **registre → étude exposition → Authelia** (Authentik/Keycloak/Samba AD/FreeIPA écartés), suivi = **sondes + registre + Diun** (Grafana/Prometheus/Netdata écartés, Uptime Kuma reporté avec déclencheur écrit), **G11 paliers d'autonomie IA**. Artéfacts : `g9-migration/{REGISTRE-IDENTITES,REGISTRE-BRIQUES,PARTAGE-SECRET-MAITRE,RESTORE-NAS-USB,EXPOSITION-INTERNET,BRIDGE-WHATSAPP}.md`, `RUNBOOKS/` ×7, charte **G11**. |
|
||||||
| 10/10/2026 | **v1.5** | **S10.2 — copie 2 (Freebox) EN PLACE sur le G9** : restic 0.19.1 + rclone 1.75.2, repo `rclone:freeboxftp:Freebox/backup/g9` (FTP local 21), snapshot 4.18 GiB, **append-only prouvé**, **restore testée** (sha256), cron 03:30/04:00/09:15 + alerte Matrix ; **mont CIFS abandonné** (restic/`chmod`, FUSE) → backend rclone, **persistance post-reboot sans montage**. **Phase 5 planifiée** (non réalisée) : VM Freebox + `rest-server --append-only` (autostart à valider), `restic copy` → 218play, identifiants `NoAccess`, chroot durci. |
|
| 10/10/2026 | **v1.5** | **S10.2 — copie 2 (Freebox) EN PLACE sur le G9** : restic 0.19.1 + rclone 1.75.2, repo `rclone:freeboxftp:Freebox/backup/g9` (FTP local 21), snapshot 4.18 GiB, **append-only prouvé**, **restore testée** (sha256), cron 03:30/04:00/09:15 + alerte Matrix ; **mont CIFS abandonné** (restic/`chmod`, FUSE) → backend rclone, **persistance post-reboot sans montage**. **Phase 5 planifiée** (non réalisée) : VM Freebox + `rest-server --append-only` (autostart à valider), `restic copy` → 218play, identifiants `NoAccess`, chroot durci. |
|
||||||
| 06/10/2026 | **v1.4** | **Cohérence inter-dossiers (source unique)** : (1) **S10.2** — le schéma 3-2-1 ne **re-déclare plus** le tableau → **pointeur** vers `DOSSIER-RECREATION.md` §R4 (source unique) + note outil (restic, quotidien/hebdo) ; (2) **S4.3** — le modèle 2 couches ne **re-déclare plus** le principe → **pointeur** vers `PROJET-SECOND-CERVEAU.md` S4.1 (source unique) + tableau **mapping briques/machines** (spécificité du dossier logiciel) ; (3) **S3.1** — le tableau est **cadré autour des briques fonctionnelles** : la stack \*arr #10 = **1 brique**, les apps Nextcloud 13-15 marquées **↳ apps du socle NC #12** ; note de comptage précisée (**28 lignes = 25 briques fonctionnelles**, dans la cible ≤ ~20 briques de consolidation). |
|
| 06/10/2026 | **v1.4** | **Cohérence inter-dossiers (source unique)** : (1) **S10.2** — le schéma 3-2-1 ne **re-déclare plus** le tableau → **pointeur** vers `DOSSIER-RECREATION.md` §R4 (source unique) + note outil (restic, quotidien/hebdo) ; (2) **S4.3** — le modèle 2 couches ne **re-déclare plus** le principe → **pointeur** vers `PROJET-SECOND-CERVEAU.md` S4.1 (source unique) + tableau **mapping briques/machines** (spécificité du dossier logiciel) ; (3) **S3.1** — le tableau est **cadré autour des briques fonctionnelles** : la stack \*arr #10 = **1 brique**, les apps Nextcloud 13-15 marquées **↳ apps du socle NC #12** ; note de comptage précisée (**28 lignes = 25 briques fonctionnelles**, dans la cible ≤ ~20 briques de consolidation). |
|
||||||
| 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). |
|
| 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). |
|
||||||
|
|||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
# Guide Matrix — utilisation et connexion
|
||||||
|
|
||||||
|
> **Objet** : rejoindre et administrer le serveur Matrix (Synapse + Element) auto-hébergé.
|
||||||
|
> **Source** : déploiement documenté dans `g9-migration/journal.md` (Phase 2, 10/10/2026) et `DOSSIER-SOFTWARE.md` S8.
|
||||||
|
> **Aucun secret dans ce guide** — les identifiants vivent dans **Vaultwarden**, jamais dans le dépôt (`.gitignore` : `matrix.env`, `*.key`, `*.cred`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. État du serveur
|
||||||
|
|
||||||
|
| Composant | Machine | URL | Rôle |
|
||||||
|
|-----------|---------|-----|------|
|
||||||
|
| **Synapse** (homeserver) | G9 (`.24`) | `http://192.168.1.24:8008` | Serveur Matrix (API + fédération) |
|
||||||
|
| **Element Web** (client) | G9 (`.24`) | `http://192.168.1.24:8009` | Client web, préconfiguré sur le homeserver local |
|
||||||
|
| **Salon ops** | — | `!mp7MFx9QbrJ0ARtzw…` | Canal d'alertes (watchtower, restic, n8n) — user `ops-bot` |
|
||||||
|
| **Cible TLS (Phase 4)** | — | `https://matrix.folzocorp.fr` | Accès sécurisé via Caddy (planifié, non en place) |
|
||||||
|
|
||||||
|
- Version Synapse : **1.162.0** ; version Element Web : **1.12.30**.
|
||||||
|
- `server_name` du homeserver : `192.168.1.24` → les identifiants ont la forme `@pseudo:192.168.1.24`.
|
||||||
|
- Cible finale documentée : migration Matrix + Jitsi vers le **J5040 (`.1`)** (16 Go) — voir `DOSSIER-SOFTWARE.md` S2.4/S8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Connexion
|
||||||
|
|
||||||
|
### 2.1 Element Web (PC, LAN)
|
||||||
|
|
||||||
|
1. Ouvrir **`http://192.168.1.24:8009`**.
|
||||||
|
2. Le homeserver est **déjà préconfiguré** (`config.json` monté en lecture seule dans le conteneur → `base_url: http://192.168.1.24:8008`). Rien à saisir.
|
||||||
|
3. Se connecter avec son compte (§3).
|
||||||
|
|
||||||
|
> ⚠️ **En `http://IP`, Element Web affiche un rond infini** (contexte non sécurisé) — lire la section **5. Dépannage** avant de diagnostiquer le serveur. Fix recommandé : tunnel `localhost` (§5.2) ou TLS Phase 4.
|
||||||
|
|
||||||
|
### 2.2 Element Android (téléphone)
|
||||||
|
|
||||||
|
1. Installer **Element** (Play Store / F-Droid).
|
||||||
|
2. À l'écran de bienvenue : **Modifier le serveur d'accueil** (Changer de serveur).
|
||||||
|
3. Saisir `http://192.168.1.24:8008` → **Suivant** → **Se connecter**.
|
||||||
|
4. Nom d'utilisateur = le pseudo **sans le domaine** (ex. `effi`, pas `@effi:192.168.1.24`) + mot de passe.
|
||||||
|
|
||||||
|
### 2.3 Element Desktop (PC)
|
||||||
|
|
||||||
|
1. Installer Element Desktop (le navigateur n'est pas concerné par la restriction de contexte sécurisé).
|
||||||
|
2. Écran de connexion → **Modifier le serveur d'accueil** → `http://192.168.1.24:8008`.
|
||||||
|
|
||||||
|
### 2.4 Accès depuis l'extérieur (hors LAN)
|
||||||
|
|
||||||
|
Matrix n'est **pas exposé** sur Internet (volontairement). Deux voies :
|
||||||
|
|
||||||
|
| Voie | Comment |
|
||||||
|
|------|---------|
|
||||||
|
| **WireGuard** (J5040 `.1`) | Se connecter au VPN depuis le téléphone/PC portable → le LAN `.1/24` est atteignable → ouvrir `http://192.168.1.24:8009` (Android : saisir directement le homeserver dans l'app) |
|
||||||
|
| **TLS Phase 4** (à faire) | Caddy + `matrix.folzocorp.fr` → l'app Element utilise `https://matrix.folzocorp.fr`, accessible de façon sécurisée |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Création de compte
|
||||||
|
|
||||||
|
> L'**inscription publique est désactivée** (pas de self-registration sur un serveur privé). Les comptes se créent **côté serveur**, en SSH sur le G9 (`.24`).
|
||||||
|
|
||||||
|
### 3.1 Prérequis : un compte admin Synapse
|
||||||
|
|
||||||
|
Si aucun admin n'existe, en créer un via le conteneur Synapse :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sur .24 — trouver le nom du conteneur
|
||||||
|
docker ps --format '{{.Names}}' | grep -i synapse
|
||||||
|
|
||||||
|
# créer un utilisateur admin (le script lit le shared secret du homeserver.yaml)
|
||||||
|
docker exec -it <conteneur_synapse> register_new_matrix_user \
|
||||||
|
-c /data/homeserver.yaml -a -u <ADMIN> -p '<MOT_DE_PASSE_ADMIN>'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si `register_new_matrix_user` refuse (pas de `registration_shared_secret` dans `homeserver.yaml`) : l'ajouter via `sudo` dans `/data/homeserver.yaml` (`registration_shared_secret: <SECRET_ALEATOIRE>`, généré par `openssl rand -hex 32`), **restart du conteneur**, relancer la commande, puis retirer le secret et restart à nouveau. Même pattern que `room_creation: true` (journal 2026-10-09). Stocker `<MOT_DE_PASSE_ADMIN>` dans **Vaultwarden**.
|
||||||
|
|
||||||
|
### 3.2 Créer les comptes (famille, bots) via l'API admin
|
||||||
|
|
||||||
|
**Étape 1 — obtenir un token admin** (login avec le compte admin) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST 'http://192.168.1.24:8008/_matrix/client/v3/login' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"auth": {
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": { "type": "m.id.user", "user": "<ADMIN>" },
|
||||||
|
"passphrase": "<MOT_DE_PASSE_ADMIN>"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
# → réponse JSON : récupérer "access_token" (= <TOKEN_ADMIN>)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Étape 2 — créer l'utilisateur** (API admin v2, `PUT` = enregistrement) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT 'http://192.168.1.24:8008/_synapse/admin/v2/users/@<PSEUDO>:192.168.1.24' \
|
||||||
|
-H "Authorization: Bearer <TOKEN_ADMIN>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"password": "<MOT_DE_PASSE_UTILISATEUR>",
|
||||||
|
"display_name": "<Nom affiché>",
|
||||||
|
"admin": false
|
||||||
|
}'
|
||||||
|
# → 201 Created = compte créé
|
||||||
|
```
|
||||||
|
|
||||||
|
- `admin: true` pour un second administrateur, `false` pour la famille/bots.
|
||||||
|
- Le pseudo est en minuscules, sans espaces ni accents (règle Matrix).
|
||||||
|
|
||||||
|
### 3.3 Première connexion + chiffrement (E2E)
|
||||||
|
|
||||||
|
1. Ouvrir Element (§2), se connecter avec `@<PSEUDO>:192.168.1.24` (Web/Desktop : pseudo seul suffit).
|
||||||
|
2. Element demande une **phrase de passe de récupération** (secure backup) → la **noter hors-ligne** (papier), c'est la porte de sortie du chiffrement.
|
||||||
|
3. Vérifier ses sessions : **Paramètres → Sécurité → Vérification** (QR code entre téléphone et PC) pour valider le chiffrement de bout en bout.
|
||||||
|
|
||||||
|
### 3.4 Désinscription / suppression
|
||||||
|
|
||||||
|
- **Désactiver** un compte (propre, conserve la trace) :
|
||||||
|
```bash
|
||||||
|
curl -s -X POST 'http://192.168.1.24:8008/_synapse/admin/v1/deactivate/<PSEUDO>' \
|
||||||
|
-H "Authorization: Bearer <TOKEN_ADMIN>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{ "erase": true }'
|
||||||
|
```
|
||||||
|
- Vérifier que l'inscription publique reste fermée dans `homeserver.yaml` : `disable_registration: true` (ne pas activer `enable_registration` sans point d'accès protégé).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Usage type (rappel du plan, S8)
|
||||||
|
|
||||||
|
| Salon | Contenu |
|
||||||
|
|-------|---------|
|
||||||
|
| **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) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dépannage
|
||||||
|
|
||||||
|
### 5.1 Le rond infini dans Element Web (cause racine documentée)
|
||||||
|
|
||||||
|
**Symptôme** : page noire, spinner infini sur `http://192.168.1.24:8009`. Console (F12) :
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser missing feature: 'crypto'
|
||||||
|
Browser missing feature: 'securecontext'
|
||||||
|
Error initialising Matrix session TypeError: window.crypto.randomUUID is not a function
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : Element ≥ 1.12 exige un **contexte sécurisé** (HTTPS, `localhost` ou `file:`). En `http://<IP>`, le navigateur coupe `crypto.randomUUID` → Element n'arrive pas à initialiser la session. **Le serveur n'y est pas pour rien.**
|
||||||
|
|
||||||
|
Anodins (à ne pas poursuivre) : `404 config.<host>.json` (config optionnelle par hôte) et `404 /_matrix/client/v1/auth_metadata` (endpoint optionnel).
|
||||||
|
|
||||||
|
**Cause secondaire** : un vieux **service worker** peut garder en cache une config obsolète (la config cassée « matrix.org » d'avant le fix du 10/10). Nettoyage : F12 → Application → Service Workers → *Unregister*, puis Storage → *Clear site data*, puis Ctrl+Maj+R.
|
||||||
|
|
||||||
|
### 5.2 Workaround sans TLS : tunnel localhost
|
||||||
|
|
||||||
|
`localhost` est un contexte sécurisé → Element fonctionne. Depuis le PC :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8009:192.168.1.24:8009 -L 8008:192.168.1.24:8008 <user>@192.168.1.24
|
||||||
|
# puis ouvrir http://localhost:8009
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Fix définitif : TLS (Phase 4)
|
||||||
|
|
||||||
|
Caddy + `matrix.folzocorp.fr` (même pattern que `obsidian.folzocorp.fr`, journal Phase 4) → Element Web servi en HTTPS, spinner résolu, accès distant propre.
|
||||||
|
|
||||||
|
### 5.4 Commandes de diagnostic (côté serveur, tout doit répondre)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) API vivante → JSON avec "versions":[...] (200)
|
||||||
|
curl -s http://192.168.1.24:8008/_matrix/client/versions
|
||||||
|
|
||||||
|
# 2) Element servi + config correcte → 200 + base_url = http://192.168.1.24:8008
|
||||||
|
curl -s http://192.168.1.24:8009/ -o /dev/null -w '%{http_code}\n'
|
||||||
|
curl -s http://192.168.1.24:8009/config.json
|
||||||
|
|
||||||
|
# 3) CORS (sans lui, Element web est muet) → Access-Control-Allow-Origin: *
|
||||||
|
curl -s -D - -o /dev/null -H "Origin: http://192.168.1.24:8009" \
|
||||||
|
http://192.168.1.24:8008/_matrix/client/versions | grep -i access-control
|
||||||
|
|
||||||
|
# 4) Conteneurs
|
||||||
|
docker ps --format '{{.Names}}\t{{.Status}}' | grep -Ei 'synapse|element'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultats vérifiés le 11/10/2026** : 1) `200` + versions r0.0.1→v1.12 ; 2) `200` + `base_url: http://192.168.1.24:8008` ; 3) `Access-Control-Allow-Origin: *` ; 4) synapse `Synapse/1.162.0` UP. Si ces 4 tests passent et qu'Element tourne en rond → problème 100 % navigateur (§5.1/5.2).
|
||||||
|
|
||||||
|
### 5.5 Alertes ops absentes du salon ops
|
||||||
|
|
||||||
|
- Vérifier le token `ops-bot` : côté G9, `/etc/restic/matrix.env` (root, `600`) pour restic ; workflow n8n « Watchtower Notification » pour les MAJ d'images.
|
||||||
|
- Test d'envoi direct :
|
||||||
|
```bash
|
||||||
|
curl -s -X POST 'http://192.168.1.24:8008/_matrix/client/v3/rooms/!mp7MFx9QbrJ0ARtzw…/send/m.room.message/test1' \
|
||||||
|
-H "Authorization: Bearer <TOKEN_OPS_BOT>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"msgtype":"m.text","body":"test alerte"}'
|
||||||
|
# → 200 + {"event_id":"$…"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sécurité (règles du projet)
|
||||||
|
|
||||||
|
| Règle | Application Matrix |
|
||||||
|
|-------|--------------------|
|
||||||
|
| Secrets hors dépôt | Token admin, mot de passe admin, `matrix.env` → **Vaultwarden** / root only, jamais git |
|
||||||
|
| Pas d'exposition Internet | Accès LAN ou WireGuard ; TLS Phase 4 pour l'exposition contrôlée |
|
||||||
|
| 2FA | À activer sur les comptes admin (S9 : 2FA sur tous les services exposés) |
|
||||||
|
| Comptes famille | `admin: false`, mot de passe unique par personne, phrase de récupération E2E notée hors-ligne |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Guide créé le 11/10/2026 — état vérifié ce jour sur `.24` (Synapse 1.162.0, Element 1.12.30). À réaligner quand Matrix migrera vers le J5040 (`.1`) et que `matrix.folzocorp.fr` (Caddy TLS) sera en place.*
|
||||||
@@ -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) |
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Phase 4 — récupération du thread supprimé
|
||||||
|
|
||||||
|
> **Ce fichier a deux objets** : §0 = état consolidé de la Phase 4 (briques, versions,
|
||||||
|
> incidents, .25) ; §1-5 = rapport de récupération du thread de session supprimé (07:20 UTC).
|
||||||
|
|
||||||
|
## 0. État consolidé de la Phase 4 (2026-10-11)
|
||||||
|
|
||||||
|
### 0.1 Briques déployées sur G9 (.24) et versions épinglées
|
||||||
|
|
||||||
|
| Brique | Image épinglée | État | Watchtower |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Vaultwarden (T4.1) | `vaultwarden/server:1.37.4` | healthy, coffre importé (953 ciphers, 2FA), ADMIN_TOKEN argon2 | false |
|
||||||
|
| Nextcloud (T4.2) | `nextcloud:35.0.1` (35.0.1.1 = version source) | healthy, `occ status` installed, 8 users, 1522 fichiers (scan 0 erreur), 67 apps activées = liste source | false |
|
||||||
|
| — MariaDB | `mariadb:10.11` (utf8mb4_bin, import en une transaction) | up | false |
|
||||||
|
| — Redis | `redis:7` | up | false |
|
||||||
|
| — Collabora (remplace OnlyOffice .25) | `collabora/code:26.04.5.1.1` | healthy, discovery 200, wopi_url posé | false |
|
||||||
|
| Gitea (T4.4) | `gitea/gitea:1.27.3` + `postgres:16` | healthy, 15 repos / 4 users importés, push-create actif | false |
|
||||||
|
| 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) | `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)
|
||||||
|
|
||||||
|
1. **Collabora TLS** : l'image 26.04 sert **TLS par défaut** (discovery http = 000) ; env `extra_features`/`O:ssl.enable` non appliqués → fix `command: ["--o:ssl.enable=false"]` (CMD appendu à l'entrypoint).
|
||||||
|
2. **Collabora healthcheck** : image **minimale** (ni `sh` ni `curl`) → `CMD curl` = unhealthy permanent → healthcheck retiré du compose (sonde externe `curl :9980/hosting/discovery`).
|
||||||
|
3. **Jellyfin auth** : le wizard laisse `Users.EnableLocalPassword=0` → « Invalid username or password » ; fix = UPDATE sqlite + restart (hash PBKDF2-SHA512 vérifié == secret) ; API = en-tête combiné **X-Emby-Authorization** requis (sinon 400 « Value cannot be null »).
|
||||||
|
4. **Caddy** : snippet `(name)` inutilisable comme adresse (« ambiguous site definition ») → `import` ; `request_id` n'est pas un directive site (validate KO) ; Caddy logue les en-têtes **reçus du client**, pas ceux envoyés en amont.
|
||||||
|
5. **Nextcloud / edge** : le dépôt d'un second `*.config.php` monté n'a pas été retenu (merge `array_replace_recursive` + config.php déjà porteur des clés) → résolu en écrivant **`g9-double-run.config.php` = source de vérité du kit**, monté `:ro` : `overwritehost=cloud.folzocorp.fr`, `overwriteprotocol=https`, `trusted_proxies=[192.168.1.0/24, 172.16.0.0/12]`, `trusted_domains += cloud.folzocorp.fr`.
|
||||||
|
6. **Watchtower** : fork nickfedor = `SCHEDULE` XOR `POLL`, pas de `--once` (⇒ `-R`) ; notification alignée `generic+http://n8n:5678/webhook/watchtower` (webhook testé **200** depuis le réseau Docker ; l'edge n'écoute que 443).
|
||||||
|
|
||||||
|
### 0.3 Alignement proxy (TRUSTED_PROXIES)
|
||||||
|
|
||||||
|
- Nextcloud : `trusted_proxies = [192.168.1.0/24, 172.16.0.0/12]` (bridge Docker de l'edge) + `overwrite*` https/host — **preuve côté app** : redirect `https://cloud.folzocorp.fr/` → `Location: https://cloud.folzocorp.fr/index.php/login`.
|
||||||
|
- Vaultwarden : `TRUSTED_HOSTS=192.168.1.0/24,172.16.0.0/12` (même réseau de confiance).
|
||||||
|
- Caddy : `X-Forwarded-Proto/Host/Port/For` posés par défaut sur tous les `reverse_proxy`.
|
||||||
|
- ufw : **443/tcp = seul port exposé WAN** (+ 80 redirect ACME) ; ports briques (3000/2222/3100/5678/5984/8008/8009/8080/8081/8096/9980) = `ALLOW IN 192.168.1.0/24` + default deny incoming.
|
||||||
|
|
||||||
|
### 0.4 État du xpenology .25 (double-run G4, rien de destructif)
|
||||||
|
|
||||||
|
- `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é)
|
||||||
|
|
||||||
|
**Session** : `e67f1166-d70c-42cd-a295-79513757233e` (tâche Kanban `8e3e7f073a6806cd` — PHASE 4)
|
||||||
|
**Suppression** : 2026-10-11 07:20:04 UTC (trace systemd : `Runner error FOREIGN KEY constraint failed, sessionId=e67f1166…`)
|
||||||
|
**Méthode** : suppression = `DELETE` sur `sessions` + cascade sur `events`. Les pages SQLite libérées gardent leur contenu tant qu'elles ne sont pas réallouées → caravage des pages libres de `sessions.db` (snapshot), reconstruction des enregistrements.
|
||||||
|
|
||||||
|
## 1. Ce qui a été récupéré
|
||||||
|
|
||||||
|
| Élément | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Snapshot de travail | `snap1` pris à 08:20:34 UTC (1 h après la suppression) |
|
||||||
|
| Pages contenant l'id de session | 21 232 |
|
||||||
|
| Texte imprimable ≥ 400 caractères | 34 227 blocs = **47,9 Mo** |
|
||||||
|
| Objets JSON payload reconstitués | **51 420 = 34,68 Mo** |
|
||||||
|
| Entrées d'index `events` (session_id, seq / event_type) | 79 830 → 37 821 seq, 42 009 event_type, plage seq 5 → 158 799 |
|
||||||
|
| Tours de conversation reconstitués | **130** (129 assistant + 3 user côté rôles) |
|
||||||
|
| Événements réinsérés dans `sessions.db` | **64 657** (33,85 Mo de payload) |
|
||||||
|
| Appels d'outils avec arguments intacts | 31 998 |
|
||||||
|
| Résultats / sorties d'outils | 124 / 275 |
|
||||||
|
|
||||||
|
## 2. Ce qui est perdu (non inventé)
|
||||||
|
|
||||||
|
- **~75 % des événements** : les pages leaf/overflow ont été réallouées et **zérées** par SQLite après la suppression (15 951 pages contenaient encore l'id de session mais avec leur tableau de cellules remis à zéro).
|
||||||
|
- **La prose du fil** (messages de l'assistant) est récupérée **en fragments dans le désordre** : les deltas de streaming sont stockés dans des pages distinctes, l'ordre d'origine n'a pas pu être reconstitué. Les 130 messages insérés contiennent donc des fragments réels, recollés dans l'ordre des pages (ordre d'allocation ≈ ordre chronologique), pas un texte propre.
|
||||||
|
- **Presque tous les messages utilisateur** sont perdus (payloads courts, pages détruites) : 3 rôles `user` identifiés, sans contenu.
|
||||||
|
- Volume attendu ≈ 158 800 événements / 80–110 Mo de payload → **≈ 35 % récupéré**.
|
||||||
|
|
||||||
|
## 3. Où sont les artefacts
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `/home/folzo/openfox/_recover/snap1/` | copie octet-pour-octet d'origine (`sessions.db`, `-wal`, `-shm`) |
|
||||||
|
| `/home/folzo/openfox/_recover/orig.sha256` | sha256 des fichiers d'origine |
|
||||||
|
| `/home/folzo/openfox/_recover/snap2/` + `snap2.sha256` | état **immédiatement avant** la réinsertion (point de rollback) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_json.jsonl` | 51 420 payloads JSON bruts (+ page, offset) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_index.jsonl` | 79 830 entrées d'index (rowid, seq, event_type) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_events.jsonl` | flux d'événements synthétisé |
|
||||||
|
| `g9-migration/PHASE4-RECOLLEMENT.md` §5 | journal des actions récupérées |
|
||||||
|
|
||||||
|
## 4. Rollback
|
||||||
|
|
||||||
|
**Chirurgical** (recommandé, ne touche à rien d'autre) :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM events WHERE session_id='e67f1166-d70c-42cd-a295-79513757233e';
|
||||||
|
DELETE FROM sessions WHERE id='e67f1166-d70c-42cd-a295-79513757233e';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restauration à l'état pré-réinsertion** :
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl stop openfox
|
||||||
|
cp /home/folzo/openfox/_recover/snap2/sessions.db{,-wal,-shm} /home/folzo/.local/share/openfox/
|
||||||
|
sha256sum -c /home/folzo/openfox/_recover/snap2.sha256
|
||||||
|
systemctl start openfox
|
||||||
|
```
|
||||||
|
|
||||||
|
`PRAGMA integrity_check` = `ok` avant et après réinsertion. Les fichiers d'origine n'ont jamais été modifiés en écriture directe : la réinsertion est passée par SQLite (WAL), et `snap1` reste la copie de l'état post-suppression.
|
||||||
|
|
||||||
|
## 5. Journal des actions récupérées
|
||||||
|
|
||||||
|
Le journal des 32 409 actions récupérées (appels d'outils + arguments intacts, résultats,
|
||||||
|
sorties) n'est **pas versionné ici** — il duplique les artefacts et concerne la base de
|
||||||
|
sessions OpenFox, pas le kit G9. Consultable / rejouable :
|
||||||
|
|
||||||
|
| Artefact | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_actions.md` | journal brut complet (32 409 lignes, page-ordre) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_actions_curated.md` | version filtrée/dédupliquée (758 lignes) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_json.jsonl` | 51 420 payloads JSON (+ page, offset) |
|
||||||
|
| `/home/folzo/openfox/_recover/phase4_events.jsonl` | flux d'événements réinsérés dans la base |
|
||||||
|
|
||||||
|
Reprise du travail Phase 4 : la tâche Kanban `8e3e7f073a6806cd` est toujours bindée à la
|
||||||
|
session `e67f1166…` et le runner n'a **jamais cessé d'écrire** (journal 09:18 → 10:13,
|
||||||
|
commits `a1864c6`, scripts 07c/07d) — c'est la source operative, pas le fil recoveré.
|
||||||
@@ -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).
|
||||||
+90
-7
@@ -28,14 +28,15 @@ xpenology → G9, phase par phase, quand le G9 sera branché (IP temporaire `.24
|
|||||||
|-----------|---------------------|
|
|-----------|---------------------|
|
||||||
| G1 | Scripts idempotents, en-têtes documentant chaque modification. |
|
| G1 | Scripts idempotents, en-têtes documentant chaque modification. |
|
||||||
| G2 | `06-restic-backup.sh` : backup + **test de restauration** avant toute migration. |
|
| 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 | `07-migrate-stateful.sh` : lecture seule du xpenology + intégrité sha256 ; `08-decommission.sh` exige la validation de toutes les briques avant wipe. |
|
| 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. |
|
| 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. |
|
| G6 | `01-hardening.sh` : ufw LAN-only (`192.168.1.0/24`), pas d'exposition WAN. |
|
||||||
| G7 | Chaque script : section **Rollback** en en-tête + colonne Rollback du journal. |
|
| G7 | Chaque script : section **Rollback** en en-tête + colonne Rollback du journal. |
|
||||||
| G8 | `journal()` de chaque script → `journal.md` (6 colonnes : date, phase, action, raison, résultat, rollback). |
|
| 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é). |
|
| 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). |
|
| 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
|
## Arborescence
|
||||||
|
|
||||||
@@ -52,21 +53,102 @@ g9-migration/
|
|||||||
│ ├── 05-beszel.sh ← T2.5 : agent Beszel
|
│ ├── 05-beszel.sh ← T2.5 : agent Beszel
|
||||||
│ ├── 06-restic-backup.sh ← T3.1 : restic G9 → Freebox + test restauration
|
│ ├── 06-restic-backup.sh ← T3.1 : restic G9 → Freebox + test restauration
|
||||||
│ ├── 06b-usb-media.sh ← T6.1 : montage des disques USB média → /mnt/usb-media
|
│ ├── 06b-usb-media.sh ← T6.1 : montage des disques USB média → /mnt/usb-media
|
||||||
│ ├── 07-migrate-stateful.sh← T4.x : export/import des stateful (xpenology → G9)
|
│ ├── 06c-postboot-check.sh ← contrôles post-reboot (lecture seule, 15+ checks)
|
||||||
│ └── 08-decommission.sh ← T6.4 : décommissionnement xpenology [DESTRUCTIF]
|
│ ├── 07-migrate-stateful.sh← T4.x : générique (rsync) — INOPÉRANT sur le DSM : sshd
|
||||||
|
│ │ refuse `rsync --server` et scp → utiliser 07a–07d
|
||||||
|
│ ├── 07a-migrate-gitea.sh ← T4.4 : Gitea + backend Postgres (transport tar sur SSH)
|
||||||
|
│ ├── 07b-migrate-vaultwarden.sh ← T4.1 : Vaultwarden (coffre + 2FA + ADMIN_TOKEN)
|
||||||
|
│ ├── 07c-migrate-nextcloud.sh ← T4.2 : Nextcloud + MariaDB + Collabora
|
||||||
|
│ ├── 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)
|
||||||
|
│ ├── 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)
|
├── compose/ ← docker-compose.yml versionnés (stockés aussi dans Gitea)
|
||||||
│ ├── vaultwarden/ ← T4.1
|
│ ├── vaultwarden/ ← T4.1
|
||||||
│ ├── nextcloud/ ← T4.2
|
│ ├── nextcloud/ ← T4.2
|
||||||
│ ├── photoprism/ ← T4.3
|
│ ├── photoprism/ ← T4.3
|
||||||
│ ├── gitea/ ← T4.4
|
│ ├── gitea/ ← T4.4
|
||||||
│ ├── n8n/ ← T4.5
|
│ ├── n8n/ ← T4.5
|
||||||
│ ├── jellyfin/ ← T6.2
|
│ ├── jellyfin/ ← T4.5 (média par référence, /media:ro)
|
||||||
│ └── 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/
|
└── configs/
|
||||||
├── fail2ban.local.conf ← config fail2ban (T2.1)
|
├── fail2ban.local.conf ← config fail2ban (T2.1)
|
||||||
└── restic.env.example ← template restic (T3.1) — REMPLIR les secrets
|
└── restic.env.example ← template restic (T3.1) — REMPLIR les secrets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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)
|
## Ordre d'exécution (phase par phase)
|
||||||
|
|
||||||
| Phase | Script / action | Garde-fou |
|
| Phase | Script / action | Garde-fou |
|
||||||
@@ -74,8 +156,9 @@ g9-migration/
|
|||||||
| 1 | (xpenology) exclure stateful de Watchtower + inventaire | G4 |
|
| 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` | — |
|
| 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 |
|
| 3 | `06-restic-backup.sh` (backup + test restauration) | G2 |
|
||||||
| 4 | `07-migrate-stateful.sh` (par brique, double-run) — **après** `03b-secrets.sh` | 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 |
|
| 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 |
|
| 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 |
|
| 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,68 @@
|
|||||||
|
# Caddyfile — T4.6 (edge G9). Reverse proxy LAN + HTTPS interne (CA interne Caddy).
|
||||||
|
# Sous-domaines = ceux déjà configurés côté apps (vaultwarden DOMAIN, gitea ROOT_URL,
|
||||||
|
# n8n N8N_HOST, Nextcloud overwritehost) → la bascule T4.6 ne touche aucune brique.
|
||||||
|
# Résolution LAN : AdGuard Home (J5040, S9.1) ou hosts statiques ; test = `curl -k
|
||||||
|
# --resolve <host>:443:192.168.1.24 https://<host>/`.
|
||||||
|
|
||||||
|
(folzocorp) {
|
||||||
|
tls internal
|
||||||
|
encode gzip
|
||||||
|
log {
|
||||||
|
output file /data/caddy-access.log {
|
||||||
|
roll_size 10mb
|
||||||
|
roll_keep 5
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pass.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:8081
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud.folzocorp.fr nextcloud.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
# WOPI : Nextcloud (1.24:8080) ↔ Collabora (1.24:9980) en http LAN (wopi_url en DB).
|
||||||
|
reverse_proxy 192.168.1.24:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
git.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
media.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:8096
|
||||||
|
}
|
||||||
|
|
||||||
|
n8n.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:5678
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.folzocorp.fr {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:8008
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accès par IP (SNI) : `curl -k https://192.168.1.24` → Nextcloud (défaut LAN).
|
||||||
|
192.168.1.24 {
|
||||||
|
import folzocorp
|
||||||
|
# Vérif T4.6 : `header` echo en réponse les X-Forwarded-* envoyés à la brique
|
||||||
|
# (mesurable : `curl -kI https://<host>/…`) — Caddy les pose par défaut sur tous les reverse_proxy.
|
||||||
|
reverse_proxy 192.168.1.24:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# edge — T4.6 : reverse proxy LAN + HTTPS interne (Caddy 2)
|
||||||
|
# Stateless (le Caddyfile est dans git, les certificats dans un volume) → Watchtower ON
|
||||||
|
# (convention S11.4 : image épinglée = exclue ; ici le pinning est tag + digest consigné).
|
||||||
|
#
|
||||||
|
# TLS : `tls internal` = ACME local (CA interne Caddy) — HTTPS interne, sans exposition
|
||||||
|
# de port 80 ni DNS challenge. Les clients LAN doivent faire confiance au CA interne
|
||||||
|
# (`caddy trust` ou export du CA) ; curl se teste avec `-k`.
|
||||||
|
#
|
||||||
|
# Let's Encrypt (plan S9.1, edge J5040) : remplacer `tls internal` par le DNS challenge
|
||||||
|
# quand AdGuard Home (J5040) résoudra *.folzocorp.fr en LAN → bascule sans toucher aux briques.
|
||||||
|
#
|
||||||
|
# ufw (01-hardening.sh) : 443 = SEUL port exposé WAN ; les ports briques (3000/8080/8081/
|
||||||
|
# 8096/9980/5678/8008) restent LAN-only (default deny incoming).
|
||||||
|
services:
|
||||||
|
edge:
|
||||||
|
image: caddy:2
|
||||||
|
# Digest consigné (S11.4) : sha256:f2a1290d0463aad60660d4ec134943f183ee2a5f6c3eb7bf32dd984f2f020772
|
||||||
|
container_name: edge
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./data:/data
|
||||||
|
- ./config:/config
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:443:443"
|
||||||
|
- "0.0.0.0:80:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
@@ -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
|
||||||
@@ -24,3 +24,9 @@ services:
|
|||||||
- "0.0.0.0:8096:8096"
|
- "0.0.0.0:8096:8096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri # accélération vidéo (si dispo)
|
- /dev/dri:/dev/dri # accélération vidéo (si dispo)
|
||||||
|
# Wizard 10.9 accompli via navigateur (T4.5) : user admin « g9ops » (mot de passe dans
|
||||||
|
# ./secrets/jellyfin-admin, 644 root:root, hors git G9). PIÈGE mesuré : le wizard laisse
|
||||||
|
# Users.EnableLocalPassword=0 → AuthenticateByName = « Invalid username or password ».
|
||||||
|
# Fix : UPDATE Users SET EnableLocalPassword=1 (sqlite /config/data/jellyfin.db) + restart.
|
||||||
|
# API : POST /Users/AuthenticateByName exige l'en-tête combiné X-Emby-Authorization
|
||||||
|
# (Emby Username=…, DeviceId=…, Device=…, Client=…, Version=…) + body {"Username","Pw"}.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,44 +1,62 @@
|
|||||||
# n8n — T4.5 (automatisation + alertes → Matrix, G9)
|
# n8n — T4.5 (automatisation, G9). Source .25 = n8n **2.42.5** sur Postgres 17
|
||||||
# Le conteneur n8n est stateless (image) mais sa DB (Postgres) est stateful → exclu.
|
# (db `2025_n8n`, user `2025_n8n_user`, 4 workflows, 4 credentials, 1 user) → vérité terrain.
|
||||||
# Workflows exportables (JSON). Licence fair-code (notée, S3.1).
|
# Image **épinglée à 2.42.5** : pas de migration de schéma pendant le double-run (S11.4).
|
||||||
|
# Clé de chiffrement : .n8n/config importé depuis la source (copie du volume `data`) → les
|
||||||
|
# credentials existants restent déchiffrables. n8n ne connaît pas les *_FILE → env_file.
|
||||||
services:
|
services:
|
||||||
n8n:
|
n8n:
|
||||||
image: n8nio/n8n:1.90
|
image: docker.n8n.io/n8nio/n8n:2.42.5
|
||||||
container_name: n8n
|
container_name: n8n
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true" # image stateless → auto
|
- "com.centurylinklabs.watchtower.enable=false" # version épinglée (schéma DB) → exclu
|
||||||
|
env_file:
|
||||||
|
- ./secrets/n8n.env
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
- N8N_HOST=n8n.folzocorp.fr
|
- N8N_HOST=n8n.folzocorp.fr
|
||||||
- N8N_PROTOCOL=https
|
- N8N_PROTOCOL=https
|
||||||
- N8N_PORT=5678
|
- N8N_PORT=5678
|
||||||
- DB_TYPE=postgresdb
|
- WEBHOOK_URL=https://n8n.folzocorp.fr
|
||||||
- DB_POSTGRESDB_HOST=n8n_db
|
- N8N_RELEASE_TYPE=stable
|
||||||
- DB_POSTGRESDB_DATABASE=n8n
|
- N8N_RUNNERS_ENABLED=true
|
||||||
- DB_POSTGRESDB_USER=n8n
|
- NODE_ENV=production
|
||||||
- DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/dbpass
|
|
||||||
- GENERIC_TIMEZONE=Europe/Paris
|
- GENERIC_TIMEZONE=Europe/Paris
|
||||||
|
- DB_TYPE=postgresdb
|
||||||
|
- DB_POSTGRESDB_HOST=db
|
||||||
|
- DB_POSTGRESDB_PORT=5432
|
||||||
|
- DB_POSTGRESDB_DATABASE=2025_n8n
|
||||||
|
- DB_POSTGRESDB_USER=2025_n8n_user
|
||||||
|
# Double-run LAN : N8N_SECURE_COOKIE=false permet l'accès http://192.168.1.24:5678
|
||||||
|
# (cookies « secure » refusés en http). **À remettre à true à la bascule TLS (T4.6).**
|
||||||
|
- N8N_SECURE_COOKIE=false
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/home/node/.n8n
|
- ./data:/home/node/.n8n
|
||||||
- ./secrets:/run/secrets:ro
|
- ./secrets:/run/secrets:ro
|
||||||
# Bind 0.0.0.0 : accessible depuis le LAN (J5040 edge/Caddy le proxy).
|
# Bind 0.0.0.0 : LAN uniquement (ufw de 01-hardening.sh restreint le port).
|
||||||
# ufw (01-hardening.sh) restreint le port au LAN 192.168.1.0/24 → pas d'exposition WAN.
|
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:5678:5678"
|
- "0.0.0.0:5678:5678"
|
||||||
depends_on:
|
depends_on:
|
||||||
- n8n_db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
n8n_db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: n8n_db
|
container_name: n8n_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false" # DB stateful → exclu
|
- "com.centurylinklabs.watchtower.enable=false" # DB stateful → exclu
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=n8n
|
- POSTGRES_DB=2025_n8n
|
||||||
- POSTGRES_USER=n8n
|
- POSTGRES_USER=2025_n8n_user
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/dbpass
|
- POSTGRES_PASSWORD_FILE=/run/secrets/dbpass
|
||||||
|
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/postgresql/data
|
- ./db:/var/lib/postgresql/data
|
||||||
- ./secrets:/run/secrets:ro
|
- ./secrets:/run/secrets:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U 2025_n8n_user -d 2025_n8n"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
# Nextcloud — T4.2 (socle famille, G9)
|
# Nextcloud — T4.2 (socle famille, G9)
|
||||||
# Stateful (app + MariaDB + Redis) → exclus de Watchtower (S11.4).
|
# Stateful (app + MariaDB + Redis + données) → exclus de Watchtower (S11.4).
|
||||||
# MAJ manuelle supervisée (S11.5) : backup DB → maintenance ON → pull/up → occ upgrade → vérif → OFF.
|
# MAJ manuelle supervisée (S11.5) : backup DB → maintenance ON → pull/up → occ upgrade → vérif → OFF.
|
||||||
# L'incident Nextcloud (06/10) est la raison de ce pinning + exclusion.
|
# L'incident Nextcloud (06/10) est la raison de ce pinning + exclusion.
|
||||||
|
#
|
||||||
|
# CONTRAINTES MESURÉES sur la source .25 (2026-10-11) :
|
||||||
|
# - config.php : dbtype=mysql, dbhost=**db**, dbname=nextcloud, dbuser=nextcloud,
|
||||||
|
# datadirectory=/var/www/html/data, version 35.0.1.1, instanceid oc9yed0p9k7q.
|
||||||
|
# → le service DB du compose G9 DOIT s'appeler `db` et porter les **identifiants
|
||||||
|
# d'origine** (sinon config.php importé ne correspond plus à la base importée).
|
||||||
|
# - apps déjà installés sur la source : calendar 6.6.2, contacts 8.9.1, mail 5.12.3
|
||||||
|
# (Groupware), deck 1.19.0, notes 6.1.0, richdocuments 12.0.1, collectives, forms,
|
||||||
|
# spreed, whiteboard… → import = conservation à l'identique, rien à installer.
|
||||||
|
# - pas de chiffrement des fichiers activé (aucune config encryption) → copie simple.
|
||||||
|
# - éditeur : Collabora (décision Phase 4) → service `collabora` + richdocuments.wopi_url.
|
||||||
services:
|
services:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
image: nextcloud:35
|
image: nextcloud:35.0.1 # = version source exacte (35.0.1.1) → aucune migration de schéma
|
||||||
container_name: nextcloud
|
container_name: nextcloud
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
- POSTGRES_HOST=nextcloud_db
|
- PHP_MEMORY_LIMIT=1G
|
||||||
- POSTGRES_DB=nextcloud
|
- PHP_UPLOAD_LIMIT=10G
|
||||||
- POSTGRES_USER=nextcloud
|
- PHP_POST_SIZE_LIMIT=10G
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/dbpass
|
|
||||||
- NEXTCLOUD_TRUSTED_PROXIES=172.16.0.0/12
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/var/www/html
|
- ./data:/var/www/html
|
||||||
- ./config:/var/www/html/config
|
- ./config:/var/www/html/config
|
||||||
|
# T4.2/T4.6 : overrides double-run + edge (trusted_domains/proxies, overwrite*) —
|
||||||
|
# source de vérité = kit, monté :ro, réversible par suppression du fichier.
|
||||||
|
- ./g9-double-run.config.php:/var/www/html/config/g9-double-run.config.php:ro
|
||||||
- ./secrets:/run/secrets:ro
|
- ./secrets:/run/secrets:ro
|
||||||
depends_on:
|
# Bind 0.0.0.0 : accessible depuis le LAN (edge/Caddy le proxy en T4.6).
|
||||||
- nextcloud_db
|
|
||||||
- redis
|
|
||||||
# Bind 0.0.0.0 : accessible depuis le LAN (J5040 edge/Caddy le proxy).
|
|
||||||
# ufw (01-hardening.sh) restreint le port au LAN 192.168.1.0/24 → pas d'exposition WAN.
|
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:8080:80"
|
- "0.0.0.0:8080:80"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost/status.php"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
nextcloud_db:
|
db:
|
||||||
image: mariadb:10.11
|
image: mariadb:10.11
|
||||||
container_name: nextcloud_db
|
container_name: nextcloud_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false" # DB stateful → exclu
|
- "com.centurylinklabs.watchtower.enable=false" # DB stateful → exclu
|
||||||
|
command:
|
||||||
|
- --transaction-isolation=READ-COMMITTED
|
||||||
|
- --binlog-format=ROW
|
||||||
environment:
|
environment:
|
||||||
- MARIADB_DATABASE=nextcloud
|
- MARIADB_DATABASE=nextcloud
|
||||||
- MARIADB_USER=nextcloud
|
- MARIADB_USER=nextcloud
|
||||||
- MARIADB_PASSWORD_FILE=/run/secrets/dbpass
|
- MARIADB_PASSWORD_FILE=/run/secrets/dbpass # = mot de passe D'ORIGINE (import)
|
||||||
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/rootpass
|
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/rootpass
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
@@ -48,6 +67,43 @@ services:
|
|||||||
container_name: nextcloud_redis
|
container_name: nextcloud_redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false" # cache persistant → exclu
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
volumes:
|
volumes:
|
||||||
- ./redis:/data
|
- ./redis:/data
|
||||||
|
|
||||||
|
# Collabora CODE — éditeur de documents (décision Phase 4, remplace OnlyOffice .25).
|
||||||
|
# Image épinglée 26.04.5.1.1 → Watchtower OFF (convention S11.4 : épinglée = exclu,
|
||||||
|
# cf. label n8n). MAJ manuelle supervisée avec backup préalable.
|
||||||
|
collabora:
|
||||||
|
image: collabora/code:26.04.5.1.1
|
||||||
|
container_name: collabora
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
# L'image officielle ne substitue que `username`/`password` dans coolwsd.xml :
|
||||||
|
# `password_file` n'est PAS lu → console 9980 restée sans mot de passe. Mot de passe
|
||||||
|
# injecté par env_file (pattern n8n) ; secrets/collabora.env généré par 03b-secrets.sh.
|
||||||
|
env_file:
|
||||||
|
- ./secrets/collabora.env
|
||||||
|
# Entrypoint = coolwsd (arguments fixes). CMD est **appendé** à l'entrypoint →
|
||||||
|
# `--o:ssl.enable=false` désactive le TLS interne (mesuré 2026-10-11 : sans cela,
|
||||||
|
# discovery http = 000, https -k = 200 ; env O:ssl.enable NON appliqué).
|
||||||
|
command:
|
||||||
|
- --o:ssl.enable=false
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
# Hôtes émetteurs de WOPI autorisés (double-run : LAN G9 + hostname de bascule T4.6).
|
||||||
|
- aliasgroup1=https://nextcloud.folzocorp.fr,http://192.168.1.24:8080,http://192.168.1.25:8080
|
||||||
|
- username=admin
|
||||||
|
- JWT_ENABLED=false
|
||||||
|
# Image 26.04 : Entrypoint = coolwsd --use-env-vars → overrides coolwsd.xml via
|
||||||
|
# variables `O:<chemin>` (mesuré : extra_features/EXTRA_FEATURES non lus, TLS par
|
||||||
|
# défaut). SSL interne désactivé : WOPI en http LAN ; Caddy (T4.6) terminera le TLS.
|
||||||
|
- O:ssl.enable=false
|
||||||
|
volumes:
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:9980:9980"
|
||||||
|
# Pas de healthcheck interne : l'image 26.04 est minimale (ni sh, ni curl →
|
||||||
|
# `CMD curl` = "executable file not found" = unhealthy permanent, mesuré 2026-10-11).
|
||||||
|
# Sonde externe : `curl -sf http://192.168.1.24:9980/hosting/discovery` (200).
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
# T4.2 double-run + T4.6 edge : overrides G9 (réversibles — supprimer ce fichier au retrait de l'edge).
|
||||||
|
# Monté :ro par compose/nextcloud → source de vérité = kit (S11.1).
|
||||||
|
# Tableau merge (array_replace_recursive) avec config.php : index 3 = ajout, indices 0-2 = conservés.
|
||||||
|
$CONFIG = array (
|
||||||
|
'trusted_domains' => array (0 => 'nextcloud.folzocorp.fr', 1 => '192.168.1.24', 2 => '192.168.1.24:8080', 3 => 'cloud.folzocorp.fr'),
|
||||||
|
'overwritehost' => 'cloud.folzocorp.fr',
|
||||||
|
'overwriteprotocol' => 'https',
|
||||||
|
'overwrite.cli.url' => 'https://cloud.folzocorp.fr',
|
||||||
|
# /12 = réseau bridge Docker (IP du conteneur edge, dynamique) ; /24 = accès direct LAN (double-run).
|
||||||
|
'trusted_proxies' => array (0 => '192.168.1.0/24', 1 => '172.16.0.0/12'),
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# PhotoPrism — T4.3 (photos, G9)
|
# PhotoPrism — T4.3 (G9)
|
||||||
# Lecture IN-PLACE des originaux (S5) : pointe sur /data/famille/photos (pas de déplacement).
|
# Décision #3 (C-T4-DECISIONS) : les photos sont **copiées** sur le G9 (/data/docker/photoprism/originals),
|
||||||
# Stateful (base + index) → exclu de Watchtower (S11.4). Image PINNÉE (S11.4, pas :latest).
|
# plus de lecture in-place sur le NAS. Image épinglée (S11.4), stateful (base + index) → Watchtower OFF.
|
||||||
|
# PhotoPrism ne connaît PAS les variables « *_FILE » : les secrets sont fournis par env_file
|
||||||
|
# (secrets/photoprism.env, 600 root, hors git) — MariaDB, lui, accepte *_FILE.
|
||||||
services:
|
services:
|
||||||
photoprism:
|
photoprism:
|
||||||
image: photoprism/photoprism:260919
|
image: photoprism/photoprism:260919
|
||||||
@@ -8,34 +10,51 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
env_file:
|
||||||
|
- ./secrets/photoprism.env
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
- PHOTOPRISM_URL=https://photos.folzocorp.fr
|
- PHOTOPRISM_SITE_URL=http://192.168.1.24:2342/
|
||||||
- PHOTOPRISM_SITE_URL=https://photos.folzocorp.fr
|
- PHOTOPRISM_SITE_TITLE=Photos Folzo
|
||||||
- PHOTOPRISM_ADMIN_USER=admin
|
- PHOTOPRISM_DATABASE_DRIVER=mysql
|
||||||
- PHOTOPRISM_ADMIN_PASSWORD_FILE=/run/secrets/admin
|
- PHOTOPRISM_DATABASE_SERVER=db:3306
|
||||||
- PHOTOPRISM_LIBRARY_PATH=/originals:/import
|
- PHOTOPRISM_DATABASE_NAME=photoprism
|
||||||
- PHOTOPRISM_DATABASE_USER=photoprism
|
- PHOTOPRISM_DATABASE_USER=photoprism
|
||||||
- PHOTOPRISM_DATABASE_PASSWORD_FILE=/run/secrets/dbpass
|
- PHOTOPRISM_LOCAL_NETWORKS=192.168.0.0/16
|
||||||
- PHOTOPRISM_LOCAL_NETWORKS=172.16.0.0/12,192.168.0.0/16
|
- PHOTOPRISM_HTTP_COMPRESSION=gzip
|
||||||
|
- PHOTOPRISM_LOG_LEVEL=info
|
||||||
|
- PHOTOPRISM_PUBLIC=false
|
||||||
|
- PHOTOPRISM_READONLY=false
|
||||||
|
- PHOTOPRISM_EXPERIMENTAL=false
|
||||||
|
- PHOTOPRISM_ORIGINALS_LIMIT=5000
|
||||||
|
- PHOTOPRISM_AUTO_INDEX=300
|
||||||
|
- PHOTOPRISM_AUTO_IMPORT=-1
|
||||||
- PHOTOPRISM_SIDECAR_ENABLED=true
|
- PHOTOPRISM_SIDECAR_ENABLED=true
|
||||||
volumes:
|
volumes:
|
||||||
- /data/famille/photos:/originals:ro # in-place (lecture seule)
|
- /data/docker/photoprism/originals:/photoprism/originals
|
||||||
- ./storage:/storage
|
- /data/docker/photoprism/storage:/photoprism/storage
|
||||||
- ./secrets:/run/secrets:ro
|
- /data/docker/photoprism/import:/photoprism/import
|
||||||
# Bind 0.0.0.0 : accessible depuis le LAN (J5040 edge/Caddy le proxy).
|
# Bind 0.0.0.0 + ufw (01-hardening.sh) restreint le port au LAN → pas d'exposition WAN.
|
||||||
# ufw (01-hardening.sh) restreint le port au LAN 192.168.1.0/24 → pas d'exposition WAN.
|
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:2342:2342"
|
- "0.0.0.0:2342:2342"
|
||||||
depends_on:
|
depends_on:
|
||||||
- photoprism_db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
photoprism_db:
|
db:
|
||||||
image: mariadb:10.11
|
image: mariadb:10.11
|
||||||
container_name: photoprism_db
|
container_name: photoprism_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
command: >
|
||||||
|
--innodb-buffer-pool-size=256M
|
||||||
|
--transaction-isolation=READ-COMMITTED
|
||||||
|
--character-set-server=utf8mb4
|
||||||
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--max-connections=512
|
||||||
|
--innodb-rollback-on-timeout=OFF
|
||||||
|
--innodb-lock-wait-timeout=120
|
||||||
environment:
|
environment:
|
||||||
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/rootpass
|
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/rootpass
|
||||||
- MARIADB_DATABASE=photoprism
|
- MARIADB_DATABASE=photoprism
|
||||||
@@ -44,3 +63,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
- ./secrets:/run/secrets:ro
|
- ./secrets:/run/secrets:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -23,7 +23,9 @@ services:
|
|||||||
# Secrets via fichiers (hors git, G9) créés par 03b-secrets.sh :
|
# Secrets via fichiers (hors git, G9) créés par 03b-secrets.sh :
|
||||||
- ADMIN_TOKEN_FILE=/run/secrets/admin
|
- ADMIN_TOKEN_FILE=/run/secrets/admin
|
||||||
# Derrière Caddy (edge) : hosts de confiance pour les WebSockets.
|
# Derrière Caddy (edge) : hosts de confiance pour les WebSockets.
|
||||||
- TRUSTED_HOSTS=192.168.1.0/24
|
# T4.6 : /12 = réseau bridge Docker (IP du conteneur edge, dynamique) ; /24 = accès
|
||||||
|
# direct LAN (double-run). Mêmes réseaux de confiance que Nextcloud (edge.config.php).
|
||||||
|
- TRUSTED_HOSTS=192.168.1.0/24,172.16.0.0/12
|
||||||
volumes:
|
volumes:
|
||||||
# DATA_FOLDER par défaut = /data → le volume DOIT pointer sur /data (sinon la
|
# DATA_FOLDER par défaut = /data → le volume DOIT pointer sur /data (sinon la
|
||||||
# base écrit dans la couche conteneur, non persistée, et l'import 07 n'est jamais lu).
|
# base écrit dans la couche conteneur, non persistée, et l'import 07 n'est jamais lu).
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ services:
|
|||||||
- WATCHTOWER_POLL_INTERVAL=3600 # nickfedor : SCHEDULE et POLL exclusifs
|
- WATCHTOWER_POLL_INTERVAL=3600 # nickfedor : SCHEDULE et POLL exclusifs
|
||||||
- WATCHTOWER_LABEL_ENABLE=true # seul ce qui est labelé true est mis à jour
|
- WATCHTOWER_LABEL_ENABLE=true # seul ce qui est labelé true est mis à jour
|
||||||
# Notifications shoutrrr generic → webhook n8n → salon Matrix ops.
|
# Notifications shoutrrr generic → webhook n8n → salon Matrix ops.
|
||||||
# ⚠️ PLACEHOLDER : remplacer par l'URL webhook réelle de la machine (workflow n8n w1pfhX0A7KvaDrtk).
|
# n8n est joint par son nom de service sur le réseau Docker local (pas par l'edge :
|
||||||
|
# l'edge n'écoute que 443 ; n8n.folzocorp.fr = voie LAN/WAN via edge, pas conteneur→conteneur).
|
||||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||||
- WATCHTOWER_NOTIFICATION_URL=generic:https://n8n.folzocorp.fr/webhook/watchtower
|
- WATCHTOWER_NOTIFICATION_URL=generic+http://n8n:5678/webhook/watchtower
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -82,3 +82,87 @@
|
|||||||
| 2026-10-11 07:56 | 4 | migrate:vaultwarden : backup restic pré-migration | Garde-fou G2 avant import Vaultwarden | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
| 2026-10-11 07:56 | 4 | migrate:vaultwarden : backup restic pré-migration | Garde-fou G2 avant import Vaultwarden | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
| 2026-10-11 07:57 | 4 | migrate:vaultwarden : coffre importé + service lancé | T4.1 (double-run G4) | Source stoppée → tar/ssh → **source relancée healthy** ; 621 fichiers sha256 identiques (source == stage == G9) ; G9 = 953 ciphers / 1 user (identiques à .25, revérifié après coup : .25 healthy 953) ; migrations diesel OK (v.20260902120000) ; alive 200, WebVault 200 ; DOMAIN conservé = `pass.folzocorp.fr` (clients existants intacts) ; image épinglée 1.37.4 (génération source, image .25 pullée 2026-10-05) | docker compose -f /data/docker/vaultwarden/docker-compose.yml down + rm -rf /data/docker/vaultwarden ; .25 intact |
|
| 2026-10-11 07:57 | 4 | migrate:vaultwarden : coffre importé + service lancé | T4.1 (double-run G4) | Source stoppée → tar/ssh → **source relancée healthy** ; 621 fichiers sha256 identiques (source == stage == G9) ; G9 = 953 ciphers / 1 user (identiques à .25, revérifié après coup : .25 healthy 953) ; migrations diesel OK (v.20260902120000) ; alive 200, WebVault 200 ; DOMAIN conservé = `pass.folzocorp.fr` (clients existants intacts) ; image épinglée 1.37.4 (génération source, image .25 pullée 2026-10-05) | docker compose -f /data/docker/vaultwarden/docker-compose.yml down + rm -rf /data/docker/vaultwarden ; .25 intact |
|
||||||
| 2026-10-11 08:01 | 4 | migrate:vaultwarden : 2FA + ADMIN_TOKEN + emergency access | T4.1 (critères 2FA + emergency access) | **2FA TOTP déjà actif côté source et importé avec le coffre** : twofactor(atype=0, enabled=1, last_used=59703916) identique sur le G9 → l'appli d'authentification de l'utilisateur continue sans re-scannage ; ADMIN_TOKEN **haché argon2id** (m=64MiB,t=3,p=4 — preset bitwarden, python3-argon2) : avis « plain text ADMIN_TOKEN » disparu, login admin 200 (dashboard Users/Invitations/Organizations/**Emergency** accessibles) ; token clair dans `/data/docker/vaultwarden/secrets/admin-plain` (600 root, à consigner dans le coffre puis supprimer) ; `EMERGENCY_ACCESS_ALLOWED=true` + `INVITATIONS_ALLOWED=true` posés | restaurer secrets/admin (token clair) + restart ; retirer les 2 env |
|
| 2026-10-11 08:01 | 4 | migrate:vaultwarden : 2FA + ADMIN_TOKEN + emergency access | T4.1 (critères 2FA + emergency access) | **2FA TOTP déjà actif côté source et importé avec le coffre** : twofactor(atype=0, enabled=1, last_used=59703916) identique sur le G9 → l'appli d'authentification de l'utilisateur continue sans re-scannage ; ADMIN_TOKEN **haché argon2id** (m=64MiB,t=3,p=4 — preset bitwarden, python3-argon2) : avis « plain text ADMIN_TOKEN » disparu, login admin 200 (dashboard Users/Invitations/Organizations/**Emergency** accessibles) ; token clair dans `/data/docker/vaultwarden/secrets/admin-plain` (600 root, à consigner dans le coffre puis supprimer) ; `EMERGENCY_ACCESS_ALLOWED=true` + `INVITATIONS_ALLOWED=true` posés | restaurer secrets/admin (token clair) + restart ; retirer les 2 env |
|
||||||
|
| 2026-10-11 08:13 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 08:16 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 08:21 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 08:28 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 08:35 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 08:40 | 4 | audit .25 : « disparition » de /volume1/docker-REC = VUE de partage DSM, pas une perte | Inventaire d'abord pris sur /volume1/docker-REC (60 entrées), puis 10 entrées une heure plus tard, propriétaire de « . » passé de Master à root, mtime /volume1 = 03:01 | `docker inspect` = vérité terrain : nextcloud_app → **/volume2/docker-REC/NextCloud/{data,config}** (+ volume nommé /volume1/@docker/volumes/f5f0…/_data), vaultwarden-server-1 → **/volume1/docker/vaultwarden**, gitea → /volume1/docker/Gitea/data. /volume1/docker-REC est une vue multi-volumes du partage « docker-REC », remontée à 03:01. **Aucune donnée perdue** : /volume2/docker-REC/NextCloud/data = 1.9 Go / 2608 fichiers ; db.sqlite3 de vaultwarden sha256 `2cad5eea42ef4b04…` **identique** entre .25 et le G9 | lecture seule, aucune action ; chemins canoniques consignés dans 07c (XP_DATA/XP_CONF = /volume2/…) |
|
||||||
|
| 2026-10-11 08:42 | 4 | kit : lancer les scripts de migration en **folzo**, jamais en `sudo` global | `sudo` remplace $HOME par /root → « Identity file /root/.ssh/id_ed25519_openfox11 not accessible » puis « Host key verification failed » (root n'a ni clé ni known_hosts) ; 3 runs de 07c échoués en préflight | 07c réécrit : appels « G9 » en **local** (`sh -c` + sudo local, la machine de pilotage EST le G9), clé en chemin absolu ; import du dump via **MARIADB_USER/MARIADB_PASSWORD** (env du conteneur MariaDB G9, pas MYSQL_*) | relancer en root avec SSH_KEY=<chemin> explicite si besoin |
|
||||||
|
| 2026-10-11 09:03 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 09:10 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 09:18 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 10:10 | 4 | clarification 08:42 : le 07c **déposé** est la variante SSH, pas « local » | La ligne 08:42 décrit une réécriture « appels G9 en local » jamais commitée ; le `07c-migrate-nextcloud.sh` du dépôt passe TOUTES les ops G9 par SSH (folzo@.24, clé sur le pilot) — cohérent avec son en-tête | 07c déposé = variante SSH : préflight SSH .25 + .24, import rsync/ssh, restore via MARIADB_USER + secrets/dbpass. La formulation « local » de 08:42 ne décrit PAS le code du dépôt. + garde-fou idempotence backup (07c/07d) + nettoyage dumps (07c) + n8n.env builder (03b) + mount secrets collabora + label watchtower collabora=false | aucune — ligne documentaire |
|
||||||
|
| 2026-10-11 10:46 | 4 | récupération du thread Phase 4 supprimé (session `e67f1166…`) | suppression accidentelle de la session 07:20 UTC (cascade `events`) ; le runner a continué à travailler (journal 09:18→10:13) | thread réinséré : **130 messages / 64 657 événements / 33,85 Mo** ; caravage des pages SQLite libérées = **51 420 payloads JSON (34,68 Mo)** + 79 830 entrées d'index ; **~35 % du volume**, prose en fragments désordonnés, messages utilisateur quasi tous perdus ; intégrité `ok` avant/après ; rapport complet `g9-migration/PHASE4-RECOLLEMENT.md` | chirurgical : `DELETE FROM events WHERE session_id='e67f1166…'; DELETE FROM sessions WHERE id='e67f1166…'` — ou restauration `snap2` (voir §4 du rapport) |
|
||||||
|
| 2026-10-11 11:05 | 4 | corrections post-revue code (5 findings) | Revue de code du diff Phase 4 | 1 **sécurité** : `collabora` ne lisait PAS `password_file` (non supporté par collabora/code) → console 9980 sans mot de passe ; remplacé par `env_file: ./secrets/collabora.env` (`password=…`) généré par 03b (pattern n8n) ; 2 **import 07c** : DEFINER retirés du dump + import en **root** (rootpass) + **rc contrôlé** (l'ancien `\| tail -5` masquait ERROR 1227) ; 3 07c : `SRC_USERS` avec `\|\| true` (préflight tué par grep rc=1) ; 4 07d : progression `tar --checkpoint=50000` sur la copie 530 Go ; 5 README : arborescence + ligne phase 4 = 07a–07d (07-migrate-stateful = générique rsync, inopérant sur DSM) ; 6 `PHASE4-RECOLLEMENT.md` dégraissé (821→75 l., dump §5 hors dépôt). Vérif : `bash -n` 3/3 OK, YAML compose OK (env_file présent, password_file absent), sed DEFINER testé | `git checkout` des fichiers modifiés (compose nextcloud, 03b, 07c, 07d, README) |
|
||||||
|
| 2026-10-11 12:44 | 4 | migrate:nextcloud : backup restic pré-migration | Garde-fou G2 avant import Nextcloud | snapshot restic Freebox (log /var/log/restic-daily.log) | restic forget <snapshot> && restic prune |
|
||||||
|
| 2026-10-11 10:45 | 4 | migrate:nextcloud : migration complète (07c) | T4.2 (double-run G4) | export app stoppée (db vivante, mysqldump --single-transaction 31,9 Mo) + tars data (1,9 Go, 2608 fichiers sha256 identiques) + config + custom_apps → **app .25 relancée healthy** ; import G9 : MariaDB 10.11 (utf8mb4_bin, DEFINER retirés, import root, filecache=2727, users=8), occ status installed/maintenance off, version 35.0.1.1 = image épinglée, occ upgrade « No upgrade required », occ app:list **identique à la source (66 apps)** ; overrides LAN dans config/g9-double-run.config.php (réversible) ; dumps supprimés sur les 3 machines (hashs mots de passe users) | docker compose -f /data/docker/nextcloud/docker-compose.yml down + rm -rf /data/docker/nextcloud ; .25 intact |
|
||||||
|
| 2026-10-11 11:02 | 4 | migrate:nextcloud : Collabora — TLS interne désactivé | L'image collabora/code:26.04 sert **TLS par défaut** : discovery http = 000, https -k = 200 ; env `extra_features`/`EXTRA_FEATURES` et `O:ssl.enable` **non appliqués** (mesuré : entrypoint = coolwsd --use-env-vars sans script) | fix = **command: --o:ssl.enable=false** (CMD appendu à l'entrypoint) → logs « SSL is disabled », discovery http **200** ; wopi_url = http://192.168.1.24:9980 (occ, persisté) ; Caddy (T4.6) terminera le TLS | retirer la ligne command: du compose + restart |
|
||||||
|
| 2026-10-11 13:40 | 4 (T4.5) | jellyfin : wizard + auth API | Déploiement frais G9 (aucun Jellyfin sur .25 — stack *arr reste sur .25) ; média par RÉFÉRENCE : bind /mnt/usb-media → /media:ro (aucune copie) | wizard 10.9.11 accompli via navigateur (langue → admin « g9ops » → médiathèque « Films3 » → /media/films) ; **PIÈGE mesuré** : le wizard laisse `Users.EnableLocalPassword=0` → AuthenticateByName = « Invalid username or password » ; fix = UPDATE sqlite + restart (hash PBKDF2-SHA512 vérifié == secret) ; en-tête API requis = **X-Emby-Authorization** combiné (400 « Value cannot be null » sans lui) ; médiathèques vides doublons supprimées → 1 lib « Films3 » = /media/films ; `Items?IncludeItemTypes=Movie` = **1** (test-reference.mkv, 0 octet, média de test en lecture seule) ; /system/info 200 ; label watchtower=false ; ufw 8096 ALLOW IN 192.168.1.0/24 | `DELETE /Library/VirtualFolders?name=…` + `UPDATE Users SET EnableLocalPassword=0` ; compose down (données source : aucune touchée) |
|
||||||
|
| 2026-10-11 13:55 | 4 (T4.2) | collabora : healthcheck retiré du compose | Limage collabora/code:26.04.5.1.1 est **minimale** : ni `sh` ni `curl` dans limage → `CMD curl` = « executable file not found » = **unhealthy permanent** (mesuré : 3 échecs successifs dans le log de santé) | healthcheck retiré (source de vérité = kit) + recréation du conteneur → **healthy** ; re-vérifications T4.2 complètes : occ installed/maintenance off 35.0.1.1, users=8, files:scan --all = 1522 fichiers 0 erreur, wopi_url (oc_appconfig richdocuments) = http://192.168.1.24:9980, discovery 200, status.php 200, 67 apps activés ; .25 relancé (nextcloud_app/db/redis + vaultwarden + gitea up) | remettre un healthcheck si une image avec shell est épinglée |
|
||||||
|
| 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,18 +68,29 @@ ufw --force reset >/dev/null
|
|||||||
ufw default deny incoming
|
ufw default deny incoming
|
||||||
ufw default allow outgoing
|
ufw default allow outgoing
|
||||||
ufw allow OpenSSH
|
ufw allow OpenSSH
|
||||||
# Services LAN-only (G9 = LAN-only, l'exposition WAN passe par l'edge — S9.2/S9.3).
|
# Services LAN-only (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) +
|
# Listes paramétrables (défaut = G9 .24) : J5040 edge = TCP_LAN="53 3000 8081"
|
||||||
# briques famille futures + Valheim (TCP+UDP). Accessibles depuis le LAN uniquement.
|
# UDP_LAN="53" WAN_TCP="80 443" WAN_UDP="51820".
|
||||||
for port in 2342 3000 3100 5678 5984 8008 8009 8080 8081 8096; do
|
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
|
ufw allow from 192.168.1.0/24 to any port "$port" proto tcp
|
||||||
done
|
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 tcp
|
||||||
ufw allow from 192.168.1.0/24 to any port "$port" proto udp
|
ufw allow from 192.168.1.0/24 to any port "$port" proto udp
|
||||||
done
|
done
|
||||||
|
# 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
|
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 + Valheim 2456-2458 TCP/UDP)" "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 ---
|
# --- 3. unattended-upgrades ---
|
||||||
log "unattended-upgrades : activation"
|
log "unattended-upgrades : activation"
|
||||||
|
|||||||
@@ -11,12 +11,17 @@
|
|||||||
#
|
#
|
||||||
# Génère (chmod 644 root:root — voir la note du code : les entrypoints conteneurs ne sont
|
# Génère (chmod 644 root:root — voir la note du code : les entrypoints conteneurs ne sont
|
||||||
# pas root, 600 les rendrait illisibles ; le dossier reste hors git, G9 respecté) pour chaque brique :
|
# pas root, 600 les rendrait illisibles ; le dossier reste hors git, G9 respecté) pour chaque brique :
|
||||||
# nextcloud : dbpass, rootpass
|
# nextcloud : dbpass, rootpass, collabora_admin + collabora.env (construit depuis
|
||||||
|
# collabora_admin — requis par env_file du compose : collabora/code ne lit
|
||||||
|
# PAS password_file, seul `password` est substitué dans coolwsd.xml)
|
||||||
# photoprism : admin, dbpass, rootpass
|
# photoprism : admin, dbpass, rootpass
|
||||||
# vaultwarden : admin
|
# vaultwarden : admin
|
||||||
# n8n : dbpass
|
# n8n : dbpass + n8n.env (construit depuis dbpass — requis par env_file du compose)
|
||||||
# gitea : dbpass (backend Postgres — voir compose/gitea)
|
# gitea : dbpass (backend Postgres — voir compose/gitea)
|
||||||
# (jellyfin, watchtower : pas de secret fichier)
|
# jellyfin : admin (mot de passe du compte admin posé par le wizard 10.9 — voir
|
||||||
|
# compose/jellyfin ; la base jellyfin.db est créée par le wizard, le
|
||||||
|
# secret documente le mot de passe réel, il n'est pas lu par l'image)
|
||||||
|
# (watchtower : pas de secret fichier)
|
||||||
#
|
#
|
||||||
# ⚠️ QUELS CREDENTIELS GAGNENT (piège de migration) :
|
# ⚠️ QUELS CREDENTIELS GAGNENT (piège de migration) :
|
||||||
# - INIT FRAÎCHE : ce sont ces secrets générés qui sont utilisés (MARIADB_*/POSTGRES_*
|
# - INIT FRAÎCHE : ce sont ces secrets générés qui sont utilisés (MARIADB_*/POSTGRES_*
|
||||||
@@ -58,7 +63,7 @@ gen_secret() {
|
|||||||
|
|
||||||
# briques : "brique:s1 s2 s3;..."
|
# briques : "brique:s1 s2 s3;..."
|
||||||
declare -A SECRET_MAP=(
|
declare -A SECRET_MAP=(
|
||||||
[nextcloud]="dbpass rootpass"
|
[nextcloud]="dbpass rootpass collabora_admin"
|
||||||
[photoprism]="admin dbpass rootpass"
|
[photoprism]="admin dbpass rootpass"
|
||||||
[vaultwarden]="admin"
|
[vaultwarden]="admin"
|
||||||
[n8n]="dbpass"
|
[n8n]="dbpass"
|
||||||
@@ -74,6 +79,30 @@ for brique in nextcloud photoprism vaultwarden n8n gitea; do
|
|||||||
for s in ${SECRET_MAP[$brique]}; do
|
for s in ${SECRET_MAP[$brique]}; do
|
||||||
gen_secret "$SECRETS_DIR/$s"
|
gen_secret "$SECRETS_DIR/$s"
|
||||||
done
|
done
|
||||||
|
if [[ "$brique" == "n8n" ]]; then
|
||||||
|
ENVF="$SECRETS_DIR/n8n.env"
|
||||||
|
if [[ -f "$ENVF" ]]; then
|
||||||
|
log " ⏭ existe déjà : $ENVF (non modifié)"
|
||||||
|
else
|
||||||
|
umask 077
|
||||||
|
{ echo "DB_POSTGRESDB_PASSWORD=$(cat "$SECRETS_DIR/dbpass")"; } > "$ENVF"
|
||||||
|
chmod 644 "$ENVF"
|
||||||
|
log " ✅ généré : $ENVF (env_file requis par compose/n8n)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$brique" == "nextcloud" ]]; then
|
||||||
|
# collabora/code ne lit PAS password_file : seul `password` est substitué dans
|
||||||
|
# coolwsd.xml → console admin 9980 protégée par env_file (pattern n8n).
|
||||||
|
ENVF="$SECRETS_DIR/collabora.env"
|
||||||
|
if [[ -f "$ENVF" ]]; then
|
||||||
|
log " ⏭ existe déjà : $ENVF (non modifié)"
|
||||||
|
else
|
||||||
|
umask 077
|
||||||
|
{ echo "password=$(cat "$SECRETS_DIR/collabora_admin")"; } > "$ENVF"
|
||||||
|
chmod 644 "$ENVF"
|
||||||
|
log " ✅ généré : $ENVF (env_file requis par compose/nextcloud → collabora)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
journal "Secrets bootstrap OK" "Briques stateful nécessitent des fichiers secrets" "nextcloud, photoprism, vaultwarden, n8n (chmod 644 root:root, dossier hors git)" "suppression des fichiers secrets générés"
|
journal "Secrets bootstrap OK" "Briques stateful nécessitent des fichiers secrets" "nextcloud, photoprism, vaultwarden, n8n (chmod 644 root:root, dossier hors git)" "suppression des fichiers secrets générés"
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
# - Sur le G9 : /data monté, Docker opérationnel.
|
# - Sur le G9 : /data monté, Docker opérationnel.
|
||||||
# - Secrets bootstrap (03b-secrets.sh) déjà exécuté.
|
# - Secrets bootstrap (03b-secrets.sh) déjà exécuté.
|
||||||
#
|
#
|
||||||
|
# ⚠️ TRANSPORT (mesuré 2026-10-11, DSM 7) : le sshd du xpenology refuse la
|
||||||
|
# commande rsync --server (« Permission denied, please try again. ») et scp
|
||||||
|
# n'est pas servi (« subsystem request failed »). Le pull rsync/scp depuis .25
|
||||||
|
# est donc IMPOSSIBLE. Transports qui fonctionnent : « ssh sudo tar cz » et
|
||||||
|
# « ssh sudo cat ». Les briques sont migrées par les scripts dédiés
|
||||||
|
# (07a-migrate-gitea.sh, 07b-migrate-vaultwarden.sh, 07c-migrate-nextcloud.sh)
|
||||||
|
# qui utilisent tar+ssh ; ce script générique est gardé pour les hôtes qui
|
||||||
|
# exposent rsync.
|
||||||
|
#
|
||||||
# ⚠️ COHÉRENCE DES BASES (G4) : le rsync d'une base VIVANTE (MariaDB/Postgres en
|
# ⚠️ COHÉRENCE DES BASES (G4) : le rsync d'une base VIVANTE (MariaDB/Postgres en
|
||||||
# service sur .25) produit une copie incohérente et un manifeste sha256 qui
|
# service sur .25) produit une copie incohérente et un manifeste sha256 qui
|
||||||
# change pendant le transfert. Le script STOPPE donc les conteneurs source
|
# change pendant le transfert. Le script STOPPE donc les conteneurs source
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 07c-migrate-nextcloud.sh — T4.2 : migration Nextcloud (xpenology .25 → G9 .24)
|
||||||
|
#
|
||||||
|
# Garde-fous : G4 (double-run : source stoppée pendant l'export puis **relancée**, rien supprimé),
|
||||||
|
# G2 (backup restic avant import), G7 (rollback), G8 (journal), G9 (aucun secret loggué/commité).
|
||||||
|
#
|
||||||
|
# SOURCE mesurée (2026-10-11) — 3 mounts du conteneur nextcloud_app :
|
||||||
|
# /volume2/docker-REC/NextCloud/data → /var/www/html/data (datadirectory, 1.9 Go)
|
||||||
|
# /volume1/@docker/volumes/<hash>/_data → /var/www/html (VOLUME NOMMÉ = code app +
|
||||||
|
# custom_apps : calendar, contacts, mail, deck, notes,
|
||||||
|
# richdocuments, collectives, forms, spreed, whiteboard…)
|
||||||
|
# /volume2/docker-REC/NextCloud/config → /var/www/html/config (config.php, redis, smtp…)
|
||||||
|
# DB : conteneur nextcloud_db (mariadb 10.6), base nextcloud / user nextcloud → mysqldump.
|
||||||
|
#
|
||||||
|
# POINTS CLÉS (C-T4.2) :
|
||||||
|
# - config.php importé impose dbhost=`db`, dbname/dbuser=nextcloud → le compose G9 nomme son
|
||||||
|
# service DB `db` et reçoit le **mot de passe d'origine** (lu depuis l'env du conteneur
|
||||||
|
# source, écrit en 644 dans secrets/dbpass, JAMAIS affiché).
|
||||||
|
# - image épinglée nextcloud:35.0.1 = version source exacte → pas de migration de schéma.
|
||||||
|
# - custom_apps copiés depuis le volume nommé : sans eux la base déclarerait des apps absents.
|
||||||
|
#
|
||||||
|
# Usage : sur la **machine de pilotage** (celle qui détient la clé .25/.24, ici /home/effi),
|
||||||
|
# ./07c-migrate-nextcloud.sh [SSH_KEY=<chemin> pour forcer la clé]
|
||||||
|
# Les ops G9 passent par SSH (folzo@.24, sudo pour la partie root) : la clé vit sur le pilot,
|
||||||
|
# PAS sur le G9. Ne PAS lancer via `sudo` global : $HOME devient /root → clé et known_hosts
|
||||||
|
# introuvables → « Host key verification failed » (mesuré 2026-10-11, 3 runs échoués).
|
||||||
|
# Rollback : G9 `docker compose -f /data/docker/nextcloud/docker-compose.yml down` +
|
||||||
|
# `rm -rf /data/docker/nextcloud` ; .25 intact (services relancés).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
XP_USER="Master"; XP_HOST="192.168.1.25"; XP_PORT=222
|
||||||
|
XP_DOCKER="/var/packages/ContainerManager/target/usr/bin/docker"
|
||||||
|
# On stoppe **l'app** (writes quiescents) mais PAS la DB : mysqldump a besoin du serveur
|
||||||
|
# tournant, et --single-transaction fournit l'instantané cohérent (G4 : relancée après).
|
||||||
|
XP_CONTAINERS="nextcloud_app"
|
||||||
|
XP_DATA="/volume2/docker-REC/NextCloud/data"
|
||||||
|
XP_CONF="/volume2/docker-REC/NextCloud/config"
|
||||||
|
XP_VOL_APP="/volume1/@docker/volumes/f5f099b69018be044f00d3d68f1e0da76cecb42bcb3b621b43d31091e33e789d/_data"
|
||||||
|
G9_USER="folzo"; G9_HOST="192.168.1.24"
|
||||||
|
KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
|
||||||
|
BRIQUE="nextcloud"
|
||||||
|
G9_DIR="/data/docker/$BRIQUE"
|
||||||
|
KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
KIT_ON_G9="/home/folzo/g9-migration"
|
||||||
|
JOURNAL="$KIT_DIR/journal.md"
|
||||||
|
STAGE="${STAGE:-$HOME/migration-staging/$BRIQUE}"
|
||||||
|
|
||||||
|
SSH_XP="ssh -p $XP_PORT -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $XP_USER@$XP_HOST"
|
||||||
|
SSH_G9="ssh -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $G9_USER@$G9_HOST"
|
||||||
|
RSH_G9="ssh -i $KEY"
|
||||||
|
|
||||||
|
log() { echo -e "\n[nextcloud] $*"; }
|
||||||
|
journal() {
|
||||||
|
printf '| %s | 4 | migrate:%s : %s | %s | %s | %s |\n' \
|
||||||
|
"$(date '+%Y-%m-%d %H:%M')" "$BRIQUE" "$1" "$2" "$3" "$4" >> "$JOURNAL"
|
||||||
|
}
|
||||||
|
manifest_in() { ( cd "$1" && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort ); }
|
||||||
|
verify_pair() { # $1 src .25 (sudo find), $2 dir local
|
||||||
|
local r l rc=0; r=$(mktemp); l=$(mktemp)
|
||||||
|
$SSH_XP "sudo sh -c \"cd '$1' && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort\"" > "$r"
|
||||||
|
manifest_in "$2" > "$l"
|
||||||
|
diff -u "$r" "$l" > "$r.diff" || rc=1
|
||||||
|
if [[ $rc -ne 0 ]]; then log " ❌ intégrité $2 :"; head -20 "$r.diff"; rm -f "$r" "$l" "$r.diff"; exit 1; fi
|
||||||
|
log " ✅ $(wc -l < "$r") fichiers identiques ($(basename "$2"))"
|
||||||
|
rm -f "$r" "$l" "$r.diff"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 0. préflight
|
||||||
|
log "=== 0/6 PRÉFLIGHT ==="
|
||||||
|
$SSH_XP "command -v tar >/dev/null && sudo tar --version >/dev/null" || { echo "ERREUR : SSH/tar .25"; exit 1; }
|
||||||
|
$SSH_G9 "command -v docker >/dev/null && command -v rsync >/dev/null" || { echo "ERREUR : docker/rsync .24"; exit 1; }
|
||||||
|
$SSH_G9 "test -d $KIT_ON_G9/compose" || { echo "ERREUR : kit absent sur le G9"; exit 1; }
|
||||||
|
for d in "$XP_DATA" "$XP_CONF" "$XP_VOL_APP/custom_apps"; do
|
||||||
|
$SSH_XP "sudo test -d '$d'" || { echo "ERREUR : source absente sur .25 : $d"; exit 1; }
|
||||||
|
done
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
SRC_USERS=$($SSH_XP "sudo $XP_DOCKER exec nextcloud_app php occ user:list 2>/dev/null | grep -c '^\s*-' || true")
|
||||||
|
log "source .25 : users occ = $SRC_USERS"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1. backup G2
|
||||||
|
log "=== 1/6 BACKUP restic (G2) ==="
|
||||||
|
if $SSH_G9 "sudo restic snapshots 2>/dev/null | grep -q '$(date '+%Y-%m-%d')'"; 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"
|
||||||
|
journal "backup restic pré-migration" "Garde-fou G2 avant import Nextcloud" "snapshot restic Freebox (log /var/log/restic-daily.log)" "restic forget <snapshot> && restic prune"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2. export (stop → export → start)
|
||||||
|
log "=== 2/6 EXPORT : stop app → mysqldump (db vivante, --single-transaction) + tars → relance app (G4) ==="
|
||||||
|
STOPPED=()
|
||||||
|
for c in $XP_CONTAINERS; do
|
||||||
|
if $SSH_XP "sudo $XP_DOCKER ps -q -f name=^${c}\$" | grep -q .; then
|
||||||
|
$SSH_XP "sudo $XP_DOCKER stop $c >/dev/null" && STOPPED+=("$c") && log " ⏸ source stoppée : $c"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
restart_source() {
|
||||||
|
for c in "${STOPPED[@]:-}"; do
|
||||||
|
[[ -n "$c" ]] || continue
|
||||||
|
$SSH_XP "sudo $XP_DOCKER start $c >/dev/null" && log " ▶ source redémarrée : $c" \
|
||||||
|
|| log " ⚠️ ÉCHEC redémarrage $c — INTERVENIR SUR .25"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap restart_source EXIT
|
||||||
|
|
||||||
|
DUMP=/tmp/nc_dump.sql
|
||||||
|
$SSH_XP "sudo $XP_DOCKER exec nextcloud_db sh -c 'mysqldump -u\"\$MYSQL_USER\" -p\"\$MYSQL_PASSWORD\" --single-transaction --routines --triggers --events --default-character-set=utf8mb4 \"\$MYSQL_DATABASE\"' > $DUMP && sudo chmod 644 $DUMP"
|
||||||
|
log " dump : $($SSH_XP "stat -c%s $DUMP") o, sha256 $($SSH_XP "sha256sum $DUMP | cut -c1-16")…"
|
||||||
|
# mot de passe DB d'origine : lu depuis l'env du conteneur, écrit en 600 local, jamais affiché
|
||||||
|
$SSH_XP "sudo $XP_DOCKER inspect nextcloud_db --format '{{range .Config.Env}}{{println .}}{{end}}'" \
|
||||||
|
| sed -n 's/^MYSQL_PASSWORD=//p' > "$STAGE/dbpass"; chmod 600 "$STAGE/dbpass"
|
||||||
|
[[ -s "$STAGE/dbpass" ]] || { echo "ERREUR : mot de passe DB source non récupéré"; exit 1; }
|
||||||
|
|
||||||
|
$SSH_XP "sudo tar cz -C '$XP_DATA' ." > "$STAGE/data.tgz"
|
||||||
|
$SSH_XP "sudo tar cz -C '$XP_CONF' ." > "$STAGE/config.tgz"
|
||||||
|
$SSH_XP "sudo tar cz -C '$XP_VOL_APP' custom_apps" > "$STAGE/custom_apps.tgz"
|
||||||
|
log " archives : data=$(du -h "$STAGE/data.tgz"|cut -f1) config=$(du -h "$STAGE/config.tgz"|cut -f1) custom_apps=$(du -h "$STAGE/custom_apps.tgz"|cut -f1)"
|
||||||
|
mkdir -p "$STAGE/x_data" "$STAGE/x_config" "$STAGE/x_custom_apps"
|
||||||
|
tar xzf "$STAGE/data.tgz" -C "$STAGE/x_data"
|
||||||
|
tar xzf "$STAGE/config.tgz" -C "$STAGE/x_config"
|
||||||
|
tar xzf "$STAGE/custom_apps.tgz" -C "$STAGE/x_custom_apps"
|
||||||
|
verify_pair "$XP_DATA" "$STAGE/x_data"
|
||||||
|
verify_pair "$XP_CONF" "$STAGE/x_config"
|
||||||
|
verify_pair "$XP_VOL_APP/custom_apps" "$STAGE/x_custom_apps/custom_apps"
|
||||||
|
rm -f "$STAGE"/*.tgz
|
||||||
|
|
||||||
|
restart_source; STOPPED=()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3. import G9
|
||||||
|
log "=== 3/6 IMPORT sur le G9 ==="
|
||||||
|
$SSH_G9 "sudo mkdir -p $G9_DIR/data/data && sudo chown -R $G9_USER:$G9_USER $G9_DIR"
|
||||||
|
# MariaDB tourne en uid 999 : le datadir doit lui appartenir (sinon « Permission denied » sur
|
||||||
|
# db.opt, mesuré 2026-10-11). Le chown -R folzo ci-dessus est nécessaire au rsync, pas à la DB.
|
||||||
|
$SSH_G9 "sudo mkdir -p $G9_DIR/db && sudo chown -R 999:999 $G9_DIR/db && sudo chmod 750 $G9_DIR/db"
|
||||||
|
rsync -a --info=stats1 -e "$RSH_G9" "$STAGE/x_data/" "$G9_USER@$G9_HOST:$G9_DIR/data/data/"
|
||||||
|
rsync -a --info=stats1 -e "$RSH_G9" "$STAGE/x_config/" "$G9_USER@$G9_HOST:$G9_DIR/config/"
|
||||||
|
rsync -a --info=stats1 -e "$RSH_G9" "$STAGE/x_custom_apps/" "$G9_USER@$G9_HOST:$G9_DIR/data/"
|
||||||
|
g=$(mktemp); l2=$(mktemp); manifest_in "$STAGE/x_data" > "$l2"
|
||||||
|
$SSH_G9 "cd '$G9_DIR/data/data' && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort" > "$g"
|
||||||
|
diff -u "$l2" "$g" > "$g.diff" || { log "❌ intégrité data sur le G9 :"; head -20 "$g.diff"; exit 1; }
|
||||||
|
log " ✅ data G9 : $(wc -l < "$g") fichiers identiques"
|
||||||
|
rm -f "$l2" "$g" "$g.diff"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 4. compose + secrets + restore DB
|
||||||
|
log "=== 4/6 COMPOSE + SECRETS + RESTORE DB ==="
|
||||||
|
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"
|
||||||
|
# dbpass = mot de passe D'ORIGINE (config.php importé), rootpass = généré par 03b-secrets.
|
||||||
|
# secrets/ est root:root sur le G9 → passage par /tmp puis install en sudo (jamais affiché).
|
||||||
|
rsync -a -e "$RSH_G9" "$STAGE/dbpass" "$G9_USER@$G9_HOST:/tmp/.nc-dbpass"
|
||||||
|
$SSH_G9 "sudo install -m 644 -o root -g root /tmp/.nc-dbpass $G9_DIR/secrets/dbpass && rm -f /tmp/.nc-dbpass && sudo chmod 644 $G9_DIR/secrets/rootpass $G9_DIR/secrets/collabora_admin 2>/dev/null || true"
|
||||||
|
rm -f "$STAGE/dbpass"; rm -rf "$STAGE"
|
||||||
|
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose up -d db redis && sleep 25 && docker compose ps --format '{{.Name}} {{.Status}}'"
|
||||||
|
log " restore du dump (base vierge, schéma oc_ + utf8mb4)"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T db sh -c 'mysql -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/dbpass)\" -e \"ALTER DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;\"' < /dev/null"
|
||||||
|
$SSH_XP "sudo cat $DUMP" > /tmp/nc_dump_local.sql
|
||||||
|
# DEFINER=`nextcloud`@`…` (routines/triggers/events, issus de --routines --triggers --events)
|
||||||
|
# importés en utilisateur non-root → « ERROR 1227 … you need SUPER » sous MariaDB 10.11.
|
||||||
|
# → DEFINER retirés localement, import en **root** (rootpass, seedé par 03b), rc contrôlé
|
||||||
|
# (l'ancien `2>&1 | tail -5` masquait l'échec et le script poursuivait).
|
||||||
|
DEF_COUNT=$(grep -c 'DEFINER=' /tmp/nc_dump_local.sql || true)
|
||||||
|
sed -E 's/DEFINER=`[^`]+`@`[^`]+`//g' /tmp/nc_dump_local.sql > /tmp/nc_dump_stripped.sql
|
||||||
|
log " DEFINER retirés du dump : $DEF_COUNT"
|
||||||
|
rsync -a --info=stats1 -e "$RSH_G9" /tmp/nc_dump_stripped.sql "$G9_USER@$G9_HOST:$G9_DIR/nc_dump.sql"
|
||||||
|
IMPORT_LOG=/tmp/nc_import.log
|
||||||
|
if ! $SSH_G9 "cd $G9_DIR && docker compose exec -T db sh -c 'mysql -u root -p\"\$(cat /run/secrets/rootpass)\" nextcloud' < $G9_DIR/nc_dump.sql" > "$IMPORT_LOG" 2>&1; then
|
||||||
|
log "❌ import du dump en échec (rc≠0) — 20 dernières lignes de la sortie mysql :"
|
||||||
|
tail -20 "$IMPORT_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qiE '^ERROR [0-9]+' "$IMPORT_LOG"; then
|
||||||
|
log "❌ erreurs MySQL détectées pendant l'import :"
|
||||||
|
grep -iE '^ERROR [0-9]+' "$IMPORT_LOG" | head -10
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log " import OK en root (base vierge, DEFINER retirés) — $(tail -1 "$IMPORT_LOG")"
|
||||||
|
rm -f /tmp/nc_dump_local.sql /tmp/nc_dump_stripped.sql "$IMPORT_LOG"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T db sh -c 'mysql -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/dbpass)\" nextcloud -e \"select count(*) as filecache from oc_filecache; select count(*) as users from oc_users;\"' < /dev/null"
|
||||||
|
# nettoyage des dumps (hashs des mots de passe users) — G9 : rien ne traîne (les 3 copies)
|
||||||
|
$SSH_XP "sudo rm -f $DUMP"
|
||||||
|
rm -f /tmp/nc_dump_local.sql
|
||||||
|
$SSH_G9 "rm -f $G9_DIR/nc_dump.sql"
|
||||||
|
log " dumps nettoyés (.25, pilot, G9)"
|
||||||
|
|
||||||
|
# override LAN du double-run (documenté, réversible) : pas de réécriture https, LAN en confiance
|
||||||
|
$SSH_G9 "cat > $G9_DIR/config/g9-double-run.config.php" <<'PHP'
|
||||||
|
<?php
|
||||||
|
# T4.2 double-run : overrides G9 (réversibles — supprimer ce fichier à la bascule T4.6).
|
||||||
|
$CONFIG = array (
|
||||||
|
'trusted_domains' => array (0 => 'nextcloud.folzocorp.fr', 1 => '192.168.1.24', 2 => '192.168.1.24:8080', 3 => 'cloud.folzocorp.fr'),
|
||||||
|
'overwritehost' => 'cloud.folzocorp.fr',
|
||||||
|
'overwriteprotocol' => 'https',
|
||||||
|
'overwrite.cli.url' => 'https://cloud.folzocorp.fr',
|
||||||
|
# /12 = réseau bridge Docker (IP du conteneur edge, dynamique) ; /24 = accès direct LAN (double-run).
|
||||||
|
'trusted_proxies' => array (0 => '192.168.1.0/24', 1 => '172.16.0.0/12'),
|
||||||
|
);
|
||||||
|
PHP
|
||||||
|
|
||||||
|
$SSH_G9 "sudo chown -R 33:33 $G9_DIR/data $G9_DIR/config && sudo chmod -R u+rwX $G9_DIR/data $G9_DIR/config"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose up -d nextcloud && sleep 30 && docker compose ps --format '{{.Name}} {{.Status}}'"
|
||||||
|
log " occ status / upgrade"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T nextcloud su -s /bin/sh -c 'php occ status' www-data < /dev/null 2>&1 | head -8"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T nextcloud su -s /bin/sh -c 'php occ upgrade' www-data < /dev/null 2>&1 | tail -6"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T nextcloud su -s /bin/sh -c 'php occ maintenance:repair --include-expensive' www-data < /dev/null 2>&1 | tail -4"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 5. Collabora
|
||||||
|
log "=== 5/6 COLLABORA ==="
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose up -d collabora && sleep 25 && curl -sf -o /dev/null -w 'collabora discovery HTTP %{http_code}\n' http://127.0.0.1:9980/hosting/discovery"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T nextcloud su -s /bin/sh -c 'php occ config:app:set richdocuments wopi_url --value=http://192.168.1.24:9980 && php occ config:app:get richdocuments wopi_url' www-data < /dev/null 2>&1 | tail -2"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 6. vérification
|
||||||
|
log "=== 6/6 VÉRIFICATION ==="
|
||||||
|
$SSH_G9 "curl -sf -o /dev/null -w 'nextcloud status.php HTTP %{http_code}\n' http://127.0.0.1:8080/status.php"
|
||||||
|
$SSH_G9 "cd $G9_DIR && docker compose exec -T nextcloud su -s /bin/sh -c 'php occ user:list' www-data < /dev/null 2>&1 | grep -c '^\s*-'"
|
||||||
|
$SSH_XP "sudo $XP_DOCKER ps --format '{{.Names}} {{.Status}}' | grep -iE 'nextcloud'"
|
||||||
|
journal "Nextcloud importé + lancé sur le G9 (+ Collabora)" "T4.2 (double-run G4)" "data 1.9 Go + config + custom_apps + mysqldump sha256 identiques ; occ status/upgrade OK ; Collabora discovery OK ; wopi_url → G9 ; source relancée" "docker compose -f $G9_DIR/docker-compose.yml down + rm -rf $G9_DIR ; .25 intact"
|
||||||
|
|
||||||
|
log "Terminé."
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 07d-migrate-photoprism.sh — T4.3 : PhotoPrism sur le G9, photos COPIÉES (décision #3).
|
||||||
|
#
|
||||||
|
# Vérité terrain mesurée 2026-10-11 : il n'y a **aucun conteneur ni image PhotoPrism sur .25**
|
||||||
|
# (`docker ps -a` : rien, `docker images` : rien). PhotoPrism n'est donc pas « migré » :
|
||||||
|
# il est **installé** sur le G9 à partir des photos du NAS (/volume1/photo = 530 Go,
|
||||||
|
# 305 059 fichiers media, 2002→2026), avec un index neuf (décision #4 : indexation progressive).
|
||||||
|
#
|
||||||
|
# Transport : le sshd de DSM refuse rsync --server et scp (voir journal 2026-10-11) →
|
||||||
|
# flux tar sur SSH, relayé par la machine de pilotage : .25 → pilot → .24.
|
||||||
|
#
|
||||||
|
# Intégrité : manifeste **métadonnées** (chemin + taille, LC_ALL=C) source vs G9, totaux
|
||||||
|
# (nb fichiers, octets), plus échantillon sha256 de 200 fichiers.
|
||||||
|
#
|
||||||
|
# Usage : sur la machine de pilotage (détenant la clé), ./07d-migrate-photoprism.sh
|
||||||
|
# Étape longue (530 Go). `SKIP_COPY=1` pour ne redéployer que la stack.
|
||||||
|
# Rollback : `docker compose -f /data/docker/photoprism/docker-compose.yml down` +
|
||||||
|
# `rm -rf /data/docker/photoprism` ; .25 intact (lecture seule sur /volume1/photo).
|
||||||
|
|
||||||
|
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}"
|
||||||
|
BRIQUE="photoprism"
|
||||||
|
G9_DIR="/data/docker/$BRIQUE"
|
||||||
|
KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
KIT_ON_G9="/home/folzo/g9-migration"
|
||||||
|
JOURNAL="$KIT_DIR/journal.md"
|
||||||
|
STAGE="${STAGE:-$HOME/migration-staging/$BRIQUE}"
|
||||||
|
MIN_FREE_GB=600
|
||||||
|
INDEX_TIMEOUT="${INDEX_TIMEOUT:-3600}"
|
||||||
|
|
||||||
|
SSH_XP="ssh -p $XP_PORT -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $XP_USER@$XP_HOST"
|
||||||
|
SSH_G9="ssh -i $KEY -o ControlMaster=auto -o ControlPath=/tmp/.g9-ctl-%r@%h:%p -o ControlPersist=600 $G9_USER@$G9_HOST"
|
||||||
|
RSH_G9="ssh -i $KEY"
|
||||||
|
|
||||||
|
log() { echo -e "\n[photoprism] $*"; }
|
||||||
|
journal() {
|
||||||
|
printf '| %s | 4 | migrate:%s : %s | %s | %s | %s |\n' \
|
||||||
|
"$(date '+%Y-%m-%d %H:%M')" "$BRIQUE" "$1" "$2" "$3" "$4" >> "$JOURNAL"
|
||||||
|
}
|
||||||
|
meta_manifest() { ( cd "$1" && find . -type f -printf '%p\t%s\n' 2>/dev/null | LC_ALL=C sort ); }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 0. préflight
|
||||||
|
log "=== 0/5 PRÉFLIGHT ==="
|
||||||
|
$SSH_XP "command -v tar >/dev/null && sudo tar --version >/dev/null" || { echo "ERREUR : SSH/tar .25"; exit 1; }
|
||||||
|
$SSH_G9 "command -v docker >/dev/null && command -v rsync >/dev/null" || { echo "ERREUR : docker/rsync .24"; exit 1; }
|
||||||
|
$SSH_XP "sudo test -d $XP_PHOTO" || { echo "ERREUR : $XP_PHOTO absent sur .25"; exit 1; }
|
||||||
|
FREE_GB=$($SSH_G9 "df -BG --output=avail /data | tail -1 | tr -dc '0-9'")
|
||||||
|
[[ "$FREE_GB" -ge "$MIN_FREE_GB" ]] || { echo "ERREUR : espace /data insuffisant (${FREE_GB} Go < ${MIN_FREE_GB} Go)"; exit 1; }
|
||||||
|
log "espace /data = ${FREE_GB} Go"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
SRC_COUNT=$($SSH_XP "sudo find $XP_PHOTO -type f | wc -l")
|
||||||
|
SRC_BYTES=$($SSH_XP "sudo find $XP_PHOTO -type f -printf '%s\n' | awk '{s+=\$1} END {print s}'")
|
||||||
|
log "source .25 : $SRC_COUNT fichiers, $SRC_BYTES octets"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1. backup G2
|
||||||
|
log "=== 1/5 BACKUP restic (G2) ==="
|
||||||
|
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 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2. copie des photos
|
||||||
|
if [[ "${SKIP_COPY:-0}" != "1" ]]; then
|
||||||
|
log "=== 2/5 COPIE $XP_PHOTO → $G9_DIR/originals (tar sur SSH, relais pilot) ==="
|
||||||
|
$SSH_G9 "sudo mkdir -p $G9_DIR/originals $G9_DIR/storage $G9_DIR/import && sudo chown -R $G9_USER:$G9_USER $G9_DIR"
|
||||||
|
# MariaDB = uid 999 : datadir à lui (sinon « Permission denied » sur db.opt).
|
||||||
|
$SSH_G9 "sudo mkdir -p $G9_DIR/db && sudo chown -R 999:999 $G9_DIR/db && sudo chmod 750 $G9_DIR/db"
|
||||||
|
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.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3. intégrité
|
||||||
|
log "=== 3/5 INTÉGRITÉ (manifeste métadonnées + échantillon sha256) ==="
|
||||||
|
$SSH_XP "sudo sh -c \"cd '$XP_PHOTO' && find . -type f -printf '%p\t%s\n' | LC_ALL=C sort\"" > "$STAGE/src.meta"
|
||||||
|
$SSH_G9 "cd '$G9_DIR/originals' && find . -type f -printf '%p\t%s\n' | LC_ALL=C sort" > "$STAGE/g9.meta"
|
||||||
|
DST_COUNT=$(wc -l < "$STAGE/g9.meta")
|
||||||
|
DST_BYTES=$(awk -F'\t' '{s+=$2} END {print s}' "$STAGE/g9.meta")
|
||||||
|
log " source=$SRC_COUNT fichiers / $SRC_BYTES o — G9=$DST_COUNT fichiers / $DST_BYTES o"
|
||||||
|
[[ "$SRC_COUNT" == "$DST_COUNT" && "$SRC_BYTES" == "$DST_BYTES" ]] || { echo "ERREUR : compte/taille différents"; diff "$STAGE/src.meta" "$STAGE/g9.meta" | head -20; exit 1; }
|
||||||
|
diff "$STAGE/src.meta" "$STAGE/g9.meta" > "$STAGE/meta.diff" || { log "❌ chemins/tailles différents :"; head -20 "$STAGE/meta.diff"; exit 1; }
|
||||||
|
log " ✅ manifeste métadonnées identique ($DST_COUNT fichiers)"
|
||||||
|
|
||||||
|
SAMPLE=$(shuf -n 200 "$STAGE/g9.meta" | cut -f1)
|
||||||
|
printf '%s\n' "$SAMPLE" > "$STAGE/sample.txt"
|
||||||
|
n=$(wc -l < "$STAGE/sample.txt")
|
||||||
|
SRC_SHA=$(printf '%s\n' "$SAMPLE" | $SSH_XP "sudo sh -c \"cd '$XP_PHOTO' && xargs -d '\n' sha256sum\"" | awk '{print $1}' | LC_ALL=C sort)
|
||||||
|
DST_SHA=$(printf '%s\n' "$SAMPLE" | $SSH_G9 "cd '$G9_DIR/originals' && xargs -d '\n' sha256sum" | awk '{print $1}' | LC_ALL=C sort)
|
||||||
|
if [[ "$SRC_SHA" == "$DST_SHA" ]]; then
|
||||||
|
log " ✅ échantillon sha256 : $n/$n fichiers identiques"
|
||||||
|
else
|
||||||
|
log "❌ échantillon sha256 divergent"; diff <(echo "$SRC_SHA") <(echo "$DST_SHA") | head -10; exit 1
|
||||||
|
fi
|
||||||
|
journal "intégration PhotoPrism" "copie de 530 Go à vérifier avant indexation" "manifeste chemins+tailles identique ($DST_COUNT fichiers, $DST_BYTES o) + échantillon sha256 $n/$n" "re-copie ciblée via tar/ssh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 4. stack + secrets
|
||||||
|
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/"
|
||||||
|
# 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 && 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 -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
|
||||||
|
log "TERMINÉ. Prochaine étape : laisser monter l'index (cron/kommand), puis T4.5 (n8n)."
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 07e-deploy-edge.sh — T4.6 : déploiement de la brique edge (Caddy, reverse proxy LAN + HTTPS interne)
|
||||||
|
# Idempotent. Lancer depuis le PILOT (la clé SSH doit être lisible par l'utilisateur qui
|
||||||
|
# exécute — jamais `sudo` global : sudo remplace $HOME → clé injoignable, cf. journal 08:42).
|
||||||
|
#
|
||||||
|
# Garde-fous : G4 (aucune action destructive sur .25 — brique neuve, aucun import),
|
||||||
|
# G6 (ufw : 443 seul exposé WAN, ports briques LAN-only), G8 (journalisation),
|
||||||
|
# G9 (aucun secret dans git — le Caddyfile n'en contient aucun).
|
||||||
|
#
|
||||||
|
# Fait :
|
||||||
|
# 1. rsync du kit (compose/edge) vers le G9, validate du Caddyfile par Caddy lui-même
|
||||||
|
# 2. rsync sudo vers /data/docker/edge + `docker compose up -d`
|
||||||
|
# 3. ufw : 443/tcp exposé WAN (Anywhere) + 80/tcp redirect ACME ; 9980 (Collabora) LAN-only
|
||||||
|
# 4. vérifications : discovery Collabora, /hosting/capabilities, status.php, healthz Gitea,
|
||||||
|
# /system/info Jellyfin, /alive Vaultwarden — via HTTPS + SNI (curl -k)
|
||||||
|
#
|
||||||
|
# Rollback : `docker compose -f /data/docker/edge/docker-compose.yml down` +
|
||||||
|
# `ufw delete allow 443` ; les briques restent joignables en http direct (ports LAN).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
G9_HOST="192.168.1.24"
|
||||||
|
G9_USER="folzo"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_openfox11}"
|
||||||
|
SSH_G9="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new $G9_USER@$G9_HOST"
|
||||||
|
RSH_G9="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new"
|
||||||
|
KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
KIT_ON_G9="/home/folzo/g9-migration"
|
||||||
|
G9_DIR="/data/docker/edge"
|
||||||
|
|
||||||
|
log() { echo -e "\n[edge] $*"; }
|
||||||
|
journal() {
|
||||||
|
local action="$1" raison="$2" resultat="$3" rollback="$4"
|
||||||
|
printf '| %s | 4 | %s | %s | %s | %s |\n' "$(date '+%Y-%m-%d %H:%M')" "$action" "$raison" "$resultat" "$rollback" >> "$KIT_DIR/journal.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "0. préflight"
|
||||||
|
$SSH_G9 "command -v docker >/dev/null && command -v rsync >/dev/null" || { echo "ERREUR : docker/rsync .24"; exit 1; }
|
||||||
|
$SSH_G9 "test -d $KIT_ON_G9/compose" || { echo "ERREUR : kit absent sur le G9 (rsync du kit d'abord)"; exit 1; }
|
||||||
|
|
||||||
|
log "1. kit → G9 + validate du Caddyfile"
|
||||||
|
rsync -a -e "$RSH_G9" "$KIT_DIR/compose/edge/" "$G9_USER@$G9_HOST:$KIT_ON_G9/compose/edge/"
|
||||||
|
$SSH_G9 "docker run --rm -v $KIT_ON_G9/compose/edge/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2 caddy validate --config /etc/caddy/Caddyfile" > /dev/null \
|
||||||
|
&& log " ✅ Caddyfile validate OK" || { echo "ERREUR : Caddyfile invalide"; exit 1; }
|
||||||
|
|
||||||
|
log "2. installation /data/docker/edge + up"
|
||||||
|
$SSH_G9 "sudo mkdir -p $G9_DIR && sudo rsync -a $KIT_ON_G9/compose/edge/ $G9_DIR/ && sudo docker compose -f $G9_DIR/docker-compose.yml up -d" | tail -3
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
log "3. ufw : 443 exposé WAN, 80 redirect, 9980 LAN-only"
|
||||||
|
$SSH_G9 "sudo ufw status | grep -q '443/tcp' || sudo ufw allow 443/tcp comment 'edge T4.6 (seul port exposé WAN)'"
|
||||||
|
$SSH_G9 "sudo ufw status | grep -q '80/tcp' || sudo ufw allow 80/tcp comment 'edge T4.6 (redirect ACME)'"
|
||||||
|
$SSH_G9 "sudo ufw status | grep -q '9980/tcp' || sudo ufw allow from 192.168.1.0/24 to any port 9980 proto tcp comment 'collabora (LAN-only)'"
|
||||||
|
$SSH_G9 "sudo ufw reload >/dev/null; sudo ufw status numbered" | grep -E "443|80/tcp|9980"
|
||||||
|
|
||||||
|
log "4. vérifications HTTPS (SNI) via edge"
|
||||||
|
for h in pass.folzocorp.fr:/alive git.folzocorp.fr:/api/healthz cloud.folzocorp.fr:/status.php media.folzocorp.fr:/system/info n8n.folzocorp.fr:/healthz; do
|
||||||
|
host="${h%%:*}"; path="${h##*:}"
|
||||||
|
code=$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 8 "https://$host$path" --resolve "$host:443:192.168.1.24" 2>/dev/null || echo "ERR")
|
||||||
|
log " $host$path → $code"
|
||||||
|
done
|
||||||
|
code=$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 8 "https://192.168.1.24/hosting/capabilities" 2>/dev/null || echo ERR)
|
||||||
|
log " 192.168.1.24 (SNI IP) → $code"
|
||||||
|
|
||||||
|
journal "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 $G9_DIR/docker-compose.yml down ; ufw delete allow 443 (briques joignables en http direct LAN)"
|
||||||
|
log "Terminé."
|
||||||
@@ -4,12 +4,16 @@
|
|||||||
#
|
#
|
||||||
# Garde-fous : G3 (destructif — validation explicite), G4 (double-run terminé), G5 (IP .25), G7 (rollback), G8 (journalisation).
|
# 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) :
|
# ⚠️ OPÉRATION IRRÉVERSIBLE CÔTÉ RÉSEAU (garde-fous G3 + G4) :
|
||||||
# Ce script ARRÊTE et WIPE le xpenology. Il ne s'exécute QUE si :
|
# 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).
|
# 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).
|
# 2. Le backup 3-2-1 est fonctionnel + restauration testée (G2).
|
||||||
# 3. L'IP du G9 est passée à .25 (G5).
|
# 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 :
|
# Usage :
|
||||||
# sudo ./08-decommission.sh --i-understand
|
# 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 "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 "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 "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
|
echo
|
||||||
read -r -p "Saisir 'WIPE XPENOLOGY' pour confirmer la destruction : " CONF
|
read -r -p "Saisir 'ETEINDRE XPENOLOGY' pour confirmer l'arrêt et le débranchement : " CONF
|
||||||
[[ "$CONF" == "WIPE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon."
|
[[ "$CONF" == "ETEINDRE XPENOLOGY" ]] || die "Confirmation non saisie. Abandon."
|
||||||
|
|
||||||
log "Arrêt des conteneurs + arrêt du système xpenology"
|
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).
|
# 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 :
|
# 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"
|
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
|
||||||
echo "⚠️ ÉTAPE MANUELLE OBLIGATOIRE :"
|
echo "⚠️ ÉTAPE MANUELLE OBLIGATOIRE :"
|
||||||
echo " 1. Dans la GUI DSM : arrêter proprement le système."
|
echo " 1. Dans la GUI DSM : arrêter proprement le système."
|
||||||
echo " 2. Débrancher le xpenology."
|
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
|
echo
|
||||||
read -r -p "Une fois le xpenology éteint et débranché, saisir 'DONE' pour journaliser : " FINAL
|
read -r -p "Une fois le xpenology éteint et débranché, saisir 'DONE' pour journaliser : " FINAL
|
||||||
[[ "$FINAL" == "DONE" ]] || die "Non confirmé."
|
[[ "$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."
|
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