2026-06-25 19:37:35 +01:00
|
|
|
# services/hardware-health.nix — RAS error attribution + watchdog auto-recovery
|
|
|
|
|
#
|
|
|
|
|
# Context: Jun 2026 the dual Xeon E5-2697 v3 began throwing a storm of
|
|
|
|
|
# *corrected* Machine Check Exceptions on both sockets (Bank 5 / Bank 20),
|
|
|
|
|
# ~18k events in 36h, eventually hanging the box. Since this host is the
|
|
|
|
|
# router, a hang takes the whole LAN offline until a manual power-cycle.
|
|
|
|
|
#
|
|
|
|
|
# This module:
|
|
|
|
|
# - rasdaemon: decodes every MCE to a specific DIMM/channel/socket and
|
|
|
|
|
# persists a per-component error DB, so a failing part can be named
|
|
|
|
|
# (needed for the seller's warranty claim). Query with `ras-mc-ctl
|
|
|
|
|
# --error-count` and `ras-mc-ctl --summary`.
|
|
|
|
|
# - hardware watchdog: if userspace hangs again, systemd stops petting
|
|
|
|
|
# /dev/watchdog0 and the chipset watchdog reboots the box (~30s),
|
|
|
|
|
# restoring the LAN without physical access.
|
2026-07-22 10:45:09 +01:00
|
|
|
# - mce-monitor: after the Jul 2026 CPU repaste/reseat, watch for the QPI
|
|
|
|
|
# fault returning. Baselines at the current rasdaemon count (so only NEW
|
|
|
|
|
# errors count) and pushes an ntfy alert on geometric thresholds — the
|
|
|
|
|
# 1st, 10th, 100th, 1000th... new error — so the first one pings
|
|
|
|
|
# immediately and a storm spaces out instead of thousands of pushes.
|
|
|
|
|
# Reuses the /var/secrets/ntfy-url topic (see services/service-health.nix).
|
2026-06-25 19:37:35 +01:00
|
|
|
|
|
|
|
|
{ config, lib, pkgs, ... }:
|
2026-07-22 10:45:09 +01:00
|
|
|
let
|
|
|
|
|
mceMonitor = pkgs.writeShellScript "mce-monitor" ''
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
host="${config.networking.hostName}"
|
|
|
|
|
secret=/var/secrets/ntfy-url
|
|
|
|
|
state=/var/lib/mce-monitor/state
|
|
|
|
|
rasctl=${pkgs.rasdaemon}/bin/ras-mc-ctl
|
|
|
|
|
grep=${pkgs.gnugrep}/bin/grep
|
|
|
|
|
|
|
|
|
|
# Cumulative decoded-MCE count from rasdaemon's DB (persists across reboots).
|
|
|
|
|
current=$($rasctl --errors 2>/dev/null | $grep -cE '^[0-9]+ 20' || echo 0)
|
|
|
|
|
|
|
|
|
|
# First run: record baseline, alert on nothing historical.
|
|
|
|
|
if [ ! -f "$state" ]; then
|
|
|
|
|
echo "$current 0" > "$state"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
read baseline last < "$state"
|
|
|
|
|
|
|
|
|
|
# DB wiped/shrank -> re-baseline, don't alert.
|
|
|
|
|
if [ "$current" -lt "$baseline" ]; then
|
|
|
|
|
echo "$current 0" > "$state"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
new=$(( current - baseline ))
|
|
|
|
|
[ "$new" -lt 1 ] && exit 0
|
|
|
|
|
|
|
|
|
|
# Highest geometric threshold crossed by the new-error count.
|
|
|
|
|
crossed=0
|
|
|
|
|
for t in 1 10 100 1000 10000 100000; do
|
|
|
|
|
[ "$new" -ge "$t" ] && crossed=$t
|
|
|
|
|
done
|
|
|
|
|
# Already alerted at this bucket -> stay quiet (increasing intervals).
|
|
|
|
|
[ "$crossed" -le "$last" ] && exit 0
|
|
|
|
|
|
|
|
|
|
if [ ! -f "$secret" ]; then
|
|
|
|
|
echo "mce-monitor: $secret missing; cannot notify" >&2
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
|
|
|
|
|
newest=$($rasctl --errors 2>/dev/null | $grep -E '^[0-9]+ 20' \
|
|
|
|
|
| ${pkgs.coreutils}/bin/tail -1 | ${pkgs.coreutils}/bin/cut -d' ' -f2-4)
|
|
|
|
|
if [ "$new" -ge 1000 ]; then prio=urgent; tag=rotating_light; else prio=high; tag=warning; fi
|
|
|
|
|
|
|
|
|
|
${pkgs.curl}/bin/curl -fsS --max-time 10 \
|
|
|
|
|
-H "Title: MCE/QPI errors returned on $host" -H "Priority: $prio" -H "Tags: $tag" \
|
|
|
|
|
-d "$new new machine-check (QPI) errors since the Jul repaste. Newest: $newest" \
|
|
|
|
|
"$url" >/dev/null 2>&1 || true
|
|
|
|
|
|
|
|
|
|
echo "$baseline $crossed" > "$state"
|
|
|
|
|
'';
|
|
|
|
|
in
|
2026-06-25 19:37:35 +01:00
|
|
|
{
|
|
|
|
|
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
|
|
|
|
|
|
|
|
|
# Decode + log + persist machine-check / memory errors per component.
|
|
|
|
|
hardware.rasdaemon.enable = true;
|
|
|
|
|
|
|
|
|
|
# ras-mc-ctl on PATH for manual inspection.
|
|
|
|
|
environment.systemPackages = [ pkgs.rasdaemon ];
|
|
|
|
|
|
|
|
|
|
# Hardware watchdog: auto-reboot a hung box instead of a dead LAN.
|
|
|
|
|
# systemd pets /dev/watchdog0 at half the runtime interval; if it stops
|
|
|
|
|
# (hang), the chipset resets after RuntimeWatchdogSec.
|
|
|
|
|
systemd.settings.Manager = {
|
|
|
|
|
RuntimeWatchdogSec = "30s";
|
|
|
|
|
RebootWatchdogSec = "10min";
|
|
|
|
|
};
|
2026-07-22 10:45:09 +01:00
|
|
|
|
|
|
|
|
# Watch for the QPI fault returning; ntfy on increasing thresholds.
|
|
|
|
|
systemd.services.mce-monitor = {
|
|
|
|
|
description = "Alert (ntfy) on new machine-check/QPI errors";
|
|
|
|
|
serviceConfig = {
|
|
|
|
|
Type = "oneshot";
|
|
|
|
|
ExecStart = mceMonitor;
|
|
|
|
|
StateDirectory = "mce-monitor";
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
systemd.timers.mce-monitor = {
|
|
|
|
|
wantedBy = [ "timers.target" ];
|
|
|
|
|
timerConfig = {
|
|
|
|
|
OnBootSec = "3min";
|
|
|
|
|
OnUnitActiveSec = "5min";
|
|
|
|
|
Persistent = true;
|
|
|
|
|
};
|
|
|
|
|
};
|
2026-06-25 19:37:35 +01:00
|
|
|
};
|
|
|
|
|
}
|