Files
beszel/internal/alerts/network_monitor_cache.go
Mike BogdanandGitHub e984ee1208 feat(alerts): add TLS certificate expiry alert for network monitors (#2627)
Adds a NetworkMonitorCert alert with a threshold in days, evaluated on the hub from the certInfo stored by #2401. Follows the NetworkMonitorLoss alert: per-monitor incidents, alert history, recovery notifications.

Refs #391
2026-10-17 13:40:19 -04:00

105 lines
3.5 KiB
Go

package alerts
import (
"encoding/json"
"sync"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
// cachedMonitor holds the monitor fields needed for the alert fast path.
type cachedMonitor struct {
interval int
// certExpires is the stored certificate expiry in Unix milliseconds, or zero if unknown.
certExpires int64
}
// networkMonitorCache keeps just the enabled monitor IDs, probe intervals, and
// certificate expiry times needed for the alert fast path. Names and targets are
// read only on transitions. Returned maps are immutable; configuration and
// certificate changes invalidate the whole entry.
type networkMonitorCache struct {
app core.App
mu sync.RWMutex
systems map[string]map[string]cachedMonitor
}
func newNetworkMonitorCache(app core.App) *networkMonitorCache {
c := &networkMonitorCache{app: app, systems: make(map[string]map[string]cachedMonitor)}
invalidate := func(e *core.RecordEvent) error {
c.invalidate(e.Record.GetString("system"))
return e.Next()
}
app.OnRecordAfterCreateSuccess("network_monitors").BindFunc(invalidate)
app.OnRecordAfterDeleteSuccess("network_monitors").BindFunc(invalidate)
app.OnRecordAfterUpdateSuccess("network_monitors").BindFunc(func(e *core.RecordEvent) error {
old := e.Record.Original()
// Realtime metric saves also invoke this hook. They must not evict config.
if old.GetString("system") != e.Record.GetString("system") ||
old.GetBool("enabled") != e.Record.GetBool("enabled") ||
old.GetInt("interval") != e.Record.GetInt("interval") ||
old.GetString("certInfo") != e.Record.GetString("certInfo") {
c.invalidate(old.GetString("system"))
c.invalidate(e.Record.GetString("system"))
}
return e.Next()
})
app.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
c.invalidate(e.Record.Id)
return e.Next()
})
return c
}
func (c *networkMonitorCache) invalidate(systemID string) {
c.mu.Lock()
delete(c.systems, systemID)
c.mu.Unlock()
}
// certChanged reports whether a cached monitor's certificate expiry differs from
// expires. Systems that are not cached load fresh data when next needed.
func (c *networkMonitorCache) certChanged(systemID, monitorID string, expires int64) bool {
c.mu.RLock()
defer c.mu.RUnlock()
m, ok := c.systems[systemID][monitorID]
return ok && m.certExpires != expires
}
func (c *networkMonitorCache) get(systemID string) (map[string]cachedMonitor, error) {
c.mu.RLock()
monitors, ok := c.systems[systemID]
c.mu.RUnlock()
if ok {
return monitors, nil
}
c.mu.Lock()
defer c.mu.Unlock()
if monitors, ok := c.systems[systemID]; ok {
return monitors, nil
}
// Keep the lock through the load so a concurrent config change cannot be
// invalidated first and then overwritten by the older query result.
var rows []struct {
ID string `db:"id"`
Interval int `db:"interval"`
CertInfo types.JSONRaw `db:"certInfo"`
}
if err := c.app.DB().Select("id", "interval", "certInfo").From("network_monitors").
Where(dbx.HashExp{"system": systemID, "enabled": true}).All(&rows); err != nil {
return nil, err
}
monitors = make(map[string]cachedMonitor, len(rows))
for _, row := range rows {
var cert monitor.CertInfo
// Missing or malformed info leaves the expiry unknown.
_ = json.Unmarshal(row.CertInfo, &cert)
monitors[row.ID] = cachedMonitor{interval: row.Interval, certExpires: cert.Expires}
}
c.systems[systemID] = monitors
return monitors, nil
}