OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / lib / probe.js

85 lines · javascript ref:
// What answers on a TCP port: open or not, and if it speaks HTTP, the status,
// redirect and page title.
//
// A raw socket and a hand-written GET rather than an HTTP client: the port may
// speak anything (SSH, SIP, a binary protocol), and all we want is the first
// few KB, read with one hard deadline.

import net from 'bare-net'
import { parseHttpHead, sameOriginPath, pageTitle } from './parse.js'

const READ_LIMIT = 64 * 1024

// Is anything listening? Resolves true/false, never rejects.
export function tcpOpen (host, port, timeoutMs = 700) {
  return new Promise((resolve) => {
    let done = false
    const sock = net.createConnection(port, host)
    const finish = (ok) => {
      if (done) return
      done = true
      clearTimeout(timer)
      sock.destroy()
      resolve(ok)
    }
    const timer = setTimeout(() => finish(false), timeoutMs)
    sock.on('connect', () => finish(true))
    sock.on('error', () => finish(false))
  })
}

// { open, http: { code, location, server, title } | null, banner }
export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host, path = '/' } = {}) {
  return new Promise((resolve) => {
    let done = false
    let open = false
    const chunks = []
    let size = 0
    const sock = net.createConnection(port, host)
    const finish = () => {
      if (done) return
      done = true
      clearTimeout(timer)
      sock.destroy()
      const text = Buffer.concat(chunks).toString('utf8')
      const http = parseHttpHead(text)
      const banner = http ? null : text.split(/\r?\n/)[0].slice(0, 120) || null
      resolve({ open, http, banner })
    }
    const timer = setTimeout(finish, timeoutMs)
    sock.on('connect', () => {
      open = true
      sock.write('GET ' + path + ' HTTP/1.0\r\nHost: ' + hostHeader + ':' + port + '\r\nUser-Agent: host-services\r\nAccept: text/html\r\nConnection: close\r\n\r\n')
    })
    sock.on('data', (d) => {
      chunks.push(d)
      size += d.length
      // Enough to see the head and a <title>; stop reading big pages.
      if (size >= READ_LIMIT || /<\/title>/i.test(d.toString('latin1'))) finish()
    })
    sock.on('end', finish)
    sock.on('close', finish)
    sock.on('error', finish)
  })
}

// probeHttp, then up to `maxRedirects` same-port redirects, so the title is
// the page a browser lands on ("Login - Radarr", not nothing for a bare 302).
// `code`/`location` stay the first response's; `title` is the landing page's,
// null when it only names an error. `landing` is the path that gave it.
export async function probeWeb (host, port, { maxRedirects = 2, ...opts } = {}) {
  const first = await probeHttp(host, port, opts)
  if (!first.http) return first
  let last = first.http
  let landing = '/'
  for (let i = 0; i < maxRedirects && last.code >= 300 && last.code < 400; i++) {
    const next = sameOriginPath(last.location, port)
    if (!next) break
    const r = await probeHttp(host, port, { ...opts, path: next })
    if (!r.http) break
    last = r.http
    landing = next
  }
  return { ...first, http: { ...first.http, title: pageTitle(last), landing } }
}