diff --git a/backend/app/data/oui_database.json b/backend/app/data/oui_database.json new file mode 100644 index 0000000..af99dd8 --- /dev/null +++ b/backend/app/data/oui_database.json @@ -0,0 +1,221 @@ +[ + { + "vendor": "Proxmox / QEMU / KVM", + "type": "vm", + "prefixes": ["52:54:00", "bc:24:11"] + }, + { + "vendor": "VMware", + "type": "vm", + "prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"] + }, + { + "vendor": "VirtualBox", + "type": "vm", + "prefixes": ["08:00:27"] + }, + { + "vendor": "Microsoft Hyper-V", + "type": "vm", + "prefixes": ["00:15:5d"] + }, + { + "vendor": "Xen", + "type": "vm", + "prefixes": ["00:16:3e"] + }, + + { + "vendor": "MikroTik", + "type": "router", + "prefixes": [ + "00:0c:42", + "08:55:31", + "18:fd:74", + "2c:c8:1b", + "48:8f:5a", + "4c:5e:0c", + "64:d1:54", + "6c:3b:6b", + "74:4d:28", + "b8:69:f4", + "c4:ad:34", + "cc:2d:e0", + "d4:ca:6d", + "dc:2c:6e", + "e4:8d:8c" + ] + }, + { + "vendor": "Ubiquiti", + "type": "ap", + "prefixes": [ + "00:15:6d", + "00:27:22", + "04:18:d6", + "24:5a:4c", + "24:a4:3c", + "44:d9:e7", + "68:72:51", + "68:d7:9a", + "74:83:c2", + "78:8a:20", + "78:45:58", + "80:2a:a8", + "94:2a:6f", + "9c:05:d6", + "b4:fb:e4", + "dc:9f:db", + "e0:63:da", + "f0:9f:c2", + "fc:ec:da" + ] + }, + { + "vendor": "Ruckus Wireless", + "type": "ap", + "prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"] + }, + { + "vendor": "Aruba Networks (HPE)", + "type": "ap", + "prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"] + }, + + { + "vendor": "Cisco Systems", + "type": "switch", + "prefixes": [ + "00:00:0c", + "00:1b:0d", + "00:1c:f6", + "00:1e:13", + "00:23:04", + "00:24:13", + "00:25:45", + "00:50:0b", + "b0:00:b4", + "b8:38:61", + "f8:c0:01" + ] + }, + { + "vendor": "Juniper Networks", + "type": "switch", + "prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"] + }, + { + "vendor": "Zyxel", + "type": "switch", + "prefixes": ["00:13:49", "60:31:97", "ec:43:f6"] + }, + + { + "vendor": "Netgear", + "type": "router", + "prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"] + }, + { + "vendor": "TP-Link", + "type": "router", + "prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"] + }, + + { + "vendor": "Synology", + "type": "nas", + "prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"] + }, + { + "vendor": "QNAP Systems", + "type": "nas", + "prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"] + }, + { + "vendor": "Asustor", + "type": "nas", + "prefixes": ["e8:9c:25"] + }, + + { + "vendor": "Hikvision", + "type": "camera", + "prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"] + }, + { + "vendor": "Dahua / Amcrest", + "type": "camera", + "prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"] + }, + { + "vendor": "Reolink", + "type": "camera", + "prefixes": ["ec:71:db"] + }, + { + "vendor": "Axis Communications", + "type": "camera", + "prefixes": ["00:40:8c", "ac:cc:8e"] + }, + + { + "vendor": "Raspberry Pi Foundation", + "type": "server", + "prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"] + }, + { + "vendor": "Dell", + "type": "server", + "prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"] + }, + { + "vendor": "Supermicro", + "type": "server", + "prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"] + }, + + { + "vendor": "Shelly", + "type": "iot", + "prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"] + }, + { + "vendor": "Espressif (ESP8266 / ESP32)", + "type": "iot", + "prefixes": [ + "24:62:ab", + "30:ae:a4", + "3c:71:bf", + "8c:aa:b5", + "a0:20:a6", + "ac:67:b2", + "b4:e6:2d", + "cc:50:e3" + ] + }, + { + "vendor": "Sonoff / ITEAD", + "type": "iot", + "prefixes": ["dc:4f:22", "e8:db:84"] + }, + { + "vendor": "TP-Link Tapo / Kasa", + "type": "iot", + "prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"] + }, + { + "vendor": "Philips Hue", + "type": "iot", + "prefixes": ["00:17:88", "ec:b5:fa"] + }, + { + "vendor": "IKEA Tradfri", + "type": "iot", + "prefixes": ["00:21:2e", "34:13:e8"] + }, + { + "vendor": "Tuya / Smart Life", + "type": "iot", + "prefixes": ["68:57:2d", "d8:f1:5b"] + } +] diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py index 1a2b4a8..460bdf3 100644 --- a/backend/app/services/fingerprint.py +++ b/backend/app/services/fingerprint.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any _SIGNATURES: list[dict[str, Any]] | None = None +_OUI_MAP: dict[str, str] | None = None _LOCK = threading.Lock() @@ -26,6 +27,29 @@ def _load() -> list[dict[str, Any]]: return _SIGNATURES +def _load_oui() -> dict[str, str]: + """Load OUI database and flatten to {prefix: node_type}.""" + global _OUI_MAP + if _OUI_MAP is None: + with _LOCK: + if _OUI_MAP is None: + path = Path(__file__).parent.parent / "data" / "oui_database.json" + try: + with open(path) as f: + entries = json.load(f) + except FileNotFoundError as err: + raise FileNotFoundError( + f"oui_database.json not found at {path}. " + "This file should be bundled with the application." + ) from err + _OUI_MAP = { + prefix.lower(): entry["type"] + for entry in entries + for prefix in entry["prefixes"] + } + return _OUI_MAP + + def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None: """Return the first signature matching port+protocol, optionally banner.""" for sig in _load(): @@ -65,55 +89,12 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]: return results -# Known OUI prefixes — lowercase, colon-separated, first 3 octets -_MAC_OUI_TYPES: dict[str, str] = { - # Hypervisors / VMs - "52:54:00": "vm", # QEMU/KVM (Proxmox VMs) - "bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+) - "00:50:56": "vm", # VMware - "00:0c:29": "vm", # VMware Workstation / Fusion - "08:00:27": "vm", # VirtualBox - "00:15:5d": "vm", # Hyper-V - # Shelly - "34:94:54": "iot", - "84:f3:eb": "iot", - "ec:fa:bc": "iot", - "30:c6:f7": "iot", - # Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT) - "a0:20:a6": "iot", - "24:62:ab": "iot", - "30:ae:a4": "iot", - "cc:50:e3": "iot", - "ac:67:b2": "iot", - "b4:e6:2d": "iot", - "3c:71:bf": "iot", - "8c:aa:b5": "iot", - # Sonoff / ITEAD - "dc:4f:22": "iot", - "e8:db:84": "iot", - # Tapo / TP-Link smart home - "b0:a7:b9": "iot", - "50:c7:bf": "iot", - "1c:3b:f3": "iot", - "10:27:f5": "iot", - # Philips Hue - "00:17:88": "iot", - "ec:b5:fa": "iot", - # IKEA Tradfri - "34:13:e8": "iot", - "00:21:2e": "iot", - # Tuya / Smart Life (widely used chip in many brands) - "d8:f1:5b": "iot", - "68:57:2d": "iot", -} - - def suggest_type_from_mac(mac: str | None) -> str | None: """Return a suggested node type from MAC OUI, or None if unknown.""" if not mac: return None prefix = mac.lower()[:8] - return _MAC_OUI_TYPES.get(prefix) + return _load_oui().get(prefix) _PORT_TYPE_HINTS: dict[int, str] = { diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py index 713b9bf..168b437 100644 --- a/backend/tests/test_fingerprint.py +++ b/backend/tests/test_fingerprint.py @@ -2,7 +2,12 @@ from unittest.mock import patch import pytest -from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type +from app.services.fingerprint import ( + fingerprint_ports, + match_port, + 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"}, @@ -173,3 +178,74 @@ def test_suggest_node_type_iot_wins_over_server_when_mqtt_present(): {"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"