commit 3c8d0674ef89b8e8b26596a641cc95e63aba2d4a Author: Eric Loyd Date: Sun Sep 6 12:08:05 2026 -0400 Initial load diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..30661c1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sunwait"] + path = sunwait + url = http://github.com/risacher/sunwait diff --git a/AstropotaPOD.sh b/AstropotaPOD.sh new file mode 100755 index 0000000..7fac5fd --- /dev/null +++ b/AstropotaPOD.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash + +myCommand="" +debug="" +PODip="192.168.0.151" +PODport="32000" +PPBAkey="" +PPBAname="" +MQTT_HOST="192.168.0.6" +MQTT_USER="" # optional +MQTT_PASS="" # optional +myDevice="all" + +do_help() { + cat << HELP_EOF +Usage: $0 [OPTIONS] + command is one of: + --ip) IP of the PPBA API server + --port) Port of the PPBA API server + --init) Starts the Unity driver (must do this first) + --start) Starts the PPBA driver (do this after --init) + --mqtt) Send HA MQTT discovery information to broker + --stats) Send MQTT stats to broker + --power [on|off] ) Powers on a device (see DEV, below) + -d|--debug) Print debug information + -h|--help) print this help information + + You can put multiple commands on the command line; they will be executed sequentially. + They are scanned for in the order listed above. + Thus, you can do "$0 --init --start --mqtt" for a one-liner startup script + DEV is a device name: + all | quad | var | dew1 | dew2 | autodew +HELP_EOF + exit +} + + +do_debug() { + [ -n "$debug" ] && echo "DEBUG: $*" >&2 +} + +do_api() { + ep="${1#/}" + method="${2:-GET}" + notes="${3}" + url="http://${PODip}:${PODport}/${ep}" + do_debug "curl -s -X ${method} \"$url\"" + [ -n "$notes" ] && echo "$notes" >&2 + curl -s -X ${method} "$url" +} + +do_getKey() { + # Get POD PPBA unique key and name + PPBAkey=$(do_api "/Server/DeviceManager/Connected" | jq -r '.data[] | select (.name =="PPBAdvance") | .uniqueKey') + PPBAname=$(do_api "/Server/DeviceManager/Device/${PPBAkey}/ProfileName" | jq -r '.data') + do_debug "PPBAkey=$PPBAkey, PPBAname=$PPBAname" + if [ -z "$PPBAkey" -o -z "$PPBAname" ]; then + # The PPBA is not powered on or cannot be found + do_debug "The PPBA is not powered on or cannot be found" + exit + fi +} + +do_init() { + do_getKey + do_api "/Server/Start" PUT "Starting server..." | jq '.status' + sleep 2 +} + +do_start() { + do_getKey + do_api "/Driver/PPBAdvance/Start?DriverUniqueKey=${PPBAkey}" OPTIONS "Starting PPBAdvance driver..." | jq '.status' +# do_api "/Driver/PPBAdvance/Power/Hub/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning off Quad Power..." | jq '.status' +# do_api "/Driver/PPBAdvance/Power/Variable/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning off Variable Power..." | jq '.status' +# do_api "/Driver/PPBAdvance/Dew/1/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning off Dew Heater 1..." | jq '.status' +# do_api "/Driver/PPBAdvance/Dew/2/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning off Dew Heater 2..." | jq '.status' +# do_api "/Driver/PPBAdvance/Dew/Auto/Off?DriverUniqueKey=${PPBAkey}" POST "Turning off Auto Dew..." | jq '.status' +} + +do_poweroff() { + do_getKey + case "$myDevice" in + Quad|quad*|All|all) do_api "/Driver/PPBAdvance/Power/Hub/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning Off Quad Power..." | jq '.status';;& + Var|var*|All|all) do_api "/Driver/PPBAdvance/Power/Variable/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning Off Variable Power..." | jq '.status';;& + Dew1|dew1|All|all) do_api "/Driver/PPBAdvance/Dew/1/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning Off Dew Heater 1..." | jq '.status';;& + Dew2|dew2|All|all) do_api "/Driver/PPBAdvance/Dew/2/Off?DriverUniqueKey=${PPBAkey}" PUT "Turning Off Dew Heater 2..." | jq '.status';;& + Auto*|auto*|All|all) do_api "/Driver/PPBAdvance/Dew/Auto/Off?DriverUniqueKey=${PPBAkey}" POST "Turning Off Auto Dew..." | jq '.status';; + esac +} + +do_poweron() { + do_getKey + case "$myDevice" in + Quad|quad*|All|all) do_api "/Driver/PPBAdvance/Power/Hub/On?DriverUniqueKey=${PPBAkey}" PUT "Turning on Quad Power..." | jq '.status';;& + Var*|var*|All|all) do_api "/Driver/PPBAdvance/Power/Variable/On?DriverUniqueKey=${PPBAkey}" PUT "Turning on Variable Power..." | jq '.status';;& + Dew1|dew1|All|all) do_api "/Driver/PPBAdvance/Dew/1/On?DriverUniqueKey=${PPBAkey}" PUT "Turning on Dew Heater 1..." | jq '.status';;& + Dew2|dew2|All|all) do_api "/Driver/PPBAdvance/Dew/2/On?DriverUniqueKey=${PPBAkey}" PUT "Turning on Dew Heater 2..." | jq '.status';;& + Auto*|auto|All|all) do_api "/Driver/PPBAdvance/Dew/Auto/On?DriverUniqueKey=${PPBAkey}" POST "Turning on Auto Dew..." | jq '.status';; + esac +} + + +mqtt_pub() { + topic="$1" + message="$2" + do_debug "mosquitto_pub -h $MQTT_HOST -t \"PPBA/status/$topic\" -m \"$message\"" + mosquitto_pub -h $MQTT_HOST -t "PPBA/status/$topic" -m "$message" -r +} + +do_stats() { + do_getKey + stats=$(do_api "/Driver/PPBAdvance/Report?DriverUniqueKey=${PPBAkey}") + for stat in "voltage" "current" "quadCurrent" "power" "temperature" "humidity" "dewPoint" "isOverCurrent" "averageAmps" "ampsPerHour" "wattPerHour" "upTime"; do + val=$(echo "$stats" | jq -r ".data.message.$stat") + mqtt_pub "$stat" "$val" + done + for stat in "powerHubStatus" "powerVariablePortStatus" "ppbA_DualUSB2Status"; do + val=$(echo "$stats" | jq -r ".data.message.$stat.state") + mqtt_pub "$stat" "$val" + done +} + +### +### + +do_mqtt() { + do_getKey + JSON_DATA=$(do_api "/Driver/PPBAdvance/Report?DriverUniqueKey=${PPBAkey}") + DEVICE_ID="ppbadv_gen2" + DISCOVERY_PREFIX="homeassistant" + STATE_TOPIC="$DEVICE_ID/state" + STATE_TOPIC="PPBA/status" + POLL_INTERVAL=30 # seconds + + publish_discovery() { + echo "Publishing Home Assistant MQTT discovery configs..." + # Helper to publish a single discovery config message + publish_sensor() { + local ENTITY="$1" + local NAME="$2" + local UNIT="$3" + local DEVICE_CLASS="$4" + local VALUE_TEMPLATE="{{ value_json.$ENTITY }}" + local TOPIC="$DISCOVERY_PREFIX/sensor/$DEVICE_ID/${ENTITY}/config" + local PAYLOAD="{ \ + \"name\": \"$NAME\", \ + \"state_topic\": \"$STATE_TOPIC/${ENTITY}\", \ + \"unique_id\": \"${DEVICE_ID}_${ENTITY}\", \ + \"device\": { \ + \"identifiers\": [\"$DEVICE_ID\"], \ + \"manufacturer\": \"Pegasus Astro\", \ + \"model\": \"Pocket Powerbox Advance Gen2\", \ + \"name\": \"PPB Advance Gen2\" \ + }" + + # Optional fields + if [ -n "$UNIT" ]; then + PAYLOAD="$PAYLOAD, \"unit_of_measurement\": \"$UNIT\"" + fi + if [ -n "$DEVICE_CLASS" ]; then + PAYLOAD="$PAYLOAD, \"device_class\": \"$DEVICE_CLASS\"" + fi + + PAYLOAD="$PAYLOAD }" + mosquitto_pub -h "$MQTT_HOST" -u "$MQTT_USER" -P "$MQTT_PASS" \ + -t "$TOPIC" -m "$PAYLOAD" -r + } + # Sensors + publish_sensor "voltage" "PPB Voltage" "V" "voltage" + publish_sensor "current" "PPB Current" "A" "current" + publish_sensor "temperature" "PPB Temperature" "°C" "temperature" + publish_sensor "humidity" "PPB Humidity" "%" "humidity" + publish_sensor "dewPoint" "PPB Dew Point" "°C" "" + publish_sensor "averageAmps" "PPB Average Amps" "A" "" + publish_sensor "ampsPerHour" "PPB Amps Per Hour" "Ah" "" + publish_sensor "wattPerHour" "PPB Watt Per Hour" "Wh" "" + publish_sensor "upTime" "PPB Uptime" "" "" + publish_sensor "powerHubStatus" "PPB Power Hub Status" "" "" + publish_sensor "powerVariablePortStatus" "PPB Variable Port" "" "" + publish_sensor "ppbA_DualUSB2Status" "PPB Dual USB2 Status" "" "" + } + publish_discovery + exit +} + +while [ -n "$1" ]; do + case "$1" in + -h|--help) do_help; shift 1;; + --ip) PODip="$2"; shift 2;; + --port) PODport="$2"; shift 2;; + --init*) do_init; shift 1;; + --start) do_start; shift 1;; + --mqtt*) do_mqtt; shift 1;; + --stat*) do_stats; shift 1;; + --power) myCommand="power${2:-on}"; myDevice="$3"; shift 3;; + --poweron) myCommand="poweron"; myDevice="$2"; shift 2;; + --poweroff) myCommand="poweroff"; myDevice="$2"; shift 2;; + -d|--debug) debug="true"; shift 1;; + *) shift 1;; + esac +done + +case "$myCommand" in + poweroff) do_poweroff;; + poweron) do_poweron;; +# *) do_help;; +esac + +exit diff --git a/add_to_cron.sh b/add_to_cron.sh new file mode 100755 index 0000000..93fb689 --- /dev/null +++ b/add_to_cron.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# Updates our crontab file so that it has all the stuff we need + +cat << EOF | crontab +# Updates Nagios day/night time includes for current seasonal sunrise/sunset +01 00 * * * \${HOME}/NWC2026-AstropotaPOD/daynight.sh + +# MQTT Stats from PPBA +* * * * * \${HOME}/NWC2026-AstropotaPOD/AstropotaPOD.sh --stats +EOF diff --git a/add_to_rpizero_cron.sh b/add_to_rpizero_cron.sh new file mode 100755 index 0000000..ce310ef --- /dev/null +++ b/add_to_rpizero_cron.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# Updates our crontab file so that it has all the stuff we need +InfluxToken="InfluxDB Token Goes Here" +flairID="Flair Client ID Goes Here" +flairCS="Flair Client Secret Goes Here" + +cat << EOF | crontab +@reboot ${HOME}/NWC2026-AstropotaPOD/start_gearman.sh +@reboot ${HOME}/NWC2026-AstropotaPOD/AstropotaPOD.sh --init --start --mqtt + +# Get AstropotaPOD Stats and set up the MQTT HA integration information +0 * * * * ${HOME}/NWC2026-AstropotaPOD/AstropotaPOD.sh --mqtt +* * * * * ${HOME}/NWC2026-AstropotaPOD/AstropotaPOD.sh --stats + +# Pull stats and throw into InfluxDB +* * * * * INFLUX_TOKEN=${InfluxToken} ${HOME}/NWC2026-AstropotaPOD/telegraf.sh --local +*/2 * * * * INFLUX_TOKEN=${InfluxToken} ${HOME}/NWC2026-AstropotaPOD/telegraf.sh --ppba +*/3 * * * * INFLUX_TOKEN=${InfluxToken} ${HOME}/NWC2026-AstropotaPOD/telegraf.sh --iss +*/15 * * * * INFLUX_TOKEN=${InfluxToken} ${HOME}/NWC2026-AstropotaPOD/telegraf.sh --weather + +# Grab Puck temp stats and throw into MQTT (which will then be pulled into Nagios via a check_mqtt plugin) +*/5 * * * * FLAIR_CLIENT_ID="${flairID}" FLAIR_CLIENT_SECRET="${flairCS}" ${HOME}/NWC2026-AstropotaPOD/flair2MQTT.sh bridge +EOF diff --git a/check_astro b/check_astro new file mode 100755 index 0000000..1f82d1b --- /dev/null +++ b/check_astro @@ -0,0 +1,188 @@ +#!/bin/bash + +. /home/eloyd/NWC2026-AstropotaPOD/AstropotaUtils.sh + +# Default states, minimums and maximums, etc +mqtt_host="192.168.0.6" +debug="" +min="" +max="" +warn="" +crit="" +exitCode="0" +statusText="OK" + +# Definitions for where the AstropotaPOD is located (using Nagios Global Headquarters) +LAT="44.973N" +LON="93.155W" +TARGET_TZ="EDT" +sunwait="/home/eloyd/NWC2026-AstropotaPOD/sunwait" + +do_debug() { + [ -z "$debug" ] && return + echo "DEBUG: $*" >&2 +} + +do_help() { + echo "Usage: $0 -w -c -m -M [-d] (kp | flux | sun | puck | NINA | weather | ppba )" + exit +} + +while getopts "w:c:m:M:hd" opt; do + case "$opt" in + w) warn="$OPTARG";; + c) crit="$OPTARG";; + m) min="$OPTARG";; + M) max="$OPTARG";; + h) do_help;; + d) debug="true";; + esac +done +shift $((OPTIND - 1)) +command="$@" +[ "$warn" = "-c" -o "$warn" = "ignored" ] && warn="" +[ "$crit" = "ignored" ] && crit="" +[ "$min" = "-M" -o "$min" = "ignored" ] && min="" +[ "$max" = "ignored" ] && max="" +do_debug "We'll be looking for: $command [-w $warn] [-c $crit] [-m $min] [-M $max]" + +get_weather() { + do_debug "In get_weather" + do_debug "mosquitto_sub -W 5 -h $mqtt_host -t AstropotaPOD/weather/$1 -C 1" + mosquitto_sub -W 5 -h $mqtt_host -t AstropotaPOD/weather/$1 -C 1 +} + +convert_to_time_t() { + theTime="$1" + whatDay="${2:-today}" + theTZ="${3:-EDT}" + do_debug "convert_to_time_t: theTime=$theTime, whatDay=$whatDay" + NOW=$(date +%s) # Now in time_t + theDate=$(date -d "$whatDay" +"%d-%b-%Y") + do_debug "theDate=$theDate" + nextEvent=$(date -d "$theDate $theTime ${theTZ}" +%s) + do_debug "nextEvent=$nextEvent" + echo "$nextEvent" +} + +# Sunwait will get today's rise (rise) today's set (set) or tomorrow's rise (tomorrow) +get_sunrise() { + do_debug "In get_sunrise" + NOW=$(date +%s) +do_debug "NOW=$NOW" + dawnTime=$($sunwait list 1 nautical rise $LAT $LON | tail -1) + nextDawn=$(convert_to_time_t $dawnTime) + # If it's 2am, then NOW is less than DAWN, we don't need to do anything. + # If it's 2pm, then NOW is greater than DAWN, so we need to get tomorrow's dawn. + if [ "$NOW" -gt "$nextDawn" ]; then + do_debug "Need to look at tomorrow's dawn" + dawnTime=$($sunwait list 2 nautical rise $LAT $LON | tail -1) + nextDawn=$(convert_to_time_t $dawnTime tomorrow) + do_debug "Tomorrow's dawn is nextDawn=$nextDawn" + else + do_debug "Dawn is still in the future for today, so the next dawn is today's dawn: $nextDawn" + fi +do_debug "dawnTime=$dawnTime, nextDawn=$nextDawn" + duskTime=$($sunwait list nautical set $LAT $LON) + nextDusk=$(date -d "${dateToday} ${duskTime} ${TARGET_TZ}" +%s) +do_debug "duskTime=$duskTime, nextDusk=$nextDusk" + echo "nextDusk=$nextDusk, nextDawn=$nextDawn" +} + +get_mqtt() { + do_debug "In get_mqtt" + do_debug "mosquitto_sub -W 5 -h $mqtt_host -t \"$1\" -C 1" + mosquitto_sub -W 5 -h $mqtt_host -t "$1" -C 1 +} + +get_puck() { + do_debug "In get_puck" + do_debug "mosquitto_sub -W 5 -h $mqtt_host -t AstropotaPOD/sensor/AstropotaPOD-e9af/$1 -C 1" + mosquitto_sub -W 5 -h $mqtt_host -t AstropotaPOD/sensor/AstropotaPOD-e9af/$1 -C 1 +} + +get_ppba() { + do_debug "In get_ppba" + do_debug "mosquitto_sub -W 5 -h $mqtt_host -t PPBA/status/$1 -C 1" + mosquitto_sub -W 5 -h $mqtt_host -t PPBA/status/$1 -C 1 +} + +get_NINA() { + do_debug "In get_NINA" + do_debug "mosquitto_sub -W 5 -h $mqtt_host -t Astro/NINA/$1 -C 1" + mosquitto_sub -W 5 -h $mqtt_host -t Astro/NINA/$1 -C 1 +} + +get_suntime() { + do_debug "In get_suntime" + ${sunwait} poll nautical $LAT $LON >/dev/null + sunState=$? + do_debug "sunState=$sunState" + exitCode=0 + statusText="OK" + case "$sunState" in + 2) exitCode=1; statusText="WARNING - Sky is too bright for imaging.";; + 3) exitCode=0; statusText="OK - Sky is dark. Safe to image deep-sky targets.";; + *) exitCode=3; statusText="UNKNOWN - invalid code returned from sunwait command";; + esac + sunRise=$(get_sunrise) + echo "${statusText} | IsDark=$exitCode, $sunRise;;;;" + exit $exitCode +} + +# main +value="" +do_debug "command is $command" +case "$command" in + f*|F*) command="10MeV-Flux"; value=$(curl -s "https://services.swpc.noaa.gov/json/goes/primary/integral-protons-7-day.json" | jq '. | map(select(.energy == ">=10 MeV")) | last | .flux');; + k*|K*) command="Kp-Index"; value=$(curl -s https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json | jq '[last][].Kp');; + n*|N*) command="$2"; value=$(get_NINA $2);; + s*|S*) command="Suntime"; get_suntime;; + t*|T*) command="SunRiseSunSet"; value=$(get_sunrise);; + w*|W*) command="$2"; value=$(get_weather $2);; + mqtt*) command="$2"; value=$(get_mqtt $2);; + ppba*) command="$2"; value=$(get_ppba $2);; + puck*) command="$2"; value=$(get_puck $2);; + *) echo "unknown command was $command";; +esac + +# --- FLOATING POINT FORMATTING --- +# If value is not empty/unknown, check its formatting +if [ "$value" != "?" ]; then + # 1. First, check if it contains RA/Dec formatting symbols (: or °) + # If it does, bypass formatting completely to preserve the raw coordinate string + if [[ "$value" == *":"* ]] || [[ "$value" == *"°"* ]]; then + value="$value" + + # 2. Otherwise, check if it's a strict decimal number and pad/round to 4 spaces + elif [[ "$value" =~ ^-?[0-9]+\.[0-9]+$ ]]; then + value=$(printf "%.4f" "$value") + fi +fi +# CRITICAL check must always run first to ensure it takes precedence +statusText=$(check_threshold "$value" "$warn" "$crit") +exitCode=$? +echo "$command $statusText: $value|$command=$value;$warn;$crit;$min;$max" +exit $exitCode + +# --- NAGIOS OUTPUT STAGE --- +# Because check_threshold already figured out the severity, we just match the exit code +case $EXIT_CODE in + 0) + echo "OK - Value is $value | $command=$value;$warn;$crit;$min;$max" + exit 0 + ;; + 1) + echo "WARNING - Value $value is outside of warning range ($warn) | $command=$value;$warn;$crit;$min;$max" + exit 1 + ;; + 2) + echo "CRITICAL - Value $value is outside of critical range ($crit) | $command=$value;$warn;$crit;$min;$max" + exit 2 + ;; + *) + echo "UNKNOWN - Received unexpected response from check engine | $command=$value;$warn;$crit;$min;$max" + exit 3 + ;; +esac + diff --git a/check_dd_wrt b/check_dd_wrt new file mode 100755 index 0000000..9a98fa6 --- /dev/null +++ b/check_dd_wrt @@ -0,0 +1,152 @@ +#!/bin/bash + +hostname="" +community="" +counter="" +perfName="" +iname="" +inum="" +debug="" +min="" +max="" +warn="" +crit="" +exitCode="0" +statusText="OK" + +# snmpwalk -v 2c -c public 192.168.0.1 1.3.6.1.2.1.2.2.1.2 | sed -e "s/^IF-MIB::ifDescr.//" | awk '{print $NF, $1}' | while read iname inum; do echo "$iname"; for oid in 1.3.6.1.2.1.2.2.1.5 1.3.6.1.2.1.2.2.1.10 1.3.6.1.2.1.2.2.1.16 1.3.6.1.2.1.2.2.1.20; do snmpget -v 2c -c public 192.168.0.1 ${oid}.${inum}; done; done +# current bandwidth snmpwalk -v 2c -c public 192.168.1.1 1.3.6.1.2.1.2.2.1.5 +# octets received snmpwalk -v 2c -c public 192.168.1.1 1.3.6.1.2.1.2.2.1.10 +# octets sent snmpwalk -v 2c -c public 192.168.1.1 1.3.6.1.2.1.2.2.1.16 +# error octets 1.3.6.1.2.1.2.2.1.20 + +do_debug() { + [ -z "$debug" ] && return + echo "DEBUG: $*" >&2 +} + +do_help() { + echo "Usage: $0 -H -C [-I | -i ) -t (b|r|s|e) [-w ] [-c ]" +# -w -c -m -M [-d] (kp | flux)" + exit +} + +while getopts "dhC:H:I:i:t:w:c:" opt; do + case "$opt" in + H) hostname="$OPTARG";; + I) iname="$OPTARG";; + i) inum="$OPTARG";; + t) counter="$OPTARG";; + C) community="$OPTARG";; + w) warn="$OPTARG";; + c) crit="$OPTARG";; + h) do_help;; + d) debug="true";; + esac +done +shift $((OPTIND - 1)) +command="$@" +do_debug "host=$hostname iname=$iname inum=$inum counter=$counter warn=$warn crit=$crit" +[ -z "$hostname" ] && echo "No hostname specified." && exit 0 +[ -z "$community" ] && echo "No community specified." && exit 0 + +check_threshold() { + local val=$1 + local range=$2 + local alert_inside=0 + local start=0 + local end="inf" + # Handle empty thresholds safely + if [[ -z "$range" ]]; then return 0; fi + # Check for the inside-range prefix '@' + if [[ "$range" == @* ]]; then + alert_inside=1 + range="${range#@}" # Strip the '@' + fi + # Parse start and end values out of the range colon (:) syntax + if [[ "$range" == *:* ]]; then + start="${range%%:*}" + end="${range#*:}" + # Handle implicit defaults + [[ -z "$start" ]] && start=0 + [[ "$start" == "~" ]] && start="-inf" + [[ -z "$end" ]] && end="inf" + else + # No colon: Shorthand for 0:X + start=0 + end="$range" + fi + # Evaluate logic using bc (handles floats and floating point math) + local outside=0 + # Check lower boundary; inclusive of endpoints + if [[ "$start" != "-inf" ]]; then + if (( $(echo "$val < $start" | bc -l) )); then outside=1; fi + fi + # Check upper boundary; inclusive of endpoints + if [[ "$end" != "inf" ]]; then + if (( $(echo "$val > $end" | bc -l) )); then outside=1; fi + fi + # Return true/false based on whether the @ symbol inverted the logic + if [[ $alert_inside -eq 1 ]]; then + # Alert if INSIDE the range + if [[ $outside -eq 0 ]]; then return 1; else return 0; fi + else + # Alert if OUTSIDE the range (Standard Nagios Behavior) + if [[ $outside -eq 1 ]]; then return 1; else return 0; fi + fi +} + +do_snmp() { + myNum="$1" + myOid="$2" + myName="$3" + do_debug "checking $hostname for $myName (inum: $myNum) on $myOid:" + output=$(snmpget -v 2c -c $community $hostname ${myOid}.${myNum} | awk '{print $NF}') + if [ -z "$output" ]; then + echo "UNKNOWN: snmpget error" + exit 3 + else + echo "$output" + fi +} + +do_stats() { + myNum="$1" + myName="$2" + myOid="" + do_debug "do_stats: myNum=$myNum myName=$myName myOid=$myOid" + case "$counter" in + b) counter="Bandwidth"; perfName="'bw_bytes'"; myOid="1.3.6.1.2.1.2.2.1.5";; + r) counter="Received"; perfName="'bytes_recv'"; myOid="1.3.6.1.2.1.2.2.1.10";; + s) counter="Sent"; perfName="'bytes_sent'"; myOid="1.3.6.1.2.1.2.2.1.16";; + e) counter="Errors"; perfName="'bytes_errors'"; myOid="1.3.6.1.2.1.2.2.1.20";; + *) echo "Invalid counter type specified."; exit 0;; + esac + do_debug "do_stats: do_snmp $myNum $myOid $myName" + value=$(do_snmp $myNum $myOid $myName) + # CRITICAL check must always run first to ensure it takes precedence + if ! check_threshold "$value" "$crit"; then + exitCode=2 + statusText="CRITICAL" + elif ! check_threshold "$value" "$warn"; then + exitCode=1 + statusText="WARNING" + fi +# [ "$value" != "?" ] && value=$(printf "%.4f" "$value") + echo "$myName $counter $statusText: $value|$perfName=${value}B;$warn;$crit;$min;$max" + exit $exitCode +} + +if [ -n "$inum" ]; then + do_debug "Specified inum=$inum" + do_stats $inum +else + do_debug "Did not specify an inum" + do_debug "snmpwalk -v 2c -c $community $hostname 1.3.6.1.2.1.2.2.1.2" + snmpwalk -v 2c -c $community $hostname 1.3.6.1.2.1.2.2.1.2 | sed -e "s/^IF-MIB::ifDescr.//" | awk '{print $1, $NF}' | while read myNum myName; do + [ -z "$iname" -a -z "$inum" ] && echo "iname: $myName (inum: $myNum)" && continue # Show interfaces if we didn't specify one + [ "$iname" != "$myName" ] && continue + do_debug "Found $iname (ifnum: $myNum)" + do_stats $myNum + done +fi diff --git a/check_iss b/check_iss new file mode 100755 index 0000000..32d9892 --- /dev/null +++ b/check_iss @@ -0,0 +1,22 @@ +#!/bin/bash +# check_iss.sh + +API="http://api.open-notify.org/iss-now.json" + +# Query the public ISS live API +DATA=$(curl --connect-timeout 5 --max-time 15 -s "$API") + +if [ "$?" -gt 0 ]; then + echo "UNKNOWN - $API returned no data or serious error: $DATA" + exit 3 +fi +if [ $(echo "$DATA" | jq -r '.message') != "success" ]; then + echo "UNKNOWN - $API timed out" + exit 3 +fi + +# Extract coordinates +echo $DATA | jq -r '.iss_position | .latitude + " " + .longitude' | \ + (read LAT LON; \ + echo "OK - ISS Location: Lat $LAT, Lon $LON | ISS_lat=$LAT;;;; ISS_lon=$LON;;;;") +exit 0 diff --git a/check_kasa b/check_kasa new file mode 100755 index 0000000..21fc1f0 --- /dev/null +++ b/check_kasa @@ -0,0 +1,106 @@ +#!/bin/bash + +# ========================================================================== +# UNIVERSAL KASA HS103 NAGIOS PLUGIN +# Usage: ./check_kasa.sh +# ========================================================================== + +plugIP="" +plugPort="9999" +metric="" + +while getopts "H:m:P:" opt; do + case "$opt" in + H) plugIP="$OPTARG";; + P) plugPort="$OPTARG";; + m) metric="$OPTARG";; + esac +done +shift $((OPTIND - 1)) +restOfLine="$@" + +if [ -z "$plugIP" ] || [ -z "$metric" ]; then + echo "UNKNOWN - Missing arguments. Usage: $0 " + echo " metric: relay_state rssi on_time sw_ver hw_ver mac model" + exit 3 +fi + +# Pre-encrypted XOR payload for get_sysinfo +# NOTE: Why this value is what it is is beyond the scope of this script +PAYLOAD="AAAAI9Dw0qHYq9+61/XPtJS20bTAn+yV5o/hh+jK8J7rh+vLtpbr" + +# Fetch raw binary stream as clean decimal strings +RAW_STREAM=$(echo "$PAYLOAD" | base64 -d | nc -w 3 "$plugIP" "$plugPort" | od -An -v -t u1) + +if [ -z "$RAW_STREAM" ]; then + echo "CRITICAL - No network response from HS103 at $plugIP" + exit 2 +fi + +# XOR Decryption Loop +DECRYPTED_JSON="" +KEY=171 + +for BYTE in $RAW_STREAM; do + if [ "$BYTE" -eq 0 ] && [ -z "$DECRYPTED_JSON" ]; then + continue + fi + OUTPUT=$(( BYTE ^ KEY )) + KEY=$BYTE + DECRYPTED_JSON+="$(printf "\\$(printf '%03o' "$OUTPUT")")" +done + +# Clean out trailing encryption anomalies +CLEAN_JSON=$(echo "$DECRYPTED_JSON" | grep -o '{.*}' | sed 's/.$//') + +# Extract the alias name universally for clean alert messaging +ALIAS=$(echo "$CLEAN_JSON" | jq -r '.get_sysinfo.alias') + +# Dynamic Evaluation Engine based on your CLI parameter Choice +case "$metric" in + relay_state) + VALUE=$(echo "$CLEAN_JSON" | jq '.get_sysinfo.relay_state') + if [ "$VALUE" -eq 1 ]; then + echo "OK - ${ALIAS} Relay is ON | relay_state=1;;;;" + exit 0 + else + echo "CRITICAL - ${ALIAS} Relay is OFF | relay_state=0;;;;" + exit 2 + fi + ;; + + rssi) + VALUE=$(echo "$CLEAN_JSON" | jq '.get_sysinfo.rssi') + # Standard Wi-Fi signal threshold parameters + if [ "$VALUE" -ge -70 ]; then + echo "OK - ${ALIAS} Wi-Fi Signal Strength: ${VALUE} dBm | rssi=${VALUE};-75;-85;;" + exit 0 + elif [ "$VALUE" -lt -70 ] && [ "$VALUE" -ge -82 ]; then + echo "WARNING - ${ALIAS} Wi-Fi Signal Weak: ${VALUE} dBm | rssi=${VALUE};-75;-85;;" + exit 1 + else + echo "CRITICAL - ${ALIAS} Wi-Fi Signal Dropping Out: ${VALUE} dBm | rssi=${VALUE};-75;-85;;" + exit 2 + fi + ;; + + on_time) + VALUE=$(echo "$CLEAN_JSON" | jq '.get_sysinfo.on_time') + # Converts raw seconds to readable hours for clear telemetry status + HOURS=$(echo "scale=2; $VALUE / 3600" | bc) + echo "OK - ${ALIAS} Continuous Active Runtime: ${HOURS} Hours | on_time=${VALUE};;;;" + exit 0 + ;; + + sw_ver|hw_ver|mac|model) + # Dynamic handling for inventory/metadata parameters + VALUE=$(echo "$CLEAN_JSON" | jq -r ".get_sysinfo.${metric}") + echo "OK - ${ALIAS} ${metric}: ${VALUE}" + exit 0 + ;; + + *) + echo "UNKNOWN - Unsupported metric: ${metric}. Choose: relay_state, rssi, on_time, sw_ver, hw_ver, mac, model" + exit 3 + ;; +esac diff --git a/check_launch b/check_launch new file mode 100755 index 0000000..20e1448 --- /dev/null +++ b/check_launch @@ -0,0 +1,48 @@ +#!/bin/bash + +# Grab parameters with built-in fallbacks +SEARCH_REGEX="${1:-Starlink}" +LIMIT_COUNT="${2:-1}" + +# Convert search string to lowercase for the safety pool check +LOWER_SEARCH=$(echo "$SEARCH_REGEX" | tr '[:upper:]' '[:lower:]') + +# Define an intelligent data pool cap based on user request +if [[ "$LOWER_SEARCH" == *"starlink"* || "$LOWER_SEARCH" == *"oneweb"* || "$LOWER_SEARCH" == *"|"* ]]; then + # For specific trackers, fetch a wider baseline pool (at least 20) + FETCH_LIMIT=$(( LIMIT_COUNT > 20 ? LIMIT_COUNT : 20 )) +else + # Otherwise, trust the user input directly + FETCH_LIMIT=$LIMIT_COUNT +fi + +# Hard-clamp the final fetch request strictly to 100 (the API's absolute max ceiling) +if [ "$FETCH_LIMIT" -gt 100 ]; then + FETCH_LIMIT=100 +fi + +# Fetch the upcoming space manifest using the stable 2.3.0 production API +DATA=$(curl -s "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=${FETCH_LIMIT}") + +# Defensive Check: Ensure the API returned a valid JSON payload containing "results" +if ! echo "$DATA" | jq -e '.results' >/dev/null 2>&1; then + echo "CRITICAL - API Error: Received invalid response or rate limit from server | launch_alert=0" + exit 2 +fi + +# Filter the data pool locally using your regex, then slice cleanly to your display limit +LAUNCH_INFO=$(echo "$DATA" | jq -r --arg regex "$SEARCH_REGEX" --argjson limit "$LIMIT_COUNT" ' + .results | + map(select(.name | test($regex; "i"))) | + .[0:$limit] | + .[] | + "\(.name) @ \(.net)"') + +if [ ! -z "$LAUNCH_INFO" ]; then + # Output matches cleanly formatted for both CLI tracking and Nagios perf-piping + echo "OK - Target Approaching: $LAUNCH_INFO" | tr -d "|" | sed '$s/$/ | launch_alert=1/' + exit 0 +else + echo "OK - No flights matching pattern [$SEARCH_REGEX] found in the upcoming queue | launch_alert=0" + exit 0 +fi diff --git a/check_ncpa.aarch64 b/check_ncpa.aarch64 new file mode 100755 index 0000000..551d8f4 Binary files /dev/null and b/check_ncpa.aarch64 differ diff --git a/check_ncpa.armv7l b/check_ncpa.armv7l new file mode 100755 index 0000000..fcdab1f Binary files /dev/null and b/check_ncpa.armv7l differ diff --git a/check_ncpa.py b/check_ncpa.py new file mode 100755 index 0000000..62af716 --- /dev/null +++ b/check_ncpa.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python +""" +SYNOPSIS + + +""" +import sys +import optparse +import traceback +import ssl + +# Python 2/3 Compatibility imports + +try: + import json +except ImportError: + import simplejson as json + +try: + import urllib.request + import urllib.parse + import urllib.error +except ImportError: + import urllib2 + import urllib + +try: + urlencode = urllib.parse.urlencode +except AttributeError: + urlencode = urllib.urlencode + +try: + urlopen = urllib.request.urlopen +except AttributeError: + urlopen = urllib2.urlopen + +try: + urlquote = urllib.parse.quote +except AttributeError: + urlquote = urllib.quote + +try: + urlerror = urllib.error.URLError +except AttributeError: + urlerror = urllib2.URLError + +try: + httperror = urllib.error.HTTPError +except AttributeError: + httperror = urllib2.HTTPError + +import shlex +import re +import signal + + +__VERSION__ = '1.2.4' + + +class ConnectionError(Exception): + error_output_prefix = "UNKNOWN: An error occurred connecting to API. " + pass + +class URLError(ConnectionError): + def __init__(self, error_message): + self.error_message = ConnectionError.error_output_prefix + "(Connection error: '" + error_message + "')" + +class HTTPError(ConnectionError): + def __init__(self, error_message): + self.error_message = ConnectionError.error_output_prefix + "(HTTP error: '" + error_message + "')" + + +def parse_args(): + version = 'check_ncpa.py, Version %s' % __VERSION__ + + parser = optparse.OptionParser() + parser.add_option("-H", "--hostname", help="The hostname to be connected to.") + parser.add_option("-M", "--metric", default='', + help="The metric to check, this is defined on client " + "system. This would also be the plugin name in the " + "plugins directory. Do not attach arguments to it, " + "use the -a directive for that. DO NOT INCLUDE the api/ " + "instruction.") + parser.add_option("-P", "--port", default=5693, type="int", + help="Port to use to connect to the client.") + parser.add_option("-w", "--warning", default=None, type="str", + help="Warning value to be passed for the check.") + parser.add_option("-c", "--critical", default=None, type="str", + help="Critical value to be passed for the check.") + parser.add_option("-u", "--units", default=None, + help="The unit prefix (k, Ki, M, Mi, G, Gi, T, Ti) for b and B unit " + "types which calculates the value returned.") + parser.add_option("-n", "--unit", default=None, + help="Overrides the unit with whatever unit you define. " + "Does not perform calculations. This changes the unit of measurement only.") + parser.add_option("-a", "--arguments", default=None, + help="Arguments for the plugin to be run. Not necessary " + "unless you're running a custom plugin. Given in the same " + "as you would call from the command line. Example: -a '-w 10 -c 20 -f /usr/local'") + parser.add_option("-t", "--token", default='', + help="The token for connecting.") + parser.add_option("-T", "--timeout", default=55, type="int", + help="Enforced timeout, will terminate plugins after " + "this amount of seconds. [%default]") + parser.add_option("-d", "--delta", action='store_true', + help="Signals that this check is a delta check and a " + "local state will kept.") + parser.add_option("-l", "--list", action='store_true', + help="List all values under a given node. Do not perform " + "a check.") + parser.add_option("-v", "--verbose", action='store_true', + help='Print more verbose error messages.') + parser.add_option("-D", "--debug", action='store_true', + help='Print LOTS of error messages. Used mostly for debugging.') + parser.add_option("-V", "--version", action='store_true', + help='Print version number of plugin.') + parser.add_option("-q", "--queryargs", default=None, + help='Extra query arguments to pass in the NCPA URL.') + parser.add_option("-s", "--secure", action='store_true', default=False, + help='Require successful certificate verification. Does not work on Python < 2.7.9.') + parser.add_option("-p", "--performance", action='store_true', default=False, + help='Print performance data even when there is none. ' + 'Will print data matching the return code of this script') + options, _ = parser.parse_args() + + if options.version: + print(version) + sys.exit(0) + + if options.arguments and options.metric and not 'plugin' in options.metric: + parser.print_help() + parser.error('You cannot specify arguments without running a custom plugin.') + + if not options.hostname: + parser.print_help() + parser.error("Hostname is required for use.") + + elif not options.metric and not options.list: + parser.print_help() + parser.error('No metric given, if you want to list all possible items ' + 'use --list.') + + options.metric = re.sub(r'^/?(api/)?', '', options.metric) + + return options + + +# ~ The following are all helper functions. I would normally split these out into +# ~ a new module but this needs to be portable. + + +def get_url_from_options(options): + host_part = get_host_part_from_options(options) + arguments = get_arguments_from_options(options) + return '%s?%s' % (host_part, arguments) + + +def get_host_part_from_options(options): + """Gets the address that will be queries for the JSON. + + """ + hostname = options.hostname + port = options.port + + if not options.metric is None: + metric = urlquote(options.metric) + else: + metric = '' + + arguments = get_check_arguments_from_options(options) + if not metric and not arguments: + api_address = 'https://%s:%d/api' % (hostname, port) + else: + api_address = 'https://%s:%d/api/%s/%s' % (hostname, port, metric, arguments) + + return api_address + + +def get_check_arguments_from_options(options): + """Gets the escaped URL for plugin arguments to be added + to the end of the host URL. This is different from the get_arguments_from_options + in that this is meant for the syntax when the user is calling a check, whereas the below + is when GET arguments need to be added. + + """ + arguments = options.arguments + if arguments is None: + return '' + else: + lex = shlex.shlex(arguments) + lex.whitespace_split = True + arguments = '/'.join([urlquote(x, safe='') for x in lex]) + return arguments + + +def get_arguments_from_options(options, **kwargs): + """Returns the http query arguments. If there is a list variable specified, + it will return the arguments necessary to query for a list. + + """ + + # Note: Changed back to units due to the units being what is passed via the + # API call which can confuse people if they don't match + arguments = { 'token': options.token, + 'units': options.units } + + if not options.list: + arguments['warning'] = options.warning + arguments['critical'] = options.critical + arguments['delta'] = options.delta + arguments['check'] = 1 + arguments['unit'] = options.unit + + args = list((k, v) for k, v in list(arguments.items()) if v is not None) + + # Get the options (comma separated) + if options.queryargs: + # for each comma, perform lookahead, split if we aren't inside quotes. + arguments_list = re.split(''',(?=(?:[^'"]|'[^']*'|"[^"]*")*$)''', options.queryargs) + for argument in arguments_list: + key, value = argument.split('=', 1) + if value is not None: + args.append((key, value)) + + #~ Encode the items in the dictionary that are not None + return urlencode(args) + + +def get_json(options): + """Get the page given by the options. This will call down the url and + encode its finding into a Python object (from JSON). + + """ + + url = get_url_from_options(options) + + if options.verbose: + print('Connecting to: ' + url) + + try: + + try: + ctx = ssl.create_default_context() + if not options.secure: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ret = urlopen(url, context=ctx) + except AttributeError: + ret = urlopen(url) + + except httperror as e: + try: + raise HTTPError('{0} {1}'.format(e.code, e.reason)) + except AttributeError: + raise HTTPError('{0}'.format(e.code)) + except urlerror as e: + raise URLError('{0}'.format(e.reason)) + + ret = ret.read() + + if options.verbose: + print('File returned contained:\n' + ret.decode('utf-8')) + + arr = json.loads(ret) + + if options.list: + return arr + + # Fix for NCPA < 2 + if 'value' in arr: + arr = arr['value'] + + # We need to flip the returncode and stdout + if isinstance(arr['stdout'], int) and not isinstance(arr['returncode'], int): + tmp = arr['returncode'] + arr['returncode'] = arr['stdout'] + arr['stdout'] = tmp + + # If we recieve and error, return critical and give out error text + elif 'error' in arr: + arr['stdout'] = 'CRITICAL: %s' % arr['error'] + arr['returncode'] = 2 + + return arr + + +def run_check(info_json): + """Run a check against the remote host. + + """ + if 'stdout' in info_json and 'returncode' in info_json: + return info_json['stdout'], info_json['returncode'] + elif 'error' in info_json: + return info_json['error'], 3 + + +def show_list(info_json): + """Show the list of available options. + + """ + return json.dumps(info_json, indent=4), 0 + + +def timeout_handler(threshold): + def wrapped(signum, frames): + stdout = "UNKNOWN: Execution exceeded timeout threshold of %ds" % threshold + print(stdout) + sys.exit(3) + return wrapped + + +def main(): + options = parse_args() + + # We need to ensure that we will only execute for a certain amount of + # seconds. + signal.signal(signal.SIGALRM, timeout_handler(options.timeout)) + signal.alarm(options.timeout) + + try: + if options.version: + stdout = 'The version of this plugin is %s' % __VERSION__ + return stdout, 0 + + info_json = get_json(options) + + if options.list: + return show_list(info_json) + else: + stdout, returncode = run_check(info_json) + + if options.performance and stdout.find("|") == -1: + stdout = "{0} | 'status'={1};1;2;;".format(stdout, returncode) + return stdout, returncode + except (HTTPError, URLError) as e: + if options.debug: + return 'The stack trace:\n' + traceback.format_exc(), 3 + elif options.verbose: + return 'An error occurred:\n' + str(e.error_message), 3 + else: + return e.error_message, 3 + except Exception as e: + if options.debug: + return 'The stack trace:\n' + traceback.format_exc(), 3 + elif options.verbose: + return 'An error occurred:\n' + str(e), 3 + else: + return 'UNKNOWN: Error occurred while running the plugin. Use the verbose flag for more details.', 3 + + +if __name__ == "__main__": + stdout, returncode = main() + if sys.version_info[0] < 3: + print(unicode(stdout).encode('utf-8')) + else: + print(stdout.encode().decode('utf-8')) + sys.exit(returncode) diff --git a/check_ncpa.x86_64 b/check_ncpa.x86_64 new file mode 100755 index 0000000..91898a1 Binary files /dev/null and b/check_ncpa.x86_64 differ diff --git a/commands.cfg b/commands.cfg new file mode 100644 index 0000000..d7469e2 --- /dev/null +++ b/commands.cfg @@ -0,0 +1,38 @@ +define command { + command_name AstropotaNotify-Host + command_line /home/nwc2026/NWC2026-AstropotaPOD/notify-by-ntfy.sh -t host -ha "$HOSTALIAS$" -h "$HOSTNAME$" -hs "$HOSTSTATE$" -ld "$LONGDATETIME$" -nt "$NOTIFICATIONTYPE$" -C "$CONTACTPAGER$" +} +define command { + command_name AstropotaNotify-Service + command_line /home/nwc2026/NWC2026-AstropotaPOD/notify-by-ntfy.sh -t service -ha "$HOSTALIAS$" -h "$HOSTNAME$" -hs "$HOSTSTATE$" -ld "$LONGDATETIME$" -nt "$NOTIFICATIONTYPE$" -s "$SERVICEDESC$" -ss "$SERVICESTATE$" -C "$CONTACTPAGER$" + +} +define command { + command_name check_astro + command_line /home/nwc2026/NWC2026-AstropotaPOD/check_astro -w $ARG2$ -c $ARG3$ -m $ARG4$ -M $ARG5$ $ARG1$ $ARG8$ +} +define command { + command_name check_dd_wrt + command_line /home/nwc2026/NWC2026-AstropotaPOD/check_dd_wrt -H $HOSTADDRESS$ -C $ARG1$ -i $ARG2$ -t $ARG3$ $ARG4$ $ARG8$ +} +define command { + command_name check_iss + command_line /home/nwc2026/NWC2026-AstropotaPOD/check_iss +} +define command { + command_name check_launch + command_line /home/nwc2026/NWC2026-AstropotaPOD/check_launch $ARG1$ $ARG2$ $ARG8$ +} +define command { + command_name check_kasa + command_line /home/nwc2026/NWC2026-AstropotaPOD/check_kasa -H $HOSTADDRESS$ -m $ARG1$ $ARG8$ +} + +define command { + command_name MQTT-PODalert + command_line /usr/bin/mosquitto_pub -h ssdnode-1.bitnetix.com -t "nagios/alerts" -m "$ARG2$" $ARG8$ +} +define command { + command_name ping-other-host + command_line $USER1$/check_icmp -H $ARG1$ -w "$ARG2$" -c "$ARG3$" -p 5 $ARG8$ +} diff --git a/daynight.sh b/daynight.sh new file mode 100755 index 0000000..4c933aa --- /dev/null +++ b/daynight.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Set your exact location (Nagios Global Headquarters) +LAT="44.973N" +LON="93.155W" + +# sunwait outputs values in clean 24-hour HH:MM format +# We use nautical because the program actually shows the end of nautical, which is the start of astronomical +DAWN=$(${HOME}/NWC2026-AstropotaPOD/sunwait list dawn nautical $LAT $LON) +DUSK=$(${HOME}/NWC2026-AstropotaPOD/sunwait list dusk nautical $LAT $LON) + +# From here, it's Nagios API time +# Your Nagios XI API Details +XI_URL="http://192.168.123.16/nagiosxi/api/v1" +API_KEY="YOUR NAGIOS XI API KEY" + +# Build the new time strings +# Day string becomes: "05:42-20:14" +# Night string becomes: "00:00-05:42,20:14-24:00" +DAY_RANGE="${DAWN}-${DUSK}" +NIGHT_RANGE="00:00-${DAWN},${DUSK}-24:00" + +# Ensure our Timeperiods exist the way we want +curl -XPOST "${XI_URL}/config/timeperiod/AstropotaPOD-day?apikey=${API_KEY}&pretty=1" \ + -d "timeperiod_name=AstropotaPOD-day&alias=Local+Daytime+recalculated+via+cron&sunday=${DAY_RANGE}&monday=${DAY_RANGE}&tuesday=${DAY_RANGE}&wednesday=${DAY_RANGE}&thursday=${DAY_RANGE}&friday=${DAY_RANGE}&saturday=${DAY_RANGE}" +curl -XPOST "${XI_URL}/config/timeperiod/AstropotaPOD-night?apikey=${API_KEY}&pretty=1" \ + -d "timeperiod_name=AstropotaPOD-night&alias=Local+Nighttime+recalculated+via+cron&sunday=${NIGHT_RANGE}&monday=${NIGHT_RANGE}&tuesday=${NIGHT_RANGE}&wednesday=${NIGHT_RANGE}&thursday=${NIGHT_RANGE}&friday=${NIGHT_RANGE}&saturday=${NIGHT_RANGE}" + +# Tell Nagios XI to safely apply the changes to the engine +curl -XPOST "${XI_URL}/system/applyconfig?apikey=${API_KEY}" diff --git a/flair2MQTT.sh b/flair2MQTT.sh new file mode 100755 index 0000000..76d6120 --- /dev/null +++ b/flair2MQTT.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# --- CONFIGURATION FROM ENVIRONMENT VARIABLES --- +CLIENT_ID="${FLAIR_CLIENT_ID}" +CLIENT_SECRET="${FLAIR_CLIENT_SECRET}" + +if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then + echo "Error: Missing environment variables." >&2 + echo "Please set FLAIR_CLIENT_ID and FLAIR_CLIENT_SECRET before running this script." >&2 + exit 1 +fi + +MQTT_BROKER="192.168.0.6" +MQTT_PORT="1883" +# Matching your requested structure +MQTT_TOPIC_BASE="AstropotaPOD/sensor" + +# --- HELPER: GET OAUTH2 TOKEN --- +get_flair_token() { + local token_response + token_response=$(curl -s -X POST "https://api.flair.co/oauth2/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}") + + echo "$token_response" | jq -r '.access_token // empty' +} + +# --- FETCH REFRESHED TOKEN --- +TOKEN=$(get_flair_token) +if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then + echo "Failed to authenticate with Flair." >&2 + exit 1 +fi + +# --- FETCH AND PUBLISH TO MQTT --- +PAYLOAD=$(curl -s -X GET "https://api.flair.co/api/puck2s" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/json") + +do_pub() { + mosquitto_pub -h "$MQTT_BROKER" -p "$MQTT_PORT" -t "${TARGET_TOPIC}/${1}" -m "$2" -r +} + +while read -r puck_data; do + if [ -z "$puck_data" ]; then continue; fi + + # Grab the human-readable name from your payload (e.g., "AstropotaPOD-e9af") + puck_name=$(echo "$puck_data" | jq -r '.attributes.name // empty') + + # Fallback to ID just in case a device doesn't have a custom name assigned + if [ -z "$puck_name" ] || [ "$puck_name" == "null" ]; then + puck_name=$(echo "$puck_data" | jq -r '.id') + fi + + # Extract metrics from payload attributes + temp_c=$(echo "$puck_data" | jq -r '.attributes."current-temperature-c" // empty') + humidity=$(echo "$puck_data" | jq -r '.attributes."current-humidity" // empty') + wifi_rssi=$(echo "$puck_data" | jq -r '.attributes."current-wifi-rssi" // empty') + power_source=$(echo "$puck_data" | jq -r '.attributes."power-source" // empty') + + reporting_interval=$(echo "$puck_data" | jq -r '.attributes."reporting-interval-ds" // empty') + firmware=$(echo "$puck_data" | jq -r '.attributes."firmware-version" // empty') + beacon_ms=$(echo "$puck_data" | jq -r '.attributes."beacon-interval-ms" // empty') + + voltage=$(echo "$puck_data" | jq -r '.attributes.voltage') + last_voltage=$(echo "$puck_data" | jq -r '.attributes."last-reported-voltage"') + if [ "$voltage" == "0" ] || [ "$voltage" == "0.0" ]; then + battery="$last_voltage" + else + battery="$voltage" + fi + + # Dynamic target base matching your requested pattern: AstropotaPOD/sensor/AstropotaPOD-e9af/ + TARGET_TOPIC="${MQTT_TOPIC_BASE}/${puck_name}" + + # Blast sub-topics cleanly to Mosquitto + [ -n "$temp_c" ] && do_pub "temperature" "$temp_c" + [ -n "$humidity" ] && do_pub "humidity" "$humidity" + [ -n "$wifi_rssi" ] && do_pub "wifi_rssi" "$wifi_rssi" + [ -n "$power_source" ] && do_pub "power_source" "$power_source" + [ -n "$battery" ] && do_pub "battery_voltage" "$battery" + [ -n "$reporting_interval" ] && do_pub "reporting_interval" "$reporting_interval" + [ -n "$firmware" ] && do_pub "firmware_version" "$firmware" + [ -n "$beacon_ms" ] && do_pub "beacon_interval" "$beacon_ms" + +# echo "Published to MQTT under: ${TARGET_TOPIC}/" + +done < <(echo "$PAYLOAD" | jq -c '.data[]') diff --git a/install_astropotapod.sh b/install_astropotapod.sh new file mode 100755 index 0000000..5bbc0dd --- /dev/null +++ b/install_astropotapod.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# This assumes a BLANK Raspberry Pi Zero W that we can install our AstropotaPOD stuff onto + +do_toilet() { + echo "$*" | toilet -f standard -F metal +} + +# 1. Update +echo "Updating repositories and ensuring we have the software we need..." +sudo apt -y update && sudo apt -y dist-upgrade && sudo apt -y autoremove + +# 2. Make sure we have the software we need +echo "Installing toilet...I know. Bear with me." +sudo apt -y install toilet figlet +do_toilet "Pre-reqs" +sudo apt -y install git snmp jq bc mosquitto-clients +sudo sed -i 's/^AcceptEnv LANG LC_\*/#AcceptEnv LANG LC_\*/g' /etc/ssh/sshd_config +sudo systemctl restart ssh +do_toilet "Pips (via apt)" +sudo apt -y install python3-requests python3-paho-mqtt + +# 3. Go get some stuff +do_toilet "Log dir" +cd +mkdir logs +#do_toilet "Getting NWC2026-AstropotaPOD" +#[ ! -d "NWC2026-AstropotaPOD" ] && git clone https://git.everwatch.global/eloyd/NWC2026-AstropotaPOD + +# 4. Install Nagios Plugins +do_toilet "Nagios Plugins" +if [ ! -f "/usr/local/nagios/libexec/check_http" ]; then + cd + tar xfz NWC2026-AstropotaPOD/nagios-plugins-2.5.tar.gz + cd nagios-plugins-2.5 + ./configure + make && sudo make install +fi + +# 5. Install NCPA +do_toilet "NCPA Client" +if [ ! -f "/usr/local/ncpa/etc/ncpa.cfg" ]; then + cd; cd NWC2026-AstropotaPOD + sudo apt -y install ./ncpa_3.3.1-1_armhf.deb + sudo sed -i -e "s/^community_string.*/community_string=AstropotamusWasHere/" /usr/local/ncpa/etc/ncpa.cfg + sudo systemctl restart ncpa +fi + +# 6. Install Nagios Mod Gearman +do_toilet "NMG Worker" +if [ ! -f "/usr/local/bin/nagios-mod-gearman-worker" ]; then + cd + git clone https://github.com/NagiosEnterprises/nagios-mod-gearman + cd nagios-mod-gearman + sudo apt -y install automake libncurses-dev gearman libgearman-dev help2man dctrl-tools libperl-dev g++ libltdl-dev pkgconf make dpkg-dev debhelper libssl-dev + sudo ./autogen.sh + sudo ./configure + sudo make install +fi + +# 7. Install check_ncpa clients +do_toilet "NCPA Clients" +cd; cd NWC2026-AstropotaPOD +sudo cp check_ncpa.py /usr/local/nagios/libexec +arch=$(uname -m) +file="check_ncpa.${arch}" +if [ -r "$file" ]; then + echo "Will install $file, since it's here..." + echo "==> Installing ${file} to /usr/local/nagios/libexec..." + sudo install -d /usr/local/nagios/libexec + sudo install -m 0755 ${file} /usr/local/nagios/libexec/check_ncpa + echo "==> Installation complete!" + echo "Installed to: /usr/local/nagios/libexec/check_ncpa" +else +echo "" +echo "###" +echo "###" +echo "###" +echo "### IMPORTANT:" +echo "###" +echo "### You must extract, compile, and install the check_ncpa_c.2.Mar-05-26.tgz beta code to /usr/local/nagios/libexec" +echo "###" +echo "### Failure to do so will result in service check failures that may be hard to diagnose." +echo "###" +echo "###" +echo "###" +echo "" +fi + +# Test +#/usr/local/bin/nagios-mod-gearman-worker --debug=2 --server=192.168.123.16 --key=AstropotamusWasHere --hosts --services --hostgroup=PODhosts --servicegroup=PODservices diff --git a/left_gradient.css b/left_gradient.css new file mode 100644 index 0000000..87db257 --- /dev/null +++ b/left_gradient.css @@ -0,0 +1,75 @@ +/* ========================================================================== + 1. SIDEBAR CANVAS: Unified Deep Space Background + ========================================================================== */ +/* Targets the primary sidebar structural container and its underlying list */ +html body.parent div.parentpage div#leftnav, +html body.parent div.parentpage .leftnav-container, +html body.parent div.parentpage ul.navside { + background-color: #02040a !important; + /* Diagonal 45-degree gradient matches the sweep pattern of the top header */ + background: linear-gradient(45deg, + #02040a 0%, + #060c1f 40%, + #0b1736 100% + ) !important; + border-right: 1px solid #1e3a8a !important; /* Low-contrast sapphire right boundary */ + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5) !important; +} + +/* ========================================================================== + 2. CATEGORY HEADERS: Structural Menu Sections + ========================================================================== */ +/* Style rules for static section headers (e.g., "Views", "Dashboards", "System") */ +html body.parent div.parentpage .menu-section-title, +html body.parent div.parentpage ul.navside li.navside-head { + color: #3b82f6 !important; /* Prominent observatory focus blue */ + font-weight: 600 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + border-bottom: 1px solid rgba(37, 99, 235, 0.15) !important; + padding-bottom: 4px !important; + margin-top: 12px !important; +} + +/* ========================================================================== + 3. NAVIGATION LINKS: Base Typography & Spacing Continuity + ========================================================================== */ +/* Maps target rules for left-hand menu lists while locking the native font layout */ +html body.parent div.parentpage ul.navside li a, +html body.parent div.parentpage .leftnav-container ul li a { + display: block !important; + color: #cbd5e1 !important; /* Clean slate-white match with top navigation */ + text-decoration: none !important; + /* Custom cubic-bezier ensures uniform physics curves with top header changes */ + transition: transform 0.15s cubic-bezier(0.25, 1, 0.5, 1), color 0.15s ease, background-color 0.15s ease !important; +} + +/* ========================================================================== + 4. NAVIGATION INTERACTION: Responsive Left-Hand Menu Pop + ========================================================================== */ +/* Triggers a physical pop response when hovering over left nav elements */ +html body.parent div.parentpage ul.navside li a:hover, +html body.parent div.parentpage .leftnav-container ul li a:hover { + color: #ffffff !important; /* Snaps text to clean white */ + background-color: rgba(255, 255, 255, 0.04) !important; /* Low-opacity selection frame */ + + /* Horizontal push (+4px to the right) functions better in vertical menus than a vertical lift */ + transform: translateX(4px) !important; + + text-shadow: 0 0 8px rgba(255, 255, 255, 0.3) !important; +} + +/* ========================================================================== + 5. MENU TREE UTILITIES: Expand/Collapse Arrow Tweaks + ========================================================================== */ +/* Adjusts standard navigation arrow structures to use the accent blue tint */ +html body.parent div.parentpage ul.navside li i.fa-chevron-right, +html body.parent div.parentpage ul.navside li i.fa-chevron-down, +html body.parent div.parentpage .leftnav-container .fa-caret-right { + color: #1d4ed8 !important; /* Integrated sapphire blue arrow indicators */ + transition: color 0.15s ease !important; +} + +html body.parent div.parentpage ul.navside li a:hover i { + color: #60a5fa !important; /* Highlights arrows on active parent item hover */ +} \ No newline at end of file diff --git a/nagios-plugins-2.5.tar.gz b/nagios-plugins-2.5.tar.gz new file mode 100644 index 0000000..00b69c7 Binary files /dev/null and b/nagios-plugins-2.5.tar.gz differ diff --git a/ncpa_3.3.1-1_armhf.deb b/ncpa_3.3.1-1_armhf.deb new file mode 100644 index 0000000..5f344cf Binary files /dev/null and b/ncpa_3.3.1-1_armhf.deb differ diff --git a/notify-by-ntfy.sh b/notify-by-ntfy.sh new file mode 100755 index 0000000..9ebe4e7 --- /dev/null +++ b/notify-by-ntfy.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +notify_host="https://notify.astropotamus.com" +chanDefault="POD" +alertType="" +msgTitle="" +message="" +tag="exclamation" +priority="3" +state="" +testMode="" + +### Define command for service +### define command { +### command_name notify-service-by-ntfy +### command_line $USER1$/notify-by-ntfy.sh -t service -ha "$HOSTALIAS$" -h "$HOSTNAME$" -hs "$HOSTSTATE$" -ld "$LONGDATETIME$" -nt "$NOTIFICATIONTYPE$" -s "$SERVICEDESC$" -ss "$SERVICESTATE$" +### } + +while [ -n "$1" ]; do + case "$1" in + --test) testMode="true"; shift 1;; + -C|--channel) channel="$2"; shift 1;; + -t|--type) alertType="$2"; shift 2;; + -ha) NAGIOS_HOSTALIAS="$2"; shift 2;; + -h) NAGIOS_HOSTNAME="$2"; shift 2;; + -hs) NAGIOS_HOSTSTATE="$2"; shift 2;; + -ld) NAGIOS_LONGDATETIME="$2"; shift 2;; + -nt) NAGIOS_NOTIFICATIONTYPE="$2"; shift 2;; + -s) NAGIOS_SERVICEDESC="$2"; shift 2;; + -ss) NAGIOS_SERVICESTATE="$2"; shift 2;; + *) shift 1;; + esac +done + +[ -z "$channel" ] && channel="$chanDefault" + +case "$NAGIOS_NOTIFICATIONTYPE" in + PROBLEM) tag="bangbang";; + RECOVERY) tag="ok";; + ACKNOWLEDGEMENT) tag="ok_hand";; + FLAPPINGSTART) tag="hatched_chick";; + FLAPPINGSTOP) tag="hourglass_flowing_sand";; + DOWNTIMESTART) tag="sleeping_bed";; + DOWNTIMEEND) tag="sunny";; + *) tag="exclamation";; +esac + +case "$NAGIOS_HOSTSTATE" in + 0) tag="$tag,+1";; + 1) tag="$tag,-1";; + 2) tag="$tag,-1";; + 3) tag="$tag,question";; +esac + +case "$NAGIOS_SERVICESTATE" in + 0) tag="$tag,+1";; + 1) tag="$tag,-1";; + 2) tag="$tag,warning";; + 3) tag="$tag,question";; +esac + +do_notify() { + [ -n "$1" ] && channel="${channel}-${1}" + curl -s \ + -H "Title: ${msgTitle}" \ + -H "Tags: ${tag}" \ + -H "Priority: ${priority}" \ + -d "${message}" \ + ${notify_host}/${channel} +} + +do_host() { + msgTitle="HOST ${NAGIOS_NOTIFICATIONTYPE}" + message="$NAGIOS_NOTIFICATIONTYPE $NAGIOS_HOSTNAME is $NAGIOS_HOSTSTATE $NAGIOS_LONGDATETIME" + do_notify "Hosts" +} + +do_service() { + msgTitle="SERVICE ${NAGIOS_NOTIFICATIONTYPE}" + message="$NAGIOS_NOTIFICATIONTYPE $NAGIOS_SERVICEDESC @ $NAGIOS_HOSTALIAS is $NAGIOS_SERVICESTATE $NAGIOS_LONGDATETIME" + do_notify "Services" +} + +if [ -n "$testMode" ]; then + NAGIOS_NOTIFICATIONTYPE="Test" + NAGIOS_HOSTNAME="FakeHost" + NAGIOS_HOSTSTATE="OK" + NAGIOS_SERVICEDESC="FakeService" + NAGIOS_HOSTALIAS="FakeHostAlias" + NAGIOS_SERVICESTATE="OK" + NAGIOS_LONGDATETIME="Today" +fi + +case "$alertType" in + h|host|host) do_host;; + s|svc|service) do_service;; + *) shift 1;; +esac + +exit diff --git a/start_gearman.sh b/start_gearman.sh new file mode 100755 index 0000000..997ba01 --- /dev/null +++ b/start_gearman.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +action="start" +xihost="192.168.123.16" +key="NRDP Key for Nagios Server" +logFile="${HOME}/logs/gearman.log" + +start_options="--server=${xihost} --key=${key}" +start_options+=" --min-worker=2" +start_options+=" --max-worker=5" +start_options+=" --spawn-rate=1" +start_options+=" --load_limit1=1" +start_options+=" --load_limit5=2" +start_options+=" --load_limit15=3" +start_options+=" --idle-timeout=600" +start_options+=" --hosts --services --hostgroup=PODhosts --servicegroup=PODservices --logmode=file --logfile=${logFile} --debug=1" + +while [ -n "$1" ]; do + case "$1" in + --start) action="start"; shift 1;; + --stop) action="stop"; shift 1;; + --restart) action="restart"; shift 1;; + *) shift 1;; + esac +done + + +do_stop() { + echo "Stopping nagios-mod-gearman-worker..." + killall /usr/local/bin/nagios-mod-gearman-worker + echo "Done." +} + +do_start() { + echo "Starting nagios-mod-gearman-worker..." + /usr/local/bin/nagios-mod-gearman-worker -d $start_options + echo "Done." +} + +do_restart() { + do_stop + sleep 5 + do_start +} + +do_${action} diff --git a/sunwait b/sunwait new file mode 160000 index 0000000..151d834 --- /dev/null +++ b/sunwait @@ -0,0 +1 @@ +Subproject commit 151d8340a748a4dac7752ebcd38983b2887f5f0c diff --git a/telegraf.sh b/telegraf.sh new file mode 100755 index 0000000..abafd3c --- /dev/null +++ b/telegraf.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +if [ -z "$INFLUX_TOKEN" ]; then + echo "WARNING: You need to set an INFLUX_TOKEN environment variable or else it won't actually work." +fi + +# This is hardcoded here because it's easier. Find it on your own. :-) +PPBAkey="00e086b2-85b9-4562-a6bd-6d5a68ad2c26" +fluxHost="192.168.0.6" +fluxPort="8086" +ppbaHost="192.168.0.151" +ppbaPort="32000" + +do_help() { + echo "Help coming soon. I promise. Do not panic!" + exit +} + +do_preface() { + cat << PREFACE_EOF +[global_tags] + +[agent] + interval = "10s" + round_interval = true + metric_batch_size = 1000 + metric_buffer_limit = 10000 + collection_jitter = "0s" + flush_interval = "10s" + flush_jitter = "0s" + precision = "0s" + +[[outputs.influxdb_v2]] + urls = ["http://${fluxHost}:${fluxPort}"] + token = "$INFLUX_TOKEN" + organization = "Astropotamus" + bucket = "AstropotaPOD" +PREFACE_EOF +} + +do_postface() { + cat << POSTFACE_EOF +POSTFACE_EOF +} + +do_ppba() { + do_preface + cat << PPBA_EOF +[[inputs.http]] + urls = [ + "http://${ppbaHost}:${ppbaPort}/Driver/PPBAdvance/Report?DriverUniqueKey=${PPBAkey}" + ] + name_override = "PPBA" + data_format = "json" + tagexclude = ["url", "host"] + json_query="data.message" + json_string_fields=["dewHubStatus_hub_?_current_isOverCurrent","powerHubStatus_state","powerVariablePortStatus_state"] +PPBA_EOF + do_postface +} + +do_weather() { + do_preface + cat << WEATHER_EOF +[[inputs.http]] + urls=["https://api.openweathermap.org/data/2.5/weather?id=5126015&units=metric&appid=043d58f75a4ffe7cf5d414ead183cb7f"] + data_format = "json" + tag_keys = [ "coord_lon", "coord_lat", "sys_country", "id", "name" ] + json_string_fields = [ "weather_0_main", "weather_0_description" ] + json_time_key = "dt" + json_time_format = "unix" + name_override = "openweathermap" +WEATHER_EOF + do_postface +} + +do_local() { + do_preface + cat << LOCAL_EOF +[[inputs.cpu]] + percpu = true + totalcpu = true + collect_cpu_time = false + report_active = false + core_tags = false +[[inputs.mem]] +[[inputs.system]] +LOCAL_EOF + do_postface +} + +tmpFile=$(mktemp) +while [ -n "$1" ]; do + case "$1" in + -h|--help) do_help;; + --ppba) do_ppba > $tmpFile; shift 1;; + --weather) do_weather > $tmpFile; shift 1;; + --iss) rm $tmpFile; echo "Not yet."; exit;; + --local) do_local > $tmpFile; shift 1;; + *) shift 1;; + esac +done +telegraf --config $tmpFile --once --quiet +rm $tmpFile diff --git a/top_gradient.css b/top_gradient.css new file mode 100644 index 0000000..7a28660 --- /dev/null +++ b/top_gradient.css @@ -0,0 +1,69 @@ +/* ========================================== + 1. THE CANVAS: Elongated Diagonal Sky Wash + ========================================== */ +html body.parent div.parentpage div#header.parenthead { + width: 100% !important; + background-color: #02040a !important; + + /* 45-degree angle stretches the colors sideways across your 1706px screen */ + background: linear-gradient(45deg, + #02040a 0%, + #0b1736 35%, + #1e3a8a 50%, + #0b1736 65%, + #02040a 100% + ) !important; + + border-bottom: 2px solid #2563eb !important; /* Sharp blue accent baseline */ + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.7) !important; +} + +/* ========================================== + 2. TEXT INTERACTION: Increased Lift + ========================================== */ +html body.parent div.parentpage div#header.parenthead .mainmenu div a, +html body.parent div.parentpage div#header.parenthead #config-menulink span a { + display: inline-block !important; + /* Uses a spring-like ease out instead of a flat linear animation */ + transition: transform 0.15s cubic-bezier(0.25, 1, 0.5, 1), color 0.15s ease !important; +} + +html body.parent div.parentpage div#header.parenthead .mainmenu div a:hover, +html body.parent div.parentpage div#header.parenthead #config-menulink span a:hover { + color: #ffffff !important; + transform: translateY(-3px) !important; /* Doubled the lift distance for a distinct reaction */ + text-shadow: 0 0 10px rgba(255, 255, 255, 0.4); /* Subtle text clarity boost */ + text-decoration: none !important; +} + +/* ========================================== + 3. BUTTON INTERACTION: Solid Scale Pop + ========================================== */ +html body.parent div.parentpage div#header.parenthead button.btn, +html body.parent div.parentpage div#header.parenthead .btn-primary { + transition: transform 0.15s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.15s ease, filter 0.15s ease !important; +} + +html body.parent div.parentpage div#header.parenthead button.btn:hover, +html body.parent div.parentpage div#header.parenthead .btn-primary:hover { + transform: translateY(-3px) scale(1.04) !important; /* Increased lift and expansion scale */ + box-shadow: 0 0 15px rgba(37, 99, 235, 0.6) !important; /* Clear highlight glow underneath */ + filter: brightness(1.2) !important; +} + +/* ========================================== + 4. UTILITY ICONS: Quick Response + ========================================== */ +html body.parent div.parentpage div#header.parenthead .header-right a i, +html body.parent div.parentpage div#header.parenthead #profile-button i, +html body.parent div.parentpage div#header.parenthead #help-button i { + display: inline-block !important; + transition: transform 0.12s ease, color 0.12s ease !important; +} + +html body.parent div.parentpage div#header.parenthead .header-right a i:hover, +html body.parent div.parentpage div#header.parenthead #profile-button:hover i, +html body.parent div.parentpage div#header.parenthead #help-button:hover i { + color: #60a5fa !important; + transform: scale(1.2) translateY(-1px) !important; /* Visually pops forward and up slightly */ +}