Wire up on-demand deep-check: API route + dashboard button
POST /api/devices/:ip/deep-check runs deep-check-device.sh on the CT122 host via SSH (reaches its own LAN IP), returns mDNS/SSDP/port scan results. "Deep check" button on unknown device rows in the dashboard shows results inline below the row. Verified end-to-end via SSH before wiring into the API: correctly identified Home Assistant via SSDP (friendlyName/manufacturer/model), and confirmed both a shell-injection attempt and an out-of-subnet IP get rejected cleanly by the forced command's input validation. Closes #15 (all four pieces: OUI, mDNS, manual labels, deep check). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,12 @@ interface RawKnownDevice {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawDeepCheck {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostsConfig {
|
export interface HostsConfig {
|
||||||
proxmox: {
|
proxmox: {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -24,6 +30,7 @@ export interface HostsConfig {
|
|||||||
};
|
};
|
||||||
sshHosts?: RawSshHost[];
|
sshHosts?: RawSshHost[];
|
||||||
knownDevices?: RawKnownDevice[];
|
knownDevices?: RawKnownDevice[];
|
||||||
|
deepCheck?: RawDeepCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -47,9 +54,17 @@ export interface AppConfig {
|
|||||||
knownDevices: Map<string, string>;
|
knownDevices: Map<string, string>;
|
||||||
discoveryFilePath: string;
|
discoveryFilePath: string;
|
||||||
oidc: OidcConfig | undefined;
|
oidc: OidcConfig | undefined;
|
||||||
|
deepCheck: DeepCheckHostConfig | undefined;
|
||||||
hosts: HostsConfig;
|
hosts: HostsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeepCheckHostConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
privateKeyPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OidcConfig {
|
export interface OidcConfig {
|
||||||
issuerUrl: string;
|
issuerUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -113,6 +128,14 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
|
|||||||
knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])),
|
knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])),
|
||||||
discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json",
|
discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json",
|
||||||
oidc,
|
oidc,
|
||||||
|
deepCheck: hosts.deepCheck
|
||||||
|
? {
|
||||||
|
host: hosts.deepCheck.host,
|
||||||
|
port: hosts.deepCheck.port ?? 22,
|
||||||
|
username: hosts.deepCheck.username ?? "root",
|
||||||
|
privateKeyPath: sshPrivateKeyPath,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
hosts,
|
hosts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DeepCheckResult> {
|
||||||
|
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<string> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ async function main() {
|
|||||||
|
|
||||||
registerAuthRoutes(app, db, cfg.oidc !== undefined);
|
registerAuthRoutes(app, db, cfg.oidc !== undefined);
|
||||||
registerHostRoutes(app, db);
|
registerHostRoutes(app, db);
|
||||||
registerDeviceRoutes(app, db);
|
registerDeviceRoutes(app, db, cfg.deepCheck);
|
||||||
|
|
||||||
if (cfg.oidc) {
|
if (cfg.oidc) {
|
||||||
const oidcConfig = await initOidc(cfg.oidc);
|
const oidcConfig = await initOidc(cfg.oidc);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type Database from "better-sqlite3";
|
|||||||
import macOuiLookup from "mac-oui-lookup";
|
import macOuiLookup from "mac-oui-lookup";
|
||||||
const { getVendor } = macOuiLookup;
|
const { getVendor } = macOuiLookup;
|
||||||
import { getRecentDevices, setDeviceLabel, clearDeviceLabel, type DeviceRow } from "../db/index.js";
|
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) {
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
if (!req.session.username) {
|
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 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 () => {
|
app.get("/api/devices", { preHandler: requireAuth }, async () => {
|
||||||
const rows = getRecentDevices(db);
|
const rows = getRecentDevices(db);
|
||||||
return { devices: rows.map(toApiDevice) };
|
return { devices: rows.map(toApiDevice) };
|
||||||
@@ -63,4 +70,24 @@ export function registerDeviceRoutes(app: FastifyInstance, db: Database.Database
|
|||||||
return { ok: true };
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,3 +78,21 @@ export function setDeviceLabel(mac: string, label: string): Promise<{ ok: true }
|
|||||||
export function clearDeviceLabel(mac: string): Promise<{ ok: true }> {
|
export function clearDeviceLabel(mac: string): Promise<{ ok: true }> {
|
||||||
return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" });
|
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<DeepCheckResult> {
|
||||||
|
return request(`/api/devices/${encodeURIComponent(ip)}/deep-check`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { setDeviceLabel, clearDeviceLabel, type Device } from "../api";
|
import { setDeviceLabel, clearDeviceLabel, deepCheckDevice, type Device, type DeepCheckResult } from "../api";
|
||||||
|
|
||||||
function ipSortKey(ip: string): number[] {
|
function ipSortKey(ip: string): number[] {
|
||||||
return ip.split(".").map(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 <p className="deep-check-empty">No additional information found for {result.ip}.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="deep-check-results">
|
||||||
|
{result.mdnsHostname && (
|
||||||
|
<>
|
||||||
|
<dt>mDNS hostname</dt>
|
||||||
|
<dd>{result.mdnsHostname}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.reverseDns && (
|
||||||
|
<>
|
||||||
|
<dt>Reverse DNS</dt>
|
||||||
|
<dd>{result.reverseDns}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.ssdp && (
|
||||||
|
<>
|
||||||
|
{result.ssdp.friendlyName && (
|
||||||
|
<>
|
||||||
|
<dt>SSDP name</dt>
|
||||||
|
<dd>{result.ssdp.friendlyName}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.ssdp.manufacturer && (
|
||||||
|
<>
|
||||||
|
<dt>Manufacturer</dt>
|
||||||
|
<dd>{result.ssdp.manufacturer}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.ssdp.modelName && (
|
||||||
|
<>
|
||||||
|
<dt>Model</dt>
|
||||||
|
<dd>{result.ssdp.modelName}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.openPorts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt>Open ports</dt>
|
||||||
|
<dd>{result.openPorts.join(", ")}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.http.map((h) => (
|
||||||
|
<div key={h.port} className="deep-check-http">
|
||||||
|
<dt>Port {h.port}</dt>
|
||||||
|
<dd>
|
||||||
|
{h.title && <span>{h.title}</span>}
|
||||||
|
{h.server && <span className="deep-check-server"> ({h.server})</span>}
|
||||||
|
{!h.title && !h.server && <span className="name-hint">no title/server header</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceTable({
|
export function DeviceTable({
|
||||||
devices,
|
devices,
|
||||||
onDeviceChanged,
|
onDeviceChanged,
|
||||||
@@ -99,6 +163,9 @@ export function DeviceTable({
|
|||||||
}) {
|
}) {
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("status");
|
const [sortKey, setSortKey] = useState<SortKey>("status");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||||
|
const [deepCheckResults, setDeepCheckResults] = useState<
|
||||||
|
Map<string, { status: "loading" } | { status: "done"; result: DeepCheckResult } | { status: "error"; message: string }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
function handleSort(key: SortKey) {
|
||||||
if (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 sorted = [...devices].sort((a, b) => {
|
||||||
const primary = COMPARATORS[sortKey](a, b);
|
const primary = COMPARATORS[sortKey](a, b);
|
||||||
const signed = sortDir === "asc" ? primary : -primary;
|
const signed = sortDir === "asc" ? primary : -primary;
|
||||||
@@ -129,11 +208,15 @@ export function DeviceTable({
|
|||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((d) => (
|
{sorted.map((d) => {
|
||||||
<tr key={d.ip} data-ip={d.ip}>
|
const checkState = deepCheckResults.get(d.ip);
|
||||||
|
return (
|
||||||
|
<Fragment key={d.ip}>
|
||||||
|
<tr data-ip={d.ip}>
|
||||||
<td>
|
<td>
|
||||||
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
||||||
{d.known ? "known" : "unknown"}
|
{d.known ? "known" : "unknown"}
|
||||||
@@ -145,11 +228,38 @@ export function DeviceTable({
|
|||||||
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
|
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
|
||||||
</td>
|
</td>
|
||||||
<td>{d.lastSeen}</td>
|
<td>{d.lastSeen}</td>
|
||||||
|
<td>
|
||||||
|
{!d.known && (
|
||||||
|
<button
|
||||||
|
className="deep-check-button"
|
||||||
|
onClick={() => runDeepCheck(d.ip)}
|
||||||
|
disabled={checkState?.status === "loading"}
|
||||||
|
>
|
||||||
|
{checkState?.status === "loading" ? "Checking…" : "Deep check"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{checkState?.status === "done" && (
|
||||||
|
<tr className="deep-check-row">
|
||||||
|
<td colSpan={6}>
|
||||||
|
<DeepCheckResultPanel result={checkState.result} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{checkState?.status === "error" && (
|
||||||
|
<tr className="deep-check-row">
|
||||||
|
<td colSpan={6}>
|
||||||
|
<p className="error">Deep check failed: {checkState.message}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="empty">
|
<td colSpan={6} className="empty">
|
||||||
No devices seen yet — discovery runs every 5 minutes.
|
No devices seen yet — discovery runs every 5 minutes.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -261,6 +261,60 @@ body {
|
|||||||
cursor: pointer;
|
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 {
|
.device-table td {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #21262d;
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ proxmox:
|
|||||||
host: 192.168.1.144
|
host: 192.168.1.144
|
||||||
node: pve
|
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
|
# 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:
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ periodic jobs like the rclone backups on CT105.
|
|||||||
|
|
||||||
## Pieces
|
## Pieces
|
||||||
|
|
||||||
- `scripts/discover-devices.sh` — ping sweep + `ip neigh show` parse, writes
|
- `scripts/discover-devices.sh` — ping sweep + `ip neigh show` parse, then an
|
||||||
`/opt/homelab-monitor/data/devices-raw.json` (`[{ip, mac, state}]`).
|
`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
|
- `deploy/systemd/homelab-monitor-discover.{service,timer}` — runs the script every
|
||||||
5 minutes on the CT122 host (not in Docker).
|
5 minutes on the CT122 host (not in Docker).
|
||||||
- `apps/api/src/discovery/index.ts` — reads that JSON file each poll cycle, labels
|
- `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
|
- `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).
|
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
|
## Deploying/updating
|
||||||
|
|
||||||
```bash
|
```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/
|
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"
|
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
|
## Known limitations
|
||||||
DHCP reservations don't change, but a device losing its reservation would show up as
|
|
||||||
"unknown" until the config is updated by hand.
|
- `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).
|
||||||
|
|||||||
Reference in New Issue
Block a user