From 1caad69448d15f0b5dd0383a00f137dac7bba4e2 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Sun, 12 Jul 2026 23:03:06 -0600 Subject: [PATCH] Identify unknown devices: OUI vendor lookup, mDNS, manual labels - OUI: mac-oui-lookup package resolves vendor from the MAC prefix (computed on read, no storage needed). Already correctly identifies the LXC host prefix as "Proxmox Server Solutions GmbH" and several "unknown" devices as "Amazon Technologies Inc." -- likely the Echo Dots / Ring gear. - mDNS: discover-devices.sh now runs avahi-resolve per discovered IP (parallel, bounded 2s timeout per host so one non-mDNS device can't stall the run), stored in a new devices.mdns_hostname column. - Manual labels: new device_labels table keyed by MAC (survives DHCP IP changes), PUT/DELETE /api/devices/:mac/label, inline-editable Name cell in the dashboard. Deliberately separate from vendor/mDNS info -- those are shown as an italic *hint* for unlabeled devices, not treated as "known" until the admin actually confirms one. - Fixed the Name column's sort comparator to match what's rendered (name, else vendor/mDNS hint) instead of just the raw name field -- caught while reasoning through what the existing sort test would actually need to assert once hints appear in the column. Part of #15 (OUI/mDNS/manual labels done; on-demand deep-check next). Co-Authored-By: Claude Sonnet 5 --- apps/api/package-lock.json | 7 +++ apps/api/package.json | 1 + apps/api/src/db/index.ts | 42 ++++++++++++-- apps/api/src/discovery/index.ts | 2 + apps/api/src/routes/devices.ts | 58 +++++++++++++++---- apps/web/src/api.ts | 13 +++++ apps/web/src/components/DeviceTable.tsx | 75 +++++++++++++++++++++++-- apps/web/src/index.css | 41 ++++++++++++++ apps/web/src/pages/Dashboard.tsx | 36 ++++++------ scripts/discover-devices.sh | 50 ++++++++++++++--- 10 files changed, 281 insertions(+), 44 deletions(-) diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index 95a786f..c4ab6bb 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -15,6 +15,7 @@ "better-sqlite3": "^11.9.1", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "mac-oui-lookup": "^1.1.4", "openid-client": "^6.8.4", "ssh2": "^1.16.0", "undici": "^7.3.0", @@ -1577,6 +1578,12 @@ "node": "20 || >=22" } }, + "node_modules/mac-oui-lookup": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mac-oui-lookup/-/mac-oui-lookup-1.1.4.tgz", + "integrity": "sha512-VuzR8PnMef92j86Mspx8gRwEtW3DjIR2xu2G5x3ezszdopEjVhrrI0r9SpkfPqJMQbsbWRnJrFxTKMXiUaJHHQ==", + "license": "MIT" + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index f6106f2..98a3543 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "better-sqlite3": "^11.9.1", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "mac-oui-lookup": "^1.1.4", "openid-client": "^6.8.4", "ssh2": "^1.16.0", "undici": "^7.3.0", diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index b5fcf57..7456689 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -41,10 +41,22 @@ export function openDb(path: string): Database.Database { ip TEXT PRIMARY KEY, mac TEXT NOT NULL, known_name TEXT, + mdns_hostname TEXT, first_seen TEXT NOT NULL DEFAULT (datetime('now')), last_seen TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- Manual admin-assigned names, keyed by MAC (not IP, which can change on + -- DHCP renewal). Overrides both known_name and mdns_hostname when present. + CREATE TABLE IF NOT EXISTS device_labels ( + mac TEXT PRIMARY KEY, + label TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); + // Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF + // NOT EXISTS above doesn't touch already-existing tables). + db.exec(`ALTER TABLE devices ADD COLUMN IF NOT EXISTS mdns_hostname TEXT`); return db; } @@ -140,15 +152,17 @@ export interface DiscoveredDevice { ip: string; mac: string; knownName: string | null; + mdnsHostname: 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) + INSERT INTO devices (ip, mac, known_name, mdns_hostname) + VALUES (@ip, @mac, @knownName, @mdnsHostname) ON CONFLICT(ip) DO UPDATE SET mac = excluded.mac, known_name = excluded.known_name, + mdns_hostname = excluded.mdns_hostname, last_seen = datetime('now') `); const tx = db.transaction((items: DiscoveredDevice[]) => { @@ -161,6 +175,8 @@ export interface DeviceRow { ip: string; mac: string; known_name: string | null; + mdns_hostname: string | null; + manual_label: string | null; first_seen: string; last_seen: string; } @@ -168,12 +184,28 @@ export interface DeviceRow { // 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. +// Manual labels are joined by MAC (survives IP changes) and take priority — +// see effectiveName() in routes/devices.ts for the full precedence order. 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` + `SELECT d.ip, d.mac, d.known_name, d.mdns_hostname, l.label AS manual_label, + d.first_seen, d.last_seen + FROM devices d + LEFT JOIN device_labels l ON l.mac = d.mac + WHERE d.last_seen > datetime('now', @cutoff) + ORDER BY (l.label IS NULL AND d.known_name IS NULL), d.ip` ) .all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[]; } + +export function setDeviceLabel(db: Database.Database, mac: string, label: string): void { + db.prepare( + `INSERT INTO device_labels (mac, label) VALUES (@mac, @label) + ON CONFLICT(mac) DO UPDATE SET label = excluded.label, updated_at = datetime('now')` + ).run({ mac, label }); +} + +export function clearDeviceLabel(db: Database.Database, mac: string): void { + db.prepare(`DELETE FROM device_labels WHERE mac = @mac`).run({ mac }); +} diff --git a/apps/api/src/discovery/index.ts b/apps/api/src/discovery/index.ts index 24592cc..a7fc8ac 100644 --- a/apps/api/src/discovery/index.ts +++ b/apps/api/src/discovery/index.ts @@ -6,6 +6,7 @@ interface RawEntry { ip: string; mac: string; state: string; + mdnsHostname?: string; } // Reads the JSON file produced by scripts/discover-devices.sh (a systemd timer @@ -30,6 +31,7 @@ export async function refreshDevices( ip: entry.ip, mac: entry.mac, knownName: knownDevices.get(entry.ip) ?? null, + mdnsHostname: entry.mdnsHostname ?? null, })); upsertDevices(db, devices); diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts index 8e4e4ed..bb9e71b 100644 --- a/apps/api/src/routes/devices.ts +++ b/apps/api/src/routes/devices.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type Database from "better-sqlite3"; -import { getRecentDevices } from "../db/index.js"; +import { getVendor } from "mac-oui-lookup"; +import { getRecentDevices, setDeviceLabel, clearDeviceLabel, type DeviceRow } from "../db/index.js"; async function requireAuth(req: FastifyRequest, reply: FastifyReply) { if (!req.session.username) { @@ -8,18 +9,53 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) { } } +// Manual label wins (deliberately set by the admin), then the static +// config/hosts.yaml known list, then the passively-resolved mDNS hostname — +// which is informational only and does NOT make a device "known" on its own, +// since nobody has actually vetted it yet. +function toApiDevice(r: DeviceRow) { + const name = r.manual_label ?? r.known_name ?? null; + return { + ip: r.ip, + mac: r.mac, + name, + known: r.manual_label !== null || r.known_name !== null, + vendor: getVendor(r.mac), + mdnsHostname: r.mdns_hostname, + firstSeen: r.first_seen, + lastSeen: r.last_seen, + }; +} + +const MAC_RE = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i; + 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, - })), - }; + return { devices: rows.map(toApiDevice) }; }); + + app.put<{ Params: { mac: string }; Body: { label: string } }>( + "/api/devices/:mac/label", + { preHandler: requireAuth }, + async (req, reply) => { + const mac = req.params.mac.toLowerCase(); + const label = req.body?.label?.trim(); + if (!MAC_RE.test(mac)) return reply.code(400).send({ error: "invalid MAC address" }); + if (!label) return reply.code(400).send({ error: "label must not be empty" }); + setDeviceLabel(db, mac, label); + return { ok: true }; + } + ); + + app.delete<{ Params: { mac: string } }>( + "/api/devices/:mac/label", + { preHandler: requireAuth }, + async (req, reply) => { + const mac = req.params.mac.toLowerCase(); + if (!MAC_RE.test(mac)) return reply.code(400).send({ error: "invalid MAC address" }); + clearDeviceLabel(db, mac); + return { ok: true }; + } + ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index f49b128..9d9dfbd 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -58,6 +58,8 @@ export interface Device { mac: string; name: string | null; known: boolean; + vendor: string | null; + mdnsHostname: string | null; firstSeen: string; lastSeen: string; } @@ -65,3 +67,14 @@ export interface Device { export function getDevices(): Promise<{ devices: Device[] }> { return request("/api/devices"); } + +export function setDeviceLabel(mac: string, label: string): Promise<{ ok: true }> { + return request(`/api/devices/${encodeURIComponent(mac)}/label`, { + method: "PUT", + body: JSON.stringify({ label }), + }); +} + +export function clearDeviceLabel(mac: string): Promise<{ ok: true }> { + return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" }); +} diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx index 894efb4..f370f2f 100644 --- a/apps/web/src/components/DeviceTable.tsx +++ b/apps/web/src/components/DeviceTable.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import type { Device } from "../api"; +import { setDeviceLabel, clearDeviceLabel, type Device } from "../api"; function ipSortKey(ip: string): number[] { return ip.split(".").map(Number); @@ -13,13 +13,21 @@ function compareIp(a: Device, b: Device): number { type SortKey = "status" | "ip" | "mac" | "name" | "lastSeen"; +// Matches what NameCell actually renders (name, else vendor/mDNS hint, else +// nothing) -- sorting by the raw `name` field alone would put hint-only rows +// in "empty string" position while displaying vendor text that visually +// belongs elsewhere in the alphabet. +function displayName(d: Device): string { + return d.name ?? d.mdnsHostname ?? d.vendor ?? ""; +} + // "known" sorts before "unknown" lexically already, so treating status as its // display string keeps the original known-first default with no special-casing. const COMPARATORS: Record number> = { status: (a, b) => (a.known === b.known ? 0 : a.known ? -1 : 1), ip: compareIp, mac: (a, b) => a.mac.localeCompare(b.mac), - name: (a, b) => (a.name ?? "").localeCompare(b.name ?? ""), + name: (a, b) => displayName(a).localeCompare(displayName(b)), lastSeen: (a, b) => a.lastSeen.localeCompare(b.lastSeen), }; @@ -31,7 +39,64 @@ const COLUMNS: { key: SortKey; label: string }[] = [ { key: "lastSeen", label: "Last seen" }, ]; -export function DeviceTable({ devices }: { devices: Device[] }) { +function NameCell({ device, onDeviceChanged }: { device: Device; onDeviceChanged: () => void }) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(device.name ?? ""); + const [saving, setSaving] = useState(false); + + if (editing) { + return ( +
{ + e.preventDefault(); + setSaving(true); + try { + const trimmed = value.trim(); + if (trimmed) await setDeviceLabel(device.mac, trimmed); + else await clearDeviceLabel(device.mac); + setEditing(false); + onDeviceChanged(); + } finally { + setSaving(false); + } + }} + > + setValue(e.target.value)} + placeholder="Label this device" + disabled={saving} + /> + + +
+ ); + } + + // Vendor/mDNS hint shown only when there's no deliberate name yet -- these + // are automated guesses, not something the admin has actually confirmed. + const hint = device.mdnsHostname ?? device.vendor; + + return ( + setEditing(true)} title="Click to set a label"> + {device.name ?? (hint ? {hint} : "—")} + + ); +} + +export function DeviceTable({ + devices, + onDeviceChanged, +}: { + devices: Device[]; + onDeviceChanged: () => void; +}) { const [sortKey, setSortKey] = useState("status"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); @@ -76,7 +141,9 @@ export function DeviceTable({ devices }: { devices: Device[] }) { {d.ip} {d.mac} - {d.name ?? "—"} + + + {d.lastSeen} ))} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5340176..e78a90e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -220,6 +220,47 @@ body { color: #58a6ff; } +.name-cell { + cursor: pointer; + display: inline-block; + min-width: 3rem; +} + +.name-cell:hover { + text-decoration: underline dotted; +} + +.name-hint { + color: #8b949e; + font-style: italic; +} + +.label-edit-form { + display: flex; + gap: 0.3rem; + align-items: center; +} + +.label-edit-form input { + padding: 0.2rem 0.4rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #0d1117; + color: #e6edf3; + font-size: 0.85rem; + width: 160px; +} + +.label-edit-form button { + padding: 0.2rem 0.5rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #21262d; + color: #e6edf3; + font-size: 0.8rem; + cursor: pointer; +} + .device-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #21262d; diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index c7b0f53..b797259 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getHosts, getDevices, logout, type HostSummary, type Device } from "../api"; import { HostCard } from "../components/HostCard"; import { DeviceTable } from "../components/DeviceTable"; @@ -9,28 +9,30 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge const [hosts, setHosts] = useState([]); const [devices, setDevices] = useState([]); const [error, setError] = useState(null); + const cancelledRef = useRef(false); + + const poll = useCallback(async () => { + try { + const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]); + if (!cancelledRef.current) { + setHosts(hostsRes.hosts); + setDevices(devicesRes.devices); + setError(null); + } + } catch (err) { + if (!cancelledRef.current) setError(err instanceof Error ? err.message : "Failed to load dashboard data"); + } + }, []); useEffect(() => { - let cancelled = false; - async function poll() { - try { - const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]); - if (!cancelled) { - setHosts(hostsRes.hosts); - setDevices(devicesRes.devices); - setError(null); - } - } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard data"); - } - } + cancelledRef.current = false; void poll(); const id = setInterval(poll, POLL_MS); return () => { - cancelled = true; + cancelledRef.current = true; clearInterval(id); }; - }, []); + }, [poll]); const groups = groupBy(hosts, (h) => h.group); @@ -65,7 +67,7 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge

