package alerts import ( "context" "linkport/internal/model" "linkport/internal/store" "time" ) type conditionSink func(store.Alert, bool) error // conditionEvaluator owns rule state; persistence and notification stay with Engine. // Engine serializes evaluations so authentication windows retain their baseline. type conditionEvaluator struct { source Source samples map[string][]sample } type sample struct { at time.Time count int64 } func (r *conditionEvaluator) evaluate(ctx context.Context, c store.AlertSettings, ps []model.Proxy, ts []model.Target, now time.Time, put conditionSink) error { var err error targets := map[string]bool{} for _, t := range ts { targets[t.ID] = true } _, health := r.source.HealthSnapshot() for _, h := range health { if !targets[h.TargetID] { continue } a := store.Alert{Key: "target_unavailable:" + h.TargetID + ":" + h.AccountID, Kind: "target_unavailable", ResourceID: h.TargetID, AccountID: h.AccountID, Severity: "critical", Message: "Target health check failed"} if err = put(a, !h.Healthy); err != nil { return err } } for _, p := range ps { if err = ctx.Err(); err != nil { return err } count := r.source.Metrics(p.ID).AuthenticationFailed samples := r.samples[p.ID] if len(samples) > 0 && count < samples[len(samples)-1].count { samples = nil } samples = append(samples, sample{now, count}) cut := now.Add(-time.Duration(c.AuthFailureWindowSeconds) * time.Second) // Keep the most recent baseline at or before the window boundary. for len(samples) > 1 && !samples[1].at.After(cut) { samples = samples[1:] } r.samples[p.ID] = samples delta := count - samples[0].count if err = put(store.Alert{Key: "auth_failure_spike:" + p.ID, Kind: "auth_failure_spike", ResourceID: p.ID, Severity: "warning", Message: "Gateway authentication failures exceeded the configured window threshold"}, delta >= int64(c.AuthFailureThreshold)); err != nil { return err } for _, a := range p.GatewayAccounts { if err = credential(put, "gateway", p.ID, a.ID, a.ExpiresAt, a.RotationReminderDays, a.Disabled, now); err != nil { return err } } } for _, t := range ts { for _, a := range t.Accounts { if err = credential(put, "target", t.ID, a.ID, a.ExpiresAt, a.RotationReminderDays, false, now); err != nil { return err } } } if certs, ok := r.source.(interface{ CertificateExpirations() map[string]time.Time }); ok { for id, expiry := range certs.CertificateExpirations() { a := store.Alert{Key: "certificate_expiry:" + id, Kind: "certificate_expiring", ResourceID: id, Severity: "warning", Message: "TLS certificate expires soon"} if !expiry.After(now) { a.Kind = "certificate_expired" a.Severity = "critical" a.Message = "TLS certificate has expired" } if err = put(a, !expiry.After(now.Add(time.Duration(c.CertificateReminderDays)*24*time.Hour))); err != nil { return err } } } return nil } func evaluateAssetCertificates(assets []model.Asset, c store.AlertSettings, now time.Time, put conditionSink) error { var err error for _, asset := range assets { summary := asset.Summary(now) expiry, parseErr := time.Parse(time.RFC3339, summary.ExpiresAt) if parseErr != nil { continue } a := store.Alert{Key: "asset_certificate_expiry:" + asset.ID, Kind: "certificate_expiring", ResourceID: asset.ID, Severity: "warning", Message: "Asset certificate expires soon; upload a renewed certificate"} if !expiry.After(now) { a.Kind = "certificate_expired" a.Severity = "critical" a.Message = "Asset certificate has expired; upload a renewed certificate" } if err = put(a, !expiry.After(now.Add(time.Duration(c.CertificateReminderDays)*24*time.Hour))); err != nil { return err } } return nil } func credential(put func(store.Alert, bool) error, scope, id, account, expiry string, days int, disabled bool, now time.Time) error { due, expired := model.CredentialDue(expiry, days, now) a := store.Alert{Key: "credential:" + scope + ":" + id + ":" + account, Kind: "credential_expiring", ResourceID: id, AccountID: account, Severity: "warning", Message: "Account credential expires soon; rotate it before expiry"} if expired { a.Kind = "credential_expired" a.Severity = "critical" a.Message = "Account credential has expired" } return put(a, due && !disabled) }