software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
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)))
}