Files
NetAlertX/server/scan/device_heuristics.py
jokob-sk 139447b253 BE: mylog() better code radability
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-25 07:54:17 +11:00

265 lines
8.7 KiB
Python
Executable File

import os
import re
import json
import base64
from pathlib import Path
from typing import Optional, Tuple
from logger import mylog
# Register NetAlertX directories
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
# Load MAC/device-type/icon rules from external file
MAC_TYPE_ICON_PATH = Path(f"{INSTALL_PATH}/back/device_heuristics_rules.json")
try:
with open(MAC_TYPE_ICON_PATH, "r", encoding="utf-8") as f:
MAC_TYPE_ICON_RULES = json.load(f)
# Precompute base64-encoded icon_html once for each rule
for rule in MAC_TYPE_ICON_RULES:
icon_html = rule.get("icon_html", "")
if icon_html:
# encode icon_html to base64 string
b64_bytes = base64.b64encode(icon_html.encode("utf-8"))
rule["icon_base64"] = b64_bytes.decode("utf-8")
else:
rule["icon_base64"] = ""
except Exception as e:
MAC_TYPE_ICON_RULES = []
mylog("none", f"[guess_device_attributes] Failed to load device_heuristics_rules.json: {e}",)
# -----------------------------------------
# Match device type and base64-encoded icon using MAC prefix and vendor patterns.
def match_mac_and_vendor(
mac_clean: str, vendor: str, default_type: str, default_icon: str
) -> Tuple[str, str]:
"""
Match device type and base64-encoded icon using MAC prefix and vendor patterns.
Args:
mac_clean: Cleaned MAC address (uppercase, no colons).
vendor: Normalized vendor name (lowercase).
default_type: Fallback device type.
default_icon: Fallback base64 icon.
Returns:
Tuple containing (device_type, base64_icon)
"""
for rule in MAC_TYPE_ICON_RULES:
dev_type = rule.get("dev_type")
base64_icon = rule.get("icon_base64", "")
patterns = rule.get("matching_pattern", [])
for pattern in patterns:
mac_prefix = pattern.get("mac_prefix", "").upper()
vendor_pattern = pattern.get("vendor", "").lower()
if mac_clean.startswith(mac_prefix):
if not vendor_pattern or vendor_pattern in vendor:
mylog("debug", "[guess_device_attributes] Matched via MAC+Vendor")
type_ = dev_type
icon = base64_icon or default_icon
return type_, icon
return default_type, default_icon
# ---------------------------------------------------
# Match device type and base64-encoded icon using vendor patterns.
def match_vendor(vendor: str, default_type: str, default_icon: str) -> Tuple[str, str]:
vendor_lc = vendor.lower()
for rule in MAC_TYPE_ICON_RULES:
dev_type = rule.get("dev_type")
base64_icon = rule.get("icon_base64", "")
patterns = rule.get("matching_pattern", [])
for pattern in patterns:
# Only apply fallback when no MAC prefix is specified
# mac_prefix = pattern.get("mac_prefix", "")
vendor_pattern = pattern.get("vendor", "").lower()
if vendor_pattern and vendor_pattern in vendor_lc:
mylog("debug", "[guess_device_attributes] Matched via Vendor")
icon = base64_icon or default_icon
return dev_type, icon
return default_type, default_icon
# ---------------------------------------------------
# Match device type and base64-encoded icon using name patterns.
def match_name(name: str, default_type: str, default_icon: str) -> Tuple[str, str]:
"""
Match device type and base64-encoded icon using name patterns from global MAC_TYPE_ICON_RULES.
Args:
name: Normalized device name (lowercase).
default_type: Fallback device type.
default_icon: Fallback base64 icon.
Returns:
Tuple containing (device_type, base64_icon)
"""
name_lower = name.lower() if name else ""
for rule in MAC_TYPE_ICON_RULES:
dev_type = rule.get("dev_type")
base64_icon = rule.get("icon_base64", "")
name_patterns = rule.get("name_pattern", [])
for pattern in name_patterns:
# Use regex search to allow pattern substrings
if re.search(pattern, name_lower, re.IGNORECASE):
mylog("debug", "[guess_device_attributes] Matched via Name")
type_ = dev_type
icon = base64_icon or default_icon
return type_, icon
return default_type, default_icon
# -------------------------------------------------------------------------------
#
def match_ip(ip: str, default_type: str, default_icon: str) -> Tuple[str, str]:
"""
Match device type and base64-encoded icon using IP regex patterns from global JSON.
Args:
ip: Device IP address as string.
default_type: Fallback device type.
default_icon: Fallback base64 icon.
Returns:
Tuple containing (device_type, base64_icon)
"""
if not ip:
return default_type, default_icon
for rule in MAC_TYPE_ICON_RULES:
ip_patterns = rule.get("ip_pattern", [])
dev_type = rule.get("dev_type")
base64_icon = rule.get("icon_base64", "")
for pattern in ip_patterns:
if re.match(pattern, ip):
mylog("debug", "[guess_device_attributes] Matched via IP")
type_ = dev_type
icon = base64_icon or default_icon
return type_, icon
return default_type, default_icon
# -------------------------------------------------------------------------------
# Guess device attributes such as type of device and associated device icon
def guess_device_attributes(
vendor: Optional[str],
mac: Optional[str],
ip: Optional[str],
name: Optional[str],
default_icon: str,
default_type: str,
) -> Tuple[str, str]:
mylog("debug", f"[guess_device_attributes] Guessing attributes for (vendor|mac|ip|name): ('{vendor}'|'{mac}'|'{ip}'|'{name}')",)
# --- Normalize inputs ---
vendor = str(vendor).lower().strip() if vendor else "unknown"
mac = str(mac).upper().strip() if mac else "00:00:00:00:00:00"
ip = str(ip).strip() if ip else "169.254.0.0"
name = str(name).lower().strip() if name else "(unknown)"
mac_clean = mac.replace(":", "").replace("-", "").upper()
# # Internet shortcut
# if mac == "INTERNET":
# return ICONS.get("globe", default_icon), DEVICE_TYPES.get("Internet", default_type)
type_ = None
icon = None
# --- Strict MAC + vendor rule matching from external file ---
type_, icon = match_mac_and_vendor(mac_clean, vendor, default_type, default_icon)
# --- Loose Vendor-based fallback ---
if not type_ or type_ == default_type:
type_, icon = match_vendor(vendor, default_type, default_icon)
# --- Loose Name-based fallback ---
if not type_ or type_ == default_type:
type_, icon = match_name(name, default_type, default_icon)
# --- Loose IP-based fallback ---
if (not type_ or type_ == default_type) or (not icon or icon == default_icon):
type_, icon = match_ip(ip, default_type, default_icon)
# Final fallbacks
type_ = type_ or default_type
icon = icon or default_icon
mylog("debug", f"[guess_device_attributes] Guessed attributes (icon|type_): ('{icon}'|'{type_}')",)
return icon, type_
# Deprecated functions with redirects (To be removed once all calls for these have been adjusted to use the updated function)
def guess_icon(
vendor: Optional[str],
mac: Optional[str],
ip: Optional[str],
name: Optional[str],
default: str,
) -> str:
"""
[DEPRECATED] Guess the appropriate FontAwesome icon for a device based on its attributes.
Use guess_device_attributes instead.
Args:
vendor: Device vendor name.
mac: Device MAC address.
ip: Device IP address.
name: Device name.
default: Default icon to return if no match is found.
Returns:
str: Base64-encoded FontAwesome icon HTML string.
"""
icon, _ = guess_device_attributes(vendor, mac, ip, name, default, "unknown_type")
return icon
def guess_type(
vendor: Optional[str],
mac: Optional[str],
ip: Optional[str],
name: Optional[str],
default: str,
) -> str:
"""
[DEPRECATED] Guess the device type based on its attributes.
Use guess_device_attributes instead.
Args:
vendor: Device vendor name.
mac: Device MAC address.
ip: Device IP address.
name: Device name.
default: Default type to return if no match is found.
Returns:
str: Device type.
"""
_, type_ = guess_device_attributes(vendor, mac, ip, name, "unknown_icon", default)
return type_
# Handler for when this is run as a program instead of called as a module.
if __name__ == "__main__":
mylog("error", "This module is not intended to be run directly.")