Network Devices ({devices.length})

- + ); diff --git a/scripts/discover-devices.sh b/scripts/discover-devices.sh index c1d5bcd..f0a0e50 100755 --- a/scripts/discover-devices.sh +++ b/scripts/discover-devices.sh @@ -5,8 +5,10 @@ # 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. +# the ARP cache, then mDNS-resolves each responder (avahi-resolve, bounded +# per-IP timeout so one non-mDNS device can't stall the whole run), and dumps +# ip+mac+state+mdnsHostname 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}" @@ -22,22 +24,56 @@ for i in $(seq 1 254); do done wait +MDNS_DIR="$(mktemp -d)" +trap 'rm -rf "$MDNS_DIR"' EXIT + +# `ip neigh show dev ` output: " lladdr [router]" +mapfile -t neighbors < <(ip neigh show dev "$IFACE") + +i=0 +for line in "${neighbors[@]}"; do + read -r ip _ mac state _ <<< "$line" + [[ "$ip" == *:* ]] && continue + [[ -z "$mac" ]] && continue + case "$state" in + REACHABLE|STALE|DELAY|PERMANENT) ;; + *) continue ;; + esac + ( + hostname=$(timeout 2 avahi-resolve -a "$ip" 2>/dev/null | awk '{print $2}') + echo "${hostname:-}" > "$MDNS_DIR/$ip" + ) & + i=$((i + 1)) + 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) + for line in "${neighbors[@]}"; do + read -r ip _ mac state _ <<< "$line" + [[ "$ip" == *:* ]] && continue [[ -z "$mac" ]] && continue case "$state" in REACHABLE|STALE|DELAY|PERMANENT) ;; *) continue ;; esac + mdns="" + [[ -f "$MDNS_DIR/$ip" ]] && mdns=$(cat "$MDNS_DIR/$ip") + # Minimal JSON string escaping -- mDNS hostnames come from the network, + # not a source we control. + mdns="${mdns//\\/\\\\}" + mdns="${mdns//\"/\\\"}" [[ $first -eq 0 ]] && echo "," first=0 - printf '{"ip":"%s","mac":"%s","state":"%s"}' "$ip" "$mac" "$state" - done < <(ip neigh show dev "$IFACE") + if [[ -n "$mdns" ]]; then + printf '{"ip":"%s","mac":"%s","state":"%s","mdnsHostname":"%s"}' "$ip" "$mac" "$state" "$mdns" + else + printf '{"ip":"%s","mac":"%s","state":"%s"}' "$ip" "$mac" "$state" + fi + done echo echo "]" } > "$TMP_FILE"