From a99fe5e99725edf2a8ff582f5b21793fc88731f2 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Wed, 23 Sep 2026 23:07:29 +0200 Subject: [PATCH] feat: add TLS certificate expiry check to HTTPS network monitors (#2401) Co-authored-by: henrygd --- agent/network_monitor.go | 25 ++- agent/network_monitor_cert.go | 74 +++++++ agent/network_monitor_cert_test.go | 184 ++++++++++++++++++ agent/network_monitor_schedule.go | 3 + agent/network_monitor_task.go | 75 +++++++ internal/entities/monitor/monitor.go | 9 + internal/hub/network_monitors.go | 3 + internal/hub/network_monitors_test.go | 2 + .../hub/systems/network_monitor_stats_test.go | 46 +++++ internal/hub/systems/system.go | 16 +- .../1790193183_network_monitor_cert.go | 24 +++ .../network-monitors-columns.tsx | 32 ++- .../network-monitors-table.tsx | 49 +++-- .../site/src/lib/network-monitor-utils.ts | 14 +- internal/site/src/lib/use-network-monitors.ts | 2 +- internal/site/src/types.d.ts | 8 + 16 files changed, 547 insertions(+), 19 deletions(-) create mode 100644 agent/network_monitor_cert.go create mode 100644 agent/network_monitor_cert_test.go create mode 100644 internal/migrations/1790193183_network_monitor_cert.go diff --git a/agent/network_monitor.go b/agent/network_monitor.go index d5257675..5fa90839 100644 --- a/agent/network_monitor.go +++ b/agent/network_monitor.go @@ -15,6 +15,7 @@ type MonitorManager struct { mu sync.RWMutex monitors map[string]*monitorTask // keyed by monitor ID probe monitorProbe + certCheck certChecker resumeGuard monitorResumeGuard } @@ -23,7 +24,7 @@ func newMonitorManager() *MonitorManager { } func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager { - return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe} + return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert} } // SyncMonitors replaces all monitor tasks with the given configs. @@ -107,7 +108,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo if !runNow { return nil, nil } - return task.runProbe(pm.probe), nil + return pm.runNow(task), nil } if exists { task.cancel() @@ -119,7 +120,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo pm.mu.Unlock() if runNow { - result := task.runProbe(pm.probe) + result := pm.runNow(task) pm.startMonitor(task) return result, nil } @@ -127,6 +128,19 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo return nil, nil } +// runNow runs a probe and any due certificate check concurrently, so the +// response fits within the hub's single probe timeout budget. +func (pm *MonitorManager) runNow(task *monitorTask) *monitor.Result { + var wg sync.WaitGroup + wg.Go(func() { task.refreshCert(pm.certCheck) }) + result := task.runProbe(pm.probe) + wg.Wait() + if result != nil { + result.Cert = task.certInfo() + } + return result +} + // DeleteMonitor stops and removes a single monitor task. func (pm *MonitorManager) DeleteMonitor(id string) { if id == "" { @@ -158,6 +172,11 @@ func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Resul if !ok { continue } + // Only the default interval updates monitor records on the hub, so + // realtime requests must not consume the unsent certificate. + if durationMs == defaultDataCacheTimeMs { + result.Cert = task.takeUnsentCert() + } results[task.config.ID] = result } diff --git a/agent/network_monitor_cert.go b/agent/network_monitor_cert.go new file mode 100644 index 00000000..08dffed2 --- /dev/null +++ b/agent/network_monitor_cert.go @@ -0,0 +1,74 @@ +package agent + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + "github.com/henrygd/beszel/internal/entities/monitor" +) + +const ( + certCheckInterval = 24 * time.Hour + certCheckRetryInterval = time.Hour +) + +// certChecker fetches the leaf certificate for an HTTPS target. +type certChecker func(context.Context, string) (monitor.CertInfo, error) + +// certCheckEnabled reports whether a monitor's certificate is checked, which is +// the case for every HTTP monitor with an https target. +func certCheckEnabled(config monitor.Config) bool { + return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://") +} + +// checkCert reads the leaf certificate presented by an HTTPS target. The chain is +// not verified, so expired or self-signed certificates are still reported. +func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) { + address, host, err := certAddress(target) + if err != nil { + return monitor.CertInfo{}, err + } + ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout) + defer cancel() + dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return monitor.CertInfo{}, err + } + defer conn.Close() + certs := conn.(*tls.Conn).ConnectionState().PeerCertificates + if len(certs) == 0 { + return monitor.CertInfo{}, errors.New("no peer certificates") + } + leaf := certs[0] + return monitor.CertInfo{ + Expires: leaf.NotAfter.UnixMilli(), + Issuer: leaf.Issuer.CommonName, + }, nil +} + +// certAddress returns the dial address and server name for an HTTPS URL. +func certAddress(target string) (address, host string, err error) { + u, err := url.Parse(target) + if err != nil { + return "", "", err + } + if !strings.EqualFold(u.Scheme, "https") { + return "", "", fmt.Errorf("certificate check requires an https target: %s", target) + } + host = u.Hostname() + if host == "" { + return "", "", fmt.Errorf("missing host in target: %s", target) + } + port := u.Port() + if port == "" { + port = "443" + } + return net.JoinHostPort(host, port), host, nil +} diff --git a/agent/network_monitor_cert_test.go b/agent/network_monitor_cert_test.go new file mode 100644 index 00000000..80583316 --- /dev/null +++ b/agent/network_monitor_cert_test.go @@ -0,0 +1,184 @@ +//go:build testing + +package agent + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + "github.com/henrygd/beszel/internal/entities/monitor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckCertReadsUnverifiedLeaf(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + + // httptest uses a self-signed certificate, which must still be reported. + info, err := checkCert(context.Background(), server.URL) + require.NoError(t, err) + leaf := server.Certificate() + assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires) + assert.Equal(t, leaf.Issuer.CommonName, info.Issuer) +} + +func TestCertAddress(t *testing.T) { + tests := []struct { + target, address, host string + wantErr bool + }{ + {target: "https://example.com", address: "example.com:443", host: "example.com"}, + {target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"}, + {target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"}, + {target: "http://example.com", wantErr: true}, + {target: "https://", wantErr: true}, + } + for _, tt := range tests { + address, host, err := certAddress(tt.target) + if tt.wantErr { + assert.Error(t, err, tt.target) + continue + } + require.NoError(t, err, tt.target) + assert.Equal(t, tt.address, address) + assert.Equal(t, tt.host, host) + } +} + +func TestRefreshCertCadence(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"}) + defer task.cancel() + var calls int + var fail error + // Far enough out that the regular interval applies for the whole test. + expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli() + check := func(context.Context, string) (monitor.CertInfo, error) { + calls++ + if fail != nil { + return monitor.CertInfo{}, fail + } + return monitor.CertInfo{Expires: expires + int64(calls)}, nil + } + + task.refreshCert(check) + require.NotNil(t, task.certInfo()) + assert.Equal(t, expires+1, task.certInfo().Expires) + + // Not due again until the check interval passes. + time.Sleep(certCheckInterval - time.Second) + task.refreshCert(check) + assert.Equal(t, 1, calls) + time.Sleep(time.Second) + task.refreshCert(check) + assert.Equal(t, 2, calls) + + // Failures keep the last known certificate and retry sooner. + fail = errors.New("connection refused") + time.Sleep(certCheckInterval) + task.refreshCert(check) + assert.Equal(t, 3, calls) + assert.Equal(t, expires+2, task.certInfo().Expires) + time.Sleep(certCheckRetryInterval) + fail = nil + task.refreshCert(check) + assert.Equal(t, 4, calls) + assert.Equal(t, expires+4, task.certInfo().Expires) + }) +} + +func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) { + for _, tc := range []struct { + name string + expires time.Duration // relative to the check + interval time.Duration + }{ + {"expired", -time.Hour, certCheckRetryInterval}, + {"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval}, + {"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"}) + defer task.cancel() + var calls int + check := func(context.Context, string) (monitor.CertInfo, error) { + calls++ + return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil + } + task.refreshCert(check) + time.Sleep(tc.interval - time.Second) + task.refreshCert(check) + assert.Equal(t, 1, calls) + time.Sleep(time.Second) + task.refreshCert(check) + assert.Equal(t, 2, calls) + }) + }) + } +} + +func TestCertCheckEnabled(t *testing.T) { + tests := []struct { + protocol, target string + want bool + }{ + {"http", "https://example.com", true}, + {"http", "HTTPS://example.com:8443/path", true}, + {"http", "http://example.com", false}, + {"http", "https://", false}, + {"tcp", "https://example.com", false}, + {"icmp", "example.com", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target) + } +} + +func TestRefreshCertSkipsNonHTTPS(t *testing.T) { + task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"}) + defer task.cancel() + task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) { + t.Fatal("certificate check must not run for non-https targets") + return monitor.CertInfo{}, nil + }) + assert.Nil(t, task.certInfo()) +} + +func TestUpsertMonitorRunNowIncludesCert(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + + pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil }) + defer pm.Stop() + config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60} + result, err := pm.UpsertMonitor(config, true) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Cert) + assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires) + + // Realtime results never carry the certificate, and the default interval + // sends it only once per check. + assert.Nil(t, pm.GetResults(1000)["cert"].Cert) + results := pm.GetResults(defaultDataCacheTimeMs) + require.NotNil(t, results["cert"].Cert) + assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires) + assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert) + + // Changing the interval keeps the known certificate without resending it. + config.Interval = 30 + _, err = pm.UpsertMonitor(config, false) + require.NoError(t, err) + pm.mu.RLock() + task := pm.monitors["cert"] + pm.mu.RUnlock() + assert.NotNil(t, task.certInfo()) + assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert) +} diff --git a/agent/network_monitor_schedule.go b/agent/network_monitor_schedule.go index fa53c36c..4394215b 100644 --- a/agent/network_monitor_schedule.go +++ b/agent/network_monitor_schedule.go @@ -14,9 +14,12 @@ func (pm *MonitorManager) startMonitor(task *monitorTask) { } delay := getStagger(interval.Milliseconds()) slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval) + // Certificate checks piggyback on probe ticks, so they run at most once per + // probe interval after they become due. go runMonitorSchedule(task.ctx, interval, delay, func() { if _, allowed := task.resumeGuard.snapshot(); allowed { task.runProbe(pm.probe) + task.refreshCert(pm.certCheck) } }) } diff --git a/agent/network_monitor_task.go b/agent/network_monitor_task.go index 2c3469ab..3ff6ed8f 100644 --- a/agent/network_monitor_task.go +++ b/agent/network_monitor_task.go @@ -21,6 +21,12 @@ type monitorTask struct { runMu sync.Mutex inflight *monitorRun lastFailureLog int64 // Unix nanoseconds + + certMu sync.Mutex + cert *monitor.CertInfo + certUnsent bool // cert has not been included in a stats result yet + certChecking bool + nextCertCheck time.Time } type monitorRun struct { @@ -45,6 +51,11 @@ func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *m task := newMonitorTask(config) if existing != nil { task.history = existing.history.clone() + // Keep the last known certificate, but check again soon for the new config. + // The hub already stores it, so it is not marked unsent. + if config.Target == existing.config.Target { + task.cert = existing.certInfo() + } } return task } @@ -107,6 +118,70 @@ func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result { return copyMonitorResult(run.result) } +// refreshCert checks the certificate of an HTTPS target when due. A failed +// check keeps the last known certificate and retries sooner, as does a +// certificate that expires before the next regular check, so renewals show up +// quickly. Concurrent callers skip rather than wait, and no lock is held during +// network I/O. +func (task *monitorTask) refreshCert(check certChecker) { + if check == nil || !certCheckEnabled(task.config) { + return + } + task.certMu.Lock() + if task.certChecking || time.Now().Before(task.nextCertCheck) { + task.certMu.Unlock() + return + } + task.certChecking = true + task.certMu.Unlock() + + info, err := check(task.ctx, task.config.Target) + + task.certMu.Lock() + defer task.certMu.Unlock() + task.certChecking = false + if task.ctx.Err() != nil { + return + } + if err != nil { + task.nextCertCheck = time.Now().Add(certCheckRetryInterval) + slog.Warn("certificate check failed", "err", err, "target", task.config.Target) + return + } + task.cert = &info + task.certUnsent = true + now := time.Now() + interval := certCheckInterval + if time.UnixMilli(info.Expires).Before(now.Add(certCheckInterval)) { + interval = certCheckRetryInterval + } + task.nextCertCheck = now.Add(interval) +} + +// certInfo returns a copy of the latest certificate info, or nil if unknown. +func (task *monitorTask) certInfo() *monitor.CertInfo { + task.certMu.Lock() + defer task.certMu.Unlock() + if task.cert == nil { + return nil + } + cert := *task.cert + return &cert +} + +// takeUnsentCert returns the latest certificate info once after each successful +// check, so unchanged info is not resent with every stats result. +func (task *monitorTask) takeUnsentCert() *monitor.CertInfo { + task.certMu.Lock() + defer task.certMu.Unlock() + if !task.certUnsent { + return nil + } + task.certUnsent = false + cert := *task.cert + return &cert +} + func copyMonitorResult(result *monitor.Result) *monitor.Result { if result == nil { return nil diff --git a/internal/entities/monitor/monitor.go b/internal/entities/monitor/monitor.go index 46995ce8..c0f0915b 100644 --- a/internal/entities/monitor/monitor.go +++ b/internal/entities/monitor/monitor.go @@ -27,6 +27,13 @@ type Config struct { Interval uint16 `cbor:"4,keyasint"` // seconds } +// CertInfo holds details of the leaf TLS certificate presented by a target. +type CertInfo struct { + // Expires is the certificate's NotAfter Unix timestamp in milliseconds. + Expires int64 `cbor:"0,keyasint" json:"expires"` + Issuer string `cbor:"1,keyasint,omitempty" json:"issuer,omitempty"` +} + // SyncRequest defines an incremental or full monitor sync request sent to the agent. type SyncRequest struct { Action SyncAction `cbor:"0,keyasint"` @@ -76,6 +83,8 @@ type Result struct { TotalCount int64 `cbor:"10,keyasint"` SuccessCount int64 `cbor:"11,keyasint"` ResponseSum int64 `cbor:"12,keyasint"` + // Cert is set for HTTPS targets when a certificate check has new info the hub has not stored yet. + Cert *CertInfo `cbor:"13,keyasint,omitempty"` } // Stats holds response times in microseconds and packet loss percentage (0-100). diff --git a/internal/hub/network_monitors.go b/internal/hub/network_monitors.go index 25ad1b65..9569d010 100644 --- a/internal/hub/network_monitors.go +++ b/internal/hub/network_monitors.go @@ -114,6 +114,9 @@ func setMonitorResultFields(record *core.Record, result monitor.Result) { record.Set("resMin1h", result.MinResponse1h) record.Set("resMax1h", result.MaxResponse1h) record.Set("loss1h", result.PacketLoss1h) + if result.Cert != nil { + record.Set("certInfo", result.Cert) + } record.Set("updated", nowString) } diff --git a/internal/hub/network_monitors_test.go b/internal/hub/network_monitors_test.go index f206ff84..1907ebb5 100644 --- a/internal/hub/network_monitors_test.go +++ b/internal/hub/network_monitors_test.go @@ -206,6 +206,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) { "resMin1h": 900, "resMax1h": 1600, "loss1h": 5, + "certInfo": map[string]any{"expires": 1800000000000}, "updated": "2026-04-29 12:00:00.000Z", }) @@ -216,6 +217,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) { assert.Equal(t, "http", newRecord.GetString("protocol")) assert.Equal(t, 443, newRecord.GetInt("port")) assert.True(t, newRecord.GetBool("enabled")) + assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo")) assert.Zero(t, newRecord.GetFloat("res")) assert.Zero(t, newRecord.GetFloat("resAvg1h")) assert.Zero(t, newRecord.GetFloat("resMin1h")) diff --git a/internal/hub/systems/network_monitor_stats_test.go b/internal/hub/systems/network_monitor_stats_test.go index 38bc8cb0..f3b1572d 100644 --- a/internal/hub/systems/network_monitor_stats_test.go +++ b/internal/hub/systems/network_monitor_stats_test.go @@ -222,3 +222,49 @@ func TestNetworkMonitorAlertsAfterCommit(t *testing.T) { }) } } + +func TestNetworkMonitorCertPersistence(t *testing.T) { + for _, realtime := range []bool{false, true} { + name := "sql" + if realtime { + name = "realtime" + } + t.Run(name, func(t *testing.T) { + sys, app := newTestSystemWithHub(t) + if realtime { + client := subscriptions.NewDefaultClient() + client.Subscribe("network_monitors/*") + app.SubscriptionsBroker().Register(client) + t.Cleanup(func() { app.SubscriptionsBroker().Unregister(client.Id()) }) + } + col, err := app.FindCachedCollectionByNameOrId("network_monitors") + require.NoError(t, err) + record := core.NewRecord(col) + record.Id = "monitor1" + record.Set("system", sys.Id) + require.NoError(t, app.SaveNoValidate(record)) + + storedCert := func() monitor.CertInfo { + t.Helper() + record, err := app.FindRecordById("network_monitors", "monitor1") + require.NoError(t, err) + var cert monitor.CertInfo + require.NoError(t, record.UnmarshalJSONField("certInfo", &cert)) + return cert + } + cert := &monitor.CertInfo{Expires: 1_800_000_000_000, Issuer: "Test CA"} + _, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{ + "monitor1": {LastProbeAt: 1000, Cert: cert}, + }}) + require.NoError(t, err) + assert.Equal(t, *cert, storedCert()) + + // Results without cert info keep the stored certificate. + _, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{ + "monitor1": {LastProbeAt: 2000}, + }}) + require.NoError(t, err) + assert.Equal(t, *cert, storedCert()) + }) + } +} diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 32c604c1..a93d69e3 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -433,6 +433,8 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map for i, f := range monitorFields { setClauses[i] = fmt.Sprintf("%s={:%s}", f, f) } + // Results omit certInfo unless it changed, so keep the stored value. + setClauses = append(setClauses, "certInfo=COALESCE({:certInfo}, certInfo)") queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", monitorCollectionName, strings.Join(setClauses, ", ")) updateQuery = db.NewQuery(queryString) } @@ -453,11 +455,23 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map var record *core.Record record, err = app.FindRecordById(monitorCollectionName, id) if err == nil { + if result.Cert != nil { + monitorData["certInfo"] = result.Cert + } record.Load(monitorData) err = app.SaveNoValidate(record) } default: - _, err = updateQuery.Bind(dbx.Params(monitorData)).Execute() + monitorData["certInfo"] = nil + if result.Cert != nil { + var cert []byte + if cert, err = json.Marshal(result.Cert); err == nil { + monitorData["certInfo"] = string(cert) + } + } + if err == nil { + _, err = updateQuery.Bind(dbx.Params(monitorData)).Execute() + } } if err != nil { app.Logger().Warn("Failed to update monitor", "system", systemId, "monitor", id, "err", err) diff --git a/internal/migrations/1790193183_network_monitor_cert.go b/internal/migrations/1790193183_network_monitor_cert.go new file mode 100644 index 00000000..023c65bc --- /dev/null +++ b/internal/migrations/1790193183_network_monitor_cert.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("network_monitors") + if err != nil { + return err + } + collection.Fields.Add(&core.JSONField{Name: "certInfo"}) + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("network_monitors") + if err != nil { + return err + } + collection.Fields.RemoveByName("certInfo") + return app.Save(collection) + }) +} diff --git a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx index f0c4af0d..eebbb3a1 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx @@ -16,6 +16,7 @@ import { PlayCircleIcon, CopyIcon, CopyPlusIcon, + ShieldCheckIcon, } from "lucide-react" import { t } from "@lingui/core/macro" import type { NetworkMonitorRecord, SystemRecord } from "@/types" @@ -29,7 +30,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Trans } from "@lingui/react/macro" +import { Plural, Trans } from "@lingui/react/macro" import { $allSystemsById, $longestSystemName } from "@/lib/stores" import { useStore } from "@nanostores/react" import { SystemStatus } from "@/lib/enums" @@ -37,9 +38,11 @@ import { Checkbox } from "@/components/ui/checkbox" import { useMemo } from "react" import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog" import { Badge } from "../ui/badge" -import { getMonitorTarget } from "@/lib/network-monitor-utils" +import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils" import { pb } from "@/lib/api" +const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" } + declare module "@tanstack/react-table" { interface ColumnMeta { label?: string @@ -249,6 +252,31 @@ export function getMonitorColumns( ) }, }, + { + id: "cert", + meta: { label: t`Certificate` }, + accessorFn: (record) => record.certInfo?.expires, + header: ({ column }) => , + cell: ({ row }) => { + const { certInfo, system } = row.original + const systemRecord = useStore($allSystemsById)[system] + + if (!certInfo?.expires) { + return - + } + + const daysLeft = getCertDaysLeft(certInfo) + const color = isMuted(row.original, systemRecord) + ? "bg-muted-foreground/50" + : certExpiryDotColors[getCertExpiryLevel(daysLeft)] + return ( + + + {daysLeft < 0 ? Expired : } + + ) + }, + }, { id: "updated", meta: { label: t`Updated` }, diff --git a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx index 78f81efb..793862e3 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx @@ -1,6 +1,6 @@ -import { getMonitorTarget } from "@/lib/network-monitor-utils" +import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils" import { t } from "@lingui/core/macro" -import { Trans } from "@lingui/react/macro" +import { Plural, Trans } from "@lingui/react/macro" import { type ColumnFiltersState, flexRender, @@ -37,14 +37,8 @@ import { isReadOnlyUser, queueUserSettings } from "@/lib/api" import { pb } from "@/lib/api" import { SystemStatus } from "@/lib/enums" import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores" -import { - cn, - isVisuallyLonger, - matchesFilterGroups, - parseFilterGroups, - parseSemVer, -} from "@/lib/utils" -import type { ChartData, NetworkMonitorRecord } from "@/types" +import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils" +import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types" import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog" import { ArrowDownIcon, @@ -53,9 +47,11 @@ import { ArrowUpIcon, EthernetPortIcon, EyeIcon, + LandmarkIcon, LoaderCircleIcon, ServerIcon, Settings2Icon, + ShieldCheckIcon, XIcon, } from "lucide-react" import { @@ -636,6 +632,36 @@ function NetworkMonitorSheet({ return } +const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" } + +function CertExpiry({ cert }: { cert: MonitorCertInfo }) { + const daysLeft = getCertDaysLeft(cert) + const expires = formatShortDate(new Date(cert.expires).toISOString()) + const level = getCertExpiryLevel(daysLeft) + return ( + <> + + + + {daysLeft < 0 ? ( + Certificate expired {expires} + ) : ( + + Certificate expires {expires} + + )} + + {cert.issuer && ( + <> + + + {cert.issuer} + + )} + + ) +} + function NetworkMonitorSheetContent({ open, onOpenChange, @@ -683,7 +709,7 @@ function NetworkMonitorSheetContent({ {system?.name ?? ""} - + {monitor.protocol.toUpperCase()} {monitor.protocol === "tcp" && monitor.port > 0 && ( <> @@ -692,6 +718,7 @@ function NetworkMonitorSheetContent({ {monitor.port} )} + {monitor.certInfo?.expires ? : null}
diff --git a/internal/site/src/lib/network-monitor-utils.ts b/internal/site/src/lib/network-monitor-utils.ts index 9ab1303d..4f4e49f6 100644 --- a/internal/site/src/lib/network-monitor-utils.ts +++ b/internal/site/src/lib/network-monitor-utils.ts @@ -1,4 +1,4 @@ -import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types" +import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types" /** Derive chart metrics from the counts and response sum stored at every retention tier. */ export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats { @@ -15,3 +15,15 @@ export function getMonitorTarget(monitor: Pick, now = Date.now()) { + return Math.floor((cert.expires - now) / 86_400_000) +} + +/** Expiry severity used for certificate colors. */ +export function getCertExpiryLevel(daysLeft: number): "ok" | "warning" | "critical" { + if (daysLeft < 7) return "critical" + if (daysLeft < 14) return "warning" + return "ok" +} diff --git a/internal/site/src/lib/use-network-monitors.ts b/internal/site/src/lib/use-network-monitors.ts index 8b8e5014..7ecaf0c9 100644 --- a/internal/site/src/lib/use-network-monitors.ts +++ b/internal/site/src/lib/use-network-monitors.ts @@ -74,7 +74,7 @@ async function fetchMonitorStats( } const NETWORK_MONITOR_FIELDS = - "id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated" + "id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated" interface UseNetworkMonitorsProps { systemId?: string diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index a8c2a3db..7fcf0d84 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -652,9 +652,17 @@ export interface NetworkMonitorRecord { loss1h: number interval: number enabled: boolean + /** Latest TLS certificate details, reported for HTTPS targets. */ + certInfo?: MonitorCertInfo | null updated: string } +/** Leaf TLS certificate details reported by the agent. Timestamps are Unix milliseconds. */ +export interface MonitorCertInfo { + expires: number + issuer?: string +} + /** Response times in microseconds and packet loss percentage (0-100). */ export interface MonitorStats { res_avg: number