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
+125 -2
View File
@@ -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) {
+47 -26
View File
@@ -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.
+6 -4
View File
@@ -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})}
+87
View File
@@ -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)
}
}
+113 -7
View File
@@ -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 ""
+12 -8
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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'&&current&&$('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'&&current&&$('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);`
+45 -4
View File
@@ -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
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)
}