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:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
# };
|
||||
}
|
||||
@@ -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 :
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user