diff --git a/.env.example b/.env.example index 3ccc4d0..23c80f4 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,10 @@ SSH_PRIVATE_KEY_PATH=./ssh/monitor_ed25519 # only reaches manufacturer-level confidence, same as the free OUI lookup — see # docs/device-discovery.md. Omit entirely to skip this enrichment. FINGERBANK_API_KEY= + +# Optional. Long-lived access token from Home Assistant (Profile > Security > +# Long-Lived Access Tokens). When set (with homeAssistant.url in +# config/hosts.yaml), pushes a persistent_notification whenever a MAC address +# is seen on the network for the very first time -- see docs/device-discovery.md. +# Detection runs either way; this only gates whether you get pushed a notification. +HOME_ASSISTANT_TOKEN= diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 4d707a7..321ac2e 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -23,6 +23,10 @@ interface RawDeepCheck { username?: string; } +interface RawHomeAssistant { + url: string; +} + export interface HostsConfig { proxmox: { host: string; @@ -31,6 +35,7 @@ export interface HostsConfig { sshHosts?: RawSshHost[]; knownDevices?: RawKnownDevice[]; deepCheck?: RawDeepCheck; + homeAssistant?: RawHomeAssistant; } export interface AppConfig { @@ -56,9 +61,15 @@ export interface AppConfig { oidc: OidcConfig | undefined; deepCheck: DeepCheckHostConfig | undefined; fingerbankApiKey: string | undefined; + homeAssistant: HomeAssistantConfig | undefined; hosts: HostsConfig; } +export interface HomeAssistantConfig { + url: string; + token: string; +} + export interface DeepCheckHostConfig { host: string; port: number; @@ -138,6 +149,10 @@ export function loadConfig(hostsConfigPath: string): AppConfig { } : undefined, fingerbankApiKey: process.env.FINGERBANK_API_KEY, + homeAssistant: + hosts.homeAssistant && process.env.HOME_ASSISTANT_TOKEN + ? { url: hosts.homeAssistant.url, token: process.env.HOME_ASSISTANT_TOKEN } + : undefined, hosts, }; } diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 4c1152e..671b417 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -53,6 +53,16 @@ export function openDb(path: string): Database.Database { label TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- Insert-only, permanent record of the first time each MAC was ever seen. + -- Deliberately separate from the devices table (keyed by IP, which churns + -- on DHCP renewal) -- keying "have we ever seen this MAC" by IP would + -- generate a false new-device alert every time an existing device's + -- lease just happened to change. + CREATE TABLE IF NOT EXISTS seen_macs ( + mac TEXT PRIMARY KEY, + first_seen 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). SQLite's ALTER @@ -185,6 +195,7 @@ export interface DeviceRow { manual_label: string | null; first_seen: string; last_seen: string; + first_ever_seen: string | null; } // Devices not seen in the last 24h (unplugged, moved, DHCP lease expired) are @@ -192,13 +203,17 @@ export interface DeviceRow { // 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. +// first_ever_seen comes from seen_macs (permanent, MAC-keyed), not +// devices.first_seen (IP-keyed, resets on DHCP lease change) — see +// recordSeenMacs()'s comment for why that distinction matters. export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] { return db .prepare( `SELECT d.ip, d.mac, d.known_name, d.mdns_hostname, l.label AS manual_label, - d.first_seen, d.last_seen + d.first_seen, d.last_seen, s.first_seen AS first_ever_seen FROM devices d LEFT JOIN device_labels l ON l.mac = d.mac + LEFT JOIN seen_macs s ON s.mac = d.mac WHERE d.last_seen > datetime('now', @cutoff) ORDER BY (l.label IS NULL AND d.known_name IS NULL), d.ip` ) @@ -222,3 +237,24 @@ export function setDeviceLabel(db: Database.Database, mac: string, label: string export function clearDeviceLabel(db: Database.Database, mac: string): void { db.prepare(`DELETE FROM device_labels WHERE mac = @mac`).run({ mac }); } + +// Records every MAC seen this discovery cycle, returning only the ones never +// recorded before. On the very first run (empty table), the whole current +// device population would otherwise look "new" and trigger an alert storm -- +// that first call just seeds the baseline and reports nothing as new. +export function recordSeenMacs(db: Database.Database, macs: string[]): string[] { + const isBootstrap = (db.prepare(`SELECT COUNT(*) AS n FROM seen_macs`).get() as { n: number }).n === 0; + + const alreadySeen = new Set( + (db.prepare(`SELECT mac FROM seen_macs`).all() as { mac: string }[]).map((r) => r.mac) + ); + const newMacs = macs.filter((mac) => !alreadySeen.has(mac)); + + const insert = db.prepare(`INSERT OR IGNORE INTO seen_macs (mac) VALUES (@mac)`); + const tx = db.transaction((items: string[]) => { + for (const mac of items) insert.run({ mac }); + }); + tx(macs); + + return isBootstrap ? [] : newMacs; +} diff --git a/apps/api/src/discovery/homeAssistant.ts b/apps/api/src/discovery/homeAssistant.ts new file mode 100644 index 0000000..6d751e6 --- /dev/null +++ b/apps/api/src/discovery/homeAssistant.ts @@ -0,0 +1,42 @@ +export interface HomeAssistantConfig { + url: string; + token: string; +} + +export interface NewDeviceInfo { + ip: string; + mac: string; + vendor: string | null; + name: string | null; +} + +// Uses persistent_notification.create rather than a specific mobile-push +// notify service -- guaranteed to work regardless of which notify +// integrations are configured in the user's Home Assistant (shows up in the +// HA UI/app either way), rather than guessing at a service name we don't +// know is set up. +export async function notifyNewDevices(cfg: HomeAssistantConfig, devices: NewDeviceInfo[]): Promise { + if (devices.length === 0) return; + + const lines = devices.map((d) => { + const label = d.name ?? d.vendor ?? "unidentified"; + return `- ${d.ip} (${d.mac}) — ${label}`; + }); + + const res = await fetch(`${cfg.url}/api/services/persistent_notification/create`, { + method: "POST", + headers: { + Authorization: `Bearer ${cfg.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: devices.length === 1 ? "New device on the network" : `${devices.length} new devices on the network`, + message: `homelab-monitor saw a MAC address it's never seen before:\n\n${lines.join("\n")}`, + notification_id: `homelab-monitor-new-device-${Date.now()}`, + }), + }); + + if (!res.ok) { + throw new Error(`Home Assistant notification failed: ${res.status}`); + } +} diff --git a/apps/api/src/discovery/index.ts b/apps/api/src/discovery/index.ts index a7fc8ac..685539f 100644 --- a/apps/api/src/discovery/index.ts +++ b/apps/api/src/discovery/index.ts @@ -1,6 +1,11 @@ import { readFile } from "node:fs/promises"; import type Database from "better-sqlite3"; -import { upsertDevices, type DiscoveredDevice } from "../db/index.js"; +import { upsertDevices, recordSeenMacs, type DiscoveredDevice } from "../db/index.js"; +import { notifyNewDevices, type HomeAssistantConfig, type NewDeviceInfo } from "./homeAssistant.js"; +// mac-oui-lookup is CommonJS; see routes/devices.ts for why this needs the +// default-import-and-destructure form instead of a named import. +import macOuiLookup from "mac-oui-lookup"; +const { getVendor } = macOuiLookup; interface RawEntry { ip: string; @@ -15,7 +20,9 @@ interface RawEntry { export async function refreshDevices( db: Database.Database, filePath: string, - knownDevices: Map + knownDevices: Map, + homeAssistant: HomeAssistantConfig | undefined, + onNotifyError: (err: unknown) => void ): Promise { let raw: RawEntry[]; try { @@ -35,4 +42,21 @@ export async function refreshDevices( })); upsertDevices(db, devices); + + const newMacs = new Set(recordSeenMacs(db, raw.map((r) => r.mac))); + if (newMacs.size > 0 && homeAssistant) { + const newDevices: NewDeviceInfo[] = raw + .filter((r) => newMacs.has(r.mac)) + .map((r) => ({ + ip: r.ip, + mac: r.mac, + vendor: getVendor(r.mac), + name: knownDevices.get(r.ip) ?? r.mdnsHostname ?? null, + })); + try { + await notifyNewDevices(homeAssistant, newDevices); + } catch (err) { + onNotifyError(err); + } + } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 31df420..8cddb27 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -83,7 +83,9 @@ async function main() { pruneOldSnapshots(db, cfg.snapshotRetentionHours); try { - await refreshDevices(db, cfg.discoveryFilePath, knownDevices); + await refreshDevices(db, cfg.discoveryFilePath, knownDevices, cfg.homeAssistant, (err) => + app.log.error({ err }, "new-device notification failed") + ); } catch (err) { app.log.error({ err }, "device discovery refresh failed"); } diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts index 57033a3..ab37316 100644 --- a/apps/api/src/routes/devices.ts +++ b/apps/api/src/routes/devices.ts @@ -27,6 +27,14 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) { // 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. +const NEW_DEVICE_WINDOW_HOURS = 24; + +function isRecent(isoTimestamp: string | null, hours: number): boolean { + if (!isoTimestamp) return false; + const seenAt = new Date(isoTimestamp.replace(" ", "T") + "Z").getTime(); + return Date.now() - seenAt < hours * 60 * 60 * 1000; +} + function toApiDevice(r: DeviceRow) { const name = r.manual_label ?? r.known_name ?? null; return { @@ -36,6 +44,7 @@ function toApiDevice(r: DeviceRow) { known: r.manual_label !== null || r.known_name !== null, vendor: getVendor(r.mac), mdnsHostname: r.mdns_hostname, + isNew: isRecent(r.first_ever_seen, NEW_DEVICE_WINDOW_HOURS), firstSeen: r.first_seen, lastSeen: r.last_seen, }; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8f718a9..15290f7 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -60,6 +60,9 @@ export interface Device { known: boolean; vendor: string | null; mdnsHostname: string | null; + // True if this MAC (not IP -- survives DHCP lease changes) was first ever + // seen within the last 24h. + isNew: boolean; firstSeen: string; lastSeen: string; } diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx index 4a20d13..232dc60 100644 --- a/apps/web/src/components/DeviceTable.tsx +++ b/apps/web/src/components/DeviceTable.tsx @@ -247,6 +247,11 @@ export function DeviceTable({ {d.known ? "known" : "unknown"} + {d.isNew && ( + + new + + )} {d.ip} {d.mac} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2108d15..efa1882 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -353,6 +353,12 @@ body { color: #f5a623; } +.device-badge.new { + background: #1a3a4d; + color: #58a6ff; + margin-left: 0.3rem; +} + .sparkline { width: 100%; height: 24px; diff --git a/config/hosts.yaml b/config/hosts.yaml index bfe9579..7475083 100644 --- a/config/hosts.yaml +++ b/config/hosts.yaml @@ -13,6 +13,13 @@ deepCheck: host: 192.168.1.103 username: root +# Push a persistent_notification to Home Assistant whenever a MAC address is +# seen for the first time ever (not "unknown", genuinely new -- see +# docs/device-discovery.md). Requires HOME_ASSISTANT_TOKEN in .env too; if +# either is missing, detection still runs but nothing gets pushed. +homeAssistant: + url: http://192.168.1.187:8123 + # Bare-metal boxes Proxmox can't see. Connects with the dedicated # `monitor` SSH key (see docs/ssh-collector-key-setup.md), read-only commands only. sshHosts: diff --git a/docs/device-discovery.md b/docs/device-discovery.md index cc03169..f512d71 100644 --- a/docs/device-discovery.md +++ b/docs/device-discovery.md @@ -116,6 +116,33 @@ ssh homelab-monitor "chmod +x /opt/homelab-monitor/scripts/*.sh && \ (In practice, `git pull` on CT122 already updates the script files at their deployed path — the above is only needed for the systemd units or a from-scratch setup.) +## New-device alerting + +Separate from known/unknown labeling: whenever a MAC address is seen on the network for +the **very first time ever**, the dashboard pushes a `persistent_notification` to Home +Assistant (config: `homeAssistant.url` in `config/hosts.yaml` + `HOME_ASSISTANT_TOKEN` +in `.env`, a long-lived access token from HA's Profile > Security). The idea: "unknown" +by itself isn't a useful alert signal (plenty of legitimate IoT gear stays permanently +unknown), but a MAC nobody's ever seen before showing up is worth a heads-up. + +- `seen_macs` table (`apps/api/src/db/index.ts`) is a permanent, insert-only, MAC-keyed + record — deliberately **not** derived from `devices.first_seen`, which is keyed by + **IP** and would generate a false "new device" alert every time an existing device's + DHCP lease just happened to change. +- **Bootstrap-safe**: the very first call (empty `seen_macs` table) seeds the baseline + from whatever's currently on the network and reports nothing as new — otherwise + turning this on for the first time would alert on the entire existing device + population (71+ devices) all at once. +- `apps/api/src/discovery/homeAssistant.ts` posts to + `{url}/api/services/persistent_notification/create` — chosen over a specific + mobile-push `notify.*` service since it's guaranteed to work regardless of which + notify integrations happen to be configured in the user's Home Assistant instance. +- Also surfaced in the dashboard itself: a blue "new" badge next to the status badge + for any device first seen within the last 24h (`isNew` in the `/api/devices` + response), independent of whether Home Assistant notifications are configured. +- Optional and gated: missing config, an HA API error, etc. don't affect discovery + itself — only whether a push notification goes out. + ## Known limitations - `knownDevices` in `config/hosts.yaml` is matched by **IP**, not MAC — fine as long as