LinuxnetworkingTested on real hardware

Homelab Network Health Check (Read-Only)

A read-only audit for one Linux host: link state, gateway reachability, a duplicate-IP probe, resolver identity, and a local-vs-public DNS fork test.

DistrosopenSUSE Tumbleweed
Shellbash
Updated
Script
bash
#!/usr/bin/env bash
# homelab-network-health-check.sh — read-only network audit for one Linux host.
# Catches the quiet failures: dead links, absent gateways, duplicate IPs,
# and DNS that answers differently than you think. Changes NOTHING.
#
# Usage:  ./homelab-network-health-check.sh          (as a normal user)
#         sudo ./homelab-network-health-check.sh     (also enables check 3)
set -u

# ==== MAKE THESE VALUES YOUR OWN ==========================================
IFACE=""                  # network interface; empty = auto-detect from default route
EXT_NAME="example.com"    # a public name that should always resolve
INT_NAME=""               # an internal name (e.g. nas.homelab.lan); empty = skip check 5
PUBLIC_DNS="9.9.9.9"      # public resolver used for the second opinion
# ==========================================================================

WARNS=0
warn() { echo "WARN  $1"; WARNS=$((WARNS+1)); }
ok()   { echo "OK    $1"; }
info() { echo "INFO  $1"; }

echo "--- 1) link + address ---"
if [ -z "$IFACE" ]; then
  IFACE=$(ip -o route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev"){print $(i+1); exit}}')
fi
ADDR=""
if [ -z "$IFACE" ]; then
  warn "no default route and no IFACE set — which interface should carry traffic?"
else
  STATE=$(ip -br link show "$IFACE" 2>/dev/null | awk '{print $2}')
  ADDR=$(ip -o -4 addr show "$IFACE" 2>/dev/null | awk '{print $4; exit}')
  case "$STATE" in
    UP|UNKNOWN) ok "$IFACE is ${STATE} with ${ADDR:-NO IPv4 address}" ;;
    *)          warn "$IFACE state is ${STATE:-missing} — link problem?" ;;
  esac
  [ -n "$ADDR" ] || warn "$IFACE has no IPv4 address — DHCP failure, or the wrong interface?"
fi

echo "--- 2) default route + gateway ---"
GW=$(ip -o route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="via"){print $(i+1); exit}}')
if [ -z "${GW:-}" ]; then
  warn "no default route — nothing beyond this subnet is reachable"
elif ping -c 1 -W 2 "$GW" >/dev/null 2>&1; then
  ok "gateway $GW answers ping"
else
  warn "gateway $GW does not answer ping (down — or it filters ICMP; confirm from another device)"
fi

echo "--- 3) duplicate-IP probe for this host's own address ---"
if [ -z "$ADDR" ]; then
  info "no IPv4 address to probe — skipped"
elif ! command -v arping >/dev/null 2>&1; then
  info "arping not installed (iputils) — probe skipped"
elif [ "$(id -u)" -ne 0 ]; then
  info "not root — probe skipped (arping needs CAP_NET_RAW; run with sudo to enable)"
