diff --git a/app/Dockerfile b/app/Dockerfile index e46e9af..acfbda5 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -29,4 +29,4 @@ COPY . . EXPOSE 8080 # Run the app via Gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "wsgi:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "wsgi:app", "--timeout", "120", "--workers", "2"] diff --git a/app/db_interface.py b/app/db_interface.py index 7af9983..d55588e 100644 --- a/app/db_interface.py +++ b/app/db_interface.py @@ -1,6 +1,6 @@ from flask import current_app import mysql.connector -import datetime +from datetime import datetime, timedelta, timezone import requests import time import os @@ -139,149 +139,83 @@ def delete_user(mac_address): conn.close() -def get_latest_auth_logs(reply_type=None, limit=5, time_range=None): +def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0): conn = get_connection() cursor = conn.cursor(dictionary=True) - # Determine the time filter based on the time_range - if time_range: - now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC'))) - if time_range == 'last_minute': - time_filter = now - timedelta(minutes=1) - elif time_range == 'last_5_minutes': - time_filter = now - timedelta(minutes=5) - elif time_range == 'last_10_minutes': - time_filter = now - timedelta(minutes=10) - elif time_range == 'last_hour': - time_filter = now - timedelta(hours=1) - elif time_range == 'last_6_hours': - time_filter = now - timedelta(hours=6) - elif time_range == 'last_12_hours': - time_filter = now - timedelta(hours=12) - elif time_range == 'last_day': - time_filter = now - timedelta(days=1) - elif time_range == 'last_30_days': - time_filter = now - timedelta(days=30) - else: # 'all' case - time_filter = None + now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC'))) + time_filter = None - if time_filter: - cursor.execute(""" - SELECT * FROM auth_logs - WHERE reply = %s AND timestamp >= %s - ORDER BY timestamp DESC - LIMIT %s - """, (reply_type, time_filter, limit)) - else: - cursor.execute(""" - SELECT * FROM auth_logs - WHERE reply = %s - ORDER BY timestamp DESC - LIMIT %s - """, (reply_type, limit)) + if time_range and time_range != 'all': + delta = { + 'last_minute': timedelta(minutes=1), + 'last_5_minutes': timedelta(minutes=5), + 'last_10_minutes': timedelta(minutes=10), + 'last_hour': timedelta(hours=1), + 'last_6_hours': timedelta(hours=6), + 'last_12_hours': timedelta(hours=12), + 'last_day': timedelta(days=1), + 'last_30_days': timedelta(days=30) + }.get(time_range) + if delta: + time_filter = now - delta + + if time_filter: + cursor.execute(""" + SELECT * FROM auth_logs + WHERE reply = %s AND timestamp >= %s + ORDER BY timestamp DESC + LIMIT %s OFFSET %s + """, (reply_type, time_filter, limit, offset)) else: cursor.execute(""" SELECT * FROM auth_logs WHERE reply = %s ORDER BY timestamp DESC - LIMIT %s - """, (reply_type, limit)) + LIMIT %s OFFSET %s + """, (reply_type, limit, offset)) logs = cursor.fetchall() cursor.close() conn.close() return logs - - -def get_vendor_info(mac, insert_if_found=True): +def count_auth_logs(reply_type=None, time_range=None): conn = get_connection() - cursor = conn.cursor(dictionary=True) - prefix = mac.lower().replace(":", "").replace("-", "")[:6] + cursor = conn.cursor() - print(f">>> Looking up MAC: {mac} → Prefix: {prefix}") - print("→ Searching in local database...") - cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,)) - row = cursor.fetchone() + now = datetime.now(pytz.timezone(current_app.config.get('APP_TIMEZONE', 'UTC'))) + time_filter = None - if row: - print(f"✓ Found locally: {row['vendor_name']} (Status: {row['status']})") - cursor.close() - conn.close() - return { - "mac": mac, - "vendor": row['vendor_name'], - "source": "local", - "status": row['status'] - } + if time_range and time_range != 'all': + delta = { + 'last_minute': timedelta(minutes=1), + 'last_5_minutes': timedelta(minutes=5), + 'last_10_minutes': timedelta(minutes=10), + 'last_hour': timedelta(hours=1), + 'last_6_hours': timedelta(hours=6), + 'last_12_hours': timedelta(hours=12), + 'last_day': timedelta(days=1), + 'last_30_days': timedelta(days=30) + }.get(time_range) + if delta: + time_filter = now - delta - print("✗ Not found locally, querying API...") + if time_filter: + cursor.execute(""" + SELECT COUNT(*) FROM auth_logs + WHERE reply = %s AND timestamp >= %s + """, (reply_type, time_filter)) + else: + cursor.execute(""" + SELECT COUNT(*) FROM auth_logs + WHERE reply = %s + """, (reply_type,)) - url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}") - api_key = current_app.config.get("OUI_API_KEY", "") - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - - try: - url = url_template.format(prefix) - print(f"→ Querying API: {url}") - response = requests.get(url, headers=headers) - - if response.status_code == 200: - data = response.json() - vendor = data.get("company", "").strip() - if vendor: - print(f"✓ Found from API: {vendor}") - if insert_if_found: - cursor.execute(""" - INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated) - VALUES (%s, %s, 'found', NOW(), NOW()) - ON DUPLICATE KEY UPDATE - vendor_name = VALUES(vendor_name), - status = 'found', - last_checked = NOW(), - last_updated = NOW() - """, (prefix, vendor)) - conn.commit() - print("→ Inserted into database (found).") - return { - "mac": mac, - "vendor": vendor, - "source": "api", - "status": "found" - } - - elif response.status_code == 404: - print("✗ API returned 404 - vendor not found.") - if insert_if_found: - cursor.execute(""" - INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated) - VALUES (%s, %s, 'not_found', NOW(), NOW()) - ON DUPLICATE KEY UPDATE - vendor_name = VALUES(vendor_name), - status = 'not_found', - last_checked = NOW(), - last_updated = NOW() - """, (prefix, "not found")) - conn.commit() - print("→ Inserted into database (not_found).") - return { - "mac": mac, - "vendor": "", - "source": "api", - "status": "not_found" - } - - else: - print(f"✗ API error: {response.status_code}") - return {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"} - - except Exception as e: - print(f"✗ Exception while querying API: {e}") - return {"mac": mac, "vendor": "", "error": str(e)} - - finally: - cursor.close() - conn.close() + count = cursor.fetchone()[0] + cursor.close() + conn.close() + return count def get_summary_counts(): conn = get_connection() @@ -458,3 +392,133 @@ def get_user_by_mac(mac_address): cursor.close() conn.close() return user + +def get_known_mac_vendors(): + conn = get_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT mac_prefix, vendor_name, status FROM mac_vendors") + entries = cursor.fetchall() + cursor.close() + conn.close() + + return { + row['mac_prefix'].lower(): { + 'vendor': row['vendor_name'], + 'status': row['status'] + } + for row in entries + } + +def get_vendor_info(mac, insert_if_found=True): + conn = get_connection() + cursor = conn.cursor(dictionary=True) + prefix = mac.lower().replace(":", "").replace("-", "")[:6] + + print(f">>> Looking up MAC: {mac} → Prefix: {prefix}") + print("→ Searching in local database...") + cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,)) + row = cursor.fetchone() + + if row: + print(f"✓ Found locally: {row['vendor_name']} (Status: {row['status']})") + cursor.close() + conn.close() + return { + "mac": mac, + "vendor": row['vendor_name'], + "source": "local", + "status": row['status'] + } + + print("✗ Not found locally, querying API...") + + url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}") + api_key = current_app.config.get("OUI_API_KEY", "") + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + + try: + url = url_template.format(prefix) + print(f"→ Querying API: {url}") + response = requests.get(url, headers=headers) + + if response.status_code == 200: + data = response.json() + vendor = data.get("company", "").strip() + + if vendor: + print(f"✓ Found from API: {vendor}") + + # Always insert found results, even if insert_if_found=False + cursor.execute(""" + INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated) + VALUES (%s, %s, 'found', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + vendor_name = VALUES(vendor_name), + status = 'found', + last_checked = NOW(), + last_updated = NOW() + """, (prefix, vendor)) + print(f"→ Inserted vendor: {vendor} → rowcount: {cursor.rowcount}") + conn.commit() + + return { + "mac": mac, + "vendor": vendor, + "source": "api", + "status": "found" + } + + else: + print("⚠️ API returned empty company field. Treating as not_found.") + # 🛠 Always insert not_found, even if insert_if_found=False + cursor.execute(""" + INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated) + VALUES (%s, %s, 'not_found', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + vendor_name = VALUES(vendor_name), + status = 'not_found', + last_checked = NOW(), + last_updated = NOW() + """, (prefix, "not found")) + print(f"→ Inserted not_found for {prefix} → rowcount: {cursor.rowcount}") + conn.commit() + return { + "mac": mac, + "vendor": "", + "source": "api", + "status": "not_found" + } + + elif response.status_code == 404: + print("✗ API returned 404 - vendor not found.") + # 🛠 Always insert not_found + cursor.execute(""" + INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated) + VALUES (%s, %s, 'not_found', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + vendor_name = VALUES(vendor_name), + status = 'not_found', + last_checked = NOW(), + last_updated = NOW() + """, (prefix, "not found")) + print(f"→ Inserted not_found (404) for {prefix} → rowcount: {cursor.rowcount}") + conn.commit() + return { + "mac": mac, + "vendor": "", + "source": "api", + "status": "not_found" + } + + else: + print(f"✗ API error: {response.status_code}") + return {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"} + + except Exception as e: + print(f"✗ Exception while querying API: {e}") + return {"mac": mac, "vendor": "", "error": str(e)} + + finally: + cursor.close() + conn.close() + diff --git a/app/static/styles.css b/app/static/styles.css index 39820b3..69f8129 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -294,3 +294,26 @@ form.inline-form { flex: 0 0 auto; padding: 6px; } + +.pagination { + margin-top: 0.75rem; + text-align: center; +} + +.pagination a, +.pagination span.current-page { + display: inline-block; + padding: 4px 10px; + margin: 0 3px; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--fg); + background-color: transparent; + text-decoration: none; +} + +.pagination span.current-page { + font-weight: bold; + background-color: var(--accent); + color: black; +} \ No newline at end of file diff --git a/app/templates/stats.html b/app/templates/stats.html index 50a9849..c474090 100644 --- a/app/templates/stats.html +++ b/app/templates/stats.html @@ -3,10 +3,9 @@ {% block content %}
-

