From b61b2d2ef1fce0d92dd6f44918b7eb43ab874148 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sun, 23 Aug 2026 08:31:17 +0200 Subject: [PATCH] 0.5.0 --- CHANGELOG.md | 13 + README.md | 16 +- configs/master.example.json | 33 +- deploy/.env.example | 1 + deploy/docker-compose.yml | 1 + deploy/guacamole/CI-CD.md | 9 +- deploy/guacamole/README.md | 24 + .../docker-compose.sessionguard.example.yml | 2 +- docs/ACCESS-AUTH.md | 144 +++++ docs/INSTALLATION.md | 24 +- guacamole-extension/pom.xml | 2 +- .../src/main/resources/guac-manifest.json | 5 +- .../main/resources/js/sessionguard-access.js | 93 ++++ internal/agent/agent.go | 2 +- internal/agent/ui.go | 4 +- internal/auth/access.go | 507 ++++++++++++++++++ internal/auth/access_helpers_test.go | 62 +++ internal/config/config.go | 71 ++- internal/config/config_test.go | 26 + internal/master/auth_store.go | 78 +++ internal/master/master.go | 92 +++- internal/master/store.go | 6 +- internal/master/ui.go | 14 +- internal/model/types.go | 37 ++ 24 files changed, 1212 insertions(+), 54 deletions(-) create mode 100644 docs/ACCESS-AUTH.md create mode 100644 guacamole-extension/src/main/resources/js/sessionguard-access.js create mode 100644 internal/auth/access.go create mode 100644 internal/auth/access_helpers_test.go create mode 100644 internal/master/auth_store.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e7138..a4444d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.5.0 — Integrated Guacamole Access Auth + +- SessionGuard Master now provides `/auth/verify` as a Traefik ForwardAuth endpoint for Guacamole. +- Added a dedicated PocketID/OIDC `access_auth` flow, independent from the Master administration login. +- Added opaque server-side access sessions persisted in the existing control-plane store; only the SHA-256 browser-token hash is used as the lookup key. +- Added strict `X-Guacamole-User` emission only for valid access sessions, group restrictions and allowed return-host validation. +- Added RP-initiated OIDC logout using the discovered `end_session_endpoint`. +- Added OIDC Back-Channel Logout with signature/issuer/audience/event/`iat`/`jti` validation and replay protection. +- Added concurrent-safe per-flow OIDC state cookies and external-prefix-aware callback cookie paths. +- Guacamole extension now ships a framework-free JS helper which redirects Guacamole logout into full SessionGuard/PocketID logout and periodically detects revoked/expired access sessions. +- Added Traefik header-scrubbing/ForwardAuth deployment guidance and migration away from `traefik-forward-auth`. +- Agent protocol remains version 4; no Agent data/schema migration is required. + ## 0.4.1 — RemoteApp PowerShell/CLIXML robustness - RemoteApp PowerShell execution now keeps stderr separate from JSON stdout. diff --git a/README.md b/README.md index 31b8a02..d4d9c36 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,22 @@ 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.4.1 (RemoteApp PowerShell/CLIXML robustness)** +**Current development version: 0.5.0 (integrated Guacamole Access Auth / ForwardAuth)** > 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.5.0 Integrated Guacamole Access Auth + +- `sessionguard-master` can now act directly as Traefik ForwardAuth for Guacamole; no separate forward-auth container is required. +- PocketID OIDC login for Guacamole uses a separate `access_auth` configuration/cookie scope from the SessionGuard admin UI. +- Browser sessions are opaque, server-side and persisted in the existing Master store; expired/revoked sessions are denied even if Guacamole still has an old auth token. +- `X-Guacamole-User` is emitted only after a valid SessionGuard access-session check. There is no fallback identity. +- OIDC RP-initiated logout and PocketID back-channel logout are supported; back-channel tokens are signature/audience/event/time/JTI checked with replay protection. +- The Guacamole extension now includes plain JavaScript which detects Guacamole logout and performs the full SessionGuard/PocketID logout, while also polling access-session status to close stale browser sessions. +- Recommended deployment exposes `/_sessionguard/auth/*` on the Guacamole hostname and routes that prefix to the same Master container. + +See `docs/ACCESS-AUTH.md` for the PocketID and Traefik migration. + ## 0.4.1 RemoteApp robustness Windows PowerShell auxiliary streams are now isolated from the JSON protocol used by RemoteApp discovery/reconciliation. CLIXML/progress noise no longer breaks RemoteApp status decoding. @@ -313,4 +325,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.4.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.5.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. diff --git a/configs/master.example.json b/configs/master.example.json index c505ac1..f9b1883 100644 --- a/configs/master.example.json +++ b/configs/master.example.json @@ -14,13 +14,38 @@ "admin_groups": [], "secure_cookie": true }, + "access_auth": { + "enabled": false, + "issuer": "", + "client_id": "", + "client_secret": "SET-BY-SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET", + "redirect_url": "https://guacamole.example.org/_sessionguard/auth/oidc/callback", + "logout_redirect_url": "https://guacamole.example.org/", + "cookie_name": "sg_access_session", + "cookie_domain": "", + "secure_cookie": true, + "session_hours": 8, + "username_claim": "preferred_username", + "allowed_groups": [], + "allowed_hosts": [ + "guacamole.example.org" + ] + }, "rbac": { "default_role": "viewer", "groups": { - "sessionguard-admins": ["admin"], - "sessionguard-helpdesk": ["helpdesk"], - "sessionguard-operators": ["operator"], - "sessionguard-auditors": ["auditor"] + "sessionguard-admins": [ + "admin" + ], + "sessionguard-helpdesk": [ + "helpdesk" + ], + "sessionguard-operators": [ + "operator" + ], + "sessionguard-auditors": [ + "auditor" + ] } }, "broker": { diff --git a/deploy/.env.example b/deploy/.env.example index d907471..31e6951 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -6,4 +6,5 @@ SESSIONGUARD_DB_PASSWORD=generate-a-long-random-password SESSIONGUARD_ENROLLMENT_TOKEN=generate-a-long-random-token SESSIONGUARD_BROKER_API_KEY=generate-a-separate-long-random-token SESSIONGUARD_OIDC_CLIENT_SECRET=pocketid-sessionguard-client-secret +SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET=pocketid-sessionguard-access-client-secret SESSIONGUARD_ALERT_WEBHOOK_URL= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e593ef7..d5b1ead 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,6 +32,7 @@ services: SESSIONGUARD_ENROLLMENT_TOKEN: ${SESSIONGUARD_ENROLLMENT_TOKEN:?SESSIONGUARD_ENROLLMENT_TOKEN is required} SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY:?SESSIONGUARD_BROKER_API_KEY is required} SESSIONGUARD_OIDC_CLIENT_SECRET: ${SESSIONGUARD_OIDC_CLIENT_SECRET:?SESSIONGUARD_OIDC_CLIENT_SECRET is required} + SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET: ${SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET:-} SESSIONGUARD_ALERT_WEBHOOK_URL: ${SESSIONGUARD_ALERT_WEBHOOK_URL:-} ports: - "127.0.0.1:8080:8080" diff --git a/deploy/guacamole/CI-CD.md b/deploy/guacamole/CI-CD.md index 273a414..2201b6a 100644 --- a/deploy/guacamole/CI-CD.md +++ b/deploy/guacamole/CI-CD.md @@ -37,8 +37,7 @@ services: SESSIONGUARD_BROKER_TIMEOUT_MS: "2500" ``` -Keep all existing Guacamole/PostgreSQL/header-auth environment variables and -Traefik labels unchanged. +Keep the existing Guacamole/PostgreSQL/header-auth environment variables. For SessionGuard 0.5.0 Access Auth, replace the old ForwardAuth middleware labels as described in `docs/ACCESS-AUTH.md`. ## Versioning @@ -48,9 +47,9 @@ Traefik labels unchanged. git describe --tags --always | sed 's/^v//' ``` -A commit tagged `v0.4.0` therefore publishes `0.4.0`; later commits are named -like `0.4.0-1-g0123456` until the next tag. +A commit tagged `v0.5.0` therefore publishes `0.5.0`; later commits are named +like `0.5.0-1-g0123456` until the next tag. -The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.4.0.jar`. +The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.5.0.jar`. Maven may therefore change the project version without requiring a Dockerfile change. diff --git a/deploy/guacamole/README.md b/deploy/guacamole/README.md index 143b1c8..5435e28 100644 --- a/deploy/guacamole/README.md +++ b/deploy/guacamole/README.md @@ -48,3 +48,27 @@ With `single_session_per_user=true`, the lease key is global per username. With ## 4. Header-auth boundary If Guacamole uses `HTTP_AUTH_HEADER=X-Guacamole-User`, untrusted traffic must not be able to reach Guacamole directly and supply that header. Keep the reverse proxy/header scrubber as the only trusted ingress path. Prefer a dedicated proxy network instead of sharing Guacamole's port with unrelated containers. + +## 5. SessionGuard Access Auth (0.5.0+) + +SessionGuard Master can replace the separate `traefik-forward-auth` service for Guacamole. Keep Guacamole's header-auth extension (`HTTP_AUTH_HEADER=X-Guacamole-User`), but configure Traefik to call: + +```text +http://sessionguard-master:8080/auth/verify +``` + +Expose the Master's `/auth/*` routes on the Guacamole hostname through the reserved public prefix `/_sessionguard/auth/*`, with that router excluded from ForwardAuth. The recommended PocketID callback is therefore: + +```text +https://guacamole.example.org/_sessionguard/auth/oidc/callback +``` + +and the PocketID logout/back-channel callback is: + +```text +https://guacamole.example.org/_sessionguard/auth/backchannel-logout +``` + +Before ForwardAuth, strip any client-provided `X-Guacamole-User` and `X-SessionGuard-*` identity headers. After successful ForwardAuth, copy only SessionGuard's auth response headers to Guacamole. + +See `docs/ACCESS-AUTH.md` for complete labels and migration steps. diff --git a/deploy/guacamole/docker-compose.sessionguard.example.yml b/deploy/guacamole/docker-compose.sessionguard.example.yml index 1dad03e..21dd1ee 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.4.0} + image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.5.0} build: context: ../.. dockerfile: deploy/guacamole/Dockerfile.guacamole diff --git a/docs/ACCESS-AUTH.md b/docs/ACCESS-AUTH.md new file mode 100644 index 0000000..3e0557d --- /dev/null +++ b/docs/ACCESS-AUTH.md @@ -0,0 +1,144 @@ +# SessionGuard Access Auth (Guacamole ForwardAuth) + +SessionGuard 0.5.0 can protect Apache Guacamole directly through Traefik ForwardAuth. The feature lives inside `sessionguard-master`; no separate authentication container is required. + +## Why this exists + +Guacamole header authentication trusts the username supplied by the reverse proxy. A stale Guacamole token or a separate proxy login cookie must therefore never be sufficient to bypass the identity provider. SessionGuard Access Auth adds a server-side browser session which Traefik verifies before every HTTP request is sent to Guacamole. + +The flow is: + +```text +Browser -> Traefik -> SessionGuard /auth/verify -> Guacamole + | + +-> PocketID OIDC login/logout +``` + +Only a valid SessionGuard access session causes `/auth/verify` to return HTTP 200 and `X-Guacamole-User`. Missing, expired or revoked sessions are redirected to PocketID. SessionGuard never invents a fallback username. + +## Recommended public URL layout + +Keep the SessionGuard administration UI on its existing hostname, but route a small authentication path on the Guacamole hostname to the same Master container: + +```text +https://sessionguard.example.org/ -> SessionGuard admin UI +https://guacamole.example.org/ -> Guacamole +https://guacamole.example.org/_sessionguard/auth/* -> SessionGuard Master /auth/* +``` + +This keeps the access cookie host-only on the Guacamole hostname. It also avoids cross-domain cookie problems. + +## Master configuration + +Create a separate PocketID client for Guacamole access where practical. The issuer may be inherited from the main `oidc` block, while the client ID/secret can be independent. + +```json +"access_auth": { + "enabled": true, + "issuer": "https://id.example.org", + "client_id": "POCKETID-GUACAMOLE-CLIENT-ID", + "client_secret": "SET-BY-SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET", + "redirect_url": "https://guacamole.example.org/_sessionguard/auth/oidc/callback", + "logout_redirect_url": "https://guacamole.example.org/", + "cookie_name": "sg_access_session", + "cookie_domain": "", + "secure_cookie": true, + "session_hours": 8, + "username_claim": "preferred_username", + "allowed_groups": ["guacamole-users"], + "allowed_hosts": ["guacamole.example.org"] +} +``` + +`cookie_domain` should normally remain empty. This creates a host-only cookie and prevents unrelated subdomains from receiving the access-session token. + +The browser cookie contains only a cryptographically-random opaque token. SessionGuard stores only its SHA-256 hash as the lookup key. Sessions are persisted in the existing Master control-plane store, so a Master restart does not silently re-authorize a user from a Guacamole token alone. + +## PocketID client + +Configure the PocketID client with: + +```text +Callback URL: +https://guacamole.example.org/_sessionguard/auth/oidc/callback + +Logout Callback / Back-channel Logout URL: +https://guacamole.example.org/_sessionguard/auth/backchannel-logout +``` + +PocketID can send OIDC back-channel logout tokens to the latter URL. SessionGuard verifies signature, issuer, audience, event claim, `iat`, `jti`, and `sid`/`sub`, rejects replayed logout tokens, and revokes matching local access sessions. + +For browser-initiated logout SessionGuard uses the `end_session_endpoint` discovered from PocketID, removes the local session first, then returns to `logout_redirect_url`. + +## Traefik + +The auth path MUST NOT itself use ForwardAuth, otherwise login/callback/logout create a redirect loop. + +Example labels for the SessionGuard Master (adapt router names/network/TLS resolver to your deployment): + +```yaml +labels: + - traefik.enable=true + - traefik.http.routers.sg-guac-auth.rule=Host(`guacamole.example.org`) && PathPrefix(`/_sessionguard/auth`) + - traefik.http.routers.sg-guac-auth.entrypoints=websecure + - traefik.http.routers.sg-guac-auth.tls=true + - traefik.http.routers.sg-guac-auth.priority=200 + - traefik.http.routers.sg-guac-auth.service=sessionguard-master + - traefik.http.routers.sg-guac-auth.middlewares=sg-guac-auth-strip + - traefik.http.middlewares.sg-guac-auth-strip.stripprefix.prefixes=/_sessionguard + - traefik.http.services.sessionguard-master.loadbalancer.server.port=8080 +``` + +On the normal Guacamole router, replace the old `traefik-forward-auth` middleware with two middlewares in this order: + +```yaml +labels: + # Never trust identity headers supplied by a browser/client. + - traefik.http.middlewares.guac-id-scrub.headers.customrequestheaders.X-Guacamole-User= + - traefik.http.middlewares.guac-id-scrub.headers.customrequestheaders.X-SessionGuard-User= + - traefik.http.middlewares.guac-id-scrub.headers.customrequestheaders.X-SessionGuard-Email= + - traefik.http.middlewares.guac-id-scrub.headers.customrequestheaders.X-SessionGuard-Groups= + + - traefik.http.middlewares.guac-sg-auth.forwardauth.address=http://sessionguard-master:8080/auth/verify + - traefik.http.middlewares.guac-sg-auth.forwardauth.authResponseHeaders=X-Guacamole-User,X-SessionGuard-User,X-SessionGuard-Email,X-SessionGuard-Groups + + # Add these to the existing Guacamole middleware chain, in this order. + - traefik.http.routers.guacamole.middlewares=guac-id-scrub,guac-sg-auth +``` + +Traefik copies `authResponseHeaders` from SessionGuard onto the upstream request and replaces conflicting values. The preceding header middleware removes untrusted client-supplied identity headers before authentication. + +Both Guacamole and SessionGuard Master must share a Docker network on which the DNS name `sessionguard-master` resolves. + +## Guacamole + +Keep Guacamole header authentication enabled: + +```text +HTTP_AUTH_HEADER=X-Guacamole-User +``` + +Use the SessionGuard-built Guacamole image from 0.5.0 or later. The extension contains a small plain-JavaScript helper (no npm/Node runtime): + +- every 30 seconds it checks `/_sessionguard/auth/status`; +- if the server-side SessionGuard access session has been revoked/expired, it navigates away from Guacamole, closing browser tunnels/WebSockets; +- after Guacamole's own logout reaches its logged-out state, it redirects to `/_sessionguard/auth/logout`, which also ends the SessionGuard/PocketID session. + +## Migration from traefik-forward-auth + +Do not run both auth middlewares on the Guacamole router. A safe migration is: + +1. Deploy SessionGuard 0.5.0 and enable `access_auth`. +2. Add the high-priority `/_sessionguard/auth` router. +3. Test `https://guacamole.example.org/_sessionguard/auth/status` (401 while logged out is correct). +4. Replace the Guacamole router's old ForwardAuth middleware with `guac-id-scrub,guac-sg-auth`. +5. Rebuild/redeploy the SessionGuard Guacamole image so its 0.5.0 extension is loaded. +6. After successful tests, remove the old `traefik-forward-auth` service and its cookies/configuration. + +## Expected behavior + +- No SessionGuard access cookie: Guacamole request redirects to PocketID. +- Valid access session: Guacamole receives exactly the PocketID username through `X-Guacamole-User`. +- Session older than `session_hours`: denied even if Guacamole still holds an old auth token. +- PocketID back-channel logout: corresponding SessionGuard sessions are revoked immediately; the browser-side poll closes an already-open Guacamole page within about 30 seconds. +- Guacamole logout button: Guacamole destroys its own token, then the SessionGuard helper performs full OIDC logout. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 34d1ffd..8eca61f 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -350,16 +350,16 @@ git describe --tags --always | sed 's/^v//' Für einen sauberen Release: ```bash -git tag v0.4.0 -git push origin v0.4.0 +git tag v0.5.0 +git push origin v0.5.0 git push origin main ``` -Ein Commit exakt auf Tag `v0.4.0` erzeugt dann: +Ein Commit exakt auf Tag `v0.5.0` erzeugt dann: ```text -git.send.nrw/sendnrw/sessionguard:0.4.0 -git.send.nrw/sendnrw/sessionguard-guacamole:0.4.0 +git.send.nrw/sendnrw/sessionguard:0.5.0 +git.send.nrw/sendnrw/sessionguard-guacamole:0.5.0 ``` `latest` wird ebenfalls aktualisiert. @@ -367,8 +367,8 @@ git.send.nrw/sendnrw/sessionguard-guacamole:0.4.0 ### 7.3 Release prüfen ```bash -docker pull git.send.nrw/sendnrw/sessionguard:0.4.0 -docker pull git.send.nrw/sendnrw/sessionguard-guacamole:0.4.0 +docker pull git.send.nrw/sendnrw/sessionguard:0.5.0 +docker pull git.send.nrw/sendnrw/sessionguard-guacamole:0.5.0 ``` Für Produktion möglichst einen festen Versions-Tag und nicht ausschließlich `latest` verwenden. @@ -402,7 +402,7 @@ Anlegen: Beispiel: ```dotenv -SESSIONGUARD_VERSION=0.4.0 +SESSIONGUARD_VERSION=0.5.0 POSTGRES_VERSION=17 TRAEFIK_NETWORK=aio_proxy @@ -655,7 +655,7 @@ image: guacamole/guacamole:${GUACAMOLE_VERSION:-1.6.0} Nachher: ```yaml -image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.4.0} +image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.5.0} ``` `guac-init` kann weiterhin das offizielle Guacamole-Image verwenden. @@ -675,7 +675,7 @@ SESSIONGUARD_BROKER_TIMEOUT_MS: "2500" In die Guacamole `.env` zusätzlich: ```dotenv -SESSIONGUARD_VERSION=0.4.0 +SESSIONGUARD_VERSION=0.5.0 SESSIONGUARD_BROKER_API_KEY= ``` @@ -1909,7 +1909,7 @@ Vorher PostgreSQL sichern. Dann neuen Tag setzen, beispielsweise: ```dotenv -SESSIONGUARD_VERSION=0.4.0 +SESSIONGUARD_VERSION=0.5.0 ``` Update: @@ -1942,7 +1942,7 @@ Broker Gleichen SessionGuard-Release-Tag verwenden: ```dotenv -SESSIONGUARD_VERSION=0.4.0 +SESSIONGUARD_VERSION=0.5.0 ``` Dann: diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml index 6e0b3ae..6f72c02 100644 --- a/guacamole-extension/pom.xml +++ b/guacamole-extension/pom.xml @@ -5,7 +5,7 @@ 4.0.0 info.hilden.sessionguard sessionguard-guacamole - 0.4.1 + 0.5.0 jar 11 diff --git a/guacamole-extension/src/main/resources/guac-manifest.json b/guacamole-extension/src/main/resources/guac-manifest.json index 915df7a..a901f68 100644 --- a/guacamole-extension/src/main/resources/guac-manifest.json +++ b/guacamole-extension/src/main/resources/guac-manifest.json @@ -1,8 +1,11 @@ { "guacamoleVersion": "1.6.0", - "name": "SessionGuard Broker", + "name": "SessionGuard Broker & Access", "namespace": "sessionguard-broker", "authProviders": [ "info.hilden.sessionguard.guacamole.SessionGuardAuthenticationProvider" + ], + "js": [ + "js/sessionguard-access.js" ] } diff --git a/guacamole-extension/src/main/resources/js/sessionguard-access.js b/guacamole-extension/src/main/resources/js/sessionguard-access.js new file mode 100644 index 0000000..e53e68d --- /dev/null +++ b/guacamole-extension/src/main/resources/js/sessionguard-access.js @@ -0,0 +1,93 @@ +/* + * SessionGuard access-session integration for Apache Guacamole. + * + * There is intentionally no npm/build step here. Guacamole loads this file + * directly from the SessionGuard extension JAR. + */ +(function () { + 'use strict'; + + var AUTH_BASE = '/_sessionguard/auth'; + var STATUS_INTERVAL_MS = 30000; + var redirecting = false; + + function safeReturnURL() { + return window.location.origin + '/'; + } + + function loginURL() { + return AUTH_BASE + '/login?return=' + encodeURIComponent(safeReturnURL()); + } + + function logoutURL() { + return AUTH_BASE + '/logout?return=' + encodeURIComponent(safeReturnURL()); + } + + function redirect(url) { + if (redirecting) { + return; + } + redirecting = true; + window.location.replace(url); + } + + function checkAccessSession() { + if (redirecting || !window.fetch) { + return; + } + + window.fetch(AUTH_BASE + '/status', { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + headers: { 'Accept': 'application/json' } + }).then(function (response) { + if (response.status === 401 || response.status === 403) { + // Navigating away also closes Guacamole WebSocket/tunnel + // connections, so a revoked PocketID/SessionGuard access + // session cannot keep an already-open browser client alive. + redirect(loginURL()); + } + }).catch(function () { + // A transient auth-status outage must not destroy an active RDP + // session. Traefik still fail-closes all new HTTP requests through + // ForwardAuth; retry this browser-side check on the next interval. + }); + } + + function watchForGuacamoleLogout() { + function loggedOutModalPresent() { + return document.querySelector('.logged-out-modal') !== null; + } + + if (loggedOutModalPresent()) { + redirect(logoutURL()); + return; + } + + var observer = new MutationObserver(function () { + if (loggedOutModalPresent()) { + observer.disconnect(); + redirect(logoutURL()); + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true + }); + } + + function start() { + watchForGuacamoleLogout(); + checkAccessSession(); + window.setInterval(checkAccessSession, STATUS_INTERVAL_MS); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start, { once: true }); + } + else { + start(); + } +}()); diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0a34ecc..cc5e0c6 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.4.1" +const Version = "0.5.0" type App struct { cfg config.Agent diff --git a/internal/agent/ui.go b/internal/agent/ui.go index 9e80057..e538f61 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}} @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}} -
+
Lokaler Agent
Lokaler Terminalserver
Live · 5s
Local Control

