feat(mcp): Anki headless + acces LAN filtre aux MCP
anki-mcp : Anki sans bureau et addon AnkiMCP, HTTP MCP sur 3141, connecte a la collection du serveur de sync. mcp-lan-access republie sur l'adresse LAN de tower les endpoints loopback (3141, 8710, 6060, 23373), firewall limite au Mac ; l'addon Anki autorise ce Host (anti DNS-rebinding). arr-mcp ne publie plus 6060 sur 0.0.0.0. Le module mcp-lan-access est importe par services/default.nix avec anki-mcp, wise-mcp et bank-mcp.
This commit is contained in:
@@ -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)
|
||||
# ========================================================================
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"package": "ankimcp_sync_bootstrap",
|
||||
"name": "AnkiMCP sync bootstrap",
|
||||
"mod": 0,
|
||||
"conflicts": []
|
||||
}
|
||||
@@ -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 <base>/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 <profil> 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<nom>.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.";
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user