0.4.0
release-tag / release-image (push) Successful in 2m2s
release-main / release-images (push) Successful in 3m34s

This commit is contained in:
2026-08-22 23:48:15 +02:00
parent b466737549
commit e104e7289f
23 changed files with 1023 additions and 85 deletions
+52
View 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
}
@@ -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
View 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
View 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)
}