Files
Void-Homelab/lib/jobs/workers/discover.js

87 lines
3.6 KiB
JavaScript

import net from 'node:net';
import * as services from '../../db/repos/monitored_services.js';
import * as devices from '../../db/repos/lan_devices.js';
import { log } from '../../log.js';
export const NAME = 'discover.lan';
// Well-known homelab ports → likely service, so candidates get a real name.
const PORT_SVC = {
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
32400: 'Plex'
};
// 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).
// Cross-reference the Network Devices band so candidates are named by service+device.
const deviceByIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
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 dev = deviceByIp[host];
const svc = PORT_SVC[port] || (probe && probe.title) || null;
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${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 };
}