mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-24 10:27:46 +02:00
feat: add TLS certificate expiry check to HTTPS network monitors (#2401)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
24
internal/migrations/1790193183_network_monitor_cert.go
Normal file
24
internal/migrations/1790193183_network_monitor_cert.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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<TData, TValue> {
|
||||
label?: string
|
||||
@@ -249,6 +252,31 @@ export function getMonitorColumns(
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cert",
|
||||
meta: { label: t`Certificate` },
|
||||
accessorFn: (record) => record.certInfo?.expires,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Certificate`} Icon={ShieldCheckIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const { certInfo, system } = row.original
|
||||
const systemRecord = useStore($allSystemsById)[system]
|
||||
|
||||
if (!certInfo?.expires) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
|
||||
const daysLeft = getCertDaysLeft(certInfo)
|
||||
const color = isMuted(row.original, systemRecord)
|
||||
? "bg-muted-foreground/50"
|
||||
: certExpiryDotColors[getCertExpiryLevel(daysLeft)]
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||
{daysLeft < 0 ? <Trans>Expired</Trans> : <Plural value={daysLeft} one="# day" other="# days" />}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
meta: { label: t`Updated` },
|
||||
|
||||
@@ -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 <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<ShieldCheckIcon className={cn("size-3.5 text-muted-foreground -me-1", certExpiryTextColors[level])} />
|
||||
<span className={certExpiryTextColors[level]}>
|
||||
{daysLeft < 0 ? (
|
||||
<Trans>Certificate expired {expires}</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Certificate expires {expires}
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
{cert.issuer && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<LandmarkIcon className="size-3.5 text-muted-foreground -me-0.5" />
|
||||
<span>{cert.issuer}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkMonitorSheetContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -683,7 +709,7 @@ function NetworkMonitorSheetContent({
|
||||
{system?.name ?? ""}
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
|
||||
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground -me-0.5" />
|
||||
{monitor.protocol.toUpperCase()}
|
||||
{monitor.protocol === "tcp" && monitor.port > 0 && (
|
||||
<>
|
||||
@@ -692,6 +718,7 @@ function NetworkMonitorSheetContent({
|
||||
<span>{monitor.port}</span>
|
||||
</>
|
||||
)}
|
||||
{monitor.certInfo?.expires ? <CertExpiry cert={monitor.certInfo} /> : null}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid gap-4">
|
||||
|
||||
@@ -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<NetworkMonitorRecord, "target" |
|
||||
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
|
||||
return `${host}:${monitor.port}`
|
||||
}
|
||||
|
||||
/** Whole days until the certificate expires; negative once expired. */
|
||||
export function getCertDaysLeft(cert: Pick<MonitorCertInfo, "expires">, 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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
8
internal/site/src/types.d.ts
vendored
8
internal/site/src/types.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user