package server import ( "context" "fmt" "net/http" "time" "sultan/pkg/checkmk" "sultan/pkg/notify" "sultan/pkg/policy" "sultan/pkg/store" ) // retention: Aufbewahrung von Einladungen, abgelaufenen Leases und // zugestellten Befehlen (entspricht der Community-Lease-Laufzeit). const retention = 10 * 24 * time.Hour func commercial(t store.Tenant) bool { return t.Tier != store.TierCommunity } // runJobs läuft bei jedem Refresh: Vertragsablauf, Benachrichtigungen, // Aufräumen. func (s *Server) runJobs(ctx context.Context) { now := time.Now() tenants, err := s.store.ListTenants(ctx) if err != nil { s.log.Error("Job: Mandanten lesen", "err", err) return } for _, t := range tenants { s.expireContract(ctx, t, now) if s.notifier != nil && commercial(t) { s.notifyContract(ctx, t, now) s.notifyDunning(ctx, t, now) } } if s.notifier != nil { s.notifyOffline(ctx, now) } s.housekeeping(ctx) } // expireContract setzt kommerzielle Verträge ab dem Ablaufzeitpunkt dauerhaft // auf expired (Community wird automatisch neu lizenziert). func (s *Server) expireContract(ctx context.Context, t store.Tenant, now time.Time) { if !commercial(t) || t.Status == store.TenantExpired || t.Status == store.TenantQuarantined || now.Before(t.ValidUntil) { return } if err := s.store.SetTenantStatus(ctx, t.ID, store.TenantExpired); err != nil { s.log.Error("Job: Vertrag auf expired setzen", "tenant", t.ID, "err", err) return } s.log.Warn("Vertrag abgelaufen – Mandant auf expired gesetzt", "tenant", t.ID, "valid_until", t.ValidUntil) } func (s *Server) notifyContract(ctx context.Context, t store.Tenant, now time.Time) { day := t.ValidUntil.UTC().Format(time.DateOnly) days := int(t.ValidUntil.Sub(now).Hours() / 24) switch { case t.Status == store.TenantExpired || !now.Before(t.ValidUntil): s.sendOnce(ctx, "contract:"+t.ID+":"+day+":0", notify.Message{ Severity: notify.Critical, Title: "Vertrag " + t.ID + " abgelaufen", Body: fmt.Sprintf("Der Vertrag von *%s* ist am %s abgelaufen. Die Seiten zeigen die Landingpage.", t.ID, day), }) case days <= checkmk.ContractCritDays: s.sendOnce(ctx, "contract:"+t.ID+":"+day+":14", notify.Message{ Severity: notify.Critical, Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days), Body: fmt.Sprintf("Vertragsende für *%s*: %s. Verlängerung erforderlich.", t.ID, day), }) case days <= checkmk.ContractWarnDays: s.sendOnce(ctx, "contract:"+t.ID+":"+day+":30", notify.Message{ Severity: notify.Warning, Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days), Body: fmt.Sprintf("Vertragsende für *%s*: %s.", t.ID, day), }) } } func (s *Server) notifyDunning(ctx context.Context, t store.Tenant, now time.Time) { level := policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now) if level == 0 { return } sev := notify.Warning if level >= 2 { sev = notify.Critical } text := map[int]string{1: "Hinweisseite mit 5 s Weiterleitung", 2: "Hinweisseite mit 10 s Weiterleitung", 3: "Kunde offline (Einladungsseite)"}[min(level, 3)] s.sendOnce(ctx, fmt.Sprintf("dunning:%s:%s:%d", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), level), notify.Message{ Severity: sev, Title: fmt.Sprintf("Mahnstufe %d für %s", level, t.ID), Body: fmt.Sprintf("Zahlung von *%s* seit %s offen. Wirkung: %s.", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), text), }) } // notifyOffline alarmiert einmal pro Ausfall, wenn ein Knoten länger als // OfflineAlertAfter getrennt ist – nicht bei angemeldeter Wartung oder drain. func (s *Server) notifyOffline(ctx context.Context, now time.Time) { nodes, err := s.store.ListNodes(ctx, "") if err != nil { return } for _, n := range nodes { if n.Status != store.NodeOffline || n.Maintenance.Active(now) || now.Sub(n.LastSeen) < s.cfg.OfflineAlertAfter { continue } s.sendOnce(ctx, "offline:"+n.ID+":"+n.LastSeen.UTC().Format(time.RFC3339Nano), notify.Message{ Severity: notify.Critical, Title: "Knoten " + n.ID + " offline", Body: fmt.Sprintf("Knoten *%s* (Mandant %s, %s) ist seit %s getrennt. Besucher sehen die Wartungsseite, solange die Lease gilt.", n.ID, n.TenantID, n.Hostname, n.LastSeen.Local().Format("2006-01-02 15:04")), }) } } // notifyBackOnline meldet „wieder online“ – nur für Ausfälle, zu denen ein // Offline-Alarm verschickt wurde, und je Ausfall genau einmal. func (s *Server) notifyBackOnline(nodeID string) { ctx := context.Background() prefix := "offline:" + nodeID + ":" keys, err := s.store.NotificationKeys(ctx, prefix) if err != nil { return } for _, k := range keys { msg := notify.Message{Severity: notify.Info, Title: "Knoten " + nodeID + " wieder online"} if since, err := time.Parse(time.RFC3339Nano, k[len(prefix):]); err == nil { msg.Body = fmt.Sprintf("Knoten *%s* ist nach %s wieder verbunden.", nodeID, outage(time.Since(since))) } else { msg.Body = fmt.Sprintf("Knoten *%s* ist wieder verbunden.", nodeID) } s.sendOnce(ctx, "recovered:"+k, msg) } } func outage(d time.Duration) string { switch { case d < time.Minute: return fmt.Sprintf("%d s", int(d/time.Second)) case d < time.Hour: return fmt.Sprintf("%d min", int(d/time.Minute)) default: return fmt.Sprintf("%d h %d min", int(d/time.Hour), int(d%time.Hour/time.Minute)) } } // sendOnce verschickt eine Nachricht genau einmal je Schlüssel; scheitert // der Versand, wird es beim nächsten Durchlauf erneut versucht. func (s *Server) sendOnce(ctx context.Context, key string, m notify.Message) { if s.store.Notified(ctx, key) { return } if err := s.notifier.Send(ctx, m); err != nil { s.log.Warn("Benachrichtigung nicht zugestellt – nächster Versuch beim nächsten Durchlauf", "key", key, "err", err) return } if err := s.store.RecordNotification(ctx, key); err != nil { s.log.Error("Benachrichtigung vermerken", "key", key, "err", err) } } func (s *Server) housekeeping(ctx context.Context) { for name, purge := range map[string]func(context.Context, time.Duration) (int64, error){ "Einladungen": s.store.PurgeEnrollmentTokens, "Leases": s.store.PurgeLeases, "Befehle": s.store.PurgeNodeCommands, } { if n, err := purge(ctx, retention); err != nil { s.log.Error("Aufräumen", "was", name, "err", err) } else if n > 0 { s.log.Info("Aufgeräumt", "was", name, "anzahl", n) } } } // recordViolation zählt Verstöße für Checkmk (letzte 24 h, im Speicher). func (s *Server) recordViolation(at time.Time) { s.vmu.Lock() defer s.vmu.Unlock() s.violations = append(s.violations, at) } func (s *Server) violations24h(now time.Time) int { s.vmu.Lock() defer s.vmu.Unlock() cutoff := now.Add(-24 * time.Hour) i := 0 for i < len(s.violations) && s.violations[i].Before(cutoff) { i++ } s.violations = s.violations[i:] return len(s.violations) } // handleCheckmk liefert den Zustand im Checkmk-Local-Check-Format. func (s *Server) handleCheckmk(w http.ResponseWriter, r *http.Request) { ctx, now := r.Context(), time.Now() state := checkmk.State{Violations24h: s.violations24h(now), OfflineCrit: s.cfg.OfflineAlertAfter, Location: time.Local} tenants, err := s.store.ListTenants(ctx) if err != nil { http.Error(w, "interner Fehler", http.StatusInternalServerError) return } for _, t := range tenants { state.Tenants = append(state.Tenants, checkmk.Tenant{ ID: t.ID, Commercial: commercial(t), Status: string(t.Status), ValidUntil: t.ValidUntil, DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now), OverdueSince: t.PaymentOverdueSince, }) } nodes, err := s.store.ListNodes(ctx, "") if err != nil { http.Error(w, "interner Fehler", http.StatusInternalServerError) return } for _, n := range nodes { cn := checkmk.Node{ID: n.ID, Status: string(n.Status), OfflineSince: n.LastSeen} if n.Maintenance.Active(now) { cn.MaintenanceActive, cn.MaintenanceUntil, cn.MaintenanceReason = true, n.Maintenance.Until, n.Maintenance.Reason } if l, err := s.store.GetLease(ctx, n.ID); err == nil { cn.LeaseUntil = l.ValidUntil } state.Nodes = append(state.Nodes, cn) } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte(checkmk.Render(state, now))) }