Update mit Guacamole-Extension
All checks were successful
release-tag / release-image (push) Successful in 2m5s
release-main / release-images (push) Successful in 5m5s

This commit is contained in:
2026-08-22 15:19:17 +02:00
parent 0f4a234f52
commit 7972ed7e38
45 changed files with 6448 additions and 402 deletions

View File

@@ -0,0 +1,174 @@
name: release-main
on:
push:
branches:
- 'main'
jobs:
release-images:
runs-on: ubuntu-latest
env:
REGISTRY: git.send.nrw
GITEA_API_BASE: https://git.send.nrw
DOCKER_ORG: sendnrw
DOCKER_LATEST: latest
GUACAMOLE_VERSION: '1.6.0'
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# Required so git describe can see repository tags.
fetch-depth: 0
- name: Get Meta
id: meta
shell: bash
run: |
set -euo pipefail
REPO_NAME="${GITHUB_REPOSITORY##*/}"
REPO_VERSION="$(git describe --tags --always | sed 's/^v//')"
echo "REPO_NAME=${REPO_NAME}" >> "$GITHUB_OUTPUT"
echo "REPO_VERSION=${REPO_VERSION}" >> "$GITHUB_OUTPUT"
echo "GUAC_IMAGE=${REPO_NAME}-guacamole" >> "$GITHUB_OUTPUT"
echo "EXT_PACKAGE=${REPO_NAME}-guacamole-extension" >> "$GITHUB_OUTPUT"
echo "Repository : ${REPO_NAME}"
echo "Version : ${REPO_VERSION}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."git.send.nrw"]
http = true
insecure = true
- name: Login to Gitea Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push SessionGuard Master
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
# Export exactly the same extension that is embedded into the Guacamole image.
# The dedicated target avoids depending on the Maven project version in CI.
- name: Build Guacamole Extension JAR
uses: docker/build-push-action@v4
with:
context: .
file: ./deploy/guacamole/Dockerfile.guacamole
target: extension-artifact
platforms: linux/amd64
push: false
outputs: type=local,dest=./dist/guacamole-extension
build-args: |
GUACAMOLE_VERSION=${{ env.GUACAMOLE_VERSION }}
- name: Verify Guacamole Extension JAR
id: extension
shell: bash
run: |
set -euo pipefail
JAR="dist/guacamole-extension/sessionguard-guacamole.jar"
test -s "$JAR"
SHA256="$(sha256sum "$JAR" | awk '{print $1}')"
echo "JAR=${JAR}" >> "$GITHUB_OUTPUT"
echo "SHA256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "SessionGuard Guacamole extension"
echo "SHA256: ${SHA256}"
# Publishes the raw JAR in Gitea's Generic Package Registry.
# The same credentials as the container registry are used here. If your
# registry account has no package-write permission, create dedicated
# PACKAGE_USERNAME / PACKAGE_TOKEN secrets and substitute them below.
- name: Publish Guacamole Extension Package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.DOCKER_USERNAME }}
PACKAGE_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
set -euo pipefail
PACKAGE_NAME="${{ steps.meta.outputs.EXT_PACKAGE }}"
VERSION="${{ steps.meta.outputs.REPO_VERSION }}"
JAR="${{ steps.extension.outputs.JAR }}"
FILE_NAME="sessionguard-guacamole.jar"
URL="${GITEA_API_BASE}/api/packages/${DOCKER_ORG}/generic/${PACKAGE_NAME}/${VERSION}/${FILE_NAME}"
# Generic package files are immutable in Gitea. A re-run for the same
# commit/version therefore returns 409; treat that as already published.
HTTP_CODE="$(curl --silent --show-error \
--output /tmp/sessionguard-package-response.txt \
--write-out '%{http_code}' \
--user "${PACKAGE_USERNAME}:${PACKAGE_PASSWORD}" \
--upload-file "$JAR" \
"$URL")"
case "$HTTP_CODE" in
201)
echo "Published ${PACKAGE_NAME}:${VERSION}"
;;
409)
echo "Package ${PACKAGE_NAME}:${VERSION} already exists; keeping immutable artifact."
;;
*)
cat /tmp/sessionguard-package-response.txt || true
echo "Package upload failed with HTTP ${HTTP_CODE}" >&2
exit 1
;;
esac
- name: Build and push Guacamole + SessionGuard Extension
uses: docker/build-push-action@v4
with:
context: .
file: ./deploy/guacamole/Dockerfile.guacamole
target: guacamole
platforms: linux/amd64
push: true
build-args: |
GUACAMOLE_VERSION=${{ env.GUACAMOLE_VERSION }}
tags: |
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.GUAC_IMAGE }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.GUAC_IMAGE }}:${{ env.DOCKER_LATEST }}
- name: Release summary
shell: bash
run: |
cat <<EOF
SessionGuard release completed.
Master image:
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
Guacamole image:
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.GUAC_IMAGE }}:${{ steps.meta.outputs.REPO_VERSION }}
Extension package:
${GITEA_API_BASE}/api/packages/${DOCKER_ORG}/generic/${{ steps.meta.outputs.EXT_PACKAGE }}/${{ steps.meta.outputs.REPO_VERSION }}/sessionguard-guacamole.jar
Extension SHA256:
${{ steps.extension.outputs.SHA256 }}
EOF

62
CHANGELOG.md Normal file
View File

@@ -0,0 +1,62 @@
# Changelog
## 0.3.0 - Broker & Director production candidate
### Broker and farm control
- Added logical Farms with explicit agent IDs, agent-side farm IDs and required-tag membership.
- Added Published Resources for desktop and RemoteApp mappings.
- Added health-aware broker with existing-session reconnect preference, leases and configurable global single-session behavior.
- Added strict farm isolation and fail-closed unknown/disabled-farm handling.
- Added `online`, `drain`, and `maintenance` server modes.
- Added `restart_when_drained` workflow.
- Added broker-specific API key and `/api/v1/broker/resolve` / `/api/v1/broker/tokens` APIs.
- Added Guacamole 1.6 extension using token injection instead of database rewriting.
### Director
- Added WTS logon/connect/last-input and client-address telemetry where available.
- Added idle-time calculation and persistent disconnect timestamps.
- Added Windows process inventory per user session and process termination command.
- Added CPU, RAM and system-disk telemetry.
- Added RDP-listener/profile-store health checks and composite health score.
- Added indexed session history and observed logon/restore timing.
- Added configurable threshold alerts and generic webhook notifications.
- Extended Master UI with health, session/process control, logon telemetry, farms, resources, leases, alerts and policy rollback.
### Enterprise control plane
- Added PostgreSQL production persistence and single-active-master advisory-lock protection.
- Split append-only audit/session history into indexed tables while keeping small control-plane state in JSONB.
- Added group-to-role RBAC.
- Added policy versioning/rollback for global, farm and agent policies.
- Added environment-secret overrides for database, enrollment, broker, OIDC and webhook credentials.
- Added hardened Docker deployment example.
- Added bounded agent telemetry/process heartbeat payloads.
- Bound queued process-termination commands to the observed RDS session and revalidated PID/session ownership before termination.
### Fixes/hardening
- Farm policies now also apply to centrally selected members (`agent_ids` / required tags), not only agents with explicit local `farm_ids`.
- Broker never reuses an existing session from a different requested farm.
- Drain hosts are reconnect-only; maintenance hosts are excluded from both reconnect and new placement.
- Broker resources cannot silently fall through to a disabled/unknown farm.
## 0.2.0 - Profile & Session Lifecycle
- Added selected-folder profile backup/restore with transactional snapshot activation and history retention.
- Added backup-before-cleanup gating and retries.
- Added disconnected-session timeout with native WTS logoff and the same backup/cleanup pipeline.
- Added master queued session control and result audit.
- Added Windows service session-change wakeups.
- Added Prometheus metrics and profile/session operation documentation.
## 0.1.1
- Fixed Master editor refresh overwriting in-progress form input.
- Added persistent Dry-Run/activity log to local and Master UI.
- Added structured Templates editor and heartbeat transfer of active policy.
## 0.1.0
- Initial Windows Agent / Linux Master MVP with WTS inventory, delayed profile cleanup, templates, PocketID/OIDC and master-agent heartbeats.

View File

@@ -11,5 +11,6 @@ COPY --from=build /out/sessionguard-master /usr/local/bin/sessionguard-master
USER sessionguard
VOLUME ["/var/lib/sessionguard"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/sessionguard-master"]
CMD ["-config", "/etc/sessionguard/master.json"]

349
README.md
View File

@@ -1,119 +1,278 @@
# SessionGuard
SessionGuard is a Go-based management layer for Windows Remote Desktop Session Hosts. It is intended for environments that use Guacamole or another access gateway and want a small subset of the operational features commonly provided by Citrix management/profile components.
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.
## Implemented MVP
**Current development version: 0.3.0 (Broker & Director production candidate)**
- Windows service agent
- RDS/WTS session inventory
- delayed profile deletion after a real session disappears
- safety exclusions, allowed profile roots, retries, and dry-run mode
- per-user template enforcement for files, directories, `.url` links and `.lnk` shortcuts
- server basics: hostname, Windows version/build, uptime, RAM
- local agent dashboard
- Linux/Docker master dashboard for all agents
- outbound agent-to-master heartbeats
- bootstrap enrollment followed by per-agent bearer credentials
- per-agent policies and "apply to all agents"
- Pocket ID / generic OIDC authentication for master and local agent UI
- last-known policy continues to work if the master is unavailable
> 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.
## Important safety note
## 0.3.0 at a glance
Profile deletion is destructive. Start with `dry_run: true`, verify exclusions and `allowed_profile_roots`, test on a non-production RDS host, and only then disable dry-run. SessionGuard calls the Windows user-profile deletion API; it does not recursively delete arbitrary profile paths itself.
### Smart broker and farms
## Build
- Logical farms with explicit agent membership and/or required tags.
- Published resources for full desktops and RemoteApps.
- Existing-session reconnect affinity before new load balancing.
- Persistent user leases to reduce duplicate sessions across hosts.
- Optional global single-session-per-user behavior.
- Health-aware placement using server health, sessions, CPU and memory pressure.
- `online`, `drain` and `maintenance` server modes.
- `restart_when_drained`: stop new allocations, wait for all user sessions to leave, then queue a controlled restart.
- Farm and global policy inheritance, plus per-agent override.
Requirements: Go 1.23+ and Internet access for the Go modules on the first build.
### Guacamole 1.6 bridge
`guacamole-extension/` contains a small Java extension using Guacamole's extension API. It does not authenticate users. It decorates the already authenticated Guacamole user context, asks the SessionGuard broker for connection tokens and injects:
- `${SESSIONGUARD_HOST}`
- `${SESSIONGUARD_AGENT_ID}`
- `${SESSIONGUARD_RESOURCE_ID}`
- `${SESSIONGUARD_REMOTE_APP}`
- `${SESSIONGUARD_REMOTE_APP_DIR}`
- `${SESSIONGUARD_REMOTE_APP_ARGS}`
This keeps PocketID / header authentication and the browser gateway separate from broker logic. See `docs/GUACAMOLE.md`.
### Director-style operations
- Consolidated server and session inventory.
- RDS logon/connect/last-input timestamps and calculated idle time.
- Per-session process inventory and remote process termination.
- CPU, memory and system-disk telemetry.
- RDP-listener and profile-store health checks.
- Composite server health score used by both the UI and broker.
- Session history for logon, reconnect, disconnect, logoff and SessionGuard `logon_ready` events.
- Logon telemetry including SessionGuard restore duration and observed time until the post-restore/template stage is ready.
- Active alerts for offline agents, CPU/RAM/disk/health, profile failures, disconnected-session count and slow observed logon.
- Optional generic JSON webhook notifications with notification throttling.
- Prometheus-compatible `/metrics` endpoint.
### Enterprise control plane
- PostgreSQL persistence for production with a database advisory lock preventing accidental active/active masters.
- Append-only audit and session-history tables with retention and indexes.
- JSONB control-plane state for agents, farms, resources, leases, policies and alerts.
- JSON-file persistence remains available as a small single-node fallback / migration path.
- PocketID / generic OIDC authentication.
- Group-to-role RBAC.
- Policy version history and rollback for global, farm and agent policies.
- Agent-initiated HTTPS heartbeats and command delivery only; no inbound management port from master to RDS hosts.
- Expiring, deduplicated command queue with result acknowledgement and audit trail.
### Windows agent and profile lifecycle
The v0.2 functionality remains:
- Windows service with native WTS session inventory and session-change wakeups.
- Send message, disconnect and logoff user sessions.
- Automatic logoff of disconnected sessions after a persistent configurable timeout.
- Backup of selected profile folders after logoff.
- Versioned transactional profile snapshots (`staging -> current`).
- Restore of the last successful snapshot on a newly observed logon.
- A failed backup blocks local profile deletion.
- Cleanup with grace period, exclusions, allowed profile roots, final WTS recheck and `DeleteProfileW`.
- Structured templates for files, folders, `.url` and `.lnk` shortcuts.
- Local PocketID/OIDC-protected fallback UI on every agent.
See `docs/PROFILE-LIFECYCLE.md` for the deliberately limited scope of profile synchronization.
## Target architecture
```text
PocketID
|
Traefik / trusted header-auth boundary
|
Apache Guacamole 1.6
+ sessionguard-guacamole extension
|
| broker token request
v
SessionGuard Master ---------------- PostgreSQL
|
| outbound agent heartbeats carry policy/commands/results
|
+-------------+-------------+
| | |
RDS01 RDS02 RDS03
Agent Agent Agent
```
## Quick start: master
Copy the example configuration and environment file:
```bash
cp configs/master.example.json deploy/master.json
cp deploy/.env.example deploy/.env
```
Set at least:
- `SESSIONGUARD_DB_PASSWORD`
- `SESSIONGUARD_ENROLLMENT_TOKEN`
- `SESSIONGUARD_BROKER_API_KEY`
- `SESSIONGUARD_OIDC_CLIENT_SECRET`
- PocketID issuer/client/redirect values in `master.json`
- `TRAEFIK_NETWORK`
Then:
```bash
cd deploy
docker compose up -d --build
```
For production, expose the master only through your existing TLS reverse proxy. The bundled compose publishes port 8080 only on `127.0.0.1` as an additional safety measure.
## Quick start: Windows agent
Build on a machine with Go module access:
```powershell
.\scripts\build.ps1
```
Or:
Copy `configs/agent.example.json`, configure PocketID, the master URL and your initial policy, then install from an elevated PowerShell:
```bash
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o sessionguard-agent.exe ./cmd/agent
go build -o sessionguard-master ./cmd/master
```powershell
.\scripts\install-agent.ps1 -BinaryPath .\dist\sessionguard-agent.exe -ConfigPath .\configs\agent.json
```
## Master deployment
For SMB profile stores, prefer a gMSA and grant it the required share/NTFS permissions. Do not store SMB passwords in the agent JSON.
1. Copy `configs/master.example.json` to `deploy/master.json` and edit it.
2. In Pocket ID create an OIDC client whose callback URL is `https://sessionguard.example.org/oidc/callback`.
3. Restrict the Pocket ID client to the intended admin group and configure the same group in `admin_groups`.
4. Put a TLS reverse proxy in front of the master.
5. Start with `docker compose -f deploy/docker-compose.yml up -d --build`.
## Guacamole integration
The container binds the example host port only to `127.0.0.1`; publish it through your reverse proxy rather than exposing plain HTTP.
Build a Guacamole image containing the extension using `deploy/guacamole/Dockerfile.guacamole`. Add these environment variables to Guacamole:
## Agent deployment
1. Copy `configs/agent.example.json` to `configs/agent.json` and edit the master URL, enrollment token and OIDC settings.
2. Create a Pocket ID OIDC client for the agent's management URL, for example `https://ts01-mgmt.example.org/oidc/callback`.
3. Build the Windows agent.
4. Run `scripts/install-agent.ps1` from an elevated PowerShell prompt.
5. Keep `dry_run: true` until profile cleanup has been observed successfully.
The service is installed as LocalSystem by default. If a template `source` points to a UNC share, grant read access to the server computer account (`DOMAIN\SERVER$`) or change the service identity to an appropriate gMSA/service account. Do not put share passwords in the SessionGuard JSON configuration.
## Example templates
```json
[
{
"id": "support",
"kind": "url",
"target": "Desktop\\Support.url",
"url": "https://support.example.org",
"overwrite": true
},
{
"id": "erp",
"kind": "shortcut",
"target": "Desktop\\ERP.lnk",
"overwrite": true,
"shortcut": {
"target": "C:\\Program Files\\ERP\\erp.exe",
"arguments": "--terminal"
}
},
{
"id": "defaults",
"kind": "file",
"target": "AppData\\Roaming\\Example\\defaults.json",
"source": "\\\\fileserver\\templates\\defaults.json",
"overwrite": true
}
]
```yaml
SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080
SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY}
SESSIONGUARD_BROKER_TIMEOUT_MS: "2500"
```
All `target` values are relative to the user's profile. Attempts to escape the profile root are rejected.
## Local management during a master outage
The Windows agent continues cleanup and template work using its persisted policy. Its local web UI remains available independently of the master, provided Pocket ID is reachable. A locally saved emergency policy remains in effect until the master reconnects; if the master already has a different desired policy for that agent, the master policy is then reapplied.
## Pocket ID notes
SessionGuard requests the `openid`, `profile`, `email` and `groups` scopes. Use `admin_groups` as a second authorization check in addition to Pocket ID's client-side allowed-group restriction. The master and agent should be served over HTTPS and `secure_cookie` should remain enabled.
## What is intentionally not implemented yet
Full Citrix-style profile roaming/restoration is not part of v0.1. Copying an entire profile, especially `NTUSER.DAT` and registry-backed settings, after Windows has loaded that profile is unsafe. See `docs/ARCHITECTURE.md` for the recommended extension path.
## Repository layout
A brokered desktop connection uses:
```text
cmd/master Linux/Docker master binary
cmd/agent Windows service binary
internal/agent agent lifecycle, cleanup, heartbeat, local UI
internal/master enrollment, dashboard, policy distribution
internal/windowsx WTS, profile and Windows server APIs
internal/templates template comparison/application
internal/auth Pocket ID / OIDC login
configs example JSON configurations
deploy Docker Compose example
scripts build/install helpers
docs architecture notes
hostname = ${SESSIONGUARD_HOST}
```
A RemoteApp can additionally use the `SESSIONGUARD_REMOTE_APP*` tokens. Map the Guacamole connection ID or name to a SessionGuard Resource in the master UI.
**Important:** with Guacamole header authentication, untrusted containers or networks must not be able to reach Guacamole directly and forge the authentication header. See `docs/GUACAMOLE.md` and `docs/SECURITY.md`.
## Master environment overrides
Secrets do not need to be stored in JSON:
| Variable | Purpose |
|---|---|
| `SESSIONGUARD_DATABASE_URL` | PostgreSQL DSN |
| `SESSIONGUARD_ENROLLMENT_TOKEN` | one-time agent enrollment shared secret |
| `SESSIONGUARD_BROKER_API_KEY` | Guacamole-extension → broker credential |
| `SESSIONGUARD_OIDC_CLIENT_SECRET` | master PocketID/OIDC client secret |
| `SESSIONGUARD_ALERT_WEBHOOK_URL` | optional alert webhook |
Agent overrides:
| Variable | Purpose |
|---|---|
| `SESSIONGUARD_MASTER_URL` | master URL |
| `SESSIONGUARD_ENROLLMENT_TOKEN` | enrollment secret |
| `SESSIONGUARD_OIDC_CLIENT_SECRET` | local agent UI OIDC secret |
## Broker placement order
1. Resolve an enabled Published Resource if one matches the requested Guacamole connection ID/name.
2. Determine the requested/default farm.
3. If enabled, reconnect a matching existing Active/Connected/Disconnected session in that farm. A `drain` host may be used for this; `maintenance` may not.
4. Reuse a non-expired matching user lease if possible.
5. Select only online, healthy, `online`-mode members of the farm.
6. Score candidates using health, active/disconnected sessions, CPU and memory pressure.
7. Create/refresh the user lease and return connection tokens.
A farm boundary is fail-closed: a session on another farm is not reused for a request for the selected farm.
## Policy precedence
```text
agent override
> explicit farm membership (order in agent.farm_ids)
> centrally selected farm (agent_ids / required_tags, deterministic farm-ID order)
> global policy
```
All policy writes are normalized and validated before activation. Every global/farm/agent policy write creates a version that can be rolled back from the UI/API.
## RBAC roles
Built-in roles are:
- `viewer`
- `helpdesk`
- `operator`
- `profile_admin`
- `policy_admin`
- `auditor`
- `admin`
PocketID `groups` claims are mapped in `master.json`. If the RBAC group mapping is intentionally empty, SessionGuard preserves backwards compatibility and grants authenticated users the `admin` role; do not leave it empty in a multi-admin production environment.
See `docs/RBAC.md`.
## Profile scope and safety
SessionGuard synchronizes selected **profile folders**, not a complete Windows profile. Do not configure `NTUSER.DAT`, registry hives, the whole profile root, EFS material or Windows-owned profile internals as roaming content.
Good candidates include:
- `AppData\Roaming\Vendor\Product`
- `Desktop`
- `Documents`
- application-owned configuration folders safe to merge after logon
If the profile backup fails, cleanup is not scheduled. If the user reconnects before snapshot activation or immediately before cleanup, the dangerous operation is cancelled.
## Build and test
Requirements:
- Go 1.23+
- Windows Server 2019/2022/2025 x64 for agent integration testing
- Maven/JDK 11+ for the Guacamole extension
- PostgreSQL 17 recommended for production
Go:
```bash
go mod tidy
go test ./...
go vet ./...
```
Guacamole extension:
```bash
cd guacamole-extension
mvn -DskipTests package
```
The current source tree contains unit tests for configuration, templates, profile snapshot behavior and broker placement invariants. A real release should additionally pass the Windows/RDS/SMB/PostgreSQL integration matrix in `docs/TESTING.md`.
## Documentation
- `docs/ARCHITECTURE.md` components, state and trust boundaries
- `docs/BROKER.md` farms, resources, leases, scoring and maintenance states
- `docs/DIRECTOR.md` telemetry, history, processes, alerts and health scoring
- `docs/GUACAMOLE.md` extension and integration with Guacamole/PocketID
- `docs/PROFILE-LIFECYCLE.md` profile backup/restore/cleanup pipeline
- `docs/RBAC.md` roles and PocketID group mapping
- `docs/API.md` HTTP API
- `docs/OPERATIONS.md` deployment, upgrade, backup and recovery
- `docs/SECURITY.md` security model and hardening
- `docs/TESTING.md` release and failure-mode test matrix
- `CHANGELOG.md` release changes
## Production-candidate status
The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.0 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment.

View File

@@ -74,7 +74,7 @@ func runConsole(path string) error {
type serviceHandler struct{ path string }
func (h *serviceHandler) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const accepts = svc.AcceptStop | svc.AcceptShutdown
const accepts = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptSessionChange
status <- svc.Status{State: svc.StartPending}
app, err := loadApp(h.path)
if err != nil {
@@ -90,6 +90,11 @@ func (h *serviceHandler) Execute(args []string, requests <-chan svc.ChangeReques
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
case svc.SessionChange:
// Windows sends SERVICE_CONTROL_SESSIONCHANGE for logon, logoff, connect,
// disconnect, lock and unlock. Wake the agent worker without doing file or
// network I/O inside the Service Control Manager callback.
app.NotifySessionChange()
case svc.Stop, svc.Shutdown:
status <- svc.Status{State: svc.StopPending}
cancel()

View File

@@ -10,7 +10,9 @@
"client_id": "POCKET-ID-AGENT-CLIENT-ID",
"client_secret": "POCKET-ID-AGENT-CLIENT-SECRET",
"redirect_url": "https://ts01-mgmt.example.org/oidc/callback",
"admin_groups": ["sessionguard-admins"],
"admin_groups": [
"sessionguard-admins"
],
"secure_cookie": true
},
"policy": {
@@ -20,9 +22,65 @@
"poll_seconds": 10,
"retry_seconds": 60,
"dry_run": true,
"exclude_users": ["Administrator", "DefaultAccount", "WDAGUtilityAccount"],
"exclude_sids": ["S-1-5-18", "S-1-5-19", "S-1-5-20"],
"allowed_profile_roots": ["C:\\Users"]
"exclude_users": [
"Administrator",
"DefaultAccount",
"WDAGUtilityAccount"
],
"exclude_sids": [
"S-1-5-18",
"S-1-5-19",
"S-1-5-20"
],
"allowed_profile_roots": [
"C:\\Users"
]
},
"profiles": {
"enabled": true,
"store_root": "\\\\fileserver\\SessionGuardProfiles",
"backup_on_logoff": true,
"restore_on_logon": true,
"backup_delay_seconds": 5,
"retry_seconds": 60,
"restore_window_seconds": 120,
"keep_versions": 2,
"exclude_users": [
"Administrator"
],
"exclude_sids": [
"S-1-5-18",
"S-1-5-19",
"S-1-5-20"
],
"folders": [
{
"path": "AppData\\Roaming\\Example",
"exclude_globs": [
"Cache/**",
"*.tmp"
]
},
{
"path": "Desktop"
},
{
"path": "Documents"
}
]
},
"sessions": {
"control_enabled": true,
"disconnected_logoff_enabled": true,
"disconnected_timeout_seconds": 3600,
"exclude_users": [
"Administrator"
],
"exclude_sids": [
"S-1-5-18",
"S-1-5-19",
"S-1-5-20"
]
},
"templates": [
{

View File

@@ -2,14 +2,47 @@
"listen": ":8080",
"public_url": "https://sessionguard.example.org",
"data_file": "/var/lib/sessionguard/master.json",
"enrollment_token": "CHANGE-THIS-TO-A-LONG-RANDOM-SECRET",
"database_url": "",
"enrollment_token": "SET-BY-SESSIONGUARD_ENROLLMENT_TOKEN",
"offline_after_seconds": 30,
"history_limit": 50000,
"oidc": {
"issuer": "https://id.example.org",
"client_id": "POCKET-ID-CLIENT-ID",
"client_secret": "POCKET-ID-CLIENT-SECRET",
"issuer": "https://auth.example.org",
"client_id": "SESSIONGUARD-POCKETID-CLIENT-ID",
"client_secret": "SET-BY-SESSIONGUARD_OIDC_CLIENT_SECRET",
"redirect_url": "https://sessionguard.example.org/oidc/callback",
"admin_groups": ["sessionguard-admins"],
"admin_groups": [],
"secure_cookie": true
},
"rbac": {
"default_role": "viewer",
"groups": {
"sessionguard-admins": ["admin"],
"sessionguard-helpdesk": ["helpdesk"],
"sessionguard-operators": ["operator"],
"sessionguard-auditors": ["auditor"]
}
},
"broker": {
"enabled": true,
"api_key": "SET-BY-SESSIONGUARD_BROKER_API_KEY",
"lease_seconds": 900,
"min_health_score": 60,
"reconnect_existing": true,
"single_session_per_user": true,
"default_farm_id": ""
},
"alerts": {
"enabled": true,
"webhook_url": "",
"cpu_percent": 90,
"memory_percent": 90,
"disk_free_gb": 10,
"health_score": 50,
"offline_seconds": 120,
"profile_failures": 3,
"disconnected_sessions": 20,
"logon_duration_seconds": 30,
"notification_min_interval_seconds": 900
}
}

9
deploy/.env.example Normal file
View File

@@ -0,0 +1,9 @@
POSTGRES_VERSION=17
TRAEFIK_NETWORK=aio_proxy
SESSIONGUARD_DB_NAME=sessionguard
SESSIONGUARD_DB_USER=sessionguard
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_ALERT_WEBHOOK_URL=

View File

@@ -1,14 +1,60 @@
services:
sessionguard-postgres:
image: postgres:${POSTGRES_VERSION:-17}
restart: unless-stopped
environment:
POSTGRES_DB: ${SESSIONGUARD_DB_NAME:-sessionguard}
POSTGRES_USER: ${SESSIONGUARD_DB_USER:-sessionguard}
POSTGRES_PASSWORD: ${SESSIONGUARD_DB_PASSWORD:?SESSIONGUARD_DB_PASSWORD is required}
volumes:
- sessionguard-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
networks: [sessionguard_backend]
sessionguard-master:
build:
context: ..
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
sessionguard-postgres:
condition: service_healthy
read_only: true
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
environment:
SESSIONGUARD_DATABASE_URL: postgres://${SESSIONGUARD_DB_USER:-sessionguard}:${SESSIONGUARD_DB_PASSWORD}@sessionguard-postgres:5432/${SESSIONGUARD_DB_NAME:-sessionguard}?sslmode=disable
SESSIONGUARD_ENROLLMENT_TOKEN: ${SESSIONGUARD_ENROLLMENT_TOKEN:?SESSIONGUARD_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_ALERT_WEBHOOK_URL: ${SESSIONGUARD_ALERT_WEBHOOK_URL:-}
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./master.json:/etc/sessionguard/master.json:ro
- sessionguard-data:/var/lib/sessionguard
tmpfs:
- /tmp:size=16m,mode=1777
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- sessionguard_backend
- proxy
networks:
sessionguard_backend:
internal: true
proxy:
external: true
name: ${TRAEFIK_NETWORK:-aio_proxy}
volumes:
sessionguard-data:
sessionguard-postgres:

56
deploy/guacamole/CI-CD.md Normal file
View File

@@ -0,0 +1,56 @@
# Gitea CI/CD for the SessionGuard Guacamole extension
The workflow `.gitea/workflows/release.yml` publishes three artifacts on every
push to `main`:
1. `git.send.nrw/sendnrw/<repo>:<version>` - SessionGuard Master
2. `git.send.nrw/sendnrw/<repo>-guacamole:<version>` - Guacamole with the
SessionGuard broker extension preinstalled
3. `sessionguard-guacamole.jar` in the Gitea Generic Package Registry under
`<repo>-guacamole-extension/<version>`
Both container images also receive the `latest` tag.
## Required secrets
The workflow reuses the existing secrets:
- `DOCKER_USERNAME`
- `DOCKER_PASSWORD`
The account needs write access to both the OCI/container registry and the
Gitea Package Registry. If these permissions should be separated, create
`PACKAGE_USERNAME` and `PACKAGE_TOKEN` secrets and use those in the package
upload step.
## Compose
Instead of the stock Guacamole image, use the CI-built image:
```yaml
services:
guacamole:
image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-latest}
environment:
SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080
SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY}
SESSIONGUARD_BROKER_TIMEOUT_MS: "2500"
```
Keep all existing Guacamole/PostgreSQL/header-auth environment variables and
Traefik labels unchanged.
## Versioning
`fetch-depth: 0` is important. The release version is determined with:
```sh
git describe --tags --always | sed 's/^v//'
```
A commit tagged `v0.3.0` therefore publishes `0.3.0`; later commits are named
like `0.3.0-1-g0123456` until the next tag.
The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.0.jar`.
Maven may therefore change the project version without requiring a Dockerfile
change.

