Files
brain-template/scripts/brain-secrets-sync.sh

311 lines
10 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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