Update mit Guacamole-Extension
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+418
-12
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);`
|
||||
|
||||
Reference in New Issue
Block a user