diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..2f23416 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 < 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. diff --git a/cmd/agent/platform_windows.go b/cmd/agent/platform_windows.go index 7e28cb6..4007d5a 100644 --- a/cmd/agent/platform_windows.go +++ b/cmd/agent/platform_windows.go @@ -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() diff --git a/configs/agent.example.json b/configs/agent.example.json index d3b0133..2a366ec 100644 --- a/configs/agent.example.json +++ b/configs/agent.example.json @@ -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": [ { diff --git a/configs/master.example.json b/configs/master.example.json index a38c4a1..c505ac1 100644 --- a/configs/master.example.json +++ b/configs/master.example.json @@ -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 } } diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..d907471 --- /dev/null +++ b/deploy/.env.example @@ -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= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 04363dd..e593ef7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/deploy/guacamole/CI-CD.md b/deploy/guacamole/CI-CD.md new file mode 100644 index 0000000..d75300d --- /dev/null +++ b/deploy/guacamole/CI-CD.md @@ -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/:` - SessionGuard Master +2. `git.send.nrw/sendnrw/-guacamole:` - Guacamole with the + SessionGuard broker extension preinstalled +3. `sessionguard-guacamole.jar` in the Gitea Generic Package Registry under + `-guacamole-extension/` + +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. diff --git a/deploy/guacamole/Dockerfile.guacamole b/deploy/guacamole/Dockerfile.guacamole new file mode 100644 index 0000000..25baead --- /dev/null +++ b/deploy/guacamole/Dockerfile.guacamole @@ -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 diff --git a/deploy/guacamole/README.md b/deploy/guacamole/README.md new file mode 100644 index 0000000..143b1c8 --- /dev/null +++ b/deploy/guacamole/README.md @@ -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. diff --git a/deploy/guacamole/docker-compose.sessionguard.example.yml b/deploy/guacamole/docker-compose.sessionguard.example.yml new file mode 100644 index 0000000..0ca54b0 --- /dev/null +++ b/deploy/guacamole/docker-compose.sessionguard.example.yml @@ -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 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..8c0cdea --- /dev/null +++ b/docs/API.md @@ -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 +``` + +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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d5bfd95..36a8b92 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 0–100 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. diff --git a/docs/BROKER.md b/docs/BROKER.md new file mode 100644 index 0000000..cd66026 --- /dev/null +++ b/docs/BROKER.md @@ -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. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md new file mode 100644 index 0000000..fd0a845 --- /dev/null +++ b/docs/DIRECTOR.md @@ -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. diff --git a/docs/GUACAMOLE.md b/docs/GUACAMOLE.md new file mode 100644 index 0000000..f474b00 --- /dev/null +++ b/docs/GUACAMOLE.md @@ -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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..4b1b6c3 --- /dev/null +++ b/docs/OPERATIONS.md @@ -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. diff --git a/docs/PROFILE-LIFECYCLE.md b/docs/PROFILE-LIFECYCLE.md new file mode 100644 index 0000000..895d589 --- /dev/null +++ b/docs/PROFILE-LIFECYCLE.md @@ -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. diff --git a/docs/RBAC.md b/docs/RBAC.md new file mode 100644 index 0000000..c5d81b6 --- /dev/null +++ b/docs/RBAC.md @@ -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`. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..54293cf --- /dev/null +++ b/docs/SECURITY.md @@ -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. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..bee200e --- /dev/null +++ b/docs/TESTING.md @@ -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 diff --git a/go.mod b/go.mod index 80a92a1..b71d43e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 61c996f..0809301 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/guacamole-extension/Dockerfile b/guacamole-extension/Dockerfile new file mode 100644 index 0000000..681ae09 --- /dev/null +++ b/guacamole-extension/Dockerfile @@ -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 diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml new file mode 100644 index 0000000..cfe6947 --- /dev/null +++ b/guacamole-extension/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + info.hilden.sessionguard + sessionguard-guacamole + 0.3.0 + jar + + 11 + UTF-8 + 1.6.0 + + + + org.apache.guacamole + guacamole-ext + ${guacamole.version} + provided + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + ${maven.compiler.release} + + + + diff --git a/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/BrokerClient.java b/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/BrokerClient.java new file mode 100644 index 0000000..8afc8bd --- /dev/null +++ b/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/BrokerClient.java @@ -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 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 parseForm(String input) { + if (input == null || input.isBlank()) return Collections.emptyMap(); + Map 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(); + } +} diff --git a/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/SessionGuardAuthenticationProvider.java b/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/SessionGuardAuthenticationProvider.java new file mode 100644 index 0000000..9494dd8 --- /dev/null +++ b/guacamole-extension/src/main/java/info/hilden/sessionguard/guacamole/SessionGuardAuthenticationProvider.java @@ -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 getTokens(Connection connection) throws GuacamoleException { + return broker.resolve(username, connection.getIdentifier(), connection.getName()); + } + }; + } +} diff --git a/guacamole-extension/src/main/resources/guac-manifest.json b/guacamole-extension/src/main/resources/guac-manifest.json new file mode 100644 index 0000000..915df7a --- /dev/null +++ b/guacamole-extension/src/main/resources/guac-manifest.json @@ -0,0 +1,8 @@ +{ + "guacamoleVersion": "1.6.0", + "name": "SessionGuard Broker", + "namespace": "sessionguard-broker", + "authProviders": [ + "info.hilden.sessionguard.guacamole.SessionGuardAuthenticationProvider" + ] +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0594544..0e102f4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -6,9 +6,12 @@ import ( "encoding/hex" "fmt" "log" + "net" "net/http" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "time" @@ -17,11 +20,12 @@ import ( "github.com/example/sessionguard/internal/config" "github.com/example/sessionguard/internal/httpx" "github.com/example/sessionguard/internal/model" + profilesync "github.com/example/sessionguard/internal/profile" tpl "github.com/example/sessionguard/internal/templates" "github.com/example/sessionguard/internal/windowsx" ) -const Version = "0.1.0" +const Version = "0.3.0" type App struct { cfg config.Agent @@ -32,12 +36,18 @@ type App struct { lastMasterOK time.Time masterErr string client *masterClient + sessionWake chan struct{} + bootstrapped bool } func New(cfg config.Agent) (*App, error) { if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return nil, err } + config.NormalizePolicy(&cfg.Policy) + if err := config.ValidatePolicy(cfg.Policy); err != nil { + return nil, err + } if cfg.Policy.Revision == "" { cfg.Policy.Revision = newRevision() cfg.Policy.UpdatedAt = time.Now().UTC() @@ -46,11 +56,24 @@ func New(cfg config.Agent) (*App, error) { if err != nil { return nil, err } - a := &App{cfg: cfg, store: stateStore{path: statePath(cfg.DataDir)}, state: st, client: newMasterClient(cfg.MasterURL)} - return a, nil + config.NormalizePolicy(&st.Policy) + return &App{cfg: cfg, store: stateStore{path: statePath(cfg.DataDir)}, state: st, client: newMasterClient(cfg.MasterURL), sessionWake: make(chan struct{}, 1)}, nil } -func newRevision() string { b := make([]byte, 12); _, _ = rand.Read(b); return hex.EncodeToString(b) } +func newRevision() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// NotifySessionChange is called by the Windows service handler for SERVICE_CONTROL_SESSIONCHANGE. +// It is deliberately non-blocking: the worker performs the actual WTS/profile work outside the SCM callback. +func (a *App) NotifySessionChange() { + select { + case a.sessionWake <- struct{}{}: + default: + } +} func (a *App) Run(ctx context.Context) error { go a.worker(ctx) @@ -58,18 +81,26 @@ func (a *App) Run(ctx context.Context) error { } func (a *App) worker(ctx context.Context) { - poll := time.NewTicker(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) + poll := time.NewTimer(0) defer poll.Stop() hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second) defer hb.Stop() - a.tick(ctx) - a.sendHeartbeat(ctx) for { select { case <-ctx.Done(): return case <-poll.C: a.tick(ctx) + poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) + case <-a.sessionWake: + a.tick(ctx) + if !poll.Stop() { + select { + case <-poll.C: + default: + } + } + poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) case <-hb.C: a.sendHeartbeat(ctx) } @@ -86,17 +117,82 @@ func (a *App) tick(ctx context.Context) { if err != nil { log.Printf("server info: %v", err) } + processes, processErr := windowsx.Processes() + if processErr != nil { + log.Printf("processes: %v", processErr) + } + health := a.calculateHealth(server) now := time.Now().UTC() + var autoLogoff []model.Session + var templateNow []model.Session a.mu.Lock() - defer a.mu.Unlock() + isBootstrap := !a.bootstrapped currentBySID := map[string]bool{} currentIDs := map[uint32]model.Session{} - for _, s := range sessions { - currentIDs[s.ID] = s + for i := range sessions { + s := &sessions[i] + if s.User == "" { + currentIDs[s.ID] = *s + continue + } + if _, existed := a.state.LastSessions[s.ID]; !existed && !isBootstrap { + t := model.SessionTelemetry{SessionID: s.ID, SID: s.SID, User: displayUser(*s), FirstSeenAt: now} + if s.LogonAt != nil { + t.LogonAt = *s.LogonAt + } + a.state.Telemetry[s.ID] = t + } if s.SID != "" { currentBySID[s.SID] = true delete(a.state.Pending, s.SID) + delete(a.state.ProfileJobs, backupJobID(s.SID)) + } + if s.State == "Disconnected" { + since, ok := a.state.DisconnectedSince[s.ID] + if !ok { + since = now + a.state.DisconnectedSince[s.ID] = since + a.appendEventLocked("info", "session_disconnected", displayUser(*s), fmt.Sprintf("Sitzung %d wurde getrennt", s.ID)) + } + s.DisconnectedSince = &since + sp := a.state.Policy.Sessions + if sp.DisconnectedLogoffEnabled && !a.sessionExcluded(*s) && now.Sub(since) >= time.Duration(sp.DisconnectedTimeoutSeconds)*time.Second { + if _, requested := a.state.AutoLogoffRequested[s.ID]; !requested { + a.state.AutoLogoffRequested[s.ID] = now + autoLogoff = append(autoLogoff, *s) + a.appendEventLocked("warning", "auto_logoff_due", displayUser(*s), fmt.Sprintf("Getrennte Sitzung %d hat das Timeout von %d Sekunden erreicht", s.ID, sp.DisconnectedTimeoutSeconds)) + } + } + } else { + delete(a.state.DisconnectedSince, s.ID) + delete(a.state.AutoLogoffRequested, s.ID) + } + currentIDs[s.ID] = *s + + if s.SID != "" && !a.state.RestoredSessions[s.ID] { + pp := a.state.Policy.Profiles + if isBootstrap { + // Never inject a restore into a session that was already present when the + // service started. Restores are reserved for sessions observed after startup. + a.state.RestoredSessions[s.ID] = true + templateNow = append(templateNow, *s) + } else if pp.Enabled && pp.RestoreOnLogon && !a.profileExcluded(*s) { + if _, exists := a.state.ProfileJobs[restoreJobID(s.ID)]; !exists { + if profilePath, e := windowsx.ProfilePath(s.SID); e == nil { + a.state.ProfileJobs[restoreJobID(s.ID)] = model.ProfileJob{ID: restoreJobID(s.ID), Operation: "restore", SID: s.SID, User: displayUser(*s), SessionID: s.ID, ProfilePath: profilePath, Reason: "logon", CreatedAt: now, DueAt: now} + t := a.state.Telemetry[s.ID] + t.RestoreStartedAt = now + a.state.Telemetry[s.ID] = t + a.appendEventLocked("info", "profile_restore_scheduled", displayUser(*s), fmt.Sprintf("Profil-Wiederherstellung für Sitzung %d eingeplant", s.ID)) + } else { + a.appendEventLocked("error", "profile_restore_schedule_error", displayUser(*s), fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", e)) + } + } + } else { + a.state.RestoredSessions[s.ID] = true + templateNow = append(templateNow, *s) + } } } @@ -104,120 +200,570 @@ func (a *App) tick(ctx context.Context) { if _, exists := currentIDs[id]; exists || prev.SID == "" || currentBySID[prev.SID] { continue } - if _, exists := a.state.Pending[prev.SID]; exists { - continue - } - if a.excluded(prev) { - continue - } - path, err := windowsx.ProfilePath(prev.SID) - if err != nil { - log.Printf("profile path for %s/%s: %v", prev.User, prev.SID, err) - continue - } - if !a.safeProfilePath(path) { - log.Printf("refusing cleanup outside allowed roots: %s (%s)", path, prev.User) - continue - } - a.state.Pending[prev.SID] = model.CleanupJob{SID: prev.SID, User: displayUser(prev), ProfilePath: path, DueAt: now.Add(time.Duration(a.state.Policy.Cleanup.GraceSeconds) * time.Second)} - log.Printf("scheduled profile cleanup: %s in %ds", displayUser(prev), a.state.Policy.Cleanup.GraceSeconds) - } - - for _, s := range sessions { - if s.SID == "" || s.User == "" { - continue - } - _, was := a.state.LastSessions[s.ID] - if !was { - a.applyTemplatesLocked(s) - } - } - - if a.state.Policy.Cleanup.Enabled { - a.processCleanupLocked(now, currentBySID) + delete(a.state.DisconnectedSince, id) + delete(a.state.AutoLogoffRequested, id) + delete(a.state.RestoredSessions, id) + delete(a.state.ProfileJobs, restoreJobID(id)) + a.handleEndedSessionLocked(prev, now) } a.state.LastSessions = currentIDs - a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Sessions: sessions, PendingCleanup: pendingSlice(a.state.Pending), PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now} - if err := a.store.save(a.state); err != nil { - log.Printf("save state: %v", err) + a.pruneTelemetryLocked(currentIDs, 500) + a.bootstrapped = true + _ = a.store.save(a.state) + a.mu.Unlock() + + for _, s := range autoLogoff { + a.autoLogoff(s) + } + for _, s := range templateNow { + a.applyTemplates(s) + } + a.processProfileJobs(ctx, now) + a.processCleanup(now) + a.refreshSnapshot(server, sessions, limitUserProcesses(processes, 3000), health, now) +} + +func (a *App) pruneTelemetryLocked(active map[uint32]model.Session, limit int) { + if limit <= 0 || len(a.state.Telemetry) <= limit { + return + } + type candidate struct { + id uint32 + time time.Time + } + candidates := make([]candidate, 0, len(a.state.Telemetry)) + for id, t := range a.state.Telemetry { + if _, ok := active[id]; ok { + continue + } + candidates = append(candidates, candidate{id: id, time: t.FirstSeenAt}) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].time.Before(candidates[j].time) }) + remove := len(a.state.Telemetry) - limit + if remove > len(candidates) { + remove = len(candidates) + } + for i := 0; i < remove; i++ { + delete(a.state.Telemetry, candidates[i].id) } } -func (a *App) applyTemplatesLocked(s model.Session) { +func limitUserProcesses(in []model.ProcessInfo, limit int) []model.ProcessInfo { + if limit <= 0 { + return nil + } + out := make([]model.ProcessInfo, 0, min(len(in), limit)) + for _, p := range in { + if p.SessionID == 0 { + continue + } + out = append(out, p) + if len(out) >= limit { + break + } + } + return out +} + +func (a *App) handleEndedSessionLocked(prev model.Session, now time.Time) { + pp := a.state.Policy.Profiles + if pp.Enabled && pp.BackupOnLogoff && !a.profileExcluded(prev) { + id := backupJobID(prev.SID) + if _, exists := a.state.ProfileJobs[id]; !exists { + profilePath, err := windowsx.ProfilePath(prev.SID) + if err != nil { + a.appendEventLocked("error", "profile_backup_schedule_error", displayUser(prev), fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", err)) + return + } + due := now.Add(time.Duration(max(0, pp.BackupDelaySeconds)) * time.Second) + a.state.ProfileJobs[id] = model.ProfileJob{ID: id, Operation: "backup", SID: prev.SID, User: displayUser(prev), ProfilePath: profilePath, Reason: "logoff", CreatedAt: now, DueAt: due} + a.appendEventLocked("info", "profile_backup_scheduled", displayUser(prev), fmt.Sprintf("Profilsicherung nach Sitzungsende in %d Sekunden eingeplant", pp.BackupDelaySeconds)) + } + return // Cleanup is intentionally gated by successful backup. + } + a.scheduleCleanupLocked(prev.SID, displayUser(prev), now) +} + +func (a *App) scheduleCleanupLocked(sid, user string, now time.Time) { + p := a.state.Policy.Cleanup + if !p.Enabled || sid == "" || excludedIdentity(user, sid, p.ExcludeUsers, p.ExcludeSIDs) { + return + } + if _, exists := a.state.Pending[sid]; exists { + return + } + path, err := windowsx.ProfilePath(sid) + if err != nil { + a.appendEventLocked("error", "cleanup_schedule_error", user, fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", err)) + return + } + if !safeProfilePath(path, p.AllowedProfileRoots) { + a.appendEventLocked("error", "cleanup_blocked", user, fmt.Sprintf("Profilpfad außerhalb der erlaubten Roots: %s", path)) + return + } + a.state.Pending[sid] = model.CleanupJob{SID: sid, User: user, ProfilePath: path, DueAt: now.Add(time.Duration(p.GraceSeconds) * time.Second)} + a.appendEventLocked("info", "cleanup_scheduled", user, fmt.Sprintf("Profilbereinigung in %d Sekunden geplant: %s", p.GraceSeconds, path)) +} + +func (a *App) processProfileJobs(ctx context.Context, now time.Time) { + a.mu.RLock() + jobs := make([]model.ProfileJob, 0, len(a.state.ProfileJobs)) + for _, j := range a.state.ProfileJobs { + if !now.Before(j.DueAt) { + jobs = append(jobs, j) + } + } + a.mu.RUnlock() + for _, job := range jobs { + select { + case <-ctx.Done(): + return + default: + } + a.processProfileJob(job) + } +} + +func (a *App) processProfileJob(job model.ProfileJob) { + policy := a.policy() + pp := policy.Profiles + if !pp.Enabled { + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + _ = a.store.save(a.state) + a.mu.Unlock() + return + } + current, err := windowsx.Sessions() + if err != nil { + a.retryProfileJob(job, fmt.Errorf("session recheck: %w", err)) + return + } + var target *model.Session + activeSID := false + for i := range current { + if current[i].SID == job.SID && current[i].User != "" { + activeSID = true + } + if current[i].ID == job.SessionID { + target = ¤t[i] + } + } + if job.Operation == "backup" && activeSID { + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + delete(a.state.Pending, job.SID) + a.appendEventLocked("info", "profile_backup_cancelled", job.User, "Profilsicherung verworfen, weil der Benutzer wieder angemeldet ist") + _ = a.store.save(a.state) + a.mu.Unlock() + return + } + if job.Operation == "restore" && (target == nil || target.SID != job.SID) { + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + _ = a.store.save(a.state) + a.mu.Unlock() + return + } + + switch job.Operation { + case "backup": + stats, err := profilesync.BackupGuarded(job.ProfilePath, pp.StoreRoot, job.SID, job.User, pp.Folders, pp.KeepVersions, func() error { + sessions, err := windowsx.Sessions() + if err != nil { + return fmt.Errorf("session recheck before snapshot activation: %w", err) + } + for _, s := range sessions { + if s.SID == job.SID && s.User != "" { + return fmt.Errorf("user session %d became active during backup", s.ID) + } + } + return nil + }) + if err != nil { + a.retryProfileJob(job, err) + a.mu.Lock() + st := a.state.ProfileStatus[job.SID] + st.SID, st.User, st.LastBackupError = job.SID, job.User, err.Error() + a.state.ProfileStatus[job.SID] = st + _ = a.store.save(a.state) + a.mu.Unlock() + return + } + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + st := a.state.ProfileStatus[job.SID] + st.SID, st.User, st.LastBackupAt, st.LastBackupError = job.SID, job.User, time.Now().UTC(), "" + a.state.ProfileStatus[job.SID] = st + a.appendEventLocked("info", "profile_backup_complete", job.User, fmt.Sprintf("Profilsicherung abgeschlossen: %d Dateien, %d Bytes", stats.Files, stats.Bytes)) + // Cleanup starts only after the backup is safely activated as current snapshot. + a.scheduleCleanupLocked(job.SID, job.User, time.Now().UTC()) + _ = a.store.save(a.state) + a.mu.Unlock() + case "restore": + stats, found, err := profilesync.Restore(job.ProfilePath, pp.StoreRoot, job.SID, pp.Folders) + if err != nil { + if !job.CreatedAt.IsZero() && time.Since(job.CreatedAt) >= time.Duration(pp.RestoreWindowSeconds)*time.Second { + a.abandonRestore(job, err, target) + return + } + a.retryProfileJob(job, err) + a.mu.Lock() + st := a.state.ProfileStatus[job.SID] + st.SID, st.User, st.LastRestoreError = job.SID, job.User, err.Error() + a.state.ProfileStatus[job.SID] = st + _ = a.store.save(a.state) + a.mu.Unlock() + return + } + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + a.state.RestoredSessions[job.SessionID] = true + st := a.state.ProfileStatus[job.SID] + st.SID, st.User, st.LastRestoreAt, st.LastRestoreError = job.SID, job.User, time.Now().UTC(), "" + a.state.ProfileStatus[job.SID] = st + if t, ok := a.state.Telemetry[job.SessionID]; ok { + t.RestoreFinishedAt = time.Now().UTC() + if !t.RestoreStartedAt.IsZero() { + t.RestoreDurationMS = t.RestoreFinishedAt.Sub(t.RestoreStartedAt).Milliseconds() + } + a.state.Telemetry[job.SessionID] = t + } + if found { + a.appendEventLocked("info", "profile_restore_complete", job.User, fmt.Sprintf("Profil-Wiederherstellung abgeschlossen: %d Dateien, %d Bytes", stats.Files, stats.Bytes)) + } else { + a.appendEventLocked("info", "profile_restore_empty", job.User, "Noch kein gespeichertes Profil vorhanden; Anmeldung wird ohne Restore fortgesetzt") + } + _ = a.store.save(a.state) + a.mu.Unlock() + if target != nil { + a.applyTemplates(*target) + } + default: + a.retryProfileJob(job, fmt.Errorf("unknown profile job operation %q", job.Operation)) + } +} + +func (a *App) abandonRestore(job model.ProfileJob, restoreErr error, target *model.Session) { + a.mu.Lock() + delete(a.state.ProfileJobs, job.ID) + a.state.RestoredSessions[job.SessionID] = true + st := a.state.ProfileStatus[job.SID] + st.SID, st.User, st.LastRestoreError = job.SID, job.User, restoreErr.Error() + a.state.ProfileStatus[job.SID] = st + a.appendEventLocked("error", "profile_restore_abandoned", job.User, fmt.Sprintf("Restore-Fenster von %d Sekunden abgelaufen; Anmeldung läuft ohne weiteren Restore weiter: %v", a.state.Policy.Profiles.RestoreWindowSeconds, restoreErr)) + _ = a.store.save(a.state) + a.mu.Unlock() + if target != nil { + a.applyTemplates(*target) + } +} + +func (a *App) retryProfileJob(job model.ProfileJob, err error) { + a.mu.Lock() + defer a.mu.Unlock() + current, ok := a.state.ProfileJobs[job.ID] + if !ok { + return + } + current.Attempts++ + current.LastError = err.Error() + current.DueAt = time.Now().UTC().Add(time.Duration(max(1, a.state.Policy.Profiles.RetrySeconds)) * time.Second) + a.state.ProfileJobs[job.ID] = current + a.appendEventLocked("error", "profile_"+job.Operation+"_error", job.User, fmt.Sprintf("Profil-%s fehlgeschlagen (Versuch %d): %v", job.Operation, current.Attempts, err)) + _ = a.store.save(a.state) +} + +func (a *App) applyTemplates(s model.Session) { path, err := windowsx.ProfilePath(s.SID) if err != nil { log.Printf("templates profile %s: %v", displayUser(s), err) return } - for _, item := range a.state.Policy.Templates { + policy := a.policy() + for _, item := range policy.Templates { changed, err := tpl.Apply(path, item) + a.mu.Lock() + if err != nil { + a.appendEventLocked("error", "template_error", displayUser(s), fmt.Sprintf("Template %s konnte nicht angewendet werden: %v", item.ID, err)) + } else if changed { + a.appendEventLocked("info", "template_applied", displayUser(s), fmt.Sprintf("Template %s angewendet", item.ID)) + } + _ = a.store.save(a.state) + a.mu.Unlock() if err != nil { log.Printf("template %s for %s: %v", item.ID, displayUser(s), err) - continue - } - if changed { - log.Printf("template %s applied for %s", item.ID, displayUser(s)) } } + + a.mu.Lock() + if t, ok := a.state.Telemetry[s.ID]; ok { + t.ReadyAt = time.Now().UTC() + base := t.LogonAt + if base.IsZero() { + base = t.FirstSeenAt + } + if !base.IsZero() { + t.ObservedLogonMS = t.ReadyAt.Sub(base).Milliseconds() + } + a.state.Telemetry[s.ID] = t + } + _ = a.store.save(a.state) + a.mu.Unlock() } -func (a *App) processCleanupLocked(now time.Time, active map[string]bool) { - p := a.state.Policy.Cleanup - for sid, job := range a.state.Pending { - if active[sid] { - delete(a.state.Pending, sid) +func (a *App) processCleanup(now time.Time) { + p := a.policy().Cleanup + if !p.Enabled { + return + } + sessions, err := windowsx.Sessions() + if err != nil { + return + } + active := map[string]bool{} + for _, s := range sessions { + if s.SID != "" { + active[s.SID] = true + } + } + a.mu.RLock() + jobs := make([]model.CleanupJob, 0, len(a.state.Pending)) + for _, job := range a.state.Pending { + if !now.Before(job.DueAt) { + jobs = append(jobs, job) + } + } + a.mu.RUnlock() + for _, job := range jobs { + if active[job.SID] { + a.mu.Lock() + delete(a.state.Pending, job.SID) + _ = a.store.save(a.state) + a.mu.Unlock() continue } - if now.Before(job.DueAt) { - continue - } - if !a.safeProfilePath(job.ProfilePath) { + if !safeProfilePath(job.ProfilePath, p.AllowedProfileRoots) { + a.mu.Lock() job.LastError = "profile path is outside allowed roots" - job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second) - a.state.Pending[sid] = job + job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) + a.state.Pending[job.SID] = job + a.appendEventLocked("error", "cleanup_blocked", job.User, fmt.Sprintf("Profilpfad außerhalb der erlaubten Roots: %s", job.ProfilePath)) + _ = a.store.save(a.state) + a.mu.Unlock() continue } + // Recheck immediately before the destructive decision. A user may have logged in + // after the session list at the beginning of this cleanup pass was collected. + latest, recheckErr := windowsx.Sessions() + if recheckErr != nil { + a.mu.Lock() + job.LastError = "final session recheck: " + recheckErr.Error() + job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) + a.state.Pending[job.SID] = job + a.appendEventLocked("error", "cleanup_recheck_error", job.User, fmt.Sprintf("Finaler Session-Recheck vor Profilbereinigung fehlgeschlagen: %v", recheckErr)) + _ = a.store.save(a.state) + a.mu.Unlock() + continue + } + sidActive := false + for _, s := range latest { + if s.SID == job.SID && s.User != "" { + sidActive = true + break + } + } + if sidActive { + a.mu.Lock() + delete(a.state.Pending, job.SID) + a.appendEventLocked("info", "cleanup_cancelled", job.User, "Profilbereinigung beim finalen Recheck verworfen, weil der Benutzer wieder angemeldet ist") + _ = a.store.save(a.state) + a.mu.Unlock() + continue + } + if p.DryRun { - job.LastError = "dry-run: deletion skipped" - job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second) - a.state.Pending[sid] = job - log.Printf("dry-run profile deletion: %s (%s)", job.User, job.ProfilePath) + a.mu.Lock() + a.appendEventLocked("dry-run", "cleanup_dry_run", job.User, fmt.Sprintf("Profil würde jetzt gelöscht: %s", job.ProfilePath)) + delete(a.state.Pending, job.SID) + _ = a.store.save(a.state) + a.mu.Unlock() continue } - if err := windowsx.DeleteProfile(sid); err != nil { + if err := windowsx.DeleteProfile(job.SID); err != nil { + a.mu.Lock() job.Attempts++ job.LastError = err.Error() - job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second) - a.state.Pending[sid] = job - log.Printf("delete profile %s: %v", job.User, err) + job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) + a.state.Pending[job.SID] = job + a.appendEventLocked("error", "cleanup_error", job.User, fmt.Sprintf("Profil konnte nicht gelöscht werden: %v", err)) + _ = a.store.save(a.state) + a.mu.Unlock() continue } - delete(a.state.Pending, sid) - log.Printf("deleted profile: %s (%s)", job.User, job.ProfilePath) + a.mu.Lock() + delete(a.state.Pending, job.SID) + a.appendEventLocked("info", "cleanup_deleted", job.User, fmt.Sprintf("Profil gelöscht: %s", job.ProfilePath)) + _ = a.store.save(a.state) + a.mu.Unlock() } } -func (a *App) excluded(s model.Session) bool { - p := a.state.Policy.Cleanup - for _, u := range p.ExcludeUsers { - if strings.EqualFold(strings.TrimSpace(u), s.User) || strings.EqualFold(strings.TrimSpace(u), displayUser(s)) { +func (a *App) autoLogoff(s model.Session) { + err := windowsx.LogoffSession(s.ID) + a.mu.Lock() + defer a.mu.Unlock() + if err != nil { + delete(a.state.AutoLogoffRequested, s.ID) + a.appendEventLocked("error", "auto_logoff_error", displayUser(s), fmt.Sprintf("Automatisches Abmelden von Sitzung %d fehlgeschlagen: %v", s.ID, err)) + } else { + a.appendEventLocked("warning", "auto_logoff_requested", displayUser(s), fmt.Sprintf("Abmeldung der getrennten Sitzung %d ausgelöst; Profilsicherung folgt nach Sitzungsende", s.ID)) + } + _ = a.store.save(a.state) +} + +func (a *App) executeSessionCommand(cmd model.SessionCommand) model.CommandResult { + res := model.CommandResult{ID: cmd.ID, Action: cmd.Action, SessionID: cmd.SessionID, PID: cmd.PID, CompletedAt: time.Now().UTC()} + policy := a.policy() + action := strings.ToLower(strings.TrimSpace(cmd.Action)) + if action != "restart_server" && !policy.Sessions.ControlEnabled { + res.Error = "session control is disabled by policy" + return res + } + if !cmd.ExpiresAt.IsZero() && time.Now().After(cmd.ExpiresAt) { + res.Error = "command expired" + return res + } + var err error + switch action { + case "logoff": + err = windowsx.LogoffSession(cmd.SessionID) + case "disconnect": + err = windowsx.DisconnectSession(cmd.SessionID) + case "message": + if strings.TrimSpace(cmd.Message) == "" { + err = fmt.Errorf("message is empty") + } else { + err = windowsx.SendMessage(cmd.SessionID, cmd.Title, cmd.Message) + } + case "kill_process": + if cmd.PID == 0 || cmd.SessionID == 0 { + err = fmt.Errorf("pid and session id are required") + } else { + // Re-resolve PID ownership immediately before termination. Windows can + // reuse a PID while a queued command is in flight; never terminate it + // if it no longer belongs to the expected RDS session. + processes, listErr := windowsx.Processes() + if listErr != nil { + err = fmt.Errorf("verify process: %w", listErr) + } else { + matched := false + for _, process := range processes { + if process.PID == cmd.PID && process.SessionID == cmd.SessionID { + matched = true + break + } + } + if !matched { + err = fmt.Errorf("process %d no longer belongs to session %d", cmd.PID, cmd.SessionID) + } else { + err = windowsx.TerminateProcess(cmd.PID) + } + } + } + case "restart_server": + err = windowsx.RestartServer(emptyAs(cmd.Message, "SessionGuard maintenance restart")) + default: + err = fmt.Errorf("unsupported session action %q", cmd.Action) + } + res.Success = err == nil + if err != nil { + res.Error = err.Error() + } + a.mu.Lock() + level := "info" + if err != nil { + level = "error" + } + a.appendEventLocked(level, "session_command", "", fmt.Sprintf("Aktion %s für Session %d/PID %d durch %s: %s", cmd.Action, cmd.SessionID, cmd.PID, emptyAs(cmd.RequestedBy, "lokal"), resultText(err))) + _ = a.store.save(a.state) + a.mu.Unlock() + return res +} + +func (a *App) processCommands(commands []model.SessionCommand) { + for _, cmd := range commands { + a.mu.RLock() + _, done := a.state.ProcessedCommands[cmd.ID] + a.mu.RUnlock() + if done || cmd.ID == "" { + continue + } + res := a.executeSessionCommand(cmd) + a.mu.Lock() + a.state.ProcessedCommands[cmd.ID] = time.Now().UTC() + a.state.CommandResults = append(a.state.CommandResults, res) + if len(a.state.CommandResults) > 100 { + a.state.CommandResults = append([]model.CommandResult(nil), a.state.CommandResults[len(a.state.CommandResults)-100:]...) + } + cutoff := time.Now().UTC().Add(-48 * time.Hour) + for id, t := range a.state.ProcessedCommands { + if t.Before(cutoff) { + delete(a.state.ProcessedCommands, id) + } + } + _ = a.store.save(a.state) + a.mu.Unlock() + } +} + +func (a *App) cleanupExcluded(s model.Session) bool { + return excludedBy(s, a.state.Policy.Cleanup.ExcludeUsers, a.state.Policy.Cleanup.ExcludeSIDs) +} +func (a *App) profileExcluded(s model.Session) bool { + return excludedBy(s, a.state.Policy.Profiles.ExcludeUsers, a.state.Policy.Profiles.ExcludeSIDs) +} +func (a *App) sessionExcluded(s model.Session) bool { + return excludedBy(s, a.state.Policy.Sessions.ExcludeUsers, a.state.Policy.Sessions.ExcludeSIDs) +} + +func excludedBy(s model.Session, users, sids []string) bool { + if excludedIdentity(displayUser(s), s.SID, users, sids) { + return true + } + for _, u := range users { + if strings.EqualFold(strings.TrimSpace(u), s.User) { return true } } - for _, x := range p.ExcludeSIDs { - if strings.EqualFold(s.SID, x) || strings.HasPrefix(strings.ToUpper(s.SID), strings.ToUpper(x)+"-") { + return false +} +func excludedIdentity(user, sid string, users, sids []string) bool { + plainUser := user + if i := strings.LastIndexAny(plainUser, `\/`); i >= 0 && i+1 < len(plainUser) { + plainUser = plainUser[i+1:] + } + for _, u := range users { + u = strings.TrimSpace(u) + if strings.EqualFold(u, user) || strings.EqualFold(u, plainUser) { + return true + } + } + for _, x := range sids { + x = strings.TrimSpace(x) + if x != "" && (strings.EqualFold(sid, x) || strings.HasPrefix(strings.ToUpper(sid), strings.ToUpper(x)+"-")) { return true } } return false } -func (a *App) safeProfilePath(path string) bool { - clean, err := filepath.Abs(filepath.Clean(path)) +func safeProfilePath(profilePath string, roots []string) bool { + clean, err := filepath.Abs(filepath.Clean(profilePath)) if err != nil { return false } - for _, root := range a.state.Policy.Cleanup.AllowedProfileRoots { + for _, root := range roots { r, err := filepath.Abs(filepath.Clean(root)) if err != nil { continue @@ -243,6 +789,46 @@ func pendingSlice(m map[string]model.CleanupJob) []model.CleanupJob { } return out } +func profileJobSlice(m map[string]model.ProfileJob) []model.ProfileJob { + out := make([]model.ProfileJob, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} +func eventSlice(events []model.AgentEvent) []model.AgentEvent { + return append([]model.AgentEvent(nil), events...) +} +func resultSlice(results []model.CommandResult) []model.CommandResult { + return append([]model.CommandResult(nil), results...) +} +func cloneProfileStatus(in map[string]model.ProfileStatus) map[string]model.ProfileStatus { + out := make(map[string]model.ProfileStatus, len(in)) + for k, v := range in { + out[k] = v + } + return out +} +func backupJobID(sid string) string { return "backup:" + sid } +func restoreJobID(id uint32) string { return "restore:" + strconv.FormatUint(uint64(id), 10) } +func emptyAs(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} +func resultText(err error) string { + if err == nil { + return "erfolgreich" + } + return err.Error() +} +func (a *App) appendEventLocked(level, typ, user, message string) { + a.state.Events = append(a.state.Events, model.AgentEvent{Time: time.Now().UTC(), Level: level, Type: typ, User: user, Message: message}) + if len(a.state.Events) > 500 { + a.state.Events = append([]model.AgentEvent(nil), a.state.Events[len(a.state.Events)-500:]...) + } +} func max(a, b int) int { if a > b { return a @@ -251,52 +837,129 @@ func max(a, b int) int { } func (a *App) policy() model.Policy { a.mu.RLock(); defer a.mu.RUnlock(); return a.state.Policy } +func (a *App) refreshSnapshot(server model.ServerInfo, sessions []model.Session, processes []model.ProcessInfo, health model.HealthStatus, now time.Time) { + a.mu.Lock() + defer a.mu.Unlock() + for i := range sessions { + if t, ok := a.state.DisconnectedSince[sessions[i].ID]; ok { + tt := t + sessions[i].DisconnectedSince = &tt + } + } + telemetry := make(map[uint32]model.SessionTelemetry, len(a.state.Telemetry)) + for id, t := range a.state.Telemetry { + telemetry[id] = t + } + a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Health: health, Sessions: sessions, Processes: processes, Telemetry: telemetry, PendingCleanup: pendingSlice(a.state.Pending), ProfileJobs: profileJobSlice(a.state.ProfileJobs), ProfileStatus: cloneProfileStatus(a.state.ProfileStatus), Events: eventSlice(a.state.Events), CommandResults: resultSlice(a.state.CommandResults), Policy: a.state.Policy, PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now} +} + +func (a *App) calculateHealth(server model.ServerInfo) model.HealthStatus { + h := model.HealthStatus{Score: 100, CalculatedAt: time.Now().UTC(), RDPListenerOK: true, ProfileStoreOK: true} + add := func(name string, ok bool, message string, penalty int) { + h.Checks = append(h.Checks, model.HealthCheck{Name: name, OK: ok, Message: message}) + if !ok { + h.Score -= penalty + } + } + memPct := 0.0 + if server.MemoryTotal > 0 { + memPct = float64(server.MemoryTotal-server.MemoryAvailable) * 100 / float64(server.MemoryTotal) + } + add("cpu", server.CPUPercent < 95, fmt.Sprintf("%.1f%%", server.CPUPercent), 20) + add("memory", memPct < 95, fmt.Sprintf("%.1f%%", memPct), 20) + diskOK := server.DiskTotal == 0 || server.DiskFree >= 5<<30 + add("system_disk", diskOK, fmt.Sprintf("%d GiB free", server.DiskFree>>30), 20) + conn, err := net.DialTimeout("tcp", "127.0.0.1:3389", 750*time.Millisecond) + if err == nil { + _ = conn.Close() + } + h.RDPListenerOK = err == nil + add("rdp_listener", h.RDPListenerOK, resultText(err), 30) + p := a.policy().Profiles + if p.Enabled && strings.TrimSpace(p.StoreRoot) != "" { + _, err = os.Stat(p.StoreRoot) + h.ProfileStoreOK = err == nil + add("profile_store", h.ProfileStoreOK, resultText(err), 30) + } + if h.Score < 0 { + h.Score = 0 + } + return h +} + func (a *App) sendHeartbeat(ctx context.Context) { if a.cfg.MasterURL == "" { return } - a.mu.Lock() - if a.state.AgentID == "" || a.state.AgentToken == "" { + a.mu.RLock() + id, token := a.state.AgentID, a.state.AgentToken + snap := a.snapshot + a.mu.RUnlock() + if id == "" || token == "" { server, _ := windowsx.Server() mid, _ := windowsx.MachineID() resp, err := a.client.enroll(ctx, model.EnrollRequest{EnrollmentToken: a.cfg.EnrollmentToken, Name: server.Hostname, MachineID: mid}) if err != nil { + a.mu.Lock() a.masterErr = err.Error() a.mu.Unlock() log.Printf("master enroll: %v", err) return } + a.mu.Lock() a.state.AgentID, a.state.AgentToken = resp.AgentID, resp.Token + id, token = resp.AgentID, resp.Token _ = a.store.save(a.state) + a.mu.Unlock() } - id, token, snap := a.state.AgentID, a.state.AgentToken, a.snapshot snap.AgentID = id - a.mu.Unlock() resp, err := a.client.heartbeat(ctx, id, token, snap) - a.mu.Lock() - defer a.mu.Unlock() if err != nil { + a.mu.Lock() a.masterErr = err.Error() + a.mu.Unlock() log.Printf("master heartbeat: %v", err) return } + a.mu.Lock() a.lastMasterOK = time.Now().UTC() a.masterErr = "" + changed := false if resp.DesiredPolicy != nil && resp.DesiredPolicy.Revision != "" && resp.DesiredPolicy.Revision != a.state.Policy.Revision { - a.state.Policy = *resp.DesiredPolicy - _ = a.store.save(a.state) - log.Printf("applied master policy revision %s", a.state.Policy.Revision) + p := *resp.DesiredPolicy + config.NormalizePolicy(&p) + if err := config.ValidatePolicy(p); err != nil { + a.appendEventLocked("error", "policy_rejected", "", fmt.Sprintf("Master-Policy verworfen: %v", err)) + } else { + a.state.Policy = p + changed = true + a.appendEventLocked("info", "policy_applied", "", fmt.Sprintf("Master-Policy %s angewendet", p.Revision)) + } + } + _ = a.store.save(a.state) + current := make([]model.Session, 0, len(a.state.LastSessions)) + if changed { for _, s := range a.state.LastSessions { + current = append(current, s) + } + } + a.mu.Unlock() + if changed { + for _, s := range current { if s.SID != "" && s.User != "" { - a.applyTemplatesLocked(s) + a.applyTemplates(s) } } } + a.processCommands(resp.Commands) } func (a *App) serveHTTP(ctx context.Context) error { mux := http.NewServeMux() - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"ok": true}) }) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version}) + }) + mux.HandleFunc("GET /metrics", a.metricsAPI) var am *auth.Manager if a.cfg.OIDC.Issuer != "" { var err error @@ -324,7 +987,8 @@ func (a *App) serveHTTP(ctx context.Context) error { mux.Handle("GET /api/v1/status", secure(http.HandlerFunc(a.statusAPI))) mux.Handle("GET /api/v1/policy", secure(http.HandlerFunc(a.policyAPI))) mux.Handle("PUT /api/v1/policy", secure(http.HandlerFunc(a.policyAPI))) - server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second} + mux.Handle("POST /api/v1/sessions/{id}/action", secure(http.HandlerFunc(a.sessionActionAPI))) + server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second} go func() { <-ctx.Done() c, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -339,6 +1003,23 @@ func (a *App) serveHTTP(ctx context.Context) error { return err } +func (a *App) metricsAPI(w http.ResponseWriter, r *http.Request) { + a.mu.RLock() + s := a.snapshot + a.mu.RUnlock() + active := 0 + disconnected := 0 + for _, x := range s.Sessions { + if x.State == "Active" { + active++ + } + if x.State == "Disconnected" { + disconnected++ + } + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + fmt.Fprintf(w, "sessionguard_agent_up 1\nsessionguard_sessions_total %d\nsessionguard_sessions_active %d\nsessionguard_sessions_disconnected %d\nsessionguard_profile_jobs %d\nsessionguard_cleanup_jobs %d\n", len(s.Sessions), active, disconnected, len(s.ProfileJobs), len(s.PendingCleanup)) +} func (a *App) statusAPI(w http.ResponseWriter, r *http.Request) { a.mu.RLock() defer a.mu.RUnlock() @@ -360,26 +1041,57 @@ func (a *App) policyAPI(w http.ResponseWriter, r *http.Request) { httpx.Error(w, 400, err.Error()) return } - p.Revision = newRevision() - p.UpdatedAt = time.Now().UTC() - if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 { - httpx.Error(w, 400, "invalid cleanup timing") + config.NormalizePolicy(&p) + if err := config.ValidatePolicy(p); err != nil { + httpx.Error(w, 400, err.Error()) return } + p.Revision = newRevision() + p.UpdatedAt = time.Now().UTC() a.mu.Lock() a.state.Policy = p + a.appendEventLocked("info", "policy_local_update", "", "Lokale Policy über WebUI geändert") _ = a.store.save(a.state) - sessions := a.state.LastSessions + sessions := make([]model.Session, 0, len(a.state.LastSessions)) + for _, s := range a.state.LastSessions { + sessions = append(sessions, s) + } a.mu.Unlock() for _, s := range sessions { if s.SID != "" && s.User != "" { - a.mu.Lock() - a.applyTemplatesLocked(s) - a.mu.Unlock() + a.applyTemplates(s) } } httpx.JSON(w, 200, p) } +func (a *App) sessionActionAPI(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + id64, err := strconv.ParseUint(r.PathValue("id"), 10, 32) + if err != nil { + httpx.Error(w, 400, "invalid session id") + return + } + var req model.SessionActionRequest + if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + u, _ := auth.UserFrom(r) + actor := u.Email + if actor == "" { + actor = u.Name + } + cmd := model.SessionCommand{ID: "local-" + newRevision(), Action: req.Action, SessionID: uint32(id64), Title: req.Title, Message: req.Message, RequestedBy: actor, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(5 * time.Minute)} + res := a.executeSessionCommand(cmd) + if !res.Success { + httpx.Error(w, 409, res.Error) + return + } + httpx.JSON(w, 200, res) +} func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -390,7 +1102,6 @@ func securityHeaders(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - func (a *App) agentPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, agentHTML) diff --git a/internal/agent/state.go b/internal/agent/state.go index 05ee8be..1fafe70 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -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 } diff --git a/internal/agent/ui.go b/internal/agent/ui.go index 96fc1ee..6552fcf 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -1,10 +1,24 @@ package agent const agentHTML = `SessionGuard Agent
SessionGuard Agent
Lokaler Terminalserver
Aktiv
Sitzungen
Cleanup geplant
Master

Sitzungen

Lokale Policy

` +: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)}} +
SessionGuard Agent
Lokaler Terminalserver
Aktiv
Getrennt
Sitzungen
Profil-Jobs
Cleanup
Master

Sitzungen

Profil-Pipeline

Aktivitäts- & Audit-Log

Lokale Policy

` const agentJS = ` -const $=id=>document.getElementById(id);function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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?'Offline':'Verbunden';$('sessions').innerHTML=''+ss.map(x=>'').join('')+'
IDBenutzerStatusClient
'+x.id+''+esc((x.domain?x.domain+'\\':'')+x.user)+''+esc(x.state)+''+esc(x.client_name||'–')+'
'}catch(e){$('master').innerHTML=''+esc(e.message)+''}} -async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML='
Cleanup aktiv
Dry-Run
Wenn der Master für diesen Agent eine gewünschte Policy gesetzt hat, ist diese nach Wiederherstellung der Verbindung wieder maßgeblich.
'}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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function when(v){if(!v)return '–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function dur(v){if(!v)return '–';let s=Math.max(0,Math.floor((Date.now()-new Date(v).getTime())/1000));return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m'} +function renderEvents(events){let rows=(events||[]).slice().reverse().slice(0,200);$('events').innerHTML=rows.length?'
'+rows.map(x=>'').join('')+'
ZeitTypBenutzerMeldung
'+esc(when(x.time))+''+esc(x.level||'info')+''+esc(x.user||'–')+''+esc(x.message||'')+'
':'
Noch keine Ereignisse.
'} +function renderProfiles(s){let jobs=s.profile_jobs||[],status=Object.values(s.profile_status||{});let html='';if(jobs.length)html+=''+jobs.map(j=>'').join('')+'
JobBenutzerFälligVersucheFehler
'+esc(j.operation)+''+esc(j.user)+''+esc(when(j.due_at))+''+j.attempts+''+esc(j.last_error||'–')+'
';if(status.length)html+=''+status.map(x=>'').join('')+'
BenutzerLetztes BackupLetzter RestoreStatus
'+esc(x.user||x.sid)+''+esc(when(x.last_backup_at))+''+esc(when(x.last_restore_at))+''+esc(x.last_backup_error||x.last_restore_error||'OK')+'
';$('profiles').innerHTML=html||'
Keine Profil-Jobs oder -Historie.
'} +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=''+ss.map(x=>'').join('')+'
IDBenutzerStatusGetrennt seitClientAktionen
'+x.id+''+esc((x.domain?x.domain+'\\':'')+x.user)+''+esc(x.state)+''+esc(x.disconnected_since?when(x.disconnected_since)+' ('+dur(x.disconnected_since)+')':'–')+''+esc(x.client_name||'–')+''+(control&&x.user?'
':'–')+'
'} +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?'Offline':'Verbunden';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML=''+esc(e.message)+''}} +function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'
Keine weiteren Angaben.
';if(k==='url')return'';if(k==='shortcut'){let s=t.shortcut||{};return'
'}return'
'} +function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'
'+esc(t.id||('Template '+(i+1)))+'
'+templateSpecific(t)+'
').join(''):'
Keine Templates konfiguriert.
'} +function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})} +function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'
'+esc(f.path||('Ordner '+(i+1)))+'
').join(''):'
Keine Profilordner konfiguriert.
'} +function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)} +function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'
Profil-Sicherung & Wiederherstellung
Zu sichernde Profilordner
Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.
Sitzungsrichtlinie
Profil-Cleanup
Templates
'} +async function loadPolicy(){try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders()}catch(e){$('policy').textContent=e.message}} +async function savePolicy(){try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy();refresh()}catch(e){alert(e.message)}} +$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});refresh();loadPolicy();setInterval(refresh,5000);` diff --git a/internal/config/config.go b/internal/config/config.go index 2d91b50..841b390 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ec28444..b57ea08 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/master/broker_test.go b/internal/master/broker_test.go new file mode 100644 index 0000000..c41407d --- /dev/null +++ b/internal/master/broker_test.go @@ -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") + } +} diff --git a/internal/master/master.go b/internal/master/master.go index 0840a9d..66467db 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -1,15 +1,19 @@ package master import ( + "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" + "encoding/json" "fmt" "log" "net/http" + "net/url" "sort" + "strconv" "strings" "time" @@ -19,51 +23,80 @@ import ( "github.com/example/sessionguard/internal/model" ) -const Version = "0.1.0" +const Version = "0.3.0" type App struct { cfg config.Master store *store auth *auth.Manager + http *http.Client } func New(ctx context.Context, cfg config.Master) (*App, error) { - s, err := newStore(cfg.DataFile) + s, err := newStore(ctx, cfg) if err != nil { return nil, err } a, err := auth.New(ctx, cfg.OIDC) if err != nil { + _ = s.close() return nil, fmt.Errorf("OIDC: %w", err) } - return &App{cfg: cfg, store: s, auth: a}, nil + return &App{cfg: cfg, store: s, auth: a, http: &http.Client{Timeout: 8 * time.Second}}, nil } func (a *App) Run(ctx context.Context) error { mux := http.NewServeMux() a.auth.Register(mux) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version}) + httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version, "store": a.store.kind()}) }) + mux.HandleFunc("GET /metrics", a.metrics) mux.HandleFunc("POST /api/v1/agents/enroll", a.enroll) mux.HandleFunc("POST /api/v1/agents/heartbeat", a.heartbeat) + mux.HandleFunc("POST /api/v1/broker/resolve", a.brokerResolve) + mux.HandleFunc("POST /api/v1/broker/tokens", a.brokerTokens) mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") _, _ = fmt.Fprint(w, masterJS) }) mux.Handle("GET /", a.auth.Require(http.HandlerFunc(a.masterPage))) + mux.Handle("GET /api/v1/me", a.auth.Require(http.HandlerFunc(a.me))) mux.Handle("GET /api/v1/dashboard", a.auth.Require(http.HandlerFunc(a.dashboard))) mux.Handle("GET /api/v1/agents/{id}", a.auth.Require(http.HandlerFunc(a.agentDetail))) - mux.Handle("PUT /api/v1/agents/{id}/policy", a.auth.Require(http.HandlerFunc(a.policy))) - mux.Handle("PUT /api/v1/policy/all", a.auth.Require(http.HandlerFunc(a.policyAll))) - server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second} + mux.Handle("PUT /api/v1/agents/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.policy)))) + mux.Handle("POST /api/v1/agents/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyRollback)))) + mux.Handle("PUT /api/v1/policy/all", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyAll)))) + mux.Handle("POST /api/v1/policy/global/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.globalPolicyRollback)))) + mux.Handle("PUT /api/v1/farms/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicy)))) + mux.Handle("POST /api/v1/farms/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicyRollback)))) + mux.Handle("PATCH /api/v1/agents/{id}/control", a.auth.Require(a.require("maintenance", http.HandlerFunc(a.agentControl)))) + mux.Handle("POST /api/v1/agents/{id}/sessions/{session}/action", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionAction)))) + mux.Handle("POST /api/v1/agents/{id}/sessions/bulk", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionBulkAction)))) + mux.Handle("POST /api/v1/agents/{id}/processes/{pid}/kill", a.auth.Require(a.require("process", http.HandlerFunc(a.processKill)))) + mux.Handle("GET /api/v1/audit", a.auth.Require(a.require("audit", http.HandlerFunc(a.audit)))) + mux.Handle("GET /api/v1/history", a.auth.Require(http.HandlerFunc(a.history))) + mux.Handle("GET /api/v1/policy/history", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyHistory)))) + mux.Handle("GET /api/v1/farms", a.auth.Require(http.HandlerFunc(a.farms))) + mux.Handle("POST /api/v1/farms", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmCreate)))) + mux.Handle("PUT /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmUpdate)))) + mux.Handle("DELETE /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmDelete)))) + mux.Handle("GET /api/v1/resources", a.auth.Require(http.HandlerFunc(a.resources))) + mux.Handle("POST /api/v1/resources", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceCreate)))) + mux.Handle("PUT /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceUpdate)))) + mux.Handle("DELETE /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceDelete)))) + mux.Handle("GET /api/v1/alerts", a.auth.Require(http.HandlerFunc(a.alerts))) + mux.Handle("GET /api/v1/leases", a.auth.Require(http.HandlerFunc(a.leases))) + server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second} + go a.monitor(ctx) go func() { <-ctx.Done() c, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(c) + _ = a.store.close() }() - log.Printf("master listening on %s", a.cfg.Listen) + log.Printf("master %s listening on %s (store=%s)", Version, a.cfg.Listen, a.store.kind()) err := server.ListenAndServe() if err == http.ErrServerClosed { return nil @@ -77,13 +110,12 @@ func (a *App) enroll(w http.ResponseWriter, r *http.Request) { httpx.Error(w, 400, err.Error()) return } - if !constantEqual(req.EnrollmentToken, a.cfg.EnrollmentToken) || req.MachineID == "" { + if !constantEqual(req.EnrollmentToken, a.cfg.EnrollmentToken) || strings.TrimSpace(req.MachineID) == "" { httpx.Error(w, 401, "invalid enrollment") return } now := time.Now().UTC() - token := randomToken(32) - id := randomToken(16) + token, id := randomToken(32), randomToken(16) a.store.mu.Lock() defer a.store.mu.Unlock() for oldID, rec := range a.store.data.Agents { @@ -93,14 +125,26 @@ func (a *App) enroll(w http.ResponseWriter, r *http.Request) { } } rec := a.store.data.Agents[id] + newEnrollment := rec.EnrolledAt.IsZero() rec.ID = id rec.Name = req.Name rec.MachineID = req.MachineID rec.TokenHash = hashToken(token) - if rec.EnrolledAt.IsZero() { + if newEnrollment { rec.EnrolledAt = now } + if rec.Tags == nil { + rec.Tags = map[string]string{} + } + if rec.MaintenanceMode == "" { + rec.MaintenanceMode = "online" + } a.store.data.Agents[id] = rec + action := "agent_reenroll" + if newEnrollment { + action = "agent_enroll" + } + a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent-bootstrap", Action: action, Target: req.Name, Result: "success", Details: req.MachineID}) if err := a.store.saveLocked(); err != nil { httpx.Error(w, 500, err.Error()) return @@ -116,39 +160,394 @@ func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) { return } var snap model.AgentSnapshot - if err := httpx.DecodeJSON(r, &snap, 2<<20); err != nil { + if err := httpx.DecodeJSON(r, &snap, 8<<20); err != nil { httpx.Error(w, 400, err.Error()) return } - a.store.mu.Lock() - defer a.store.mu.Unlock() - rec, ok := a.store.data.Agents[id] - if !ok || !constantEqual(hashToken(token), rec.TokenHash) { - httpx.Error(w, 401, "invalid agent credentials") - return - } if snap.ProtocolVersion != model.ProtocolVersion { httpx.Error(w, 409, "protocol version mismatch") return } now := time.Now().UTC() + var notify []model.Alert + a.store.mu.Lock() + rec, ok := a.store.data.Agents[id] + if !ok || !constantEqual(hashToken(token), rec.TokenHash) { + a.store.mu.Unlock() + httpx.Error(w, 401, "invalid agent credentials") + return + } + previous := rec.Snapshot snap.AgentID = id rec.LastSeen = now rec.Snapshot = snap if snap.Server.Hostname != "" { rec.Name = snap.Server.Hostname } + a.recordSessionHistoryLocked(rec, previous, snap, now) + results := map[string]model.CommandResult{} + for _, res := range snap.CommandResults { + if res.ID != "" { + results[res.ID] = res + } + } + pending := make([]model.SessionCommand, 0, len(rec.PendingCommands)) + for _, cmd := range rec.PendingCommands { + if result, found := results[cmd.ID]; found { + status, details := "success", "" + if !result.Success { + status = "error" + details = result.Error + } + a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent:" + rec.Name, Action: "command_result:" + cmd.Action, Target: commandTarget(rec, cmd), Result: status, Details: details}) + continue + } + if !cmd.ExpiresAt.IsZero() && now.After(cmd.ExpiresAt) { + a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "command_expired:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "expired"}) + continue + } + pending = append(pending, cmd) + } + rec.PendingCommands = pending + if rec.RestartWhenDrained && userSessionCount(snap.Sessions) == 0 && !hasPendingAction(rec.PendingCommands, "restart_server") { + cmd := model.SessionCommand{ID: randomToken(12), Action: "restart_server", RequestedBy: "system:drain", Message: "SessionGuard restart after drain", CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)} + rec.PendingCommands = append(rec.PendingCommands, cmd) + rec.RestartWhenDrained = false + a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "restart_when_drained", Target: rec.Name, Result: "queued"}) + } a.store.data.Agents[id] = rec + notify = a.evaluateAgentAlertsLocked(rec, now) + desired := a.effectivePolicyLocked(rec) + var sendPolicy *model.Policy + if desired != nil && desired.Revision != snap.PolicyRevision { + cp := *desired + sendPolicy = &cp + } + commands := append([]model.SessionCommand(nil), rec.PendingCommands...) if err := a.store.saveLocked(); err != nil { + a.store.mu.Unlock() httpx.Error(w, 500, err.Error()) return } - var desired *model.Policy - if rec.DesiredPolicy != nil && rec.DesiredPolicy.Revision != snap.PolicyRevision { - p := *rec.DesiredPolicy - desired = &p + a.store.mu.Unlock() + for _, al := range notify { + a.notifyAlert(al) } - httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: desired, ServerTime: now}) + httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: sendPolicy, Commands: commands, ServerTime: now}) +} + +func (a *App) recordSessionHistoryLocked(rec model.AgentRecord, old, new model.AgentSnapshot, now time.Time) { + om := map[uint32]model.Session{} + nm := map[uint32]model.Session{} + for _, s := range old.Sessions { + if s.User != "" { + om[s.ID] = s + } + } + for _, s := range new.Sessions { + if s.User != "" { + nm[s.ID] = s + } + } + for id, s := range nm { + prev, exists := om[id] + event := "" + if !exists { + event = "logon" + } else if prev.State != s.State { + event = strings.ToLower(s.State) + } + if event != "" { + a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: event, State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit) + } + } + for id, s := range om { + if _, exists := nm[id]; !exists { + a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: "logoff", State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit) + } + } + for id, telemetry := range new.Telemetry { + oldTelemetry := old.Telemetry[id] + if telemetry.ObservedLogonMS > 0 && oldTelemetry.ObservedLogonMS == 0 { + details := fmt.Sprintf("logon=%dms restore=%dms", telemetry.ObservedLogonMS, telemetry.RestoreDurationMS) + a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: telemetry.User, SID: telemetry.SID, Event: "logon_ready", State: "Active", Details: details}, a.cfg.HistoryLimit) + } + } +} + +func (a *App) effectivePolicyLocked(rec model.AgentRecord) *model.Policy { + if rec.DesiredPolicy != nil { + return rec.DesiredPolicy + } + + // Explicit per-agent farm assignments have precedence and keep their + // configured order. This makes policy precedence predictable when an agent + // intentionally belongs to multiple farms. + for _, fid := range rec.FarmIDs { + if f, ok := a.store.data.Farms[fid]; ok && f.Enabled && f.Policy != nil { + return f.Policy + } + } + + // Farms may also select agents centrally through AgentIDs or RequiredTags. + // Evaluate these deterministically so a server receives the same policy + // after every master restart. + ids := make([]string, 0, len(a.store.data.Farms)) + for id := range a.store.data.Farms { + ids = append(ids, id) + } + sort.Strings(ids) + for _, fid := range ids { + f := a.store.data.Farms[fid] + if !f.Enabled || f.Policy == nil { + continue + } + if contains(f.AgentIDs, rec.ID) || (len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags)) { + return f.Policy + } + } + return a.store.data.GlobalPolicy +} + +func (a *App) brokerResolve(w http.ResponseWriter, r *http.Request) { + if !a.cfg.Broker.Enabled { + httpx.Error(w, 404, "broker disabled") + return + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if !constantEqual(token, a.cfg.Broker.APIKey) { + httpx.Error(w, 401, "invalid broker credential") + return + } + var req model.BrokerRequest + if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + if strings.TrimSpace(req.Username) == "" { + httpx.Error(w, 400, "username is required") + return + } + resp, err := a.resolveBroker(req) + if err != nil { + httpx.Error(w, 503, err.Error()) + return + } + httpx.JSON(w, 200, resp) +} + +func (a *App) brokerTokens(w http.ResponseWriter, r *http.Request) { + if !a.cfg.Broker.Enabled { + httpx.Error(w, 404, "broker disabled") + return + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if !constantEqual(token, a.cfg.Broker.APIKey) { + httpx.Error(w, 401, "invalid broker credential") + return + } + if err := r.ParseForm(); err != nil { + httpx.Error(w, 400, "invalid form") + return + } + req := model.BrokerRequest{ + Username: r.FormValue("username"), + ConnectionID: r.FormValue("connection_id"), + ConnectionName: r.FormValue("connection_name"), + ResourceID: r.FormValue("resource_id"), + FarmID: r.FormValue("farm_id"), + } + if strings.TrimSpace(req.Username) == "" { + httpx.Error(w, 400, "username is required") + return + } + // The Guacamole extension decorates every connection. Only connections + // explicitly mapped as SessionGuard Resources should therefore invoke the + // broker. Returning an empty token set leaves unrelated/static Guacamole + // connections untouched and prevents a broker outage from breaking them. + if !a.hasMappedResource(req) { + w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + w.WriteHeader(http.StatusOK) + return + } + resp, err := a.resolveBroker(req) + if err != nil { + httpx.Error(w, 503, err.Error()) + return + } + values := url.Values{} + for k, v := range resp.Tokens { + values.Set(k, v) + } + values.Set("SESSIONGUARD_REASON", resp.Reason) + values.Set("SESSIONGUARD_FARM_ID", resp.FarmID) + w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + _, _ = fmt.Fprint(w, values.Encode()) +} + +func (a *App) hasMappedResource(req model.BrokerRequest) bool { + a.store.mu.RLock() + defer a.store.mu.RUnlock() + _, ok := a.findResourceLocked(req) + return ok +} + +func (a *App) findResourceLocked(req model.BrokerRequest) (*model.Resource, bool) { + // Prefer stable explicit identifiers over names. Guacamole sends both + // connection ID and name; deterministic precedence prevents a bad mapping + // from being selected according to Go map iteration order. + if req.ResourceID != "" { + if res, ok := a.store.data.Resources[req.ResourceID]; ok && res.Enabled { + cp := res + return &cp, true + } + } + if req.ConnectionID != "" { + for _, res := range a.store.data.Resources { + if res.Enabled && res.GuacamoleConnectionID != "" && res.GuacamoleConnectionID == req.ConnectionID { + cp := res + return &cp, true + } + } + } + if req.ConnectionName != "" { + for _, res := range a.store.data.Resources { + if res.Enabled && res.GuacamoleConnectionName != "" && strings.EqualFold(res.GuacamoleConnectionName, req.ConnectionName) { + cp := res + return &cp, true + } + } + } + return nil, false +} + +func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, error) { + now := time.Now().UTC() + userKey := normalizeUser(req.Username) + a.store.mu.Lock() + defer a.store.mu.Unlock() + resource, _ := a.findResourceLocked(req) + farmID := req.FarmID + if resource != nil { + farmID = resource.FarmID + } + if farmID == "" { + farmID = a.cfg.Broker.DefaultFarmID + } + if farmID != "" { + if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled { + return model.BrokerResponse{}, fmt.Errorf("farm %q is not available", farmID) + } + } + leaseKey := a.brokerLeaseKey(userKey, farmID, resourceID(resource)) + + // Existing RDS session wins for the requested farm, including on a draining host. + // Maintenance hosts are never selected. This provides Citrix-like reconnect affinity. + if a.cfg.Broker.ReconnectExisting { + for id, rec := range a.store.data.Agents { + if rec.MaintenanceMode == "maintenance" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || !a.agentInFarmLocked(id, rec, farmID) { + continue + } + for _, sess := range rec.Snapshot.Sessions { + if brokerSessionState(sess.State) && sessionMatchesUser(sess, userKey) { + lease := a.putLeaseLocked(leaseKey, userKey, id, farmID, resourceID(resource), "existing-session", now) + if err := a.store.saveLocked(); err != nil { + return model.BrokerResponse{}, err + } + return brokerResponse(rec, farmID, resource, true, "existing-session", lease), nil + } + } + } + } + if lease, ok := a.store.data.Leases[leaseKey]; ok && now.Before(lease.ExpiresAt) { + if rec, found := a.store.data.Agents[lease.AgentID]; found && rec.MaintenanceMode != "maintenance" && agentOnline(rec, now, a.cfg.OfflineAfterSeconds) && a.agentInFarmLocked(lease.AgentID, rec, farmID) { + lease.ExpiresAt = now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second) + a.store.data.Leases[leaseKey] = lease + if err := a.store.saveLocked(); err != nil { + return model.BrokerResponse{}, err + } + return brokerResponse(rec, farmID, resource, true, "existing-lease", lease), nil + } + } + candidates := a.farmCandidatesLocked(farmID, now) + if len(candidates) == 0 { + return model.BrokerResponse{}, fmt.Errorf("no healthy online server is available for farm %q", farmID) + } + sort.Slice(candidates, func(i, j int) bool { return brokerScore(candidates[i]) > brokerScore(candidates[j]) }) + chosen := candidates[0] + lease := a.putLeaseLocked(leaseKey, userKey, chosen.ID, farmID, resourceID(resource), "load-balance", now) + if err := a.store.saveLocked(); err != nil { + return model.BrokerResponse{}, err + } + return brokerResponse(chosen, farmID, resource, false, "load-balance", lease), nil +} + +func (a *App) brokerLeaseKey(user, farm, resource string) string { + if a.cfg.Broker.SingleSession { + return user + } + return user + "|" + farm + "|" + resource +} + +func brokerSessionState(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "active", "connected", "disconnected": + return true + default: + return false + } +} + +func (a *App) agentInFarmLocked(id string, rec model.AgentRecord, farmID string) bool { + if farmID == "" { + return true + } + f, ok := a.store.data.Farms[farmID] + if !ok || !f.Enabled { + return false + } + if contains(f.AgentIDs, id) || contains(rec.FarmIDs, farmID) { + return true + } + return len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags) +} + +func (a *App) farmCandidatesLocked(farmID string, now time.Time) []model.AgentRecord { + if farmID != "" { + if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled { + return nil + } + } + out := []model.AgentRecord{} + for id, rec := range a.store.data.Agents { + if rec.MaintenanceMode != "online" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || rec.Snapshot.Health.Score < a.cfg.Broker.MinHealthScore { + continue + } + if !a.agentInFarmLocked(id, rec, farmID) { + continue + } + out = append(out, rec) + } + return out +} + +func (a *App) putLeaseLocked(key, user, agent, farm, res, reason string, now time.Time) model.UserLease { + l := model.UserLease{UserKey: user, AgentID: agent, FarmID: farm, ResourceID: res, CreatedAt: now, ExpiresAt: now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second), Reason: reason} + a.store.data.Leases[key] = l + return l +} + +func brokerResponse(rec model.AgentRecord, farm string, res *model.Resource, reconnect bool, reason string, lease model.UserLease) model.BrokerResponse { + tokens := map[string]string{"SESSIONGUARD_HOST": rec.Snapshot.Server.Hostname, "SESSIONGUARD_AGENT_ID": rec.ID} + rid := "" + if res != nil { + rid = res.ID + tokens["SESSIONGUARD_RESOURCE_ID"] = res.ID + tokens["SESSIONGUARD_REMOTE_APP"] = res.RemoteApp + tokens["SESSIONGUARD_REMOTE_APP_DIR"] = res.RemoteAppDir + tokens["SESSIONGUARD_REMOTE_APP_ARGS"] = res.RemoteAppArgs + } + return model.BrokerResponse{AgentID: rec.ID, Hostname: rec.Snapshot.Server.Hostname, FarmID: farm, ResourceID: rid, Reconnect: reconnect, Reason: reason, HealthScore: rec.Snapshot.Health.Score, Tokens: tokens, LeaseExpires: lease.ExpiresAt} } func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { @@ -157,21 +556,33 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() type row struct { model.AgentRecord - Online bool `json:"online"` - Active int `json:"active_sessions"` - Total int `json:"total_sessions"` + Online bool `json:"online"` + Active int `json:"active_sessions"` + Disconnected int `json:"disconnected_sessions"` + Total int `json:"total_sessions"` + MemoryPercent float64 `json:"memory_percent"` + BrokerScore float64 `json:"broker_score"` } out := make([]row, 0, len(recs)) for _, rec := range recs { - active := 0 - for _, s := range rec.Snapshot.Sessions { - if s.State == "Active" { - active++ - } + active, disc := sessionCounts(rec.Snapshot.Sessions) + mem := 0.0 + if rec.Snapshot.Server.MemoryTotal > 0 { + mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal) } - out = append(out, row{AgentRecord: rec, Online: now.Sub(rec.LastSeen) < time.Duration(a.cfg.OfflineAfterSeconds)*time.Second, Active: active, Total: len(rec.Snapshot.Sessions)}) + out = append(out, row{AgentRecord: rec, Online: agentOnline(rec, now, a.cfg.OfflineAfterSeconds), Active: active, Disconnected: disc, Total: active + disc, MemoryPercent: mem, BrokerScore: brokerScore(rec)}) } - httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now}) + a.store.mu.RLock() + farms := len(a.store.data.Farms) + resources := len(a.store.data.Resources) + alerts := 0 + for _, x := range a.store.data.Alerts { + if x.Active { + alerts++ + } + } + a.store.mu.RUnlock() + httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "store": a.store.kind()}) } func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) { rec, ok := a.store.get(r.PathValue("id")) @@ -181,6 +592,7 @@ func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) { } httpx.JSON(w, 200, rec) } + func (a *App) policy(w http.ResponseWriter, r *http.Request) { if !httpx.SameOrigin(r) { httpx.Error(w, 403, "cross-origin request rejected") @@ -192,12 +604,14 @@ func (a *App) policy(w http.ResponseWriter, r *http.Request) { httpx.Error(w, 400, err.Error()) return } - if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 { - httpx.Error(w, 400, "invalid cleanup timing") + config.NormalizePolicy(&p) + if err := config.ValidatePolicy(p); err != nil { + httpx.Error(w, 400, err.Error()) return } p.Revision = randomToken(12) p.UpdatedAt = time.Now().UTC() + actor := requestActor(r) a.store.mu.Lock() defer a.store.mu.Unlock() rec, ok := a.store.data.Agents[id] @@ -207,6 +621,8 @@ func (a *App) policy(w http.ResponseWriter, r *http.Request) { } rec.DesiredPolicy = &p a.store.data.Agents[id] = rec + a.recordPolicyVersionLocked("agent:"+id, p, actor) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_update", Target: rec.Name, Result: "queued", Details: p.Revision}) if err := a.store.saveLocked(); err != nil { httpx.Error(w, 500, err.Error()) return @@ -223,31 +639,850 @@ func (a *App) policyAll(w http.ResponseWriter, r *http.Request) { httpx.Error(w, 400, err.Error()) return } - if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 { - httpx.Error(w, 400, "invalid cleanup timing") + config.NormalizePolicy(&p) + if err := config.ValidatePolicy(p); err != nil { + httpx.Error(w, 400, err.Error()) return } p.Revision = randomToken(12) p.UpdatedAt = time.Now().UTC() + actor := requestActor(r) a.store.mu.Lock() defer a.store.mu.Unlock() - for id, rec := range a.store.data.Agents { - cp := p - rec.DesiredPolicy = &cp - a.store.data.Agents[id] = rec - } + a.store.data.GlobalPolicy = &p + a.recordPolicyVersionLocked("global", p, actor) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "global_policy_update", Target: "all agents", Result: "queued", Details: p.Revision}) if err := a.store.saveLocked(); err != nil { httpx.Error(w, 500, err.Error()) return } - httpx.JSON(w, 200, map[string]any{"updated_agents": len(a.store.data.Agents), "policy": p}) + httpx.JSON(w, 200, p) +} +func (a *App) farmPolicy(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + var p model.Policy + if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + config.NormalizePolicy(&p) + if err := config.ValidatePolicy(p); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + p.Revision = randomToken(12) + p.UpdatedAt = time.Now().UTC() + id, actor := r.PathValue("id"), requestActor(r) + a.store.mu.Lock() + defer a.store.mu.Unlock() + f, ok := a.store.data.Farms[id] + if !ok { + httpx.Error(w, 404, "farm not found") + return + } + f.Policy = &p + a.store.data.Farms[id] = f + a.recordPolicyVersionLocked("farm:"+id, p, actor) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "farm_policy_update", Target: f.Name, Result: "queued", Details: p.Revision}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 200, p) +} + +func (a *App) globalPolicyRollback(w http.ResponseWriter, r *http.Request) { + a.rollbackPolicyTarget(w, r, "global", "") +} +func (a *App) farmPolicyRollback(w http.ResponseWriter, r *http.Request) { + a.rollbackPolicyTarget(w, r, "farm:"+r.PathValue("id"), r.PathValue("id")) +} +func (a *App) rollbackPolicyTarget(w http.ResponseWriter, r *http.Request, target, farmID string) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + rev, actor := r.PathValue("revision"), requestActor(r) + a.store.mu.Lock() + defer a.store.mu.Unlock() + var found *model.Policy + for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- { + v := a.store.data.PolicyHistory[i] + if v.Target == target && v.Revision == rev { + p := v.Policy + found = &p + break + } + } + if found == nil { + httpx.Error(w, 404, "revision not found") + return + } + found.Revision = randomToken(12) + found.UpdatedAt = time.Now().UTC() + name := "global" + if target == "global" { + a.store.data.GlobalPolicy = found + } else { + f, ok := a.store.data.Farms[farmID] + if !ok { + httpx.Error(w, 404, "farm not found") + return + } + f.Policy = found + a.store.data.Farms[farmID] = f + name = f.Name + } + a.recordPolicyVersionLocked(target, *found, actor) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: name, Result: "queued", Details: rev + " -> " + found.Revision}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 200, found) +} + +func (a *App) recordPolicyVersionLocked(target string, p model.Policy, actor string) { + a.store.data.PolicyHistory = append(a.store.data.PolicyHistory, model.PolicyVersion{ID: randomToken(10), Target: target, Revision: p.Revision, CreatedAt: time.Now().UTC(), Actor: actor, Policy: p}) + if len(a.store.data.PolicyHistory) > 500 { + a.store.data.PolicyHistory = append([]model.PolicyVersion(nil), a.store.data.PolicyHistory[len(a.store.data.PolicyHistory)-500:]...) + } +} +func (a *App) policyRollback(w http.ResponseWriter, r *http.Request) { + id, rev := r.PathValue("id"), r.PathValue("revision") + actor := requestActor(r) + a.store.mu.Lock() + defer a.store.mu.Unlock() + rec, ok := a.store.data.Agents[id] + if !ok { + httpx.Error(w, 404, "agent not found") + return + } + var found *model.Policy + for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- { + v := a.store.data.PolicyHistory[i] + if v.Target == "agent:"+id && v.Revision == rev { + p := v.Policy + found = &p + break + } + } + if found == nil { + httpx.Error(w, 404, "revision not found") + return + } + found.Revision = randomToken(12) + found.UpdatedAt = time.Now().UTC() + rec.DesiredPolicy = found + a.store.data.Agents[id] = rec + a.recordPolicyVersionLocked("agent:"+id, *found, actor) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: rec.Name, Result: "queued", Details: rev + " -> " + found.Revision}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 200, found) +} +func (a *App) policyHistory(w http.ResponseWriter, r *http.Request) { + a.store.mu.RLock() + defer a.store.mu.RUnlock() + httpx.JSON(w, 200, map[string]any{"history": append([]model.PolicyVersion(nil), a.store.data.PolicyHistory...)}) +} + +func (a *App) agentControl(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + var req struct { + Mode string `json:"mode"` + RestartWhenDrained bool `json:"restart_when_drained"` + Tags map[string]string `json:"tags"` + FarmIDs []string `json:"farm_ids"` + } + if err := httpx.DecodeJSON(r, &req, 128<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode != "" && mode != "online" && mode != "drain" && mode != "maintenance" { + httpx.Error(w, 400, "mode must be online, drain or maintenance") + return + } + id := r.PathValue("id") + a.store.mu.Lock() + defer a.store.mu.Unlock() + rec, ok := a.store.data.Agents[id] + if !ok { + httpx.Error(w, 404, "agent not found") + return + } + if mode != "" { + rec.MaintenanceMode = mode + } + rec.RestartWhenDrained = req.RestartWhenDrained + if req.Tags != nil { + rec.Tags = req.Tags + } + if req.FarmIDs != nil { + rec.FarmIDs = req.FarmIDs + } + a.store.data.Agents[id] = rec + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "agent_control", Target: rec.Name, Result: "success", Details: fmt.Sprintf("mode=%s restart_when_drained=%v", rec.MaintenanceMode, rec.RestartWhenDrained)}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + rec.TokenHash = "" + httpx.JSON(w, 200, rec) +} + +func (a *App) sessionAction(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + id := r.PathValue("id") + session64, err := strconv.ParseUint(r.PathValue("session"), 10, 32) + if err != nil { + httpx.Error(w, 400, "invalid session id") + return + } + var req model.SessionActionRequest + if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + action := strings.ToLower(strings.TrimSpace(req.Action)) + if action != "logoff" && action != "disconnect" && action != "message" { + httpx.Error(w, 400, "invalid action") + return + } + if action == "logoff" && !a.hasPermission(r, "session_logoff") { + httpx.Error(w, 403, "role may not log off sessions") + return + } + if action == "message" && strings.TrimSpace(req.Message) == "" { + httpx.Error(w, 400, "message is required") + return + } + cmd := model.SessionCommand{ID: randomToken(12), Action: action, SessionID: uint32(session64), Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)} + a.queueCommand(w, id, cmd) +} +func (a *App) sessionBulkAction(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + var req struct { + Action string `json:"action"` + Scope string `json:"scope"` + Title string `json:"title"` + Message string `json:"message"` + } + if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + action := strings.ToLower(strings.TrimSpace(req.Action)) + scope := strings.ToLower(strings.TrimSpace(req.Scope)) + if scope == "" { + scope = "all" + } + if action != "logoff" && action != "disconnect" && action != "message" { + httpx.Error(w, 400, "invalid action") + return + } + if scope != "all" && scope != "disconnected" && scope != "active" { + httpx.Error(w, 400, "scope must be all, active or disconnected") + return + } + if action == "logoff" && !a.hasPermission(r, "session_logoff") { + httpx.Error(w, 403, "role may not log off sessions") + return + } + if action == "message" && strings.TrimSpace(req.Message) == "" { + httpx.Error(w, 400, "message is required") + return + } + id := r.PathValue("id") + now := time.Now().UTC() + a.store.mu.Lock() + defer a.store.mu.Unlock() + rec, ok := a.store.data.Agents[id] + if !ok { + httpx.Error(w, 404, "agent not found") + return + } + policy := rec.Snapshot.Policy + if p := a.effectivePolicyLocked(rec); p != nil { + policy = *p + } + if !policy.Sessions.ControlEnabled { + httpx.Error(w, 409, "session control is disabled by policy") + return + } + added := 0 + for _, sess := range rec.Snapshot.Sessions { + if sess.User == "" { + continue + } + state := strings.ToLower(sess.State) + if scope == "disconnected" && state != "disconnected" { + continue + } + if scope == "active" && state != "active" && state != "connected" { + continue + } + if len(rec.PendingCommands) >= 100 { + break + } + rec.PendingCommands = append(rec.PendingCommands, model.SessionCommand{ID: randomToken(12), Action: action, SessionID: sess.ID, Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: now, ExpiresAt: now.Add(2 * time.Minute)}) + added++ + } + a.store.data.Agents[id] = rec + a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: requestActor(r), Action: "bulk:" + action, Target: rec.Name, Result: "queued", Details: fmt.Sprintf("scope=%s sessions=%d", scope, added)}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 202, map[string]any{"queued": added}) +} + +func (a *App) processKill(w http.ResponseWriter, r *http.Request) { + if !httpx.SameOrigin(r) { + httpx.Error(w, 403, "cross-origin request rejected") + return + } + pid64, err := strconv.ParseUint(r.PathValue("pid"), 10, 32) + if err != nil { + httpx.Error(w, 400, "invalid pid") + return + } + agentID := r.PathValue("id") + rec, ok := a.store.get(agentID) + if !ok { + httpx.Error(w, 404, "agent not found") + return + } + pid := uint32(pid64) + var sessionID uint32 + for _, process := range rec.Snapshot.Processes { + if process.PID == pid { + sessionID = process.SessionID + break + } + } + if sessionID == 0 { + httpx.Error(w, 404, "process is no longer present in a user session") + return + } + cmd := model.SessionCommand{ID: randomToken(12), Action: "kill_process", SessionID: sessionID, PID: pid, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)} + a.queueCommand(w, agentID, cmd) +} +func (a *App) queueCommand(w http.ResponseWriter, id string, cmd model.SessionCommand) { + a.store.mu.Lock() + defer a.store.mu.Unlock() + rec, ok := a.store.data.Agents[id] + if !ok { + httpx.Error(w, 404, "agent not found") + return + } + policy := rec.Snapshot.Policy + if p := a.effectivePolicyLocked(rec); p != nil { + policy = *p + } + if !policy.Sessions.ControlEnabled { + httpx.Error(w, 409, "session control is disabled by policy") + return + } + if len(rec.PendingCommands) >= 100 { + httpx.Error(w, 429, "too many pending commands") + return + } + rec.PendingCommands = append(rec.PendingCommands, cmd) + a.store.data.Agents[id] = rec + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: cmd.RequestedBy, Action: "command:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "queued"}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 202, cmd) +} + +func (a *App) farms(w http.ResponseWriter, r *http.Request) { + a.store.mu.RLock() + defer a.store.mu.RUnlock() + out := make([]model.Farm, 0, len(a.store.data.Farms)) + for _, f := range a.store.data.Farms { + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + httpx.JSON(w, 200, map[string]any{"farms": out}) +} +func (a *App) farmCreate(w http.ResponseWriter, r *http.Request) { + var f model.Farm + if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + if f.ID == "" { + f.ID = randomToken(8) + } + a.saveFarm(w, r, f, false) +} +func (a *App) farmUpdate(w http.ResponseWriter, r *http.Request) { + var f model.Farm + if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + f.ID = r.PathValue("id") + a.saveFarm(w, r, f, true) +} +func (a *App) saveFarm(w http.ResponseWriter, r *http.Request, f model.Farm, mustExist bool) { + if strings.TrimSpace(f.Name) == "" { + httpx.Error(w, 400, "farm name is required") + return + } + if f.RequiredTags == nil { + f.RequiredTags = map[string]string{} + } + if f.Policy != nil { + config.NormalizePolicy(f.Policy) + if err := config.ValidatePolicy(*f.Policy); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + if f.Policy.Revision == "" { + f.Policy.Revision = randomToken(12) + f.Policy.UpdatedAt = time.Now().UTC() + } + } + a.store.mu.Lock() + defer a.store.mu.Unlock() + _, exists := a.store.data.Farms[f.ID] + if mustExist && !exists { + httpx.Error(w, 404, "farm not found") + return + } + a.store.data.Farms[f.ID] = f + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_save", Target: f.Name, Result: "success"}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 200, f) +} +func (a *App) farmDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + a.store.mu.Lock() + defer a.store.mu.Unlock() + f, ok := a.store.data.Farms[id] + if !ok { + httpx.Error(w, 404, "farm not found") + return + } + delete(a.store.data.Farms, id) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_delete", Target: f.Name, Result: "success"}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + w.WriteHeader(204) +} +func (a *App) resources(w http.ResponseWriter, r *http.Request) { + a.store.mu.RLock() + defer a.store.mu.RUnlock() + out := make([]model.Resource, 0, len(a.store.data.Resources)) + for _, x := range a.store.data.Resources { + out = append(out, x) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + httpx.JSON(w, 200, map[string]any{"resources": out}) +} +func (a *App) resourceCreate(w http.ResponseWriter, r *http.Request) { + var x model.Resource + if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + if x.ID == "" { + x.ID = randomToken(8) + } + a.saveResource(w, r, x, false) +} +func (a *App) resourceUpdate(w http.ResponseWriter, r *http.Request) { + var x model.Resource + if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil { + httpx.Error(w, 400, err.Error()) + return + } + x.ID = r.PathValue("id") + a.saveResource(w, r, x, true) +} +func (a *App) saveResource(w http.ResponseWriter, r *http.Request, x model.Resource, mustExist bool) { + if strings.TrimSpace(x.Name) == "" || strings.TrimSpace(x.FarmID) == "" { + httpx.Error(w, 400, "resource name and farm_id are required") + return + } + if x.Kind != "desktop" && x.Kind != "remoteapp" { + httpx.Error(w, 400, "kind must be desktop or remoteapp") + return + } + if x.Kind == "remoteapp" && strings.TrimSpace(x.RemoteApp) == "" { + httpx.Error(w, 400, "remote_app is required for remoteapp resources") + return + } + a.store.mu.Lock() + defer a.store.mu.Unlock() + if _, ok := a.store.data.Farms[x.FarmID]; !ok { + httpx.Error(w, 400, "farm does not exist") + return + } + _, exists := a.store.data.Resources[x.ID] + if mustExist && !exists { + httpx.Error(w, 404, "resource not found") + return + } + if x.Enabled { + for id, existing := range a.store.data.Resources { + if id == x.ID || !existing.Enabled { + continue + } + if x.GuacamoleConnectionID != "" && existing.GuacamoleConnectionID == x.GuacamoleConnectionID { + httpx.Error(w, 409, "guacamole_connection_id is already mapped by another enabled resource") + return + } + if x.GuacamoleConnectionName != "" && strings.EqualFold(existing.GuacamoleConnectionName, x.GuacamoleConnectionName) { + httpx.Error(w, 409, "guacamole_connection_name is already mapped by another enabled resource") + return + } + } + } + a.store.data.Resources[x.ID] = x + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_save", Target: x.Name, Result: "success"}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + httpx.JSON(w, 200, x) +} +func (a *App) resourceDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + a.store.mu.Lock() + defer a.store.mu.Unlock() + x, ok := a.store.data.Resources[id] + if !ok { + httpx.Error(w, 404, "resource not found") + return + } + delete(a.store.data.Resources, id) + a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_delete", Target: x.Name, Result: "success"}) + if err := a.store.saveLocked(); err != nil { + httpx.Error(w, 500, err.Error()) + return + } + w.WriteHeader(204) +} + +func (a *App) history(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items := a.store.history(limit) + user := normalizeUser(r.URL.Query().Get("user")) + agent := strings.TrimSpace(r.URL.Query().Get("agent")) + if user != "" || agent != "" { + filtered := make([]model.SessionHistoryEvent, 0, len(items)) + for _, x := range items { + if user != "" && normalizeUser(x.User) != user { + continue + } + if agent != "" && x.AgentID != agent { + continue + } + filtered = append(filtered, x) + } + items = filtered + } + httpx.JSON(w, 200, map[string]any{"history": items}) +} +func (a *App) audit(w http.ResponseWriter, r *http.Request) { + httpx.JSON(w, 200, map[string]any{"audit": a.store.audit(500)}) +} +func (a *App) alerts(w http.ResponseWriter, r *http.Request) { + a.store.mu.RLock() + defer a.store.mu.RUnlock() + out := make([]model.Alert, 0, len(a.store.data.Alerts)) + for _, x := range a.store.data.Alerts { + out = append(out, x) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Active != out[j].Active { + return out[i].Active + } + return out[i].LastSeenAt.After(out[j].LastSeenAt) + }) + httpx.JSON(w, 200, map[string]any{"alerts": out}) +} + +func (a *App) leases(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC() + a.store.mu.RLock() + out := make([]model.UserLease, 0, len(a.store.data.Leases)) + for _, l := range a.store.data.Leases { + if now.Before(l.ExpiresAt) { + out = append(out, l) + } + } + a.store.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) }) + httpx.JSON(w, 200, map[string]any{"leases": out}) +} + +func (a *App) monitor(ctx context.Context) { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + a.store.mu.Lock() + leasesChanged := false + for key, lease := range a.store.data.Leases { + if now.After(lease.ExpiresAt) { + delete(a.store.data.Leases, key) + leasesChanged = true + } + } + if !a.cfg.Alerts.Enabled { + if leasesChanged { + if err := a.store.saveLocked(); err != nil { + log.Printf("master monitor persistence: %v", err) + } + } + a.store.mu.Unlock() + continue + } + changed := []model.Alert{} + for id, rec := range a.store.data.Agents { + if now.Sub(rec.LastSeen) > time.Duration(a.cfg.Alerts.OfflineSeconds)*time.Second { + changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", fmt.Sprintf("Agent seit %s nicht erreichbar", now.Sub(rec.LastSeen).Round(time.Second)), true, now)...) + } else { + changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", "", false, now)...) + } + } + if err := a.store.saveLocked(); err != nil { + log.Printf("master monitor persistence: %v", err) + } + a.store.mu.Unlock() + for _, al := range changed { + a.notifyAlert(al) + } + } + } +} +func (a *App) evaluateAgentAlertsLocked(rec model.AgentRecord, now time.Time) []model.Alert { + if !a.cfg.Alerts.Enabled { + return nil + } + changed := []model.Alert{} + s := rec.Snapshot.Server + mem := 0.0 + if s.MemoryTotal > 0 { + mem = float64(s.MemoryTotal-s.MemoryAvailable) * 100 / float64(s.MemoryTotal) + } + checks := []struct { + key, sev, typ, msg string + active bool + }{{"cpu:" + rec.ID, "warning", "cpu_high", fmt.Sprintf("CPU %.1f%%", s.CPUPercent), s.CPUPercent >= float64(a.cfg.Alerts.CPUPercent)}, {"memory:" + rec.ID, "warning", "memory_high", fmt.Sprintf("RAM %.1f%%", mem), mem >= float64(a.cfg.Alerts.MemoryPercent)}, {"disk:" + rec.ID, "critical", "disk_low", fmt.Sprintf("Systemdisk %d GiB frei", s.DiskFree>>30), s.DiskTotal > 0 && s.DiskFree < uint64(a.cfg.Alerts.DiskFreeGB)<<30}, {"health:" + rec.ID, "critical", "health_low", fmt.Sprintf("Health Score %d", rec.Snapshot.Health.Score), rec.Snapshot.Health.Score < a.cfg.Alerts.HealthScore}} + _, disc := sessionCounts(rec.Snapshot.Sessions) + checks = append(checks, struct { + key, sev, typ, msg string + active bool + }{"disc:" + rec.ID, "warning", "disconnected_sessions", fmt.Sprintf("%d getrennte Sitzungen", disc), disc >= a.cfg.Alerts.DisconnectedSessions}) + maxLogonMS := int64(0) + for _, telemetry := range rec.Snapshot.Telemetry { + if telemetry.ObservedLogonMS > maxLogonMS { + maxLogonMS = telemetry.ObservedLogonMS + } + } + checks = append(checks, struct { + key, sev, typ, msg string + active bool + }{"logon:" + rec.ID, "warning", "logon_slow", fmt.Sprintf("Letzte gemessene Logon-Pipeline %.1f s", float64(maxLogonMS)/1000), maxLogonMS >= int64(a.cfg.Alerts.LogonDurationSeconds)*1000}) + fail := 0 + for _, st := range rec.Snapshot.ProfileStatus { + if st.LastBackupError != "" || st.LastRestoreError != "" { + fail++ + } + } + checks = append(checks, struct { + key, sev, typ, msg string + active bool + }{"profiles:" + rec.ID, "critical", "profile_failures", fmt.Sprintf("%d Profile mit Fehlerstatus", fail), fail >= a.cfg.Alerts.ProfileFailures}) + for _, c := range checks { + changed = append(changed, a.setAlertLocked(c.key, rec, c.sev, c.typ, c.msg, c.active, now)...) + } + return changed +} +func (a *App) setAlertLocked(key string, rec model.AgentRecord, severity, typ, msg string, active bool, now time.Time) []model.Alert { + al, exists := a.store.data.Alerts[key] + if !exists { + al = model.Alert{ID: randomToken(8), Key: key, AgentID: rec.ID, Hostname: rec.Name, Severity: severity, Type: typ, FirstSeenAt: now} + } + if active { + was := al.Active + al.Active = true + al.LastSeenAt = now + al.Message = msg + al.Severity = severity + if !was { + al.FirstSeenAt = now + } + a.store.data.Alerts[key] = al + if !was || now.Sub(al.LastNotifiedAt) >= time.Duration(a.cfg.Alerts.NotificationMinInterval)*time.Second { + al.LastNotifiedAt = now + a.store.data.Alerts[key] = al + return []model.Alert{al} + } + return nil + } + if exists && al.Active { + al.Active = false + al.ResolvedAt = now + al.LastSeenAt = now + al.LastNotifiedAt = now + a.store.data.Alerts[key] = al + return []model.Alert{al} + } + return nil +} +func (a *App) notifyAlert(al model.Alert) { + if !a.cfg.Alerts.Enabled || strings.TrimSpace(a.cfg.Alerts.WebhookURL) == "" { + return + } + body, _ := json.Marshal(al) + req, err := http.NewRequest(http.MethodPost, a.cfg.Alerts.WebhookURL, bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := a.http.Do(req) + if err != nil { + log.Printf("alert webhook: %v", err) + return + } + _ = resp.Body.Close() +} + +func (a *App) metrics(w http.ResponseWriter, r *http.Request) { + recs := a.store.all() + now := time.Now().UTC() + online, active, disconnected, profileJobs, cleanupJobs := 0, 0, 0, 0, 0 + healthTotal := 0 + for _, rec := range recs { + if agentOnline(rec, now, a.cfg.OfflineAfterSeconds) { + online++ + } + aa, dd := sessionCounts(rec.Snapshot.Sessions) + active += aa + disconnected += dd + profileJobs += len(rec.Snapshot.ProfileJobs) + cleanupJobs += len(rec.Snapshot.PendingCleanup) + healthTotal += rec.Snapshot.Health.Score + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + fmt.Fprintf(w, "sessionguard_master_up 1\nsessionguard_agents_total %d\nsessionguard_agents_online %d\nsessionguard_sessions_active %d\nsessionguard_sessions_disconnected %d\nsessionguard_profile_jobs %d\nsessionguard_cleanup_jobs %d\nsessionguard_health_score_sum %d\n", len(recs), online, active, disconnected, profileJobs, cleanupJobs, healthTotal) +} + +func (a *App) me(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFrom(r) + httpx.JSON(w, 200, map[string]any{"user": u, "roles": a.roles(r), "permissions": a.permissions(r)}) +} + +var rolePermissions = map[string][]string{"viewer": {"view"}, "helpdesk": {"view", "session"}, "operator": {"view", "session", "session_logoff", "process", "maintenance"}, "profile_admin": {"view", "session", "profile"}, "policy_admin": {"view", "policy"}, "auditor": {"view", "audit"}, "admin": {"view", "session", "session_logoff", "process", "maintenance", "profile", "policy", "audit", "manage"}} + +func (a *App) roles(r *http.Request) []string { + u, ok := auth.UserFrom(r) + if !ok { + return nil + } + if len(a.cfg.RBAC.Groups) == 0 { + return []string{"admin"} + } + set := map[string]bool{} + for _, g := range u.Groups { + for configured, roles := range a.cfg.RBAC.Groups { + if strings.EqualFold(g, configured) { + for _, role := range roles { + set[strings.ToLower(role)] = true + } + } + } + } + if len(set) == 0 && a.cfg.RBAC.DefaultRole != "" { + set[strings.ToLower(a.cfg.RBAC.DefaultRole)] = true + } + out := make([]string, 0, len(set)) + for role := range set { + out = append(out, role) + } + sort.Strings(out) + return out +} +func (a *App) permissions(r *http.Request) []string { + set := map[string]bool{} + for _, role := range a.roles(r) { + for _, p := range rolePermissions[role] { + set[p] = true + } + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out +} +func (a *App) hasPermission(r *http.Request, p string) bool { + for _, x := range a.permissions(r) { + if x == p { + return true + } + } + return false +} +func (a *App) require(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !a.hasPermission(r, permission) { + httpx.Error(w, 403, "insufficient role permission: "+permission) + return + } + next.ServeHTTP(w, r) + }) +} +func requestActor(r *http.Request) string { + u, ok := auth.UserFrom(r) + if !ok { + return "unknown" + } + if strings.TrimSpace(u.Email) != "" { + return u.Email + } + if strings.TrimSpace(u.Name) != "" { + return u.Name + } + return u.Sub } func (a *App) masterPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, masterHTML) } - func randomToken(n int) string { b := make([]byte, n) _, _ = rand.Read(b) @@ -269,3 +1504,117 @@ func securityHeaders(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } +func commandTarget(rec model.AgentRecord, cmd model.SessionCommand) string { + if cmd.PID != 0 { + return fmt.Sprintf("%s/process/%d", rec.Name, cmd.PID) + } + if cmd.SessionID != 0 { + return fmt.Sprintf("%s/session/%d", rec.Name, cmd.SessionID) + } + return rec.Name +} +func userSessionCount(ss []model.Session) int { + n := 0 + for _, s := range ss { + if s.User != "" { + n++ + } + } + return n +} +func hasPendingAction(cmds []model.SessionCommand, action string) bool { + for _, c := range cmds { + if c.Action == action { + return true + } + } + return false +} +func agentOnline(rec model.AgentRecord, now time.Time, seconds int) bool { + return !rec.LastSeen.IsZero() && now.Sub(rec.LastSeen) < time.Duration(seconds)*time.Second +} +func sessionCounts(ss []model.Session) (int, int) { + a, d := 0, 0 + for _, s := range ss { + if s.User == "" { + continue + } + if s.State == "Active" { + a++ + } + if s.State == "Disconnected" { + d++ + } + } + return a, d +} +func brokerScore(rec model.AgentRecord) float64 { + active, disc := sessionCounts(rec.Snapshot.Sessions) + mem := 0.0 + if rec.Snapshot.Server.MemoryTotal > 0 { + mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal) + } + return float64(rec.Snapshot.Health.Score)*10 - float64(active)*20 - float64(disc)*5 - rec.Snapshot.Server.CPUPercent*2 - mem +} +func normalizeUser(u string) string { + u = strings.ToLower(strings.TrimSpace(u)) + u = strings.ReplaceAll(u, "/", `\`) + return u +} +func sessionMatchesUser(s model.Session, key string) bool { + if s.User == "" { + return false + } + key = normalizeUser(key) + short := normalizeUser(s.User) + qualified := normalizeUser(displaySessionUser(s)) + + // A caller that supplied an explicit DOMAIN\user identity is expressing a + // domain boundary. Never drop that qualifier merely because the short user + // name happens to match another domain. + if strings.Contains(key, `\`) { + return qualified == key + } + if key == short || key == qualified { + return true + } + if s.Domain != "" && normalizeUser(s.User+"@"+s.Domain) == key { + return true + } + // PocketID preferred_username is often a UPN while WTS exposes a NetBIOS + // domain. In that case the UPN suffix cannot be reliably derived from WTS; + // permit local-part matching for UPNs, but retain strict matching above for + // explicit DOMAIN\user values. + if i := strings.Index(key, "@"); i > 0 && key[:i] == short { + return true + } + return false +} +func displaySessionUser(s model.Session) string { + if s.Domain != "" { + return s.Domain + `\` + s.User + } + return s.User +} +func contains(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} +func tagsMatch(have, need map[string]string) bool { + for k, v := range need { + if !strings.EqualFold(have[k], v) { + return false + } + } + return true +} +func resourceID(r *model.Resource) string { + if r == nil { + return "" + } + return r.ID +} diff --git a/internal/master/store.go b/internal/master/store.go index a740948..2b4a0a6 100644 --- a/internal/master/store.go +++ b/internal/master/store.go @@ -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] + } +} diff --git a/internal/master/ui.go b/internal/master/ui.go index 4bd073e..f8918fb 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -1,26 +1,38 @@ package master -const masterHTML = ` - -SessionGuard Master -
SessionGuard
Master Console
-
Server
Online
Aktive Sitzungen
Cleanup geplant
-

Terminalserver

Server auswählen

Links einen Agent auswählen.
` +const masterHTML = `SessionGuard Master
SessionGuard
Master Console · v0.3 · Broker & Director
Server
Online
Aktiv
Getrennt
Profil-Jobs
Cleanup
Farms
Alerts

Terminalserver

Server auswählen

Links einen Agent auswählen.

Farms & Broker

Published Resources

Session-Historie

Policy-Historie & Rollback

Alerts

Audit-Log

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

Aktive Broker-Leases

'+brokerLeases.map(l=>'').join('')+'
BenutzerAgentFarmResourceGrundBis
'+esc(l.user_key)+''+esc(l.agent_id)+''+esc(l.farm_id||'–')+''+esc(l.resource_id||'–')+''+esc(l.reason||'–')+''+when(l.expires_at)+'
':'';$('farms').innerHTML='
'+(farmCache.length?''+farmCache.map(f=>'').join('')+'
NameIDAgentenRequired Tags
'+esc(f.name)+''+esc(f.id)+''+esc((f.agent_ids||[]).length)+''+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'
':'
Noch keine Farms.
')+lease} +function renderResources(){let opts=farmCache.map(f=>'').join('');$('resources').innerHTML='
'+(resourceCache.length?''+resourceCache.map(x=>'').join('')+'
NameTypFarmGuacamoleRemoteApp
'+esc(x.name)+''+esc(x.kind)+''+esc(x.farm_id)+''+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+''+esc(x.remote_app||'–')+'
':'
Noch keine Resources.
')} +function eventsHTML(a){let e=((a.snapshot||{}).events||[]).slice().reverse().slice(0,160);return e.length?''+e.map(x=>'').join('')+'
ZeitTypBenutzerMeldung
'+when(x.time)+''+esc(x.level)+''+esc(x.user||'–')+''+esc(x.message)+'
':'
Keine Ereignisse.
'} +function activePolicy(a){if(a.desired_policy&&a.desired_policy.revision)return a.desired_policy;if(a.snapshot&&a.snapshot.policy)return a.snapshot.policy;return{cleanup:{grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,allowed_profile_roots:['C:\\Users']},profiles:{retry_seconds:60,keep_versions:2,folders:[]},sessions:{disconnected_timeout_seconds:3600},templates:[]}} +function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overview')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='
Sitzungen
Logon Performance
Prozesse
Profil-Pipeline
Agent-Ereignisse
Policy
'+policyForm(p,a)+'
';editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overview').innerHTML=overview(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)} +function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'
Keine weiteren Angaben.
';if(k==='url')return'';if(k==='shortcut'){let s=t.shortcut||{};return'
'}return'
'} +function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'
'+esc(t.id||('Template '+(i+1)))+'
'+templateSpecific(t)+'
').join(''):'
Keine Templates.
'} +function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})} +function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'
'+esc(f.path||('Ordner '+(i+1)))+'
').join(''):'
Keine Profilordner.
'} +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'
Editor-Basis: '+esc(source)+'. Live-Refresh verändert offene Eingaben nicht.
Profil-Sicherung & Wiederherstellung
Profilordner
Sitzungsrichtlinie
Profil-Cleanup
Templates
'} +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);` diff --git a/internal/model/types.go b/internal/model/types.go index c397835..bd2c110 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -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"` } diff --git a/internal/profile/sync.go b/internal/profile/sync.go new file mode 100644 index 0000000..685692b --- /dev/null +++ b/internal/profile/sync.go @@ -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 +} diff --git a/internal/profile/sync_test.go b/internal/profile/sync_test.go new file mode 100644 index 0000000..1ef72e6 --- /dev/null +++ b/internal/profile/sync_test.go @@ -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) + } +} diff --git a/internal/windowsx/windows_stub.go b/internal/windowsx/windows_stub.go index 2d978fd..d19df0d 100644 --- a/internal/windowsx/windows_stub.go +++ b/internal/windowsx/windows_stub.go @@ -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 } diff --git a/internal/windowsx/windows_windows.go b/internal/windowsx/windows_windows.go index 40b8d32..761379f 100644 --- a/internal/windowsx/windows_windows.go +++ b/internal/windowsx/windows_windows.go @@ -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 { diff --git a/scripts/install-agent.ps1 b/scripts/install-agent.ps1 index 6eb9afd..216b3ba 100644 --- a/scripts/install-agent.ps1 +++ b/scripts/install-agent.ps1 @@ -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" }