From e104e7289f14f587d6bb8dc8ffde8e78f9f1a837 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sat, 22 Aug 2026 23:48:15 +0200 Subject: [PATCH] 0.4.0 --- CHANGELOG.md | 13 ++ README.md | 18 +- deploy/guacamole/CI-CD.md | 6 +- .../docker-compose.sessionguard.example.yml | 2 +- docs/API.md | 29 ++- docs/ARCHITECTURE.md | 28 ++- docs/BROKER.md | 27 ++- docs/GUACAMOLE.md | 4 +- docs/INSTALLATION.md | 58 ++++- docs/REMOTEAPP.md | 105 +++++++++ guacamole-extension/Dockerfile | 7 +- guacamole-extension/pom.xml | 2 +- internal/agent/agent.go | 127 +++++++++- internal/agent/state.go | 73 +++--- internal/agent/ui.go | 10 +- internal/master/broker_test.go | 87 +++++++ internal/master/master.go | 120 +++++++++- internal/master/ui.go | 20 +- internal/model/types.go | 49 +++- internal/windowsx/remoteapp_status.go | 52 +++++ internal/windowsx/remoteapp_status_test.go | 45 ++++ internal/windowsx/remoteapp_stub.go | 10 + internal/windowsx/remoteapp_windows.go | 216 ++++++++++++++++++ 23 files changed, 1023 insertions(+), 85 deletions(-) create mode 100644 docs/REMOTEAPP.md create mode 100644 internal/windowsx/remoteapp_status.go create mode 100644 internal/windowsx/remoteapp_status_test.go create mode 100644 internal/windowsx/remoteapp_stub.go create mode 100644 internal/windowsx/remoteapp_windows.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b6962f2..fe88e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.4.0 — Agent-managed RemoteApps + +- Added a farm-scoped RemoteApp desired-state model to Published Resources. +- Windows Agent discovers RemoteApps through `root\CIMv2\TerminalServices` / `Win32_TSPublishedApplication`. +- Optional Agent-managed publication creates/updates aliases, executable/icon settings and command-line policy through the Terminal Services WMI provider. +- Reconciliation deletes only aliases originally created by SessionGuard; pre-existing/manual RemoteApps remain in place if management is later disabled. +- Agent heartbeat reports per-app executable, publication, ownership, sync and error state. +- Managed RemoteApps are brokered fail-closed per host until the Agent reports `published + path_exists + in_sync`. +- Master WebUI now exposes application path, alias, icon, command-line policy and per-farm readiness. +- Agent WebUI now includes a RemoteApps inventory/status view. +- Protocol version increased from 3 to 4; Master and Agent must be upgraded together. +- No Node.js/npm/frontend framework or build step introduced. + ## 0.3.4 — Modern Web UI - Master- und Agent-WebUI vollständig modernisiert, weiterhin ohne Framework oder Build-Schritt. diff --git a/README.md b/README.md index 11e0c8b..80447c5 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,24 @@ SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions. -**Current development version: 0.3.4 (Broker & Director production candidate)** +**Current development version: 0.4.0 (RemoteApp control plane)** > SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC. +## 0.4.0 RemoteApp control plane + +- The Master can define RemoteApp desired state per Published Resource/Farm. +- Windows Agents discover the local `Win32_TSPublishedApplication` inventory through the documented Terminal Services WMI provider. +- For resources with **Agent-managed RemoteApp publication** enabled, the Agent creates/updates the local RemoteApp registration and removes only aliases that the Agent itself originally created. Existing/manual RemoteApps may be adopted for desired-state checks but are never deleted merely because management is later disabled. +- Executable presence, publication state and desired-state convergence are reported back in every Agent snapshot. +- The broker fails closed per managed RemoteApp: a host is eligible only when that application is present, published and in sync. Desktop resources and unmanaged RemoteApps keep their previous placement behavior. +- Master and Agent WebUIs show RemoteApp configuration/readiness without Node.js or a frontend build chain. +- Agent protocol version is now `4`; upgrade Master and Agents together. + +> The local Terminal Services WMI provider is used instead of direct registry manipulation. This mode should be canary-tested on the exact Windows Server/RDSH versions in your environment; it does not claim to recreate every Microsoft Connection Broker/Collection management semantic. + +See `docs/REMOTEAPP.md` for the canary, ownership/rollback behavior and Guacamole mapping. + ## 0.3.4 Modern Web UI - Master and Agent consoles use a responsive enterprise-style sidebar and dashboard layout. @@ -295,4 +309,4 @@ The current source tree contains unit tests for configuration, templates, profil ## Production-candidate status -The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.4 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment. +The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.4.0 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment. diff --git a/deploy/guacamole/CI-CD.md b/deploy/guacamole/CI-CD.md index 9e98827..273a414 100644 --- a/deploy/guacamole/CI-CD.md +++ b/deploy/guacamole/CI-CD.md @@ -48,9 +48,9 @@ Traefik labels unchanged. git describe --tags --always | sed 's/^v//' ``` -A commit tagged `v0.3.1` therefore publishes `0.3.1`; later commits are named -like `0.3.1-1-g0123456` until the next tag. +A commit tagged `v0.4.0` therefore publishes `0.4.0`; later commits are named +like `0.4.0-1-g0123456` until the next tag. -The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.1.jar`. +The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.4.0.jar`. Maven may therefore change the project version without requiring a Dockerfile change. diff --git a/deploy/guacamole/docker-compose.sessionguard.example.yml b/deploy/guacamole/docker-compose.sessionguard.example.yml index 0c2d840..1dad03e 100644 --- a/deploy/guacamole/docker-compose.sessionguard.example.yml +++ b/deploy/guacamole/docker-compose.sessionguard.example.yml @@ -2,7 +2,7 @@ # Build context must point at the SessionGuard repository root. services: guacamole: - image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.1} + image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.4.0} build: context: ../.. dockerfile: deploy/guacamole/Dockerfile.guacamole diff --git a/docs/API.md b/docs/API.md index 8c0cdea..a755370 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,4 +1,4 @@ -# HTTP API (v0.3) +# HTTP API (v0.4) All JSON APIs return an error object with an `error` field on failure unless otherwise stated. @@ -114,7 +114,26 @@ Farm fields include `name`, `description`, `agent_ids`, `required_tags`, optiona - `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. +Resource fields include desktop/RemoteApp kind, farm ID, Guacamole connection ID/name and RemoteApp parameters. RemoteApp resources additionally support Agent-managed local publication: + +```json +{ + "name": "Sage", + "kind": "remoteapp", + "farm_id": "erp", + "remote_app": "||Sage", + "manage_remote_app": true, + "remote_app_path": "C:\\Program Files\\Sage\\Sage.exe", + "remote_app_icon_path": "", + "remote_app_icon_index": 0, + "remote_app_command_line_setting": 0, + "remote_app_required_command_line": "", + "remote_app_show_in_portal": false, + "enabled": true +} +``` + +`remote_app_command_line_setting` is `0` (deny client-provided arguments), `1` (allow), or `2` (require the configured command line). For Agent-managed RemoteApps the broker mirrors this policy into Guacamole tokens: setting `0` emits no RemoteApp arguments, setting `1` uses `remote_app_args`, and setting `2` forces `remote_app_required_command_line`. ## Director/history @@ -128,6 +147,8 @@ Resource fields include desktop/RemoteApp kind, farm ID, Guacamole connection ID ## Agent protocol -`model.ProtocolVersion` is `3` for v0.3. +`model.ProtocolVersion` is `4` for v0.4. Master and Agent must use the same protocol version. -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. +Heartbeat snapshots contain server/health/session/process/telemetry/profile/event state plus `remote_apps`. Each RemoteApp status can report `resource_id`, `alias`, `path`, `path_exists`, `published`, `managed`, `in_sync` and `error`. + +Heartbeat responses contain effective desired policy, pending commands and `desired_remote_apps`. The latter is intentionally always present (including an empty array) so an Agent can safely remove SessionGuard-owned registrations that are no longer desired. Agents acknowledge completed commands in later heartbeats. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36a8b92..187ae61 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# SessionGuard 0.3 Architecture +# SessionGuard 0.4 Architecture ## Purpose @@ -9,7 +9,7 @@ SessionGuard separates the functions commonly bundled into a Citrix deployment i - **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. +- **SessionGuard Agent**: Windows/RDS integration, profile lifecycle, templates, telemetry, local RemoteApp reconciliation and fallback administration. SessionGuard intentionally does not implement a new remote-display protocol. @@ -76,6 +76,7 @@ Persistent agent state under `data_dir` contains: - profile status - bounded event history - bounded logon telemetry +- desired/managed RemoteApp state and last observed RemoteApp inventory The service can therefore continue cleanup/profile/session policy during a master outage using the last accepted policy. @@ -103,6 +104,29 @@ A request contains the authenticated username plus Guacamole connection ID/name 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. + +## RemoteApp desired-state model + +For a Resource with Agent-managed publication enabled, the Master derives a `RemoteAppSpec` for each member of the Resource farm and returns it in the normal outbound heartbeat response. No inbound WMI/WinRM connection from Master to the RDS host is introduced. + +```text +Master Resource desired state + | + | heartbeat response + v +Windows Agent + | + | local Terminal Services WMI provider + v +Win32_TSPublishedApplication + | + | observed state in next snapshot + v +Master / Broker readiness +``` + +The Agent discovers all local RemoteApps but mutates only explicitly desired SessionGuard resources. Deletion is limited to aliases recorded in Agent state as having been created by SessionGuard. For managed RemoteApps, the broker treats missing executable, missing publication, desired-state mismatch, or reconciliation errors as host-specific unavailability for that Resource. + ## Policy hierarchy Policy precedence: diff --git a/docs/BROKER.md b/docs/BROKER.md index cd66026..f40348a 100644 --- a/docs/BROKER.md +++ b/docs/BROKER.md @@ -1,4 +1,4 @@ -# Broker, Farms and Published Resources +# Broker, Farms, Apps and Desktops ## Goal @@ -46,7 +46,7 @@ Desktop example: } ``` -RemoteApp example: +RemoteApp example (Guacamole parameters only): ```json { @@ -61,6 +61,19 @@ RemoteApp example: } ``` +To let SessionGuard publish and continuously reconcile the RemoteApp on all members of the farm, additionally set: + +```json +{ + "manage_remote_app": true, + "remote_app_path": "C:\\Program Files\\Sage\\Sage.exe", + "remote_app_command_line_setting": 0, + "remote_app_show_in_portal": false +} +``` + +The Master sends this desired state only to Agents that are members of the Resource farm. Agents use the local Terminal Services WMI provider, report the observed state, and never delete unrelated/manual RemoteApps. + Connection ID matching is preferred where stable IDs are known; connection-name matching is case-insensitive and convenient for initial deployment. ## Placement algorithm @@ -73,13 +86,15 @@ Given `username`, optional `resource_id`, `farm_id`, and Guacamole connection id 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: +7. For a managed RemoteApp Resource, require the Agent to report that exact Resource as `published`, `path_exists` and `in_sync` with no error. This readiness gate also applies to reconnect and lease reuse. +8. 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. + - at or above `min_health_score`, + - application-ready when Agent-managed RemoteApp publication is enabled. +9. Rank candidates by broker score and select the highest score. +10. Create or refresh the lease and return connection tokens. ## Broker score diff --git a/docs/GUACAMOLE.md b/docs/GUACAMOLE.md index f474b00..ef1e000 100644 --- a/docs/GUACAMOLE.md +++ b/docs/GUACAMOLE.md @@ -52,7 +52,9 @@ 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. +If your Agents report short Windows computer names and your DNS requires a suffix, a Guacamole hostname such as `${SESSIONGUARD_HOST}.example.org` is valid. + +Create a matching Published Resource in SessionGuard by Guacamole connection ID or name. In v0.4 the Resource can optionally enable **Agent-managed RemoteApp publication** and specify the executable path. The Master then distributes the desired alias/path to every Agent in the farm. A managed RemoteApp is not brokered to a host until the Agent reports that it is present and in sync. The broker also aligns `${SESSIONGUARD_REMOTE_APP_ARGS}` with the Windows command-line policy (deny/allow/require). ## Header-auth security boundary diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 2a2d4fe..34d1ffd 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -350,16 +350,16 @@ git describe --tags --always | sed 's/^v//' Für einen sauberen Release: ```bash -git tag v0.3.1 -git push origin v0.3.1 +git tag v0.4.0 +git push origin v0.4.0 git push origin main ``` -Ein Commit exakt auf Tag `v0.3.1` erzeugt dann: +Ein Commit exakt auf Tag `v0.4.0` erzeugt dann: ```text -git.send.nrw/sendnrw/sessionguard:0.3.1 -git.send.nrw/sendnrw/sessionguard-guacamole:0.3.1 +git.send.nrw/sendnrw/sessionguard:0.4.0 +git.send.nrw/sendnrw/sessionguard-guacamole:0.4.0 ``` `latest` wird ebenfalls aktualisiert. @@ -367,8 +367,8 @@ git.send.nrw/sendnrw/sessionguard-guacamole:0.3.1 ### 7.3 Release prüfen ```bash -docker pull git.send.nrw/sendnrw/sessionguard:0.3.1 -docker pull git.send.nrw/sendnrw/sessionguard-guacamole:0.3.1 +docker pull git.send.nrw/sendnrw/sessionguard:0.4.0 +docker pull git.send.nrw/sendnrw/sessionguard-guacamole:0.4.0 ``` Für Produktion möglichst einen festen Versions-Tag und nicht ausschließlich `latest` verwenden. @@ -402,7 +402,7 @@ Anlegen: Beispiel: ```dotenv -SESSIONGUARD_VERSION=0.3.1 +SESSIONGUARD_VERSION=0.4.0 POSTGRES_VERSION=17 TRAEFIK_NETWORK=aio_proxy @@ -655,7 +655,7 @@ image: guacamole/guacamole:${GUACAMOLE_VERSION:-1.6.0} Nachher: ```yaml -image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.1} +image: git.send.nrw/sendnrw/sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.4.0} ``` `guac-init` kann weiterhin das offizielle Guacamole-Image verwenden. @@ -675,7 +675,7 @@ SESSIONGUARD_BROKER_TIMEOUT_MS: "2500" In die Guacamole `.env` zusätzlich: ```dotenv -SESSIONGUARD_VERSION=0.3.1 +SESSIONGUARD_VERSION=0.4.0 SESSIONGUARD_BROKER_API_KEY= ``` @@ -1909,7 +1909,7 @@ Vorher PostgreSQL sichern. Dann neuen Tag setzen, beispielsweise: ```dotenv -SESSIONGUARD_VERSION=0.3.1 +SESSIONGUARD_VERSION=0.4.0 ``` Update: @@ -1942,7 +1942,7 @@ Broker Gleichen SessionGuard-Release-Tag verwenden: ```dotenv -SESSIONGUARD_VERSION=0.3.1 +SESSIONGUARD_VERSION=0.4.0 ``` Dann: @@ -2299,3 +2299,37 @@ docs/TESTING.md ``` Diese Anleitung sollte zusammen mit `docs/TESTING.md` als Go-Live-Grundlage verwendet werden. + +## SessionGuard-managed RemoteApps (v0.4) + +This mode is intended for RD Session Hosts where you want SessionGuard Agents to maintain the local RemoteApp allow-list instead of manually publishing the same alias on every farm member. It uses the documented Terminal Services WMI provider in `root\CIMv2\TerminalServices`. Administrator rights are required for changes; the normal SessionGuard Windows service account must therefore retain its existing local system/administrative privileges. + +Before enabling it for a production farm, verify the provider on a canary RDS host: + +```powershell +Get-WmiObject ` + -Namespace 'root\cimv2\TerminalServices' ` + -Class Win32_TSPublishedApplication ` + -Authentication PacketPrivacy | + Select-Object Alias,Path,PathExists,CommandLineSetting +``` + +An empty result is valid when no RemoteApps are published. A class/provider error means this SessionGuard mode should not be enabled on that host until the Windows RDS installation is corrected. + +In **Master → Apps & Desktops**, create/edit a Resource: + +```text +Type: RemoteApp +Farm: +RemoteApp Alias: ||Sage +Agent-managed publication: enabled +Executable: C:\Program Files\Sage\Sage.exe +Command-line policy: deny / allow / require +``` + +The executable must exist at the configured path on each eligible farm member. The Agent reconciles on desired-state changes and periodically thereafter. The Master UI shows per-host readiness. A managed RemoteApp remains unavailable on any host that has not yet reported a healthy synchronized registration. + +SessionGuard only removes aliases that the same Agent originally created and recorded as owned. Existing manually published RemoteApps are discovered and can be adopted for desired-state checks, but are not deleted when management is later disabled. Canary-test this behavior on the exact Windows Server version used in your environment. + +Because v0.4 changes the heartbeat protocol to version 4, upgrade Master and Agents as one coordinated rollout. A v0.3 Agent will be rejected by a v0.4 Master with a protocol-version mismatch until upgraded. + diff --git a/docs/REMOTEAPP.md b/docs/REMOTEAPP.md new file mode 100644 index 0000000..0cacfad --- /dev/null +++ b/docs/REMOTEAPP.md @@ -0,0 +1,105 @@ +# Agent-managed RemoteApps (v0.4) + +SessionGuard v0.4 can maintain the local Windows RemoteApp registration on every RD Session Host that belongs to a Resource farm. The Master carries the desired state; the Agent reconciles it locally through the Terminal Services WMI provider and reports observed readiness back to the broker. + +## Safety model + +- No inbound WMI/WinRM connection from Master to Windows is introduced. +- The Windows Agent performs all WMI calls locally. +- Existing unrelated RemoteApps are discovered but not modified. +- If a pre-existing alias is explicitly put under SessionGuard management, SessionGuard may update that registration to the configured desired state, but records it as **adopted** rather than owned. +- Automatic removal is limited to aliases that the Agent itself originally created and persisted as owned. +- A managed RemoteApp is brokered fail-closed per host until the Agent reports: executable exists, registration exists, desired state is in sync, and no reconciliation error is present. + +## Windows prerequisite / canary check + +Run on one RD Session Host in an elevated Windows PowerShell: + +```powershell +Get-WmiObject ` + -Namespace 'root\cimv2\TerminalServices' ` + -Class Win32_TSPublishedApplication ` + -Authentication PacketPrivacy | + Select-Object Alias,Path,VPath,PathExists,CommandLineSetting,RequiredCommandLine,ShowInPortal +``` + +An empty result is valid. A class/provider error means the RemoteApp WMI provider is not available on this server and Agent-managed publication must not yet be enabled there. + +The Agent service must run with administrative/local-system privileges because Windows requires administrative rights to change these WMI objects. + +## Create the Resource in Master + +Open **Apps & Desktops** and create/edit a Resource: + +```text +Name: Sage 100 +Type: RemoteApp +Farm: ERP +Guacamole Connection: Sage 100 +RemoteApp Alias: ||Sage +Agent-managed publication: enabled +Executable: C:\Program Files\Sage\Sage.exe +Icon path: optional +Icon index: 0 +Command-line policy: deny / allow / require +Required arguments: only for require +Show in RD Web Access: optional +``` + +The managed alias must be globally unique across enabled SessionGuard-managed Resources. This prevents collisions when a Windows Agent belongs to multiple farms. + +### Command-line policy + +- `deny (0)`: Windows disallows RemoteApp command-line arguments and SessionGuard sends an empty `${SESSIONGUARD_REMOTE_APP_ARGS}` token. +- `allow (1)`: Windows permits arguments and SessionGuard sends the Resource's Guacamole arguments. +- `require (2)`: Windows requires the configured arguments and SessionGuard forces those same required arguments into `${SESSIONGUARD_REMOTE_APP_ARGS}`. + +## Agent reconciliation + +The desired Resource is sent in the normal outbound heartbeat only to members of its farm. The Agent reconciles immediately when desired state changes and then periodically (60 seconds) from persisted local desired state, including during a temporary Master outage. + +The local Agent UI contains **RemoteApps**. The Master server detail page also contains the observed inventory. + +Typical states: + +```text +Ready published=yes, path_exists=yes, in_sync=yes +Not ready executable missing / WMI publication failed / mismatch +SessionGuard currently managed desired state + (created) registration was originally created by SessionGuard + (adopted) registration existed before SessionGuard took it over +locally discovered visible only; not modified by SessionGuard +``` + +The Master Resource table shows `ready/total`, for example `3/3 ready`. The broker excludes only the non-ready hosts for this particular managed application; other desktops/resources can still use those hosts if their own health/rules allow it. + +## Guacamole + +Create one logical Guacamole RDP connection for the application: + +```text +hostname: ${SESSIONGUARD_HOST}.stadt-hilden.de +remote-app: ${SESSIONGUARD_REMOTE_APP} +remote-app-dir: ${SESSIONGUARD_REMOTE_APP_DIR} +remote-app-args: ${SESSIONGUARD_REMOTE_APP_ARGS} +``` + +Map its Guacamole connection ID (preferred) or connection name to the SessionGuard Resource. + +## Upgrade order + +v0.4 uses Agent protocol version 4. Upgrade Master and Agents as a coordinated rollout. Until an Agent is upgraded, a v0.4 Master will reject its old protocol heartbeat. For a production farm, update one canary Agent first, verify local RemoteApp status, and then roll out the remaining hosts quickly within the maintenance window. + +## Rollback behavior + +If you disable Agent-managed publication or delete the Resource: + +- SessionGuard-created aliases are removed on the Agent after it receives the new desired state. +- Adopted/pre-existing aliases remain locally published. +- Unrelated manually published RemoteApps are never part of SessionGuard cleanup. + +If an Agent loses its local ownership state, SessionGuard errs on the safe side and will not infer ownership merely from the alias; a stale registration may remain and can be removed manually. + +## Microsoft provider reference + +SessionGuard uses Microsoft's documented `Win32_TSPublishedApplication` / `Win32_TSPublishedApplicationList` provider in `Root\CIMv2\TerminalServices` with packet-privacy authentication. This feature manages the local RDSH RemoteApp provider; it does not attempt to reproduce every RD Connection Broker/Collection management semantic. diff --git a/guacamole-extension/Dockerfile b/guacamole-extension/Dockerfile index 681ae09..0523fd0 100644 --- a/guacamole-extension/Dockerfile +++ b/guacamole-extension/Dockerfile @@ -2,7 +2,10 @@ FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /src COPY pom.xml . COPY src ./src -RUN mvn -B -DskipTests package +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 FROM scratch -COPY --from=build /src/target/sessionguard-guacamole-0.3.0.jar /sessionguard-guacamole.jar +COPY --from=build /tmp/sessionguard-guacamole.jar /sessionguard-guacamole.jar diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml index 1b59dd5..2ca3a78 100644 --- a/guacamole-extension/pom.xml +++ b/guacamole-extension/pom.xml @@ -5,7 +5,7 @@ 4.0.0 info.hilden.sessionguard sessionguard-guacamole - 0.3.1 + 0.4.0 jar 11 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bc410b6..d5603bd 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -25,7 +25,7 @@ import ( "github.com/example/sessionguard/internal/windowsx" ) -const Version = "0.3.3" +const Version = "0.4.0" type App struct { cfg config.Agent @@ -85,6 +85,11 @@ func (a *App) worker(ctx context.Context) { defer poll.Stop() hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second) defer hb.Stop() + remoteApps := time.NewTicker(60 * time.Second) + defer remoteApps.Stop() + if a.remoteAppReconcileNeeded() { + a.syncRemoteApps() + } for { select { case <-ctx.Done(): @@ -103,10 +108,20 @@ func (a *App) worker(ctx context.Context) { poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) case <-hb.C: a.sendHeartbeat(ctx) + case <-remoteApps.C: + if a.remoteAppReconcileNeeded() { + a.syncRemoteApps() + } } } } +func (a *App) remoteAppReconcileNeeded() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return len(a.state.DesiredRemoteApps) > 0 || len(a.state.ManagedRemoteApps) > 0 +} + func (a *App) tick(ctx context.Context) { sessions, err := windowsx.Sessions() if err != nil { @@ -850,7 +865,7 @@ func (a *App) refreshSnapshot(server model.ServerInfo, sessions []model.Session, 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} + 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), RemoteApps: append([]model.RemoteAppStatus(nil), a.state.RemoteAppStatus...), Policy: a.state.Policy, PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now} } func (a *App) calculateHealth(server model.ServerInfo) model.HealthStatus { @@ -936,6 +951,12 @@ func (a *App) sendHeartbeat(ctx context.Context) { a.appendEventLocked("info", "policy_applied", "", fmt.Sprintf("Master-Policy %s angewendet", p.Revision)) } } + remoteAppsChanged := !remoteAppSpecsEqual(a.state.DesiredRemoteApps, resp.DesiredRemoteApps) + if remoteAppsChanged { + a.state.DesiredRemoteApps = append([]model.RemoteAppSpec(nil), resp.DesiredRemoteApps...) + a.appendEventLocked("info", "remoteapp_desired_state", "", fmt.Sprintf("RemoteApp-Sollzustand aktualisiert: %d Apps", len(resp.DesiredRemoteApps))) + } + remoteAppSyncDue := remoteAppsChanged || a.state.LastRemoteAppSync.IsZero() || time.Since(a.state.LastRemoteAppSync) >= 60*time.Second _ = a.store.save(a.state) current := make([]model.Session, 0, len(a.state.LastSessions)) if changed { @@ -944,6 +965,9 @@ func (a *App) sendHeartbeat(ctx context.Context) { } } a.mu.Unlock() + if remoteAppSyncDue { + a.syncRemoteApps() + } if changed { for _, s := range current { if s.SID != "" && s.User != "" { @@ -954,6 +978,105 @@ func (a *App) sendHeartbeat(ctx context.Context) { a.processCommands(resp.Commands) } +func remoteAppSpecsEqual(a, b []model.RemoteAppSpec) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func (a *App) syncRemoteApps() { + a.mu.RLock() + desired := append([]model.RemoteAppSpec(nil), a.state.DesiredRemoteApps...) + previous := make(map[string]model.RemoteAppSpec, len(a.state.ManagedRemoteApps)) + for alias, spec := range a.state.ManagedRemoteApps { + previous[alias] = spec + } + owned := make(map[string]bool, len(a.state.OwnedRemoteAppAliases)) + for alias, isOwned := range a.state.OwnedRemoteAppAliases { + if isOwned { + owned[strings.ToLower(alias)] = true + } + } + a.mu.RUnlock() + + wanted := make(map[string]model.RemoteAppSpec, len(desired)) + for _, spec := range desired { + wanted[strings.ToLower(spec.Alias)] = spec + } + remove := make([]string, 0) + for alias := range previous { + key := strings.ToLower(alias) + if _, ok := wanted[key]; !ok && owned[key] { + remove = append(remove, alias) + } + } + sort.Strings(remove) + + managedStatus, reconcileErr := windowsx.ReconcileRemoteApps(desired, remove) + if reconcileErr == nil { + for _, st := range managedStatus { + if st.Owned { + owned[strings.ToLower(st.Alias)] = true + } + } + for _, alias := range remove { + delete(owned, strings.ToLower(alias)) + } + } + discovered, discoverErr := windowsx.DiscoverRemoteApps() + status := managedStatus + if discoverErr == nil { + status = windowsx.RemoteAppStatusByDesired(discovered, desired) + managedErrors := map[string]string{} + for _, st := range managedStatus { + if st.Error != "" { + managedErrors[strings.ToLower(st.Alias)] = st.Error + } + } + for i := range status { + key := strings.ToLower(status[i].Alias) + status[i].Owned = owned[key] + if errText := managedErrors[key]; errText != "" { + status[i].Error = errText + status[i].InSync = false + } + } + } + now := time.Now().UTC() + a.mu.Lock() + a.state.LastRemoteAppSync = now + if reconcileErr != nil { + a.appendEventLocked("error", "remoteapp_reconcile_error", "", reconcileErr.Error()) + } + if discoverErr != nil { + a.appendEventLocked("error", "remoteapp_discovery_error", "", discoverErr.Error()) + } + if reconcileErr == nil { + a.state.ManagedRemoteApps = map[string]model.RemoteAppSpec{} + for _, spec := range desired { + a.state.ManagedRemoteApps[spec.Alias] = spec + } + a.state.OwnedRemoteAppAliases = map[string]bool{} + for alias, isOwned := range owned { + if isOwned { + a.state.OwnedRemoteAppAliases[alias] = true + } + } + } + if status != nil { + a.state.RemoteAppStatus = status + a.snapshot.RemoteApps = append([]model.RemoteAppStatus(nil), status...) + } + _ = a.store.save(a.state) + a.mu.Unlock() +} + func (a *App) serveHTTP(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/agent/state.go b/internal/agent/state.go index 1fafe70..f7119c8 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -13,20 +13,25 @@ import ( ) 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"` - 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"` + 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"` + DesiredRemoteApps []model.RemoteAppSpec `json:"desired_remote_apps,omitempty"` + ManagedRemoteApps map[string]model.RemoteAppSpec `json:"managed_remote_apps,omitempty"` + OwnedRemoteAppAliases map[string]bool `json:"owned_remote_app_aliases,omitempty"` + RemoteAppStatus []model.RemoteAppStatus `json:"remote_app_status,omitempty"` + LastRemoteAppSync time.Time `json:"last_remote_app_sync,omitempty"` } type stateStore struct { @@ -36,18 +41,22 @@ 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{}, - 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{}, + 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{}, + DesiredRemoteApps: []model.RemoteAppSpec{}, + ManagedRemoteApps: map[string]model.RemoteAppSpec{}, + OwnedRemoteAppAliases: map[string]bool{}, + RemoteAppStatus: []model.RemoteAppStatus{}, } b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { @@ -89,6 +98,18 @@ func loadState(path string, initial model.Policy) (State, error) { if s.Telemetry == nil { s.Telemetry = map[uint32]model.SessionTelemetry{} } + if s.DesiredRemoteApps == nil { + s.DesiredRemoteApps = []model.RemoteAppSpec{} + } + if s.ManagedRemoteApps == nil { + s.ManagedRemoteApps = map[string]model.RemoteAppSpec{} + } + if s.OwnedRemoteAppAliases == nil { + s.OwnedRemoteAppAliases = map[string]bool{} + } + if s.RemoteAppStatus == nil { + s.RemoteAppStatus = []model.RemoteAppStatus{} + } 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. diff --git a/internal/agent/ui.go b/internal/agent/ui.go index 1c677f7..0decccb 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -36,14 +36,15 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}} @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}} -
+
Lokaler Agent
Lokaler Terminalserver
Live · 5s
-
Local Control

