Files
homelable/backend/tests/test_fingerprint.py
Pouzor 5040b70073 feat(nodes): add KVM switch as a device type
Adds `kvm` to NodeType, with a label, a MonitorCog icon, a per-theme
accent and a registered node component, plus the Hardware group of the
node and custom-style pickers and the MCP NODE_TYPES list.

Scanner autodetect: port-agnostic signatures for PiKVM, TinyPilot,
JetKVM and NanoKVM, matched on the HTTP probe's title/headers, and `kvm`
placed ahead of `server` in the suggest_node_type priority list.

suggest_node_type called match_port, which drops the HTTP-probe signals,
so no `port: null` signature could influence a suggested type — Unraid,
OpenMediaVault and Cockpit included. It now calls match_service with the
probe signals, which is what makes the KVM signatures reachable.

Closes #314

ha-relevant: yes
2026-08-12 23:15:43 +02:00

371 lines
15 KiB
Python

from unittest.mock import patch
import pytest
from app.services.fingerprint import (
fingerprint_ports,
match_port,
match_service,
suggest_node_type,
suggest_type_from_mac,
)
MOCK_SIGNATURES = [
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
{"port": 443, "protocol": "tcp", "banner_regex": None, "service_name": "HTTPS", "icon": "🔒", "category": "web", "suggested_node_type": "server"},
{"port": 22, "protocol": "tcp", "banner_regex": None, "service_name": "SSH", "icon": "🖥", "category": "remote", "suggested_node_type": None},
{"port": 8006, "protocol": "tcp", "banner_regex": "proxmox", "service_name": "Proxmox API", "icon": "🔧", "category": "web", "suggested_node_type": "proxmox"},
{"port": 8006, "protocol": "tcp", "banner_regex": None, "service_name": "Web UI", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
]
@pytest.fixture(autouse=True)
def mock_signatures():
with patch("app.services.fingerprint._load", return_value=MOCK_SIGNATURES):
yield
# ── match_port ────────────────────────────────────────────────────────────────
def test_match_port_known_port():
result = match_port(80, "tcp")
assert result is not None
assert result["service_name"] == "HTTP"
def test_match_port_unknown_port():
result = match_port(9999, "tcp")
assert result is None
def test_match_port_wrong_protocol():
result = match_port(80, "udp")
assert result is None
def test_match_port_with_banner_match():
result = match_port(8006, "tcp", banner="proxmox virtual environment")
assert result is not None
assert result["service_name"] == "Proxmox API"
def test_match_port_with_banner_no_match_falls_through_to_next():
# banner doesn't match proxmox regex → skips first sig, matches second (no banner_regex)
result = match_port(8006, "tcp", banner="something else")
assert result is not None
assert result["service_name"] == "Web UI"
def test_match_port_no_banner_skips_banner_regex_sigs():
# no banner → banner_regex sigs are skipped, falls through to generic fallback
result = match_port(8006, "tcp", banner=None)
assert result is not None
assert result["service_name"] == "Web UI"
# ── fingerprint_ports ─────────────────────────────────────────────────────────
def test_fingerprint_ports_known_ports():
results = fingerprint_ports([{"port": 80, "protocol": "tcp"}, {"port": 443, "protocol": "tcp"}])
assert len(results) == 2
assert results[0]["service_name"] == "HTTP"
assert results[1]["service_name"] == "HTTPS"
assert results[0]["port"] == 80
assert results[0]["category"] == "web"
def test_fingerprint_ports_unknown_port():
results = fingerprint_ports([{"port": 9999, "protocol": "tcp"}])
assert len(results) == 1
assert results[0]["service_name"] == "TCP/9999"
assert results[0]["icon"] is None
assert results[0]["category"] is None
def test_fingerprint_ports_mixed():
results = fingerprint_ports([{"port": 22, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}])
assert results[0]["service_name"] == "SSH"
assert results[1]["service_name"] == "TCP/9999"
def test_fingerprint_ports_empty():
assert fingerprint_ports([]) == []
def test_fingerprint_ports_defaults_protocol_to_tcp():
results = fingerprint_ports([{"port": 80}])
assert results[0]["service_name"] == "HTTP"
# ── suggest_node_type ─────────────────────────────────────────────────────────
def test_suggest_node_type_returns_proxmox_from_port():
result = suggest_node_type([{"port": 8006, "protocol": "tcp"}])
assert result == "proxmox"
def test_suggest_node_type_returns_server_from_http():
result = suggest_node_type([{"port": 80, "protocol": "tcp"}])
assert result == "server"
def test_suggest_node_type_no_match_returns_generic():
result = suggest_node_type([{"port": 9999, "protocol": "tcp"}])
assert result == "generic"
def test_suggest_node_type_empty_returns_generic():
assert suggest_node_type([]) == "generic"
def test_suggest_node_type_priority_proxmox_over_server():
# both proxmox and server ports open → proxmox wins (higher priority)
result = suggest_node_type([{"port": 80, "protocol": "tcp"}, {"port": 8006, "protocol": "tcp"}])
assert result == "proxmox"
# A port-agnostic signature: only an HTTP probe can ever match it.
KVM_SIGNATURE = {
"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "PiKVM",
"service_name": "PiKVM", "icon": "monitorcog", "category": "kvm",
"suggested_node_type": "kvm",
}
def test_suggest_node_type_kvm_from_http_title():
# An IP-KVM answers on 443 like any web app; only the probed title identifies it.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
])
assert result == "kvm"
def test_suggest_node_type_priority_kvm_over_server():
# The same host also answers on 80 (→ server); the KVM signal outranks it.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
])
assert result == "kvm"
def test_suggest_node_type_ignores_port_agnostic_sig_without_probe():
# No http_signals (probe disabled) → port-agnostic entries can't match and the
# port-only guess stands, unchanged from pre-probe behaviour.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([{"port": 443, "protocol": "tcp"}])
assert result == "server"
def test_suggest_node_type_camera_from_rtsp_port():
# RTSP port 554 → camera (via _PORT_TYPE_HINTS)
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
assert result == "camera"
def test_suggest_node_type_camera_from_signature():
# patch _load to return a camera sig so we test the sig path too
with patch("app.services.fingerprint._load", return_value=[
{"port": 554, "protocol": "tcp", "banner_regex": None, "service_name": "RTSP", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}
]):
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
assert result == "camera"
# ── IoT detection ─────────────────────────────────────────────────────────────
def test_suggest_node_type_iot_from_mqtt_port():
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_coap_port():
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_esphome_port():
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_shelly_mac_overrides_http_port():
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_espressif_mac_returns_iot():
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
assert result == "iot"
def test_suggest_node_type_tuya_mac_returns_iot():
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 1883, "protocol": "tcp"},
])
assert result == "iot"
# ── OUI vendor detection ──────────────────────────────────────────────────────
def test_suggest_type_from_mac_mikrotik_returns_router():
# The motivating case: MikroTik MAC should be recognized as a router
assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
def test_suggest_type_from_mac_ubiquiti_returns_ap():
# Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
# so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
def test_suggest_type_from_mac_synology_returns_nas():
assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
def test_suggest_type_from_mac_qnap_returns_nas():
assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
def test_suggest_type_from_mac_hikvision_returns_camera():
assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
def test_suggest_type_from_mac_dahua_returns_camera():
assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
def test_suggest_type_from_mac_cisco_returns_switch():
assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
def test_suggest_type_from_mac_raspberry_pi_returns_server():
assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
def test_suggest_type_from_mac_handles_uppercase():
# MACs may arrive in any case; lookup must be case-insensitive
assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
def test_suggest_type_from_mac_unknown_oui_returns_none():
assert suggest_type_from_mac("00:00:01:11:22:33") is None
def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
# MikroTik device with no scanned ports should still be classified as router via MAC
assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
def test_suggest_node_type_synology_mac_with_http_returns_nas():
# NAS priority beats server, so a Synology MAC + open HTTP → nas
result = suggest_node_type(
[{"port": 80, "protocol": "tcp"}],
mac="00:11:32:11:22:33",
)
assert result == "nas"
def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
# Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
result = suggest_node_type(
[{"port": 179, "protocol": "tcp"}],
mac="24:a4:3c:11:22:33",
)
assert result == "router"
# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
HTTP_SIGNATURES = [
# Generic web fallback on 8096 (port-only guess)
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
"service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
# Same port, but confirmed by HTML title → should win when probe confirms
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
# Port-agnostic: matches on HTTP content regardless of port
{"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
"service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
# Banner-based entry, no http
{"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
"service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
]
@pytest.fixture
def http_signatures():
with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
yield
def test_http_regex_confirmed_beats_port_only(http_signatures):
# Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": "Jellyfin", "headers": {}})
assert sig["service_name"] == "Jellyfin"
def test_http_regex_matches_on_header(http_signatures):
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
assert sig["service_name"] == "Jellyfin"
def test_http_regex_miss_falls_back_to_port_only(http_signatures):
# Probe ran but nothing matched the http_regex → generic port-only entry wins
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": "Some Other App", "headers": {}})
assert sig["service_name"] == "HTTP"
def test_probe_disabled_ignores_http_regex(http_signatures):
# http_signals=None (deep scan off) → http_regex entry degrades to port-only,
# generic entry (listed first) wins — identical to pre-probe behaviour.
sig = match_service(8096, "tcp", banner=None, http_signals=None)
assert sig["service_name"] == "HTTP"
def test_port_agnostic_match_on_custom_port(http_signatures):
# Portainer found on a non-standard port, recognised purely by HTTP content
sig = match_service(54321, "tcp", banner=None,
http_signals={"title": "Portainer", "headers": {}})
assert sig["service_name"] == "Portainer"
def test_port_agnostic_requires_probe(http_signatures):
# Same custom port, probe off → no signal → no match
assert match_service(54321, "tcp", banner=None, http_signals=None) is None
def test_banner_match_still_works_with_probe(http_signatures):
sig = match_service(9090, "tcp", banner="prometheus 2.x",
http_signals={"title": "x", "headers": {}})
assert sig["service_name"] == "Prometheus"
def test_match_port_alias_has_no_http(http_signatures):
# match_port() is the probe-less alias → http_regex entry degrades to port-only
sig = match_port(8096, "tcp")
assert sig["service_name"] == "HTTP"
def test_fingerprint_ports_uses_http_signals(http_signatures):
results = fingerprint_ports([
{"port": 8096, "protocol": "tcp", "banner": None,
"http_signals": {"title": "Jellyfin", "headers": {}}},
])
assert results[0]["service_name"] == "Jellyfin"