0.5.0
All checks were successful
release-tag / release-image (push) Successful in 2m6s
release-main / release-images (push) Successful in 3m40s

This commit is contained in:
2026-08-23 08:31:17 +02:00
parent 1b29cbb39d
commit b61b2d2ef1
24 changed files with 1212 additions and 54 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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=

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

144
docs/ACCESS-AUTH.md Normal file
View File

@@ -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.

View File

@@ -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=<EXAKT_DERSELBE_BROKER_API_KEY_WIE_AM_MASTER>
```
@@ -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:

View File

@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>info.hilden.sessionguard</groupId>
<artifactId>sessionguard-guacamole</artifactId>
<version>0.4.1</version>
<version>0.5.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>11</maven.compiler.release>

View File

@@ -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"
]
}

View File

@@ -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();
}
}());

View File

@@ -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

View File

@@ -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}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.4.1</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.0</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master"></div></div></div></section>
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
@@ -44,7 +44,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
<section class="page-section" id="profiles-section"><div class="section-heading"><div><div class="eyebrow">Profile</div><h2>Profil-Pipeline</h2><div class="section-copy">Backup, Restore, Retry und Cleanup-Status pro Benutzer.</div></div></div><section class="panel"><div id="profiles"></div></section></section>
<section class="page-section" id="events-section"><div class="section-heading"><div><div class="eyebrow">Telemetry</div><h2>Aktivitäts- & Audit-Log</h2><div class="section-copy">Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.</div></div></div><section class="panel"><div id="events"></div></section></section>
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div></div><section class="panel"><div id="policy"></div></section></section>
</main></div></div><script src="/app.js?v=0.4.1"></script></body></html>`
</main></div></div><script src="/app.js?v=0.5.0"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;

507
internal/auth/access.go Normal file
View File

@@ -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)})
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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{}

View File