View File

@@ -0,0 +1,18 @@
ARG GUACAMOLE_VERSION=1.6.0
FROM maven:3.9-eclipse-temurin-17 AS extension-build
WORKDIR /src
COPY guacamole-extension/pom.xml ./pom.xml
COPY guacamole-extension/src ./src
RUN mvn -B -DskipTests package \
&& JAR="$(find target -maxdepth 1 -type f -name 'sessionguard-guacamole-*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' | head -n 1)" \
&& test -n "$JAR" \
&& cp "$JAR" /tmp/sessionguard-guacamole.jar
# This target is used by CI to export the extension as a standalone JAR.
FROM scratch AS extension-artifact
COPY --from=extension-build /tmp/sessionguard-guacamole.jar /sessionguard-guacamole.jar
# Default/final image: stock Guacamole plus the SessionGuard broker extension.
FROM guacamole/guacamole:${GUACAMOLE_VERSION} AS guacamole
COPY --from=extension-build /tmp/sessionguard-guacamole.jar /etc/guacamole/extensions/sessionguard-broker.jar

View File

@@ -0,0 +1,50 @@
# SessionGuard + Guacamole 1.6 integration
The `sessionguard-guacamole` extension does **not** authenticate users. It decorates the `UserContext` produced by Guacamole's existing providers and asks the SessionGuard broker for connection parameter tokens at connect time.
## 1. Build Guacamole with the extension
Use `Dockerfile.guacamole` instead of the stock image directly. It builds `guacamole-extension/` with Maven and copies the JAR to `/etc/guacamole/extensions/`.
The Guacamole container needs these additional environment variables:
```yaml
SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080
SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY}
SESSIONGUARD_BROKER_TIMEOUT_MS: "2500"
```
Use a private Docker network between Guacamole and SessionGuard Master where possible. The broker API key is independent from the agent enrollment token and PocketID client secret.
## 2. Configure Guacamole RDP connections
For a brokered desktop connection set the RDP hostname to:
```text
${SESSIONGUARD_HOST}
```
For a brokered RemoteApp resource, these SessionGuard tokens can be used as connection parameters:
```text
hostname: ${SESSIONGUARD_HOST}
remote-app: ${SESSIONGUARD_REMOTE_APP}
remote-app-dir: ${SESSIONGUARD_REMOTE_APP_DIR}
remote-app-args: ${SESSIONGUARD_REMOTE_APP_ARGS}
```
The extension identifies a SessionGuard resource by Guacamole connection ID or connection name. Create the corresponding mapping in SessionGuard under **Published Resources**. Unmapped Guacamole connections receive no SessionGuard tokens and remain untouched; this lets brokered and static/admin connections coexist.
## 3. Broker behavior
1. Existing matching RDS session in the requested farm wins (if reconnect is enabled).
2. Otherwise a valid SessionGuard user lease is reused.
3. Otherwise only online, `online`-mode farm members above `min_health_score` are considered.
4. Candidates are scored by health, active/disconnected sessions, CPU and memory pressure.
5. `drain` blocks new allocations but allows reconnects; `maintenance` blocks both.
With `single_session_per_user=true`, the lease key is global per username. With it disabled, leases are scoped by user + farm + resource.
## 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.

View File

@@ -0,0 +1,29 @@
# Merge these changes into the existing Guacamole stack.
# Build context must point at the SessionGuard repository root.
services:
guacamole:
image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.0}
build:
context: ../..
dockerfile: deploy/guacamole/Dockerfile.guacamole
args:
GUACAMOLE_VERSION: ${GUACAMOLE_VERSION:-1.6.0}
environment:
# Existing Guacamole variables stay unchanged.
SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080
SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY}
SESSIONGUARD_BROKER_TIMEOUT_MS: "2500"
networks:
- aio_proxy
- sessionguard_control
sessionguard-master:
# Your SessionGuard master may be in another compose project. If so, attach
# it to this external network and use its Docker DNS name above.
networks:
- sessionguard_control
networks:
sessionguard_control:
name: sessionguard_control
internal: true

133
docs/API.md Normal file
View File

@@ -0,0 +1,133 @@
# HTTP API (v0.3)
All JSON APIs return an error object with an `error` field on failure unless otherwise stated.
## Authentication classes
### Public/internal service endpoints
- `GET /healthz`
- `GET /metrics`
- `POST /api/v1/agents/enroll` enrollment-token authenticated in request body
- `POST /api/v1/agents/heartbeat` per-agent bearer authentication
- `POST /api/v1/broker/resolve` broker bearer API key
- `POST /api/v1/broker/tokens` broker bearer API key, form-encoded for Guacamole extension
### OIDC-protected admin endpoints
All remaining `/api/v1/...` endpoints require an authenticated Master OIDC session; write operations additionally require the documented RBAC permission.
## Broker
### `POST /api/v1/broker/resolve`
Header:
```text
Authorization: Bearer <SESSIONGUARD_BROKER_API_KEY>
```
JSON request:
```json
{
"username": "DOMAIN\\user",
"connection_id": "42",
"connection_name": "Office Desktop",
"resource_id": "optional",
"farm_id": "optional"
}
```
Response includes selected agent/hostname, farm/resource, reconnect reason, health score, lease expiry and token map.
### `POST /api/v1/broker/tokens`
Same broker credential. Accepts form fields `username`, `connection_id`, `connection_name`, `resource_id`, `farm_id`. Returns URL-encoded token pairs for the Guacamole extension.
## Agents and dashboard
- `GET /api/v1/dashboard`
- `GET /api/v1/agents/{id}`
- `PATCH /api/v1/agents/{id}/control` `maintenance`
Control body:
```json
{
"mode": "online|drain|maintenance",
"restart_when_drained": false,
"tags": {"role":"office"},
"farm_ids": ["office"]
}
```
## Sessions
- `POST /api/v1/agents/{id}/sessions/{session}/action` `session`
- `POST /api/v1/agents/{id}/sessions/bulk` `session`
Actions: `message`, `disconnect`, `logoff`. Forced logoff additionally requires the server-side role permission checks applicable to the operation.
Bulk request example:
```json
{
"action": "logoff",
"scope": "disconnected"
}
```
Scopes include `all`, `active`, and `disconnected`.
## Processes
- `POST /api/v1/agents/{id}/processes/{pid}/kill` `process`
The Master queues an expiring command; it is not a synchronous remote Win32 call.
## Policies
- `PUT /api/v1/agents/{id}/policy` `policy`
- `PUT /api/v1/policy/all` `policy`
- `PUT /api/v1/farms/{id}/policy` `policy`
- `GET /api/v1/policy/history` `policy`
- `POST /api/v1/agents/{id}/policy/rollback/{revision}` `policy`
- `POST /api/v1/farms/{id}/policy/rollback/{revision}` `policy`
- `POST /api/v1/policy/global/rollback/{revision}` `policy`
Policy writes are normalized, validated, assigned a fresh revision and appended to policy history.
## Farms
- `GET /api/v1/farms`
- `POST /api/v1/farms` `manage`
- `PUT /api/v1/farms/{id}` `manage`
- `DELETE /api/v1/farms/{id}` `manage`
Farm fields include `name`, `description`, `agent_ids`, `required_tags`, optional `policy`, and `enabled`.
## Published resources
- `GET /api/v1/resources`
- `POST /api/v1/resources` `manage`
- `PUT /api/v1/resources/{id}` `manage`
- `DELETE /api/v1/resources/{id}` `manage`
Resource fields include desktop/RemoteApp kind, farm ID, Guacamole connection ID/name and RemoteApp parameters.
## Director/history
- `GET /api/v1/history?user=&agent=&limit=`
- `GET /api/v1/alerts`
- `GET /api/v1/leases`
- `GET /api/v1/audit` `audit`
- `GET /api/v1/me` current OIDC user, roles and permissions
`history_limit` bounds retained history. Query `limit` is additionally constrained server-side.
## Agent protocol
`model.ProtocolVersion` is `3` for v0.3.
Heartbeat snapshots contain server/health/session/process/telemetry/profile/event state. The response contains effective desired policy and pending commands. Agents acknowledge completed commands in later heartbeats.

View File

@@ -1,51 +1,176 @@
# SessionGuard architecture
# SessionGuard 0.3 Architecture
## Components
## Purpose
- **Agent (Windows service):** watches RDS/WTS sessions, applies user-profile templates, schedules profile cleanup, exposes a local management UI, and sends outbound heartbeats to the master.
- **Master (Linux/Docker):** receives enrollments and heartbeats, stores the latest server snapshots, provides a consolidated dashboard, and distributes per-agent or bulk policies.
- **Pocket ID:** authenticates administrators through OIDC. The master and each independently usable agent UI have their own callback URL.
SessionGuard separates the functions commonly bundled into a Citrix deployment into components that can be independently replaced:
## Connection model
- **PocketID/OIDC**: administrator identity and group claims.
- **Traefik / forward-auth**: trusted ingress for Guacamole.
- **Apache Guacamole**: browser gateway and RDP transport.
- **Windows RDS**: Windows session runtime.
- **SessionGuard Master**: broker, Director, policy control plane, history, alerting and command queue.
- **SessionGuard Agent**: Windows/RDS integration, profile lifecycle, templates, telemetry and local fallback administration.
Agents initiate HTTPS calls to the master. There is no requirement for the master to open an inbound management connection to a terminal server. Enrollment uses a bootstrap secret once; the master then returns an agent-specific bearer token and stores only its SHA-256 hash.
SessionGuard intentionally does not implement a new remote-display protocol.
## Profile cleanup state machine
## Network model
1. The agent polls WTS sessions.
2. A session that was present in the previous persisted snapshot and disappears is treated as logged off.
3. If no other session with the same SID exists, the profile is scheduled for cleanup after `grace_seconds`.
4. If the SID appears again before the deadline, cleanup is cancelled.
5. Immediately before deletion, the allowed-root rule and active-session rule are checked again.
6. Deletion uses the Windows `DeleteProfileW` API. Failures are retried.
Agents initiate all master communication:
The agent persists the previous session set and pending cleanup jobs so a service restart does not normally lose a logout transition.
```text
Agent --HTTPS heartbeat--> Master
<-- policy + commands--
-- results + telemetry-->
```
## Template engine
The master does not open SMB/RPC/WMI/WinRM management sessions to RDS hosts. This keeps the Windows hosts behind normal firewall/NAT boundaries and makes master outages less disruptive.
Targets are always relative to the resolved user profile path. Supported types:
Guacamole reaches only the broker API using a dedicated API key:
- `directory`: ensure a directory exists.
- `file`: write inline content/base64 content or copy a source file (including a UNC path).
- `url`: create an Internet Shortcut (`.url`).
- `shortcut`: create or update a Windows Shell Link (`.lnk`) and compare its key properties before changing it.
```text
Guacamole extension --HTTP(S) + bearer key--> /api/v1/broker/tokens
```
Templates are evaluated on a newly observed user session and again when a policy revision changes.
That API key is separate from agent enrollment credentials and PocketID secrets.
## Policy precedence
## Master persistence
- The agent starts with its local configured/persisted policy.
- A master policy for an agent becomes authoritative once received.
- If the master is unavailable, the last policy remains active and can be edited locally.
- When the master reconnects and still has a different desired policy, the master's policy wins.
Production mode uses PostgreSQL.
## Deliberate non-goal in v0.1: full roaming-profile replacement
### `sessionguard_state`
A complete restore of a Windows user profile from a share is not implemented. Restoring `NTUSER.DAT`, registry state, and profile files after the Windows profile has already been loaded is race-prone and can corrupt state. Citrix Profile Management operates much deeper in the logon/logoff lifecycle than a normal post-logon service loop.
Single JSONB control-plane document containing relatively small mutable state:
A future profile provider should therefore either:
- agents and last snapshots
- farms
- published resources
- leases
- policy versions/control state
- active alerts
1. synchronize only explicitly selected user-data directories, or
2. integrate with a supported pre-profile-load mechanism / profile-container technology.
### `sessionguard_audit`
The current design keeps this concern separate from cleanup and template enforcement rather than pretending that copying a profile directory after logon is equivalent.
Append-only administrative and command audit events. Indexed by time and actor.
### `sessionguard_session_history`
Append-only Director session history. Indexed by time, lower-cased username and agent ID.
History retention is bounded by `history_limit`. This avoids rewriting a permanently growing history document on every agent heartbeat.
JSON-file storage remains supported when `database_url` is empty. It is intended for development, migration and small single-node installations, not a HA master.
### Single-active-master guard
v0.3 is intentionally single-active-master. A dedicated PostgreSQL connection holds a session-level advisory lock for the lifetime of the Master, and control-plane writes verify that lock is still held. A second Master against the same database refuses to start. This prevents accidental split-brain; it is not a claim of seamless HA/failover.
## Agent state
Persistent agent state under `data_dir` contains:
- enrolled agent ID/token
- last accepted policy
- pending cleanup/profile jobs
- disconnected-session timers
- command deduplication/results
- profile status
- bounded event history
- bounded logon telemetry
The service can therefore continue cleanup/profile/session policy during a master outage using the last accepted policy.
## Session event model
The Windows service subscribes to service session-change notifications and also polls WTS. Immediate notifications wake the worker; polling is the reconciliation mechanism.
The agent records:
- session ID
- user/domain/SID
- state
- client name/address where available
- logon/connect/last-input timestamps
- disconnected-since timestamp
- idle seconds
The master compares consecutive snapshots and emits history events such as `logon`, `reconnect`, `disconnect`, `state_change`, `logoff`, and `logon_ready`.
## Broker model
Broker selection is entirely master-side and never modifies the Guacamole database.
A request contains the authenticated username plus Guacamole connection ID/name or an explicit SessionGuard resource/farm. The master resolves the resource and farm, then follows the placement algorithm documented in `BROKER.md`.
The response contains tokens rather than Guacamole-specific mutable state. The extension injects them into the current user context immediately before the connection parameters are resolved.
## Policy hierarchy
Policy precedence:
1. explicit agent desired policy
2. policy of a farm explicitly listed in the agent's `farm_ids`, in configured order
3. policy of a centrally matching farm (`agent_ids` or `required_tags`), deterministic farm-ID order
4. global policy
5. agent's local policy if no master desired policy applies
Master policy changes are versioned. Rollback creates a new revision derived from the selected historical version, so history remains append-only rather than being rewritten.
## Command model
Administrative actions are queued on the master and delivered through the next heartbeat:
```text
UI -> Master command queue -> heartbeat response -> Agent -> WTS/Win32 -> result -> heartbeat -> audit
```
Commands have IDs, creation time, expiry time and actor. The agent remembers recently executed IDs to avoid duplicate execution if a heartbeat is retried.
Supported command families include:
- message session
- disconnect session
- logoff session
- terminate process
- restart server after drain
## Maintenance states
- `online`: new and existing connections allowed.
- `drain`: no new broker placements; existing sessions may reconnect.
- `maintenance`: neither new placement nor reconnect is allowed.
With `restart_when_drained`, the master queues a restart only once the agent reports zero user sessions.
## Health model
The agent calculates a 0100 health score from local checks. Current checks include:
- CPU pressure
- memory pressure
- system-disk free space
- local RDP listener reachability
- profile-store reachability when profile sync is enabled
The broker excludes hosts below `broker.min_health_score` for new sessions. Alerts have independent thresholds.
## Failure behavior
### Master unavailable
- Existing Windows sessions continue.
- Agent keeps its last accepted policy.
- Profile jobs and disconnect timers continue.
- Local agent UI remains usable if its OIDC provider is reachable.
- New Guacamole broker requests fail because authoritative farm placement is unavailable. Do not silently fall back to an arbitrary RDS host if duplicate-profile protection matters.
### PostgreSQL unavailable
Master writes fail rather than pretending state was persisted. Existing master in-memory state may still answer reads, but operators should treat the master as degraded and restore DB connectivity.
### Profile share unavailable
Backup/restore jobs retry within their configured semantics. A failed backup blocks cleanup. Restore stops retrying after the configured restore window rather than injecting files into an already-running desktop later.
### Agent unavailable
It is removed from new broker placement after `offline_after_seconds`. Offline alerts use their own threshold.

