Update mit Guacamole-Extension
release-tag / release-image (push) Successful in 2m5s
release-main / release-images (push) Successful in 5m5s

This commit is contained in:
2026-08-22 15:19:17 +02:00
parent 0f4a234f52
commit 7972ed7e38
45 changed files with 6448 additions and 402 deletions
+812 -101
View File
File diff suppressed because it is too large Load Diff
+67 -6
View File
@@ -6,17 +6,27 @@ import (
"os"
"path/filepath"
"sync"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
)
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"`
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"`
}
type stateStore struct {
@@ -25,7 +35,20 @@ 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{}}
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{},
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
@@ -42,6 +65,44 @@ func loadState(path string, initial model.Policy) (State, error) {
if s.Pending == nil {
s.Pending = map[string]model.CleanupJob{}
}
if s.ProfileJobs == nil {
s.ProfileJobs = map[string]model.ProfileJob{}
}
if s.ProfileStatus == nil {
s.ProfileStatus = map[string]model.ProfileStatus{}
}
if s.DisconnectedSince == nil {
s.DisconnectedSince = map[uint32]time.Time{}
}
if s.AutoLogoffRequested == nil {
s.AutoLogoffRequested = map[uint32]time.Time{}
}
if s.ProcessedCommands == nil {
s.ProcessedCommands = map[string]time.Time{}
}
if s.CommandResults == nil {
s.CommandResults = []model.CommandResult{}
}
if s.Events == nil {
s.Events = []model.AgentEvent{}
}
if s.Telemetry == nil {
s.Telemetry = map[uint32]model.SessionTelemetry{}
}
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.
s.RestoredSessions = map[uint32]bool{}
for id := range s.LastSessions {
s.RestoredSessions[id] = true
}
}
if len(s.Events) > 500 {
s.Events = append([]model.AgentEvent(nil), s.Events[len(s.Events)-500:]...)
}
if len(s.CommandResults) > 100 {
s.CommandResults = append([]model.CommandResult(nil), s.CommandResults[len(s.CommandResults)-100:]...)
}
if s.Policy.Revision == "" {
s.Policy = initial
}
+19 -5
View File
@@ -1,10 +1,24 @@
package agent
const agentHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Agent</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0c1120;color:#edf2fb}*{box-sizing:border-box}body{margin:0;background:#0c1120}.wrap{max-width:1200px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center}.brand{font-size:20px;font-weight:750}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.card,.panel{background:#131d32;border:1px solid #263653;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:750;margin-top:7px}.panel{margin-top:15px;overflow:hidden}.panel h2{font-size:15px;padding:14px 16px;margin:0;border-bottom:1px solid #263653}.table{width:100%;border-collapse:collapse}.table td,.table th{padding:11px 13px;border-bottom:1px solid #22314d;text-align:left;font-size:13px}.table th{color:#93a5c2}.form{padding:16px;display:grid;gap:11px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}input,textarea{width:100%;background:#0d1629;color:#edf2fb;border:1px solid #334866;border-radius:9px;padding:8px}textarea{min-height:110px;font-family:ui-monospace,Consolas,monospace;font-size:12px}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}button{background:#5d8eff;color:#fff;border:0;border-radius:9px;padding:9px 13px;font-weight:650;cursor:pointer}.secondary{background:#22314d}.bad{color:#ff9b9b}.good{color:#74e59a}@media(max-width:850px){.grid{grid-template-columns:repeat(2,1fr)}.cols{grid-template-columns:1fr}}</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:16px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1450px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{font-size:20px;font-weight:800}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.card,.panel{background:rgba(18,27,49,.88);border:1px solid #263654;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:800;margin-top:5px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:15px 18px;border-bottom:1px solid #263654}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:10px 12px;border-bottom:1px solid #22314e;text-align:left;font-size:13px;vertical-align:top}.table th{color:#94a5c4}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning{color:#ffd166}.form{padding:16px;display:grid;gap:13px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.two{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}label{display:grid;gap:5px;font-size:13px;color:#c8d4e7}input,textarea,select{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px}textarea{min-height:90px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.check label{display:block}button{background:#5b8cff;color:white;border:0;border-radius:9px;padding:8px 11px;font-weight:650;cursor:pointer}button.secondary{background:#263654}button.danger{background:#743b46}.actions{display:flex;gap:5px;flex-wrap:wrap}.log{max-height:360px;overflow:auto}.event-error{color:#ffaaaa}.event-warning{color:#ffd166}.event-dry-run{color:#ffd166}.empty{padding:18px;color:#8190aa}.section{border-top:1px solid #2a3d5d;padding-top:14px;margin-top:4px}.section-title{font-weight:800;margin-bottom:10px}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px}.list{display:grid;gap:10px}.item{border:1px solid #2b3d5c;background:#0f192c;border-radius:12px;padding:12px;display:grid;gap:9px}.item-head{display:flex;justify-content:space-between;align-items:center}.note{padding:10px;border:1px solid #334866;background:#101b31;border-radius:10px;color:#9fb0ca;font-size:12px}@media(max-width:1050px){.grid{grid-template-columns:repeat(3,1fr)}.cols,.two{grid-template-columns:1fr}}@media(max-width:650px){.grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="disc"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="profileJobs"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:15px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Profil-Pipeline</h2><div id="profiles"></div></section><section class="panel"><h2>Aktivitäts- &amp; Audit-Log</h2><div id="events"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
const agentJS = `
const $=id=>document.getElementById(id);function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}async function api(u,o){let r=await fetch(u,o),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)}
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{},ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('total').textContent=ss.length;$('pending').textContent=(s.pending_cleanup||[]).length;$('master').innerHTML=d.master_error?'<span class="bad">Offline</span>':'<span class="good">Verbunden</span>';$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</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.client_name||'')+'</td></tr>').join('')+'</tbody></table>'}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML='<div class="form"><div class="cols"><label>Grace (s)<input id="grace" type="number" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'> Cleanup aktiv</div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'> Dry-Run</div><label>Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:220px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div><button onclick="savePolicy()">Lokal speichern</button></div><div class="muted">Wenn der Master für diesen Agent eine gewünschte Policy gesetzt hat, ist diese nach Wiederherstellung der Verbindung wieder maßgeblich.</div></div>'}catch(e){$('policy').textContent=e.message}}
async function savePolicy(){try{let p={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:JSON.parse($('templates').value||'[]')};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy()}catch(e){alert(e.message)}}refresh();loadPolicy();setInterval(refresh,5000);`
let policyTemplates=[],profileFolders=[],lastSnapshot=null;
const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
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 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="bad">Offline</span>':'<span class="good">Verbunden</span>';renderSessions(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})}
function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'<div class="item profile-folder"><div class="item-head"><strong>'+esc(f.path||('Ordner '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-profile-folder" data-index="'+i+'">Löschen</button></div><div class="two"><label>Pfad relativ zum Profil<input data-field="path" value="'+esc(f.path||'')+'" placeholder="AppData\\Roaming\\Hersteller"></label><label>Ausschlüsse (eine Zeile; * oder Pfad/**)<textarea data-field="exclude">'+esc((f.exclude_globs||[]).join('\n'))+'</textarea></label></div></div>').join(''):'<div class="empty">Keine Profilordner konfiguriert.</div>'}
function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)}
function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'<div class="form"><div class="section-title">Profil-Sicherung & Wiederherstellung</div><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="two"><label>Profil-Store (UNC oder lokaler Pfad)<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" max="50" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Von Profil-Sync ausgeschlossene Benutzer<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Von Profil-Sync ausgeschlossene SIDs<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Zu sichernde Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><div class="note">Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.</div><div class="section"><div class="section-title">Sitzungsrichtlinie</div><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Administrative Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Timeout für getrennte Sitzung (s; min. 60)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Vom Auto-Logoff ausgeschlossene Benutzer<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Vom Auto-Logoff ausgeschlossene SIDs<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="section-title">Profil-Cleanup</div><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup: Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup: SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div></div><div><button type="button" data-action="save-policy">Lokal speichern</button></div></div>'}
async function loadPolicy(){try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders()}catch(e){$('policy').textContent=e.message}}
async function savePolicy(){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('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy();refresh()}catch(e){alert(e.message)}}
$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();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()}});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});refresh();loadPolicy();setInterval(refresh,5000);`
+158 -13
View File
@@ -5,17 +5,23 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/example/sessionguard/internal/model"
)
type Master struct {
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file,omitempty"`
DatabaseURL string `json:"database_url,omitempty"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
RBAC model.RBACConfig `json:"rbac"`
Broker model.BrokerConfig `json:"broker"`
Alerts model.AlertConfig `json:"alerts"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
HistoryLimit int `json:"history_limit"`
}
type Agent struct {
@@ -34,6 +40,7 @@ func LoadMaster(path string) (Master, error) {
if err := read(path, &c); err != nil {
return c, err
}
applyMasterEnv(&c)
if c.Listen == "" {
c.Listen = ":8080"
}
@@ -43,7 +50,55 @@ func LoadMaster(path string) (Master, error) {
if c.OfflineAfterSeconds <= 0 {
c.OfflineAfterSeconds = 30
}
return c, validateOIDC(c.OIDC)
if c.HistoryLimit <= 0 {
c.HistoryLimit = 50000
}
if c.Broker.LeaseSeconds <= 0 {
c.Broker.LeaseSeconds = 900
}
if c.Broker.MinHealthScore <= 0 {
c.Broker.MinHealthScore = 60
}
if c.Alerts.CPUPercent <= 0 {
c.Alerts.CPUPercent = 90
}
if c.Alerts.MemoryPercent <= 0 {
c.Alerts.MemoryPercent = 90
}
if c.Alerts.DiskFreeGB <= 0 {
c.Alerts.DiskFreeGB = 10
}
if c.Alerts.HealthScore <= 0 {
c.Alerts.HealthScore = 50
}
if c.Alerts.OfflineSeconds <= 0 {
c.Alerts.OfflineSeconds = 120
}
if c.Alerts.ProfileFailures <= 0 {
c.Alerts.ProfileFailures = 3
}
if c.Alerts.DisconnectedSessions <= 0 {
c.Alerts.DisconnectedSessions = 20
}
if c.Alerts.LogonDurationSeconds <= 0 {
c.Alerts.LogonDurationSeconds = 30
}
if c.Alerts.NotificationMinInterval <= 0 {
c.Alerts.NotificationMinInterval = 900
}
if c.RBAC.DefaultRole == "" {
c.RBAC.DefaultRole = "viewer"
}
if c.RBAC.Groups == nil {
c.RBAC.Groups = map[string][]string{}
}
if err := validateOIDC(c.OIDC); err != nil {
return c, err
}
if c.Broker.Enabled && strings.TrimSpace(c.Broker.APIKey) == "" {
return c, errors.New("broker.api_key is required when broker is enabled")
}
return c, nil
}
func LoadAgent(path string) (Agent, error) {
@@ -51,6 +106,7 @@ func LoadAgent(path string) (Agent, error) {
if err := read(path, &c); err != nil {
return c, err
}
applyAgentEnv(&c)
if c.Listen == "" {
c.Listen = ":9091"
}
@@ -78,6 +134,10 @@ func LoadAgent(path string) (Agent, error) {
if c.Policy.Cleanup.ExcludeSIDs == nil {
c.Policy.Cleanup.ExcludeSIDs = []string{"S-1-5-18", "S-1-5-19", "S-1-5-20"}
}
NormalizePolicy(&c.Policy)
if err := ValidatePolicy(c.Policy); err != nil {
return c, err
}
if c.OIDC.Issuer != "" {
if err := validateOIDC(c.OIDC); err != nil {
return c, err
@@ -86,17 +146,37 @@ func LoadAgent(path string) (Agent, error) {
return c, nil
}
func applyMasterEnv(c *Master) {
set := func(name string, dst *string) {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
*dst = v
}
}
set("SESSIONGUARD_DATABASE_URL", &c.DatabaseURL)
set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken)
set("SESSIONGUARD_BROKER_API_KEY", &c.Broker.APIKey)
set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
set("SESSIONGUARD_ALERT_WEBHOOK_URL", &c.Alerts.WebhookURL)
}
func applyAgentEnv(c *Agent) {
set := func(name string, dst *string) {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
*dst = v
}
}
set("SESSIONGUARD_MASTER_URL", &c.MasterURL)
set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken)
set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
}
func read(path string, out any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(b, out); err != nil {
return err
}
return nil
return json.Unmarshal(b, out)
}
func SaveJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
@@ -111,10 +191,75 @@ func SaveJSON(path string, v any) error {
}
return os.Rename(tmp, path)
}
func validateOIDC(c model.OIDCConfig) error {
if c.Issuer == "" || c.ClientID == "" || c.RedirectURL == "" {
return errors.New("oidc issuer, client_id and redirect_url are required")
}
return nil
}
func NormalizePolicy(p *model.Policy) {
if p.Cleanup.GraceSeconds <= 0 {
p.Cleanup.GraceSeconds = 600
}
if p.Cleanup.PollSeconds <= 0 {
p.Cleanup.PollSeconds = 10
}
if p.Cleanup.RetrySeconds <= 0 {
p.Cleanup.RetrySeconds = 60
}
if len(p.Cleanup.AllowedProfileRoots) == 0 {
p.Cleanup.AllowedProfileRoots = []string{`C:\Users`}
}
if p.Profiles.BackupDelaySeconds < 0 {
p.Profiles.BackupDelaySeconds = 0
}
if p.Profiles.BackupDelaySeconds == 0 {
p.Profiles.BackupDelaySeconds = 5
}
if p.Profiles.RetrySeconds <= 0 {
p.Profiles.RetrySeconds = 60
}
if p.Profiles.RestoreWindowSeconds <= 0 {
p.Profiles.RestoreWindowSeconds = 120
}
if p.Profiles.KeepVersions < 0 {
p.Profiles.KeepVersions = 0
}
if p.Sessions.DisconnectedTimeoutSeconds <= 0 {
p.Sessions.DisconnectedTimeoutSeconds = 3600
}
}
func ValidatePolicy(p model.Policy) error {
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 || p.Cleanup.RetrySeconds < 1 {
return errors.New("invalid cleanup timing")
}
if p.Profiles.Enabled {
if strings.TrimSpace(p.Profiles.StoreRoot) == "" {
return errors.New("profiles.store_root is required when profile sync is enabled")
}
if len(p.Profiles.Folders) == 0 {
return errors.New("at least one profiles.folders entry is required when profile sync is enabled")
}
if p.Profiles.BackupDelaySeconds < 0 {
return errors.New("profiles.backup_delay_seconds must be >= 0")
}
if p.Profiles.RetrySeconds < 1 {
return errors.New("profiles.retry_seconds must be >= 1")
}
if p.Profiles.RestoreWindowSeconds < 10 {
return errors.New("profiles.restore_window_seconds must be >= 10")
}
for _, f := range p.Profiles.Folders {
v := strings.ReplaceAll(strings.TrimSpace(f.Path), `\`, "/")
if v == "" || strings.HasPrefix(v, "/") || strings.Contains(v, ":") || v == ".." || strings.HasPrefix(v, "../") || strings.Contains(v, "/../") {
return errors.New("profile folder paths must be relative and may not escape the user profile")
}
}
}
if p.Sessions.DisconnectedLogoffEnabled && p.Sessions.DisconnectedTimeoutSeconds < 60 {
return errors.New("sessions.disconnected_timeout_seconds must be >= 60")
}
return nil
}
+26
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/example/sessionguard/internal/model"
)
func TestAgentDefaults(t *testing.T) {
@@ -22,3 +24,27 @@ func TestAgentDefaults(t *testing.T) {
t.Fatal("missing allowed profile root")
}
}
func TestValidateProfilePolicy(t *testing.T) {
p := model.Policy{
Cleanup: model.CleanupPolicy{GraceSeconds: 600, PollSeconds: 10, RetrySeconds: 60, AllowedProfileRoots: []string{`C:\Users`}},
Profiles: model.ProfilePolicy{Enabled: true, StoreRoot: `\\server\profiles`, RetrySeconds: 60, RestoreWindowSeconds: 120, Folders: []model.ProfileFolder{{Path: `AppData\Roaming\Example`}}},
Sessions: model.SessionPolicy{DisconnectedTimeoutSeconds: 3600},
}
if err := ValidatePolicy(p); err != nil {
t.Fatal(err)
}
p.Profiles.Folders[0].Path = `..\Windows`
if err := ValidatePolicy(p); err == nil {
t.Fatal("expected profile traversal validation error")
}
}
func TestDisconnectedTimeoutMinimum(t *testing.T) {
p := model.Policy{Cleanup: model.CleanupPolicy{GraceSeconds: 600, PollSeconds: 10, RetrySeconds: 60}, Sessions: model.SessionPolicy{DisconnectedLogoffEnabled: true, DisconnectedTimeoutSeconds: 30}}
NormalizePolicy(&p)
p.Sessions.DisconnectedTimeoutSeconds = 30
if err := ValidatePolicy(p); err == nil {
t.Fatal("expected disconnected timeout validation error")
}
}
+141
View File
@@ -0,0 +1,141 @@
package master
import (
"context"
"testing"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
)
type memoryPersistence struct{}
func (memoryPersistence) Load(context.Context, *data) error { return nil }
func (memoryPersistence) Save(context.Context, data) error { return nil }
func (memoryPersistence) AppendAudit(context.Context, model.AuditEntry, int) error { return nil }
func (memoryPersistence) AppendHistory(context.Context, model.SessionHistoryEvent, int) error {
return nil
}
func (memoryPersistence) Close() error { return nil }
func (memoryPersistence) Kind() string { return "memory" }
func brokerTestApp() *App {
d := emptyData()
d.Farms["office"] = model.Farm{ID: "office", Name: "Office", Enabled: true}
return &App{
cfg: config.Master{
OfflineAfterSeconds: 60,
Broker: model.BrokerConfig{
Enabled: true, LeaseSeconds: 900, MinHealthScore: 60,
ReconnectExisting: true, SingleSession: true, DefaultFarmID: "office",
},
},
store: &store{data: d, backend: memoryPersistence{}},
}
}
func testAgent(id, host, mode string, health int, sessions ...model.Session) model.AgentRecord {
return model.AgentRecord{
ID: id, Name: host, LastSeen: time.Now().UTC(), MaintenanceMode: mode,
FarmIDs: []string{"office"},
Snapshot: model.AgentSnapshot{
Server: model.ServerInfo{Hostname: host, CPUPercent: 20, MemoryTotal: 100, MemoryAvailable: 70},
Health: model.HealthStatus{Score: health}, Sessions: sessions,
},
}
}
func TestBrokerReconnectsExistingSessionOnDrain(t *testing.T) {
a := brokerTestApp()
a.store.data.Agents["rds01"] = testAgent("rds01", "rds01.example.test", "drain", 90,
model.Session{ID: 7, User: "Max", Domain: "EXAMPLE", State: "Disconnected"})
a.store.data.Agents["rds02"] = testAgent("rds02", "rds02.example.test", "online", 100)
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "rds01" || got.Reason != "existing-session" || !got.Reconnect {
t.Fatalf("unexpected broker result: %+v", got)
}
}
func TestBrokerDoesNotUseDrainForNewSession(t *testing.T) {
a := brokerTestApp()
a.store.data.Agents["rds01"] = testAgent("rds01", "rds01.example.test", "drain", 100)
a.store.data.Agents["rds02"] = testAgent("rds02", "rds02.example.test", "online", 80)
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\NewUser`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "rds02" || got.Reason != "load-balance" {
t.Fatalf("unexpected broker result: %+v", got)
}
}
func TestBrokerKeepsFarmIsolation(t *testing.T) {
a := brokerTestApp()
a.store.data.Farms["erp"] = model.Farm{ID: "erp", Name: "ERP", Enabled: true}
a.store.data.Agents["office01"] = testAgent("office01", "office01.example.test", "online", 80)
erp := testAgent("erp01", "erp01.example.test", "online", 100,
model.Session{ID: 3, User: "Max", Domain: "EXAMPLE", State: "Disconnected"})
erp.FarmIDs = []string{"erp"}
a.store.data.Agents["erp01"] = erp
got, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "office"})
if err != nil {
t.Fatal(err)
}
if got.AgentID != "office01" {
t.Fatalf("broker crossed farm boundary: %+v", got)
}
}
func TestBrokerRejectsUnknownFarm(t *testing.T) {
a := brokerTestApp()
_, err := a.resolveBroker(model.BrokerRequest{Username: `EXAMPLE\Max`, FarmID: "missing"})
if err == nil {
t.Fatal("expected unknown farm to fail closed")
}
}
func TestEffectivePolicyIncludesTagSelectedFarm(t *testing.T) {
a := brokerTestApp()
p := model.Policy{Revision: "farm-policy"}
f := a.store.data.Farms["office"]
f.RequiredTags = map[string]string{"role": "office"}
f.Policy = &p
a.store.data.Farms["office"] = f
rec := model.AgentRecord{ID: "rds01", Tags: map[string]string{"role": "office"}}
got := a.effectivePolicyLocked(rec)
if got == nil || got.Revision != "farm-policy" {
t.Fatalf("tag-selected farm policy not applied: %+v", got)
}
}
func TestGuacamoleTokenBridgeOnlyBrokersMappedResources(t *testing.T) {
a := brokerTestApp()
a.store.data.Resources["office-desktop"] = model.Resource{
ID: "office-desktop", Name: "Office Desktop", Kind: "desktop", FarmID: "office",
GuacamoleConnectionName: "Office Desktop", Enabled: true,
}
if !a.hasMappedResource(model.BrokerRequest{ConnectionName: "office desktop"}) {
t.Fatal("expected case-insensitive configured Guacamole connection to be mapped")
}
if a.hasMappedResource(model.BrokerRequest{ConnectionName: "Static Admin RDP"}) {
t.Fatal("unmapped Guacamole connection must remain untouched by broker token bridge")
}
}
func TestDomainQualifiedBrokerIdentityDoesNotCrossDomain(t *testing.T) {
s := model.Session{User: "Max", Domain: "DOMAINA", State: "Disconnected"}
if sessionMatchesUser(s, normalizeUser(`DOMAINB\Max`)) {
t.Fatal("domain-qualified identity matched a session from a different domain")
}
if !sessionMatchesUser(s, normalizeUser(`DOMAINA\Max`)) {
t.Fatal("matching domain-qualified identity did not match")
}
}
+1395 -46
View File
File diff suppressed because it is too large Load Diff
+418 -12
View File
@@ -1,43 +1,140 @@
package master
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
_ "github.com/lib/pq"
)
type data struct {
Agents map[string]model.AgentRecord `json:"agents"`
Agents map[string]model.AgentRecord `json:"agents"`
Farms map[string]model.Farm `json:"farms,omitempty"`
Resources map[string]model.Resource `json:"resources,omitempty"`
Leases map[string]model.UserLease `json:"leases,omitempty"`
Audit []model.AuditEntry `json:"audit,omitempty"`
SessionHistory []model.SessionHistoryEvent `json:"session_history,omitempty"`
PolicyHistory []model.PolicyVersion `json:"policy_history,omitempty"`
Alerts map[string]model.Alert `json:"alerts,omitempty"`
GlobalPolicy *model.Policy `json:"global_policy,omitempty"`
}
type persistence interface {
Load(context.Context, *data) error
Save(context.Context, data) error
AppendAudit(context.Context, model.AuditEntry, int) error
AppendHistory(context.Context, model.SessionHistoryEvent, int) error
Close() error
Kind() string
}
type store struct {
path string
mu sync.RWMutex
data data
mu sync.RWMutex
data data
backend persistence
persistErr error
}
func newStore(path string) (*store, error) {
s := &store{path: path, data: data{Agents: map[string]model.AgentRecord{}}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
func emptyData() data {
return data{
Agents: map[string]model.AgentRecord{}, Farms: map[string]model.Farm{}, Resources: map[string]model.Resource{},
Leases: map[string]model.UserLease{}, Audit: []model.AuditEntry{}, SessionHistory: []model.SessionHistoryEvent{},
PolicyHistory: []model.PolicyVersion{}, Alerts: map[string]model.Alert{},
}
}
func newStore(ctx context.Context, cfg config.Master) (*store, error) {
var b persistence
var err error
if cfg.DatabaseURL != "" {
b, err = newPostgresPersistence(ctx, cfg.DatabaseURL, cfg.HistoryLimit)
} else {
b = &jsonPersistence{path: cfg.DataFile}
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
s := &store{data: emptyData(), backend: b}
if err := b.Load(ctx, &s.data); err != nil {
_ = b.Close()
return nil, err
}
s.normalize()
return s, nil
}
func (s *store) normalize() {
if s.data.Agents == nil {
s.data.Agents = map[string]model.AgentRecord{}
}
return s, nil
if s.data.Farms == nil {
s.data.Farms = map[string]model.Farm{}
}
if s.data.Resources == nil {
s.data.Resources = map[string]model.Resource{}
}
if s.data.Leases == nil {
s.data.Leases = map[string]model.UserLease{}
}
if s.data.Audit == nil {
s.data.Audit = []model.AuditEntry{}
}
if s.data.SessionHistory == nil {
s.data.SessionHistory = []model.SessionHistoryEvent{}
}
if s.data.PolicyHistory == nil {
s.data.PolicyHistory = []model.PolicyVersion{}
}
if s.data.Alerts == nil {
s.data.Alerts = map[string]model.Alert{}
}
for id, a := range s.data.Agents {
if a.Tags == nil {
a.Tags = map[string]string{}
}
if a.MaintenanceMode == "" {
a.MaintenanceMode = "online"
}
s.data.Agents[id] = a
}
}
func (s *store) saveLocked() error {
persistErr := s.persistErr
s.persistErr = nil
if err := s.backend.Save(context.Background(), s.data); err != nil {
return err
}
return persistErr
}
func (s *store) close() error { return s.backend.Close() }
func (s *store) kind() string { return s.backend.Kind() }
func (s *store) appendAuditLocked(e model.AuditEntry) {
s.data.Audit = append(s.data.Audit, e)
if len(s.data.Audit) > 10000 {
s.data.Audit = append([]model.AuditEntry(nil), s.data.Audit[len(s.data.Audit)-10000:]...)
}
if err := s.backend.AppendAudit(context.Background(), e, 10000); err != nil && s.persistErr == nil {
s.persistErr = err
}
}
func (s *store) appendHistoryLocked(e model.SessionHistoryEvent, limit int) {
s.data.SessionHistory = append(s.data.SessionHistory, e)
if limit > 0 && len(s.data.SessionHistory) > limit {
s.data.SessionHistory = append([]model.SessionHistoryEvent(nil), s.data.SessionHistory[len(s.data.SessionHistory)-limit:]...)
}
if err := s.backend.AppendHistory(context.Background(), e, limit); err != nil && s.persistErr == nil {
s.persistErr = err
}
}
func (s *store) saveLocked() error { return config.SaveJSON(s.path, s.data) }
func (s *store) all() []model.AgentRecord {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -55,3 +152,312 @@ func (s *store) get(id string) (model.AgentRecord, bool) {
a.TokenHash = ""
return a, ok
}
func (s *store) audit(limit int) []model.AuditEntry {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 1000 {
limit = 300
}
start := len(s.data.Audit) - limit
if start < 0 {
start = 0
}
return append([]model.AuditEntry(nil), s.data.Audit[start:]...)
}
func (s *store) history(limit int) []model.SessionHistoryEvent {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 5000 {
limit = 1000
}
start := len(s.data.SessionHistory) - limit
if start < 0 {
start = 0
}
return append([]model.SessionHistoryEvent(nil), s.data.SessionHistory[start:]...)
}
type jsonPersistence struct{ path string }
func (j *jsonPersistence) Kind() string { return "json" }
func (j *jsonPersistence) Close() error { return nil }
func (j *jsonPersistence) Load(_ context.Context, out *data) error {
b, err := os.ReadFile(j.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
return json.Unmarshal(b, out)
}
func (j *jsonPersistence) Save(_ context.Context, d data) error { return config.SaveJSON(j.path, d) }
func (j *jsonPersistence) AppendAudit(context.Context, model.AuditEntry, int) error { return nil }
func (j *jsonPersistence) AppendHistory(context.Context, model.SessionHistoryEvent, int) error {
return nil
}
type postgresPersistence struct {
db *sql.DB
leaderConn *sql.Conn
historyLimit int
}
func newPostgresPersistence(ctx context.Context, dsn string, historyLimit int) (*postgresPersistence, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(c); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
// v0.3 persists a single mutable control-plane state document. Protect it
// from accidental active/active masters until a future HA design moves all
// mutable coordination to transactionally independent database rows.
leaderConn, err := db.Conn(c)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres leader connection: %w", err)
}
const masterLockID int64 = 0x534755415244 // "SGUARD"
var leader bool
if err := leaderConn.QueryRowContext(c, `SELECT pg_try_advisory_lock($1)`, masterLockID).Scan(&leader); err != nil {
_ = leaderConn.Close()
_ = db.Close()
return nil, fmt.Errorf("postgres master lock: %w", err)
}
if !leader {
_ = leaderConn.Close()
_ = db.Close()
return nil, errors.New("another active SessionGuard master holds the PostgreSQL control-plane lock")
}
stmts := []string{
`CREATE TABLE IF NOT EXISTS sessionguard_state (id integer PRIMARY KEY CHECK (id=1), payload jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS sessionguard_audit (id bigserial PRIMARY KEY, event_time timestamptz NOT NULL, actor text NOT NULL, action text NOT NULL, target text NOT NULL DEFAULT '', result text NOT NULL, payload jsonb NOT NULL)`,
`CREATE INDEX IF NOT EXISTS sessionguard_audit_time_idx ON sessionguard_audit(event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_audit_actor_idx ON sessionguard_audit(actor)`,
`CREATE TABLE IF NOT EXISTS sessionguard_session_history (id bigserial PRIMARY KEY, event_time timestamptz NOT NULL, agent_id text NOT NULL, username text NOT NULL DEFAULT '', sid text NOT NULL DEFAULT '', event text NOT NULL, payload jsonb NOT NULL)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_time_idx ON sessionguard_session_history(event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_user_idx ON sessionguard_session_history(lower(username), event_time DESC)`,
`CREATE INDEX IF NOT EXISTS sessionguard_history_agent_idx ON sessionguard_session_history(agent_id, event_time DESC)`,
`CREATE TABLE IF NOT EXISTS sessionguard_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`,
`INSERT INTO sessionguard_migrations(version) VALUES (2) ON CONFLICT DO NOTHING`,
}
for _, q := range stmts {
if _, err := db.ExecContext(c, q); err != nil {
_ = leaderConn.Close()
_ = db.Close()
return nil, fmt.Errorf("postgres schema: %w", err)
}
}
return &postgresPersistence{db: db, leaderConn: leaderConn, historyLimit: historyLimit}, nil
}
func (p *postgresPersistence) Kind() string { return "postgres" }
func (p *postgresPersistence) Close() error {
if p.leaderConn != nil {
_ = p.leaderConn.Close() // releases the session-level advisory lock
}
return p.db.Close()
}
func (p *postgresPersistence) Load(ctx context.Context, out *data) error {
var raw []byte
err := p.db.QueryRowContext(ctx, `SELECT payload FROM sessionguard_state WHERE id=1`).Scan(&raw)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
if err := json.Unmarshal(raw, out); err != nil {
return err
}
}
// Older v0.3 development builds kept history inside the state blob. If the
// normalized tables are empty, migrate those events once before replacing
// the in-memory slices from the dedicated tables.
legacyAudit := append([]model.AuditEntry(nil), out.Audit...)
legacyHistory := append([]model.SessionHistoryEvent(nil), out.SessionHistory...)
if n, _ := p.tableCount(ctx, "sessionguard_audit"); n == 0 && len(legacyAudit) > 0 {
for _, e := range legacyAudit {
if err := p.AppendAudit(ctx, e, 10000); err != nil {
return err
}
}
}
if n, _ := p.tableCount(ctx, "sessionguard_session_history"); n == 0 && len(legacyHistory) > 0 {
for _, e := range legacyHistory {
if err := p.AppendHistory(ctx, e, p.historyLimit); err != nil {
return err
}
}
}
audit, err := p.loadAudit(ctx, 10000)
if err != nil {
return err
}
out.Audit = audit
history, err := p.loadHistory(ctx, p.historyLimit)
if err != nil {
return err
}
out.SessionHistory = history
return nil
}
func (p *postgresPersistence) Save(ctx context.Context, d data) error {
if err := p.ensureLeader(ctx); err != nil {
return err
}
// High-volume append-only data lives in dedicated tables. Keeping it out of
// this JSONB document makes heartbeats O(control-plane-state), not O(history).
d.Audit = nil
d.SessionHistory = nil
raw, err := json.Marshal(d)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = p.db.ExecContext(c, `INSERT INTO sessionguard_state(id,payload,updated_at) VALUES(1,$1,now()) ON CONFLICT(id) DO UPDATE SET payload=EXCLUDED.payload, updated_at=now()`, string(raw))
return err
}
func (p *postgresPersistence) ensureLeader(ctx context.Context) error {
if p.leaderConn == nil {
return errors.New("PostgreSQL control-plane lock connection is unavailable")
}
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var held bool
err := p.leaderConn.QueryRowContext(c, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND pid=pg_backend_pid() AND granted)`).Scan(&held)
if err != nil {
return fmt.Errorf("PostgreSQL control-plane lock check failed: %w", err)
}
if !held {
return errors.New("PostgreSQL control-plane lock was lost; restart the SessionGuard master")
}
return nil
}
func (p *postgresPersistence) AppendAudit(ctx context.Context, e model.AuditEntry, limit int) error {
raw, err := json.Marshal(e)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if _, err = p.db.ExecContext(c, `INSERT INTO sessionguard_audit(event_time,actor,action,target,result,payload) VALUES($1,$2,$3,$4,$5,$6)`, e.Time, e.Actor, e.Action, e.Target, e.Result, string(raw)); err != nil {
return err
}
return p.retain(c, "sessionguard_audit", limit)
}
func (p *postgresPersistence) AppendHistory(ctx context.Context, e model.SessionHistoryEvent, limit int) error {
raw, err := json.Marshal(e)
if err != nil {
return err
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if _, err = p.db.ExecContext(c, `INSERT INTO sessionguard_session_history(event_time,agent_id,username,sid,event,payload) VALUES($1,$2,$3,$4,$5,$6)`, e.Time, e.AgentID, e.User, e.SID, e.Event, string(raw)); err != nil {
return err
}
return p.retain(c, "sessionguard_session_history", limit)
}
func (p *postgresPersistence) retain(ctx context.Context, table string, limit int) error {
if limit <= 0 {
return nil
}
var q string
switch table {
case "sessionguard_audit":
q = `DELETE FROM sessionguard_audit WHERE id < COALESCE((SELECT id FROM sessionguard_audit ORDER BY id DESC OFFSET ($1 - 1) LIMIT 1),0)`
case "sessionguard_session_history":
q = `DELETE FROM sessionguard_session_history WHERE id < COALESCE((SELECT id FROM sessionguard_session_history ORDER BY id DESC OFFSET ($1 - 1) LIMIT 1),0)`
default:
return errors.New("unsupported retention table")
}
_, err := p.db.ExecContext(ctx, q, limit)
return err
}
func (p *postgresPersistence) loadAudit(ctx context.Context, limit int) ([]model.AuditEntry, error) {
rows, err := p.db.QueryContext(ctx, `SELECT payload FROM sessionguard_audit ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.AuditEntry{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var e model.AuditEntry
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
reverseAudit(out)
return out, nil
}
func (p *postgresPersistence) loadHistory(ctx context.Context, limit int) ([]model.SessionHistoryEvent, error) {
if limit <= 0 {
limit = 50000
}
rows, err := p.db.QueryContext(ctx, `SELECT payload FROM sessionguard_session_history ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.SessionHistoryEvent{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var e model.SessionHistoryEvent
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
reverseHistory(out)
return out, nil
}
func (p *postgresPersistence) tableCount(ctx context.Context, table string) (int64, error) {
var q string
switch table {
case "sessionguard_audit":
q = `SELECT count(*) FROM sessionguard_audit`
case "sessionguard_session_history":
q = `SELECT count(*) FROM sessionguard_session_history`
default:
return 0, errors.New("unsupported table")
}
var n int64
err := p.db.QueryRowContext(ctx, q).Scan(&n)
return n, err
}
func reverseAudit(x []model.AuditEntry) {
for i, j := 0, len(x)-1; i < j; i, j = i+1, j-1 {
x[i], x[j] = x[j], x[i]
}
}
func reverseHistory(x []model.SessionHistoryEvent) {
for i, j := 0, len(x)-1; i < j; i, j = i+1, j-1 {
x[i], x[j] = x[j], x[i]
}
}
+33 -21
View File
@@ -1,26 +1,38 @@
package master
const masterHTML = `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1500px;margin:auto;padding:28px}.top{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}.brand{display:flex;gap:12px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:800}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.card,.panel{background:rgba(18,27,49,.85);border:1px solid #263654;border-radius:16px;box-shadow:0 14px 35px rgba(0,0,0,.18)}.card{padding:18px}.value{font-size:28px;font-weight:750;margin-top:7px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:16px 18px;border-bottom:1px solid #263654}.split{display:grid;grid-template-columns:1.05fr 1.45fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:12px 14px;border-bottom:1px solid #22314e;text-align:left;font-size:13px}.table th{color:#94a5c4;font-weight:600}.row{cursor:pointer}.row:hover{background:#182642}.status{display:inline-flex;gap:6px;align-items:center}.dot{width:8px;height:8px;border-radius:50%;background:#5ee08b}.off{background:#65728a}.pill{padding:3px 8px;border-radius:999px;background:#1b2a4a;color:#bfd0ee;font-size:12px}button{background:#5b8cff;color:white;border:0;border-radius:10px;padding:9px 13px;font-weight:650;cursor:pointer}button.secondary{background:#22314e}input,textarea{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px 10px}textarea{min-height:128px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.form{padding:16px;display:grid;gap:12px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.sessions{max-height:270px;overflow:auto}.empty{padding:24px;color:#8190aa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none}@media(max-width:950px){.grid{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}.cols{grid-template-columns:1fr}} </style></head>
<body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div>
<div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktive Sitzungen</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="mCleanup"></div></div></div>
<div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></div><div class="toast" id="toast"></div><script src="/app.js"></script></body></html>`
const masterHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1600px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{display:flex;gap:11px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:900}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.card,.panel{background:rgba(18,27,49,.88);border:1px solid #263654;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:800;margin-top:5px}.split{display:grid;grid-template-columns:minmax(350px,.9fr) minmax(650px,1.6fr);gap:16px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:15px 18px;border-bottom:1px solid #263654}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:10px 12px;border-bottom:1px solid #22314e;text-align:left;font-size:13px;vertical-align:top}.table th{color:#94a5c4}.row{cursor:pointer}.row:hover,.row.selected{background:#182642}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#5ee08b;margin-right:7px}.dot.off{background:#65728a}.form{padding:16px;display:grid;gap:13px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.two{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}label{display:grid;gap:5px;font-size:13px;color:#c8d4e7}input,textarea,select{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px}textarea{min-height:88px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}.check label{display:block}button{background:#5b8cff;color:white;border:0;border-radius:9px;padding:8px 11px;font-weight:650;cursor:pointer}button.secondary{background:#263654}button.danger{background:#743b46}.actions{display:flex;gap:5px;flex-wrap:wrap}.empty{padding:20px;color:#8190aa}.section-title{font-weight:800;padding:12px 16px;border-top:1px solid #263654;border-bottom:1px solid #263654}.content{max-height:320px;overflow:auto}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px}.list{display:grid;gap:10px}.item{border:1px solid #2b3d5c;background:#0f192c;border-radius:12px;padding:12px;display:grid;gap:9px}.item-head{display:flex;justify-content:space-between;align-items:center}.note{padding:10px;border:1px solid #334866;background:#101b31;border-radius:10px;color:#9fb0ca;font-size:12px}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning,.event-warning,.event-dry-run{color:#ffd166}.event-error{color:#ffaaaa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none;z-index:10}@media(max-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.cols,.two{grid-template-columns:1fr}}@media(max-width:650px){.grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console · v0.3 · Broker & Director</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktiv</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="mDisc"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="mProfile"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="mCleanup"></div></div><div class="card"><div class="muted">Farms</div><div class="value" id="mFarms"></div></div><div class="card"><div class="muted">Alerts</div><div class="value" id="mAlerts"></div></div></div><div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div><section class="panel"><h2>Farms & Broker</h2><div id="farms"></div></section><section class="panel"><h2>Published Resources</h2><div id="resources"></div></section><section class="panel"><h2>Session-Historie</h2><div id="history" class="content"></div></section><section class="panel"><h2>Policy-Historie & Rollback</h2><div id="policyHistory" class="content"></div></section><section class="panel"><h2>Alerts</h2><div id="alerts" class="content"></div></section><section class="panel"><h2>Audit-Log</h2><div id="audit" class="content"></div></section></div><div class="toast" id="toast"></div><script src="/app.js"></script></body></html>`
const masterJS = `
let selected=null, current=null;
const $=id=>document.getElementById(id);
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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 age(sec){if(!sec)return '';let d=Math.floor(sec/86400),h=Math.floor(sec%86400/3600),m=Math.floor(sec%3600/60);return d+'d '+h+'h '+m+'m'}
function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',2500)}
async function api(url,opt){let r=await fetch(url,opt);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}
async function refresh(){try{let d=await api('/api/v1/dashboard');let a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Sitzungen</th><th>Build</th></tr></thead><tbody>'+a.map(x=>'<tr class="row" data-id="'+esc(x.id)+'"><td><span class="status"><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</span></td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.server.os||'')+'</span></td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+esc(x.snapshot.agent_version||'')+'</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id));if(selected)selectAgent(selected,true)}catch(e){toast(e.message)}}
async function selectAgent(id,quiet){selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current)}catch(e){if(!quiet)toast(e.message)}}
function renderDetail(a){let s=a.snapshot.server||{},sessions=a.snapshot.sessions||[],p=a.desired_policy||defaultPolicy(a.snapshot.policy_revision);$('detailTitle').textContent=a.name||a.id;$('detail').className='';$('detail').innerHTML='<div class="grid" style="grid-template-columns:repeat(3,1fr);padding:16px"><div class="card"><div class="muted">Uptime</div><div class="value" style="font-size:20px">'+age(s.uptime_seconds)+'</div></div><div class="card"><div class="muted">RAM frei</div><div class="value" style="font-size:20px">'+bytes(s.memory_available)+' / '+bytes(s.memory_total)+'</div></div><div class="card"><div class="muted">Policy</div><div class="value" style="font-size:15px">'+esc(a.snapshot.policy_revision||'')+'</div></div></div><div class="sessions"><table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</th></tr></thead><tbody>'+sessions.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td><span class="pill">'+esc(x.state)+'</span></td><td>'+esc(x.client_name||'')+'</td></tr>').join('')+'</tbody></table></div>'+policyForm(p)}
function defaultPolicy(rev){return {revision:rev||'',cleanup:{enabled:true,grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,exclude_users:['Administrator','DefaultAccount','WDAGUtilityAccount'],exclude_sids:['S-1-5-18','S-1-5-19','S-1-5-20'],allowed_profile_roots:['C:\\Users']},templates:[]}}
function policyForm(p){let c=p.cleanup||{};return '<div class="form"><strong>Policy bearbeiten</strong><div class="cols"><label>Grace Period (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label for="enabled">Profil-Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label for="dry">Dry-Run (empfohlen zum Testen)</label></div><label>Ausgeschlossene Benutzer (eine Zeile je Eintrag)<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Ausgeschlossene SIDs / SID-Präfixe<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:230px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div style="display:flex;gap:8px"><button onclick="savePolicy(false)">Für diesen Server speichern</button><button class="secondary" onclick="savePolicy(true)">Auf alle Server anwenden</button></div></div>'}
function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
async function savePolicy(all){if(!selected)return;try{let p={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:JSON.parse($('templates').value||'[]')};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)}catch(e){toast(e.message)}}
let selected=null,current=null,editorAgent=null,policyTemplates=[],profileFolders=[],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 renderAgentTable(a){$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Mode</th><th>Health</th><th>Sitzungen</th><th>Jobs</th></tr></thead><tbody>'+a.map(x=>'<tr class="row '+(x.id===selected?'selected':'')+'" data-id="'+esc(x.id)+'"><td><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.agent_version||'')+'</span></td><td>'+esc(x.maintenance_mode||'online')+'</td><td>'+esc((x.snapshot.health||{}).score??'')+'</td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+((x.snapshot.profile_jobs||[]).length)+' P / '+((x.snapshot.pending_cleanup||[]).length)+' C</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id,false,false))}
async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];renderFarms();let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];renderResources();let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}catch(e){toast(e.message)}}
async function selectAgent(id,quiet,preserve){let same=selected===id;selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current,!!preserve&&same)}catch(e){if(!quiet)toast(e.message)}}
function renderPolicyHistory(){let rows=(policyHistory||[]).slice().reverse().slice(0,250);$('policyHistory').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Ziel</th><th>Revision</th><th>Akteur</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.created_at)+'</td><td>'+esc(x.target)+'</td><td><code>'+esc(x.revision)+'</code></td><td>'+esc(x.actor||'')+'</td><td><button class="secondary" data-global-action="rollback-policy" data-target="'+esc(x.target)+'" data-revision="'+esc(x.revision)+'">Rollback</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Policy-Versionen.</div>'}
function renderAudit(rows){rows=(rows||[]).slice().reverse().slice(0,250);$('audit').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ziel</th><th>Ergebnis</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+esc(when(x.time))+'</td><td>'+esc(x.actor)+'</td><td>'+esc(x.action)+'</td><td>'+esc(x.target||'')+'</td><td class="'+(x.result==='error'?'bad':x.result==='success'?'good':'')+'">'+esc(x.result)+(x.details?'<br><span class="muted">'+esc(x.details)+'</span>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Audit-Einträge.</div>'}
function overview(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0,tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<div class="form"><div class="cols"><div><span class="muted">OS</span><br><strong>'+esc(v.os||'')+'</strong></div><div><span class="muted">Health</span><br><strong class="'+((h.score||0)>=70?'good':'bad')+'">'+esc(h.score??'')+'/100</strong></div><div><span class="muted">CPU</span><br><strong>'+Number(v.cpu_percent||0).toFixed(1)+' %</strong></div><div><span class="muted">RAM</span><br><strong>'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei</strong></div><div><span class="muted">Systemdisk</span><br><strong>'+bytes(v.disk_free)+' frei</strong></div><div><span class="muted">Heartbeat</span><br><strong>'+when(a.last_seen)+'</strong></div></div><div class="note">RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'</div><div class="two"><label>Maintenance Mode<select id="agentMode"><option value="online" '+((a.maintenance_mode||'online')==='online'?'selected':'')+'>Online</option><option value="drain" '+(a.maintenance_mode==='drain'?'selected':'')+'>Drain</option><option value="maintenance" '+(a.maintenance_mode==='maintenance'?'selected':'')+'>Maintenance</option></select></label><label>Farm IDs (eine pro Zeile)<textarea id="agentFarms">'+esc((a.farm_ids||[]).join('\n'))+'</textarea></label><label>Tags key=value<textarea id="agentTags">'+esc(tags)+'</textarea></label><div><div class="check"><input id="restartDrained" type="checkbox" '+(a.restart_when_drained?'checked':'')+'><label>Neu starten sobald leer</label></div><div class="actions"><button type="button" data-action="save-control">Serversteuerung speichern</button><button type="button" class="secondary" data-action="broadcast">Nachricht an alle</button><button type="button" class="danger" data-action="logoff-disconnected">Getrennte abmelden</button></div></div></div></div>'}
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 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 class="'+(x.active?'bad':'good')+'">'+(x.active?'AKTIV':'gelöst')+'</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 renderFarms(){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>':'';$('farms').innerHTML='<div 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><button type="button" data-global-action="create-farm">Farm anlegen</button></div>'+(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>'+esc((f.agent_ids||[]).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 opts=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');$('resources').innerHTML='<div 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">'+opts+'</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><button type="button" data-global-action="create-resource">Resource anlegen</button></div>'+(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 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||!$('overview')){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="overview"></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>';editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overview').innerHTML=overview(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(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})}
function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'<div class="item profile-folder"><div class="item-head"><strong>'+esc(f.path||('Ordner '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-profile-folder" data-index="'+i+'">Löschen</button></div><div class="two"><label>Relativer Profilpfad<input data-field="path" value="'+esc(f.path||'')+'"></label><label>Ausschlüsse<textarea data-field="exclude">'+esc((f.exclude_globs||[]).join('\n'))+'</textarea></label></div></div>').join(''):'<div class="empty">Keine Profilordner.</div>'}
function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)}
function policyForm(p,a){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{},source=a.desired_policy&&a.desired_policy.revision?'Master-Soll-Policy':'Agent-Ist-Policy';return'<div class="form"><div class="note">Editor-Basis: '+esc(source)+'. Live-Refresh verändert offene Eingaben nicht.</div><strong>Profil-Sicherung & Wiederherstellung</strong><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="cols"><label>Store Root<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Profil-Sync Benutzer ausschließen<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Profil-Sync SIDs ausschließen<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><strong>Sitzungsrichtlinie</strong><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Disconnect-Timeout (s)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Auto-Logoff Benutzer ausschließen<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Auto-Logoff SIDs ausschließen<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label><strong>Profil-Cleanup</strong><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div><div class="actions"><button type="button" data-action="save-one">Für diesen Server speichern</button><button type="button" class="secondary" data-action="save-all">Auf alle anwenden</button><button type="button" class="secondary" data-action="reload-policy">Editor neu laden</button></div></div>'}
async function bulkSessionAction(action,scope){if(!selected)return;let body={action,scope};if(action==='message'){let m=prompt('Nachricht an alle Benutzersitzungen:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Die ausgewählten Sitzungen wirklich abmelden? Die Profil-Pipeline läuft anschließend.'))return;try{let r=await api('/api/v1/agents/'+encodeURIComponent(selected)+'/sessions/bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast((r.queued||0)+' Sitzungsaktionen eingeplant')}catch(e){toast(e.message)}}
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==='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==='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)})});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==='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})});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)}});
refresh();setInterval(refresh,5000);`
+309 -34
View File
@@ -2,7 +2,7 @@ package model
import "time"
const ProtocolVersion = 1
const ProtocolVersion = 3
type OIDCConfig struct {
Issuer string `json:"issuer"`
@@ -13,6 +13,35 @@ type OIDCConfig struct {
SecureCookie bool `json:"secure_cookie"`
}
type RBACConfig struct {
DefaultRole string `json:"default_role,omitempty"`
Groups map[string][]string `json:"groups,omitempty"`
}
type BrokerConfig struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key,omitempty"`
LeaseSeconds int `json:"lease_seconds"`
MinHealthScore int `json:"min_health_score"`
ReconnectExisting bool `json:"reconnect_existing"`
SingleSession bool `json:"single_session_per_user"`
DefaultFarmID string `json:"default_farm_id,omitempty"`
}
type AlertConfig struct {
Enabled bool `json:"enabled"`
WebhookURL string `json:"webhook_url,omitempty"`
CPUPercent int `json:"cpu_percent"`
MemoryPercent int `json:"memory_percent"`
DiskFreeGB int `json:"disk_free_gb"`
HealthScore int `json:"health_score"`
OfflineSeconds int `json:"offline_seconds"`
ProfileFailures int `json:"profile_failures"`
DisconnectedSessions int `json:"disconnected_sessions"`
LogonDurationSeconds int `json:"logon_duration_seconds"`
NotificationMinInterval int `json:"notification_min_interval_seconds"`
}
type CleanupPolicy struct {
Enabled bool `json:"enabled"`
GraceSeconds int `json:"grace_seconds"`
@@ -24,6 +53,33 @@ type CleanupPolicy struct {
AllowedProfileRoots []string `json:"allowed_profile_roots,omitempty"`
}
type ProfileFolder struct {
Path string `json:"path"`
ExcludeGlobs []string `json:"exclude_globs,omitempty"`
}
type ProfilePolicy struct {
Enabled bool `json:"enabled"`
StoreRoot string `json:"store_root,omitempty"`
BackupOnLogoff bool `json:"backup_on_logoff"`
RestoreOnLogon bool `json:"restore_on_logon"`
BackupDelaySeconds int `json:"backup_delay_seconds"`
RetrySeconds int `json:"retry_seconds"`
RestoreWindowSeconds int `json:"restore_window_seconds"`
KeepVersions int `json:"keep_versions"`
ExcludeUsers []string `json:"exclude_users,omitempty"`
ExcludeSIDs []string `json:"exclude_sids,omitempty"`
Folders []ProfileFolder `json:"folders,omitempty"`
}
type SessionPolicy struct {
ControlEnabled bool `json:"control_enabled"`
DisconnectedLogoffEnabled bool `json:"disconnected_logoff_enabled"`
DisconnectedTimeoutSeconds int `json:"disconnected_timeout_seconds"`
ExcludeUsers []string `json:"exclude_users,omitempty"`
ExcludeSIDs []string `json:"exclude_sids,omitempty"`
}
type ShortcutSpec struct {
Target string `json:"target"`
Arguments string `json:"arguments,omitempty"`
@@ -34,7 +90,7 @@ type ShortcutSpec struct {
type TemplateItem struct {
ID string `json:"id"`
Kind string `json:"kind"` // file, directory, url, shortcut
Kind string `json:"kind"`
Target string `json:"target"`
Source string `json:"source,omitempty"`
Content string `json:"content,omitempty"`
@@ -48,27 +104,73 @@ type Policy struct {
Revision string `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
Cleanup CleanupPolicy `json:"cleanup"`
Profiles ProfilePolicy `json:"profiles"`
Sessions SessionPolicy `json:"sessions"`
Templates []TemplateItem `json:"templates,omitempty"`
}
type Session struct {
ID uint32 `json:"id"`
State string `json:"state"`
User string `json:"user,omitempty"`
Domain string `json:"domain,omitempty"`
SID string `json:"sid,omitempty"`
ClientName string `json:"client_name,omitempty"`
StationName string `json:"station_name,omitempty"`
ID uint32 `json:"id"`
State string `json:"state"`
User string `json:"user,omitempty"`
Domain string `json:"domain,omitempty"`
SID string `json:"sid,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientAddress string `json:"client_address,omitempty"`
StationName string `json:"station_name,omitempty"`
LogonAt *time.Time `json:"logon_at,omitempty"`
ConnectAt *time.Time `json:"connect_at,omitempty"`
LastInputAt *time.Time `json:"last_input_at,omitempty"`
DisconnectedSince *time.Time `json:"disconnected_since,omitempty"`
IdleSeconds int64 `json:"idle_seconds,omitempty"`
}
type ProcessInfo struct {
PID uint32 `json:"pid"`
SessionID uint32 `json:"session_id"`
Name string `json:"name"`
UserSID string `json:"user_sid,omitempty"`
MemoryBytes uint64 `json:"memory_bytes,omitempty"`
}
type ServerInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
UptimeSeconds uint64 `json:"uptime_seconds"`
MemoryTotal uint64 `json:"memory_total"`
MemoryAvailable uint64 `json:"memory_available"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
UptimeSeconds uint64 `json:"uptime_seconds"`
MemoryTotal uint64 `json:"memory_total"`
MemoryAvailable uint64 `json:"memory_available"`
CPUPercent float64 `json:"cpu_percent,omitempty"`
DiskTotal uint64 `json:"disk_total,omitempty"`
DiskFree uint64 `json:"disk_free,omitempty"`
}
type HealthCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
type HealthStatus struct {
Score int `json:"score"`
ProfileStoreOK bool `json:"profile_store_ok"`
RDPListenerOK bool `json:"rdp_listener_ok"`
Checks []HealthCheck `json:"checks,omitempty"`
CalculatedAt time.Time `json:"calculated_at"`
}
type SessionTelemetry struct {
SessionID uint32 `json:"session_id"`
SID string `json:"sid,omitempty"`
User string `json:"user,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LogonAt time.Time `json:"logon_at,omitempty"`
RestoreStartedAt time.Time `json:"restore_started_at,omitempty"`
RestoreFinishedAt time.Time `json:"restore_finished_at,omitempty"`
ReadyAt time.Time `json:"ready_at,omitempty"`
RestoreDurationMS int64 `json:"restore_duration_ms,omitempty"`
ObservedLogonMS int64 `json:"observed_logon_ms,omitempty"`
}
type CleanupJob struct {
@@ -80,26 +182,198 @@ type CleanupJob struct {
LastError string `json:"last_error,omitempty"`
}
type ProfileJob struct {
ID string `json:"id"`
Operation string `json:"operation"`
SID string `json:"sid"`
User string `json:"user"`
SessionID uint32 `json:"session_id,omitempty"`
ProfilePath string `json:"profile_path"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
DueAt time.Time `json:"due_at"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
}
type ProfileStatus struct {
SID string `json:"sid"`
User string `json:"user,omitempty"`
LastBackupAt time.Time `json:"last_backup_at,omitempty"`
LastRestoreAt time.Time `json:"last_restore_at,omitempty"`
LastBackupError string `json:"last_backup_error,omitempty"`
LastRestoreError string `json:"last_restore_error,omitempty"`
}
type AgentEvent struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Type string `json:"type"`
User string `json:"user,omitempty"`
Message string `json:"message"`
}
type SessionCommand struct {
ID string `json:"id"`
Action string `json:"action"`
SessionID uint32 `json:"session_id,omitempty"`
PID uint32 `json:"pid,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
RequestedBy string `json:"requested_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type CommandResult struct {
ID string `json:"id"`
Action string `json:"action"`
SessionID uint32 `json:"session_id,omitempty"`
PID uint32 `json:"pid,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type SessionActionRequest struct {
Action string `json:"action"`
PID uint32 `json:"pid,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
type AuditEntry struct {
Time time.Time `json:"time"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Result string `json:"result"`
Details string `json:"details,omitempty"`
}
type AgentSnapshot struct {
ProtocolVersion int `json:"protocol_version"`
AgentID string `json:"agent_id"`
Server ServerInfo `json:"server"`
Sessions []Session `json:"sessions"`
PendingCleanup []CleanupJob `json:"pending_cleanup,omitempty"`
PolicyRevision string `json:"policy_revision"`
AgentVersion string `json:"agent_version"`
Time time.Time `json:"time"`
ProtocolVersion int `json:"protocol_version"`
AgentID string `json:"agent_id"`
Server ServerInfo `json:"server"`
Health HealthStatus `json:"health"`
Sessions []Session `json:"sessions"`
Processes []ProcessInfo `json:"processes,omitempty"`
Telemetry map[uint32]SessionTelemetry `json:"telemetry,omitempty"`
PendingCleanup []CleanupJob `json:"pending_cleanup,omitempty"`
ProfileJobs []ProfileJob `json:"profile_jobs,omitempty"`
ProfileStatus map[string]ProfileStatus `json:"profile_status,omitempty"`
Events []AgentEvent `json:"events,omitempty"`
CommandResults []CommandResult `json:"command_results,omitempty"`
Policy Policy `json:"policy"`
PolicyRevision string `json:"policy_revision"`
AgentVersion string `json:"agent_version"`
Time time.Time `json:"time"`
}
type AgentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
TokenHash string `json:"token_hash"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeen time.Time `json:"last_seen"`
Snapshot AgentSnapshot `json:"snapshot"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
TokenHash string `json:"token_hash"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeen time.Time `json:"last_seen"`
Snapshot AgentSnapshot `json:"snapshot"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
PendingCommands []SessionCommand `json:"pending_commands,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
FarmIDs []string `json:"farm_ids,omitempty"`
MaintenanceMode string `json:"maintenance_mode,omitempty"` // online, drain, maintenance
RestartWhenDrained bool `json:"restart_when_drained,omitempty"`
}
type Farm struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
AgentIDs []string `json:"agent_ids,omitempty"`
RequiredTags map[string]string `json:"required_tags,omitempty"`
Policy *Policy `json:"policy,omitempty"`
Enabled bool `json:"enabled"`
}
type Resource struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // desktop, remoteapp
FarmID string `json:"farm_id"`
GuacamoleConnectionID string `json:"guacamole_connection_id,omitempty"`
GuacamoleConnectionName string `json:"guacamole_connection_name,omitempty"`
RemoteApp string `json:"remote_app,omitempty"`
RemoteAppDir string `json:"remote_app_dir,omitempty"`
RemoteAppArgs string `json:"remote_app_args,omitempty"`
Enabled bool `json:"enabled"`
}
type UserLease struct {
UserKey string `json:"user_key"`
AgentID string `json:"agent_id"`
FarmID string `json:"farm_id,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Reason string `json:"reason,omitempty"`
}
type BrokerRequest struct {
Username string `json:"username"`
ConnectionID string `json:"connection_id,omitempty"`
ConnectionName string `json:"connection_name,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
FarmID string `json:"farm_id,omitempty"`
}
type BrokerResponse struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
FarmID string `json:"farm_id,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
Reconnect bool `json:"reconnect"`
Reason string `json:"reason"`
HealthScore int `json:"health_score"`
Tokens map[string]string `json:"tokens"`
LeaseExpires time.Time `json:"lease_expires"`
}
type SessionHistoryEvent struct {
Time time.Time `json:"time"`
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
SessionID uint32 `json:"session_id"`
User string `json:"user,omitempty"`
SID string `json:"sid,omitempty"`
Event string `json:"event"`
State string `json:"state,omitempty"`
ClientName string `json:"client_name,omitempty"`
Details string `json:"details,omitempty"`
}
type PolicyVersion struct {
ID string `json:"id"`
Target string `json:"target"`
Revision string `json:"revision"`
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"`
Policy Policy `json:"policy"`
}
type Alert struct {
ID string `json:"id"`
Key string `json:"key"`
Severity string `json:"severity"`
AgentID string `json:"agent_id,omitempty"`
Hostname string `json:"hostname,omitempty"`
Type string `json:"type"`
Message string `json:"message"`
Active bool `json:"active"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt time.Time `json:"resolved_at,omitempty"`
LastNotifiedAt time.Time `json:"last_notified_at,omitempty"`
}
type EnrollRequest struct {
@@ -114,6 +388,7 @@ type EnrollResponse struct {
}
type HeartbeatResponse struct {
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
ServerTime time.Time `json:"server_time"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
Commands []SessionCommand `json:"commands,omitempty"`
ServerTime time.Time `json:"server_time"`
}
+383
View File
@@ -0,0 +1,383 @@
package profile
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/example/sessionguard/internal/model"
)
type Stats struct {
Files int `json:"files"`
Dirs int `json:"dirs"`
Bytes int64 `json:"bytes"`
}
type Manifest struct {
Version int `json:"version"`
SID string `json:"sid"`
User string `json:"user"`
CreatedAt time.Time `json:"created_at"`
Folders []model.ProfileFolder `json:"folders"`
Stats Stats `json:"stats"`
}
func Backup(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int) (Stats, error) {
return BackupGuarded(profileRoot, storeRoot, sid, user, folders, keepVersions, nil)
}
// BackupGuarded copies a complete staging snapshot and calls activationGuard immediately
// before replacing current. A guard failure leaves the previous current snapshot untouched.
func BackupGuarded(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int, activationGuard func() error) (Stats, error) {
var total Stats
if strings.TrimSpace(storeRoot) == "" {
return total, errors.New("profile store_root is empty")
}
if strings.TrimSpace(sid) == "" {
return total, errors.New("profile SID is empty")
}
if len(folders) == 0 {
return total, errors.New("no profile folders configured")
}
userRoot := filepath.Join(storeRoot, safeSID(sid))
if err := os.MkdirAll(userRoot, 0o700); err != nil {
return total, fmt.Errorf("create profile store: %w", err)
}
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
staging := filepath.Join(userRoot, ".staging-"+stamp)
if err := os.MkdirAll(staging, 0o700); err != nil {
return total, err
}
ok := false
defer func() {
if !ok {
_ = os.RemoveAll(staging)
}
}()
for _, folder := range folders {
rel, err := cleanRelative(folder.Path)
if err != nil {
return total, fmt.Errorf("profile folder %q: %w", folder.Path, err)
}
src := filepath.Join(profileRoot, rel)
dst := filepath.Join(staging, rel)
st, err := copyTree(src, dst, folder.ExcludeGlobs)
if errors.Is(err, os.ErrNotExist) {
// A configured application folder may legitimately not exist for every user.
continue
}
if err != nil {
return total, fmt.Errorf("backup %q: %w", folder.Path, err)
}
total.Files += st.Files
total.Dirs += st.Dirs
total.Bytes += st.Bytes
}
manifest := Manifest{Version: 1, SID: sid, User: user, CreatedAt: time.Now().UTC(), Folders: folders, Stats: total}
b, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return total, err
}
if err := os.WriteFile(filepath.Join(staging, ".sessionguard-manifest.json"), b, 0o600); err != nil {
return total, fmt.Errorf("write manifest: %w", err)
}
if activationGuard != nil {
if err := activationGuard(); err != nil {
return total, fmt.Errorf("snapshot activation guard: %w", err)
}
}
current := filepath.Join(userRoot, "current")
archive := ""
if _, err := os.Stat(current); err == nil {
if keepVersions > 0 {
history := filepath.Join(userRoot, "history")
if err := os.MkdirAll(history, 0o700); err != nil {
return total, err
}
archive = filepath.Join(history, stamp)
if err := os.Rename(current, archive); err != nil {
return total, fmt.Errorf("archive previous profile snapshot: %w", err)
}
} else {
// Keep a temporary rollback copy until the new snapshot is active.
archive = filepath.Join(userRoot, ".previous-"+stamp)
if err := os.Rename(current, archive); err != nil {
return total, fmt.Errorf("stage previous profile snapshot: %w", err)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return total, err
}
if err := os.Rename(staging, current); err != nil {
if archive != "" {
_ = os.Rename(archive, current)
}
return total, fmt.Errorf("activate profile snapshot: %w", err)
}
if keepVersions == 0 && archive != "" {
_ = os.RemoveAll(archive)
}
ok = true
if keepVersions > 0 {
if err := pruneHistory(filepath.Join(userRoot, "history"), keepVersions); err != nil {
return total, fmt.Errorf("prune profile history: %w", err)
}
}
return total, nil
}
func Restore(profileRoot, storeRoot, sid string, folders []model.ProfileFolder) (Stats, bool, error) {
var total Stats
if strings.TrimSpace(storeRoot) == "" {
return total, false, errors.New("profile store_root is empty")
}
current := filepath.Join(storeRoot, safeSID(sid), "current")
if _, err := os.Stat(current); errors.Is(err, os.ErrNotExist) {
return total, false, nil
} else if err != nil {
return total, false, err
}
for _, folder := range folders {
rel, err := cleanRelative(folder.Path)
if err != nil {
return total, true, fmt.Errorf("profile folder %q: %w", folder.Path, err)
}
src := filepath.Join(current, rel)
dst := filepath.Join(profileRoot, rel)
st, err := copyTree(src, dst, folder.ExcludeGlobs)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return total, true, fmt.Errorf("restore %q: %w", folder.Path, err)
}
total.Files += st.Files
total.Dirs += st.Dirs
total.Bytes += st.Bytes
}
return total, true, nil
}
func cleanRelative(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "" {
return "", errors.New("path is empty")
}
// Normalize both separators so validation is identical on Windows and in tests.
normalized := strings.ReplaceAll(v, `\`, "/")
if strings.HasPrefix(normalized, "/") || strings.Contains(normalized, ":") {
return "", errors.New("path must be relative to the user profile")
}
cleanSlash := path.Clean(normalized)
if cleanSlash == "." || cleanSlash == ".." || strings.HasPrefix(cleanSlash, "../") {
return "", errors.New("path escapes the user profile")
}
return filepath.FromSlash(cleanSlash), nil
}
func safeSID(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
func copyTree(src, dst string, excludes []string) (Stats, error) {
var stats Stats
info, err := os.Lstat(src)
if err != nil {
return stats, err
}
if info.Mode()&os.ModeSymlink != 0 {
return stats, fmt.Errorf("refusing symbolic link/reparse-point root %s", src)
}
if !info.IsDir() {
if err := copyFile(src, dst, info); err != nil {
return stats, err
}
stats.Files = 1
stats.Bytes = info.Size()
return stats, nil
}
root := src
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if rel == "." {
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
return err
}
stats.Dirs++
return nil
}
relSlash := filepath.ToSlash(rel)
if excluded(relSlash, excludes) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.Type()&os.ModeSymlink != 0 {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
fi, err := d.Info()
if err != nil {
return err
}
if err := os.MkdirAll(target, fi.Mode().Perm()); err != nil {
return err
}
stats.Dirs++
return nil
}
fi, err := d.Info()
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return nil
}
if err := copyFile(p, target, fi); err != nil {
return err
}
stats.Files++
stats.Bytes += fi.Size()
return nil
})
return stats, err
}
func copyFile(src, dst string, info os.FileInfo) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".sessionguard-tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
_ = os.Chtimes(tmp, info.ModTime(), info.ModTime())
if err := replaceFile(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// replaceFile avoids the destructive "remove destination and hope Rename works"
// pattern that is especially risky on Windows. If a destination exists it is first
// moved aside; a failed activation restores the previous file.
func replaceFile(tmp, dst string) error {
if _, err := os.Lstat(dst); errors.Is(err, os.ErrNotExist) {
return os.Rename(tmp, dst)
} else if err != nil {
return err
}
old := dst + ".sessionguard-old"
_ = os.Remove(old)
if err := os.Rename(dst, old); err != nil {
return fmt.Errorf("stage existing destination: %w", err)
}
if err := os.Rename(tmp, dst); err != nil {
if rollbackErr := os.Rename(old, dst); rollbackErr != nil {
return fmt.Errorf("activate replacement: %v; rollback failed: %w", err, rollbackErr)
}
return fmt.Errorf("activate replacement: %w", err)
}
if err := os.Remove(old); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove replaced destination: %w", err)
}
return nil
}
func excluded(rel string, patterns []string) bool {
rel = strings.TrimPrefix(filepath.ToSlash(rel), "./")
for _, raw := range patterns {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
if p == "" {
continue
}
if strings.HasSuffix(p, "/**") {
prefix := strings.TrimSuffix(p, "/**")
if rel == prefix || strings.HasPrefix(rel, prefix+"/") {
return true
}
continue
}
if ok, _ := path.Match(p, rel); ok {
return true
}
}
return false
}
func pruneHistory(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(names)))
if keep < 0 {
keep = 0
}
if keep >= len(names) {
return nil
}
for _, name := range names[keep:] {
if err := os.RemoveAll(filepath.Join(dir, name)); err != nil {
return err
}
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package profile
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/example/sessionguard/internal/model"
)
func TestBackupRestoreAndExclude(t *testing.T) {
root := t.TempDir()
profileA := filepath.Join(root, "profile-a")
store := filepath.Join(root, "store")
profileB := filepath.Join(root, "profile-b")
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "settings.json"), "v1")
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "Cache", "skip.bin"), "skip")
folders := []model.ProfileFolder{{Path: "AppData/Roaming/Example", ExcludeGlobs: []string{"Cache/**"}}}
st, err := Backup(profileA, store, "S-1-5-21-test", `DOMAIN\user`, folders, 2)
if err != nil {
t.Fatal(err)
}
if st.Files != 1 {
t.Fatalf("expected 1 file, got %+v", st)
}
st, found, err := Restore(profileB, store, "S-1-5-21-test", folders)
if err != nil || !found || st.Files != 1 {
t.Fatalf("restore: found=%v stats=%+v err=%v", found, st, err)
}
b, err := os.ReadFile(filepath.Join(profileB, "AppData", "Roaming", "Example", "settings.json"))
if err != nil || string(b) != "v1" {
t.Fatalf("restored content = %q, err=%v", b, err)
}
if _, err := os.Stat(filepath.Join(profileB, "AppData", "Roaming", "Example", "Cache", "skip.bin")); !os.IsNotExist(err) {
t.Fatalf("excluded cache restored unexpectedly: %v", err)
}
}
func TestCleanRelativeRejectsEscape(t *testing.T) {
for _, v := range []string{"../x", `..\\x`, `/etc`, `C:\\Users\\x`, ""} {
if _, err := cleanRelative(v); err == nil {
t.Fatalf("expected %q to be rejected", v)
}
}
}
func mustWrite(t *testing.T, p, s string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRestoreOverwritesSnapshotFilesButKeepsLocalExtras(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
store := filepath.Join(root, "store")
dest := filepath.Join(root, "dest")
folders := []model.ProfileFolder{{Path: "Desktop"}}
mustWrite(t, filepath.Join(source, "Desktop", "managed.txt"), "from-backup")
if _, err := Backup(source, store, "S-1-5-21-merge", `DOMAIN\\user`, folders, 0); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(dest, "Desktop", "managed.txt"), "local-old")
mustWrite(t, filepath.Join(dest, "Desktop", "local-only.txt"), "keep-me")
if _, found, err := Restore(dest, store, "S-1-5-21-merge", folders); err != nil || !found {
t.Fatalf("restore found=%v err=%v", found, err)
}
b, err := os.ReadFile(filepath.Join(dest, "Desktop", "managed.txt"))
if err != nil || string(b) != "from-backup" {
t.Fatalf("managed content=%q err=%v", b, err)
}
b, err = os.ReadFile(filepath.Join(dest, "Desktop", "local-only.txt"))
if err != nil || string(b) != "keep-me" {
t.Fatalf("local-only content=%q err=%v", b, err)
}
}
func TestBackupHistoryRetention(t *testing.T) {
root := t.TempDir()
profileRoot := filepath.Join(root, "profile")
store := filepath.Join(root, "store")
folders := []model.ProfileFolder{{Path: "Desktop"}}
for i, content := range []string{"one", "two", "three", "four"} {
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), content)
if _, err := Backup(profileRoot, store, "S-1-5-21-history", `DOMAIN\\user`, folders, 2); err != nil {
t.Fatalf("backup %d: %v", i, err)
}
time.Sleep(2 * time.Millisecond)
}
history := filepath.Join(store, "S-1-5-21-history", "history")
entries, err := os.ReadDir(history)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("history entries=%d, want 2", len(entries))
}
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-history", "current", "Desktop", "version.txt"))
if err != nil || string(b) != "four" {
t.Fatalf("current content=%q err=%v", b, err)
}
}
func TestBackupActivationGuardKeepsPreviousCurrent(t *testing.T) {
root := t.TempDir()
profileRoot := filepath.Join(root, "profile")
store := filepath.Join(root, "store")
folders := []model.ProfileFolder{{Path: "Desktop"}}
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "old")
if _, err := Backup(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "new")
if _, err := BackupGuarded(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1, func() error {
return errors.New("user became active")
}); err == nil {
t.Fatal("expected activation guard error")
}
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-guard", "current", "Desktop", "version.txt"))
if err != nil || string(b) != "old" {
t.Fatalf("current snapshot changed despite guard: %q err=%v", b, err)
}
}
+11 -5
View File
@@ -9,8 +9,14 @@ import (
var ErrUnsupported = errors.New("Windows functionality is only available on Windows")
func Sessions() ([]model.Session, error) { return nil, ErrUnsupported }
func Server() (model.ServerInfo, error) { return model.ServerInfo{}, ErrUnsupported }
func ProfilePath(string) (string, error) { return "", ErrUnsupported }
func DeleteProfile(string) error { return ErrUnsupported }
func MachineID() (string, error) { return "nonwindows", nil }
func Sessions() ([]model.Session, error) { return nil, ErrUnsupported }
func Processes() ([]model.ProcessInfo, error) { return nil, ErrUnsupported }
func Server() (model.ServerInfo, error) { return model.ServerInfo{}, ErrUnsupported }
func ProfilePath(string) (string, error) { return "", ErrUnsupported }
func DeleteProfile(string) error { return ErrUnsupported }
func LogoffSession(uint32) error { return ErrUnsupported }
func DisconnectSession(uint32) error { return ErrUnsupported }
func SendMessage(uint32, string, string) error { return ErrUnsupported }
func TerminateProcess(uint32) error { return ErrUnsupported }
func RestartServer(string) error { return ErrUnsupported }
func MachineID() (string, error) { return "nonwindows", nil }
+257 -12
View File
@@ -5,8 +5,11 @@ package windowsx
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"github.com/example/sessionguard/internal/model"
@@ -17,15 +20,23 @@ import (
var (
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSEnumerateProcessesW = wtsapi32.NewProc("WTSEnumerateProcessesW")
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSQueryUserToken = wtsapi32.NewProc("WTSQueryUserToken")
procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession")
procWTSDisconnectSession = wtsapi32.NewProc("WTSDisconnectSession")
procWTSSendMessageW = wtsapi32.NewProc("WTSSendMessageW")
userenv = windows.NewLazySystemDLL("userenv.dll")
procDeleteProfileW = userenv.NewProc("DeleteProfileW")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procExpandEnvironmentStringsW = kernel32.NewProc("ExpandEnvironmentStringsW")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
psapi = windows.NewLazySystemDLL("psapi.dll")
procGetProcessMemoryInfo = psapi.NewProc("GetProcessMemoryInfo")
)
type wtsSessionInfo struct {
@@ -33,18 +44,45 @@ type wtsSessionInfo struct {
WinStationName *uint16
State uint32
}
type wtsProcessInfo struct {
SessionID uint32
ProcessID uint32
ProcessName *uint16
UserSID *windows.SID
}
type wtsClientAddress struct {
AddressFamily uint32
Address [20]byte
}
type wtsInfoExLevel1 struct {
SessionID uint32
SessionState uint32
SessionFlags int32
WinStationName [33]uint16
UserName [21]uint16
DomainName [18]uint16
LogonTime int64
ConnectTime int64
DisconnectTime int64
LastInputTime int64
CurrentTime int64
IncomingBytes uint32
OutgoingBytes uint32
IncomingFrames uint32
OutgoingFrames uint32
}
const (
wtsUserName = 5
wtsWinStationName = 6
wtsDomainName = 7
wtsClientName = 10
wtsUserName = 5
wtsWinStationName = 6
wtsDomainName = 7
wtsClientName = 10
wtsClientAddressClass = 14
wtsSessionInfoEx = 25
)
var stateNames = map[uint32]string{
0: "Active", 1: "Connected", 2: "ConnectQuery", 3: "Shadow", 4: "Disconnected",
5: "Idle", 6: "Listen", 7: "Reset", 8: "Down", 9: "Init",
}
var stateNames = map[uint32]string{0: "Active", 1: "Connected", 2: "ConnectQuery", 3: "Shadow", 4: "Disconnected", 5: "Idle", 6: "Listen", 7: "Reset", 8: "Down", 9: "Init"}
func Sessions() ([]model.Session, error) {
var buf uintptr
@@ -70,6 +108,23 @@ func Sessions() ([]model.Session, error) {
if s.StationName == "" {
s.StationName, _ = queryString(row.SessionID, wtsWinStationName)
}
if ca, err := queryClientAddress(row.SessionID); err == nil {
s.ClientAddress = ca
}
if ex, err := querySessionInfoEx(row.SessionID); err == nil {
s.LogonAt = fileTimePtr(ex.LogonTime)
s.ConnectAt = fileTimePtr(ex.ConnectTime)
s.LastInputAt = fileTimePtr(ex.LastInputTime)
if s.State == "Disconnected" {
s.DisconnectedSince = fileTimePtr(ex.DisconnectTime)
}
if s.LastInputAt != nil {
idle := time.Since(*s.LastInputAt)
if idle > 0 {
s.IdleSeconds = int64(idle / time.Second)
}
}
}
if s.User != "" {
var token windows.Token
r, _, _ := procWTSQueryUserToken.Call(uintptr(row.SessionID), uintptr(unsafe.Pointer(&token)))
@@ -98,6 +153,54 @@ func queryString(sessionID uint32, class uintptr) (string, error) {
}
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(p))), nil
}
func queryClientAddress(sessionID uint32) (string, error) {
var p uintptr
var bytes uint32
r, _, e := procWTSQuerySessionInformationW.Call(0, uintptr(sessionID), wtsClientAddressClass, uintptr(unsafe.Pointer(&p)), uintptr(unsafe.Pointer(&bytes)))
if r == 0 {
return "", e
}
defer procWTSFreeMemory.Call(p)
if p == 0 || bytes < uint32(unsafe.Sizeof(wtsClientAddress{})) {
return "", nil
}
a := (*wtsClientAddress)(unsafe.Pointer(p))
if a.AddressFamily == 2 {
return fmt.Sprintf("%d.%d.%d.%d", a.Address[2], a.Address[3], a.Address[4], a.Address[5]), nil
}
return "", nil
}
func querySessionInfoEx(sessionID uint32) (wtsInfoExLevel1, error) {
var out wtsInfoExLevel1
var p uintptr
var bytes uint32
r, _, e := procWTSQuerySessionInformationW.Call(0, uintptr(sessionID), wtsSessionInfoEx, uintptr(unsafe.Pointer(&p)), uintptr(unsafe.Pointer(&bytes)))
if r == 0 {
return out, e
}
defer procWTSFreeMemory.Call(p)
if p == 0 || bytes < 8+uint32(unsafe.Sizeof(out)) {
return out, fmt.Errorf("WTSSessionInfoEx buffer too small")
}
level := *(*uint32)(unsafe.Pointer(p))
if level != 1 {
return out, fmt.Errorf("unsupported WTSSessionInfoEx level %d", level)
}
out = *(*wtsInfoExLevel1)(unsafe.Pointer(p + 8))
return out, nil
}
func fileTimePtr(v int64) *time.Time {
if v <= 0 {
return nil
}
const unixDelta = 116444736000000000
ns := (v - unixDelta) * 100
if ns <= 0 {
return nil
}
t := time.Unix(0, ns).UTC()
return &t
}
type memoryStatusEx struct {
Length uint32
@@ -110,6 +213,36 @@ type memoryStatusEx struct {
AvailVirtual uint64
AvailExtendedVirtual uint64
}
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
var cpuMu sync.Mutex
var prevIdle, prevKernel, prevUser uint64
func ft64(f filetime) uint64 { return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) }
func cpuPercent() float64 {
var idle, kernel, user filetime
r, _, _ := procGetSystemTimes.Call(uintptr(unsafe.Pointer(&idle)), uintptr(unsafe.Pointer(&kernel)), uintptr(unsafe.Pointer(&user)))
if r == 0 {
return 0
}
i, k, u := ft64(idle), ft64(kernel), ft64(user)
cpuMu.Lock()
defer cpuMu.Unlock()
pi, pk, pu := prevIdle, prevKernel, prevUser
prevIdle, prevKernel, prevUser = i, k, u
if pk == 0 {
return 0
}
total := (k - pk) + (u - pu)
if total == 0 {
return 0
}
busy := total - (i - pi)
return float64(busy) * 100 / float64(total)
}
func Server() (model.ServerInfo, error) {
host, _ := os.Hostname()
@@ -119,7 +252,19 @@ func Server() (model.ServerInfo, error) {
return model.ServerInfo{}, fmt.Errorf("GlobalMemoryStatusEx: %w", e)
}
ticks, _, _ := procGetTickCount64.Call()
info := model.ServerInfo{Hostname: host, OS: "Windows", UptimeSeconds: uint64(ticks) / 1000, MemoryTotal: m.TotalPhys, MemoryAvailable: m.AvailPhys}
info := model.ServerInfo{Hostname: host, OS: "Windows", UptimeSeconds: uint64(ticks) / 1000, MemoryTotal: m.TotalPhys, MemoryAvailable: m.AvailPhys, CPUPercent: cpuPercent()}
drive := os.Getenv("SystemDrive")
if drive == "" {
drive = `C:`
}
root, err := windows.UTF16PtrFromString(drive + `\`)
if err == nil {
var avail, total, free uint64
if rr, _, _ := procGetDiskFreeSpaceExW.Call(uintptr(unsafe.Pointer(root)), uintptr(unsafe.Pointer(&avail)), uintptr(unsafe.Pointer(&total)), uintptr(unsafe.Pointer(&free))); rr != 0 {
info.DiskTotal = total
info.DiskFree = free
}
}
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE); err == nil {
defer k.Close()
if v, _, err := k.GetStringValue("ProductName"); err == nil {
@@ -135,6 +280,57 @@ func Server() (model.ServerInfo, error) {
return info, nil
}
func Processes() ([]model.ProcessInfo, error) {
var buf uintptr
var count uint32
r, _, e := procWTSEnumerateProcessesW.Call(0, 0, 1, uintptr(unsafe.Pointer(&buf)), uintptr(unsafe.Pointer(&count)))
if r == 0 {
return nil, fmt.Errorf("WTSEnumerateProcessesW: %w", e)
}
defer procWTSFreeMemory.Call(buf)
rows := unsafe.Slice((*wtsProcessInfo)(unsafe.Pointer(buf)), int(count))
out := make([]model.ProcessInfo, 0, len(rows))
for _, row := range rows {
p := model.ProcessInfo{PID: row.ProcessID, SessionID: row.SessionID}
if row.ProcessName != nil {
p.Name = windows.UTF16PtrToString(row.ProcessName)
}
if row.UserSID != nil {
p.UserSID = row.UserSID.String()
}
p.MemoryBytes = processMemory(row.ProcessID)
out = append(out, p)
}
return out, nil
}
type processMemoryCounters struct {
CB uint32
PageFaultCount uint32
PeakWorkingSetSize uintptr
WorkingSetSize uintptr
QuotaPeakPagedPoolUsage uintptr
QuotaPagedPoolUsage uintptr
QuotaPeakNonPagedPoolUsage uintptr
QuotaNonPagedPoolUsage uintptr
PagefileUsage uintptr
PeakPagefileUsage uintptr
}
func processMemory(pid uint32) uint64 {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return 0
}
defer windows.CloseHandle(h)
m := processMemoryCounters{CB: uint32(unsafe.Sizeof(processMemoryCounters{}))}
r, _, _ := procGetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&m)), uintptr(m.CB))
if r == 0 {
return 0
}
return uint64(m.WorkingSetSize)
}
func ProfilePath(sid string) (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
if err != nil {
@@ -147,7 +343,6 @@ func ProfilePath(sid string) (string, error) {
}
return expandEnv(p), nil
}
func expandEnv(s string) string {
in, err := windows.UTF16PtrFromString(s)
if err != nil {
@@ -164,7 +359,6 @@ func expandEnv(s string) string {
}
return windows.UTF16ToString(buf)
}
func DeleteProfile(sid string) error {
p, err := windows.UTF16PtrFromString(sid)
if err != nil {
@@ -179,7 +373,58 @@ func DeleteProfile(sid string) error {
}
return nil
}
func LogoffSession(id uint32) error {
return boolCallSession(procWTSLogoffSession, "WTSLogoffSession", id)
}
func DisconnectSession(id uint32) error {
return boolCallSession(procWTSDisconnectSession, "WTSDisconnectSession", id)
}
func boolCallSession(p *windows.LazyProc, name string, id uint32) error {
r, _, e := p.Call(0, uintptr(id), 0)
if r == 0 {
if e == syscall.Errno(0) {
return fmt.Errorf("%s(%d) failed", name, id)
}
return fmt.Errorf("%s(%d): %w", name, id, e)
}
return nil
}
func SendMessage(sessionID uint32, title, message string) error {
if strings.TrimSpace(title) == "" {
title = "SessionGuard"
}
t, err := windows.UTF16FromString(title)
if err != nil {
return err
}
m, err := windows.UTF16FromString(message)
if err != nil {
return err
}
var response uint32
r, _, e := procWTSSendMessageW.Call(0, uintptr(sessionID), uintptr(unsafe.Pointer(&t[0])), uintptr((len(t)-1)*2), uintptr(unsafe.Pointer(&m[0])), uintptr((len(m)-1)*2), 0, 60, uintptr(unsafe.Pointer(&response)), 0)
if r == 0 {
if e == syscall.Errno(0) {
return fmt.Errorf("WTSSendMessageW(%d) failed", sessionID)
}
return fmt.Errorf("WTSSendMessageW(%d): %w", sessionID, e)
}
return nil
}
func TerminateProcess(pid uint32) error {
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return err
}
defer windows.CloseHandle(h)
return windows.TerminateProcess(h, 1)
}
func RestartServer(reason string) error {
if strings.TrimSpace(reason) == "" {
reason = "SessionGuard maintenance restart"
}
return exec.Command("shutdown.exe", "/r", "/t", "0", "/d", "p:4:1", "/c", reason).Run()
}
func MachineID() (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE)
if err != nil {