network/gaiia/match_radius.py
2026-07-19 14:42:02 -05:00

202 lines
6.8 KiB
Python

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