From 333f2ae51ba7ed403f92508ab42ac302378c69f0 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 27 Aug 2026 10:46:57 -0500 Subject: [PATCH] feat(sensors): lm-sensors+TEMPer SNMP extend on 6 hosts with scoped ACLs [#341][#439][#458][#459] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- WORKING.md | 16 +-- dcinfra/sensors/temper/deploy.sh | 145 +++++++++++++++++++ dcinfra/sensors/temper/lmsensors-extend.sh | 97 +++++++++++++ tests/unit/lmsensors.sh | 155 +++++++++++++++++++++ 4 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 dcinfra/sensors/temper/deploy.sh create mode 100644 dcinfra/sensors/temper/lmsensors-extend.sh create mode 100644 tests/unit/lmsensors.sh diff --git a/WORKING.md b/WORKING.md index 06b6b89..cb4ea3d 100644 --- a/WORKING.md +++ b/WORKING.md @@ -5,12 +5,12 @@ A commit is blocked while any task below remains unchecked. ## 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] Kuma ICMP 84/84 + fixed devices (#435) -- [x] DHCP/PTR cleanup, DNS incident + recovery (#420/#449) -- [x] TSYSGroupAIOS adoption completed (test.sh gate live) -- [x] Sept FCP plan of record (#313, #452, #453) -- [x] Tripp Lite → tsys9 NUT server live (#439, #372) -- [x] AWX IaC mandate ticketed (#454) +- [x] PDU AP7830 verdict: metered, LibreNMS graphs Output Current (#439) +- [x] tsys1 nut-server restart + HA reachability both UPS (#439, #456) +- [x] Sensor stack TDD + deploy: 6 hosts, scoped ACLs, verified (#341, #458) +- [x] Security hardening rulings a+b executed + verified (#439, #458) +- [x] HA founder handoff steps written to #439 +- [x] Tickets spawned: #455 upsmon fleet, #456/#458 AWX shadows, #457 tuning, #459 tsys4 egress (fixed) diff --git a/dcinfra/sensors/temper/deploy.sh b/dcinfra/sensors/temper/deploy.sh new file mode 100644 index 0000000..d0b01b1 --- /dev/null +++ b/dcinfra/sensors/temper/deploy.sh @@ -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" diff --git a/dcinfra/sensors/temper/lmsensors-extend.sh b/dcinfra/sensors/temper/lmsensors-extend.sh new file mode 100644 index 0000000..7eb2686 --- /dev/null +++ b/dcinfra/sensors/temper/lmsensors-extend.sh @@ -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 " " 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 diff --git a/tests/unit/lmsensors.sh b/tests/unit/lmsensors.sh new file mode 100644 index 0000000..8c811ff --- /dev/null +++ b/tests/unit/lmsensors.sh @@ -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"