17 Commits

Author SHA1 Message Date
root
607b76ff82 feat(apps): OBD2 placeholder rail item (launchpad for the parked OBD2 project)
Adds an OBD2 item to the Apps rail; with no records UI deployed yet it links to
the OBD2 Telemetry project + tasks and the research/wiki page rather than
embedding. Swap to embedView once LubeLogger/Tracktor is up. → 2.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:05:21 +10:00
root
555a4c652c docs: documentation policy — every change lands in the Void wiki AND git
Standing rule: no work is done until it's documented in both the Void wiki
(page API) and git (code + spec/plan/CHANGELOG), pushed to Gitea. Verbose-first;
consolidate later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:53:23 +10:00
root
a042cbaaa5 fix(devices): exclude homelab guests (network_hosts + bc:24:11 OUI) from discovery
The scan was surfacing every Proxmox container/host as a 'new' device. Filter
the scan against the network_hosts inventory and the Proxmox guest OUI so the
devices band stays IoT/personal-only, per the spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:31:10 +10:00
root
ca186d41ba docs(deploy): arp-scan + setcap for LAN device discovery
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:28:51 +10:00
root
5f1b789250 chore(release): 2.1.0 — LAN device discovery; retire static devices.json 2026-06-08 21:11:08 +10:00
root
056e6a099b feat(devices): DB-backed devices band + discovered review/add/edit UI 2026-06-08 21:06:05 +10:00
root
0fe25d96ec feat(devices): /api/devices band + discovered review/edit endpoints 2026-06-08 21:04:41 +10:00
root
e9c1fb17ac feat(devices): hourly scan-cycle orchestration + cron 2026-06-08 20:58:52 +10:00
root
2ca2adc485 feat(devices): lan_devices repo (upsert/absent/prune/promote) 2026-06-08 20:58:08 +10:00
root
0083e80dc7 feat(devices): lan_devices table + seed from curated devices.json 2026-06-08 20:57:11 +10:00
root
e3b482624d feat(devices): arp-scan parser + randomized-MAC detection 2026-06-08 20:56:40 +10:00
root
26eeb2c100 docs(devices): implementation plan for LAN device discovery
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:54:43 +10:00
root
b9b94c9777 docs(devices): add randomized-MAC retention/prune to discovery spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:48:21 +10:00
root
d513ca8fa4 docs(devices): spec for LAN device discovery (MAC inventory + review/name)
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>
2026-06-08 20:44:19 +10:00
root
0866459b23 feat(devices): map MACs to LAN devices; identify Orbi satellite + Galaxy Tab
ARP/nmap rescan (2026-06-08) attaches real MACs to the devices band and shows
them in the UI. Reclassifies two flagged "unknowns": .13 = Orbi mesh satellite
(BC:A5:11:.. Netgear; the uhttpd UI made it look like a rogue OpenWrt box) →
Network; .171 = Galaxy Tab S4 (randomized MAC) → Personal. Remaining flags are
.15 (ASUSTek, needs ID) and .34/.35/.51 (offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:31:37 +10:00
root
d69d605108 chore(infra): drop retired ct301 from network_hosts seed
Void 1 (CT 301, .11) is retired/destroyed; remove its inventory seed row so
fresh installs don't list a dead host. Live row already deleted; the migrate
runner is filename-tracked so 023 won't re-run on existing DBs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:20:47 +10:00
root
9aacc58c35 chore(release): 2.0.0 — drop -alpha; Void 1 retired, CTs renamed
Void 2 reaches GA. Void 1 (CT 301) was stopped, fully backed up (vzdump +
off-CT data tarball), and destroyed; CT 310/311 renamed void-db/void-app;
the legacy void1 registry tile removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:09:11 +10:00
29 changed files with 1671 additions and 76 deletions

24
AGENTS.md Normal file
View File

@@ -0,0 +1,24 @@
# Agent working agreement — Void / homelab
## Documentation policy (standing rule — do not skip)
**Every change, decision, fix, or incident must end up documented in BOTH places before the work is considered done:**
1. **The Void wiki** — the `wiki` space served by the Void. Update the relevant existing page, or create one. This is the human-facing source of truth and what `infra_audit` reads.
2. **Git** — the code plus an appropriate written artifact (a spec/plan under `docs/superpowers/`, a `CHANGELOG.md` entry, and/or a doc page), committed and **pushed** to Gitea.
Capture **verbose-first** — we consolidate/compress later. Losing information is the only failure mode; over-documenting is fine. Do this proactively as part of finishing a task, not only when asked.
### How — wiki
Owner token: `OWNER_TOKEN` in `/opt/void-server/.env` on CT 311 (`void-app`, `192.168.1.216`). API on the LAN at `http://192.168.1.216:3000`:
- Edit a page: `PATCH /api/pages/:id` with `{ "body_md": "…", "title": "…" }`
- Create a page: `POST /api/spaces/2201a3dd-2d40-425c-a4cf-7f18882a9146/pages` with `{ "slug", "title", "body_md", "parent_id" }`
- Per-LXC / per-service pages parent under **Hosts & Services** (`ab398d61-805a-46dd-b1ba-6f09374bd7aa`).
- **Do not** write a contiguous `IP:port` for a remote-site or inactive host — `infra_audit` probes those and will false-flag them.
### How — git
Commit code + docs together and push to the project's Gitea repo:
- `void-v2``Hynes/Void-Homelab`
- `farm-timelapse``Hynes/farm-timelapse`
Specs/plans live in `docs/superpowers/{specs,plans}/`; user-facing changes get a `CHANGELOG.md` entry.

View File

@@ -3,6 +3,24 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.1.1 — OBD2 Apps rail placeholder
- **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed.
## 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).
## 2.0.0 — General availability (Void 1 retired)
- **Dropped the `-alpha` tag.** Void 2 is *the* homelab dashboard; `void.hynesy.com` has served it since alpha-18.
- **Void 1 retired.** CT 301 stopped, backed up (vzdump archive + off-CT data tarball), and destroyed 2026-06-08.
- **CTs renamed**: `void2-db` / `void2-app` (CT 310 / 311) → `void-db` / `void-app`.
- **Registry**: removed the legacy `void1` service tile.
## 2.0.0-alpha.27
- feat: Timelapse + AI Usage folded into the left rail as an "Apps" section,
embedded as cross-origin HTTPS iframes; each stays chromeless at its own URL.

View File

@@ -23,7 +23,6 @@
{ "id": "qbittorrent", "name": "qBittorrent", "category": "media", "host": "win10", "url": "http://192.168.1.230:8080", "icon": "qbittorrent" },
{ "id": "chaptarr", "name": "Chaptarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8789", "external": "https://chaptarr.hynesy.com", "icon": "readarr" },
{ "id": "void1", "name": "The Void 1.x", "category": "other", "host": "ct301", "url": "http://192.168.1.11:2424", "icon": "void" },
{ "id": "farm-timelapse", "name": "Farm Timelapse", "category": "other", "host": "192.168.1.108", "url": "http://192.168.1.108:8000", "icon": "" },
{ "id": "magicmirror", "name": "MagicMirror", "category": "other", "host": "ct111 · .224", "url": "http://192.168.1.224:8080", "icon": "magicmirror" },
{ "id": "claude-usage", "name": "Claude Usage", "category": "other", "host": "ct300", "url": "http://192.168.1.212:8080", "icon": "claude" }

View File

@@ -127,6 +127,25 @@ re-initdb the cluster, use `--encoding=UTF8 --locale=C.UTF-8`.
mkdir -p /var/lib/void/icons
chown void: /var/lib/void/icons
```
## LAN device discovery (2.1.0)
The hourly device scan (`lib/cron` → `runDeviceScanCycle`) shells `arp-scan`. The
service runs as the non-root `void` user, so `arp-scan` needs a raw-socket
capability:
```bash
apt-get install -y arp-scan
setcap cap_net_raw,cap_net_admin+eip "$(readlink -f "$(command -v arp-scan)")"
# verify as the service user (run from the service WorkingDirectory so the
# OUI vendor files resolve):
runuser -u void -- sh -c 'cd /opt/void-server && arp-scan --localnet --plain | head'
```
**⚠ Re-apply the `setcap` after any `arp-scan` package upgrade** — the upgrade
replaces the binary and drops the capability, after which scans silently find
nothing. `migration 024` creates `lan_devices` and seeds it from the old
`devices.json`, so the band still renders even before the first scan runs.
- **Service registry** — edit `config/services.json` to the real homelab service URLs and CT numbers. The committed seed values are best-guess placeholders and should be updated before the health band is meaningful.
## Deploy safety (push.sh, hardened)

View File

@@ -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 -- <path>`.
---
### 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 "IP<ws>MAC<ws>[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('<!doctype html><html><body><div id="h"></div></body></html>', { 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 `IP<tab>MAC<tab>vendor` 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.

View File

@@ -0,0 +1,166 @@
# 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 (dozenshundreds), 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. |
| Randomized-MAC bloat | **Auto-prune unreviewed + absent rows** (randomized >24h, others >14d); keep `known`/`ignored` forever | Rotated randomized MACs never accumulate; the table stays bounded. |
## 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.)
- `prune()` — delete unreviewed + absent rows past their TTL: `status='new' AND
present=false AND ((randomized AND last_seen < now()-'24h') OR (NOT randomized
AND last_seen < now()-'14d'))`. Never touches `known`/`ignored`.
### Cron (`lib/cron/index.js`)
Add hourly (`7 * * * *`): `runScan()` → `upsertScan` → `markAbsent` → `prune()`.
Wrapped in try/catch — a scan failure logs and never crashes the cron, and
`prune()` only runs after a *successful* scan (so a failed scan can't reap rows).
### 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.
- **Randomized devices** get a small "randomized MAC" badge (with a tooltip:
naming pins it only until the MAC rotates; disable SSID randomization for
stable tracking). A `known` device that's been `present=false` for ≥30d shows
an "absent Nd" marker for easy manual cleanup (never auto-deleted).
- 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`).
- Push notifications on new-device discovery.
- Stable identity for randomized-MAC devices across rotations (not solvable from
L2 alone; the user-side fix is disabling MAC randomization for the SSID).
## References
- Scanopy — github.com/scanopy/scanopy ; scanopy.net (self-hosted discovery/topology, AGPL-3.0).

View File

@@ -1,6 +1,6 @@
import { canAct } from '../auth/capability.js';
import * as pendingChanges from '../db/repos/pending_changes.js';
import { ForbiddenError } from './errors.js';
import { ForbiddenError, UnauthorizedError } from './errors.js';
const METHOD_TO_ACTION = { POST: 'create', PATCH: 'update', PUT: 'update', DELETE: 'delete' };
@@ -15,9 +15,8 @@ export function requireWrite(entity_type) {
}
export function requireOwner(req, _res, next) {
if (req.actor?.kind !== 'user') {
return next(new ForbiddenError('owner-only endpoint'));
}
if (!req.actor) return next(new UnauthorizedError('owner-only endpoint'));
if (req.actor.kind !== 'user') return next(new ForbiddenError('owner-only endpoint'));
next();
}

View File

@@ -33,6 +33,12 @@ export class ForbiddenError extends ApiError {
}
}
export class UnauthorizedError extends ApiError {
constructor(message = 'unauthorized', details) {
super('unauthorized', message, 401, details);
}
}
export function asyncWrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

82
lib/api/routes/devices.js Normal file
View File

@@ -0,0 +1,82 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap, errorMiddleware } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import * as devices from '../../db/repos/lan_devices.js';
import * as agents from '../../db/repos/agents.js';
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
import { accessOwnerEmail } from '../../auth/cf_access.js';
export const router = Router();
// Soft auth: identifies the actor if auth is present but never blocks the request.
// Owner-only sub-routes enforce 401/403 via requireOwner.
async function softAuth(req, _res, next) {
try {
const cfEmail = await accessOwnerEmail(req);
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme === 'Bearer' && token) {
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
req.actor = { kind: 'user', id: null }; return next();
}
try {
const agent = await agents.verifyToken(token);
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
} catch { /* ignore */ }
}
} catch { /* ignore */ }
next();
}
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
router.use(softAuth);
// 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());
}));
router.use(errorMiddleware);

View File

@@ -5,6 +5,7 @@ import { enqueue } from '../jobs/queue.js';
import { checkAll } from '../health/checker.js';
import * as statusRepo from '../db/repos/service_status.js';
import * as services from '../db/repos/monitored_services.js';
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
export function startCron() {
// Daily at 03:00 local time
@@ -35,5 +36,11 @@ export function startCron() {
} catch (e) { log.error({ err: e }, 'health check failed'); }
});
// 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'); }
});
log.info('cron started');
}

View File

@@ -32,7 +32,6 @@ INSERT INTO network_hosts (id, kind, name, node, ip, mac, note) VALUES
('ct111','lxc','magicmirror','z','192.168.1.224','BC:24:11:6C:D4:E6','MagicMirror (static, was DHCP .27)'),
('ct112','lxc','obd2','z','192.168.1.225','BC:24:11:E7:D8:BF','OBD2 telemetry (static, was DHCP .28)'),
('ct300','lxc','claude','z','192.168.1.212','BC:24:11:9E:AA:73','Claude Code workspace'),
('ct301','lxc','void1','z','192.168.1.11','BC:24:11:4D:B7:CC','Void 1.x legacy'),
('ct310','lxc','void2-db','z','192.168.1.215','BC:24:11:49:C6:29','Void 2.0 Postgres'),
('ct311','lxc','void2-app','z','192.168.1.216','BC:24:11:9B:B7:3A','Void 2.0 app'),
('vm117','vm','Pterodactyl-Deb','z','192.168.1.247','BC:24:11:37:C1:F7','Game panel (static, in-guest)'),

View File

@@ -0,0 +1,40 @@
-- 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;

View File

@@ -0,0 +1,73 @@
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;
}

31
lib/infra/scan.js Normal file
View File

@@ -0,0 +1,31 @@
// 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 "IP<ws>MAC<ws>[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);
}

25
lib/infra/scan_cycle.js Normal file
View File

@@ -0,0 +1,25 @@
// One discovery cycle: scan → drop homelab guests → upsert → mark-absent → prune.
// Homelab containers/hosts are excluded from the IoT/personal devices band — they
// live in the network_hosts inventory, not here. We drop any MAC that's in
// network_hosts OR carries the Proxmox guest OUI (bc:24:11). Deps injected for
// tests. Prune only runs after a successful, non-empty scan.
import { runScan } from './scan.js';
import * as devices from '../db/repos/lan_devices.js';
import * as netHosts from '../db/repos/network_hosts.js';
import { log } from '../log.js';
const HOMELAB_OUI = 'bc:24:11'; // Proxmox auto-generated guest MAC prefix
export async function runDeviceScanCycle({ scan = runScan, repo = devices, hosts = netHosts } = {}) {
const inventory = new Set((await hosts.all()).map(h => String(h.mac || '').toLowerCase()));
const rows = (await scan()).filter(r => !inventory.has(r.mac) && !r.mac.startsWith(HOMELAB_OUI));
if (!rows.length) {
log.warn('device scan found no non-homelab 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 };
}

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.0.0-alpha.27",
"version": "2.1.1",
"type": "module",
"private": true,
"scripts": {

View File

@@ -27,6 +27,7 @@ const VIEWS = {
terminal: () => import('./views/terminal.js'),
timelapse: () => import('./views/timelapse.js'),
'ai-usage': () => import('./views/aiusage.js'),
obd2: () => import('./views/obd2.js'),
settings: () => import('./views/settings.js'),
jobs: () => import('./views/jobs.js')
};

View File

@@ -131,7 +131,8 @@ export function renderSidebar(root) {
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Apps'),
navItem('Timelapse', '/timelapse'),
navItem('AI Usage', '/ai-usage')
navItem('AI Usage', '/ai-usage'),
navItem('OBD2', '/obd2')
)
);

View File

@@ -1,38 +0,0 @@
{
"note": "Auto-scanned LAN devices (ARP/nmap 2026-06-02). Separate from Little Blue's homelab services. Vendor-guessed; identification + live discovery to come.",
"groups": [
{ "name": "Smart Home", "devices": [
{ "name": "Amazon Echo", "ip": "192.168.1.3", "vendor": "Amazon" },
{ "name": "Amazon Echo", "ip": "192.168.1.4", "vendor": "Amazon" },
{ "name": "Smart device", "ip": "192.168.1.6", "vendor": "Beken" },
{ "name": "Smart device", "ip": "192.168.1.23", "vendor": "Tuya" },
{ "name": "Xiaomi device", "ip": "192.168.1.20", "vendor": "Xiaomi" }
]},
{ "name": "Entertainment", "devices": [
{ "name": "Google / Nest", "ip": "192.168.1.12", "vendor": "Google" },
{ "name": "Google / Nest", "ip": "192.168.1.14", "vendor": "Google" },
{ "name": "Google / Nest", "ip": "192.168.1.18", "vendor": "Google" },
{ "name": "Google / Nest", "ip": "192.168.1.21", "vendor": "Google" },
{ "name": "Google / Nest", "ip": "192.168.1.22", "vendor": "Google" },
{ "name": "Cambridge Audio", "ip": "192.168.1.29", "vendor": "StreamMagic" },
{ "name": "Apple TV / HomePod", "ip": "192.168.1.43", "vendor": "Apple" },
{ "name": "Samsung TV", "ip": "192.168.1.24", "vendor": "Samsung" }
]},
{ "name": "Personal", "devices": [
{ "name": "Apple device", "ip": "192.168.1.133", "vendor": "Apple" },
{ "name": "Samsung device", "ip": "192.168.1.61", "vendor": "Samsung" },
{ "name": "TP-Link device", "ip": "192.168.1.10", "vendor": "TP-Link" }
]},
{ "name": "Network", "devices": [
{ "name": "Gateway / Router", "ip": "192.168.1.1", "vendor": "Netgear" }
]},
{ "name": "Flagged / Unknown", "devices": [
{ "name": "Rogue OpenWrt", "ip": "192.168.1.13", "vendor": "Netgear · uhttpd", "flag": true },
{ "name": "ASUS device", "ip": "192.168.1.15", "vendor": "ASUSTek", "flag": true },
{ "name": "Unknown", "ip": "192.168.1.34", "vendor": "randomized MAC", "flag": true },
{ "name": "Unknown", "ip": "192.168.1.35", "vendor": "unknown", "flag": true },
{ "name": "Unknown", "ip": "192.168.1.51", "vendor": "randomized MAC", "flag": true },
{ "name": "Unknown", "ip": "192.168.1.171", "vendor": "randomized MAC", "flag": true }
]}
]
}

View File

@@ -28,6 +28,7 @@ const ROUTES = [
{ name: 'terminal', re: /^\/terminal$/, keys: [] },
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
{ name: 'settings', re: /^\/settings$/, keys: [] },
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
{ name: 'home', re: /^\/?$/, keys: [] }

View File

@@ -562,9 +562,18 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
}
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
.dv-tile.flag { border-color: var(--bad); background: #1a1012; }
.dv-tile.flag .dv-nm { color: var(--bad); }
.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; }
/* ===== Discovered services + scan (Plan: DB-backed registry) ===== */
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);

View File

@@ -1,35 +1,67 @@
// Network Devices band — IoT / personal / unknown LAN devices, kept SEPARATE
// from Little Blue's homelab-service health band. Read-only, static source
// (public/devices.json), no health probing. Live discovery comes later.
import { el, mount } from '../dom.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;
try {
const res = await fetch('/devices.json');
const data = await res.json();
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(d =>
el('div', { class: 'dv-tile' + (d.flag ? ' flag' : '') },
el('span', { class: 'dv-nm' }, d.name),
el('span', { class: 'dv-ip' }, d.ip),
el('span', { class: 'dv-vendor' }, d.vendor || ''))))));
mount(host,
el('div', { class: 'dv-hd' },
el('div', { class: 'dv-title' }, 'Network · Devices'),
el('span', { class: 'dv-count' }, `${total} on the LAN`)),
el('div', { class: 'dv-note' }, data.note || ''),
sections);
} catch {
mount(host, el('span', { class: 'muted' }, 'Device list unavailable'));
}
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` : ''}`)),
...sections,
discPanel);
}
export function renderDevicesBand(el_) { host = el_; load(); }
export function renderDevicesBand(root) { host = root; return load(); }
export function stopDevicesBand() { host = null; }

27
public/views/obd2.js Normal file
View File

@@ -0,0 +1,27 @@
// #/obd2 — Apps rail placeholder for the OBD2 Telemetry project (parked).
// No records UI is deployed yet, so this links into the project + wiki instead of
// embedding. Swap to embedView({ src: 'https://obd2.hynesy.com/' }) once the
// LubeLogger/Tracktor dashboard is up.
import { el, mount } from '../dom.js';
import { navigate } from '../router.js';
const WIKI = '/page/bea9d582-44a2-4eec-a1ba-69ade15d3a73';
const PROJECT = '/project/02fc5b4c-12f4-4d0c-8220-6b053da71c46';
export async function render(main) {
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ OBD2 Telemetry'),
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'project · parked, being set up')
),
el('div', { class: 'card', style: { maxWidth: '760px' } },
el('h3', {}, 'OBD2 Telemetry — being set up'),
el('p', { class: 'muted' }, 'Capture vehicle records from the cars OBD2 port into the homelab (CT 112 · Postgres + TimescaleDB) with a maintenance/records UI. The capture pipeline is being rebuilt and the records UI isnt deployed yet — nothing to embed here yet.'),
el('p', {}, 'Plan: AndrOBD (F-Droid) + the BT ELM327 → CSV/MQTT → Timescale; WiCAN hardware later; LubeLogger / Tracktor for the UI (this tile will then embed it).'),
el('div', { style: { display: 'flex', gap: '8px', marginTop: '14px' } },
el('button', { class: 'primary', onclick: () => navigate(PROJECT) }, 'Project + tasks'),
el('button', { class: 'ghost', onclick: () => navigate(WIKI) }, 'Research / wiki')
)
)
);
}

View File

@@ -7,13 +7,14 @@ import * as queue from './lib/jobs/queue.js';
import { registerWorkers } from './lib/jobs/index.js';
import { router as ingestRouter } from './lib/api/routes/ingest.js';
import { router as iconsRouter } from './lib/api/routes/icons.js';
import { router as devicesRouter } from './lib/api/routes/devices.js';
import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js';
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
const VERSION = '2.0.0-alpha.27';
const VERSION = '2.1.1';
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
@@ -52,6 +53,10 @@ export function createApp() {
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
app.use('/api/icons', iconsRouter);
// /api/devices — band data is public (like the static devices.json it replaces);
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
app.use('/api/devices', devicesRouter);
app.get('/health', async (_req, res) => {
let db_ok = false;
try {

41
tests/api/devices.test.js Normal file
View File

@@ -0,0 +1,41 @@
// 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);
});
});

View File

@@ -0,0 +1,36 @@
// 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('<!doctype html><html><body><div id="h"></div></body></html>', { 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);
});
});

35
tests/infra/scan.test.js Normal file
View File

@@ -0,0 +1,35 @@
// 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']);
});
});

View File

@@ -0,0 +1,47 @@
// tests/infra/scan_cycle.test.js
import { describe, it, expect, vi } from 'vitest';
import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js';
function fakeRepo() {
return {
upsertScan: vi.fn(async r => r.length),
markAbsent: vi.fn(async () => 1),
prune: vi.fn(async () => 2)
};
}
const noHosts = { all: async () => [] };
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, hosts: noHosts });
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, hosts: noHosts });
expect(repo.upsertScan).not.toHaveBeenCalled();
expect(repo.prune).not.toHaveBeenCalled();
expect(res).toEqual({ seen: 0 });
});
it('excludes homelab guests (network_hosts inventory + bc:24:11 OUI)', async () => {
const repo = fakeRepo();
const hosts = { all: async () => [{ mac: 'BC:24:11:9B:B7:3A' }, { mac: '00:E0:4C:0F:36:00' }] };
const scan = async () => [
{ mac: 'bc:24:11:9b:b7:3a', ip: '192.168.1.216', vendor: '', randomized: false }, // in inventory
{ mac: 'bc:24:11:de:ad:00', ip: '192.168.1.99', vendor: '', randomized: false }, // proxmox OUI
{ mac: '00:e0:4c:0f:36:00', ip: '192.168.1.124', vendor: '', randomized: false }, // PVE host in inventory
{ mac: 'd8:eb:46:77:37:a8', ip: '192.168.1.25', vendor: 'Google', randomized: false } // real device
];
const res = await runDeviceScanCycle({ scan, repo, hosts });
expect(res.seen).toBe(1);
expect(repo.upsertScan).toHaveBeenCalledWith([expect.objectContaining({ mac: 'd8:eb:46:77:37:a8' })]);
expect(repo.markAbsent).toHaveBeenCalledWith(['d8:eb:46:77:37:a8']);
});
});

View File

@@ -0,0 +1,63 @@
// 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');
});
});