feat(flake): lock updates and protonmail-mcp flake
This commit is contained in:
@@ -24,3 +24,11 @@ creation_rules:
|
||||
key_groups:
|
||||
- age:
|
||||
- *tower
|
||||
- path_regex: secrets/protonmail\.yml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *tower
|
||||
- path_regex: secrets/zitadel\.yml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *tower
|
||||
|
||||
Generated
+52
@@ -183,6 +183,23 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"firefox-devtools-mcp": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1790073791,
|
||||
"narHash": "sha256-Ch9dk7E3Nn1lt8dgrEo43P36oB73KfRwyHlZgDOyA8s=",
|
||||
"owner": "mozilla",
|
||||
"repo": "firefox-devtools-mcp",
|
||||
"rev": "bdb377af9ce3a3596e8c196786014ecee50fe178",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "mozilla",
|
||||
"repo": "firefox-devtools-mcp",
|
||||
"rev": "bdb377af9ce3a3596e8c196786014ecee50fe178",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
@@ -1112,6 +1129,39 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"proton-mail-bridge-client": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1790247463,
|
||||
"narHash": "sha256-AoIuoIb8us2DfyjgfoU0/AouILBnSIVNB1GGtw+AYH0=",
|
||||
"owner": "googlarz",
|
||||
"repo": "proton-mail-bridge-client",
|
||||
"rev": "811be5de9dda5debf4d41ceae22b69c9fed8a42f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "googlarz",
|
||||
"repo": "proton-mail-bridge-client",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"protonmail-mcp": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"proton-mail-bridge-client": "proton-mail-bridge-client"
|
||||
},
|
||||
"locked": {
|
||||
"path": "./protonmail-mcp",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "./protonmail-mcp",
|
||||
"type": "path"
|
||||
},
|
||||
"parent": []
|
||||
},
|
||||
"pyproject-build-systems": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -1446,6 +1496,7 @@
|
||||
"clawdbot": "clawdbot",
|
||||
"discord-bot": "discord-bot",
|
||||
"disko": "disko",
|
||||
"firefox-devtools-mcp": "firefox-devtools-mcp",
|
||||
"hermes-agent": "hermes-agent",
|
||||
"home-manager": "home-manager_4",
|
||||
"jovian": "jovian",
|
||||
@@ -1460,6 +1511,7 @@
|
||||
"nur": "nur",
|
||||
"option-pricer": "option-pricer",
|
||||
"portfolio-optimizer": "portfolio-optimizer",
|
||||
"protonmail-mcp": "protonmail-mcp",
|
||||
"pyproject-nix": "pyproject-nix_8",
|
||||
"sops": "sops",
|
||||
"sopswarden": "sopswarden",
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
# hermes-agent.inputs.nixpkgs.follows = "github:NousResearch/hermes-agent";
|
||||
|
||||
protonmail-mcp.url = "path:./protonmail-mcp";
|
||||
protonmail-mcp.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
firefox-devtools-mcp = {
|
||||
# Officiel Mozilla, MCP browser automation via WebDriver BiDi. Pin sur le
|
||||
# commit de la release v0.10.4 (2026-09-22) ; flake=false, source brute.
|
||||
url = "github:mozilla/firefox-devtools-mcp/bdb377af9ce3a3596e8c196786014ecee50fe178";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
jovian.url = "github:Jovian-Experiments/Jovian-NixOS";
|
||||
# jovian.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
@@ -148,6 +158,7 @@
|
||||
./profiles/common
|
||||
./profiles/tower/configuration.nix
|
||||
./services
|
||||
inputs.protonmail-mcp.nixosModules.default
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Client Eternal Terminal : ouvre (ou crée) une session zellij sur l'hôte distant
|
||||
# et l'enregistre côté serveur avec asciinema (script `et-session`, cf.
|
||||
# services/eternal-terminal/default.nix — module côté serveur).
|
||||
#
|
||||
# et lit ~/.ssh/config : les alias `ssh` existants (modules/home/ssh) servent donc
|
||||
# aussi de cibles à `et <alias>`.
|
||||
let
|
||||
cfg = config.my.et;
|
||||
|
||||
hostModule =
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
remoteCommand = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = cfg.remoteCommand;
|
||||
description = ''
|
||||
Commande lancée sur l'hôte distant à la connexion. Chemin absolu de
|
||||
préférence : le PATH des shells non interactifs distants n'est pas garanti.
|
||||
'';
|
||||
};
|
||||
session = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = cfg.defaultSession;
|
||||
description = "Nom de la session zellij à attacher (créée si absente).";
|
||||
};
|
||||
user = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Utilisateur distant (`et -u`) ; null = celui de ~/.ssh/config.";
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.port;
|
||||
default = null;
|
||||
description = "Port de l'etserver distant (`et -p`), défaut ET = 2022 ; null = défaut.";
|
||||
};
|
||||
functionName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "et${name}";
|
||||
description = "Nom de la fonction zsh générée.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
|
||||
{
|
||||
options.my.et = {
|
||||
enable = lib.mkEnableOption "connexions Eternal Terminal avec zellij et enregistrement automatiques";
|
||||
|
||||
defaultSession = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "main";
|
||||
description = "Session zellij par défaut (surchargée par ET_SESSION).";
|
||||
};
|
||||
|
||||
remoteCommand = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/run/current-system/sw/bin/et-session";
|
||||
description = ''
|
||||
Commande de session distante. Le défaut est le chemin absolu du script
|
||||
installé par services/eternal-terminal (chemin stable sur NixOS).
|
||||
'';
|
||||
};
|
||||
|
||||
wrapEt = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Redéfinir `et` en fonction zsh : `et <hôte>` attache zellij enregistré,
|
||||
tout appel avec option (`et -v 3 hôte`, `et -t 10080:80 hôte`) passe
|
||||
directement au binaire.
|
||||
'';
|
||||
};
|
||||
|
||||
hosts = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule hostModule);
|
||||
default = { };
|
||||
example = {
|
||||
tower = {
|
||||
port = 2022;
|
||||
};
|
||||
towerlocal = { };
|
||||
};
|
||||
description = ''
|
||||
Alias à exposer : chaque entrée `name` génère une fonction zsh `etname`.
|
||||
`name` doit être un hôte/alias présent dans ~/.ssh/config.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [ pkgs.eternal-terminal ];
|
||||
|
||||
programs.zsh.initContent = lib.mkAfter (
|
||||
let
|
||||
mkFunction =
|
||||
name: h:
|
||||
let
|
||||
flags = lib.escapeShellArgs (
|
||||
lib.optionals (h.user != null) [ "-u" h.user ]
|
||||
++ lib.optionals (h.port != null) [ "-p" (toString h.port) ]
|
||||
++ [ name ]
|
||||
);
|
||||
in
|
||||
''
|
||||
${h.functionName}() {
|
||||
command et ${flags} -c ${lib.escapeShellArg "${h.remoteCommand} ${h.session}"}
|
||||
}
|
||||
'';
|
||||
in
|
||||
''
|
||||
# --- my.et : Eternal Terminal + zellij + enregistrement asciinema ---
|
||||
|
||||
# Attache (ou crée) une session zellij distante enregistrée : ets <hôte> [session]
|
||||
ets() {
|
||||
local host="${"$"}{1:?usage: ets <hôte> [session]}"
|
||||
shift
|
||||
local session="${"$"}{1:-${"$"}{ET_SESSION:-${cfg.defaultSession}}}"
|
||||
command et "$host" -c "${"$"}{ET_REMOTE_COMMAND:-${cfg.remoteCommand}} $session"
|
||||
}
|
||||
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList mkFunction cfg.hosts)}
|
||||
''
|
||||
+ lib.optionalString cfg.wrapEt ''
|
||||
# et <hôte> (sans option) = zellij enregistré ; le reste passe au binaire.
|
||||
et() {
|
||||
if (( $# >= 1 )) && [[ "$1" != -* ]]; then
|
||||
command et "$@" -c "${"$"}{ET_REMOTE_COMMAND:-${cfg.remoteCommand}} ${"$"}{ET_SESSION:-${cfg.defaultSession}}"
|
||||
else
|
||||
command et "$@"
|
||||
fi
|
||||
}
|
||||
''
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# modules/home/wayvnc/default.nix
|
||||
# Démarre wayvnc automatiquement avec la session niri (via graphical-session.target,
|
||||
# le même point d'accroche que ironbar : niri.service upstream fait
|
||||
# BindsTo=graphical-session.target + Before=graphical-session.target, donc démarrer
|
||||
# niri.service active aussi graphical-session.target, ce qui déclenche ce service).
|
||||
#
|
||||
# Sécurité : aucune option = wayvnc écoute uniquement sur 127.0.0.1:5900 (pas
|
||||
# d'exposition LAN, pas d'auth nécessaire). Accès distant :
|
||||
# ssh -L 5900:localhost:5900 marc@tower
|
||||
# puis un client VNC sur vnc://localhost:5900 (macOS : `open vnc://localhost:5900`)
|
||||
{pkgs, ...}: {
|
||||
home.packages = [pkgs.wayvnc];
|
||||
|
||||
systemd.user.services.wayvnc = {
|
||||
Unit = {
|
||||
Description = "wayvnc VNC server (accès distant à la session niri)";
|
||||
After = ["graphical-session.target"];
|
||||
PartOf = ["graphical-session.target"];
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${pkgs.wayvnc}/bin/wayvnc";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
Install = {
|
||||
WantedBy = ["graphical-session.target"];
|
||||
};
|
||||
};
|
||||
}
|
||||
Generated
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1790218706,
|
||||
"narHash": "sha256-6e4Na3z008XpdVyOXgfasXIn1aN+z8AXFG+jXdQSyuI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c508844df6c28fa6dabc1b6af70f3ccbd65c5201",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"proton-mail-bridge-client": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1790247463,
|
||||
"narHash": "sha256-AoIuoIb8us2DfyjgfoU0/AouILBnSIVNB1GGtw+AYH0=",
|
||||
"owner": "googlarz",
|
||||
"repo": "proton-mail-bridge-client",
|
||||
"rev": "811be5de9dda5debf4d41ceae22b69c9fed8a42f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "googlarz",
|
||||
"repo": "proton-mail-bridge-client",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"proton-mail-bridge-client": "proton-mail-bridge-client"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# Proton Mail MCP pour NixOS
|
||||
#
|
||||
# - packages.<system>.default : proton-mail-bridge-client (serveur MCP + CLI)
|
||||
# https://github.com/googlarz/proton-mail-bridge-client
|
||||
# - nixosModules.default : Proton Mail Bridge headless (service système)
|
||||
# + entrée MCP prête à brancher dans Hermes
|
||||
#
|
||||
# Flake local (sous-dossier de ce repo), branché en path: depuis le flake.nix
|
||||
# racine (pas de repo GitHub séparé pour l'instant) :
|
||||
# inputs.protonmail-mcp.url = "path:./protonmail-mcp";
|
||||
# inputs.protonmail-mcp.inputs.nixpkgs.follows = "nixpkgs";
|
||||
# modules = [ inputs.protonmail-mcp.nixosModules.default ];
|
||||
# (déplaçable vers "github:MarcPartensky/<repo>" si ça sort un jour dans son
|
||||
# propre repo : même wiring, juste changer l'url ci-dessus.)
|
||||
#
|
||||
# Sur l'hôte Hermes (Bridge et le MCP doivent tourner sur la même machine) :
|
||||
# sops.secrets."protonmail/bridge-password".owner = "hermes";
|
||||
# services.protonmail-mcp = {
|
||||
# enable = true;
|
||||
# username = "marc.partensky@proton.me";
|
||||
# passwordFile = config.sops.secrets."protonmail/bridge-password".path;
|
||||
# };
|
||||
# services.hermes-agent.mcpServers.protonmail = config.services.protonmail-mcp.mcpServer;
|
||||
#
|
||||
# Premier login Bridge (une seule fois) : sudo protonmail-bridge-login
|
||||
# puis dans le CLI : login, info (copier le mot de passe Bridge dans sops), exit
|
||||
{
|
||||
description = "Proton Mail MCP (proton-mail-bridge-client) + Proton Mail Bridge headless";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
proton-mail-bridge-client = {
|
||||
url = "github:googlarz/proton-mail-bridge-client";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
proton-mail-bridge-client,
|
||||
}:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
mkPackage =
|
||||
pkgs:
|
||||
pkgs.callPackage (
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
importNpmLock,
|
||||
}:
|
||||
let
|
||||
src = proton-mail-bridge-client;
|
||||
package = lib.importJSON "${src}/package.json";
|
||||
lock = lib.importJSON "${src}/package-lock.json";
|
||||
# TypeScript 7 = un binaire natif par plateforme (~10 Mo chacun, 20 plateformes) :
|
||||
# on ne garde que celui de la machine qui build
|
||||
tsPrefix = "node_modules/@typescript/typescript-";
|
||||
tsPlatform =
|
||||
{
|
||||
x86_64-linux = "linux-x64";
|
||||
aarch64-linux = "linux-arm64";
|
||||
aarch64-darwin = "darwin-arm64";
|
||||
}
|
||||
.${stdenv.hostPlatform.system};
|
||||
in
|
||||
buildNpmPackage {
|
||||
pname = package.name;
|
||||
inherit (package) version;
|
||||
inherit src;
|
||||
|
||||
# Deps lues depuis package-lock.json : aucun hash à maintenir,
|
||||
# `nix flake update` suffit pour monter de version
|
||||
npmDeps = importNpmLock {
|
||||
inherit package;
|
||||
packageLock = lock // {
|
||||
packages = lib.filterAttrs (
|
||||
name: _: !(lib.hasPrefix tsPrefix name) || name == tsPrefix + tsPlatform
|
||||
) lock.packages;
|
||||
};
|
||||
};
|
||||
npmConfigHook = importNpmLock.npmConfigHook;
|
||||
|
||||
# better-sqlite3 compilé depuis les sources (pas de réseau dans le sandbox)
|
||||
env.npm_config_build_from_source = "true";
|
||||
# le script "prepare" relancerait tsc pendant `npm pack`
|
||||
npmPackFlags = [ "--ignore-scripts" ];
|
||||
|
||||
meta = {
|
||||
description = "MCP server and CLI for Proton Mail through Proton Mail Bridge";
|
||||
homepage = "https://github.com/googlarz/proton-mail-bridge-client";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "proton-mail-bridge-mcp";
|
||||
platforms = systems;
|
||||
};
|
||||
}
|
||||
) { };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (pkgs: rec {
|
||||
proton-mail-bridge-client = mkPackage pkgs;
|
||||
default = proton-mail-bridge-client;
|
||||
});
|
||||
|
||||
overlays.default = final: _prev: {
|
||||
proton-mail-bridge-client = mkPackage final;
|
||||
};
|
||||
|
||||
nixosModules.default =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.protonmail-mcp;
|
||||
user = "protonmail-bridge";
|
||||
home = "/var/lib/protonmail-bridge";
|
||||
|
||||
# Premier login : coupe le service et ouvre le CLI Bridge sous l'utilisateur dédié
|
||||
loginHelper = pkgs.writeShellApplication {
|
||||
name = "protonmail-bridge-login";
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils
|
||||
config.systemd.package
|
||||
];
|
||||
text = ''
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "À lancer avec sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl stop protonmail-bridge.service
|
||||
trap 'systemctl start protonmail-bridge.service' EXIT
|
||||
echo "CLI Bridge : login, puis info (mot de passe à mettre dans sops), puis exit"
|
||||
systemd-run --pty --wait --collect --quiet \
|
||||
--uid=${user} --gid=${user} \
|
||||
--setenv=HOME=${home} \
|
||||
--setenv=PATH=${
|
||||
lib.makeBinPath [
|
||||
pkgs.pass
|
||||
pkgs.gnupg
|
||||
]
|
||||
} \
|
||||
${lib.getExe cfg.bridgePackage} --cli
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.protonmail-mcp = {
|
||||
enable = lib.mkEnableOption "Proton Mail Bridge headless et le serveur MCP proton-mail-bridge-client";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = mkPackage pkgs;
|
||||
defaultText = lib.literalMD "`proton-mail-bridge-client` construit par ce flake";
|
||||
description = "Paquet du serveur MCP.";
|
||||
};
|
||||
|
||||
bridgePackage = lib.mkPackageOption pkgs "protonmail-bridge" { };
|
||||
|
||||
username = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "you@proton.me";
|
||||
description = "Adresse Proton (identifiant IMAP/SMTP de Bridge).";
|
||||
};
|
||||
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/run/secrets/protonmail/bridge-password";
|
||||
description = "Fichier contenant le mot de passe généré par Bridge (commande info du CLI), pas celui du compte Proton. Doit être lisible par le client MCP.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = {
|
||||
PROTONMAIL_ALLOW_SEND = "true";
|
||||
PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF = "true";
|
||||
};
|
||||
description = "Variables PROTONMAIL_* supplémentaires, prioritaires sur les garde-fous par défaut.";
|
||||
};
|
||||
|
||||
mcpServer = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
readOnly = true;
|
||||
defaultText = lib.literalMD "`command` + `env` dérivés des options ci-dessus";
|
||||
description = "Entrée MCP stdio prête à l'emploi, pour services.hermes-agent.mcpServers.";
|
||||
default = {
|
||||
command = lib.getExe cfg.package;
|
||||
env = {
|
||||
PROTONMAIL_USERNAME = cfg.username;
|
||||
PROTONMAIL_PASSWORD_FILE = cfg.passwordFile;
|
||||
# Garde-fous (l'agent lit des mails écrits par n'importe qui) :
|
||||
# lecture + brouillons, ni envoi ni suppression, 25 outils au lieu de 96
|
||||
PROTONMAIL_TOOL_TIER = "core";
|
||||
PROTONMAIL_ALLOW_SEND = "false";
|
||||
PROTONMAIL_ALLOWED_ACTIONS = "mark_read,mark_unread,star,unstar,archive";
|
||||
}
|
||||
// cfg.environment;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = !config.services.protonmail-bridge.enable;
|
||||
message = "services.protonmail-mcp lance déjà Proton Bridge : désactive services.protonmail-bridge.";
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${user} = {
|
||||
isSystemUser = true;
|
||||
group = user;
|
||||
inherit home;
|
||||
};
|
||||
users.groups.${user} = { };
|
||||
|
||||
systemd.services.protonmail-bridge = {
|
||||
description = "Proton Mail Bridge (headless)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
path = [
|
||||
pkgs.pass
|
||||
pkgs.gnupg
|
||||
];
|
||||
# Trousseau "pass" exigé par Bridge : clé GPG sans phrase de passe,
|
||||
# protégée par les droits 0700 du dossier d'état
|
||||
preStart = ''
|
||||
if ! gpg --list-secret-keys ${user} >/dev/null 2>&1; then
|
||||
gpg --batch --pinentry-mode loopback --passphrase "" \
|
||||
--quick-gen-key ${user} default default never
|
||||
fi
|
||||
[ -f "$HOME/.password-store/.gpg-id" ] || pass init ${user}
|
||||
'';
|
||||
serviceConfig = {
|
||||
User = user;
|
||||
Group = user;
|
||||
StateDirectory = "protonmail-bridge";
|
||||
StateDirectoryMode = "0700";
|
||||
ExecStart = "${lib.getExe cfg.bridgePackage} --noninteractive";
|
||||
Restart = "always";
|
||||
RestartSec = 10;
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictSUIDSGID = true;
|
||||
LockPersonality = true;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
cfg.package
|
||||
loginHelper
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
bridge_password: ENC[AES256_GCM,data:wQcsTjgeco5adaFu4onNcWkXIEO71MtSRRZsY1sOv4c0hWL31KHLT+hXqNMl,iv:0I4DJ8Ce8NVxBqyOUQuWXoXtNTAvuRTWWXcmyBEH59I=,tag:fyH/4siYdzG/mZcVluUZXw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOeWxBWTlMS2Ywb1pNT0Zu
|
||||
Zy8vdW5TdUQzUWV3MUJTSW5tTERVLzJlV1VnClUvYTVkRXNSMXZaeTZ4amYvdFRh
|
||||
RVg5UE1RTDFlaTJCYWNkNTlFOG1DNjQKLS0tIGFid1Y3QWU5T1BDQzFyUWMraUpD
|
||||
R1dzTUxjQnRUbzVuZnJ6K01sMVRNdmcKGcjvpi7LYGmIhP1OEjrMqI5ho09Wgtrt
|
||||
z38WGqa5DA3oWTqFURXnSpX4241u3dcFtZSwQ1+HGUm9mUx6ZP5ZSQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
|
||||
lastmodified: "2026-09-24T23:58:23Z"
|
||||
mac: ENC[AES256_GCM,data:22GAQLwjmwEDlydzdSnJl6BcCjereUyKixbZ9mNTtAEj6TuhUE3xtPmx3MFykEEbPo2Im3JrKzoOiFq724rkdrJ7KxowPgQ1oG+aGwDbonyALnzKDQIj8yGPgUhdBpUVA211DXKL6xoA9/xr6PY08PqKq0iuAUqigpF32kZLEAU=,iv:AMz/92xIQuNgKsp0AKp766JRiWRns5wO+QYoXOnZcfs=,tag:3S1ZPtJDXeG1vBrbolkCsA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
@@ -0,0 +1,16 @@
|
||||
zitadel_mcp_pat: ENC[AES256_GCM,data:5TtuBAkGCNu7KKZX5OXSoQNd2dcEP/2/WVOkdKE8oZUwDrun0Q==,iv:9I5H03JHfpXtelZSOZczIHNrav6W1Sg5ZE15zpYO7uk=,tag:C2rrNKdRKjgMw6Bepjg3yA==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTT0NyQVliOFVMeDFwbUxB
|
||||
c1NHZ3czb3Y2ODR4UkVIWkdMSXRDOUdMcDJNClNTSEZ0RzloVW1pRnIxai9IT1JH
|
||||
M3hyenJ0TW9IZGRwTnFXYnFJWmtyemsKLS0tIE9XR08zUlN1NEx2UHJqTlM1eDR6
|
||||
eU92RmJIaDBna1N2cE4wNDNMOGYyUjgKTCiFiRQ+FHgWIiq3NFo0mIroLAWysUDr
|
||||
xZowsxaYr8kPSwyBJfwCDamAmofYrJr3/u3Lqz2KgtB2hKytZUn0eA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
|
||||
lastmodified: "2026-09-25T01:06:13Z"
|
||||
mac: ENC[AES256_GCM,data:kErglUT+GMaSVjIQw2Q1Wlz47MkX6qmFi459mMmmb41/vsIzmOAh3PUJhOuu3dOblIN0QmR0BxXmz/pSCJYSKU6AjUyJD4v2d3AVH1E2mlAeTDC/hu2NlM1VLUPpCwAPOQt0mOkCdcQlUqWo0mNIzDjvuqXXB5efXjYUibadWHk=,iv:MBpgWtW0gMEr2tgyKx9M90AibaleYAIvHo0219JAmLU=,tag:YWt1AS6QmA6uPe7QncfJ6Q==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
@@ -30,8 +30,11 @@
|
||||
./eternal-terminal
|
||||
./nextcloud-mcp
|
||||
./amazon-mcp
|
||||
./protonmail-mcp
|
||||
./firefox-mcp
|
||||
# ./gotify
|
||||
./zitadel
|
||||
./zitadel-mcp
|
||||
./roku
|
||||
# ./kanidm
|
||||
# ./syncserver
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# services/firefox-mcp/default.nix
|
||||
# Serveur MCP Firefox DevTools (mozilla/firefox-devtools-mcp) : officiel Mozilla,
|
||||
# automatise un LibreWolf réel via WebDriver BiDi (geckodriver + Selenium) —
|
||||
# navigation, snapshots d'accessibilité, clics/saisie, réseau, téléchargements,
|
||||
# screenshots, exécution JS. PAS un navigateur patché comme Playwright.
|
||||
# Actif (releases régulières, dernière v0.10.4 le 2026-09-22), dual
|
||||
# MIT/Apache-2.0, documenté dans firefox-source-docs.mozilla.org.
|
||||
#
|
||||
# Transport stdio (hermes spawn le binaire en subprocess), comme amazon-mcp.
|
||||
# Tools préfixés mcp_firefox_* (navigate_page, take_snapshot, click_by_uid,
|
||||
# fill_by_uid, list_network_requests, screenshot_page, list_console_messages...).
|
||||
# Preset par défaut "basic" (inclut déjà evaluate_script). Pour plus de outils :
|
||||
# ajouter "--tool-preset" "developer" (debug/réseau/profiler) ou "mozilla"
|
||||
# (contexte privilégié) aux args du wrapper ci-dessous.
|
||||
#
|
||||
# Navigateur : LibreWolf (pkgs.librewolf-unwrapped), même moteur Gecko.
|
||||
# Geckodriver n'accepte que le VRAI binaire applicatif : son contrôle de
|
||||
# « Firefox executable » se fait via l'application.ini situé DANS le dossier du
|
||||
# binaire (vérifié : ${pkgs.librewolf-unwrapped}/lib/librewolf/librewolf passe,
|
||||
# Name=LibreWolf dans application.ini ne le dérange pas), alors que le shim
|
||||
# bin/librewolf (qui n'a pas d'application.ini à côté) est refusé avec
|
||||
# « binary is not a Firefox executable » — pareil pour pkgs.librewolf/bin/librewolf,
|
||||
# un script bash nix.
|
||||
#
|
||||
# Profil : dédié et persistant sous /var/lib/hermes/firefox-mcp/profile (JAMAIS
|
||||
# le profil personnel de marc — recommandation sécurité upstream, README
|
||||
# "Security"). Le serveur crée lui-même le sous-dossier
|
||||
# firefox_devtools_mcp_profile/ au premier lancement (src/firefox/profile.ts),
|
||||
# pas besoin de le pré-créer : juste le parent, via tmpfiles ci-dessous.
|
||||
#
|
||||
# Headless par défaut : le service hermes n'a pas de session graphique attachée
|
||||
# (ProtectSystem=strict, pas de DISPLAY/WAYLAND_DISPLAY). Pour du debug visuel,
|
||||
# lancer le wrapper à la main sans --headless depuis une session graphique de
|
||||
# marc (wayvnc) — même principe que amazon-mcp-login mais pas câblé en script
|
||||
# dédié ici faute de besoin (pas de login/2FA à faire pour naviguer).
|
||||
#
|
||||
# geckodriver : le paquet npm "geckodriver" (dépendance de firefox-devtools-mcp)
|
||||
# télécharge un binaire non-nix au premier usage UNIQUEMENT s'il ne trouve aucun
|
||||
# geckodriver sur le PATH (voir GECKODRIVER_AUTO_INSTALL dans son README — pas
|
||||
# activé ici, donc pas de tentative réseau au build). On met pkgs.geckodriver
|
||||
# (vrai binaire nix) en tête de PATH pour que findGeckodriverInPath()
|
||||
# (src/firefox/core.ts) le trouve en premier et court-circuite tout téléchargement.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
inputs,
|
||||
...
|
||||
}: let
|
||||
stateDir = "/var/lib/hermes/firefox-mcp";
|
||||
browser = pkgs.librewolf-unwrapped;
|
||||
nodejs = pkgs.nodejs_22; # LTS ; le projet exige seulement node >=20.19.0
|
||||
|
||||
firefox-devtools-mcp = pkgs.buildNpmPackage {
|
||||
pname = "firefox-devtools-mcp";
|
||||
version = "0.10.4";
|
||||
src = inputs.firefox-devtools-mcp;
|
||||
inherit nodejs;
|
||||
|
||||
npmDepsHash = "sha256-+pJwxFmgnFWUCvt5wWWFXvyO5oUiTamgaEkQdk0lZoo=";
|
||||
|
||||
meta = {
|
||||
description = "Official Mozilla MCP server for Firefox DevTools automation (WebDriver BiDi)";
|
||||
homepage = "https://github.com/mozilla/firefox-devtools-mcp";
|
||||
license = [lib.licenses.mit lib.licenses.asl20];
|
||||
mainProgram = "firefox-devtools-mcp";
|
||||
};
|
||||
};
|
||||
|
||||
# PIÈGE vérifié en vrai : geckodriver lit la version de Firefox depuis
|
||||
# `application.ini` à côté du binaire (mozversion), sinon en scannant le
|
||||
# binaire ; sinon il refuse la session avec
|
||||
# « <chemin> is not a Firefox executable » (src/capabilities.rs, version()).
|
||||
# Ni ${pkgs.librewolf}/bin/librewolf (script bash de wrapFirefox) ni
|
||||
# librewolf-unwrapped/bin/librewolf (shim ELF de 16 Ko, pas d'application.ini
|
||||
# dans bin/) ne passent : le VRAI binaire d'application est
|
||||
# lib/librewolf/librewolf, dont le dossier contient bien application.ini.
|
||||
wrapper = pkgs.writeShellScript "firefox-mcp-hermes" ''
|
||||
export PATH="${pkgs.geckodriver}/bin:$PATH"
|
||||
exec ${lib.getExe firefox-devtools-mcp} \
|
||||
--firefox-path "${browser}/lib/librewolf/librewolf" \
|
||||
--profile-path "${stateDir}/profile" \
|
||||
--headless \
|
||||
"$@"
|
||||
'';
|
||||
|
||||
# Profil réellement utilisé par le serveur : <profile-path>/firefox_devtools_mcp_profile
|
||||
# (resolveProfilePath(), src/firefox/profile.ts — il ajoute toujours ce sous-dossier
|
||||
# pour ne jamais tomber sur un vrai profil perso). C'est CE dossier qu'il faut
|
||||
# ouvrir pour le login manuel, sinon les cookies ne sont pas ceux que le
|
||||
# serveur headless relit ensuite.
|
||||
profileDir = "${stateDir}/profile/firefox_devtools_mcp_profile";
|
||||
|
||||
# Login manuel : le serveur tourne en headless, donc l'humain doit ouvrir le
|
||||
# même profil dans un navigateur VISIBLE une fois, se connecter (Proton,
|
||||
# webmail sans Bridge), puis fermer. À lancer depuis la session graphique de
|
||||
# marc (niri), sans sudo — le profil est partagé par ACL (voir tmpfiles).
|
||||
loginHelper = pkgs.writeShellApplication {
|
||||
name = "firefox-mcp-login";
|
||||
runtimeInputs = [pkgs.librewolf pkgs.procps pkgs.gnugrep];
|
||||
text = ''
|
||||
url="''${1:-https://mail.proton.me/}"
|
||||
# Firefox ne crée PAS le dossier de profil : sans lui il meurt sur
|
||||
# « Could not find profile folder. » (vérifié). Le serveur le crée au
|
||||
# premier lancement, ce mkdir couvre le cas où on se connecte avant.
|
||||
mkdir -p "${profileDir}"
|
||||
# Firefox refuse deux instances sur un même profil : on prévient avant.
|
||||
if pgrep -f -- "--profile ${profileDir}" >/dev/null; then
|
||||
echo "Un navigateur MCP tourne déjà sur ce profil (session hermes active)." >&2
|
||||
echo "Arrête-la d'abord : le profil ne peut pas être ouvert deux fois." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Connecte-toi à $url dans la fenêtre qui s'ouvre, puis ferme-la."
|
||||
exec librewolf --no-remote --profile "${profileDir}" "$url"
|
||||
'';
|
||||
};
|
||||
in {
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${stateDir} 0700 hermes hermes -"
|
||||
# Profil partagé : hermes (serveur MCP headless) <-> marc (login manuel).
|
||||
# "a" ne rejoue qu'au BOOT ; après le premier switch :
|
||||
# sudo systemd-tmpfiles --create
|
||||
"a /var/lib/hermes - - - - u:marc:--x,m::rwx"
|
||||
"d ${stateDir}/profile 0750 hermes hermes -"
|
||||
"a ${stateDir} - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
|
||||
"a ${stateDir}/profile - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
|
||||
"d ${profileDir} 0750 hermes hermes -"
|
||||
"a ${profileDir} - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
|
||||
];
|
||||
|
||||
environment.systemPackages = [loginHelper];
|
||||
|
||||
services.hermes-agent.mcpServers.firefox = {
|
||||
command = "${wrapper}";
|
||||
timeout = 180; # certaines pages/SPA peuvent dépasser le défaut de 120s
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# services/protonmail-mcp/default.nix
|
||||
# Proton Mail Bridge headless (service système) + serveur MCP
|
||||
# proton-mail-bridge-client, câblés via le flake local ../../protonmail-mcp
|
||||
# (nixosModules.default = inputs.protonmail-mcp.nixosModules.default, importé
|
||||
# directement dans flake.nix pour tower).
|
||||
#
|
||||
# Secret sops : secrets/protonmail.yml, fichier SÉPARÉ de tower.yml — hermes
|
||||
# n'a ni le binaire sops ni la clé age privée de tower (lecture seule pour
|
||||
# root), donc pas moyen de rééditer tower.yml en place. Un fichier neuf ne
|
||||
# demande que les clés PUBLIQUES déjà présentes dans .sops.yaml -> chiffrable
|
||||
# par hermes via `nix run nixpkgs#sops -- --encrypt --in-place`.
|
||||
# Contient pour l'instant un mot de passe PLACEHOLDER (Bridge non logué) :
|
||||
# le service démarre mais le MCP n'authentifie pas encore.
|
||||
#
|
||||
# PREMIER LOGIN (une fois, humain — nécessite le vrai mot de passe Proton de
|
||||
# marc.partensky@proton.me, potentiellement 2FA) :
|
||||
# sudo protonmail-bridge-login
|
||||
# Dans le CLI Bridge : login, puis info (copier le mot de passe Bridge généré),
|
||||
# puis exit.
|
||||
# Ensuite : remplacer le placeholder dans secrets/protonmail.yml par ce mot de
|
||||
# passe (re-chiffrement complet du fichier, toujours sans clé privée), puis
|
||||
# décommenter la ligne services.hermes-agent.mcpServers.protonmail ci-dessous
|
||||
# et relancer `just nixos`.
|
||||
{config, ...}: {
|
||||
sops.secrets."protonmail/bridge-password" = {
|
||||
sopsFile = ../../secrets/protonmail.yml;
|
||||
key = "bridge_password";
|
||||
owner = "hermes";
|
||||
};
|
||||
|
||||
services.protonmail-mcp = {
|
||||
enable = true;
|
||||
username = "marc.partensky@proton.me";
|
||||
passwordFile = config.sops.secrets."protonmail/bridge-password".path;
|
||||
};
|
||||
|
||||
# À décommenter seulement après le premier login Bridge (mot de passe réel
|
||||
# dans secrets/protonmail.yml, pas le placeholder) :
|
||||
# services.hermes-agent.mcpServers.protonmail = config.services.protonmail-mcp.mcpServer;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
# services/zitadel-mcp/default.nix
|
||||
# Serveur MCP Zitadel pour hermes : création/gestion des comptes (humains) de
|
||||
# ses potes via l'API Zitadel, transport stdio (hermes spawn le subprocess).
|
||||
#
|
||||
# Auth : Personal Access Token d'un service user Zitadel. Le token vit dans un
|
||||
# fichier sops (secrets/zitadel.yml), jamais dans le nix store ; le serveur le
|
||||
# lit à chaque appel.
|
||||
#
|
||||
# Créer le PAT dans la console (https://auth.marcpartensky.com) :
|
||||
# Users -> Service Users -> New (Access Token Type = Bearer)
|
||||
# puis Organisation ZITADEL -> Managers -> ajouter le service user
|
||||
# -> rôle ORG_OWNER (ou org user manager pour un scope plus étroit)
|
||||
# puis Service User -> Personal Access Tokens -> New, copier le token.
|
||||
#
|
||||
# Debug : tester le serveur à la main en tant que hermes
|
||||
# { printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 5; } \
|
||||
# | ZITADEL_PAT_FILE=... /nix/store/.../bin/zitadel-mcp
|
||||
# (garder stdin ouvert : un EOF tue la requête en vol)
|
||||
{pkgs, config, ...}: let
|
||||
zitadel-mcp =
|
||||
pkgs.writers.writePython3Bin "zitadel-mcp" {
|
||||
libraries = [pkgs.python3Packages.mcp];
|
||||
flakeIgnore = ["E501" "W503" "W504"];
|
||||
} ''
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
from mcp.server import MCPServer # mcp >= 2
|
||||
except ImportError:
|
||||
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
|
||||
|
||||
BASE = os.environ.get("ZITADEL_URL", "https://auth.marcpartensky.com").rstrip("/")
|
||||
ORG_ID = os.environ.get("ZITADEL_ORG_ID", "") # optionnel : x-zitadel-orgid
|
||||
|
||||
STATES = {
|
||||
0: "unspecified",
|
||||
1: "active",
|
||||
2: "inactive",
|
||||
3: "deleted",
|
||||
4: "locked",
|
||||
5: "suspend",
|
||||
6: "initial (invité, pas encore activé)",
|
||||
}
|
||||
|
||||
mcp = MCPServer("zitadel")
|
||||
|
||||
|
||||
def _pat():
|
||||
path = os.environ.get("ZITADEL_PAT_FILE")
|
||||
if path and os.path.exists(path):
|
||||
with open(path) as fh:
|
||||
return fh.read().strip()
|
||||
return os.environ.get("ZITADEL_PAT", "").strip()
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
"""Appel API Zitadel. Lève RuntimeError avec le corps d'erreur brut."""
|
||||
token = _pat()
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
"Aucun PAT Zitadel : renseigner ZITADEL_PAT_FILE (secret sops) "
|
||||
"ou ZITADEL_PAT."
|
||||
)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if ORG_ID:
|
||||
req.add_header("x-zitadel-orgid", ORG_ID)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode()
|
||||
return resp.status, (json.loads(raw) if raw.strip() else {})
|
||||
except urllib.error.HTTPError as err:
|
||||
raw = err.read().decode()
|
||||
try:
|
||||
detail = json.dumps(json.loads(raw), ensure_ascii=False)
|
||||
except Exception:
|
||||
detail = raw
|
||||
raise RuntimeError(f"HTTP {err.code} sur {method} {path} : {detail[:900]}") from None
|
||||
|
||||
|
||||
def _view(user):
|
||||
"""Réduit un UserView v1 aux champs utiles."""
|
||||
state = user.get("state")
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"login": user.get("preferredLoginName") or user.get("username"),
|
||||
"username": user.get("username"),
|
||||
"email": user.get("email"),
|
||||
"email_verified": user.get("isEmailVerified"),
|
||||
"nom": user.get("displayName"),
|
||||
"prenom": user.get("firstName"),
|
||||
"nom_famille": user.get("lastName"),
|
||||
"etat": STATES.get(state, state) if isinstance(state, int) else state,
|
||||
"type": user.get("type"),
|
||||
"derniere_connexion": user.get("lastLogin"),
|
||||
"creation": (user.get("details") or {}).get("creationDate"),
|
||||
}
|
||||
|
||||
|
||||
def _search_users(**kwargs):
|
||||
body = {"offset": 0, "limit": kwargs.pop("limit", 50)}
|
||||
queries = kwargs.pop("queries", None)
|
||||
if queries:
|
||||
body["queries"] = queries
|
||||
_, resp = api("POST", "/management/v1/users/_search", body)
|
||||
return resp
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def status() -> dict:
|
||||
"""État de l'instance Zitadel et de l'authentification du serveur.
|
||||
|
||||
Renvoie l'org courante et le nombre de comptes humains. Sert de test
|
||||
de vie du PAT (renvoie l'erreur HTTP brute si le token est invalide
|
||||
ou sans droits).
|
||||
"""
|
||||
_, org = api("GET", "/management/v1/orgs/me")
|
||||
teams = _search_users(limit=1)
|
||||
return {
|
||||
"instance": BASE,
|
||||
"org": {
|
||||
"id": (org.get("org") or {}).get("id"),
|
||||
"nom": (org.get("org") or {}).get("name"),
|
||||
"domaine": (org.get("org") or {}).get("primaryDomain"),
|
||||
},
|
||||
"comptes_humains": (teams.get("details") or {}).get("totalResult"),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_users(filtre: str = "", limit: int = 50) -> list:
|
||||
"""Liste les comptes (humains et techniques) de l'org.
|
||||
|
||||
filtre : sous-chaîne cherchée dans le login ou l'email (casse
|
||||
ignorée). Vide = tous.
|
||||
"""
|
||||
users = (_search_users(limit=max(limit, 1)).get("result") or [])
|
||||
views = [_view(u) for u in users]
|
||||
if filtre:
|
||||
needle = filtre.lower()
|
||||
views = [
|
||||
v
|
||||
for v in views
|
||||
if any(
|
||||
needle in (v[champ] or "").lower()
|
||||
for champ in ("login", "email", "nom")
|
||||
)
|
||||
]
|
||||
return views
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_user(email_ou_login: str) -> dict:
|
||||
"""Trouve un compte par email ou login (égalité, casse ignorée)."""
|
||||
for query in (
|
||||
{"emailQuery": {"emailAddress": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
|
||||
{"userNameQuery": {"userName": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
|
||||
):
|
||||
resp = _search_users(limit=5, queries=[query])
|
||||
found = resp.get("result") or []
|
||||
if found:
|
||||
return _view(found[0])
|
||||
return {"trouve": False, "recherche": email_ou_login}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_user(user_id: str) -> dict:
|
||||
"""Détail complet d'un compte par son user_id (id technique Zitadel)."""
|
||||
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}")
|
||||
return _view(resp.get("user") or {})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_friend(
|
||||
email: str,
|
||||
prenom: str,
|
||||
nom: str,
|
||||
username: str = "",
|
||||
envoyer_invitation: bool = True,
|
||||
mot_de_passe: str = "",
|
||||
langue: str = "fr",
|
||||
) -> dict:
|
||||
"""Crée un compte humain pour un pote et lui envoie l'invitation.
|
||||
|
||||
Par défaut : email marqué vérifié (évite un second mail de
|
||||
vérification) puis envoi du mail d'invitation Zitadel ; le pote
|
||||
choisit lui-même son mot de passe/passkey, l'agent ne le voit jamais.
|
||||
Décocher envoyer_invitation n'a de sens que si mot_de_passe est
|
||||
fourni (compte utilisable tout de suite, mot de passe à transmettre
|
||||
hors ligne).
|
||||
"""
|
||||
if not envoyer_invitation and not mot_de_passe:
|
||||
return {
|
||||
"erreur": "sans invitation il faut fournir mot_de_passe, sinon "
|
||||
"le compte reste en état initial et inutilisable"
|
||||
}
|
||||
body = {
|
||||
"username": username or email,
|
||||
"profile": {"givenName": prenom, "familyName": nom, "preferredLanguage": langue},
|
||||
"email": {"email": email, "isVerified": True},
|
||||
}
|
||||
if mot_de_passe:
|
||||
body["password"] = {"password": mot_de_passe, "changeRequired": False}
|
||||
_, created = api("POST", "/v2/users/human", body)
|
||||
user_id = created.get("userId")
|
||||
out = {"id": user_id, "email": email, "invitation": "non demandée"}
|
||||
if envoyer_invitation:
|
||||
_, invite = api(
|
||||
"POST",
|
||||
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
|
||||
{"sendCode": {}},
|
||||
)
|
||||
out["invitation"] = "mail envoyé"
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def invite_user(user_id: str) -> dict:
|
||||
"""(Re)envoie le mail d'invitation à un compte existant, y compris un
|
||||
compte resté en état initial jamais activé. Écrase l'ancien code."""
|
||||
_, resp = api(
|
||||
"POST",
|
||||
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
|
||||
{"sendCode": {}},
|
||||
)
|
||||
return {"ok": True, "details": resp.get("details")}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_password(user_id: str, mot_de_passe: str, changement_obligatoire: bool = False) -> dict:
|
||||
"""Fixe le mot de passe d'un compte (l'agent le voit passer : à
|
||||
réserver aux cas où l'invitation par mail ne convient pas)."""
|
||||
_, resp = api(
|
||||
"POST",
|
||||
f"/v2/users/{urllib.parse.quote(user_id)}/password",
|
||||
{
|
||||
"newPassword": {
|
||||
"password": mot_de_passe,
|
||||
"changeRequired": changement_obligatoire,
|
||||
},
|
||||
"verification": {},
|
||||
},
|
||||
)
|
||||
return {"ok": True, "details": resp.get("details")}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reset_password(user_id: str) -> dict:
|
||||
"""Envoie un mail de réinitialisation de mot de passe (le pote choisit
|
||||
lui-même le nouveau)."""
|
||||
_, resp = api(
|
||||
"POST",
|
||||
f"/v2/users/{urllib.parse.quote(user_id)}/password/reset",
|
||||
{"sendLink": {}},
|
||||
)
|
||||
return {"ok": True, "details": resp.get("details")}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_active(user_id: str, actif: bool) -> dict:
|
||||
"""Active ou désactive un compte. Désactivé = ne peut plus se
|
||||
connecter, données conservées. Réversible."""
|
||||
action = "reactivate" if actif else "deactivate"
|
||||
_, resp = api("POST", f"/v2/users/{urllib.parse.quote(user_id)}/{action}")
|
||||
return {"ok": True, "etat": "actif" if actif else "désactivé", "details": resp.get("details")}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_user(user_id: str) -> dict:
|
||||
"""Supprime un compte (irréversible). Préférer set_active(False)."""
|
||||
api("DELETE", f"/v2/users/{urllib.parse.quote(user_id)}")
|
||||
return {"ok": True, "supprime": user_id}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_projects() -> list:
|
||||
"""Projets Zitadel de l'org (les applications à rôle, ex. Nextcloud,
|
||||
Portainer) : à croiser avec list_project_roles."""
|
||||
_, resp = api("POST", "/management/v1/projects/_search", {"offset": 0, "limit": 50})
|
||||
return [
|
||||
{"id": p.get("id"), "nom": p.get("name"), "etat": p.get("state")}
|
||||
for p in (resp.get("result") or [])
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_project_roles(project_id: str) -> list:
|
||||
"""Rôles disponibles dans un projet (à passer à grant_roles)."""
|
||||
_, resp = api(
|
||||
"POST",
|
||||
f"/management/v1/projects/{urllib.parse.quote(project_id)}/roles/_search",
|
||||
{"offset": 0, "limit": 100},
|
||||
)
|
||||
return [
|
||||
{
|
||||
"cle": r.get("key"),
|
||||
"nom": r.get("displayName") or (r.get("details") or {}).get("displayName"),
|
||||
"groupe": r.get("group"),
|
||||
}
|
||||
for r in (resp.get("result") or [])
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def grant_roles(user_id: str, project_id: str, roles: list[str]) -> dict:
|
||||
"""Donne des rôles projet à un compte sur un projet (accès applicatif)."""
|
||||
_, resp = api(
|
||||
"POST",
|
||||
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants",
|
||||
{"projectId": project_id, "roleKeys": roles},
|
||||
)
|
||||
return {"ok": True, "grant_id": resp.get("userGrantId"), "roles": roles}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_user_grants(user_id: str) -> list:
|
||||
"""Rôles projet déjà accordés à un compte."""
|
||||
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}/grants")
|
||||
out = []
|
||||
for g in resp.get("userGrantList") or resp.get("result") or []:
|
||||
out.append(
|
||||
{
|
||||
"grant_id": g.get("id"),
|
||||
"projet": g.get("projectName") or g.get("projectId"),
|
||||
"roles": g.get("roleKeys"),
|
||||
"etat": g.get("state"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def revoke_grant(user_id: str, grant_id: str) -> dict:
|
||||
"""Retire un rôle projet (voir list_user_grants pour le grant_id)."""
|
||||
api(
|
||||
"DELETE",
|
||||
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants/{urllib.parse.quote(grant_id)}",
|
||||
)
|
||||
return {"ok": True, "retire": grant_id}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
'';
|
||||
in {
|
||||
# Le PAT vit dans secrets/zitadel.yml (fichier sops distinct : tower.yml n'est
|
||||
# pas modifiable par hermes, sa clé MAC exigerait la clé age privée).
|
||||
sops.secrets."zitadel_mcp_pat" = {
|
||||
sopsFile = ../../secrets/zitadel.yml;
|
||||
owner = "hermes";
|
||||
group = "hermes";
|
||||
mode = "0400";
|
||||
restartUnits = ["hermes-agent.service"];
|
||||
};
|
||||
|
||||
services.hermes-agent.mcpServers.zitadel = {
|
||||
command = "${zitadel-mcp}/bin/zitadel-mcp";
|
||||
env = {
|
||||
ZITADEL_URL = "https://auth.marcpartensky.com";
|
||||
ZITADEL_PAT_FILE = config.sops.secrets."zitadel_mcp_pat".path;
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user