Wire up on-demand deep-check: API route + dashboard button
CI / web (push) Successful in 18s
CI / api (push) Successful in 24s

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:
2026-07-12 23:15:24 -06:00
parent e0ef618f0a
commit 542a3d8ce0
9 changed files with 421 additions and 28 deletions
+23
View File
@@ -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<string, string>;
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,
};
}
+98
View File
@@ -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,
});
});
}
+1 -1
View File
@@ -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);
+28 -1
View File
@@ -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" });
}
}
);
}
+18
View File
@@ -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<DeepCheckResult> {
return request(`/api/devices/${encodeURIComponent(ip)}/deep-check`, { method: "POST" });
}
+128 -18
View File
@@ -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 <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({
devices,
onDeviceChanged,
@@ -99,6 +163,9 @@ export function DeviceTable({
}) {
const [sortKey, setSortKey] = useState<SortKey>("status");
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) {
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({
</button>
</th>
))}
<th>Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((d) => (
<tr key={d.ip} data-ip={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>
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
</td>
<td>{d.lastSeen}</td>
</tr>
))}
{sorted.map((d) => {
const checkState = deepCheckResults.get(d.ip);
return (
<Fragment key={d.ip}>
<tr data-ip={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>
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
</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>
{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 && (
<tr>
<td colSpan={5} className="empty">
<td colSpan={6} className="empty">
No devices seen yet discovery runs every 5 minutes.
</td>
</tr>
+54
View File
@@ -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;