diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index a795b25..1aa4cb7 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -17,6 +17,12 @@ interface RawKnownDevice { name: string; } +interface RawDeepCheck { + host: string; + port?: number; + username?: string; +} + export interface HostsConfig { proxmox: { host: string; @@ -24,6 +30,7 @@ export interface HostsConfig { }; sshHosts?: RawSshHost[]; knownDevices?: RawKnownDevice[]; + deepCheck?: RawDeepCheck; } export interface AppConfig { @@ -47,9 +54,17 @@ export interface AppConfig { knownDevices: Map; discoveryFilePath: string; oidc: OidcConfig | undefined; + deepCheck: DeepCheckHostConfig | undefined; hosts: HostsConfig; } +export interface DeepCheckHostConfig { + host: string; + port: number; + username: string; + privateKeyPath: string; +} + export interface OidcConfig { issuerUrl: string; clientId: string; @@ -113,6 +128,14 @@ export function loadConfig(hostsConfigPath: string): AppConfig { knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])), discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json", oidc, + deepCheck: hosts.deepCheck + ? { + host: hosts.deepCheck.host, + port: hosts.deepCheck.port ?? 22, + username: hosts.deepCheck.username ?? "root", + privateKeyPath: sshPrivateKeyPath, + } + : undefined, hosts, }; } diff --git a/apps/api/src/discovery/deepCheck.ts b/apps/api/src/discovery/deepCheck.ts new file mode 100644 index 0000000..12c8a61 --- /dev/null +++ b/apps/api/src/discovery/deepCheck.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { Client } from "ssh2"; + +export interface DeepCheckConfig { + host: string; + port: number; + username: string; + privateKeyPath: string; +} + +export interface DeepCheckResult { + ip: string; + mdnsHostname: string | null; + reverseDns: string | null; + ssdp: { + location: string; + friendlyName: string | null; + manufacturer: string | null; + modelName: string | null; + } | null; + openPorts: number[]; + http: { port: number; title: string | null; server: string | null }[]; +} + +const IP_RE = /^192\.168\.1\.([0-9]{1,3})$/; + +function isValidLanIp(ip: string): boolean { + const m = IP_RE.exec(ip); + if (!m) return false; + const octet = Number(m[1]); + return octet >= 1 && octet <= 254; +} + +// Runs deep-check-device.sh on the CT122 host via SSH, same key as +// SshHostCollector but a different authorized_keys entry on CT122 itself +// (forced command reads $SSH_ORIGINAL_COMMAND for the target IP -- see +// docs/device-discovery.md). Validated here too, before ever opening a +// connection, even though the remote script re-validates independently. +export async function deepCheckDevice(cfg: DeepCheckConfig, targetIp: string): Promise { + if (!isValidLanIp(targetIp)) { + throw new Error("invalid target IP"); + } + const privateKey = readFileSync(cfg.privateKeyPath); + const output = await runSsh(cfg, privateKey, targetIp); + + let parsed: DeepCheckResult & { error?: string }; + try { + parsed = JSON.parse(output); + } catch { + throw new Error(`deep-check produced invalid output: ${output.slice(0, 200)}`); + } + if (parsed.error) throw new Error(parsed.error); + return parsed; +} + +function runSsh(cfg: DeepCheckConfig, privateKey: Buffer, targetIp: string): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + const timeout = setTimeout(() => { + conn.end(); + reject(new Error("deep-check ssh timeout")); + }, 20_000); + + conn + .on("ready", () => { + // The command string is ignored server-side by the forced command, + // but is exactly what becomes $SSH_ORIGINAL_COMMAND there. + conn.exec(targetIp, (err, stream) => { + if (err) { + clearTimeout(timeout); + conn.end(); + return reject(err); + } + let stdout = ""; + stream + .on("close", () => { + clearTimeout(timeout); + conn.end(); + resolve(stdout); + }) + .on("data", (data: Buffer) => { + stdout += data.toString(); + }); + }); + }) + .on("error", (err) => { + clearTimeout(timeout); + reject(err); + }) + .connect({ + host: cfg.host, + port: cfg.port, + username: cfg.username, + privateKey, + readyTimeout: 8_000, + }); + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b4bcb62..2652cd9 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -99,7 +99,7 @@ async function main() { registerAuthRoutes(app, db, cfg.oidc !== undefined); registerHostRoutes(app, db); - registerDeviceRoutes(app, db); + registerDeviceRoutes(app, db, cfg.deepCheck); if (cfg.oidc) { const oidcConfig = await initOidc(cfg.oidc); diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts index 157e2df..da7ade9 100644 --- a/apps/api/src/routes/devices.ts +++ b/apps/api/src/routes/devices.ts @@ -7,6 +7,8 @@ import type Database from "better-sqlite3"; import macOuiLookup from "mac-oui-lookup"; const { getVendor } = macOuiLookup; import { getRecentDevices, setDeviceLabel, clearDeviceLabel, type DeviceRow } from "../db/index.js"; +import { deepCheckDevice } from "../discovery/deepCheck.js"; +import type { DeepCheckHostConfig } from "../config/index.js"; async function requireAuth(req: FastifyRequest, reply: FastifyReply) { if (!req.session.username) { @@ -33,8 +35,13 @@ function toApiDevice(r: DeviceRow) { } const MAC_RE = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i; +const IP_RE = /^192\.168\.1\.([0-9]{1,3})$/; -export function registerDeviceRoutes(app: FastifyInstance, db: Database.Database): void { +export function registerDeviceRoutes( + app: FastifyInstance, + db: Database.Database, + deepCheckConfig: DeepCheckHostConfig | undefined +): void { app.get("/api/devices", { preHandler: requireAuth }, async () => { const rows = getRecentDevices(db); return { devices: rows.map(toApiDevice) }; @@ -63,4 +70,24 @@ export function registerDeviceRoutes(app: FastifyInstance, db: Database.Database return { ok: true }; } ); + + // Admin-triggered, single-device, on-demand -- not automatic, to avoid the + // noise/risk of doing this for the whole subnet on every poll. Runs on the + // CT122 host via SSH (see docs/device-discovery.md); can take up to ~15s + // (SSDP listen window + bounded port scan). + app.post<{ Params: { ip: string } }>( + "/api/devices/:ip/deep-check", + { preHandler: requireAuth }, + async (req, reply) => { + if (!deepCheckConfig) return reply.code(501).send({ error: "deep check not configured" }); + if (!IP_RE.test(req.params.ip)) return reply.code(400).send({ error: "invalid IP address" }); + try { + const result = await deepCheckDevice(deepCheckConfig, req.params.ip); + return result; + } catch (err) { + req.log.error({ err, ip: req.params.ip }, "deep check failed"); + return reply.code(502).send({ error: err instanceof Error ? err.message : "deep check failed" }); + } + } + ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9d9dfbd..0644dad 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -78,3 +78,21 @@ export function setDeviceLabel(mac: string, label: string): Promise<{ ok: true } export function clearDeviceLabel(mac: string): Promise<{ ok: true }> { return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" }); } + +export interface DeepCheckResult { + ip: string; + mdnsHostname: string | null; + reverseDns: string | null; + ssdp: { + location: string; + friendlyName: string | null; + manufacturer: string | null; + modelName: string | null; + } | null; + openPorts: number[]; + http: { port: number; title: string | null; server: string | null }[]; +} + +export function deepCheckDevice(ip: string): Promise { + return request(`/api/devices/${encodeURIComponent(ip)}/deep-check`, { method: "POST" }); +} diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx index 94b5c0f..d769622 100644 --- a/apps/web/src/components/DeviceTable.tsx +++ b/apps/web/src/components/DeviceTable.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { setDeviceLabel, clearDeviceLabel, type Device } from "../api"; +import { Fragment, useState } from "react"; +import { setDeviceLabel, clearDeviceLabel, deepCheckDevice, type Device, type DeepCheckResult } from "../api"; function ipSortKey(ip: string): number[] { return ip.split(".").map(Number); @@ -90,6 +90,70 @@ function NameCell({ device, onDeviceChanged }: { device: Device; onDeviceChanged ); } +function DeepCheckResultPanel({ result }: { result: DeepCheckResult }) { + const nothingFound = + !result.mdnsHostname && !result.reverseDns && !result.ssdp && result.openPorts.length === 0; + + if (nothingFound) { + return

No additional information found for {result.ip}.

; + } + + return ( +
+ {result.mdnsHostname && ( + <> +
mDNS hostname
+
{result.mdnsHostname}
+ + )} + {result.reverseDns && ( + <> +
Reverse DNS
+
{result.reverseDns}
+ + )} + {result.ssdp && ( + <> + {result.ssdp.friendlyName && ( + <> +
SSDP name
+
{result.ssdp.friendlyName}
+ + )} + {result.ssdp.manufacturer && ( + <> +
Manufacturer
+
{result.ssdp.manufacturer}
+ + )} + {result.ssdp.modelName && ( + <> +
Model
+
{result.ssdp.modelName}
+ + )} + + )} + {result.openPorts.length > 0 && ( + <> +
Open ports
+
{result.openPorts.join(", ")}
+ + )} + {result.http.map((h) => ( +
+
Port {h.port}
+
+ {h.title && {h.title}} + {h.server && ({h.server})} + {!h.title && !h.server && no title/server header} +
+
+ ))} +
+ ); +} + export function DeviceTable({ devices, onDeviceChanged, @@ -99,6 +163,9 @@ export function DeviceTable({ }) { const [sortKey, setSortKey] = useState("status"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [deepCheckResults, setDeepCheckResults] = useState< + Map + >(new Map()); function handleSort(key: SortKey) { if (key === sortKey) { @@ -109,6 +176,18 @@ export function DeviceTable({ } } + async function runDeepCheck(ip: string) { + setDeepCheckResults((prev) => new Map(prev).set(ip, { status: "loading" })); + try { + const result = await deepCheckDevice(ip); + setDeepCheckResults((prev) => new Map(prev).set(ip, { status: "done", result })); + } catch (err) { + setDeepCheckResults((prev) => + new Map(prev).set(ip, { status: "error", message: err instanceof Error ? err.message : "deep check failed" }) + ); + } + } + const sorted = [...devices].sort((a, b) => { const primary = COMPARATORS[sortKey](a, b); const signed = sortDir === "asc" ? primary : -primary; @@ -129,27 +208,58 @@ export function DeviceTable({ ))} + Actions - {sorted.map((d) => ( - - - - {d.known ? "known" : "unknown"} - - - {d.ip} - {d.mac} - - - - {d.lastSeen} - - ))} + {sorted.map((d) => { + const checkState = deepCheckResults.get(d.ip); + return ( + + + + + {d.known ? "known" : "unknown"} + + + {d.ip} + {d.mac} + + + + {d.lastSeen} + + {!d.known && ( + + )} + + + {checkState?.status === "done" && ( + + + + + + )} + {checkState?.status === "error" && ( + + +

Deep check failed: {checkState.message}

+ + + )} +
+ ); + })} {sorted.length === 0 && ( - + No devices seen yet — discovery runs every 5 minutes. diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e78a90e..8bab5b4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -261,6 +261,60 @@ body { cursor: pointer; } +.deep-check-button { + padding: 0.2rem 0.5rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #21262d; + color: #e6edf3; + font-size: 0.75rem; + cursor: pointer; +} + +.deep-check-button:hover:not(:disabled) { + border-color: #58a6ff; +} + +.deep-check-button:disabled { + opacity: 0.6; + cursor: default; +} + +.deep-check-row td { + background: #0d1117; + padding: 0.6rem 1rem; +} + +.deep-check-empty { + color: #8b949e; + font-size: 0.85rem; + margin: 0; +} + +.deep-check-results { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2rem 1rem; + margin: 0; + font-size: 0.85rem; +} + +.deep-check-results dt { + color: #8b949e; +} + +.deep-check-results dd { + margin: 0; +} + +.deep-check-http { + display: contents; +} + +.deep-check-server { + color: #8b949e; +} + .device-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #21262d; diff --git a/config/hosts.yaml b/config/hosts.yaml index e51c24a..bfe9579 100644 --- a/config/hosts.yaml +++ b/config/hosts.yaml @@ -4,6 +4,15 @@ proxmox: host: 192.168.1.144 node: pve +# Target for the on-demand "deep check" device investigation (SSDP/mDNS/port +# scan). This is CT122's OWN host, since multicast needs real network access +# — see docs/device-discovery.md. Same monitor_ed25519 key as sshHosts below, +# but a separate authorized_keys entry on CT122 itself with a parameterized +# forced command (reads $SSH_ORIGINAL_COMMAND for the target IP). +deepCheck: + host: 192.168.1.103 + username: root + # 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 2e2cb2d..3928d1d 100644 --- a/docs/device-discovery.md +++ b/docs/device-discovery.md @@ -23,8 +23,10 @@ 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}]`). +- `scripts/discover-devices.sh` — ping sweep + `ip neigh show` parse, then an + `avahi-resolve` mDNS reverse lookup per discovered IP (parallel, 2s timeout each so + one non-mDNS device can't stall the run). Writes + `/opt/homelab-monitor/data/devices-raw.json` (`[{ip, mac, state, mdnsHostname?}]`). - `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 @@ -33,17 +35,69 @@ periodic jobs like the rclone backups on CT105. - `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). +## Identifying unknown devices + +Three passive/config-driven layers, plus one on-demand active one: + +1. **MAC OUI vendor lookup** (`mac-oui-lookup` npm package, `apps/api/src/routes/devices.ts`) + — computed on every read from the MAC prefix, no storage needed. Already resolves + most smart-home gear to a vendor (e.g. "Amazon Technologies Inc.", "Ring LLC", + "Tuya Smart Inc." — a very common IoT chipset vendor). +2. **mDNS hostname** — see `discover-devices.sh` above, stored in `devices.mdns_hostname`. +3. **Manual labels** — `device_labels` table, keyed by **MAC** (survives DHCP IP + changes, unlike `knownDevices` in `config/hosts.yaml` — see "Known limitation" + below). `PUT`/`DELETE /api/devices/:mac/label`, inline-editable in the dashboard's + Name column. Manual label > `knownDevices` config name > mDNS hostname (shown as an + *italic hint*, not treated as "known" — nobody's actually confirmed it yet). +4. **On-demand deep check** — admin-triggered, single device, not automatic (avoids the + noise/risk of doing this for the whole subnet on every poll). See below. + +### Deep check + +`scripts/deep-check-device.sh` runs, on request, against one target IP: mDNS resolve, +a targeted SSDP/UPnP query (many smart-home devices announce a `friendlyName` / +`manufacturer` / `modelName` this way — confirmed working against Home Assistant), +reverse DNS, and a small curated TCP port scan (21,22,23,80,443,554,5000,8000,8008, +8009,8060,8080,8443,9100,32400,62078) with an HTTP title/server grab on anything open. +Bounded timeouts throughout, finishes in well under 15s. + +Same multicast-needs-real-network-access constraint as the ping sweep applies, so this +also runs on the CT122 **host**, not in the container — but unlike the scheduled +discovery script, this is triggered on demand from `POST /api/devices/:ip/deep-check`. +The API reaches it via SSH into CT122's own LAN IP (192.168.1.103), using the same +`monitor_ed25519` key as `SshHostCollector` but a **separate `authorized_keys` entry on +CT122 itself**. That entry's forced command is *parameterized*: forced commands ignore +whatever the client literally requests, but OpenSSH still exposes it via +`$SSH_ORIGINAL_COMMAND`, which the script reads and validates strictly (regex-anchored +`192.168.1.<1-254>`, never passed to a shell) before using it — confirmed a shell +injection attempt (`; rm -rf / #`) and an out-of-subnet IP both get rejected cleanly. + +`deepCheck:` in `config/hosts.yaml` declares the target host (CT122 itself); +`apps/api/src/discovery/deepCheck.ts` does the SSH round-trip and JSON parsing. + +Requires `avahi-utils` and `miniupnpc` installed on the CT122 host (`apt-get install +avahi-utils miniupnpc` — a one-time host package install, not part of any deploy +script, so re-provisioning CT122 from scratch would need to redo this step). + ## Deploying/updating ```bash -scp scripts/discover-devices.sh homelab-monitor:/opt/homelab-monitor/scripts/ +scp scripts/discover-devices.sh scripts/deep-check-device.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 && \ +ssh homelab-monitor "chmod +x /opt/homelab-monitor/scripts/*.sh && \ systemctl daemon-reload && systemctl enable --now homelab-monitor-discover.timer" ``` -## Known limitation +(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.) -`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. +## Known limitations + +- `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. (Manual labels don't have this + problem — they're keyed by MAC.) +- Deep check's SSDP/port-scan often finds nothing for cloud-connected devices (Ring, + Echo) that deliberately minimize their LAN footprint — OUI vendor + mDNS are the + primary identification layers for those; deep check helps most for devices that run + a local web UI or SSDP responder (smart TVs, media devices, printers, Home Assistant).