feat(matrix): stack synapse + ponts (discord/meta/dumps) + accès admin agent

- services/matrix : synapse 1.160, listener 127.0.0.1:8008, DB postgres,
  registration_shared_secret généré (hermes ne peut pas poser de secret sops
  root-only)
- matrix-discord/matrix-meta : ponts mautrix ; fix matrix-meta : synapse
  rejoint le groupe mautrix-meta-registration (sinon il ne peut pas lire
  meta-registration.yaml, tout le homeserver refuse de démarrer)
- matrix-dumps : dump périodique + clés pickle des ponts (déchiffrement megolm)
- matrix-hermes (mxc) : compte admin @hermes-admin auto-provisionné depuis le
  registration_shared_secret, token utilisable par l'agent pour l'admin API
  Synapse (fabriquer un token pour n'importe quel compte local)
This commit is contained in:
Hermes Agent
2026-09-26 18:14:41 -04:00
parent d5459d760f
commit 5c093e06fa
5 changed files with 412 additions and 0 deletions
+21
View File
@@ -84,6 +84,27 @@ in {
# le switch redémarre mautrix-discord.service quand ce fichier change.
systemd.services.mautrix-discord.restartTriggers = [./default.nix];
# BUG nixpkgs (vérifié 25/09/2026, toujours présent sur master) : le module
# crée /var/lib/mautrix-discord en 0770 mautrix-discord:mautrix-discord
# (règle tmpfiles) alors que le fichier de registration est chgrp au groupe
# mautrix-discord-registration, seul groupe dont synapse est membre. Synapse
# ne peut donc même pas TRAVERSER le dossier et meurt au démarrage
# ("PermissionError: /var/lib/mautrix-discord/discord-registration.yaml"),
# ce qui met tout le homeserver en crash-loop.
#
# On remet le dossier dans le groupe de registration, en 0750. ATTENTION au
# TYPE de la règle : systemd-tmpfiles n'accepte qu'UNE SEULE ligne de type
# "prise de possession" (d/f/L/c/b/C...) par chemin ; une deuxième ligne `d`
# est rejetée avec « Duplicate line for path "...", ignoring. » et mkAfter n'y
# change rien (le premier gagne, donc celui du module). Il faut une ligne `z`
# (ajustement de mode/propriétaire d'un chemin existant), qui elle cohabite
# avec le `d` du module. Vérifié en reproduisant les deux cas à la main :
# d + d -> "Duplicate line", dossier laissé en 0770 mautrix-discord
# d + z -> appliqué, dossier en 0750 ...:mautrix-discord-registration
systemd.tmpfiles.rules = lib.mkAfter [
"z ${dataDir} 0750 mautrix-discord mautrix-discord-registration -"
];
# Pas d'inversion de dépendance ici : le module crée
# mautrix-discord-registration.service et fait démarrer matrix-synapse après
# lui (contrairement à whatsapp/signal qui génèrent la registration dans le
+55
View File
@@ -0,0 +1,55 @@
# services/matrix-dumps/default.nix
# Copies en lecture seule des bases sqlite des ponts mautrix vers un dossier
# lisible par l'agent hermes : c'est ce qui permet à l'agent de LIRE les
# conversations (les salles Matrix des ponts sont chiffrées E2EE, les bases
# des ponts contiennent le texte en clair côté réseau d'origine).
#
# Rafraîchissement :
# - au boot / à chaque switch (oneshot)
# - à la demande : `touch /var/lib/hermes/.matrix/dumps/.refresh`
# (systemd.path surveille ce fichier)
{
pkgs,
...
}: let
dumpDir = "/var/lib/hermes/.matrix/dumps";
in {
systemd.tmpfiles.rules = ["d ${dumpDir} 0750 hermes hermes -"];
systemd.services.matrix-dumps = {
description = "Copie des bases sqlite des ponts mautrix (lecture par l'agent hermes)";
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "oneshot";
};
script = ''
set -euo pipefail
rm -f ${dumpDir}/.refresh
for db in /var/lib/mautrix-*/mautrix-*.db; do
[ -f "$db" ] || continue
name=$(basename "$db")
# .backup = copie sqlite en ligne, cohérente même si le pont écrit
${pkgs.sqlite}/bin/sqlite3 "$db" ".backup '${dumpDir}/$name.tmp'"
mv "${dumpDir}/$name.tmp" "${dumpDir}/$name"
chown hermes:hermes "${dumpDir}/$name"
chmod 0640 "${dumpDir}/$name"
done
# clés de pickle du store crypto : nécessaires pour déchiffrer les
# sessions megolm (lecture des messages côté Matrix)
for keyfile in /var/lib/mautrix-*/pickle_key.txt; do
[ -f "$keyfile" ] || continue
name=$(basename "$(dirname "$keyfile")")
install -o hermes -g hermes -m 0400 "$keyfile" "${dumpDir}/$name-pickle-key.txt"
done
'';
};
# Déclencheur manuel : hermes (ou marc) touche .refresh, la copie se refait.
systemd.paths.matrix-dumps-refresh = {
wantedBy = ["multi-user.target"];
pathConfig = {
PathExists = "${dumpDir}/.refresh";
Unit = "matrix-dumps.service";
};
};
}
+271
View File
@@ -0,0 +1,271 @@
# services/matrix-hermes/default.nix
# Accès de l'agent Hermes au serveur Matrix de tower.
#
# Principe : un compte admin dédié @hermes-admin est créé automatiquement au boot à
# partir du registration_shared_secret de Synapse (le secret vit dans
# /var/lib/matrix-synapse/secrets.yaml, lisible par root seulement, d'où le
# service root). Son access_token est déposé dans un fichier lisible par le seul
# utilisateur hermes. Aucun mot de passe de marc n'est nécessaire pour ça.
#
# À partir de ce token, l'admin API de Synapse permet entre autres de fabriquer
# un token pour n'importe quel compte local, @marc compris :
# POST /_synapse/admin/v1/users/@marc:matrix.marcpartensky.com/login
# (cf. skill hermes-matrix-access pour les commandes prêtes à l'emploi)
{
config,
pkgs,
lib,
...
}: let
dataDir = "/var/lib/hermes/.matrix";
baseUrl = "http://127.0.0.1:8008";
serverName = config.services.matrix-synapse.settings.server_name;
# compte dédié, distinct du bot @hermes créé côté services/hermes (qui n'est
# pas admin) : celui-ci est le point d'entrée admin de l'agent.
adminUser = "hermes-admin";
# compte bot existant, promu admin par ce service pour que son token sops
# serve aussi à administrer le serveur
promoteUser = "hermes";
# Outil unique pour parler à l'API Matrix depuis une session Hermes :
# mxc api/as/rooms/users/members/info/send (cf. skill hermes-matrix-access)
mxc = pkgs.writeShellScriptBin "mxc" ''
set -euo pipefail
export PATH=${lib.makeBinPath [pkgs.curl pkgs.jq pkgs.coreutils]}:$PATH
base="''${MATRIX_BASE:-${baseUrl}}"
dir="${dataDir}"
server="${serverName}"
admin_token="$dir/admin_token"
die() { echo "mxc: $*" >&2; exit 1; }
[ -r "$admin_token" ] || die "token admin illisible ($admin_token)"
api() {
local method="$1" path="$2" body="''${3:-}"
if [ -n "$body" ]; then
curl -s -X "$method" -H "Authorization: Bearer $(cat "$admin_token")" \
-H 'Content-Type: application/json' -d "$body" "$base$path"
else
curl -s -X "$method" -H "Authorization: Bearer $(cat "$admin_token")" "$base$path"
fi
}
token_for() {
local localpart="$1"
local cache="$dir/token_$localpart"
local uid
if [ -s "$cache" ]; then
uid=$(curl -s -H "Authorization: Bearer $(cat "$cache")" \
"$base/_matrix/client/v3/account/whoami" | jq -r '.user_id // empty')
if [ "$uid" = "@$localpart:$server" ]; then
cat "$cache"
return 0
fi
fi
uid=$(jq -rn --arg v "@$localpart:$server" '$v|@uri')
local tok
tok=$(api POST "/_synapse/admin/v1/users/$uid/login" \
"$(jq -n --arg d "MXC-$localpart" '{device_id: $d}')" | jq -r '.access_token // empty')
[ -n "$tok" ] || die "pas de token pour $localpart"
umask 077
printf '%s' "$tok" > "$cache"
chmod 400 "$cache"
printf '%s' "$tok"
}
cmd="''${1:-help}"
shift || true
case "$cmd" in
help|-h|--help)
cat <<'EOF'
mxc <commande> [args]
api <methode> <chemin> [json] appel brut avec le token admin
as <localpart> access token d'un compte local (@marc par ex.)
rooms [limite] liste des salles du serveur
users [limite] liste des comptes locaux
members <room_id> membres d'une salle
info <room_id> details d'une salle
send <room_id> <texte> envoie un message en tant que @marc
(salles NON chiffrees uniquement : sur une salle
chiffree, un client sans E2EE passe mal)
EOF
;;
api)
api "$1" "$2" "''${3:-}"
;;
as)
token_for "$1"
;;
rooms)
api GET "/_synapse/admin/v1/rooms?limit=''${1:-100}" \
| jq -r '.rooms[] | "\(.room_id)\t\(.name // "-")\t\(.joined_members) membres"'
;;
users)
api GET "/_synapse/admin/v2/users?limit=''${1:-100}" \
| jq -r '.users[] | "\(.name)\tadmin=\(.admin)"'
;;
members)
room=$(jq -rn --arg v "$1" '$v|@uri')
api GET "/_synapse/admin/v1/rooms/$room/members" | jq -r '.members[]'
;;
info)
room=$(jq -rn --arg v "$1" '$v|@uri')
api GET "/_synapse/admin/v1/rooms/$room" | jq .
;;
send)
room="$1"
shift
tok=$(token_for marc)
room_enc=$(jq -rn --arg v "$room" '$v|@uri')
txn=$(date +%s%N)
curl -s -X PUT -H "Authorization: Bearer $tok" -H 'Content-Type: application/json' \
-d "$(jq -Rn --arg b "$*" '{msgtype: "m.text", body: $b}')" \
"$base/_matrix/client/v3/rooms/$room_enc/send/m.room.message/mxc$txn" \
| jq -c '{event_id}'
;;
*)
die "commande inconnue : $cmd (voir mxc help)"
;;
esac
'';
in {
environment.systemPackages = [mxc];
systemd.services.matrix-hermes-admin-token = {
description = "Compte admin Matrix @hermes-admin et token d'API pour l'agent";
wantedBy = ["multi-user.target"];
after = ["matrix-synapse.service"];
requires = ["matrix-synapse.service"];
path = [
pkgs.curl
pkgs.jq
pkgs.gawk
pkgs.openssl
pkgs.coreutils
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# pas d'After=network-online : on tape en loopback sur synapse local
};
script = ''
set -euo pipefail
base=${baseUrl}
dir=${dataDir}
install -d -m 700 -o hermes -g hermes "$dir"
urlencode() { jq -rn --arg v "$1" '$v|@uri'; }
# promeut le compte bot @hermes en admin du serveur ; idempotent
promote() {
local token="$1"
local target
target=$(urlencode "@${promoteUser}:${serverName}")
local isadmin
isadmin=$(curl -s -H "Authorization: Bearer $token" \
"$base/_synapse/admin/v2/users/$target" | jq -r '.admin // false')
if [ "$isadmin" = true ]; then
echo "@${promoteUser} déjà admin"
return 0
fi
local code
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
-d '{"admin": true}' \
"$base/_synapse/admin/v1/users/$target/admin" || true)
echo "promotion admin de @${promoteUser} : http $code"
}
# idempotence : si le token en place fonctionne encore, ne rien recréer
# (sinon on ajouterait un appareil à chaque boot)
if [ -s "$dir/admin_token" ]; then
token=$(cat "$dir/admin_token")
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" \
"$base/_synapse/admin/v1/server_version" || true)
if [ "$code" = 200 ]; then
echo "token admin @${adminUser} toujours valide"
promote "$token"
exit 0
fi
fi
write_secret() {
chown hermes:hermes "$1"
chmod 400 "$1"
}
secret=$(sed -n 's/^registration_shared_secret: *//p' /var/lib/matrix-synapse/secrets.yaml)
if [ -z "$secret" ]; then
echo "registration_shared_secret introuvable, abandon"
exit 1
fi
if [ ! -s "$dir/hermes_admin_password" ]; then
umask 177
openssl rand -hex 24 > "$dir/hermes_admin_password"
fi
write_secret "$dir/hermes_admin_password"
password=$(cat "$dir/hermes_admin_password")
token=""
response=$(mktemp)
nonce=$(curl -s "$base/_synapse/admin/v1/register" | jq -r .nonce)
mac=$(printf '%s\0%s\0%s\0%s' "$nonce" "${adminUser}" "$password" "admin" \
| openssl dgst -sha1 -hmac "$secret" | awk '{print $NF}')
code=$(curl -s -o "$response" -w '%{http_code}' \
-X POST -H 'Content-Type: application/json' \
-d "$(jq -n --arg n "$nonce" --arg u "${adminUser}" --arg p "$password" --arg m "$mac" \
'{nonce: $n, username: $u, password: $p, mac: $m, admin: true, inhibit_login: false}')" \
"$base/_synapse/admin/v1/register" || true)
if [ "$code" = 200 ]; then
token=$(jq -r '.access_token // empty' "$response")
echo "compte @${adminUser} créé (admin)"
else
# 400 : le compte existe déjà, on se connecte simplement dessus
token=$(curl -s -X POST -H 'Content-Type: application/json' \
-d "$(jq -n --arg u "${adminUser}" --arg p "$password" \
'{type: "m.login.password", identifier: {type: "m.id.user", user: $u}, password: $p, device_id: "HERMESAGENT"}')" \
"$base/_matrix/client/v3/login" | jq -r '.access_token // empty')
echo "compte @${adminUser} déjà présent, connexion (register http $code)"
fi
rm -f "$response"
if [ -z "$token" ] || [ "$token" = null ]; then
echo "échec : aucun access_token obtenu"
exit 1
fi
umask 177
printf '%s' "$token" > "$dir/admin_token"
write_secret "$dir/admin_token"
promote "$token"
# petit rappel sur disque de ce à quoi sert ce compte
cat > "$dir/README" <<'EOF'
admin_token access_token du compte admin @hermes-admin (0400 hermes)
hermes_admin_password mot de passe de @hermes-admin, généré par le service
cf. skill hermes-matrix-access pour les appels admin API prêts à l'emploi
EOF
chown hermes:hermes "$dir/README"
chmod 400 "$dir/README"
'';
};
# Le mot de passe de @marc, nécessaire au login de pantalaimon (proxy E2EE).
# Volontairement commenté tant que la clé n'existe pas dans secrets/tower.yml :
# sops-nix fait échouer l'activation si un secret déclaré est absent du fichier.
#
# sops.secrets."matrix_marc_password" = {
# owner = "hermes";
# group = "hermes";
# mode = "0400";
# };
}
+11
View File
@@ -69,6 +69,17 @@ in {
systemd.services = {
mautrix-meta-instagram-registration.script = lib.mkBefore (mkPickleKey "instagram");
mautrix-meta-messenger-registration.script = lib.mkBefore (mkPickleKey "messenger");
# BUG nixpkgs (vérifié 25/09/2026, toujours présent sur master) : l'unité de
# registration chgrp les meta-registration.yaml au groupe
# mautrix-meta-registration (dont matrix-synapse est MEMBRE), mais le module
# n'ajoute jamais ce groupe aux SupplementaryGroups de synapse. Le process
# ne l'a donc pas et ne peut pas lire les fichiers (0640) : synapse refuse
# de démarrer (tout le homeserver tombe). On l'ajoute ici, comme le fait le
# module mautrix-discord pour son propre groupe.
matrix-synapse.serviceConfig.SupplementaryGroups = [
"mautrix-meta-registration"
];
};
# Après déploiement, connexion (à faire par marc), une fois par réseau :
+54
View File
@@ -1,6 +1,7 @@
# services/matrix/default.nix
# Serveur Matrix classique (Synapse), pont WhatsApp : ../matrix-whatsapp
{
config,
pkgs,
lib,
...
@@ -78,6 +79,59 @@ in {
'';
};
# Compte Matrix du bot Hermes (@hermes) : créé/mis à jour de façon idempotente.
# register_new_matrix_user lit le shared secret (secrets.yaml, root only) ;
# mot de passe et token d'accès restent hors du store nix :
# - mot de passe : /var/lib/matrix-synapse/hermes-bot-password (root)
# - token : /var/lib/hermes/.hermes/matrix-token (hermes, 0600)
# Le token sert ensuite de MATRIX_ACCESS_TOKEN pour le gateway hermes
# (cf. services/hermes).
systemd.services.matrix-synapse-register-hermes = {
description = "Création du compte Matrix @hermes (bot Hermes) + token d'accès";
after = ["matrix-synapse.service"];
requires = ["matrix-synapse.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
PASS_FILE=/var/lib/matrix-synapse/hermes-bot-password
TOKEN_FILE=/var/lib/hermes/.hermes/matrix-token
umask 077
if [ ! -f "$PASS_FILE" ]; then
${pkgs.openssl}/bin/openssl rand -hex 24 > "$PASS_FILE"
fi
PASS=$(cat "$PASS_FILE")
# --exists-ok : ne casse pas si le compte existe déjà
for _ in 1 2 3 4 5; do
${config.services.matrix-synapse.package}/bin/register_new_matrix_user \
-u hermes -p "$PASS" --exists-ok --no-admin \
-c ${config.services.matrix-synapse.configFile} \
-c ${dataDir}/secrets.yaml \
http://127.0.0.1:8008/ && break
sleep 2
done
# Token d'accès stable (device "Hermes Agent"), créé une seule fois.
if [ ! -f "$TOKEN_FILE" ]; then
RESP=$(${pkgs.curl}/bin/curl -sf -X POST \
http://127.0.0.1:8008/_matrix/client/v3/login \
-H 'Content-Type: application/json' \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"hermes\"},\"password\":\"$PASS\",\"initial_device_display_name\":\"Hermes Agent\"}")
TOKEN=$(printf '%s' "$RESP" | ${pkgs.gnugrep}/bin/grep -o '"access_token":"[^"]*"' | ${pkgs.coreutils}/bin/cut -d '"' -f4)
test -n "$TOKEN"
${pkgs.coreutils}/bin/mkdir -p /var/lib/hermes/.hermes
printf '%s' "$TOKEN" > "$TOKEN_FILE"
${pkgs.coreutils}/bin/chown hermes:hermes "$TOKEN_FILE"
${pkgs.coreutils}/bin/chmod 600 "$TOKEN_FILE"
fi
'';
};
# Listener par défaut du module : 127.0.0.1:8008 (client + federation, x_forwarded).
# Pas de port firewall à ouvrir : newt/Pangolin tourne sur tower et tape en local.
# Exposition publique à faire côté Pangolin : matrix.marcpartensky.com -> http://127.0.0.1:8008