Compare commits
9 Commits
26eeb2c100
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a042cbaaa5 | ||
|
|
ca186d41ba | ||
|
|
5f1b789250 | ||
|
|
056e6a099b | ||
|
|
0fe25d96ec | ||
|
|
e9c1fb17ac | ||
|
|
2ca2adc485 | ||
|
|
0083e80dc7 | ||
|
|
e3b482624d |
@@ -3,6 +3,15 @@
|
|||||||
All notable changes to Void 2.0 are documented here.
|
All notable changes to Void 2.0 are documented here.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
- **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.
|
- **Void 1 retired.** CT 301 stopped, backed up (vzdump archive + off-CT data tarball), and destroyed 2026-06-08.
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ re-initdb the cluster, use `--encoding=UTF8 --locale=C.UTF-8`.
|
|||||||
mkdir -p /var/lib/void/icons
|
mkdir -p /var/lib/void/icons
|
||||||
chown void: /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.
|
- **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)
|
## Deploy safety (push.sh, hardened)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { canAct } from '../auth/capability.js';
|
import { canAct } from '../auth/capability.js';
|
||||||
import * as pendingChanges from '../db/repos/pending_changes.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' };
|
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) {
|
export function requireOwner(req, _res, next) {
|
||||||
if (req.actor?.kind !== 'user') {
|
if (!req.actor) return next(new UnauthorizedError('owner-only endpoint'));
|
||||||
return next(new ForbiddenError('owner-only endpoint'));
|
if (req.actor.kind !== 'user') return next(new ForbiddenError('owner-only endpoint'));
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
export function asyncWrap(fn) {
|
||||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/api/routes/devices.js
Normal file
82
lib/api/routes/devices.js
Normal 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);
|
||||||
@@ -5,6 +5,7 @@ import { enqueue } from '../jobs/queue.js';
|
|||||||
import { checkAll } from '../health/checker.js';
|
import { checkAll } from '../health/checker.js';
|
||||||
import * as statusRepo from '../db/repos/service_status.js';
|
import * as statusRepo from '../db/repos/service_status.js';
|
||||||
import * as services from '../db/repos/monitored_services.js';
|
import * as services from '../db/repos/monitored_services.js';
|
||||||
|
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||||
|
|
||||||
export function startCron() {
|
export function startCron() {
|
||||||
// Daily at 03:00 local time
|
// Daily at 03:00 local time
|
||||||
@@ -35,5 +36,11 @@ export function startCron() {
|
|||||||
} catch (e) { log.error({ err: e }, 'health check failed'); }
|
} 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');
|
log.info('cron started');
|
||||||
}
|
}
|
||||||
|
|||||||
40
lib/db/migrations/024_lan_devices.sql
Normal file
40
lib/db/migrations/024_lan_devices.sql
Normal 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;
|
||||||
73
lib/db/repos/lan_devices.js
Normal file
73
lib/db/repos/lan_devices.js
Normal 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
31
lib/infra/scan.js
Normal 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
25
lib/infra/scan_cycle.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"note": "LAN devices — ARP/nmap rescan 2026-06-08 with MACs mapped. Separate from Little Blue's homelab services (Proxmox BC:24:11:* guests, tracked in the infra inventory). Locally-administered / randomized MACs (2nd hex digit 2/6/A/E) can't be OUI-identified and are labelled by hand.",
|
|
||||||
"groups": [
|
|
||||||
{ "name": "Smart Home", "devices": [
|
|
||||||
{ "name": "Amazon Echo", "ip": "192.168.1.3", "mac": "48:43:DD:FC:2F:84", "vendor": "Amazon" },
|
|
||||||
{ "name": "Amazon Echo", "ip": "192.168.1.4", "mac": "14:0A:C5:6D:15:6E", "vendor": "Amazon" },
|
|
||||||
{ "name": "Smart device", "ip": "192.168.1.6", "mac": "C8:47:8C:01:17:70", "vendor": "Beken" },
|
|
||||||
{ "name": "Smart device", "ip": "192.168.1.23", "mac": "D4:A6:51:12:36:92", "vendor": "Tuya" },
|
|
||||||
{ "name": "Xiaomi device", "ip": "192.168.1.20", "mac": "EC:4D:3E:36:EF:E1", "vendor": "Xiaomi" }
|
|
||||||
]},
|
|
||||||
{ "name": "Entertainment", "devices": [
|
|
||||||
{ "name": "Google / Nest", "ip": "192.168.1.12", "mac": "1C:53:F9:BB:32:24", "vendor": "Google" },
|
|
||||||
{ "name": "Google / Nest", "ip": "192.168.1.14", "mac": "D4:F5:47:95:33:93", "vendor": "Google" },
|
|
||||||
{ "name": "Google / Nest", "ip": "192.168.1.18", "mac": "EC:4D:3E:37:38:8F", "vendor": "Google", "note": "MAC OUI is Xiaomi — label may be wrong" },
|
|
||||||
{ "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", "mac": "48:70:1E:01:4F:7B", "vendor": "StreamMagic" },
|
|
||||||
{ "name": "Apple TV / HomePod", "ip": "192.168.1.43", "mac": "08:66:98:B9:CF:F2", "vendor": "Apple" },
|
|
||||||
{ "name": "Samsung TV", "ip": "192.168.1.24", "mac": "1C:86:9A:4C:F0:EC", "vendor": "Samsung" }
|
|
||||||
]},
|
|
||||||
{ "name": "Personal", "devices": [
|
|
||||||
{ "name": "Galaxy Tab S4", "ip": "192.168.1.171", "mac": "5A:DA:61:7A:0F:12", "vendor": "Samsung (randomized MAC)" },
|
|
||||||
{ "name": "Apple device", "ip": "192.168.1.133", "mac": "1C:57:DC:70:E8:2D", "vendor": "Apple" },
|
|
||||||
{ "name": "Samsung device", "ip": "192.168.1.61", "mac": "A0:D0:5B:04:70:96", "vendor": "Samsung" },
|
|
||||||
{ "name": "TP-Link device", "ip": "192.168.1.10", "mac": "14:EB:B6:40:7E:93", "vendor": "TP-Link" }
|
|
||||||
]},
|
|
||||||
{ "name": "Network", "devices": [
|
|
||||||
{ "name": "Gateway / Router", "ip": "192.168.1.1", "mac": "44:A5:6E:68:D0:E9", "vendor": "Netgear" },
|
|
||||||
{ "name": "Orbi Satellite", "ip": "192.168.1.13", "mac": "BC:A5:11:3E:06:88", "vendor": "Netgear (Orbi mesh)" }
|
|
||||||
]},
|
|
||||||
{ "name": "Flagged / Unknown", "devices": [
|
|
||||||
{ "name": "ASUS device", "ip": "192.168.1.15", "mac": "24:4B:FE:8E:09:A4", "vendor": "ASUSTek", "flag": true },
|
|
||||||
{ "name": "Unknown", "ip": "192.168.1.34", "vendor": "randomized MAC · offline", "flag": true },
|
|
||||||
{ "name": "Unknown", "ip": "192.168.1.35", "vendor": "unknown · offline", "flag": true },
|
|
||||||
{ "name": "Unknown", "ip": "192.168.1.51", "vendor": "randomized MAC · offline", "flag": true }
|
|
||||||
]}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -566,6 +566,14 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
|
.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 { border-color: var(--bad); background: #1a1012; }
|
||||||
.dv-tile.flag .dv-nm { color: var(--bad); }
|
.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) ===== */
|
/* ===== Discovered services + scan (Plan: DB-backed registry) ===== */
|
||||||
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);
|
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
// Network Devices band — IoT / personal / unknown LAN devices, kept SEPARATE
|
// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor,
|
||||||
// from Little Blue's homelab-service health band. Read-only, static source
|
// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote
|
||||||
// (public/devices.json), no health probing. Live discovery comes later.
|
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||||
import { el, mount } from '../dom.js';
|
import { el, mount, clear } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
let host;
|
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() {
|
async function load() {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
try {
|
let data, discovered = [];
|
||||||
const res = await fetch('/devices.json');
|
try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; }
|
||||||
const data = await res.json();
|
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 total = data.groups.reduce((n, g) => n + g.devices.length, 0);
|
||||||
const sections = data.groups.map(g =>
|
const sections = data.groups.map(g =>
|
||||||
el('div', { class: 'dv-section' },
|
el('div', { class: 'dv-section' },
|
||||||
@@ -16,21 +46,22 @@ async function load() {
|
|||||||
el('span', { class: 'gname' }, g.name),
|
el('span', { class: 'gname' }, g.name),
|
||||||
el('span', { class: 'gcount' }, String(g.devices.length)),
|
el('span', { class: 'gcount' }, String(g.devices.length)),
|
||||||
el('span', { class: 'line' })),
|
el('span', { class: 'line' })),
|
||||||
el('div', { class: 'dv-tiles' }, g.devices.map(d =>
|
el('div', { class: 'dv-tiles' }, g.devices.map(tile))));
|
||||||
el('div', { class: 'dv-tile' + (d.flag ? ' flag' : '') },
|
|
||||||
el('span', { class: 'dv-nm' }, d.name),
|
const discPanel = discovered.length
|
||||||
el('span', { class: 'dv-ip' }, d.ip),
|
? el('div', { class: 'dv-discovered' },
|
||||||
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
|
el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`),
|
||||||
el('span', { class: 'dv-vendor' }, d.vendor || ''))))));
|
...discovered.map(d => discoveredRow(d, load)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
clear(host);
|
||||||
mount(host,
|
mount(host,
|
||||||
el('div', { class: 'dv-hd' },
|
el('div', { class: 'dv-hd' },
|
||||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
||||||
el('span', { class: 'dv-count' }, `${total} on the LAN`)),
|
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)),
|
||||||
el('div', { class: 'dv-note' }, data.note || ''),
|
...sections,
|
||||||
sections);
|
discPanel);
|
||||||
} catch {
|
|
||||||
mount(host, el('span', { class: 'muted' }, 'Device list unavailable'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export function renderDevicesBand(el_) { host = el_; load(); }
|
|
||||||
|
export function renderDevicesBand(root) { host = root; return load(); }
|
||||||
export function stopDevicesBand() { host = null; }
|
export function stopDevicesBand() { host = null; }
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import * as queue from './lib/jobs/queue.js';
|
|||||||
import { registerWorkers } from './lib/jobs/index.js';
|
import { registerWorkers } from './lib/jobs/index.js';
|
||||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||||
import { router as iconsRouter } from './lib/api/routes/icons.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 { startCron } from './lib/cron/index.js';
|
||||||
import { seedFromConfig } from './lib/health/registry.js';
|
import { seedFromConfig } from './lib/health/registry.js';
|
||||||
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
||||||
import { handleMcp } from './lib/mcp/http.js';
|
import { handleMcp } from './lib/mcp/http.js';
|
||||||
import httpProxy from 'http-proxy';
|
import httpProxy from 'http-proxy';
|
||||||
|
|
||||||
const VERSION = '2.0.0';
|
const VERSION = '2.1.0';
|
||||||
|
|
||||||
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
|
// 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
|
// 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.
|
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
|
||||||
app.use('/api/icons', iconsRouter);
|
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) => {
|
app.get('/health', async (_req, res) => {
|
||||||
let db_ok = false;
|
let db_ok = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
41
tests/api/devices.test.js
Normal file
41
tests/api/devices.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
tests/frontend/devices_band.test.js
Normal file
36
tests/frontend/devices_band.test.js
Normal 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
35
tests/infra/scan.test.js
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
tests/infra/scan_cycle.test.js
Normal file
47
tests/infra/scan_cycle.test.js
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
tests/repos/lan_devices.test.js
Normal file
63
tests/repos/lan_devices.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user