#!/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"([^<]+)", 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"([^<]+)", 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()