// The rest of the LAN: which devices are up and what they serve.
//
// 1. Sweep every address of the host's /24 with a few TCP connects. Any reply,
// even a refusal, makes the kernel learn the device's MAC, so afterwards
// /proc/net/arp lists every device that answered ARP -- including ones that
// drop all TCP (a firewalled Windows box still answers ARP).
// 2. Names and advertised services from mDNS (avahi).
// 3. A TCP connect scan of SCAN_PORTS on every device, then an HTTP probe of
// each open port for its status and page title.
//
// Connect scans need no root. The result is kept on disk so a restart shows
// the last scan at once.
import fs from 'bare-fs'
import path from 'bare-path'
import EventEmitter from 'bare-events'
import { run } from './run.js'
import { tcpOpen, probeWeb } from './probe.js'
import { parseArp, parseAvahiBrowse, parseIpAddr } from './parse.js'
const SWEEP_PORTS = [80, 443, 22, 445, 139, 53, 8080, 5357, 62078, 9100]
// Common service ports plus everything a full nmap top-1000 scan of this LAN
// found open (2026-10-19).
export const SCAN_PORTS = [
21, 22, 23, 25, 53, 80, 81, 88, 110, 111, 135, 139, 143, 389, 443, 445, 515,
548, 554, 587, 631, 993, 995, 1080, 1194, 1400, 1883, 1900, 2049, 2179, 2375,
3000, 3001, 3128, 3306, 3389, 3399, 3400, 3401, 4040, 4041, 4042, 4050, 4060,
4443, 4445, 5000, 5001, 5005, 5055, 5060, 5357, 5432, 5900, 6379, 6443, 7000,
7220, 7221, 7878, 8000, 8001, 8008, 8009, 8020, 8080, 8081, 8085, 8086, 8088,
8096, 8123, 8181, 8200, 8443, 8800, 8888, 8920, 8989, 9000, 9001, 9090, 9091,
9100, 9117, 9696, 9999, 10000, 11434, 20005, 32400, 49152, 62078
]
const NO_HTTP_PROBE = new Set([21, 22, 23, 25, 53, 110, 111, 135, 139, 143, 445, 515, 1883, 2049, 3306, 3389, 5060, 5432, 5900, 6379, 9100])
async function pool (items, limit, fn) {
const out = new Array(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i], i)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return out
}
export class LanScanner extends EventEmitter {
constructor ({ configDir, intervalMs = 30 * 60 * 1000, ports = SCAN_PORTS } = {}) {
super()
this.file = configDir ? path.join(configDir, 'lan.json') : null
this.intervalMs = intervalMs
this.ports = ports
this.result = null
this.scanning = false
this.progress = ''
this._timer = null
this._load()
}
_load () {
if (!this.file) return
try {
this.result = JSON.parse(fs.readFileSync(this.file).toString())
} catch {}
}
_save () {
if (!this.file) return
try {
fs.mkdirSync(path.dirname(this.file), { recursive: true })
fs.writeFileSync(this.file + '.tmp', JSON.stringify(this.result))
fs.renameSync(this.file + '.tmp', this.file)
} catch (err) {
console.error(JSON.stringify({ msg: 'lan save failed', error: err.message }))
}
}
start () {
const age = this.result ? Date.now() - Date.parse(this.result.at) : Infinity
const first = Math.max(0, Math.min(this.intervalMs - age, this.intervalMs))
this._timer = setTimeout(() => this._tick(), first)
}
stop () {
clearTimeout(this._timer)
this._timer = null
}
_tick () {
this.scan().finally(() => {
if (this._timer !== null) this._timer = setTimeout(() => this._tick(), this.intervalMs)
})
}
state () {
return { scanning: this.scanning, progress: this.progress, result: this.result }
}
async scan () {
if (this.scanning) return this.result
this.scanning = true
const started = Date.now()
try {
this.progress = 'finding the LAN'
const own = parseIpAddr((await run('ip', ['-4', '-o', 'addr', 'show', 'scope', 'global'])).stdout)
.filter((a) => !/^(tun|tunnel|wg|docker|br-|veth|virbr)/.test(a.dev) && a.prefix >= 16 && a.prefix <= 30)
const subnets = [...new Set(own.map((a) => a.ip.split('.').slice(0, 3).join('.')))]
this.progress = 'sweeping ' + subnets.map((s) => s + '.0/24').join(', ')
const sweep = []
for (const s of subnets) for (let i = 1; i < 255; i++) for (const p of SWEEP_PORTS) sweep.push([s + '.' + i, p])
await pool(sweep, 256, ([h, p]) => tcpOpen(h, p, 500))
const arp = parseArp(fs.readFileSync('/proc/net/arp').toString())
.filter((a) => subnets.some((s) => a.ip.startsWith(s + '.')))
const hosts = new Map()
for (const a of own) hosts.set(a.ip, { ip: a.ip, mac: null, self: true, names: [], mdns: [], ports: [] })
for (const a of arp) if (!hosts.has(a.ip)) hosts.set(a.ip, { ip: a.ip, mac: a.mac, self: false, names: [], mdns: [], ports: [] })
this.progress = 'mDNS names and services'
const [browse] = await Promise.all([
run('avahi-browse', ['-a', '-t', '-r', '-p', '--no-db-lookup'], { timeoutMs: 12000 }),
pool([...hosts.values()], 16, async (h) => {
const r = await run('avahi-resolve', ['-a', h.ip], { timeoutMs: 3000 })
const name = r.stdout.trim().split(/\s+/)[1]
if (name) h.names.push(name)
})
])
for (const svc of parseAvahiBrowse(browse.stdout)) {
const h = hosts.get(svc.ip)
if (!h) continue
if (!h.names.includes(svc.host)) h.names.push(svc.host)
if (!h.mdns.some((m) => m.type === svc.type && m.port === svc.port && m.name === svc.name)) {
h.mdns.push({ name: svc.name, type: svc.type, port: svc.port })
}
}
const list = [...hosts.values()]
this.progress = 'port scan of ' + list.length + ' devices'
const jobs = []
for (const h of list) for (const p of this.ports) jobs.push([h, p])
const open = await pool(jobs, 192, async ([h, p]) => ((await tcpOpen(h.ip, p, 800)) ? [h, p] : null))
this.progress = 'reading what answers'
await pool(open.filter(Boolean), 32, async ([h, p]) => {
if (NO_HTTP_PROBE.has(p)) return h.ports.push({ port: p, http: null, banner: null })
const r = await probeWeb(h.ip, p, { timeoutMs: 3000 })
h.ports.push({ port: p, http: r.http, banner: r.banner })
})
for (const h of list) h.ports.sort((a, b) => a.port - b.port)
list.sort((a, b) => Number(a.ip.split('.')[3]) - Number(b.ip.split('.')[3]))
this.result = { at: new Date().toISOString(), durationMs: Date.now() - started, subnets, ports: this.ports.length, hosts: list }
this._save()
this.emit('scanned', this.result)
return this.result
} catch (err) {
console.error(JSON.stringify({ msg: 'lan scan failed', error: err.stack || String(err) }))
return this.result
} finally {
this.scanning = false
this.progress = ''
}
}
}