Alert on genuinely new (never-seen-before) devices
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:
@@ -51,3 +51,10 @@ SSH_PRIVATE_KEY_PATH=./ssh/monitor_ed25519
|
|||||||
# only reaches manufacturer-level confidence, same as the free OUI lookup — see
|
# only reaches manufacturer-level confidence, same as the free OUI lookup — see
|
||||||
# docs/device-discovery.md. Omit entirely to skip this enrichment.
|
# docs/device-discovery.md. Omit entirely to skip this enrichment.
|
||||||
FINGERBANK_API_KEY=
|
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=
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ interface RawDeepCheck {
|
|||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawHomeAssistant {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostsConfig {
|
export interface HostsConfig {
|
||||||
proxmox: {
|
proxmox: {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -31,6 +35,7 @@ export interface HostsConfig {
|
|||||||
sshHosts?: RawSshHost[];
|
sshHosts?: RawSshHost[];
|
||||||
knownDevices?: RawKnownDevice[];
|
knownDevices?: RawKnownDevice[];
|
||||||
deepCheck?: RawDeepCheck;
|
deepCheck?: RawDeepCheck;
|
||||||
|
homeAssistant?: RawHomeAssistant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -56,9 +61,15 @@ export interface AppConfig {
|
|||||||
oidc: OidcConfig | undefined;
|
oidc: OidcConfig | undefined;
|
||||||
deepCheck: DeepCheckHostConfig | undefined;
|
deepCheck: DeepCheckHostConfig | undefined;
|
||||||
fingerbankApiKey: string | undefined;
|
fingerbankApiKey: string | undefined;
|
||||||
|
homeAssistant: HomeAssistantConfig | undefined;
|
||||||
hosts: HostsConfig;
|
hosts: HostsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HomeAssistantConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeepCheckHostConfig {
|
export interface DeepCheckHostConfig {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -138,6 +149,10 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fingerbankApiKey: process.env.FINGERBANK_API_KEY,
|
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,
|
hosts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ export function openDb(path: string): Database.Database {
|
|||||||
label TEXT NOT NULL,
|
label TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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
|
// Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF
|
||||||
// NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER
|
// NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER
|
||||||
@@ -185,6 +195,7 @@ export interface DeviceRow {
|
|||||||
manual_label: string | null;
|
manual_label: string | null;
|
||||||
first_seen: string;
|
first_seen: string;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
|
first_ever_seen: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices not seen in the last 24h (unplugged, moved, DHCP lease expired) are
|
// 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.
|
// 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 —
|
// Manual labels are joined by MAC (survives IP changes) and take priority —
|
||||||
// see effectiveName() in routes/devices.ts for the full precedence order.
|
// 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[] {
|
export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT d.ip, d.mac, d.known_name, d.mdns_hostname, l.label AS manual_label,
|
`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
|
FROM devices d
|
||||||
LEFT JOIN device_labels l ON l.mac = d.mac
|
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)
|
WHERE d.last_seen > datetime('now', @cutoff)
|
||||||
ORDER BY (l.label IS NULL AND d.known_name IS NULL), d.ip`
|
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 {
|
export function clearDeviceLabel(db: Database.Database, mac: string): void {
|
||||||
db.prepare(`DELETE FROM device_labels WHERE mac = @mac`).run({ mac });
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import type Database from "better-sqlite3";
|
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 {
|
interface RawEntry {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -15,7 +20,9 @@ interface RawEntry {
|
|||||||
export async function refreshDevices(
|
export async function refreshDevices(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
knownDevices: Map<string, string>
|
knownDevices: Map<string, string>,
|
||||||
|
homeAssistant: HomeAssistantConfig | undefined,
|
||||||
|
onNotifyError: (err: unknown) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let raw: RawEntry[];
|
let raw: RawEntry[];
|
||||||
try {
|
try {
|
||||||
@@ -35,4 +42,21 @@ export async function refreshDevices(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
upsertDevices(db, devices);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ async function main() {
|
|||||||
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
|
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
app.log.error({ err }, "device discovery refresh failed");
|
app.log.error({ err }, "device discovery refresh failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
// config/hosts.yaml known list, then the passively-resolved mDNS hostname —
|
// 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,
|
// which is informational only and does NOT make a device "known" on its own,
|
||||||
// since nobody has actually vetted it yet.
|
// 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) {
|
function toApiDevice(r: DeviceRow) {
|
||||||
const name = r.manual_label ?? r.known_name ?? null;
|
const name = r.manual_label ?? r.known_name ?? null;
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +44,7 @@ function toApiDevice(r: DeviceRow) {
|
|||||||
known: r.manual_label !== null || r.known_name !== null,
|
known: r.manual_label !== null || r.known_name !== null,
|
||||||
vendor: getVendor(r.mac),
|
vendor: getVendor(r.mac),
|
||||||
mdnsHostname: r.mdns_hostname,
|
mdnsHostname: r.mdns_hostname,
|
||||||
|
isNew: isRecent(r.first_ever_seen, NEW_DEVICE_WINDOW_HOURS),
|
||||||
firstSeen: r.first_seen,
|
firstSeen: r.first_seen,
|
||||||
lastSeen: r.last_seen,
|
lastSeen: r.last_seen,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface Device {
|
|||||||
known: boolean;
|
known: boolean;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
mdnsHostname: 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;
|
firstSeen: string;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,11 @@ export function DeviceTable({
|
|||||||
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
||||||
{d.known ? "known" : "unknown"}
|
{d.known ? "known" : "unknown"}
|
||||||
</span>
|
</span>
|
||||||
|
{d.isNew && (
|
||||||
|
<span className="device-badge new" title="First seen on this network within the last 24h">
|
||||||
|
new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{d.ip}</td>
|
<td>{d.ip}</td>
|
||||||
<td className="mono">{d.mac}</td>
|
<td className="mono">{d.mac}</td>
|
||||||
|
|||||||
@@ -353,6 +353,12 @@ body {
|
|||||||
color: #f5a623;
|
color: #f5a623;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-badge.new {
|
||||||
|
background: #1a3a4d;
|
||||||
|
color: #58a6ff;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sparkline {
|
.sparkline {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ deepCheck:
|
|||||||
host: 192.168.1.103
|
host: 192.168.1.103
|
||||||
username: root
|
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
|
# 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.
|
# `monitor` SSH key (see docs/ssh-collector-key-setup.md), read-only commands only.
|
||||||
sshHosts:
|
sshHosts:
|
||||||
|
|||||||
@@ -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
|
(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.)
|
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
|
## Known limitations
|
||||||
|
|
||||||
- `knownDevices` in `config/hosts.yaml` is matched by **IP**, not MAC — fine as long as
|
- `knownDevices` in `config/hosts.yaml` is matched by **IP**, not MAC — fine as long as
|
||||||
|
|||||||
Reference in New Issue
Block a user