feat(hermes-dashboard): terminal web hermes (Pangolin SSO)

Backend hermes-agent en mode dashboard (9120) + nginx host-rewrite (9121),
ressource newt terminal.marcpartensky.com. Ports jamais ouverts au firewall :
/api/pty est un shell.
This commit is contained in:
Hermes Agent
2026-09-26 18:14:41 -04:00
parent 355ed8ee0a
commit 67b4551b2d
3 changed files with 124 additions and 0 deletions
+4
View File
@@ -36,3 +36,7 @@ creation_rules:
key_groups:
- age:
- *tower
- path_regex: secrets/hermes-dashboard\.yml$
key_groups:
- age:
- *tower
+16
View File
@@ -0,0 +1,16 @@
hermes_dashboard_token: ENC[AES256_GCM,data:GwVYozAwOZshLyCeDmi3vPQuztguEkWJG3crRjxGc+S7kk/f3kzx6A==,iv:Kw2j5BW1UtrrSRquF0F3IrrRQDivZa6Sx/rzFQJlI1w=,tag:KYheaNQWU+eyPY+ndSSFNw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0RVdNM2VOOVRBQkhwM3Va
dmpueXduYmxTeGlhU0lrVWdBOCtjYlhhNmtZCm9GNmRiUVQ2a2I3OU5qeUNlR2wr
SWg4ZHpJTGVOV3dPME9lWFpCYk0zekEKLS0tIGZ1ckJmd1VaZkR2a3duOHBHZkx3
dUh1dmY4ZGhQc2tERVF5Vm44SUd0OTQKX75Br9KWCG3FfHAprPqGRrAkHeAyg96x
lSKGHHD6cehSJ+0r1UwzaPGROCM38VNCap2nzOoV/owMku0I/Z2DUA==
-----END AGE ENCRYPTED FILE-----
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
lastmodified: "2026-09-25T21:03:10Z"
mac: ENC[AES256_GCM,data:UnrAaaphA8XiJdQ22udJq0ucjWDJ5ToaZMVP1CTxyIFvR+l+zzQo0dmzA/onPX7NFpc1nRTvPR3vuFlbTrU1kzZib5+BOCT5bqCF9cQ4hSa9OPfXiyEIX/zWCbYVffntNzT93sESaKPsdW8MwbzEAvQ0fsugC7OBF0/+xsUZAGc=,iv:7H2t5wcLoiZLEY58GpNucg7RCsiMCId5zN69Z4UvgCM=,tag:JAB8B3uDPIxIFb5Gy88d3Q==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+104
View File
@@ -0,0 +1,104 @@
# services/hermes-dashboard/default.nix
# Terminal web + panneau d'admin de Hermes sur tower.
#
# Rien à packager : le backend est déjà dans l'input `hermes-agent` du flake.
# `hermes serve` et `hermes dashboard` sont le MÊME point d'entrée, à un flag
# près, et les deux exposent les sockets /api/ws et /api/pty. C'est /api/pty
# qui est le terminal web ; `dashboard` ajoute en plus l'interface navigateur
# sur le même port. Un seul des deux modes peut tourner par instance.
#
# Chaîne : Pangolin (terminal.marcpartensky.com, SSO)
# -> newt (site tower)
# -> 127.0.0.1:9121 (nginx, réécrit Host: 127.0.0.1:9120)
# -> 127.0.0.1:9120 (hermes-backend.service, mode dashboard).
#
# Le backend bindé en loopback ne déclenche PAS la garde d'authentification du
# dashboard (elle ne s'active que sur une adresse non-loopback) : c'est le SSO
# Pangolin qui protège l'accès, exactement comme la ressource noVNC de tower.
# Ne JAMAIS exposer 9120 ou 9121 au firewall : /api/pty est un shell.
#
# ⚠ Le PTY hérite du durcissement de l'unité hermes-backend (User=hermes).
# Sans le `NoNewPrivileges = false` posé dans services/hermes/default.nix, le
# shell obtenu n'a aucun sudo, pas même le nixos-rebuild NOPASSWD.
{
config,
lib,
...
}: let
backendPort = 9120;
proxyPort = 9121;
backendUrl = "http://127.0.0.1:${toString backendPort}";
backendHost = "127.0.0.1:${toString backendPort}";
in {
# Token de session stable : sans lui le backend en régénère un à chaque
# démarrage, que personne d'autre ne peut connaître (donc aucun client
# externe ne peut se rattacher à CE backend).
sops.secrets."hermes_dashboard_token" = {
sopsFile = ../../secrets/hermes-dashboard.yml;
key = "hermes_dashboard_token";
owner = config.services.hermes-agent.user;
group = config.services.hermes-agent.group;
mode = "0400";
};
services.hermes-agent.backend = {
mode = "dashboard";
host = "127.0.0.1";
port = backendPort;
sessionTokenFile = config.sops.secrets."hermes_dashboard_token".path;
};
# Proxy local : le backend refuse toute requête dont le Host diffère de
# l'adresse sur laquelle il a bindé (défense anti-DNS-rebinding), d'où la
# réécriture. Même mécanique que services/hermes-pocket.
services.nginx.virtualHosts."hermes-dashboard-proxy" = {
listen = [
{
addr = "127.0.0.1";
port = proxyPort;
}
];
# `/` couvre l'app web, /api/ws et /api/pty ; proxyWebsockets pose les
# en-têtes Upgrade/Connection dont le PTY a besoin.
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.
services.newt.blueprint.proxy-resources.hermes-terminal = {
name = "Terminal web Hermes";
protocol = "http";
full-domain = "terminal.marcpartensky.com";
auth.sso-enabled = true;
targets = [
{
hostname = "127.0.0.1";
port = proxyPort;
method = "http";
}
];
};
# Garde-fou : ces ports ne doivent jamais être ouverts sur le LAN.
assertions = [
{
assertion = !(lib.elem backendPort config.networking.firewall.allowedTCPPorts);
message = "hermes-dashboard : le port ${toString backendPort} (/api/pty = shell) ne doit pas être ouvert au firewall.";
}
{
assertion = !(lib.elem proxyPort config.networking.firewall.allowedTCPPorts);
message = "hermes-dashboard : le port ${toString proxyPort} (proxy du /api/pty) ne doit pas être ouvert au firewall.";
}
];
}