network/scripts/cpe_mac_audit.py
2026-07-19 14:42:02 -05:00

774 lines
31 KiB
Python
Executable file

#!/usr/bin/env python3
"""Audit CPE MAC addresses: compare Gaiia assignments against live DHCP leases.
For every active (paying) customer in Gaiia, find their assigned CPE MAC(s),
then cross-reference against live DHCP leases on all tower routers. Flags:
MAC_MISMATCH -- DHCP host-name matches a Gaiia account, but the lease MAC
belongs to a *different* account in Gaiia (or no account).
MAC_UNKNOWN -- DHCP lease MAC not found in Gaiia inventory at all, but
host-name fuzzy-matches an active account (inventory gap).
GAIIA_ORPHAN -- Active customer's CPE MAC is in Gaiia but NOT in any DHCP
lease (device offline / replaced / MAC wrong in Gaiia).
Usage:
GAIIA_KEY=... ./scripts/cpe_mac_audit.py
GAIIA_KEY=... ./scripts/cpe_mac_audit.py --routers 982,core
GAIIA_KEY=... ./scripts/cpe_mac_audit.py --json > audit.json
GAIIA_KEY=... ./scripts/cpe_mac_audit.py --verbose # also show OK entries
"""
from __future__ import annotations
import argparse
import json
import os
import ipaddress
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "gaiia", "src"))
from gaiia import GaiiaClient, GaiiaTransportError # noqa: E402
TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"]
MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool")
CPE_NET = ipaddress.IPv4Network("10.10.0.0/16")
PAYING = {"ACTIVE", "TRIAL"}
# Indoor routers / WiFi APs — not CPE radios, skip them.
_INDOOR_PATTERNS = ("AC1200", "AC1000", "Vilo", "hc220", "GWN")
# MAC OUI → vendor / device type hints for "needs creation" reporting.
# First 3 bytes of the MAC (upper case, colon-separated).
_OUI_HINTS: dict[str, str] = {}
for _prefix, _hint in [
# Ubiquiti
("00:04:56", "Ubiquiti (legacy)"),
("00:27:22", "Ubiquiti (legacy)"),
("00:01:9F", "Ubiquiti AC1000/AC1200"),
("04:18:D6", "Ubiquiti Loco M5"),
("24:A4:3C", "Ubiquiti Loco M5"),
("24:2F:D0", "Ubiquiti AC1200-Mesh"),
("24:5A:4C", "Ubiquiti"),
("5C:62:88", "Ubiquiti"),
("68:72:51", "Ubiquiti"),
("68:D7:9A", "Ubiquiti Power Beam AC 2.4"),
("70:A7:41", "Ubiquiti Nanobeam AC 2.4"),
("74:83:C2", "Ubiquiti Loco M5"),
("74:AC:B9", "Ubiquiti PowerBeam M5"),
("74:FE:CE", "Ubiquiti AC1200-Mesh"),
("78:45:58", "Ubiquiti PowerBeam AC Gen2"),
("78:8A:20", "Ubiquiti Loco M5"),
("80:2A:A8", "Ubiquiti Loco M5"),
("8C:90:2D", "Ubiquiti AC1200-Mesh"),
("98:03:8E", "Ubiquiti AC1200-Mesh"),
("98:25:4A", "Ubiquiti AC1200-Mesh"),
("A8:5E:45", "Ubiquiti"),
("B0:19:21", "Ubiquiti AC1200-Mesh"),
("B4:FB:E4", "Ubiquiti Loco M5"),
("CC:2D:E0", "Ubiquiti wAP 60G"),
("D0:21:F9", "Ubiquiti"),
("D8:44:89", "Ubiquiti AC1200-Mesh"),
("DC:9F:DB", "Ubiquiti"),
("E0:63:DA", "Ubiquiti PowerBeam M5 / Loco M5"),
("E4:38:83", "Ubiquiti PowerBeam / LiteBeam"),
("E8:DA:00", "Ubiquiti"),
("EC:74:D7", "Ubiquiti GWN"),
("F0:9F:C2", "Ubiquiti"),
("F0:A7:31", "Ubiquiti AC1200-Mesh"),
("F4:92:BF", "Ubiquiti Loco M5 / PowerBeam"),
("F4:E2:C6", "Ubiquiti PowerBeam AC Gen2"),
("FC:EC:DA", "Ubiquiti LiteBeam / Loco"),
# Cambium
("58:C1:7A", "Cambium ePMP"),
# MikroTik
("18:E8:29", "MikroTik"),
("00:15:6D", "Ubiquiti NanoBridge M900"),
]:
_OUI_HINTS[_prefix] = _hint
def guess_device(mac: str) -> str:
"""Best-effort device type from MAC OUI prefix."""
n = fmt_mac(mac)
if not n:
return "unknown"
prefix = n[:8] # first 3 bytes: AA:BB:CC
return _OUI_HINTS.get(prefix, n[:8])
# ---------- helpers ----------
def norm_mac(s: str) -> str:
return re.sub(r"[^0-9a-f]", "", (s or "").lower())
def fmt_mac(s: str) -> str:
n = norm_mac(s)
return ":".join(n[i:i+2] for i in range(0, 12, 2)).upper() if len(n) == 12 else s
def name_tokens(s: str) -> set[str]:
return {w for w in re.split(r"[^a-z0-9]+", s.lower()) if len(w) >= 3}
def parse_kv_line(line: str) -> dict[str, str]:
kv: dict[str, str] = {}
for part in re.split(r" +", line.strip()):
if "=" in part:
k, v = part.split("=", 1)
kv[k] = v
return kv
# ---------- MikroTik ----------
def fetch_dhcp_leases(router: str) -> tuple[list[dict[str, Any]], bool]:
"""Return (leases, reachable). reachable=False means the router timed out or errored."""
p = subprocess.run(
[MTT, "api", router, "/ip/dhcp-server/lease/print"],
capture_output=True, text=True, timeout=90,
cwd=os.path.join(REPO, "mikrotik-tool"),
)
if p.returncode != 0:
print(f"!! {router}: {p.stderr.strip()[:200]}", file=sys.stderr)
return [], False
out: list[dict[str, Any]] = []
for line in p.stdout.splitlines():
if not line.startswith(".id="):
continue
kv = parse_kv_line(line)
if kv.get("status") != "bound":
continue
mac = kv.get("active-mac-address") or kv.get("mac-address") or ""
if not mac:
continue
addr = kv.get("active-address") or kv.get("address") or ""
if addr:
try:
if ipaddress.IPv4Address(addr) not in CPE_NET:
continue
except ValueError:
continue
out.append({
"router": router,
"mac": norm_mac(mac),
"mac_raw": mac,
"address": addr,
"host": kv.get("host-name", ""),
})
return out, True
# ---------- Gaiia ----------
def _retry_query(client: GaiiaClient, query: str, variables: dict | None = None, max_tries: int = 5):
"""Run a Gaiia query with retries on transport errors."""
for attempt in range(1, max_tries + 1):
try:
return client.query(query, variables)
except GaiiaTransportError:
if attempt == max_tries:
raise
delay = min(2 ** attempt, 30)
print(f" (transport error, retrying in {delay}s — attempt {attempt}/{max_tries})",
file=sys.stderr)
time.sleep(delay)
def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str):
cur: str | None = None
while True:
d = _retry_query(client, q_first) if cur is None else _retry_query(client, q_with, {"after": cur})
node = d
for part in path.split("."):
node = node[part]
for n in node.get("nodes") or []:
yield n
pi = node.get("pageInfo") or {}
if not pi.get("hasNextPage"):
return
cur = pi.get("endCursor")
if not cur:
return
def fetch_gaiia() -> tuple[dict[str, dict], dict[str, dict], dict[str, dict]]:
"""Return (accounts_by_id, mac_to_cpe, mac_to_any).
mac_to_cpe: outdoor CPE radios only (used for the main audit).
mac_to_any: ALL inventory items including indoor routers (used to resolve unknown MACs)."""
accounts: dict[str, dict] = {}
q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){
nodes{ id readableId displayName status{ name }
billingSubscriptions(first:50){ nodes{ status } } }
pageInfo{ hasNextPage endCursor } } }"""
q_acct_first = """query{ accounts(first:100){
nodes{ id readableId displayName status{ name }
billingSubscriptions(first:50){ nodes{ status } } }
pageInfo{ hasNextPage endCursor } } }"""
mac_to_cpe: dict[str, dict] = {}
mac_to_any: dict[str, dict] = {}
q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){
nodes{ id model{ name }
fields{ nodes{ data modelField{ name } } }
assignation{ assigneeType assignee{ __typename ... on Account{ id } } }
}
pageInfo{ hasNextPage endCursor } } }"""
q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "")
def _add_macs(macs, item_id, model, acct_id, assignee_type, target_map):
for m in macs:
if m in target_map and target_map[m].get("account_id"):
continue
target_map[m] = {
"item_id": item_id,
"model": model,
"account_id": acct_id,
"assignee_type": assignee_type,
}
with GaiiaClient(timezone="America/Chicago") as g:
for a in paginate(g, q_acct_with, q_acct_first, "accounts"):
subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])]
accounts[a["id"]] = {
"id": a["id"],
"readable": a["readableId"],
"name": a["displayName"],
"status": a["status"]["name"],
"paying": any(s in PAYING for s in subs),
"subs": subs,
}
for it in paginate(g, q_inv_with, q_inv_first, "inventoryItems"):
model = (it.get("model") or {}).get("name", "")
is_indoor = any(p.lower() in model.lower() for p in _INDOOR_PATTERNS)
macs = []
for f in ((it.get("fields") or {}).get("nodes") or []):
mf = f.get("modelField") or {}
if "mac" in mf.get("name", "").lower() and f.get("data"):
m = norm_mac(f["data"])
if len(m) == 12:
macs.append(m)
assn = it.get("assignation") or {}
assignee = assn.get("assignee") or {}
assignee_type = assn.get("assigneeType") or None # None = truly unassigned
acct_id = assignee["id"] if assignee.get("__typename") == "Account" else None
_add_macs(macs, it["id"], model, acct_id, assignee_type, mac_to_any)
if not is_indoor:
_add_macs(macs, it["id"], model, acct_id, assignee_type, mac_to_cpe)
return accounts, mac_to_cpe, mac_to_any
# ---------- matching ----------
def fuzzy_match_host(host: str, accounts: dict[str, dict]) -> dict | None:
"""Match a DHCP host-name to a Gaiia account by displayName tokens.
Requires >=2 token overlap to reduce false positives."""
if not host:
return None
htoks = name_tokens(host)
if len(htoks) < 2:
return None
best, best_score = None, 0
for aid, a in accounts.items():
atoks = name_tokens(a["name"])
score = len(htoks & atoks)
if score >= 2 and score > best_score:
best_score = score
best = {**a, "id": aid, "match_score": score}
return best
# ---------- audit ----------
def audit(leases, accounts, mac_to_cpe, mac_to_any):
"""Compare DHCP leases against Gaiia assignments. Returns categorized results."""
paying = {aid: a for aid, a in accounts.items() if a["paying"]}
mismatches: list[dict] = [] # MAC belongs to different account than host suggests
unknowns: list[dict] = [] # MAC not in mac_to_cpe, but host matches paying account
unassigned: list[dict] = [] # MAC in Gaiia but not assigned to any account
ok_entries: list[dict] = [] # everything matches
unmatched_host: list[dict] = [] # MAC in Gaiia, assigned, but host doesn't match any account
# MAC_UNKNOWN sub-categories, resolved against mac_to_any (full inventory)
fixable: list[dict] = [] # item exists, unassigned → can auto-assign
exists_assigned: list[dict] = [] # item exists, assigned to a different account
needs_creation: list[dict] = [] # not in Gaiia inventory at all
# Track which Gaiia MACs we've seen in DHCP
seen_gaiia_macs: set[str] = set()
for lease in leases:
mac = lease["mac"]
gaiia = mac_to_cpe.get(mac)
if gaiia:
seen_gaiia_macs.add(mac)
gaiia_acct_id = gaiia.get("account_id")
gaiia_acct = accounts.get(gaiia_acct_id) if gaiia_acct_id else None
gaiia_has_assignee = bool(gaiia.get("assignee_type"))
else:
gaiia_acct_id = None
gaiia_acct = None
gaiia_has_assignee = False
host_match = fuzzy_match_host(lease["host"], accounts)
entry = {
"lease": lease,
"gaiia_mac_owner": gaiia_acct,
"host_match": host_match,
}
if gaiia_acct and host_match:
if gaiia_acct["id"] == host_match["id"]:
ok_entries.append(entry)
else:
mismatches.append(entry)
elif gaiia_acct and not host_match:
unmatched_host.append(entry)
elif not gaiia_acct and host_match:
if host_match["paying"]:
unknowns.append(entry)
# Resolve against full inventory (including indoor routers)
any_item = mac_to_any.get(mac)
if not any_item:
# MAC genuinely absent from Gaiia inventory.
needs_creation.append(entry)
elif any_item.get("account_id"):
# Exists but assigned to a *different* account.
owner = accounts.get(any_item["account_id"])
exists_assigned.append({
**entry,
"existing_item": any_item,
"existing_owner_name": owner["name"] if owner else None,
})
else:
# Exists but not on any account: truly unassigned OR parked at an
# inventory location/site. Either way, reassign to matched account.
fixable.append({**entry, "existing_item": any_item})
elif gaiia and not gaiia_acct and not gaiia_has_assignee:
unassigned.append(entry)
# else: lease with no Gaiia MAC match and no host match → truly unknown, skip
# Items assigned to sites/locations are not flagged (silently correct).
# Gaiia orphans: paying customers whose CPE MACs are NOT in any DHCP lease
orphans: list[dict] = []
for mac, info in mac_to_cpe.items():
acct_id = info.get("account_id")
if not acct_id or acct_id not in paying:
continue
if mac not in seen_gaiia_macs:
orphans.append({
"mac": mac,
"mac_formatted": fmt_mac(mac),
"account": paying[acct_id],
"item_id": info["item_id"],
"model": info["model"],
})
return {
"mismatches": mismatches,
"unknowns": unknowns,
"fixable": fixable,
"exists_assigned": exists_assigned,
"needs_creation": needs_creation,
"unassigned": unassigned,
"unmatched_host": unmatched_host,
"orphans": orphans,
"ok": ok_entries,
"mac_to_any": mac_to_any,
}
# ---------- output ----------
def print_report(results, verbose=False):
m = results["mismatches"]
u = results["unknowns"]
fixable = results["fixable"]
exists_assigned = results["exists_assigned"]
needs_creation = results["needs_creation"]
na = results["unassigned"]
uh = results["unmatched_host"]
orph = results["orphans"]
stale = results.get("stale_orphans", [])
ok = results["ok"]
print(f"\n=== CPE MAC AUDIT ===")
print(f" {len(ok)} OK | {len(m)} MAC_MISMATCH | {len(u)} MAC_UNKNOWN | "
f"{len(orph)} GAIIA_ORPHAN | {len(stale)} STALE")
print(f" ({len(fixable)} fixable | {len(exists_assigned)} conflict | "
f"{len(needs_creation)} needs-creation)")
print(f" ({len(na)} unassigned | {len(uh)} unmatched host)")
if m:
print(f"\n--- {len(m)} MAC MISMATCH (DHCP host-name matches account A, "
f"but lease MAC belongs to account B) ---")
for e in sorted(m, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
g_owner = e["gaiia_mac_owner"]
h_match = e["host_match"]
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r}")
print(f" -> host suggests: #{h_match['readable']} {h_match['name']!r} "
f"(paying={h_match['paying']})")
print(f" -> MAC belongs to: #{g_owner['readable']} {g_owner['name']!r} "
f"(paying={g_owner['paying']})")
if fixable:
print(f"\n--- {len(fixable)} FIXABLE (MAC exists in Gaiia — unassigned or parked "
f"at a location — auto-fix with --fix) ---")
for e in sorted(fixable, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
h = e["host_match"]
item = e["existing_item"]
cur = item.get("assignee_type") or "UNASSIGNED"
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r}")
print(f" -> assign to: #{h['readable']} {h['name']!r} "
f"item={item['item_id']} model={item['model']} currently={cur}")
if exists_assigned:
print(f"\n--- {len(exists_assigned)} CONFLICT (MAC exists in Gaiia but assigned to different account) ---")
for e in sorted(exists_assigned, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
h = e["host_match"]
item = e["existing_item"]
owner_name = e.get("existing_owner_name") or "unassigned"
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r}")
print(f" -> host suggests: #{h['readable']} {h['name']!r}")
print(f" -> item {item['item_id']} ({item['model']}) already assigned to: {owner_name!r}")
if needs_creation:
print(f"\n--- {len(needs_creation)} NEEDS CREATION (MAC not in Gaiia — "
f"add device and assign to account) ---")
for e in sorted(needs_creation, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
h = e["host_match"]
dev = guess_device(l["mac"])
print(f" router={l['router']:9} ip={l['address']:16} "
f"mac={fmt_mac(l['mac']):17} device={dev}")
print(f" account=#{h['readable']} name={h['name']!r} "
f"host={l['host']!r}")
if orph:
print(f"\n--- {len(orph)} GAIIA ORPHAN (not in DHCP, tower may be unreachable) ---")
for e in sorted(orph, key=lambda x: x["account"]["name"]):
a = e["account"]
print(f" {e['mac_formatted']:17} #{a['readable']:6} {a['name']!r} "
f"model={e['model']} item={e['item_id']}")
if stale:
print(f"\n--- {len(stale)} STALE (on reachable tower but not in DHCP — "
f"unassign with --fix) ---")
for e in sorted(stale, key=lambda x: x["account"]["name"]):
a = e["account"]
print(f" {e['mac_formatted']:17} #{a['readable']:6} {a['name']!r} "
f"model={e['model']} item={e['item_id']}")
if na:
print(f"\n--- {len(na)} UNASSIGNED (MAC in Gaiia inventory but not assigned to any account) ---")
for e in sorted(na, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r}")
if uh:
print(f"\n--- {len(uh)} UNMATCHED HOST (MAC assigned in Gaiia, host-name doesn't match any account) ---")
for e in sorted(uh, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
g = e["gaiia_mac_owner"]
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r}")
print(f" -> MAC belongs to: #{g['readable']} {g['name']!r} (paying={g['paying']})")
if verbose and ok:
print(f"\n--- {len(ok)} OK (MAC matches expected account) ---")
for e in sorted(ok, key=lambda x: (x["lease"]["router"], x["lease"]["address"])):
l = e["lease"]
a = e["gaiia_mac_owner"]
print(f" {l['router']:9} {l['address']:16} {fmt_mac(l['mac']):17} "
f"host={l['host']!r} acct=#{a['readable']} {a['name']!r}")
if not any([m, u, orph, stale, na, uh]):
print("\nNo issues found.")
def json_report(results):
def strip(obj):
if isinstance(obj, dict):
return {k: strip(v) for k, v in obj.items()}
if isinstance(obj, list):
return [strip(v) for v in obj]
return obj
out = {
"summary": {
"ok": len(results["ok"]),
"mac_mismatch": len(results["mismatches"]),
"mac_unknown": len(results["unknowns"]),
"fixable": len(results["fixable"]),
"exists_assigned": len(results["exists_assigned"]),
"needs_creation": len(results["needs_creation"]),
"gaiia_orphan": len(results["orphans"]),
"unassigned": len(results["unassigned"]),
"unmatched_host": len(results["unmatched_host"]),
},
"mismatches": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
"gaiia_mac_owner": e["gaiia_mac_owner"],
"host_match": e["host_match"],
}
for e in results["mismatches"]
],
"fixable": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
"host_match": e["host_match"],
"existing_item": e["existing_item"],
}
for e in results["fixable"]
],
"exists_assigned": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
"host_match": e["host_match"],
"existing_item": e["existing_item"],
"existing_owner_name": e.get("existing_owner_name"),
}
for e in results["exists_assigned"]
],
"needs_creation": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
"host_match": e["host_match"],
"device_guess": guess_device(e["lease"]["mac"]),
}
for e in results["needs_creation"]
],
"orphans": results["orphans"],
"unassigned": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
}
for e in results["unassigned"]
],
"unmatched_host": [
{
"lease": {**e["lease"], "mac": fmt_mac(e["lease"]["mac"])},
"gaiia_mac_owner": e["gaiia_mac_owner"],
}
for e in results["unmatched_host"]
],
}
json.dump(strip(out), sys.stdout, indent=2, default=str)
# ---------- main ----------
def do_fixes(client, fixable):
"""Call assignInventoryItem for each fixable entry."""
mut = """mutation M($i: AssignInventoryItemInput!) {
assignInventoryItem(input: $i) {
errors { code message }
}
}"""
ok = 0
for e in fixable:
h = e["host_match"]
item = e["existing_item"]
l = e["lease"]
try:
client.mutate(mut, "assignInventoryItem",
{"i": {"inventoryItemId": item["item_id"],
"assigneeType": "ACCOUNT",
"assigneeId": h["id"]}})
print(f" assigned {item['item_id']} ({item['model']}) -> #{h['readable']} "
f"{h['name']!r} [MAC {fmt_mac(l['mac'])}]", file=sys.stderr)
ok += 1
except Exception as exc:
print(f" FAILED {item['item_id']}: {exc}", file=sys.stderr)
return ok
def do_reassigns(client, conflicts):
"""Reassign inventory items from the wrong account to the matched account."""
mut = """mutation M($i: AssignInventoryItemInput!) {
assignInventoryItem(input: $i) {
errors { code message }
}
}"""
ok = 0
for e in conflicts:
h = e["host_match"]
item = e["existing_item"]
l = e["lease"]
old = e.get("existing_owner_name") or "unknown"
try:
client.mutate(mut, "assignInventoryItem",
{"i": {"inventoryItemId": item["item_id"],
"assigneeType": "ACCOUNT",
"assigneeId": h["id"]}})
print(f" reassigned {item['item_id']} ({item['model']}) "
f"from {old!r} -> #{h['readable']} {h['name']!r} "
f"[MAC {fmt_mac(l['mac'])}]", file=sys.stderr)
ok += 1
except Exception as exc:
print(f" FAILED {item['item_id']}: {exc}", file=sys.stderr)
return ok
def do_unassigns(client, stale_orphans, leases, accounts):
"""Reassign stale CPEs to their tower's inventory location."""
# Fetch all inventory locations
locs = {}
q_loc = """query { inventoryLocations(first: 100) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}}"""
r = client.query(q_loc)
for loc in (r.get("inventoryLocations") or {}).get("nodes") or []:
locs[loc["name"].lower()] = loc["id"]
# Map account ID -> router from DHCP leases
acct_to_router: dict[str, str] = {}
for l in leases:
host_match = fuzzy_match_host(l["host"], accounts)
if host_match and host_match["paying"]:
if host_match["id"] not in acct_to_router:
acct_to_router[host_match["id"]] = l["router"]
# Map router -> location (fuzzy: router name should match location name)
# Known mappings from the fleet
ROUTER_TO_LOC: dict[str, str] = {
"verona": "verona", "climax": "climax", "culleoka": "culleoka",
"newhope": "newhope", "lowry": "lowrycrossing", "982": "982",
"494": "494", "core": "380",
}
mut = """mutation M($i: AssignInventoryItemInput!) {
assignInventoryItem(input: $i) {
errors { code message }
}
}"""
ok = 0
for o in stale_orphans:
acct_id = o["account"]["id"]
router = acct_to_router.get(acct_id)
loc_key = ROUTER_TO_LOC.get(router) if router else None
loc_id = locs.get(loc_key) if loc_key else None
if not loc_id:
print(f" SKIP {o['item_id']} ({o['model']}): "
f"can't determine location (router={router})",
file=sys.stderr)
continue
try:
client.mutate(mut, "assignInventoryItem",
{"i": {"inventoryItemId": o["item_id"],
"assigneeType": "INVENTORY_LOCATION",
"assigneeId": loc_id}})
a = o["account"]
print(f" unassigned {o['item_id']} ({o['model']}) from #{a['readable']} "
f"{a['name']!r} -> {loc_key}",
file=sys.stderr)
ok += 1
except Exception as exc:
print(f" FAILED {o['item_id']}: {exc}", file=sys.stderr)
return ok
def main():
ap = argparse.ArgumentParser(description="Audit CPE MACs: Gaiia vs live DHCP leases")
ap.add_argument("--routers", help="comma-separated subset of tower routers")
ap.add_argument("--json", action="store_true", help="emit machine-readable JSON")
ap.add_argument("--verbose", "-v", action="store_true", help="also show OK entries")
ap.add_argument("--fix", action="store_true",
help="auto-assign unassigned inventory items to matched accounts")
args = ap.parse_args()
if not os.environ.get("GAIIA_KEY"):
sys.exit("GAIIA_KEY not set")
routers = args.routers.split(",") if args.routers else TOWER_ROUTERS
print("Fetching Gaiia accounts + inventory...", file=sys.stderr)
accounts, mac_to_cpe, mac_to_any = fetch_gaiia()
paying = sum(1 for a in accounts.values() if a["paying"])
cpe_assigned = sum(1 for v in mac_to_cpe.values() if v.get("account_id"))
print(f" {len(accounts)} accounts ({paying} paying), "
f"{len(mac_to_cpe)} outdoor CPEs in inventory ({cpe_assigned} account-assigned), "
f"{len(mac_to_any)} total items",
file=sys.stderr)
print(f"Fetching DHCP leases from {len(routers)} routers...", file=sys.stderr)
with ThreadPoolExecutor(max_workers=len(routers)) as ex:
results_list = list(ex.map(fetch_dhcp_leases, routers))
lease_lists = [r[0] for r in results_list]
reachable = {router: ok for router, (_, ok) in zip(routers, results_list)}
leases = [l for sub in lease_lists for l in sub]
print(f" {len(leases)} bound leases in 10.10.0.0/16 "
f"({sum(1 for v in reachable.values() if not v)} unreachable)",
file=sys.stderr)
results = audit(leases, accounts, mac_to_cpe, mac_to_any)
# Separate orphans into stale (on reachable towers) vs unknown
# An orphan is stale if the account has at least one DHCP lease on a reachable router.
accts_with_leases = {e["host_match"]["id"] for e in results["ok"] + results["mismatches"]
if e.get("host_match")}
accts_with_leases |= {e["host_match"]["id"] for e in results["unknowns"]
if e.get("host_match")}
# Also include account IDs from unmatched_host (MAC is in Gaiia, assigned)
for e in results["unmatched_host"]:
if e.get("gaiia_mac_owner"):
accts_with_leases.add(e["gaiia_mac_owner"]["id"])
stale_orphans = [o for o in results["orphans"] if o["account"]["id"] in accts_with_leases]
unknown_orphans = [o for o in results["orphans"] if o["account"]["id"] not in accts_with_leases]
results["stale_orphans"] = stale_orphans
results["unknown_orphans"] = unknown_orphans
# Replace orphans with unknown-only for display
results["orphans"] = unknown_orphans
if args.fix:
with GaiiaClient(timezone="America/Chicago") as g:
if results["fixable"]:
print(f"\nApplying {len(results['fixable'])} assignments...", file=sys.stderr)
n = do_fixes(g, results["fixable"])
print(f" {n}/{len(results['fixable'])} assigned", file=sys.stderr)
if results["exists_assigned"]:
print(f"\nReassigning {len(results['exists_assigned'])} conflicting items...",
file=sys.stderr)
n = do_reassigns(g, results["exists_assigned"])
print(f" {n}/{len(results['exists_assigned'])} reassigned", file=sys.stderr)
if stale_orphans:
print(f"\nUnassigning {len(stale_orphans)} stale CPEs "
f"(on reachable towers but not in DHCP)...", file=sys.stderr)
n = do_unassigns(g, stale_orphans, leases, accounts)
print(f" {n}/{len(stale_orphans)} unassigned", file=sys.stderr)
if args.json:
json_report(results)
else:
print_report(results, verbose=args.verbose)
if __name__ == "__main__":
main()