package agent import ( "context" "crypto/rand" "encoding/hex" "fmt" "log" "net" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/example/sessionguard/internal/auth" "github.com/example/sessionguard/internal/config" "github.com/example/sessionguard/internal/httpx" "github.com/example/sessionguard/internal/model" profilesync "github.com/example/sessionguard/internal/profile" tpl "github.com/example/sessionguard/internal/templates" "github.com/example/sessionguard/internal/windowsx" ) const Version = "0.5.2" type App struct { cfg config.Agent store stateStore mu sync.RWMutex state State snapshot model.AgentSnapshot lastMasterOK time.Time masterErr string client *masterClient sessionWake chan struct{} bootstrapped bool } func New(cfg config.Agent) (*App, error) { if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return nil, err } config.NormalizePolicy(&cfg.Policy) if err := config.ValidatePolicy(cfg.Policy); err != nil { return nil, err } if cfg.Policy.Revision == "" { cfg.Policy.Revision = newRevision() cfg.Policy.UpdatedAt = time.Now().UTC() } st, err := loadState(statePath(cfg.DataDir), cfg.Policy) if err != nil { return nil, err } config.NormalizePolicy(&st.Policy) return &App{cfg: cfg, store: stateStore{path: statePath(cfg.DataDir)}, state: st, client: newMasterClient(cfg.MasterURL), sessionWake: make(chan struct{}, 1)}, nil } func newRevision() string { b := make([]byte, 12) _, _ = rand.Read(b) return hex.EncodeToString(b) } // NotifySessionChange is called by the Windows service handler for SERVICE_CONTROL_SESSIONCHANGE. // It is deliberately non-blocking: the worker performs the actual WTS/profile work outside the SCM callback. func (a *App) NotifySessionChange() { select { case a.sessionWake <- struct{}{}: default: } } func (a *App) Run(ctx context.Context) error { go a.worker(ctx) return a.serveHTTP(ctx) } func (a *App) worker(ctx context.Context) { poll := time.NewTimer(0) defer poll.Stop() hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second) defer hb.Stop() remoteApps := time.NewTicker(60 * time.Second) defer remoteApps.Stop() if a.remoteAppReconcileNeeded() { a.syncRemoteApps() } for { select { case <-ctx.Done(): return case <-poll.C: a.tick(ctx) poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) case <-a.sessionWake: a.tick(ctx) if !poll.Stop() { select { case <-poll.C: default: } } poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second) case <-hb.C: a.sendHeartbeat(ctx) case <-remoteApps.C: if a.remoteAppReconcileNeeded() { a.syncRemoteApps() } } } } func (a *App) remoteAppReconcileNeeded() bool { a.mu.RLock() defer a.mu.RUnlock() return len(a.state.DesiredRemoteApps) > 0 || len(a.state.ManagedRemoteApps) > 0 } func (a *App) tick(ctx context.Context) { sessions, err := windowsx.Sessions() if err != nil { log.Printf("sessions: %v", err) return } server, err := windowsx.Server() if err != nil { log.Printf("server info: %v", err) } processes, processErr := windowsx.Processes() if processErr != nil { log.Printf("processes: %v", processErr) } health := a.calculateHealth(server) now := time.Now().UTC() var autoLogoff []model.Session var templateNow []model.Session a.mu.Lock() isBootstrap := !a.bootstrapped currentBySID := map[string]bool{} currentIDs := map[uint32]model.Session{} for i := range sessions { s := &sessions[i] if s.User == "" { currentIDs[s.ID] = *s continue } if _, existed := a.state.LastSessions[s.ID]; !existed && !isBootstrap { t := model.SessionTelemetry{SessionID: s.ID, SID: s.SID, User: displayUser(*s), FirstSeenAt: now} if s.LogonAt != nil { t.LogonAt = *s.LogonAt } a.state.Telemetry[s.ID] = t } if s.SID != "" { currentBySID[s.SID] = true delete(a.state.Pending, s.SID) delete(a.state.ProfileJobs, backupJobID(s.SID)) } if s.State == "Disconnected" { since, ok := a.state.DisconnectedSince[s.ID] if !ok { since = now a.state.DisconnectedSince[s.ID] = since a.appendEventLocked("info", "session_disconnected", displayUser(*s), fmt.Sprintf("Sitzung %d wurde getrennt", s.ID)) } s.DisconnectedSince = &since sp := a.state.Policy.Sessions if sp.DisconnectedLogoffEnabled && !a.sessionExcluded(*s) && now.Sub(since) >= time.Duration(sp.DisconnectedTimeoutSeconds)*time.Second { if _, requested := a.state.AutoLogoffRequested[s.ID]; !requested { a.state.AutoLogoffRequested[s.ID] = now autoLogoff = append(autoLogoff, *s) a.appendEventLocked("warning", "auto_logoff_due", displayUser(*s), fmt.Sprintf("Getrennte Sitzung %d hat das Timeout von %d Sekunden erreicht", s.ID, sp.DisconnectedTimeoutSeconds)) } } } else { delete(a.state.DisconnectedSince, s.ID) delete(a.state.AutoLogoffRequested, s.ID) } currentIDs[s.ID] = *s if s.SID != "" && !a.state.RestoredSessions[s.ID] { pp := a.state.Policy.Profiles if isBootstrap { // Never inject a restore into a session that was already present when the // service started. Restores are reserved for sessions observed after startup. a.state.RestoredSessions[s.ID] = true templateNow = append(templateNow, *s) } else if pp.Enabled && pp.RestoreOnLogon && !a.profileExcluded(*s) { if _, exists := a.state.ProfileJobs[restoreJobID(s.ID)]; !exists { if profilePath, e := windowsx.ProfilePath(s.SID); e == nil { a.state.ProfileJobs[restoreJobID(s.ID)] = model.ProfileJob{ID: restoreJobID(s.ID), Operation: "restore", SID: s.SID, User: displayUser(*s), SessionID: s.ID, ProfilePath: profilePath, Reason: "logon", CreatedAt: now, DueAt: now} t := a.state.Telemetry[s.ID] t.RestoreStartedAt = now a.state.Telemetry[s.ID] = t a.appendEventLocked("info", "profile_restore_scheduled", displayUser(*s), fmt.Sprintf("Profil-Wiederherstellung für Sitzung %d eingeplant", s.ID)) } else { a.appendEventLocked("error", "profile_restore_schedule_error", displayUser(*s), fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", e)) } } } else { a.state.RestoredSessions[s.ID] = true templateNow = append(templateNow, *s) } } } for id, prev := range a.state.LastSessions { if _, exists := currentIDs[id]; exists || prev.SID == "" || currentBySID[prev.SID] { continue } delete(a.state.DisconnectedSince, id) delete(a.state.AutoLogoffRequested, id) delete(a.state.RestoredSessions, id) delete(a.state.ProfileJobs, restoreJobID(id)) a.handleEndedSessionLocked(prev, now) } a.state.LastSessions = currentIDs a.pruneTelemetryLocked(currentIDs, 500) a.bootstrapped = true _ = a.store.save(a.state) a.mu.Unlock() for _, s := range autoLogoff { a.autoLogoff(s) } for _, s := range templateNow { a.applyTemplates(s) } a.processProfileJobs(ctx, now) a.processCleanup(now) a.refreshSnapshot(server, sessions, limitUserProcesses(processes, 3000), health, now) } func (a *App) pruneTelemetryLocked(active map[uint32]model.Session, limit int) { if limit <= 0 || len(a.state.Telemetry) <= limit { return } type candidate struct { id uint32 time time.Time } candidates := make([]candidate, 0, len(a.state.Telemetry)) for id, t := range a.state.Telemetry { if _, ok := active[id]; ok { continue } candidates = append(candidates, candidate{id: id, time: t.FirstSeenAt}) } sort.Slice(candidates, func(i, j int) bool { return candidates[i].time.Before(candidates[j].time) }) remove := len(a.state.Telemetry) - limit if remove > len(candidates) { remove = len(candidates) } for i := 0; i < remove; i++ { delete(a.state.Telemetry, candidates[i].id) } } func limitUserProcesses(in []model.ProcessInfo, limit int) []model.ProcessInfo { if limit <= 0 { return nil } out := make([]model.ProcessInfo, 0, min(len(in), limit)) for _, p := range in { if p.SessionID == 0 { continue } out = append(out, p) if len(out) >= limit { break } } return out } func (a *App) handleEndedSessionLocked(prev model.Session, now time.Time) { pp := a.state.Policy.Profiles if pp.Enabled && pp.BackupOnLogoff && !a.profileExcluded(prev) { id := backupJobID(prev.SID) if _, exists := a.state.ProfileJobs[id]; !exists { profilePath, err := windowsx.ProfilePath(prev.SID) if err != nil { a.appendEventLocked("error", "profile_backup_schedule_error", displayUser(prev), fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", err)) return } due := now.Add(time.Duration(max(0, pp.BackupDelaySeconds)) * time.Second) a.state.ProfileJobs[id] = model.ProfileJob{ID: id, Operation: "backup", SID: prev.SID, User: displayUser(prev), ProfilePath: profilePath, Reason: "logoff", CreatedAt: now, DueAt: due} a.appendEventLocked("info", "profile_backup_scheduled", displayUser(prev), fmt.Sprintf("Profilsicherung nach Sitzungsende in %d Sekunden eingeplant", pp.BackupDelaySeconds)) } return // Cleanup is intentionally gated by successful backup. } a.scheduleCleanupLocked(prev.SID, displayUser(prev), now) } func (a *App) scheduleCleanupLocked(sid, user string, now time.Time) { p := a.state.Policy.Cleanup if !p.Enabled || sid == "" || excludedIdentity(user, sid, p.ExcludeUsers, p.ExcludeSIDs) { return } if _, exists := a.state.Pending[sid]; exists { return } path, err := windowsx.ProfilePath(sid) if err != nil { a.appendEventLocked("error", "cleanup_schedule_error", user, fmt.Sprintf("Profilpfad konnte nicht ermittelt werden: %v", err)) return } if !safeProfilePath(path, p.AllowedProfileRoots) { a.appendEventLocked("error", "cleanup_blocked", user, fmt.Sprintf("Profilpfad außerhalb der erlaubten Roots: %s", path)) return } a.state.Pending[sid] = model.CleanupJob{SID: sid, User: user, ProfilePath: path, DueAt: now.Add(time.Duration(p.GraceSeconds) * time.Second)} a.appendEventLocked("info", "cleanup_scheduled", user, fmt.Sprintf("Profilbereinigung in %d Sekunden geplant: %s", p.GraceSeconds, path)) } func (a *App) processProfileJobs(ctx context.Context, now time.Time) { a.mu.RLock() jobs := make([]model.ProfileJob, 0, len(a.state.ProfileJobs)) for _, j := range a.state.ProfileJobs { if !now.Before(j.DueAt) { jobs = append(jobs, j) } } a.mu.RUnlock() for _, job := range jobs { select { case <-ctx.Done(): return default: } a.processProfileJob(job) } } func (a *App) processProfileJob(job model.ProfileJob) { policy := a.policy() pp := policy.Profiles if !pp.Enabled { a.mu.Lock() delete(a.state.ProfileJobs, job.ID) _ = a.store.save(a.state) a.mu.Unlock() return } current, err := windowsx.Sessions() if err != nil { a.retryProfileJob(job, fmt.Errorf("session recheck: %w", err)) return } var target *model.Session activeSID := false for i := range current { if current[i].SID == job.SID && current[i].User != "" { activeSID = true } if current[i].ID == job.SessionID { target = ¤t[i] } } if job.Operation == "backup" && activeSID { a.mu.Lock() delete(a.state.ProfileJobs, job.ID) delete(a.state.Pending, job.SID) a.appendEventLocked("info", "profile_backup_cancelled", job.User, "Profilsicherung verworfen, weil der Benutzer wieder angemeldet ist") _ = a.store.save(a.state) a.mu.Unlock() return } if job.Operation == "restore" && (target == nil || target.SID != job.SID) { a.mu.Lock() delete(a.state.ProfileJobs, job.ID) _ = a.store.save(a.state) a.mu.Unlock() return } switch job.Operation { case "backup": stats, err := profilesync.BackupGuarded(job.ProfilePath, pp.StoreRoot, job.SID, job.User, pp.Folders, pp.KeepVersions, func() error { sessions, err := windowsx.Sessions() if err != nil { return fmt.Errorf("session recheck before snapshot activation: %w", err) } for _, s := range sessions { if s.SID == job.SID && s.User != "" { return fmt.Errorf("user session %d became active during backup", s.ID) } } return nil }) if err != nil { a.retryProfileJob(job, err) a.mu.Lock() st := a.state.ProfileStatus[job.SID] st.SID, st.User, st.LastBackupError = job.SID, job.User, err.Error() a.state.ProfileStatus[job.SID] = st _ = a.store.save(a.state) a.mu.Unlock() return } a.mu.Lock() delete(a.state.ProfileJobs, job.ID) st := a.state.ProfileStatus[job.SID] st.SID, st.User, st.LastBackupAt, st.LastBackupError = job.SID, job.User, time.Now().UTC(), "" a.state.ProfileStatus[job.SID] = st a.appendEventLocked("info", "profile_backup_complete", job.User, fmt.Sprintf("Profilsicherung abgeschlossen: %d Dateien, %d Bytes", stats.Files, stats.Bytes)) // Cleanup starts only after the backup is safely activated as current snapshot. a.scheduleCleanupLocked(job.SID, job.User, time.Now().UTC()) _ = a.store.save(a.state) a.mu.Unlock() case "restore": stats, found, err := profilesync.Restore(job.ProfilePath, pp.StoreRoot, job.SID, pp.Folders) if err != nil { if !job.CreatedAt.IsZero() && time.Since(job.CreatedAt) >= time.Duration(pp.RestoreWindowSeconds)*time.Second { a.abandonRestore(job, err, target) return } a.retryProfileJob(job, err) a.mu.Lock() st := a.state.ProfileStatus[job.SID] st.SID, st.User, st.LastRestoreError = job.SID, job.User, err.Error() a.state.ProfileStatus[job.SID] = st _ = a.store.save(a.state) a.mu.Unlock() return } a.mu.Lock() delete(a.state.ProfileJobs, job.ID) a.state.RestoredSessions[job.SessionID] = true st := a.state.ProfileStatus[job.SID] st.SID, st.User, st.LastRestoreAt, st.LastRestoreError = job.SID, job.User, time.Now().UTC(), "" a.state.ProfileStatus[job.SID] = st if t, ok := a.state.Telemetry[job.SessionID]; ok { t.RestoreFinishedAt = time.Now().UTC() if !t.RestoreStartedAt.IsZero() { t.RestoreDurationMS = t.RestoreFinishedAt.Sub(t.RestoreStartedAt).Milliseconds() } a.state.Telemetry[job.SessionID] = t } if found { a.appendEventLocked("info", "profile_restore_complete", job.User, fmt.Sprintf("Profil-Wiederherstellung abgeschlossen: %d Dateien, %d Bytes", stats.Files, stats.Bytes)) } else { a.appendEventLocked("info", "profile_restore_empty", job.User, "Noch kein gespeichertes Profil vorhanden; Anmeldung wird ohne Restore fortgesetzt") } _ = a.store.save(a.state) a.mu.Unlock() if target != nil { a.applyTemplates(*target) } default: a.retryProfileJob(job, fmt.Errorf("unknown profile job operation %q", job.Operation)) } } func (a *App) abandonRestore(job model.ProfileJob, restoreErr error, target *model.Session) { a.mu.Lock() delete(a.state.ProfileJobs, job.ID) a.state.RestoredSessions[job.SessionID] = true st := a.state.ProfileStatus[job.SID] st.SID, st.User, st.LastRestoreError = job.SID, job.User, restoreErr.Error() a.state.ProfileStatus[job.SID] = st a.appendEventLocked("error", "profile_restore_abandoned", job.User, fmt.Sprintf("Restore-Fenster von %d Sekunden abgelaufen; Anmeldung läuft ohne weiteren Restore weiter: %v", a.state.Policy.Profiles.RestoreWindowSeconds, restoreErr)) _ = a.store.save(a.state) a.mu.Unlock() if target != nil { a.applyTemplates(*target) } } func (a *App) retryProfileJob(job model.ProfileJob, err error) { a.mu.Lock() defer a.mu.Unlock() current, ok := a.state.ProfileJobs[job.ID] if !ok { return } current.Attempts++ current.LastError = err.Error() current.DueAt = time.Now().UTC().Add(time.Duration(max(1, a.state.Policy.Profiles.RetrySeconds)) * time.Second) a.state.ProfileJobs[job.ID] = current a.appendEventLocked("error", "profile_"+job.Operation+"_error", job.User, fmt.Sprintf("Profil-%s fehlgeschlagen (Versuch %d): %v", job.Operation, current.Attempts, err)) _ = a.store.save(a.state) } func (a *App) applyTemplates(s model.Session) { path, err := windowsx.ProfilePath(s.SID) if err != nil { log.Printf("templates profile %s: %v", displayUser(s), err) return } policy := a.policy() for _, item := range policy.Templates { changed, err := tpl.Apply(path, item) a.mu.Lock() if err != nil { a.appendEventLocked("error", "template_error", displayUser(s), fmt.Sprintf("Template %s konnte nicht angewendet werden: %v", item.ID, err)) } else if changed { a.appendEventLocked("info", "template_applied", displayUser(s), fmt.Sprintf("Template %s angewendet", item.ID)) } _ = a.store.save(a.state) a.mu.Unlock() if err != nil { log.Printf("template %s for %s: %v", item.ID, displayUser(s), err) } } a.mu.Lock() if t, ok := a.state.Telemetry[s.ID]; ok { t.ReadyAt = time.Now().UTC() base := t.LogonAt if base.IsZero() { base = t.FirstSeenAt } if !base.IsZero() { t.ObservedLogonMS = t.ReadyAt.Sub(base).Milliseconds() } a.state.Telemetry[s.ID] = t } _ = a.store.save(a.state) a.mu.Unlock() } func (a *App) processCleanup(now time.Time) { p := a.policy().Cleanup if !p.Enabled { return } sessions, err := windowsx.Sessions() if err != nil { return } active := map[string]bool{} for _, s := range sessions { if s.SID != "" { active[s.SID] = true } } a.mu.RLock() jobs := make([]model.CleanupJob, 0, len(a.state.Pending)) for _, job := range a.state.Pending { if !now.Before(job.DueAt) { jobs = append(jobs, job) } } a.mu.RUnlock() for _, job := range jobs { if active[job.SID] { a.mu.Lock() delete(a.state.Pending, job.SID) _ = a.store.save(a.state) a.mu.Unlock() continue } if !safeProfilePath(job.ProfilePath, p.AllowedProfileRoots) { a.mu.Lock() job.LastError = "profile path is outside allowed roots" job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) a.state.Pending[job.SID] = job a.appendEventLocked("error", "cleanup_blocked", job.User, fmt.Sprintf("Profilpfad außerhalb der erlaubten Roots: %s", job.ProfilePath)) _ = a.store.save(a.state) a.mu.Unlock() continue } // Recheck immediately before the destructive decision. A user may have logged in // after the session list at the beginning of this cleanup pass was collected. latest, recheckErr := windowsx.Sessions() if recheckErr != nil { a.mu.Lock() job.LastError = "final session recheck: " + recheckErr.Error() job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) a.state.Pending[job.SID] = job a.appendEventLocked("error", "cleanup_recheck_error", job.User, fmt.Sprintf("Finaler Session-Recheck vor Profilbereinigung fehlgeschlagen: %v", recheckErr)) _ = a.store.save(a.state) a.mu.Unlock() continue } sidActive := false for _, s := range latest { if s.SID == job.SID && s.User != "" { sidActive = true break } } if sidActive { a.mu.Lock() delete(a.state.Pending, job.SID) a.appendEventLocked("info", "cleanup_cancelled", job.User, "Profilbereinigung beim finalen Recheck verworfen, weil der Benutzer wieder angemeldet ist") _ = a.store.save(a.state) a.mu.Unlock() continue } if p.DryRun { a.mu.Lock() a.appendEventLocked("dry-run", "cleanup_dry_run", job.User, fmt.Sprintf("Profil würde jetzt gelöscht: %s", job.ProfilePath)) delete(a.state.Pending, job.SID) _ = a.store.save(a.state) a.mu.Unlock() continue } if err := windowsx.DeleteProfile(job.SID); err != nil { a.mu.Lock() job.Attempts++ job.LastError = err.Error() job.DueAt = time.Now().UTC().Add(time.Duration(p.RetrySeconds) * time.Second) a.state.Pending[job.SID] = job a.appendEventLocked("error", "cleanup_error", job.User, fmt.Sprintf("Profil konnte nicht gelöscht werden: %v", err)) _ = a.store.save(a.state) a.mu.Unlock() continue } a.mu.Lock() delete(a.state.Pending, job.SID) a.appendEventLocked("info", "cleanup_deleted", job.User, fmt.Sprintf("Profil gelöscht: %s", job.ProfilePath)) _ = a.store.save(a.state) a.mu.Unlock() } } func (a *App) autoLogoff(s model.Session) { err := windowsx.LogoffSession(s.ID) a.mu.Lock() defer a.mu.Unlock() if err != nil { delete(a.state.AutoLogoffRequested, s.ID) a.appendEventLocked("error", "auto_logoff_error", displayUser(s), fmt.Sprintf("Automatisches Abmelden von Sitzung %d fehlgeschlagen: %v", s.ID, err)) } else { a.appendEventLocked("warning", "auto_logoff_requested", displayUser(s), fmt.Sprintf("Abmeldung der getrennten Sitzung %d ausgelöst; Profilsicherung folgt nach Sitzungsende", s.ID)) } _ = a.store.save(a.state) } func (a *App) executeSessionCommand(cmd model.SessionCommand) model.CommandResult { res := model.CommandResult{ID: cmd.ID, Action: cmd.Action, SessionID: cmd.SessionID, PID: cmd.PID, CompletedAt: time.Now().UTC()} policy := a.policy() action := strings.ToLower(strings.TrimSpace(cmd.Action)) if action != "restart_server" && !policy.Sessions.ControlEnabled { res.Error = "session control is disabled by policy" return res } if !cmd.ExpiresAt.IsZero() && time.Now().After(cmd.ExpiresAt) { res.Error = "command expired" return res } var err error switch action { case "logoff": err = windowsx.LogoffSession(cmd.SessionID) case "disconnect": err = windowsx.DisconnectSession(cmd.SessionID) case "message": if strings.TrimSpace(cmd.Message) == "" { err = fmt.Errorf("message is empty") } else { err = windowsx.SendMessage(cmd.SessionID, cmd.Title, cmd.Message) } case "kill_process": if cmd.PID == 0 || cmd.SessionID == 0 { err = fmt.Errorf("pid and session id are required") } else { // Re-resolve PID ownership immediately before termination. Windows can // reuse a PID while a queued command is in flight; never terminate it // if it no longer belongs to the expected RDS session. processes, listErr := windowsx.Processes() if listErr != nil { err = fmt.Errorf("verify process: %w", listErr) } else { matched := false for _, process := range processes { if process.PID == cmd.PID && process.SessionID == cmd.SessionID { matched = true break } } if !matched { err = fmt.Errorf("process %d no longer belongs to session %d", cmd.PID, cmd.SessionID) } else { err = windowsx.TerminateProcess(cmd.PID) } } } case "restart_server": err = windowsx.RestartServer(emptyAs(cmd.Message, "SessionGuard maintenance restart")) default: err = fmt.Errorf("unsupported session action %q", cmd.Action) } res.Success = err == nil if err != nil { res.Error = err.Error() } a.mu.Lock() level := "info" if err != nil { level = "error" } a.appendEventLocked(level, "session_command", "", fmt.Sprintf("Aktion %s für Session %d/PID %d durch %s: %s", cmd.Action, cmd.SessionID, cmd.PID, emptyAs(cmd.RequestedBy, "lokal"), resultText(err))) _ = a.store.save(a.state) a.mu.Unlock() return res } func (a *App) processCommands(commands []model.SessionCommand) { for _, cmd := range commands { a.mu.RLock() _, done := a.state.ProcessedCommands[cmd.ID] a.mu.RUnlock() if done || cmd.ID == "" { continue } res := a.executeSessionCommand(cmd) a.mu.Lock() a.state.ProcessedCommands[cmd.ID] = time.Now().UTC() a.state.CommandResults = append(a.state.CommandResults, res) if len(a.state.CommandResults) > 100 { a.state.CommandResults = append([]model.CommandResult(nil), a.state.CommandResults[len(a.state.CommandResults)-100:]...) } cutoff := time.Now().UTC().Add(-48 * time.Hour) for id, t := range a.state.ProcessedCommands { if t.Before(cutoff) { delete(a.state.ProcessedCommands, id) } } _ = a.store.save(a.state) a.mu.Unlock() } } func (a *App) cleanupExcluded(s model.Session) bool { return excludedBy(s, a.state.Policy.Cleanup.ExcludeUsers, a.state.Policy.Cleanup.ExcludeSIDs) } func (a *App) profileExcluded(s model.Session) bool { return excludedBy(s, a.state.Policy.Profiles.ExcludeUsers, a.state.Policy.Profiles.ExcludeSIDs) } func (a *App) sessionExcluded(s model.Session) bool { return excludedBy(s, a.state.Policy.Sessions.ExcludeUsers, a.state.Policy.Sessions.ExcludeSIDs) } func excludedBy(s model.Session, users, sids []string) bool { if excludedIdentity(displayUser(s), s.SID, users, sids) { return true } for _, u := range users { if strings.EqualFold(strings.TrimSpace(u), s.User) { return true } } return false } func excludedIdentity(user, sid string, users, sids []string) bool { plainUser := user if i := strings.LastIndexAny(plainUser, `\/`); i >= 0 && i+1 < len(plainUser) { plainUser = plainUser[i+1:] } for _, u := range users { u = strings.TrimSpace(u) if strings.EqualFold(u, user) || strings.EqualFold(u, plainUser) { return true } } for _, x := range sids { x = strings.TrimSpace(x) if x != "" && (strings.EqualFold(sid, x) || strings.HasPrefix(strings.ToUpper(sid), strings.ToUpper(x)+"-")) { return true } } return false } func safeProfilePath(profilePath string, roots []string) bool { clean, err := filepath.Abs(filepath.Clean(profilePath)) if err != nil { return false } for _, root := range roots { r, err := filepath.Abs(filepath.Clean(root)) if err != nil { continue } c, rr := strings.ToLower(clean), strings.ToLower(r) if c == rr || strings.HasPrefix(c, rr+string(os.PathSeparator)) { return true } } return false } func displayUser(s model.Session) string { if s.Domain != "" { return s.Domain + `\` + s.User } return s.User } func pendingSlice(m map[string]model.CleanupJob) []model.CleanupJob { out := make([]model.CleanupJob, 0, len(m)) for _, v := range m { out = append(out, v) } return out } func profileJobSlice(m map[string]model.ProfileJob) []model.ProfileJob { out := make([]model.ProfileJob, 0, len(m)) for _, v := range m { out = append(out, v) } return out } func eventSlice(events []model.AgentEvent) []model.AgentEvent { return append([]model.AgentEvent(nil), events...) } func resultSlice(results []model.CommandResult) []model.CommandResult { return append([]model.CommandResult(nil), results...) } func cloneProfileStatus(in map[string]model.ProfileStatus) map[string]model.ProfileStatus { out := make(map[string]model.ProfileStatus, len(in)) for k, v := range in { out[k] = v } return out } func backupJobID(sid string) string { return "backup:" + sid } func restoreJobID(id uint32) string { return "restore:" + strconv.FormatUint(uint64(id), 10) } func emptyAs(v, d string) string { if strings.TrimSpace(v) == "" { return d } return v } func resultText(err error) string { if err == nil { return "erfolgreich" } return err.Error() } func (a *App) appendEventLocked(level, typ, user, message string) { a.state.Events = append(a.state.Events, model.AgentEvent{Time: time.Now().UTC(), Level: level, Type: typ, User: user, Message: message}) if len(a.state.Events) > 500 { a.state.Events = append([]model.AgentEvent(nil), a.state.Events[len(a.state.Events)-500:]...) } } func max(a, b int) int { if a > b { return a } return b } func (a *App) policy() model.Policy { a.mu.RLock(); defer a.mu.RUnlock(); return a.state.Policy } func (a *App) refreshSnapshot(server model.ServerInfo, sessions []model.Session, processes []model.ProcessInfo, health model.HealthStatus, now time.Time) { a.mu.Lock() defer a.mu.Unlock() for i := range sessions { if t, ok := a.state.DisconnectedSince[sessions[i].ID]; ok { tt := t sessions[i].DisconnectedSince = &tt } } telemetry := make(map[uint32]model.SessionTelemetry, len(a.state.Telemetry)) for id, t := range a.state.Telemetry { telemetry[id] = t } a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Health: health, Sessions: sessions, Processes: processes, Telemetry: telemetry, PendingCleanup: pendingSlice(a.state.Pending), ProfileJobs: profileJobSlice(a.state.ProfileJobs), ProfileStatus: cloneProfileStatus(a.state.ProfileStatus), Events: eventSlice(a.state.Events), CommandResults: resultSlice(a.state.CommandResults), RemoteApps: append([]model.RemoteAppStatus(nil), a.state.RemoteAppStatus...), Policy: a.state.Policy, PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now} } func (a *App) calculateHealth(server model.ServerInfo) model.HealthStatus { h := model.HealthStatus{Score: 100, CalculatedAt: time.Now().UTC(), RDPListenerOK: true, ProfileStoreOK: true} add := func(name string, ok bool, message string, penalty int) { h.Checks = append(h.Checks, model.HealthCheck{Name: name, OK: ok, Message: message}) if !ok { h.Score -= penalty } } memPct := 0.0 if server.MemoryTotal > 0 { memPct = float64(server.MemoryTotal-server.MemoryAvailable) * 100 / float64(server.MemoryTotal) } add("cpu", server.CPUPercent < 95, fmt.Sprintf("%.1f%%", server.CPUPercent), 20) add("memory", memPct < 95, fmt.Sprintf("%.1f%%", memPct), 20) diskOK := server.DiskTotal == 0 || server.DiskFree >= 5<<30 add("system_disk", diskOK, fmt.Sprintf("%d GiB free", server.DiskFree>>30), 20) conn, err := net.DialTimeout("tcp", "127.0.0.1:3389", 750*time.Millisecond) if err == nil { _ = conn.Close() } h.RDPListenerOK = err == nil add("rdp_listener", h.RDPListenerOK, resultText(err), 30) p := a.policy().Profiles if p.Enabled && strings.TrimSpace(p.StoreRoot) != "" { _, err = os.Stat(p.StoreRoot) h.ProfileStoreOK = err == nil add("profile_store", h.ProfileStoreOK, resultText(err), 30) } if h.Score < 0 { h.Score = 0 } return h } func (a *App) sendHeartbeat(ctx context.Context) { if a.cfg.MasterURL == "" { return } a.mu.RLock() id, token := a.state.AgentID, a.state.AgentToken snap := a.snapshot a.mu.RUnlock() if id == "" || token == "" { server, _ := windowsx.Server() mid, _ := windowsx.MachineID() resp, err := a.client.enroll(ctx, model.EnrollRequest{EnrollmentToken: a.cfg.EnrollmentToken, Name: server.Hostname, MachineID: mid}) if err != nil { a.mu.Lock() a.masterErr = err.Error() a.mu.Unlock() log.Printf("master enroll: %v", err) return } a.mu.Lock() a.state.AgentID, a.state.AgentToken = resp.AgentID, resp.Token id, token = resp.AgentID, resp.Token _ = a.store.save(a.state) a.mu.Unlock() } snap.AgentID = id resp, err := a.client.heartbeat(ctx, id, token, snap) if err != nil { a.mu.Lock() a.masterErr = err.Error() a.mu.Unlock() log.Printf("master heartbeat: %v", err) return } a.mu.Lock() a.lastMasterOK = time.Now().UTC() a.masterErr = "" changed := false if resp.DesiredPolicy != nil && resp.DesiredPolicy.Revision != "" && resp.DesiredPolicy.Revision != a.state.Policy.Revision { p := *resp.DesiredPolicy config.NormalizePolicy(&p) if err := config.ValidatePolicy(p); err != nil { a.appendEventLocked("error", "policy_rejected", "", fmt.Sprintf("Master-Policy verworfen: %v", err)) } else { a.state.Policy = p changed = true a.appendEventLocked("info", "policy_applied", "", fmt.Sprintf("Master-Policy %s angewendet", p.Revision)) } } remoteAppsChanged := !remoteAppSpecsEqual(a.state.DesiredRemoteApps, resp.DesiredRemoteApps) if remoteAppsChanged { a.state.DesiredRemoteApps = append([]model.RemoteAppSpec(nil), resp.DesiredRemoteApps...) a.appendEventLocked("info", "remoteapp_desired_state", "", fmt.Sprintf("RemoteApp-Sollzustand aktualisiert: %d Apps", len(resp.DesiredRemoteApps))) } remoteAppSyncDue := remoteAppsChanged || a.state.LastRemoteAppSync.IsZero() || time.Since(a.state.LastRemoteAppSync) >= 60*time.Second _ = a.store.save(a.state) current := make([]model.Session, 0, len(a.state.LastSessions)) if changed { for _, s := range a.state.LastSessions { current = append(current, s) } } a.mu.Unlock() if remoteAppSyncDue { a.syncRemoteApps() } if changed { for _, s := range current { if s.SID != "" && s.User != "" { a.applyTemplates(s) } } } a.processCommands(resp.Commands) } func remoteAppSpecsEqual(a, b []model.RemoteAppSpec) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func (a *App) syncRemoteApps() { a.mu.RLock() desired := append([]model.RemoteAppSpec(nil), a.state.DesiredRemoteApps...) previous := make(map[string]model.RemoteAppSpec, len(a.state.ManagedRemoteApps)) for alias, spec := range a.state.ManagedRemoteApps { previous[alias] = spec } owned := make(map[string]bool, len(a.state.OwnedRemoteAppAliases)) for alias, isOwned := range a.state.OwnedRemoteAppAliases { if isOwned { owned[strings.ToLower(alias)] = true } } a.mu.RUnlock() wanted := make(map[string]model.RemoteAppSpec, len(desired)) for _, spec := range desired { wanted[strings.ToLower(spec.Alias)] = spec } remove := make([]string, 0) for alias := range previous { key := strings.ToLower(alias) if _, ok := wanted[key]; !ok && owned[key] { remove = append(remove, alias) } } sort.Strings(remove) managedStatus, reconcileErr := windowsx.ReconcileRemoteApps(desired, remove) if reconcileErr == nil { for _, st := range managedStatus { if st.Owned { owned[strings.ToLower(st.Alias)] = true } } for _, alias := range remove { delete(owned, strings.ToLower(alias)) } } discovered, discoverErr := windowsx.DiscoverRemoteApps() status := managedStatus if discoverErr == nil { status = windowsx.RemoteAppStatusByDesired(discovered, desired) managedErrors := map[string]string{} for _, st := range managedStatus { if st.Error != "" { managedErrors[strings.ToLower(st.Alias)] = st.Error } } for i := range status { key := strings.ToLower(status[i].Alias) status[i].Owned = owned[key] if errText := managedErrors[key]; errText != "" { status[i].Error = errText status[i].InSync = false } } } now := time.Now().UTC() a.mu.Lock() a.state.LastRemoteAppSync = now if reconcileErr != nil { a.appendEventLocked("error", "remoteapp_reconcile_error", "", reconcileErr.Error()) } if discoverErr != nil { a.appendEventLocked("error", "remoteapp_discovery_error", "", discoverErr.Error()) } if reconcileErr == nil { a.state.ManagedRemoteApps = map[string]model.RemoteAppSpec{} for _, spec := range desired { a.state.ManagedRemoteApps[spec.Alias] = spec } a.state.OwnedRemoteAppAliases = map[string]bool{} for alias, isOwned := range owned { if isOwned { a.state.OwnedRemoteAppAliases[alias] = true } } } if status != nil { a.state.RemoteAppStatus = status a.snapshot.RemoteApps = append([]model.RemoteAppStatus(nil), status...) } _ = a.store.save(a.state) a.mu.Unlock() } func (a *App) serveHTTP(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version}) }) mux.HandleFunc("GET /metrics", a.metricsAPI) var am *auth.Manager if a.cfg.OIDC.Issuer != "" { var err error am, err = auth.New(ctx, a.cfg.OIDC) if err != nil { log.Printf("local OIDC unavailable: %v", err) } } if am != nil { am.Register(mux) } secure := func(h http.Handler) http.Handler { if am == nil { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "OIDC is not configured or unavailable", http.StatusServiceUnavailable) }) } return am.Require(h) } mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, agentJS) }) mux.Handle("GET /", secure(http.HandlerFunc(a.agentPage))) mux.Handle("GET /api/v1/status", secure(http.HandlerFunc(a.statusAPI))) mux.Handle("GET /api/v1/policy", secure(http.HandlerFunc(a.policyAPI))) mux.Handle("PUT /api/v1/policy", secure(http.HandlerFunc(a.policyAPI))) mux.Handle("POST /api/v1/sessions/{id}/action", secure(http.HandlerFunc(a.sessionActionAPI))) server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second} go func() { <-ctx.Done() c, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(c) }() log.Printf("agent web listening on %s", a.cfg.Listen) err := server.ListenAndServe() if err == http.ErrServerClosed { return nil } return err } func (a *App) metricsAPI(w http.ResponseWriter, r *http.Request) { a.mu.RLock() s := a.snapshot a.mu.RUnlock() active := 0 disconnected := 0 for _, x := range s.Sessions { if x.State == "Active" { active++ } if x.State == "Disconnected" { disconnected++ } } w.Header().Set("Content-Type", "text/plain; version=0.0.4") fmt.Fprintf(w, "sessionguard_agent_up 1\nsessionguard_sessions_total %d\nsessionguard_sessions_active %d\nsessionguard_sessions_disconnected %d\nsessionguard_profile_jobs %d\nsessionguard_cleanup_jobs %d\n", len(s.Sessions), active, disconnected, len(s.ProfileJobs), len(s.PendingCleanup)) } func (a *App) statusAPI(w http.ResponseWriter, r *http.Request) { a.mu.RLock() defer a.mu.RUnlock() httpx.JSON(w, 200, map[string]any{"snapshot": a.snapshot, "last_master_ok": a.lastMasterOK, "master_error": a.masterErr, "master_url": a.cfg.MasterURL}) } func (a *App) policyAPI(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { a.mu.RLock() defer a.mu.RUnlock() httpx.JSON(w, 200, a.state.Policy) return } if !httpx.SameOrigin(r) { httpx.Error(w, 403, "cross-origin request rejected") return } var p model.Policy if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil { httpx.Error(w, 400, err.Error()) return } config.NormalizePolicy(&p) if err := config.ValidatePolicy(p); err != nil { httpx.Error(w, 400, err.Error()) return } p.Revision = newRevision() p.UpdatedAt = time.Now().UTC() a.mu.Lock() a.state.Policy = p a.appendEventLocked("info", "policy_local_update", "", "Lokale Policy über WebUI geändert") _ = a.store.save(a.state) sessions := make([]model.Session, 0, len(a.state.LastSessions)) for _, s := range a.state.LastSessions { sessions = append(sessions, s) } a.mu.Unlock() for _, s := range sessions { if s.SID != "" && s.User != "" { a.applyTemplates(s) } } httpx.JSON(w, 200, p) } func (a *App) sessionActionAPI(w http.ResponseWriter, r *http.Request) { if !httpx.SameOrigin(r) { httpx.Error(w, 403, "cross-origin request rejected") return } id64, err := strconv.ParseUint(r.PathValue("id"), 10, 32) if err != nil { httpx.Error(w, 400, "invalid session id") return } var req model.SessionActionRequest if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil { httpx.Error(w, 400, err.Error()) return } u, _ := auth.UserFrom(r) actor := u.Email if actor == "" { actor = u.Name } cmd := model.SessionCommand{ID: "local-" + newRevision(), Action: req.Action, SessionID: uint32(id64), Title: req.Title, Message: req.Message, RequestedBy: actor, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(5 * time.Minute)} res := a.executeSessionCommand(cmd) if !res.Success { httpx.Error(w, 409, res.Error) return } httpx.JSON(w, 200, res) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:") next.ServeHTTP(w, r) }) } func (a *App) agentPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store, max-age=0") _, _ = fmt.Fprint(w, agentHTML) }