feat: 2.0.0-alpha.11 — DB-backed service registry + LAN auto-discovery
- monitored_services table (mig 015) replaces config/services.json (now a boot seed) - owner CRUD over /api/health/services; GET is DB-backed; cron+worker read the DB - discover.lan worker: pure-Node TCP sweep + HTTP-title probe -> disabled 'discovered' candidates (never clobbers curated entries); POST /api/health/discover + GET .../discovered - dashboard: Scan button + Discovered(N) section with one-click promote Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,9 @@ import * as embed from './workers/embed.js';
|
||||
import * as karakeep from './workers/karakeep.js';
|
||||
import * as speedtest from './workers/speedtest.js';
|
||||
import * as healthCheck from './workers/health_check.js';
|
||||
import * as discover from './workers/discover.js';
|
||||
|
||||
const WORKERS = [echo, url, blob, embed, karakeep, speedtest, healthCheck];
|
||||
const WORKERS = [echo, url, blob, embed, karakeep, speedtest, healthCheck, discover];
|
||||
|
||||
export async function registerWorkers() {
|
||||
for (const w of WORKERS) {
|
||||
|
||||
72
lib/jobs/workers/discover.js
Normal file
72
lib/jobs/workers/discover.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import net from 'node:net';
|
||||
import * as services from '../../db/repos/monitored_services.js';
|
||||
import { log } from '../../log.js';
|
||||
|
||||
export const NAME = 'discover.lan';
|
||||
|
||||
// Common homelab web/service ports to probe.
|
||||
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
||||
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
||||
const HTTPS_PORTS = new Set([443, 8443, 8006]);
|
||||
|
||||
function tcpOpen(host, port, timeoutMs = 350) {
|
||||
return new Promise(resolve => {
|
||||
const sock = net.connect({ host, port });
|
||||
let done = false;
|
||||
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on('connect', () => finish(true));
|
||||
sock.on('timeout', () => finish(false));
|
||||
sock.on('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function httpTitle(url) {
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2500) });
|
||||
let title = '';
|
||||
if (res.status >= 200 && res.status < 400) {
|
||||
const html = await res.text().catch(() => '');
|
||||
const m = html.match(/<title>([^<]{1,80})/i);
|
||||
title = m ? m[1].trim().replace(/\s+/g, ' ') : '';
|
||||
}
|
||||
return { code: res.status, title };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Test seam.
|
||||
let _tcp = tcpOpen, _http = httpTitle;
|
||||
export function _setProbes({ tcp, http } = {}) { _tcp = tcp || tcpOpen; _http = http || httpTitle; }
|
||||
|
||||
async function mapPool(items, concurrency, fn) {
|
||||
const out = new Array(items.length);
|
||||
let i = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); }
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function handler(job) {
|
||||
const subnet = job?.data?.subnet || process.env.DISCOVER_SUBNET || '192.168.1';
|
||||
const targets = [];
|
||||
for (let h = 1; h <= 254; h++) for (const port of PORTS) targets.push({ host: `${subnet}.${h}`, port });
|
||||
|
||||
// 1) TCP sweep → live host:ports
|
||||
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
||||
|
||||
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo)
|
||||
let added = 0;
|
||||
for (const { host, port } of open) {
|
||||
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
||||
const url = `${scheme}://${host}:${port}`;
|
||||
const probe = await _http(url);
|
||||
const name = (probe && probe.title) || `${host}:${port}`;
|
||||
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
||||
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
||||
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
||||
if (r) added++;
|
||||
}
|
||||
log.info({ open: open.length, added }, 'lan discovery complete');
|
||||
return { open: open.length, added };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { load } from '../../health/registry.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';
|
||||
export const NAME = 'health.check';
|
||||
export async function handler(_job) {
|
||||
const results = await checkAll(load());
|
||||
const results = await checkAll(await services.listEnabled());
|
||||
for (const r of results) await statusRepo.upsert(r);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user