160
docs/BROKER.md Normal file
View File

@@ -0,0 +1,160 @@
# Broker, Farms and Published Resources
## Goal
The SessionGuard broker provides the Citrix-like placement functions that Guacamole's generic RDP connections do not know about: reconnect affinity, farm boundaries, drain/maintenance state, health-aware placement and user leases.
## Farms
A farm is a logical group of RDS hosts.
Membership can be expressed in three ways:
1. the agent has the farm ID in `farm_ids`;
2. the farm has the agent ID in `agent_ids`;
3. the agent satisfies all `required_tags` of the farm.
Example:
```json
{
"id": "office",
"name": "Office",
"required_tags": {
"role": "office",
"environment": "production"
},
"enabled": true
}
```
Tags are exact key/value matches. Explicit membership and tag membership can coexist.
## Published resources
A Resource maps a Guacamole connection to a farm and optionally to a RemoteApp definition.
Desktop example:
```json
{
"name": "Office Desktop",
"kind": "desktop",
"farm_id": "office",
"guacamole_connection_name": "Office Desktop",
"enabled": true
}
```
RemoteApp example:
```json
{
"name": "Sage",
"kind": "remoteapp",
"farm_id": "erp",
"guacamole_connection_name": "Sage",
"remote_app": "||Sage",
"remote_app_dir": "C:\\Program Files\\Sage",
"remote_app_args": "",
"enabled": true
}
```
Connection ID matching is preferred where stable IDs are known; connection-name matching is case-insensitive and convenient for initial deployment.
## Placement algorithm
Given `username`, optional `resource_id`, `farm_id`, and Guacamole connection identity:
1. Resolve an enabled Resource by explicit resource ID, Guacamole connection ID, or Guacamole connection name.
2. Resolve the farm: Resource farm → explicit request farm → configured default farm.
3. If a non-empty farm does not exist or is disabled, fail closed.
4. If `reconnect_existing` is enabled, search only matching farm members for an existing `Active`, `Connected` or `Disconnected` session for the user.
5. `maintenance` hosts are excluded from reconnect; `drain` hosts are allowed for reconnect.
6. Reuse a non-expired lease if its host is still available and belongs to the farm.
7. For a new session, consider only hosts that are:
- online,
- in `online` maintenance mode,
- members of the farm,
- at or above `min_health_score`.
8. Rank candidates by broker score and select the highest score.
9. Create or refresh the lease and return connection tokens.
## Broker score
The current score deliberately favors health and penalizes load:
```text
health score * 10
- active sessions * 20
- disconnected sessions * 5
- CPU percent * 2
- memory-used percent
```
This is an intentionally understandable heuristic rather than an opaque ML model. Thresholds and formula can be evolved without changing the Guacamole integration.
## User leases
A lease reduces races between two nearly simultaneous connection attempts.
With:
```json
"single_session_per_user": true
```
the normalized username is the global lease key. The intent is that a user is directed back to one RDS host whenever possible.
With it disabled, the lease key is scoped by:
```text
user | farm | resource
```
This permits independent sessions in separate published resources/farms.
Leases expire after `lease_seconds`; successful broker use refreshes the expiry.
## Username normalization
Broker comparisons are case-insensitive. `DOMAIN\user` and matching WTS domain/user fields are normalized before comparison.
In environments with ambiguous duplicate short usernames across domains, pass the domain-qualified username from the identity/gateway layer.
## Drain and maintenance
### Drain
Use before patching or planned retirement of a server:
- new sessions: blocked
- existing-session reconnect: allowed
- existing sessions: untouched
Optionally set `restart_when_drained=true`. When zero user sessions remain, a restart command is queued.
### Maintenance
Use for a host that must not receive any brokered access:
- new sessions: blocked
- reconnect: blocked
Existing Windows sessions are not forcefully killed merely by changing this state; use bulk session actions if required.
## Farm policy inheritance
Farm policies are independent from broker selection but use the same membership concepts. Explicit `agent.farm_ids` determine precedence when an agent intentionally belongs to multiple farms. Centrally inferred memberships are evaluated deterministically by farm ID.
## Broker API security
The broker endpoints are not user-OIDC endpoints. They are server-to-server APIs protected by `SESSIONGUARD_BROKER_API_KEY`.
Recommendations:
- generate a high-entropy secret;
- keep Guacamole and Master on a private Docker/network path;
- do not reuse the enrollment token, OIDC client secret or PostgreSQL password;
- rotate the key by updating Master and Guacamole together during a controlled maintenance window.

113
docs/DIRECTOR.md Normal file
View File

@@ -0,0 +1,113 @@
# Director Functions
SessionGuard 0.3 turns the master UI into an RDS-oriented operational console rather than merely a configuration page.
## Server telemetry
Each heartbeat can include:
- hostname and Windows version/build
- uptime
- CPU utilization
- physical memory total/available
- system disk total/free
- composite health score and health-check results
- agent version and heartbeat time
## Session telemetry
For each RDS session, the agent reports the available WTS data:
- session ID
- state
- domain/user/SID
- client name/address where available
- logon timestamp
- connect timestamp
- last-input timestamp
- persistent disconnected-since timestamp
- calculated idle seconds
Not every WTS field is guaranteed to be populated by every Windows version/client combination. The UI treats missing values as unavailable rather than inventing them.
## Session history
The master diffs consecutive snapshots and records history events. Typical timeline:
```text
08:01:12 logon
08:01:20 logon_ready logon=8124ms restore=1380ms
10:44:03 disconnect
10:55:11 reconnect
16:31:52 disconnect
17:31:55 logoff (possibly automatic timeout)
```
History can be filtered by user and agent through the API. Production PostgreSQL stores history in its own indexed append-only table.
## Logon telemetry
SessionGuard can observe part of the logon path that it directly participates in:
- first observed new session
- native WTS logon time if available
- profile restore start/completion
- time until restore/template stage is ready
`ObservedLogonMS` is therefore an **RDS/SessionGuard observed duration**, not a claim to reproduce all Citrix Director logon phases. It is useful for trend detection and alerting, especially when restore/profile storage is responsible for slow logons.
## Process inventory
The Windows agent enumerates processes and associates them with RDS session IDs. The heartbeat includes only user-session processes and caps the list to protect payload size.
From the Director UI an authorized operator can request termination of a process. The action is queued, expires, is executed locally by the agent and is audited. The command carries the session ID observed with the PID; immediately before termination the agent re-enumerates processes and refuses the action if that PID no longer belongs to the expected RDS session. This reduces PID-reuse risk for delayed commands.
Process termination is intentionally an elevated operation and requires the `process` permission.
## Health score
Current local checks start at 100 and subtract penalties when a check fails. The UI exposes both score and check details. The same score feeds new-session brokering.
Current checks:
- CPU below the critical local threshold
- memory below the critical local threshold
- minimum system-disk free space
- TCP reachability of the local RDP listener
- configured profile store reachable
This health score is a placement signal, not a substitute for Windows monitoring/EDR.
## Alerts
When enabled, the master maintains active alerts for conditions including:
- agent offline
- CPU above configured threshold
- memory above configured threshold
- system disk below configured free GiB
- health score below configured threshold
- profile failures above configured count
- disconnected sessions above configured count
- observed logon duration above configured threshold
Alerts have first-seen, last-seen, active/resolved state and notification throttling. A generic JSON webhook can receive alert changes.
## Prometheus
`GET /metrics` exposes aggregate master metrics without usernames/SIDs:
- `sessionguard_master_up`
- `sessionguard_agents_total`
- `sessionguard_agents_online`
- `sessionguard_sessions_active`
- `sessionguard_sessions_disconnected`
- `sessionguard_profile_jobs`
- `sessionguard_cleanup_jobs`
- `sessionguard_health_score_sum`
The endpoint is intentionally small in v0.3. Place it behind network ACLs or your metrics network even though it contains no per-user data.
## Retention
`history_limit` bounds session history and audit retention. PostgreSQL retention is applied to the append-only tables; it is not necessary to rewrite an ever-growing control-plane blob on every heartbeat.

92
docs/GUACAMOLE.md Normal file
View File

@@ -0,0 +1,92 @@
# Guacamole 1.6 Integration
## Design
The SessionGuard Guacamole extension is deliberately **not** an authentication provider in the identity sense. PocketID/header authentication stays responsible for user identity.
The extension subclasses Guacamole's authentication-provider extension point only to decorate the authenticated `UserContext` with a `TokenInjectingUserContext`. At connection time it sends the current Guacamole username plus connection identity to the SessionGuard broker and injects returned tokens.
This avoids:
- writing dynamic hostnames directly into the Guacamole PostgreSQL database;
- reimplementing PocketID authentication in SessionGuard;
- opening a management connection from Master to RDS hosts.
## Build
```bash
cd guacamole-extension
mvn -DskipTests package
```
Or use `deploy/guacamole/Dockerfile.guacamole`, which builds the JAR and copies it to the stock Guacamole extension directory.
## Required Guacamole environment
```yaml
SESSIONGUARD_MASTER_URL: http://sessionguard-master:8080
SESSIONGUARD_BROKER_API_KEY: ${SESSIONGUARD_BROKER_API_KEY}
SESSIONGUARD_BROKER_TIMEOUT_MS: "2500"
```
Use the same broker API key configured on the SessionGuard Master. The URL should preferably be an internal network URL, not the public Internet endpoint.
## Desktop connection
Configure the Guacamole RDP hostname as:
```text
${SESSIONGUARD_HOST}
```
The extension resolves the target host at connection time.
## RemoteApp
Use:
```text
hostname: ${SESSIONGUARD_HOST}
remote-app: ${SESSIONGUARD_REMOTE_APP}
remote-app-dir: ${SESSIONGUARD_REMOTE_APP_DIR}
remote-app-args: ${SESSIONGUARD_REMOTE_APP_ARGS}
```
Create a matching Published Resource in SessionGuard by Guacamole connection ID or name.
## Header-auth security boundary
Your Guacamole configuration uses a trusted header such as `X-Guacamole-User`. That is safe only if untrusted requests cannot reach Guacamole directly with an attacker-controlled copy of that header.
Recommended topology:
```text
Internet
|
Traefik + forward-auth/header scrubber
|
dedicated trusted Guacamole proxy network
|
Guacamole
+--- private backend ---> guacd
+--- private backend ---> PostgreSQL
+--- private control ---> SessionGuard Master
```
Do not rely solely on the fact that the public Traefik router strips inbound identity headers if arbitrary unrelated containers can directly connect to Guacamole's port on a shared Docker network.
## Integration with the supplied compose
Your existing PocketID → traefik-forward-auth → Guacamole chain can remain unchanged. The required additions are:
1. build Guacamole using `deploy/guacamole/Dockerfile.guacamole`;
2. add the three `SESSIONGUARD_*` variables above;
3. give Guacamole private reachability to the SessionGuard Master;
4. add SessionGuard Published Resources matching your Guacamole connections;
5. replace fixed RDP hostnames in brokered connections with `${SESSIONGUARD_HOST}`.
`deploy/guacamole/docker-compose.sessionguard.example.yml` provides a focused example rather than replacing your complete existing stack.
## Failure behavior
If the SessionGuard broker is unavailable, the extension fails the brokered connection instead of choosing an arbitrary backend. This is intentional where reconnect affinity/profile concurrency matters. Existing already-established Guacamole/RDP sessions are unaffected.

106
docs/OPERATIONS.md Normal file
View File

@@ -0,0 +1,106 @@
# Production Operations
## Recommended rollout order
1. Deploy PostgreSQL and Master behind TLS/reverse proxy.
2. Configure PocketID group RBAC and verify a read-only user and an administrator.
3. Upgrade one RDS host/agent and keep profile cleanup in Dry-Run.
4. Verify heartbeat telemetry, processes, health and session history.
5. Create a canary Farm containing only that host.
6. Load the Guacamole extension and create one brokered canary connection.
7. Verify existing-session reconnect and lease behavior.
8. Add a second canary RDS host; test load balancing, drain and maintenance.
9. Enable selected-folder profile restore/backup if not already validated.
10. Enable automatic disconnected-session logoff.
11. Enable destructive cleanup only after profile backup/recovery has been exercised.
12. Expand farm membership gradually.
## PostgreSQL
Production should set `SESSIONGUARD_DATABASE_URL`.
Example:
```text
postgres://sessionguard:SECRET@sessionguard-postgres:5432/sessionguard?sslmode=disable
```
Use TLS (`sslmode=require`/verify modes) when PostgreSQL crosses an untrusted network. The provided compose uses an internal Docker network and therefore demonstrates `sslmode=disable` only for that local link.
Back up the SessionGuard database like any other control-plane database. It contains operational metadata, session usernames/SIDs, audit history and policies, but not profile files themselves.
## Master backup and restore
Back up:
- PostgreSQL database
- `master.json` configuration (without assuming env-injected secrets are present)
- external secret-management values
- custom Guacamole extension image/tag
Restore database and configuration together. After Master starts, Agents will reconnect with their existing per-agent credentials.
## Profile store backup
The profile store is independent from PostgreSQL and must be protected separately. Snapshot/backup the underlying SMB storage according to your recovery objectives.
SessionGuard maintains per-user `current` plus optional historical snapshots, but these are operational generations, not a replacement for storage backup.
## Drain workflow
For planned patching:
1. Set host to `drain`.
2. Optionally send a message to users.
3. Existing users can reconnect to the same host; new users go elsewhere.
4. Wait for sessions to finish or use policy/bulk logoff as appropriate.
5. Set `restart_when_drained` if automatic restart is desired.
6. After reboot/health verification, set host to `online`.
Use `maintenance` when reconnects must also be blocked.
## Upgrades
### Master
- back up PostgreSQL;
- deploy the new binary/image;
- start one master against the DB and verify `/healthz` reports the expected version/store;
- verify OIDC and dashboard;
- then upgrade the Guacamole extension if required.
The database migration code is designed to be forward-applied by the Master. v0.3 also holds a PostgreSQL advisory lock and intentionally refuses a second active Master against the same database. Do not attempt active/active deployment; for manual failover stop/lose the old Master first, then start the replacement against the same restored/reachable database.
### Agents
Upgrade in farm batches using drain mode. Agent state in `C:\ProgramData\SessionGuard` should be preserved across binary replacement.
## Observability
Monitor at minimum:
- `/healthz`
- `/metrics`
- active alerts in Director
- PostgreSQL health
- profile-store availability
- master heartbeat freshness
Alert webhook delivery is best-effort and should complement, not replace, your infrastructure monitoring.
## Master outage
Existing sessions and agent-side policy continue. Brokered **new connections** cannot be authoritatively placed while the Master is unavailable. This fail-closed behavior protects farm affinity and profile concurrency.
If emergency bypass access is required, maintain a separately controlled break-glass RDP path rather than silently making the normal broker choose an arbitrary host.
## JSON fallback migration
If upgrading from JSON storage:
1. stop the old Master;
2. preserve `master.json`;
3. start v0.3 once with the existing file and verify state;
4. configure PostgreSQL and perform an explicit controlled migration/import workflow before retiring the file.
The code can read legacy control-plane JSON state and PostgreSQL migrations can normalize embedded history, but operationally treat backend migration as a change window and verify counts/history.

163
docs/PROFILE-LIFECYCLE.md Normal file
View File

@@ -0,0 +1,163 @@
# Profile lifecycle
## Scope
SessionGuard synchronizes configured **folders inside an existing Windows profile**. It does not replace Windows profile loading and does not roam registry hives.
Do not configure the entire `C:\Users\username` profile root. In particular, do not use SessionGuard to copy:
- `NTUSER.DAT` / `UsrClass.dat`;
- registry hive transaction logs;
- Windows profile service metadata;
- profile container/VHD(X) files;
- EFS material that depends on user-specific key handling.
Use application/data directories whose consistency model is compatible with file copying after logoff and merging at logon.
## Configuration
```json
"profiles": {
"enabled": true,
"store_root": "\\\\fileserver\\SessionGuardProfiles",
"backup_on_logoff": true,
"restore_on_logon": true,
"backup_delay_seconds": 5,
"retry_seconds": 60,
"restore_window_seconds": 120,
"keep_versions": 2,
"exclude_users": ["Administrator"],
"exclude_sids": ["S-1-5-18", "S-1-5-19", "S-1-5-20"],
"folders": [
{
"path": "AppData\\Roaming\\Example",
"exclude_globs": ["Cache/**", "*.tmp"]
},
{ "path": "Desktop" },
{ "path": "Documents" }
]
}
```
### `store_root`
May be a local path or UNC path. In a multi-server RDS farm it should normally be a resilient SMB location.
A user's data is keyed by SID, not username, so renamed accounts do not silently receive a second profile snapshot namespace.
### `backup_delay_seconds`
Delay after the session has disappeared before a backup job starts. Default: 5 seconds. This gives Windows and applications a short period to release profile file handles; locked files are then handled by the normal retry pipeline.
### `retry_seconds`
Backup retry interval and restore retry interval while the restore window is still open.
### `restore_window_seconds`
Maximum period, measured from the first restore job creation for a session, during which restore retries are allowed. Default: 120 seconds.
If the store is still unavailable after this window, SessionGuard marks the restore as abandoned, records the error, marks the session as past its restore phase and applies templates. It will not suddenly perform a late restore into a long-running session.
### `keep_versions`
Number of previous completed `current` snapshots retained under `history`. `0` keeps only `current`.
### `folders[].path`
Must be relative to the Windows user profile. Absolute paths, drive-qualified paths and `..` traversal are rejected.
### `exclude_globs`
Patterns are evaluated relative to that configured folder. Supported behavior:
- standard `path.Match`-style patterns such as `*.tmp`;
- `prefix/**` to exclude a complete subtree such as `Cache/**`.
## Backup semantics
1. Backup is queued after the previous session disappears and waits `backup_delay_seconds`.
2. Before copying, no other session with the same SID may be active.
3. Every configured folder is copied into a new staging tree.
4. Missing configured folders are accepted; different users may not have every application installed/initialized.
5. Symbolic links are not followed.
6. Regular files are copied through a temporary destination file before rename; replacement uses a rollback file when required by the platform.
7. A manifest is written after all configured folders complete.
8. Immediately before activation, the agent performs another WTS session check. If the SID became active while copying, activation is rejected and the previous `current` remains authoritative.
9. The old `current` is moved aside.
10. Staging is renamed to `current`.
11. Historical retention is pruned.
12. Only then is the backup considered successful.
If any required read/write operation fails, the staging tree is removed and the backup job is retried. Cleanup does not proceed.
## Restore semantics
Restore is a **merge** operation. Files present in the snapshot overwrite the corresponding local files. Files that exist locally but are absent from the snapshot are not deleted.
This is intentionally safer than mirroring/deleting at logon. An application may create new machine-specific files between deployments, and deleting them merely because they were absent in an older snapshot would be hazardous.
After restore succeeds (or no previous snapshot exists), templates are evaluated.
## Service startup safety
On service startup, sessions already present are treated as already past the restore phase. This prevents installing/upgrading SessionGuard during working hours from overwriting profile files in sessions that were opened before the service started.
## ACLs and NTFS metadata
The built-in Go copy engine copies regular file data and timestamps and creates destination files/directories under the service identity. It does not claim to reproduce the full NTFS security descriptor, owner, SACL, alternate data streams or every application-specific extended attribute.
For the intended selected-folder use case, destination files normally inherit ACLs from the existing user profile directories. Validate permissions with the applications you actually roam before production rollout.
If your requirement is byte-for-byte/full-metadata profile portability, use a profile-container or specialized profile-management product rather than expanding the selected-folder engine to the entire Windows profile.
## SMB identity
### LocalSystem
For remote SMB resources, a domain-joined server running as LocalSystem commonly authenticates as its machine account:
```text
DOMAIN\RDSHOST01$
```
Grant that account share and NTFS rights if you keep LocalSystem.
### gMSA
For farms, prefer a dedicated group Managed Service Account. Grant it:
- modify/create/delete rights under the SessionGuard profile share;
- local Full Control on `C:\ProgramData\SessionGuard`;
- "Log on as a service" as required by your domain policy.
The installation script supports a gMSA service identity.
## Failure examples
### Profile share unavailable on logoff
- backup job changes to error/retry state;
- local profile remains on disk;
- cleanup is blocked;
- master/local UI shows the job and error;
- when the share returns, backup retries and cleanup can continue.
### Profile share unavailable on logon
- restore retries during `restore_window_seconds`;
- after the window expires, SessionGuard logs the failure and stops retrying for that session;
- templates still run;
- the next real logon gets a fresh restore opportunity.
### User reconnects while backup is pending
If the same SID becomes active before the backup starts, the pending backup and cleanup are cancelled. SessionGuard avoids copying/deleting a profile that has become live again.
## Interaction with the v0.3 broker
Profile correctness is one reason the broker prefers an existing session and maintains a user lease. With `broker.single_session_per_user=true`, two nearly simultaneous Guacamole starts for the same normalized username are steered toward the same leased host whenever possible.
This is an additional coordination layer, not a distributed filesystem lock. The profile pipeline still performs its own WTS rechecks before snapshot activation and cleanup because users may reach RDS through emergency/direct paths outside the broker.
For selected-folder profile synchronization in a multi-host farm, keep the normal Guacamole path brokered and avoid exposing arbitrary direct RDP paths to regular users. Maintain any direct RDP route as an explicitly controlled break-glass path.

55
docs/RBAC.md Normal file
View File

@@ -0,0 +1,55 @@
# RBAC and PocketID Group Mapping
## Identity
The Master uses standard OIDC and expects the `groups` claim when group-based RBAC is configured. PocketID can provide this claim to the SessionGuard OIDC client.
## Configuration
Example:
```json
"rbac": {
"default_role": "viewer",
"groups": {
"sessionguard-admins": ["admin"],
"sessionguard-helpdesk": ["helpdesk"],
"sessionguard-operators": ["operator"],
"sessionguard-policy": ["policy_admin"],
"sessionguard-profile": ["profile_admin"],
"sessionguard-auditors": ["auditor"]
}
}
```
Group matching is case-insensitive. A user may receive multiple roles and therefore the union of their permissions.
If `rbac.groups` is empty, all authenticated users receive `admin` for backwards compatibility. This is convenient for upgrades but should not be used as the final production configuration.
## Built-in roles
| Role | Permissions | Intended use |
|---|---|---|
| `viewer` | `view` | read-only Director |
| `helpdesk` | `view`, `session` | messages/disconnect operations that do not require forced logoff/process control |
| `operator` | `view`, `session`, `session_logoff`, `process`, `maintenance` | RDS operations staff |
| `profile_admin` | `view`, `session`, `profile` | profile operations/pipeline administration |
| `policy_admin` | `view`, `policy` | policy versions and rollout |
| `auditor` | `view`, `audit` | audit review |
| `admin` | all current permissions | full control |
`manage` covers farm/resource administration. `maintenance` covers server mode/restart-when-drained. `process` covers process termination.
## Separation of identities
Do not reuse credentials across these trust relationships:
- PocketID OIDC client secret: browser/admin authentication
- broker API key: Guacamole extension → Master
- enrollment token: new Agent → Master bootstrap
- per-agent bearer token: established Agent → Master heartbeat
- PostgreSQL password: Master → database
## Audit actor
For authenticated administrative changes, SessionGuard records the OIDC email when present, otherwise name, otherwise subject identifier. System-generated operations use an explicit system actor such as `system:drain`.

