diff --git a/snmp/Openwrt/README.md b/snmp/Openwrt/README.md new file mode 100644 index 000000000..40a4dad1b --- /dev/null +++ b/snmp/Openwrt/README.md @@ -0,0 +1,96 @@ +# OpenWrt wireless + sensors SNMP agent + +These scripts expose per-radio and aggregate wireless metrics (plus lm-sensors +temperatures) to LibreNMS through net-snmp `pass_persist` handlers. Radios and +VAPs are discovered live at request time, so there is no per-interface or +per-metric snmpd configuration to maintain. See +[Device-Notes/Openwrt.md](https://docs.librenms.org/Support/Device-Notes/#openwrt) +for the LibreNMS-side install and discovery steps. + +## Scripts + +| Script | Purpose | +| --- | --- | +| `openwrt-snmp-pass.sh` | Wireless `pass_persist` handler. Serves the OpenWrt wireless subtree (OPENWRT-WIRELESS-MIB, `.1.3.6.1.4.1.60652.102.1.10`) from a single snmpd line. | +| `lm-sensors-pass.sh` | Temperature `pass_persist` handler (LM-SENSORS-MIB emulation). | +| `wlInterfaces.sh` | Interface/label inventory. Helper used by `openwrt-snmp-pass.sh`. | +| `wlClients.sh [iface]` | Client counts (per interface, or aggregate). Helper. | +| `wlFrequency.sh ` | Channel frequency (MHz). Helper. | +| `wlNoiseFloor.sh ` | Noise floor (dBm). Helper. | +| `wlRate.sh ` | TX/RX rate (Mbit/s). Helper. | +| `wlSNR.sh ` | SNR (dB). Helper. | + +The `wl*.sh` scripts are collection helpers invoked by `openwrt-snmp-pass.sh`; +they are not registered with snmpd directly. They can be run standalone for +debugging. + +## snmpd configuration + +Copy the scripts to a directory on the device (the helpers must sit next to +`openwrt-snmp-pass.sh`, which calls them by relative path): + +```sh +mkdir -p /usr/libexec/openwrt-snmp +for s in openwrt-snmp-pass lm-sensors-pass wlInterfaces wlClients \ + wlFrequency wlNoiseFloor wlRate wlSNR; do + wget -O "/usr/libexec/openwrt-snmp/$s.sh" \ + "https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/Openwrt/$s.sh" +done +chmod +x /usr/libexec/openwrt-snmp/*.sh +``` + +Register the two handlers in `/etc/config/snmpd`: + +``` +config pass + option miboid '.1.3.6.1.4.1.60652.102.1.10' + option prog '/usr/libexec/openwrt-snmp/openwrt-snmp-pass.sh' + option persist '1' + +config pass + option miboid '.1.3.6.1.4.1.2021.13.16.2.1' + option prog '/usr/libexec/openwrt-snmp/lm-sensors-pass.sh' + option persist '1' +``` + +OS detection additionally reads a `distro` and a `hardware` extend +(`nsExtendOutput1Line."distro"` / `."hardware"`); these use small inline +commands rather than shipped scripts: + +``` +config extend + option name 'distro' + option prog '/bin/sh' + option args '-c '\''. /etc/os-release; echo $PRETTY_NAME'\''' + +config extend + option name 'hardware' + option prog '/bin/cat' + option args '/tmp/sysinfo/model' +``` + +(`/tmp/sysinfo/model` is OpenWrt's board-detection model string; it works on all +targets including x86, unlike `/sys/firmware/devicetree/base/model`.) + +Restart snmpd: + +```sh +/etc/init.d/snmpd restart +``` + +## Validation + +From the LibreNMS host, walk the wireless subtree and the temperature table: + +```sh +snmpwalk -v2c -c your_community_string .1.3.6.1.4.1.60652.102.1.10 +snmpwalk -v2c -c your_community_string .1.3.6.1.4.1.2021.13.16 +``` + +On the device, the handler can be exercised directly: + +```sh +/usr/libexec/openwrt-snmp/openwrt-snmp-pass.sh --snapshot # full OID table +/usr/libexec/openwrt-snmp/openwrt-snmp-pass.sh -g # single GET +/usr/libexec/openwrt-snmp/openwrt-snmp-pass.sh -n # GETNEXT +``` diff --git a/snmp/Openwrt/lm-sensors-pass.sh b/snmp/Openwrt/lm-sensors-pass.sh new file mode 100755 index 000000000..94255be77 --- /dev/null +++ b/snmp/Openwrt/lm-sensors-pass.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +# lm-sensors-pass.sh +# SNMP pass script for LM-SENSORS-MIB thermal sensors +# Provides proper MIB structure at .1.3.6.1.4.1.2021.13.16.2.1 + +BASE_OID=".1.3.6.1.4.1.2021.13.16.2.1" + +# Function to get all thermal zone data +# Output format: index:name:temp +# Re-indexes zones sequentially starting from 1 +get_zones() { + idx=0 + for zone in /sys/devices/virtual/thermal/thermal_zone*; do + [ -d "$zone" ] || continue + idx=$((idx + 1)) + zone_type=$(cat "$zone/type" 2>/dev/null || echo "unknown") + zone_temp=$(cat "$zone/temp" 2>/dev/null || echo "0") + echo "$idx:$zone_type:$zone_temp" + done | sort -t':' -k1 -n +} + +case "$1" in + -g) + # GET request - return exact OID match + REQ_OID="$2" + FOUND=0 + + while IFS=':' read -r idx name temp; do + case "$REQ_OID" in + "$BASE_OID.1.$idx") + echo "$REQ_OID" + echo "integer" + echo "$idx" + FOUND=1 + break + ;; + "$BASE_OID.2.$idx") + echo "$REQ_OID" + echo "string" + echo "$name" + FOUND=1 + break + ;; + "$BASE_OID.3.$idx") + echo "$REQ_OID" + echo "gauge" + echo "$temp" + FOUND=1 + break + ;; + esac + done << EOF +$(get_zones) +EOF + + [ "$FOUND" -eq 0 ] && echo "NONE" + ;; + + -n) + # GETNEXT request - return next OID after requested + REQ_OID="$2" + + # Create temporary file with all OIDs + TMP_FILE="$(mktemp /tmp/snmp_oids.XXXXXX 2>/dev/null || echo "/tmp/snmp_oids.$$")" + TMP_SORTED="${TMP_FILE}.sorted" + TMP_LIST="${TMP_FILE}.list" + : > "$TMP_FILE" + trap 'rm -f "$TMP_FILE" "$TMP_SORTED" "$TMP_LIST"' EXIT INT TERM + + get_zones | while IFS=':' read -r idx name temp; do + # Pad index to ensure proper numeric sorting + # Format: column.index where index is zero-padded to 3 digits + { + printf '%d.%03d|%s.1.%s|integer|%s\n' 1 "$idx" "$BASE_OID" "$idx" "$idx" + printf '%d.%03d|%s.2.%s|string|%s\n' 2 "$idx" "$BASE_OID" "$idx" "$name" + printf '%d.%03d|%s.3.%s|gauge|%s\n' 3 "$idx" "$BASE_OID" "$idx" "$temp" + } >> "$TMP_FILE" + done + + # Sort by our padded key, then extract and compare OIDs + sort -t'|' -k1 "$TMP_FILE" > "$TMP_SORTED" + cut -d'|' -f2- "$TMP_SORTED" > "$TMP_LIST" + + FOUND=0 + while IFS='|' read -r oid type value; do + # Use awk for proper numeric OID comparison + is_greater=$(awk -v req="$REQ_OID" -v curr="$oid" ' + BEGIN { + # Split OIDs into arrays + req_len = split(req, req_parts, "."); + curr_len = split(curr, curr_parts, "."); + max_len = (curr_len > req_len) ? curr_len : req_len; + + # Compare each part numerically + for (i = 1; i <= max_len; i++) { + req_val = (i <= req_len) ? req_parts[i] : 0; + curr_val = (i <= curr_len) ? curr_parts[i] : 0; + + if (curr_val > req_val) { + print "1"; + exit; + } else if (curr_val < req_val) { + print "0"; + exit; + } + } + print "0"; + } + ') + + if [ "$is_greater" = "1" ]; then + echo "$oid" + echo "$type" + echo "$value" + FOUND=1 + break + fi + done < "$TMP_LIST" + + if [ "$FOUND" -eq 0 ]; then + echo "NONE" + fi + ;; + + *) + echo "Usage: $0 -g|-n OID" >&2 + exit 1 + ;; +esac diff --git a/snmp/Openwrt/openwrt-snmp-pass.sh b/snmp/Openwrt/openwrt-snmp-pass.sh new file mode 100755 index 000000000..8b91642d2 --- /dev/null +++ b/snmp/Openwrt/openwrt-snmp-pass.sh @@ -0,0 +1,388 @@ +#!/bin/sh +# +# openwrt-snmp-pass.sh +# +# net-snmp pass_persist handler that serves the OpenWrt wireless subtree +# openwrtWireless = OPENWRT-WIRELESS-MIB { openwrtObjects 10 } from a SINGLE snmpd line +# (UCI: config pass / option miboid / option persist '1'): +# +# pass_persist .1.3.6.1.4.1.60652.102.1.10 /usr/libexec/openwrt-snmp/openwrt-snmp-pass.sh +# +# Radios/VAPs are discovered live at request time (no per-interface or +# per-metric configuration). Rows are indexed by the kernel ifIndex, so the +# table joins onto IF-MIB ifTable/ifXTable. +# +# Object layout (relative to the wireless base .102.1.10): +# .1.0 openwrtWirelessInterfaceCount (gauge) +# .2.0 openwrtWirelessClientCount (gauge, AP-side) +# .3.1.. openwrtWirelessInterfaceTable columns: +# col 2 Name string col10 RxRateMin gauge +# col 3 Label string col11 RxRateAvg gauge +# col 4 Clients gauge col12 RxRateMax gauge +# col 5 Frequency gauge col13 SnrMin integer +# col 6 NoiseFloor integer col14 SnrAvg integer +# col 7 TxRateMin gauge col15 SnrMax integer +# col 8 TxRateAvg gauge col16 ChannelUtil gauge (percent) +# col 9 TxRateMax gauge col17 TxPower integer (dBm) +# (col 1, openwrtWlIfaceIfIndex, is the not-accessible table index.) +# +# Modes: +# pass_persist (default): read PING/get/getnext on stdin (snmpd uses this). +# one-shot for testing: openwrt-snmp-pass.sh -g +# openwrt-snmp-pass.sh -n +# +# Testing without a device: set OPENWRT_WL_MOCK= to a TSV of pre-built +# interface records (see collect_records() for the field order); the metric +# collectors and iwinfo are then bypassed. + +BASE=".1.3.6.1.4.1.60652.102" +WL="$BASE.1.10" # openwrtWireless = { openwrtObjects 10 } +IFCOUNT_OID="$WL.1.0" +CLIENTS_OID="$WL.2.0" +ENTRY="$WL.3.1" + +# Snapshot cache TTL (seconds). A single SNMP walk issues many getnext calls; +# caching avoids re-running iwinfo/iw for every OID in the walk. +TTL="${OPENWRT_WL_TTL:-20}" + +SCRIPTDIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd || echo .) +TAB=$(printf '\t') + +SNAP_FILE="/tmp/openwrt-snmp-pass.$$.snap" +SNAP_TS=0 +trap 'rm -f "$SNAP_FILE"' EXIT INT TERM + +now() { date +%s 2>/dev/null || echo 0; } + +# -------------------------------------------------------------------------- +# Data collection +# -------------------------------------------------------------------------- +# collect_records: one tab-separated record per wireless interface, fields: +# 1 ifIndex 2 name 3 label 4 clients 5 freqMHz 6 noiseDbm +# 7 txMin 8 txAvg 9 txMax 10 rxMin 11 rxAvg 12 rxMax +# 13 snrMin 14 snrAvg 15 snrMax +# Interfaces without a readable /sys ifindex are skipped (cannot be indexed). + +sanitize_int() { + # First signed integer token, decimals truncated; default 0. + printf '%s' "$1" | awk ' + { if (match($0, /-?[0-9]+/)) print substr($0, RSTART, RLENGTH) + 0; else print 0; exit } + END { if (NR == 0) print 0 }' +} + +# Helper command paths (overridable for testing). +WLIFACES="${OPENWRT_WL_IFACES_CMD:-$SCRIPTDIR/wlInterfaces.sh}" +WLCLIENTS="${OPENWRT_WL_CLIENTS_CMD:-$SCRIPTDIR/wlClients.sh}" + +to_lower() { printf '%s' "$1" | tr 'A-Z' 'a-z'; } + +iface_ifindex() { + if [ -n "${OPENWRT_WL_IFINDEX_MAP:-}" ]; then + awk -v k="$1" '$1==k {print $2; exit}' "$OPENWRT_WL_IFINDEX_MAP" + return + fi + cat "/sys/class/net/$1/ifindex" 2>/dev/null +} + +iface_associated() { + # For client/STA interfaces with an empty assoc list: 1 when associated, else 0. + ap=$(iwinfo "$1" info 2>/dev/null | sed -n 's/.*Access Point:[[:space:]]*\([^ ]\+\).*/\1/p' | head -1) + case "$ap" in + ''|Not-Associated|00:00:00:00:00:00) echo 0 ;; + *) echo 1 ;; + esac +} + +# -------------------------------------------------------------------------- +# Per-interface metric collection. ubus-first (the OpenWrt-native, structured +# path) with an iwinfo-CLI fallback. Both emit the SAME tagged block so a single +# aggregator handles either source. This replaces the previous ~15 iwinfo/iw +# invocations per interface (which risked exceeding snmpd's pass timeout on a +# cold cache) with one ubus call-set (or two iwinfo CLI calls) per interface. +# +# Tagged block (line order irrelevant): +# MODE FREQ NOISE ASSOC +# RX TX SNR (one set per associated station) +# -------------------------------------------------------------------------- + +stats_via_ubus() { + _if="$1" + command -v ubus >/dev/null 2>&1 || return 1 + command -v jsonfilter >/dev/null 2>&1 || return 1 + ubus list iwinfo >/dev/null 2>&1 || return 1 + + _info=$(ubus call iwinfo info "{\"device\":\"$_if\"}" 2>/dev/null) + _assoc=$(ubus call iwinfo assoclist "{\"device\":\"$_if\"}" 2>/dev/null) + [ -n "$_info$_assoc" ] || return 1 + + printf 'MODE %s\n' "$(printf '%s' "$_info" | jsonfilter -e '@.mode' 2>/dev/null)" + printf 'FREQ %s\n' "$(printf '%s' "$_info" | jsonfilter -e '@.frequency' 2>/dev/null)" + printf 'NOISE %s\n' "$(printf '%s' "$_info" | jsonfilter -e '@.noise' 2>/dev/null)" + printf 'TXPOWER %s\n' "$(printf '%s' "$_info" | jsonfilter -e '@.txpower' 2>/dev/null)" + + # Associated-station count from a field that always exists per station + # (iwinfo's assoclist JSON has no top-level "snr"). + printf 'ASSOC %s\n' "$(printf '%s' "$_assoc" | jsonfilter -e '@.results[*].signal' 2>/dev/null | awk 'NF' | wc -l | awk '{print $1}')" + # iwinfo ubus rates are kbit/s -> Mbit/s. + printf '%s' "$_assoc" | jsonfilter -e '@.results[*].rx.rate' 2>/dev/null | awk 'NF{printf "RX %d\n", $1/1000}' + printf '%s' "$_assoc" | jsonfilter -e '@.results[*].tx.rate' 2>/dev/null | awk 'NF{printf "TX %d\n", $1/1000}' + # No "snr" field in the JSON: compute SNR = per-station signal - noise, + # pairing the two arrays by index. Skip stations whose noise floor is + # unavailable (0), e.g. radios that do not report survey noise. + { printf '%s' "$_assoc" | jsonfilter -e '@.results[*].signal' 2>/dev/null | sed 's/^/S /' + printf '%s' "$_assoc" | jsonfilter -e '@.results[*].noise' 2>/dev/null | sed 's/^/N /' + } | awk ' + $1=="S" { s[++a]=$2 } + $1=="N" { n[++b]=$2 } + END { for (i=1; i<=a; i++) if (n[i]+0 != 0) printf "SNR %d\n", s[i]-n[i] }' + return 0 +} + +stats_via_cli() { + _if="$1" + command -v iwinfo >/dev/null 2>&1 || return 1 + _info=$(iwinfo "$_if" info 2>/dev/null) + _assoc=$(iwinfo "$_if" assoclist 2>/dev/null) + [ -n "$_info$_assoc" ] || return 1 + + _mode=$(printf '%s\n' "$_info" | awk '{for(i=1;i<=NF;i++) if($i=="Mode:"){print $(i+1); exit}}') + _freq=$(printf '%s\n' "$_info" | sed -n 's/.*(\([0-9]\{3,\}\)[[:space:]]*MHz).*/\1/p' | head -1) + if [ -z "$_freq" ]; then + _ghz=$(printf '%s\n' "$_info" | sed -n 's/.*[ (]\([0-9]\+\.[0-9]\+\)[[:space:]]*GHz.*/\1/p' | head -1) + [ -n "$_ghz" ] && _freq=$(awk -v g="$_ghz" 'BEGIN{printf "%d", g*1000}') + fi + _noise=$(printf '%s\n' "$_info" | awk '{for(i=1;i<=NF;i++) if($i=="Noise:"){print $(i+1); exit}}') + # "Tx-Power: 23 dBm" + _txpower=$(printf '%s\n' "$_info" | sed -n 's/.*Tx-Power:[[:space:]]*\(-\{0,1\}[0-9]\{1,\}\).*/\1/p' | head -1) + + printf 'MODE %s\nFREQ %s\nNOISE %s\nTXPOWER %s\n' "$_mode" "$_freq" "$_noise" "$_txpower" + + printf '%s\n' "$_assoc" | awk ' + BEGIN { IGNORECASE=1; n=0 } + /^[[:space:]]*([0-9a-f]{2}:){5}[0-9a-f]{2}[[:space:]]/ { + n++ + if (match($0, /SNR[[:space:]]*-?[0-9]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9-]/,"",s); print "SNR " s } + next + } + /^[[:space:]]*RX:[[:space:]]*[0-9]/ { print "RX " ($2 + 0) } + /^[[:space:]]*TX:[[:space:]]*[0-9]/ { print "TX " ($2 + 0) } + END { print "ASSOC " n } + ' + + # Fallback for client/STA where assoclist is empty: single current values. + if ! printf '%s\n' "$_assoc" | grep -qiE '([0-9a-f]{2}:){5}[0-9a-f]{2}'; then + _br=$(printf '%s\n' "$_info" | awk '{for(i=1;i<=NF;i++) if($i=="Rate:"){print $(i+1); exit}}') + _sig=$(printf '%s\n' "$_info" | awk '{for(i=1;i<=NF;i++) if($i=="Signal:"){print $(i+1); exit}}') + _br=$(sanitize_int "$_br") + if [ "$_br" -gt 0 ] 2>/dev/null; then printf 'RX %s\nTX %s\n' "$_br" "$_br"; fi + # Derive SNR only from a real signal reading (not absent / 0 / "unknown"); + # sanitize_int defaults to 0, so guard on the RAW value first. + case "$_sig" in + ''|0|unknown|*[!0-9-]*) ;; + *) + _nz=$(sanitize_int "$_noise") + [ "$_nz" != 0 ] && printf 'SNR %s\n' "$(( $(sanitize_int "$_sig") - _nz ))" + ;; + esac + fi + return 0 +} + +# iface_airtime : channel airtime utilisation (percent) from hostapd ubus, +# or empty when unavailable (no ubus/jsonfilter, or client/STA without hostapd). +iface_airtime() { + command -v ubus >/dev/null 2>&1 || return 0 + command -v jsonfilter >/dev/null 2>&1 || return 0 + ubus call "hostapd.$1" get_status 2>/dev/null | jsonfilter -e '@.airtime.utilization' 2>/dev/null +} + +# iface_stats : emit the 14 numeric metric fields (tab-separated) in +# record column order 4..17: clients freq noise tx{min,avg,max} rx{min,avg,max} +# snr{min,avg,max} channelUtil txPower, plus a 15th trailing mode tag (used by +# build_snapshot for the AP-side aggregate, not an OID column). +iface_stats() { + _if="$1" + _block=$(stats_via_ubus "$_if") || _block="" + [ -n "$_block" ] || _block=$(stats_via_cli "$_if") || _block="" + # Channel utilisation is hostapd-only; append it to the block. + _block="$_block +UTIL $(iface_airtime "$_if")" + + _mode=$(printf '%s\n' "$_block" | awk '$1=="MODE"{print $2; exit}') + _agg=$(printf '%s\n' "$_block" | awk ' + $1=="FREQ" {freq=$2} + $1=="NOISE" {noise=$2} + $1=="ASSOC" {assoc=$2} + $1=="UTIL" {util=$2} + $1=="TXPOWER" {txpower=$2} + $1=="RX" {v=$2+0; rc++; rsum+=v; if(rmin==""||vrmax)rmax=v} + $1=="TX" {v=$2+0; tc++; tsum+=v; if(tmin==""||vtmax)tmax=v} + $1=="SNR" {v=$2+0; sc++; ssum+=v; if(smin==""||vsmax)smax=v} + END { + printf "%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d\n", + (assoc==""?0:assoc), (freq==""?0:freq), (noise==""?0:noise), + (tmin==""?0:tmin),(tc?tsum/tc:0),(tmax==""?0:tmax), + (rmin==""?0:rmin),(rc?rsum/rc:0),(rmax==""?0:rmax), + (smin==""?0:smin),(sc?ssum/sc:0),(smax==""?0:smax), + (util==""?0:util), (txpower==""?0:txpower) + }') + IFS='|' read -r _assoc _freq _noise _tmin _tavg _tmax _rmin _ravg _rmax _smin _savg _smax _util _txpower < validated hostapd dedup via wlClients.sh; client/STA -> peer + # count (assoc list usually holds the upstream AP; 0/1 link-up otherwise). + case "$(to_lower "$_mode")" in + client|sta|mesh*|adhoc|ad-hoc) + if [ "${_assoc:-0}" -gt 0 ] 2>/dev/null; then + _clients="$_assoc" + else + _clients=$(iface_associated "$_if") + fi + ;; + *) + _clients=$("$WLCLIENTS" "$_if" 2>/dev/null , (