diff --git a/profiles/tower/configuration.nix b/profiles/tower/configuration.nix index 43072d8..3f66041 100644 --- a/profiles/tower/configuration.nix +++ b/profiles/tower/configuration.nix @@ -238,6 +238,37 @@ in { openFirewall = true; }; + # ======================================================================== + # ANKI - serveur MCP headless (Anki + addon AnkiMCP) + # ======================================================================== + # Anki sans bureau + addon AnkiMCP, expose en HTTP MCP sur la loopback + # (127.0.0.1:3141) et consomme par Hermes (mcpServers.anki). La collection + # est celle du compte "marc" du serveur de sync anki.marcpartensky.com : + # ce service en est un client de plus, comme le Mac et le telephone. + # Rien n'est ouvert au firewall ni expose par newt (exposition publique + # possible plus tard, avec cle http_api_key, cf services/anki-mcp). + services.anki-mcp.enable = true; + + # ======================================================================== + # MCP - accès LAN filtré (URLs cliquables depuis Hermes Desktop sur le Mac) + # ======================================================================== + # Republie sur l'adresse LAN de tower les quatre endpoints MCP HTTP qui + # écoutent en loopback. Le firewall n'accepte que 192.168.1.48 (le Mac) ; + # rien n'est ouvert à la box ni à Pangolin. Les modules nextcloud-mcp, + # arr-mcp, anki-mcp et le serveur beeper lisent `host` pour construire leur + # URL MCP, donc une seule valeur à changer si l'adresse DHCP bouge. + services.mcp-lan-access = { + enable = true; + host = "192.168.1.44"; + clientAddress = "192.168.1.48"; + endpoints = { + nextcloud = 8710; + anki = 3141; + arrmcp = 6060; + beeper = 23373; + }; + }; + # ======================================================================== # NGINX - vhosts locaux (TLS porte par Pangolin/newt, pas par nginx ici) # ======================================================================== diff --git a/services/anki-mcp/bootstrap/__init__.py b/services/anki-mcp/bootstrap/__init__.py new file mode 100644 index 0000000..02f8fc0 --- /dev/null +++ b/services/anki-mcp/bootstrap/__init__.py @@ -0,0 +1,74 @@ +"""Amorce le compte du serveur de sync pour l'instance headless AnkiMCP. + +Anki n'expose aucun chemin non interactif pour renseigner « serveur de sync +personnalisé + compte + mot de passe » : la saisie passe par l'interface +graphique. Ce module le fait une fois au chargement du profil, puis persiste +la clé de sync (hkey) dans le profil, exactement comme le ferait +`aqt.sync` après un login réussi : c'est ce que lit ensuite `mw.pm.sync_auth()`, +donc l'outil `sync` de l'addon AnkiMCP. + +Variables d'environnement attendues (posées par le service systemd) : + ANKI_SYNC_USER compte sur le serveur de sync + ANKI_SYNC_ENDPOINT URL du serveur, ex. https://anki.marcpartensky.com + ANKI_SYNC_PASSWORD_FILE fichier contenant le mot de passe (LoadCredential) + +Idempotent : si le profil a déjà une clé de sync, rien n'est fait. La sync +automatique au démarrage est désactivée : la première mise en accord avec le +serveur (collection locale vide) demande une sync unidirectionnelle, et +l'assistant Anki l'affiche en boîte de dialogue modale, intenable sans écran. +C'est l'outil `sync` du serveur MCP qui pilote la sync, avec un choix de +direction explicite. +""" + +import os + +from aqt import gui_hooks, mw + +_TAG = "[ankimcp-sync-bootstrap]" + +# Évite d'empiler les tentatives si le profil est rouvert dans le même run. +_done = False + + +def _log(message: str) -> None: + print(f"{_TAG} {message}", flush=True) + + +def _bootstrap() -> None: + global _done + if _done or mw is None or mw.col is None or mw.pm is None: + return + _done = True + + try: + # Sans écran, une sync automatique qui tombe sur un conflit ouvre un + # dialogue modal : on la désactive, l'agent déclenche la sync lui-même. + if mw.pm.profile.get("autoSync"): + mw.pm.profile["autoSync"] = False + + if mw.pm.profile.get("syncKey"): + _log("profil déjà rattaché au serveur de sync, rien à faire") + return + + user = os.environ.get("ANKI_SYNC_USER", "") + endpoint = os.environ.get("ANKI_SYNC_ENDPOINT", "") + password_file = os.environ.get("ANKI_SYNC_PASSWORD_FILE", "") + if not (user and endpoint and password_file): + _log("ANKI_SYNC_USER/ANKI_SYNC_ENDPOINT/ANKI_SYNC_PASSWORD_FILE " + "incomplets, amorçage ignoré") + return + + with open(password_file, encoding="utf-8") as handle: + password = handle.read().strip() + + auth = mw.col.sync_login(user, password, endpoint) + mw.pm.set_sync_key(auth.hkey) + mw.pm.set_sync_username(user) + mw.pm.set_custom_sync_url(endpoint) + _log(f"compte {user} rattaché à {endpoint}") + except Exception as exc: # réseau, identifiants, schéma : on réessaiera + _done = False + _log(f"amorçage échoué ({type(exc).__name__}: {exc})") + + +gui_hooks.profile_did_open.append(_bootstrap) diff --git a/services/anki-mcp/bootstrap/manifest.json b/services/anki-mcp/bootstrap/manifest.json new file mode 100644 index 0000000..b36af7a --- /dev/null +++ b/services/anki-mcp/bootstrap/manifest.json @@ -0,0 +1,6 @@ +{ + "package": "ankimcp_sync_bootstrap", + "name": "AnkiMCP sync bootstrap", + "mod": 0, + "conflicts": [] +} diff --git a/services/anki-mcp/default.nix b/services/anki-mcp/default.nix new file mode 100644 index 0000000..fae960e --- /dev/null +++ b/services/anki-mcp/default.nix @@ -0,0 +1,165 @@ +# services/anki-mcp/default.nix +# Serveur MCP Anki headless : Anki (sans bureau, sous Xvfb) + addon AnkiMCP +# (ankimcp.ai), qui expose la collection en HTTP MCP sur la loopback. +# +# Choix d'architecture : l'addon AnkiMCP tourne DANS Anki (aucun processus +# séparé, aucun AnkiConnect à installer), donc la seule façon d'en faire un +# service est de faire tourner un Anki sans écran. La collection de ce service +# et celle de ton Anki de bureau (Mac, téléphone) se réconcilient par le +# serveur de sync auto-hébergé (services/anki-sync-server), dont ce module +# emprunte le secret : un seul compte, une seule collection, plusieurs clients. +# +# Détails qui coûtent du temps si on les ignore : +# - Anki lit sa liste d'addons dans $ANKI_ADDONS quand cette variable est +# posée (patch nixpkgs), sinon dans /addons21. On construit donc un +# dossier d'addons dédié : l'addon AnkiMCP plus un petit addon d'amorçage, +# car Anki n'offre AUCUN chemin non interactif pour renseigner le serveur de +# sync et le compte (la saisie passe par la boîte de dialogue des +# préférences). L'addon d'amorçage fait le login au chargement du profil et +# persiste la clé, ce que lit ensuite mw.pm.sync_auth() donc l'outil `sync`. +# - L'addon "nixos" livré par pkgs.anki.withAddons n'est PAS repris ici : il +# remplace addonManager.writeConfig par une boîte de dialogue modale, +# intenable sans écran. +# - Anki vient du nixpkgs de l'input anki-mcp, celui qui a construit l'addon : +# l'addon y est lié contre son interpréteur python (3.14) via vendor/shared, +# un autre nixpkgs casse l'ABI des modules compilés (pydantic_core). +# - Pas de -p au premier démarrage : le profil n'existe pas encore et +# Anki afficherait alors le sélecteur de profils (modal). Sans -p, Anki crée +# le profil unique tout seul puis le charge silencieusement. +{ config, lib, pkgs, inputs, ... }: +let + cfg = config.services.anki-mcp; + + system = pkgs.stdenv.hostPlatform.system; + + # nixpkgs qui a construit l'addon ET le paquet Anki (cf en-tête). + ankiPkgs = inputs.anki-mcp.inputs.nixpkgs.legacyPackages.${system}; + mcpAddon = inputs.anki-mcp.packages.${system}.addon; + + # Addon d'amorçage : rattache le profil au serveur de sync (login une fois). + bootstrapAddon = pkgs.runCommand "anki-addon-ankimcp-sync-bootstrap" { } '' + mkdir -p $out/share/anki/addons/ankimcp_sync_bootstrap + cp ${./bootstrap/__init__.py} $out/share/anki/addons/ankimcp_sync_bootstrap/__init__.py + cp ${./bootstrap/manifest.json} $out/share/anki/addons/ankimcp_sync_bootstrap/manifest.json + ''; + + # AnkiMCP protège son transport HTTP contre le DNS-rebinding : le Host de + # l'URL LAN (192.168.1.44:3141) n'est accepté que s'il figure dans + # http_allowed_hosts, sinon le serveur répond 421 Misdirected Request. Le + # dossier d'addon de l'input est en lecture seule (store), on en fait donc + # une copie dont seul config.json change. La copie n'est utilisée que quand + # l'accès LAN est activé. + mcpAddonForLan = pkgs.runCommand "anki-addon-anki-mcp-server-lan" { } '' + mkdir -p $out + cp -r ${mcpAddon}/share/anki/addons/anki-mcp-server/. $out/ + chmod -R u+w $out + ${pkgs.jq}/bin/jq --arg host "${config.services.mcp-lan-access.host}:${toString port}" \ + '.http_allowed_hosts = [$host]' $out/config.json > $out/config.json.new + mv $out/config.json.new $out/config.json + ''; + + # Dossier d'addons d'Anki ($ANKI_ADDONS) : un simple répertoire de liens. + addonsDir = pkgs.runCommand "ankimcp-addons" { } '' + mkdir -p $out + ln -s ${ + if config.services.mcp-lan-access.enable + then mcpAddonForLan + else "${mcpAddon}/share/anki/addons/anki-mcp-server" + } $out/anki-mcp-server + ln -s ${bootstrapAddon}/share/anki/addons/ankimcp_sync_bootstrap $out/ankimcp_sync_bootstrap + ''; + + stateDir = "/var/lib/anki-mcp"; + port = 3141; + + # Tant que prefs21.db n'existe pas, Anki se croit au premier lancement et + # ouvre un sélecteur de langue modal qui bloque l'ouverture du profil : on + # amorce le fichier une fois (cf l'en-tête de seed-prefs.py). + seedPrefs = pkgs.writeShellScript "anki-mcp-seed-prefs" '' + exec ${pkgs.python3}/bin/python3 ${./seed-prefs.py} ${stateDir}/anki/prefs21.db + ''; + + start = pkgs.writeShellScript "anki-mcp-start" '' + set -eu + # Tout l'état d'Anki vit sous $HOME (profil, collection, medias, logs). + export HOME=${stateDir} + export ANKI_SYNC_USER=${cfg.syncUser} + export ANKI_SYNC_ENDPOINT=${cfg.syncEndpoint} + export ANKI_SYNC_PASSWORD_FILE="$CREDENTIALS_DIRECTORY/anki-sync-password" + + # Anki écrit dans son profil : le chemin -b reste sous $HOME pour que + # DynamicUser et StateDirectory suffisent (aucun /home/user en jeu). + exec ${pkgs.xvfb-run}/bin/xvfb-run -a -s "-screen 0 1280x1024x24" \ + ${ankiPkgs.anki}/bin/anki -b ${stateDir}/anki + ''; +in { + options.services.anki-mcp = { + enable = lib.mkEnableOption "serveur MCP Anki headless (Anki + addon AnkiMCP)"; + + syncUser = lib.mkOption { + type = lib.types.str; + default = "marc"; + description = "Compte du serveur de sync Anki utilisé par l'instance headless."; + }; + + syncEndpoint = lib.mkOption { + type = lib.types.str; + default = "https://anki.marcpartensky.com"; + description = '' + URL du serveur de sync (services/anki-sync-server). Le mot de passe + vient du secret anki-sync-server/password, lu par LoadCredential. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.anki-mcp = { + description = "Anki + AnkiMCP headless (serveur MCP Anki pour l'agent)"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target"]; + wants = ["network-online.target"]; + # La sync est pilotée par l'outil `sync` du MCP, pas au démarrage : + # cf l'addon d'amorçage (autoSync désactivé). + serviceConfig = { + DynamicUser = true; + StateDirectory = "anki-mcp"; + RuntimeDirectory = "anki-mcp"; + WorkingDirectory = stateDir; + # Même compte que le serveur de sync : le secret est déjà là. + LoadCredential = "anki-sync-password:${config.sops.secrets."anki-sync-server/password".path}"; + ExecStartPre = seedPrefs; + ExecStart = start; + Restart = "always"; + RestartSec = "5s"; + TimeoutStopSec = "30s"; + }; + environment = { + ANKI_ADDONS = "${addonsDir}"; + # Qt/WebEngine sans écran : rendu logiciel, pas de bac à sable. + # DISPLAY est posé par xvfb-run (display libre choisi à l'exécution). + QTWEBENGINE_DISABLE_SANDBOX = "1"; + QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu"; + LIBGL_ALWAYS_SOFTWARE = "1"; + QT_QPA_PLATFORM = "xcb"; + QT_XKB_CONFIG_ROOT = "${pkgs.xkeyboard_config}/share/X11/xkb"; + LANG = "C.UTF-8"; + }; + }; + + # Câblage Hermes : le MCP est consommé par URL (transport HTTP), comme + # nextcloud ou arrmcp. Le port reste sur la loopback et rien n'est ouvert au + # firewall. L'URL LAN du module services/mcp-lan-access ne fait que rendre + # le lien cliquable depuis Hermes Desktop sur le Mac ; le firewall la + # restreint au Mac et tower la joint par lo. + # Exposition publique éventuelle : passer par une ressource Pangolin/newt + # dédiée (services/newt) avec http_api_key + http_allowed_hosts posés dans + # la config de l'addon, jamais sans clé. + services.hermes-agent.mcpServers.anki = { + url = + if config.services.mcp-lan-access.enable + then "http://${config.services.mcp-lan-access.host}:${toString port}/" + else "http://127.0.0.1:${toString port}/"; + timeout = 120; + }; + }; +} diff --git a/services/anki-mcp/seed-prefs.py b/services/anki-mcp/seed-prefs.py new file mode 100644 index 0000000..72e095b --- /dev/null +++ b/services/anki-mcp/seed-prefs.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Crée le prefs21.db d'Anki s'il n'existe pas encore. + +Anki considère `prefs21.db` absent comme « premier lancement » et affiche +alors un sélecteur de langue MODAL (aqt/__init__.py, `if firstTime: +pm.setDefaultLang()`), puis attend un clic. Sans écran, ce dialogue bloque +l'ouverture du profil : pas de collection, pas de serveur MCP. Les images +headless d'ankimcp contournent le problème en embarquant un prefs21.db +pré-configuré ; ici on le génère, ce qui reste lisible. + +Contenu minimal attendu par Anki (aqt/profiles.py) : une base sqlite avec une +table `profiles`, et une ligne `_global` dont la colonne `data` est un dict +picklé. Les clés sont celles de `metaConf`, avec `firstRun` à False et +`defaultLang` renseigné (c'est ce dernier qui évite le sélecteur). + +Le script est idempotent : il ne touche jamais un prefs21.db existant, donc +aucun risque d'écraser un réglage posé par Anki ensuite. +""" + +import os +import pickle +import random +import sqlite3 +import sys +import time + +LANG = os.environ.get("ANKI_LANG", "en_US") + + +def main() -> int: + path = sys.argv[1] + os.makedirs(os.path.dirname(path), exist_ok=True) + if os.path.exists(path): + return 0 + + meta = { + "ver": 0, + "updates": True, + "created": int(time.time()), + "id": random.randrange(0, 2**63), + "lastMsg": 0, + "suppressUpdate": False, + "firstRun": False, + "defaultLang": LANG, + } + + con = sqlite3.connect(path) + con.execute( + "create table if not exists profiles " + "(name text primary key collate nocase, data blob not null)" + ) + con.execute( + "insert or replace into profiles values ('_global', ?)", + (pickle.dumps(meta, protocol=4),), + ) + con.commit() + con.close() + print(f"prefs21.db amorcé ({path}, langue {LANG})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/arr-mcp/default.nix b/services/arr-mcp/default.nix index 7b2efe1..28e31b3 100644 --- a/services/arr-mcp/default.nix +++ b/services/arr-mcp/default.nix @@ -6,7 +6,11 @@ in { virtualisation.oci-containers.containers.arr-mcp = { image = "ghcr.io/bardesss/arr-mcp:latest"; - ports = [ "${toString port}:6060" ]; + # Port publié sur la loopback uniquement : l'accès LAN passe par le + # redirecteur filtré de services/mcp-lan-access, jamais par le container. + # (Avant, "6060:6060" publiait sur 0.0.0.0 et seul le firewall retenait + # l'instance non claimée.) + ports = [ "127.0.0.1:${toString port}:6060" ]; # Le serveur arr-mcp doit atteindre Radarr (8086), Sonarr (8006), # Jellyfin (8096) et Prowlarr (8087) en loopback. environment = { @@ -17,9 +21,14 @@ in { }; }; - # Hermes : connexion au serveur MCP arr-mcp (transport HTTP, loopback) + # Hermes : connexion au serveur MCP arr-mcp (transport HTTP). L'URL passe par + # le redirecteur LAN de services/mcp-lan-access pour être cliquable depuis + # Hermes Desktop sur le Mac ; côté tower, le trafic local repasse par lo. services.hermes-agent.mcpServers.arrmcp = { - url = "http://127.0.0.1:${toString port}/mcp"; + url = + if config.services.mcp-lan-access.enable + then "http://${config.services.mcp-lan-access.host}:${toString port}/mcp" + else "http://127.0.0.1:${toString port}/mcp"; timeout = 120; }; } diff --git a/services/default.nix b/services/default.nix index 33d6b65..98e50c2 100644 --- a/services/default.nix +++ b/services/default.nix @@ -49,10 +49,12 @@ # http://127.0.0.1:8642/p/discord-bridge/v1/chat/completions. ./eternal-terminal ./nextcloud-mcp + ./mcp-lan-access ./amazon-mcp ./hinge-auto ./firefox-mcp - # ./stalwart-mcp # FIXME: cargoHash mismatch, needs fix + # ./stalwart-mcp # importé par profiles/tower uniquement (voir le commentaire + # dans le module : sur anywhere la clé sops entre en conflit avec services/stalwart) ./gotify ./zitadel # ./zitadel-mcp # FIXME: secret zitadel_mcp_env manquant dans zitadel.yml @@ -66,7 +68,10 @@ ./openrouter-mcp ./reactive-resume ./anki-sync-server + ./anki-mcp ./winnie + ./wise-mcp + ./bank-mcp ./llm-steamdeck # ./kanidm # ./syncserver diff --git a/services/mcp-lan-access/default.nix b/services/mcp-lan-access/default.nix new file mode 100644 index 0000000..fcc9855 --- /dev/null +++ b/services/mcp-lan-access/default.nix @@ -0,0 +1,98 @@ +# services/mcp-lan-access/default.nix +# +# Rend cliquables depuis le Mac de marc les URLs des serveurs MCP HTTP qui +# écoutent en loopback sur tower (nextcloud 8710, anki 3141, arrmcp 6060, +# beeper 23373). Hermes Desktop affiche l'URL de `mcp_servers..url` telle +# quelle dans Capabilities -> MCP : en loopback, le clic part sur le localhost +# du Mac et ne peut pas répondre. +# +# Principe : un redirecteur TCP par port, qui écoute sur l'adresse LAN de tower +# et relaie vers 127.0.0.1. systemd-socket-proxyd est un proxy TCP pur : il ne +# touche pas aux en-têtes HTTP, donc les flux SSE / streamable HTTP des serveurs +# MCP passent intacts (pas de buffering nginx à régler). +# +# Sécurité : le firewall NixOS (backend iptables sur tower) n'accepte ces ports +# que depuis `clientAddress` (le Mac). Les ports ne vont JAMAIS dans +# allowedTCPPorts, et les services eux-mêmes restent sur la loopback. Le trafic +# de tower vers sa propre adresse LAN sort par lo (interface de confiance), donc +# Hermes continue de joindre les mêmes URLs sans règle supplémentaire. +# +# Le couple adresse/port est dupliqué dans les URLs MCP : changer `host` ici +# met à jour les quatre URLs du même coup (chaque module service lit cette +# option). Si wlan0 change d'adresse DHCP, mettre `host` à jour et rebuild. +{ config, lib, ... }: +let + cfg = config.services.mcp-lan-access; + + forwards = lib.mapAttrsToList (name: port: { inherit name port; }) cfg.endpoints; + + unitName = f: "mcp-lan-${f.name}"; +in { + options.services.mcp-lan-access = { + enable = lib.mkEnableOption "accès LAN filtré aux endpoints MCP loopback de tower"; + + host = lib.mkOption { + type = lib.types.str; + default = "192.168.1.44"; + description = '' + Adresse LAN de tower sur laquelle les redirecteurs écoutent. Sert aussi + de base aux URLs MCP des modules nextcloud-mcp, arr-mcp, anki-mcp et au + serveur beeper déclaré dans services/hermes. + ''; + }; + + clientAddress = lib.mkOption { + type = lib.types.str; + default = "192.168.1.48"; + description = "Seule adresse source que le firewall accepte sur ces ports."; + }; + + endpoints = lib.mkOption { + type = lib.types.attrsOf lib.types.port; + default = { }; + example = { + nextcloud = 8710; + anki = 3141; + }; + description = "Attribut nom -> port loopback à republier sur `host`."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.sockets = lib.listToAttrs (map (f: { + name = unitName f; + value = { + description = "Redirecteur LAN ${cfg.host}:${toString f.port} vers 127.0.0.1:${toString f.port}"; + wantedBy = [ "sockets.target" ]; + listenStreams = [ "${cfg.host}:${toString f.port}" ]; + # wlan0 est en DHCP : FreeBind crée le socket avant que l'adresse existe + # au boot ; il accepte dès que l'adresse est posée. Sans ça, le socket + # unit échoue en "Cannot assign requested address" au démarrage. + socketConfig.FreeBind = true; + }; + }) forwards); + + systemd.services = lib.listToAttrs (map (f: { + name = unitName f; + value = { + description = "Redirecteur LAN vers MCP ${f.name} (127.0.0.1:${toString f.port})"; + requires = [ "${unitName f}.socket" ]; + after = [ "${unitName f}.socket" ]; + serviceConfig = { + ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd --connections-max=64 127.0.0.1:${toString f.port}"; + DynamicUser = true; + PrivateTmp = true; + }; + }; + }) forwards); + + networking.firewall.extraCommands = lib.concatMapStrings (f: + "iptables -w -A nixos-fw -p tcp -s ${cfg.clientAddress} --dport ${toString f.port} -j nixos-fw-accept\n" + ) forwards; + + assertions = [{ + assertion = !(lib.any (f: lib.elem f.port config.networking.firewall.allowedTCPPorts) forwards); + message = "mcp-lan-access : ne pas mettre ces ports dans networking.firewall.allowedTCPPorts ; la règle filtrée par source de ce module suffit."; + }]; + }; +} diff --git a/services/nextcloud-mcp/default.nix b/services/nextcloud-mcp/default.nix index 1b1764f..b8f6d2a 100644 --- a/services/nextcloud-mcp/default.nix +++ b/services/nextcloud-mcp/default.nix @@ -43,5 +43,10 @@ in { # Hermes : transport HTTP vers le container loopback. # Les tools apparaissent préfixés mcp_nextcloud_* au démarrage d'hermes. - services.hermes-agent.mcpServers.nextcloud.url = "http://127.0.0.1:${toString port}/mcp"; + # URL LAN (module services/mcp-lan-access) : joignable depuis tower (trafic + # local par lo) et cliquable depuis Hermes Desktop sur le Mac du marc. + services.hermes-agent.mcpServers.nextcloud.url = + if config.services.mcp-lan-access.enable + then "http://${config.services.mcp-lan-access.host}:${toString port}/mcp" + else "http://127.0.0.1:${toString port}/mcp"; }