0.5.0
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.0 — Integrated Guacamole Access Auth
|
||||
|
||||
- SessionGuard Master now provides `/auth/verify` as a Traefik ForwardAuth endpoint for Guacamole.
|
||||
- Added a dedicated PocketID/OIDC `access_auth` flow, independent from the Master administration login.
|
||||
- Added opaque server-side access sessions persisted in the existing control-plane store; only the SHA-256 browser-token hash is used as the lookup key.
|
||||
- Added strict `X-Guacamole-User` emission only for valid access sessions, group restrictions and allowed return-host validation.
|
||||
- Added RP-initiated OIDC logout using the discovered `end_session_endpoint`.
|
||||
- Added OIDC Back-Channel Logout with signature/issuer/audience/event/`iat`/`jti` validation and replay protection.
|
||||
- Added concurrent-safe per-flow OIDC state cookies and external-prefix-aware callback cookie paths.
|
||||
- Guacamole extension now ships a framework-free JS helper which redirects Guacamole logout into full SessionGuard/PocketID logout and periodically detects revoked/expired access sessions.
|
||||
- Added Traefik header-scrubbing/ForwardAuth deployment guidance and migration away from `traefik-forward-auth`.
|
||||
- Agent protocol remains version 4; no Agent data/schema migration is required.
|
||||
|
||||
## 0.4.1 — RemoteApp PowerShell/CLIXML robustness
|
||||
|
||||
- RemoteApp PowerShell execution now keeps stderr separate from JSON stdout.
|
||||
|
||||
16
README.md
16
README.md
@@ -2,10 +2,22 @@
|
||||
|
||||
SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions.
|
||||
|
||||
**Current development version: 0.4.1 (RemoteApp PowerShell/CLIXML robustness)**
|
||||
**Current development version: 0.5.0 (integrated Guacamole Access Auth / ForwardAuth)**
|
||||
|
||||
> SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC.
|
||||
|
||||
## 0.5.0 Integrated Guacamole Access Auth
|
||||
|
||||
- `sessionguard-master` can now act directly as Traefik ForwardAuth for Guacamole; no separate forward-auth container is required.
|
||||
- PocketID OIDC login for Guacamole uses a separate `access_auth` configuration/cookie scope from the SessionGuard admin UI.
|
||||
- Browser sessions are opaque, server-side and persisted in the existing Master store; expired/revoked sessions are denied even if Guacamole still has an old auth token.
|
||||
- `X-Guacamole-User` is emitted only after a valid SessionGuard access-session check. There is no fallback identity.
|
||||
- OIDC RP-initiated logout and PocketID back-channel logout are supported; back-channel tokens are signature/audience/event/time/JTI checked with replay protection.
|
||||
- The Guacamole extension now includes plain JavaScript which detects Guacamole logout and performs the full SessionGuard/PocketID logout, while also polling access-session status to close stale browser sessions.
|
||||
- Recommended deployment exposes `/_sessionguard/auth/*` on the Guacamole hostname and routes that prefix to the same Master container.
|
||||
|
||||
See `docs/ACCESS-AUTH.md` for the PocketID and Traefik migration.
|
||||
|
||||
## 0.4.1 RemoteApp robustness
|
||||
|
||||
Windows PowerShell auxiliary streams are now isolated from the JSON protocol used by RemoteApp discovery/reconciliation. CLIXML/progress noise no longer breaks RemoteApp status decoding.
|
||||
@@ -313,4 +325,4 @@ The current source tree contains unit tests for configuration, templates, profil
|
||||
|
||||
## Production-candidate status
|
||||
|
||||
The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.4.0 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment.
|
||||
The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.5.0 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
144
docs/ACCESS-AUTH.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}());
|
||||
@@ -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
|
||||
|
||||
@@ -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
507
internal/auth/access.go
Normal 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)})
|
||||
}
|
||||
62
internal/auth/access_helpers_test.go
Normal file
62
internal/auth/access_helpers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
78
internal/master/auth_store.go
Normal file
78
internal/master/auth_store.go
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'&¤t&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
|
||||
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&¤t&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='revoke-access'){if(!confirm('Access-Session von '+(b.dataset.user||'diesem Benutzer')+' wirklich widerrufen?'))return;await api('/api/v1/access/sessions/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Access-Session widerrufen');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
|
||||
initShell();refresh();setInterval(refresh,5000);`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user