From 1b29cbb39d50354b36b6791195328cb856a144f7 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sun, 23 Aug 2026 00:05:58 +0200 Subject: [PATCH] 0.4.1 --- CHANGELOG.md | 9 ++++ README.md | 6 ++- guacamole-extension/pom.xml | 2 +- internal/agent/agent.go | 2 +- internal/agent/ui.go | 4 +- internal/master/master.go | 2 +- internal/master/ui.go | 4 +- internal/windowsx/powershell_json.go | 55 +++++++++++++++++++++++ internal/windowsx/powershell_json_test.go | 40 +++++++++++++++++ internal/windowsx/remoteapp_windows.go | 37 ++++++++------- 10 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 internal/windowsx/powershell_json.go create mode 100644 internal/windowsx/powershell_json_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fe88e06..f7e7138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.4.1 — RemoteApp PowerShell/CLIXML robustness + +- RemoteApp PowerShell execution now keeps stderr separate from JSON stdout. +- Suppresses PowerShell progress/information/verbose/debug/warning streams for machine-readable RemoteApp calls. +- Forces UTF-8 console output where supported. +- JSON decoder defensively extracts the first valid JSON object/array and tolerates CLIXML/banner noise before or after the payload. +- Added regression tests for the `#< CLIXML` contamination observed on Windows PowerShell 5.1. +- Protocol remains version 4; no Master/Agent schema migration is required. + ## 0.4.0 — Agent-managed RemoteApps - Added a farm-scoped RemoteApp desired-state model to Published Resources. diff --git a/README.md b/README.md index 80447c5..31b8a02 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,14 @@ SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions. -**Current development version: 0.4.0 (RemoteApp control plane)** +**Current development version: 0.4.1 (RemoteApp PowerShell/CLIXML robustness)** > 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.1 RemoteApp robustness + +Windows PowerShell auxiliary streams are now isolated from the JSON protocol used by RemoteApp discovery/reconciliation. CLIXML/progress noise no longer breaks RemoteApp status decoding. + ## 0.4.0 RemoteApp control plane - The Master can define RemoteApp desired state per Published Resource/Farm. diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml index 2ca3a78..6e0b3ae 100644 --- a/guacamole-extension/pom.xml +++ b/guacamole-extension/pom.xml @@ -5,7 +5,7 @@ 4.0.0 info.hilden.sessionguard sessionguard-guacamole - 0.4.0 + 0.4.1 jar 11 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d5603bd..0a34ecc 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -25,7 +25,7 @@ import ( "github.com/example/sessionguard/internal/windowsx" ) -const Version = "0.4.0" +const Version = "0.4.1" type App struct { cfg config.Agent diff --git a/internal/agent/ui.go b/internal/agent/ui.go index 0decccb..9e80057 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}} @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}} -
+
Lokaler Agent
Lokaler Terminalserver
Live · 5s
Local Control

Terminalserver-Status

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

Sitzungen

Aktive und getrennte Benutzer sowie administrative Aktionen.
@@ -44,7 +44,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
Profile

Profil-Pipeline

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

Aktivitäts- & Audit-Log

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

