436 lines
16 KiB
Python
Executable file
436 lines
16 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Sync MikroTik live PPPoE+DHCP state -> Gaiia MacAddressIpAddressAssignation.
|
|
|
|
Bypasses the broken Gaiia AWS webhook workflow that's supposed to keep
|
|
Gaiia's IP-assignment table in sync with reality. Calls the public-but-
|
|
hidden mutations:
|
|
upsertMacAddressIpAddressAssignations(input)
|
|
deactivateMacAddressIpAddressAssignations(input)
|
|
both of which are reachable via raw GraphQL even though they don't appear
|
|
in Mutation.fields introspection.
|
|
|
|
Defaults to dry-run. Use --apply to actually push changes.
|
|
|
|
Usage:
|
|
GAIIA_KEY=... ./scripts/gaiia_ip_sync.py # dry-run
|
|
GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --apply # do it
|
|
GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --routers 982 # one tower
|
|
GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --no-deactivate # only upserts
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
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, GaiiaAPIError # noqa: E402
|
|
|
|
TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"]
|
|
MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool")
|
|
|
|
PAYING = {"ACTIVE", "TRIAL"}
|
|
CUSTOMER_DHCP_KEYWORDS = ("cpe", "cgnat", "customer", "hotspot")
|
|
|
|
|
|
# ---------- 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 smush(s: str) -> str:
|
|
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
|
|
|
|
|
|
def parse_kv_line(line: str) -> dict[str, str]:
|
|
kv = {}
|
|
for part in re.split(r" +", line.strip()):
|
|
if "=" in part:
|
|
k, v = part.split("=", 1)
|
|
kv[k] = v
|
|
return kv
|
|
|
|
|
|
# ---------- MikroTik fetch ----------
|
|
|
|
def fetch_ppp_active(router: str) -> list[dict[str, Any]]:
|
|
p = subprocess.run([MTT, "api", router, "/ppp/active/print"],
|
|
capture_output=True, text=True, timeout=90,
|
|
cwd=os.path.join(REPO, "mikrotik-tool"))
|
|
if p.returncode != 0:
|
|
print(f"!! {router} ppp active: {p.stderr.strip()[:200]}", file=sys.stderr)
|
|
return []
|
|
out = []
|
|
for line in p.stdout.splitlines():
|
|
if not line.startswith(".id="): continue
|
|
kv = parse_kv_line(line)
|
|
if kv.get("service") != "pppoe": continue
|
|
out.append({
|
|
"source": "pppoe",
|
|
"router": router,
|
|
"ip": kv.get("address", ""),
|
|
"mac": norm_mac(kv.get("caller-id", "")),
|
|
"mac_raw": kv.get("caller-id", ""),
|
|
"username": kv.get("name", ""),
|
|
"host": "",
|
|
"uptime": kv.get("uptime", ""),
|
|
})
|
|
return out
|
|
|
|
|
|
def fetch_dhcp_leases(router: str) -> list[dict[str, Any]]:
|
|
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} dhcp leases: {p.stderr.strip()[:200]}", file=sys.stderr)
|
|
return []
|
|
out = []
|
|
for line in p.stdout.splitlines():
|
|
if not line.startswith(".id="): continue
|
|
kv = parse_kv_line(line)
|
|
if kv.get("status") != "bound": continue
|
|
server = (kv.get("active-server") or kv.get("server") or "").lower()
|
|
if not any(k in server for k in CUSTOMER_DHCP_KEYWORDS):
|
|
continue
|
|
mac = kv.get("active-mac-address") or kv.get("mac-address") or ""
|
|
if not mac:
|
|
continue
|
|
out.append({
|
|
"source": "dhcp",
|
|
"router": router,
|
|
"ip": kv.get("active-address") or kv.get("address") or "",
|
|
"mac": norm_mac(mac),
|
|
"mac_raw": mac,
|
|
"username": "",
|
|
"host": kv.get("host-name", ""),
|
|
"server": server,
|
|
})
|
|
return out
|
|
|
|
|
|
def fetch_router_state(routers: list[str]) -> list[dict[str, Any]]:
|
|
with ThreadPoolExecutor(max_workers=len(routers) * 2) as ex:
|
|
futs = []
|
|
for r in routers:
|
|
futs.append(ex.submit(fetch_ppp_active, r))
|
|
futs.append(ex.submit(fetch_dhcp_leases, r))
|
|
sessions = []
|
|
for f in futs:
|
|
sessions.extend(f.result())
|
|
return sessions
|
|
|
|
|
|
# ---------- Gaiia fetch ----------
|
|
|
|
def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str):
|
|
cur: str | None = None
|
|
while True:
|
|
d = client.query(q_first) if cur is None else client.query(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(client: GaiiaClient):
|
|
"""Return (accounts_by_id, mac_to_inventory, ip_to_current_assignment, all_assignments)."""
|
|
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 } } }"""
|
|
for a in paginate(client, 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,
|
|
}
|
|
|
|
mac_to_inv: dict[str, dict] = {}
|
|
ip_to_assn: dict[str, list[dict]] = {}
|
|
all_assn: list[dict] = []
|
|
q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){
|
|
nodes{ id ipAddressV4
|
|
fields{ nodes{ data modelField{ name } } }
|
|
assignation{ assigneeType assignee{ __typename ... on Account{ id } } }
|
|
ipAddressAssignments(filter:{isActive:true}, first:50){
|
|
nodes{ id ipAddress macAddress isActive type
|
|
account{ id readableId displayName } } }
|
|
}
|
|
pageInfo{ hasNextPage endCursor } } }"""
|
|
q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "")
|
|
for it in paginate(client, q_inv_with, q_inv_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"):
|
|
m = norm_mac(f["data"])
|
|
if len(m) == 12:
|
|
macs.append(m)
|
|
assn = it.get("assignation") or {}
|
|
assignee = assn.get("assignee") or {}
|
|
acct_id = assignee["id"] if assignee.get("__typename") == "Account" else None
|
|
for m in macs:
|
|
rec = {"item_id": it["id"], "account_id": acct_id}
|
|
if m in mac_to_inv and mac_to_inv[m]["account_id"]:
|
|
continue
|
|
mac_to_inv[m] = rec
|
|
for ipa in (it.get("ipAddressAssignments") or {}).get("nodes") or []:
|
|
if not ipa.get("ipAddress"): continue
|
|
entry = {**ipa, "inventory_item_id": it["id"]}
|
|
ip_to_assn.setdefault(ipa["ipAddress"], []).append(entry)
|
|
all_assn.append(entry)
|
|
|
|
return accounts, mac_to_inv, ip_to_assn, all_assn
|
|
|
|
|
|
# ---------- resolution ----------
|
|
|
|
def build_username_idx(accounts):
|
|
idx: dict[str, list[str]] = {}
|
|
for aid, a in accounts.items():
|
|
k = smush(a["name"])
|
|
if k:
|
|
idx.setdefault(k, []).append(aid)
|
|
return idx
|
|
|
|
|
|
def resolve_session(s, mac_to_inv, accounts, name_idx):
|
|
"""Return (account_id, inventory_item_id, match_via) or (None, None, reason)."""
|
|
inv = mac_to_inv.get(s["mac"]) if s["mac"] else None
|
|
if inv and inv["account_id"]:
|
|
return inv["account_id"], inv["item_id"], "mac"
|
|
# fallback: fuzzy displayName match on username (PPPoE) or host (DHCP)
|
|
key = smush(s["username"] or s["host"])
|
|
cands = name_idx.get(key, []) if key else []
|
|
paying = [c for c in cands if accounts[c]["paying"]]
|
|
if len(paying) == 1:
|
|
return paying[0], inv["item_id"] if inv else None, "name-paying"
|
|
if len(cands) == 1:
|
|
return cands[0], inv["item_id"] if inv else None, "name"
|
|
if not cands:
|
|
return None, inv["item_id"] if inv else None, ("mac-unassigned" if inv else "unmatched")
|
|
return None, inv["item_id"] if inv else None, "name-ambiguous"
|
|
|
|
|
|
# ---------- diff ----------
|
|
|
|
def plan(sessions, accounts, mac_to_inv, ip_to_assn):
|
|
name_idx = build_username_idx(accounts)
|
|
upserts = []
|
|
skipped = [] # sessions we can't resolve
|
|
seen_ips = set()
|
|
|
|
for s in sessions:
|
|
if ":" in s["ip"] or not s["ip"]:
|
|
continue # IPv6 / blank skip
|
|
seen_ips.add(s["ip"])
|
|
acct_id, item_id, via = resolve_session(s, mac_to_inv, accounts, name_idx)
|
|
if not acct_id:
|
|
skipped.append((s, via))
|
|
continue
|
|
current = ip_to_assn.get(s["ip"], [])
|
|
# is there an exact-match active assignment already?
|
|
match = None
|
|
for c in current:
|
|
same_mac = norm_mac(c["macAddress"]) == s["mac"]
|
|
same_acct = (c.get("account") or {}).get("id") == acct_id
|
|
same_item = c.get("inventory_item_id") == item_id if item_id else True
|
|
if same_mac and same_acct and same_item:
|
|
match = c
|
|
break
|
|
if match:
|
|
continue # already correct
|
|
upserts.append({
|
|
"session": s,
|
|
"via": via,
|
|
"account_id": acct_id,
|
|
"inventory_item_id": item_id,
|
|
"current": current,
|
|
})
|
|
|
|
# Deactivations: any IP that has an active Gaiia assignment but no live session for it
|
|
deactivations = []
|
|
for ip, assignments in ip_to_assn.items():
|
|
if ip in seen_ips:
|
|
continue
|
|
for a in assignments:
|
|
deactivations.append({"ip": ip, "id": a["id"], "current": a})
|
|
|
|
return upserts, deactivations, skipped
|
|
|
|
|
|
# ---------- mutations ----------
|
|
|
|
def do_upserts(client, upserts):
|
|
if not upserts:
|
|
return
|
|
BATCH = 50
|
|
payload = []
|
|
for u in upserts:
|
|
s = u["session"]
|
|
entry = {
|
|
"ipAddress": s["ip"],
|
|
"type": "IPV4",
|
|
"macAddress": fmt_mac(s["mac"]),
|
|
"accountId": u["account_id"],
|
|
"expired": False,
|
|
}
|
|
if u["inventory_item_id"]:
|
|
entry["inventoryItemId"] = u["inventory_item_id"]
|
|
payload.append(entry)
|
|
|
|
mut = """mutation M($i: UpsertMacAddressIpAddressAssignationsInput!) {
|
|
upsertMacAddressIpAddressAssignations(input: $i) {
|
|
assignations { id ipAddress macAddress account { readableId } }
|
|
errors { code message }
|
|
}
|
|
}"""
|
|
total_ok = 0
|
|
for i in range(0, len(payload), BATCH):
|
|
chunk = payload[i:i+BATCH]
|
|
try:
|
|
res = client.mutate(mut, "upsertMacAddressIpAddressAssignations",
|
|
{"i": {"assignations": chunk}})
|
|
n = len(res.get("assignations") or [])
|
|
total_ok += n
|
|
print(f" upsert batch {i//BATCH+1}: {n}/{len(chunk)} ok")
|
|
except GaiiaAPIError as e:
|
|
print(f" upsert batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr)
|
|
print(f"upserts applied: {total_ok}/{len(payload)}")
|
|
|
|
|
|
def do_deactivates(client, deactivations):
|
|
if not deactivations:
|
|
return
|
|
BATCH = 50
|
|
ids = [d["id"] for d in deactivations]
|
|
mut = """mutation M($i: DeactivateMacAddressIpAddressAssignationsInput!) {
|
|
deactivateMacAddressIpAddressAssignations(input: $i) {
|
|
assignations { id ipAddress }
|
|
errors { code message }
|
|
}
|
|
}"""
|
|
total_ok = 0
|
|
for i in range(0, len(ids), BATCH):
|
|
chunk = ids[i:i+BATCH]
|
|
try:
|
|
res = client.mutate(mut, "deactivateMacAddressIpAddressAssignations",
|
|
{"i": {"ids": chunk}})
|
|
n = len(res.get("assignations") or [])
|
|
total_ok += n
|
|
print(f" deactivate batch {i//BATCH+1}: {n}/{len(chunk)} ok")
|
|
except GaiiaAPIError as e:
|
|
print(f" deactivate batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr)
|
|
print(f"deactivations applied: {total_ok}/{len(ids)}")
|
|
|
|
|
|
# ---------- main ----------
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--apply", action="store_true", help="actually call mutations (default: dry-run)")
|
|
ap.add_argument("--routers", help="comma-separated subset")
|
|
ap.add_argument("--no-deactivate", action="store_true", help="don't deactivate stale Gaiia assignments")
|
|
ap.add_argument("--limit", type=int, default=0, help="cap upserts (for testing)")
|
|
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(f"Fetching live state from {len(routers)} routers...", file=sys.stderr)
|
|
sessions = fetch_router_state(routers)
|
|
print(f" {len(sessions)} customer sessions "
|
|
f"({sum(1 for s in sessions if s['source']=='pppoe')} pppoe, "
|
|
f"{sum(1 for s in sessions if s['source']=='dhcp')} dhcp)", file=sys.stderr)
|
|
|
|
print("Fetching Gaiia state...", file=sys.stderr)
|
|
with GaiiaClient(timezone="America/Chicago") as g:
|
|
accounts, mac_to_inv, ip_to_assn, all_assn = fetch_gaiia(g)
|
|
paying = sum(1 for a in accounts.values() if a["paying"])
|
|
print(f" {len(accounts)} accounts ({paying} paying), "
|
|
f"{len(mac_to_inv)} MACs in inventory, "
|
|
f"{len(all_assn)} active IP assignments", file=sys.stderr)
|
|
|
|
upserts, deactivations, skipped = plan(sessions, accounts, mac_to_inv, ip_to_assn)
|
|
|
|
if args.limit:
|
|
upserts = upserts[:args.limit]
|
|
deactivations = deactivations[:args.limit]
|
|
|
|
print()
|
|
print(f"=== Plan ===")
|
|
print(f" {len(upserts)} upserts (Gaiia is wrong / missing)")
|
|
print(f" {len(deactivations)} deactivations (Gaiia has stale active assignment)")
|
|
print(f" {len(skipped)} sessions we couldn't resolve to an account (skipped)")
|
|
if upserts:
|
|
print("\n--- UPSERTS ---")
|
|
for u in upserts[:200]:
|
|
s = u["session"]
|
|
acct = accounts.get(u["account_id"], {})
|
|
cur = ""
|
|
if u["current"]:
|
|
c = u["current"][0]
|
|
ca = (c.get("account") or {}).get("displayName") or "<none>"
|
|
cur = f" was: ip={c['ipAddress']} mac={c['macAddress']} acct={ca!r}"
|
|
print(f" {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} "
|
|
f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} -> "
|
|
f"#{acct.get('readable')} {acct.get('name')!r} via={u['via']}")
|
|
if cur: print(cur)
|
|
if len(upserts) > 200:
|
|
print(f" ... and {len(upserts)-200} more")
|
|
if deactivations:
|
|
print("\n--- DEACTIVATIONS ---")
|
|
for d in deactivations[:200]:
|
|
c = d["current"]
|
|
ca = (c.get("account") or {}).get("displayName") or "<none>"
|
|
print(f" ip={d['ip']} mac={c['macAddress']} acct={ca!r}")
|
|
if len(deactivations) > 200:
|
|
print(f" ... and {len(deactivations)-200} more")
|
|
if skipped:
|
|
print("\n--- SKIPPED (couldn't resolve) ---")
|
|
for s, why in skipped[:50]:
|
|
print(f" {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} "
|
|
f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} reason={why}")
|
|
if len(skipped) > 50:
|
|
print(f" ... and {len(skipped)-50} more")
|
|
|
|
if not args.apply:
|
|
print("\n(dry-run; pass --apply to execute)")
|
|
return
|
|
|
|
print("\n=== Applying ===")
|
|
with GaiiaClient(timezone="America/Chicago") as g:
|
|
do_upserts(g, upserts)
|
|
if not args.no_deactivate:
|
|
do_deactivates(g, deactivations)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|