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"` 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"` AuthSessions map[string]model.AuthSession `json:"auth_sessions,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 { mu sync.RWMutex data data backend persistence persistErr error } 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{}, AuthSessions: map[string]model.AuthSession{}, } } 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 } 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{} } 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{} } if s.data.AuthSessions == nil { s.data.AuthSessions = map[string]model.AuthSession{} } 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) all() []model.AgentRecord { s.mu.RLock() defer s.mu.RUnlock() out := make([]model.AgentRecord, 0, len(s.data.Agents)) for _, a := range s.data.Agents { a.TokenHash = "" out = append(out, a) } return out } func (s *store) get(id string) (model.AgentRecord, bool) { s.mu.RLock() defer s.mu.RUnlock() a, ok := s.data.Agents[id] 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] } }