Files
nixos/services/hermes-webui/default.nix
T

166 lines
6.8 KiB
Nix

# services/hermes-webui/default.nix
# UI web de chat Hermes (projet communautaire github:nesquena/hermes-webui,
# input flake) tournant sur le profil « pocket », isolé du gateway Matrix.
#
# Chaîne : Pangolin (hermes.marcpartensky.com, SSO)
# -> newt (site tower)
# -> 127.0.0.1:9131 (nginx, réécrit Host: 127.0.0.1:9130)
# -> 127.0.0.1:9130 (services.hermes-webui).
#
# Le serveur tourne l'agent EN PROCESS (pas de gateway API) : il lit donc le
# HERMES_HOME qu'on lui donne. Deux pièges vérifiés le 25/09/2026 :
# 1. HERMES_HOME seul ne suffit PAS : sans HERMES_WEBUI_ISOLATED_PROFILE=1 le
# serveur retombe sur le profil par défaut et écrit dans le state.db
# racine (/var/lib/hermes/.hermes/state.db), partagé avec le gateway
# Matrix et les sessions CLI. Avec le flag, le profil actif devient bien
# pocket et seule sa propre base est touchée.
# 2. Il n'y a PAS de garde d'authentification sur une adresse loopback (elle
# ne s'active que sur une adresse non-loopback). C'est le SSO Pangolin qui
# protège l'accès, exactement comme terminal.marcpartensky.com. Ne jamais
# ouvrir 9130/9131 au firewall : le serveur expose aussi un PTY (/api/pty)
# et l'API complète (sessions, mémoire, fichiers) sans mot de passe.
{
config,
lib,
pkgs,
inputs,
...
}: let
profileDir = "/var/lib/hermes/.hermes/profiles/pocket";
backendPort = 9130;
proxyPort = 9131;
backendUrl = "http://127.0.0.1:${toString backendPort}";
backendHost = "127.0.0.1:${toString backendPort}";
in {
imports = [ inputs.hermes-webui.nixosModules.default ];
services.hermes-webui = {
enable = true;
# Paquet amont + patch local : le WebUI n'écrit son titre de session qu'à
# la fin d'un run (et jamais si le run est interrompu), donc la sidebar
# affiche le premier message brut pendant tout le run. Le moteur Hermes
# (hermes_state) a déjà le bon titre dans state.db quelques secondes après
# le premier message. core_title_adoption.py démarre un watchdog qui
# recopie ce titre dans le sidecar du WebUI (state.db ouvert en lecture
# seule, désactivable par HERMES_WEBUI_CORE_TITLE_ADOPTION=0).
# À retirer quand le comportement existe en amont dans l'input.
package = inputs.hermes-webui.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs (old: {
postInstall = (old.postInstall or "") + ''
chmod u+w $out/hermes-webui/api
install -m 0644 ${./core_title_adoption.py} $out/hermes-webui/api/core_title_adoption.py
install -m 0644 ${./api_init.py} $out/hermes-webui/api/__init__.py
'';
});
# même compte que le service hermes : il doit lire/écrire l'état du profil
user = "hermes";
group = "hermes";
host = "127.0.0.1";
port = backendPort;
# état du WebUI (sessions propres au WebUI, caches) : sous le profil, pas
# dans /var/lib/hermes-webui, pour rester dans le périmètre du profil et
# du ProtectSystem du service hermes si on l'y rattache un jour.
stateDir = "${profileDir}/webui";
hermesHome = profileDir;
# HERMES_WEBUI_PYTHON est déduit de passthru.hermesVenv du paquet agent :
# c'est cet interpréteur qui importe run_agent (vérifié : le bootstrap
# relance server.py avec lui). Pas de agent.dir explicite : la découverte
# par l'interpréteur trouve site-packages tout seul.
agent.package = config.services.hermes-agent.package;
# clés de provider du profil pocket (OPENROUTER_API_KEY, KIMI_API_KEY…).
# Le module refuse les clés WebUI protégées (HOST/PORT/STATE_DIR/...) ici.
environmentFiles = ["${profileDir}/.env"];
extraEnvironment = {
HERMES_WEBUI_ISOLATED_PROFILE = "1";
# cwd du serveur (le reste du store nix est en lecture seule)
HERMES_WEBUI_SERVER_CWD = "/var/lib/hermes";
# nginx réécrit Host: 127.0.0.1:9130 (cf. plus haut), donc le garde CSRF
# du backend (api/routes.py: _check_same_origin_browser_request) voit un
# Origin (https://hermes.marcpartensky.com) qui ne matche jamais
# le Host réécrit -> "Cross-origin mismatch". On déclare explicitement
# l'origine publique comme fiable plutôt que de faire confiance à
# X-Forwarded-Host (HERMES_WEBUI_TRUST_FORWARDED_HOST), Pangolin/newt
# n'étant pas garanti de le poser.
HERMES_WEBUI_ALLOWED_ORIGINS = "https://hermes.marcpartensky.com";
};
};
# Proxy local : le serveur du WebUI vérifie le Host, et nginx est le seul
# point d'entrée loopback. Même mécanique que services/hermes-dashboard.
services.nginx.virtualHosts."hermes-webui-proxy" = {
listen = [
{
addr = "127.0.0.1";
port = proxyPort;
}
];
locations."/" = {
proxyPass = backendUrl;
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host ${backendHost};
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
'';
};
};
# Ressource publique Pangolin, appliquée par le site qui porte le blueprint
# (donc tower) : `site` est omis volontairement, cf. services/newt.
# NB : cle `proxy-resources` + `protocol` (schema attendu par le Pangolin du
# VPS), cf. le commentaire detaille dans services/newt.
#
# Domaine : le WebUI est l'interface principale, il vit donc sur
# hermes.marcpartensky.com. hermes-mobile.marcpartensky.com est reserve a
# l'appli mobile (backend du profil pocket), ne pas le reprendre ici.
# ATTENTION : une ressource Pangolin manuelle (id 22, nom "hermes") occupe
# deja hermes.marcpartensky.com cote VPS (cible tower:10274, port mort).
# Tant qu'elle existe, deux routers Traefik viseront le meme host.
services.newt.blueprint.proxy-resources.hermes-webui = {
name = "hermes-webui";
protocol = "http";
full-domain = "hermes.marcpartensky.com";
auth.sso-enabled = true;
targets = [
{
hostname = "127.0.0.1";
port = proxyPort;
method = "http";
healthcheck = {
enabled = true;
hostname = "127.0.0.1";
port = proxyPort;
path = "/";
scheme = "http";
mode = "http";
method = "GET";
interval = 30;
unhealthy-interval = 30;
timeout = 5;
healthy-threshold = 1;
unhealthy-threshold = 3;
};
}
];
};
# Garde-fou : ces ports ne doivent jamais être ouverts sur le LAN.
assertions = [
{
assertion = !(lib.elem backendPort config.networking.firewall.allowedTCPPorts);
message = "hermes-webui : le port ${toString backendPort} (API + PTY sans mot de passe) ne doit pas être ouvert au firewall.";
}
{
assertion = !(lib.elem proxyPort config.networking.firewall.allowedTCPPorts);
message = "hermes-webui : le port ${toString proxyPort} (proxy du backend) ne doit pas être ouvert au firewall.";
}
];
}