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.
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) andping— present nearly everywhere by default. - Optional:
arping(from iputils) for the duplicate-IP probe, anddig(packaged as bind-utils on openSUSE/Fedora, dnsutils on Debian/Ubuntu) for the resolver comparisons. Missing tools skip their checks with anINFOline instead of failing. - Check 3 needs root (
sudo) — raw ARP requires theCAP_NET_RAWcapability. 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), andPUBLIC_DNS(9.9.9.9is 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
sudoto enable it). The commands are plainiproute2/iputils/getentand portable across distributions.