From c2ffc72451bbafe7ef1348b2d7198b00b17628cf Mon Sep 17 00:00:00 2001 From: jbergner Date: Sat, 22 Aug 2026 16:25:16 +0200 Subject: [PATCH] Update --- CHANGELOG.md | 11 + Dockerfile | 4 +- README.md | 12 +- deploy/guacamole/CI-CD.md | 6 +- .../docker-compose.sessionguard.example.yml | 2 +- docs/INSTALLATION.md | 2301 +++++++++++++++++ go.mod | 2 +- guacamole-extension/pom.xml | 2 +- internal/agent/agent.go | 4 +- internal/agent/ui.go | 13 +- internal/master/master.go | 4 +- internal/master/ui.go | 15 +- 12 files changed, 2351 insertions(+), 25 deletions(-) create mode 100644 docs/INSTALLATION.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eec6dd..1c088dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.1 - Web UI editor stability + +- Fixed Master live refresh replacing Farm, Resource and server-control forms every five seconds. +- Split live server metrics from editable server-control fields. +- Farm and Resource editors are mounted once; only their data tables and broker leases refresh. +- Added explicit reset/reload actions instead of implicit form replacement. +- Added Agent policy dirty-state protection and visible "Ungespeicherte Änderungen" state. +- Added explicit Agent policy reload with confirmation before discarding unsaved edits. +- Added `Cache-Control: no-store, max-age=0` for the Web UI HTML and JavaScript. +- Added browser-level regression verification covering a full live-refresh cycle. + ## 0.3.0 - Broker & Director production candidate ### Broker and farm control diff --git a/Dockerfile b/Dockerfile index 6a3cda0..6644e86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.26-bookworm AS build +FROM golang:1.23-bookworm AS build WORKDIR /src COPY go.mod ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/sessionguard-master ./cmd/master -FROM alpine:3.24 +FROM alpine:3.20 RUN apk add --no-cache ca-certificates && addgroup -S sessionguard && adduser -S -G sessionguard sessionguard && mkdir -p /var/lib/sessionguard /etc/sessionguard && chown -R sessionguard:sessionguard /var/lib/sessionguard /etc/sessionguard COPY --from=build /out/sessionguard-master /usr/local/bin/sessionguard-master USER sessionguard diff --git a/README.md b/README.md index 5eb24a2..5c27a77 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,18 @@ SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions. -**Current development version: 0.3.0 (Broker & Director production candidate)** +**Current development version: 0.3.1 (Broker & Director production candidate)** > SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC. +## 0.3.1 UI stability fix + +- Live dashboard refresh no longer recreates editable Master forms. +- Server live metrics and server-control inputs are rendered independently. +- Farm and Published Resource editors remain mounted while their live tables refresh. +- Agent policy edits are protected by a dirty-state guard and an explicit reload action. +- HTML and `app.js` are served with `Cache-Control: no-store` to prevent stale UI code after upgrades. + ## 0.3.0 at a glance ### Smart broker and farms @@ -275,4 +283,4 @@ The current source tree contains unit tests for configuration, templates, profil ## Production-candidate status -The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.0 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment. +The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.1 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment. diff --git a/deploy/guacamole/CI-CD.md b/deploy/guacamole/CI-CD.md index d75300d..9e98827 100644 --- a/deploy/guacamole/CI-CD.md +++ b/deploy/guacamole/CI-CD.md @@ -48,9 +48,9 @@ Traefik labels unchanged. git describe --tags --always | sed 's/^v//' ``` -A commit tagged `v0.3.0` therefore publishes `0.3.0`; later commits are named -like `0.3.0-1-g0123456` until the next tag. +A commit tagged `v0.3.1` therefore publishes `0.3.1`; later commits are named +like `0.3.1-1-g0123456` until the next tag. -The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.0.jar`. +The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.1.jar`. Maven may therefore change the project version without requiring a Dockerfile change. diff --git a/deploy/guacamole/docker-compose.sessionguard.example.yml b/deploy/guacamole/docker-compose.sessionguard.example.yml index 0ca54b0..0c2d840 100644 --- a/deploy/guacamole/docker-compose.sessionguard.example.yml +++ b/deploy/guacamole/docker-compose.sessionguard.example.yml @@ -2,7 +2,7 @@ # Build context must point at the SessionGuard repository root. services: guacamole: - image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.0} + image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.1} build: context: ../.. dockerfile: deploy/guacamole/Dockerfile.guacamole diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..2a2d4fe --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,2301 @@ +# SessionGuard v0.3 – Installation und Konfiguration + +> Schritt-für-Schritt-Anleitung für den Betrieb von SessionGuard als RDS-Control-Plane zusammen mit Apache Guacamole 1.6, PocketID, Traefik, PostgreSQL und Windows Remote Desktop Session Hosts. + +Diese Anleitung ist auf den aktuellen SessionGuard-v0.3-Arbeitsstand und die vorhandene Umgebung mit `git.send.nrw`, `ts.hilden.info`, `guacauth.hilden.info` und `auth.hilden.info` zugeschnitten. Alle Secrets sind Platzhalter und müssen durch eigene, zufällige Werte ersetzt werden. + +--- + +## 1. Zielbild + +Nach der Installation sieht die Umgebung logisch so aus: + +```text +Internet + | + v +Traefik + | + +----------------------+-----------------------+ + | | | + v v v +Guacamole PocketID SessionGuard Master ++ SessionGuard | | +Extension | +---- PostgreSQL + | | | + +----------------------+ | + | | + | Broker API | Agent Heartbeats + v v +SessionGuard Master <---------------------- Windows RDS Agents + | | + | Brokerentscheidung +-- RDS01 + | +-- RDS02 + +----------------------------------------------+-- RDS03 +``` + +Die Verantwortlichkeiten bleiben bewusst getrennt: + +| Komponente | Aufgabe | +|---|---| +| PocketID | Benutzeridentität und OIDC-Gruppen | +| Traefik / traefik-forward-auth | TLS, Edge-Proxy und Guacamole-Header-Authentifizierung | +| Guacamole | HTML5/RDP-Gateway | +| SessionGuard Guacamole Extension | fragt beim Verbindungsaufbau den Broker nach dem Ziel-RDS-Host | +| SessionGuard Master | Broker, Director, Farms, Policies, RBAC, Alerts, Audit, Session-Historie | +| SessionGuard Agent | lokale RDS-/WTS-Steuerung, Profil-Pipeline, Templates und Telemetrie | +| PostgreSQL | persistenter SessionGuard-Control-Plane- und Director-State | +| Windows RDS | eigentliche Benutzer-Sitzungen | + +--- + +# Teil A – Voraussetzungen + +## 2. Voraussetzungen prüfen + +### 2.1 Docker-/Linux-Seite + +Benötigt werden: + +- Docker Engine +- Docker Compose v2 +- bestehender Traefik v3 +- Zugriff auf `git.send.nrw` +- DNS für Guacamole, PocketID und SessionGuard +- PostgreSQL 17 wird empfohlen + +Vorhanden sind bereits: + +```text +Guacamole: https://ts.hilden.info +Forward Auth: https://guacauth.hilden.info +PocketID: https://auth.hilden.info +``` + +Für SessionGuard wird in dieser Anleitung beispielhaft verwendet: + +```text +SessionGuard: https://sessionguard.hilden.info +``` + +Dafür einen DNS-Eintrag auf denselben Traefik-Endpunkt erstellen. + +### 2.2 Windows-Seite + +Pro RDS-Host: + +- Windows Server 2019/2022/2025 x64 +- RDS Session Host installiert und funktionsfähig +- lokaler Administrator für die Installation +- Netzwerkzugriff zum SessionGuard Master +- bei Profil-Sicherung: Zugriff auf den gewünschten SMB-Store +- empfohlen: gMSA für den SessionGuard-Dienst, wenn auf SMB-Shares geschrieben werden soll + +### 2.3 Build-Voraussetzungen + +Falls der Windows-Agent lokal gebaut wird: + +```text +Go 1.23+ +PowerShell 5.1+ +Git +``` + +Für einen lokalen Build der Guacamole-Extension: + +```text +JDK 17 +Maven 3.9+ +``` + +Im normalen Gitea-CI/CD-Betrieb wird die Guacamole-Extension bereits im Docker-Build erstellt. + +--- + +# Teil B – Secrets und PocketID + +## 3. SessionGuard-Secrets erzeugen + +Für unterschiedliche Vertrauensbeziehungen **unterschiedliche Secrets** verwenden. + +Beispiel unter Linux: + +```bash +openssl rand -base64 48 +openssl rand -base64 48 +openssl rand -base64 48 +openssl rand -base64 48 +``` + +Benötigt werden mindestens: + +```text +SESSIONGUARD_DB_PASSWORD +SESSIONGUARD_ENROLLMENT_TOKEN +SESSIONGUARD_BROKER_API_KEY +SESSIONGUARD_OIDC_CLIENT_SECRET +``` + +Die Secrets dürfen nicht wiederverwendet werden. + +Insbesondere sind diese Werte voneinander zu trennen: + +```text +PocketID OIDC Client Secret Browser -> SessionGuard +Broker API Key Guacamole Extension -> Master +Enrollment Token neuer Agent -> Master +Per-Agent Token Agent -> Master, wird automatisch erzeugt +PostgreSQL Password Master -> PostgreSQL +``` + +--- + +## 4. PocketID für den SessionGuard Master konfigurieren + +In PocketID einen **neuen vertraulichen OIDC-Client nur für SessionGuard** anlegen. + +Beispiel: + +```text +Name: SessionGuard Master +Client Type: Confidential +``` + +Redirect URI: + +```text +https://sessionguard.hilden.info/oidc/callback +``` + +Scopes: + +```text +openid profile email groups +``` + +Client-ID und Client-Secret notieren. + +Das Client-Secret später ausschließlich über: + +```text +SESSIONGUARD_OIDC_CLIENT_SECRET +``` + +an den Master übergeben. + +### 4.1 Empfohlene PocketID-Gruppen + +Beispielsweise: + +```text +sessionguard-admins +sessionguard-helpdesk +sessionguard-operators +sessionguard-policy +sessionguard-profile +sessionguard-auditors +``` + +Empfohlene SessionGuard-Zuordnung: + +| PocketID-Gruppe | SessionGuard-Rolle | +|---|---| +| `sessionguard-admins` | `admin` | +| `sessionguard-helpdesk` | `helpdesk` | +| `sessionguard-operators` | `operator` | +| `sessionguard-policy` | `policy_admin` | +| `sessionguard-profile` | `profile_admin` | +| `sessionguard-auditors` | `auditor` | + +Für Produktion sollte `rbac.groups` nicht leer bleiben. Ein leeres Mapping vergibt aus Kompatibilitätsgründen weitreichendere Rechte als für einen finalen Produktionsbetrieb gewünscht. + +--- + +## 5. PocketID für die lokalen Agent-WebUIs + +Das lokale Agent-WebUI ist als Fallback vorgesehen, wenn der Master nicht erreichbar ist. + +Pro Terminalserver kann ein eigener OIDC-Client verwendet werden, zum Beispiel: + +```text +https://rds01-mgmt.hilden.info/oidc/callback +https://rds02-mgmt.hilden.info/oidc/callback +``` + +Alternativ kann ein zentral verwalteter OIDC-Client mehrere explizit erlaubte Redirect-URIs enthalten, sofern dies in der eigenen PocketID-Konfiguration gewünscht ist. + +Für das lokale Agent-UI nur Administratorgruppen freigeben, beispielsweise: + +```json +"admin_groups": [ + "sessionguard-admins" +] +``` + +Das Agent-WebUI selbst lauscht standardmäßig auf Port `9091`. Für einen produktiven OIDC-Login sollte davor HTTPS über einen Reverse Proxy verwendet werden. + +--- + +# Teil C – Docker-Netzwerke + +## 6. Netzwerkstruktur härten + +Die vorhandene Umgebung verwendet das externe Netzwerk: + +```text +aio_proxy +``` + +Das funktioniert technisch. Bei Guacamole Header Authentication ist aber wichtig, dass kein nicht vertrauenswürdiger Container Guacamole direkt erreichen und selbst einen `X-Guacamole-User`-Header senden kann. + +Empfohlen sind daher getrennte Netze: + +```text +guacamole_proxy Traefik <-> Guacamole / ForwardAuth + +guacamole_backend Guacamole <-> guacd / PostgreSQL + +sessionguard_control Guacamole <-> SessionGuard Master + +sessionguard_backend SessionGuard Master <-> SessionGuard PostgreSQL +``` + +### 6.1 Gemeinsames privates Control-Netz erstellen + +Wenn Guacamole und SessionGuard in unterschiedlichen Compose-Projekten laufen: + +```bash +docker network create --internal sessionguard_control +``` + +In beiden Compose-Dateien anschließend: + +```yaml +networks: + sessionguard_control: + external: true + name: sessionguard_control +``` + +Damit kann die Guacamole-Extension den Master intern erreichen, ohne dessen Broker-API über das Internet aufrufen zu müssen. + +### 6.2 Optional: dediziertes Guacamole-Proxy-Netz + +Besonders bei Header-Auth empfohlen: + +```bash +docker network create guacamole_proxy +``` + +Traefik, `traefik-forward-auth` und `guacamole` werden an dieses Netzwerk angeschlossen. Andere Stacks sollten dort nicht Mitglied sein. + +Wenn der bestehende `aio_proxy` von sehr vielen Containern gemeinsam verwendet wird, sollte die Umstellung auf ein dediziertes Netzwerk als Security-Hardening eingeplant werden. + +--- + +# Teil D – Gitea CI/CD + +## 7. CI/CD-Dateien ins Repository übernehmen + +Im SessionGuard-Repository sollten vorhanden sein: + +```text +.gitea/workflows/release.yml +deploy/guacamole/Dockerfile.guacamole +guacamole-extension/ +``` + +Die Pipeline erzeugt bei Push auf `main`: + +```text +git.send.nrw/sendnrw/: +git.send.nrw/sendnrw/:latest + +git.send.nrw/sendnrw/-guacamole: +git.send.nrw/sendnrw/-guacamole:latest +``` + +Zusätzlich wird veröffentlicht: + +```text +sessionguard-guacamole.jar +``` + +in der Gitea Generic Package Registry. + +### 7.1 Gitea Secrets + +Im Repository mindestens hinterlegen: + +```text +DOCKER_USERNAME +DOCKER_PASSWORD +``` + +Der verwendete Account benötigt Schreibrechte auf: + +- Container Registry +- Generic Package Registry + +Später empfiehlt sich eine Trennung in eigene Package-Credentials. + +### 7.2 Release-Versionen + +Die CI verwendet: + +```bash +git describe --tags --always | sed 's/^v//' +``` + +Für einen sauberen Release: + +```bash +git tag v0.3.1 +git push origin v0.3.1 +git push origin main +``` + +Ein Commit exakt auf Tag `v0.3.1` erzeugt dann: + +```text +git.send.nrw/sendnrw/sessionguard:0.3.1 +git.send.nrw/sendnrw/sessionguard-guacamole:0.3.1 +``` + +`latest` wird ebenfalls aktualisiert. + +### 7.3 Release prüfen + +```bash +docker pull git.send.nrw/sendnrw/sessionguard:0.3.1 +docker pull git.send.nrw/sendnrw/sessionguard-guacamole:0.3.1 +``` + +Für Produktion möglichst einen festen Versions-Tag und nicht ausschließlich `latest` verwenden. + +--- + +# Teil E – SessionGuard Master + +## 8. Verzeichnisstruktur anlegen + +Auf dem Docker-Host beispielsweise: + +```bash +mkdir -p /opt/sessionguard +cd /opt/sessionguard +``` + +Anlegen: + +```text +/opt/sessionguard/ +├── docker-compose.yml +├── .env +└── master.json +``` + +--- + +## 9. `.env` für SessionGuard erstellen + +Beispiel: + +```dotenv +SESSIONGUARD_VERSION=0.3.1 +POSTGRES_VERSION=17 + +TRAEFIK_NETWORK=aio_proxy +SESSIONGUARD_HOST=sessionguard.hilden.info +TRAEFIK_ENTRYPOINT=websecure +TRAEFIK_CERTRESOLVER=letsencrypt + +SESSIONGUARD_DB_NAME=sessionguard +SESSIONGUARD_DB_USER=sessionguard +SESSIONGUARD_DB_PASSWORD= + +SESSIONGUARD_ENROLLMENT_TOKEN= +SESSIONGUARD_BROKER_API_KEY= +SESSIONGUARD_OIDC_CLIENT_SECRET= + +# Optional +SESSIONGUARD_ALERT_WEBHOOK_URL= +``` + +Datei schützen: + +```bash +chmod 600 .env +``` + +--- + +## 10. `master.json` erstellen + +Beispiel für die vorhandene Domain: + +```json +{ + "listen": ":8080", + "public_url": "https://sessionguard.hilden.info", + "data_file": "/var/lib/sessionguard/master.json", + "database_url": "", + "enrollment_token": "SET-BY-SESSIONGUARD_ENROLLMENT_TOKEN", + "offline_after_seconds": 30, + "history_limit": 50000, + "oidc": { + "issuer": "https://auth.hilden.info", + "client_id": "", + "client_secret": "SET-BY-SESSIONGUARD_OIDC_CLIENT_SECRET", + "redirect_url": "https://sessionguard.hilden.info/oidc/callback", + "admin_groups": [], + "secure_cookie": true + }, + "rbac": { + "default_role": "viewer", + "groups": { + "sessionguard-admins": ["admin"], + "sessionguard-helpdesk": ["helpdesk"], + "sessionguard-operators": ["operator"], + "sessionguard-policy": ["policy_admin"], + "sessionguard-profile": ["profile_admin"], + "sessionguard-auditors": ["auditor"] + } + }, + "broker": { + "enabled": true, + "api_key": "SET-BY-SESSIONGUARD_BROKER_API_KEY", + "lease_seconds": 900, + "min_health_score": 60, + "reconnect_existing": true, + "single_session_per_user": true, + "default_farm_id": "" + }, + "alerts": { + "enabled": true, + "webhook_url": "", + "cpu_percent": 90, + "memory_percent": 90, + "disk_free_gb": 10, + "health_score": 50, + "offline_seconds": 120, + "profile_failures": 3, + "disconnected_sessions": 20, + "logon_duration_seconds": 30, + "notification_min_interval_seconds": 900 + } +} +``` + +Die echten Secrets werden bewusst nicht in `master.json` geschrieben, sondern durch ENV-Variablen überschrieben. + +--- + +## 11. SessionGuard Master Compose erstellen + +Für die Registry-basierte Installation: + +```yaml +services: + sessionguard-postgres: + image: postgres:${POSTGRES_VERSION:-17} + restart: unless-stopped + environment: + POSTGRES_DB: ${SESSIONGUARD_DB_NAME:-sessionguard} + POSTGRES_USER: ${SESSIONGUARD_DB_USER:-sessionguard} + POSTGRES_PASSWORD: ${SESSIONGUARD_DB_PASSWORD:?required} + volumes: + - sessionguard-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + networks: + - sessionguard_backend + + sessionguard-master: + image: git.send.nrw/sendnrw/sessionguard:${SESSIONGUARD_VERSION:-latest} + restart: unless-stopped + depends_on: + sessionguard-postgres: + condition: service_healthy + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + SESSIONGUARD_DATABASE_URL: postgres://${SESSIONGUARD_DB_USER:-sessionguard}:${SESSIONGUARD_DB_PASSWORD}@sessionguard-postgres:5432/${SESSIONGUARD_DB_NAME:-sessionguard}?sslmode=disable + SESSIONGUARD_ENROLLMENT_TOKEN: ${SESSIONGUARD_ENROLLMENT_TOKEN} + SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY} + SESSIONGUARD_OIDC_CLIENT_SECRET: ${SESSIONGUARD_OIDC_CLIENT_SECRET} + SESSIONGUARD_ALERT_WEBHOOK_URL: ${SESSIONGUARD_ALERT_WEBHOOK_URL:-} + volumes: + - ./master.json:/etc/sessionguard/master.json:ro + tmpfs: + - /tmp:size=16m,mode=1777 + networks: + - sessionguard_backend + - sessionguard_control + - proxy + labels: + - traefik.enable=true + - traefik.docker.network=${TRAEFIK_NETWORK} + - traefik.http.routers.sessionguard.rule=Host(`${SESSIONGUARD_HOST}`) + - traefik.http.routers.sessionguard.entrypoints=${TRAEFIK_ENTRYPOINT} + - traefik.http.routers.sessionguard.tls=true + - traefik.http.routers.sessionguard.tls.certresolver=${TRAEFIK_CERTRESOLVER} + - traefik.http.services.sessionguard.loadbalancer.server.port=8080 + +networks: + sessionguard_backend: + internal: true + sessionguard_control: + external: true + name: sessionguard_control + proxy: + external: true + name: ${TRAEFIK_NETWORK:-aio_proxy} + +volumes: + sessionguard-postgres: +``` + +Wenn `sessionguard_control` noch nicht existiert: + +```bash +docker network create --internal sessionguard_control +``` + +--- + +## 12. Master starten + +```bash +cd /opt/sessionguard +docker compose pull +docker compose up -d +``` + +Status: + +```bash +docker compose ps +``` + +Logs: + +```bash +docker compose logs -f sessionguard-master +``` + +Health intern testen: + +```bash +docker compose exec sessionguard-master \ + wget -qO- http://127.0.0.1:8080/healthz +``` + +Extern testen: + +```text +https://sessionguard.hilden.info +``` + +Beim ersten Login sollte PocketID erscheinen. + +--- + +## 13. RBAC prüfen + +Mindestens zwei Tests durchführen: + +1. Benutzer in `sessionguard-admins` anmelden. +2. Benutzer ohne Admin-Gruppe bzw. mit Viewer-Gruppe anmelden. + +Der zweite Benutzer darf keine administrativen Änderungen durchführen können. + +Erst danach weitere Agents anbinden. + +--- + +# Teil F – Guacamole mit SessionGuard Extension + +## 14. Wichtige Korrektur der vorhandenen PocketID-/ForwardAuth-Konfiguration + +In der vorhandenen `.env` steht: + +```text +TFA_HOST=guacauth.hilden.info +``` + +Daher muss die PocketID Redirect URI für den **traefik-forward-auth-Client** dazu passen: + +```text +https://guacauth.hilden.info/portals/guacamole/oauth2/callback +``` + +Ein alter Kommentar mit `guac-sso.hilden.info` sollte entfernt werden, damit es später nicht zu Fehlkonfigurationen kommt. + +Der SessionGuard Master benötigt **einen eigenen OIDC-Client**. ForwardAuth- und SessionGuard-Client-Secret dürfen nicht identisch sein. + +--- + +## 15. Guacamole-Image austauschen + +Im bestehenden Compose nur beim eigentlichen `guacamole`-Service das Image ändern. + +Vorher: + +```yaml +image: guacamole/guacamole:${GUACAMOLE_VERSION:-1.6.0} +``` + +Nachher: + +```yaml +image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.1} +``` + +`guac-init` kann weiterhin das offizielle Guacamole-Image verwenden. + +--- + +## 16. SessionGuard-Variablen zu Guacamole ergänzen + +Im `guacamole.environment` ergänzen: + +```yaml +SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080 +SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY} +SESSIONGUARD_BROKER_TIMEOUT_MS: "2500" +``` + +In die Guacamole `.env` zusätzlich: + +```dotenv +SESSIONGUARD_VERSION=0.3.1 +SESSIONGUARD_BROKER_API_KEY= +``` + +Der Broker API Key ist **kein Benutzer-Secret**, sondern ein Service-to-Service-Key zwischen Guacamole-Extension und SessionGuard Master. + +--- + +## 17. Guacamole an `sessionguard_control` anschließen + +Beim `guacamole`-Service: + +```yaml +networks: + - aio_proxy + - sessionguard_control +``` + +Am Ende der Compose-Datei: + +```yaml +networks: + aio_proxy: + external: true + sessionguard_control: + external: true + name: sessionguard_control +``` + +Danach kann Guacamole den Master per Docker DNS erreichen: + +```text +http://sessionguard-master:8080 +``` + +Wenn Master und Guacamole in unterschiedlichen Compose-Projekten laufen, funktioniert der DNS-Name nur dann zuverlässig, wenn beide Container im gleichen externen Docker-Netz hängen und dort der Name/Alias verfügbar ist. Falls der tatsächliche Containername anders lautet, einen expliziten Alias setzen oder den tatsächlichen DNS-Namen in `SESSIONGUARD_MASTER_URL` verwenden. + +--- + +## 18. Guacamole neu starten + +```bash +docker compose pull guacamole +docker compose up -d guacamole +``` + +Logs prüfen: + +```bash +docker compose logs -f guacamole +``` + +Die SessionGuard-Extension muss beim Guacamole-Start ohne API-/Classloader-Fehler geladen werden. + +--- + +## 19. Header-Auth absichern + +Die vorhandene Konfiguration entfernt vor ForwardAuth bereits eingehende Identitätsheader: + +```yaml +- traefik.http.middlewares.guac-strip-auth.headers.customrequestheaders.X-Guacamole-User= +- traefik.http.middlewares.guac-strip-auth.headers.customrequestheaders.X-Forwarded-User= +- traefik.http.middlewares.guac-strip-auth.headers.customrequestheaders.X-Authenticated-User= +``` + +und verwendet anschließend: + +```yaml +- traefik.http.routers.guacamole.middlewares=guac-strip-auth,guac-pocketid +``` + +Das ist richtig gedacht. + +Zusätzlich sicherstellen: + +- Guacamole-Port `8080` nicht direkt veröffentlichen. +- Guacamole nicht aus beliebigen Container-Netzen erreichbar machen. +- `aio_proxy` möglichst nicht als allgemeines Netz für unzusammenhängende Stacks verwenden. +- `guacd` und Guacamole-PostgreSQL nur in einem internen Backend-Netz betreiben. + +Ein direkter Request eines fremden Containers an Guacamole darf nicht durch einen selbst gesetzten `X-Guacamole-User` authentifiziert werden können. + +--- + +# Teil G – Windows Agent + +## 20. Windows-Agent bauen + +Der aktuelle CI-Workflow baut die Container-Images und die Guacamole-JAR, veröffentlicht aber noch nicht automatisch den Windows-Agent als Release-Artefakt. + +Bis dahin Agent auf einem Buildsystem erzeugen: + +```powershell +git clone +cd sessionguard + +go mod tidy +go test ./... +go vet ./... + +.\scripts\build.ps1 +``` + +Ergebnis: + +```text +.\bin\sessionguard-agent.exe +``` + +und zusätzlich: + +```text +.\bin\sessionguard-master-linux-amd64 +``` + +Vor Produktion sollte genau der Commit gebaut werden, aus dem auch Master und Guacamole-Image erzeugt wurden. + +--- + +## 21. Agent-Konfiguration erstellen + +Pro Server eine `agent.json` erstellen. + +Beispiel für `RDS01`: + +```json +{ + "listen": ":9091", + "public_url": "https://rds01-mgmt.hilden.info", + "data_dir": "C:\\ProgramData\\SessionGuard", + "master_url": "https://sessionguard.hilden.info", + "enrollment_token": "SET-BY-SESSIONGUARD_ENROLLMENT_TOKEN", + "heartbeat_seconds": 10, + "oidc": { + "issuer": "https://auth.hilden.info", + "client_id": "", + "client_secret": "SET-BY-SESSIONGUARD_OIDC_CLIENT_SECRET", + "redirect_url": "https://rds01-mgmt.hilden.info/oidc/callback", + "admin_groups": [ + "sessionguard-admins" + ], + "secure_cookie": true + }, + "policy": { + "cleanup": { + "enabled": true, + "grace_seconds": 600, + "poll_seconds": 10, + "retry_seconds": 60, + "dry_run": true, + "exclude_users": [ + "Administrator", + "DefaultAccount", + "WDAGUtilityAccount" + ], + "exclude_sids": [ + "S-1-5-18", + "S-1-5-19", + "S-1-5-20" + ], + "allowed_profile_roots": [ + "C:\\Users" + ] + }, + "profiles": { + "enabled": true, + "store_root": "\\\\fileserver\\SessionGuardProfiles", + "backup_on_logoff": true, + "restore_on_logon": false, + "backup_delay_seconds": 5, + "retry_seconds": 60, + "restore_window_seconds": 120, + "keep_versions": 2, + "exclude_users": [ + "Administrator" + ], + "exclude_sids": [ + "S-1-5-18", + "S-1-5-19", + "S-1-5-20" + ], + "folders": [ + { + "path": "AppData\\Roaming\\Example", + "exclude_globs": [ + "Cache/**", + "*.tmp" + ] + }, + { + "path": "Desktop" + }, + { + "path": "Documents" + } + ] + }, + "sessions": { + "control_enabled": true, + "disconnected_logoff_enabled": false, + "disconnected_timeout_seconds": 3600, + "exclude_users": [ + "Administrator" + ], + "exclude_sids": [ + "S-1-5-18", + "S-1-5-19", + "S-1-5-20" + ] + }, + "templates": [] + } +} +``` + +### Warum für die erste Installation `restore_on_logon=false`? + +Der sichere Rollout ist: + +1. Backup testen. +2. Restore manuell mit Testbenutzer validieren. +3. Erst danach automatischen Restore aktivieren. +4. Cleanup zunächst im Dry-Run lassen. + +So existiert immer eine lokale Rückfallmöglichkeit. + +--- + +## 22. Secrets des Agenten über Environment setzen + +Empfohlen ist, sensible Werte nicht im JSON zu hinterlegen. + +Der Agent unterstützt insbesondere: + +```text +SESSIONGUARD_MASTER_URL +SESSIONGUARD_ENROLLMENT_TOKEN +SESSIONGUARD_OIDC_CLIENT_SECRET +``` + +Bei einem Windows-Dienst müssen diese Werte so gesetzt werden, dass sie im Dienstkontext verfügbar sind. Alternativ kann die initiale Konfigurationsdatei mit strikten ACLs geschützt werden. + +Der Installer schützt standardmäßig: + +```text +C:\ProgramData\SessionGuard +``` + +für `SYSTEM` und lokale Administratoren. + +--- + +## 23. gMSA für Profil-Shares vorbereiten + +Empfohlenes Dienstkonto beispielsweise: + +```text +DOMAIN\svc-sessionguard$ +``` + +Das gMSA benötigt auf dem Profil-Share die erforderlichen Share- und NTFS-Rechte. + +Mindestens erforderlich für Backup/Restore: + +- Lesen +- Ordner anlegen +- Dateien schreiben +- Dateien ersetzen/umbenennen +- alte Versionen entsprechend Retention löschen + +Keine SMB-Passwörter in `agent.json` speichern. + +Wenn LocalSystem verwendet wird, greift der Server gegenüber SMB typischerweise über sein Computerkonto zu, beispielsweise: + +```text +DOMAIN\RDS01$ +``` + +Dann müssten entsprechend die Computerkonten auf dem Share berechtigt werden. + +--- + +## 24. Agent installieren + +Elevated PowerShell: + +```powershell +.\scripts\install-agent.ps1 ` + -Binary .\bin\sessionguard-agent.exe ` + -Config .\configs\agent.json +``` + +Mit gMSA: + +```powershell +.\scripts\install-agent.ps1 ` + -Binary .\bin\sessionguard-agent.exe ` + -Config .\configs\agent.json ` + -GmsaAccount "DOMAIN\svc-sessionguard$" +``` + +Der Installer: + +- kopiert die Binary nach `C:\Program Files\SessionGuard` +- legt den State unter `C:\ProgramData\SessionGuard` ab +- installiert den Windows-Dienst `SessionGuardAgent` +- aktiviert Delayed Auto Start +- konfiguriert automatische Service-Recovery +- startet den Dienst + +--- + +## 25. Agent prüfen + +```powershell +Get-Service SessionGuardAgent +``` + +Erwartet: + +```text +Status: Running +``` + +Falls erforderlich Windows Event Log bzw. Dienst-Logs prüfen. + +Anschließend Master öffnen: + +```text +https://sessionguard.hilden.info +``` + +Der Server sollte nach Enrollment/Heartbeat in der Serverliste erscheinen. + +Ein Agent, der nach `offline_after_seconds` keinen Heartbeat gesendet hat, wird im Director als offline dargestellt. + +--- + +## 26. Lokales Agent-WebUI veröffentlichen + +Das Agent-WebUI lauscht standardmäßig: + +```text +http://RDS01:9091 +``` + +Produktiv nicht direkt öffentlich ins Internet stellen. + +Empfohlen: + +```text +Management-Netz / Reverse Proxy / TLS + | + v +http://RDS01:9091 +``` + +Windows Firewall beispielsweise nur für die Reverse-Proxy-IP öffnen: + +```powershell +New-NetFirewallRule ` + -DisplayName "SessionGuard Agent UI" ` + -Direction Inbound ` + -Action Allow ` + -Protocol TCP ` + -LocalPort 9091 ` + -RemoteAddress +``` + +--- + +# Teil H – Farms, Tags und Broker + +## 27. Erste Farm anlegen + +Im Master-WebUI unter: + +```text +Farms & Broker +``` + +Beispiel: + +```text +Name: Office +ID: wird erzeugt bzw. im UI angezeigt +``` + +Optional Required Tags: + +```text +role=office +environment=production +``` + +Farm aktivieren. + +--- + +## 28. Agents taggen und einer Farm zuordnen + +Server im Master auswählen. + +Unter Serversteuerung beispielsweise: + +```text +Maintenance Mode: Online + +Tags: +role=office +environment=production +location=hilden +``` + +Optional Farm-ID explizit hinterlegen. + +Die explizite Zuordnung hat gegenüber automatisch aus Tags ermittelten Farm-Zuordnungen eine höhere Policy-Relevanz. + +--- + +## 29. Server-Modi verstehen + +### Online + +```text +Neue Sitzungen: erlaubt +Reconnect: erlaubt +``` + +### Drain + +```text +Neue Sitzungen: nicht auf diesem Host +Reconnect bestehend: erlaubt +``` + +Verwenden vor Updates/Wartung. + +### Maintenance + +```text +Neue Sitzungen: blockiert +Reconnect: blockiert +``` + +Bestehende Windows-Sitzungen werden durch das Umschalten allein nicht automatisch beendet. + +### Restart when drained + +Aktivieren: + +```text +Neu starten sobald leer +``` + +Dann: + +1. Host auf Drain. +2. Keine neuen Sessions mehr. +3. Bestehende Sessions laufen aus. +4. Bei 0 Benutzersitzungen wird ein Restart-Kommando erzeugt. +5. Nach Neustart Agent-Health prüfen. +6. Host wieder auf `online` setzen. + +--- + +## 30. Broker-Grundeinstellungen + +In `master.json`: + +```json +"broker": { + "enabled": true, + "api_key": "SET-BY-SESSIONGUARD_BROKER_API_KEY", + "lease_seconds": 900, + "min_health_score": 60, + "reconnect_existing": true, + "single_session_per_user": true, + "default_farm_id": "" +} +``` + +Empfohlen für das vorhandene Profilmodell: + +```text +reconnect_existing = true +single_session_per_user = true +``` + +Damit versucht SessionGuard zuerst, einen Benutzer auf seine vorhandene aktive/getrennte Sitzung zurückzuführen. + +Das reduziert gleichzeitige Profilzustände desselben Benutzers auf mehreren Hosts. + +--- + +# Teil I – Guacamole Published Resources + +## 31. Brokered Desktop in Guacamole anlegen + +In Guacamole eine normale RDP-Verbindung anlegen. + +Beispielname: + +```text +Office Desktop +``` + +Beim RDP Hostname **keinen festen RDS-Host** eintragen, sondern: + +```text +${SESSIONGUARD_HOST} +``` + +RDP-Port bleibt normalerweise: + +```text +3389 +``` + +Die bisherige RDP-Credential-Konfiguration bleibt unverändert. SessionGuard entscheidet über den Zielhost, ersetzt aber nicht automatisch die Windows-Anmeldeinformationen. + +--- + +## 32. Passende Resource in SessionGuard anlegen + +Im Master-WebUI unter: + +```text +Published Resources +``` + +Beispiel: + +```text +Name: Office Desktop +Typ: desktop +Farm: Office +Guacamole Connection Name: Office Desktop +Enabled: ja +``` + +Wenn möglich langfristig die Guacamole Connection-ID verwenden, da sie gegenüber Umbenennungen stabiler ist. Connection-Namen müssen eindeutig bleiben. + +SessionGuard priorisiert beim Matching: + +```text +Resource-ID +-> Guacamole Connection-ID +-> Guacamole Connection-Name +``` + +Doppelte aktive Zuordnungen werden abgelehnt. + +--- + +## 33. RemoteApp veröffentlichen + +Guacamole-Verbindung: + +```text +hostname: ${SESSIONGUARD_HOST} +remote-app: ${SESSIONGUARD_REMOTE_APP} +remote-app-dir: ${SESSIONGUARD_REMOTE_APP_DIR} +remote-app-args: ${SESSIONGUARD_REMOTE_APP_ARGS} +``` + +SessionGuard Resource zum Beispiel: + +```text +Name: Sage +Typ: remoteapp +Farm: ERP +Remote App: ||Sage +Remote App Dir: C:\Program Files\Sage +Remote App Args: +``` + +Die RemoteApp muss auf allen Hosts der betreffenden Farm konsistent bereitgestellt sein. + +--- + +## 34. Broker-Funktion testen + +### Test 1 – neue Sitzung + +1. Zwei RDS-Hosts auf `online`. +2. Beide in derselben Farm. +3. Beide Health Score >= `min_health_score`. +4. Testbenutzer verbindet sich über die brokered Guacamole-Verbindung. +5. Im Master prüfen, welcher Host ausgewählt wurde. +6. Broker-Lease prüfen. + +### Test 2 – Reconnect + +1. Benutzer trennt die RDS-Sitzung. +2. Benutzer öffnet dieselbe Guacamole Resource erneut. +3. SessionGuard muss denselben RDS-Host zurückgeben. + +### Test 3 – Drain + +1. RDS01 auf `drain` setzen. +2. Neuer Benutzer darf nicht neu auf RDS01 platziert werden. +3. Benutzer mit vorhandener Sitzung auf RDS01 darf dort reconnecten. + +### Test 4 – Maintenance + +1. RDS01 auf `maintenance` setzen. +2. Keine neuen Broker-Verbindungen nach RDS01. +3. Auch vorhandener Reconnect darf nicht dorthin gebrokert werden. + +--- + +# Teil J – Profil-Pipeline + +## 35. Profil-Store vorbereiten + +Beispiel: + +```text +\\fileserver\SessionGuardProfiles +``` + +SessionGuard synchronisiert bewusst nur ausgewählte Profil-Unterordner. + +Geeignete Beispiele: + +```text +Desktop +Documents +AppData\Roaming\Hersteller\Produkt +``` + +Nicht konfigurieren: + +```text +NTUSER.DAT +UsrClass.dat +komplettes C:\Users\ +Registry-Hives +EFS-Schlüsselmaterial +Windows-interne Profilstrukturen +``` + +--- + +## 36. Profilordner konfigurieren + +Im Policy-Editor des Master-WebUIs: + +```text +Profil-Sicherung & Wiederherstellung +``` + +Beispiel: + +```text +Store Root: \\fileserver\SessionGuardProfiles +Backup nach Logoff: ja +Restore bei Logon: zunächst nein +Backup-Verzögerung: 5 Sekunden +Retry: 60 Sekunden +Restore-Fenster: 120 Sekunden +Historische Versionen: 2 +``` + +Profilordner beispielsweise: + +```text +Desktop +Documents +AppData\Roaming\Example +``` + +Exclude-Globs für einen Ordner: + +```text +Cache/** +*.tmp +``` + +--- + +## 37. Backup zuerst ohne Cleanup-Risiko testen + +Zunächst: + +```text +Backup: aktiviert +Restore: deaktiviert +Cleanup: aktiviert +Cleanup Dry-Run: aktiviert +Auto-Logoff: deaktiviert +``` + +Testablauf: + +1. Testbenutzer anmelden. +2. Testdatei in einen synchronisierten Ordner schreiben. +3. Benutzer sauber abmelden. +4. Master öffnen. +5. Aktivitäts-/Profiljob-Log prüfen. +6. SMB-Store prüfen. +7. Sicherstellen, dass ein `current`-Snapshot vorhanden ist. +8. Dry-Run-Eintrag für geplanten Cleanup prüfen. + +Wichtig: + +> Schlägt das Backup fehl, darf SessionGuard das lokale Profil nicht löschen. + +--- + +## 38. Restore aktivieren + +Erst nach erfolgreichem Backup-Test: + +```text +Restore bei Logon = aktiviert +``` + +Test: + +1. Testbenutzer abmelden. +2. Gesicherte Datei lokal gezielt verändern/entfernen, ohne den Backup-Store zu verändern. +3. Benutzer erneut anmelden. +4. Prüfen, dass der letzte `current`-Snapshot innerhalb des Restore-Fensters eingespielt wurde. +5. Master-Aktivitätslog prüfen. + +Der Restore arbeitet als Merge und nicht als vollständiger Profil-Mirror. + +--- + +## 39. Cleanup produktiv aktivieren + +Erst wenn Backup und Restore mehrfach erfolgreich getestet wurden: + +```text +Cleanup Dry-Run = aus +``` + +Empfohlener Startwert: + +```text +Grace: 600 Sekunden +``` + +Pipeline: + +```text +Logoff + | + +-- Backup Delay + | + +-- Backup nach staging + | + +-- Benutzer erneut aktiv? -> Abbruch + | + +-- staging -> current + | + +-- Cleanup Grace + | + +-- finaler WTS-Recheck + | + +-- DeleteProfileW +``` + +Ein Reconnect blockiert gefährliche Operationen. + +--- + +# Teil K – Disconnect Auto-Logoff + +## 40. Getrennte Sitzungen zunächst beobachten + +Im Director prüfen: + +- welche Anwendungen häufig getrennt bleiben +- typische Disconnect-Dauer +- ob bestimmte Service-/Admin-Benutzer ausgeschlossen werden müssen + +Dann Timeout definieren, zum Beispiel: + +```text +3600 Sekunden = 1 Stunde +``` + +--- + +## 41. Auto-Logoff aktivieren + +Policy: + +```text +Sitzungssteuerung erlauben: ja +Getrennte Sitzungen automatisch abmelden: ja +Disconnect Timeout: 3600 +``` + +Wichtig: + +Der automatische Logoff verwendet dieselbe Nachbearbeitung wie ein manueller Logoff: + +```text +Disconnected + | +Timeout erreicht + | +WTSLogoffSession + | +Profil-Backup + | +Cleanup Grace + | +Profil-Cleanup +``` + +Damit wird ein Benutzerprofil nicht einfach gelöscht, bevor die Sicherung abgeschlossen wurde. + +--- + +# Teil L – Templates + +## 42. Templates konfigurieren + +Im Policy-Editor können folgende Typen angelegt werden: + +```text +Datei +Ordner +URL-Verknüpfung +Windows .lnk +``` + +### URL-Beispiel + +```text +ID: support-url +Typ: URL +Ziel: Desktop\Support.url +URL: https://support.example.org +Overwrite: ja +``` + +### Datei von SMB + +```text +ID: settings-file +Typ: Datei +Ziel: AppData\Roaming\Example\defaults.json +Quelle: \\fileserver\sessionguard-templates\defaults.json +Overwrite: ja +``` + +### Windows Shortcut + +```text +ID: company-shortcut +Typ: Shortcut +Ziel: Desktop\Fachanwendung.lnk +Target: C:\Program Files\Example\app.exe +Description: Fachanwendung +Overwrite: ja +``` + +Templates werden geprüft und nur bei Bedarf erzeugt/aktualisiert. + +--- + +# Teil M – Director und Session-Steuerung + +## 43. Director prüfen + +Der Master zeigt pro Server unter anderem: + +```text +Online/Offline +Maintenance Mode +Health Score +CPU +RAM +Systemdisk +RDP Listener +Profile Store +aktive Sitzungen +getrennte Sitzungen +Profiljobs +Cleanupjobs +Prozesse +``` + +Pro Session: + +```text +Session-ID +Benutzer +Status +Logon-Zeit +Idle-Zeit +Clientname +Clientadresse +``` + +--- + +## 44. Sitzung steuern + +Je nach RBAC-Rolle sind verfügbar: + +```text +Nachricht senden +Trennen +Abmelden +Prozess beenden +``` + +Prozessbeendigung wird zusätzlich gegen PID-Wiederverwendung abgesichert, indem der Agent PID und Session-ID vor `TerminateProcess` erneut verifiziert. + +Administrative Aktionen werden im Audit-Log protokolliert. + +--- + +# Teil N – Policies und Rollback + +## 45. Policy-Ebenen + +Die Policy-Priorität lautet: + +```text +Agent Override + > explizite Farm-Zuordnung + > zentral/tagbasiert ermittelte Farm + > globale Policy +``` + +Empfehlung: + +- globale Baseline für alle RDS-Hosts +- Farm-Policy für Office/ERP/etc. +- Agent-Override nur für begründete Ausnahmen + +--- + +## 46. Policy-Änderungen ausrollen + +Für Änderungen zunächst einen Canary-Host verwenden. + +Beispiel: + +1. Policy nur auf RDS03 anwenden. +2. mindestens mehrere echte Logons/Logoffs beobachten. +3. Director, Profiljobs und Alerts prüfen. +4. danach auf Farm oder alle Server ausrollen. + +Das WebUI bietet: + +```text +Für diesen Server speichern +Auf alle anwenden +Editor neu laden +``` + +--- + +## 47. Policy-Rollback + +Unter: + +```text +Policy-Historie & Rollback +``` + +alte Revision auswählen und: + +```text +Rollback +``` + +verwenden. + +Der Rollback löscht die Historie nicht, sondern erzeugt eine neue aktuelle Revision auf Basis der gewählten älteren Version. + +--- + +# Teil O – Alerts und Monitoring + +## 48. Alert-Grenzwerte konfigurieren + +Beispiel: + +```json +"alerts": { + "enabled": true, + "cpu_percent": 90, + "memory_percent": 90, + "disk_free_gb": 10, + "health_score": 50, + "offline_seconds": 120, + "profile_failures": 3, + "disconnected_sessions": 20, + "logon_duration_seconds": 30, + "notification_min_interval_seconds": 900 +} +``` + +Damit werden unter anderem überwacht: + +```text +Agent offline +CPU hoch +RAM hoch +Disk knapp +Health Score zu niedrig +Profilfehler +zu viele getrennte Sessions +langsame beobachtete Logons +``` + +--- + +## 49. Webhook konfigurieren + +Optional: + +```dotenv +SESSIONGUARD_ALERT_WEBHOOK_URL=https://example.org/sessionguard-webhook +``` + +SessionGuard sendet JSON-Webhooks. + +Die Webhook-Zustellung ist Best Effort und ersetzt kein unabhängiges Infrastrukturmonitoring. + +--- + +## 50. Prometheus-Metriken + +Der Master stellt bereit: + +```text +/metrics +``` + +und Health: + +```text +/healthz +``` + +Beide Endpunkte in das bestehende Monitoring integrieren. + +--- + +# Teil P – PostgreSQL und Backup + +## 51. PostgreSQL sichern + +SessionGuard speichert dort unter anderem: + +```text +Agents +Farms +Resources +Broker-Leases +Policy-State +Policy-Historie +Audit +Session-Historie +Alerts +``` + +Regelmäßiges Backup beispielsweise mit `pg_dump`: + +```bash +docker compose exec -T sessionguard-postgres \ + pg_dump -U sessionguard -d sessionguard -Fc \ + > sessionguard-$(date +%F).dump +``` + +Das Profil-Share wird **nicht** in PostgreSQL gespeichert und muss separat gesichert werden. + +--- + +## 52. Master-HA beachten + +v0.3 ist als **Single-Active-Master** ausgelegt. + +Der Master hält einen PostgreSQL Advisory Lock. Ein zweiter aktiver Master gegen dieselbe DB soll dadurch nicht gleichzeitig als Control Plane arbeiten. + +Für manuellen Failover: + +1. alten Master stoppen bzw. dessen Ausfall sicher feststellen +2. PostgreSQL-Verfügbarkeit prüfen +3. Ersatz-Master gegen dieselbe DB starten +4. `/healthz` prüfen +5. Agent-Heartbeats prüfen +6. Broker-Test durchführen + +Kein Active/Active-Deployment konfigurieren. + +--- + +# Teil Q – Produktions-Rollout + +## 53. Empfohlene Rollout-Reihenfolge + +### Phase 1 – Infrastruktur + +- [ ] SessionGuard PostgreSQL bereitstellen +- [ ] SessionGuard Master bereitstellen +- [ ] HTTPS/Traefik konfigurieren +- [ ] PocketID OIDC testen +- [ ] RBAC testen +- [ ] PostgreSQL-Backup testen + +### Phase 2 – erster Agent + +- [ ] einen Canary-RDS-Host wählen +- [ ] Agent installieren +- [ ] Heartbeat prüfen +- [ ] Director-Telemetrie prüfen +- [ ] Cleanup Dry-Run aktiv lassen +- [ ] Auto-Logoff deaktiviert lassen + +### Phase 3 – Guacamole Broker + +- [ ] SessionGuard-Guacamole-Image deployen +- [ ] Extension-Startup prüfen +- [ ] Canary-Farm erstellen +- [ ] eine Published Resource erstellen +- [ ] `${SESSIONGUARD_HOST}` in Guacamole verwenden +- [ ] neue Sitzung testen +- [ ] Reconnect testen + +### Phase 4 – zweite RDS-Node + +- [ ] zweiten Agent hinzufügen +- [ ] gleiche Farm +- [ ] Broker-Verteilung testen +- [ ] Drain testen +- [ ] Maintenance testen +- [ ] Restart when drained testen + +### Phase 5 – Profile + +- [ ] Profil-Backup aktivieren +- [ ] Backup mehrfach testen +- [ ] SMB-Ausfall simulieren +- [ ] sicherstellen, dass Cleanup bei Backupfehler blockiert bleibt +- [ ] Restore aktivieren +- [ ] Restore mehrfach testen +- [ ] Reconnect während Backup testen +- [ ] erst danach Cleanup Dry-Run deaktivieren + +### Phase 6 – Session Lifecycle + +- [ ] Disconnect-Dauer beobachten +- [ ] sinnvollen Timeout festlegen +- [ ] Ausschlussbenutzer festlegen +- [ ] Auto-Logoff aktivieren +- [ ] Backup-Pipeline nach Auto-Logoff prüfen + +### Phase 7 – breite Einführung + +- [ ] weitere Farms anlegen +- [ ] weitere RDS-Hosts aufnehmen +- [ ] Policies zentralisieren +- [ ] Alerts anbinden +- [ ] Monitoring anbinden +- [ ] Betriebsdokumentation/Notfallzugang dokumentieren + +--- + +# Teil R – Funktionstests vor Produktivfreigabe + +## 54. Mindest-Testmatrix + +### Broker + +- [ ] neue Verbindung wird auf gesunden Online-Host gelegt +- [ ] Benutzer reconnectet auf seinen vorhandenen Host +- [ ] Drain blockiert neue Sessions +- [ ] Drain erlaubt Reconnect +- [ ] Maintenance blockiert Brokerzugriff +- [ ] Farm-Isolation funktioniert +- [ ] Lease-Race bei zwei fast gleichzeitigen Verbindungsversuchen funktioniert + +### Session Control + +- [ ] Nachricht +- [ ] Disconnect +- [ ] Logoff +- [ ] getrennte Sessions gesammelt abmelden +- [ ] Prozess beenden +- [ ] Audit-Eintrag vorhanden + +### Profile + +- [ ] normaler Backup-Job +- [ ] Restore +- [ ] Snapshot-History +- [ ] SMB-Ausfall +- [ ] Reconnect während Backup +- [ ] Reconnect unmittelbar vor Cleanup +- [ ] Cleanup Dry-Run +- [ ] echter Cleanup erst nach erfolgreichem Backup + +### Director + +- [ ] Logon erscheint +- [ ] Disconnect erscheint +- [ ] Reconnect erscheint +- [ ] Logoff erscheint +- [ ] Prozesse sichtbar +- [ ] CPU/RAM/Disk sichtbar +- [ ] Agent Offline Alert funktioniert + +### Security + +- [ ] Viewer kann nicht administrieren +- [ ] Helpdesk-Rechte sind begrenzt +- [ ] Operator kann Maintenance/Sessions steuern +- [ ] Policy Admin kann Policy ändern/rollbacken +- [ ] fremder Container kann Guacamole nicht per gefälschtem `X-Guacamole-User` umgehen + +--- + +# Teil S – Updates + +## 55. Master aktualisieren + +Vorher PostgreSQL sichern. + +Dann neuen Tag setzen, beispielsweise: + +```dotenv +SESSIONGUARD_VERSION=0.3.1 +``` + +Update: + +```bash +docker compose pull sessionguard-master +docker compose up -d sessionguard-master +``` + +Prüfen: + +```bash +docker compose logs --tail=200 sessionguard-master +``` + +und: + +```text +/healthz +OIDC Login +Dashboard +Agent Heartbeats +Broker +``` + +--- + +## 56. Guacamole Extension aktualisieren + +Gleichen SessionGuard-Release-Tag verwenden: + +```dotenv +SESSIONGUARD_VERSION=0.3.1 +``` + +Dann: + +```bash +docker compose pull guacamole +docker compose up -d guacamole +``` + +Guacamole-Logs unmittelbar prüfen. + +Master und Guacamole-Extension sollten möglichst aus demselben SessionGuard-Release stammen. + +--- + +## 57. Windows Agents aktualisieren + +Pro Farm in Wellen: + +1. Host auf `drain` setzen. +2. Sitzungen auslaufen lassen. +3. optional `restart_when_drained` verwenden. +4. neuen Agent-Build kopieren/installieren. +5. State unter `C:\ProgramData\SessionGuard` erhalten. +6. Dienst prüfen. +7. Heartbeat/Health im Master prüfen. +8. Host wieder `online` setzen. +9. nächster Host. + +Installer erneut ausführen: + +```powershell +.\scripts\install-agent.ps1 ` + -Binary .\bin\sessionguard-agent.exe ` + -Config .\configs\agent.json ` + -GmsaAccount "DOMAIN\svc-sessionguard$" +``` + +--- + +# Teil T – Troubleshooting + +## 58. Agent erscheint nicht im Master + +Prüfen: + +```text +Master URL korrekt? +DNS vom RDS-Host? +HTTPS-Zertifikat vertrauenswürdig? +Enrollment Token identisch? +Firewall/Proxy? +SessionGuardAgent läuft? +``` + +PowerShell: + +```powershell +Get-Service SessionGuardAgent +Test-NetConnection sessionguard.hilden.info -Port 443 +``` + +Master-Logs: + +```bash +docker compose logs -f sessionguard-master +``` + +--- + +## 59. Guacamole meldet Brokerfehler + +Prüfen: + +```text +SessionGuard Master erreichbar? +SESSIONGUARD_BROKER_API_KEY identisch? +Guacamole und Master im sessionguard_control Netzwerk? +Farm aktiviert? +Resource aktiviert? +Resource korrekt mit Guacamole Connection gemappt? +mindestens ein gesunder Online-Host in Farm? +``` + +Netzwerk prüfen: + +```bash +docker network inspect sessionguard_control +``` + +Guacamole-Logs: + +```bash +docker compose logs -f guacamole +``` + +--- + +## 60. Broker findet keinen Server + +Prüfen: + +```text +Farm enabled? +Agent online? +Agent in Farm? +Required Tags erfüllt? +Maintenance Mode = online? +Health Score >= min_health_score? +Resource zeigt auf richtige Farm? +``` + +Ein Host auf `drain` ist nur für Reconnects geeignet, nicht für neue Benutzer. + +Ein Host auf `maintenance` wird auch für Reconnect nicht verwendet. + +--- + +## 61. Profil-Backup schlägt fehl + +Prüfen: + +- Dienstkonto des Agenten +- Share-Rechte +- NTFS-Rechte +- DNS zum Fileserver +- SMB-Erreichbarkeit +- freier Speicher +- gesperrte Dateien +- Pfad in `store_root` + +Mit gMSA besonders wichtig: Tests müssen im Kontext des Dienstkontos betrachtet werden, nicht nur mit dem eigenen Admin-Account. + +Bei Backupfehler darf der Cleanup nicht fortgesetzt werden. + +--- + +## 62. Restore wird nicht ausgeführt + +Prüfen: + +```text +restore_on_logon=true? +current Snapshot vorhanden? +SMB erreichbar? +Benutzer/SID ausgeschlossen? +Restore-Fenster überschritten? +Agent während Logon aktiv? +``` + +Ein Restore, der das konfigurierte Restore-Fenster überschritten hat, wird bewusst nicht verspätet in eine bereits laufende Sitzung injiziert. + +--- + +## 63. OIDC Redirect-Fehler + +Die Redirect URI muss Zeichen für Zeichen zum PocketID-Client passen. + +Master: + +```text +https://sessionguard.hilden.info/oidc/callback +``` + +ForwardAuth entsprechend der vorhandenen ENV: + +```text +https://guacauth.hilden.info/portals/guacamole/oauth2/callback +``` + +Agent beispielsweise: + +```text +https://rds01-mgmt.hilden.info/oidc/callback +``` + +Außerdem `public_url`, `redirect_url`, DNS und TLS prüfen. + +--- + +## 64. PostgreSQL startet nicht + +```bash +docker compose logs sessionguard-postgres +``` + +Prüfen: + +```text +SESSIONGUARD_DB_PASSWORD gesetzt? +Volume beschreibbar? +Disk voll? +PostgreSQL-Volume aus inkompatibler Major-Version? +``` + +PostgreSQL Major Upgrades nicht durch simples Austauschen von `17` auf eine spätere Major-Version durchführen; dafür das normale PostgreSQL-Upgradeverfahren verwenden. + +--- + +# Teil U – Empfohlene Produktionswerte + +## 65. Startwerte + +Diese Werte sind ein sinnvoller Ausgangspunkt, müssen aber zur eigenen Umgebung passen: + +| Einstellung | Startwert | +|---|---:| +| Agent Heartbeat | 10 s | +| Agent offline nach | 30 s | +| Broker Lease | 900 s | +| Broker min. Health | 60 | +| Profil Backup Delay | 5 s | +| Profil Retry | 60 s | +| Restore Window | 120 s | +| Snapshot History | 2 | +| Cleanup Grace | 600 s | +| Cleanup Poll | 10 s | +| Cleanup Retry | 60 s | +| Disconnect Auto-Logoff | zunächst aus | +| Disconnect Timeout | später z. B. 3600 s | +| CPU Alert | 90 % | +| RAM Alert | 90 % | +| Disk Alert | < 10 GB | +| Alert Repeat Minimum | 900 s | + +--- + +# Teil V – Go-Live-Checkliste + +## 66. Go-Live + +Vor dem breiten Rollout müssen alle Punkte erfüllt sein: + +- [ ] SessionGuard Master nur per HTTPS administrierbar +- [ ] getrennte Secrets für DB/OIDC/Broker/Enrollment +- [ ] RBAC-Gruppen eingerichtet +- [ ] PostgreSQL-Backup erfolgreich getestet +- [ ] SessionGuard Master Restore-Verfahren dokumentiert +- [ ] Guacamole Header-Auth kann nicht direkt aus fremden Netzen umgangen werden +- [ ] Guacamole Extension lädt fehlerfrei +- [ ] Brokered Desktop funktioniert +- [ ] Reconnect Affinity funktioniert +- [ ] Drain funktioniert +- [ ] Maintenance funktioniert +- [ ] mindestens zwei RDS-Hosts in einer Canary-Farm getestet +- [ ] Agent-Dienst Recovery getestet +- [ ] Profil-Backup mehrfach erfolgreich +- [ ] Profil-Restore mehrfach erfolgreich +- [ ] SMB-Ausfall getestet +- [ ] Reconnect während Profil-Backup getestet +- [ ] Cleanup Dry-Run überprüft +- [ ] echter Profil-Cleanup nur nach erfolgreichem Backup aktiviert +- [ ] Auto-Logoff separat getestet +- [ ] Audit-Log geprüft +- [ ] Alerts geprüft +- [ ] `/healthz` überwacht +- [ ] `/metrics` überwacht +- [ ] Break-Glass-RDP-Zugang dokumentiert +- [ ] Upgrade- und Rollback-Verfahren dokumentiert + +--- + +# 67. Empfohlene Reihenfolge für diese konkrete Umgebung + +Für die vorhandene Guacamole/PocketID-Installation würde ich genau so vorgehen: + +```text +1. sessionguard.hilden.info in DNS anlegen +2. separaten PocketID OIDC Client für SessionGuard erstellen +3. RBAC-Gruppen in PocketID erstellen/zuweisen +4. sessionguard_control Docker-Netz erstellen +5. SessionGuard PostgreSQL + Master deployen +6. OIDC/RBAC im Master prüfen +7. einen RDS-Canary auswählen +8. Agent dort installieren +9. Director/Health prüfen +10. Office-Canary-Farm erstellen +11. Guacamole auf sessionguard-guacamole: umstellen +12. Guacamole mit sessionguard_control verbinden +13. eine neue Guacamole-Testconnection mit ${SESSIONGUARD_HOST} erstellen +14. passende Published Resource in SessionGuard erstellen +15. Broker-Verbindung testen +16. Disconnect/Reconnect testen +17. zweiten Canary-RDS-Host hinzufügen +18. Load Placement testen +19. Drain/Maintenance testen +20. Profil-Backup testen +21. Restore testen +22. Cleanup Dry-Run beobachten +23. Cleanup aktivieren +24. Disconnect Auto-Logoff aktivieren +25. danach schrittweise die restlichen RDS-Hosts aufnehmen +``` + +Damit wird nicht gleichzeitig Gateway, Broker, Profilmanagement und Session-Lifecycle produktiv umgestellt. Jede Schicht kann einzeln validiert und bei Problemen zurückgenommen werden. + +--- + +# 68. Wichtige Betriebsgrenzen + +SessionGuard ersetzt in diesem Stand nicht: + +- ICA/HDX +- den Windows RDS Session Host +- einen vollständigen Windows Profile Provider wie FSLogix/UPM +- Active/Active-Master-HA +- die eigentliche RDP-Credential-Authentifizierung + +SessionGuard übernimmt stattdessen gezielt: + +```text +Broker +Reconnect Affinity +Farms +Drain / Maintenance +Director +Session Control +Profile Folder Lifecycle +Templates +Policies +RBAC +Audit +Alerts +``` + +Guacamole bleibt das Gateway und Windows bleibt die Session Runtime. + +--- + +## 69. Relevante Projektdateien + +Im Repository: + +```text +README.md +configs/master.example.json +configs/agent.example.json +deploy/docker-compose.yml +deploy/.env.example +deploy/guacamole/Dockerfile.guacamole +deploy/guacamole/docker-compose.sessionguard.example.yml +deploy/guacamole/CI-CD.md +scripts/build.ps1 +scripts/install-agent.ps1 +docs/ARCHITECTURE.md +docs/BROKER.md +docs/DIRECTOR.md +docs/GUACAMOLE.md +docs/OPERATIONS.md +docs/PROFILE-LIFECYCLE.md +docs/RBAC.md +docs/SECURITY.md +docs/TESTING.md +``` + +Diese Anleitung sollte zusammen mit `docs/TESTING.md` als Go-Live-Grundlage verwendet werden. diff --git a/go.mod b/go.mod index b71d43e..1c21553 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/example/sessionguard -go 1.23.0 +go 1.26.0 require ( github.com/coreos/go-oidc/v3 v3.14.1 diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml index cfe6947..1b59dd5 100644 --- a/guacamole-extension/pom.xml +++ b/guacamole-extension/pom.xml @@ -5,7 +5,7 @@ 4.0.0 info.hilden.sessionguard sessionguard-guacamole - 0.3.0 + 0.3.1 jar 11 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0e102f4..28130ce 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -25,7 +25,7 @@ import ( "github.com/example/sessionguard/internal/windowsx" ) -const Version = "0.3.0" +const Version = "0.3.1" type App struct { cfg config.Agent @@ -981,6 +981,7 @@ func (a *App) serveHTTP(ctx context.Context) error { } mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, agentJS) }) mux.Handle("GET /", secure(http.HandlerFunc(a.agentPage))) @@ -1104,5 +1105,6 @@ func securityHeaders(next http.Handler) http.Handler { } func (a *App) agentPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, agentHTML) } diff --git a/internal/agent/ui.go b/internal/agent/ui.go index 6552fcf..8b80cce 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -2,10 +2,10 @@ package agent const agentHTML = `SessionGuard Agent
SessionGuard Agent
Lokaler Terminalserver
Aktiv
Getrennt
Sitzungen
Profil-Jobs
Cleanup
Master

