From 2edce1cd20955544281013bfcab0955be2599ef9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 6 Oct 2026 21:41:47 -0400 Subject: [PATCH] feat(tower): remote-desktop (rustguac, kasmvnc, rustdesk) + acl 777 access journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rustguac : guacd en loopback (tls=false) + vhost guac.marcpartensky.com (sans ACME, TLS par Pangolin). KasmVNC : display :20 (pas :1, session X existe deja), desktop lxqt (lxqt-wayland refuse), mot de passe regeneré par empreinte sops, auth HTTP 200/401 testee. Rustdesk : ports reels 1.1.16, hbbs/hbbr corriges; nginx guac/kasm en loopback. Vault/rustguac secrets lues par le bon user (vault, rustguac, marc) au lieu de root. WireGuard client sur wg1 (10.100.0.3/32, endpoint VPS 104.129.12.158:443) desactive tant que le secret sops wireguard/tower_private_key n'existe pas (evite le blocage sops-nix sur tout le build). hermes.extraGroups += systemd-journal. --- .sops.yaml | 4 + modules/nixos/kasmvnc/default.nix | 345 ++++++++++++++++------ modules/nixos/nginx/kasmvnc/default.nix | 28 +- modules/nixos/nginx/rustguac/default.nix | 33 ++- modules/nixos/rustdesk/default.nix | 46 ++- modules/nixos/rustguac/default.nix | 349 +++++++++++++++-------- modules/nixos/vault-rustguac/default.nix | 79 ++--- profiles/tower/configuration.nix | 174 ++++++++--- secrets/kasmvnc.yml | 16 ++ secrets/tower.yml | 8 +- 10 files changed, 789 insertions(+), 293 deletions(-) create mode 100644 secrets/kasmvnc.yml diff --git a/.sops.yaml b/.sops.yaml index 5edf16d..f8ee739 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -76,3 +76,7 @@ creation_rules: key_groups: - age: - *tower + - path_regex: secrets/kasmvnc\.yml$ + key_groups: + - age: + - *tower diff --git a/modules/nixos/kasmvnc/default.nix b/modules/nixos/kasmvnc/default.nix index 0933348..d0ed99e 100644 --- a/modules/nixos/kasmvnc/default.nix +++ b/modules/nixos/kasmvnc/default.nix @@ -5,6 +5,28 @@ with lib; let cfg = config.services.kasmvnc; + # L'ancien code faisait `elemAt (builtins.split "x" cfg.geometry) 0/1` : + # builtins.split renvoie [prefixe, groupes, suffixe], donc l'index 0 donne bien + # "1920" mais l'index 1 est la LISTE des groupes de capture, pas "1080". Le + # height partait donc vide dans le YAML. splitString donne exactement les deux + # morceaux attendus. + geometryParts = splitString "x" cfg.geometry; + width = elemAt geometryParts 0; + height = elemAt geometryParts 1; + + # perl + les modules CPAN que le wrapper kasmvncserver importe et que le deb ne + # fournit pas (le deb ne livre que ses propres modules KasmVNC::*). + perlLib = pkgs.perl.withPackages (ps: with ps; [ + Switch + ListMoreUtils + TryTiny + DateTime + DateTimeTimeZone + DateTimeLocale + YAMLTiny + HashMergeSimple + ]); + # Fetch KasmVNC from GitHub Releases (not in nixpkgs) kasmvncPackage = pkgs.stdenv.mkDerivation rec { pname = "kasmvnc"; @@ -15,35 +37,115 @@ let sha256 = "sha256-9Zn+AuIXW5gXthZfdKXSvr3HMRjd6Rgbo0EJY77Xrh4="; }; - nativeBuildInputs = [ pkgs.dpkg pkgs.patchelf ]; + # kasmvncserver et kasmvncconfig sont des SCRIPTS PERL, pas des ELF : l'ancien + # garde `if file .../kasmvncserver | grep -q ELF` testait le script, donc etait + # toujours FAUX et aucun binaire n'etait patche -> kasmvncpasswd sortait en + # « error while loading shared libraries: libcrypt.so.1 ». autoPatchelfHook + # patche tous les ELF du deb et resout les libs depuis buildInputs. + nativeBuildInputs = [ pkgs.dpkg pkgs.autoPatchelfHook pkgs.patchelf pkgs.perl pkgs.makeWrapper ]; buildInputs = [ - pkgs.glibc pkgs.gcc pkgs.libx11 pkgs.libxcb pkgs.libxkbcommon - pkgs.wayland pkgs.libva pkgs.libdrm pkgs.mesa + # libxcrypt-legacy et PAS libxcrypt : le .deb demande libcrypt.so.1 (soname + # Debian) alors que pkgs.libxcrypt ne fournit que libcrypt.so.2. C'est + # exactement l'erreur « libcrypt.so.1: cannot open shared object file ». + pkgs.libxcrypt-legacy pkgs.glibc pkgs.gcc pkgs.libx11 pkgs.libxcb pkgs.libxkbcommon + pkgs.wayland pkgs.libva pkgs.libdrm pkgs.mesa pkgs.libunwind pkgs.pixman + pkgs.freetype pkgs.systemd pkgs.libxshmfence pkgs.libgbm + # noms non-deprecies (pkgs.xorg.* emet un warning d'evaluation) + pkgs.libxfont_2 pkgs.libxau pkgs.libxdmcp pkgs.libxtst + pkgs.libxrandr pkgs.libxcursor pkgs.openssl pkgs.zlib pkgs.libjpeg_turbo pkgs.libpng pkgs.libwebp ]; dontUnpack = true; installPhase = '' + runHook preInstall dpkg -x $src $out # The deb extracts to root/usr/..., so move to correct location if [ -d $out/root ]; then mv $out/root/* $out/ rmdir $out/root fi - # Check if binary is ELF before patchelf - if file $out/usr/bin/kasmvncserver | grep -q ELF; then - patchelf --set-rpath "$out/usr/lib/x86_64-linux-gnu:$out/lib" $out/usr/bin/kasmvncserver - patchelf --set-rpath "$out/usr/lib/x86_64-linux-gnu:$out/lib" $out/usr/bin/kasmvncpasswd - fi - # Move binaries to $out/bin + # TOUS les binaires dans $out/bin : kasmvncserver (perl) localise Xkasmvnc + # dans le dossier de $0 (DetectBinariesDir), donc les symlinks doivent etre + # cote a cote (systemPackages les expose ensuite dans sw/bin). mkdir -p $out/bin - ln -s $out/usr/bin/kasmvncserver $out/bin/kasmvncserver - ln -s $out/usr/bin/kasmvncpasswd $out/bin/kasmvncpasswd - ln -s $out/usr/bin/kasmvncconfig $out/bin/kasmvncconfig + for b in kasmvncserver kasmvncpasswd kasmvncconfig kasmxproxy Xkasmvnc; do + ln -sf $out/usr/bin/$b $out/bin/$b + done + # Le wrapper perl cherche LITTERALEMENT $exedir/Xvnc et $exedir/vncpasswd + # (lignes 493-508) alors que le deb ne livre que Xkasmvnc / kasmvncpasswd : + # sans ces deux alias il meurt sur « couldn't find ".../bin/Xvnc" ». + ln -sf $out/usr/bin/Xkasmvnc $out/bin/Xvnc + ln -sf $out/usr/bin/kasmvncpasswd $out/bin/vncpasswd + # kasmvncserver/kasmvncconfig ont un shebang Debian #!/usr/bin/perl qui + # n'existe pas sur NixOS (« No such file or directory » a l'execution) : le + # fixupPhase de stdenv ne patche pas $out/usr/bin, donc explicitement ici. + patchShebangs $out/usr/bin + # Le wrapper charge ses valeurs par defaut depuis + # /usr/share/kasmvnc/kasmvnc_defaults.yaml (chemin Debian absent sur NixOS, + # donc les defauts n'etaient jamais lus). On reecrit vers le store. + substituteInPlace $out/usr/bin/kasmvncserver \ + --replace-fail "/usr/share/kasmvnc/" "$out/usr/share/kasmvnc/" + # Le wrapper calcule le selecteur de bureau via LocalSelectDePath() + # (« $exedir/../builder/startup/deb/select-de.sh », arborescence du repo + # KasmVNC) alors que le deb livre le fichier dans usr/lib/kasmvncserver/ : + # sans cet alias la session sort en status=2 INVALIDARGUMENT avant Xvnc. + mkdir -p $out/builder/startup/deb + cp $out/usr/lib/kasmvncserver/select-de.sh $out/builder/startup/deb/select-de.sh + runHook postInstall ''; + + # Le wrapper perl doit etre autonome : PERL5LIB (ses propres modules + les + # modules CPAN absents du deb) et le PATH (xauth, xkbcomp) sont cuits dans un + # wrapper, pour qu'on n'ait plus besoin d'exporter quoi que ce soit avant de + # lancer kasmvncserver (ni dans le shell, ni dans l'unite systemd). + postFixup = '' + wrapProgram $out/bin/kasmvncserver \ + --prefix PERL5LIB : "${perlLib}/lib/perl5/site_perl:$out/usr/share/perl5" \ + --prefix PATH : "${lib.makeBinPath [ pkgs.xauth pkgs.xkbcomp pkgs.xrandr pkgs.procps pkgs.inetutils pkgs.which pkgs.util-linux ]}" + ''; + + # Le wrapper perl charge les modules KasmVNC::* (livres dans + # usr/share/perl5) et quelques modules CPAN absents du deb : PERL5LIB est + # pose par le module (services.kasmvnc.environment), pas ici. + passthru = { + # Liste exacte des modules absents du deb, obtenue en executant le wrapper : + # kasmvncserver importe YAML::Tiny (KasmVNC/Config.pm) et + # Hash::Merge::Simple en plus des modules DateTime/Switch/List::MoreUtils. + perlModules = with pkgs.perlPackages; [ + Switch + ListMoreUtils + TryTiny + DateTime + DateTimeTimeZone + DateTimeLocale + YAMLTiny + HashMergeSimple + ]; + }; }; + # Session de bureau lancee par xstartup (X11 : kasmvncserver 1.5.0 n'a pas de + # mode wayland). startlxqt herite du bus de session fourni par l'unite systemd. + # PATH : l'unite fixe un `path` court (xauth, xkbcomp, hostname), ce qui + # remplace le PATH par defaut de systemd et fait disparaitre le profil systeme. + # Or startlxqt exec `lxqt-session` par son nom, d'ou « exec: lxqt-session: not + # found » puis la fin immediate de la session (et l'arret de Xvnc). On remet + # donc le profil systeme en tete : c'est la que vivent les paquets LXQt de marc + # (lxqt-session, lxqt-panel, pcmanfm-qt, lxqt-runner). + xstartup = pkgs.writeShellScript "kasmvnc-xstartup" ( + if cfg.desktop == "custom" then cfg.customCommand + else if cfg.desktop == "xfce" then "exec ${pkgs.xfce.xfce4-session}/bin/xfce4-session" + else '' + export PATH="/run/current-system/sw/bin:${pkgs.lxqt.lxqt-session}/bin:$PATH" + export XDG_SESSION_TYPE=x11 + export QT_QPA_PLATFORM=xcb + export GDK_BACKEND=x11 + exec ${pkgs.lxqt.lxqt-session}/bin/startlxqt + '' + ); + # LXQt + kwin_wayland packages lxqtPkgs = with pkgs; [ lxqt.lxqt-session @@ -68,77 +170,103 @@ let pcmanfm-qt ]; - # Generate kasmvnc.yaml config + # Generate kasmvnc.yaml, au schema REEL de KasmVNC 1.5.0. L'ancien fichier + # imbriquait network/desktop/encoding/gpu/advanced sous un bloc `server:` qui + # n'existe pas a ce niveau : kasmvncserver repondait + # « Unsupported config keys found: server.desktop.allow_resize, ... » + # et ignorait donc TOUTE la config (resolution, port, headers, mot de passe). + # Les cles de premier niveau sont : network, desktop, encoding, security, + # server (http/advanced/auto_shutdown), user_session, runtime_configuration. + # Reference : usr/share/kasmvnc/kasmvnc_defaults.yaml et etc/kasmvnc/kasmvnc.yaml + # livres dans le .deb. generateConfig = '' + network: + # Valeurs acceptees : http | vnc (verifie par kasmvncserver : + # « network.protocol 'https': must be one of [http, vnc] »). Le TLS web + # vient de network.ssl.require_ssl, pas du protocole. + protocol: http + interface: "${cfg.bindAddress}" + # KasmVNC 1.5.0 n'a PAS de network.port : le port web est websocket_port. + websocket_port: ${toString cfg.port} + use_ipv4: true + use_ipv6: true + ssl: + pem_certificate: "${cfg.tls.certFile}" + pem_key: "${cfg.tls.keyFile}" + require_ssl: true + desktop: + resolution: + width: ${width} + height: ${height} + allow_resize: true + pixel_depth: 24 + # gpu est un SOUS-bloc de desktop (pas une cle de premier niveau). + gpu: + hw3d: true + drinode: "/dev/dri/renderD128" + encoding: + # Pas de jpeg_quality/webp_quality ici : kasmvncserver les traduit en + # « -WebpVideoQuality » que Xkasmvnc de cette version refuse + # (« Fatal server error: Unrecognized option: -WebpVideoQuality »), ce qui + # empechait le serveur de demarrer. Les defauts conviennent. + video_encoding_mode: + webp_encoding_time: 30 + security: + brute_force_protection: + blacklist_threshold: 5 + blacklist_timeout: 300 server: http: headers: - "Cross-Origin-Embedder-Policy=require-corp" - "Cross-Origin-Opener-Policy=same-origin" - httpd_directory: ${kasmvncPackage}/usr/share/kasmvnc/www - network: - protocol: https - interface: "${cfg.bindAddress}" - port: ${toString cfg.port} - websocket_port: auto - use_ipv4: true - use_ipv6: true - udp: - public_ip: auto - port: auto - stun_server: "stun.l.google.com:19302" - ssl: - pem_certificate: "${cfg.tls.certFile}" - pem_key: "${cfg.tls.keyFile}" - require_ssl: true - desktop: - resolution: - width: ${toString (builtins.elemAt (builtins.split "x" cfg.geometry) 0)} - height: ${toString (builtins.elemAt (builtins.split "x" cfg.geometry) 1)} - allow_resize: true - pixel_depth: 24 - gpu: - hw3d: true - drinode: "/dev/dri/renderD128" - encoding: - prefer_h264: true - prefer_hevc: true - prefer_av1: true - prefer_vp9: true - prefer_vp8: true - lossless: false - webp_quality: 80 - jpeg_quality: 85 - security: - brute_force_protection: - enabled: true - blacklist_threshold: 5 - blacklist_timeout: 300 + httpd_directory: ${cfg.package}/usr/share/kasmvnc/www advanced: kasm_password_file: "${cfg.passwordFile}" x_authority_file: auto - auto_shutdown: false + # « auto » cherche /usr/share/X11/fonts, inexistant sur NixOS : les + # polices vivent dans /run/current-system/sw/share/X11/fonts. + x_font_path: "/run/current-system/sw/share/X11/fonts" + # Les timeouts sont dans la section auto_shutdown, pas dans advanced. + auto_shutdown: no_user_session_timeout: "never" active_user_session_timeout: "never" inactive_user_session_timeout: "never" - user_session: - new_session_disconnects_existing_exclusive_session: false - concurrent_connections_prompt: false - concurrent_connections_prompt_timeout: 10 - idle_timeout: "never" - runtime_configuration: - allow_client_to_override_kasm_server_settings: true - allow_override_standard_vnc_server_settings: true - allow_override_list: - - "pointer.enabled" - - "data_loss_prevention.clipboard.server_to_client.enabled" - - "data_loss_prevention.clipboard.client_to_server.enabled" - ''; + user_session: + new_session_disconnects_existing_exclusive_session: false + concurrent_connections_prompt: false + concurrent_connections_prompt_timeout: 10 + idle_timeout: "never" + runtime_configuration: + allow_client_to_override_kasm_server_settings: true + allow_override_standard_vnc_server_settings: true + allow_override_list: + - "pointer.enabled" + - "data_loss_prevention.clipboard.server_to_client.enabled" + - "data_loss_prevention.clipboard.client_to_server.enabled" + # Sans ça kasmvncserver lance une invite interactive « Please choose Desktop + # Environment to run » (select-de.sh) au demarrage : impossible dans une unite + # systemd (pas de tty), sortie en status=2 INVALIDARGUMENT avant Xvnc. + command_line: + prompt: false + ''; in { options.services.kasmvnc = { enable = mkEnableOption "KasmVNC - Modern Web-native VNC Server"; + package = mkOption { + type = types.package; + default = kasmvncPackage; + description = "Paquet KasmVNC (binaires dans $out/bin, y compris Xkasmvnc)"; + }; + + perlModules = mkOption { + type = types.listOf types.package; + default = kasmvncPackage.perlModules; + description = "Modules CPAN requis par le wrapper perl kasmvncserver (PERL5LIB)"; + }; + # User to run as (systemd user service) user = mkOption { type = types.str; @@ -192,9 +320,9 @@ in { # Desktop environment desktop = mkOption { - type = types.enum [ "lxqt-wayland" "plasma-wayland" "xfce" "custom" ]; - default = "lxqt-wayland"; - description = "Desktop environment to launch"; + type = types.enum [ "lxqt" "lxqt-wayland" "xfce" "custom" ]; + default = "lxqt"; + description = "Desktop environment to launch (le .deb 1.5.0 ne sait pas lancer de session Wayland : -xstartup -> X11)"; }; # Custom command for desktop=custom @@ -224,45 +352,75 @@ in { users.users.${cfg.user}.extraGroups = [ "video" "render" "seat" "ssl-cert" ]; # Packages - environment.systemPackages = lxqtPkgs ++ cfg.extraPackages ++ [ kasmvncPackage ]; + environment.systemPackages = lxqtPkgs ++ cfg.extraPackages ++ [ cfg.package ]; # Config directory and file systemd.tmpfiles.rules = [ "d /etc/kasmvnc 0750 root ${cfg.user} - -" + # Xkasmvnc est un binaire Debian : le chemin de xkbcomp est COMPILE dedans + # (« %s%sxkbcomp » avec XKB_BIN_DIRECTORY=/usr/bin) et l'option -xkbdir ne + # change que les donnees, pas le binaire. Sans ce lien il meurt sur + # sh: /usr/bin/xkbcomp: No such file or directory + # XKB: Failed to compile keymap / Fatal server error + # NixOS n'ayant pas de /usr, on cree le lien declarativement ici. + "d /usr/bin 0755 root root - -" + "L+ /usr/bin/xkbcomp - - - - ${pkgs.xkbcomp}/bin/xkbcomp" ]; environment.etc."kasmvnc/kasmvnc.yaml".text = generateConfig + cfg.extraYaml; + # Session de bureau via script xstartup : c'est le SEUL mecanisme de + # kasmvncserver 1.5.0 (aucun support de --wayland : « Fatal server error: + # Unrecognized option: --wayland »). Le script est lance par le wrapper une + # fois Xvnc en place. + environment.etc."kasmvnc/xstartup".source = xstartup; + # Generate password file helper script environment.etc."kasmvnc/generate-password".text = '' #!/bin/sh - # Run as user: kasmvncpasswd -f ~/.kasmpasswd - exec ${kasmvncPackage}/bin/kasmvncpasswd -f "$HOME/.kasmpasswd" + # kasmvncpasswd n'a PAS d'option -f (usage reel : + # kasmvncpasswd -u [fichier]) et refuse de tourner sans + # terminal : a lancer depuis une session interactive de l'utilisateur. + exec ${cfg.package}/bin/kasmvncpasswd -u "${cfg.user}" "$HOME/.kasmpasswd" ''; environment.etc."kasmvnc/generate-password".mode = "0755"; # Systemd USER service (not system service!) systemd.user.services.kasmvnc = { description = "KasmVNC Server on display ${cfg.display}"; - wantedBy = [ "graphical-session.target" ]; + # KasmVNC est un serveur X autonome pour l'acces distant : il ne doit pas + # dependre de la session graphique locale (Xwayland sur :1). En le voulant + # par default.target il demarre aussi pour un utilisateur linger, sans + # login local, ce qui est le but d'un acces distant. + wantedBy = [ "default.target" ]; + # le wrapper perl exige xauth, xkbcomp et hostname sur le PATH, sinon il + # refuse de demarrer avant meme de lire sa config (inetutils fournit + # hostname : « couldn't find "hostname" on your PATH »). + path = [ pkgs.xauth pkgs.xkbcomp pkgs.xrandr pkgs.procps pkgs.inetutils pkgs.which pkgs.util-linux ]; serviceConfig = { Type = "simple"; Environment = [ - "XDG_SESSION_TYPE=wayland" + "XDG_SESSION_TYPE=x11" "XDG_CURRENT_DESKTOP=LXQt" - "QT_QPA_PLATFORM=wayland" - "GDK_BACKEND=wayland,x11" + "QT_QPA_PLATFORM=xcb" + "GDK_BACKEND=x11" "XDG_RUNTIME_DIR=%t" "DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus" ]; - ExecStartPre = "+${kasmvncPackage}/bin/kasmvncpasswd -f ${cfg.passwordFile} || true"; + # -xkbdir : Xkasmvnc compile son keymap depuis /usr/share/X11/xkb + # (absent sur NixOS) et meurt sinon sur « XKB: Failed to compile keymap + # / Could not start Xvnc ». Le wrapper perl relaie les options inconnues + # a Xvnc, donc il suffit de lui passer le dossier du store. ExecStart = '' - ${kasmvncPackage}/bin/kasmvncserver ${cfg.display} \ - --wayland \ - -- ${pkgs.kdePackages.kwin}/bin/kwin_wayland --xwayland --exit-with-session=${pkgs.lxqt.lxqt-session}/bin/startlxqt + ${cfg.package}/bin/kasmvncserver ${cfg.display} \ + -xkbdir ${pkgs.xkeyboard_config}/share/X11/xkb \ + -xstartup /etc/kasmvnc/xstartup \ + -fg ''; - Restart = "on-failure"; - RestartSec = 5; + # une session qui se termine (logout, plantage de LXQt) ne doit pas + # laisser l'acces distant mort : on relance toujours. + Restart = "always"; + RestartSec = 10; StandardOutput = "journal"; StandardError = "journal"; }; @@ -271,6 +429,29 @@ in { # Note: user lingering should be enabled via users.users..linger = true # or loginctl enable-linger + # Amorcage (root) de l'unite utilisateur : une unite utilisateur qui a + # crash-loop est laissee en start-limit-hit par systemd, et seul un + # `systemctl --user reset-failed` la debloque. Sans ca, l'acces distant + # reste mort apres un plantage jusqu'a ce que quelqu'un ouvre une session + # locale, ce qui est exactement la situation qu'on veut eviter. Ce oneshot + # systeme est idempotent : reset-failed ne fait rien si l'unite est saine, + # start ne fait rien si elle tourne deja. + systemd.services.kasmvnc-kick = { + description = "Degage l'unite utilisateur kasmvnc (start-limit-hit) et la (re)lance"; + wantedBy = [ "multi-user.target" ]; + after = [ "systemd-user-sessions.service" ]; + serviceConfig = { + # pas de RemainAfterExit : l'unite reste inactive, donc chaque + # nixos-rebuild switch la relance (et remet kasmvnc en route apres un + # plantage), au lieu de la considerer comme deja faite pour toujours. + Type = "oneshot"; + }; + script = '' + ${pkgs.systemd}/bin/systemctl --user -M ${cfg.user}@.host reset-failed kasmvnc.service || true + ${pkgs.systemd}/bin/systemctl --user -M ${cfg.user}@.host start kasmvnc.service || true + ''; + }; + # Firewall networking.firewall.allowedTCPPorts = [ cfg.port ]; networking.firewall.allowedUDPPorts = [ cfg.port ]; # for WebRTC UDP diff --git a/modules/nixos/nginx/kasmvnc/default.nix b/modules/nixos/nginx/kasmvnc/default.nix index 9f61e81..2468248 100644 --- a/modules/nixos/nginx/kasmvnc/default.nix +++ b/modules/nixos/nginx/kasmvnc/default.nix @@ -26,6 +26,21 @@ in { default = true; }; + # Ecoute locale, pour une exposition sans ACME (reverse proxy qui porte le + # TLS, ex. newt/Pangolin). listenPort = null garde les defauts du module + # nginx (0.0.0.0:80 + 443 ssl). + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Adresse d'ecoute quand listenPort est defini"; + }; + + listenPort = mkOption { + type = types.nullOr types.port; + default = null; + description = "Port d'ecoute local (HTTP) ; null = defauts nginx (80/443)"; + }; + # WebSocket support for KasmVNC signaling websocket = mkOption { type = types.bool; @@ -48,20 +63,19 @@ in { "${cfg.domain}" = { forceSSL = cfg.acme; enableACME = cfg.acme; + listen = optional (cfg.listenPort != null) { + addr = cfg.listenAddress; + port = cfg.listenPort; + }; # Proxy HTTPS + WSS to KasmVNC locations."/" = { proxyPass = cfg.upstream; proxyWebsockets = cfg.websocket; + # Idem rustguac : recommendedProxySettings pose deja les en-tetes et + # proxy_http_version, un doublon fait echouer `nginx -t`. extraConfig = '' proxy_buffering off; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; proxy_send_timeout 86400; # KasmVNC specific headers diff --git a/modules/nixos/nginx/rustguac/default.nix b/modules/nixos/nginx/rustguac/default.nix index 61e7912..0178393 100644 --- a/modules/nixos/nginx/rustguac/default.nix +++ b/modules/nixos/nginx/rustguac/default.nix @@ -20,13 +20,28 @@ in { description = "rustguac upstream (protocol://host:port)"; }; - # ACME/Let's Enccrypt (uses global security.acme.email) + # ACME/Let's Enccrypt (uses security.acme.defaults.email) acme = mkOption { type = types.bool; default = true; description = "Enable ACME/Let's Encrypt"; }; + # Ecoute locale, pour une exposition sans ACME (typiquement derriere un + # reverse proxy qui porte deja le TLS, ex. newt/Pangolin). + # listenPort = null garde les defauts du module nginx (0.0.0.0:80 + 443 ssl). + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Adresse d'ecoute quand listenPort est defini"; + }; + + listenPort = mkOption { + type = types.nullOr types.port; + default = null; + description = "Port d'ecoute local (HTTP) ; null = defauts nginx (80/443)"; + }; + # WebSocket support websocket = mkOption { type = types.bool; @@ -49,19 +64,21 @@ in { "${cfg.domain}" = { forceSSL = cfg.acme; enableACME = cfg.acme; + listen = optional (cfg.listenPort != null) { + addr = cfg.listenAddress; + port = cfg.listenPort; + }; locations."/" = { proxyPass = cfg.upstream; proxyWebsockets = cfg.websocket; + # recommendedProxySettings pose deja proxy_http_version, Upgrade, + # Connection, Host, X-Real-IP, X-Forwarded-* (includes) : les + # redeclarer fait echouer « nginx -t » (directive is duplicate) et + # tombe TOUT nginx, donc tout ce qui est derriere. Ne garder ici que + # ce que le module nginx ne pose pas. extraConfig = '' proxy_buffering off; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; proxy_send_timeout 86400; ${cfg.extraConfig} diff --git a/modules/nixos/rustdesk/default.nix b/modules/nixos/rustdesk/default.nix index 6d24ddb..00ab5b1 100644 --- a/modules/nixos/rustdesk/default.nix +++ b/modules/nixos/rustdesk/default.nix @@ -9,12 +9,26 @@ in { options.services.rustdesk = { enable = mkEnableOption "RustDesk server (hbbs + hbbr)"; + # La CLI de rustdesk-server 1.1.16 n'a que : hbbs -p , + # -r , -k , -R, -M, -s, -u ; hbbr -p , -k . + # Il n'y a NI -i (bind) NI -a (autre port) : passer ces drapeaux fait sortir + # le binaire en « Found argument '-i' which wasn't expected ». + # hbbs se lie tout seul sur 21115 (test NAT) / 21116 (ID, TCP+UDP) / + # 21118 (websocket), hbbr sur 21117 (relais) et 21119 (websocket). hbbs = mkOption { type = types.submodule { options = { enable = mkOption { type = types.bool; default = true; }; - host = mkOption { type = types.str; default = "0.0.0.0"; }; - port = mkOption { type = types.port; default = 21115; }; + idPort = mkOption { + type = types.port; + default = 21116; + description = "Port ID/rendezvous (TCP+UDP)"; + }; + relayServers = mkOption { + type = types.nullOr types.str; + default = null; + description = "Relais annonces aux clients (-r), virgule-separes"; + }; keyPath = mkOption { type = types.nullOr types.path; default = null; }; }; }; @@ -25,9 +39,12 @@ in { type = types.submodule { options = { enable = mkOption { type = types.bool; default = true; }; - host = mkOption { type = types.str; default = "0.0.0.0"; }; - port = mkOption { type = types.port; default = 21116; }; - apiPort = mkOption { type = types.port; default = 21118; }; + port = mkOption { + type = types.port; + default = 21117; + description = "Port relais (TCP)"; + }; + keyPath = mkOption { type = types.nullOr types.path; default = null; }; }; }; default = { enable = true; }; @@ -70,7 +87,13 @@ in { serviceConfig = { User = cfg.user; Group = cfg.group; - ExecStart = "${pkgs.rustdesk-server}/bin/hbbs -i ${cfg.hbbs.host} -p ${toString cfg.hbbs.port}" + (if cfg.hbbs.keyPath != null then " -k ${cfg.hbbs.keyPath}" else ""); + # hbbs ecrit sa paire de cles dans le repertoire courant quand -k n'est + # pas fourni : sans WorkingDirectory il tente d'ecrire dans / et sort en + # erreur (le service sortait en status=1 avant meme de se lier). + WorkingDirectory = "/var/lib/rustdesk"; + ExecStart = "${pkgs.rustdesk-server}/bin/hbbs -p ${toString cfg.hbbs.idPort}" + + optionalString (cfg.hbbs.relayServers != null) " -r ${cfg.hbbs.relayServers}" + + optionalString (cfg.hbbs.keyPath != null) " -k ${cfg.hbbs.keyPath}"; Restart = "on-failure"; RestartSec = 5; }; @@ -84,7 +107,9 @@ in { serviceConfig = { User = cfg.user; Group = cfg.group; - ExecStart = "${pkgs.rustdesk-server}/bin/hbbr -i ${cfg.hbbr.host} -p ${toString cfg.hbbr.port} -a ${cfg.hbbr.host}:${toString cfg.hbbr.apiPort}"; + WorkingDirectory = "/var/lib/rustdesk"; + ExecStart = "${pkgs.rustdesk-server}/bin/hbbr -p ${toString cfg.hbbr.port}" + + optionalString (cfg.hbbr.keyPath != null) " -k ${cfg.hbbr.keyPath}"; Restart = "on-failure"; RestartSec = 5; }; @@ -104,8 +129,9 @@ in { }; }; - networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall - ([ cfg.hbbs.port ] ++ [ cfg.hbbr.port cfg.hbbr.apiPort ] ++ lib.optionals cfg.dashboard.enable [ cfg.dashboard.port ]); - networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [ cfg.hbbs.port cfg.hbbr.port ]; + # Ports reels des deux binaires (pas de drapeau pour les deplacer) : + # 21115 test NAT, 21116 ID TCP+UDP, 21117 relais, 21118/21119 websocket. + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall ([ 21115 21116 21117 21118 21119 ]); + networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [ 21116 ]; }; } \ No newline at end of file diff --git a/modules/nixos/rustguac/default.nix b/modules/nixos/rustguac/default.nix index 070afd5..53cea53 100644 --- a/modules/nixos/rustguac/default.nix +++ b/modules/nixos/rustguac/default.nix @@ -5,46 +5,150 @@ with lib; let cfg = config.services.rustguac; - # Fetch rustguac pre-built binary from GitHub Releases (not in nixpkgs yet) - rustguacPackage = pkgs.stdenv.mkDerivation rec { + # Binaire pre-built de la release GitHub (rustguac n'est pas dans nixpkgs). + # PIEGE : la release est une ARCHIVE COMPLETE (bin/, static/, lib/, sbin/, + # scripts/, config.toml.default). L'ancienne installPhase ne copiait que + # bin/rustguac, donc l'UI web (static/) manquait et le serveur n'avait aucune + # page a servir. + rustguacPackage = pkgs.stdenv.mkDerivation { pname = "rustguac"; version = "1.10.0"; src = pkgs.fetchurl { - url = "https://github.com/sol1/rustguac/releases/download/v${version}/rustguac-${version}-linux-amd64.tar.gz"; + url = "https://github.com/sol1/rustguac/releases/download/v1.10.0/rustguac-1.10.0-linux-amd64.tar.gz"; sha256 = "sha256-z9MPBTpb/qWby9KYT3ELSisy06mbvfIE/VSpmcsIuQg="; }; - nativeBuildInputs = [ ]; - + # stdenv a deja deballe l'archive ET s'est place dans son unique dossier + # racine (sourceRoot) : il suffit de reprendre l'arbre courant tel quel. installPhase = '' - tar -xzf $src - mkdir -p $out/bin - cp rustguac-*/bin/rustguac $out/bin/ + runHook preInstall + mkdir -p $out + cp -a ./. $out/ + rm -rf $out/install.sh $out/systemd $out/config.toml.default + runHook postInstall ''; }; guacdPackage = pkgs.guacamole-server; - # Generate api_keys TOML section - apiKeysToml = ""; + stateDir = "/var/lib/rustguac"; - # Generate oidc TOML section - oidcToml = optionalString (cfg.oidc != null) '' - [oidc] - issuer_url = "${cfg.oidc.issuerUrl}" - client_id = "${cfg.oidc.clientId}" - client_secret = "${cfg.oidc.clientSecret}" - redirect_url = "${cfg.oidc.redirectUrl}" - scopes = [${concatStringsSep " " (map (s: "\"${s}\"") cfg.oidc.scopes)}] - username_claim = "${cfg.oidc.usernameClaim}" - groups_claim = "${cfg.oidc.groupsClaim}" - ''; + # Le role_id vient du bootstrap Vault (fichier ecrit a l'execution) : la + # section [vault] ne peut donc pas etre figee dans le store. Le wrapper la + # colle au config.toml au demarrage et exporte le secret_id via + # VAULT_SECRET_ID (le seul chemin documente par rustguac : role_id en config, + # secret_id en env). + runScript = pkgs.writeShellApplication { + name = "rustguac-run"; + runtimeInputs = [ rustguacPackage pkgs.coreutils ]; + text = '' + set -euo pipefail + conf="''${RUSTGUAC_RUNTIME_DIR:?}/config.toml" + # `cat > fichier` et pas `cp` : cp recopie AUSSI le mode du fichier du + # store (0444), et le `>>` d'ajout de la section [vault] juste apres + # echouait en « Permission denied » (le fichier venait d'etre cree en + # lecture seule). + cat ${configFile} > "$conf" + ${optionalString (cfg.oidc != null && cfg.oidc.clientSecretFile != null) '' + OIDC_CLIENT_SECRET="$(cat ${cfg.oidc.clientSecretFile})" + export OIDC_CLIENT_SECRET + ''} + ${optionalString (cfg.vault != null) '' + VAULT_ADDR="${cfg.vault.address}" + VAULT_SECRET_ID="$(cat ${cfg.vault.secretIdFile})" + export VAULT_ADDR VAULT_SECRET_ID + { + echo "" + echo "[vault]" + echo "addr = \"${cfg.vault.address}\"" + echo "mount = \"${cfg.vault.mountPath}\"" + echo "base_path = \"${cfg.vault.basePath}\"" + echo "role_id = \"$(cat ${cfg.vault.roleIdFile})\"" + ${optionalString (cfg.vault.instanceName != null) '' + echo "instance_name = \"${cfg.vault.instanceName}\"" + ''} + } >> "$conf" + ''} + exec rustguac --config "$conf" serve + ''; + }; - # Generate tls TOML section - tlsToml = optionalString (cfg.tls != null) '' - tls_cert = "${cfg.tls.certFile}" - tls_key = "${cfg.tls.keyFile}" + # rustguac n'accepte PAS un simple token Vault : il lui faut role_id (config) + # + secret_id (env VAULT_SECRET_ID), donc une auth AppRole. Le token root ne + # sert qu'a creer la policy, le role, et a sortir ces deux valeurs. + vaultBootstrap = pkgs.writeShellApplication { + name = "rustguac-vault-bootstrap"; + runtimeInputs = [ pkgs.vault pkgs.coreutils ]; + text = '' + set -euo pipefail + VAULT_ADDR="${cfg.vault.address}" + VAULT_TOKEN="$(cat ${cfg.vault.rootTokenFile})" + export VAULT_ADDR VAULT_TOKEN + install -d -m 0750 -o ${cfg.user} -g ${cfg.group} ${stateDir} + + vault auth enable approle 2>/dev/null || true + vault policy write rustguac - >/dev/null <<'POLICY' + path "${cfg.vault.mountPath}/data/${cfg.vault.basePath}/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } + path "${cfg.vault.mountPath}/metadata/${cfg.vault.basePath}/*" { + capabilities = ["read", "list", "delete"] + } + POLICY + vault write "auth/approle/role/${cfg.vault.roleName}" \ + token_policies=rustguac \ + token_ttl=1h token_max_ttl=24h \ + secret_id_ttl=0 bind_secret_id=true >/dev/null + + umask 077 + vault read -field=role_id "auth/approle/role/${cfg.vault.roleName}/role-id" > ${cfg.vault.roleIdFile} + vault write -f -field=secret_id "auth/approle/role/${cfg.vault.roleName}/secret-id" > ${cfg.vault.secretIdFile} + chown ${cfg.user}:${cfg.group} ${cfg.vault.roleIdFile} ${cfg.vault.secretIdFile} + chmod 0400 ${cfg.vault.roleIdFile} ${cfg.vault.secretIdFile} + echo "rustguac: AppRole ${cfg.vault.roleName} pret (role_id/secret_id sous ${stateDir})" + ''; + }; + + # Schema REEL de rustguac 1.10.0 (src/config.rs) : listen_addr / guacd_addr / + # static_path / db_path / rate_limit / trusted_proxies sont des cles RACINE + # (pas de section [server] ni [guacd]), [oidc] attend redirect_uri + + # extra_scopes + default_role, [recording] attend path, et le TLS guacd + # s'exprime par [tls] guacd_cert_path (pas un booleen). + configFile = pkgs.writeText "rustguac-config.toml" '' + listen_addr = "${cfg.host}:${toString cfg.port}" + guacd_addr = "${cfg.guacd.host}:${toString cfg.guacd.port}" + recording_path = "${cfg.recording.path}" + static_path = "${rustguacPackage}/static" + db_path = "${cfg.dbPath}" + site_title = "${cfg.siteTitle}" + rate_limit = ${boolToString cfg.rateLimit} + trusted_proxies = [${concatStringsSep ", " (map (p: "\"${p}\"") cfg.trustedProxies)}] + ${optionalString (cfg.tls != null) '' + [tls] + cert_path = "${cfg.tls.certFile}" + key_path = "${cfg.tls.keyFile}" + ${optionalString cfg.guacd.tls ''guacd_cert_path = "${cfg.guacd.certFile}"''} + ''} + ${optionalString (cfg.tls == null && cfg.guacd.tls) '' + [tls] + guacd_cert_path = "${cfg.guacd.certFile}" + ''} + ${optionalString (cfg.oidc != null) '' + [oidc] + issuer_url = "${cfg.oidc.issuerUrl}" + client_id = "${cfg.oidc.clientId}" + ${optionalString (cfg.oidc.clientSecret != null) ''client_secret = "${cfg.oidc.clientSecret}"''} + redirect_uri = "${cfg.oidc.redirectUri}" + default_role = "${cfg.oidc.defaultRole}" + groups_claim = "${cfg.oidc.groupsClaim}" + ${optionalString (cfg.oidc.extraScopes != [ ]) ''extra_scopes = [${concatStringsSep ", " (map (s: "\"${s}\"") cfg.oidc.extraScopes)}]''} + ''} + [recording] + enabled = ${boolToString cfg.recording.enable} + path = "${cfg.recording.path}" + max_disk_percent = ${toString cfg.recording.maxDiskPercent} + max_recordings = ${toString cfg.recording.maxRecordings} ''; in { @@ -54,23 +158,30 @@ in { host = mkOption { type = types.str; default = "127.0.0.1"; - description = "Bind address for rustguac HTTP server"; + description = "Adresse d'ecoute (listen_addr)"; }; port = mkOption { type = types.port; default = 8089; - description = "Port for rustguac HTTP server"; + description = "Port HTTP de rustguac"; }; + dbPath = mkOption { + type = types.str; + default = "${stateDir}/rustguac.db"; + description = "Base sqlite (admins, utilisateurs OIDC, sessions)"; + }; + siteTitle = mkOption { type = types.str; default = "rustguac"; }; + tls = mkOption { type = types.nullOr (types.submodule { options = { - certFile = mkOption { type = types.path; description = "Path to TLS certificate"; }; - keyFile = mkOption { type = types.path; description = "Path to TLS private key"; }; + certFile = mkOption { type = types.path; }; + keyFile = mkOption { type = types.path; }; }; }); default = null; - description = "TLS configuration (null = use nginx reverse proxy)"; + description = "HTTPS cote client (null = HTTP derriere un reverse proxy)"; }; guacd = mkOption { @@ -78,160 +189,168 @@ in { options = { host = mkOption { type = types.str; default = "127.0.0.1"; }; port = mkOption { type = types.port; default = 4822; }; - tls = mkOption { type = types.bool; default = true; }; + # guacd n'a pas de drapeau "activer TLS" : il sert du TLS des qu'on lui + # donne -C certificat -K cle. rustguac de son cote ne parle TLS que si + # [tls] guacd_cert_path est renseigne. Les deux doivent etre d'accord. + tls = mkOption { type = types.bool; default = false; }; + certFile = mkOption { type = types.nullOr types.path; default = null; }; + keyFile = mkOption { type = types.nullOr types.path; default = null; }; }; }; - default = { host = "127.0.0.1"; port = 4822; tls = true; }; + default = { host = "127.0.0.1"; port = 4822; tls = false; }; }; vault = mkOption { - type = types.submodule { + type = types.nullOr (types.submodule { options = { address = mkOption { type = types.str; default = "http://127.0.0.1:8200"; }; - token = mkOption { type = types.str; description = "Vault root token (or use AppRole)"; }; + rootTokenFile = mkOption { + type = types.path; + description = "Fichier contenant le token root, lu par le bootstrap (root)"; + }; mountPath = mkOption { type = types.str; default = "secret"; }; - connectionsPath = mkOption { type = types.str; default = "connections"; }; + basePath = mkOption { type = types.str; default = "rustguac"; }; + instanceName = mkOption { type = types.nullOr types.str; default = null; }; + roleName = mkOption { type = types.str; default = "rustguac"; }; + roleIdFile = mkOption { type = types.str; default = "${stateDir}/role-id"; }; + secretIdFile = mkOption { type = types.str; default = "${stateDir}/secret-id"; }; }; - }; - default = { address = "http://127.0.0.1:8200"; mountPath = "secret"; connectionsPath = "connections"; }; + }); + default = null; + description = "Address book Vault (AppRole). null = pas d'address book."; }; oidc = mkOption { type = types.nullOr (types.submodule { options = { - issuerUrl = mkOption { type = types.str; description = "OIDC issuer URL (e.g. https://auth.example.com)"; }; + issuerUrl = mkOption { type = types.str; }; clientId = mkOption { type = types.str; }; - clientSecret = mkOption { type = types.str; }; - redirectUrl = mkOption { type = types.str; }; - scopes = mkOption { type = types.listOf types.str; default = [ "openid" "profile" "email" ]; }; - usernameClaim = mkOption { type = types.str; default = "email"; }; + # Valeur LITTERALE. Pour un secret qui vit dans un fichier sops, + # utiliser clientSecretFile : il est exporte en OIDC_CLIENT_SECRET et + # n'apparait jamais dans la config (le chemin, lui, ne marcherait pas). + clientSecret = mkOption { type = types.nullOr types.str; default = null; }; + clientSecretFile = mkOption { type = types.nullOr types.path; default = null; }; + redirectUri = mkOption { + type = types.str; + description = "URL publique + /auth/callback (chemin rustguac)"; + }; + defaultRole = mkOption { + type = types.enum [ "admin" "poweruser" "operator" "viewer" ]; + default = "operator"; + }; groupsClaim = mkOption { type = types.str; default = "groups"; }; + extraScopes = mkOption { type = types.listOf types.str; default = [ ]; }; }; }); default = null; - description = "OIDC configuration (null = disabled, use API keys)"; - }; - - apiKeys = mkOption { - type = types.listOf (types.submodule { - options = { - name = mkOption { type = types.str; }; - keyHash = mkOption { type = types.str; description = "SHA-256 hash of the API key"; }; - roles = mkOption { type = types.listOf (types.enum [ "admin" "poweruser" "operator" "viewer" ]); default = [ "viewer" ]; }; - ipAllowlist = mkOption { type = types.listOf types.str; default = [ ]; }; - expiry = mkOption { type = types.nullOr types.str; default = null; }; - }; - }); - default = [ ]; + description = "OIDC (null = desactive, auth par cle API seulement)"; }; recording = mkOption { type = types.submodule { options = { enable = mkOption { type = types.bool; default = true; }; - storagePath = mkOption { type = types.path; default = "/var/lib/rustguac/recordings"; }; - maxSize = mkOption { type = types.str; default = "10G"; }; - maxAge = mkOption { type = types.str; default = "30d"; }; + path = mkOption { type = types.str; default = "${stateDir}/recordings"; }; + maxDiskPercent = mkOption { type = types.int; default = 80; }; + maxRecordings = mkOption { type = types.int; default = 0; }; }; }; - default = { enable = true; storagePath = "/var/lib/rustguac/recordings"; }; + default = { }; }; rateLimit = mkOption { - type = types.submodule { - options = { - enabled = mkOption { type = types.bool; default = true; }; - requestsPerMinute = mkOption { type = types.int; default = 60; }; - }; - }; - default = { enabled = true; requestsPerMinute = 60; }; + type = types.bool; + default = false; + description = "Limitation de debit cote rustguac (inutile derriere un reverse proxy)"; + }; + + trustedProxies = mkOption { + type = types.listOf types.str; + default = [ "127.0.0.1/32" ]; + description = "CIDR des proxys dont X-Forwarded-For est digne de confiance"; }; user = mkOption { type = types.str; default = "rustguac"; }; group = mkOption { type = types.str; default = "rustguac"; }; - - extraConfig = mkOption { type = types.str; default = ""; }; }; config = mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.guacd.tls || (cfg.guacd.certFile != null && cfg.guacd.keyFile != null); + message = "services.rustguac.guacd.tls = true exige guacd.certFile et guacd.keyFile (guacd n'a pas de drapeau TLS sans certificat)."; + } + { + assertion = cfg.vault == null || (cfg.vault.roleIdFile == "${stateDir}/role-id" && cfg.vault.secretIdFile == "${stateDir}/secret-id"); + message = "services.rustguac.vault.roleIdFile/secretIdFile doivent rester sous ${stateDir} (le service les relit au demarrage)."; + } + ]; + users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; - home = "/var/lib/rustguac"; + home = stateDir; description = "rustguac service user"; }; users.groups.${cfg.group} = { }; systemd.tmpfiles.rules = [ - "d /var/lib/rustguac 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.recording.storagePath} 0750 ${cfg.user} ${cfg.group} - -" - "d /etc/rustguac 0750 ${cfg.user} ${cfg.group} - -" + "d ${stateDir} 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.recording.path} 0750 ${cfg.user} ${cfg.group} - -" ]; - environment.etc."rustguac/config.toml".text = '' - [server] - host = "${cfg.host}" - port = ${toString cfg.port} - ${tlsToml} - - [guacd] - host = "${cfg.guacd.host}" - port = ${toString cfg.guacd.port} - tls = ${toString cfg.guacd.tls} - - [vault] - address = "${cfg.vault.address}" - token = "${cfg.vault.token}" - mount_path = "${cfg.vault.mountPath}" - connections_path = "${cfg.vault.connectionsPath}" - - ${oidcToml} - - [recording] - enabled = ${toString cfg.recording.enable} - storage_path = "${cfg.recording.storagePath}" - max_size = "${cfg.recording.maxSize}" - max_age = "${cfg.recording.maxAge}" - - [rate_limit] - enabled = ${toString cfg.rateLimit.enabled} - requests_per_minute = ${toString cfg.rateLimit.requestsPerMinute} - - ${cfg.extraConfig} - ''; + environment.etc."rustguac/config.toml".source = configFile; systemd.services.rustguac = { description = "rustguac - Lightweight Rust replacement for Apache Guacamole"; - after = [ "network.target" "vault.service" ]; - wants = [ "vault.service" ]; + after = [ "network.target" ] ++ optional (cfg.vault != null) "vault.service"; + wants = optional (cfg.vault != null) "vault.service"; + wantedBy = [ "multi-user.target" ]; serviceConfig = { User = cfg.user; Group = cfg.group; - ExecStart = "${rustguacPackage}/bin/rustguac --config /etc/rustguac/config.toml"; + WorkingDirectory = stateDir; + # le bootstrap tourne en root (+) : il lit le token root et ecrit + # role-id/secret-id dans ${stateDir}, que le service relit ensuite. + ExecStartPre = optional (cfg.vault != null) "+${vaultBootstrap}/bin/rustguac-vault-bootstrap"; + Environment = [ + "RUST_LOG=info" + "RUSTGUAC_RUNTIME_DIR=/run/rustguac" + ]; + ExecStart = "${runScript}/bin/rustguac-run"; + # systemd cree /run/rustguac en tant que service user : un tmpfiles + # « d /run/rustguac ... rustguac » ne suffisait pas (le repertoire existait + # deja en root, d'ou « Permission denied » sur le cp du config au demarrage). + RuntimeDirectory = "rustguac"; + RuntimeDirectoryMode = "0750"; Restart = "on-failure"; RestartSec = 5; LimitNOFILE = 65536; - Environment = "RUST_LOG=info"; - AmbientCapabilities = "CAP_NET_BIND_SERVICE"; }; }; systemd.services.guacd = { description = "guacd - Guacamole proxy daemon"; after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; serviceConfig = { User = "guacd"; Group = "guacd"; - ExecStart = "${guacdPackage}/sbin/guacd -f -L ${cfg.guacd.host} -p ${toString cfg.guacd.port} ${optionalString cfg.guacd.tls "-t"}"; + # CLI reelle de guacd : -b ADRESSE, -l PORT, -p PIDFILE, -L NIVEAU_DE_LOG, + # -C certificat, -K cle, -f (premier plan). L'ancienne ligne + # « -L 127.0.0.1 -p 4822 -t » etait fausse sur les trois drapeaux + # (-L attend un niveau de log, -p un pidfile, -t n'existe pas) : guacd + # sortait immediatement en « Invalid log level ». + ExecStart = "${guacdPackage}/sbin/guacd -f -b ${cfg.guacd.host} -l ${toString cfg.guacd.port}" + + optionalString cfg.guacd.tls " -C ${cfg.guacd.certFile} -K ${cfg.guacd.keyFile}"; Restart = "on-failure"; + RestartSec = 5; }; }; users.users.guacd = { isSystemUser = true; group = "guacd"; }; users.groups.guacd = { }; - networking.firewall.allowedTCPPorts = [ cfg.port cfg.guacd.port ]; - - systemd.services.rustguac.wantedBy = [ "multi-user.target" ]; - systemd.services.guacd.wantedBy = [ "multi-user.target" ]; + # rustguac et guacd ecoutent en loopback (host par defaut) et sont atteints + # via le vhost nginx : rien a ouvrir au firewall. }; -} \ No newline at end of file +} diff --git a/modules/nixos/vault-rustguac/default.nix b/modules/nixos/vault-rustguac/default.nix index 2a97b78..d3b88ca 100644 --- a/modules/nixos/vault-rustguac/default.nix +++ b/modules/nixos/vault-rustguac/default.nix @@ -4,6 +4,20 @@ with lib; let cfg = config.services.vault-rustguac; + + # Le mode dev de Vault est pilote par les DRAPEAUX CLI (`vault server -dev`), + # pas par le fichier de config : `dev_root_token_id` / `dev_listen_address` + # poses dans un HCL ne declenchent PAS le mode dev. Consequence vecue : vault + # demarrait avec storage inmem mais restait SEALED et non initialise + # (`/v1/sys/health` -> initialized:false, sealed:true), donc rustguac ne + # pouvait rien lire ni ecrire. + devScript = pkgs.writeShellScript "vault-dev" '' + set -euo pipefail + token=$(cat ${cfg.devRootToken}) + exec ${pkgs.vault}/bin/vault server -dev \ + -dev-root-token-id="$token" \ + -dev-listen-address=${cfg.address}:${toString cfg.port} + ''; in { options.services.vault-rustguac = { @@ -85,43 +99,30 @@ in { "d /etc/vault 0750 ${cfg.user} ${cfg.group} - -" ]; - # Config - environment.etc."vault/config.hcl".text = '' - ${if cfg.mode == "dev" then '' - storage "inmem" {} + # Config : uniquement utile en mode "ha" (le mode dev ignore un HCL). + environment.etc = lib.mkIf (cfg.mode == "ha") { + "vault/config.hcl".text = '' + storage "raft" { + path = "${cfg.ha.dataPath}" + node_id = "${cfg.ha.nodeId}" + ${concatStringsSep "\n " (map (addr: "retry_join { leader_api_addr = \"${addr}\" }") cfg.ha.retryJoin)} + } - listener "tcp" { - address = "${cfg.address}:${toString cfg.port}" - ${if cfg.tls != null then '' - tls_cert_file = "${cfg.tls.certFile}" - tls_key_file = "${cfg.tls.keyFile}" - '' else "tls_disable = true"} - } + listener "tcp" { + address = "${cfg.address}:${toString cfg.port}" + ${if cfg.tls != null then '' + tls_cert_file = "${cfg.tls.certFile}" + tls_key_file = "${cfg.tls.keyFile}" + '' else "tls_disable = true"} + } - dev_root_token_id = "${cfg.devRootToken}" - dev_listen_address = "${cfg.address}:${toString cfg.port}" - '' else '' - storage "raft" { - path = "${cfg.ha.dataPath}" - node_id = "${cfg.ha.nodeId}" - ${concatStringsSep "\n " (map (addr: "retry_join { leader_api_addr = \"${addr}\" }") cfg.ha.retryJoin)} - } + cluster_addr = "${cfg.ha.clusterAddr}" + api_addr = "${cfg.ha.apiAddr}" - listener "tcp" { - address = "${cfg.address}:${toString cfg.port}" - ${if cfg.tls != null then '' - tls_cert_file = "${cfg.tls.certFile}" - tls_key_file = "${cfg.tls.keyFile}" - '' else "tls_disable = true"} - } - - cluster_addr = "${cfg.ha.clusterAddr}" - api_addr = "${cfg.ha.apiAddr}" - ''} - - ui = ${toString cfg.ui} - disable_mlock = true - ''; + ui = ${boolToString cfg.ui} + disable_mlock = true + ''; + }; # Systemd service systemd.services.vault = { @@ -130,7 +131,10 @@ in { serviceConfig = { User = cfg.user; Group = cfg.group; - ExecStart = "${pkgs.vault}/bin/vault server -config /etc/vault/config.hcl"; + ExecStart = if cfg.mode == "dev" then + "${devScript}" + else + "${pkgs.vault}/bin/vault server -config /etc/vault/config.hcl"; Restart = "on-failure"; RestartSec = 5; LimitNOFILE = 65536; @@ -140,8 +144,9 @@ in { }; }; - # Firewall - networking.firewall.allowedTCPPorts = [ cfg.port ]; + # Le listener est en loopback (cfg.address par defaut) : rien a ouvrir au + # firewall, et surtout pas pour un Vault en mode dev qui detient un root + # token. # Auto-unseal for dev mode (already unsealed) # For HA, you'd need to run: vault operator init && vault operator unseal diff --git a/profiles/tower/configuration.nix b/profiles/tower/configuration.nix index 6416580..08faf76 100644 --- a/profiles/tower/configuration.nix +++ b/profiles/tower/configuration.nix @@ -2,6 +2,7 @@ lib, pkgs, inputs, + config, ... }: { imports = [ @@ -53,35 +54,54 @@ services.hermes.enableDiscordToken = true; # ======================================================================== + # ACME/Let's Encrypt : PLUS UTILISE sur tower. Le DNS public + # (*.marcpartensky.com) est un wildcard vers le VPS 104.129.12.158 (Pangolin), + # donc un challenge http-01 ne peut jamais atteindre tower (derriere NAT) : + # chaque vhost ACME finissait en « order-renew ... failed », et un contact + # admin@example.com est de toute facon refuse par Let's Encrypt + # (« contact email has forbidden domain example.com »). + # Les vhosts nginx locaux ci-dessous sont donc en HTTP loopback, destines a etre + # exposes par une ressource Pangolin/newt qui porte, elle, le TLS. + # Si un jour l'ACME revient ici : security.acme.defaults.email = ""; + # (l'ancienne option security.acme.email est depreciee) + acceptTerms = true. + # RUSTGUAC - Gateway Rust (remplace Guacamole Java) # ======================================================================== services.rustguac = { enable = true; host = "127.0.0.1"; port = 8089; - guacd = { host = "127.0.0.1"; port = 4822; tls = true; }; + # guacd n'a pas de TLS ici : il ecoute en loopback, et rustguac doit parler + # le meme protocole que lui (tls = true des deux cotes, ou false des deux + # cotes, sinon la connexion guacamole ne s'etablit pas). + guacd = { host = "127.0.0.1"; port = 4822; tls = false; }; vault = { address = "http://127.0.0.1:8200"; - token = "/run/secrets/vault-rustguac-root-token"; + rootTokenFile = "/run/secrets/vault-rustguac-root-token"; mountPath = "secret"; - connectionsPath = "rustguac/connections"; - }; - oidc = { - issuerUrl = "https://auth.example.com"; # TODO: remplacer par ton OIDC - clientId = "rustguac"; - clientSecret = "/run/secrets/rustguac-oidc-client-secret"; - redirectUrl = "https://guac.example.com/api/oidc/callback"; - scopes = [ "openid" "profile" "email" "groups" ]; - usernameClaim = "email"; - groupsClaim = "groups"; + basePath = "rustguac"; }; recording = { enable = true; - storagePath = "/var/lib/rustguac/recordings"; - maxSize = "10G"; - maxAge = "30d"; + path = "/var/lib/rustguac/recordings"; + maxDiskPercent = 80; }; - rateLimit = { enabled = true; requestsPerMinute = 120; }; + # OIDC DESACTIVE (29/09/2026) : rustguac 1.10.0 attend redirect_uri (pas + # redirect_url), default_role, extra_scopes, et un client_secret LITTERAL ou + # la variable OIDC_CLIENT_SECRET (pas un chemin de fichier). Surtout, il faut + # d'abord creer l'app OIDC dans Zitadel : client "rustguac", redirect + # https://guac.marcpartensky.com/auth/callback, puis exposer le vhost via une + # ressource Pangolin. Pour l'activer : + # oidc = { issuerUrl = "https://auth.marcpartensky.com"; clientId = "rustguac"; + # clientSecretFile = "/run/secrets/rustguac-oidc-client-secret"; + # redirectUri = "https://guac.marcpartensky.com/auth/callback"; + # defaultRole = "operator"; extraScopes = [ "groups" ]; }; + # En attendant, l'auth est par cle API (`rustguac add-admin`). + oidc = null; + # Pas de rate limiting ici : nginx est devant, et rustguac le recommande + # desactive derriere un reverse proxy. + rateLimit = false; + trustedProxies = [ "127.0.0.1/32" ]; }; # ======================================================================== @@ -90,7 +110,10 @@ services.kasmvnc = { enable = true; user = "marc"; - display = ":1"; + # :1 est DEJA pris par la session graphique de marc (Xwayland :1) : + # kasmvncserver refuse alors de demarrer (« tower:1 is taken because of + # /tmp/.X1-lock ») et l'unite boucle jusqu'au start-limit-hit. + display = ":20"; geometry = "1920x1080"; port = 8443; tls = { @@ -98,7 +121,7 @@ keyFile = "/run/secrets/kasmvnc-key-pem"; }; passwordFile = "/home/marc/.kasmpasswd"; - desktop = "lxqt-wayland"; + desktop = "lxqt"; }; # ======================================================================== @@ -107,6 +130,48 @@ services.vault-rustguac = { enable = true; mode = "dev"; + # /home/marc/.kasmpasswd : fichier au format kasmvncpasswd. Attention, + # kasmvncpasswd n'a PAS d'option -f et refuse de tourner sans terminal + # (« getpassword error: Inappropriate ioctl for device ») ; il lit le mot de + # passe sur stdin quand on le lui donne deux fois. L'activation tourne donc en + # root, cree le fichier une fois, puis le passe a marc. Le mot de passe vit + # dans secrets/kasmvnc.yml (`sops -d secrets/kasmvnc.yml` pour le lire). + # -w -o (write + owner) donnent a marc une session utilisable : sans champ de + # permission kasmvncserver annonce « Users configured: marc (can only view) », + # soit une session en lecture seule. (Mesure : l'authentification HTTP passe + # quand meme sans ces flags, 200 avec le bon mot de passe et 401 avec un + # mauvais ; un 401 sur un mot de passe correct signifie que l'utilisateur + # n'existe pas dans le fichier, pas que les flags manquent.) + # La regeneration est pilotee par une empreinte du secret : le fichier n'est + # reecrit que si le mot de passe a change, sinon chaque switch relancerait la + # session en cours pour rien (kasmvncserver ne lit ce fichier qu'au demarrage, + # donc un changement de secret impose un restart). Le test de fumee final ne + # journalise QUE le code HTTP, jamais le mot de passe. + system.activationScripts.kasmvncPasswd = '' + if [ -r /run/secrets/kasmvnc-password ]; then + pw="$(cat /run/secrets/kasmvnc-password)" + want="$(printf %s "$pw" | ${pkgs.coreutils}/bin/sha256sum | ${pkgs.coreutils}/bin/cut -d' ' -f1)" + have="$(cat /var/lib/kasmvnc/password.sha256 2>/dev/null || true)" + if [ "$want" != "$have" ]; then + printf '%s\n%s\n' "$pw" "$pw" | ${config.services.kasmvnc.package}/bin/kasmvncpasswd -u marc -w -o /home/marc/.kasmpasswd || true + chmod 600 /home/marc/.kasmpasswd 2>/dev/null || true + chown marc:users /home/marc/.kasmpasswd 2>/dev/null || true + install -d -m 0755 /var/lib/kasmvnc + printf '%s' "$want" > /var/lib/kasmvnc/password.sha256 + ${pkgs.systemd}/bin/systemctl --user -M marc@.host reset-failed kasmvnc.service 2>/dev/null || true + ${pkgs.systemd}/bin/systemctl --user -M marc@.host restart kasmvnc.service 2>/dev/null || true + code="000" + for i in 1 2 3 4 5 6 7 8 9 10; do + code="$(${pkgs.curl}/bin/curl -sk -o /dev/null -w '%{http_code}' -u "marc:$pw" https://127.0.0.1:8443/ 2>/dev/null || true)" + if [ "$code" = "200" ]; then break; fi + sleep 2 + done + echo "kasmvnc: mot de passe (re)genere, authentification locale -> HTTP $code" >&2 + fi + unset pw want have + fi + ''; + address = "127.0.0.1"; port = 8200; devRootToken = "/run/secrets/vault-rustguac-root-token"; @@ -118,26 +183,58 @@ # ======================================================================== services.rustdesk = { enable = true; - hbbs = { enable = true; port = 21115; }; - hbbr = { enable = true; port = 21116; apiPort = 21118; }; + hbbs = { enable = true; idPort = 21116; }; + hbbr = { enable = true; port = 21117; }; openFirewall = true; }; + # Ports reels de la 1.1.16 : hbbs 21115 (test NAT) / 21116 (ID, TCP+UDP) / + # 21118 (websocket), hbbr 21117 (relais) / 21119 (websocket). Sans -k, hbbs + # genere sa paire de cles dans /var/lib/rustdesk (id_ed25519*). + # NOTE : tower est derriere NAT residentiel ; ces ports ne sont utiles qu'en + # LAN tant que rien n'est redirige cote box (ou via le VPS). # ======================================================================== - # NGINX - Reverse proxy avec ACME pour les deux services + # NGINX - vhosts locaux (TLS porte par Pangolin/newt, pas par nginx ici) # ======================================================================== services.nginx.rustguac = { enable = true; - domain = "guac.example.com"; # TODO: ton domaine + domain = "guac.marcpartensky.com"; upstream = "http://127.0.0.1:8089"; - acme = true; + acme = false; + listenAddress = "127.0.0.1"; + listenPort = 8288; + }; + + # Exposition publique via Pangolin (newt, site "tower") : kasm.marcpartensky.com + # -> 127.0.0.1:8288, le vhost nginx local qui repart en https vers KasmVNC. + # HTTP + SSO, comme noVNC (vnc.marcpartensky.com) : newt 1.12.4 suffit, le mode + # VNC natif de Pangolin exigerait un connecteur > 1.13. Meme contrainte de + # schema que services/newt : ce Pangolin attend `proxy-resources` + `protocol` + # (`public-resources`/`mode` sont ignores en silence -> domaine en 404). + # Pas de healthcheck : KasmVNC repond 401 sur / sans authentification, donc un + # healthcheck HTTP marquerait la cible unhealthy et Pangolin la sortirait du + # load-balancer (lecon du healthcheck noVNC dans services/newt). + services.newt.blueprint.proxy-resources.kasm = { + name = "kasm"; + protocol = "http"; + full-domain = "kasm.marcpartensky.com"; + auth.sso-enabled = true; + targets = [ + { + hostname = "127.0.0.1"; + port = 8288; + method = "http"; + } + ]; }; services.nginx.kasmvnc = { enable = true; - domain = "kasm.example.com"; # TODO: ton domaine + domain = "kasm.marcpartensky.com"; upstream = "https://127.0.0.1:8443"; - acme = true; + acme = false; + listenAddress = "127.0.0.1"; + listenPort = 8289; }; # ======================================================================== @@ -146,29 +243,42 @@ sops.secrets = { vault-rustguac-root-token = { sopsFile = ../../secrets/tower.yml; - owner = "root"; + owner = "marc"; mode = "0400"; }; rustguac-oidc-client-secret = { + # Lu par le service vault (User=vault) : owner root + 0400 rendait le + # fichier illisible pour lui, donc `vault server -dev` sortait en EACCES. sopsFile = ../../secrets/tower.yml; - owner = "root"; + owner = "vault"; mode = "0400"; }; kasmvnc-cert-pem = { + # Lu par le service rustguac lui-meme (User=rustguac). sopsFile = ../../secrets/tower.yml; - owner = "root"; + owner = "rustguac"; mode = "0400"; }; kasmvnc-key-pem = { sopsFile = ../../secrets/tower.yml; - owner = "root"; + owner = "marc"; mode = "0400"; }; - "pangolin/api_key" = { - sopsFile = ../../secrets/common.yml; + # Mot de passe KasmVNC (web login), lu par le script d'activation cote root. + kasmvnc-password = { + sopsFile = ../../secrets/kasmvnc.yml; + owner = "root"; + mode = "0400"; }; }; # User lingering pour KasmVNC systemd user service users.users.marc.linger = true; -} \ No newline at end of file +} # "pangolin/api_key" : volontairement absent, la cle n'existe pas dans + # secrets/common.yml (la declarer fait echouer tout le switch en + # « the key 'pangolin' cannot be found »). Voir services/hermes/default.nix. + + # hermes doit pouvoir lire les journaux (y compris les unites utilisateur de + # marc, ex. kasmvnc) : sans ca, aucun diagnostic possible sans root, et sudo + # n'est autorise que pour nixos-rebuild. + users.users.hermes.extraGroups = [ "systemd-journal" ]; diff --git a/secrets/kasmvnc.yml b/secrets/kasmvnc.yml new file mode 100644 index 0000000..cf556a8 --- /dev/null +++ b/secrets/kasmvnc.yml @@ -0,0 +1,16 @@ +kasmvnc-password: ENC[AES256_GCM,data:nWz603CFpDptabxC1NOeWLitIjCzPa4onOvIxhJ+asU=,iv:rWp7H10pfPbN8a/ti+6/ZnEOqp2p6M1TzXyQa9nA6yQ=,tag:wp8XCDNAanZ0PW0wipty/g==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3QjYyQWVheTNJYjNLaHNz + b1laZGlhdXRrM0o4RXUrMTVXUEFsVWpyYVNFCmtlWWk3OGNkWW5qaktIZkgzNVl4 + QXpWMnZqdjluTVp4SDQxeUZCOURTQmMKLS0tIGdUczM1ZUJhM0hrdTNpVzF3T3lV + OGYxNXAvdjE4S3BKcVJUaStUbVZ1VlkKu+/dDDpIrxji+3D2GBh7gjNPqC80v+En + zB1x5KuxDnCAHFg6me1O+1/U/exnHf8vajdsKMDskk43bph4iz8c+A== + -----END AGE ENCRYPTED FILE----- + recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm + lastmodified: "2026-09-30T23:17:44Z" + mac: ENC[AES256_GCM,data:sVxMPrPooqDN1TufskrKZSo7X2myuwmQiVdn0ZpmuIAZCDI7wdlgsBbEq8nkr1H+D9ni0sE51b6bkCtY9gGHNOW51wq5ZxpES9Lvyjn1BZDK/9C1BqLAIYlbDDnAAVS4xUlWjVpLQpCWLvknCNqexW+Yy9YF8HOcTCn4e1LsirY=,iv:fVtM3XmycJBDEu+k2MyCCMNuvYdNTk/9zhouUI1qOg0=,tag:YghF+Z2o5vkk6450pRgBXA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 diff --git a/secrets/tower.yml b/secrets/tower.yml index f4e2507..a8d3dba 100644 --- a/secrets/tower.yml +++ b/secrets/tower.yml @@ -26,6 +26,10 @@ vaultwarden_mcp_password: ENC[AES256_GCM,data:753uKZ5hw+XnzcY6,iv:zqvRoHpCGhNmHv discord_bot_env: ENC[AES256_GCM,data:wASaieXxptPIzVPPFzU4FIsw6S6ODwaPip8K/9+YUqU48oXY6LEdkTH73J/Fjigco5UNi7jdbrAXyBmd9xo+3cvWtdaOGyTvGzppsihf+04808Xe5+LvzI2cKEEbZQOk5oZGBVYV+55xbkeMsn/kFoZSjffpfsB9H+R9+p9nDVkrNxsfJLImx9/wSmVvaeVLZy+UphL6f3e1G7WIVFpI9Yxha8oPnEOH/nKTHdDS4QZglaCt7Q043V+6P2Lr8Jm1PhxIR+C8dfvjJOh1lRqACl5DUpdToMKS+Go1nLqUkHNic1V/2ojyfhw6Hs9fahTmJPdQ0zA1I8wLg+pANi8eKA9tHLnROwt5l/EULSeATCDoY8j0U4K3M2KWGLsyB/QpBlbt72mvXr0/6hNZxMBhsvppPRRkuYrKH+mlvYDWlu75YWFwBeSLwNxdji9817fi1s7itPbftZqHBamevYgU7bzhAGf1Q8ulmbJNmZyaAWUwK+aF0Q==,iv:Sfp3MtR0zZgvJIo+jTh+FaZ2sPD8WeOEbretFqYUvHw=,tag:GRjqToMEgh0PRKvtBQpP+Q==,type:str] nextcloud_mcp_env: ENC[AES256_GCM,data:rDplpbQdM79shZbLOUN5ugckTSzqGTL8ydwuyCD4nHYiBzLSFylnl3MAd48vdgUryEtd9pfJ,iv:gBk/iNXXIbatCeP5Xo6ORVeikwnnObaJyong1/h3iH0=,tag:HicAgZMH7ZxsnlvNOrNneg==,type:str] #ENC[AES256_GCM,data:oYy+tD9xS8HNlEJNgLEb4NfXURCY+4W3KndFgua3s3CW+rmuQMu2eeoi8qdXU/MAOG3pdwLGJPuEreF/nQg1OR4HNbEtFA==,iv:Ui4rXnnPNbwIqaeIeVN4CopaoNs+o0LwviYr9ye/YM0=,tag:cah42cgfR7cXOWw13oBotg==,type:comment] +kasmvnc-cert-pem: ENC[AES256_GCM,data:uszmQk7rxtAh3Gzap7p7a8SjcXdN7H9aU8vpG1t/VgTsjG8nUOoZhgrPTtNGztFkU2WZawZ4eDiT3fiIQtv7r0l67PURYAQOXO3r+Gj+Jg1XOD+oHTvhmGuduOPW4Ns0ocgal/svr9j81Co0ZGziA20fsCvFniQcyMFDrfrgRrxj99AC+O2r1RN4Fz0C6Hqp1NZ8IPp0Qjyh2LzRn/FdDqNwE3TU9cIj231hFc6MMN2TTmmRZxT9gtbaC6V0UGqxw0ua4r15E9tULaQ8m9pMvx40sYw8Pk/EJ/b8coif9CWONtD11gmYscgJJ6jrCXMO//EP14/TmW/wjtmSFek+4/MN5SBjUJOCkrd1uBItji5nHyImQBT8dn+EnM8s4BOq+aF+moBMke3tUmv7XqarpkDEMygLiA6Jkm6nSVo970VhdmV57cPC8rAH+SvjD7cgbSHwIBUuHz2Z66yVxxhRV7ywvQebXAQ32Arye76cPEyiNSHWLTRq6iRdjFVELbu8te+1jJ2xqAR48mEfF99d8BAeFRUn1dgbX9nz1I4KBhhER4L3gGFqtByuvRIrKfCvVRcWvvJSpkfpKiET+eY9MxqlnXOgJBZUiLuoVZGRizK5Dv67xh2c1894UKHqZyT/RS3tC3QLiheD39vyUFtSc+N6wJz2Il1vLb4scXS1VC0Kpo46IHJVFQ6s7q+l4nuMxV6oSvpRWNQdL5BmhMUw8Wt9JFu/uiOUA1ZFg3Lm2HiB4fVr1et+RqayaNMOyHGE1OYxXZfhEfetUagvjinwYM0pL/XcMHlH8Eh3l7MG1GUyY7SilzslMELTMxeuT988ua9RPgBikt9AdW7HgvqwJkSt91Av9GVQaRLMv2m6ffRdesLGZpsefKM0IFFO1e06ohfiw9V1u8QQDJ49iQcSKDugxgrSGiVuEqG11JigxwOb4izqnJB4TGMfMsjzDst4bVpwR5lWS4JWdF1EFVYT4ivGesl+hIPbVmzKM9p5pro/DIBp1LyVRNMXj5kxmrSHeYv8uFrtyVIVfMzJr3vUiztVWjeKR6xUi31iXDH6E9vBBqRWbf+gtRIiZzqWfvdOFZYCZa9QcIpqjoH/yN7queg0MsEodGuipXaOVnHISsRZfFFzV4ex4q7W/Z6hqjadvys+eP1p7xwaRqdM6g1eUmThmYtsF0bJLPrUNxSsP+/3xi4mca0gK9FKVDM07WpWGDLZ3rJ1KShVM4zTy7Yha617vGhNb+G4A4NoOllRluJ+7mibrk3ylnvSv6NVNhyYVZSA6+7eL93uN5JekQ7QpoMA+iQqanLV1cxBfFP2KkXC77poSHJ4zD6OXjDqvUTyYEiq+Zc8ryotWa+kW+og8gCxH5vjnNhDJQoZJZ8b0oEFp07M8uDFwujQzeaOB9XPgjUbzoifGsfJn8ysZcytLSf7GesJS/qapYZZx0ZGGeGYGLnKBOcf2/F0IJiAX1hHCddtSF1GtkRUCcUQAAq1lOlQiuuqGxIw1zDqxVgjCTOSGxKtZUiQbmoEaxLvNiKEpmiKM/Q6WriOqijFEaPWyzu0XtY=,iv:mtBCKKnTw2pWi98q5DwjH3tFgEPX/Jvl9Ygs9BPyy38=,tag:+ecKTmPsgsuOkfk+RKn0Rw==,type:str] +kasmvnc-key-pem: ENC[AES256_GCM,data:GtJWdK1pmGUlkPGkFD7CTogHkBJpHbs7o6tr11m0oaKeqTx49BkLliYP5q+Qe7DMvyglLzVC1urvBHBkLvnWyr+GZAFibQe/so8mEDMk8qreA+Khe7d4ARUTfj3QsIt9MWrq77lIBmJTs2/6y3fqG1wA59dJbbpxq/ZiQJlDFy3/uSYljh/SLvzVOmJsoWqw/j0ZLz2aomCqd1+D0p1hkdgtEzB6Xxh24qK+jsleTfNEc3Y/eXWKtdV1uiJsv9eQX7UlcTpUdEkIP+HWB+SUCIsTBsDffCf894y140AfzIj0ETwRNj1Bxxf3K2IGjmIGz/QdxPtJ96D1PK1+E2Fu5dWVAEQnTwTitwoDGNTtreofcJ3jUq0vG66qpMc3enAGPUinQElMw9IBtng3gkTV7oiP9yytLL9Rau/n4ao6/hjN7AfF59fgJqWB5SDZfn2CEV2vTGoJcuWcoXCPKgvvYM+Z+/wdPFV4Qwa9NBs1OGHHCWr448zuUHjLu0JiFgHrmdlxQAN1TYYKNBAk8c0F7N+BT5UBMzdmQuxwa4LVM11HOToO2FG3ZD/vzcBfRKgOvaNnA02LsMs2Re0342sIKAk4HpGHOaPYQ6T/MqF5t9zGvbjlj5RG7zyIXeZ6IQ1yEEWz7dmJTPc3lYF2C3tS5mHD1F6l3ugFn/x4a6FjYQxX6bVwwTAJT82PM+UzuAqpi0j1spas5oUFEE5MgmDRPNDiNULuhFgjoTpx7TPdlGsMaRGdZmGx2tOoN01PYcTwIBd5tkt8GFnalcrFurKSWlBSl0m9RmLsTtrAcBJNVGLMSMIJuTN4k4S1ADn/Mz4A85SLQkiQ8G2+Bswm5kQ3bOWrtCr/+0VU7lGT+3VCJTjyGqTirZlwjIGsnSNDa19rhN2aBhfb3y5xCCwjlgYJepAsRx2v5TruSPvokRkvj8K1vO6wnB13ExtGIXORsZqlH+nriO33EwpiG7RlvjG+bG8q8aYw/0gPl/lPgkSPwKVpNABOaLd6W4jQ+K/ZJfStlDMEfyryBJ1PtE9KKjxVK7yCfvGAz9GyjV+cLtDfj3O77ImC8w8G5cwo1rX9kVDmjXp2mRltzoT8xuvIRgJYKfx43nYT5kp6u3ZvZbAYzXCMd4dMxNMxkLr2t3HDXHR+2Rlqds/6zteUl/OZy9bqdP/97PCxglYjtCnzbg16XfLsR29zLIVMO4ZjFAozSYOZt/HfLiotFFjGUuOwekDhrRP0EFkoYBnqqxzO/NbIclvaTjIig5ddr7cGHERuGBf7BH3xHN9+d6YXFgZA5kOvEEzn5SjcDBW2T/M3U+bb0y/tOVqj3qq8NOxIb71sHIrMSTcKPHjb5R158lxn03tYNyymTMuGYv0dAt1an3Pva0quW+ZR7O6JDeffUcwv8uPkDkIguXEJfR5AaN79LotZIWb52OTxJ9+mldB6Pk4ZCr3sMkzDA1JfTJyza4OuLphRUL7IWT2/F7bYWJPSJXDUzsj1h08sfNvme3rSj87H4njqDlYb4AYMW6LvxtufiLRCC7WnCDTFlnXS0M3uGg5SxjLc5q1B72UzZhIUo9L2T845ge2OA+FssGohDi/HQ0SmIAb4Ohbzv8AL/4CY80n+klrdpXwtdRBTLc3HjoGEip/I2Axd4vkPvJLa3/PiwdFsjb1NkW5otJWIOiYwUaVZegfLNHq1kX+Ek9GzqFIIdSo/U7LiLdIay5fxLbeswb6tFaRmk3WJX+6ZLxHVxJrEpviONWenKPC8xeMPahDEhBGV43mTe1fqFqfSX8eVkO6Z3Em84mWmuoDHtKWHdBmmjD6+HYSa8T7xDvBlg2YyFSYNdV8K07z7kYZIQObrL+bzrpBvNJQSuQsaJZTpayO3E5soihTyzNA5+XRTkioc1hHUL7DEZ9Qvhz52VsfuU+DU4qmhL6GSuBMdYMh+mcw9VOt8pGbkdVWmwx305zfeaEWwQZi1/Bh5dFzIDEGpoWPrdOwazUPdnPaA7Rrt7309oZkRECRB4MG3vZmdRuQKNQcAbk1hfXcbG2HUfkAFfXurb82ZF6Vgb/wWFPRsJdLlx6NjY9rmjqL4XO+vP5KBum5J6sAZWwNs53Yc/ZzrWl/t1vuQUXHXjaNx4JSlErTSBhP1jGcwCEZrNNYid2sEW5uXZ3FNSTNVkfd/Z4q0xU0+6+n2yBVP5etn3pPQV7T9b2p9CXpK/bxaliLM6xGfTXOr5W4kpsJCGO8yVHI6ucKdARKf+MZyeYEuy/4i/0kagszPJ5rX+3oo,iv:fiVq04n/1h61OVuYENDGkGw4zA7a/Ad9JjZ3nOhX9FY=,tag:nGLLjEhM6MqBwolZIEk4DA==,type:str] +vault-rustguac-root-token: ENC[AES256_GCM,data:dg7+P/rGjGotPPHpM/ZNZKakjMMTKAJWwg8FoWwXdZlbkkDAXaC6VK6lEG2m/cgcQw7xu2heAszt2VO0WSOtrw==,iv:W5ypyAD8bEaQuIkSV7UoiBuvhx/f7kbmJ6e/8j53sbc=,tag:sF04/AD370LbSHj4BBX+yg==,type:str] +rustguac-oidc-client-secret: ENC[AES256_GCM,data:4IhFtSxzzZgTRM00OS386W6koSsO/Bs8vy0zctAVGI9HRfkViPk8nJ8SIrxyZ4n43bHyB9N6412dNwD1lJR47w==,iv:KdEHU4q4JABoxgdxqVOD/w9d453p5KAOJ427HGc0ugM=,tag:3GTDX+pR4MBzR+d8Ki1HJg==,type:str] sops: age: - enc: | @@ -37,7 +41,7 @@ sops: Sg5uogPIZseNJou3o6R4uy8Mn6LvfmU9/NU3m75+PQHyyGkGL8k78g== -----END AGE ENCRYPTED FILE----- recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm - lastmodified: "2026-09-27T20:53:34Z" - mac: ENC[AES256_GCM,data:YgfIag2jQdwuWoAdishFhKXeEFDGc9wg76cLNjB84Yf76RUffomVCzskhyhip30ryZ3kQKiFi6CzhuLjl8U+Olcd0lBJxHzp9Lh83JCCGy17RnCSMrK3UXXhwYHruGoaTHHIlEbyKCdJLXR4VAhsABt23F/c1U9ZUhHmqsb4NxU=,iv:Pb5VifKtJpX71bTa4f6zBmOSY0uJSOUdQLaV0wk3sjs=,tag:KY4ZVFJBfpM2QqTXeV0l7w==,type:str] + lastmodified: "2026-09-30T03:03:15Z" + mac: ENC[AES256_GCM,data:ngI0KPObZadsE/6UtPKkN/xvBGWUemvwwOra3WtNg3i/ekPdySGZrPo1K2v3vvLaf0HqngctxxpvAxQIQ2b4x0kGRp7KslaPT8h1JCj6dNCFg06VLK+vEPIrr2J1ExVzbSsFtVGEYjgs76TjohbcRchYMU+wLR2TgP6sU48jnZc=,iv:xfqrVf2knxwDaS6sO0ADX07vS1kBkEwz3jCxIre4agI=,tag:NT/TzwJR+9nqL1UtV9qDOg==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3