230 lines
8.1 KiB
Go
230 lines
8.1 KiB
Go
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)))
|
||
}
|