Terminalserver-Status

Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.
Aktiv
Getrennt
Sitzungen
Profil-Jobs
Cleanup
RemoteApps
Master
RDS

Sitzungen

Aktive und getrennte Benutzer sowie administrative Aktionen.
@@ -44,7 +44,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
Profile

Profil-Pipeline

Backup, Restore, Retry und Cleanup-Status pro Benutzer.
Telemetry

Aktivitäts- & Audit-Log

Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.
Configuration

Lokale Policy

Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.
-
` +
` const agentJS = ` let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false; diff --git a/internal/auth/access.go b/internal/auth/access.go new file mode 100644 index 0000000..b28402f --- /dev/null +++ b/internal/auth/access.go @@ -0,0 +1,507 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/example/sessionguard/internal/model" + "golang.org/x/oauth2" +) + +const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + +type AccessSessionStore interface { + PutAuthSession(model.AuthSession) error + GetAuthSession(hash string) (model.AuthSession, bool) + DeleteAuthSession(hash string) error + RevokeAuthSessions(sid, sub string) (int, error) + CleanupAuthSessions(time.Time) error +} + +type accessPending struct { + Nonce string + ReturnURL string + Exp time.Time +} + +type AccessManager struct { + cfg model.AccessAuthConfig + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + logoutVer *oidc.IDTokenVerifier + oauth oauth2.Config + sessions AccessSessionStore + endSession string + mu sync.Mutex + pending map[string]accessPending + logoutSeen map[string]time.Time +} + +func NewAccess(ctx context.Context, cfg model.AccessAuthConfig, sessions AccessSessionStore) (*AccessManager, error) { + if !cfg.Enabled { + return nil, nil + } + if sessions == nil { + return nil, errors.New("access auth session store is required") + } + p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/")) + if err != nil { + return nil, err + } + var discovery struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + _ = p.Claims(&discovery) + return &AccessManager{ + cfg: cfg, + provider: p, + verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + // Back-channel logout tokens are not ID tokens and may omit exp. We + // still verify issuer, audience and signature, then validate the + // logout-specific claims below. + logoutVer: p.Verifier(&oidc.Config{ClientID: cfg.ClientID, SkipExpiryCheck: true}), + oauth: oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, + Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, + }, + sessions: sessions, + endSession: discovery.EndSessionEndpoint, + pending: map[string]accessPending{}, + logoutSeen: map[string]time.Time{}, + }, nil +} + +func (m *AccessManager) Register(mux *http.ServeMux) { + if m == nil { + return + } + mux.HandleFunc("/auth/verify", m.Verify) + mux.HandleFunc("GET /auth/login", m.Login) + mux.HandleFunc("GET /auth/oidc/callback", m.Callback) + mux.HandleFunc("GET /auth/logout", m.Logout) + mux.HandleFunc("POST /auth/logout", m.Logout) + mux.HandleFunc("POST /auth/backchannel-logout", m.BackchannelLogout) + mux.HandleFunc("GET /auth/status", m.Status) +} + +func (m *AccessManager) Login(w http.ResponseWriter, r *http.Request) { + _ = m.sessions.CleanupAuthSessions(time.Now().UTC()) + target := m.validReturnURL(r.URL.Query().Get("return")) + state, nonce := randomAccessToken(24), randomAccessToken(24) + m.mu.Lock() + m.prunePendingLocked(time.Now()) + m.pending[state] = accessPending{Nonce: nonce, ReturnURL: target, Exp: time.Now().Add(5 * time.Minute)} + m.mu.Unlock() + + // One state cookie per login flow avoids the common multi-tab race where a + // second login overwrites the first flow's single state cookie. + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName(state), Value: state, Path: m.externalCallbackPath(), + HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode, + MaxAge: 300, + }) + http.Redirect(w, r, m.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) +} + +func (m *AccessManager) Callback(w http.ResponseWriter, r *http.Request) { + if e := r.URL.Query().Get("error"); e != "" { + http.Error(w, "OIDC: "+e, http.StatusUnauthorized) + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + http.Error(w, "missing OIDC state", http.StatusUnauthorized) + return + } + cookieName := stateCookieName(state) + c, err := r.Cookie(cookieName) + if err != nil || c.Value != state { + http.Error(w, "OIDC state is not bound to this browser", http.StatusUnauthorized) + return + } + clearCookie(w, cookieName, "", m.externalCallbackPath(), m.cfg.SecureCookie) + + m.mu.Lock() + p, ok := m.pending[state] + delete(m.pending, state) + m.mu.Unlock() + if !ok || time.Now().After(p.Exp) { + http.Error(w, "invalid or expired OIDC state", http.StatusUnauthorized) + return + } + + tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "OIDC token exchange failed", http.StatusUnauthorized) + return + } + rawIDToken, ok := tok.Extra("id_token").(string) + if !ok || strings.TrimSpace(rawIDToken) == "" { + http.Error(w, "missing id_token", http.StatusUnauthorized) + return + } + idToken, err := m.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + http.Error(w, "invalid id_token", http.StatusUnauthorized) + return + } + if idToken.Nonce != p.Nonce { + http.Error(w, "invalid OIDC nonce", http.StatusUnauthorized) + return + } + + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + http.Error(w, "invalid OIDC claims", http.StatusUnauthorized) + return + } + username := claimString(claims, m.cfg.UsernameClaim) + if username == "" { + // Pocket ID documents preferred_username and it is a safer Guacamole + // identity than display-name. Never invent a fallback identity. + username = claimString(claims, "preferred_username") + } + if username == "" { + http.Error(w, "OIDC token has no usable username claim", http.StatusForbidden) + return + } + groups := claimStrings(claims, "groups") + if !allowedGroups(groups, m.cfg.AllowedGroups) { + http.Error(w, "user is not in an allowed access group", http.StatusForbidden) + return + } + + browserToken := randomAccessToken(32) + now := time.Now().UTC() + sess := model.AuthSession{ + ID: randomAccessToken(12), TokenHash: hashAccessToken(browserToken), Subject: idToken.Subject, + SID: claimString(claims, "sid"), Username: username, + Email: claimString(claims, "email"), Name: claimString(claims, "name"), + Groups: groups, IDToken: rawIDToken, CreatedAt: now, + ExpiresAt: now.Add(time.Duration(m.cfg.SessionHours) * time.Hour), + } + if err := m.sessions.PutAuthSession(sess); err != nil { + http.Error(w, "could not create access session", http.StatusInternalServerError) + return + } + m.setSessionCookie(w, browserToken, int(time.Until(sess.ExpiresAt).Seconds())) + http.Redirect(w, r, p.ReturnURL, http.StatusFound) +} + +func (m *AccessManager) Verify(w http.ResponseWriter, r *http.Request) { + sess, ok := m.sessionFromRequest(r) + if !ok { + target := m.forwardedTarget(r) + http.Redirect(w, r, m.loginURL(target), http.StatusFound) + return + } + // These are the only identity headers Traefik should copy to Guacamole. + // authResponseHeaders replaces conflicting client-provided values. + w.Header().Set("X-Guacamole-User", sess.Username) + w.Header().Set("X-SessionGuard-User", sess.Username) + if sess.Email != "" { + w.Header().Set("X-SessionGuard-Email", sess.Email) + } + if len(sess.Groups) > 0 { + w.Header().Set("X-SessionGuard-Groups", strings.Join(sess.Groups, ",")) + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) +} + +func (m *AccessManager) Status(w http.ResponseWriter, r *http.Request) { + sess, ok := m.sessionFromRequest(r) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if !ok { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"authenticated":false}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "authenticated": true, "username": sess.Username, "email": sess.Email, + "groups": sess.Groups, "expires_at": sess.ExpiresAt, + }) +} + +func (m *AccessManager) Logout(w http.ResponseWriter, r *http.Request) { + sess, _ := m.sessionFromRequest(r) + if c, err := r.Cookie(m.cfg.CookieName); err == nil { + _ = m.sessions.DeleteAuthSession(hashAccessToken(c.Value)) + } + clearCookie(w, m.cfg.CookieName, m.cfg.CookieDomain, "/", m.cfg.SecureCookie) + + target := strings.TrimSpace(m.cfg.LogoutRedirectURL) + if target == "" { + target = m.validReturnURL(r.URL.Query().Get("return")) + } + if m.endSession == "" { + http.Redirect(w, r, target, http.StatusFound) + return + } + u, err := url.Parse(m.endSession) + if err != nil { + http.Redirect(w, r, target, http.StatusFound) + return + } + q := u.Query() + if sess.IDToken != "" { + q.Set("id_token_hint", sess.IDToken) + } else { + q.Set("client_id", m.cfg.ClientID) + } + if target != "" { + q.Set("post_logout_redirect_uri", target) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (m *AccessManager) BackchannelLogout(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + raw := strings.TrimSpace(r.Form.Get("logout_token")) + if raw == "" { + http.Error(w, "missing logout_token", http.StatusBadRequest) + return + } + tok, err := m.logoutVer.Verify(r.Context(), raw) + if err != nil { + http.Error(w, "invalid logout_token", http.StatusBadRequest) + return + } + var claims struct { + SID string `json:"sid"` + Sub string `json:"sub"` + Nonce string `json:"nonce"` + JTI string `json:"jti"` + IAT int64 `json:"iat"` + Events map[string]json.RawMessage `json:"events"` + } + if err := tok.Claims(&claims); err != nil { + http.Error(w, "invalid logout_token claims", http.StatusBadRequest) + return + } + if claims.Nonce != "" || claims.Events == nil { + http.Error(w, "invalid logout_token claims", http.StatusBadRequest) + return + } + if _, ok := claims.Events[backchannelLogoutEvent]; !ok { + http.Error(w, "missing backchannel logout event", http.StatusBadRequest) + return + } + if claims.SID == "" && claims.Sub == "" { + http.Error(w, "logout_token has neither sid nor sub", http.StatusBadRequest) + return + } + if strings.TrimSpace(claims.JTI) == "" { + http.Error(w, "logout_token has no jti", http.StatusBadRequest) + return + } + if claims.IAT == 0 || time.Since(time.Unix(claims.IAT, 0)) > 10*time.Minute || time.Until(time.Unix(claims.IAT, 0)) > 5*time.Minute { + http.Error(w, "logout_token iat outside allowed window", http.StatusBadRequest) + return + } + if !m.acceptLogoutJTI(claims.JTI, time.Now()) { + http.Error(w, "logout_token replayed", http.StatusBadRequest) + return + } + if _, err := m.sessions.RevokeAuthSessions(claims.SID, claims.Sub); err != nil { + http.Error(w, "could not revoke access session", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +func (m *AccessManager) sessionFromRequest(r *http.Request) (model.AuthSession, bool) { + c, err := r.Cookie(m.cfg.CookieName) + if err != nil || strings.TrimSpace(c.Value) == "" { + return model.AuthSession{}, false + } + return m.sessions.GetAuthSession(hashAccessToken(c.Value)) +} + +func (m *AccessManager) setSessionCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: m.cfg.CookieName, Value: token, Path: "/", Domain: m.cfg.CookieDomain, + HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode, + MaxAge: maxAge, + }) +} + +func (m *AccessManager) forwardedTarget(r *http.Request) string { + proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + uri := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri")) + if proto == "" { + proto = "https" + } + if uri == "" { + uri = "/" + } + if host == "" { + return m.cfg.LogoutRedirectURL + } + return m.validReturnURL(proto + "://" + host + uri) +} + +func (m *AccessManager) externalCallbackPath() string { + u, err := url.Parse(m.cfg.RedirectURL) + if err != nil || strings.TrimSpace(u.Path) == "" { + return "/auth/oidc/callback" + } + return u.Path +} + +func (m *AccessManager) loginURL(target string) string { + u, _ := url.Parse(m.cfg.RedirectURL) + // Preserve an external Traefik prefix such as /_sessionguard. The Master + // itself sees /auth/* after StripPrefix, while the browser must be sent to + // the externally routable prefixed URL. + base := strings.TrimSuffix(u.Path, "/oidc/callback") + if base == u.Path { + base = strings.TrimSuffix(u.Path, "/") + } + u.Path = base + "/login" + u.RawQuery = url.Values{"return": []string{target}}.Encode() + return u.String() +} + +func (m *AccessManager) validReturnURL(raw string) string { + fallback := strings.TrimSpace(m.cfg.LogoutRedirectURL) + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme != "https" || u.Hostname() == "" { + return fallback + } + host := strings.ToLower(u.Hostname()) + for _, allowed := range m.cfg.AllowedHosts { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == host { + return u.String() + } + if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) { + return u.String() + } + } + // If no allow-list was provided, constrain redirects to the configured + // cookie domain or, for host-only cookies, to logout_redirect_url. This is + // still closed against arbitrary open redirects. + if len(m.cfg.AllowedHosts) == 0 { + if m.cfg.CookieDomain != "" { + d := strings.TrimPrefix(strings.ToLower(m.cfg.CookieDomain), ".") + if host == d || strings.HasSuffix(host, "."+d) { + return u.String() + } + } + if f, err := url.Parse(fallback); err == nil && strings.EqualFold(f.Hostname(), host) { + return u.String() + } + } + return fallback +} + +func (m *AccessManager) acceptLogoutJTI(jti string, now time.Time) bool { + m.mu.Lock() + defer m.mu.Unlock() + for k, exp := range m.logoutSeen { + if !now.Before(exp) { + delete(m.logoutSeen, k) + } + } + if _, exists := m.logoutSeen[jti]; exists { + return false + } + m.logoutSeen[jti] = now.Add(15 * time.Minute) + return true +} + +func (m *AccessManager) prunePendingLocked(now time.Time) { + for k, p := range m.pending { + if now.After(p.Exp) { + delete(m.pending, k) + } + } +} + +func allowedGroups(got, allowed []string) bool { + if len(allowed) == 0 { + return true + } + set := map[string]struct{}{} + for _, g := range got { + set[strings.ToLower(strings.TrimSpace(g))] = struct{}{} + } + for _, g := range allowed { + if _, ok := set[strings.ToLower(strings.TrimSpace(g))]; ok { + return true + } + } + return false +} + +func claimString(claims map[string]any, key string) string { + v, ok := claims[key] + if !ok { + return "" + } + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func claimStrings(claims map[string]any, key string) []string { + v, ok := claims[key] + if !ok { + return nil + } + switch x := v.(type) { + case []any: + out := make([]string, 0, len(x)) + for _, e := range x { + if s, ok := e.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + case []string: + return append([]string(nil), x...) + case string: + if strings.TrimSpace(x) != "" { + return []string{strings.TrimSpace(x)} + } + } + return nil +} + +func randomAccessToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} +func hashAccessToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) } +func stateCookieName(state string) string { + if len(state) > 16 { + state = state[:16] + } + return "sg_access_state_" + state +} +func clearCookie(w http.ResponseWriter, name, domain, path string, secure bool) { + http.SetCookie(w, &http.Cookie{Name: name, Value: "", Domain: domain, Path: path, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)}) +} diff --git a/internal/auth/access_helpers_test.go b/internal/auth/access_helpers_test.go new file mode 100644 index 0000000..302d87c --- /dev/null +++ b/internal/auth/access_helpers_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "testing" + "time" + + "github.com/example/sessionguard/internal/model" +) + +func TestExternalCallbackPathAndLoginURL(t *testing.T) { + m := &AccessManager{cfg: model.AccessAuthConfig{ + RedirectURL: "https://ts.hilden.info/_sessionguard/auth/oidc/callback", + LogoutRedirectURL: "https://ts.hilden.info/", + AllowedHosts: []string{"ts.hilden.info"}, + }} + if got := m.externalCallbackPath(); got != "/_sessionguard/auth/oidc/callback" { + t.Fatalf("callback path = %q", got) + } + want := "https://ts.hilden.info/_sessionguard/auth/login?return=https%3A%2F%2Fts.hilden.info%2F" + if got := m.loginURL("https://ts.hilden.info/"); got != want { + t.Fatalf("login URL = %q, want %q", got, want) + } +} + +func TestValidReturnURL(t *testing.T) { + m := &AccessManager{cfg: model.AccessAuthConfig{ + LogoutRedirectURL: "https://ts.hilden.info/", + AllowedHosts: []string{"ts.hilden.info"}, + }} + if got := m.validReturnURL("https://ts.hilden.info/#/client/1"); got != "https://ts.hilden.info/#/client/1" { + t.Fatalf("allowed return URL changed to %q", got) + } + if got := m.validReturnURL("https://evil.example/"); got != "https://ts.hilden.info/" { + t.Fatalf("open redirect was accepted: %q", got) + } + if got := m.validReturnURL("javascript:alert(1)"); got != "https://ts.hilden.info/" { + t.Fatalf("non-https redirect was accepted: %q", got) + } +} + +func TestLogoutJTIReplayProtection(t *testing.T) { + m := &AccessManager{logoutSeen: map[string]time.Time{}} + now := time.Now() + if !m.acceptLogoutJTI("abc", now) { + t.Fatal("first jti rejected") + } + if m.acceptLogoutJTI("abc", now.Add(time.Second)) { + t.Fatal("replayed jti accepted") + } + if !m.acceptLogoutJTI("abc", now.Add(16*time.Minute)) { + t.Fatal("expired replay marker was not pruned") + } +} + +func TestAllowedGroupsCaseInsensitive(t *testing.T) { + if !allowedGroups([]string{"SessionGuard-Users"}, []string{"sessionguard-users"}) { + t.Fatal("case-insensitive group match failed") + } + if allowedGroups([]string{"other"}, []string{"sessionguard-users"}) { + t.Fatal("unexpected group match") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 841b390..9f3ef98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,8 @@ package config import ( "encoding/json" "errors" + "fmt" + "net/url" "os" "path/filepath" "strings" @@ -11,17 +13,18 @@ import ( ) type Master struct { - Listen string `json:"listen"` - PublicURL string `json:"public_url"` - DataFile string `json:"data_file,omitempty"` - DatabaseURL string `json:"database_url,omitempty"` - EnrollmentToken string `json:"enrollment_token"` - OIDC model.OIDCConfig `json:"oidc"` - RBAC model.RBACConfig `json:"rbac"` - Broker model.BrokerConfig `json:"broker"` - Alerts model.AlertConfig `json:"alerts"` - OfflineAfterSeconds int `json:"offline_after_seconds"` - HistoryLimit int `json:"history_limit"` + Listen string `json:"listen"` + PublicURL string `json:"public_url"` + DataFile string `json:"data_file,omitempty"` + DatabaseURL string `json:"database_url,omitempty"` + EnrollmentToken string `json:"enrollment_token"` + OIDC model.OIDCConfig `json:"oidc"` + AccessAuth model.AccessAuthConfig `json:"access_auth"` + RBAC model.RBACConfig `json:"rbac"` + Broker model.BrokerConfig `json:"broker"` + Alerts model.AlertConfig `json:"alerts"` + OfflineAfterSeconds int `json:"offline_after_seconds"` + HistoryLimit int `json:"history_limit"` } type Agent struct { @@ -92,6 +95,29 @@ func LoadMaster(path string) (Master, error) { if c.RBAC.Groups == nil { c.RBAC.Groups = map[string][]string{} } + if c.AccessAuth.Enabled { + if strings.TrimSpace(c.AccessAuth.Issuer) == "" { + c.AccessAuth.Issuer = c.OIDC.Issuer + } + if strings.TrimSpace(c.AccessAuth.ClientID) == "" { + c.AccessAuth.ClientID = c.OIDC.ClientID + } + if strings.TrimSpace(c.AccessAuth.ClientSecret) == "" { + c.AccessAuth.ClientSecret = c.OIDC.ClientSecret + } + if c.AccessAuth.CookieName == "" { + c.AccessAuth.CookieName = "sg_access_session" + } + if c.AccessAuth.SessionHours <= 0 { + c.AccessAuth.SessionHours = 8 + } + if c.AccessAuth.UsernameClaim == "" { + c.AccessAuth.UsernameClaim = "preferred_username" + } + if err := validateAccessAuth(c.AccessAuth); err != nil { + return c, err + } + } if err := validateOIDC(c.OIDC); err != nil { return c, err } @@ -156,6 +182,7 @@ func applyMasterEnv(c *Master) { set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken) set("SESSIONGUARD_BROKER_API_KEY", &c.Broker.APIKey) set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret) + set("SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET", &c.AccessAuth.ClientSecret) set("SESSIONGUARD_ALERT_WEBHOOK_URL", &c.Alerts.WebhookURL) } @@ -198,6 +225,28 @@ func validateOIDC(c model.OIDCConfig) error { return nil } +func validateAccessAuth(c model.AccessAuthConfig) error { + if strings.TrimSpace(c.Issuer) == "" || strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" || strings.TrimSpace(c.RedirectURL) == "" || strings.TrimSpace(c.LogoutRedirectURL) == "" { + return errors.New("access_auth issuer, client_id, client_secret, redirect_url and logout_redirect_url are required when access_auth is enabled") + } + for label, raw := range map[string]string{"redirect_url": c.RedirectURL, "logout_redirect_url": c.LogoutRedirectURL} { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Hostname() == "" || u.Scheme == "" { + return fmt.Errorf("access_auth.%s must be an absolute URL", label) + } + if c.SecureCookie && !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("access_auth.%s must use https when secure_cookie is enabled", label) + } + } + if c.SessionHours < 1 || c.SessionHours > 168 { + return errors.New("access_auth.session_hours must be between 1 and 168") + } + if strings.ContainsAny(c.CookieName, " ;,\t\r\n") { + return errors.New("access_auth.cookie_name contains invalid characters") + } + return nil +} + func NormalizePolicy(p *model.Policy) { if p.Cleanup.GraceSeconds <= 0 { p.Cleanup.GraceSeconds = 600 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b57ea08..56509ff 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,3 +48,29 @@ func TestDisconnectedTimeoutMinimum(t *testing.T) { t.Fatal("expected disconnected timeout validation error") } } + +func TestValidateAccessAuthRequiresHTTPSForSecureCookie(t *testing.T) { + c := model.AccessAuthConfig{ + Enabled: true, Issuer: "https://id.example.org", ClientID: "client", ClientSecret: "secret", + RedirectURL: "http://guac.example.org/_sessionguard/auth/oidc/callback", LogoutRedirectURL: "https://guac.example.org/", + CookieName: "sg_access_session", SecureCookie: true, SessionHours: 8, + } + if err := validateAccessAuth(c); err == nil { + t.Fatal("expected https validation error") + } + c.RedirectURL = "https://guac.example.org/_sessionguard/auth/oidc/callback" + if err := validateAccessAuth(c); err != nil { + t.Fatal(err) + } +} + +func TestValidateAccessAuthRequiresClientSecret(t *testing.T) { + c := model.AccessAuthConfig{ + Issuer: "https://id.example.org", ClientID: "client", + RedirectURL: "https://guac.example.org/_sessionguard/auth/oidc/callback", LogoutRedirectURL: "https://guac.example.org/", + CookieName: "sg_access_session", SecureCookie: true, SessionHours: 8, + } + if err := validateAccessAuth(c); err == nil { + t.Fatal("expected missing client secret validation error") + } +} diff --git a/internal/master/auth_store.go b/internal/master/auth_store.go new file mode 100644 index 0000000..ec2bf2f --- /dev/null +++ b/internal/master/auth_store.go @@ -0,0 +1,78 @@ +package master + +import ( + "strings" + "time" + + "github.com/example/sessionguard/internal/model" +) + +// authSessionStore adapts the Master's existing persistent control-plane store +// to the auth package. Auth session mutations are rare (login/logout/revoke), so +// persisting the small map alongside control-plane state avoids a separate +// database dependency while verification itself stays read-only and cheap. +type authSessionStore struct{ s *store } + +func (r authSessionStore) PutAuthSession(sess model.AuthSession) error { + r.s.mu.Lock() + defer r.s.mu.Unlock() + if r.s.data.AuthSessions == nil { + r.s.data.AuthSessions = map[string]model.AuthSession{} + } + r.s.data.AuthSessions[sess.TokenHash] = sess + return r.s.saveLocked() +} + +func (r authSessionStore) GetAuthSession(hash string) (model.AuthSession, bool) { + r.s.mu.RLock() + defer r.s.mu.RUnlock() + sess, ok := r.s.data.AuthSessions[hash] + if !ok || (!sess.ExpiresAt.IsZero() && time.Now().UTC().After(sess.ExpiresAt)) { + return model.AuthSession{}, false + } + return sess, true +} + +func (r authSessionStore) DeleteAuthSession(hash string) error { + r.s.mu.Lock() + defer r.s.mu.Unlock() + if _, ok := r.s.data.AuthSessions[hash]; !ok { + return nil + } + delete(r.s.data.AuthSessions, hash) + return r.s.saveLocked() +} + +func (r authSessionStore) RevokeAuthSessions(sid, sub string) (int, error) { + sid = strings.TrimSpace(sid) + sub = strings.TrimSpace(sub) + r.s.mu.Lock() + defer r.s.mu.Unlock() + n := 0 + for h, sess := range r.s.data.AuthSessions { + if (sid != "" && sess.SID == sid) || (sub != "" && sess.Subject == sub) { + delete(r.s.data.AuthSessions, h) + n++ + } + } + if n == 0 { + return 0, nil + } + return n, r.s.saveLocked() +} + +func (r authSessionStore) CleanupAuthSessions(now time.Time) error { + r.s.mu.Lock() + defer r.s.mu.Unlock() + changed := false + for h, sess := range r.s.data.AuthSessions { + if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) { + delete(r.s.data.AuthSessions, h) + changed = true + } + } + if !changed { + return nil + } + return r.s.saveLocked() +} diff --git a/internal/master/master.go b/internal/master/master.go index e65c5da..49bceb7 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -23,13 +23,14 @@ import ( "github.com/example/sessionguard/internal/model" ) -const Version = "0.4.1" +const Version = "0.5.0" type App struct { - cfg config.Master - store *store - auth *auth.Manager - http *http.Client + cfg config.Master + store *store + auth *auth.Manager + access *auth.AccessManager + http *http.Client } func New(ctx context.Context, cfg config.Master) (*App, error) { @@ -42,12 +43,20 @@ func New(ctx context.Context, cfg config.Master) (*App, error) { _ = s.close() return nil, fmt.Errorf("OIDC: %w", err) } - return &App{cfg: cfg, store: s, auth: a, http: &http.Client{Timeout: 8 * time.Second}}, nil + access, err := auth.NewAccess(ctx, cfg.AccessAuth, authSessionStore{s: s}) + if err != nil { + _ = s.close() + return nil, fmt.Errorf("access auth: %w", err) + } + return &App{cfg: cfg, store: s, auth: a, access: access, http: &http.Client{Timeout: 8 * time.Second}}, nil } func (a *App) Run(ctx context.Context) error { mux := http.NewServeMux() a.auth.Register(mux) + if a.access != nil { + a.access.Register(mux) + } mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version, "store": a.store.kind()}) }) @@ -88,6 +97,8 @@ func (a *App) Run(ctx context.Context) error { mux.Handle("DELETE /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceDelete)))) mux.Handle("GET /api/v1/alerts", a.auth.Require(http.HandlerFunc(a.alerts))) mux.Handle("GET /api/v1/leases", a.auth.Require(http.HandlerFunc(a.leases))) + mux.Handle("GET /api/v1/access/sessions", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessions)))) + mux.Handle("DELETE /api/v1/access/sessions/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessionRevoke)))) server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second} go a.monitor(ctx) go func() { @@ -590,13 +601,19 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { farms := len(a.store.data.Farms) resources := len(a.store.data.Resources) alerts := 0 + accessSessions := 0 for _, x := range a.store.data.Alerts { if x.Active { alerts++ } } + for _, sess := range a.store.data.AuthSessions { + if sess.ExpiresAt.IsZero() || now.Before(sess.ExpiresAt) { + accessSessions++ + } + } a.store.mu.RUnlock() - httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "store": a.store.kind()}) + httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "access_sessions": accessSessions, "store": a.store.kind()}) } func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) { rec, ok := a.store.get(r.PathValue("id")) @@ -1274,6 +1291,67 @@ func (a *App) alerts(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"alerts": out}) } +func (a *App) accessSessions(w http.ResponseWriter, r *http.Request) { + type publicSession struct { + ID string `json:"id"` + Subject string `json:"subject"` + SID string `json:"sid,omitempty"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Groups []string `json:"groups,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + } + now := time.Now().UTC() + a.store.mu.RLock() + out := make([]publicSession, 0, len(a.store.data.AuthSessions)) + for _, sess := range a.store.data.AuthSessions { + if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) { + continue + } + out = append(out, publicSession{ID: sess.ID, Subject: sess.Subject, SID: sess.SID, Username: sess.Username, Email: sess.Email, Name: sess.Name, Groups: append([]string(nil), sess.Groups...), CreatedAt: sess.CreatedAt, ExpiresAt: sess.ExpiresAt}) + } + a.store.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + httpx.JSON(w, 200, map[string]any{"sessions": out}) +} + +func (a *App) accessSessionRevoke(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + httpx.Error(w, 400, "session id is required") + return + } + a.store.mu.Lock() + var username string + var hash string + for h, sess := range a.store.data.AuthSessions { + if sess.ID == id { + hash, username = h, sess.Username + break + } + } + if hash == "" { + a.store.mu.Unlock() + httpx.Error(w, 404, "access session not found") + return + } + delete(a.store.data.AuthSessions, hash) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "access_session_revoke", Target: username, Result: "success", Details: id}) + err := a.store.saveLocked() + a.store.mu.Unlock() + if err != nil { + httpx.Error(w, 500, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (a *App) leases(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() a.store.mu.RLock() diff --git a/internal/master/store.go b/internal/master/store.go index 2b4a0a6..4156056 100644 --- a/internal/master/store.go +++ b/internal/master/store.go @@ -24,6 +24,7 @@ type data struct { SessionHistory []model.SessionHistoryEvent `json:"session_history,omitempty"` PolicyHistory []model.PolicyVersion `json:"policy_history,omitempty"` Alerts map[string]model.Alert `json:"alerts,omitempty"` + AuthSessions map[string]model.AuthSession `json:"auth_sessions,omitempty"` GlobalPolicy *model.Policy `json:"global_policy,omitempty"` } @@ -47,7 +48,7 @@ func emptyData() data { return data{ Agents: map[string]model.AgentRecord{}, Farms: map[string]model.Farm{}, Resources: map[string]model.Resource{}, Leases: map[string]model.UserLease{}, Audit: []model.AuditEntry{}, SessionHistory: []model.SessionHistoryEvent{}, - PolicyHistory: []model.PolicyVersion{}, Alerts: map[string]model.Alert{}, + PolicyHistory: []model.PolicyVersion{}, Alerts: map[string]model.Alert{}, AuthSessions: map[string]model.AuthSession{}, } } @@ -96,6 +97,9 @@ func (s *store) normalize() { if s.data.Alerts == nil { s.data.Alerts = map[string]model.Alert{} } + if s.data.AuthSessions == nil { + s.data.AuthSessions = map[string]model.AuthSession{} + } for id, a := range s.data.Agents { if a.Tags == nil { a.Tags = map[string]string{} diff --git a/internal/master/ui.go b/internal/master/ui.go index 43ea74e..fc5d7f4 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -37,21 +37,22 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
-
` +
` const masterJS = ` let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],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)} @@ -67,10 +68,11 @@ function initShell(){ if('IntersectionObserver' in window){let links=[...document.querySelectorAll('.nav-link[href^="#"]')],map=new Map(links.map(a=>[a.getAttribute('href').slice(1),a]));let io=new IntersectionObserver(entries=>{let best=entries.filter(e=>e.isIntersecting).sort((a,b)=>b.intersectionRatio-a.intersectionRatio)[0];if(!best)return;let a=map.get(best.target.id);if(a){links.forEach(x=>x.classList.remove('active'));a.classList.add('active')}},{rootMargin:'-18% 0px -65% 0px',threshold:[0,.15,.4]});map.forEach((_,id)=>{let el=document.getElementById(id);if(el)io.observe(el)})} } 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||[];agentCache=a;$('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 refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];agentCache=a;$('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;$('mAccess').textContent=d.access_sessions||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');if((me.permissions||[]).includes('manage')){try{let as=await api('/api/v1/access/sessions');renderAccessSessions(as.sessions||[])}catch(e){$('accessSessions').innerHTML='
Access-Sessions konnten nicht geladen werden: '+esc(e.message)+'
'}}else $('accessSessions').innerHTML='
Access-Sessions erfordern Manage/Admin-Rechte.
';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 renderAccessSessions(rows){let root=$('accessSessions');if(!root)return;rows=rows||[];root.innerHTML=rows.length?''+rows.map(x=>'').join('')+'
BenutzerGruppenErstelltLäuft ab
'+esc(x.username||'–')+''+(x.name?'
'+esc(x.name)+'':'')+(x.email?'
'+esc(x.email)+'':'')+'
'+esc((x.groups||[]).join(', ')||'–')+''+esc(when(x.created_at))+''+esc(when(x.expires_at))+'
':'
Keine aktiven Guacamole Access-Sessions.
'} 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)}} @@ -99,5 +101,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==='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('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});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'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={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(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();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('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});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'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={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(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();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==='revoke-access'){if(!confirm('Access-Session von '+(b.dataset.user||'diesem Benutzer')+' wirklich widerrufen?'))return;await api('/api/v1/access/sessions/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Access-Session widerrufen');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)}}); initShell();refresh();setInterval(refresh,5000);` diff --git a/internal/model/types.go b/internal/model/types.go index 482598c..381dbfa 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -13,6 +13,43 @@ type OIDCConfig struct { SecureCookie bool `json:"secure_cookie"` } +// AccessAuthConfig configures the SessionGuard Master as a Traefik ForwardAuth +// endpoint for browser-facing services such as Apache Guacamole. It deliberately +// has its own redirect/cookie scope because the SessionGuard admin UI and the +// protected application may live on different DNS domains. Blank issuer/client +// fields inherit their values from the primary OIDC configuration. +type AccessAuthConfig struct { + Enabled bool `json:"enabled"` + Issuer string `json:"issuer,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURL string `json:"redirect_url"` + LogoutRedirectURL string `json:"logout_redirect_url,omitempty"` + CookieName string `json:"cookie_name,omitempty"` + CookieDomain string `json:"cookie_domain,omitempty"` + SecureCookie bool `json:"secure_cookie"` + SessionHours int `json:"session_hours,omitempty"` + UsernameClaim string `json:"username_claim,omitempty"` + AllowedGroups []string `json:"allowed_groups,omitempty"` + AllowedHosts []string `json:"allowed_hosts,omitempty"` +} + +// AuthSession is an opaque, server-side browser session. SessionGuard stores +// only the SHA-256 hash of the random browser token as the map key. +type AuthSession struct { + ID string `json:"id"` + TokenHash string `json:"token_hash"` + Subject string `json:"subject"` + SID string `json:"sid,omitempty"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Groups []string `json:"groups,omitempty"` + IDToken string `json:"id_token,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + type RBACConfig struct { DefaultRole string `json:"default_role,omitempty"` Groups map[string][]string `json:"groups,omitempty"`