diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 6a9fa5d..81506e7 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -12,12 +12,18 @@ interface RawSshHost { diskPaths?: { path: string; label: string }[]; } +interface RawKnownDevice { + ip: string; + name: string; +} + export interface HostsConfig { proxmox: { host: string; node: string; }; sshHosts?: RawSshHost[]; + knownDevices?: RawKnownDevice[]; } export interface AppConfig { @@ -39,6 +45,8 @@ export interface AppConfig { tokenSecret: string; }; sshHosts: SshHostConfig[]; + knownDevices: Map; + discoveryFilePath: string; hosts: HostsConfig; } @@ -80,6 +88,8 @@ export function loadConfig(hostsConfigPath: string): AppConfig { tokenSecret: required("PROXMOX_TOKEN_SECRET"), }, sshHosts, + knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])), + discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json", hosts, }; } diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index a9d4c70..84a053a 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -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 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; } @@ -100,3 +108,45 @@ export function pruneOldSnapshots(db: Database.Database, retentionHours: number) 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[]; +} diff --git a/apps/api/src/discovery/index.ts b/apps/api/src/discovery/index.ts new file mode 100644 index 0000000..24592cc --- /dev/null +++ b/apps/api/src/discovery/index.ts @@ -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 +): Promise { + 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); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d72c418..e5bf78f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,8 @@ import { SshHostCollector } from "./collectors/sshHost.js"; import type { Collector } from "./collectors/types.js"; import { registerAuthRoutes } from "./routes/auth.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"; @@ -41,6 +43,12 @@ async function main() { } } 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 }); @@ -53,6 +61,7 @@ async function main() { registerAuthRoutes(app, db); registerHostRoutes(app, db); + registerDeviceRoutes(app, db); await pollOnce(); setInterval(() => { diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts new file mode 100644 index 0000000..8e4e4ed --- /dev/null +++ b/apps/api/src/routes/devices.ts @@ -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, + })), + }; + }); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c193a81..6e10f35 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -39,3 +39,16 @@ export function me(): Promise<{ username: string }> { export function getHosts(): Promise<{ hosts: HostSummary[] }> { 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"); +} diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx new file mode 100644 index 0000000..9408b17 --- /dev/null +++ b/apps/web/src/components/DeviceTable.tsx @@ -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 ( + + + + + + + + + + + + {sorted.map((d) => ( + + + + + + + + ))} + {sorted.length === 0 && ( + + + + )} + +
StatusIPMACNameLast seen
+ + {d.known ? "known" : "unknown"} + + {d.ip}{d.mac}{d.name ?? "—"}{d.lastSeen}
+ No devices seen yet — discovery runs every 5 minutes. +
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8767a2c..d2635b1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -159,3 +159,55 @@ body { color: #8b949e; 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; +} diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index 1c0f041..c7b0f53 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -1,24 +1,27 @@ 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 { DeviceTable } from "../components/DeviceTable"; const POLL_MS = 15000; export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) { const [hosts, setHosts] = useState([]); + const [devices, setDevices] = useState([]); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function poll() { try { - const { hosts } = await getHosts(); + const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]); if (!cancelled) { - setHosts(hosts); + setHosts(hostsRes.hosts); + setDevices(devicesRes.devices); setError(null); } } 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(); @@ -58,6 +61,12 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge ))} +
+

+ Network Devices ({devices.length}) +

+ +
); } diff --git a/config/hosts.yaml b/config/hosts.yaml index 30a9c2b..e51c24a 100644 --- a/config/hosts.yaml +++ b/config/hosts.yaml @@ -25,3 +25,61 @@ sshHosts: diskPaths: - path: / 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) diff --git a/deploy/systemd/homelab-monitor-discover.service b/deploy/systemd/homelab-monitor-discover.service new file mode 100644 index 0000000..34d7da7 --- /dev/null +++ b/deploy/systemd/homelab-monitor-discover.service @@ -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 diff --git a/deploy/systemd/homelab-monitor-discover.timer b/deploy/systemd/homelab-monitor-discover.timer new file mode 100644 index 0000000..74a1a2b --- /dev/null +++ b/deploy/systemd/homelab-monitor-discover.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run homelab-monitor LAN device discovery every 5 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/docker-compose.yml b/docker-compose.yml index afd967d..9f3b1a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,14 @@ services: - HOSTS_CONFIG_PATH=/app/config/hosts.yaml - DB_PATH=/app/data/monitor.db - SSH_PRIVATE_KEY_PATH=/app/ssh/monitor_ed25519 + - DISCOVERY_FILE_PATH=/app/data/devices-raw.json volumes: - ./config/hosts.yaml:/app/config/hosts.yaml: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: - web @@ -28,6 +32,3 @@ services: networks: web: - -volumes: - monitor-data: diff --git a/docs/device-discovery.md b/docs/device-discovery.md new file mode 100644 index 0000000..2e2cb2d --- /dev/null +++ b/docs/device-discovery.md @@ -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. diff --git a/scripts/discover-devices.sh b/scripts/discover-devices.sh new file mode 100644 index 0000000..c1d5bcd --- /dev/null +++ b/scripts/discover-devices.sh @@ -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 ` output: " lladdr [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"