212 lines
9.6 KiB
Python
212 lines
9.6 KiB
Python
"""终端类型识别:手机 / 平板 / 电脑 / 电视 / 游戏机 / 摄像头 / 智能设备 / NAS / 网络设备。
|
|
|
|
依据(越靠前越可信):主机名 → MAC 厂商(IEEE OUI 公开数据)→ 最近用过的应用 → 随机 MAC(手机、平板的隐私地址)。
|
|
结果写进 hosts 表(dtype_auto / vendor),每小时刷新一次;用户手动指定的 dtype 优先。
|
|
"""
|
|
import csv
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from app.config import get_settings
|
|
|
|
URL = "https://standards-oui.ieee.org/oui/oui.csv"
|
|
MAX_AGE = 30 * 86400
|
|
TYPES = ("phone", "tablet", "computer", "tv", "console", "camera", "iot", "nas", "network", "unknown")
|
|
|
|
# 主机名规则:(正则, 类型),按顺序取第一条命中的
|
|
HOSTNAME_RULES = [
|
|
(r"ipad", "tablet"), (r"iphone", "phone"), (r"apple-?tv|appletv", "tv"),
|
|
(r"macbook|imac|mac-?mini|mac-?pro|mac-?studio", "computer"),
|
|
(r"\b(tab|pad)\b|matepad|galaxy-?tab|xiaomi-?pad|mipad", "tablet"),
|
|
(r"nintendo|switch|ps[345]\b|playstation|xbox|steam-?deck", "console"),
|
|
(r"mitv|mi-?box|\btv\b|-tv\b|tv-|android-?tv|chromecast|hisense|skyworth|letv|sony-?bravia|roku|fire-?tv", "tv"),
|
|
(r"camera|\bipc\b|ipcam|hikvision|dahua|ezviz|imou|xiaomi-?cam|chuangmi|isa-camera|c[0-9]{2,3}[a-z]?-", "camera"),
|
|
(r"synology|diskstation|\bds[0-9]{3,4}|qnap|truenas|unraid|\bnas\b|-nas\b|nas-|omv|proxmox|\bpve\b", "nas"),
|
|
(r"openwrt|lede|mikrotik|routeros|\bap\b|-ap\b|ap-|router|unifi|tplink|tp-link|ruijie|h3c|wifi-?ext|mesh", "network"),
|
|
(r"^esp[_-]|esp32|esp8266|tuya|yeelink|lumi-|mijia|miot|zhimi|roborock|dreame|deerma|xiaomi-.*-(plug|light|fan|heater)|shelly|sonoff|"
|
|
r"midea|gree|haier|aqara|broadlink|wemo|hue|smartthings|ewelink", "iot"),
|
|
(r"android|redmi|xiaomi|^mi-|oneplus|huawei|honor|oppo|vivo|realme|iqoo|galaxy|samsung-sm|sm-[a-z][0-9]|meizu|pixel|nokia", "phone"),
|
|
(r"desktop-|laptop-|^win-|windows|-pc\b|\bpc-|thinkpad|surface|ubuntu|debian|fedora|archlinux|manjaro|deepin|uos", "computer"),
|
|
]
|
|
_HOST_RE = [(re.compile(p, re.I), t) for p, t in HOSTNAME_RULES]
|
|
|
|
# 厂商关键字 → 类型(厂商只生产这一类设备时才放进来)
|
|
VENDOR_TYPES = [
|
|
(r"hikvision|dahua|ezviz|zhejiang uniview|imou|reolink|axis comm", "camera"),
|
|
(r"espressif|tuya|shelly|sonoff|itead|broadlink|lumi united|aqara|yeelight|roborock|dreame|ecovacs|midea|gree electric|haier|"
|
|
r"philips lighting|signify|tp-link.*(tapo|kasa)", "iot"),
|
|
(r"nintendo|sony interactive|microsoft.*xbox", "console"),
|
|
(r"synology|qnap|western digital|seagate", "nas"),
|
|
(r"mikrotik|routerboard|ubiquiti|tp-link|ruijie|h3c|new h3c|cisco|juniper|netgear|aruba|tenda|mercury|fiberhome|zte|"
|
|
r"shenzhen gongjin|openwrt", "network"),
|
|
(r"hisense|tcl|skyworth|konka|changhong|roku|sharp", "tv"),
|
|
]
|
|
_VENDOR_RE = [(re.compile(p, re.I), t) for p, t in VENDOR_TYPES]
|
|
|
|
# 用过的应用 → 类型线索:只放“这类设备才会用”的;智能设备另按“只用智能家居 / 摄像头类应用”判断(手机上的米家 App 也会访问同样的域名)
|
|
APP_HINTS = {"windows_update": "computer", "xbox": "console", "playstation": "console", "nintendo": "console"}
|
|
NEUTRAL_CATS = {"network", "system", "cdn", "proxy", "security", "ads", "web", "tls", "cn_net", "update", "unknown"} # 系统、DNS、未识别:判断时不计
|
|
|
|
|
|
# 厂商全名缩短:去掉公司后缀,常见的直接给短名
|
|
SHORT = [(r"apple", "Apple"), (r"xiaomi|beijing xiaomi", "Xiaomi"), (r"huawei", "Huawei"), (r"honor device", "Honor"),
|
|
(r"guangdong oppo|oppo", "OPPO"), (r"vivo mobile", "vivo"), (r"samsung", "Samsung"), (r"oneplus", "OnePlus"),
|
|
(r"realme", "realme"), (r"lenovo", "Lenovo"), (r"dell", "Dell"), (r"hewlett|hp inc", "HP"), (r"intel", "Intel"),
|
|
(r"microsoft", "Microsoft"), (r"google", "Google"), (r"amazon", "Amazon"), (r"sony", "Sony"),
|
|
(r"nintendo", "Nintendo"), (r"espressif", "Espressif"), (r"tuya", "Tuya"), (r"hikvision", "Hikvision"),
|
|
(r"dahua", "Dahua"), (r"tp-link", "TP-Link"), (r"mikrotik|routerboard", "MikroTik"), (r"synology", "Synology"),
|
|
(r"qnap", "QNAP"), (r"ubiquiti", "Ubiquiti"), (r"realtek", "Realtek"), (r"azurewave", "AzureWave"),
|
|
(r"liteon|lite-on", "Lite-On"), (r"murata", "Murata"), (r"hon hai|foxconn", "Foxconn")]
|
|
_SHORT_RE = [(re.compile(p, re.I), n) for p, n in SHORT]
|
|
|
|
|
|
def short_vendor(name: str) -> str:
|
|
for r, n in _SHORT_RE:
|
|
if r.search(name):
|
|
return n
|
|
s = re.sub(r"[,.]?\s*(co\.?,?\s*ltd\.?|ltd\.?|inc\.?|corp(oration)?\.?|limited|gmbh|s\.?a\.?|llc|company|technolog(y|ies)|"
|
|
r"electronics?|communications?|international)\b", "", name, flags=re.I)
|
|
return re.sub(r"\s+", " ", s).strip(" ,.") or name
|
|
|
|
|
|
def is_random_mac(mac: str | None) -> bool:
|
|
"""本地管理地址(第二个十六进制位是 2/6/A/E):手机、平板的隐私 MAC 基本都是这种"""
|
|
return bool(mac) and len(mac) >= 2 and mac[1].upper() in "26AE"
|
|
|
|
|
|
class OuiDb:
|
|
def __init__(self):
|
|
self.vendors: dict[str, str] = {}
|
|
self.updated = 0
|
|
self.error = ""
|
|
self.lock = threading.Lock()
|
|
|
|
@staticmethod
|
|
def path() -> str:
|
|
return os.path.join(os.environ.get("DATA_DIR", "data"), "oui.csv")
|
|
|
|
def load(self, path: str | None = None) -> bool:
|
|
path = path or self.path()
|
|
if not os.path.exists(path):
|
|
return False
|
|
m = {}
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for row in csv.reader(f):
|
|
if len(row) >= 3 and len(row[1]) == 6 and row[0] == "MA-L":
|
|
m[row[1].upper()] = row[2].strip()
|
|
with self.lock:
|
|
self.vendors = m
|
|
self.updated = int(os.path.getmtime(path))
|
|
return bool(m)
|
|
|
|
def update(self, force: bool = False) -> bool:
|
|
path = self.path()
|
|
if not force and os.path.exists(path) and time.time() - os.path.getmtime(path) < MAX_AGE:
|
|
if not self.vendors:
|
|
self.load(path)
|
|
return False
|
|
tmp = path + ".part"
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
r = httpx.get(URL, timeout=120, follow_redirects=True, headers={"User-Agent": "ros-dpi"})
|
|
r.raise_for_status()
|
|
if b"Registry,Assignment" not in r.content[:200]:
|
|
raise ValueError("不是 IEEE OUI CSV")
|
|
with open(tmp, "wb") as f:
|
|
f.write(r.content)
|
|
os.replace(tmp, path)
|
|
self.error = ""
|
|
except Exception as e: # noqa: BLE001 没有外网时保留旧数据
|
|
self.error = f"{type(e).__name__}: {str(e)[:160]}"
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
if not self.vendors:
|
|
self.load(path)
|
|
return False
|
|
return self.load(path)
|
|
|
|
def lookup(self, mac: str | None) -> str:
|
|
if not mac or is_random_mac(mac):
|
|
return ""
|
|
return self.vendors.get(mac.replace(":", "").replace("-", "").upper()[:6], "")
|
|
|
|
def status(self) -> dict:
|
|
return {"rows": len(self.vendors), "updated": self.updated, "error": self.error}
|
|
|
|
|
|
oui = OuiDb()
|
|
|
|
|
|
def _by_apps(apps: set[str]) -> str | None:
|
|
from app.engine import engine
|
|
cl = engine.classifier
|
|
hints = {APP_HINTS[a] for a in apps if a in APP_HINTS}
|
|
if len(hints) == 1:
|
|
return hints.pop()
|
|
cats = {cl.app_info(a).category for a in apps}
|
|
if cats - NEUTRAL_CATS and cats - NEUTRAL_CATS <= {"iot", "camera"}:
|
|
return "camera" if "camera" in cats else "iot"
|
|
return None
|
|
|
|
|
|
def classify(mac: str | None, hostname: str | None, apps: set[str] | None = None, vendor_full: str | None = None) -> tuple[str, str, str]:
|
|
"""返回 (类型, 厂商短名, 依据:hostname / vendor / apps / random / "")"""
|
|
vendor_full = vendor_full if vendor_full is not None else oui.lookup(mac)
|
|
vendor = short_vendor(vendor_full) if vendor_full else ""
|
|
name = (hostname or "").strip()
|
|
if name:
|
|
for r, t in _HOST_RE:
|
|
if r.search(name):
|
|
return t, vendor, "hostname"
|
|
if vendor_full:
|
|
for r, t in _VENDOR_RE:
|
|
if r.search(vendor_full):
|
|
return t, vendor, "vendor"
|
|
if apps:
|
|
t = _by_apps(apps)
|
|
if t:
|
|
return t, vendor, "apps"
|
|
if vendor in ("Apple", "Xiaomi", "Huawei", "Honor", "OPPO", "vivo", "Samsung", "OnePlus", "realme"):
|
|
return "phone", vendor, "vendor"
|
|
if is_random_mac(mac):
|
|
return "phone", vendor, "random"
|
|
return "unknown", vendor, ""
|
|
|
|
|
|
def refresh_all():
|
|
"""按最近 7 天用过的应用重新识别所有终端,结果写回 hosts 表"""
|
|
from sqlalchemy import select
|
|
|
|
from app import database, timeutil
|
|
from app.models import Host, TrafficDay
|
|
if not database.is_configured():
|
|
return 0
|
|
since = timeutil.range_bounds("last7")[0]
|
|
n = 0
|
|
with database.session() as db:
|
|
apps: dict[str, set] = {}
|
|
for host, app in db.execute(select(TrafficDay.host, TrafficDay.app).where(TrafficDay.ts >= since)
|
|
.group_by(TrafficDay.host, TrafficDay.app)):
|
|
apps.setdefault(host, set()).add(app)
|
|
for h in db.query(Host):
|
|
t, v, _by = classify(h.mac, h.hostname, apps.get(h.key))
|
|
v = (v or "")[:64] # hosts.vendor 是 64:个别 IEEE 机构名更长,超长会让整次刷新回滚
|
|
if (h.dtype_auto, h.vendor) != (t, v or None):
|
|
h.dtype_auto, h.vendor = t, v or None
|
|
n += 1
|
|
db.commit()
|
|
return n
|
|
|
|
|
|
def startup():
|
|
if get_settings().oui_db:
|
|
oui.update()
|
|
else:
|
|
oui.load()
|
|
refresh_all()
|
|
|