Terminalserver-Status

Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.
Aktiv
Getrennt
Sitzungen
Profil-Jobs
Cleanup
Master
+
Local Control

Terminalserver-Status

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

Sitzungen

Aktive und getrennte Benutzer sowie administrative Aktionen.
+
Delivery

RemoteApps

Vom Windows RemoteApp-Provider entdeckte Anwendungen und der vom Master verwaltete Sollzustand.
Profile

Profil-Pipeline

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

Aktivitäts- & Audit-Log

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

Lokale Policy

Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.
-
` +
` const agentJS = ` let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false; @@ -61,10 +62,11 @@ function initShell(){ } 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 renderRemoteApps(s){let rows=s.remote_apps||[];$('remoteapps').innerHTML=rows.length?''+rows.map(x=>'').join('')+'
StatusAliasPfadVerwaltungFehler
'+(x.published&&x.path_exists&&x.in_sync?'Bereit':'Nicht bereit')+'||'+esc(x.alias)+'
'+esc(x.display_name||x.resource_id||'–')+'
'+esc(x.path||'–')+''+(x.managed?'Master'+(x.owned?' (angelegt)':' (übernommen)'):'lokal entdeckt')+''+esc(x.error||'–')+'
':'
Keine RemoteApps gefunden. Bei Agent-gesteuerten Apps erscheint der Status nach dem nächsten Master-Heartbeat.
'} 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)+''}} +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;$('remoteAppCount').textContent=(s.remote_apps||[]).filter(x=>x.published&&x.path_exists&&x.in_sync).length+'/'+(s.remote_apps||[]).length;$('master').innerHTML=d.master_error?'Offline':'Verbunden';renderSessions(s);renderRemoteApps(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})} diff --git a/internal/master/broker_test.go b/internal/master/broker_test.go index c41407d..6e79c75 100644 --- a/internal/master/broker_test.go +++ b/internal/master/broker_test.go @@ -139,3 +139,90 @@ func TestDomainQualifiedBrokerIdentityDoesNotCrossDomain(t *testing.T) { t.Fatal("matching domain-qualified identity did not match") } } + +func TestManagedRemoteAppOnlyUsesReadyHost(t *testing.T) { + a := brokerTestApp() + a.store.data.Resources["sage"] = model.Resource{ + ID: "sage", Name: "Sage", Kind: "remoteapp", FarmID: "office", Enabled: true, + RemoteApp: "||Sage", ManageRemoteApp: true, RemoteAppPath: `C:\\Program Files\\Sage\\Sage.exe`, + } + notReady := testAgent("rds01", "rds01.example.test", "online", 100) + notReady.Snapshot.RemoteApps = []model.RemoteAppStatus{{ + ResourceID: "sage", Alias: "Sage", Published: true, PathExists: false, Managed: true, InSync: false, + }} + ready := testAgent("rds02", "rds02.example.test", "online", 80) + ready.Snapshot.RemoteApps = []model.RemoteAppStatus{{ + ResourceID: "sage", Alias: "Sage", Published: true, PathExists: true, Managed: true, InSync: true, + }} + a.store.data.Agents["rds01"] = notReady + a.store.data.Agents["rds02"] = ready + + got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\\Max`, ResourceID: "sage"}) + if err != nil { + t.Fatal(err) + } + if got.AgentID != "rds02" { + t.Fatalf("managed RemoteApp was brokered to a host that is not ready: %+v", got) + } +} + +func TestManagedRemoteAppFailsClosedWithoutReadyHost(t *testing.T) { + a := brokerTestApp() + a.store.data.Resources["sage"] = model.Resource{ + ID: "sage", Name: "Sage", Kind: "remoteapp", FarmID: "office", Enabled: true, + RemoteApp: "||Sage", ManageRemoteApp: true, RemoteAppPath: `C:\\Program Files\\Sage\\Sage.exe`, + } + rec := testAgent("rds01", "rds01.example.test", "online", 100) + rec.Snapshot.RemoteApps = []model.RemoteAppStatus{{ + ResourceID: "sage", Alias: "Sage", Published: false, PathExists: true, Managed: true, InSync: false, + Error: "RemoteApp provider did not return the registration after Put()", + }} + a.store.data.Agents["rds01"] = rec + + if _, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\\Max`, ResourceID: "sage"}); err == nil { + t.Fatal("managed RemoteApp without a ready host must fail closed") + } +} + +func TestDesiredRemoteAppsAreScopedToAgentFarmAndNormalized(t *testing.T) { + a := brokerTestApp() + a.store.data.Farms["erp"] = model.Farm{ID: "erp", Name: "ERP", Enabled: true} + a.store.data.Resources["office-app"] = model.Resource{ + ID: "office-app", Name: "Office App", Kind: "remoteapp", FarmID: "office", Enabled: true, + RemoteApp: "||OfficeApp", ManageRemoteApp: true, RemoteAppPath: `C:\\Apps\\Office.exe`, + RemoteAppCommandLine: 2, RemoteAppRequiredArgs: "/sessionguard", RemoteAppShowInPortal: true, + } + a.store.data.Resources["erp-app"] = model.Resource{ + ID: "erp-app", Name: "ERP App", Kind: "remoteapp", FarmID: "erp", Enabled: true, + RemoteApp: "||ERP", ManageRemoteApp: true, RemoteAppPath: `C:\\Apps\\ERP.exe`, + } + rec := testAgent("rds01", "rds01.example.test", "online", 100) + + got := a.desiredRemoteAppsLocked("rds01", rec) + if len(got) != 1 { + t.Fatalf("expected exactly one farm-scoped desired RemoteApp, got %#v", got) + } + if got[0].Alias != "OfficeApp" || got[0].ResourceID != "office-app" || got[0].CommandLineSetting != 2 || got[0].RequiredCommandLine != "/sessionguard" { + t.Fatalf("unexpected desired RemoteApp: %+v", got[0]) + } +} + +func TestManagedRemoteAppCommandLinePolicyControlsBrokerArgs(t *testing.T) { + rec := testAgent("rds01", "rds01.example.test", "online", 100) + lease := model.UserLease{ExpiresAt: time.Now().Add(time.Minute)} + + deny := model.Resource{ID: "deny", Kind: "remoteapp", RemoteApp: "||App", ManageRemoteApp: true, RemoteAppCommandLine: 0, RemoteAppArgs: "--from-guac"} + if got := brokerResponse(rec, "office", &deny, false, "test", lease).Tokens["SESSIONGUARD_REMOTE_APP_ARGS"]; got != "" { + t.Fatalf("deny policy leaked Guacamole args: %q", got) + } + + allow := model.Resource{ID: "allow", Kind: "remoteapp", RemoteApp: "||App", ManageRemoteApp: true, RemoteAppCommandLine: 1, RemoteAppArgs: "--from-guac"} + if got := brokerResponse(rec, "office", &allow, false, "test", lease).Tokens["SESSIONGUARD_REMOTE_APP_ARGS"]; got != "--from-guac" { + t.Fatalf("allow policy did not preserve Guacamole args: %q", got) + } + + require := model.Resource{ID: "require", Kind: "remoteapp", RemoteApp: "||App", ManageRemoteApp: true, RemoteAppCommandLine: 2, RemoteAppArgs: "--wrong", RemoteAppRequiredArgs: "--required"} + if got := brokerResponse(rec, "office", &require, false, "test", lease).Tokens["SESSIONGUARD_REMOTE_APP_ARGS"]; got != "--required" { + t.Fatalf("required policy did not force desired args: %q", got) + } +} diff --git a/internal/master/master.go b/internal/master/master.go index 8193d2c..361ed67 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -23,7 +23,7 @@ import ( "github.com/example/sessionguard/internal/model" ) -const Version = "0.3.3" +const Version = "0.4.0" type App struct { cfg config.Master @@ -224,6 +224,7 @@ func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) { cp := *desired sendPolicy = &cp } + desiredRemoteApps := a.desiredRemoteAppsLocked(id, rec) commands := append([]model.SessionCommand(nil), rec.PendingCommands...) if err := a.store.saveLocked(); err != nil { a.store.mu.Unlock() @@ -234,7 +235,7 @@ func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) { for _, al := range notify { a.notifyAlert(al) } - httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: sendPolicy, Commands: commands, ServerTime: now}) + httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: sendPolicy, DesiredRemoteApps: desiredRemoteApps, Commands: commands, ServerTime: now}) } func (a *App) recordSessionHistoryLocked(rec model.AgentRecord, old, new model.AgentSnapshot, now time.Time) { @@ -446,7 +447,7 @@ func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, erro // 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) { + if rec.MaintenanceMode == "maintenance" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || !a.agentInFarmLocked(id, rec, farmID) || !agentResourceReady(rec, resource) { continue } for _, sess := range rec.Snapshot.Sessions { @@ -461,7 +462,7 @@ func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, erro } } 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) { + 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) && agentResourceReady(rec, resource) { 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 { @@ -470,7 +471,7 @@ func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, erro return brokerResponse(rec, farmID, resource, true, "existing-lease", lease), nil } } - candidates := a.farmCandidatesLocked(farmID, now) + candidates := a.farmCandidatesLocked(farmID, resource, now) if len(candidates) == 0 { return model.BrokerResponse{}, fmt.Errorf("no healthy online server is available for farm %q", farmID) } @@ -513,7 +514,7 @@ func (a *App) agentInFarmLocked(id string, rec model.AgentRecord, farmID string) return len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags) } -func (a *App) farmCandidatesLocked(farmID string, now time.Time) []model.AgentRecord { +func (a *App) farmCandidatesLocked(farmID string, resource *model.Resource, now time.Time) []model.AgentRecord { if farmID != "" { if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled { return nil @@ -527,6 +528,9 @@ func (a *App) farmCandidatesLocked(farmID string, now time.Time) []model.AgentRe if !a.agentInFarmLocked(id, rec, farmID) { continue } + if !agentResourceReady(rec, resource) { + continue + } out = append(out, rec) } return out @@ -546,7 +550,16 @@ func brokerResponse(rec model.AgentRecord, farm string, res *model.Resource, rec 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 + remoteArgs := res.RemoteAppArgs + if res.Kind == "remoteapp" && res.ManageRemoteApp { + switch res.RemoteAppCommandLine { + case 0: + remoteArgs = "" + case 2: + remoteArgs = res.RemoteAppRequiredArgs + } + } + tokens["SESSIONGUARD_REMOTE_APP_ARGS"] = remoteArgs } 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} } @@ -1138,6 +1151,34 @@ func (a *App) saveResource(w http.ResponseWriter, r *http.Request, x model.Resou httpx.Error(w, 400, "remote_app is required for remoteapp resources") return } + if x.Kind == "desktop" { + x.RemoteApp = "" + x.RemoteAppDir = "" + x.RemoteAppArgs = "" + x.ManageRemoteApp = false + x.RemoteAppPath = "" + x.RemoteAppIconPath = "" + x.RemoteAppIconIndex = 0 + x.RemoteAppCommandLine = 0 + x.RemoteAppRequiredArgs = "" + x.RemoteAppShowInPortal = false + } + if x.Kind == "remoteapp" { + alias := remoteAppAlias(x.RemoteApp) + if !validRemoteAppAlias(alias) { + httpx.Error(w, 400, "remote_app alias must contain only letters, numbers, dot, dash or underscore") + return + } + x.RemoteApp = "||" + alias + if x.ManageRemoteApp && strings.TrimSpace(x.RemoteAppPath) == "" { + httpx.Error(w, 400, "remote_app_path is required when agent RemoteApp management is enabled") + return + } + if x.RemoteAppCommandLine > 2 { + httpx.Error(w, 400, "remote_app_command_line_setting must be 0, 1 or 2") + return + } + } a.store.mu.Lock() defer a.store.mu.Unlock() if _, ok := a.store.data.Farms[x.FarmID]; !ok { @@ -1162,6 +1203,10 @@ func (a *App) saveResource(w http.ResponseWriter, r *http.Request, x model.Resou httpx.Error(w, 409, "guacamole_connection_name is already mapped by another enabled resource") return } + if x.Kind == "remoteapp" && x.ManageRemoteApp && existing.Kind == "remoteapp" && existing.ManageRemoteApp && strings.EqualFold(remoteAppAlias(existing.RemoteApp), remoteAppAlias(x.RemoteApp)) { + httpx.Error(w, 409, "managed remote_app alias is already used by another enabled resource") + return + } } } a.store.data.Resources[x.ID] = x @@ -1614,6 +1659,67 @@ func tagsMatch(have, need map[string]string) bool { } return true } +func remoteAppAlias(remoteApp string) string { + v := strings.TrimSpace(remoteApp) + v = strings.TrimPrefix(v, "||") + return strings.TrimSpace(v) +} + +func validRemoteAppAlias(alias string) bool { + if alias == "" || len(alias) > 128 { + return false + } + for _, r := range alias { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { + continue + } + return false + } + return true +} + +func (a *App) desiredRemoteAppsLocked(agentID string, rec model.AgentRecord) []model.RemoteAppSpec { + out := make([]model.RemoteAppSpec, 0) + for _, res := range a.store.data.Resources { + if !res.Enabled || res.Kind != "remoteapp" || !res.ManageRemoteApp || strings.TrimSpace(res.RemoteAppPath) == "" { + continue + } + if !a.agentInFarmLocked(agentID, rec, res.FarmID) { + continue + } + alias := remoteAppAlias(res.RemoteApp) + if !validRemoteAppAlias(alias) { + continue + } + out = append(out, model.RemoteAppSpec{ + ResourceID: res.ID, Alias: alias, DisplayName: res.Name, Path: res.RemoteAppPath, + IconPath: res.RemoteAppIconPath, IconIndex: res.RemoteAppIconIndex, + CommandLineSetting: res.RemoteAppCommandLine, RequiredCommandLine: res.RemoteAppRequiredArgs, + ShowInPortal: res.RemoteAppShowInPortal, + }) + } + sort.Slice(out, func(i, j int) bool { + if strings.EqualFold(out[i].Alias, out[j].Alias) { + return out[i].ResourceID < out[j].ResourceID + } + return strings.ToLower(out[i].Alias) < strings.ToLower(out[j].Alias) + }) + return out +} + +func agentResourceReady(rec model.AgentRecord, res *model.Resource) bool { + if res == nil || res.Kind != "remoteapp" || !res.ManageRemoteApp { + return true + } + alias := remoteAppAlias(res.RemoteApp) + for _, st := range rec.Snapshot.RemoteApps { + if st.ResourceID == res.ID || (st.ResourceID == "" && strings.EqualFold(st.Alias, alias)) { + return st.Published && st.PathExists && st.InSync && strings.TrimSpace(st.Error) == "" + } + } + return false +} + func resourceID(r *model.Resource) string { if r == nil { return "" diff --git a/internal/master/ui.go b/internal/master/ui.go index e32f24e..f8dbd6d 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -37,8 +37,8 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
-
` +
` const masterJS = ` -let selected=null,current=null,editorAgent=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)} +let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)} function initShell(){ let saved='';try{saved=localStorage.getItem('sessionguard-theme')||''}catch(e){} @@ -76,15 +76,19 @@ function controlHTML(a){let tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v 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 remoteAppsHTML(a){let rows=((a.snapshot||{}).remote_apps||[]);return rows.length?''+rows.map(x=>'').join('')+'
StatusAliasPfadQuelleFehler
'+(x.published&&x.path_exists&&x.in_sync?'Bereit':'Nicht bereit')+'||'+esc(x.alias)+'
'+esc(x.display_name||x.resource_id||'–')+'
'+esc(x.path||'–')+''+(x.managed?'SessionGuard'+(x.owned?' (angelegt)':' (übernommen)'):'entdeckt')+''+esc(x.error||'–')+'
':'
Keine RemoteApps entdeckt oder verwaltet.
'} 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 agentMatchesFarm(a,f){if(!a||!f)return false;if((f.agent_ids||[]).includes(a.id)||(a.farm_ids||[]).includes(f.id))return true;let required=Object.entries(f.required_tags||{});return required.length>0&&required.every(([k,v])=>String((a.tags||{})[k]??'')===String(v))}function farmMembers(f){return agentCache.filter(a=>agentMatchesFarm(a,f))}function renderFarms(){let root=$('farms');if(!root)return;if(!$('farmEditor'))root.innerHTML='
';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)+'
':'';$('farmData').innerHTML=(farmCache.length?''+farmCache.map(f=>'').join('')+'
NameIDAgentenRequired Tags
'+esc(f.name)+''+esc(f.id)+''+farmMembers(f).length+''+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'
':'
Noch keine Farms.
')+lease} -function renderResources(){let root=$('resources');if(!root)return;if(!$('resourceEditor'))root.innerHTML='
';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').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 resourceReadiness(x){if(x.kind!=='remoteapp')return{ready:0,total:0,text:'Desktop',cls:'neutral',detail:''};if(!x.manage_remote_app)return{ready:0,total:0,text:'Extern',cls:'neutral',detail:'RemoteApp wird nicht durch SessionGuard provisioniert'};let f=farmCache.find(z=>z.id===x.farm_id),members=f?farmMembers(f):[],ready=0,detail=[];members.forEach(a=>{let st=((a.snapshot||{}).remote_apps||[]).find(r=>r.resource_id===x.id||(!r.resource_id&&String(r.alias||'').toLowerCase()===String(x.remote_app||'').replace(/^\|\|/,'').toLowerCase()));let ok=!!(st&&st.published&&st.path_exists&&st.in_sync&&!st.error);if(ok)ready++;detail.push((a.name||a.id)+': '+(ok?'bereit':(st&&st.error?st.error:'noch kein Status')))});return{ready,total:members.length,text:ready+'/'+members.length+' bereit',cls:members.length&&ready===members.length?'good':'bad',detail:detail.join('\n')}} +function resourceField(id,show){let e=$(id);if(!e)return;let w=e.closest('label')||e.closest('.check');if(w)w.hidden=!show;e.disabled=!show}function updateResourceEditorState(){if(!$('resKind'))return;let remote=$('resKind').value==='remoteapp';if($('remoteAppManageNote'))$('remoteAppManageNote').hidden=!remote;['resRemoteApp','resRemoteDir','resRemoteArgs'].forEach(id=>resourceField(id,remote));resourceField('resManage',remote);if(!remote&&$('resManage'))$('resManage').checked=false;let managed=remote&&$('resManage')&&$('resManage').checked;['resRemotePath','resRemoteIcon','resIconIndex','resCmd','resRemoteRequired','resPortal'].forEach(id=>resourceField(id,managed))}function resetResourceForm(){resourceEditID=null;['resName','resConnID','resConnName','resRemoteApp','resRemoteDir','resRemoteArgs','resRemotePath','resRemoteIcon','resRemoteRequired'].forEach(id=>{if($(id))$(id).value=''});if($('resKind'))$('resKind').value='desktop';if($('resManage'))$('resManage').checked=false;if($('resIconIndex'))$('resIconIndex').value='0';if($('resCmd'))$('resCmd').value='0';if($('resPortal'))$('resPortal').checked=false;if($('resSave'))$('resSave').textContent='Resource anlegen';updateResourceEditorState()} +function editResource(id){let x=resourceCache.find(r=>r.id===id);if(!x)return;resourceEditID=id;$('resName').value=x.name||'';$('resKind').value=x.kind||'desktop';$('resFarm').value=x.farm_id||'';$('resConnID').value=x.guacamole_connection_id||'';$('resConnName').value=x.guacamole_connection_name||'';$('resRemoteApp').value=x.remote_app||'';$('resRemoteDir').value=x.remote_app_dir||'';$('resRemoteArgs').value=x.remote_app_args||'';$('resManage').checked=!!x.manage_remote_app;$('resRemotePath').value=x.remote_app_path||'';$('resRemoteIcon').value=x.remote_app_icon_path||'';$('resIconIndex').value=x.remote_app_icon_index||0;$('resCmd').value=String(x.remote_app_command_line_setting||0);$('resRemoteRequired').value=x.remote_app_required_command_line||'';$('resPortal').checked=!!x.remote_app_show_in_portal;$('resSave').textContent='Änderungen speichern';updateResourceEditorState();$('resourceEditor').scrollIntoView({behavior:'smooth',block:'center'})} +function renderResources(){let root=$('resources');if(!root)return;if(!$('resourceEditor'))root.innerHTML='
Agent-gesteuerte RemoteApp-Veröffentlichung
Aktivierst du diese Option, sorgt jeder SessionGuard-Agent der Farm dafür, dass der Alias lokal über den Windows RemoteApp-WMI-Provider veröffentlicht ist. Hosts mit fehlender oder fehlerhafter App werden vom Broker für diese Resource ausgeschlossen.
';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').innerHTML=resourceCache.length?''+resourceCache.map(x=>{let h=resourceReadiness(x);return''}).join('')+'
NameTypFarmGuacamoleRemoteAppAgent-Status
'+esc(x.name)+'
'+esc(x.id)+'
'+esc(x.kind)+''+esc(x.farm_id)+''+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+''+esc(x.remote_app||'–')+(x.manage_remote_app?'
'+esc(x.remote_app_path||'Pfad fehlt')+'':'')+'
'+esc(h.text)+'
':'
Noch keine Resources.
';updateResourceEditorState()} 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||!$('overviewStats')){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='
Serversteuerung
Sitzungen
Logon Performance
Prozesse
Profil-Pipeline
Agent-Ereignisse
Policy
'+policyForm(p,a)+'
';$('controlEditor').innerHTML=controlHTML(a);editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overviewStats').innerHTML=overviewStats(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)} +function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overviewStats')){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='
Serversteuerung
Sitzungen
Logon Performance
Prozesse
RemoteApps
Profil-Pipeline
Agent-Ereignisse
Policy
'+policyForm(p,a)+'
';$('controlEditor').innerHTML=controlHTML(a);editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overviewStats').innerHTML=overviewStats(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('remoteAppList').innerHTML=remoteAppsHTML(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})} @@ -95,5 +99,5 @@ async function bulkSessionAction(action,scope){if(!selected)return;let body={act async function saveAgentControl(){if(!selected)return;let tags={};($('agentTags').value||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)tags[x.slice(0,i).trim()]=x.slice(i+1).trim()});let body={mode:$('agentMode').value,restart_when_drained:$('restartDrained').checked,tags,farm_ids:$('agentFarms').value.split('\n').map(x=>x.trim()).filter(Boolean)};try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/control',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Serversteuerung gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}} async function killProcess(pid){if(!selected||!confirm('Prozess '+pid+' wirklich beenden?'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/processes/'+pid+'/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});toast('Prozess-Beenden an Agent gesendet')}catch(e){toast(e.message)}} async function savePolicy(all){if(!selected)return;try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api(all?'/api/v1/policy/all':'/api/v1/agents/'+encodeURIComponent(selected)+'/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});toast(all?'Policy auf alle Server angewendet':'Policy gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}} -$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&¤t&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){$('resName').value='';$('resKind').value='desktop';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';return}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})});$('resName').value='';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';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)}}); +$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&¤t&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}}); initShell();refresh();setInterval(refresh,5000);` diff --git a/internal/model/types.go b/internal/model/types.go index bd2c110..482598c 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -2,7 +2,7 @@ package model import "time" -const ProtocolVersion = 3 +const ProtocolVersion = 4 type OIDCConfig struct { Issuer string `json:"issuer"` @@ -109,6 +109,38 @@ type Policy struct { Templates []TemplateItem `json:"templates,omitempty"` } +// RemoteAppSpec describes the desired RemoteApp registration on an RD Session Host. +// ResourceID binds the local Windows publication back to the SessionGuard resource. +type RemoteAppSpec struct { + ResourceID string `json:"resource_id"` + Alias string `json:"alias"` + DisplayName string `json:"display_name"` + Path string `json:"path"` + IconPath string `json:"icon_path,omitempty"` + IconIndex int32 `json:"icon_index,omitempty"` + CommandLineSetting uint32 `json:"command_line_setting"` // 0=deny, 1=allow, 2=require + RequiredCommandLine string `json:"required_command_line,omitempty"` + ShowInPortal bool `json:"show_in_portal,omitempty"` +} + +// RemoteAppStatus is the agent-observed state of a RemoteApp publication. +type RemoteAppStatus struct { + ResourceID string `json:"resource_id,omitempty"` + Alias string `json:"alias"` + DisplayName string `json:"display_name,omitempty"` + Path string `json:"path,omitempty"` + VPath string `json:"vpath,omitempty"` + PathExists bool `json:"path_exists"` + Published bool `json:"published"` + Managed bool `json:"managed"` + Owned bool `json:"owned,omitempty"` // registration was created by SessionGuard + InSync bool `json:"in_sync"` + CommandLineSetting uint32 `json:"command_line_setting,omitempty"` + RequiredCommandLine string `json:"required_command_line,omitempty"` + Error string `json:"error,omitempty"` + ObservedAt time.Time `json:"observed_at"` +} + type Session struct { ID uint32 `json:"id"` State string `json:"state"` @@ -264,6 +296,7 @@ type AgentSnapshot struct { ProfileStatus map[string]ProfileStatus `json:"profile_status,omitempty"` Events []AgentEvent `json:"events,omitempty"` CommandResults []CommandResult `json:"command_results,omitempty"` + RemoteApps []RemoteAppStatus `json:"remote_apps,omitempty"` Policy Policy `json:"policy"` PolicyRevision string `json:"policy_revision"` AgentVersion string `json:"agent_version"` @@ -306,6 +339,13 @@ type Resource struct { RemoteApp string `json:"remote_app,omitempty"` RemoteAppDir string `json:"remote_app_dir,omitempty"` RemoteAppArgs string `json:"remote_app_args,omitempty"` + ManageRemoteApp bool `json:"manage_remote_app,omitempty"` + RemoteAppPath string `json:"remote_app_path,omitempty"` + RemoteAppIconPath string `json:"remote_app_icon_path,omitempty"` + RemoteAppIconIndex int32 `json:"remote_app_icon_index,omitempty"` + RemoteAppCommandLine uint32 `json:"remote_app_command_line_setting,omitempty"` // 0=deny, 1=allow, 2=require + RemoteAppRequiredArgs string `json:"remote_app_required_command_line,omitempty"` + RemoteAppShowInPortal bool `json:"remote_app_show_in_portal,omitempty"` Enabled bool `json:"enabled"` } @@ -388,7 +428,8 @@ type EnrollResponse struct { } type HeartbeatResponse struct { - DesiredPolicy *Policy `json:"desired_policy,omitempty"` - Commands []SessionCommand `json:"commands,omitempty"` - ServerTime time.Time `json:"server_time"` + DesiredPolicy *Policy `json:"desired_policy,omitempty"` + DesiredRemoteApps []RemoteAppSpec `json:"desired_remote_apps"` + Commands []SessionCommand `json:"commands,omitempty"` + ServerTime time.Time `json:"server_time"` } diff --git a/internal/windowsx/remoteapp_status.go b/internal/windowsx/remoteapp_status.go new file mode 100644 index 0000000..735c105 --- /dev/null +++ b/internal/windowsx/remoteapp_status.go @@ -0,0 +1,52 @@ +package windowsx + +import ( + "sort" + "strings" + "time" + + "github.com/example/sessionguard/internal/model" +) + +// RemoteAppStatusByDesired overlays discovery results with SessionGuard desired +// state while preserving unrelated/manual RemoteApps for visibility. +func RemoteAppStatusByDesired(discovered []model.RemoteAppStatus, desired []model.RemoteAppSpec) []model.RemoteAppStatus { + now := time.Now().UTC() + byAlias := make(map[string]model.RemoteAppStatus, len(discovered)) + for _, st := range discovered { + byAlias[strings.ToLower(st.Alias)] = st + } + out := make([]model.RemoteAppStatus, 0, len(desired)+len(discovered)) + seen := map[string]bool{} + for _, want := range desired { + key := strings.ToLower(want.Alias) + st, ok := byAlias[key] + if !ok { + st = model.RemoteAppStatus{Alias: want.Alias, Path: want.Path, ObservedAt: now} + } + st.ResourceID = want.ResourceID + st.DisplayName = want.DisplayName + st.Managed = true + pathMatches := strings.EqualFold(strings.TrimSpace(st.Path), strings.TrimSpace(want.Path)) || strings.EqualFold(strings.TrimSpace(st.VPath), strings.TrimSpace(want.Path)) + st.InSync = st.Published && st.PathExists && pathMatches && st.CommandLineSetting == want.CommandLineSetting && st.RequiredCommandLine == want.RequiredCommandLine + if st.Error == "" { + switch { + case !st.Published: + st.Error = "RemoteApp is not published" + case !st.PathExists: + st.Error = "RemoteApp executable is not available" + case !st.InSync: + st.Error = "RemoteApp registration differs from desired state" + } + } + out = append(out, st) + seen[key] = true + } + for _, st := range discovered { + if !seen[strings.ToLower(st.Alias)] { + out = append(out, st) + } + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Alias) < strings.ToLower(out[j].Alias) }) + return out +} diff --git a/internal/windowsx/remoteapp_status_test.go b/internal/windowsx/remoteapp_status_test.go new file mode 100644 index 0000000..c86ac1e --- /dev/null +++ b/internal/windowsx/remoteapp_status_test.go @@ -0,0 +1,45 @@ +package windowsx + +import ( + "testing" + + "github.com/example/sessionguard/internal/model" +) + +func TestRemoteAppStatusByDesiredMarksReadyManagedApp(t *testing.T) { + got := RemoteAppStatusByDesired([]model.RemoteAppStatus{{ + Alias: "Sage", Path: `C:\Program Files\Sage\Sage.exe`, PathExists: true, + Published: true, InSync: true, CommandLineSetting: 0, + }}, []model.RemoteAppSpec{{ + ResourceID: "sage", Alias: "Sage", DisplayName: "Sage 100", Path: `C:\Program Files\Sage\Sage.exe`, CommandLineSetting: 0, + }}) + if len(got) != 1 || !got[0].Managed || !got[0].InSync || got[0].ResourceID != "sage" || got[0].Error != "" { + t.Fatalf("unexpected ready status: %#v", got) + } +} + +func TestRemoteAppStatusByDesiredFailsMissingExecutable(t *testing.T) { + got := RemoteAppStatusByDesired([]model.RemoteAppStatus{{ + Alias: "Sage", Path: `C:\Program Files\Sage\Sage.exe`, Published: true, PathExists: false, + }}, []model.RemoteAppSpec{{ResourceID: "sage", Alias: "Sage", Path: `C:\Program Files\Sage\Sage.exe`}}) + if len(got) != 1 || got[0].InSync || got[0].Error == "" { + t.Fatalf("missing executable was not reported fail-closed: %#v", got) + } +} + +func TestRemoteAppStatusByDesiredPreservesUnmanagedDiscovery(t *testing.T) { + got := RemoteAppStatusByDesired([]model.RemoteAppStatus{{Alias: "Manual", Published: true, PathExists: true}}, nil) + if len(got) != 1 || got[0].Managed || got[0].Alias != "Manual" { + t.Fatalf("manual RemoteApp should remain visible and unmanaged: %#v", got) + } +} + +func TestRemoteAppStatusByDesiredAcceptsMatchingVirtualPath(t *testing.T) { + got := RemoteAppStatusByDesired([]model.RemoteAppStatus{{ + Alias: "App", Path: `C:\\Program Files\\Vendor\\App.exe`, VPath: `%ProgramFiles%\\Vendor\\App.exe`, + PathExists: true, Published: true, + }}, []model.RemoteAppSpec{{ResourceID: "app", Alias: "App", Path: `%ProgramFiles%\\Vendor\\App.exe`}}) + if len(got) != 1 || !got[0].InSync || got[0].Error != "" { + t.Fatalf("matching virtual path should be in sync: %#v", got) + } +} diff --git a/internal/windowsx/remoteapp_stub.go b/internal/windowsx/remoteapp_stub.go new file mode 100644 index 0000000..8c7f001 --- /dev/null +++ b/internal/windowsx/remoteapp_stub.go @@ -0,0 +1,10 @@ +//go:build !windows + +package windowsx + +import "github.com/example/sessionguard/internal/model" + +func DiscoverRemoteApps() ([]model.RemoteAppStatus, error) { return nil, ErrUnsupported } +func ReconcileRemoteApps([]model.RemoteAppSpec, []string) ([]model.RemoteAppStatus, error) { + return nil, ErrUnsupported +} diff --git a/internal/windowsx/remoteapp_windows.go b/internal/windowsx/remoteapp_windows.go new file mode 100644 index 0000000..0c5710c --- /dev/null +++ b/internal/windowsx/remoteapp_windows.go @@ -0,0 +1,216 @@ +//go:build windows + +package windowsx + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "sort" + "strings" + "unicode/utf16" + + "github.com/example/sessionguard/internal/model" +) + +var remoteAppAliasRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) + +type remoteAppReconcileRequest struct { + Desired []model.RemoteAppSpec `json:"desired"` + RemoveAliases []string `json:"remove_aliases,omitempty"` +} + +// DiscoverRemoteApps returns all RemoteApp registrations known by the local +// RD Session Host provider. It is read-only and does not require SessionGuard +// to own the entries. +func DiscoverRemoteApps() ([]model.RemoteAppStatus, error) { + const script = `$ErrorActionPreference='Stop' +$items = @(Get-WmiObject -Namespace 'root\cimv2\TerminalServices' -Class Win32_TSPublishedApplication -Authentication PacketPrivacy | ForEach-Object { + [pscustomobject]@{ + resource_id = '' + alias = [string]$_.Alias + display_name = [string]$_.Name + path = [string]$_.Path + vpath = [string]$_.VPath + path_exists = [bool]$_.PathExists + published = $true + managed = $false + in_sync = $true + command_line_setting = [uint32]$_.CommandLineSetting + required_command_line = [string]$_.RequiredCommandLine + error = '' + observed_at = [DateTime]::UtcNow.ToString('o') + } +}) +ConvertTo-Json -InputObject @($items) -Compress -Depth 4` + var out []model.RemoteAppStatus + if err := runPowerShellJSON(script, &out); err != nil { + return nil, fmt.Errorf("discover RemoteApps: %w", err) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Alias) < strings.ToLower(out[j].Alias) }) + return out, nil +} + +// ReconcileRemoteApps applies only the explicitly desired SessionGuard-owned +// registrations and removes only aliases that the caller identifies as +// previously SessionGuard-managed. Existing unrelated RemoteApps are left +// untouched. +func ReconcileRemoteApps(desired []model.RemoteAppSpec, removeAliases []string) ([]model.RemoteAppStatus, error) { + for _, app := range desired { + if !remoteAppAliasRE.MatchString(app.Alias) { + return nil, fmt.Errorf("invalid RemoteApp alias %q", app.Alias) + } + if strings.TrimSpace(app.Path) == "" { + return nil, fmt.Errorf("RemoteApp %q has empty path", app.Alias) + } + if app.CommandLineSetting > 2 { + return nil, fmt.Errorf("RemoteApp %q has invalid command-line setting %d", app.Alias, app.CommandLineSetting) + } + } + cleanRemove := make([]string, 0, len(removeAliases)) + for _, alias := range removeAliases { + if remoteAppAliasRE.MatchString(alias) { + cleanRemove = append(cleanRemove, alias) + } + } + req := remoteAppReconcileRequest{Desired: desired, RemoveAliases: cleanRemove} + b, err := json.Marshal(req) + if err != nil { + return nil, err + } + payload := base64.StdEncoding.EncodeToString(b) + script := fmt.Sprintf(`$ErrorActionPreference='Stop' +$raw=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')) +$req=$raw | ConvertFrom-Json +$ns='root\cimv2\TerminalServices' + +function Get-App([string]$Alias) { + $safe=$Alias.Replace("'", "''") + return Get-WmiObject -Namespace $ns -Class Win32_TSPublishedApplication -Authentication PacketPrivacy -Filter ("Alias='"+$safe+"'") | Select-Object -First 1 +} +function New-RdpFile($a) { + $display=[string]$a.display_name + if ([string]::IsNullOrWhiteSpace($display)) { $display=[string]$a.alias } + $args='' + if ([uint32]$a.command_line_setting -eq 2) { $args=[string]$a.required_command_line } + return (@( + 'screen mode id:i:2', + 'use multimon:i:0', + 'session bpp:i:32', + 'compression:i:1', + 'keyboardhook:i:2', + 'audiocapturemode:i:0', + 'videoplaybackmode:i:1', + 'networkautodetect:i:1', + 'bandwidthautodetect:i:1', + 'displayconnectionbar:i:1', + 'redirectprinters:i:1', + 'redirectsmartcards:i:1', + 'redirectclipboard:i:1', + 'autoreconnection enabled:i:1', + 'authentication level:i:2', + 'prompt for credentials:i:1', + 'negotiate security layer:i:1', + 'alternate shell:s:rdpinit.exe', + 'remoteapplicationmode:i:1', + ('remoteapplicationprogram:s:||'+[string]$a.alias), + ('remoteapplicationname:s:'+$display), + ('remoteapplicationcmdline:s:'+$args), + 'full address:s:localhost' + ) -join [Environment]::NewLine) +} + +foreach($alias in @($req.remove_aliases)) { + if ([string]::IsNullOrWhiteSpace([string]$alias)) { continue } + $old=Get-App ([string]$alias) + if ($null -ne $old) { $null=$old.Delete() } +} + +$results=@() +foreach($a in @($req.desired)) { + $alias=[string]$a.alias + $path=[Environment]::ExpandEnvironmentVariables([string]$a.path) + $icon=[Environment]::ExpandEnvironmentVariables([string]$a.icon_path) + if ([string]::IsNullOrWhiteSpace($icon)) { $icon=$path } + $status=[ordered]@{ + resource_id=[string]$a.resource_id; alias=$alias; display_name=[string]$a.display_name; + path=$path; vpath=[string]$a.path; path_exists=$false; published=$false; managed=$true; owned=$false; in_sync=$false; + command_line_setting=[uint32]$a.command_line_setting; required_command_line=[string]$a.required_command_line; + error=''; observed_at=[DateTime]::UtcNow.ToString('o') + } + try { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Executable not found: $path" } + $status.path_exists=$true + $obj=Get-App $alias + if ($null -eq $obj) { + $class=Get-WmiObject -Namespace $ns -List -Class Win32_TSPublishedApplication -Authentication PacketPrivacy + $obj=$class.CreateInstance() + $obj.Alias=$alias + $status.owned=$true + } + $obj.Path=$path + $obj.VPath=[string]$a.path + $obj.IconPath=$icon + $obj.IconIndex=[int]$a.icon_index + $obj.CommandLineSetting=[uint32]$a.command_line_setting + $obj.RequiredCommandLine=[string]$a.required_command_line + $obj.ShowInPortal=[bool]$a.show_in_portal + $obj.RDPFileContents=New-RdpFile $a + $null=$obj.Put() + $check=Get-App $alias + if ($null -eq $check) { throw 'RemoteApp provider did not return the registration after Put()' } + $status.published=$true + $status.path_exists=[bool]$check.PathExists + $status.in_sync=([string]$check.Path -ieq $path) -and ([uint32]$check.CommandLineSetting -eq [uint32]$a.command_line_setting) -and ([string]$check.RequiredCommandLine -ceq [string]$a.required_command_line) + if (-not $status.in_sync) { $status.error='RemoteApp registration differs from desired state after reconciliation' } + } catch { + $status.error=$_.Exception.Message + } + $results += [pscustomobject]$status +} +ConvertTo-Json -InputObject @($results) -Compress -Depth 5`, payload) + + var out []model.RemoteAppStatus + if err := runPowerShellJSON(script, &out); err != nil { + return nil, fmt.Errorf("reconcile RemoteApps: %w", err) + } + return out, nil +} + +func runPowerShellJSON(script string, out any) error { + encoded := encodePowerShell(script) + cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded) + b, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(b)) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("PowerShell: %s", msg) + } + raw := strings.TrimSpace(string(b)) + if raw == "" { + raw = "[]" + } + // ConvertTo-Json emits an object instead of an array when there is exactly + // one item on older Windows PowerShell. Accept both forms. + if strings.HasPrefix(raw, "{") { + raw = "[" + raw + "]" + } + if err := json.Unmarshal([]byte(raw), out); err != nil { + return fmt.Errorf("decode PowerShell JSON: %w (output=%q)", err, raw) + } + return nil +} + +func encodePowerShell(script string) string { + u16 := utf16.Encode([]rune(script)) + b := make([]byte, len(u16)*2) + for i, v := range u16 { + b[i*2] = byte(v) + b[i*2+1] = byte(v >> 8) + } + return base64.StdEncoding.EncodeToString(b) +}