#!/usr/bin/env python3 """Cross-reference live DHCP leases against Gaiia paying accounts. Flags three classes of unpaid internet access on customer-facing pools: (1) UNMATCHED -- bound DHCP lease whose MAC has no matching Gaiia inventory item assigned to any account. (2) UNASSIGNED -- bound lease whose MAC IS in Gaiia inventory but the item isn't assigned to an Account (sitting in InventoryLocation, NetworkSite, etc.). (3) UNPAID -- bound lease assigned to a Gaiia account that has zero ACTIVE/TRIAL billing subscriptions. Sources of truth: - Gaiia GraphQL (GAIIA_KEY env) - MikroTik tower routers via mikrotik-tool API-SSL Usage: GAIIA_KEY=... ./scripts/freeloaders.py GAIIA_KEY=... ./scripts/freeloaders.py --json > out.json # full dump """ from __future__ import annotations import argparse import json import os import re import subprocess import sys 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 # noqa: E402 # Routers that hand out customer DHCP. Skip `home` (graham's house) and `edge` # (no customer pools) by default. TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"] MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool") CUSTOMER_KEYWORDS = ("cpe", "cgnat", "customer", "hotspot") INFRA_KEYWORDS = ("tower", "infra", "mgmt", "management") def norm_mac(s: str) -> str: return re.sub(r"[^0-9a-f]", "", s.lower()) def parse_kv_line(line: str) -> dict[str, str]: """Parse a `.id=*X key=value key=value` line from mikrotik-tool api output.""" 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 def fetch_leases(router: str) -> list[dict[str, Any]]: proc = 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 proc.returncode != 0: print(f"!! {router}: {proc.stderr.strip()[:200]}", file=sys.stderr) return [] out: list[dict[str, Any]] = [] for line in proc.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 out.append({ "router": router, "mac": norm_mac(mac), "mac_raw": mac, "address": kv.get("active-address") or kv.get("address") or "", "host": kv.get("host-name", ""), "server": kv.get("active-server") or kv.get("server", ""), "comment": kv.get("comment", ""), }) return out def paginate(client: GaiiaClient, query_with_after: str, query_without_after: str, path: str): """Iterate Connection nodes; uses a separate first-page query that omits $after because the gaiia API rejects null cursor values.""" cursor: str | None = None while True: if cursor is None: data = client.query(query_without_after) else: data = client.query(query_with_after, {"after": cursor}) node = data 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 cursor = pi.get("endCursor") if not cursor: return def fetch_gaiia() -> tuple[dict[str, dict], dict[str, dict]]: mac_to_item: dict[str, dict] = {} account_info: dict[str, dict] = {} accts_with = """ query Q($after: String!) { accounts(first: 100, after: $after) { nodes { id readableId displayName status { name } billingSubscriptions(first: 50) { nodes { id status } } } pageInfo { hasNextPage endCursor } } }""" accts_first = """ query { accounts(first: 100) { nodes { id readableId displayName status { name } billingSubscriptions(first: 50) { nodes { id status } } } pageInfo { hasNextPage endCursor } } }""" items_with = """ query Q($after: String!) { inventoryItems(first: 100, after: $after) { nodes { id ipAddressV4 model { name } fields { nodes { data modelField { name } } } assignation { assigneeType assignee { __typename ... on Account { id readableId displayName } } } } pageInfo { hasNextPage endCursor } } }""" items_first = """ query { inventoryItems(first: 100) { nodes { id ipAddressV4 model { name } fields { nodes { data modelField { name } } } assignation { assigneeType assignee { __typename ... on Account { id readableId displayName } } } } pageInfo { hasNextPage endCursor } } }""" with GaiiaClient(timezone="America/Chicago") as g: for a in paginate(g, accts_with, accts_first, "accounts"): subs = (a.get("billingSubscriptions") or {}).get("nodes") or [] statuses = [s["status"] for s in subs] account_info[a["id"]] = { "readable": a["readableId"], "name": a["displayName"], "status": a["status"]["name"], "paying": any(s in ("ACTIVE", "TRIAL") for s in statuses), "sub_statuses": statuses, } for it in paginate(g, items_with, items_first, "inventoryItems"): macs = [] for f in ((it.get("fields") or {}).get("nodes") or []): mf = f.get("modelField") or {} if mf.get("name", "").lower().startswith("mac") and f.get("data"): macs.append(norm_mac(f["data"])) assn = it.get("assignation") or {} assignee = assn.get("assignee") or {} atype = assignee.get("__typename") for m in macs: if len(m) != 12: continue rec = { "item_id": it["id"], "model": (it.get("model") or {}).get("name", ""), "ip": it.get("ipAddressV4"), "assignee_type": atype, } if atype == "Account": rec["account_id"] = assignee["id"] # Prefer Account-assigned items if MAC is duplicated in inventory if m in mac_to_item and mac_to_item[m].get("assignee_type") == "Account": continue mac_to_item[m] = rec return mac_to_item, account_info 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 fuzzy_account_match(host: str, accounts: dict[str, dict]) -> dict | None: """Best-effort account lookup by DHCP host-name against displayName. Requires >=2 token overlap (first+last name) to reduce false positives.""" if not host: return None htoks = _name_tokens(host) if len(htoks) < 2: return None best = None best_score = 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 def classify(leases, mac_to_item, accounts): unmatched_paying, unmatched_unpaid, unmatched_unknown = [], [], [] unassigned, unpaid = [], [] for lease in leases: server = lease["server"].lower() if not any(k in server for k in CUSTOMER_KEYWORDS): continue if any(k in lease["comment"].lower() for k in ("backhaul", "exclusion", "infra")): continue mac = lease["mac"] item = mac_to_item.get(mac) if not item: # Fall back to name match on DHCP host-name guess = fuzzy_account_match(lease["host"], accounts) if guess is None: unmatched_unknown.append(lease) elif guess["paying"]: unmatched_paying.append((lease, guess)) else: unmatched_unpaid.append((lease, guess)) elif item.get("assignee_type") != "Account": unassigned.append((lease, item)) else: acct = accounts.get(item["account_id"]) if not acct or not acct["paying"]: unpaid.append((lease, item, acct)) return unmatched_paying, unmatched_unpaid, unmatched_unknown, unassigned, unpaid def main(): ap = argparse.ArgumentParser() ap.add_argument("--json", action="store_true", help="emit machine-readable JSON") ap.add_argument("--routers", help="comma-separated subset of routers") 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 inventory + accounts...", file=sys.stderr) mac_to_item, accounts = fetch_gaiia() paying = sum(1 for a in accounts.values() if a["paying"]) print(f" {len(accounts)} accounts ({paying} paying), " f"{len(mac_to_item)} MACs in inventory " f"({sum(1 for v in mac_to_item.values() if v.get('assignee_type')=='Account')} account-assigned)", file=sys.stderr) print(f"Fetching DHCP leases from {len(routers)} routers in parallel...", file=sys.stderr) with ThreadPoolExecutor(max_workers=len(routers)) as ex: results = list(ex.map(fetch_leases, routers)) leases = [l for sub in results for l in sub] cust_leases = [l for l in leases if any(k in l["server"].lower() for k in CUSTOMER_KEYWORDS)] print(f" {len(leases)} bound leases ({len(cust_leases)} on customer pools)", file=sys.stderr) um_paying, um_unpaid, um_unknown, unassigned, unpaid = classify(leases, mac_to_item, accounts) if args.json: json.dump({ "summary": { "accounts": len(accounts), "paying_accounts": paying, "bound_leases_total": len(leases), "bound_leases_customer_pools": len(cust_leases), "unmatched_paying_by_name": len(um_paying), "unmatched_unpaid_by_name": len(um_unpaid), "unmatched_unknown": len(um_unknown), "unassigned_in_gaiia": len(unassigned), "unpaid_account": len(unpaid), }, "unmatched_paying": [{"lease": l, "account": a} for l, a in um_paying], "unmatched_unpaid": [{"lease": l, "account": a} for l, a in um_unpaid], "unmatched_unknown": um_unknown, "unassigned": [{"lease": l, "item": i} for l, i in unassigned], "unpaid": [{"lease": l, "item": i, "account": a} for l, i, a in unpaid], }, sys.stdout, indent=2, default=str) return print() print(f"### FREELOADER REPORT ###") print() print(f"!!! {len(unpaid)} CRITICAL: bound CPE on account with NO ACTIVE subscription !!!") for l, i, a in sorted(unpaid, key=lambda x: (x[0]["router"], x[0]["address"])): nm, st = a["name"], a["status"] subs = ",".join(a["sub_statuses"]) if a["sub_statuses"] else "" print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " f"host={l['host']!r} acct=#{a['readable']} {nm!r} status={st} subs=[{subs}]") print() print(f"!!! {len(um_unpaid)} CRITICAL: MAC unknown to Gaiia, but host-name matches a NON-paying account !!!") for l, a in sorted(um_unpaid, key=lambda x: (x[0]["router"], x[0]["address"])): subs = ",".join(a["sub_statuses"]) if a["sub_statuses"] else "" print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " f"host={l['host']!r} -> acct=#{a['readable']} {a['name']!r} status={a['status']} subs=[{subs}]") print() print(f"--- {len(unassigned)} bound CPE: inventory item exists but assigned to InventoryLocation/etc (not an Account) ---") for l, i in sorted(unassigned, key=lambda x: (x[0]["router"], x[0]["address"])): print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " f"item={i['item_id']} assignee_type={i.get('assignee_type')} host={l['host']!r}") print() print(f"--- {len(um_unknown)} UNKNOWN: bound lease, no MAC match and no fuzzy name match to any account ---") for l in sorted(um_unknown, key=lambda x: (x["router"], x["address"])): print(f" {l['router']:9} {l['address']:16} {l['mac_raw']:17} " f"server={l['server']!r} host={l['host']!r}") print() print(f"(info) {len(um_paying)} bound leases not in Gaiia inventory but host-name matches a PAYING account (likely just inventory gap, not freeloaders)") if __name__ == "__main__": main()