0.4.0
This commit is contained in:
13
CHANGELOG.md
13
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.
|
||||
|
||||
18
README.md
18
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
29
docs/API.md
29
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=<EXAKT_DERSELBE_BROKER_API_KEY_WIE_AM_MASTER>
|
||||
```
|
||||
|
||||
@@ -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: <target 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.
|
||||
|
||||
|
||||
105
docs/REMOTEAPP.md
Normal file
105
docs/REMOTEAPP.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>info.hilden.sessionguard</groupId>
|
||||
<artifactId>sessionguard-guacamole</artifactId>
|
||||
<version>0.3.1</version>
|
||||
<version>0.4.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<maven.compiler.release>11</maven.compiler.release>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
</style></head><body>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.3.4</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.4.0</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
|
||||
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master">–</div></div></div></section>
|
||||
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master">–</div></div></div></section>
|
||||
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
|
||||
<section class="page-section" id="remoteapps-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>RemoteApps</h2><div class="section-copy">Vom Windows RemoteApp-Provider entdeckte Anwendungen und der vom Master verwaltete Sollzustand.</div></div></div><section class="panel"><div id="remoteapps"></div></section></section>
|
||||
<section class="page-section" id="profiles-section"><div class="section-heading"><div><div class="eyebrow">Profile</div><h2>Profil-Pipeline</h2><div class="section-copy">Backup, Restore, Retry und Cleanup-Status pro Benutzer.</div></div></div><section class="panel"><div id="profiles"></div></section></section>
|
||||
<section class="page-section" id="events-section"><div class="section-heading"><div><div class="eyebrow">Telemetry</div><h2>Aktivitäts- & Audit-Log</h2><div class="section-copy">Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.</div></div></div><section class="panel"><div id="events"></div></section></section>
|
||||
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div></div><section class="panel"><div id="policy"></div></section></section>
|
||||
</main></div></div><script src="/app.js?v=0.3.4"></script></body></html>`
|
||||
</main></div></div><script src="/app.js?v=0.4.0"></script></body></html>`
|
||||
|
||||
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?'<div class="log"><table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+rows.map(x=>'<tr class="event-'+esc(x.level||'info')+'"><td>'+esc(when(x.time))+'</td><td>'+esc(x.level||'info')+'</td><td>'+esc(x.user||'–')+'</td><td>'+esc(x.message||'')+'</td></tr>').join('')+'</tbody></table></div>':'<div class="empty">Noch keine Ereignisse.</div>'}
|
||||
function renderRemoteApps(s){let rows=s.remote_apps||[];$('remoteapps').innerHTML=rows.length?'<table class="table"><thead><tr><th>Status</th><th>Alias</th><th>Pfad</th><th>Verwaltung</th><th>Fehler</th></tr></thead><tbody>'+rows.map(x=>'<tr><td><span class="badge '+(x.published&&x.path_exists&&x.in_sync?'good':'bad')+'">'+(x.published&&x.path_exists&&x.in_sync?'Bereit':'Nicht bereit')+'</span></td><td><strong>||'+esc(x.alias)+'</strong><br><span class="muted">'+esc(x.display_name||x.resource_id||'–')+'</span></td><td>'+esc(x.path||'–')+'</td><td>'+(x.managed?'<span class="badge good">Master</span>'+(x.owned?' <span class="muted">(angelegt)</span>':' <span class="muted">(übernommen)</span>'):'<span class="badge neutral">lokal entdeckt</span>')+'</td><td>'+esc(x.error||'–')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine RemoteApps gefunden. Bei Agent-gesteuerten Apps erscheint der Status nach dem nächsten Master-Heartbeat.</div>'}
|
||||
function renderProfiles(s){let jobs=s.profile_jobs||[],status=Object.values(s.profile_status||{});let html='';if(jobs.length)html+='<table class="table"><thead><tr><th>Job</th><th>Benutzer</th><th>Fällig</th><th>Versuche</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+esc(when(j.due_at))+'</td><td>'+j.attempts+'</td><td>'+esc(j.last_error||'–')+'</td></tr>').join('')+'</tbody></table>';if(status.length)html+='<table class="table"><thead><tr><th>Benutzer</th><th>Letztes Backup</th><th>Letzter Restore</th><th>Status</th></tr></thead><tbody>'+status.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+esc(when(x.last_backup_at))+'</td><td>'+esc(when(x.last_restore_at))+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'OK')+'</td></tr>').join('')+'</tbody></table>';$('profiles').innerHTML=html||'<div class="empty">Keine Profil-Jobs oder -Historie.</div>'}
|
||||
async function sessionAction(id,action){let body={action};if(action==='message'){let m=prompt('Nachricht an die Sitzung:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profil-Pipeline startet nach dem Sitzungsende.'))return;try{await api('/api/v1/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});setTimeout(refresh,400)}catch(e){alert(e.message)}}
|
||||
function renderSessions(s){let p=s.policy||{},control=p.sessions&&p.sessions.control_enabled,ss=s.sessions||[];$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Getrennt seit</th><th>Client</th><th>Aktionen</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(x.disconnected_since?when(x.disconnected_since)+' ('+dur(x.disconnected_since)+')':'–')+'</td><td>'+esc(x.client_name||'–')+'</td><td>'+(control&&x.user?'<div class="actions"><button class="secondary" data-session="'+x.id+'" data-session-action="message">Nachricht</button><button class="secondary" data-session="'+x.id+'" data-session-action="disconnect">Trennen</button><button class="danger" data-session="'+x.id+'" data-session-action="logoff">Abmelden</button></div>':'–')+'</td></tr>').join('')+'</tbody></table>'}
|
||||
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('master').innerHTML=d.master_error?'<span class="status-badge status-danger">Offline</span>':'<span class="status-badge status-online">Verbunden</span>';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
|
||||
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?'<span class="status-badge status-danger">Offline</span>':'<span class="status-badge status-online">Verbunden</span>';renderSessions(s);renderRemoteApps(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
|
||||
function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'<div class="muted">Keine weiteren Angaben.</div>';if(k==='url')return'<label>URL<input data-field="url" value="'+esc(t.url||'')+'"></label>';if(k==='shortcut'){let s=t.shortcut||{};return'<div class="two"><label>Zielprogramm<input data-field="shortcut.target" value="'+esc(s.target||'')+'"></label><label>Argumente<input data-field="shortcut.arguments" value="'+esc(s.arguments||'')+'"></label><label>Arbeitsverzeichnis<input data-field="shortcut.working_directory" value="'+esc(s.working_directory||'')+'"></label><label>Icon<input data-field="shortcut.icon_location" value="'+esc(s.icon_location||'')+'"></label></div><label>Beschreibung<input data-field="shortcut.description" value="'+esc(s.description||'')+'"></label>'}return'<label>Quelldatei / UNC-Pfad<input data-field="source" value="'+esc(t.source||'')+'"></label><div class="two"><label>Inline-Inhalt<textarea data-field="content">'+esc(t.content||'')+'</textarea></label><label>Inline Base64<textarea data-field="content_base64">'+esc(t.content_base64||'')+'</textarea></label></div>'}
|
||||
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'<div class="item template-card"><div class="item-head"><strong>'+esc(t.id||('Template '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-template" data-index="'+i+'">Löschen</button></div><div class="cols"><label>ID<input data-field="id" value="'+esc(t.id||'')+'"></label><label>Typ<select data-field="kind" data-action="template-kind"><option value="file" '+((t.kind||'file')==='file'?'selected':'')+'>Datei</option><option value="directory" '+(t.kind==='directory'?'selected':'')+'>Ordner</option><option value="url" '+(t.kind==='url'?'selected':'')+'>URL</option><option value="shortcut" '+(t.kind==='shortcut'?'selected':'')+'>.lnk</option></select></label><label>Ziel relativ zum Profil<input data-field="target" value="'+esc(t.target||'')+'"></label></div><div class="check"><input data-field="overwrite" type="checkbox" '+(t.overwrite?'checked':'')+'><label>Aktualisieren/überschreiben</label></div>'+templateSpecific(t)+'</div>').join(''):'<div class="empty">Keine Templates konfiguriert.</div>'}
|
||||
function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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}}
|
||||
</style></head><body>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.3.4</div></div></div>
|
||||
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Published Resources</a></nav>
|
||||
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.4.0</div></div></div>
|
||||
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a></nav>
|
||||
<nav class="nav-group"><div class="nav-label">Betrieb</div><a class="nav-link" href="#history-section"><span class="nav-icon">↻</span>Session-Historie</a><a class="nav-link" href="#policies-section"><span class="nav-icon">≋</span>Policy-Historie</a><a class="nav-link" href="#alerts-section"><span class="nav-icon">!</span>Alerts</a><a class="nav-link" href="#audit-section"><span class="nav-icon">✓</span>Audit-Log</a></nav>
|
||||
<div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Master Console</div><div class="topbar-sub">Broker, Director, Profile & Policy Management</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button" title="Theme wechseln">◐</button></div></header>
|
||||
@@ -46,15 +46,15 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
|
||||
<div class="metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Server</span><span class="metric-icon">▣</span></div><div class="value" id="mServers">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Online</span><span class="metric-icon">●</span></div><div class="value" id="mOnline">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="mActive">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="mDisc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="mProfile">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="mCleanup">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Farms</span><span class="metric-icon">⌘</span></div><div class="value" id="mFarms">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Alerts</span><span class="metric-icon">!</span></div><div class="value" id="mAlerts">–</div></div></div></section>
|
||||
<section class="page-section" id="servers"><div class="section-heading"><div><div class="eyebrow">Director</div><h2>Terminalserver</h2><div class="section-copy">Server auswählen, Sitzungen steuern und Health-Details prüfen.</div></div></div><div class="workspace-grid"><section class="panel"><div class="panel-head"><div><h2>Serverliste</h2><div class="panel-kicker">Online, Sessions, Health und Jobs</div></div></div><div id="agents"></div></section><section class="panel"><div class="panel-head"><div><h2 id="detailTitle">Server auswählen</h2><div class="panel-kicker">Live-Details und Konfiguration</div></div></div><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></section>
|
||||
<section class="page-section" id="farms-section"><div class="section-heading"><div><div class="eyebrow">Broker</div><h2>Farms & Broker</h2><div class="section-copy">Hosts gruppieren, Tags definieren und aktive Leases nachvollziehen.</div></div></div><section class="panel"><div id="farms"></div></section></section>
|
||||
<section class="page-section" id="resources-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>Published Resources</h2><div class="section-copy">Desktops und RemoteApps mit Guacamole-Verbindungen und Farms verknüpfen.</div></div></div><section class="panel"><div id="resources"></div></section></section>
|
||||
<section class="page-section" id="resources-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>Apps & Desktops</h2><div class="section-copy">Guacamole-Ressourcen zu Farms zuordnen und RemoteApps optional direkt durch die Windows-Agents veröffentlichen.</div></div></div><section class="panel"><div id="resources"></div></section></section>
|
||||
<section class="page-section" id="history-section"><div class="section-heading"><div><div class="eyebrow">Historie</div><h2>Session-Historie</h2><div class="section-copy">Logon, Reconnect, Disconnect und Profilereignisse nachvollziehen.</div></div></div><section class="panel"><div id="history" class="content"></div></section></section>
|
||||
<section class="page-section" id="policies-section"><div class="section-heading"><div><div class="eyebrow">Governance</div><h2>Policy-Historie & Rollback</h2><div class="section-copy">Revisionen vergleichen und frühere Sollstände wieder aktivieren.</div></div></div><section class="panel"><div id="policyHistory" class="content"></div></section></section>
|
||||
<section class="page-section" id="alerts-section"><div class="section-heading"><div><div class="eyebrow">Monitoring</div><h2>Alerts</h2><div class="section-copy">Aktive und gelöste Betriebswarnungen.</div></div></div><section class="panel"><div id="alerts" class="content"></div></section></section>
|
||||
<section class="page-section" id="audit-section"><div class="section-heading"><div><div class="eyebrow">Compliance</div><h2>Audit-Log</h2><div class="section-copy">Administrative Änderungen und Aktionen revisionsfreundlich nachvollziehen.</div></div></div><section class="panel"><div id="audit" class="content"></div></section></section>
|
||||
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.3.4"></script></body></html>`
|
||||
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.4.0"></script></body></html>`
|
||||
|
||||
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&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
|
||||
let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
|
||||
|
||||
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?'<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Logon / Idle</th><th>Client</th><th>Aktionen</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(when(x.logon_at))+'<br><span class="muted">Idle '+Math.round((x.idle_seconds||0)/60)+' min</span></td><td>'+esc(x.client_name||'–')+'<br><span class="muted">'+esc(x.client_address||'')+'</span></td><td>'+(control&&x.user?'<div class="actions"><button class="secondary" data-session="'+x.id+'" data-session-action="message">Nachricht</button><button class="secondary" data-session="'+x.id+'" data-session-action="disconnect">Trennen</button><button class="danger" data-session="'+x.id+'" data-session-action="logoff">Abmelden</button></div>':'–')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Benutzersitzungen.</div>'}
|
||||
function profileHTML(a){let s=a.snapshot||{},jobs=s.profile_jobs||[],st=Object.values(s.profile_status||{});let h=jobs.length?'<table class="table"><thead><tr><th>Operation</th><th>Benutzer</th><th>Fällig</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+when(j.due_at)+'</td><td>'+esc(j.last_error||'–')+'</td></tr>').join('')+'</tbody></table>':'';if(st.length)h+='<table class="table"><thead><tr><th>Benutzer</th><th>Backup</th><th>Restore</th><th>Fehler</th></tr></thead><tbody>'+st.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+when(x.last_backup_at)+'</td><td>'+when(x.last_restore_at)+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'–')+'</td></tr>').join('')+'</tbody></table>';return h||'<div class="empty">Keine Profil-Jobs/Statusdaten.</div>'}
|
||||
function remoteAppsHTML(a){let rows=((a.snapshot||{}).remote_apps||[]);return rows.length?'<table class="table"><thead><tr><th>Status</th><th>Alias</th><th>Pfad</th><th>Quelle</th><th>Fehler</th></tr></thead><tbody>'+rows.map(x=>'<tr><td><span class="badge '+(x.published&&x.path_exists&&x.in_sync?'good':'bad')+'">'+(x.published&&x.path_exists&&x.in_sync?'Bereit':'Nicht bereit')+'</span></td><td><strong>||'+esc(x.alias)+'</strong><br><span class="muted">'+esc(x.display_name||x.resource_id||'–')+'</span></td><td>'+esc(x.path||'–')+'</td><td>'+(x.managed?'<span class="badge good">SessionGuard</span>'+(x.owned?' <span class="muted">(angelegt)</span>':' <span class="muted">(übernommen)</span>'):'<span class="badge neutral">entdeckt</span>')+'</td><td>'+esc(x.error||'–')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine RemoteApps entdeckt oder verwaltet.</div>'}
|
||||
function processesHTML(a){let ps=((a.snapshot||{}).processes||[]).filter(p=>p.session_id!==0).sort((x,y)=>y.memory_bytes-x.memory_bytes).slice(0,300);return ps.length?'<table class="table"><thead><tr><th>PID</th><th>Session</th><th>Prozess</th><th>RAM</th><th></th></tr></thead><tbody>'+ps.map(p=>'<tr><td>'+p.pid+'</td><td>'+p.session_id+'</td><td>'+esc(p.name)+'</td><td>'+bytes(p.memory_bytes)+'</td><td><button class="danger" data-process="'+p.pid+'" data-action="kill-process">Beenden</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Session-Prozesse erfasst.</div>'}
|
||||
function telemetryHTML(a){let rows=Object.values(((a.snapshot||{}).telemetry)||{}).sort((x,y)=>new Date(y.first_seen_at)-new Date(x.first_seen_at)).slice(0,100);return rows.length?'<table class="table"><thead><tr><th>Session</th><th>Benutzer</th><th>Logon erkannt</th><th>Restore</th><th>Bis Ready</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+x.session_id+'</td><td>'+esc(x.user||x.sid||'–')+'</td><td>'+when(x.logon_at||x.first_seen_at)+'</td><td>'+((x.restore_duration_ms||0)/1000).toFixed(2)+' s</td><td>'+((x.observed_logon_ms||0)/1000).toFixed(2)+' s</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Logon-Telemetrie.</div>'}
|
||||
function renderHistory(rows){rows=(rows||[]).slice().reverse();$('history').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Server</th><th>Benutzer</th><th>Ereignis</th><th>Client</th><th>Details</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.time)+'</td><td>'+esc(x.hostname)+'</td><td>'+esc(x.user)+'</td><td>'+esc(x.event)+'</td><td>'+esc(x.client_name||'–')+'</td><td>'+esc(x.details||'–')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Session-Historie.</div>'}
|
||||
function renderAlerts(rows){$('alerts').innerHTML=rows.length?'<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Typ</th><th>Meldung</th><th>Zuletzt</th></tr></thead><tbody>'+rows.map(x=>'<tr><td><span class="badge '+(x.active?'bad':'good')+'">'+(x.active?'AKTIV':'gelöst')+'</span></td><td>'+esc(x.hostname||'–')+'</td><td>'+esc(x.type)+'</td><td>'+esc(x.message)+'</td><td>'+when(x.last_seen_at)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Alerts.</div>'}
|
||||
function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function 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='<div id="farmEditor" class="form"><div class="cols"><label>Neue Farm<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc"></label><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label></div><div class="actions"><button type="button" data-global-action="create-farm">Farm anlegen</button><button type="button" class="secondary" data-global-action="clear-farm-form">Eingaben leeren</button></div></div><div id="farmData"></div>';let lease=brokerLeases.length?'<h3>Aktive Broker-Leases</h3><table class="table"><thead><tr><th>Benutzer</th><th>Agent</th><th>Farm</th><th>Resource</th><th>Grund</th><th>Bis</th></tr></thead><tbody>'+brokerLeases.map(l=>'<tr><td>'+esc(l.user_key)+'</td><td>'+esc(l.agent_id)+'</td><td>'+esc(l.farm_id||'–')+'</td><td>'+esc(l.resource_id||'–')+'</td><td>'+esc(l.reason||'–')+'</td><td>'+when(l.expires_at)+'</td></tr>').join('')+'</tbody></table>':'';$('farmData').innerHTML=(farmCache.length?'<table class="table"><thead><tr><th>Name</th><th>ID</th><th>Agenten</th><th>Required Tags</th><th></th></tr></thead><tbody>'+farmCache.map(f=>'<tr><td>'+esc(f.name)+'</td><td>'+esc(f.id)+'</td><td title="'+esc(farmMembers(f).map(a=>a.name||a.id).join(', '))+'">'+farmMembers(f).length+'</td><td>'+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'</td><td><button class="danger" data-global-action="delete-farm" data-id="'+esc(f.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Farms.</div>')+lease}
|
||||
function renderResources(){let root=$('resources');if(!root)return;if(!$('resourceEditor'))root.innerHTML='<div id="resourceEditor" class="form"><div class="cols"><label>Name<input id="resName" placeholder="Office Desktop"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName"></label><label>RemoteApp<input id="resRemoteApp" placeholder="||Sage"></label><label>RemoteApp Working Dir<input id="resRemoteDir"></label><label>RemoteApp Argumente<input id="resRemoteArgs"></label></div><div class="actions"><button type="button" data-global-action="create-resource">Resource anlegen</button><button type="button" class="secondary" data-global-action="clear-resource-form">Eingaben leeren</button></div></div><div id="resourceData"></div>';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').innerHTML=resourceCache.length?'<table class="table"><thead><tr><th>Name</th><th>Typ</th><th>Farm</th><th>Guacamole</th><th>RemoteApp</th><th></th></tr></thead><tbody>'+resourceCache.map(x=>'<tr><td>'+esc(x.name)+'</td><td>'+esc(x.kind)+'</td><td>'+esc(x.farm_id)+'</td><td>'+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+'</td><td>'+esc(x.remote_app||'–')+'</td><td><button class="danger" data-global-action="delete-resource" data-id="'+esc(x.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Resources.</div>'}
|
||||
function 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='<div id="resourceEditor" class="form"><div class="cols"><label>Name<input id="resName" placeholder="Sage 100"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName" placeholder="Sage"></label><label>RemoteApp Alias<input id="resRemoteApp" placeholder="||Sage"></label><label>Guacamole Working Dir<input id="resRemoteDir"></label><label>Guacamole Argumente (bei verwalteten Apps: Policy „Erlauben“)<input id="resRemoteArgs"></label></div><div class="note" id="remoteAppManageNote"><strong>Agent-gesteuerte RemoteApp-Veröffentlichung</strong><br>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.</div><div class="check"><input id="resManage" type="checkbox"><label>RemoteApp auf Farm-Agents automatisch veröffentlichen und überwachen</label></div><div class="cols"><label>Executable auf dem Terminalserver<input id="resRemotePath" placeholder="C:\\Program Files\\Hersteller\\app.exe"></label><label>Icon-Pfad (optional)<input id="resRemoteIcon" placeholder="leer = Executable"></label><label>Icon-Index<input id="resIconIndex" type="number" value="0"></label><label>Command-Line-Policy<select id="resCmd"><option value="0">Keine Argumente erlauben</option><option value="1">Argumente aus RDP/Guacamole erlauben</option><option value="2">Vorgegebene Argumente erzwingen</option></select></label><label>Vorgegebene Argumente<input id="resRemoteRequired"></label><div class="check"><input id="resPortal" type="checkbox"><label>Auch in RD Web Access anzeigen</label></div></div><div class="actions"><button id="resSave" type="button" data-global-action="save-resource">Resource anlegen</button><button type="button" class="secondary" data-global-action="clear-resource-form">Eingaben leeren</button></div></div><div id="resourceData"></div>';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').innerHTML=resourceCache.length?'<table class="table"><thead><tr><th>Name</th><th>Typ</th><th>Farm</th><th>Guacamole</th><th>RemoteApp</th><th>Agent-Status</th><th></th></tr></thead><tbody>'+resourceCache.map(x=>{let h=resourceReadiness(x);return'<tr><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.id)+'</span></td><td>'+esc(x.kind)+'</td><td>'+esc(x.farm_id)+'</td><td>'+esc(x.guacamole_connection_id||x.guacamole_connection_name||'–')+'</td><td>'+esc(x.remote_app||'–')+(x.manage_remote_app?'<br><span class="muted">'+esc(x.remote_app_path||'Pfad fehlt')+'</span>':'')+'</td><td title="'+esc(h.detail)+'"><span class="badge '+h.cls+'">'+esc(h.text)+'</span></td><td><div class="actions"><button class="secondary" data-global-action="edit-resource" data-id="'+esc(x.id)+'">Bearbeiten</button><button class="danger" data-global-action="delete-resource" data-id="'+esc(x.id)+'">Löschen</button></div></td></tr>'}).join('')+'</tbody></table>':'<div class="empty">Noch keine Resources.</div>';updateResourceEditorState()}
|
||||
function eventsHTML(a){let e=((a.snapshot||{}).events||[]).slice().reverse().slice(0,160);return e.length?'<table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+e.map(x=>'<tr class="event-'+esc(x.level)+'"><td>'+when(x.time)+'</td><td>'+esc(x.level)+'</td><td>'+esc(x.user||'–')+'</td><td>'+esc(x.message)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Ereignisse.</div>'}
|
||||
function activePolicy(a){if(a.desired_policy&&a.desired_policy.revision)return a.desired_policy;if(a.snapshot&&a.snapshot.policy)return a.snapshot.policy;return{cleanup:{grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,allowed_profile_roots:['C:\\Users']},profiles:{retry_seconds:60,keep_versions:2,folders:[]},sessions:{disconnected_timeout_seconds:3600},templates:[]}}
|
||||
function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('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='<div id="overviewStats"></div><div class="section-title">Serversteuerung</div><div id="controlEditor"></div><div class="section-title">Sitzungen</div><div id="sessionList" class="content"></div><div class="section-title">Logon Performance</div><div id="telemetryList" class="content"></div><div class="section-title">Prozesse</div><div id="processList" class="content"></div><div class="section-title">Profil-Pipeline</div><div id="profileList" class="content"></div><div class="section-title">Agent-Ereignisse</div><div id="eventList" class="content"></div><div class="section-title">Policy</div><div id="policyEditor">'+policyForm(p,a)+'</div>';$('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='<div id="overviewStats"></div><div class="section-title">Serversteuerung</div><div id="controlEditor"></div><div class="section-title">Sitzungen</div><div id="sessionList" class="content"></div><div class="section-title">Logon Performance</div><div id="telemetryList" class="content"></div><div class="section-title">Prozesse</div><div id="processList" class="content"></div><div class="section-title">RemoteApps</div><div id="remoteAppList" class="content"></div><div class="section-title">Profil-Pipeline</div><div id="profileList" class="content"></div><div class="section-title">Agent-Ereignisse</div><div id="eventList" class="content"></div><div class="section-title">Policy</div><div id="policyEditor">'+policyForm(p,a)+'</div>';$('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'<div class="muted">Keine weiteren Angaben.</div>';if(k==='url')return'<label>URL<input data-field="url" value="'+esc(t.url||'')+'"></label>';if(k==='shortcut'){let s=t.shortcut||{};return'<div class="two"><label>Zielprogramm<input data-field="shortcut.target" value="'+esc(s.target||'')+'"></label><label>Argumente<input data-field="shortcut.arguments" value="'+esc(s.arguments||'')+'"></label><label>Arbeitsverzeichnis<input data-field="shortcut.working_directory" value="'+esc(s.working_directory||'')+'"></label><label>Icon<input data-field="shortcut.icon_location" value="'+esc(s.icon_location||'')+'"></label></div><label>Beschreibung<input data-field="shortcut.description" value="'+esc(s.description||'')+'"></label>'}return'<label>Quelldatei / UNC-Pfad<input data-field="source" value="'+esc(t.source||'')+'"></label><div class="two"><label>Inline-Inhalt<textarea data-field="content">'+esc(t.content||'')+'</textarea></label><label>Inline Base64<textarea data-field="content_base64">'+esc(t.content_base64||'')+'</textarea></label></div>'}
|
||||
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'<div class="item template-card"><div class="item-head"><strong>'+esc(t.id||('Template '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-template" data-index="'+i+'">Löschen</button></div><div class="cols"><label>ID<input data-field="id" value="'+esc(t.id||'')+'"></label><label>Typ<select data-field="kind" data-action="template-kind"><option value="file" '+((t.kind||'file')==='file'?'selected':'')+'>Datei</option><option value="directory" '+(t.kind==='directory'?'selected':'')+'>Ordner</option><option value="url" '+(t.kind==='url'?'selected':'')+'>URL</option><option value="shortcut" '+(t.kind==='shortcut'?'selected':'')+'>.lnk</option></select></label><label>Ziel<input data-field="target" value="'+esc(t.target||'')+'"></label></div><div class="check"><input data-field="overwrite" type="checkbox" '+(t.overwrite?'checked':'')+'><label>Aktualisieren/überschreiben</label></div>'+templateSpecific(t)+'</div>').join(''):'<div class="empty">Keine Templates.</div>'}
|
||||
function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})}
|
||||
@@ -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);`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
52
internal/windowsx/remoteapp_status.go
Normal file
52
internal/windowsx/remoteapp_status.go
Normal file
@@ -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
|
||||
}
|
||||
45
internal/windowsx/remoteapp_status_test.go
Normal file
45
internal/windowsx/remoteapp_status_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
10
internal/windowsx/remoteapp_stub.go
Normal file
10
internal/windowsx/remoteapp_stub.go
Normal file
@@ -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
|
||||
}
|
||||
216
internal/windowsx/remoteapp_windows.go
Normal file
216
internal/windowsx/remoteapp_windows.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user