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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
registerHostRoutes(app, db);
|
||||
registerDeviceRoutes(app, db);
|
||||
registerDeviceRoutes(app, db, cfg.deepCheck);
|
||||
|
||||
if (cfg.oidc) {
|
||||
const oidcConfig = await initOidc(cfg.oidc);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user