Persistent MAC-keyed lan_devices store fed by an hourly arp-scan on CT 311; diffs new vs known, mirrors the services discovered→promote flow for naming/ editing. Upsert-by-MAC keeps the table bounded. Borrows decoupled-scanner + MAC-identity lessons from scanopy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
8.1 KiB
Markdown
157 lines
8.1 KiB
Markdown
# Design: LAN Device Discovery (MAC inventory + review/name)
|
||
|
||
**Date:** 2026-06-08
|
||
**Status:** Approved (brainstorm), pending implementation plan
|
||
**Repo:** void-v2 (CT 311 / `void-app`)
|
||
|
||
## Summary
|
||
|
||
Replace the static, hand-maintained `public/devices.json` with a **persistent,
|
||
MAC-keyed device store** fed by a recurring ARP scan. Each scan **logs MACs to
|
||
the DB and diffs against what's known** — new devices land in a review queue;
|
||
known devices just get their IP / `last_seen` / presence updated. The owner can
|
||
**add a discovered device, edit it, and give it a name** for reference (mirrors
|
||
the Void's existing services "discovered → promote" pattern).
|
||
|
||
## Background
|
||
|
||
- **Static today:** `public/views/devices_band.js` does `fetch('/devices.json')`
|
||
— a curated, manually-edited list (IP/MAC/vendor/group/flag). It re-reads a
|
||
static file; nothing is persisted or diffed.
|
||
- **Existing precedent to mirror:** `monitored_services` uses
|
||
`source='discovered' AND NOT enabled` as a review queue; `PATCH /services/:id`
|
||
promotes + edits. We reproduce this shape for devices.
|
||
- **Separate from `network_hosts`:** that table is the homelab-guest inventory
|
||
(Proxmox `BC:24:11:*` LXCs, infra_audit). The devices band is IoT / personal /
|
||
unknown LAN gear — kept separate.
|
||
- **Scan engine:** the Void host (CT 311) has `ip`/`arp` but **not**
|
||
`nmap`/`arp-scan`. We add `arp-scan` (chosen for reliable L2 ARP sweeps that
|
||
ICMP-blocking devices can't dodge, plus a built-in OUI vendor DB).
|
||
|
||
### Lessons borrowed from Scanopy (self-hosted discovery tool)
|
||
|
||
- **Decouple scanner from storage/UI** — the scanner just scans and reports; the
|
||
server owns dedup + persistence. → isolated `lib/infra/scan.js`.
|
||
- **MAC is the identity, IP is a mutable attribute** — key on MAC, update IP each
|
||
scan (handles DHCP churn). → `mac` primary key.
|
||
- **Scheduled rescans + timestamp inventory** — periodic batch with
|
||
`first_seen`/`last_seen`/`present`, diff by "MAC seen before?". → hourly cron.
|
||
- **Vendor via OUI** — `arp-scan` ships an OUI database; vendor is free.
|
||
- **Randomized MACs are an open problem** even for Scanopy — so we at least
|
||
**flag** locally-administered MACs so the user knows OUI can't ID them.
|
||
|
||
## Decisions
|
||
|
||
| Decision | Choice | Rationale |
|
||
|---|---|---|
|
||
| Scan engine | **`arp-scan --localnet` on CT 311**, hourly cron | Reliable L2 sweep + built-in OUI; self-contained (no external scanner dep). |
|
||
| Cadence | **Hourly** (staggered, e.g. `7 * * * *`) | "No rush"; device drift is slow. |
|
||
| DB growth | **Upsert by MAC — one row per device, no per-scan history** | Table is bounded by distinct devices ever seen (dozens–hundreds), not scan count → no bloat. |
|
||
| Identity | **MAC primary key**; IP a mutable column | Survives DHCP IP changes. |
|
||
| Review flow | Mirror services `discovered → promote` | New MAC → `status='new'`; owner names/edits → `status='known'`. |
|
||
| Source of truth | **DB** (`lan_devices`); `devices.json` becomes the one-time migration seed, then removed | Single source of truth. |
|
||
|
||
## Architecture
|
||
|
||
scan (arp-scan) → `parseArpScan` (+randomized flag) → `upsertScan` by MAC →
|
||
`markAbsent` for unseen → review queue (`status='new'`) → owner names/groups/promotes
|
||
→ known devices render in the band.
|
||
|
||
## Components
|
||
|
||
### Migration `024_lan_devices.sql`
|
||
Table `lan_devices`:
|
||
- `mac text PRIMARY KEY`
|
||
- `ip text`, `vendor text`
|
||
- `name text` (owner-given reference name, null until named)
|
||
- `grp text` (Smart Home | Entertainment | Personal | Network | Flagged)
|
||
- `note text`
|
||
- `status text NOT NULL DEFAULT 'new'` (`new` | `known` | `ignored`)
|
||
- `randomized boolean NOT NULL DEFAULT false` (locally-administered MAC)
|
||
- `flagged boolean NOT NULL DEFAULT false`
|
||
- `first_seen timestamptz NOT NULL DEFAULT now()`
|
||
- `last_seen timestamptz NOT NULL DEFAULT now()`
|
||
- `present boolean NOT NULL DEFAULT true`
|
||
|
||
**Seed (embedded SQL, from the current curated `devices.json`):**
|
||
- Devices **with a MAC**: non-flagged → `status='known'` with their name/group;
|
||
flagged (e.g. `.15` ASUS) → `status='new'`, `flagged=true`.
|
||
- The `.13` Orbi satellite and `.171` Galaxy Tab S4 fixes carry over as `known`.
|
||
- MAC-less curated entries (`.21/.22/.34/.35/.51`, currently offline) are **not
|
||
seeded** — they reappear as `new` (with a real MAC) the first time they're seen
|
||
online. (Documented so it's expected, not a gap.)
|
||
|
||
### `lib/infra/scan.js` (decoupled scanner)
|
||
- `parseArpScan(text) -> [{ ip, mac, vendor, randomized }]` — **pure** parser of
|
||
`arp-scan` tab-separated output (skips banner/footer); `randomized` = first
|
||
octet has the locally-administered bit (`& 0x02`).
|
||
- `isRandomizedMac(mac) -> boolean` — pure helper.
|
||
- `runScan({ exec }) -> rows` — shells `arp-scan --localnet -x` (interface
|
||
auto/`-I eth0`), returns `parseArpScan(stdout)`. `exec` injected for tests.
|
||
|
||
### `lib/db/repos/lan_devices.js`
|
||
- `upsertScan(rows)` — insert unseen MACs as `status='new'`; for existing, update
|
||
`ip`, `vendor`, `last_seen=now()`, `present=true` (never overwrite owner
|
||
`name`/`grp`/`status`).
|
||
- `markAbsent(seenMacs)` — `present=false` for MACs not in the latest scan.
|
||
- `listKnown()` (`status='known'`, grouped by `grp`), `listDiscovered()`
|
||
(`status='new'`), `get(mac)`, `update(mac, {name, grp, status, note, flagged})`,
|
||
`remove(mac)`. (`ignored` devices show in neither.)
|
||
|
||
### Cron (`lib/cron/index.js`)
|
||
Add hourly (`7 * * * *`): `runScan()` → `upsertScan` → `markAbsent`. Wrapped in
|
||
try/catch — a scan failure logs and never crashes the cron.
|
||
|
||
### API `lib/api/routes/devices.js` (mount `/api/devices`, owner-gated)
|
||
- `GET /` — known devices grouped for the band.
|
||
- `GET /discovered` — `status='new'` review queue.
|
||
- `PATCH /:mac` — set `name`/`grp`/`status`/`note`/`flagged` (this is "add from
|
||
discovered" + "edit" + "name"); promoting = `status:'known'`.
|
||
- `DELETE /:mac` — remove.
|
||
- `POST /scan` — run a scan immediately (owner).
|
||
- `:mac` param validated against a MAC regex.
|
||
|
||
### Frontend
|
||
- `public/views/devices_band.js` — fetch `/api/devices` (grouped) instead of the
|
||
static file; render the MAC (existing `.dv-mac` style from today's change).
|
||
- **Discovered review** — a section/panel listing `/api/devices/discovered`, each
|
||
with an **Add / Edit** form (name + group select + notes) that `PATCH`es to
|
||
promote; plus inline edit for known devices and an Ignore/Delete action.
|
||
- Remove `public/devices.json` (superseded by the DB).
|
||
|
||
## Infra setup (one-time, on CT 311)
|
||
`apt install arp-scan` + grant the binary raw-socket capability so the non-root
|
||
`void` service user can run it:
|
||
`setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan`. Captured in
|
||
`deploy/README.md`. If the capability/tool is missing, the scan logs a clear
|
||
error and the feature degrades to "no new discoveries" (existing data still shows).
|
||
|
||
## Error handling
|
||
- `arp-scan` missing / unprivileged / non-zero exit → `runScan` throws; cron
|
||
catches, logs, leaves the DB untouched (known devices still render).
|
||
- Empty/garbled scan output → `parseArpScan` returns `[]`; `markAbsent([])` is a
|
||
no-op guard (never blanket-marks everything absent on a failed scan).
|
||
- Bad MAC in PATCH → 400 via zod.
|
||
|
||
## Testing
|
||
- **`parseArpScan` / `isRandomizedMac`** — pure unit tests (sample arp-scan
|
||
output incl. a randomized MAC, banner/footer lines, a malformed line).
|
||
- **`lan_devices` repo** (vitest + test DB) — `upsertScan` inserts new vs updates
|
||
existing without clobbering owner fields; `markAbsent` flips presence; promote
|
||
via `update`.
|
||
- **API** (supertest) — `/discovered` lists only `new`; `PATCH` promotes/edits;
|
||
owner-gated.
|
||
- **Frontend** (jsdom) — band renders groups + MAC from `/api/devices`;
|
||
discovered panel renders the add/edit form.
|
||
- **Manual** — `POST /api/devices/scan`, confirm new devices appear, name one,
|
||
see it move to the band.
|
||
|
||
## Out of scope (YAGNI)
|
||
- Service/port fingerprinting, SNMP/LLDP topology (that's Scanopy's job).
|
||
- Multi-subnet/VLAN scanning (single `/24`).
|
||
- Auto-pruning stale `new` devices (revisit only if the queue gets noisy).
|
||
- Push notifications on new-device discovery.
|
||
|
||
## References
|
||
- Scanopy — github.com/scanopy/scanopy ; scanopy.net (self-hosted discovery/topology, AGPL-3.0).
|