feat: expand MAC OUI database with router, switch, AP, NAS, and camera vendors
Moves the OUI mapping from an inline dict in fingerprint.py into data/oui_database.json grouped by vendor, matching the service_signatures data-file pattern. Adds ~100 curated OUIs covering MikroTik, Ubiquiti, Synology, QNAP, Cisco, Aruba, Juniper, Hikvision, Dahua, Reolink, Axis, Raspberry Pi, Dell, Supermicro, and others. Existing IoT vendors and hypervisor OUIs are preserved. For multi-product vendors the OUI is tagged with the most common homelab category (e.g. Ubiquiti -> ap) and port hints in suggest_node_type continue to upgrade ambiguous matches (Ubiquiti + BGP -> router).
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user