Files
edgeguard-native/cmd/edgeguard-scheduler/main.go
Debian 49e3e39ea6 fix(i18n): RemoteTargets S3/SFTP-Labels + NTP-Interface-Labels vollständig lokalisieren
RemoteTargets: alle 20+ hardkodierten Form-Labels und deutschen Hilfetexte
(S3: Endpoint/Region/Bucket/AccessKey/SecretKey/PathPrefix/HTTPS;
 SFTP: Host/Port/Username/Password/PrivateKey/RemoteDir/HostKeyFp)
sind jetzt in t()-Aufrufen mit remotes.s3.*/remotes.sftp.*-Keys.

NTP: Listen-Address-Dropdown-Labels (alle IPv4/IPv6-Interfaces, Loopback)
verwenden jetzt dns.settings.allIPv4/allIPv6/loopback (identisch zu DNS-Settings).
NTP-Pool-Adresse-Placeholder → t('ntp.pool.addressPlaceholder').

en/de: alle neuen Keys ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:52:13 +02:00

628 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// edgeguard-scheduler runs background jobs that don't belong on the
// API request path:
//
// - ACME cert renewal (every 6h, re-issues anything < 30d to expiry)
//
// Future jobs (cluster heartbeat, backup, audit-log retention)
// hang off the same Tick loop. Stays single-process — no leader
// election yet (Phase 3).
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strconv"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.86"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
// 6h is enough: LE renewal window is 30 days; missing one tick
// makes no difference. Hourly would log too much.
renewTickInterval = 6 * time.Hour
// certDir matches handlers.NewTLSCertsHandler default — HAProxy
// reads from this directory.
certDir = "/etc/edgeguard/tls"
// licenseTickInterval — daily re-verify against
// license.netcell-it.com. Result lands in the licenses table.
licenseTickInterval = 24 * time.Hour
// backupTickInterval — daily scheduled backup at ~03:00 (Tick
// alignment ist approximativ, weil time.Ticker bei Boot startet).
// Retention: 14 erfolgreiche Backups (default in backup.Service).
backupTickInterval = 24 * time.Hour
// staleSweepTickInterval — Phase 3.2: alle 30s prüfen ob Peers
// last_seen länger als staleThreshold nicht gemeldet haben →
// status='offline'. Symmetrisch zum 30s-API-Heartbeat.
staleSweepTickInterval = 30 * time.Second
// staleThreshold — Peer gilt als offline wenn last_seen älter als
// das ist. 4× Heartbeat-Intervall lässt einen verpassten Tick
// (Restart, GC-Pause, kurzer Network-Glitch) zu ohne false positive.
staleThreshold = 2 * time.Minute
// clusterCertCheckInterval — täglicher Check ob CA + peer-cert
// in den nächsten clusterCertWarnDays ablaufen. Bei Hit feuert
// ein Alert (dedupe 12h damit der Operator nicht alle 24h dieselbe
// Warnung sieht).
clusterCertCheckInterval = 24 * time.Hour
// clusterCertWarnDays — Schwelle für die Cert-Expiry-Warnung.
// Operator hat damit min. 30 Tage Vorlauf für `edgeguard-ctl
// cluster-renew-self` oder einen manuellen Re-Join.
clusterCertWarnDays = 30
// clusterCertAutoRenewDays — Schwelle ab der wir automatisch
// neu signieren (nur Founder mit lokaler CA). Wir liegen 2× vor
// der Warn-Schwelle damit ein verpasster Tick + ein verpasster
// Restart-Window noch passen.
clusterCertAutoRenewDays = 60
// diskCheckInterval — stündliche Disk-Usage-Prüfung. Fire-Schwellen
// in runDiskCheck (warning 80%, error 90%). Stündlich ist schnell
// genug damit der Operator vor /var = 100% noch Zeit zum Aufräumen
// hat, ohne Log-Spam zu produzieren (Dedupe 12h pro Severity).
diskCheckInterval = 1 * time.Hour
diskWarnPct = 80.0
diskCriticalPct = 90.0
// auditCleanupInterval — täglicher Cleanup. Audit-Rows älter als
// auditRetentionDays werden gelöscht. Idempotent — wenn nichts da
// ist passiert nichts.
auditCleanupInterval = 24 * time.Hour
auditRetentionDays = 90
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
slog.Info("edgeguard-scheduler starting", "version", version)
ctx := context.Background()
pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil {
slog.Error("scheduler: DB open failed — sleeping forever", "error", err)
select {}
}
defer pool.Close()
tlsRepo := tlscerts.New(pool)
setupStore := setup.NewStore(setup.DefaultDir)
st, _ := setupStore.Load()
var renewer *certrenewer.Service
if st != nil && st.ACMEEmail != "" {
issuer := acme.New(st.ACMEEmail)
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
slog.Info("scheduler: ACME renewer enabled",
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
} else {
slog.Warn("scheduler: setup.acme_email empty — ACME renewal disabled until setup wizard ran")
}
licRepo := licsvc.New(pool)
licClient := license.NewClient()
licKeyStore := license.NewKeyStore()
nodeID := os.Getenv("EDGEGUARD_NODE_ID")
slog.Info("scheduler: license re-verify enabled", "tick", licenseTickInterval)
backupSvc := backup.New(pool)
backupSvc.RemoteUploader = newSchedRemoteAdapter(backupremote.New(pool))
slog.Info("scheduler: daily backup enabled", "tick", backupTickInterval,
"dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN)
alertSvc := alerts.New(pool)
auditRepo := audit.New(pool)
alertDedupe := newDedupe(12 * time.Hour)
if renewer != nil {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
// die die API hat (gleiches /var/lib/edgeguard/node-id).
localID, _ := cluster.EnsureNodeID("")
slog.Info("scheduler: stale-sweeper enabled",
"tick", staleSweepTickInterval, "threshold", staleThreshold, "node_id", localID)
// Initial-Sweep + initial-Heartbeat damit /cluster/status nach
// Scheduler-Boot direkt einen frischen Zustand sieht.
runHeartbeat(ctx, pool, localID, version)
runStaleSweep(ctx, pool)
renewTick := time.NewTicker(renewTickInterval)
defer renewTick.Stop()
licTick := time.NewTicker(licenseTickInterval)
defer licTick.Stop()
backupTick := time.NewTicker(backupTickInterval)
defer backupTick.Stop()
sweepTick := time.NewTicker(staleSweepTickInterval)
defer sweepTick.Stop()
clusterCertTick := time.NewTicker(clusterCertCheckInterval)
defer clusterCertTick.Stop()
// Initial-Cert-Check direkt beim Start, sonst sieht der Operator
// einen Warning erst nach 24h ab Boot.
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
diskTick := time.NewTicker(diskCheckInterval)
defer diskTick.Stop()
// Initial-Disk-Check: wenn die Box schon bei 95% steht beim
// Scheduler-Boot, wollen wir keine Stunde auf den ersten Alert
// warten.
runDiskCheck(ctx, alertSvc, alertDedupe)
auditTick := time.NewTicker(auditCleanupInterval)
defer auditTick.Stop()
for {
select {
case <-renewTick.C:
if renewer != nil {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
case <-licTick.C:
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
case <-backupTick.C:
runBackup(ctx, backupSvc, version, alertSvc, setupStore)
case <-sweepTick.C:
// Symmetrisches Heartbeat aus dem Scheduler — falls die API
// pausiert/hängt, hält der Scheduler die eigene Row warm.
// Idempotent zur API-Heartbeat-Goroutine.
runHeartbeat(ctx, pool, localID, version)
runStaleSweep(ctx, pool)
case <-clusterCertTick.C:
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
case <-diskTick.C:
runDiskCheck(ctx, alertSvc, alertDedupe)
case <-auditTick.C:
runAuditCleanup(ctx, auditRepo, setupStore)
}
}
}
// runAuditCleanup löscht audit_log-Rows älter als die konfigurierte
// Retention. Operator kann den Wert in den Settings übersteuern; ohne
// Setup-Custom fällt's auf auditRetentionDays-Default zurück.
// Schutz vor unbounded growth — auf einer aktiven Box wird das Log
// nach 1-2 Jahren mehrere GB groß und macht die /logs-Page langsam.
// Best-effort: Fehler werden nur geloggt, der Tick läuft beim nächsten
// Zyklus wieder.
func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store) {
if r == nil {
return
}
keepDays := auditRetentionDays
if setupStore != nil {
if st, err := setupStore.Load(); err == nil && st != nil && st.AuditRetentionDays > 0 {
keepDays = st.AuditRetentionDays
}
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := r.Cleanup(cctx, keepDays)
if err != nil {
slog.Warn("scheduler: audit cleanup failed",
"keep_days", keepDays, "error", err)
return
}
if n > 0 {
slog.Info("scheduler: audit cleanup",
"deleted", n, "keep_days", keepDays)
}
}
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
// nächsten Backup-Run oder größeren apt-Update droht "no space
// left" und damit failed services.
// - >= 80% → Warning. Operator hat noch Luft aber sollte aufräumen.
// - < 80% → kein Alert.
//
// Dedupe-Keys pro Severity, damit ein lang-belegtes Filesystem nicht
// jede Stunde feuert (12h pro Stufe). Bei Übergang warning→critical
// gibt's einen frischen Alert weil die Keys verschieden sind.
//
// Fix-Hint im Body: was der Operator als Erstes prüfen soll
// (/var/backups/edgeguard, /var/log/edgeguard, /var/cache/apt).
func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err != nil {
slog.Warn("scheduler: disk-check statfs failed", "error", err)
return
}
total := float64(fs.Blocks) * float64(fs.Bsize)
free := float64(fs.Bavail) * float64(fs.Bsize)
if total <= 0 {
return
}
usedPct := (total - free) * 100 / total
freeGB := free / (1024 * 1024 * 1024)
totalGB := total / (1024 * 1024 * 1024)
var key, title string
var sev alerts.Severity
switch {
case usedPct >= diskCriticalPct:
key = "disk.full.critical"
sev = alerts.SeverityError
title = fmt.Sprintf("Disk kritisch voll: %.0f%%", usedPct)
case usedPct >= diskWarnPct:
key = "disk.full.warning"
sev = alerts.SeverityWarning
title = fmt.Sprintf("Disk-Belegung hoch: %.0f%%", usedPct)
default:
return
}
if !d.shouldFire(key) {
return
}
desc := fmt.Sprintf(
"Wurzel-Filesystem (/) ist zu %.1f%% belegt — noch %.2f GB von %.2f GB frei.\n\n"+
"Häufige Verursacher checken:\n"+
" sudo du -hs /var/backups/edgeguard /var/log/edgeguard /var/cache/apt /var/lib/postgresql\n\n"+
"Backup-Retention ist 14 (default). Manuell aufräumen:\n"+
" ls -lhS /var/backups/edgeguard | head\n"+
" sudo apt-get clean # /var/cache/apt leeren",
usedPct, freeGB, totalGB)
if _, err := a.Fire(ctx, "disk.full", sev, title, desc); err != nil {
slog.Warn("scheduler: disk-check alert fire failed", "error", err)
}
}
// runClusterCertExpiryCheck warnt wenn CA oder peer.crt in <
// clusterCertWarnDays Tagen ablaufen (oder schon abgelaufen sind).
// Dedupe pro Cert-Typ + 12h.
//
// Zusätzlich (Phase 1.1.1): wenn das peer.crt < clusterCertAutoRenewDays
// remaining hat UND eine lokale CA existiert, wird automatisch neu
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
store := clustertls.New("")
// Auto-Renew zuerst — danach lesen wir die (eventuell frischen)
// Cert-Infos für den Alert-Check ab.
tryAutoRenew(ctx, store, a, d)
check := func(kind, key string, info *clustertls.CertInfo, err error) {
if err != nil {
return // Cert nicht vorhanden / unleserlich — keine Warnung.
}
if info.DaysRemaining > clusterCertWarnDays {
return
}
if !d.shouldFire(key) {
return
}
sev := alerts.SeverityWarning
title := "Cluster-" + kind + " läuft bald ab"
desc := fmt.Sprintf("%s (CN=%s) läuft in %d Tagen ab (NotAfter=%s).",
kind, info.CommonName, info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
if info.DaysRemaining < 0 {
sev = alerts.SeverityError
title = "Cluster-" + kind + " ist abgelaufen"
desc = fmt.Sprintf("%s (CN=%s) ist seit %d Tagen abgelaufen (NotAfter=%s).",
kind, info.CommonName, -info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
}
desc += "\n\nFix: sudo edgeguard-ctl cluster-renew-self (founder/single-node)\n sudo systemctl restart edgeguard-api"
if _, err := a.Fire(ctx, "cluster.cert.expiring", sev, title, desc); err != nil {
slog.Warn("scheduler: cluster cert alert fire failed", "kind", kind, "error", err)
}
}
if store.HasCA() {
info, err := store.CACertInfo()
check("CA", "cluster.cert.expiring:ca", info, err)
}
if store.HasPeer() {
info, err := store.PeerCertInfo()
check("peer-Cert", "cluster.cert.expiring:peer", info, err)
}
}
// tryAutoRenew: wenn das peer.crt < clusterCertAutoRenewDays Tage
// remaining hat UND wir eine lokale CA haben (= Founder-Node), wird
// automatisch ein frisches peer.{crt,key} signiert. Edgeguard-api
// muss anschließend manuell restartet werden damit der Listener das
// neue Material lädt — wir alarmieren das, restarten aber nicht
// selbst (würde laufende Requests + die Heartbeat-Goroutine killen).
//
// Joiner-Nodes (keine eigene CA) ignorieren wir hier; sie laufen über
// einen anderen Renewal-Pfad (Phase 3.6, Renewal-Token via mTLS).
func tryAutoRenew(ctx context.Context, store *clustertls.Store, a *alerts.Service, d *dedupe) {
if !store.HasPeer() || !store.HasCA() {
return
}
info, err := store.PeerCertInfo()
if err != nil {
return
}
if info.DaysRemaining > clusterCertAutoRenewDays {
return
}
// CN aus dem alten Cert übernehmen — sonst würde ein Hostname-
// Wechsel mitten in der Renewal unbemerkt durchgehen.
cn := info.CommonName
if cn == "" {
cn = "edgeguard-node"
}
if err := store.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
slog.Warn("scheduler: cluster cert auto-renew failed", "error", err)
// Failure-Alert dedupe 12h — Operator soll daran erinnert werden.
if d.shouldFire("cluster.cert.auto_renew.failed") {
_, _ = a.Fire(ctx, "cluster.cert.auto_renew.failed",
alerts.SeverityError,
"Cluster-Peer-Cert Auto-Renew fehlgeschlagen",
"clustertls.RenewSelfSigned: "+err.Error()+
"\n\nFix: sudo edgeguard-ctl cluster-renew-self")
}
return
}
// Success — neuer Cert auf Disk, alter Cert noch im API-Speicher.
// Info-Alert mit Restart-Hinweis. Dedupe 24h damit nicht
// gefloodet wird wenn der Operator nicht restartet.
if d.shouldFire("cluster.cert.auto_renew.ok") {
fresh, _ := store.PeerCertInfo()
until := info.NotAfter.Format(time.RFC3339)
if fresh != nil {
until = fresh.NotAfter.Format(time.RFC3339)
}
_, _ = a.Fire(ctx, "cluster.cert.auto_renewed",
alerts.SeverityInfo,
"Cluster-Peer-Cert automatisch erneuert",
fmt.Sprintf("Neues Peer-Cert auf Disk (CN=%s, gültig bis %s). "+
"Damit edgeguard-api das neue Cert in den mTLS-Listener lädt:\n\n"+
" sudo systemctl restart edgeguard-api\n\n"+
"Bis dahin nutzt der laufende Prozess das alte Cert.", cn, until))
}
slog.Info("scheduler: cluster peer cert auto-renewed", "cn", cn,
"old_days_remaining", info.DaysRemaining)
}
// dedupe verhindert dass derselbe Alert-Key (z.B. "cert.expiring:utm-1.netcell-it.de")
// öfter als alle 12h gefeuert wird. In-memory — Scheduler-Restart
// resettet, was OK ist (Operator soll bei restart wieder einen kennen-
// lernen-Event sehen können).
type dedupe struct {
ttl time.Duration
last map[string]time.Time
}
func newDedupe(ttl time.Duration) *dedupe { return &dedupe{ttl: ttl, last: map[string]time.Time{}} }
func (d *dedupe) shouldFire(key string) bool {
now := time.Now()
if last, ok := d.last[key]; ok && now.Sub(last) < d.ttl {
return false
}
d.last[key] = now
return true
}
// runCertExpiryCheck prüft tls_certs auf bevorstehende Expiry. Warning
// bei <14d Restzeit. Dedupe pro Cert-Name 12h damit der scheduler
// nicht alle 6h dieselbe Warnung feuert.
func runCertExpiryCheck(ctx context.Context, repo *tlscerts.Repo,
a *alerts.Service, d *dedupe) {
if repo == nil || a == nil {
return
}
certs, err := repo.List(ctx)
if err != nil {
slog.Warn("scheduler: cert-expiry list failed", "error", err)
return
}
threshold := 14 * 24 * time.Hour
now := time.Now()
for _, c := range certs {
if c.NotAfter == nil {
continue
}
remain := c.NotAfter.Sub(now)
if remain > threshold || remain < -90*24*time.Hour {
continue
}
key := "cert.expiring:" + c.Domain
if !d.shouldFire(key) {
continue
}
days := int(remain.Hours() / 24)
sev := alerts.SeverityWarning
if days < 3 {
sev = alerts.SeverityError
}
_, err := a.Fire(ctx, "cert.expiring", sev,
"TLS-Zertifikat läuft ab: "+c.Domain,
"Cert für "+c.Domain+" läuft in "+strconv.Itoa(days)+" Tagen ab ("+c.NotAfter.Format(time.RFC3339)+"). Renewer-Status: "+c.Status)
if err != nil {
slog.Warn("scheduler: alert fire failed", "error", err)
}
}
}
// runHeartbeat schreibt last_seen + status=online + version + config_hash
// auf die eigene ha_nodes-Row. Pool kann nil sein (scheduler-pool-fail
// beim Boot) — dann no-op. Errors landen im WARN, kein Abort der Schleife.
func runHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, version string) {
if pool == nil || localID == "" {
return
}
hbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := cluster.Heartbeat(hbCtx, pool, localID, version); err != nil {
slog.Warn("scheduler: heartbeat failed", "error", err)
}
}
// runStaleSweep markiert Peers mit last_seen < NOW()-staleThreshold als
// offline. Logged nur wenn Rows betroffen sind (sonst floodet das Log
// mit "0 rows" alle 30s).
func runStaleSweep(ctx context.Context, pool *pgxpoolPool) {
if pool == nil {
return
}
swCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
flipped, err := cluster.SweepStaleNodes(swCtx, pool, staleThreshold)
if err != nil {
slog.Warn("scheduler: stale-sweep failed", "error", err)
return
}
if flipped > 0 {
slog.Info("scheduler: marked stale peers offline",
"count", flipped, "threshold", staleThreshold)
}
}
// pgxpoolPool ist ein lokaler Alias damit die Signatur stabil bleibt
// wenn wir später den pool austauschen wollen (z.B. read-only-replica).
type pgxpoolPool = pgxpool.Pool
// runBackup führt einen scheduled Backup aus + prunet alte. Failures
// loggen wir + alarmieren — verlorene Backups sind kritisch.
func runBackup(ctx context.Context, svc *backup.Service, version string, a *alerts.Service, setupStore *setup.Store) {
res, err := svc.Run(ctx, backup.KindScheduled, version)
if err != nil {
slog.Warn("scheduler: backup failed", "error", err, "file", res.File)
if a != nil {
_, _ = a.Fire(ctx, "backup.failed", alerts.SeverityError,
"Backup fehlgeschlagen",
"Scheduled Backup konnte nicht erstellt werden: "+err.Error())
}
return
}
slog.Info("scheduler: backup done",
"file", res.File, "size", res.SizeBytes,
"db_bytes", res.DBDumpBytes, "files_bytes", res.FilesBytes,
"sha256", res.SHA256)
keepN := backup.DefaultKeepN
if setupStore != nil {
if st, err := setupStore.Load(); err == nil && st != nil && st.BackupRetentionKeep > 0 {
keepN = st.BackupRetentionKeep
}
}
if err := svc.Prune(ctx, keepN); err != nil {
slog.Warn("scheduler: backup prune failed", "error", err)
}
}
// runLicenseVerify performs a single re-verify pass. Empty key = no-op
// (box stays in trial), so this is safe to call on every tick.
// Bei valid:false-Antwort + Stand >7d alt → Warnung an Alerts.
func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeyStore,
repo *licsvc.Repo, nodeID string, a *alerts.Service, d *dedupe) {
key := ks.Get()
if key == "" {
slog.Debug("scheduler: license verify skipped — no key")
return
}
res, err := c.Verify(key)
if err != nil {
_ = repo.MarkError(ctx, key, err.Error())
slog.Warn("scheduler: license verify failed", "error", err)
return
}
payload, _ := json.Marshal(res)
status := "active"
if !res.Valid {
status = "expired"
if res.Status == "revoked" {
status = "invalid"
}
}
if err := repo.Upsert(ctx, key, status, res.ExpiresAt, nodeID, 0, payload, ""); err != nil {
slog.Warn("scheduler: license db upsert failed", "error", err)
return
}
slog.Info("scheduler: license verified",
"status", status, "valid", res.Valid, "expires_at", res.ExpiresAt)
// Alarm bei ungültiger Lizenz (revoked, expired) — dedupe 12h damit
// der Operator nicht alle 24h denselben Alert bekommt.
if a != nil && d != nil && !res.Valid {
if d.shouldFire("license.invalid") {
_, _ = a.Fire(ctx, "license.invalid", alerts.SeverityError,
"License "+status,
"License-Server liefert valid=false. Reason: "+res.Reason)
}
}
}
func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service, d *dedupe) {
res, err := r.Run(ctx)
if err != nil {
slog.Error("scheduler: renewer run failed", "error", err)
if a != nil && d != nil && d.shouldFire("cert.renewer.run_failed") {
_, _ = a.Fire(ctx, "cert.renewer.run_failed", alerts.SeverityError,
"ACME-Renewer-Lauf fehlgeschlagen",
"Certrenewer-Cycle abgebrochen: "+err.Error())
}
return
}
slog.Info("scheduler: renewer pass complete",
"checked", res.Checked, "renewed", res.Renewed,
"failed", res.Failed, "skipped", res.Skipped)
if a != nil && res.Failed > 0 && d != nil && d.shouldFire("cert.renew_failed") {
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal teilweise fehlgeschlagen",
fmt.Sprintf("Renewer-Cycle: %d checked, %d renewed, %d failed, %d skipped",
res.Checked, res.Renewed, res.Failed, res.Skipped))
}
}
// schedRemoteAdapter ist die scheduler-seitige Kopie des Adapters
// aus edgeguard-api — gleicher Code, separater Type damit kein
// Cross-Binary-Import nötig wird.
type schedRemoteAdapter struct{ s *backupremote.Service }
func newSchedRemoteAdapter(s *backupremote.Service) backup.RemoteUploader {
return schedRemoteAdapter{s: s}
}
func (a schedRemoteAdapter) UploadAll(ctx context.Context, localPath string) ([]backup.RemoteUploadInfo, error) {
res, err := a.s.UploadAll(ctx, localPath)
out := make([]backup.RemoteUploadInfo, len(res))
for i, r := range res {
out[i] = backup.RemoteUploadInfo{
RemoteID: r.RemoteID,
RemoteName: r.RemoteName,
OK: r.OK,
SizeBytes: r.SizeBytes,
DurationMs: r.DurationMs,
Error: r.Error,
}
}
return out, err
}