Files
Varaverk/Watchdogs/System/network_watchdog.sh
T
Gmer4Lfe ea9f39a803 watchdogs: make healthy-state outputs always visible
log() is gated on ENABLE_LOGGING — silent on normal runs. Healthy
confirmations (All healthy, Network healthy, Storage healthy, WebGUI
healthy) were invisible, making banners appear with nothing after them.
Switched all four to plain echo so they show every cycle.
2026-05-24 17:25:33 -04:00

319 lines
14 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ================================= Network Watchdog ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Services-layer connectivity — checks that the outside world can actually reach
# what it needs to reach. Silent when everything is reachable. Only fires when
# something in the connectivity chain has broken.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Check 1 — Internet Connectivity
# curl external endpoint → fail = alert + skip all remaining checks.
# Internet down means DDNS and NPM checks would false-positive — gating prevents noise.
#
# Check 2 — DDNS (Cloudflare)
# Public IP via ifconfig.me vs DNS record via dig @1.1.1.1.
# Match → pass (silent). Mismatch → restart DDNS container + notify.
# Container restart triggers an immediate Cloudflare record update.
#
# Check 3 — Tailscale
# tailscale status → Running → pass (silent). Not running → notify.
# Notify only — no restart attempt. Tailscale state issues warrant human review.
#
# Check 4 — NPM Proxy (external check)
# curl external URL → 2-strike system before restarting NginxProxyManager.
# Strike 1: warn + notify. Strike 2: restart NPM + notify + clear strikes.
# Strikes auto-clear when the external URL becomes reachable again.
# External check — verifies the full stack (DNS → NPM → backend), not just NPM running.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# docker restart requires root.
#
# Single Instance Lock
# acquire_lock prevents concurrent runs.
#
# Internet Gates All Checks
# If internet is down, DDNS and NPM checks are skipped — no cascade of false positives.
#
# Strike Before Acting on NPM
# Single curl failure could be transient DNS hiccup or CDN blip.
# Two consecutive failures confirms NPM is the problem.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# NETWORK_WATCHDOG_ENABLED toggle entire watchdog (default: true)
# NETWORK_WATCHDOG_INTERNET_URL endpoint for internet reachability check
# NETWORK_WATCHDOG_INTERNET_TIMEOUT curl timeout in seconds for internet check
# NETWORK_WATCHDOG_CHECK_TAILSCALE toggle tailscale check (default: true)
# NETWORK_WATCHDOG_NPM_TIMEOUT curl timeout for NPM external check
# NETWORK_WATCHDOG_NPM_STRIKE_LIMIT consecutive failures before NPM restart
# NETWORK_WATCHDOG_NPM_STATE_FILE strike count persistence (/tmp — resets on reboot)
#
# host*.conf (host-specific)
#
# HOST*_NETWORK_WATCHDOG_DDNS_DOMAIN domain to resolve and compare to public IP
# HOST*_NETWORK_WATCHDOG_DDNS_CONTAINER container to restart on DDNS mismatch
# HOST*_NETWORK_WATCHDOG_NPM_URL external URL to test full proxy stack
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# network_watchdog.sh
# Run all connectivity checks. Silent when healthy.
#
# network_watchdog.sh --dry-run
# Run all checks without restarting any containers.
#
# network_watchdog.sh --status
# Show configuration, current public IP, DNS record, NPM strike state.
#
# network_watchdog.sh --log
# Verbose output — show each check result even when passing.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Resolve host-specific config ━━━
# ==============================================================================================
_ddns_domain_var="${MY_ID}_NETWORK_WATCHDOG_DDNS_DOMAIN"
_ddns_container_var="${MY_ID}_NETWORK_WATCHDOG_DDNS_CONTAINER"
_npm_url_var="${MY_ID}_NETWORK_WATCHDOG_NPM_URL"
DDNS_DOMAIN="${!_ddns_domain_var:-}"
DDNS_CONTAINER="${!_ddns_container_var:-}"
NPM_URL="${!_npm_url_var:-}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
validate_unraid_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
[[ "${NETWORK_WATCHDOG_ENABLED:-true}" != "true" ]] && log "Network watchdog disabled" && exit 0
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be restarted"
touch "${NETWORK_WATCHDOG_NPM_STATE_FILE}" 2>/dev/null
# ━━━ Strike helpers ━━━
get_strikes() { grep "^${1}:" "${2}" 2>/dev/null | cut -d: -f2 || echo "0"; }
set_strikes() {
local key="$1" count="$2" file="$3"
if grep -q "^${key}:" "$file" 2>/dev/null; then
sed -i "s|^${key}:.*|${key}:${count}|" "$file"
else
echo "${key}:${count}" >> "$file"
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY NETWORK WATCHDOG STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Internet URL: ${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}"
echo "$ICON_GEAR DDNS domain: ${DDNS_DOMAIN:-not configured}"
echo "$ICON_GEAR DDNS container: ${DDNS_CONTAINER:-not configured}"
echo "$ICON_GEAR Tailscale: ${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}"
echo "$ICON_GEAR NPM URL: ${NPM_URL:-not configured}"
echo "$ICON_GEAR NPM strikes: $(get_strikes "npm" "${NETWORK_WATCHDOG_NPM_STATE_FILE}") / ${NETWORK_WATCHDOG_NPM_STRIKE_LIMIT:-2}"
echo ""
echo "── Current State ──"
if curl -sf --max-time "${NETWORK_WATCHDOG_INTERNET_TIMEOUT:-5}" \
"${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}" >/dev/null 2>&1; then
echo " $ICON_SUCCESS Internet: reachable"
else
echo " $ICON_ERROR Internet: NOT reachable"
fi
if [[ -n "$DDNS_DOMAIN" ]]; then
_pub=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null | tr -d '[:space:]')
_dns=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo " $ICON_GEAR Public IP: ${_pub:-unknown}"
echo " $ICON_GEAR DNS record: ${_dns:-unknown}"
[[ "$_pub" == "$_dns" ]] && \
echo " $ICON_SUCCESS DDNS: in sync" || \
echo " $ICON_ERROR DDNS: MISMATCH — public=$_pub dns=$_dns"
else
echo " $ICON_GEAR DDNS: not configured for $MY_ID"
fi
if [[ "${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}" == "true" ]] && command -v tailscale >/dev/null 2>&1; then
if tailscale status --json 2>/dev/null | grep -qE '"BackendState":\s*"Running"'; then
echo " $ICON_SUCCESS Tailscale: running"
else
echo " $ICON_ERROR Tailscale: NOT running"
fi
else
echo " $ICON_GEAR Tailscale: check disabled or not installed"
fi
if [[ -n "$NPM_URL" ]]; then
if curl -sf --max-time "${NETWORK_WATCHDOG_NPM_TIMEOUT:-10}" "$NPM_URL" >/dev/null 2>&1; then
echo " $ICON_SUCCESS NPM proxy: reachable ($NPM_URL)"
else
echo " $ICON_ERROR NPM proxy: NOT reachable ($NPM_URL)"
fi
else
echo " $ICON_GEAR NPM proxy: not configured for $MY_ID"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Check 1 — Internet Connectivity ━━━
# ==============================================================================================
echo "━━━ $ICON_NET Network Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
ISSUES=0
if ! curl -sf --max-time "${NETWORK_WATCHDOG_INTERNET_TIMEOUT:-5}" \
"${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}" >/dev/null 2>&1; then
warn "$ICON_ERROR Internet not reachable — skipping DDNS, Tailscale, and NPM checks"
notify "Network watchdog: internet not reachable on $(hostname) ($MY_ID)" \
"Network Watchdog" "warning"
exit 1
fi
log "$ICON_SUCCESS Internet reachable"
# ==============================================================================================
# ━━━ Check 2 — DDNS ━━━
# ==============================================================================================
if [[ -n "$DDNS_DOMAIN" ]] && [[ -n "$DDNS_CONTAINER" ]]; then
PUBLIC_IP=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null | tr -d '[:space:]')
DNS_IP=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [[ -z "$PUBLIC_IP" ]]; then
warn "Could not determine public IP — skipping DDNS check"
elif [[ -z "$DNS_IP" ]]; then
warn "Could not resolve $DDNS_DOMAIN — skipping DDNS check"
elif [[ "$PUBLIC_IP" == "$DNS_IP" ]]; then
log "$ICON_SUCCESS DDNS in sync — $DDNS_DOMAIN$DNS_IP"
else
warn "$ICON_ERROR DDNS mismatch — public=$PUBLIC_IP dns=$DNS_IP"
(( ISSUES++ ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $DDNS_CONTAINER"
else
warn "Restarting $DDNS_CONTAINER to trigger Cloudflare update..."
if docker restart "$DDNS_CONTAINER" >/dev/null 2>&1; then
warn "$DDNS_CONTAINER restarted ✅"
notify "DDNS mismatch on $(hostname) ($MY_ID) — $DDNS_DOMAIN was $DNS_IP, public is $PUBLIC_IP$DDNS_CONTAINER restarted" \
"Network Watchdog" "warning"
else
warn "$DDNS_CONTAINER restart failed"
notify "DDNS mismatch on $(hostname) ($MY_ID) — $DDNS_CONTAINER restart failed — manual intervention needed" \
"Network Watchdog" "warning"
fi
fi
fi
else
log "DDNS check not configured for $MY_ID — skipping"
fi
# ==============================================================================================
# ━━━ Check 3 — Tailscale ━━━
# ==============================================================================================
if [[ "${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}" == "true" ]]; then
if ! command -v tailscale >/dev/null 2>&1; then
log "Tailscale not installed — skipping"
elif tailscale status --json 2>/dev/null | grep -qE '"BackendState":\s*"Running"'; then
log "$ICON_SUCCESS Tailscale running"
else
warn "$ICON_ERROR Tailscale not in Running state"
notify "Tailscale not running on $(hostname) ($MY_ID) — manual check needed" \
"Network Watchdog" "warning"
fi
fi
# ==============================================================================================
# ━━━ Check 4 — NPM Proxy (external) ━━━
# ==============================================================================================
if [[ -n "$NPM_URL" ]]; then
NPM_STRIKES=$(get_strikes "npm" "${NETWORK_WATCHDOG_NPM_STATE_FILE}")
NPM_STRIKE_LIMIT="${NETWORK_WATCHDOG_NPM_STRIKE_LIMIT:-2}"
if curl -sf --max-time "${NETWORK_WATCHDOG_NPM_TIMEOUT:-10}" "$NPM_URL" >/dev/null 2>&1; then
log "$ICON_SUCCESS NPM proxy reachable — $NPM_URL"
if [[ "$NPM_STRIKES" -gt 0 ]]; then
log "NPM strikes cleared (was $NPM_STRIKES)"
set_strikes "npm" 0 "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
fi
else
NPM_STRIKES=$(( NPM_STRIKES + 1 ))
set_strikes "npm" "$NPM_STRIKES" "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
(( ISSUES++ ))
if [[ "$NPM_STRIKES" -lt "$NPM_STRIKE_LIMIT" ]]; then
warn "$ICON_ERROR NPM proxy not reachable — $NPM_URL (strike $NPM_STRIKES/$NPM_STRIKE_LIMIT)"
notify "NPM proxy not reachable on $(hostname) ($MY_ID) — $NPM_URL (strike $NPM_STRIKES/$NPM_STRIKE_LIMIT)" \
"Network Watchdog" "warning"
else
warn "$ICON_ERROR NPM proxy strike limit reached ($NPM_STRIKES) — restarting NginxProxyManager"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart NginxProxyManager"
else
if docker restart NginxProxyManager >/dev/null 2>&1; then
warn "NginxProxyManager restarted ✅"
set_strikes "npm" 0 "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
notify "NPM proxy restarted on $(hostname) ($MY_ID) — $NPM_URL was unreachable for $NPM_STRIKES cycles" \
"Network Watchdog" "warning"
else
warn "NginxProxyManager restart failed — manual intervention needed"
notify "NPM proxy restart FAILED on $(hostname) ($MY_ID) — manual intervention needed" \
"Network Watchdog" "warning"
fi
fi
fi
fi
else
log "NPM check not configured for $MY_ID — skipping"
fi
# ==============================================================================================
# ━━━ Exit ━━━
# ==============================================================================================
if [[ "$ISSUES" -gt 0 ]]; then
exit 1
else
echo "Network healthy ✅ ($(date '+%H:%M:%S'))"
exit 0
fi