Add optional Fingerbank device fingerprinting to deep-check
CI / web (push) Successful in 16s
CI / api (push) Successful in 21s

Folded into the existing on-demand Deep check button: queries
Fingerbank's interrogate API with the device's MAC plus the SSDP
SERVER header when deep-check-device.sh finds one, showing the
confidence band alongside the result. Runs directly from the API
container (no host-level access needed, just an outbound HTTPS call),
unlike the SSDP/mDNS steps.

Confirmed via direct testing: without DHCP fingerprint data (which we
structurally don't have, not being the DHCP server), MAC-only queries
often can't get past manufacturer-level confidence -- same info the
free OUI lookup already provides. Documented honestly in
docs/device-discovery.md rather than overselling it. Still worth
having as opt-in enrichment for devices that do expose richer signals.

Gated behind optional FINGERBANK_API_KEY -- missing key, API errors,
or no match all degrade gracefully without affecting the rest of
deep-check's local findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:35:35 -06:00
parent ee9b6d15cd
commit d0d6ae95f1
11 changed files with 183 additions and 13 deletions
+2
View File
@@ -55,6 +55,7 @@ export interface AppConfig {
discoveryFilePath: string;
oidc: OidcConfig | undefined;
deepCheck: DeepCheckHostConfig | undefined;
fingerbankApiKey: string | undefined;
hosts: HostsConfig;
}
@@ -136,6 +137,7 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
privateKeyPath: sshPrivateKeyPath,
}
: undefined,
fingerbankApiKey: process.env.FINGERBANK_API_KEY,
hosts,
};
}
+7
View File
@@ -205,6 +205,13 @@ export function getRecentDevices(db: Database.Database, sinceHours = 24): Device
.all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[];
}
export function getDeviceMac(db: Database.Database, ip: string): string | null {
const row = db.prepare(`SELECT mac FROM devices WHERE ip = @ip`).get({ ip }) as
| { mac: string }
| undefined;
return row?.mac ?? null;
}
export function setDeviceLabel(db: Database.Database, mac: string, label: string): void {
db.prepare(
`INSERT INTO device_labels (mac, label) VALUES (@mac, @label)
+1
View File
@@ -14,6 +14,7 @@ export interface DeepCheckResult {
reverseDns: string | null;
ssdp: {
location: string;
server: string | null;
friendlyName: string | null;
manufacturer: string | null;
modelName: string | null;
+54
View File
@@ -0,0 +1,54 @@
export interface FingerbankResult {
deviceName: string | null;
score: number | null;
version: string | null;
}
export interface FingerbankSignals {
mac: string;
// "Upnp string" per Fingerbank's docs -- we pass the SSDP SERVER header
// captured by deep-check-device.sh, when present (e.g.
// "Linux/3.10 UPnP/1.0 MiniDLNA/1.1"), as extra fingerprinting signal
// beyond the bare MAC.
upnpServer: string | null;
}
// On-demand only (folded into the existing deep-check button), never
// automatic/bulk -- keeps this well under Fingerbank's free-tier query
// limits and avoids sending every device's MAC to a third party by default.
export async function identifyDevice(
apiKey: string,
signals: FingerbankSignals
): Promise<FingerbankResult | null> {
const body: Record<string, unknown> = { mac: signals.mac };
if (signals.upnpServer) body.upnp_user_agents = [signals.upnpServer];
const res = await fetch("https://api.fingerbank.org/api/v2/combinations/interrogate", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 404) {
// No match in Fingerbank's database -- not an error, just nothing found.
return null;
}
if (!res.ok) {
throw new Error(`Fingerbank API returned ${res.status}`);
}
const data = (await res.json()) as {
device_name?: string;
score?: number;
version?: string;
};
return {
deviceName: data.device_name ?? null,
score: data.score ?? null,
version: data.version ?? null,
};
}
+1 -1
View File
@@ -99,7 +99,7 @@ async function main() {
registerAuthRoutes(app, db, cfg.oidc !== undefined);
registerHostRoutes(app, db);
registerDeviceRoutes(app, db, cfg.deepCheck);
registerDeviceRoutes(app, db, cfg.deepCheck, cfg.fingerbankApiKey);
if (cfg.oidc) {
const oidcConfig = await initOidc(cfg.oidc);
+34 -6
View File
@@ -6,8 +6,15 @@ import type Database from "better-sqlite3";
// require()) -- import the default and destructure instead.
import macOuiLookup from "mac-oui-lookup";
const { getVendor } = macOuiLookup;
import { getRecentDevices, setDeviceLabel, clearDeviceLabel, type DeviceRow } from "../db/index.js";
import {
getRecentDevices,
getDeviceMac,
setDeviceLabel,
clearDeviceLabel,
type DeviceRow,
} from "../db/index.js";
import { deepCheckDevice } from "../discovery/deepCheck.js";
import { identifyDevice } from "../discovery/fingerbank.js";
import type { DeepCheckHostConfig } from "../config/index.js";
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
@@ -40,7 +47,8 @@ const IP_RE = /^192\.168\.1\.([0-9]{1,3})$/;
export function registerDeviceRoutes(
app: FastifyInstance,
db: Database.Database,
deepCheckConfig: DeepCheckHostConfig | undefined
deepCheckConfig: DeepCheckHostConfig | undefined,
fingerbankApiKey: string | undefined
): void {
app.get("/api/devices", { preHandler: requireAuth }, async () => {
const rows = getRecentDevices(db);
@@ -72,9 +80,10 @@ export function registerDeviceRoutes(
);
// 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).
// noise/risk of doing this for the whole subnet on every poll (and, for
// Fingerbank, avoids sending every device's MAC to a third party by
// default). 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 },
@@ -83,7 +92,26 @@ export function registerDeviceRoutes(
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;
// Fingerbank is optional enrichment -- a missing key, rate limit, or
// network hiccup shouldn't sink the local findings that already
// succeeded via SSH.
let fingerbank = null;
if (fingerbankApiKey) {
const mac = getDeviceMac(db, req.params.ip);
if (mac) {
try {
fingerbank = await identifyDevice(fingerbankApiKey, {
mac,
upnpServer: result.ssdp?.server ?? null,
});
} catch (err) {
req.log.error({ err, ip: req.params.ip }, "fingerbank lookup failed");
}
}
}
return { ...result, fingerbank };
} 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" });
+8
View File
@@ -79,18 +79,26 @@ export function clearDeviceLabel(mac: string): Promise<{ ok: true }> {
return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" });
}
export interface FingerbankResult {
deviceName: string | null;
score: number | null;
version: string | null;
}
export interface DeepCheckResult {
ip: string;
mdnsHostname: string | null;
reverseDns: string | null;
ssdp: {
location: string;
server: string | null;
friendlyName: string | null;
manufacturer: string | null;
modelName: string | null;
} | null;
openPorts: number[];
http: { port: number; title: string | null; server: string | null }[];
fingerbank: FingerbankResult | null;
}
export function deepCheckDevice(ip: string): Promise<DeepCheckResult> {
+27 -1
View File
@@ -90,9 +90,23 @@ function NameCell({ device, onDeviceChanged }: { device: Device; onDeviceChanged
);
}
// Bands per Fingerbank's own docs: below 30 "very little confidence", 31-50
// moderate, 51-75 high, 76+ very high. Shown alongside the result so a
// manufacturer-only guess isn't mistaken for a confirmed identification.
function confidenceLabel(score: number): string {
if (score < 30) return "low confidence";
if (score <= 50) return "moderate confidence";
if (score <= 75) return "high confidence";
return "very high confidence";
}
function DeepCheckResultPanel({ result }: { result: DeepCheckResult }) {
const nothingFound =
!result.mdnsHostname && !result.reverseDns && !result.ssdp && result.openPorts.length === 0;
!result.mdnsHostname &&
!result.reverseDns &&
!result.ssdp &&
result.openPorts.length === 0 &&
!result.fingerbank?.deviceName;
if (nothingFound) {
return <p className="deep-check-empty">No additional information found for {result.ip}.</p>;
@@ -100,6 +114,18 @@ function DeepCheckResultPanel({ result }: { result: DeepCheckResult }) {
return (
<dl className="deep-check-results">
{result.fingerbank?.deviceName && (
<>
<dt>Fingerbank ID</dt>
<dd>
{result.fingerbank.deviceName}
{result.fingerbank.version && ` (${result.fingerbank.version})`}
{result.fingerbank.score !== null && (
<span className="name-hint"> {confidenceLabel(result.fingerbank.score)}</span>
)}
</dd>
</>
)}
{result.mdnsHostname && (
<>
<dt>mDNS hostname</dt>