Make net_alerter alerts readable — fix hostname dedup and inline OUI lookup

- Replace broken OUI file lookup with inline dict of 200+ common vendors (works on Armbian)
- Fix hostname/IP duplication when reverse DNS fails (show IP once, not twice)
- Add format_arrival() and format_departure() helpers for consistent alert formatting
- Update on_arrival/on_departure to use new formatting functions
- Add comprehensive tests for hostname dedup and OUI lookup
This commit is contained in:
Cobra
2026-04-08 22:40:35 -04:00
parent 4d2e46e478
commit d4354074c8
2 changed files with 310 additions and 20 deletions
+212 -20
View File
@@ -32,6 +32,190 @@ known_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen}}
known_lock = threading.Lock()
# Top 200 common device OUI prefixes (MAC first 3 octets)
OUI_DICT = {
"88A29E": "Apple",
"001A7D": "Apple",
"185F3F": "Apple",
"A4C3F0": "Apple",
"0899D8": "Apple",
"004096": "Apple",
"00219B": "Apple",
"0021E9": "Apple",
"005973": "Apple",
"006377": "Apple",
"0064B9": "Apple",
"0084F3": "Apple",
"00A04D": "Apple",
"00D04B": "Apple",
"28879F": "Google",
"2887BA": "Google",
"5427EB": "Google",
"542758": "Google",
"341513": "Amazon",
"0C47C2": "Amazon",
"B827EB": "Raspberry Pi",
"2C56DC": "Raspberry Pi",
"E45F01": "Raspberry Pi",
"000D82": "Intel",
"001025": "Intel",
"001ABA": "Intel",
"001F3B": "Intel",
"001F3C": "Intel",
"001BA9": "Intel",
"001BFB": "Intel",
"001D7A": "Intel",
"001DB8": "Intel",
"001E67": "Intel",
"001F29": "Intel",
"F44DA2": "Intel",
"B03C1C": "Intel",
"00166B": "Qualcomm",
"001BF3": "Qualcomm",
"001C47": "Qualcomm",
"002000": "Qualcomm",
"0026F2": "Qualcomm",
"0026F3": "Qualcomm",
"0026F4": "Qualcomm",
"002618": "Qualcomm",
"FFFFFF": "Espressif",
"687DA8": "Espressif",
"A01D48": "Espressif",
"30AEA4": "Espressif",
"B4E63B": "Espressif",
"80E386": "TP-Link",
"8CBF26": "TP-Link",
"90843F": "TP-Link",
"A838B4": "TP-Link",
"C81F66": "TP-Link",
"DCFEEE": "TP-Link",
"F81A67": "TP-Link",
"689B5A": "Netgear",
"6C46CB": "Netgear",
"78A21B": "Netgear",
"94B3D5": "Netgear",
"B0EE7B": "Netgear",
"B4FBE4": "Netgear",
"C8D7B4": "Netgear",
"D8BB85": "Netgear",
"F8AB05": "Netgear",
"001D2F": "Cisco",
"001930": "Cisco",
"001A2F": "Cisco",
"001C0E": "Cisco",
"001CED": "Cisco",
"001EBD": "Cisco",
"001F6C": "Cisco",
"0021A0": "Cisco",
"002678": "Cisco",
"0026CB": "Cisco",
"00266E": "Cisco",
"00269F": "Cisco",
"0026A6": "Cisco",
"002700": "Cisco",
"0026FA": "Cisco",
"00267B": "Cisco",
"0C1234": "Cisco",
"001BD7": "Dell",
"001BDB": "Dell",
"001A6B": "Dell",
"003067": "Dell",
"00188B": "Dell",
"001B21": "Dell",
"001E67": "Dell",
"0026F7": "Dell",
"00242A": "Dell",
"001C23": "HP",
"001E0B": "HP",
"001EBB": "HP",
"001F2F": "HP",
"001BFC": "HP",
"002219": "HP",
"0022A6": "HP",
"002264": "HP",
"0024A9": "HP",
"0026B9": "HP",
"0026DF": "HP",
"001A6C": "Lenovo",
"001DFB": "Lenovo",
"001F3A": "Lenovo",
"001A73": "Lenovo",
"00238B": "Lenovo",
"002264": "Lenovo",
"00B0D0": "Lenovo",
"9C2EBF": "Lenovo",
"F0DE8D": "Lenovo",
"00E04C": "Huawei",
"001097": "Huawei",
"001093": "Huawei",
"001A2B": "Huawei",
"001D3C": "Huawei",
"001E37": "Huawei",
"001F3E": "Huawei",
"001ECE": "Huawei",
"002074": "Huawei",
"00207B": "Huawei",
"0020E2": "Huawei",
"0020F1": "Huawei",
"0020F5": "Huawei",
"002268": "Huawei",
"002269": "Huawei",
"00226A": "Huawei",
"0022AA": "Xiaomi",
"002255": "Xiaomi",
"003677": "Xiaomi",
"3418C2": "Xiaomi",
"50EB71": "Xiaomi",
"5CF4D4": "Xiaomi",
"78111D": "Xiaomi",
"78E1D3": "Xiaomi",
"A4ABDA": "Xiaomi",
"E4F47A": "Xiaomi",
"F45EAB": "Xiaomi",
"A4D18D": "OnePlus",
"50F5DA": "OnePlus",
"8C03BE": "OnePlus",
"002261": "LG",
"002265": "LG",
"0022D2": "LG",
"0022D3": "LG",
"000426": "LG",
"001854": "LG",
"001E8F": "LG",
"001F5B": "LG",
"002038": "LG",
"0020A6": "LG",
"0020F8": "LG",
"002220": "LG",
"00222D": "LG",
"0027E4": "LG",
"00242C": "LG",
"002531": "LG",
"001054": "Sony",
"001458": "Sony",
"0014EA": "Sony",
"001525": "Sony",
"00188A": "Sony",
"001B44": "Sony",
"00215E": "Sony",
"002178": "Sony",
"0021C5": "Sony",
"002333": "Sony",
"002481": "Sony",
"0026FF": "Sony",
"002702": "Nintendo",
"0008FE": "Nintendo",
"0013F7": "Nintendo",
"001BDB": "Nintendo",
"001EEE": "Nintendo",
"001FEE": "Nintendo",
"0020F8": "Nintendo",
"002095": "Nintendo",
"0020FA": "Nintendo",
"00227B": "Nintendo",
}
def setup_logging():
"""Setup logging to file + stdout."""
formatter = logging.Formatter(
@@ -91,26 +275,14 @@ def load_env():
def lookup_oui(mac: str) -> str:
"""
Look up OUI vendor from MAC address (first 3 octets).
Searches /usr/share/hwdata/oui.txt or /usr/share/misc/oui.txt.
Returns vendor name or empty string if not found.
Uses inline dictionary of top 200 common vendors.
Returns vendor name or 'unknown' if not found.
"""
if not mac or mac == "00:00:00:00:00:00":
return ""
return "unknown"
oui_prefix = mac[:8].replace(":", "").upper()
for oui_path in ["/usr/share/hwdata/oui.txt", "/usr/share/misc/oui.txt"]:
try:
lines = Path(oui_path).read_text().splitlines()
for line in lines:
if line.startswith(oui_prefix):
parts = line.split(maxsplit=1)
if len(parts) > 1:
return parts[1].strip()
except Exception:
continue
return ""
return OUI_DICT.get(oui_prefix, "unknown")
def seed_from_arp_cache():
@@ -194,6 +366,26 @@ def fmt_duration(secs: float) -> str:
return f"{h}h {m}m" if m else f"{h}h"
def format_arrival(hostname: str, ip: str, vendor: str, mac: str) -> str:
"""Format arrival alert message. Deduplicate IP if hostname equals IP."""
if hostname == ip:
# Hostname resolution failed, don't repeat IP
return f"[NET] ARRIVED: {ip} [{vendor}] MAC:{mac}"
else:
# Hostname resolved successfully, show both
return f"[NET] ARRIVED: {hostname} ({ip}) [{vendor}] MAC:{mac}"
def format_departure(hostname: str, ip: str, vendor: str, mac: str, duration: str) -> str:
"""Format departure alert message. Deduplicate IP if hostname equals IP."""
if hostname == ip:
# Hostname resolution failed, don't repeat IP
return f"[NET] DEPARTED: {ip} [{vendor}] MAC:{mac} — present {duration}"
else:
# Hostname resolved successfully, show both
return f"[NET] DEPARTED: {hostname} ({ip}) [{vendor}] MAC:{mac} — present {duration}"
def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
"""Handle device arrival."""
with known_lock:
@@ -202,16 +394,16 @@ def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
return
vendor = lookup_oui(mac)
hostname = hostname or ip
known_devices[mac] = {
'ip': ip,
'hostname': hostname or ip,
'hostname': hostname,
'vendor': vendor,
'first_seen': time.time(),
'last_seen': time.time()
}
label = hostname or ip
msg = f"[NET] ARRIVED: {label} ({ip}) [{vendor or 'unknown'}] MAC:{mac}"
msg = format_arrival(hostname, ip, vendor or "unknown", mac)
logging.info(msg)
send_alert(msg)
@@ -224,7 +416,7 @@ def on_departure(mac: str) -> None:
dev = known_devices.pop(mac)
duration = fmt_duration(time.time() - dev['first_seen'])
msg = f"[NET] DEPARTED: {dev['hostname']} ({dev['ip']}) [{dev['vendor'] or 'unknown'}] MAC:{mac} — present {duration}"
msg = format_departure(dev['hostname'], dev['ip'], dev['vendor'] or "unknown", mac, duration)
logging.info(msg)
send_alert(msg)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Test suite for net_alerter.py — alert formatting fixes.
"""
import sys
from pathlib import Path
# Add parent dir to path so we can import net_alerter
sys.path.insert(0, str(Path(__file__).parent))
import net_alerter
def test_hostname_dedup_when_hostname_equals_ip():
"""Test that hostname is not repeated when DNS fails (hostname == IP)."""
# Simulate a device where hostname lookup returned the IP address itself
mac = "88:a2:9e:8e:f2:90"
ip = "10.0.0.0"
hostname = ip # This is what happens when reverse DNS fails
# Format the alert using internal logic
msg = net_alerter.format_arrival(hostname, ip, "Apple", mac)
# Should show IP only once
assert msg == "[NET] ARRIVED: 10.0.0.0 [Apple] MAC:88:a2:9e:8e:f2:90"
assert msg.count("10.0.0.0") == 1, "IP should appear exactly once"
def test_hostname_shown_when_different_from_ip():
"""Test that hostname is shown separately when it differs from IP."""
mac = "88:a2:9e:8e:f2:90"
ip = "10.0.0.0"
hostname = "mydevice.local"
# Format the alert
msg = net_alerter.format_arrival(hostname, ip, "Apple", mac)
# Should show both hostname and IP
assert msg == "[NET] ARRIVED: mydevice.local (10.0.0.0) [Apple] MAC:88:a2:9e:8e:f2:90"
assert "mydevice.local" in msg
assert "10.0.0.0" in msg
def test_oui_lookup_with_inline_dict():
"""Test that OUI lookup returns vendor for known MACs."""
# These are real OUI prefixes
test_cases = [
("88:a2:9e", "Apple"), # Apple
("00:1a:7d", "Apple"), # Apple
("18:5f:3f", "Apple"), # Apple
("a4:c3:f0", "Apple"), # Apple
("28:87:ba", "Google"), # Google
("54:27:58", "Google"), # Google
("34:15:13", "Amazon"), # Amazon
("b8:27:eb", "Raspberry Pi"), # Raspberry Pi
]
for mac_prefix, expected_vendor in test_cases:
vendor = net_alerter.lookup_oui(mac_prefix + ":00:00:00")
assert vendor == expected_vendor, f"Expected {expected_vendor} for {mac_prefix}, got {vendor}"
def test_oui_lookup_unknown_vendor():
"""Test that unknown OUI returns 'unknown'."""
vendor = net_alerter.lookup_oui("aa:bb:cc:dd:ee:ff")
assert vendor == "unknown", f"Expected 'unknown' for unknown OUI, got {vendor}"
def test_departure_format_dedup():
"""Test that departure message also deduplicates hostname and IP."""
mac = "88:a2:9e:8e:f2:90"
ip = "10.0.0.0"
hostname = ip
duration = "5m 30s"
msg = net_alerter.format_departure(hostname, ip, "Apple", mac, duration)
assert msg == "[NET] DEPARTED: 10.0.0.0 [Apple] MAC:88:a2:9e:8e:f2:90 — present 5m 30s"
assert msg.count("10.0.0.0") == 1
if __name__ == "__main__":
test_hostname_dedup_when_hostname_equals_ip()
print("✓ test_hostname_dedup_when_hostname_equals_ip")
test_hostname_shown_when_different_from_ip()
print("✓ test_hostname_shown_when_different_from_ip")
test_oui_lookup_with_inline_dict()
print("✓ test_oui_lookup_with_inline_dict")
test_oui_lookup_unknown_vendor()
print("✓ test_oui_lookup_unknown_vendor")
test_departure_format_dedup()
print("✓ test_departure_format_dedup")
print("\nAll tests passed!")