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
+10
View File
@@ -12,12 +12,18 @@ interface RawSshHost {
diskPaths?: { path: string; label: string }[]; diskPaths?: { path: string; label: string }[];
} }
interface RawKnownDevice {
ip: string;
name: string;
}
export interface HostsConfig { export interface HostsConfig {
proxmox: { proxmox: {
host: string; host: string;
node: string; node: string;
}; };
sshHosts?: RawSshHost[]; sshHosts?: RawSshHost[];
knownDevices?: RawKnownDevice[];
} }
export interface AppConfig { export interface AppConfig {
@@ -39,6 +45,8 @@ export interface AppConfig {
tokenSecret: string; tokenSecret: string;
}; };
sshHosts: SshHostConfig[]; sshHosts: SshHostConfig[];
knownDevices: Map<string, string>;
discoveryFilePath: string;
hosts: HostsConfig; hosts: HostsConfig;
} }
@@ -80,6 +88,8 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
tokenSecret: required("PROXMOX_TOKEN_SECRET"), tokenSecret: required("PROXMOX_TOKEN_SECRET"),
}, },
sshHosts, sshHosts,
knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])),
discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json",
hosts, hosts,
}; };
} }
+50
View File
@@ -36,6 +36,14 @@ export function openDb(path: string): Database.Database {
); );
CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts); CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts);
CREATE TABLE IF NOT EXISTS devices (
ip TEXT PRIMARY KEY,
mac TEXT NOT NULL,
known_name TEXT,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);
`); `);
return db; return db;
} }
@@ -100,3 +108,45 @@ export function pruneOldSnapshots(db: Database.Database, retentionHours: number)
cutoff: `-${retentionHours} hours`, cutoff: `-${retentionHours} hours`,
}); });
} }
export interface DiscoveredDevice {
ip: string;
mac: string;
knownName: string | null;
}
export function upsertDevices(db: Database.Database, devices: DiscoveredDevice[]): void {
const upsert = db.prepare(`
INSERT INTO devices (ip, mac, known_name)
VALUES (@ip, @mac, @knownName)
ON CONFLICT(ip) DO UPDATE SET
mac = excluded.mac,
known_name = excluded.known_name,
last_seen = datetime('now')
`);
const tx = db.transaction((items: DiscoveredDevice[]) => {
for (const d of items) upsert.run(d);
});
tx(devices);
}
export interface DeviceRow {
ip: string;
mac: string;
known_name: string | null;
first_seen: string;
last_seen: string;
}
// Devices not seen in the last 24h (unplugged, moved, DHCP lease expired) are
// dropped from the list rather than shown as permanently "known but offline" —
// there's no persistent per-device up/down state to track like hosts have.
export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] {
return db
.prepare(
`SELECT ip, mac, known_name, first_seen, last_seen FROM devices
WHERE last_seen > datetime('now', @cutoff)
ORDER BY known_name IS NULL, ip`
)
.all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[];
}
+36
View File
@@ -0,0 +1,36 @@
import { readFile } from "node:fs/promises";
import type Database from "better-sqlite3";
import { upsertDevices, type DiscoveredDevice } from "../db/index.js";
interface RawEntry {
ip: string;
mac: string;
state: string;
}
// Reads the JSON file produced by scripts/discover-devices.sh (a systemd timer
// on the deploy host, not this process) and labels each entry against the
// known-device list from config/hosts.yaml. See docs/device-discovery.md.
export async function refreshDevices(
db: Database.Database,
filePath: string,
knownDevices: Map<string, string>
): Promise<void> {
let raw: RawEntry[];
try {
raw = JSON.parse(await readFile(filePath, "utf8"));
} catch (err) {
// Missing file (timer hasn't run yet, or not deployed on this host) isn't
// fatal — just means no device data this cycle.
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
const devices: DiscoveredDevice[] = raw.map((entry) => ({
ip: entry.ip,
mac: entry.mac,
knownName: knownDevices.get(entry.ip) ?? null,
}));
upsertDevices(db, devices);
}
+9
View File
@@ -10,6 +10,8 @@ import { SshHostCollector } from "./collectors/sshHost.js";
import type { Collector } from "./collectors/types.js"; import type { Collector } from "./collectors/types.js";
import { registerAuthRoutes } from "./routes/auth.js"; import { registerAuthRoutes } from "./routes/auth.js";
import { registerHostRoutes } from "./routes/hosts.js"; import { registerHostRoutes } from "./routes/hosts.js";
import { registerDeviceRoutes } from "./routes/devices.js";
import { refreshDevices } from "./discovery/index.js";
const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml"; const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml";
@@ -41,6 +43,12 @@ async function main() {
} }
} }
pruneOldSnapshots(db, cfg.snapshotRetentionHours); pruneOldSnapshots(db, cfg.snapshotRetentionHours);
try {
await refreshDevices(db, cfg.discoveryFilePath, cfg.knownDevices);
} catch (err) {
app.log.error({ err }, "device discovery refresh failed");
}
} }
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -53,6 +61,7 @@ async function main() {
registerAuthRoutes(app, db); registerAuthRoutes(app, db);
registerHostRoutes(app, db); registerHostRoutes(app, db);
registerDeviceRoutes(app, db);
await pollOnce(); await pollOnce();
setInterval(() => { setInterval(() => {
+25
View File
@@ -0,0 +1,25 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type Database from "better-sqlite3";
import { getRecentDevices } from "../db/index.js";
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.session.username) {
reply.code(401).send({ error: "not authenticated" });
}
}
export function registerDeviceRoutes(app: FastifyInstance, db: Database.Database): void {
app.get("/api/devices", { preHandler: requireAuth }, async () => {
const rows = getRecentDevices(db);
return {
devices: rows.map((r) => ({
ip: r.ip,
mac: r.mac,
name: r.known_name,
known: r.known_name !== null,
firstSeen: r.first_seen,
lastSeen: r.last_seen,
})),
};
});
}
+13
View File
@@ -39,3 +39,16 @@ export function me(): Promise<{ username: string }> {
export function getHosts(): Promise<{ hosts: HostSummary[] }> { export function getHosts(): Promise<{ hosts: HostSummary[] }> {
return request("/api/hosts"); return request("/api/hosts");
} }
export interface Device {
ip: string;
mac: string;
name: string | null;
known: boolean;
firstSeen: string;
lastSeen: string;
}
export function getDevices(): Promise<{ devices: Device[] }> {
return request("/api/devices");
}
+50
View File
@@ -0,0 +1,50 @@
import type { Device } from "../api";
function ipSortKey(ip: string): number[] {
return ip.split(".").map(Number);
}
export function DeviceTable({ devices }: { devices: Device[] }) {
const sorted = [...devices].sort((a, b) => {
if (a.known !== b.known) return a.known ? -1 : 1;
const [ak, bk] = [ipSortKey(a.ip), ipSortKey(b.ip)];
for (let i = 0; i < 4; i++) if (ak[i] !== bk[i]) return ak[i] - bk[i];
return 0;
});
return (
<table className="device-table">
<thead>
<tr>
<th>Status</th>
<th>IP</th>
<th>MAC</th>
<th>Name</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{sorted.map((d) => (
<tr key={d.ip}>
<td>
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
{d.known ? "known" : "unknown"}
</span>
</td>
<td>{d.ip}</td>
<td className="mono">{d.mac}</td>
<td>{d.name ?? "—"}</td>
<td>{d.lastSeen}</td>
</tr>
))}
{sorted.length === 0 && (
<tr>
<td colSpan={5} className="empty">
No devices seen yet discovery runs every 5 minutes.
</td>
</tr>
)}
</tbody>
</table>
);
}
+52
View File
@@ -159,3 +159,55 @@ body {
color: #8b949e; color: #8b949e;
margin-top: 0.35rem; margin-top: 0.35rem;
} }
.device-count {
color: #8b949e;
font-weight: 400;
}
.device-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
font-size: 0.85rem;
}
.device-table th {
text-align: left;
color: #8b949e;
font-weight: 500;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #30363d;
}
.device-table td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #21262d;
}
.device-table .mono {
font-family: ui-monospace, monospace;
color: #8b949e;
}
.device-table .empty {
text-align: center;
color: #8b949e;
padding: 1rem;
}
.device-badge {
font-size: 0.7rem;
padding: 0.1rem 0.5rem;
border-radius: 10px;
}
.device-badge.known {
background: #1a4d2e;
color: #3fb950;
}
.device-badge.unknown {
background: #4d2a1a;
color: #f5a623;
}
+13 -4
View File
@@ -1,24 +1,27 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getHosts, logout, type HostSummary } from "../api"; import { getHosts, getDevices, logout, type HostSummary, type Device } from "../api";
import { HostCard } from "../components/HostCard"; import { HostCard } from "../components/HostCard";
import { DeviceTable } from "../components/DeviceTable";
const POLL_MS = 15000; const POLL_MS = 15000;
export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) { export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) {
const [hosts, setHosts] = useState<HostSummary[]>([]); const [hosts, setHosts] = useState<HostSummary[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function poll() { async function poll() {
try { try {
const { hosts } = await getHosts(); const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]);
if (!cancelled) { if (!cancelled) {
setHosts(hosts); setHosts(hostsRes.hosts);
setDevices(devicesRes.devices);
setError(null); setError(null);
} }
} catch (err) { } catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load hosts"); if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard data");
} }
} }
void poll(); void poll();
@@ -58,6 +61,12 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge
</div> </div>
</section> </section>
))} ))}
<section>
<h2>
Network Devices <span className="device-count">({devices.length})</span>
</h2>
<DeviceTable devices={devices} />
</section>
</div> </div>
); );
} }
+58
View File
@@ -25,3 +25,61 @@ sshHosts:
diskPaths: diskPaths:
- path: / - path: /
label: os label: os
# LAN device discovery reads devices-raw.json (produced by scripts/discover-devices.sh
# via a systemd timer on the deploy host, see docs/device-discovery.md) and labels any
# IP found here as "known" instead of "unknown". Keyed by IP since these are all DHCP
# reservations, not truly static — update if a reservation ever changes.
knownDevices:
- ip: 192.168.1.144
name: pve (Proxmox host)
- ip: 192.168.1.180
name: omv (NAS)
- ip: 192.168.1.171
name: ripper (DVD ripper)
- ip: 192.168.1.103
name: homelab-monitor (this dashboard, CT122)
- ip: 192.168.1.226
name: audiobookshelf (CT100)
- ip: 192.168.1.119
name: nextcloud (CT101)
- ip: 192.168.1.185
name: nginxproxymanager (CT102)
- ip: 192.168.1.125
name: immich (CT103)
- ip: 192.168.1.243
name: jellyfin (CT104)
- ip: 192.168.1.245
name: rclone (CT105)
- ip: 192.168.1.168
name: booklore (CT106)
- ip: 192.168.1.200
name: actualbudget (CT107)
- ip: 192.168.1.135
name: graylog (CT108)
- ip: 192.168.1.172
name: zabbix (CT109)
- ip: 192.168.1.215
name: cloudflared (CT110)
- ip: 192.168.1.246
name: pve-runner / lisilou-portfolio (CT111)
- ip: 192.168.1.237
name: thatguylaserworks (CT112)
- ip: 192.168.1.149
name: n8n (CT113)
- ip: 192.168.1.236
name: keycloak (CT114, being decommissioned)
- ip: 192.168.1.184
name: postgresql (CT115)
- ip: 192.168.1.186
name: gitea (CT116)
- ip: 192.168.1.247
name: gitea-runner (CT117)
- ip: 192.168.1.187
name: homeassistant (CT118)
- ip: 192.168.1.183
name: mosquitto (CT119)
- ip: 192.168.1.241
name: vaultwarden (CT120)
- ip: 192.168.1.208
name: authentik (CT121)
@@ -0,0 +1,10 @@
[Unit]
Description=homelab-monitor LAN device discovery (ping sweep + ARP read)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/homelab-monitor/scripts/discover-devices.sh
Environment=SUBNET_PREFIX=192.168.1
Environment=OUTPUT_FILE=/opt/homelab-monitor/data/devices-raw.json
Environment=IFACE=eth0
@@ -0,0 +1,9 @@
[Unit]
Description=Run homelab-monitor LAN device discovery every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
+5 -4
View File
@@ -8,10 +8,14 @@ services:
- HOSTS_CONFIG_PATH=/app/config/hosts.yaml - HOSTS_CONFIG_PATH=/app/config/hosts.yaml
- DB_PATH=/app/data/monitor.db - DB_PATH=/app/data/monitor.db
- SSH_PRIVATE_KEY_PATH=/app/ssh/monitor_ed25519 - SSH_PRIVATE_KEY_PATH=/app/ssh/monitor_ed25519
- DISCOVERY_FILE_PATH=/app/data/devices-raw.json
volumes: volumes:
- ./config/hosts.yaml:/app/config/hosts.yaml:ro - ./config/hosts.yaml:/app/config/hosts.yaml:ro
- ./ssh/monitor_ed25519:/app/ssh/monitor_ed25519:ro - ./ssh/monitor_ed25519:/app/ssh/monitor_ed25519:ro
- monitor-data:/app/data # Bind mount (not a named volume) so scripts/discover-devices.sh, which
# runs on the host via systemd (see deploy/systemd/), can write
# devices-raw.json into the same place the container reads it from.
- ./data:/app/data
networks: networks:
- web - web
@@ -28,6 +32,3 @@ services:
networks: networks:
web: web:
volumes:
monitor-data:
+49
View File
@@ -0,0 +1,49 @@
# LAN device discovery
Shows every device currently on the LAN, labeled known/unknown, in the dashboard's
"Network Devices" section.
## Why this isn't pure passive ARP reading
The original idea was to just read the ARP table (no active network traffic). In
practice that produces a nearly empty list: a host's ARP cache only contains entries
for peers it has actually exchanged traffic with, and the dashboard container has no
reason to talk to most LAN devices on its own. So discovery does a lightweight ICMP
ping sweep (no port scanning) first, to populate the cache, then reads it.
## Why this runs outside the Docker container
Real ARP entries live in the network namespace of whichever host is directly on the
LAN interface. The `api` container sits behind Docker's own bridge network — pinging
from inside it and reading `/proc/net/arp` there would show Docker's internal network,
not the actual LAN. Rather than switch the containers to `network_mode: host` (loses
container network isolation), discovery runs as a small script directly on the CT122
host via a systemd timer — the same pattern the homelab already uses for host-level
periodic jobs like the rclone backups on CT105.
## Pieces
- `scripts/discover-devices.sh` — ping sweep + `ip neigh show` parse, writes
`/opt/homelab-monitor/data/devices-raw.json` (`[{ip, mac, state}]`).
- `deploy/systemd/homelab-monitor-discover.{service,timer}` — runs the script every
5 minutes on the CT122 host (not in Docker).
- `apps/api/src/discovery/index.ts` — reads that JSON file each poll cycle, labels
entries against `knownDevices` in `config/hosts.yaml` (matched by IP), upserts into
the `devices` SQLite table.
- `GET /api/devices` — serves devices seen in the last 24h (older entries are dropped
rather than shown as stale-known, since there's no per-device up/down tracking).
## Deploying/updating
```bash
scp scripts/discover-devices.sh homelab-monitor:/opt/homelab-monitor/scripts/
scp deploy/systemd/homelab-monitor-discover.* homelab-monitor:/etc/systemd/system/
ssh homelab-monitor "chmod +x /opt/homelab-monitor/scripts/discover-devices.sh && \
systemctl daemon-reload && systemctl enable --now homelab-monitor-discover.timer"
```
## Known limitation
`knownDevices` in `config/hosts.yaml` is matched by **IP**, not MAC — fine as long as
DHCP reservations don't change, but a device losing its reservation would show up as
"unknown" until the config is updated by hand.
+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"