Authentication Stats

-
+ @@ -99,35 +119,73 @@ {{ entry.description or '' }} {% endif %} - - {{ entry.vendor }} + {{ entry.vendor or '...' }} {{ entry.ago }} - {% if not entry.already_exists %} - - - - -
+
+ + + +
{% else %} - Already exists in VLAN {{ entry.existing_vlan or 'unknown' }} + Already exists in VLAN {{ entry.existing_vlan or 'unknown' }} {% endif %} {% endfor %} - {% else %} - No data available. - {% endif %} + {% if total_pages_fallback > 1 %} + + {% endif %}
+ - {# closes .stats-page #} + {% endblock %} diff --git a/app/views/__pycache__/user_views.cpython-39.pyc b/app/views/__pycache__/user_views.cpython-39.pyc index f6f19ed..ad91d5a 100644 Binary files a/app/views/__pycache__/user_views.cpython-39.pyc and b/app/views/__pycache__/user_views.cpython-39.pyc differ diff --git a/app/views/stats_views.py b/app/views/stats_views.py index 5cb8d98..525351f 100644 --- a/app/views/stats_views.py +++ b/app/views/stats_views.py @@ -1,8 +1,11 @@ -from flask import Blueprint, render_template, request, current_app, redirect, url_for -from db_interface import get_latest_auth_logs, get_all_groups, get_vendor_info, get_user_by_mac, add_user +from flask import Blueprint, render_template, request, current_app, redirect, url_for, jsonify +from db_interface import get_latest_auth_logs, count_auth_logs, get_all_groups, get_vendor_info, get_user_by_mac, add_user, get_known_mac_vendors +from math import ceil import pytz import humanize from datetime import datetime, timezone, timedelta +from time import sleep +import threading stats = Blueprint('stats', __name__) @@ -21,32 +24,47 @@ def get_time_filter_delta(time_range): @stats.route('/stats', methods=['GET', 'POST']) def stats_page(): time_range = request.form.get('time_range') or request.args.get('time_range') or 'last_minute' - limit = 1000 # Fetch enough to allow filtering by time later + + # Per-card pagination values + per_page = 25 + page_accept = int(request.args.get('page_accept', 1)) + page_reject = int(request.args.get('page_reject', 1)) + page_fallback = int(request.args.get('page_fallback', 1)) # Timezone setup tz_name = current_app.config.get('APP_TIMEZONE', 'UTC') local_tz = pytz.timezone(tz_name) - def is_within_selected_range(ts): - if time_range == "all": - return True - delta = get_time_filter_delta(time_range) - if not delta or not ts: - return True - now = datetime.now(timezone.utc) - if ts.tzinfo is None: - ts = ts.replace(tzinfo=timezone.utc) - return (now - ts) <= delta + # Accept pagination + total_accept = count_auth_logs('Access-Accept', time_range) + total_pages_accept = ceil(total_accept / per_page) + offset_accept = (page_accept - 1) * per_page + accept_entries = get_latest_auth_logs('Access-Accept', per_page, time_range, offset_accept) + + # Reject pagination + total_reject = count_auth_logs('Access-Reject', time_range) + total_pages_reject = ceil(total_reject / per_page) + offset_reject = (page_reject - 1) * per_page + reject_entries = get_latest_auth_logs('Access-Reject', per_page, time_range, offset_reject) + + # Fallback pagination + total_fallback = count_auth_logs('Accept-Fallback', time_range) + total_pages_fallback = ceil(total_fallback / per_page) + offset_fallback = (page_fallback - 1) * per_page + fallback_entries = get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback) def enrich(entry): - if entry.get('timestamp') and entry['timestamp'].tzinfo is None: - entry['timestamp'] = entry['timestamp'].replace(tzinfo=timezone.utc) + ts = entry.get('timestamp') + if ts: + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + local_time = ts.astimezone(local_tz) + entry['ago'] = humanize.naturaltime(datetime.now(local_tz) - local_time) + else: + entry['ago'] = 'unknown' - local_time = entry['timestamp'].astimezone(local_tz) - entry['ago'] = humanize.naturaltime(datetime.now(local_tz) - local_time) - - vendor_info = get_vendor_info(entry['mac_address']) or {} - entry['vendor'] = vendor_info.get('vendor', 'Unknown Vendor') + vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False) + entry['vendor'] = vendor_info['vendor'] if vendor_info else None # placeholder user = get_user_by_mac(entry['mac_address']) entry['already_exists'] = user is not None @@ -55,20 +73,29 @@ def stats_page(): return entry - # Get and enrich logs after filtering - accept_entries = [enrich(e) for e in get_latest_auth_logs('Access-Accept', limit) if is_within_selected_range(e.get('timestamp'))] - reject_entries = [enrich(e) for e in get_latest_auth_logs('Access-Reject', limit) if is_within_selected_range(e.get('timestamp'))] - fallback_entries = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', limit) if is_within_selected_range(e.get('timestamp'))] + # Enrich entries + accept_entries = [enrich(e) for e in accept_entries] + reject_entries = [enrich(e) for e in reject_entries] + fallback_entries = [enrich(e) for e in fallback_entries] available_groups = get_all_groups() return render_template( "stats.html", + time_range=time_range, accept_entries=accept_entries, reject_entries=reject_entries, fallback_entries=fallback_entries, available_groups=available_groups, - time_range=time_range + + page_accept=page_accept, + total_pages_accept=total_pages_accept, + + page_reject=page_reject, + total_pages_reject=total_pages_reject, + + page_fallback=page_fallback, + total_pages_fallback=total_pages_fallback ) @stats.route('/add', methods=['POST']) @@ -80,3 +107,42 @@ def add(): add_user(mac, desc, group_id) return redirect(url_for('stats.stats_page')) + +@stats.route('/lookup_mac_async', methods=['POST']) +def lookup_mac_async(): + data = request.get_json() + macs = data.get('macs', []) + results = {} + + rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2)) + delay = 1.0 / rate_limit if rate_limit > 0 else 0.5 + + # Lowercase cleaned prefixes + prefixes_to_lookup = {} + for mac in macs: + prefix = mac.lower().replace(":", "").replace("-", "")[:6] + prefixes_to_lookup[prefix] = mac # Use last MAC that used this prefix + + known_vendors = get_known_mac_vendors() # local DB cache + vendor_cache = {} # cache during this request + + for prefix, mac in prefixes_to_lookup.items(): + if prefix in known_vendors: + results[mac] = known_vendors[prefix]['vendor'] + continue + + if prefix in vendor_cache: + print(f"→ Prefix {prefix} already queried in this request, skipping.") + results[mac] = vendor_cache[prefix] + continue + + info = get_vendor_info(mac) # will insert into DB + vendor_name = info.get('vendor', '') + vendor_cache[prefix] = vendor_name + results[mac] = vendor_name + + sleep(delay) # throttle + + return jsonify(results) + + diff --git a/app/views/user_views.py b/app/views/user_views.py index f166e2a..2925092 100644 --- a/app/views/user_views.py +++ b/app/views/user_views.py @@ -7,8 +7,8 @@ user = Blueprint('user', __name__, url_prefix='/user') @user.route('/') def user_list(): users = get_all_users() - groups = get_all_groups() - return render_template('user_list.html', users=users, groups=groups) + available_groups = get_all_groups() + return render_template('user_list.html', users=users, available_groups=available_groups) @user.route('/add', methods=['POST'])