#!/usr/bin/env python3 """ Match RADIUS PPPoE usernames to Gaiia accounts by exact name-to-username mapping. Deterministic only — no fuzzy matching. Leaves ambiguous/unmatched rows blank. A Gaiia displayName like "Jose Ortiz" generates candidate usernames: joseortiz, jortiz, joseo, jose, ortiz The RADIUS username must match one of these exactly. """ import csv import json import re import subprocess import sys from collections import defaultdict GAIIA_KEY = "gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c" GAIIA_ENDPOINT = "https://api.gaiia.com/api/v1" def fetch_all_accounts(): """Fetch all Gaiia accounts with id and displayName.""" accounts = [] cursor = None while True: after_arg = f', after: "{cursor}"' if cursor else "" query = f""" query {{ accounts(first: 250{after_arg}) {{ nodes {{ id readableId name displayName }} pageInfo {{ hasNextPage endCursor }} }} }} """ result = subprocess.run( [ "curl", "-s", "-X", "POST", GAIIA_ENDPOINT, "-H", "Content-Type: application/json", "-H", f"X-Gaiia-Api-Key: {GAIIA_KEY}", "-H", "x-timezone: America/Chicago", "-d", json.dumps({"query": query}), ], capture_output=True, text=True, timeout=30, ) data = json.loads(result.stdout) nodes = data["data"]["accounts"]["nodes"] for n in nodes: name = n.get("displayName") or n.get("name") or "" if name: accounts.append((n["id"], name.strip(), n["readableId"])) page_info = data["data"]["accounts"]["pageInfo"] if not page_info["hasNextPage"]: break cursor = page_info["endCursor"] return accounts def generate_username_variants(display_name): """Generate candidate PPPoE-style usernames from a Gaiia display name. "Jose Ortiz" -> {joseortiz, jortiz, jose, ortiz} "Graham McIntire" -> {grahammcintire, gmcintire, graham, mcintire} "Kory Biggs Shop" -> {korybiggsshop, kbiggs, kory, biggs, ...} "Mid Valley Pumping LLC" -> {midvalleypumpingllc, midvalley, ...} """ name = display_name.lower().strip() # Remove punctuation name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() parts = name.split() variants = set() # Full concatenation: firstlast with no spaces variants.add("".join(parts)) # First initial + last name: e.g. jortiz, gmcintire if len(parts) >= 2: variants.add(parts[0][0] + parts[-1]) # Also first name + last name initial variants.add(parts[0] + parts[-1][0]) # Each individual part for p in parts: variants.add(p) # First + last (just the two parts concatenated) if len(parts) >= 2: variants.add(parts[0] + parts[-1]) # Handle numbered suffixes (e.g. "Perla Chavez 2" -> "perlachavez2") if len(parts) >= 3 and re.match(r'^\d+$', parts[-1]): variants.add("".join(parts[:-1]) + parts[-1]) return variants def parse_radius_input(text): """Parse the pipe-delimited RADIUS user table.""" users = [] pattern = r'\|\s*(\d+)\s*\|\s*(\S+)\s*\|\s*Clear(?:t|T)ext-Password\s*\|\s*:=\s*\|\s*(\S+)\s*\|' for match in re.finditer(pattern, text): users.append({ "radius_id": int(match.group(1)), "username": match.group(2), "password": match.group(3), }) return users def match_users(radius_users, accounts): """Match RADIUS users to Gaiia accounts by exact username variant matching.""" # Build mapping: username_variant -> list of (account_id, account_name, readable_id) variant_map = defaultdict(list) for acct_id, acct_name, readable_id in accounts: for variant in generate_username_variants(acct_name): variant_map[variant].append((acct_id, acct_name, readable_id)) # Track which Gaiia accounts have been used (to avoid double-matching) used_accounts = set() results = [] for user in radius_users: username = user["username"].lower() candidates = variant_map.get(username, []) # Filter out already-used accounts available = [(a, n, r) for a, n, r in candidates if a not in used_accounts] if len(available) == 1: acct_id, acct_name, readable_id = available[0] used_accounts.add(acct_id) results.append({ **user, "gaiia_id": acct_id, "gaiia_name": acct_name, "gaiia_readable_id": readable_id, "match_type": "exact", }) elif len(available) > 1: # Multiple candidates — leave blank for manual review results.append({ **user, "gaiia_id": "", "gaiia_name": f"AMBIGUOUS: {', '.join(n for _,n,_ in available)}", "gaiia_readable_id": "", "match_type": "ambiguous", }) else: results.append({ **user, "gaiia_id": "", "gaiia_name": "", "gaiia_readable_id": "", "match_type": "no_match", }) return results def main(): text = sys.stdin.read() radius_users = parse_radius_input(text) print(f"Parsed {len(radius_users)} RADIUS users", file=sys.stderr) print("Fetching Gaiia accounts...", file=sys.stderr) accounts = fetch_all_accounts() print(f"Fetched {len(accounts)} Gaiia accounts with names", file=sys.stderr) results = match_users(radius_users, accounts) writer = csv.writer(sys.stdout) writer.writerow(["radius_id", "username", "password", "gaiia_id", "gaiia_name", "gaiia_readable_id", "match_type"]) for r in results: writer.writerow([ r["radius_id"], r["username"], r["password"], r["gaiia_id"], r["gaiia_name"], r["gaiia_readable_id"], r["match_type"], ]) matched = sum(1 for r in results if r["gaiia_id"]) ambiguous = sum(1 for r in results if r["match_type"] == "ambiguous") unmatched = sum(1 for r in results if r["match_type"] == "no_match") print(f"\nMatched: {matched}, Ambiguous: {ambiguous}, No match: {unmatched} (total: {len(results)})", file=sys.stderr) if ambiguous: print("\nAMBIGUOUS (multiple Gaiia accounts match same username):", file=sys.stderr) for r in results: if r["match_type"] == "ambiguous": print(f" {r['username']}: {r['gaiia_name']}", file=sys.stderr) if unmatched: print("\nNO MATCH:", file=sys.stderr) for r in results: if r["match_type"] == "no_match": print(f" {r['username']}", file=sys.stderr) if __name__ == "__main__": main()