92
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,92 @@
# Security Model
## Trust boundaries
SessionGuard has five distinct credential classes:
1. OIDC/PocketID browser administrator authentication.
2. Agent enrollment token used only to bootstrap a new agent.
3. Per-agent bearer tokens for heartbeats.
4. Guacamole broker API key.
5. PostgreSQL credentials.
Use independent high-entropy values and rotate them independently.
## Guacamole header authentication
If Guacamole trusts `X-Guacamole-User` or another header, the reverse proxy/header-auth component is part of the authentication boundary. An attacker who can directly reach Guacamole and set the trusted header may bypass the upstream authentication flow.
Therefore:
- do not publish Guacamole's backend port directly;
- strip user-supplied identity headers at the trusted edge;
- prefer a dedicated network between Traefik/forward-auth and Guacamole;
- do not attach unrelated/untrusted containers to a network that can directly reach Guacamole's trusted backend listener.
## Master split-brain protection
v0.3 is a single-active-master design. PostgreSQL mode acquires a dedicated advisory lock and verifies it on control-plane writes. This prevents an accidentally duplicated Master from independently assigning leases/policies against the same database. It does not provide automatic HA; operational failover is still explicit.
## Broker fail-closed behavior
The broker does not fall back from a missing/disabled farm to an arbitrary server. `maintenance` blocks reconnect and new placement; `drain` blocks new placement. This prevents availability logic from silently violating profile/session isolation.
## Profile deletion
SessionGuard does not recursively remove arbitrary configured paths. Cleanup resolves the Windows profile path for the SID, verifies it is beneath an allowed root, verifies exclusions, performs a fresh session recheck and calls Windows `DeleteProfileW`.
When profile backup is enabled, a failed backup blocks cleanup.
## Profile synchronization
Only explicitly configured relative folders are copied. Configuration rejects paths that are absolute or escape the profile root. The copy engine does not intentionally follow symlinks/reparse-like entries into unrelated filesystem locations.
Do not include:
- `NTUSER.DAT`
- `UsrClass.dat`
- registry hives
- the entire profile root
- EFS/private-key material without a separately reviewed design
## Reconnect races
Before activating a completed backup as `current`, the agent rechecks whether the SID became active. If so, activation is abandoned. Before cleanup the agent also performs a fresh WTS recheck.
These checks reduce profile corruption/data-loss risk when a user reconnects during backup or cleanup grace periods.
## Command execution
Master commands are short-lived and authenticated through the per-agent heartbeat. Agent command IDs are deduplicated. Results are returned and audited.
High-impact actions such as process termination, forced logoff and maintenance changes are protected by RBAC on the Master. Process termination is additionally bound to the observed RDS session and revalidated on the agent immediately before termination to reduce PID-reuse risk.
## Local agent UI
The local UI is an intentional break-glass/control-plane fallback. Protect it with OIDC and network ACLs. Do not expose it broadly to the Internet merely because it supports PocketID.
## Container hardening
The supplied Master compose demonstrates:
- read-only root filesystem
- `no-new-privileges`
- dropped Linux capabilities
- private backend network
- a small tmpfs
Review the Guacamole stack separately. Avoid forcing `user: 0:0` on long-running containers unless the specific image/runtime actually requires it.
## Database data sensitivity
PostgreSQL includes usernames, SIDs, client metadata, session history, audit actors, policies and operational state. Treat DB backups accordingly. SessionGuard does not store user passwords.
## Webhook security
Alert webhooks may contain hostnames and operational details. Send them only to trusted HTTPS destinations. v0.3 uses a generic webhook and does not currently implement request signing; if the receiver requires authenticated provenance, place a trusted relay in front of it or add a signing layer before exposing it externally.
## Limitations
- The RDS agent runs with high Windows privileges to enumerate/control sessions and delete profiles; compromise of the agent binary/service account is high impact.
- The current WTS extended-session parsing is targeted at supported x64 Windows Server deployment. Validate on your exact Windows Server versions.
- SessionGuard is not an EDR, PAM product, or complete Windows profile provider.

184
docs/TESTING.md Normal file
View File

@@ -0,0 +1,184 @@
# Release and Failure-Mode Test Matrix
A production release should pass this matrix on the actual supported Windows Server, Guacamole and storage versions.
## Automated Go tests
Run:
```bash
go mod tidy
go test ./...
go vet ./...
```
Current unit coverage includes:
- policy normalization/validation
- template create/update logic
- profile backup/restore merge behavior
- version retention
- snapshot activation guard when a user becomes active
- broker reconnect to an existing session on a drain host
- drain exclusion for new sessions
- farm isolation
- unknown-farm fail-closed behavior
- farm policy selection through tags
## Guacamole extension build
```bash
cd guacamole-extension
mvn -DskipTests package
```
Then launch Guacamole 1.6 and verify the extension appears in startup logs without version/API errors.
## Broker integration
### New connection
- two online agents in the same farm
- both above minimum health
- verify one is selected and a lease appears
- make selected host significantly more loaded; after lease expiry use a new test user and verify score affects placement
### Reconnect
- disconnect a user's RDS session
- initiate the same Guacamole resource again
- verify broker returns the host containing that session even if that host is `drain`
### Farm isolation
- same username has a stale/disconnected session in Farm B
- connect to a resource in Farm A
- verify Farm B is never selected
### Maintenance
- `drain`: existing session reconnect allowed, new user rejected/placed elsewhere
- `maintenance`: existing-session reconnect is not brokered there
- below-minimum-health host: no new placement
- disabled/missing farm: request fails closed
### Lease race
Issue two near-simultaneous broker requests for the same user and verify they converge on the same valid lease/host in the expected deployment topology.
## Drain / restart-when-empty
1. Set server to drain and `restart_when_drained`.
2. Verify no new users are placed there.
3. Verify existing disconnected user can reconnect.
4. End all user sessions.
5. Verify exactly one restart command is queued/executed.
6. Verify the server returns with agent heartbeat and can be set online.
## Session control
- message
- disconnect
- forced logoff
- bulk disconnected logoff
- process termination
- command expiry if agent remains offline past TTL
- duplicate heartbeat delivery must not re-execute the same command ID
- result appears in audit log
## Profile pipeline
### Normal logoff
- modify selected profile data
- log off
- verify post-logoff delay
- verify `staging` is built
- verify snapshot becomes `current`
- verify cleanup starts only after successful backup
### SMB failure
- make profile store unavailable before logoff
- verify backup enters error/retry
- verify local profile is **not** deleted
- restore SMB and verify retry succeeds before cleanup proceeds
### Reconnect during backup
- use enough data to keep backup running
- reconnect same SID before snapshot activation
- verify new snapshot is not promoted over `current`
- verify no destructive cleanup occurs for the active user
### Restore window
- make SMB unavailable at logon
- keep unavailable beyond `restore_window_seconds`
- verify SessionGuard stops trying to inject the restore into the now-running desktop
### Cleanup race
- let cleanup grace nearly expire
- reconnect user
- verify final WTS recheck cancels `DeleteProfileW`
### Retention
Generate more backups than `keep_versions` and verify `current` plus the configured history count remain valid.
## Director/history
- logon, disconnect, reconnect and logoff should create ordered history rows
- `logon_ready` should contain observed and restore timing when available
- process list should contain user-session processes and remain payload-bounded
- history user/agent filters should work after PostgreSQL restart
## Alerts
Individually cross each configured threshold and verify:
- active alert created
- `last_seen_at` updates
- webhook fires at most at notification minimum interval
- alert resolves after condition clears
Also test profile-failure and offline-agent alerts.
## PostgreSQL
- clean schema bootstrap
- restart Master and verify state/history
- DB unavailable during write: API should surface failure rather than claim success
- restore DB and verify agents resume heartbeats
- backup/restore PostgreSQL into a test instance
- retention with history above configured limit
## OIDC/RBAC
With real PocketID groups:
- viewer cannot mutate
- helpdesk can perform allowed session operations
- operator can use maintenance/process functions
- policy admin can write/rollback policy
- auditor can read audit
- admin can manage farms/resources
- user with no matching group receives `default_role`
## Header-auth / network test
From an unrelated container/network, attempt direct HTTP access to Guacamole with a forged `X-Guacamole-User`. The architecture is acceptable only if that bypass path is unreachable/blocked.
## Canary release gate
Before broad rollout record:
- exact Windows Server builds tested
- exact Guacamole image/extension build
- PocketID version/client configuration
- PostgreSQL version
- profile-store SMB server/version
- successful restore from PostgreSQL backup
- successful restore of at least one user profile snapshot
- successful drain/reconnect/restart workflow

3
go.mod
View File

@@ -1,9 +1,10 @@
module github.com/example/sessionguard
go 1.26.0
go 1.23.0
require (
github.com/coreos/go-oidc/v3 v3.14.1
github.com/lib/pq v1.10.9
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
)

2
go.sum
View File

@@ -6,6 +6,8 @@ github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=

View File

