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"