169 lines
5.2 KiB
Python
169 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Identify device models via HTTPS for each IP in cpes.txt.
|
|
|
|
For Cambium ePMP devices: reads /js/cambium_sku.js to get the SKU number,
|
|
maps it to a model name. For Ubiquiti devices: tries API endpoints.
|
|
|
|
Usage:
|
|
python3 scripts/identify_models.py [cpes.txt]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import ssl
|
|
import sys
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from typing import Any
|
|
|
|
# Cambium ePMP SKU → model name
|
|
CAMBIUM_SKU_MAP: dict[int, str] = {
|
|
8: "ePMP Force 110",
|
|
28: "ePMP Force 300",
|
|
35: "ePMP Force 180",
|
|
55: "ePMP Force 200",
|
|
}
|
|
|
|
_CTX = ssl.create_default_context()
|
|
_CTX.check_hostname = False
|
|
_CTX.verify_mode = ssl.CERT_NONE
|
|
|
|
|
|
def http_get(url: str, timeout: float = 8) -> str | None:
|
|
try:
|
|
req = urllib.request.Request(url)
|
|
resp = urllib.request.urlopen(req, context=_CTX, timeout=timeout)
|
|
return resp.read().decode(errors="replace")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def identify_model(ip: str) -> str:
|
|
# Try both HTTPS and HTTP
|
|
for proto in ("https", "http"):
|
|
body = http_get(f"{proto}://{ip}/js/cambium_sku.js")
|
|
if body:
|
|
break
|
|
|
|
# 1. Try Cambium ePMP SKU file
|
|
if body:
|
|
m = re.search(r"window\.sku\s*=\s*(\d+)", body)
|
|
if m:
|
|
sku = int(m.group(1))
|
|
fw_m = re.search(r"window\.cambiumFWVersion\s*=\s*['\"]([^'\"]+)", body)
|
|
fw = f" (fw={fw_m.group(1)})" if fw_m else ""
|
|
model = CAMBIUM_SKU_MAP.get(sku, f"Cambium SKU {sku}")
|
|
return f"{model}{fw}"
|
|
|
|
# 2. Try Ubiquiti airOS 8.x API
|
|
body = http_get(f"https://{ip}/api/v1.0/status")
|
|
if body:
|
|
try:
|
|
data = json.loads(body)
|
|
host = data.get("host", {})
|
|
model = host.get("deviceModel", "")
|
|
fw = host.get("firmwareVersion", "")
|
|
if model:
|
|
return f"{model} (fw={fw})" if fw else model
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# 3. Try Ubiquiti status.cgi
|
|
body = http_get(f"https://{ip}/status.cgi")
|
|
if body:
|
|
# Look for model in the XML/HTML
|
|
for pattern in [
|
|
r"<title>([^<]+)</title>",
|
|
r"model.*?([A-Za-z0-9\-\. ]{5,50})",
|
|
]:
|
|
m = re.search(pattern, body, re.IGNORECASE)
|
|
if m and m.group(1).strip() not in ("ePMP", "Ubiquiti", "Login"):
|
|
return m.group(1).strip()
|
|
|
|
# 4. Fallback: check if it's Cambium at all
|
|
body = http_get(f"https://{ip}/")
|
|
if body:
|
|
if "ePMP" in body:
|
|
return "Cambium ePMP (unknown model)"
|
|
if "Ubiquiti" in body or "airOS" in body or "airMAX" in body:
|
|
return "Ubiquiti (unknown model)"
|
|
# Try to extract title
|
|
m = re.search(r"<title>([^<]+)</title>", body)
|
|
if m:
|
|
return f"Unknown ({m.group(1).strip()})"
|
|
|
|
return "no HTTPS response"
|
|
|
|
|
|
def parse_cpes(path: str) -> list[dict[str, str]]:
|
|
entries: list[dict[str, str]] = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
# Extract fields positionally:
|
|
# MAC is first 17 chars (AA:BB:CC:DD:EE:FF)
|
|
mac_m = re.match(r"([0-9A-Fa-f:]{17})", line)
|
|
if not mac_m:
|
|
continue
|
|
mac = mac_m.group(1)
|
|
# IP is last field
|
|
ip_m = re.search(r"(\d+\.\d+\.\d+\.\d+)$", line)
|
|
if not ip_m:
|
|
continue
|
|
ip = ip_m.group(1)
|
|
# Account number: # followed by digits
|
|
acct_m = re.search(r"#(\d+)", line)
|
|
readable = acct_m.group(1) if acct_m else ""
|
|
entries.append({
|
|
"mac": mac,
|
|
"readable": readable,
|
|
"name": "", # filled in below
|
|
"ip": ip,
|
|
})
|
|
return entries
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("input", nargs="?", default="cpes.txt")
|
|
ap.add_argument("-w", "--workers", type=int, default=20)
|
|
args = ap.parse_args()
|
|
|
|
if not os.path.exists(args.input):
|
|
sys.exit(f"{args.input} not found")
|
|
|
|
entries = parse_cpes(args.input)
|
|
print(f"Identifying {len(entries)} devices via HTTPS...", file=sys.stderr)
|
|
|
|
models: dict[str, str] = {}
|
|
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
|
futs = {ex.submit(identify_model, e["ip"]): e for e in entries}
|
|
for i, f in enumerate(as_completed(futs), 1):
|
|
e = futs[f]
|
|
models[e["ip"]] = f.result()
|
|
if i % 20 == 0:
|
|
print(f" {i}/{len(entries)}", file=sys.stderr)
|
|
print(f" {len(entries)}/{len(entries)} done", file=sys.stderr)
|
|
|
|
# Output sorted by model
|
|
entries.sort(key=lambda e: models[e["ip"]].lower())
|
|
|
|
# Show grouped counts
|
|
groups: dict[str, list] = {}
|
|
for e in entries:
|
|
m = models[e["ip"]]
|
|
groups.setdefault(m, []).append(e)
|
|
|
|
for model, devs in sorted(groups.items(), key=lambda x: x[0].lower()):
|
|
print(f"\n## {model} ({len(devs)} devices)")
|
|
for d in devs:
|
|
print(f" {d['mac']:17} #{d['readable']:5} {d['name']:24} {d['ip']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|