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

354 lines
13 KiB
Python

#!/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 "<none>"
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 "<none>"
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()