Sitzungen

Profil-Pipeline

Aktivitäts- & Audit-Log

Lokale Policy

` +
SessionGuard Agent
Lokaler Terminalserver
Aktiv
Getrennt
Sitzungen
Profil-Jobs
Cleanup
Master

Sitzungen

Profil-Pipeline

Aktivitäts- & Audit-Log

Lokale Policy

` const agentJS = ` -let policyTemplates=[],profileFolders=[],lastSnapshot=null; +let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false; const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function when(v){if(!v)return '–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function dur(v){if(!v)return '–';let s=Math.max(0,Math.floor((Date.now()-new Date(v).getTime())/1000));return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m'} function renderEvents(events){let rows=(events||[]).slice().reverse().slice(0,200);$('events').innerHTML=rows.length?'
'+rows.map(x=>'').join('')+'
ZeitTypBenutzerMeldung
'+esc(when(x.time))+''+esc(x.level||'info')+''+esc(x.user||'–')+''+esc(x.message||'')+'
':'
Noch keine Ereignisse.
'} @@ -18,7 +18,8 @@ function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=poli function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})} function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'
'+esc(f.path||('Ordner '+(i+1)))+'
').join(''):'
Keine Profilordner konfiguriert.
'} function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)} -function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'
Profil-Sicherung & Wiederherstellung
Zu sichernde Profilordner
Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.
Sitzungsrichtlinie
Profil-Cleanup
Templates
'} -async function loadPolicy(){try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders()}catch(e){$('policy').textContent=e.message}} -async function savePolicy(){try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy();refresh()}catch(e){alert(e.message)}} -$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});refresh();loadPolicy();setInterval(refresh,5000);` +function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'
Profil-Sicherung & Wiederherstellung
Zu sichernde Profilordner
Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.
Sitzungsrichtlinie
Profil-Cleanup
Templates
'} +function updatePolicyDirty(){let e=$('policyDirtyState');if(e)e.textContent=policyDirty?'Ungespeicherte Änderungen':''}function markPolicyDirty(){policyDirty=true;updatePolicyDirty()}async function reloadPolicy(){if(policyDirty&&!confirm('Ungespeicherte Änderungen verwerfen und Policy neu laden?'))return;policyDirty=false;await loadPolicy(true)} +async function loadPolicy(force){if(policyDirty&&!force)return;try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders();policyDirty=false;policyLoaded=true;updatePolicyDirty()}catch(e){if(!policyLoaded)$('policy').textContent=e.message}} +async function savePolicy(){try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});policyDirty=false;await loadPolicy(true);refresh()}catch(e){alert(e.message)}} +$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='reload-policy')reloadPolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates();markPolicyDirty()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates();markPolicyDirty()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders();markPolicyDirty()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders();markPolicyDirty()}});$('policy').addEventListener('input',e=>{if(e.target.matches('input,textarea,select'))markPolicyDirty()});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates();markPolicyDirty()}});refresh();loadPolicy(true);setInterval(refresh,5000);` diff --git a/internal/master/master.go b/internal/master/master.go index 66467db..b0f279e 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -23,7 +23,7 @@ import ( "github.com/example/sessionguard/internal/model" ) -const Version = "0.3.0" +const Version = "0.3.1" type App struct { cfg config.Master @@ -58,6 +58,7 @@ func (a *App) Run(ctx context.Context) error { mux.HandleFunc("POST /api/v1/broker/tokens", a.brokerTokens) mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, masterJS) }) mux.Handle("GET /", a.auth.Require(http.HandlerFunc(a.masterPage))) @@ -1481,6 +1482,7 @@ func requestActor(r *http.Request) string { func (a *App) masterPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, masterHTML) } func randomToken(n int) string { diff --git a/internal/master/ui.go b/internal/master/ui.go index f8918fb..99dbe51 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -2,16 +2,17 @@ package master const masterHTML = `SessionGuard Master
SessionGuard
Master Console · v0.3 · Broker & Director
Server
Online
Aktiv
Getrennt
Profil-Jobs
Cleanup
Farms
Alerts

