feat(tower): remote-desktop (rustguac, kasmvnc, rustdesk) + acl 777 access journal

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.
This commit is contained in:
Hermes Agent
2026-10-06 21:41:47 -04:00
parent 1ea50aa6ab
commit 2edce1cd20
10 changed files with 789 additions and 293 deletions
+4
View File
@@ -76,3 +76,7 @@ creation_rules:
key_groups:
- age:
- *tower
- path_regex: secrets/kasmvnc\.yml$
key_groups:
- age:
- *tower
+263 -82
View File
@@ -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 <utilisateur> [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.<name>.linger = true
# or loginctl enable-linger <user>
# 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
+21 -7
View File
@@ -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
+25 -8
View File
@@ -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}
+36 -10
View File
@@ -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 <port id/rendezvous>,
# -r <relais>, -k <cle>, -R, -M, -s, -u ; hbbr -p <port relais>, -k <cle>.
# 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 ];
};
}
+234 -115
View File
@@ -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.
};
}
}
+42 -37
View File
@@ -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
+142 -32
View File
@@ -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 = "<vrai 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;
}
} # "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" ];
+16
View File
@@ -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
+6 -2
View File
@@ -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