From 26eeb2c1005294b012e788b59a9e4781490a3fdd Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 20:54:43 +1000 Subject: [PATCH] docs(devices): implementation plan for LAN device discovery Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-08-lan-device-discovery.md | 847 ++++++++++++++++++ 1 file changed, 847 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-lan-device-discovery.md diff --git a/docs/superpowers/plans/2026-06-08-lan-device-discovery.md b/docs/superpowers/plans/2026-06-08-lan-device-discovery.md new file mode 100644 index 0000000..746e163 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-lan-device-discovery.md @@ -0,0 +1,847 @@ +# LAN Device Discovery — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the static `devices.json` with a persistent, MAC-keyed `lan_devices` store fed by an hourly `arp-scan`, with a "discovered → name/edit → promote" review flow and randomized-MAC auto-pruning. + +**Architecture:** A decoupled scanner (`lib/infra/scan.js`, pure parser + injected exec) feeds a repo (`lib/db/repos/lan_devices.js`) keyed by MAC. An hourly cron runs scan→upsert→mark-absent→prune. An owner API (`/api/devices`) exposes the band + review/edit. The front-end band reads the DB and offers an add/edit panel. + +**Tech Stack:** Node/Express, Postgres (`pg`), vanilla-ESM SPA, vitest+supertest+jsdom, `arp-scan`. + +**Spec:** `docs/superpowers/specs/2026-06-08-lan-device-discovery-design.md` +**Branch:** `feat/lan-device-discovery` (spec already committed there). + +**Conventions to mirror:** repo = `lib/db/repos/monitored_services.js`; route = `lib/api/routes/health.js` (`Router`, `asyncWrap`, `requireOwner` from `../cap.js`, `validate` from `../validate.js`); repo test = `tests/repos/monitored_services.test.js` (`resetDb()`+`migrateUp()`); route test = `tests/server.test.js` (supertest, `OWNER_TOKEN='test-token'`, `Authorization: Bearer test-token`); frontend test = `tests/frontend/service_tile.test.js` (jsdom). Run a single file: `npm test -- `. + +--- + +### Task 1: Scanner module (pure parse + randomized detection + runScan) + +**Files:** +- Create: `lib/infra/scan.js` +- Test: `tests/infra/scan.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/infra/scan.test.js +import { describe, it, expect } from 'vitest'; +import { isRandomizedMac, parseArpScan, runScan } from '../../lib/infra/scan.js'; + +const SAMPLE = [ + 'Interface: eth0, type: EN10MB, MAC: bc:24:11:9b:b7:3a, IPv4: 192.168.1.216', + 'Starting arp-scan 1.10.0', + '192.168.1.13\tbc:a5:11:3e:06:88\tNetgear', + '192.168.1.171\t5a:da:61:7a:0f:12\t(Unknown)', + '192.168.1.1\t44:A5:6E:68:D0:E9\tNetgear Inc.', + 'garbage line that is not a host', + '', + '3 packets received by filter, 0 packets dropped' +].join('\n'); + +describe('scan parsing', () => { + it('isRandomizedMac flags locally-administered MACs', () => { + expect(isRandomizedMac('5a:da:61:7a:0f:12')).toBe(true); // 0x5a & 0x02 + expect(isRandomizedMac('bc:a5:11:3e:06:88')).toBe(false); // 0xbc & 0x02 == 0 + expect(isRandomizedMac('44:A5:6E:68:D0:E9')).toBe(false); + }); + + it('parseArpScan keeps only host lines, lowercases MAC, flags randomized', () => { + const rows = parseArpScan(SAMPLE); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ ip: '192.168.1.13', mac: 'bc:a5:11:3e:06:88', vendor: 'Netgear', randomized: false }); + expect(rows[1]).toEqual({ ip: '192.168.1.171', mac: '5a:da:61:7a:0f:12', vendor: '(Unknown)', randomized: true }); + expect(rows[2].mac).toBe('44:a5:6e:68:d0:e9'); // lowercased + }); + + it('runScan parses the injected exec stdout', async () => { + const rows = await runScan({ exec: async () => ({ stdout: SAMPLE }) }); + expect(rows.map(r => r.ip)).toEqual(['192.168.1.13', '192.168.1.171', '192.168.1.1']); + }); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `npm test -- tests/infra/scan.test.js` +Expected: FAIL — `Cannot find module '../../lib/infra/scan.js'`. + +- [ ] **Step 3: Create `lib/infra/scan.js`** + +```js +// Decoupled LAN scanner: pure parser + a thin arp-scan runner (exec injected +// for tests). The repo/cron own persistence — this module only produces rows. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const pexec = promisify(execFile); + +// A locally-administered (randomized) MAC has bit 0x02 set in its first octet. +export function isRandomizedMac(mac) { + const first = parseInt(String(mac).split(':')[0], 16); + return Number.isFinite(first) && (first & 0x02) === 0x02; +} + +// Keep only "IPMAC[vendor]" lines; ignore banner/footer/garbage. +export function parseArpScan(text) { + const re = /^(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\s*(.*)$/; + const out = []; + for (const line of String(text).split('\n')) { + const m = line.match(re); + if (!m) continue; + const mac = m[2].toLowerCase(); + out.push({ ip: m[1], mac, vendor: m[3].trim(), randomized: isRandomizedMac(mac) }); + } + return out; +} + +// Run arp-scan on the local /24. `exec(file, args) -> {stdout}` injected for tests. +export async function runScan({ exec = pexec } = {}) { + const { stdout } = await exec('arp-scan', ['--localnet', '--plain', '--retry=2']); + return parseArpScan(stdout); +} +``` + +- [ ] **Step 4: Run it; verify it passes** + +Run: `npm test -- tests/infra/scan.test.js` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add lib/infra/scan.js tests/infra/scan.test.js +git commit -m "feat(devices): arp-scan parser + randomized-MAC detection" +``` + +--- + +### Task 2: Migration `024_lan_devices` (table + seed) + +**Files:** +- Create: `lib/db/migrations/024_lan_devices.sql` +- Test: covered by the repo test in Task 3 (seed assertions). This task is verified by running migrations. + +- [ ] **Step 1: Create the migration** + +```sql +-- 024_lan_devices.sql +-- LAN device inventory keyed by MAC, fed by the hourly arp-scan. Separate from +-- network_hosts (homelab guests). New MACs land status='new' for owner review. +CREATE TABLE IF NOT EXISTS lan_devices ( + mac text PRIMARY KEY, + ip text, + vendor text, + name text, + grp text, + note text, + status text NOT NULL DEFAULT 'new', -- new | known | ignored + randomized boolean NOT NULL DEFAULT false, + 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 from the curated devices.json (MACs lowercased). Named devices -> 'known'; +-- the unidentified ASUS box -> 'new'. present=false until the first live scan. +INSERT INTO lan_devices (mac, ip, vendor, name, grp, status, flagged, randomized, present) VALUES + ('48:43:dd:fc:2f:84','192.168.1.3','Amazon','Amazon Echo','Smart Home','known',false,false,false), + ('14:0a:c5:6d:15:6e','192.168.1.4','Amazon','Amazon Echo','Smart Home','known',false,false,false), + ('c8:47:8c:01:17:70','192.168.1.6','Beken','Smart device','Smart Home','known',false,false,false), + ('d4:a6:51:12:36:92','192.168.1.23','Tuya','Smart device','Smart Home','known',false,false,false), + ('ec:4d:3e:36:ef:e1','192.168.1.20','Xiaomi','Xiaomi device','Smart Home','known',false,false,false), + ('1c:53:f9:bb:32:24','192.168.1.12','Google','Google / Nest','Entertainment','known',false,false,false), + ('d4:f5:47:95:33:93','192.168.1.14','Google','Google Nest Mini','Entertainment','known',false,false,false), + ('ec:4d:3e:37:38:8f','192.168.1.18','Google','Google / Nest','Entertainment','known',false,false,false), + ('48:70:1e:01:4f:7b','192.168.1.29','StreamMagic','Cambridge Audio','Entertainment','known',false,false,false), + ('08:66:98:b9:cf:f2','192.168.1.43','Apple','Apple TV / HomePod','Entertainment','known',false,false,false), + ('1c:86:9a:4c:f0:ec','192.168.1.24','Samsung','Samsung TV','Entertainment','known',false,false,false), + ('5a:da:61:7a:0f:12','192.168.1.171','Samsung','Galaxy Tab S4','Personal','known',false,true,false), + ('1c:57:dc:70:e8:2d','192.168.1.133','Apple','Apple device','Personal','known',false,false,false), + ('a0:d0:5b:04:70:96','192.168.1.61','Samsung','Samsung device','Personal','known',false,false,false), + ('14:eb:b6:40:7e:93','192.168.1.10','TP-Link','TP-Link device','Personal','known',false,false,false), + ('44:a5:6e:68:d0:e9','192.168.1.1','Netgear','Gateway / Router','Network','known',false,false,false), + ('bc:a5:11:3e:06:88','192.168.1.13','Netgear (Orbi mesh)','Orbi Satellite','Network','known',false,false,false), + ('24:4b:fe:8e:09:a4','192.168.1.15','ASUSTek','ASUS device','Flagged','new',true,false,false) +ON CONFLICT (mac) DO NOTHING; +``` + +- [ ] **Step 2: Run migrations against the test DB to verify the SQL is valid** + +Run: `node -e "import('./lib/db/migrate.js').then(m=>m.migrateUp()).then(()=>{console.log('migrated');process.exit(0)}).catch(e=>{console.error(e);process.exit(1)})"` +Expected: prints `migrated` (no SQL error). (Uses the env `DATABASE_URL`; in dev this is the test/dev DB.) + +- [ ] **Step 3: Commit** + +```bash +git add lib/db/migrations/024_lan_devices.sql +git commit -m "feat(devices): lan_devices table + seed from curated devices.json" +``` + +--- + +### Task 3: `lan_devices` repo (upsert / absent / prune / promote) + +**Files:** +- Create: `lib/db/repos/lan_devices.js` +- Test: `tests/repos/lan_devices.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/repos/lan_devices.test.js +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { pool } from '../../lib/db/pool.js'; +import * as repo from '../../lib/db/repos/lan_devices.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('lan_devices repo', () => { + it('seed: 17 known, 1 discovered (ASUS)', async () => { + expect(await repo.listKnown()).toHaveLength(17); + const disc = await repo.listDiscovered(); + expect(disc).toHaveLength(1); + expect(disc[0].mac).toBe('24:4b:fe:8e:09:a4'); + expect(disc[0].flagged).toBe(true); + }); + + it('upsertScan inserts unseen as new, updates known IP without clobbering name', async () => { + await repo.upsertScan([ + { mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: 'NewCo', randomized: false }, // new + { mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.77', vendor: 'Netgear', randomized: false } // known Orbi, IP changed + ]); + const orbi = await repo.get('bc:a5:11:3e:06:88'); + expect(orbi.ip).toBe('192.168.1.77'); // ip updated + expect(orbi.name).toBe('Orbi Satellite'); // name preserved + expect(orbi.status).toBe('known'); // status preserved + expect(orbi.present).toBe(true); + const fresh = await repo.get('aa:bb:cc:dd:ee:ff'); + expect(fresh.status).toBe('new'); + }); + + it('markAbsent flips present for unseen; empty list is a no-op', async () => { + await repo.upsertScan([{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: '', randomized: false }]); + await repo.markAbsent(['aa:bb:cc:dd:ee:ff']); // only this one seen + expect((await repo.get('bc:a5:11:3e:06:88')).present).toBe(false); // seeded device now absent + expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(true); + const before = (await repo.get('aa:bb:cc:dd:ee:ff')).present; + expect(await repo.markAbsent([])).toBe(0); // guard: no-op + expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(before); + }); + + it('prune deletes stale new+absent (randomized >24h, others >14d); keeps known', async () => { + await pool.query(`INSERT INTO lan_devices (mac, status, randomized, present, last_seen) + VALUES ('11:11:11:11:11:11','new',true,false, now()-interval '2 days'), + ('22:22:22:22:22:22','new',false,false, now()-interval '20 days'), + ('33:33:33:33:33:33','new',true,false, now()-interval '1 hour'), + ('44:44:44:44:44:44','known',true,false, now()-interval '99 days')`); + const n = await repo.prune(); + expect(n).toBe(2); // the two stale 'new' + expect(await repo.get('33:33:33:33:33:33')).not.toBeNull(); // recent kept + expect(await repo.get('44:44:44:44:44:44')).not.toBeNull(); // known kept + }); + + it('update promotes + names a discovered device', async () => { + await repo.update('24:4b:fe:8e:09:a4', { name: 'ASUS RT-AX88U', grp: 'Network', status: 'known', flagged: false }); + expect(await repo.listDiscovered()).toHaveLength(0); + const d = await repo.get('24:4b:fe:8e:09:a4'); + expect(d.name).toBe('ASUS RT-AX88U'); + expect(d.status).toBe('known'); + }); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `npm test -- tests/repos/lan_devices.test.js` +Expected: FAIL — `Cannot find module '../../lib/db/repos/lan_devices.js'`. + +- [ ] **Step 3: Create `lib/db/repos/lan_devices.js`** + +```js +import { pool } from '../pool.js'; + +const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present'; + +export async function listKnown() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM lan_devices WHERE status='known' ORDER BY grp, name NULLS LAST, ip`); + return rows; +} + +export async function listDiscovered() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM lan_devices WHERE status='new' ORDER BY last_seen DESC`); + return rows; +} + +export async function get(mac) { + const { rows: [r] } = await pool.query(`SELECT ${COLS} FROM lan_devices WHERE mac=$1`, [mac]); + return r || null; +} + +// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present +// WITHOUT touching owner-curated name/grp/status/flagged. +export async function upsertScan(rows) { + for (const r of rows) { + await pool.query( + `INSERT INTO lan_devices (mac, ip, vendor, randomized, status, present, first_seen, last_seen) + VALUES ($1,$2,$3,$4,'new',true,now(),now()) + ON CONFLICT (mac) DO UPDATE SET + ip = EXCLUDED.ip, + vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor), + last_seen = now(), present = true`, + [r.mac, r.ip ?? null, r.vendor ?? null, !!r.randomized]); + } + return rows.length; +} + +// Mark devices not in the latest scan as absent. Empty input is a no-op so a +// failed/empty scan can never blanket-mark everything offline. +export async function markAbsent(seenMacs) { + if (!seenMacs || !seenMacs.length) return 0; + const { rowCount } = await pool.query( + `UPDATE lan_devices SET present=false WHERE present=true AND NOT (mac = ANY($1::text[]))`, + [seenMacs]); + return rowCount; +} + +// Reap unreviewed + absent rows past their TTL. Never touches known/ignored. +export async function prune() { + const { rowCount } = await pool.query( + `DELETE FROM lan_devices WHERE status='new' AND present=false AND ( + (randomized AND last_seen < now() - interval '24 hours') OR + (NOT randomized AND last_seen < now() - interval '14 days'))`); + return rowCount; +} + +const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged']; +export async function update(mac, patch) { + const sets = [], vals = []; + for (const k of PATCHABLE) { + if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); } + } + if (!sets.length) return get(mac); + vals.push(mac); + const { rows: [r] } = await pool.query( + `UPDATE lan_devices SET ${sets.join(', ')} WHERE mac=$${vals.length} RETURNING ${COLS}`, vals); + return r || null; +} + +export async function remove(mac) { + const { rowCount } = await pool.query(`DELETE FROM lan_devices WHERE mac=$1`, [mac]); + return rowCount > 0; +} +``` + +- [ ] **Step 4: Run it; verify it passes** + +Run: `npm test -- tests/repos/lan_devices.test.js` +Expected: PASS (6 passed). + +- [ ] **Step 5: Commit** + +```bash +git add lib/db/repos/lan_devices.js tests/repos/lan_devices.test.js +git commit -m "feat(devices): lan_devices repo (upsert/absent/prune/promote)" +``` + +--- + +### Task 4: Scan-cycle orchestration + cron wiring + +**Files:** +- Create: `lib/infra/scan_cycle.js` +- Modify: `lib/cron/index.js` +- Test: `tests/infra/scan_cycle.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/infra/scan_cycle.test.js +import { describe, it, expect, vi } from 'vitest'; +import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js'; + +function fakeRepo() { + return { + calls: [], + upsertScan: vi.fn(async r => r.length), + markAbsent: vi.fn(async () => 1), + prune: vi.fn(async () => 2) + }; +} + +describe('runDeviceScanCycle', () => { + it('scan→upsert→markAbsent→prune on a non-empty scan', async () => { + const repo = fakeRepo(); + const scan = vi.fn(async () => [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '1.2.3.4', vendor: 'x', randomized: false }]); + const res = await runDeviceScanCycle({ scan, repo }); + expect(repo.upsertScan).toHaveBeenCalledOnce(); + expect(repo.markAbsent).toHaveBeenCalledWith(['aa:bb:cc:dd:ee:ff']); + expect(repo.prune).toHaveBeenCalledOnce(); + expect(res).toEqual({ seen: 1, pruned: 2 }); + }); + + it('skips upsert/prune when the scan returns nothing', async () => { + const repo = fakeRepo(); + const res = await runDeviceScanCycle({ scan: async () => [], repo }); + expect(repo.upsertScan).not.toHaveBeenCalled(); + expect(repo.prune).not.toHaveBeenCalled(); + expect(res).toEqual({ seen: 0 }); + }); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `npm test -- tests/infra/scan_cycle.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `lib/infra/scan_cycle.js`** + +```js +// One discovery cycle: scan → upsert → mark-absent → prune. Deps injected for +// tests. Prune only runs after a successful, non-empty scan, so a failed scan +// can never reap rows. +import { runScan } from './scan.js'; +import * as devices from '../db/repos/lan_devices.js'; +import { log } from '../log.js'; + +export async function runDeviceScanCycle({ scan = runScan, repo = devices } = {}) { + const rows = await scan(); + if (!rows.length) { + log.warn('device scan returned no hosts; skipping upsert/prune'); + return { seen: 0 }; + } + await repo.upsertScan(rows); + await repo.markAbsent(rows.map(r => r.mac)); + const pruned = await repo.prune(); + log.info({ seen: rows.length, pruned }, 'device scan cycle complete'); + return { seen: rows.length, pruned }; +} +``` + +- [ ] **Step 4: Run it; verify it passes** + +Run: `npm test -- tests/infra/scan_cycle.test.js` +Expected: PASS (2 passed). + +- [ ] **Step 5: Wire the hourly cron** + +In `lib/cron/index.js`, add the import near the other imports: +```js +import { runDeviceScanCycle } from '../infra/scan_cycle.js'; +``` +Then inside `startCron()`, before the final `log.info('cron started')`, add: +```js + // Hourly LAN device scan (staggered off the :00 speedtest) + cron.schedule('7 * * * *', async () => { + try { await runDeviceScanCycle(); } + catch (e) { log.error({ err: e }, 'device scan cycle failed'); } + }); +``` + +- [ ] **Step 6: Sanity-check + commit** + +Run: `node --check lib/cron/index.js` +Expected: clean (exit 0). +```bash +git add lib/infra/scan_cycle.js lib/cron/index.js tests/infra/scan_cycle.test.js +git commit -m "feat(devices): hourly scan-cycle orchestration + cron" +``` + +--- + +### Task 5: API route `/api/devices` + +**Files:** +- Create: `lib/api/routes/devices.js` +- Modify: `lib/api/index.js` (import + mount) +- Test: `tests/api/devices.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/api/devices.test.js +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../server.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +let app; +const owner = r => r.set('Authorization', 'Bearer test-token'); +beforeAll(async () => { process.env.OWNER_TOKEN = 'test-token'; app = createApp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('/api/devices', () => { + it('GET / returns known devices grouped', async () => { + const res = await request(app).get('/api/devices'); + expect(res.status).toBe(200); + const names = res.body.groups.map(g => g.name); + expect(names).toContain('Network'); + const net = res.body.groups.find(g => g.name === 'Network'); + expect(net.devices.some(d => d.name === 'Orbi Satellite')).toBe(true); + }); + + it('GET /discovered requires owner and lists new devices', async () => { + expect((await request(app).get('/api/devices/discovered')).status).toBe(401); + const res = await owner(request(app).get('/api/devices/discovered')); + expect(res.status).toBe(200); + expect(res.body.some(d => d.mac === '24:4b:fe:8e:09:a4')).toBe(true); + }); + + it('PATCH /:mac promotes + names (owner)', async () => { + const res = await owner(request(app).patch('/api/devices/24:4b:fe:8e:09:a4')) + .send({ name: 'ASUS Router', grp: 'Network', status: 'known' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('ASUS Router'); + expect((await owner(request(app).get('/api/devices/discovered'))).body).toHaveLength(0); + }); + + it('PATCH rejects a bad MAC', async () => { + expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `npm test -- tests/api/devices.test.js` +Expected: FAIL — route not mounted (404s / module missing). + +- [ ] **Step 3: Create `lib/api/routes/devices.js`** + +```js +import { Router } from 'express'; +import { z } from 'zod'; +import { asyncWrap } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import { validate } from '../validate.js'; +import * as devices from '../../db/repos/lan_devices.js'; + +export const router = Router(); +const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; + +// GET /devices — known devices grouped for the band (open within the app, like /services). +router.get('/', asyncWrap(async (_req, res) => { + const byGrp = new Map(); + for (const d of await devices.listKnown()) { + const g = d.grp || 'Flagged'; + if (!byGrp.has(g)) byGrp.set(g, []); + byGrp.get(g).push(d); + } + const order = [...GROUP_ORDER, ...[...byGrp.keys()].filter(g => !GROUP_ORDER.includes(g))]; + res.json({ groups: order.filter(g => byGrp.has(g)).map(name => ({ name, devices: byGrp.get(name) })) }); +})); + +// GET /devices/discovered — review queue (owner). +router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => { + res.json(await devices.listDiscovered()); +})); + +const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) }); +const patchBody = z.object({ + name: z.string().max(120).optional(), + grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(), + status: z.enum(['new', 'known', 'ignored']).optional(), + note: z.string().max(500).optional(), + flagged: z.boolean().optional() +}); + +// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered". +router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody }), asyncWrap(async (req, res) => { + const updated = await devices.update(req.params.mac.toLowerCase(), req.body); + if (!updated) return res.status(404).json({ error: { code: 'not_found' } }); + res.json(updated); +})); + +// DELETE /devices/:mac (owner). +router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => { + if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } }); + res.status(204).end(); +})); + +// POST /devices/scan — run a scan now (owner). +router.post('/scan', requireOwner, asyncWrap(async (_req, res) => { + const { runDeviceScanCycle } = await import('../../infra/scan_cycle.js'); + res.json(await runDeviceScanCycle()); +})); +``` + +- [ ] **Step 4: Mount it in `lib/api/index.js`** + +Add with the other route imports: +```js +import { router as devicesRouter } from './routes/devices.js'; +``` +Add with the other `api.use(...)` mounts: +```js + api.use('/devices', devicesRouter); +``` + +- [ ] **Step 5: Run it; verify it passes** + +Run: `npm test -- tests/api/devices.test.js` +Expected: PASS (4 passed). + +- [ ] **Step 6: Commit** + +```bash +git add lib/api/routes/devices.js lib/api/index.js tests/api/devices.test.js +git commit -m "feat(devices): /api/devices band + discovered review/edit endpoints" +``` + +--- + +### Task 6: Front-end — DB-backed band + discovered review/add/edit + +**Files:** +- Modify: `public/views/devices_band.js` (rewrite the data source + add review panel) +- Modify: `public/style.css` (badges/panel styles) +- Test: `tests/frontend/devices_band.test.js` (create) + +- [ ] **Step 1: Write the failing test** + +```js +// tests/frontend/devices_band.test.js +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { JSDOM } from 'jsdom'; + +vi.mock('../../public/api.js', () => ({ + api: { + get: vi.fn(async (p) => { + if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [ + { mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', vendor: 'Netgear', randomized: false, present: true } ] } ] }; + if (p === '/api/devices/discovered') return [ + { mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ]; + return {}; + }), + patch: vi.fn(async () => ({})) + } +})); + +let renderDevicesBand; +beforeAll(async () => { + const dom = new JSDOM('
', { url: 'http://localhost/' }); + global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; + ({ renderDevicesBand } = await import('../../public/views/devices_band.js')); +}); +afterAll(() => { delete global.window; delete global.document; delete global.Node; }); + +describe('devices band', () => { + it('renders known devices from the API with MAC, and a discovered count', async () => { + const host = document.getElementById('h'); + await renderDevicesBand(host); + await new Promise(r => setTimeout(r, 0)); + expect(host.textContent).toContain('Orbi Satellite'); + expect(host.querySelector('.dv-mac').textContent).toBe('bc:a5:11:3e:06:88'); + expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present + expect(host.textContent).toMatch(/Discovered/i); + }); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `npm test -- tests/frontend/devices_band.test.js` +Expected: FAIL — band still fetches `/devices.json` (uses `fetch`, not `api`) and has no `.dv-discovered`. + +- [ ] **Step 3: Rewrite `public/views/devices_band.js`** + +```js +// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor, +// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote +// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band. +import { el, mount, clear } from '../dom.js'; +import { api } from '../api.js'; + +let host; +const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; + +function tile(d) { + return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') }, + el('span', { class: 'dv-nm' }, d.name || 'Unknown'), + el('span', { class: 'dv-ip' }, d.ip || ''), + d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null, + el('span', { class: 'dv-vendor' }, + (d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : ''))); +} + +function discoveredRow(d, onDone) { + const nameI = el('input', { class: 'dv-edit-name', placeholder: d.vendor || 'name', value: d.name || '' }); + const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g))); + const add = el('button', { class: 'dv-add' }, 'Add'); + add.onclick = async () => { + await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, status: 'known', flagged: false }); + onDone(); + }; + const ignore = el('button', { class: 'ghost dv-ignore' }, 'Ignore'); + ignore.onclick = async () => { await api.patch('/api/devices/' + d.mac, { status: 'ignored' }); onDone(); }; + return el('div', { class: 'dv-disc-row' }, + el('span', { class: 'dv-ip' }, d.ip || ''), + el('span', { class: 'dv-mac' }, d.mac + (d.randomized ? ' · randomized' : '')), + el('span', { class: 'dv-vendor' }, d.vendor || ''), + nameI, grpS, add, ignore); +} + +async function load() { + if (!host) return; + let data, discovered = []; + try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; } + try { discovered = await api.get('/api/devices/discovered'); } catch { /* owner-only; ignore for non-owner */ } + + const total = data.groups.reduce((n, g) => n + g.devices.length, 0); + const sections = data.groups.map(g => + el('div', { class: 'dv-section' }, + el('div', { class: 'dv-group' }, + el('span', { class: 'gname' }, g.name), + el('span', { class: 'gcount' }, String(g.devices.length)), + el('span', { class: 'line' })), + el('div', { class: 'dv-tiles' }, g.devices.map(tile)))); + + const discPanel = discovered.length + ? el('div', { class: 'dv-discovered' }, + el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`), + ...discovered.map(d => discoveredRow(d, load))) + : null; + + clear(host); + mount(host, + el('div', { class: 'dv-hd' }, + el('div', { class: 'dv-title' }, 'Network · Devices'), + el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)), + discPanel, + ...sections); +} + +export function renderDevicesBand(root) { host = root; return load(); } +export function stopDevicesBand() { host = null; } +``` + +- [ ] **Step 4: Add styles in `public/style.css`** + +After the existing `.dv-tile .dv-mac { … }` line (added earlier), add: +```css +.dv-tile.absent { opacity: .5; } +.dv-discovered { border: 1px solid var(--accent-dim); border-radius: 6px; padding: 10px 12px; margin: 10px 0; background: var(--accent-soft); } +.dv-disc-hd { font-family: var(--font-display); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; color: var(--accent); margin-bottom: 8px; } +.dv-disc-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 0; } +.dv-disc-row .dv-edit-name { flex: 1 1 120px; } +.dv-disc-row .dv-add { background: var(--accent-dim); color: var(--text); border: 1px solid var(--accent); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-family: var(--font-ui); font-size: 12px; } +.dv-disc-row .dv-add:hover { background: var(--accent); color: var(--bg); } +.dv-disc-row .ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-size: 12px; } +``` + +- [ ] **Step 5: Run it; verify it passes** + +Run: `npm test -- tests/frontend/devices_band.test.js` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add public/views/devices_band.js public/style.css tests/frontend/devices_band.test.js +git commit -m "feat(devices): DB-backed devices band + discovered review/add/edit UI" +``` + +--- + +### Task 7: Retire `devices.json`, version bump, CHANGELOG + +**Files:** +- Delete: `public/devices.json` +- Modify: `package.json`, `server.js`, `CHANGELOG.md` + +- [ ] **Step 1: Remove the static file** + +```bash +git rm public/devices.json +``` + +- [ ] **Step 2: Bump version to 2.1.0** + +- `package.json`: `"version": "2.1.0"` +- `server.js`: `const VERSION = '2.1.0';` + +- [ ] **Step 3: CHANGELOG entry** + +Prepend under the `Format:` line in `CHANGELOG.md`: +```markdown +## 2.1.0 — LAN device discovery +- **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`, + `lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and + self-updating. New MACs land in a **Discovered** review queue; the owner names/ + groups/promotes them (`/api/devices`). Devices are keyed by MAC (IP is mutable); + unreviewed + absent rows auto-prune (randomized >24h, others >14d) so randomized + MACs can't bloat the table. Replaces the static `public/devices.json` (now seeded + into the table by the migration). +``` + +- [ ] **Step 4: Run the full suite** + +Run: `npm test` +Expected: all green (existing + the new device tests). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore(release): 2.1.0 — LAN device discovery; retire static devices.json" +``` + +--- + +### Task 8: Infra setup + deploy + +**Files:** none in-repo beyond `deploy/README.md`. Live infra on CT 311 (`void-app`, `192.168.1.216`). + +- [ ] **Step 1: Install arp-scan + grant raw-socket capability (so the `void` user can scan)** + +```bash +ssh root@192.168.1.216 "apt-get update -qq && apt-get install -y arp-scan && \ + setcap cap_net_raw,cap_net_admin+eip \$(readlink -f \$(command -v arp-scan)) && \ + echo '--- verify as void user ---' && sudo -u void arp-scan --localnet --plain --retry=2 | head -5" +``` +Expected: a few `IPMACvendor` lines printed as the `void` user (proves the capability works without root). + +- [ ] **Step 2: Document it in `deploy/README.md`** + +Append a short "LAN device scan" section noting the `apt install arp-scan` + `setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan` requirement (re-apply after an arp-scan package upgrade), then: +```bash +git add deploy/README.md && git commit -m "docs(deploy): arp-scan + setcap for device discovery" +``` + +- [ ] **Step 3: Snapshot + deploy** + +```bash +ssh root@192.168.1.124 "pct snapshot 311 pre_2_1_0 --description 'before LAN device discovery'" +cd /project/src/void-v2 && ./deploy/push.sh +``` +Expected: `Deployed 2.1.0 — healthy. ✓` (migration 024 runs as part of deploy). + +- [ ] **Step 4: Trigger a scan and verify** + +```bash +TOK=$(ssh root@192.168.1.216 "grep -m1 '^OWNER_TOKEN=' /opt/void-server/.env | cut -d= -f2- | tr -d '\"'") +curl -s -X POST -H "Authorization: Bearer $TOK" http://192.168.1.216:3000/api/devices/scan +curl -s http://192.168.1.216:3000/api/devices | python3 -c "import sys,json;d=json.load(sys.stdin);print('groups:',[g['name'] for g in d['groups']])" +curl -s -H "Authorization: Bearer $TOK" http://192.168.1.216:3000/api/devices/discovered | python3 -c "import sys,json;print('discovered:',len(json.load(sys.stdin)))" +``` +Expected: scan returns `{"seen":N,...}`; band shows the seeded groups; discovered count ≥ 0 (new MACs found on the live LAN beyond the seed appear here). + +- [ ] **Step 5: Browser check (webapp-testing)** + +Open `void.hynesy.com`, find the Devices band (Sacred Valley): confirm devices show IP+MAC, randomized devices show the badge, and the **Discovered** panel lets you name + Add a device (it then moves into a group). + +--- + +## Self-review notes + +- **Spec coverage:** MAC-keyed store + seed (T2,T3) · decoupled arp-scan + randomized flag (T1) · hourly cron scan→upsert→mark-absent→prune (T4) · discovered/review/name/edit/promote API (T5) + UI (T6) · prune/retention for randomized bloat (T3 `prune`, T4 wiring) · DB-backed band replacing devices.json (T6,T7) · setcap/arp-scan infra (T8). All spec sections map to a task. +- **Type/name consistency:** `lan_devices` columns (`mac,ip,vendor,name,grp,note,status,randomized,flagged,first_seen,last_seen,present`) are identical across migration (T2), repo (T3), API (T5), and UI (T6). Repo methods `upsertScan/markAbsent/prune/listKnown/listDiscovered/get/update/remove` match their callers in `scan_cycle.js` (T4) and the route (T5). `runScan({exec})` / `runDeviceScanCycle({scan,repo})` injection shapes match their tests. +- **Out of scope (not planned, per spec):** port/service fingerprinting, SNMP/LLDP, multi-VLAN, push notifications, stable identity across MAC rotations.