feat(sensors): lm-sensors+TEMPer SNMP extend on 6 hosts with scoped ACLs [#341][#439][#458][#459]

Native lm-sensors output plus optional TEMPer USB probe, unified in
lm-sensors format behind one snmpd extend (lmsensors). Idempotent
deploy binds snmpd to explicit LAN+Tailscale addresses only and
source-scopes the community to the pollers (LibreNMS, Home Assistant
app-connector LAN IP, admin workstation, Cloudron) per the founder's
security ruling. TDD unit suite included; fleet verified live (6 hosts)
and negative-tested (refused source).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-27 10:46:57 -05:00
parent 350d984e34
commit 333f2ae51b
4 changed files with 405 additions and 8 deletions
+8 -8
View File
@@ -5,12 +5,12 @@ A commit is blocked while any task below remains unchecked.
## Current Tasks ## Current Tasks
(all done — session complete) (all done — session state persisted to Redmine #439/#341/#458/#459,
Discourse audit #298 replies #37/#38, and git)
- [x] Fleet audit + naming convergence (#446) - [x] PDU AP7830 verdict: metered, LibreNMS graphs Output Current (#439)
- [x] Kuma ICMP 84/84 + fixed devices (#435) - [x] tsys1 nut-server restart + HA reachability both UPS (#439, #456)
- [x] DHCP/PTR cleanup, DNS incident + recovery (#420/#449) - [x] Sensor stack TDD + deploy: 6 hosts, scoped ACLs, verified (#341, #458)
- [x] TSYSGroupAIOS adoption completed (test.sh gate live) - [x] Security hardening rulings a+b executed + verified (#439, #458)
- [x] Sept FCP plan of record (#313, #452, #453) - [x] HA founder handoff steps written to #439
- [x] Tripp Lite → tsys9 NUT server live (#439, #372) - [x] Tickets spawned: #455 upsmon fleet, #456/#458 AWX shadows, #457 tuning, #459 tsys4 egress (fixed)
- [x] AWX IaC mandate ticketed (#454)
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# Deploy lm-sensors extend (native sensors + TEMPer) + snmpd on a host [#341/#439]
#
# Run ON the target host via the remote.sh chokepoint (bash -s). Copy the
# wrapper over first (vm-copy / prox-copy). Idempotent: safe to re-run.
#
# Native lm-sensors (CPU/board temps, fans) is installed on every target.
# The TEMPer USB layer (venv + temperusb + udev rule) is installed ONLY when
# a 0c45:7401 probe is attached.
#
# Usage (from repo root):
# VM_IP=stlpc-artroom VM_USER=root bash tests/remote.sh vm-copy dcinfra/sensors/temper/lmsensors-extend.sh /tmp/lmsensors-extend.sh
# VM_IP=stlpc-artroom VM_USER=root bash tests/remote.sh vm-file dcinfra/sensors/temper/deploy.sh
# PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-copy dcinfra/sensors/temper/lmsensors-extend.sh /tmp/lmsensors-extend.sh
# PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file dcinfra/sensors/temper/deploy.sh
#
# snmpd is a targeted exception: sensor hosts + tsys-* Proxmox hosts
# (founder-directed 2026-08-27), not fleet-wide.
set -euo pipefail
COMMUNITY="${SNMP_COMMUNITY:-kn3lmgmt}"
VENV_DIR="${VENV_DIR:-/opt/temper-venv}"
WRAP_SRC="${WRAP_SRC:-/tmp/lmsensors-extend.sh}"
WRAP_DST="/usr/local/bin/lmsensors-extend"
WRAP_DST_OLD="/usr/local/bin/temper-lmsensors"
SNMPD_CONF="/etc/snmp/snmpd.conf"
UDEV_RULE="/etc/udev/rules.d/99-temper.rules"
MARK_BEGIN="# lmsensors-extend managed block begin"
MARK_END="# lmsensors-extend managed block end"
OLD_MARK_BEGIN="# temper-lmsensors managed block begin"
OLD_MARK_END="# temper-lmsensors managed block end"
# Source allowlist (founder ruling 2026-08-27: bind Tailscale-only where
# possible; HA is a Tailscale APP connector so its polls source from its LAN
# IP and must be ACL'd explicitly — no broad subnet grants).
# 192.168.3.176 tsys-librenms LAN (poller)
# 100.86.204.77 tsys-librenms Tailscale
# 192.168.3.12 pfv-bms / Home Assistant LAN (app connector source)
# 100.67.108.125 pfv-bms Tailscale
# 100.101.187.119 admin workstation Tailscale
# 152.53.37.179 + 2a0a:4cc0:2000:686::a496:3bff:fe35:610a Cloudron VPS
# (future Uptime Kuma SNMP polling)
SNMP_SOURCES="${SNMP_SOURCES:-192.168.3.176 100.86.204.77 192.168.3.12 100.67.108.125 100.101.187.119 152.53.37.179 2a0a:4cc0:2000:686:a496:3bff:fe35:610a}"
log() { echo "[deploy] $*"; }
has_probe() {
command -v lsusb >/dev/null 2>&1 && lsusb | grep -qi '0c45:7401'
}
host_lan_ip() {
# first 192.168.* address (mgmt/user LAN; excludes 127/10.100 storage/dockers)
ip -4 addr show 2>/dev/null | grep -oP 'inet \K192\.168\.[0-9.]+' | head -1
}
host_ts_ip() {
command -v tailscale >/dev/null 2>&1 && tailscale ip -4 2>/dev/null | head -1
}
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ -f "$WRAP_SRC" ]] || { echo "wrapper not found at $WRAP_SRC (copy it first)" >&2; exit 1; }
# --- native lm-sensors layer (all hosts) ---
if ! dpkg -s lm-sensors >/dev/null 2>&1; then
log "installing lm-sensors"
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq lm-sensors
fi
if [[ -z "$(sensors 2>/dev/null)" ]]; then
log "no sensor readings yet; trying coretemp/k10temp modules"
modprobe coretemp 2>/dev/null || true
modprobe k10temp 2>/dev/null || true
fi
log "native sensors: $(sensors 2>/dev/null | grep -c '°C') temp readings"
# --- TEMPer USB layer (probe-conditional) ---
if has_probe; then
log "TEMPer probe present: installing venv layer"
if ! dpkg -s python3-venv >/dev/null 2>&1; then
log "installing python3-venv"
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-venv
fi
if [[ ! -x "$VENV_DIR/bin/pip" ]]; then
[[ -d "$VENV_DIR" ]] && { log "removing broken venv $VENV_DIR"; rm -rf "$VENV_DIR"; }
log "creating venv $VENV_DIR"
python3 -m venv "$VENV_DIR"
fi
log "ensuring temperusb in venv"
"$VENV_DIR/bin/pip" install --quiet --disable-pip-version-check temperusb
# TEMPer USB dongle must be readable by the unprivileged snmpd user
# (extend runs as Debian-snmp; pyusb needs the raw usb device node).
log "installing udev rule for 0c45:7401"
cat >"$UDEV_RULE" <<'EOF'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0c45", ATTRS{idProduct}=="7401", MODE="0666"
EOF
udevadm control --reload-rules
udevadm trigger
else
log "no TEMPer probe: skipping venv/udev layer"
fi
log "installing wrapper to $WRAP_DST"
install -m 0755 "$WRAP_SRC" "$WRAP_DST"
rm -f "$WRAP_DST_OLD"
if ! dpkg -s snmpd >/dev/null 2>&1 || ! command -v snmpget >/dev/null 2>&1; then
log "installing snmpd + snmp client"
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq snmpd snmp
fi
log "configuring snmpd (community=$COMMUNITY, scoped sources)"
LAN_IP="$(host_lan_ip)"
TS_IP="$(host_ts_ip)"
AGENT_ADDRS=""
[[ -n "$LAN_IP" ]] && AGENT_ADDRS="udp:${LAN_IP}:161"
[[ -n "$TS_IP" ]] && AGENT_ADDRS="${AGENT_ADDRS:+$AGENT_ADDRS,}udp:${TS_IP}:161"
[[ -n "$AGENT_ADDRS" ]] || { echo "no LAN/Tailscale address found; refusing to bind 0.0.0.0" >&2; exit 1; }
sed -i "/^$OLD_MARK_BEGIN\$/,/^$OLD_MARK_END\$/d" "$SNMPD_CONF"
sed -i "/^$MARK_BEGIN\$/,/^$MARK_END\$/d" "$SNMPD_CONF"
sed -i 's/^agentaddress/#agentaddress/' "$SNMPD_CONF"
{
echo "$MARK_BEGIN"
echo "agentaddress $AGENT_ADDRS"
for src in $SNMP_SOURCES; do
echo "rocommunity $COMMUNITY $src"
done
echo "extend lmsensors $WRAP_DST"
echo "$MARK_END"
} >>"$SNMPD_CONF"
log "agentaddress: $AGENT_ADDRS"
log "restarting snmpd"
systemctl enable --now snmpd >/dev/null
systemctl restart snmpd
log "wrapper output:"
"$WRAP_DST"
log "snmp extend self-test (expect lm-sensors block below):"
sleep 1
snmpget -v2c -c "$COMMUNITY" -Ovq localhost \
.1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115 2>/dev/null || \
log "WARN: snmpget self-test failed (check snmpd logs)"
log "done"
@@ -0,0 +1,97 @@
#!/bin/bash
# lm-sensors extend for snmpd: native sensors + TEMPer USB block [#341/#439]
#
# Deployed to /usr/local/bin/lmsensors-extend on sensor hosts by deploy.sh.
# Output = lm-sensors format, consumed by Home Assistant's SNMP integration
# (nsExtendOutputFull."lmsensors") and, if enabled later, LibreNMS's
# lm-sensors app.
#
# Layers (each optional, at least one required):
# 1. native /usr/bin/sensors output (CPU/board temps, fans) — lm-sensors pkg
# 2. TEMPer USB probe (temperusb in venv /opt/temper-venv) — probe-optional
# (venv-on-host is founder-approved for sensor hosts, incl. Proxmox)
set -euo pipefail
VENV_PY="${VENV_PY:-/opt/temper-venv/bin/python}"
NATIVE_SENSORS_BIN="${NATIVE_SENSORS_BIN:-/usr/bin/sensors}"
CHIP_NAME="${CHIP_NAME:-temper-usb-1}"
# Render lm-sensors format block from internal/external Celsius values.
# Empty/absent external renders internal only. Non-numeric input is rejected.
format_block() {
local internal="${1:-}" external="${2:-}"
local num_re='^-?[0-9]+(\.[0-9]+)?$'
[[ "$internal" =~ $num_re ]] || {
echo "error: internal temp not numeric: '$internal'" >&2
return 1
}
local signed
printf '%s\nAdapter: USB adapter\n' "$CHIP_NAME"
signed="${internal#+}"; [[ "$signed" == -* ]] || signed="+$signed"
printf 'Internal: %s°C\n' "$signed"
if [[ -n "$external" && "$external" =~ $num_re ]]; then
signed="${external#+}"; [[ "$signed" == -* ]] || signed="+$signed"
printf 'External: %s°C\n' "$signed"
fi
}
# True when a TEMPer USB dongle (0c45:7401) is attached and the venv exists.
has_temper_probe() {
command -v lsusb >/dev/null 2>&1 || return 1
[[ -x "$VENV_PY" ]] || return 1
lsusb | grep -qi '0c45:7401'
}
# Read the TEMPer probe via temperusb; prints "<internal> <external>" in C.
# Sensor 0 = internal, sensor 1 = external probe (if attached).
read_temper() {
"$VENV_PY" - <<'PY'
from temperusb import TemperHandler
internal = external = ""
for device in TemperHandler().get_devices():
try:
temps = device.get_temperatures(sensors=[0, 1])
except (ValueError, KeyError):
temps = device.get_temperatures()
for index, slot in ((0, "internal"), (1, "external")):
reading = temps.get(index)
if reading and reading.get("temperature_c") is not None:
value = f"{reading['temperature_c']:.1f}"
if slot == "internal":
internal = value
else:
external = value
print(f"{internal} {external}".strip())
PY
}
run_native_sensors() {
[[ -x "$NATIVE_SENSORS_BIN" ]] || return 1
"$NATIVE_SENSORS_BIN" 2>/dev/null
}
main() {
local out="" block internal external native
if native="$(run_native_sensors)" && [[ -n "$native" ]]; then
out+="$native"$'\n'
fi
if has_temper_probe; then
if block="$(read_temper 2>/dev/null)" \
&& read -r internal external <<<"$block" \
&& block="$(format_block "${internal:-}" "${external:-}")"; then
out+="$block"$'\n'
else
echo "warn: TEMPer probe present but read failed" >&2
fi
fi
[[ -n "$out" ]] || {
echo "error: no sensor sources available (lm-sensors pkg? TEMPer venv?)" >&2
return 1
}
printf '%s' "$out"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main
fi
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# Unit tests for the lm-sensors extend wrapper (dcinfra/sensors/temper) [#341/#439]
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$PROJECT_ROOT/dcinfra/sensors/temper/lmsensors-extend.sh"
failed=0
# Script must exist and source cleanly (main guarded for sourcing)
if [[ -f "$SCRIPT" ]] && (source "$SCRIPT"); then
echo "✅ script exists + sources cleanly"
else
echo "❌ script missing or fails to source: $SCRIPT"
exit 1
fi
# shellcheck disable=SC1091
source "$SCRIPT"
# lm-sensors format block: chip, adapter, one line per sensor, +X.X°C
test_format_two_sensors() {
local got expected
expected=$(printf 'temper-usb-1\nAdapter: USB adapter\nInternal: +23.5°C\nExternal: +18.2°C')
got="$(format_block "23.5" "18.2")"
if [[ "$got" == "$expected" ]]; then
echo "✅ format_block renders two-sensor lm-sensors output"
else
echo "❌ format_block two-sensor output mismatch"
printf 'got:\n%s\nexpected:\n%s\n' "$got" "$expected"
((++failed))
fi
}
test_format_single_sensor() {
local got expected
expected=$(printf 'temper-usb-1\nAdapter: USB adapter\nInternal: +23.5°C')
got="$(format_block "23.5" "")"
if [[ "$got" == "$expected" ]]; then
echo "✅ format_block omits absent external sensor"
else
echo "❌ format_block single-sensor output mismatch"
printf 'got:\n%s\nexpected:\n%s\n' "$got" "$expected"
((++failed))
fi
}
test_format_negative() {
if format_block "-5.0" | grep -q 'Internal: -5.0°C'; then
echo "✅ format_block handles negative temps"
else
echo "❌ format_block mangles negative temps"
((++failed))
fi
}
test_format_garbage_rejected() {
if format_block "not-a-number" >/dev/null 2>&1; then
echo "❌ format_block accepted non-numeric input"
((++failed))
else
echo "✅ format_block rejects non-numeric input"
fi
}
# main() composition with stubbed binaries via PATH + env overrides
STUB_DIR="$(mktemp -d "/tmp/lmsensors-test-$$-XXXXXX")"
trap 'rm -rf "$STUB_DIR"' EXIT
make_stub_lsusb() {
if [[ "${1:-}" == "probe" ]]; then
printf '#!/bin/bash\necho "Bus 001 Device 007: ID 0c45:7401 Microdia TEMPer Temperature Sensor"\n' >"$STUB_DIR/lsusb"
else
printf '#!/bin/bash\nexit 0\n' >"$STUB_DIR/lsusb"
fi
chmod +x "$STUB_DIR/lsusb"
}
make_stub_sensors() {
printf '#!/bin/bash\nprintf "coretemp-isa-0000\\nAdapter: ISA adapter\\nPackage id 0: +45.0°C\\nCore 0: +41.0°C\\n"\n' >"$STUB_DIR/sensors"
chmod +x "$STUB_DIR/sensors"
}
make_stub_python() {
printf '#!/bin/bash\necho "23.5 18.2"\n' >"$STUB_DIR/fakepython"
chmod +x "$STUB_DIR/fakepython"
}
test_main_native_only() {
make_stub_lsusb noprobe
make_stub_sensors
local got
got="$(PATH="$STUB_DIR:$PATH" NATIVE_SENSORS_BIN="$STUB_DIR/sensors" \
VENV_PY=/nonexistent main)"
if grep -q 'Package id 0' <<<"$got" && ! grep -q 'temper-usb-1' <<<"$got"; then
echo "✅ main: native sensors only (no probe)"
else
echo "❌ main: native-only composition wrong"
((++failed))
fi
}
test_main_temper_only() {
make_stub_lsusb probe
make_stub_python
local got
got="$(PATH="$STUB_DIR:$PATH" NATIVE_SENSORS_BIN=/nonexistent \
VENV_PY="$STUB_DIR/fakepython" main)"
if grep -q 'Internal: +23.5°C' <<<"$got" && ! grep -q 'Package id 0' <<<"$got"; then
echo "✅ main: TEMPer only (no lm-sensors bin)"
else
echo "❌ main: temper-only composition wrong"
((++failed))
fi
}
test_main_merge() {
make_stub_lsusb probe
make_stub_sensors
make_stub_python
local got
got="$(PATH="$STUB_DIR:$PATH" NATIVE_SENSORS_BIN="$STUB_DIR/sensors" \
VENV_PY="$STUB_DIR/fakepython" main)"
if grep -q 'Package id 0' <<<"$got" && grep -q 'Internal: +23.5°C' <<<"$got"; then
echo "✅ main: native + TEMPer merged"
else
echo "❌ main: merge composition wrong"
((++failed))
fi
}
test_main_neither_fails() {
make_stub_lsusb noprobe
if PATH="$STUB_DIR:$PATH" NATIVE_SENSORS_BIN=/nonexistent \
VENV_PY=/nonexistent main >/dev/null 2>&1; then
echo "❌ main: succeeded with no sensor sources"
((++failed))
else
echo "✅ main: fails cleanly with no sensor sources"
fi
}
test_format_two_sensors
test_format_single_sensor
test_format_negative
test_format_garbage_rejected
test_main_native_only
test_main_temper_only
test_main_merge
test_main_neither_fails
if ((failed > 0)); then
echo "❌ lm-sensors extend: $failed test(s) failed"
exit 1
fi
echo "PASS: lm-sensors extend unit tests"