diff --git a/.env.example b/.env.example index d2c5253..3ccc4d0 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,10 @@ SNAPSHOT_RETENTION_HOURS=24 # Dedicated key for SshHostCollector (omv, ripper) — see docs/ssh-collector-key-setup.md. # In docker-compose this is mounted from ./ssh/monitor_ed25519 (gitignored, not this path). SSH_PRIVATE_KEY_PATH=./ssh/monitor_ed25519 + +# Optional. Free API key from fingerbank.org — enriches the "Deep check" button's +# results with device fingerprinting (MAC + any SSDP signal found). Without a DHCP +# fingerprint (which we don't have access to, not being the DHCP server), this often +# only reaches manufacturer-level confidence, same as the free OUI lookup — see +# docs/device-discovery.md. Omit entirely to skip this enrichment. +FINGERBANK_API_KEY= diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 1aa4cb7..4d707a7 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -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, }; } diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 330e8d3..4c1152e 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -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) diff --git a/apps/api/src/discovery/deepCheck.ts b/apps/api/src/discovery/deepCheck.ts index 12c8a61..602cd92 100644 --- a/apps/api/src/discovery/deepCheck.ts +++ b/apps/api/src/discovery/deepCheck.ts @@ -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; diff --git a/apps/api/src/discovery/fingerbank.ts b/apps/api/src/discovery/fingerbank.ts new file mode 100644 index 0000000..0a67c8f --- /dev/null +++ b/apps/api/src/discovery/fingerbank.ts @@ -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 { + const body: Record = { 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, + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2652cd9..31df420 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, cfg.deepCheck); + registerDeviceRoutes(app, db, cfg.deepCheck, cfg.fingerbankApiKey); 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 da7ade9..57033a3 100644 --- a/apps/api/src/routes/devices.ts +++ b/apps/api/src/routes/devices.ts @@ -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" }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 0644dad..8f718a9 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx index d769622..4a20d13 100644 --- a/apps/web/src/components/DeviceTable.tsx +++ b/apps/web/src/components/DeviceTable.tsx @@ -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

No additional information found for {result.ip}.

