Files
homelable/backend/app/services/http_probe.py
T
Pouzor 6fa1b1a3d0 fix(scan): never send an HTTP GET to a raw printing port
A printer treats whatever bytes land on TCP/9100 as a print job, so our
probe's "GET / HTTP/1.1" came out of the tray as a page of plaintext.

Two paths reached it, and neither could be configured away — 9100 is
hard-coded in the scanner's port list, and user ranges only ever add to
that list:

- the deep-scan HTTP probe, once per scan;
- the status checker, every 60 s for as long as the node exists, which is
  the one that turns a scan annoyance into a ream of paper.

Both already had a non-HTTP port denylist; 515 and 9100-9107 were simply
missing from it. nmap ships `Exclude T:9100-9107` for this exact reason,
which is why the -sV pass never triggered it and only our own probes did.

Node Exporter also lives on 9100 and loses its status check as a result.
A grey dot is the cheaper mistake. Its fingerprint entry is port-match
only, so discovery and labelling are unaffected.

Fixes #404

ha-relevant: yes
2026-09-03 23:15:36 +02:00

116 lines
4.1 KiB
Python

"""HTTP probe: GET a discovered port and extract identifying signals.
Used by the optional deep-scan mode to confirm what service sits behind an
open port, regardless of port number. Returns the page <title> plus a small
set of identifying response headers, which fingerprint.match_service() then
matches against signature http_regex fields.
"""
import asyncio
import logging
import re
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Headers that commonly carry the application name.
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
# Cap how much body we read when hunting for <title>. This is a cap on the
# download itself, not just on what we scan: the body is streamed and the
# connection dropped once we hold this much. Some endpoints stream without end
# and send no Content-Length (bandwidth-test endpoints, MJPEG cameras), so
# buffering a full response would OOM the backend.
_MAX_BODY_BYTES = 64 * 1024
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
_PROBE_TIMEOUT = 3.0
# Ports we never bother probing over HTTP (not web services).
#
# 515 and 9100-9107 are raw printing (LPD / JetDirect). Those are not merely
# uninteresting: a printer takes whatever bytes land on 9100 as a print job, so
# a GET line makes it spit out a page of plaintext. Never send them anything.
# nmap already ships `Exclude T:9100-9107` for the same reason, which is why
# the -sV pass never triggered this — only our own probe did.
_PRINTER_PORTS = frozenset({515, *range(9100, 9108)})
_NON_HTTP_PORTS = frozenset({
22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379,
}) | _PRINTER_PORTS
async def _read_capped(resp: httpx.Response) -> str:
"""Read at most _MAX_BODY_BYTES of a streaming response, then stop."""
chunks: list[bytes] = []
size = 0
async for chunk in resp.aiter_bytes():
chunks.append(chunk)
size += len(chunk)
if size >= _MAX_BODY_BYTES:
break
raw = b"".join(chunks)[:_MAX_BODY_BYTES]
encoding = resp.charset_encoding or "utf-8"
try:
return raw.decode(encoding, errors="replace")
except LookupError:
return raw.decode("utf-8", errors="replace")
def _extract_title(body: str) -> str | None:
m = _TITLE_RE.search(body)
if not m:
return None
title = re.sub(r"\s+", " ", m.group(1)).strip()
return title or None
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
try:
async with client.stream("GET", url, follow_redirects=True) as resp:
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
body = await _read_capped(resp)
except (httpx.HTTPError, OSError):
return None
title = _extract_title(body)
if not title and not headers:
return None
return {"title": title, "headers": headers}
async def probe_port(
ip: str, port: int, verify_tls: bool = False
) -> dict[str, Any] | None:
"""
GET https:// then http:// for a port and return {title, headers} or None.
None means the port did not answer HTTP or yielded no usable signal.
"""
if port in _NON_HTTP_PORTS:
return None
async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
for scheme in ("https", "http"):
result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
if result is not None:
return result
return None
async def probe_open_ports(
ip: str,
open_ports: list[dict[str, Any]],
verify_tls: bool = False,
concurrency: int = 50,
) -> list[dict[str, Any]]:
"""
Probe every open port for HTTP signals (option 2: probe all, match after).
Returns the same port dicts, each enriched with an http_signals key
(None when the port gave no HTTP signal).
"""
sem = asyncio.Semaphore(concurrency)
async def _one(p: dict[str, Any]) -> dict[str, Any]:
async with sem:
signals = await probe_port(ip, p["port"], verify_tls)
return {**p, "http_signals": signals}
return await asyncio.gather(*(_one(p) for p in open_ports))