else
  MYIP=${ADDR%%/*}
  if arping -D -c 2 -I "$IFACE" "$MYIP" >/dev/null 2>&1; then
    ok "no other device answers for $MYIP"
  else
    warn "another device also claims $MYIP — a live IP conflict"
  fi
fi

echo "--- 4) resolver identity + external name ---"
NS=$(awk '/^nameserver/{print $2}' /etc/resolv.conf 2>/dev/null | tr '\n' ' ')
info "resolv.conf nameserver(s): ${NS:-none found}"
if command -v resolvectl >/dev/null 2>&1; then
  CUR=$(resolvectl status 2>/dev/null | awk -F': ' '/Current DNS Server/{print $2; exit}')
  [ -n "$CUR" ] && info "systemd-resolved current server: $CUR"
fi
if getent hosts "$EXT_NAME" >/dev/null 2>&1; then
  ok "$EXT_NAME resolves through the system lookup path"
else
  warn "$EXT_NAME does not resolve — resolver down, or no DNS at all"
fi
if command -v dig >/dev/null 2>&1; then
  if [ -n "$(dig +short +time=3 +tries=1 "$EXT_NAME" @"$PUBLIC_DNS" 2>/dev/null)" ]; then
    ok "$EXT_NAME resolves via public resolver $PUBLIC_DNS"
  else
    warn "public resolver $PUBLIC_DNS gave no answer — is outbound DNS blocked?"
  fi
else
  info "dig not installed (bind-utils / dnsutils) — public-resolver compare skipped"
fi

echo "--- 5) internal name (optional) ---"
if [ -z "$INT_NAME" ]; then
  info "INT_NAME not set — skipped (set it to your NAS or router name to test local DNS)"
else
  if getent hosts "$INT_NAME" >/dev/null 2>&1; then
    ok "$INT_NAME resolves through the system lookup path"
  else
    warn "$INT_NAME does not resolve — local DNS records missing, or the wrong resolver answering"
  fi
  if command -v dig >/dev/null 2>&1 && [ -n "${NS:-}" ]; then
    FIRST_NS=${NS%% *}
    if [ -n "$(dig +short +time=3 +tries=1 "$INT_NAME" @"$FIRST_NS" 2>/dev/null)" ]; then
      ok "$INT_NAME answered by configured resolver $FIRST_NS"
    else
      warn "configured resolver $FIRST_NS returns nothing for $INT_NAME"
    fi
  fi
fi

echo
if [ "$WARNS" -gt 0 ]; then
  echo "Result: $WARNS warning(s) — each maps to a diagnosis section in the network-issues post."
  exit 1
fi
echo "Result: all clear."

What this does

First: make these values your own — the interface, test names, and resolver at the top of the script are placeholders (full list in Notes). Edit the marked block before running.

Network failures rarely announce themselves — the link light stays on while the gateway stops answering, a second device quietly claims your address, or DNS keeps working differently on this machine than everywhere else. This script is the five-minute counter: a strictly read-only audit of one Linux host, in the order the network stack actually depends on things. It checks the link and address on your active interface, the default route and whether the gateway answers, whether any other device claims your own IP (the same duplicate-address probe DHCP clients use, per the arping manual), which resolver the machine is really using, and finally the fork test — the same lookup through the system path, your configured resolver, and a public resolver, because the disagreements are where DNS problems hide.

It prints OK/WARN/INFO per check and exits non-zero when anything warns, so you can run it by hand, from cron, or as an Uptime Kuma push monitor. Every warning maps to a diagnosis-and-fix section in the companion post, Green Lights, No Packets.

Prerequisites

  • Any modern Linux with iproute2 (ip) and ping — present nearly everywhere by default.
  • Optional: arping (from iputils) for the duplicate-IP probe, and dig (packaged as bind-utils on openSUSE/Fedora, dnsutils on Debian/Ubuntu) for the resolver comparisons. Missing tools skip their checks with an INFO line instead of failing.
  • Check 3 needs root (sudo) — raw ARP requires the CAP_NET_RAW capability. Everything else runs as a normal user.

Notes

  • Placeholders to replace, all in the marked block at the top: IFACE (leave empty to auto-detect from the default route — right for most single-NIC hosts), EXT_NAME (any public name that should always resolve), INT_NAME (set this to a name only your local DNS knows, like your NAS — it’s the check that catches split-DNS and rebind-protection surprises; empty skips it), and PUBLIC_DNS (9.9.9.9 is Quad9; any public resolver works). If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.
  • Check 2’s ping can cry wolf on gateways that filter ICMP — some routers deliberately don’t answer ping. If the warning surprises you, confirm from a second device before touching anything.
  • Check 3 answers a question most tools can’t: “is anyone else using my address right now?” It probes with the duplicate-address-detection mode from the DHCP standard, so a warning here is a live conflict, not a guess. The hunt for which device is the companion post’s Issue 1.
  • Check 5 is the one to configure. External DNS working while internal names fail is the signature of most homelab DNS incidents — a missing local record, the wrong resolver winning, or a router’s rebind protection eating answers. The companion post’s Issue 4 walks each branch.
  • This audits one host — run it from a second machine too when something smells wrong; a conflict or DNS split often only shows from a particular vantage point.
  • Tested read-only on openSUSE Tumbleweed as a normal user (the root-only probe verified to skip cleanly; run with sudo to enable it). The commands are plain iproute2/iputils/getent and portable across distributions.