@@ -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}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell">
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.4.1</div></div></div>
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a></nav>
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.0</div></div></div>
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a><a class="nav-link" href="#access-section"><span class="nav-icon">◎</span>Access-Sessions</a></nav>
<nav class="nav-group"><div class="nav-label">Betrieb</div><a class="nav-link" href="#history-section"><span class="nav-icon">↻</span>Session-Historie</a><a class="nav-link" href="#policies-section"><span class="nav-icon">≋</span>Policy-Historie</a><a class="nav-link" href="#alerts-section"><span class="nav-icon">!</span>Alerts</a><a class="nav-link" href="#audit-section"><span class="nav-icon">✓</span>Audit-Log</a></nav>
<div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Master Console</div><div class="topbar-sub">Broker, Director, Profile & Policy Management</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button" title="Theme wechseln">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Control Plane</div><h1>Infrastruktur auf einen Blick</h1><div class="section-copy">Live-Status der RDS-Farm, Profile und Broker-Ressourcen.</div></div></div>
<div class="metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Server</span><span class="metric-icon">▣</span></div><div class="value" id="mServers"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Online</span><span class="metric-icon">●</span></div><div class="value" id="mOnline"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="mActive"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="mDisc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="mProfile"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="mCleanup"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Farms</span><span class="metric-icon">⌘</span></div><div class="value" id="mFarms"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Alerts</span><span class="metric-icon">!</span></div><div class="value" id="mAlerts"></div></div></div></section>
<div class="metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Server</span><span class="metric-icon">▣</span></div><div class="value" id="mServers"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Online</span><span class="metric-icon">●</span></div><div class="value" id="mOnline"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="mActive"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="mDisc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="mProfile"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="mCleanup"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Farms</span><span class="metric-icon">⌘</span></div><div class="value" id="mFarms"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Access</span><span class="metric-icon">◎</span></div><div class="value" id="mAccess"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Alerts</span><span class="metric-icon">!</span></div><div class="value" id="mAlerts"></div></div></div></section>
<section class="page-section" id="servers"><div class="section-heading"><div><div class="eyebrow">Director</div><h2>Terminalserver</h2><div class="section-copy">Server auswählen, Sitzungen steuern und Health-Details prüfen.</div></div></div><div class="workspace-grid"><section class="panel"><div class="panel-head"><div><h2>Serverliste</h2><div class="panel-kicker">Online, Sessions, Health und Jobs</div></div></div><div id="agents"></div></section><section class="panel"><div class="panel-head"><div><h2 id="detailTitle">Server auswählen</h2><div class="panel-kicker">Live-Details und Konfiguration</div></div></div><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></section>
<section class="page-section" id="farms-section"><div class="section-heading"><div><div class="eyebrow">Broker</div><h2>Farms & Broker</h2><div class="section-copy">Hosts gruppieren, Tags definieren und aktive Leases nachvollziehen.</div></div></div><section class="panel"><div id="farms"></div></section></section>
<section class="page-section" id="resources-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>Apps & Desktops</h2><div class="section-copy">Guacamole-Ressourcen zu Farms zuordnen und RemoteApps optional direkt durch die Windows-Agents veröffentlichen.</div></div></div><section class="panel"><div id="resources"></div></section></section>
<section class="page-section" id="access-section"><div class="section-heading"><div><div class="eyebrow">Access Plane</div><h2>Guacamole Access-Sessions</h2><div class="section-copy">Serverseitige PocketID-Sitzungen, die Traefik zur Laufzeit vor jedem Guacamole-Request prüft. Eine widerrufene oder abgelaufene Session liefert keinen Benutzer-Header mehr.</div></div></div><section class="panel"><div id="accessSessions"><div class="empty">Access-Sessions werden geladen…</div></div></section></section>
<section class="page-section" id="history-section"><div class="section-heading"><div><div class="eyebrow">Historie</div><h2>Session-Historie</h2><div class="section-copy">Logon, Reconnect, Disconnect und Profilereignisse nachvollziehen.</div></div></div><section class="panel"><div id="history" class="content"></div></section></section>
<section class="page-section" id="policies-section"><div class="section-heading"><div><div class="eyebrow">Governance</div><h2>Policy-Historie & Rollback</h2><div class="section-copy">Revisionen vergleichen und frühere Sollstände wieder aktivieren.</div></div></div><section class="panel"><div id="policyHistory" class="content"></div></section></section>
<section class="page-section" id="alerts-section"><div class="section-heading"><div><div class="eyebrow">Monitoring</div><h2>Alerts</h2><div class="section-copy">Aktive und gelöste Betriebswarnungen.</div></div></div><section class="panel"><div id="alerts" class="content"></div></section></section>
<section class="page-section" id="audit-section"><div class="section-heading"><div><div class="eyebrow">Compliance</div><h2>Audit-Log</h2><div class="section-copy">Administrative Änderungen und Aktionen revisionsfreundlich nachvollziehen.</div></div></div><section class="panel"><div id="audit" class="content"></div></section></section>
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.4.1"></script></body></html>`
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.5.0"></script></body></html>`
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?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='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Mode</th><th>Health</th><th>Sitzungen</th><th>Jobs</th></tr></thead><tbody>'+a.map(x=>'<tr class="row '+(x.id===selected?'selected':'')+'" data-id="'+esc(x.id)+'"><td><span class="status-badge '+(x.online?'status-online':'status-offline')+'"><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</span></td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.agent_version||'')+'</span></td><td><span class="badge '+((x.maintenance_mode||'online')==='online'?'good':(x.maintenance_mode==='drain'?'warning':'bad'))+'">'+esc(x.maintenance_mode||'online')+'</span></td><td>'+esc((x.snapshot.health||{}).score??'')+'</td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+((x.snapshot.profile_jobs||[]).length)+' P / '+((x.snapshot.pending_cleanup||[]).length)+' C</td></tr>').join('')+'</tbody></table>';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='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}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='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}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='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}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='<div class="empty">Access-Sessions konnten nicht geladen werden: '+esc(e.message)+'</div>'}}else $('accessSessions').innerHTML='<div class="empty">Access-Sessions erfordern Manage/Admin-Rechte.</div>';try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}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?'<table class="table"><thead><tr><th>Zeit</th><th>Ziel</th><th>Revision</th><th>Akteur</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.created_at)+'</td><td>'+esc(x.target)+'</td><td><code>'+esc(x.revision)+'</code></td><td>'+esc(x.actor||'')+'</td><td><button class="secondary" data-global-action="rollback-policy" data-target="'+esc(x.target)+'" data-revision="'+esc(x.revision)+'">Rollback</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Policy-Versionen.</div>'}
function renderAudit(rows){rows=(rows||[]).slice().reverse().slice(0,250);$('audit').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ziel</th><th>Ergebnis</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+esc(when(x.time))+'</td><td>'+esc(x.actor)+'</td><td>'+esc(x.action)+'</td><td>'+esc(x.target||'')+'</td><td class="'+(x.result==='error'?'bad':x.result==='success'?'good':'')+'">'+esc(x.result)+(x.details?'<br><span class="muted">'+esc(x.details)+'</span>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Audit-Einträge.</div>'}
function renderAccessSessions(rows){let root=$('accessSessions');if(!root)return;rows=rows||[];root.innerHTML=rows.length?'<table class="table"><thead><tr><th>Benutzer</th><th>Gruppen</th><th>Erstellt</th><th>Läuft ab</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td><strong>'+esc(x.username||'')+'</strong>'+(x.name?'<br><span class="muted">'+esc(x.name)+'</span>':'')+(x.email?'<br><span class="muted">'+esc(x.email)+'</span>':'')+'</td><td>'+esc((x.groups||[]).join(', ')||'')+'</td><td>'+esc(when(x.created_at))+'</td><td>'+esc(when(x.expires_at))+'</td><td><button class="danger" data-global-action="revoke-access" data-id="'+esc(x.id)+'" data-user="'+esc(x.username||'')+'">Widerrufen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine aktiven Guacamole Access-Sessions.</div>'}
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'<div class="form"><div class="cols"><div><span class="muted">OS</span><br><strong>'+esc(v.os||'')+'</strong></div><div><span class="muted">Health</span><br><strong class="'+((h.score||0)>=70?'good':'bad')+'">'+esc(h.score??'')+'/100</strong></div><div><span class="muted">CPU</span><br><strong>'+Number(v.cpu_percent||0).toFixed(1)+' %</strong></div><div><span class="muted">RAM</span><br><strong>'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei</strong></div><div><span class="muted">Systemdisk</span><br><strong>'+bytes(v.disk_free)+' frei</strong></div><div><span class="muted">Heartbeat</span><br><strong>'+when(a.last_seen)+'</strong></div></div><div class="note">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+'</div></div>'}
function controlHTML(a){let tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<div class="form"><div class="two"><label>Maintenance Mode<select id="agentMode"><option value="online" '+((a.maintenance_mode||'online')==='online'?'selected':'')+'>Online</option><option value="drain" '+(a.maintenance_mode==='drain'?'selected':'')+'>Drain</option><option value="maintenance" '+(a.maintenance_mode==='maintenance'?'selected':'')+'>Maintenance</option></select></label><label>Farm IDs (eine pro Zeile)<textarea id="agentFarms">'+esc((a.farm_ids||[]).join('\n'))+'</textarea></label><label>Tags key=value<textarea id="agentTags">'+esc(tags)+'</textarea></label><div><div class="check"><input id="restartDrained" type="checkbox" '+(a.restart_when_drained?'checked':'')+'><label>Neu starten sobald leer</label></div><div class="actions"><button type="button" data-action="save-control">Serversteuerung speichern</button><button type="button" class="secondary" data-action="broadcast">Nachricht an alle</button><button type="button" class="danger" data-action="logoff-disconnected">Getrennte abmelden</button><button type="button" class="secondary" data-action="reload-control">Steuerung neu laden</button></div></div></div></div>'}
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'&&current&&$('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'&&current&&$('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);`

View File

@@ -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"`