Add LAN device discovery (ping sweep + ARP, known/unknown labeling)
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s

Runs as a host-level systemd timer on CT122 (scripts/discover-devices.sh)
rather than inside the api container, since real ARP entries live in the
host's network namespace, not Docker's bridge network. See
docs/device-discovery.md for the full writeup, including why literal
passive-only ARP reading was dropped (near-empty result in practice).

API reads the resulting JSON file each poll cycle, cross-references
config/hosts.yaml's knownDevices list by IP, and serves /api/devices.
Dashboard gets a new "Network Devices" table.

Closes #9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:49:22 -06:00
parent d75bd58792
commit 04282232cc
15 changed files with 434 additions and 8 deletions
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Runs on the CT122 host (not in a container) via the homelab-monitor-discover
# systemd timer. Real ARP entries live in the host's network namespace, not an
# isolated Docker bridge network, so this has to run here rather than inside
# the api container — see docs/device-discovery.md.
#
# Pings every address in the subnet (ICMP only, no port scanning) to populate
# the ARP cache, then dumps ip+mac+state pairs as JSON. The API service reads
# this file and does known/unknown labeling using config/hosts.yaml.
set -euo pipefail
SUBNET_PREFIX="${SUBNET_PREFIX:-192.168.1}"
OUTPUT_FILE="${OUTPUT_FILE:-/opt/homelab-monitor/data/devices-raw.json}"
IFACE="${IFACE:-eth0}"
mkdir -p "$(dirname "$OUTPUT_FILE")"
for i in $(seq 1 254); do
ping -c 1 -W 1 "${SUBNET_PREFIX}.${i}" >/dev/null 2>&1 &
# cap concurrency so we don't fork 254 pings at once
if (( i % 32 == 0 )); then wait; fi
done
wait
TMP_FILE="$(mktemp)"
{
echo "["
first=1
# `ip neigh show dev <iface>` output: "<ip> lladdr <mac> <state> [router]"
while read -r ip _ mac state _; do
[[ "$ip" == *:* ]] && continue # skip IPv6 (link-local neighbor entries)
[[ -z "$mac" ]] && continue
case "$state" in
REACHABLE|STALE|DELAY|PERMANENT) ;;
*) continue ;;
esac
[[ $first -eq 0 ]] && echo ","
first=0
printf '{"ip":"%s","mac":"%s","state":"%s"}' "$ip" "$mac" "$state"
done < <(ip neigh show dev "$IFACE")
echo
echo "]"
} > "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"