Alert on genuinely new (never-seen-before) devices
CI / web (push) Successful in 16s
CI / api (push) Successful in 23s

New seen_macs table: permanent, insert-only, MAC-keyed record of the
first time each device was ever seen -- deliberately decoupled from
devices.first_seen (IP-keyed, would false-positive on every DHCP
lease change). Bootstrap-safe: first call seeds the baseline from
whatever's currently on the network without alerting on all 71+
existing devices at once. Verified locally: bootstrap call reports
nothing new, repeat calls with the same MACs report nothing new, one
genuinely new MAC gets reported exactly once.

Pushes a Home Assistant persistent_notification when a new MAC
appears (gated behind HOME_ASSISTANT_TOKEN + homeAssistant.url in
hosts.yaml -- missing config just means no push, detection still
runs). Also surfaced directly in the dashboard as a blue "new" badge
for anything first seen in the last 24h, independent of HA config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:58:26 -06:00
parent c614768704
commit 199d0da675
12 changed files with 187 additions and 4 deletions
+15
View File
@@ -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,
};
}
+37 -1
View File
@@ -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;
}
+42
View File
@@ -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<void> {
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}`);
}
}
+26 -2
View File
@@ -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<string, string>
knownDevices: Map<string, string>,
homeAssistant: HomeAssistantConfig | undefined,
onNotifyError: (err: unknown) => void
): Promise<void> {
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);
}
}
}
+3 -1
View File
@@ -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");
}
+9
View File
@@ -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,
};