OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / lib / lan.js

169 lines · javascript ref:
// 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 = ''
    }
  }
}