sync: scission owner/template + brain-template-export + BRAIN_MODE guard + /visualize scope filter + port orphelins fix

This commit is contained in:
2026-03-21 02:34:47 +01:00
parent 78323a0094
commit 2fd53cce8e
93 changed files with 6953 additions and 684 deletions

310
scripts/brain-secrets-sync.sh Executable file
View File

@@ -0,0 +1,310 @@
#!/usr/bin/env bash
# brain-secrets-sync.sh — Registre secrets + sync SSH (ADR-040)
#
# Usage :
# brain-secrets-sync.sh status → compare registre vs MYSECRETS local
# brain-secrets-sync.sh audit → secrets expirés, rotation due, manquants
# brain-secrets-sync.sh sync <peer> → récupère les secrets manquants via SSH
# brain-secrets-sync.sh diff <peer> → compare clés locales vs peer (sans valeurs)
#
# Sécurité :
# - Jamais de valeur affichée — noms de clés uniquement
# - Transport via SSH (chiffré par construction)
# - Gate humain obligatoire avant toute sync
#
# Tier free : python3 + pyyaml (pip install pyyaml si absent)
set -euo pipefail
BRAIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SECRETS_DIR="$HOME/Dev/BrainSecrets"
REGISTRY="$SECRETS_DIR/secrets.yml"
MYSECRETS="$SECRETS_DIR/MYSECRETS"
COMPOSE_LOCAL="$BRAIN_ROOT/brain-compose.local.yml"
CMD="${1:-help}"
PEER="${2:-}"
# ── Vérifications ────────────────────────────────────────────
if [[ ! -f "$REGISTRY" ]]; then
echo "❌ Registre absent : $REGISTRY"
echo " → Créer avec le format ADR-040 (voir profil/decisions/040-*)"
exit 1
fi
if [[ ! -f "$MYSECRETS" ]]; then
echo "❌ MYSECRETS absent : $MYSECRETS"
exit 1
fi
# ── Commandes ────────────────────────────────────────────────
case "$CMD" in
status|audit|diff)
python3 - "$REGISTRY" "$MYSECRETS" "$COMPOSE_LOCAL" "$CMD" "$PEER" <<'PYEOF'
import sys, os
from datetime import datetime, date
registry_path = sys.argv[1]
mysecrets_path = sys.argv[2]
compose_path = sys.argv[3]
cmd = sys.argv[4]
peer = sys.argv[5] if len(sys.argv) > 5 else ""
# Parse YAML sans dépendance lourde (fallback si pyyaml absent)
try:
import yaml
with open(registry_path) as f:
registry = yaml.safe_load(f)
except ImportError:
# Fallback basique — parse les clés du registre
print("⚠️ pyyaml absent — install: pip install pyyaml")
print(" Fallback : comparaison clés MYSECRETS uniquement")
registry = None
# Parse MYSECRETS (KEY=VALUE)
local_keys = set()
with open(mysecrets_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key = line.split('=', 1)[0].strip()
if key:
local_keys.add(key)
# Détecter la machine courante
machine = "unknown"
if os.path.exists(compose_path):
try:
with open(compose_path) as f:
compose = yaml.safe_load(f) if registry else {}
machine = compose.get("machine", "unknown")
except Exception:
pass
if registry is None:
sys.exit(0)
secrets = registry.get("secrets", {})
if cmd == "status":
print(f"📋 Registre : {len(secrets)} secrets | Machine : {machine}")
print(f" MYSECRETS : {len(local_keys)} clés locales\n")
missing = []
present = []
other_machine = []
registry_keys = set(secrets.keys())
extra = local_keys - registry_keys # clés locales absentes du registre
for key, meta in secrets.items():
machines = meta.get("machines", [])
required = meta.get("required", False)
scope = meta.get("scope", "—")
if machine in machines or machine == "unknown":
if key in local_keys:
present.append(key)
else:
tag = "🔴 REQUIRED" if required else "⚪ optional"
missing.append(f" {tag} {key} (scope: {scope})")
elif key in local_keys:
other_machine.append(f" {key} → déclaré pour {machines}")
if missing:
print(f"❌ Manquants ({len(missing)}) :")
for m in missing:
print(m)
else:
print(f"✅ Tous les secrets requis pour {machine} sont présents")
if other_machine:
print(f"\n Clés présentes localement mais assignées à d'autres machines ({len(other_machine)}) :")
for o in other_machine:
print(o)
if extra:
print(f"\n⚠ Clés dans MYSECRETS absentes du registre ({len(extra)}) :")
for k in sorted(extra):
print(f" ? {k} → ajouter dans secrets.yml")
print(f"\n✅ {len(present)} clés présentes et déclarées")
elif cmd == "audit":
today = date.today()
issues = []
for key, meta in secrets.items():
expires = meta.get("expires_at")
rotated = meta.get("rotated_at")
required = meta.get("required", False)
if expires:
try:
exp_date = date.fromisoformat(str(expires))
days_left = (exp_date - today).days
if days_left < 0:
issues.append(f" 🔴 EXPIRÉ : {key} — expiré depuis {-days_left}j")
elif days_left < 30:
issues.append(f" 🟡 EXPIRE BIENTÔT : {key} — {days_left}j restants")
except ValueError:
pass
if rotated:
try:
rot_date = date.fromisoformat(str(rotated))
age = (today - rot_date).days
if age > 180 and required:
issues.append(f" 🟡 ROTATION DUE : {key} — dernière rotation il y a {age}j")
except ValueError:
pass
if issues:
print(f"🔍 Audit — {len(issues)} problème(s) :\n")
for i in issues:
print(i)
else:
print("✅ Audit clean — aucun secret expiré ou en attente de rotation")
# Stats par scope
scopes = {}
for key, meta in secrets.items():
s = meta.get("scope", "unknown")
scopes[s] = scopes.get(s, 0) + 1
print(f"\n📊 {len(secrets)} secrets répartis :")
for s, n in sorted(scopes.items()):
print(f" {s}: {n}")
elif cmd == "diff":
if not peer:
print("❌ Usage: brain-secrets-sync.sh diff <peer>")
print(" Ex: brain-secrets-sync.sh diff laptop")
sys.exit(1)
print(f"📋 Diff registre : {machine} vs {peer}\n")
local_expected = set()
peer_expected = set()
for key, meta in secrets.items():
machines = meta.get("machines", [])
if machine in machines:
local_expected.add(key)
if peer in machines:
peer_expected.add(key)
both = local_expected & peer_expected
only_local = local_expected - peer_expected
only_peer = peer_expected - local_expected
print(f" Communs : {len(both)}")
print(f" {machine} only : {len(only_local)}")
print(f" {peer} only : {len(only_peer)}")
if only_local:
print(f"\n Sur {machine} uniquement :")
for k in sorted(only_local):
print(f" {k}")
if only_peer:
print(f"\n Sur {peer} uniquement :")
for k in sorted(only_peer):
print(f" {k}")
PYEOF
;;
sync)
if [[ -z "$PEER" ]]; then
echo "❌ Usage: brain-secrets-sync.sh sync <peer>"
echo " Ex: brain-secrets-sync.sh sync desktop"
echo ""
echo " Peers connus (brain-compose.local.yml) :"
grep -A2 "peers:" "$COMPOSE_LOCAL" 2>/dev/null | grep -E "^\s+\w+:" | sed 's/://;s/^ / /' || echo " (aucun peer configuré)"
exit 1
fi
# Résoudre l'IP du peer
PEER_URL=$(python3 -c "
import yaml, sys
with open('$COMPOSE_LOCAL') as f:
c = yaml.safe_load(f)
peers = c.get('peers', {})
p = peers.get('$PEER', {})
url = p.get('url', '')
if url:
# Extraire host de http://ip:port
host = url.replace('http://','').replace('https://','').split(':')[0]
print(host)
" 2>/dev/null || echo "")
if [[ -z "$PEER_URL" ]]; then
echo "❌ Peer '$PEER' non trouvé dans brain-compose.local.yml"
echo " Ajouter sous peers: dans brain-compose.local.yml"
exit 1
fi
echo "🔄 Sync depuis $PEER ($PEER_URL)"
echo ""
echo "⚠️ CONFIRMATION REQUISE — cette commande va :"
echo " 1. Lire les noms de clés sur $PEER via SSH (pas les valeurs)"
echo " 2. Identifier les clés manquantes localement"
echo " 3. Copier UNIQUEMENT les clés manquantes via SSH"
echo ""
read -p "Continuer ? (oui/non) " confirm
if [[ "$confirm" != "oui" ]]; then
echo "Annulé."
exit 0
fi
# Étape 1 : lister les clés sur le peer
echo ""
echo "→ Lecture des clés sur $PEER..."
PEER_KEYS=$(ssh "$PEER_URL" "grep '^[^#].*=' ~/Dev/BrainSecrets/MYSECRETS 2>/dev/null | cut -d= -f1 | sort" 2>/dev/null || echo "")
if [[ -z "$PEER_KEYS" ]]; then
echo "❌ Impossible de lire MYSECRETS sur $PEER"
echo " Vérifier : ssh $PEER_URL 'test -f ~/Dev/BrainSecrets/MYSECRETS'"
exit 1
fi
# Étape 2 : identifier les manquantes
LOCAL_KEYS=$(grep "^[^#].*=" "$MYSECRETS" | cut -d= -f1 | sort)
MISSING=$(comm -23 <(echo "$PEER_KEYS") <(echo "$LOCAL_KEYS"))
if [[ -z "$MISSING" ]]; then
echo "✅ Aucune clé manquante — MYSECRETS déjà complet"
exit 0
fi
echo "Clés manquantes localement :"
echo "$MISSING" | sed 's/^/ /'
echo ""
read -p "Copier ces clés depuis $PEER ? (oui/non) " confirm2
if [[ "$confirm2" != "oui" ]]; then
echo "Annulé."
exit 0
fi
# Étape 3 : copier les valeurs manquantes via SSH (jamais affichées)
for key in $MISSING; do
ssh "$PEER_URL" "grep '^${key}=' ~/Dev/BrainSecrets/MYSECRETS" >> "$MYSECRETS" 2>/dev/null
echo "$key"
done
echo ""
echo "✅ Sync terminée — $(echo "$MISSING" | wc -l) clé(s) ajoutée(s) à MYSECRETS"
echo " Les valeurs n'ont jamais été affichées."
;;
help|*)
echo "brain-secrets-sync.sh — Registre secrets + sync SSH (ADR-040)"
echo ""
echo "Usage :"
echo " status → compare registre vs MYSECRETS local"
echo " audit → secrets expirés, rotation due"
echo " sync <peer> → récupère les secrets manquants via SSH"
echo " diff <peer> → compare clés par machine (sans valeurs)"
echo ""
echo "Registre : ~/Dev/BrainSecrets/secrets.yml"
echo "Valeurs : ~/Dev/BrainSecrets/MYSECRETS"
;;
esac