Files
homelab-monitor/docs/device-discovery.md
T
jhodgkin d0d6ae95f1
CI / web (push) Successful in 16s
CI / api (push) Successful in 21s
Add optional Fingerbank device fingerprinting to deep-check
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>
2026-07-12 23:35:35 -06:00

7.6 KiB

LAN device discovery

Shows every device currently on the LAN, labeled known/unknown, in the dashboard's "Network Devices" section.

Why this isn't pure passive ARP reading

The original idea was to just read the ARP table (no active network traffic). In practice that produces a nearly empty list: a host's ARP cache only contains entries for peers it has actually exchanged traffic with, and the dashboard container has no reason to talk to most LAN devices on its own. So discovery does a lightweight ICMP ping sweep (no port scanning) first, to populate the cache, then reads it.

Why this runs outside the Docker container

Real ARP entries live in the network namespace of whichever host is directly on the LAN interface. The api container sits behind Docker's own bridge network — pinging from inside it and reading /proc/net/arp there would show Docker's internal network, not the actual LAN. Rather than switch the containers to network_mode: host (loses container network isolation), discovery runs as a small script directly on the CT122 host via a systemd timer — the same pattern the homelab already uses for host-level periodic jobs like the rclone backups on CT105.

Pieces

  • scripts/discover-devices.sh — ping sweep + ip neigh show parse, then an 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 5 minutes on the CT122 host (not in Docker).
  • apps/api/src/discovery/index.ts — reads that JSON file each poll cycle, labels entries against knownDevices in config/hosts.yaml (matched by IP), upserts into the devices SQLite table.
  • 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).

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 labelsdevice_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).

Fingerbank enrichment (optional)

When FINGERBANK_API_KEY is set, the deep-check route (apps/api/src/routes/devices.ts) also queries Fingerbank'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

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/
ssh homelab-monitor "chmod +x /opt/homelab-monitor/scripts/*.sh && \
  systemctl daemon-reload && systemctl enable --now homelab-monitor-discover.timer"

(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.)

Known limitations

  • 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).