diff --git a/.sops.yaml b/.sops.yaml index 6f45260..33e6e03 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -36,3 +36,7 @@ creation_rules: key_groups: - age: - *tower + - path_regex: secrets/hermes-dashboard\.yml$ + key_groups: + - age: + - *tower diff --git a/secrets/hermes-dashboard.yml b/secrets/hermes-dashboard.yml new file mode 100644 index 0000000..52358e8 --- /dev/null +++ b/secrets/hermes-dashboard.yml @@ -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 diff --git a/services/hermes-dashboard/default.nix b/services/hermes-dashboard/default.nix new file mode 100644 index 0000000..bf89e28 --- /dev/null +++ b/services/hermes-dashboard/default.nix @@ -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."; + } + ]; +}