; @@ -100,6 +114,18 @@ function DeepCheckResultPanel({ result }: { result: DeepCheckResult }) { return (
+ {result.fingerbank?.deviceName && ( + <> +
Fingerbank ID
+
+ {result.fingerbank.deviceName} + {result.fingerbank.version && ` (${result.fingerbank.version})`} + {result.fingerbank.score !== null && ( + — {confidenceLabel(result.fingerbank.score)} + )} +
+ + )} {result.mdnsHostname && ( <>
mDNS hostname
diff --git a/docs/device-discovery.md b/docs/device-discovery.md index 3928d1d..cc03169 100644 --- a/docs/device-discovery.md +++ b/docs/device-discovery.md @@ -79,6 +79,31 @@ Requires `avahi-utils` and `miniupnpc` installed on the CT122 host (`apt-get ins 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). +### Fingerbank enrichment (optional) + +When `FINGERBANK_API_KEY` is set, the deep-check route (`apps/api/src/routes/devices.ts`) +also queries [Fingerbank](https://fingerbank.org)'s `/api/v2/combinations/interrogate` +with the device's MAC plus, if found, the SSDP `SERVER` header as an `upnp_user_agents` +signal (`apps/api/src/discovery/fingerbank.ts`). Unlike the other deep-check steps this +runs directly from the API container — no multicast/raw-socket access needed, just a +normal outbound HTTPS call — so no host-level or SSH changes were needed for this part. + +**Honest limitation, confirmed by testing against a real device**: without a DHCP +fingerprint (which requires being the DHCP server — we're not, and have no way to +intercept that traffic from CT122), MAC-only or MAC+UPnP-signal queries often can't get +past manufacturer-level identification. A Nintendo device on this LAN queried as +`{"mac": "..."}` returned `device_name: "Hardware Manufacturer/Nintendo"` at +`score: 29` ("very little confidence" per Fingerbank's own bands) — no more specific +than the free OUI lookup already gives for free. The UI shows the confidence band +alongside the result (`confidenceLabel()` in `DeviceTable.tsx`) precisely so a +manufacturer-only guess at low confidence isn't mistaken for a confirmed ID. Still worth +having as opt-in enrichment — some devices *do* expose richer signals (a real DHCP +fingerprint, a distinctive UPnP string) that push the score meaningfully higher — but +don't expect it to reliably answer "what specific model is this" on its own. + +Optional and gated: missing key, a Fingerbank API error, or no match all degrade +gracefully — the rest of deep-check's local findings (mDNS/SSDP/ports) are unaffected. + ## Deploying/updating ```bash diff --git a/scripts/deep-check-device.sh b/scripts/deep-check-device.sh index 3d6a35b..2ffa6fb 100755 --- a/scripts/deep-check-device.sh +++ b/scripts/deep-check-device.sh @@ -28,7 +28,11 @@ fi MDNS=$(timeout 2 avahi-resolve -a "$IP" 2>/dev/null | awk '{print $2}' || true) RDNS=$(timeout 2 getent hosts "$IP" 2>/dev/null | awk '{print $2}' | head -1 || true) -SSDP_LOCATION=$(timeout 4 python3 - "$IP" <<'PYEOF' || true +# Prints LOCATION and SERVER headers (one per line) from the first ssdp:all +# response the target IP sends -- SERVER is a useful extra fingerprinting +# signal (e.g. "Linux/3.10 UPnP/1.0 MiniDLNA/1.1") beyond just the device +# descriptor XML, fed to Fingerbank as an upnp_user_agents hint. +SSDP_RAW=$(timeout 4 python3 - "$IP" <<'PYEOF' || true import socket, sys, time target = sys.argv[1] msg = "\r\n".join([ @@ -44,14 +48,21 @@ while time.time() - start < 3: try: data, addr = sock.recvfrom(4096) if addr[0] == target: + location = server = "" for line in data.decode(errors="replace").split("\r\n"): if line.lower().startswith("location:"): - print(line.split(":", 1)[1].strip()) - sys.exit(0) + location = line.split(":", 1)[1].strip() + elif line.lower().startswith("server:"): + server = line.split(":", 1)[1].strip() + print(location) + print(server) + sys.exit(0) except socket.timeout: break PYEOF ) +SSDP_LOCATION=$(echo "$SSDP_RAW" | sed -n '1p') +SSDP_SERVER=$(echo "$SSDP_RAW" | sed -n '2p') SSDP_FRIENDLY="" SSDP_MANUFACTURER="" SSDP_MODEL="" if [[ -n "$SSDP_LOCATION" ]]; then @@ -85,11 +96,11 @@ for p in "${open_ports[@]}"; do http_titles+=("$p|$title|$server") done -python3 - "$IP" "$MDNS" "$RDNS" "$SSDP_LOCATION" "$SSDP_FRIENDLY" "$SSDP_MANUFACTURER" "$SSDP_MODEL" \ +python3 - "$IP" "$MDNS" "$RDNS" "$SSDP_LOCATION" "$SSDP_SERVER" "$SSDP_FRIENDLY" "$SSDP_MANUFACTURER" "$SSDP_MODEL" \ "$(IFS=,; echo "${open_ports[*]}")" "$(printf '%s\n' "${http_titles[@]}")" <<'PYEOF' import json, sys -ip, mdns, rdns, ssdp_loc, ssdp_friendly, ssdp_mfr, ssdp_model, ports_csv, http_raw = sys.argv[1:10] +ip, mdns, rdns, ssdp_loc, ssdp_server, ssdp_friendly, ssdp_mfr, ssdp_model, ports_csv, http_raw = sys.argv[1:11] http = [] for line in http_raw.splitlines(): @@ -108,6 +119,7 @@ result = { "reverseDns": rdns or None, "ssdp": { "location": ssdp_loc or None, + "server": ssdp_server or None, "friendlyName": ssdp_friendly or None, "manufacturer": ssdp_mfr or None, "modelName": ssdp_model or None,