identites: balayage mesure (gitea 2fa=0/4, synapse admin=0), porte de sortie synapse outillee, backup-perimetre + reste-humain

This commit is contained in:
OpenFox
2026-10-12 20:16:38 +02:00
parent 79197d13cb
commit e27ac1ea90
5 changed files with 198 additions and 0 deletions
+31
View File
@@ -109,3 +109,34 @@ Le port SSH de Gitea est ouvert (`2222`) mais la clé `id_ed25519_openfox11` y e
Le registre dit **qui** et **comment**. Il ne donne **pas** le login unique (c'est la tâche Le registre dit **qui** et **comment**. Il ne donne **pas** le login unique (c'est la tâche
`IDENTITÉ UNIQUE — Authelia`), et ne supprime pas le besoin de coffre (c'est Vaultwarden). `IDENTITÉ UNIQUE — Authelia`), et ne supprime pas le besoin de coffre (c'est Vaultwarden).
Tant que l'IdP n'est pas posé, **chaque brique garde son compte local** : c'est voulu (break-glass). Tant que l'IdP n'est pas posé, **chaque brique garde son compte local** : c'est voulu (break-glass).
## 7. État **mesuré** des comptes (12/10, lecture seule — `scripts/15-identity-sweep.sh`, `16-identity-sweep-sqlite.sh`)
Ce qui suit sort de commandes, pas d'hypothèses. Aucun compte créé, aucun privilège changé,
aucune valeur secrète affichée.
| 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** | **2 comptes** : `@ops`, `@ops-bot` | `admin` = **0**, `locked` = 0, `deactivated` = 0, 14 devices | **Trou critique : aucun compte admin.** C-MX1 (6 comptes) et C-MX3 (2FA admin) sont bloqués **à la racine** : il n'y a pas d'admin à protéger, et pas de porte de sortie si le compte `ops-bot` est perdu |
| **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 (`auth_users`) | — | les 3 sont à nommer dans la moitié B |
| **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, et la porte de sortie (à utiliser **toi**)
`database.name: sqlite3` → `docker exec synapse python3 -c "…select name,admin from users…"`
donne 2 comptes, **aucun admin**. Conséquence mesurée : **aucun moyen officiel de créer un compte
famille, de verrouiller un compte, ni de réinitialiser un mot de passe.**
Porte de sortie documentée (le levier existe déjà dans la brique, je ne l'utilise pas) :
`homeserver.yaml` contient `registration_shared_secret` → l'endpoint
`POST /_synapse/admin/v1/register` accepte une inscription signée avec ce secret, ce qui permet de
créer **le premier compte admin** (puis `admin: true` en base). C'est un geste **humain**
(création de compte = interdit permanent G11) ; la procédure complète est à écrire dans
`RUNBOOKS/matrix.md` avant, et le secret ne doit **pas** être recopié dans le kit.
Rollback : `docker exec synapse python3 -c "update users set admin=0 where name='…'"` (à faire
avec `mode=ro` d'abord pour vérifier, puis écriture assumée).
+2
View File
@@ -150,3 +150,5 @@
| 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 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: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: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) |
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# balayage-identites.sh — PREUVE en lecture seule de l'existant (C-ID4/7/9, C-MX1).
# Ne crée aucun compte, ne change aucun privilège, n'affiche aucun secret :
# uniquement des noms, des drapeaux (admin/actif/2FA) et des compteurs.
set -uo pipefail
echo "== Gitea (admin user list, CLI local) =="
docker exec -u git gitea gitea admin user list 2>/dev/null | cut -c1-150
echo
echo "== Nextcloud (occ, local) =="
docker exec -u www-data nextcloud php occ user:list 2>/dev/null | cut -c1-110
echo "-- groupes admin --"
docker exec -u www-data nextcloud php occ group:list 2>/dev/null | cut -c1-110
echo "-- backend d'auth + 2FA providers --"
docker exec -u www-data nextcloud php occ config:system:get maintenance_mode 2>/dev/null
docker exec -u www-data nextcloud sh -c "ls apps | grep -iE 'twofactor' | tr '\n' ' '" 2>/dev/null; echo
docker exec -u www-data nextcloud php occ twofactorprovider:states 2>/dev/null | cut -c1-90
echo
echo "== Jellyfin (sqlite local, drapeaux uniquement) =="
docker exec jellyfin sh -c 'command -v sqlite3 >/dev/null && sqlite3 /config/data/jellyfin.db "select count(*), (select count(*) from Users where EnableUserMode=1) from Users;" 2>/dev/null'
docker exec jellyfin sh -c 'command -v sqlite3 >/dev/null && sqlite3 /config/data/jellyfin.db "select Id, Username, EnableLocalPassword, IsAdministrator from Users;" 2>/dev/null' | cut -c1-120
echo
echo "== Synapse (postgres local, drapeaux uniquement) =="
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select count(*), sum(case when admin=1 then 1 else 0 end), sum(case when locked=1 then 1 else 0 end), sum(case when deactivated=1 then 1 else 0 end) from users;" 2>/dev/null'
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select name, admin, locked, deactivated from users order by admin desc, name;" 2>/dev/null' | cut -c1-90
echo "-- 2FA activé par utilisateur --"
docker exec -i synapse sh -c 'command -v psql >/dev/null && psql -U synapse -d synapse -tAc "select count(*) from one_time_keys;" 2>/dev/null'
echo
echo "== n8n (postgres local, drapeaux uniquement) =="
docker exec -i n8n sh -c 'command -v psql >/dev/null && psql -h db -U n8n -d n8n -tAc "select count(*), sum(case when role=\$\$OWNER\$\$ then 1 else 0 end) from user;" 2>/dev/null' 2>/dev/null
docker exec -i n8n sh -c 'command -v psql >/dev/null && psql -h db -U n8n -d n8n -tAc "select id, email, role from user order by role desc;" 2>/dev/null' 2>/dev/null | cut -c1-90
echo
echo "== Vaultwarden / AdGuard (compteurs, aucune valeur) =="
docker exec vaultwarden sh -c 'ls -l /data/sessions 2>/dev/null | wc -l' 2>/dev/null
echo -n "admin token présent (taille du fichier secret, 0 = absent) : "
docker inspect -f '{{json .Config.Env}}' vaultwarden 2>/dev/null | python3 -c "import json,sys; print([e.split('=')[0] for e in json.load(sys.stdin) if 'ADMIN_TOKEN' in e])"
docker exec adguard sh -c 'ls /config/users* 2>/dev/null | wc -l' 2>/dev/null
echo
echo "== PhotoPrism (comptes) =="
docker exec -i photoprism_db sh -c 'mariadb -u root -p"$(cat $MARIADB_ROOT_PASSWORD_FILE)" $MARIADB_DATABASE -N -e "select count(*), sum(case when role=1 then 1 else 0 end) from auth_users" 2>/dev/null'
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# balayage-identites-2.sh — drapeaux d'identité via sqlite local (lecture seule, mode=ro).
# Synapse (sqlite embarqué), Jellyfin (jellyfin.db), n8n (database.sqlite), PhotoPrism (MariaDB).
# N'affiche ni hash, ni mot de passe, ni token : uniquement noms + drapeaux + compteurs.
set -uo pipefail
echo "== Synapse (sqlite local) =="
docker exec -i synapse python3 - <<'PY'
import sqlite3
for path in ("/data/home/server.sqlite", "/data/server.sqlite", "/home/server.sqlite"):
try:
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
cur = con.cursor()
print("db:", path)
print("users total/admin/locked/deactivated:",
cur.execute("select count(*), sum(admin), sum(locked), sum(deactivated) from users").fetchone())
for row in cur.execute("select name, admin, locked, deactivated from users order by admin desc, name limit 12"):
print(" ", row)
try:
print("e2e one_time_keys:", cur.execute("select count(*) from one_time_keys").fetchone()[0])
except Exception:
pass
break
except Exception as e:
print(" %s: %s" % (path, type(e).__name__))
PY
echo
echo "== Jellyfin (jellyfin.db) =="
docker exec -i jellyfin python3 - <<'PY'
import sqlite3
con = sqlite3.connect("file:/config/data/jellyfin.db?mode=ro", uri=True)
cur = con.cursor()
cols = [r[1] for r in cur.execute("pragma table_info(Users)")]
print("colonnes Users:", [c for c in cols if c in ("Username", "EnableLocalPassword", "IsAdministrator", "AuthMethod", "MustChangePassword")])
for row in cur.execute("select Username, EnableLocalPassword, IsAdministrator, AuthMethod from Users order by IsAdministrator desc"):
print(" ", row)
print("compte à mot de passe vide/hash:", cur.execute("select count(*) from Users where Password is null").fetchone()[0])
PY
echo
echo "== n8n (database.sqlite) =="
docker exec -i n8n sh -c 'ls -l /data/.n8n/*.sqlite 2>/dev/null | wc -l'
docker exec -i n8n python3 - <<'PY'
import sqlite3, glob
for path in glob.glob("/data/.n8n/*.sqlite"):
try:
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
cur = con.cursor()
tables = [r[0] for r in cur.execute("select name from sqlite_master where type='table'")]
if "user" not in tables:
continue
for row in cur.execute("select id, email, role, disabled from user order by role"):
print(" ", row)
break
except Exception as e:
print(" %s: %s" % (path, type(e).__name__))
PY
echo
echo "== PhotoPrism (auth_users, MariaDB) =="
docker exec photoprism_db sh -c 'mariadb -u root -p"$(cat $MARIADB_ROOT_PASSWORD_FILE)" $MARIADB_DATABASE -N -e "select count(*), sum(role=1), sum(auth_provider is not null) from auth_users" 2>/dev/null'
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# 17-synapse-admin.sh — CRÉER LE PREMIER COMPTE ADMIN Synapse (C-MX1, C-MX3).
#
# Constat mesuré 12/10 : Synapse a 2 comptes (@ops, @ops-bot) et **admin = 0** -> aucune porte
# de sortie officielle. Le levier existe déjà dans la brique : `registration_shared_secret`
# dans homeserver.yaml autorise l'inscription signée via /_synapse/admin/v1/register.
#
# Création de compte = **interdit permanent pour l'assistante** (G11) : ce script est fait pour
# être lancé PAR TOI, qui tape le mot de passe. Le secret n'est jamais affiché, jamais écrit
# ailleurs que dans une variable de ce shell.
#
# Usage : ./17-synapse-admin.sh <pseudo> [--admin]
set -euo pipefail
SYNAPSE_URL="${SYNAPSE_URL:-http://192.168.1.24:8008}"
CTN="${CTN:-synapse}"
CFG="${CFG:-/data/homeserver.yaml}"
USER="${1:?usage: $0 <pseudo> [--admin]}"
ADMIN="${2:-}"
if [[ "$ADMIN" == "--admin" ]]; then ADMIN_FLAG="true"; else ADMIN_FLAG="false"; fi
if [[ "$ADMIN_FLAG" == "true" ]]; then
echo "Tu vas créer un compte ADMIN '$USER'. C'est irréversible en pratique (le 1er admin)."
printf "Confirme en tapant CREER : "
read -r CONFIRM
[[ "$CONFIRM" == "CREER" ]] || { echo "abandonné"; exit 1; }
fi
printf "Mot de passe du compte (saisie masquée) : "
read -rs PASSWORD; echo
if (( ${#PASSWORD} < 12 )); then echo "mot de passe trop court (< 12) — abandon"; exit 1; fi
SECRET="$(docker exec "$CTN" sh -c "sed -n 's/^registration_shared_secret: \"\(.*\)\"/\1/p' '$CFG'")"
if [[ -z "$SECRET" ]]; then echo "secret introuvable dans $CFG — abandon"; exit 1; fi
NONCE="$(curl -s -m 10 "$SYNAPSE_URL/_synapse/admin/v1/register" | python3 -c 'import json,sys; print(json.load(sys.stdin)["nonce"])')"
if [[ -z "$NONCE" ]]; then echo "nonce vide (endpoint fermé ?) — abandon"; exit 1; fi
MAC="$(python3 -c 'import hmac,hashlib,sys; print(hmac.new(sys.argv[1].encode(), ("\n".join(sys.argv[2:5])).encode(), hashlib.sha256).hexdigest())' "$SECRET" "$NONCE" "$USER" "$PASSWORD" "$ADMIN_FLAG")"
BODY="$(python3 -c 'import json,sys; print(json.dumps({"nonce":sys.argv[1],"username":sys.argv[2],"password":sys.argv[3],"mac":sys.argv[4],"admin":sys.argv[5]}))' "$NONCE" "$USER" "$PASSWORD" "$MAC" "$ADMIN_FLAG")"
RESP="$(curl -s -m 15 -X POST "$SYNAPSE_URL/_synapse/admin/v1/register" -H 'Content-Type: application/json' -d "$BODY")"
echo "réponse : $(printf '%s' "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("user_id", d))' 2>/dev/null || printf '%s' "$RESP" | cut -c1-160)"
echo
echo "== vérification (lecture seule) =="
docker exec "$CTN" python3 -c "
import sqlite3
c = sqlite3.connect('file:/data/homeserver.db?mode=ro', uri=True).cursor()
print('comptes total/admin:', c.execute('select count(*), sum(admin) from users').fetchone())
for r in c.execute('select name, admin, locked, deactivated from users order by admin desc'):
print(' ', r)
"
echo
echo "Rollback : docker exec $CTN python3 -c \"import sqlite3; ...\" (update users set admin=0 où name='$USER') — désactivation propre, jamais de purge."
echo "Après : active la 2FA sur ce compte (C-MX3) et consigne l'entrée dans le coffre (infra/matrix/admin)."