@@ -0,0 +1,8 @@
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN mvn -B -DskipTests package
FROM scratch
COPY --from=build /src/target/sessionguard-guacamole-0.3.0.jar /sessionguard-guacamole.jar

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>info.hilden.sessionguard</groupId>
<artifactId>sessionguard-guacamole</artifactId>
<version>0.3.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>11</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<guacamole.version>1.6.0</guacamole.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.guacamole</groupId>
<artifactId>guacamole-ext</artifactId>
<version>${guacamole.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration><release>${maven.compiler.release}</release></configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,93 @@
package info.hilden.sessionguard.guacamole;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.guacamole.GuacamoleException;
final class BrokerClient {
private final URI endpoint;
private final String apiKey;
private final int timeoutMs;
private BrokerClient(URI endpoint, String apiKey, int timeoutMs) {
this.endpoint = endpoint;
this.apiKey = apiKey;
this.timeoutMs = timeoutMs;
}
static BrokerClient fromEnvironment() {
String base = envRequired("SESSIONGUARD_MASTER_URL").replaceAll("/+$", "");
String key = envRequired("SESSIONGUARD_BROKER_API_KEY");
int timeout = 2500;
String rawTimeout = System.getenv("SESSIONGUARD_BROKER_TIMEOUT_MS");
if (rawTimeout != null && !rawTimeout.isBlank()) {
try { timeout = Math.max(250, Integer.parseInt(rawTimeout)); }
catch (NumberFormatException ignored) { }
}
return new BrokerClient(URI.create(base + "/api/v1/broker/tokens"), key, timeout);
}
Map<String, String> resolve(String username, String connectionId, String connectionName)
throws GuacamoleException {
String body = "username=" + enc(username)
+ "&connection_id=" + enc(connectionId)
+ "&connection_name=" + enc(connectionName);
try {
HttpURLConnection c = (HttpURLConnection) endpoint.toURL().openConnection();
c.setRequestMethod("POST");
c.setConnectTimeout(timeoutMs);
c.setReadTimeout(timeoutMs);
c.setDoOutput(true);
c.setRequestProperty("Authorization", "Bearer " + apiKey);
c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
c.setRequestProperty("Accept", "application/x-www-form-urlencoded");
c.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8));
int status = c.getResponseCode();
if (status != 200) {
String msg = c.getErrorStream() == null ? "" : new String(c.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
throw new GuacamoleException("SessionGuard broker returned HTTP " + status + ": " + msg);
}
String response = new String(c.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return parseForm(response);
}
catch (IOException e) {
throw new GuacamoleException("SessionGuard broker is unavailable", e);
}
}
private static Map<String, String> parseForm(String input) {
if (input == null || input.isBlank()) return Collections.emptyMap();
Map<String, String> out = new HashMap<>();
for (String pair : input.split("&")) {
int eq = pair.indexOf('=');
String key = eq < 0 ? pair : pair.substring(0, eq);
String value = eq < 0 ? "" : pair.substring(eq + 1);
out.put(dec(key), dec(value));
}
return out;
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private static String dec(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
private static String envRequired(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required by SessionGuard Guacamole extension");
}
return value.trim();
}
}

View File

@@ -0,0 +1,42 @@
package info.hilden.sessionguard.guacamole;
import java.util.Map;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.AbstractAuthenticationProvider;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.TokenInjectingUserContext;
import org.apache.guacamole.net.auth.UserContext;
/**
* Adds broker-selected Guacamole connection tokens to UserContexts created by
* the actual authentication/storage providers. PocketID/header authentication
* remains authoritative; this extension performs no user authentication.
*/
public final class SessionGuardAuthenticationProvider extends AbstractAuthenticationProvider {
private final BrokerClient broker = BrokerClient.fromEnvironment();
@Override
public String getIdentifier() {
return "sessionguard-broker";
}
@Override
public UserContext decorate(UserContext context, AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException {
if (context == null || authenticatedUser == null) {
return context;
}
final String username = authenticatedUser.getIdentifier();
return new TokenInjectingUserContext(context) {
@Override
protected Map<String, String> getTokens(Connection connection) throws GuacamoleException {
return broker.resolve(username, connection.getIdentifier(), connection.getName());
}
};
}
}

View File

@@ -0,0 +1,8 @@
{
"guacamoleVersion": "1.6.0",
"name": "SessionGuard Broker",
"namespace": "sessionguard-broker",
"authProviders": [
"info.hilden.sessionguard.guacamole.SessionGuardAuthenticationProvider"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,27 @@ import (
"os"
"path/filepath"
"sync"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
)
type State struct {
AgentID string `json:"agent_id,omitempty"`
AgentToken string `json:"agent_token,omitempty"`
Policy model.Policy `json:"policy"`
LastSessions map[uint32]model.Session `json:"last_sessions,omitempty"`
Pending map[string]model.CleanupJob `json:"pending,omitempty"`
AgentID string `json:"agent_id,omitempty"`
AgentToken string `json:"agent_token,omitempty"`
Policy model.Policy `json:"policy"`
LastSessions map[uint32]model.Session `json:"last_sessions,omitempty"`
Pending map[string]model.CleanupJob `json:"pending,omitempty"`
ProfileJobs map[string]model.ProfileJob `json:"profile_jobs,omitempty"`
ProfileStatus map[string]model.ProfileStatus `json:"profile_status,omitempty"`
DisconnectedSince map[uint32]time.Time `json:"disconnected_since,omitempty"`
AutoLogoffRequested map[uint32]time.Time `json:"auto_logoff_requested,omitempty"`
RestoredSessions map[uint32]bool `json:"restored_sessions,omitempty"`
ProcessedCommands map[string]time.Time `json:"processed_commands,omitempty"`
CommandResults []model.CommandResult `json:"command_results,omitempty"`
Events []model.AgentEvent `json:"events,omitempty"`
Telemetry map[uint32]model.SessionTelemetry `json:"telemetry,omitempty"`
}
type stateStore struct {
@@ -25,7 +35,20 @@ type stateStore struct {
}
func loadState(path string, initial model.Policy) (State, error) {
s := State{Policy: initial, LastSessions: map[uint32]model.Session{}, Pending: map[string]model.CleanupJob{}}
s := State{
Policy: initial,
LastSessions: map[uint32]model.Session{},
Pending: map[string]model.CleanupJob{},
ProfileJobs: map[string]model.ProfileJob{},
ProfileStatus: map[string]model.ProfileStatus{},
DisconnectedSince: map[uint32]time.Time{},
AutoLogoffRequested: map[uint32]time.Time{},
RestoredSessions: map[uint32]bool{},
ProcessedCommands: map[string]time.Time{},
CommandResults: []model.CommandResult{},
Events: []model.AgentEvent{},
Telemetry: map[uint32]model.SessionTelemetry{},
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
@@ -42,6 +65,44 @@ func loadState(path string, initial model.Policy) (State, error) {
if s.Pending == nil {
s.Pending = map[string]model.CleanupJob{}
}
if s.ProfileJobs == nil {
s.ProfileJobs = map[string]model.ProfileJob{}
}
if s.ProfileStatus == nil {
s.ProfileStatus = map[string]model.ProfileStatus{}
}
if s.DisconnectedSince == nil {
s.DisconnectedSince = map[uint32]time.Time{}
}
if s.AutoLogoffRequested == nil {
s.AutoLogoffRequested = map[uint32]time.Time{}
}
if s.ProcessedCommands == nil {
s.ProcessedCommands = map[string]time.Time{}
}
if s.CommandResults == nil {
s.CommandResults = []model.CommandResult{}
}
if s.Events == nil {
s.Events = []model.AgentEvent{}
}
if s.Telemetry == nil {
s.Telemetry = map[uint32]model.SessionTelemetry{}
}
if s.RestoredSessions == nil {
// Upgrade safety: do not restore into sessions that were already active before
// upgrading from a version that did not track per-session restore state.
s.RestoredSessions = map[uint32]bool{}
for id := range s.LastSessions {
s.RestoredSessions[id] = true
}
}
if len(s.Events) > 500 {
s.Events = append([]model.AgentEvent(nil), s.Events[len(s.Events)-500:]...)
}
if len(s.CommandResults) > 100 {
s.CommandResults = append([]model.CommandResult(nil), s.CommandResults[len(s.CommandResults)-100:]...)
}
if s.Policy.Revision == "" {
s.Policy = initial
}

View File

@@ -1,10 +1,24 @@
package agent
const agentHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Agent</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0c1120;color:#edf2fb}*{box-sizing:border-box}body{margin:0;background:#0c1120}.wrap{max-width:1200px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center}.brand{font-size:20px;font-weight:750}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.card,.panel{background:#131d32;border:1px solid #263653;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:750;margin-top:7px}.panel{margin-top:15px;overflow:hidden}.panel h2{font-size:15px;padding:14px 16px;margin:0;border-bottom:1px solid #263653}.table{width:100%;border-collapse:collapse}.table td,.table th{padding:11px 13px;border-bottom:1px solid #22314d;text-align:left;font-size:13px}.table th{color:#93a5c2}.form{padding:16px;display:grid;gap:11px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}input,textarea{width:100%;background:#0d1629;color:#edf2fb;border:1px solid #334866;border-radius:9px;padding:8px}textarea{min-height:110px;font-family:ui-monospace,Consolas,monospace;font-size:12px}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}button{background:#5d8eff;color:#fff;border:0;border-radius:9px;padding:9px 13px;font-weight:650;cursor:pointer}.secondary{background:#22314d}.bad{color:#ff9b9b}.good{color:#74e59a}@media(max-width:850px){.grid{grid-template-columns:repeat(2,1fr)}.cols{grid-template-columns:1fr}}</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:16px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1450px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{font-size:20px;font-weight:800}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.card,.panel{background:rgba(18,27,49,.88);border:1px solid #263654;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:800;margin-top:5px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:15px 18px;border-bottom:1px solid #263654}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:10px 12px;border-bottom:1px solid #22314e;text-align:left;font-size:13px;vertical-align:top}.table th{color:#94a5c4}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning{color:#ffd166}.form{padding:16px;display:grid;gap:13px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.two{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}label{display:grid;gap:5px;font-size:13px;color:#c8d4e7}input,textarea,select{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px}textarea{min-height:90px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.check label{display:block}button{background:#5b8cff;color:white;border:0;border-radius:9px;padding:8px 11px;font-weight:650;cursor:pointer}button.secondary{background:#263654}button.danger{background:#743b46}.actions{display:flex;gap:5px;flex-wrap:wrap}.log{max-height:360px;overflow:auto}.event-error{color:#ffaaaa}.event-warning{color:#ffd166}.event-dry-run{color:#ffd166}.empty{padding:18px;color:#8190aa}.section{border-top:1px solid #2a3d5d;padding-top:14px;margin-top:4px}.section-title{font-weight:800;margin-bottom:10px}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px}.list{display:grid;gap:10px}.item{border:1px solid #2b3d5c;background:#0f192c;border-radius:12px;padding:12px;display:grid;gap:9px}.item-head{display:flex;justify-content:space-between;align-items:center}.note{padding:10px;border:1px solid #334866;background:#101b31;border-radius:10px;color:#9fb0ca;font-size:12px}@media(max-width:1050px){.grid{grid-template-columns:repeat(3,1fr)}.cols,.two{grid-template-columns:1fr}}@media(max-width:650px){.grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="disc"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="profileJobs"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:15px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Profil-Pipeline</h2><div id="profiles"></div></section><section class="panel"><h2>Aktivitäts- &amp; Audit-Log</h2><div id="events"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
const agentJS = `
const $=id=>document.getElementById(id);function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}async function api(u,o){let r=await fetch(u,o),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)}
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{},ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('total').textContent=ss.length;$('pending').textContent=(s.pending_cleanup||[]).length;$('master').innerHTML=d.master_error?'<span class="bad">Offline</span>':'<span class="good">Verbunden</span>';$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(x.client_name||'')+'</td></tr>').join('')+'</tbody></table>'}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML='<div class="form"><div class="cols"><label>Grace (s)<input id="grace" type="number" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'> Cleanup aktiv</div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'> Dry-Run</div><label>Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:220px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div><button onclick="savePolicy()">Lokal speichern</button></div><div class="muted">Wenn der Master für diesen Agent eine gewünschte Policy gesetzt hat, ist diese nach Wiederherstellung der Verbindung wieder maßgeblich.</div></div>'}catch(e){$('policy').textContent=e.message}}
async function savePolicy(){try{let p={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:JSON.parse($('templates').value||'[]')};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy()}catch(e){alert(e.message)}}refresh();loadPolicy();setInterval(refresh,5000);`
let policyTemplates=[],profileFolders=[],lastSnapshot=null;
const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function when(v){if(!v)return '';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'':d.toLocaleString('de-DE')}function dur(v){if(!v)return '';let s=Math.max(0,Math.floor((Date.now()-new Date(v).getTime())/1000));return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m'}
function renderEvents(events){let rows=(events||[]).slice().reverse().slice(0,200);$('events').innerHTML=rows.length?'<div class="log"><table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+rows.map(x=>'<tr class="event-'+esc(x.level||'info')+'"><td>'+esc(when(x.time))+'</td><td>'+esc(x.level||'info')+'</td><td>'+esc(x.user||'')+'</td><td>'+esc(x.message||'')+'</td></tr>').join('')+'</tbody></table></div>':'<div class="empty">Noch keine Ereignisse.</div>'}
function renderProfiles(s){let jobs=s.profile_jobs||[],status=Object.values(s.profile_status||{});let html='';if(jobs.length)html+='<table class="table"><thead><tr><th>Job</th><th>Benutzer</th><th>Fällig</th><th>Versuche</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+esc(when(j.due_at))+'</td><td>'+j.attempts+'</td><td>'+esc(j.last_error||'')+'</td></tr>').join('')+'</tbody></table>';if(status.length)html+='<table class="table"><thead><tr><th>Benutzer</th><th>Letztes Backup</th><th>Letzter Restore</th><th>Status</th></tr></thead><tbody>'+status.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+esc(when(x.last_backup_at))+'</td><td>'+esc(when(x.last_restore_at))+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'OK')+'</td></tr>').join('')+'</tbody></table>';$('profiles').innerHTML=html||'<div class="empty">Keine Profil-Jobs oder -Historie.</div>'}
async function sessionAction(id,action){let body={action};if(action==='message'){let m=prompt('Nachricht an die Sitzung:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profil-Pipeline startet nach dem Sitzungsende.'))return;try{await api('/api/v1/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});setTimeout(refresh,400)}catch(e){alert(e.message)}}
function renderSessions(s){let p=s.policy||{},control=p.sessions&&p.sessions.control_enabled,ss=s.sessions||[];$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Getrennt seit</th><th>Client</th><th>Aktionen</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(x.disconnected_since?when(x.disconnected_since)+' ('+dur(x.disconnected_since)+')':'')+'</td><td>'+esc(x.client_name||'')+'</td><td>'+(control&&x.user?'<div class="actions"><button class="secondary" data-session="'+x.id+'" data-session-action="message">Nachricht</button><button class="secondary" data-session="'+x.id+'" data-session-action="disconnect">Trennen</button><button class="danger" data-session="'+x.id+'" data-session-action="logoff">Abmelden</button></div>':'')+'</td></tr>').join('')+'</tbody></table>'}
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('master').innerHTML=d.master_error?'<span class="bad">Offline</span>':'<span class="good">Verbunden</span>';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'<div class="muted">Keine weiteren Angaben.</div>';if(k==='url')return'<label>URL<input data-field="url" value="'+esc(t.url||'')+'"></label>';if(k==='shortcut'){let s=t.shortcut||{};return'<div class="two"><label>Zielprogramm<input data-field="shortcut.target" value="'+esc(s.target||'')+'"></label><label>Argumente<input data-field="shortcut.arguments" value="'+esc(s.arguments||'')+'"></label><label>Arbeitsverzeichnis<input data-field="shortcut.working_directory" value="'+esc(s.working_directory||'')+'"></label><label>Icon<input data-field="shortcut.icon_location" value="'+esc(s.icon_location||'')+'"></label></div><label>Beschreibung<input data-field="shortcut.description" value="'+esc(s.description||'')+'"></label>'}return'<label>Quelldatei / UNC-Pfad<input data-field="source" value="'+esc(t.source||'')+'"></label><div class="two"><label>Inline-Inhalt<textarea data-field="content">'+esc(t.content||'')+'</textarea></label><label>Inline Base64<textarea data-field="content_base64">'+esc(t.content_base64||'')+'</textarea></label></div>'}
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'<div class="item template-card"><div class="item-head"><strong>'+esc(t.id||('Template '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-template" data-index="'+i+'">Löschen</button></div><div class="cols"><label>ID<input data-field="id" value="'+esc(t.id||'')+'"></label><label>Typ<select data-field="kind" data-action="template-kind"><option value="file" '+((t.kind||'file')==='file'?'selected':'')+'>Datei</option><option value="directory" '+(t.kind==='directory'?'selected':'')+'>Ordner</option><option value="url" '+(t.kind==='url'?'selected':'')+'>URL</option><option value="shortcut" '+(t.kind==='shortcut'?'selected':'')+'>.lnk</option></select></label><label>Ziel relativ zum Profil<input data-field="target" value="'+esc(t.target||'')+'"></label></div><div class="check"><input data-field="overwrite" type="checkbox" '+(t.overwrite?'checked':'')+'><label>Aktualisieren/überschreiben</label></div>'+templateSpecific(t)+'</div>').join(''):'<div class="empty">Keine Templates konfiguriert.</div>'}
function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})}
function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'<div class="item profile-folder"><div class="item-head"><strong>'+esc(f.path||('Ordner '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-profile-folder" data-index="'+i+'">Löschen</button></div><div class="two"><label>Pfad relativ zum Profil<input data-field="path" value="'+esc(f.path||'')+'" placeholder="AppData\\Roaming\\Hersteller"></label><label>Ausschlüsse (eine Zeile; * oder Pfad/**)<textarea data-field="exclude">'+esc((f.exclude_globs||[]).join('\n'))+'</textarea></label></div></div>').join(''):'<div class="empty">Keine Profilordner konfiguriert.</div>'}
function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)}
function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'<div class="form"><div class="section-title">Profil-Sicherung & Wiederherstellung</div><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="two"><label>Profil-Store (UNC oder lokaler Pfad)<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" max="50" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Von Profil-Sync ausgeschlossene Benutzer<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Von Profil-Sync ausgeschlossene SIDs<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Zu sichernde Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><div class="note">Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.</div><div class="section"><div class="section-title">Sitzungsrichtlinie</div><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Administrative Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Timeout für getrennte Sitzung (s; min. 60)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Vom Auto-Logoff ausgeschlossene Benutzer<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Vom Auto-Logoff ausgeschlossene SIDs<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="section-title">Profil-Cleanup</div><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup: Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup: SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div></div><div><button type="button" data-action="save-policy">Lokal speichern</button></div></div>'}
async function loadPolicy(){try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders()}catch(e){$('policy').textContent=e.message}}
async function savePolicy(){try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy();refresh()}catch(e){alert(e.message)}}
$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});refresh();loadPolicy();setInterval(refresh,5000);`

View File

@@ -5,17 +5,23 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/example/sessionguard/internal/model"
)
type Master struct {
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
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"`
}
type Agent struct {
@@ -34,6 +40,7 @@ func LoadMaster(path string) (Master, error) {
if err := read(path, &c); err != nil {
return c, err
}
applyMasterEnv(&c)
if c.Listen == "" {
c.Listen = ":8080"
}
@@ -43,7 +50,55 @@ func LoadMaster(path string) (Master, error) {
if c.OfflineAfterSeconds <= 0 {
c.OfflineAfterSeconds = 30
}
return c, validateOIDC(c.OIDC)
if c.HistoryLimit <= 0 {
c.HistoryLimit = 50000
}
if c.Broker.LeaseSeconds <= 0 {
c.Broker.LeaseSeconds = 900
}
if c.Broker.MinHealthScore <= 0 {
c.Broker.MinHealthScore = 60
}
if c.Alerts.CPUPercent <= 0 {
c.Alerts.CPUPercent = 90
}
if c.Alerts.MemoryPercent <= 0 {
c.Alerts.MemoryPercent = 90
}
if c.Alerts.DiskFreeGB <= 0 {
c.Alerts.DiskFreeGB = 10
}
if c.Alerts.HealthScore <= 0 {
c.Alerts.HealthScore = 50
}
if c.Alerts.OfflineSeconds <= 0 {
c.Alerts.OfflineSeconds = 120
}
if c.Alerts.ProfileFailures <= 0 {
c.Alerts.ProfileFailures = 3
}
if c.Alerts.DisconnectedSessions <= 0 {
c.Alerts.DisconnectedSessions = 20
}
if c.Alerts.LogonDurationSeconds <= 0 {
c.Alerts.LogonDurationSeconds = 30
}
if c.Alerts.NotificationMinInterval <= 0 {
c.Alerts.NotificationMinInterval = 900
}
if c.RBAC.DefaultRole == "" {
c.RBAC.DefaultRole = "viewer"
}
if c.RBAC.Groups == nil {
c.RBAC.Groups = map[string][]string{}
}
if err := validateOIDC(c.OIDC); err != nil {
return c, err
}
if c.Broker.Enabled && strings.TrimSpace(c.Broker.APIKey) == "" {
return c, errors.New("broker.api_key is required when broker is enabled")
}
return c, nil
}
func LoadAgent(path string) (Agent, error) {
@@ -51,6 +106,7 @@ func LoadAgent(path string) (Agent, error) {
if err := read(path, &c); err != nil {
return c, err
}
applyAgentEnv(&c)
if c.Listen == "" {
c.Listen = ":9091"
}
@@ -78,6 +134,10 @@ func LoadAgent(path string) (Agent, error) {
if c.Policy.Cleanup.ExcludeSIDs == nil {
c.Policy.Cleanup.ExcludeSIDs = []string{"S-1-5-18", "S-1-5-19", "S-1-5-20"}
}
NormalizePolicy(&c.Policy)
if err := ValidatePolicy(c.Policy); err != nil {
return c, err
}
if c.OIDC.Issuer != "" {
if err := validateOIDC(c.OIDC); err != nil {
return c, err
@@ -86,17 +146,37 @@ func LoadAgent(path string) (Agent, error) {
return c, nil
}
func applyMasterEnv(c *Master) {
set := func(name string, dst *string) {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
*dst = v
}
}
set("SESSIONGUARD_DATABASE_URL", &c.DatabaseURL)
set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken)
set("SESSIONGUARD_BROKER_API_KEY", &c.Broker.APIKey)
set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
set("SESSIONGUARD_ALERT_WEBHOOK_URL", &c.Alerts.WebhookURL)
}
func applyAgentEnv(c *Agent) {
set := func(name string, dst *string) {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
*dst = v
}
}
set("SESSIONGUARD_MASTER_URL", &c.MasterURL)
set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken)
set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
}
func read(path string, out any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(b, out); err != nil {
return err
}
return nil
return json.Unmarshal(b, out)
}
func SaveJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
@@ -111,10 +191,75 @@ func SaveJSON(path string, v any) error {
}
return os.Rename(tmp, path)
}
func validateOIDC(c model.OIDCConfig) error {
if c.Issuer == "" || c.ClientID == "" || c.RedirectURL == "" {
return errors.New("oidc issuer, client_id and redirect_url are required")
}
return nil
}
func NormalizePolicy(p *model.Policy) {
if p.Cleanup.GraceSeconds <= 0 {
p.Cleanup.GraceSeconds = 600
}
if p.Cleanup.PollSeconds <= 0 {
p.Cleanup.PollSeconds = 10
}
if p.Cleanup.RetrySeconds <= 0 {
p.Cleanup.RetrySeconds = 60
}
if len(p.Cleanup.AllowedProfileRoots) == 0 {
p.Cleanup.AllowedProfileRoots = []string{`C:\Users`}
}
if p.Profiles.BackupDelaySeconds < 0 {
p.Profiles.BackupDelaySeconds = 0
}
if p.Profiles.BackupDelaySeconds == 0 {
p.Profiles.BackupDelaySeconds = 5
}
if p.Profiles.RetrySeconds <= 0 {
p.Profiles.RetrySeconds = 60
}
if p.Profiles.RestoreWindowSeconds <= 0 {
p.Profiles.RestoreWindowSeconds = 120
}
if p.Profiles.KeepVersions < 0 {
p.Profiles.KeepVersions = 0
}
if p.Sessions.DisconnectedTimeoutSeconds <= 0 {
p.Sessions.DisconnectedTimeoutSeconds = 3600
}
}
func ValidatePolicy(p model.Policy) error {
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 || p.Cleanup.RetrySeconds < 1 {
return errors.New("invalid cleanup timing")
}
if p.Profiles.Enabled {
if strings.TrimSpace(p.Profiles.StoreRoot) == "" {
return errors.New("profiles.store_root is required when profile sync is enabled")
}
if len(p.Profiles.Folders) == 0 {
return errors.New("at least one profiles.folders entry is required when profile sync is enabled")
}
if p.Profiles.BackupDelaySeconds < 0 {
return errors.New("profiles.backup_delay_seconds must be >= 0")
}
if p.Profiles.RetrySeconds < 1 {
return errors.New("profiles.retry_seconds must be >= 1")
}
if p.Profiles.RestoreWindowSeconds < 10 {
return errors.New("profiles.restore_window_seconds must be >= 10")
}
for _, f := range p.Profiles.Folders {
v := strings.ReplaceAll(strings.TrimSpace(f.Path), `\`, "/")
if v == "" || strings.HasPrefix(v, "/") || strings.Contains(v, ":") || v == ".." || strings.HasPrefix(v, "../") || strings.Contains(v, "/../") {
return errors.New("profile folder paths must be relative and may not escape the user profile")
}
}
}
if p.Sessions.DisconnectedLogoffEnabled && p.Sessions.DisconnectedTimeoutSeconds < 60 {
return errors.New("sessions.disconnected_timeout_seconds must be >= 60")
}
return nil
}

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/example/sessionguard/internal/model"
)
func TestAgentDefaults(t *testing.T) {
@@ -22,3 +24,27 @@ func TestAgentDefaults(t *testing.T) {
t.Fatal("missing allowed profile root")
}
}
func TestValidateProfilePolicy(t *testing.T) {
p := model.Policy{
Cleanup: model.CleanupPolicy{GraceSeconds: 600, PollSeconds: 10, RetrySeconds: 60, AllowedProfileRoots: []string{`C:\Users`}},
Profiles: model.ProfilePolicy{Enabled: true, StoreRoot: `\\server\profiles`, RetrySeconds: 60, RestoreWindowSeconds: 120, Folders: []model.ProfileFolder{{Path: `AppData\Roaming\Example`}}},
Sessions: model.SessionPolicy{DisconnectedTimeoutSeconds: 3600},
}
if err := ValidatePolicy(p); err != nil {
t.Fatal(err)
}
p.Profiles.Folders[0].Path = `..\Windows`
if err := ValidatePolicy(p); err == nil {
t.Fatal("expected profile traversal validation error")
}
}
func TestDisconnectedTimeoutMinimum(t *testing.T) {
p := model.Policy{Cleanup: model.CleanupPolicy{GraceSeconds: 600, PollSeconds: 10, RetrySeconds: 60}, Sessions: model.SessionPolicy{DisconnectedLogoffEnabled: true, DisconnectedTimeoutSeconds: 30}}
NormalizePolicy(&p)
p.Sessions.DisconnectedTimeoutSeconds = 30
if err := ValidatePolicy(p); err == nil {
t.Fatal("expected disconnected timeout validation error")
}
}

View File

@@ -0,0 +1,141 @@
package master
import (
"context"
"testing"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
)
type memoryPersistence struct{}
func (memoryPersistence) Load(context.Context, *data) error { return nil }
func (memoryPersistence) Save(context.Context, data) error { return nil }
func (memoryPersistence) AppendAudit(context.Context, model.AuditEntry, int) error { return nil }
func (memoryPersistence) AppendHistory(context.Context, model.SessionHistoryEvent, int) error {
return nil
}
func (memoryPersistence) Close() error { return nil }
func (memoryPersistence) Kind() string { return "memory" }
func brokerTestApp() *App {
d := emptyData()
d.Farms["office"] = model.Farm{ID: "office", Name: "Office", Enabled: true}
return &App{
cfg: config.Master{
OfflineAfterSeconds: 60,
Broker: model.BrokerConfig{
Enabled: true, LeaseSeconds: 900, MinHealthScore: 60,
ReconnectExisting: true, SingleSession: true, DefaultFarmID: "office",
},
},
store: &store{data: d, backend: memoryPersistence{}},
}
}
func testAgent(id, host, mode string, health int, sessions ...model.Session) model.AgentRecord {
return model.AgentRecord{
ID: id, Name: host, LastSeen: time.Now().UTC(), MaintenanceMode: mode,
FarmIDs: []string{"office"},
Snapshot: model.AgentSnapshot{
Server: model.ServerInfo{Hostname: host, CPUPercent: 20, MemoryTotal: 100, MemoryAvailable: 70},
Health: model.HealthStatus{Score: health}, Sessions: sessions,
},
}
}
func TestBrokerReconnectsExistingSessionOnDrain(t *testing.T) {
a := brokerTestApp()
a.store.data.Agents["rds01"] = testAgent("rds01", "rds01.example.test", "drain", 90,
model.Session{ID: 7, User: "Max", Domain: "EXAMPLE", State: "Disconnected"})
a.store.data.Agents["rds02"] = testAgent("rds02", "rds02.example.test", "online", 100)
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "rds01" || got.Reason != "existing-session" || !got.Reconnect {
t.Fatalf("unexpected broker result: %+v", got)
}
}
func TestBrokerDoesNotUseDrainForNewSession(t *testing.T) {
a := brokerTestApp()
a.store.data.Agents["rds01"] = testAgent("rds01", "rds01.example.test", "drain", 100)
a.store.data.Agents["rds02"] = testAgent("rds02", "rds02.example.test", "online", 80)
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\NewUser`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "rds02" || got.Reason != "load-balance" {
t.Fatalf("unexpected broker result: %+v", got)
}
}
func TestBrokerKeepsFarmIsolation(t *testing.T) {
a := brokerTestApp()
a.store.data.Farms["erp"] = model.Farm{ID: "erp", Name: "ERP", Enabled: true}
a.store.data.Agents["office01"] = testAgent("office01", "office01.example.test", "online", 80)
erp := testAgent("erp01", "erp01.example.test", "online", 100,
model.Session{ID: 3, User: "Max", Domain: "EXAMPLE", State: "Disconnected"})
erp.FarmIDs = []string{"erp"}
a.store.data.Agents["erp01"] = erp
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "office01" {
t.Fatalf("broker crossed farm boundary: %+v", got)
}
}
func TestBrokerRejectsUnknownFarm(t *testing.T) {
a := brokerTestApp()
_, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "missing"})
if err == nil {
t.Fatal("expected unknown farm to fail closed")
}
}
func TestEffectivePolicyIncludesTagSelectedFarm(t *testing.T) {
a := brokerTestApp()
p := model.Policy{Revision: "farm-policy"}
f := a.store.data.Farms["office"]
f.RequiredTags = map[string]string{"role": "office"}
f.Policy = &p
a.store.data.Farms["office"] = f
rec := model.AgentRecord{ID: "rds01", Tags: map[string]string{"role": "office"}}
got := a.effectivePolicyLocked(rec)
if got == nil || got.Revision != "farm-policy" {
t.Fatalf("tag-selected farm policy not applied: %+v", got)
}
}
func TestGuacamoleTokenBridgeOnlyBrokersMappedResources(t *testing.T) {
a := brokerTestApp()
a.store.data.Resources["office-desktop"] = model.Resource{
ID: "office-desktop", Name: "Office Desktop", Kind: "desktop", FarmID: "office",
GuacamoleConnectionName: "Office Desktop", Enabled: true,
}
if !a.hasMappedResource(model.BrokerRequest{ConnectionName: "office desktop"}) {
t.Fatal("expected case-insensitive configured Guacamole connection to be mapped")
}
if a.hasMappedResource(model.BrokerRequest{ConnectionName: "Static Admin RDP"}) {
t.Fatal("unmapped Guacamole connection must remain untouched by broker token bridge")
}
}
func TestDomainQualifiedBrokerIdentityDoesNotCrossDomain(t *testing.T) {
s := model.Session{User: "Max", Domain: "DOMAINA", State: "Disconnected"}
if sessionMatchesUser(s, normalizeUser(`DOMAINB\Max`)) {
t.Fatal("domain-qualified identity matched a session from a different domain")
}
if !sessionMatchesUser(s, normalizeUser(`DOMAINA\Max`)) {
t.Fatal("matching domain-qualified identity did not match")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,140 @@
package master
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
_ "github.com/lib/pq"
)
type data struct {
Agents map[string]model.AgentRecord `json:"agents"`
Agents map[string]model.AgentRecord `json:"agents"`
Farms map[string]model.Farm `json:"farms,omitempty"`
Resources map[string]model.Resource `json:"resources,omitempty"`
Leases map[string]model.UserLease `json:"leases,omitempty"`
Audit []model.AuditEntry `json:"audit,omitempty"`
SessionHistory []model.SessionHistoryEvent `json:"session_history,omitempty"`
PolicyHistory []model.PolicyVersion `json:"policy_history,omitempty"`
Alerts map[string]model.Alert `json:"alerts,omitempty"`
GlobalPolicy *model.Policy `json:"global_policy,omitempty"`
}
type persistence interface {
Load(context.Context, *data) error
Save(context.Context, data) error
AppendAudit(context.Context, model.AuditEntry, int) error
AppendHistory(context.Context, model.SessionHistoryEvent, int) error
Close() error
Kind() string
}
type store struct {
path string
mu sync.RWMutex
data data
mu sync.RWMutex
data data
backend persistence
persistErr error
}
func newStore(path string) (*store, error) {
s := &store{path: path, data: data{Agents: map[string]model.AgentRecord{}}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
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{},
}
}
func newStore(ctx context.Context, cfg config.Master) (*store, error) {
var b persistence
var err error
if cfg.DatabaseURL != "" {
b, err = newPostgresPersistence(ctx, cfg.DatabaseURL, cfg.HistoryLimit)
} else {
b = &jsonPersistence{path: cfg.DataFile}
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
s := &store{data: emptyData(), backend: b}
if err := b.Load(ctx, &s.data); err != nil {
_ = b.Close()
return nil, err
}
s.normalize()
return s, nil
}
func (s *store) normalize() {
if s.data.Agents == nil {
s.data.Agents = map[string]model.AgentRecord{}
}
return s, nil
if s.data.Farms == nil {
s.data.Farms = map[string]model.Farm{}
}
if s.data.Resources == nil {
s.data.Resources = map[string]model.Resource{}
}
if s.data.Leases == nil {
s.data.Leases = map[string]model.UserLease{}
}
if s.data.Audit == nil {
s.data.Audit = []model.AuditEntry{}
}
if s.data.SessionHistory == nil {
s.data.SessionHistory = []model.SessionHistoryEvent{}
}
if s.data.PolicyHistory == nil {
s.data.PolicyHistory = []model.PolicyVersion{}
}
if s.data.Alerts == nil {
s.data.Alerts = map[string]model.Alert{}
}
for id, a := range s.data.Agents {
if a.Tags == nil {
a.Tags = map[string]string{}
}
if a.MaintenanceMode == "" {
a.MaintenanceMode = "online"
}
s.data.Agents[id] = a
}
}
func (s *store) saveLocked() error {
persistErr := s.persistErr
s.persistErr = nil
if err := s.backend.Save(context.Background(), s.data); err != nil {
return err
}
return persistErr
}
func (s *store) close() error { return s.backend.Close() }
func (s *store) kind() string { return s.backend.Kind() }
func (s *store) appendAuditLocked(e model.AuditEntry) {
s.data.Audit = append(s.data.Audit, e)
if len(s.data.Audit) > 10000 {
s.data.Audit = append([]model.AuditEntry(nil), s.data.Audit[len(s.data.Audit)-10000:]...)
}
if err := s.backend.AppendAudit(context.Background(), e, 10000); err != nil && s.persistErr == nil {
s.persistErr = err
}
}
func (s *store) appendHistoryLocked(e model.SessionHistoryEvent, limit int) {
s.data.SessionHistory = append(s.data.SessionHistory, e)
if limit > 0 && len(s.data.SessionHistory) > limit {
s.data.SessionHistory = append([]model.SessionHistoryEvent(nil), s.data.SessionHistory[len(s.data.SessionHistory)-limit:]...)
}
if err := s.backend.AppendHistory(context.Background(), e, limit); err != nil && s.persistErr == nil {
s.persistErr = err
}
}
func (s *store) saveLocked() error { return config.SaveJSON(s.path, s.data) }
func (s *store) all() []model.AgentRecord {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -55,3 +152,312 @@ func (s *store) get(id string) (model.AgentRecord, bool) {
a.TokenHash = ""
return a, ok
}
func (s *store) audit(limit int) []model.AuditEntry {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 1000 {
limit = 300
}
start := len(s.data.Audit) - limit
if start < 0 {
start = 0
}
return append([]model.AuditEntry(nil), s.data.Audit[start:]...)
}
func (s *store) history(limit int) []model.SessionHistoryEvent {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 5000 {
limit = 1000
}
start := len(s.data.SessionHistory) - limit
if start < 0 {
start = 0
}
return append([]model.SessionHistoryEvent(nil), s.data.SessionHistory[start:]...)
}
type jsonPersistence struct{ path string }
func (j *jsonPersistence) Kind() string { return "json" }
func (j *jsonPersistence) Close() error { return nil }
func (j *jsonPersistence) Load(_ context.Context, out *data) error {
b, err := os.ReadFile(j.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
return json.Unmarshal(b, out)
}
func (j *jsonPersistence) Save(_ context.Context, d data) error { return config.SaveJSON(j.path, d) }
func (j *jsonPersistence) AppendAudit(context.Context, model.AuditEntry, int) error { return nil }
func (j *jsonPersistence) AppendHistory(context.Context, model.SessionHistoryEvent, int) error {
return nil
}
type postgresPersistence struct {
db *sql.DB
leaderConn *sql.Conn
historyLimit int
}
func newPostgresPersistence(ctx context.Context, dsn string, historyLimit int) (*postgresPersistence, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(c); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
// v0.3 persists a single mutable control-plane state document. Protect it
// from accidental active/active masters until a future HA design moves all
// mutable coordination to transactionally independent database rows.
leaderConn, err := db.Conn(c)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres leader connection: %w", err)
}
const masterLockID int64 = 0x534755415244 // "SGUARD"
var leader bool
if err := leaderConn.QueryRowContext(c, `SELECT pg_try_advisory_lock($1)`, masterLockID).Scan(&leader); err != nil {
_ = leaderConn.Close()
_ = db.Close()
return nil, fmt.Errorf("postgres master lock: %w", err)
}
if !leader {
_ = leaderConn.Close()
_ = db.Close()
return nil, errors.New("another active SessionGuard master holds the PostgreSQL control-plane lock")
}
stmts := []string{
`CREATE TABLE IF NOT EXISTS sessionguard_state (id integer PRIMARY KEY CHECK (id=1), payload jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS sessionguard_audit (id bigserial PRIMARY KEY, event_time timestamptz NOT NULL, actor text NOT NULL, action text NOT NULL, target text NOT NULL DEFAULT '', result text NOT NULL, payload jsonb NOT NULL)`,
`CREATE INDEX IF NOT EXISTS sessionguard_audit_time_idx ON sessionguard_audit(event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_audit_actor_idx ON sessionguard_audit(actor)`,
`CREATE TABLE IF NOT EXISTS sessionguard_session_history (id bigserial PRIMARY KEY, event_time timestamptz NOT NULL, agent_id text NOT NULL, username text NOT NULL DEFAULT '', sid text NOT NULL DEFAULT '', event text NOT NULL, payload jsonb NOT NULL)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_time_idx ON sessionguard_session_history(event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_user_idx ON sessionguard_session_history(lower(username), event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_agent_idx ON sessionguard_session_history(agent_id, event_time DESC)`,
`CREATE TABLE IF NOT EXISTS sessionguard_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`,
`INSERT INTO sessionguard_migrations(version) VALUES (2) ON CONFLICT DO NOTHING`,
}
for _, q := range stmts {
if _, err := db.ExecContext(c, q); err != nil {
_ = leaderConn.Close()
_ = db.Close()
return nil, fmt.Errorf("postgres schema: %w", err)
}
}
return &postgresPersistence{db: db, leaderConn: leaderConn, historyLimit: historyLimit}, nil
}
func (p *postgresPersistence) Kind() string { return "postgres" }
func (p *postgresPersistence) Close() error {
if p.leaderConn != nil {
_ = p.leaderConn.Close() // releases the session-level advisory lock
}
return p.db.Close()
}
func (p *postgresPersistence) Load(ctx context.Context, out *data) error {
var raw []byte
err := p.db.QueryRowContext(ctx, `SELECT payload FROM sessionguard_state WHERE id=1`).Scan(&raw)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
if err := json.Unmarshal(raw, out); err != nil {
return err
}
}
// Older v0.3 development builds kept history inside the state blob. If the
// normalized tables are empty, migrate those events once before replacing
// the in-memory slices from the dedicated tables.
legacyAudit := append([]model.AuditEntry(nil), out.Audit...)
legacyHistory := append([]model.SessionHistoryEvent(nil), out.SessionHistory...)
if n, _ := p.tableCount(ctx, "sessionguard_audit"); n == 0 && len(legacyAudit) > 0 {
for _, e := range legacyAudit {
if err := p.AppendAudit(ctx, e, 10000); err != nil {
return err
}
}
}
if n, _ := p.tableCount(ctx, "sessionguard_session_history"); n == 0 && len(legacyHistory) > 0 {
for _, e := range legacyHistory {
if err := p.AppendHistory(ctx, e, p.historyLimit); err != nil {
return err
}
}
}
audit, err := p.loadAudit(ctx, 10000)
if err != nil {
return err
}
out.Audit = audit
history, err := p.loadHistory(ctx, p.historyLimit)
if err != nil {
return err
}
out.SessionHistory = history
return nil
}
func (p *postgresPersistence) Save(ctx context.Context, d data) error {
if err := p.ensureLeader(ctx); err != nil {
return err
}
// High-volume append-only data lives in dedicated tables. Keeping it out of
// this JSONB document makes heartbeats O(control-plane-state), not O(history).
d.Audit = nil
d.SessionHistory = nil
raw, err := json.Marshal(d)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = p.db.ExecContext(c, `INSERT INTO sessionguard_state(id,payload,updated_at) VALUES(1,$1,now()) ON CONFLICT(id) DO UPDATE SET payload=EXCLUDED.payload, updated_at=now()`, string(raw))
return err
}
func (p *postgresPersistence) ensureLeader(ctx context.Context) error {
if p.leaderConn == nil {
return errors.New("PostgreSQL control-plane lock connection is unavailable")
}
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var held bool
err := p.leaderConn.QueryRowContext(c, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND pid=pg_backend_pid() AND granted)`).Scan(&held)
if err != nil {
return fmt.Errorf("PostgreSQL control-plane lock check failed: %w", err)
}
if !held {
return errors.New("PostgreSQL control-plane lock was lost; restart the SessionGuard master")
}
return nil
}
func (p *postgresPersistence) AppendAudit(ctx context.Context, e model.AuditEntry, limit int) error {
raw, err := json.Marshal(e)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if _, err = p.db.ExecContext(c, `INSERT INTO sessionguard_audit(event_time,actor,action,target,result,payload) VALUES($1,$2,$3,$4,$5,$6)`, e.Time, e.Actor, e.Action, e.Target, e.Result, string(raw)); err != nil {
return err
}
return p.retain(c, "sessionguard_audit", limit)
}
func (p *postgresPersistence) AppendHistory(ctx context.Context, e model.SessionHistoryEvent, limit int) error {
raw, err := json.Marshal(e)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if _, err = p.db.ExecContext(c, `INSERT INTO sessionguard_session_history(event_time,agent_id,username,sid,event,payload) VALUES($1,$2,$3,$4,$5,$6)`, e.Time, e.AgentID, e.User, e.SID, e.Event, string(raw)); err != nil {
return err
}
return p.retain(c, "sessionguard_session_history", limit)
}
func (p *postgresPersistence) retain(ctx context.Context, table string, limit int) error {
if limit <= 0 {
return nil
}
var q string
switch table {
case "sessionguard_audit":
q = `DELETE FROM sessionguard_audit WHERE id < COALESCE((SELECT id FROM sessionguard_audit ORDER BY id DESC OFFSET ($1 - 1) LIMIT 1),0)`
case "sessionguard_session_history":
q = `DELETE FROM sessionguard_session_history WHERE id < COALESCE((SELECT id FROM sessionguard_session_history ORDER BY id DESC OFFSET ($1 - 1) LIMIT 1),0)`
default:
return errors.New("unsupported retention table")
}
_, err := p.db.ExecContext(ctx, q, limit)
return err
}
func (p *postgresPersistence) loadAudit(ctx context.Context, limit int) ([]model.AuditEntry, error) {
rows, err := p.db.QueryContext(ctx, `SELECT payload FROM sessionguard_audit ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.AuditEntry{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var e model.AuditEntry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
reverseAudit(out)
return out, nil
}
func (p *postgresPersistence) loadHistory(ctx context.Context, limit int) ([]model.SessionHistoryEvent, error) {
if limit <= 0 {
limit = 50000
}
rows, err := p.db.QueryContext(ctx, `SELECT payload FROM sessionguard_session_history ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.SessionHistoryEvent{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var e model.SessionHistoryEvent
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
reverseHistory(out)
return out, nil
}
func (p *postgresPersistence) tableCount(ctx context.Context, table string) (int64, error) {
var q string
switch table {
case "sessionguard_audit":
q = `SELECT count(*) FROM sessionguard_audit`
case "sessionguard_session_history":
q = `SELECT count(*) FROM sessionguard_session_history`
default:
return 0, errors.New("unsupported table")
}
var n int64
err := p.db.QueryRowContext(ctx, q).Scan(&n)
return n, err
}
func reverseAudit(x []model.AuditEntry) {
for i, j := 0, len(x)-1; i < j; i, j = i+1, j-1 {
x[i], x[j] = x[j], x[i]
}
}
func reverseHistory(x []model.SessionHistoryEvent) {
for i, j := 0, len(x)-1; i < j; i, j = i+1, j-1 {
x[i], x[j] = x[j], x[i]
}
}

View File

@@ -1,26 +1,38 @@
package master
const masterHTML = `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1500px;margin:auto;padding:28px}.top{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}.brand{display:flex;gap:12px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:800}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.card,.panel{background:rgba(18,27,49,.85);border:1px solid #263654;border-radius:16px;box-shadow:0 14px 35px rgba(0,0,0,.18)}.card{padding:18px}.value{font-size:28px;font-weight:750;margin-top:7px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:16px 18px;border-bottom:1px solid #263654}.split{display:grid;grid-template-columns:1.05fr 1.45fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:12px 14px;border-bottom:1px solid #22314e;text-align:left;font-size:13px}.table th{color:#94a5c4;font-weight:600}.row{cursor:pointer}.row:hover{background:#182642}.status{display:inline-flex;gap:6px;align-items:center}.dot{width:8px;height:8px;border-radius:50%;background:#5ee08b}.off{background:#65728a}.pill{padding:3px 8px;border-radius:999px;background:#1b2a4a;color:#bfd0ee;font-size:12px}button{background:#5b8cff;color:white;border:0;border-radius:10px;padding:9px 13px;font-weight:650;cursor:pointer}button.secondary{background:#22314e}input,textarea{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px 10px}textarea{min-height:128px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.form{padding:16px;display:grid;gap:12px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.sessions{max-height:270px;overflow:auto}.empty{padding:24px;color:#8190aa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none}@media(max-width:950px){.grid{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}.cols{grid-template-columns:1fr}} </style></head>
<body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div>
<div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktive Sitzungen</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="mCleanup"></div></div></div>
<div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></div><div class="toast" id="toast"></div><script src="/app.js"></script></body></html>`
const masterHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1600px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{display:flex;gap:11px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:900}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.card,.panel{background:rgba(18,27,49,.88);border:1px solid #263654;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:800;margin-top:5px}.split{display:grid;grid-template-columns:minmax(350px,.9fr) minmax(650px,1.6fr);gap:16px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:15px 18px;border-bottom:1px solid #263654}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:10px 12px;border-bottom:1px solid #22314e;text-align:left;font-size:13px;vertical-align:top}.table th{color:#94a5c4}.row{cursor:pointer}.row:hover,.row.selected{background:#182642}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#5ee08b;margin-right:7px}.dot.off{background:#65728a}.form{padding:16px;display:grid;gap:13px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.two{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}label{display:grid;gap:5px;font-size:13px;color:#c8d4e7}input,textarea,select{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px}textarea{min-height:88px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}.check label{display:block}button{background:#5b8cff;color:white;border:0;border-radius:9px;padding:8px 11px;font-weight:650;cursor:pointer}button.secondary{background:#263654}button.danger{background:#743b46}.actions{display:flex;gap:5px;flex-wrap:wrap}.empty{padding:20px;color:#8190aa}.section-title{font-weight:800;padding:12px 16px;border-top:1px solid #263654;border-bottom:1px solid #263654}.content{max-height:320px;overflow:auto}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px}.list{display:grid;gap:10px}.item{border:1px solid #2b3d5c;background:#0f192c;border-radius:12px;padding:12px;display:grid;gap:9px}.item-head{display:flex;justify-content:space-between;align-items:center}.note{padding:10px;border:1px solid #334866;background:#101b31;border-radius:10px;color:#9fb0ca;font-size:12px}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning,.event-warning,.event-dry-run{color:#ffd166}.event-error{color:#ffaaaa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none;z-index:10}@media(max-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.cols,.two{grid-template-columns:1fr}}@media(max-width:650px){.grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console · v0.3 · Broker & Director</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktiv</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="mDisc"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="mProfile"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="mCleanup"></div></div><div class="card"><div class="muted">Farms</div><div class="value" id="mFarms"></div></div><div class="card"><div class="muted">Alerts</div><div class="value" id="mAlerts"></div></div></div><div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div><section class="panel"><h2>Farms & Broker</h2><div id="farms"></div></section><section class="panel"><h2>Published Resources</h2><div id="resources"></div></section><section class="panel"><h2>Session-Historie</h2><div id="history" class="content"></div></section><section class="panel"><h2>Policy-Historie & Rollback</h2><div id="policyHistory" class="content"></div></section><section class="panel"><h2>Alerts</h2><div id="alerts" class="content"></div></section><section class="panel"><h2>Audit-Log</h2><div id="audit" class="content"></div></section></div><div class="toast" id="toast"></div><script src="/app.js"></script></body></html>`
const masterJS = `
let selected=null, current=null;
const $=id=>document.getElementById(id);
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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 age(sec){if(!sec)return '';let d=Math.floor(sec/86400),h=Math.floor(sec%86400/3600),m=Math.floor(sec%3600/60);return d+'d '+h+'h '+m+'m'}
function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',2500)}
async function api(url,opt){let r=await fetch(url,opt);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}
async function refresh(){try{let d=await api('/api/v1/dashboard');let a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Sitzungen</th><th>Build</th></tr></thead><tbody>'+a.map(x=>'<tr class="row" data-id="'+esc(x.id)+'"><td><span class="status"><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.server.os||'')+'</span></td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+esc(x.snapshot.agent_version||'')+'</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id));if(selected)selectAgent(selected,true)}catch(e){toast(e.message)}}
async function selectAgent(id,quiet){selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current)}catch(e){if(!quiet)toast(e.message)}}
function renderDetail(a){let s=a.snapshot.server||{},sessions=a.snapshot.sessions||[],p=a.desired_policy||defaultPolicy(a.snapshot.policy_revision);$('detailTitle').textContent=a.name||a.id;$('detail').className='';$('detail').innerHTML='<div class="grid" style="grid-template-columns:repeat(3,1fr);padding:16px"><div class="card"><div class="muted">Uptime</div><div class="value" style="font-size:20px">'+age(s.uptime_seconds)+'</div></div><div class="card"><div class="muted">RAM frei</div><div class="value" style="font-size:20px">'+bytes(s.memory_available)+' / '+bytes(s.memory_total)+'</div></div><div class="card"><div class="muted">Policy</div><div class="value" style="font-size:15px">'+esc(a.snapshot.policy_revision||'')+'</div></div></div><div class="sessions"><table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</th></tr></thead><tbody>'+sessions.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td><span class="pill">'+esc(x.state)+'</span></td><td>'+esc(x.client_name||'')+'</td></tr>').join('')+'</tbody></table></div>'+policyForm(p)}
function defaultPolicy(rev){return {revision:rev||'',cleanup:{enabled:true,grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,exclude_users:['Administrator','DefaultAccount','WDAGUtilityAccount'],exclude_sids:['S-1-5-18','S-1-5-19','S-1-5-20'],allowed_profile_roots:['C:\\Users']},templates:[]}}
function policyForm(p){let c=p.cleanup||{};return '<div class="form"><strong>Policy bearbeiten</strong><div class="cols"><label>Grace Period (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label for="enabled">Profil-Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label for="dry">Dry-Run (empfohlen zum Testen)</label></div><label>Ausgeschlossene Benutzer (eine Zeile je Eintrag)<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Ausgeschlossene SIDs / SID-Präfixe<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:230px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div style="display:flex;gap:8px"><button onclick="savePolicy(false)">Für diesen Server speichern</button><button class="secondary" onclick="savePolicy(true)">Auf alle Server anwenden</button></div></div>'}
function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
async function savePolicy(all){if(!selected)return;try{let p={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:JSON.parse($('templates').value||'[]')};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)}catch(e){toast(e.message)}}
let selected=null,current=null,editorAgent=null,policyTemplates=[],profileFolders=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));function when(v){if(!v)return'';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
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><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.agent_version||'')+'</span></td><td>'+esc(x.maintenance_mode||'online')+'</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||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];renderFarms();let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];renderResources();let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<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 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 overview(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0,tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<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 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></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)}}
function sessionsHTML(a){let s=a.snapshot||{},ss=s.sessions||[],p=(a.desired_policy&&a.desired_policy.revision)?a.desired_policy:s.policy,control=p&&p.sessions&&p.sessions.control_enabled;return ss.length?'<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Logon / Idle</th><th>Client</th><th>Aktionen</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(when(x.logon_at))+'<br><span class="muted">Idle '+Math.round((x.idle_seconds||0)/60)+' min</span></td><td>'+esc(x.client_name||'')+'<br><span class="muted">'+esc(x.client_address||'')+'</span></td><td>'+(control&&x.user?'<div class="actions"><button class="secondary" data-session="'+x.id+'" data-session-action="message">Nachricht</button><button class="secondary" data-session="'+x.id+'" data-session-action="disconnect">Trennen</button><button class="danger" data-session="'+x.id+'" data-session-action="logoff">Abmelden</button></div>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Benutzersitzungen.</div>'}
function profileHTML(a){let s=a.snapshot||{},jobs=s.profile_jobs||[],st=Object.values(s.profile_status||{});let h=jobs.length?'<table class="table"><thead><tr><th>Operation</th><th>Benutzer</th><th>Fällig</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+when(j.due_at)+'</td><td>'+esc(j.last_error||'')+'</td></tr>').join('')+'</tbody></table>':'';if(st.length)h+='<table class="table"><thead><tr><th>Benutzer</th><th>Backup</th><th>Restore</th><th>Fehler</th></tr></thead><tbody>'+st.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+when(x.last_backup_at)+'</td><td>'+when(x.last_restore_at)+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'')+'</td></tr>').join('')+'</tbody></table>';return h||'<div class="empty">Keine Profil-Jobs/Statusdaten.</div>'}
function processesHTML(a){let ps=((a.snapshot||{}).processes||[]).filter(p=>p.session_id!==0).sort((x,y)=>y.memory_bytes-x.memory_bytes).slice(0,300);return ps.length?'<table class="table"><thead><tr><th>PID</th><th>Session</th><th>Prozess</th><th>RAM</th><th></th></tr></thead><tbody>'+ps.map(p=>'<tr><td>'+p.pid+'</td><td>'+p.session_id+'</td><td>'+esc(p.name)+'</td><td>'+bytes(p.memory_bytes)+'</td><td><button class="danger" data-process="'+p.pid+'" data-action="kill-process">Beenden</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Session-Prozesse erfasst.</div>'}
function telemetryHTML(a){let rows=Object.values(((a.snapshot||{}).telemetry)||{}).sort((x,y)=>new Date(y.first_seen_at)-new Date(x.first_seen_at)).slice(0,100);return rows.length?'<table class="table"><thead><tr><th>Session</th><th>Benutzer</th><th>Logon erkannt</th><th>Restore</th><th>Bis Ready</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+x.session_id+'</td><td>'+esc(x.user||x.sid||'')+'</td><td>'+when(x.logon_at||x.first_seen_at)+'</td><td>'+((x.restore_duration_ms||0)/1000).toFixed(2)+' s</td><td>'+((x.observed_logon_ms||0)/1000).toFixed(2)+' s</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Logon-Telemetrie.</div>'}
function renderHistory(rows){rows=(rows||[]).slice().reverse();$('history').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Server</th><th>Benutzer</th><th>Ereignis</th><th>Client</th><th>Details</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.time)+'</td><td>'+esc(x.hostname)+'</td><td>'+esc(x.user)+'</td><td>'+esc(x.event)+'</td><td>'+esc(x.client_name||'')+'</td><td>'+esc(x.details||'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Session-Historie.</div>'}
function renderAlerts(rows){$('alerts').innerHTML=rows.length?'<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Typ</th><th>Meldung</th><th>Zuletzt</th></tr></thead><tbody>'+rows.map(x=>'<tr><td class="'+(x.active?'bad':'good')+'">'+(x.active?'AKTIV':'gelöst')+'</td><td>'+esc(x.hostname||'')+'</td><td>'+esc(x.type)+'</td><td>'+esc(x.message)+'</td><td>'+when(x.last_seen_at)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Alerts.</div>'}
function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function renderFarms(){let lease=brokerLeases.length?'<h3>Aktive Broker-Leases</h3><table class="table"><thead><tr><th>Benutzer</th><th>Agent</th><th>Farm</th><th>Resource</th><th>Grund</th><th>Bis</th></tr></thead><tbody>'+brokerLeases.map(l=>'<tr><td>'+esc(l.user_key)+'</td><td>'+esc(l.agent_id)+'</td><td>'+esc(l.farm_id||'')+'</td><td>'+esc(l.resource_id||'')+'</td><td>'+esc(l.reason||'')+'</td><td>'+when(l.expires_at)+'</td></tr>').join('')+'</tbody></table>':'';$('farms').innerHTML='<div class="form"><div class="cols"><label>Neue Farm<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc"></label><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label></div><button type="button" data-global-action="create-farm">Farm anlegen</button></div>'+(farmCache.length?'<table class="table"><thead><tr><th>Name</th><th>ID</th><th>Agenten</th><th>Required Tags</th><th></th></tr></thead><tbody>'+farmCache.map(f=>'<tr><td>'+esc(f.name)+'</td><td>'+esc(f.id)+'</td><td>'+esc((f.agent_ids||[]).length)+'</td><td>'+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'</td><td><button class="danger" data-global-action="delete-farm" data-id="'+esc(f.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Farms.</div>')+lease}
function renderResources(){let opts=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');$('resources').innerHTML='<div class="form"><div class="cols"><label>Name<input id="resName" placeholder="Office Desktop"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm">'+opts+'</select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName"></label><label>RemoteApp<input id="resRemoteApp" placeholder="||Sage"></label><label>RemoteApp Working Dir<input id="resRemoteDir"></label><label>RemoteApp Argumente<input id="resRemoteArgs"></label></div><button type="button" data-global-action="create-resource">Resource anlegen</button></div>'+(resourceCache.length?'<table class="table"><thead><tr><th>Name</th><th>Typ</th><th>Farm</th><th>Guacamole</th><th>RemoteApp</th><th></th></tr></thead><tbody>'+resourceCache.map(x=>'<tr><td>'+esc(x.name)+'</td><td>'+esc(x.kind)+'</td><td>'+esc(x.farm_id)+'</td><td>'+esc(x.guacamole_connection_id||x.guacamole_connection_name||'')+'</td><td>'+esc(x.remote_app||'')+'</td><td><button class="danger" data-global-action="delete-resource" data-id="'+esc(x.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Resources.</div>')}
function eventsHTML(a){let e=((a.snapshot||{}).events||[]).slice().reverse().slice(0,160);return e.length?'<table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+e.map(x=>'<tr class="event-'+esc(x.level)+'"><td>'+when(x.time)+'</td><td>'+esc(x.level)+'</td><td>'+esc(x.user||'')+'</td><td>'+esc(x.message)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Ereignisse.</div>'}
function activePolicy(a){if(a.desired_policy&&a.desired_policy.revision)return a.desired_policy;if(a.snapshot&&a.snapshot.policy)return a.snapshot.policy;return{cleanup:{grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,allowed_profile_roots:['C:\\Users']},profiles:{retry_seconds:60,keep_versions:2,folders:[]},sessions:{disconnected_timeout_seconds:3600},templates:[]}}
function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overview')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='<div id="overview"></div><div class="section-title">Sitzungen</div><div id="sessionList" class="content"></div><div class="section-title">Logon Performance</div><div id="telemetryList" class="content"></div><div class="section-title">Prozesse</div><div id="processList" class="content"></div><div class="section-title">Profil-Pipeline</div><div id="profileList" class="content"></div><div class="section-title">Agent-Ereignisse</div><div id="eventList" class="content"></div><div class="section-title">Policy</div><div id="policyEditor">'+policyForm(p,a)+'</div>';editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overview').innerHTML=overview(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)}
function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'<div class="muted">Keine weiteren Angaben.</div>';if(k==='url')return'<label>URL<input data-field="url" value="'+esc(t.url||'')+'"></label>';if(k==='shortcut'){let s=t.shortcut||{};return'<div class="two"><label>Zielprogramm<input data-field="shortcut.target" value="'+esc(s.target||'')+'"></label><label>Argumente<input data-field="shortcut.arguments" value="'+esc(s.arguments||'')+'"></label><label>Arbeitsverzeichnis<input data-field="shortcut.working_directory" value="'+esc(s.working_directory||'')+'"></label><label>Icon<input data-field="shortcut.icon_location" value="'+esc(s.icon_location||'')+'"></label></div><label>Beschreibung<input data-field="shortcut.description" value="'+esc(s.description||'')+'"></label>'}return'<label>Quelldatei / UNC-Pfad<input data-field="source" value="'+esc(t.source||'')+'"></label><div class="two"><label>Inline-Inhalt<textarea data-field="content">'+esc(t.content||'')+'</textarea></label><label>Inline Base64<textarea data-field="content_base64">'+esc(t.content_base64||'')+'</textarea></label></div>'}
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'<div class="item template-card"><div class="item-head"><strong>'+esc(t.id||('Template '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-template" data-index="'+i+'">Löschen</button></div><div class="cols"><label>ID<input data-field="id" value="'+esc(t.id||'')+'"></label><label>Typ<select data-field="kind" data-action="template-kind"><option value="file" '+((t.kind||'file')==='file'?'selected':'')+'>Datei</option><option value="directory" '+(t.kind==='directory'?'selected':'')+'>Ordner</option><option value="url" '+(t.kind==='url'?'selected':'')+'>URL</option><option value="shortcut" '+(t.kind==='shortcut'?'selected':'')+'>.lnk</option></select></label><label>Ziel<input data-field="target" value="'+esc(t.target||'')+'"></label></div><div class="check"><input data-field="overwrite" type="checkbox" '+(t.overwrite?'checked':'')+'><label>Aktualisieren/überschreiben</label></div>'+templateSpecific(t)+'</div>').join(''):'<div class="empty">Keine Templates.</div>'}
function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})}
function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'<div class="item profile-folder"><div class="item-head"><strong>'+esc(f.path||('Ordner '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-profile-folder" data-index="'+i+'">Löschen</button></div><div class="two"><label>Relativer Profilpfad<input data-field="path" value="'+esc(f.path||'')+'"></label><label>Ausschlüsse<textarea data-field="exclude">'+esc((f.exclude_globs||[]).join('\n'))+'</textarea></label></div></div>').join(''):'<div class="empty">Keine Profilordner.</div>'}
function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)}
function policyForm(p,a){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{},source=a.desired_policy&&a.desired_policy.revision?'Master-Soll-Policy':'Agent-Ist-Policy';return'<div class="form"><div class="note">Editor-Basis: '+esc(source)+'. Live-Refresh verändert offene Eingaben nicht.</div><strong>Profil-Sicherung & Wiederherstellung</strong><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="cols"><label>Store Root<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Profil-Sync Benutzer ausschließen<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Profil-Sync SIDs ausschließen<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><strong>Sitzungsrichtlinie</strong><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Disconnect-Timeout (s)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Auto-Logoff Benutzer ausschließen<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Auto-Logoff SIDs ausschließen<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label><strong>Profil-Cleanup</strong><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div><div class="actions"><button type="button" data-action="save-one">Für diesen Server speichern</button><button type="button" class="secondary" data-action="save-all">Auf alle anwenden</button><button type="button" class="secondary" data-action="reload-policy">Editor neu laden</button></div></div>'}
async function bulkSessionAction(action,scope){if(!selected)return;let body={action,scope};if(action==='message'){let m=prompt('Nachricht an alle Benutzersitzungen:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Die ausgewählten Sitzungen wirklich abmelden? Die Profil-Pipeline läuft anschließend.'))return;try{let r=await api('/api/v1/agents/'+encodeURIComponent(selected)+'/sessions/bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast((r.queued||0)+' Sitzungsaktionen eingeplant')}catch(e){toast(e.message)}}
async function saveAgentControl(){if(!selected)return;let tags={};($('agentTags').value||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)tags[x.slice(0,i).trim()]=x.slice(i+1).trim()});let body={mode:$('agentMode').value,restart_when_drained:$('restartDrained').checked,tags,farm_ids:$('agentFarms').value.split('\n').map(x=>x.trim()).filter(Boolean)};try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/control',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Serversteuerung gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
async function killProcess(pid){if(!selected||!confirm('Prozess '+pid+' wirklich beenden?'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/processes/'+pid+'/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});toast('Prozess-Beenden an Agent gesendet')}catch(e){toast(e.message)}}
async function savePolicy(all){if(!selected)return;try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api(all?'/api/v1/policy/all':'/api/v1/agents/'+encodeURIComponent(selected)+'/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});toast(all?'Policy auf alle Server angewendet':'Policy gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='create-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;await api('/api/v1/resources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),enabled:true})});toast('Resource angelegt');await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
refresh();setInterval(refresh,5000);`

View File

@@ -2,7 +2,7 @@ package model
import "time"
const ProtocolVersion = 1
const ProtocolVersion = 3
type OIDCConfig struct {
Issuer string `json:"issuer"`
@@ -13,6 +13,35 @@ type OIDCConfig struct {
SecureCookie bool `json:"secure_cookie"`
}
type RBACConfig struct {
DefaultRole string `json:"default_role,omitempty"`
Groups map[string][]string `json:"groups,omitempty"`
}
type BrokerConfig struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key,omitempty"`
LeaseSeconds int `json:"lease_seconds"`
MinHealthScore int `json:"min_health_score"`
ReconnectExisting bool `json:"reconnect_existing"`
SingleSession bool `json:"single_session_per_user"`
DefaultFarmID string `json:"default_farm_id,omitempty"`
}
type AlertConfig struct {
Enabled bool `json:"enabled"`
WebhookURL string `json:"webhook_url,omitempty"`
CPUPercent int `json:"cpu_percent"`
MemoryPercent int `json:"memory_percent"`
DiskFreeGB int `json:"disk_free_gb"`
HealthScore int `json:"health_score"`
OfflineSeconds int `json:"offline_seconds"`
ProfileFailures int `json:"profile_failures"`
DisconnectedSessions int `json:"disconnected_sessions"`
LogonDurationSeconds int `json:"logon_duration_seconds"`
NotificationMinInterval int `json:"notification_min_interval_seconds"`
}
type CleanupPolicy struct {
Enabled bool `json:"enabled"`
GraceSeconds int `json:"grace_seconds"`
@@ -24,6 +53,33 @@ type CleanupPolicy struct {
AllowedProfileRoots []string `json:"allowed_profile_roots,omitempty"`
}
type ProfileFolder struct {
Path string `json:"path"`
ExcludeGlobs []string `json:"exclude_globs,omitempty"`
}
type ProfilePolicy struct {
Enabled bool `json:"enabled"`
StoreRoot string `json:"store_root,omitempty"`
BackupOnLogoff bool `json:"backup_on_logoff"`
RestoreOnLogon bool `json:"restore_on_logon"`
BackupDelaySeconds int `json:"backup_delay_seconds"`
RetrySeconds int `json:"retry_seconds"`
RestoreWindowSeconds int `json:"restore_window_seconds"`
KeepVersions int `json:"keep_versions"`
ExcludeUsers []string `json:"exclude_users,omitempty"`
ExcludeSIDs []string `json:"exclude_sids,omitempty"`
Folders []ProfileFolder `json:"folders,omitempty"`
}
type SessionPolicy struct {
ControlEnabled bool `json:"control_enabled"`
DisconnectedLogoffEnabled bool `json:"disconnected_logoff_enabled"`
DisconnectedTimeoutSeconds int `json:"disconnected_timeout_seconds"`
ExcludeUsers []string `json:"exclude_users,omitempty"`
ExcludeSIDs []string `json:"exclude_sids,omitempty"`
}
type ShortcutSpec struct {
Target string `json:"target"`
Arguments string `json:"arguments,omitempty"`
@@ -34,7 +90,7 @@ type ShortcutSpec struct {
type TemplateItem struct {
ID string `json:"id"`
Kind string `json:"kind"` // file, directory, url, shortcut
Kind string `json:"kind"`
Target string `json:"target"`
Source string `json:"source,omitempty"`
Content string `json:"content,omitempty"`
@@ -48,27 +104,73 @@ type Policy struct {
Revision string `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
Cleanup CleanupPolicy `json:"cleanup"`
Profiles ProfilePolicy `json:"profiles"`
Sessions SessionPolicy `json:"sessions"`
Templates []TemplateItem `json:"templates,omitempty"`
}
type Session struct {
ID uint32 `json:"id"`
State string `json:"state"`
User string `json:"user,omitempty"`
Domain string `json:"domain,omitempty"`
SID string `json:"sid,omitempty"`
ClientName string `json:"client_name,omitempty"`
StationName string `json:"station_name,omitempty"`
ID uint32 `json:"id"`
State string `json:"state"`
User string `json:"user,omitempty"`
Domain string `json:"domain,omitempty"`
SID string `json:"sid,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientAddress string `json:"client_address,omitempty"`
StationName string `json:"station_name,omitempty"`
LogonAt *time.Time `json:"logon_at,omitempty"`
ConnectAt *time.Time `json:"connect_at,omitempty"`
LastInputAt *time.Time `json:"last_input_at,omitempty"`
DisconnectedSince *time.Time `json:"disconnected_since,omitempty"`
IdleSeconds int64 `json:"idle_seconds,omitempty"`
}
type ProcessInfo struct {
PID uint32 `json:"pid"`
SessionID uint32 `json:"session_id"`
Name string `json:"name"`
UserSID string `json:"user_sid,omitempty"`
MemoryBytes uint64 `json:"memory_bytes,omitempty"`
}
type ServerInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
UptimeSeconds uint64 `json:"uptime_seconds"`
MemoryTotal uint64 `json:"memory_total"`
MemoryAvailable uint64 `json:"memory_available"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
UptimeSeconds uint64 `json:"uptime_seconds"`
MemoryTotal uint64 `json:"memory_total"`
MemoryAvailable uint64 `json:"memory_available"`
CPUPercent float64 `json:"cpu_percent,omitempty"`
DiskTotal uint64 `json:"disk_total,omitempty"`
DiskFree uint64 `json:"disk_free,omitempty"`
}
type HealthCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
type HealthStatus struct {
Score int `json:"score"`
ProfileStoreOK bool `json:"profile_store_ok"`
RDPListenerOK bool `json:"rdp_listener_ok"`
Checks []HealthCheck `json:"checks,omitempty"`
CalculatedAt time.Time `json:"calculated_at"`
}
type SessionTelemetry struct {
SessionID uint32 `json:"session_id"`
SID string `json:"sid,omitempty"`
User string `json:"user,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LogonAt time.Time `json:"logon_at,omitempty"`
RestoreStartedAt time.Time `json:"restore_started_at,omitempty"`
RestoreFinishedAt time.Time `json:"restore_finished_at,omitempty"`
ReadyAt time.Time `json:"ready_at,omitempty"`
RestoreDurationMS int64 `json:"restore_duration_ms,omitempty"`
ObservedLogonMS int64 `json:"observed_logon_ms,omitempty"`
}
type CleanupJob struct {
@@ -80,26 +182,198 @@ type CleanupJob struct {
LastError string `json:"last_error,omitempty"`
}
type ProfileJob struct {
ID string `json:"id"`
Operation string `json:"operation"`
SID string `json:"sid"`
User string `json:"user"`
SessionID uint32 `json:"session_id,omitempty"`
ProfilePath string `json:"profile_path"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
DueAt time.Time `json:"due_at"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
}
type ProfileStatus struct {
SID string `json:"sid"`
User string `json:"user,omitempty"`
LastBackupAt time.Time `json:"last_backup_at,omitempty"`
LastRestoreAt time.Time `json:"last_restore_at,omitempty"`
LastBackupError string `json:"last_backup_error,omitempty"`
LastRestoreError string `json:"last_restore_error,omitempty"`
}
type AgentEvent struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Type string `json:"type"`
User string `json:"user,omitempty"`
Message string `json:"message"`
}
type SessionCommand struct {
ID string `json:"id"`
Action string `json:"action"`
SessionID uint32 `json:"session_id,omitempty"`
PID uint32 `json:"pid,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
RequestedBy string `json:"requested_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type CommandResult struct {
ID string `json:"id"`
Action string `json:"action"`
SessionID uint32 `json:"session_id,omitempty"`
PID uint32 `json:"pid,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type SessionActionRequest struct {
Action string `json:"action"`
PID uint32 `json:"pid,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
type AuditEntry struct {
Time time.Time `json:"time"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Result string `json:"result"`
Details string `json:"details,omitempty"`
}
type AgentSnapshot struct {
ProtocolVersion int `json:"protocol_version"`
AgentID string `json:"agent_id"`
Server ServerInfo `json:"server"`
Sessions []Session `json:"sessions"`
PendingCleanup []CleanupJob `json:"pending_cleanup,omitempty"`
PolicyRevision string `json:"policy_revision"`
AgentVersion string `json:"agent_version"`
Time time.Time `json:"time"`
ProtocolVersion int `json:"protocol_version"`
AgentID string `json:"agent_id"`
Server ServerInfo `json:"server"`
Health HealthStatus `json:"health"`
Sessions []Session `json:"sessions"`
Processes []ProcessInfo `json:"processes,omitempty"`
Telemetry map[uint32]SessionTelemetry `json:"telemetry,omitempty"`
PendingCleanup []CleanupJob `json:"pending_cleanup,omitempty"`
ProfileJobs []ProfileJob `json:"profile_jobs,omitempty"`
ProfileStatus map[string]ProfileStatus `json:"profile_status,omitempty"`
Events []AgentEvent `json:"events,omitempty"`
CommandResults []CommandResult `json:"command_results,omitempty"`
Policy Policy `json:"policy"`
PolicyRevision string `json:"policy_revision"`
AgentVersion string `json:"agent_version"`
Time time.Time `json:"time"`
}
type AgentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
TokenHash string `json:"token_hash"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeen time.Time `json:"last_seen"`
Snapshot AgentSnapshot `json:"snapshot"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
TokenHash string `json:"token_hash"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeen time.Time `json:"last_seen"`
Snapshot AgentSnapshot `json:"snapshot"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
PendingCommands []SessionCommand `json:"pending_commands,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
FarmIDs []string `json:"farm_ids,omitempty"`
MaintenanceMode string `json:"maintenance_mode,omitempty"` // online, drain, maintenance
RestartWhenDrained bool `json:"restart_when_drained,omitempty"`
}
type Farm struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
AgentIDs []string `json:"agent_ids,omitempty"`
RequiredTags map[string]string `json:"required_tags,omitempty"`
Policy *Policy `json:"policy,omitempty"`
Enabled bool `json:"enabled"`
}
type Resource struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // desktop, remoteapp
FarmID string `json:"farm_id"`
GuacamoleConnectionID string `json:"guacamole_connection_id,omitempty"`
GuacamoleConnectionName string `json:"guacamole_connection_name,omitempty"`
RemoteApp string `json:"remote_app,omitempty"`
RemoteAppDir string `json:"remote_app_dir,omitempty"`
RemoteAppArgs string `json:"remote_app_args,omitempty"`
Enabled bool `json:"enabled"`
}
type UserLease struct {
UserKey string `json:"user_key"`
AgentID string `json:"agent_id"`
FarmID string `json:"farm_id,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Reason string `json:"reason,omitempty"`
}
type BrokerRequest struct {
Username string `json:"username"`
ConnectionID string `json:"connection_id,omitempty"`
ConnectionName string `json:"connection_name,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
FarmID string `json:"farm_id,omitempty"`
}
type BrokerResponse struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
FarmID string `json:"farm_id,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
Reconnect bool `json:"reconnect"`
Reason string `json:"reason"`
HealthScore int `json:"health_score"`
Tokens map[string]string `json:"tokens"`
LeaseExpires time.Time `json:"lease_expires"`
}
type SessionHistoryEvent struct {
Time time.Time `json:"time"`
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
SessionID uint32 `json:"session_id"`
User string `json:"user,omitempty"`
SID string `json:"sid,omitempty"`
Event string `json:"event"`
State string `json:"state,omitempty"`
ClientName string `json:"client_name,omitempty"`
Details string `json:"details,omitempty"`
}
type PolicyVersion struct {
ID string `json:"id"`
Target string `json:"target"`
Revision string `json:"revision"`
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"`
Policy Policy `json:"policy"`
}
type Alert struct {
ID string `json:"id"`
Key string `json:"key"`
Severity string `json:"severity"`
AgentID string `json:"agent_id,omitempty"`
Hostname string `json:"hostname,omitempty"`
Type string `json:"type"`
Message string `json:"message"`
Active bool `json:"active"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt time.Time `json:"resolved_at,omitempty"`
LastNotifiedAt time.Time `json:"last_notified_at,omitempty"`
}
type EnrollRequest struct {
@@ -114,6 +388,7 @@ type EnrollResponse struct {
}
type HeartbeatResponse struct {
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
ServerTime time.Time `json:"server_time"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
Commands []SessionCommand `json:"commands,omitempty"`
ServerTime time.Time `json:"server_time"`
}

383
internal/profile/sync.go Normal file
View File

@@ -0,0 +1,383 @@
package profile
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/example/sessionguard/internal/model"
)
type Stats struct {
Files int `json:"files"`
Dirs int `json:"dirs"`
Bytes int64 `json:"bytes"`
}
type Manifest struct {
Version int `json:"version"`
SID string `json:"sid"`
User string `json:"user"`
CreatedAt time.Time `json:"created_at"`
Folders []model.ProfileFolder `json:"folders"`
Stats Stats `json:"stats"`
}
func Backup(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int) (Stats, error) {
return BackupGuarded(profileRoot, storeRoot, sid, user, folders, keepVersions, nil)
}
// BackupGuarded copies a complete staging snapshot and calls activationGuard immediately
// before replacing current. A guard failure leaves the previous current snapshot untouched.
func BackupGuarded(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int, activationGuard func() error) (Stats, error) {
var total Stats
if strings.TrimSpace(storeRoot) == "" {
return total, errors.New("profile store_root is empty")
}
if strings.TrimSpace(sid) == "" {
return total, errors.New("profile SID is empty")
}
if len(folders) == 0 {
return total, errors.New("no profile folders configured")
}
userRoot := filepath.Join(storeRoot, safeSID(sid))
if err := os.MkdirAll(userRoot, 0o700); err != nil {
return total, fmt.Errorf("create profile store: %w", err)
}
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
staging := filepath.Join(userRoot, ".staging-"+stamp)
if err := os.MkdirAll(staging, 0o700); err != nil {
return total, err
}
ok := false
defer func() {
if !ok {
_ = os.RemoveAll(staging)
}
}()
for _, folder := range folders {
rel, err := cleanRelative(folder.Path)
if err != nil {
return total, fmt.Errorf("profile folder %q: %w", folder.Path, err)
}
src := filepath.Join(profileRoot, rel)
dst := filepath.Join(staging, rel)
st, err := copyTree(src, dst, folder.ExcludeGlobs)
if errors.Is(err, os.ErrNotExist) {
// A configured application folder may legitimately not exist for every user.
continue
}
if err != nil {
return total, fmt.Errorf("backup %q: %w", folder.Path, err)
}
total.Files += st.Files
total.Dirs += st.Dirs
total.Bytes += st.Bytes
}
manifest := Manifest{Version: 1, SID: sid, User: user, CreatedAt: time.Now().UTC(), Folders: folders, Stats: total}
b, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return total, err
}
if err := os.WriteFile(filepath.Join(staging, ".sessionguard-manifest.json"), b, 0o600); err != nil {
return total, fmt.Errorf("write manifest: %w", err)
}
if activationGuard != nil {
if err := activationGuard(); err != nil {
return total, fmt.Errorf("snapshot activation guard: %w", err)
}
}
current := filepath.Join(userRoot, "current")
archive := ""
if _, err := os.Stat(current); err == nil {
if keepVersions > 0 {
history := filepath.Join(userRoot, "history")
if err := os.MkdirAll(history, 0o700); err != nil {
return total, err
}
archive = filepath.Join(history, stamp)
if err := os.Rename(current, archive); err != nil {
return total, fmt.Errorf("archive previous profile snapshot: %w", err)
}
} else {
// Keep a temporary rollback copy until the new snapshot is active.
archive = filepath.Join(userRoot, ".previous-"+stamp)
if err := os.Rename(current, archive); err != nil {
return total, fmt.Errorf("stage previous profile snapshot: %w", err)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return total, err
}
if err := os.Rename(staging, current); err != nil {
if archive != "" {
_ = os.Rename(archive, current)
}
return total, fmt.Errorf("activate profile snapshot: %w", err)
}
if keepVersions == 0 && archive != "" {
_ = os.RemoveAll(archive)
}
ok = true
if keepVersions > 0 {
if err := pruneHistory(filepath.Join(userRoot, "history"), keepVersions); err != nil {
return total, fmt.Errorf("prune profile history: %w", err)
}
}
return total, nil
}
func Restore(profileRoot, storeRoot, sid string, folders []model.ProfileFolder) (Stats, bool, error) {
var total Stats
if strings.TrimSpace(storeRoot) == "" {
return total, false, errors.New("profile store_root is empty")
}
current := filepath.Join(storeRoot, safeSID(sid), "current")
if _, err := os.Stat(current); errors.Is(err, os.ErrNotExist) {
return total, false, nil
} else if err != nil {
return total, false, err
}
for _, folder := range folders {
rel, err := cleanRelative(folder.Path)
if err != nil {
return total, true, fmt.Errorf("profile folder %q: %w", folder.Path, err)
}
src := filepath.Join(current, rel)
dst := filepath.Join(profileRoot, rel)
st, err := copyTree(src, dst, folder.ExcludeGlobs)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return total, true, fmt.Errorf("restore %q: %w", folder.Path, err)
}
total.Files += st.Files
total.Dirs += st.Dirs
total.Bytes += st.Bytes
}
return total, true, nil
}
func cleanRelative(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "" {
return "", errors.New("path is empty")
}
// Normalize both separators so validation is identical on Windows and in tests.
normalized := strings.ReplaceAll(v, `\`, "/")
if strings.HasPrefix(normalized, "/") || strings.Contains(normalized, ":") {
return "", errors.New("path must be relative to the user profile")
}
cleanSlash := path.Clean(normalized)
if cleanSlash == "." || cleanSlash == ".." || strings.HasPrefix(cleanSlash, "../") {
return "", errors.New("path escapes the user profile")
}
return filepath.FromSlash(cleanSlash), nil
}
func safeSID(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
func copyTree(src, dst string, excludes []string) (Stats, error) {
var stats Stats
info, err := os.Lstat(src)
if err != nil {
return stats, err
}
if info.Mode()&os.ModeSymlink != 0 {
return stats, fmt.Errorf("refusing symbolic link/reparse-point root %s", src)
}
if !info.IsDir() {
if err := copyFile(src, dst, info); err != nil {
return stats, err
}
stats.Files = 1
stats.Bytes = info.Size()
return stats, nil
}
root := src
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if rel == "." {
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
return err
}
stats.Dirs++
return nil
}
relSlash := filepath.ToSlash(rel)
if excluded(relSlash, excludes) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.Type()&os.ModeSymlink != 0 {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
fi, err := d.Info()
if err != nil {
return err
}
if err := os.MkdirAll(target, fi.Mode().Perm()); err != nil {
return err
}
stats.Dirs++
return nil
}
fi, err := d.Info()
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return nil
}
if err := copyFile(p, target, fi); err != nil {
return err
}
stats.Files++
stats.Bytes += fi.Size()
return nil
})
return stats, err
}
func copyFile(src, dst string, info os.FileInfo) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".sessionguard-tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
_ = os.Chtimes(tmp, info.ModTime(), info.ModTime())
if err := replaceFile(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// replaceFile avoids the destructive "remove destination and hope Rename works"
// pattern that is especially risky on Windows. If a destination exists it is first
// moved aside; a failed activation restores the previous file.
func replaceFile(tmp, dst string) error {
if _, err := os.Lstat(dst); errors.Is(err, os.ErrNotExist) {
return os.Rename(tmp, dst)
} else if err != nil {
return err
}
old := dst + ".sessionguard-old"
_ = os.Remove(old)
if err := os.Rename(dst, old); err != nil {
return fmt.Errorf("stage existing destination: %w", err)
}
if err := os.Rename(tmp, dst); err != nil {
if rollbackErr := os.Rename(old, dst); rollbackErr != nil {
return fmt.Errorf("activate replacement: %v; rollback failed: %w", err, rollbackErr)
}
return fmt.Errorf("activate replacement: %w", err)
}
if err := os.Remove(old); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove replaced destination: %w", err)
}
return nil
}
func excluded(rel string, patterns []string) bool {
rel = strings.TrimPrefix(filepath.ToSlash(rel), "./")
for _, raw := range patterns {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
if p == "" {
continue
}
if strings.HasSuffix(p, "/**") {
prefix := strings.TrimSuffix(p, "/**")
if rel == prefix || strings.HasPrefix(rel, prefix+"/") {
return true
}
continue
}
if ok, _ := path.Match(p, rel); ok {
return true
}
}
return false
}
func pruneHistory(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(names)))
if keep < 0 {
keep = 0
}
if keep >= len(names) {
return nil
}
for _, name := range names[keep:] {
if err := os.RemoveAll(filepath.Join(dir, name)); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,129 @@
package profile
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/example/sessionguard/internal/model"
)
func TestBackupRestoreAndExclude(t *testing.T) {
root := t.TempDir()
profileA := filepath.Join(root, "profile-a")
store := filepath.Join(root, "store")
profileB := filepath.Join(root, "profile-b")
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "settings.json"), "v1")
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "Cache", "skip.bin"), "skip")
folders := []model.ProfileFolder{{Path: "AppData/Roaming/Example", ExcludeGlobs: []string{"Cache/**"}}}
st, err := Backup(profileA, store, "S-1-5-21-test", `DOMAIN\user`, folders, 2)
if err != nil {
t.Fatal(err)
}
if st.Files != 1 {
t.Fatalf("expected 1 file, got %+v", st)
}
st, found, err := Restore(profileB, store, "S-1-5-21-test", folders)
if err != nil || !found || st.Files != 1 {
t.Fatalf("restore: found=%v stats=%+v err=%v", found, st, err)
}
b, err := os.ReadFile(filepath.Join(profileB, "AppData", "Roaming", "Example", "settings.json"))
if err != nil || string(b) != "v1" {
t.Fatalf("restored content = %q, err=%v", b, err)
}
if _, err := os.Stat(filepath.Join(profileB, "AppData", "Roaming", "Example", "Cache", "skip.bin")); !os.IsNotExist(err) {
t.Fatalf("excluded cache restored unexpectedly: %v", err)
}
}
func TestCleanRelativeRejectsEscape(t *testing.T) {
for _, v := range []string{"../x", `..\\x`, `/etc`, `C:\\Users\\x`, ""} {
if _, err := cleanRelative(v); err == nil {
t.Fatalf("expected %q to be rejected", v)
}
}
}
func mustWrite(t *testing.T, p, s string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRestoreOverwritesSnapshotFilesButKeepsLocalExtras(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
store := filepath.Join(root, "store")
dest := filepath.Join(root, "dest")
folders := []model.ProfileFolder{{Path: "Desktop"}}
mustWrite(t, filepath.Join(source, "Desktop", "managed.txt"), "from-backup")
if _, err := Backup(source, store, "S-1-5-21-merge", `DOMAIN\\user`, folders, 0); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(dest, "Desktop", "managed.txt"), "local-old")
mustWrite(t, filepath.Join(dest, "Desktop", "local-only.txt"), "keep-me")
if _, found, err := Restore(dest, store, "S-1-5-21-merge", folders); err != nil || !found {
t.Fatalf("restore found=%v err=%v", found, err)
}
b, err := os.ReadFile(filepath.Join(dest, "Desktop", "managed.txt"))
if err != nil || string(b) != "from-backup" {
t.Fatalf("managed content=%q err=%v", b, err)
}
b, err = os.ReadFile(filepath.Join(dest, "Desktop", "local-only.txt"))
if err != nil || string(b) != "keep-me" {
t.Fatalf("local-only content=%q err=%v", b, err)
}
}
func TestBackupHistoryRetention(t *testing.T) {
root := t.TempDir()
profileRoot := filepath.Join(root, "profile")
store := filepath.Join(root, "store")
folders := []model.ProfileFolder{{Path: "Desktop"}}
for i, content := range []string{"one", "two", "three", "four"} {
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), content)
if _, err := Backup(profileRoot, store, "S-1-5-21-history", `DOMAIN\\user`, folders, 2); err != nil {
t.Fatalf("backup %d: %v", i, err)
}
time.Sleep(2 * time.Millisecond)
}
history := filepath.Join(store, "S-1-5-21-history", "history")
entries, err := os.ReadDir(history)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("history entries=%d, want 2", len(entries))
}
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-history", "current", "Desktop", "version.txt"))
if err != nil || string(b) != "four" {
t.Fatalf("current content=%q err=%v", b, err)
}
}
func TestBackupActivationGuardKeepsPreviousCurrent(t *testing.T) {
root := t.TempDir()
profileRoot := filepath.Join(root, "profile")
store := filepath.Join(root, "store")
folders := []model.ProfileFolder{{Path: "Desktop"}}
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "old")
if _, err := Backup(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "new")
if _, err := BackupGuarded(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1, func() error {
return errors.New("user became active")
}); err == nil {
t.Fatal("expected activation guard error")
}
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-guard", "current", "Desktop", "version.txt"))
if err != nil || string(b) != "old" {
t.Fatalf("current snapshot changed despite guard: %q err=%v", b, err)
}
}

View File

@@ -9,8 +9,14 @@ import (
var ErrUnsupported = errors.New("Windows functionality is only available on Windows")
func Sessions() ([]model.Session, error) { return nil, ErrUnsupported }
func Server() (model.ServerInfo, error) { return model.ServerInfo{}, ErrUnsupported }
func ProfilePath(string) (string, error) { return "", ErrUnsupported }
func DeleteProfile(string) error { return ErrUnsupported }
func MachineID() (string, error) { return "nonwindows", nil }
func Sessions() ([]model.Session, error) { return nil, ErrUnsupported }
func Processes() ([]model.ProcessInfo, error) { return nil, ErrUnsupported }
func Server() (model.ServerInfo, error) { return model.ServerInfo{}, ErrUnsupported }
func ProfilePath(string) (string, error) { return "", ErrUnsupported }
func DeleteProfile(string) error { return ErrUnsupported }
func LogoffSession(uint32) error { return ErrUnsupported }
func DisconnectSession(uint32) error { return ErrUnsupported }
func SendMessage(uint32, string, string) error { return ErrUnsupported }
func TerminateProcess(uint32) error { return ErrUnsupported }
func RestartServer(string) error { return ErrUnsupported }
func MachineID() (string, error) { return "nonwindows", nil }

View File

@@ -5,8 +5,11 @@ package windowsx
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"github.com/example/sessionguard/internal/model"
@@ -17,15 +20,23 @@ import (
var (
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSEnumerateProcessesW = wtsapi32.NewProc("WTSEnumerateProcessesW")
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSQueryUserToken = wtsapi32.NewProc("WTSQueryUserToken")
procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession")
procWTSDisconnectSession = wtsapi32.NewProc("WTSDisconnectSession")
procWTSSendMessageW = wtsapi32.NewProc("WTSSendMessageW")
userenv = windows.NewLazySystemDLL("userenv.dll")
procDeleteProfileW = userenv.NewProc("DeleteProfileW")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procExpandEnvironmentStringsW = kernel32.NewProc("ExpandEnvironmentStringsW")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
psapi = windows.NewLazySystemDLL("psapi.dll")
procGetProcessMemoryInfo = psapi.NewProc("GetProcessMemoryInfo")
)
type wtsSessionInfo struct {
@@ -33,18 +44,45 @@ type wtsSessionInfo struct {
WinStationName *uint16
State uint32
}
type wtsProcessInfo struct {
SessionID uint32
ProcessID uint32
ProcessName *uint16
UserSID *windows.SID
}
type wtsClientAddress struct {
AddressFamily uint32
Address [20]byte
}
type wtsInfoExLevel1 struct {
SessionID uint32
SessionState uint32
SessionFlags int32
WinStationName [33]uint16
UserName [21]uint16
DomainName [18]uint16
LogonTime int64
ConnectTime int64
DisconnectTime int64
LastInputTime int64
CurrentTime int64
IncomingBytes uint32
OutgoingBytes uint32
IncomingFrames uint32
OutgoingFrames uint32
}
const (
wtsUserName = 5
wtsWinStationName = 6
wtsDomainName = 7
wtsClientName = 10
wtsUserName = 5
wtsWinStationName = 6
wtsDomainName = 7
wtsClientName = 10
wtsClientAddressClass = 14
wtsSessionInfoEx = 25
)
var stateNames = map[uint32]string{
0: "Active", 1: "Connected", 2: "ConnectQuery", 3: "Shadow", 4: "Disconnected",
5: "Idle", 6: "Listen", 7: "Reset", 8: "Down", 9: "Init",
}
var stateNames = map[uint32]string{0: "Active", 1: "Connected", 2: "ConnectQuery", 3: "Shadow", 4: "Disconnected", 5: "Idle", 6: "Listen", 7: "Reset", 8: "Down", 9: "Init"}
func Sessions() ([]model.Session, error) {
var buf uintptr
@@ -70,6 +108,23 @@ func Sessions() ([]model.Session, error) {
if s.StationName == "" {
s.StationName, _ = queryString(row.SessionID, wtsWinStationName)
}
if ca, err := queryClientAddress(row.SessionID); err == nil {
s.ClientAddress = ca
}
if ex, err := querySessionInfoEx(row.SessionID); err == nil {
s.LogonAt = fileTimePtr(ex.LogonTime)
s.ConnectAt = fileTimePtr(ex.ConnectTime)
s.LastInputAt = fileTimePtr(ex.LastInputTime)
if s.State == "Disconnected" {
s.DisconnectedSince = fileTimePtr(ex.DisconnectTime)
}
if s.LastInputAt != nil {
idle := time.Since(*s.LastInputAt)
if idle > 0 {
s.IdleSeconds = int64(idle / time.Second)
}
}
}
if s.User != "" {
var token windows.Token
r, _, _ := procWTSQueryUserToken.Call(uintptr(row.SessionID), uintptr(unsafe.Pointer(&token)))
@@ -98,6 +153,54 @@ func queryString(sessionID uint32, class uintptr) (string, error) {
}
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(p))), nil
}
func queryClientAddress(sessionID uint32) (string, error) {
var p uintptr
var bytes uint32
r, _, e := procWTSQuerySessionInformationW.Call(0, uintptr(sessionID), wtsClientAddressClass, uintptr(unsafe.Pointer(&p)), uintptr(unsafe.Pointer(&bytes)))
if r == 0 {
return "", e
}
defer procWTSFreeMemory.Call(p)
if p == 0 || bytes < uint32(unsafe.Sizeof(wtsClientAddress{})) {
return "", nil
}
a := (*wtsClientAddress)(unsafe.Pointer(p))
if a.AddressFamily == 2 {
return fmt.Sprintf("%d.%d.%d.%d", a.Address[2], a.Address[3], a.Address[4], a.Address[5]), nil
}
return "", nil
}
func querySessionInfoEx(sessionID uint32) (wtsInfoExLevel1, error) {
var out wtsInfoExLevel1
var p uintptr
var bytes uint32
r, _, e := procWTSQuerySessionInformationW.Call(0, uintptr(sessionID), wtsSessionInfoEx, uintptr(unsafe.Pointer(&p)), uintptr(unsafe.Pointer(&bytes)))
if r == 0 {
return out, e
}
defer procWTSFreeMemory.Call(p)
if p == 0 || bytes < 8+uint32(unsafe.Sizeof(out)) {
return out, fmt.Errorf("WTSSessionInfoEx buffer too small")
}
level := *(*uint32)(unsafe.Pointer(p))
if level != 1 {
return out, fmt.Errorf("unsupported WTSSessionInfoEx level %d", level)
}
out = *(*wtsInfoExLevel1)(unsafe.Pointer(p + 8))
return out, nil
}
func fileTimePtr(v int64) *time.Time {
if v <= 0 {
return nil
}
const unixDelta = 116444736000000000
ns := (v - unixDelta) * 100
if ns <= 0 {
return nil
}
t := time.Unix(0, ns).UTC()
return &t
}
type memoryStatusEx struct {
Length uint32
@@ -110,6 +213,36 @@ type memoryStatusEx struct {
AvailVirtual uint64
AvailExtendedVirtual uint64
}
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
var cpuMu sync.Mutex
var prevIdle, prevKernel, prevUser uint64
func ft64(f filetime) uint64 { return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) }
func cpuPercent() float64 {
var idle, kernel, user filetime
r, _, _ := procGetSystemTimes.Call(uintptr(unsafe.Pointer(&idle)), uintptr(unsafe.Pointer(&kernel)), uintptr(unsafe.Pointer(&user)))
if r == 0 {
return 0
}
i, k, u := ft64(idle), ft64(kernel), ft64(user)
cpuMu.Lock()
defer cpuMu.Unlock()
pi, pk, pu := prevIdle, prevKernel, prevUser
prevIdle, prevKernel, prevUser = i, k, u
if pk == 0 {
return 0
}
total := (k - pk) + (u - pu)
if total == 0 {
return 0
}
busy := total - (i - pi)
return float64(busy) * 100 / float64(total)
}
func Server() (model.ServerInfo, error) {
host, _ := os.Hostname()
@@ -119,7 +252,19 @@ func Server() (model.ServerInfo, error) {
return model.ServerInfo{}, fmt.Errorf("GlobalMemoryStatusEx: %w", e)
}
ticks, _, _ := procGetTickCount64.Call()
info := model.ServerInfo{Hostname: host, OS: "Windows", UptimeSeconds: uint64(ticks) / 1000, MemoryTotal: m.TotalPhys, MemoryAvailable: m.AvailPhys}
info := model.ServerInfo{Hostname: host, OS: "Windows", UptimeSeconds: uint64(ticks) / 1000, MemoryTotal: m.TotalPhys, MemoryAvailable: m.AvailPhys, CPUPercent: cpuPercent()}
drive := os.Getenv("SystemDrive")
if drive == "" {
drive = `C:`
}
root, err := windows.UTF16PtrFromString(drive + `\`)
if err == nil {
var avail, total, free uint64
if rr, _, _ := procGetDiskFreeSpaceExW.Call(uintptr(unsafe.Pointer(root)), uintptr(unsafe.Pointer(&avail)), uintptr(unsafe.Pointer(&total)), uintptr(unsafe.Pointer(&free))); rr != 0 {
info.DiskTotal = total
info.DiskFree = free
}
}
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE); err == nil {
defer k.Close()
if v, _, err := k.GetStringValue("ProductName"); err == nil {
@@ -135,6 +280,57 @@ func Server() (model.ServerInfo, error) {
return info, nil
}
func Processes() ([]model.ProcessInfo, error) {
var buf uintptr
var count uint32
r, _, e := procWTSEnumerateProcessesW.Call(0, 0, 1, uintptr(unsafe.Pointer(&buf)), uintptr(unsafe.Pointer(&count)))
if r == 0 {
return nil, fmt.Errorf("WTSEnumerateProcessesW: %w", e)
}
defer procWTSFreeMemory.Call(buf)
rows := unsafe.Slice((*wtsProcessInfo)(unsafe.Pointer(buf)), int(count))
out := make([]model.ProcessInfo, 0, len(rows))
for _, row := range rows {
p := model.ProcessInfo{PID: row.ProcessID, SessionID: row.SessionID}
if row.ProcessName != nil {
p.Name = windows.UTF16PtrToString(row.ProcessName)
}
if row.UserSID != nil {
p.UserSID = row.UserSID.String()
}
p.MemoryBytes = processMemory(row.ProcessID)
out = append(out, p)
}
return out, nil
}
type processMemoryCounters struct {
CB uint32
PageFaultCount uint32
PeakWorkingSetSize uintptr
WorkingSetSize uintptr
QuotaPeakPagedPoolUsage uintptr
QuotaPagedPoolUsage uintptr
QuotaPeakNonPagedPoolUsage uintptr
QuotaNonPagedPoolUsage uintptr
PagefileUsage uintptr
PeakPagefileUsage uintptr
}
func processMemory(pid uint32) uint64 {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return 0
}
defer windows.CloseHandle(h)
m := processMemoryCounters{CB: uint32(unsafe.Sizeof(processMemoryCounters{}))}
r, _, _ := procGetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&m)), uintptr(m.CB))
if r == 0 {
return 0
}
return uint64(m.WorkingSetSize)
}
func ProfilePath(sid string) (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
if err != nil {
@@ -147,7 +343,6 @@ func ProfilePath(sid string) (string, error) {
}
return expandEnv(p), nil
}
func expandEnv(s string) string {
in, err := windows.UTF16PtrFromString(s)
if err != nil {
@@ -164,7 +359,6 @@ func expandEnv(s string) string {
}
return windows.UTF16ToString(buf)
}
func DeleteProfile(sid string) error {
p, err := windows.UTF16PtrFromString(sid)
if err != nil {
@@ -179,7 +373,58 @@ func DeleteProfile(sid string) error {
}
return nil
}
func LogoffSession(id uint32) error {
return boolCallSession(procWTSLogoffSession, "WTSLogoffSession", id)
}
func DisconnectSession(id uint32) error {
return boolCallSession(procWTSDisconnectSession, "WTSDisconnectSession", id)
}
func boolCallSession(p *windows.LazyProc, name string, id uint32) error {
r, _, e := p.Call(0, uintptr(id), 0)
if r == 0 {
if e == syscall.Errno(0) {
return fmt.Errorf("%s(%d) failed", name, id)
}
return fmt.Errorf("%s(%d): %w", name, id, e)
}
return nil
}
func SendMessage(sessionID uint32, title, message string) error {
if strings.TrimSpace(title) == "" {
title = "SessionGuard"
}
t, err := windows.UTF16FromString(title)
if err != nil {
return err
}
m, err := windows.UTF16FromString(message)
if err != nil {
return err
}
var response uint32
r, _, e := procWTSSendMessageW.Call(0, uintptr(sessionID), uintptr(unsafe.Pointer(&t[0])), uintptr((len(t)-1)*2), uintptr(unsafe.Pointer(&m[0])), uintptr((len(m)-1)*2), 0, 60, uintptr(unsafe.Pointer(&response)), 0)
if r == 0 {
if e == syscall.Errno(0) {
return fmt.Errorf("WTSSendMessageW(%d) failed", sessionID)
}
return fmt.Errorf("WTSSendMessageW(%d): %w", sessionID, e)
}
return nil
}
func TerminateProcess(pid uint32) error {
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return err
}
defer windows.CloseHandle(h)
return windows.TerminateProcess(h, 1)
}
func RestartServer(reason string) error {
if strings.TrimSpace(reason) == "" {
reason = "SessionGuard maintenance restart"
}
return exec.Command("shutdown.exe", "/r", "/t", "0", "/d", "p:4:1", "/c", reason).Run()
}
func MachineID() (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE)
if err != nil {

View File

@@ -1,14 +1,47 @@
param(
[string]$Binary = ".\bin\sessionguard-agent.exe",
[string]$Config = ".\configs\agent.json"
[string]$Config = ".\configs\agent.json",
[string]$GmsaAccount = ""
)
$ErrorActionPreference = 'Stop'
$service = 'SessionGuardAgent'
$dest = 'C:\Program Files\SessionGuard'
$data = 'C:\ProgramData\SessionGuard'
New-Item -ItemType Directory -Force -Path $dest,$data | Out-Null
$existing = Get-Service -Name $service -ErrorAction SilentlyContinue
if ($existing -and $existing.Status -ne 'Stopped') {
Stop-Service -Name $service -Force
$existing.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(20))
}
Copy-Item $Binary "$dest\sessionguard-agent.exe" -Force
Copy-Item $Config "$data\agent.json" -Force
icacls "$data\agent.json" /inheritance:r /grant:r 'SYSTEM:(R)' 'Administrators:(F)' | Out-Null
& "$dest\sessionguard-agent.exe" -config "$data\agent.json" -service install
& "$dest\sessionguard-agent.exe" -service start
Write-Host 'SessionGuard Agent installed and started.'
if (Test-Path $Config) {
Copy-Item $Config "$data\agent.json" -Force
} elseif (-not (Test-Path "$data\agent.json")) {
throw "Config '$Config' does not exist and no existing $data\agent.json was found."
}
# Protect credentials, enrollment material and the persisted per-agent token/state.
icacls $data /inheritance:r /grant:r 'SYSTEM:(OI)(CI)(F)' 'Administrators:(OI)(CI)(F)' | Out-Null
if ($GmsaAccount) {
icacls $data /grant "$GmsaAccount`:(OI)(CI)(F)" | Out-Null
}
if (-not $existing) {
& "$dest\sessionguard-agent.exe" -config "$data\agent.json" -service install
}
# Enterprise service defaults: delayed start and automatic recovery after crashes.
& sc.exe config $service start= delayed-auto | Out-Null
& sc.exe failure $service reset= 86400 actions= restart/5000/restart/15000/restart/60000 | Out-Null
& sc.exe failureflag $service 1 | Out-Null
if ($GmsaAccount) {
# gMSA accounts are specified as DOMAIN\name$ and use an empty service password.
& sc.exe config $service obj= $GmsaAccount password= "" | Out-Null
}
Start-Service -Name $service
Write-Host "SessionGuard Agent installed/updated and started."
if ($GmsaAccount) { Write-Host "Service identity: $GmsaAccount" } else { Write-Host "Service identity: LocalSystem" }