Terminalserver

Server auswählen

Links einen Agent auswählen.

Farms & Broker

Published Resources

Session-Historie

Policy-Historie & Rollback

Alerts

Audit-Log

` +
SessionGuard
Master Console · v0.3.1 · Broker & Director
Server
Online
Aktiv
Getrennt
Profil-Jobs
Cleanup
Farms
Alerts

Terminalserver

Server auswählen

Links einen Agent auswählen.

Farms & Broker

Published Resources

Session-Historie

Policy-Historie & Rollback

Alerts

Audit-Log

` const masterJS = ` let selected=null,current=null,editorAgent=null,policyTemplates=[],profileFolders=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)} function renderAgentTable(a){$('agents').innerHTML=''+a.map(x=>'').join('')+'
StatusServerModeHealthSitzungenJobs
'+(x.online?'Online':'Offline')+''+esc(x.name)+'
'+esc(x.snapshot.agent_version||'–')+'
'+esc(x.maintenance_mode||'online')+''+esc((x.snapshot.health||{}).score??'–')+''+x.active_sessions+' aktiv / '+x.total_sessions+''+((x.snapshot.profile_jobs||[]).length)+' P / '+((x.snapshot.pending_cleanup||[]).length)+' C
';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id,false,false))} -async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];renderFarms();let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];renderResources();let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='
Policy-Historie erfordert Policy-Admin/Admin-Rolle.
'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='
Audit-Log erfordert Auditor/Admin-Rolle.
'}}catch(e){toast(e.message)}} +async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();renderResources();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='
Policy-Historie erfordert Policy-Admin/Admin-Rolle.
'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='
Audit-Log erfordert Auditor/Admin-Rolle.
'}}catch(e){toast(e.message)}} async function selectAgent(id,quiet,preserve){let same=selected===id;selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current,!!preserve&&same)}catch(e){if(!quiet)toast(e.message)}} function renderPolicyHistory(){let rows=(policyHistory||[]).slice().reverse().slice(0,250);$('policyHistory').innerHTML=rows.length?''+rows.map(x=>'').join('')+'
ZeitZielRevisionAkteur
'+when(x.created_at)+''+esc(x.target)+''+esc(x.revision)+''+esc(x.actor||'–')+'
':'
Noch keine Policy-Versionen.
'} function renderAudit(rows){rows=(rows||[]).slice().reverse().slice(0,250);$('audit').innerHTML=rows.length?''+rows.map(x=>'').join('')+'
ZeitAkteurAktionZielErgebnis
'+esc(when(x.time))+''+esc(x.actor)+''+esc(x.action)+''+esc(x.target||'–')+''+esc(x.result)+(x.details?'
'+esc(x.details)+'':'')+'
':'
Noch keine Audit-Einträge.
'} -function overview(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0,tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'
OS
'+esc(v.os||'–')+'
Health
'+esc(h.score??'–')+'/100
CPU
'+Number(v.cpu_percent||0).toFixed(1)+' %
RAM
'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei
Systemdisk
'+bytes(v.disk_free)+' frei
Heartbeat
'+when(a.last_seen)+'
RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'
'} +function overviewStats(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0;return'
OS
'+esc(v.os||'–')+'
Health
'+esc(h.score??'–')+'/100
CPU
'+Number(v.cpu_percent||0).toFixed(1)+' %
RAM
'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei
Systemdisk
'+bytes(v.disk_free)+' frei
Heartbeat
'+when(a.last_seen)+'
RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'
'} +function controlHTML(a){let tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'
'} async function sessionAction(id,action){if(!selected)return;let body={action};if(action==='message'){let m=prompt('Nachricht an Sitzung '+id+':');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profilsicherung startet nach dem Sitzungsende.'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Sitzungsaktion an Agent gesendet');setTimeout(()=>selectAgent(selected,true,true),1000)}catch(e){toast(e.message)}} function sessionsHTML(a){let s=a.snapshot||{},ss=s.sessions||[],p=(a.desired_policy&&a.desired_policy.revision)?a.desired_policy:s.policy,control=p&&p.sessions&&p.sessions.control_enabled;return ss.length?''+ss.map(x=>'').join('')+'
IDBenutzerStatusLogon / IdleClientAktionen
'+x.id+''+esc((x.domain?x.domain+'\\':'')+x.user)+''+esc(x.state)+''+esc(when(x.logon_at))+'
Idle '+Math.round((x.idle_seconds||0)/60)+' min
'+esc(x.client_name||'–')+'
'+esc(x.client_address||'')+'
'+(control&&x.user?'
':'–')+'
':'
Keine Benutzersitzungen.
'} function profileHTML(a){let s=a.snapshot||{},jobs=s.profile_jobs||[],st=Object.values(s.profile_status||{});let h=jobs.length?''+jobs.map(j=>'').join('')+'
OperationBenutzerFälligFehler
'+esc(j.operation)+''+esc(j.user)+''+when(j.due_at)+''+esc(j.last_error||'–')+'
':'';if(st.length)h+=''+st.map(x=>'').join('')+'
BenutzerBackupRestoreFehler
'+esc(x.user||x.sid)+''+when(x.last_backup_at)+''+when(x.last_restore_at)+''+esc(x.last_backup_error||x.last_restore_error||'–')+'
';return h||'
Keine Profil-Jobs/Statusdaten.
'} @@ -19,11 +20,11 @@ function processesHTML(a){let ps=((a.snapshot||{}).processes||[]).filter(p=>p.se function telemetryHTML(a){let rows=Object.values(((a.snapshot||{}).telemetry)||{}).sort((x,y)=>new Date(y.first_seen_at)-new Date(x.first_seen_at)).slice(0,100);return rows.length?''+rows.map(x=>'').join('')+'
SessionBenutzerLogon erkanntRestoreBis Ready
'+x.session_id+''+esc(x.user||x.sid||'–')+''+when(x.logon_at||x.first_seen_at)+''+((x.restore_duration_ms||0)/1000).toFixed(2)+' s'+((x.observed_logon_ms||0)/1000).toFixed(2)+' s
':'
Noch keine Logon-Telemetrie.
'} function renderHistory(rows){rows=(rows||[]).slice().reverse();$('history').innerHTML=rows.length?''+rows.map(x=>'').join('')+'
ZeitServerBenutzerEreignisClientDetails
'+when(x.time)+''+esc(x.hostname)+''+esc(x.user)+''+esc(x.event)+''+esc(x.client_name||'–')+''+esc(x.details||'–')+'
':'
Noch keine Session-Historie.
'} function renderAlerts(rows){$('alerts').innerHTML=rows.length?''+rows.map(x=>'').join('')+'
StatusServerTypMeldungZuletzt
'+(x.active?'AKTIV':'gelöst')+''+esc(x.hostname||'–')+''+esc(x.type)+''+esc(x.message)+''+when(x.last_seen_at)+'
':'
Keine Alerts.
'} -function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function renderFarms(){let lease=brokerLeases.length?'

Aktive Broker-Leases

'+brokerLeases.map(l=>'').join('')+'
BenutzerAgentFarmResourceGrundBis
'+esc(l.user_key)+''+esc(l.agent_id)+''+esc(l.farm_id||'–')+''+esc(l.resource_id||'–')+''+esc(l.reason||'–')+''+when(l.expires_at)+'
':'';$('farms').innerHTML='
'+(farmCache.length?''+farmCache.map(f=>'').join('')+'
NameIDAgentenRequired Tags
'+esc(f.name)+''+esc(f.id)+''+esc((f.agent_ids||[]).length)+''+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'
':'
Noch keine Farms.
')+lease} -function renderResources(){let opts=farmCache.map(f=>'').join('');$('resources').innerHTML='
'+(resourceCache.length?''+resourceCache.map(x=>'').join('')+'
NameTypFarmGuacamoleRemoteApp
'+esc(x.name)+''+esc(x.kind)+''+esc(x.farm_id)+''+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+''+esc(x.remote_app||'–')+'
':'
Noch keine Resources.
')} +function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function renderFarms(){let root=$('farms');if(!root)return;if(!$('farmEditor'))root.innerHTML='
';let lease=brokerLeases.length?'

Aktive Broker-Leases

'+brokerLeases.map(l=>'').join('')+'
BenutzerAgentFarmResourceGrundBis
'+esc(l.user_key)+''+esc(l.agent_id)+''+esc(l.farm_id||'–')+''+esc(l.resource_id||'–')+''+esc(l.reason||'–')+''+when(l.expires_at)+'
':'';$('farmData').innerHTML=(farmCache.length?''+farmCache.map(f=>'').join('')+'
NameIDAgentenRequired Tags
'+esc(f.name)+''+esc(f.id)+''+esc((f.agent_ids||[]).length)+''+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'
':'
Noch keine Farms.
')+lease} +function renderResources(){let root=$('resources');if(!root)return;if(!$('resourceEditor'))root.innerHTML='
';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').innerHTML=resourceCache.length?''+resourceCache.map(x=>'').join('')+'
NameTypFarmGuacamoleRemoteApp
'+esc(x.name)+''+esc(x.kind)+''+esc(x.farm_id)+''+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+''+esc(x.remote_app||'–')+'
':'
Noch keine Resources.
'} function eventsHTML(a){let e=((a.snapshot||{}).events||[]).slice().reverse().slice(0,160);return e.length?''+e.map(x=>'').join('')+'
ZeitTypBenutzerMeldung
'+when(x.time)+''+esc(x.level)+''+esc(x.user||'–')+''+esc(x.message)+'
':'
Keine Ereignisse.
'} function activePolicy(a){if(a.desired_policy&&a.desired_policy.revision)return a.desired_policy;if(a.snapshot&&a.snapshot.policy)return a.snapshot.policy;return{cleanup:{grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,allowed_profile_roots:['C:\\Users']},profiles:{retry_seconds:60,keep_versions:2,folders:[]},sessions:{disconnected_timeout_seconds:3600},templates:[]}} -function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overview')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='
Sitzungen
Logon Performance
Prozesse
Profil-Pipeline
Agent-Ereignisse
Policy
'+policyForm(p,a)+'
';editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overview').innerHTML=overview(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)} +function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overviewStats')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='
Serversteuerung
Sitzungen
Logon Performance
Prozesse
Profil-Pipeline
Agent-Ereignisse
Policy
'+policyForm(p,a)+'
';$('controlEditor').innerHTML=controlHTML(a);editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overviewStats').innerHTML=overviewStats(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)} function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'
Keine weiteren Angaben.
';if(k==='url')return'';if(k==='shortcut'){let s=t.shortcut||{};return'
'}return'
'} function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'
'+esc(t.id||('Template '+(i+1)))+'
'+templateSpecific(t)+'
').join(''):'
Keine Templates.
'} function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})} @@ -34,5 +35,5 @@ async function bulkSessionAction(action,scope){if(!selected)return;let body={act async function saveAgentControl(){if(!selected)return;let tags={};($('agentTags').value||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)tags[x.slice(0,i).trim()]=x.slice(i+1).trim()});let body={mode:$('agentMode').value,restart_when_drained:$('restartDrained').checked,tags,farm_ids:$('agentFarms').value.split('\n').map(x=>x.trim()).filter(Boolean)};try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/control',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Serversteuerung gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}} async function killProcess(pid){if(!selected||!confirm('Prozess '+pid+' wirklich beenden?'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/processes/'+pid+'/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});toast('Prozess-Beenden an Agent gesendet')}catch(e){toast(e.message)}} async function savePolicy(all){if(!selected)return;try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api(all?'/api/v1/policy/all':'/api/v1/agents/'+encodeURIComponent(selected)+'/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});toast(all?'Policy auf alle Server angewendet':'Policy gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}} -$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='create-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;await api('/api/v1/resources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),enabled:true})});toast('Resource angelegt');await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}}); +$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&¤t&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){$('resName').value='';$('resKind').value='desktop';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';return}if(a==='create-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;await api('/api/v1/resources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),enabled:true})});$('resName').value='';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';toast('Resource angelegt');await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}}); refresh();setInterval(refresh,5000);`