Lokale Policy

Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.
-
` +
` const agentJS = ` let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false; diff --git a/internal/master/master.go b/internal/master/master.go index 361ed67..e65c5da 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -23,7 +23,7 @@ import ( "github.com/example/sessionguard/internal/model" ) -const Version = "0.4.0" +const Version = "0.4.1" type App struct { cfg config.Master diff --git a/internal/master/ui.go b/internal/master/ui.go index f8dbd6d..43ea74e 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -37,7 +37,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac @media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
-
` +
` const masterJS = ` let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)} diff --git a/internal/windowsx/powershell_json.go b/internal/windowsx/powershell_json.go new file mode 100644 index 0000000..1f9b77d --- /dev/null +++ b/internal/windowsx/powershell_json.go @@ -0,0 +1,55 @@ +package windowsx + +import ( + "encoding/json" + "fmt" + "strings" +) + +// decodePowerShellJSON extracts the first JSON value from PowerShell stdout. +// Windows PowerShell can occasionally surround redirected output with startup, +// progress, or CLIXML noise. The command runner keeps stderr separate, but this +// parser is deliberately defensive so a valid JSON payload is not discarded +// merely because a host emits an unexpected banner before/after it. +func decodePowerShellJSON(raw string, out any) error { + raw = strings.TrimSpace(strings.TrimPrefix(raw, "\ufeff")) + if raw == "" { + raw = "[]" + } + + payload, err := firstJSONValue(raw) + if err != nil { + return fmt.Errorf("no JSON value in PowerShell output: %w (output=%q)", err, raw) + } + + payload = strings.TrimSpace(payload) + // Windows PowerShell/ConvertTo-Json may return a single object when only one + // item exists. RemoteApp callers always decode into a slice, so normalize it. + if strings.HasPrefix(payload, "{") { + payload = "[" + payload + "]" + } + if err := json.Unmarshal([]byte(payload), out); err != nil { + return fmt.Errorf("decode PowerShell JSON: %w (json=%q, output=%q)", err, payload, raw) + } + return nil +} + +func firstJSONValue(raw string) (string, error) { + var lastErr error + for i := 0; i < len(raw); i++ { + if raw[i] != '[' && raw[i] != '{' { + continue + } + dec := json.NewDecoder(strings.NewReader(raw[i:])) + var msg json.RawMessage + if err := dec.Decode(&msg); err != nil { + lastErr = err + continue + } + return string(msg), nil + } + if lastErr == nil { + lastErr = fmt.Errorf("missing JSON object/array delimiter") + } + return "", lastErr +} diff --git a/internal/windowsx/powershell_json_test.go b/internal/windowsx/powershell_json_test.go new file mode 100644 index 0000000..2e3e304 --- /dev/null +++ b/internal/windowsx/powershell_json_test.go @@ -0,0 +1,40 @@ +package windowsx + +import ( + "testing" + + "github.com/example/sessionguard/internal/model" +) + +func TestDecodePowerShellJSONCleanArray(t *testing.T) { + var got []model.RemoteAppStatus + if err := decodePowerShellJSON(`[{"alias":"Calc","published":true}]`, &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Alias != "Calc" || !got[0].Published { + t.Fatalf("unexpected result: %#v", got) + } +} + +func TestDecodePowerShellJSONSingleObject(t *testing.T) { + var got []model.RemoteAppStatus + if err := decodePowerShellJSON(`{"alias":"Calc","published":true}`, &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Alias != "Calc" { + t.Fatalf("unexpected result: %#v", got) + } +} + +func TestDecodePowerShellJSONIgnoresCLIXMLNoise(t *testing.T) { + raw := "#< CLIXML\r\n" + + `[{"resource_id":"281750fc5851c7fd","alias":"Rechner","display_name":"Rechner","path":"C:\\Windows\\system32\\win32calc.exe","path_exists":true,"published":true,"managed":true,"in_sync":true}]` + + "\r\nSystem.Management.Automation.PSCustomObjectSystem.Object1Module werden fuer erstmalige Verwendung vorbereitet." + var got []model.RemoteAppStatus + if err := decodePowerShellJSON(raw, &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Alias != "Rechner" || got[0].Path != `C:\Windows\system32\win32calc.exe` { + t.Fatalf("unexpected result: %#v", got) + } +} diff --git a/internal/windowsx/remoteapp_windows.go b/internal/windowsx/remoteapp_windows.go index 0c5710c..58b9387 100644 --- a/internal/windowsx/remoteapp_windows.go +++ b/internal/windowsx/remoteapp_windows.go @@ -3,6 +3,7 @@ package windowsx import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -180,29 +181,33 @@ ConvertTo-Json -InputObject @($results) -Compress -Depth 5`, payload) } func runPowerShellJSON(script string, out any) error { - encoded := encodePowerShell(script) + // Suppress PowerShell's auxiliary streams. On Windows PowerShell 5.1 these + // can otherwise be serialized as "#< CLIXML" when handles are redirected. + // stderr is also kept separate from stdout so progress/information records + // can never corrupt the JSON protocol used between PowerShell and the agent. + preamble := `$ProgressPreference='SilentlyContinue' +$InformationPreference='SilentlyContinue' +$VerbosePreference='SilentlyContinue' +$DebugPreference='SilentlyContinue' +$WarningPreference='SilentlyContinue' +try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch {} +` + encoded := encodePowerShell(preamble + script) cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded) - b, err := cmd.CombinedOutput() + var stderr bytes.Buffer + cmd.Stderr = &stderr + b, err := cmd.Output() if err != nil { - msg := strings.TrimSpace(string(b)) + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + 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 + return decodePowerShellJSON(string(b), out) } func encodePowerShell(script string) string {