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:
root
2026-06-04 07:55:08 +10:00
parent b728696020
commit ce26895d8e
17 changed files with 466 additions and 46 deletions

View 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 };
}

View File

@@ -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);
}