Add LAN device discovery (ping sweep + ARP, known/unknown labeling)
Runs as a host-level systemd timer on CT122 (scripts/discover-devices.sh) rather than inside the api container, since real ARP entries live in the host's network namespace, not Docker's bridge network. See docs/device-discovery.md for the full writeup, including why literal passive-only ARP reading was dropped (near-empty result in practice). API reads the resulting JSON file each poll cycle, cross-references config/hosts.yaml's knownDevices list by IP, and serves /api/devices. Dashboard gets a new "Network Devices" table. Closes #9. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,12 +12,18 @@ interface RawSshHost {
|
|||||||
diskPaths?: { path: string; label: string }[];
|
diskPaths?: { path: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawKnownDevice {
|
||||||
|
ip: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostsConfig {
|
export interface HostsConfig {
|
||||||
proxmox: {
|
proxmox: {
|
||||||
host: string;
|
host: string;
|
||||||
node: string;
|
node: string;
|
||||||
};
|
};
|
||||||
sshHosts?: RawSshHost[];
|
sshHosts?: RawSshHost[];
|
||||||
|
knownDevices?: RawKnownDevice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -39,6 +45,8 @@ export interface AppConfig {
|
|||||||
tokenSecret: string;
|
tokenSecret: string;
|
||||||
};
|
};
|
||||||
sshHosts: SshHostConfig[];
|
sshHosts: SshHostConfig[];
|
||||||
|
knownDevices: Map<string, string>;
|
||||||
|
discoveryFilePath: string;
|
||||||
hosts: HostsConfig;
|
hosts: HostsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +88,8 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
|
|||||||
tokenSecret: required("PROXMOX_TOKEN_SECRET"),
|
tokenSecret: required("PROXMOX_TOKEN_SECRET"),
|
||||||
},
|
},
|
||||||
sshHosts,
|
sshHosts,
|
||||||
|
knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])),
|
||||||
|
discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json",
|
||||||
hosts,
|
hosts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export function openDb(path: string): Database.Database {
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts);
|
CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
mac TEXT NOT NULL,
|
||||||
|
known_name TEXT,
|
||||||
|
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
@@ -100,3 +108,45 @@ export function pruneOldSnapshots(db: Database.Database, retentionHours: number)
|
|||||||
cutoff: `-${retentionHours} hours`,
|
cutoff: `-${retentionHours} hours`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredDevice {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
knownName: 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)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
mac = excluded.mac,
|
||||||
|
known_name = excluded.known_name,
|
||||||
|
last_seen = datetime('now')
|
||||||
|
`);
|
||||||
|
const tx = db.transaction((items: DiscoveredDevice[]) => {
|
||||||
|
for (const d of items) upsert.run(d);
|
||||||
|
});
|
||||||
|
tx(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRow {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
known_name: string | null;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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`
|
||||||
|
)
|
||||||
|
.all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import { upsertDevices, type DiscoveredDevice } from "../db/index.js";
|
||||||
|
|
||||||
|
interface RawEntry {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the JSON file produced by scripts/discover-devices.sh (a systemd timer
|
||||||
|
// on the deploy host, not this process) and labels each entry against the
|
||||||
|
// known-device list from config/hosts.yaml. See docs/device-discovery.md.
|
||||||
|
export async function refreshDevices(
|
||||||
|
db: Database.Database,
|
||||||
|
filePath: string,
|
||||||
|
knownDevices: Map<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
let raw: RawEntry[];
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(await readFile(filePath, "utf8"));
|
||||||
|
} catch (err) {
|
||||||
|
// Missing file (timer hasn't run yet, or not deployed on this host) isn't
|
||||||
|
// fatal — just means no device data this cycle.
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices: DiscoveredDevice[] = raw.map((entry) => ({
|
||||||
|
ip: entry.ip,
|
||||||
|
mac: entry.mac,
|
||||||
|
knownName: knownDevices.get(entry.ip) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
upsertDevices(db, devices);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { SshHostCollector } from "./collectors/sshHost.js";
|
|||||||
import type { Collector } from "./collectors/types.js";
|
import type { Collector } from "./collectors/types.js";
|
||||||
import { registerAuthRoutes } from "./routes/auth.js";
|
import { registerAuthRoutes } from "./routes/auth.js";
|
||||||
import { registerHostRoutes } from "./routes/hosts.js";
|
import { registerHostRoutes } from "./routes/hosts.js";
|
||||||
|
import { registerDeviceRoutes } from "./routes/devices.js";
|
||||||
|
import { refreshDevices } from "./discovery/index.js";
|
||||||
|
|
||||||
const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml";
|
const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml";
|
||||||
|
|
||||||
@@ -41,6 +43,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
|
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshDevices(db, cfg.discoveryFilePath, cfg.knownDevices);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, "device discovery refresh failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -53,6 +61,7 @@ async function main() {
|
|||||||
|
|
||||||
registerAuthRoutes(app, db);
|
registerAuthRoutes(app, db);
|
||||||
registerHostRoutes(app, db);
|
registerHostRoutes(app, db);
|
||||||
|
registerDeviceRoutes(app, db);
|
||||||
|
|
||||||
await pollOnce();
|
await pollOnce();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import { getRecentDevices } from "../db/index.js";
|
||||||
|
|
||||||
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
if (!req.session.username) {
|
||||||
|
reply.code(401).send({ error: "not authenticated" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,3 +39,16 @@ export function me(): Promise<{ username: string }> {
|
|||||||
export function getHosts(): Promise<{ hosts: HostSummary[] }> {
|
export function getHosts(): Promise<{ hosts: HostSummary[] }> {
|
||||||
return request("/api/hosts");
|
return request("/api/hosts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
name: string | null;
|
||||||
|
known: boolean;
|
||||||
|
firstSeen: string;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevices(): Promise<{ devices: Device[] }> {
|
||||||
|
return request("/api/devices");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Device } from "../api";
|
||||||
|
|
||||||
|
function ipSortKey(ip: string): number[] {
|
||||||
|
return ip.split(".").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceTable({ devices }: { devices: Device[] }) {
|
||||||
|
const sorted = [...devices].sort((a, b) => {
|
||||||
|
if (a.known !== b.known) return a.known ? -1 : 1;
|
||||||
|
const [ak, bk] = [ipSortKey(a.ip), ipSortKey(b.ip)];
|
||||||
|
for (let i = 0; i < 4; i++) if (ak[i] !== bk[i]) return ak[i] - bk[i];
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="device-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>MAC</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((d) => (
|
||||||
|
<tr key={d.ip}>
|
||||||
|
<td>
|
||||||
|
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
||||||
|
{d.known ? "known" : "unknown"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{d.ip}</td>
|
||||||
|
<td className="mono">{d.mac}</td>
|
||||||
|
<td>{d.name ?? "—"}</td>
|
||||||
|
<td>{d.lastSeen}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty">
|
||||||
|
No devices seen yet — discovery runs every 5 minutes.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,3 +159,55 @@ body {
|
|||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-count {
|
||||||
|
color: #8b949e;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: #8b949e;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-table td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-table .mono {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-table .empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #8b949e;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-badge.known {
|
||||||
|
background: #1a4d2e;
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-badge.unknown {
|
||||||
|
background: #4d2a1a;
|
||||||
|
color: #f5a623;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getHosts, logout, type HostSummary } from "../api";
|
import { getHosts, getDevices, logout, type HostSummary, type Device } from "../api";
|
||||||
import { HostCard } from "../components/HostCard";
|
import { HostCard } from "../components/HostCard";
|
||||||
|
import { DeviceTable } from "../components/DeviceTable";
|
||||||
|
|
||||||
const POLL_MS = 15000;
|
const POLL_MS = 15000;
|
||||||
|
|
||||||
export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) {
|
export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) {
|
||||||
const [hosts, setHosts] = useState<HostSummary[]>([]);
|
const [hosts, setHosts] = useState<HostSummary[]>([]);
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const { hosts } = await getHosts();
|
const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setHosts(hosts);
|
setHosts(hostsRes.hosts);
|
||||||
|
setDevices(devicesRes.devices);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load hosts");
|
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard data");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void poll();
|
void poll();
|
||||||
@@ -58,6 +61,12 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
Network Devices <span className="device-count">({devices.length})</span>
|
||||||
|
</h2>
|
||||||
|
<DeviceTable devices={devices} />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,61 @@ sshHosts:
|
|||||||
diskPaths:
|
diskPaths:
|
||||||
- path: /
|
- path: /
|
||||||
label: os
|
label: os
|
||||||
|
|
||||||
|
# LAN device discovery reads devices-raw.json (produced by scripts/discover-devices.sh
|
||||||
|
# via a systemd timer on the deploy host, see docs/device-discovery.md) and labels any
|
||||||
|
# IP found here as "known" instead of "unknown". Keyed by IP since these are all DHCP
|
||||||
|
# reservations, not truly static — update if a reservation ever changes.
|
||||||
|
knownDevices:
|
||||||
|
- ip: 192.168.1.144
|
||||||
|
name: pve (Proxmox host)
|
||||||
|
- ip: 192.168.1.180
|
||||||
|
name: omv (NAS)
|
||||||
|
- ip: 192.168.1.171
|
||||||
|
name: ripper (DVD ripper)
|
||||||
|
- ip: 192.168.1.103
|
||||||
|
name: homelab-monitor (this dashboard, CT122)
|
||||||
|
- ip: 192.168.1.226
|
||||||
|
name: audiobookshelf (CT100)
|
||||||
|
- ip: 192.168.1.119
|
||||||
|
name: nextcloud (CT101)
|
||||||
|
- ip: 192.168.1.185
|
||||||
|
name: nginxproxymanager (CT102)
|
||||||
|
- ip: 192.168.1.125
|
||||||
|
name: immich (CT103)
|
||||||
|
- ip: 192.168.1.243
|
||||||
|
name: jellyfin (CT104)
|
||||||
|
- ip: 192.168.1.245
|
||||||
|
name: rclone (CT105)
|
||||||
|
- ip: 192.168.1.168
|
||||||
|
name: booklore (CT106)
|
||||||
|
- ip: 192.168.1.200
|
||||||
|
name: actualbudget (CT107)
|
||||||
|
- ip: 192.168.1.135
|
||||||
|
name: graylog (CT108)
|
||||||
|
- ip: 192.168.1.172
|
||||||
|
name: zabbix (CT109)
|
||||||
|
- ip: 192.168.1.215
|
||||||
|
name: cloudflared (CT110)
|
||||||
|
- ip: 192.168.1.246
|
||||||
|
name: pve-runner / lisilou-portfolio (CT111)
|
||||||
|
- ip: 192.168.1.237
|
||||||
|
name: thatguylaserworks (CT112)
|
||||||
|
- ip: 192.168.1.149
|
||||||
|
name: n8n (CT113)
|
||||||
|
- ip: 192.168.1.236
|
||||||
|
name: keycloak (CT114, being decommissioned)
|
||||||
|
- ip: 192.168.1.184
|
||||||
|
name: postgresql (CT115)
|
||||||
|
- ip: 192.168.1.186
|
||||||
|
name: gitea (CT116)
|
||||||
|
- ip: 192.168.1.247
|
||||||
|
name: gitea-runner (CT117)
|
||||||
|
- ip: 192.168.1.187
|
||||||
|
name: homeassistant (CT118)
|
||||||
|
- ip: 192.168.1.183
|
||||||
|
name: mosquitto (CT119)
|
||||||
|
- ip: 192.168.1.241
|
||||||
|
name: vaultwarden (CT120)
|
||||||
|
- ip: 192.168.1.208
|
||||||
|
name: authentik (CT121)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=homelab-monitor LAN device discovery (ping sweep + ARP read)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/opt/homelab-monitor/scripts/discover-devices.sh
|
||||||
|
Environment=SUBNET_PREFIX=192.168.1
|
||||||
|
Environment=OUTPUT_FILE=/opt/homelab-monitor/data/devices-raw.json
|
||||||
|
Environment=IFACE=eth0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run homelab-monitor LAN device discovery every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+5
-4
@@ -8,10 +8,14 @@ services:
|
|||||||
- HOSTS_CONFIG_PATH=/app/config/hosts.yaml
|
- HOSTS_CONFIG_PATH=/app/config/hosts.yaml
|
||||||
- DB_PATH=/app/data/monitor.db
|
- DB_PATH=/app/data/monitor.db
|
||||||
- SSH_PRIVATE_KEY_PATH=/app/ssh/monitor_ed25519
|
- SSH_PRIVATE_KEY_PATH=/app/ssh/monitor_ed25519
|
||||||
|
- DISCOVERY_FILE_PATH=/app/data/devices-raw.json
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/hosts.yaml:/app/config/hosts.yaml:ro
|
- ./config/hosts.yaml:/app/config/hosts.yaml:ro
|
||||||
- ./ssh/monitor_ed25519:/app/ssh/monitor_ed25519:ro
|
- ./ssh/monitor_ed25519:/app/ssh/monitor_ed25519:ro
|
||||||
- monitor-data:/app/data
|
# Bind mount (not a named volume) so scripts/discover-devices.sh, which
|
||||||
|
# runs on the host via systemd (see deploy/systemd/), can write
|
||||||
|
# devices-raw.json into the same place the container reads it from.
|
||||||
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
|
||||||
@@ -28,6 +32,3 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
|
||||||
volumes:
|
|
||||||
monitor-data:
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# LAN device discovery
|
||||||
|
|
||||||
|
Shows every device currently on the LAN, labeled known/unknown, in the dashboard's
|
||||||
|
"Network Devices" section.
|
||||||
|
|
||||||
|
## Why this isn't pure passive ARP reading
|
||||||
|
|
||||||
|
The original idea was to just read the ARP table (no active network traffic). In
|
||||||
|
practice that produces a nearly empty list: a host's ARP cache only contains entries
|
||||||
|
for peers it has actually exchanged traffic with, and the dashboard container has no
|
||||||
|
reason to talk to most LAN devices on its own. So discovery does a lightweight ICMP
|
||||||
|
ping sweep (no port scanning) first, to populate the cache, then reads it.
|
||||||
|
|
||||||
|
## Why this runs outside the Docker container
|
||||||
|
|
||||||
|
Real ARP entries live in the network namespace of whichever host is directly on the
|
||||||
|
LAN interface. The `api` container sits behind Docker's own bridge network — pinging
|
||||||
|
from inside it and reading `/proc/net/arp` there would show Docker's internal network,
|
||||||
|
not the actual LAN. Rather than switch the containers to `network_mode: host` (loses
|
||||||
|
container network isolation), discovery runs as a small script directly on the CT122
|
||||||
|
host via a systemd timer — the same pattern the homelab already uses for host-level
|
||||||
|
periodic jobs like the rclone backups on CT105.
|
||||||
|
|
||||||
|
## Pieces
|
||||||
|
|
||||||
|
- `scripts/discover-devices.sh` — ping sweep + `ip neigh show` parse, writes
|
||||||
|
`/opt/homelab-monitor/data/devices-raw.json` (`[{ip, mac, state}]`).
|
||||||
|
- `deploy/systemd/homelab-monitor-discover.{service,timer}` — runs the script every
|
||||||
|
5 minutes on the CT122 host (not in Docker).
|
||||||
|
- `apps/api/src/discovery/index.ts` — reads that JSON file each poll cycle, labels
|
||||||
|
entries against `knownDevices` in `config/hosts.yaml` (matched by IP), upserts into
|
||||||
|
the `devices` SQLite table.
|
||||||
|
- `GET /api/devices` — serves devices seen in the last 24h (older entries are dropped
|
||||||
|
rather than shown as stale-known, since there's no per-device up/down tracking).
|
||||||
|
|
||||||
|
## Deploying/updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp scripts/discover-devices.sh homelab-monitor:/opt/homelab-monitor/scripts/
|
||||||
|
scp deploy/systemd/homelab-monitor-discover.* homelab-monitor:/etc/systemd/system/
|
||||||
|
ssh homelab-monitor "chmod +x /opt/homelab-monitor/scripts/discover-devices.sh && \
|
||||||
|
systemctl daemon-reload && systemctl enable --now homelab-monitor-discover.timer"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
`knownDevices` in `config/hosts.yaml` is matched by **IP**, not MAC — fine as long as
|
||||||
|
DHCP reservations don't change, but a device losing its reservation would show up as
|
||||||
|
"unknown" until the config is updated by hand.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs on the CT122 host (not in a container) via the homelab-monitor-discover
|
||||||
|
# systemd timer. Real ARP entries live in the host's network namespace, not an
|
||||||
|
# isolated Docker bridge network, so this has to run here rather than inside
|
||||||
|
# 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.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SUBNET_PREFIX="${SUBNET_PREFIX:-192.168.1}"
|
||||||
|
OUTPUT_FILE="${OUTPUT_FILE:-/opt/homelab-monitor/data/devices-raw.json}"
|
||||||
|
IFACE="${IFACE:-eth0}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||||
|
|
||||||
|
for i in $(seq 1 254); do
|
||||||
|
ping -c 1 -W 1 "${SUBNET_PREFIX}.${i}" >/dev/null 2>&1 &
|
||||||
|
# cap concurrency so we don't fork 254 pings at once
|
||||||
|
if (( i % 32 == 0 )); then wait; fi
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
TMP_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "["
|
||||||
|
first=1
|
||||||
|
# `ip neigh show dev <iface>` output: "<ip> lladdr <mac> <state> [router]"
|
||||||
|
while read -r ip _ mac state _; do
|
||||||
|
[[ "$ip" == *:* ]] && continue # skip IPv6 (link-local neighbor entries)
|
||||||
|
[[ -z "$mac" ]] && continue
|
||||||
|
case "$state" in
|
||||||
|
REACHABLE|STALE|DELAY|PERMANENT) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
[[ $first -eq 0 ]] && echo ","
|
||||||
|
first=0
|
||||||
|
printf '{"ip":"%s","mac":"%s","state":"%s"}' "$ip" "$mac" "$state"
|
||||||
|
done < <(ip neigh show dev "$IFACE")
|
||||||
|
echo
|
||||||
|
echo "]"
|
||||||
|
} > "$TMP_FILE"
|
||||||
|
|
||||||
|
mv "$TMP_FILE" "$OUTPUT_FILE"
|
||||||
Reference in New Issue
Block a user