Compare commits

..

22 Commits

Author SHA1 Message Date
89c2d4fba3 increased upload size for restore to 100mb 2025-04-24 18:58:47 -04:00
d011550f3a fix saving, remove unused function 2025-04-10 08:12:42 -04:00
7d6dfec4c9 cleaned up some more stuff in the code 2025-04-09 10:02:29 -04:00
e53e5004e1 improved start sequence, fixed user update 2025-04-09 09:48:02 -04:00
ae4cd12f97 add console logging to radius 2025-04-09 08:51:14 -04:00
0a254c9d20 fixed some timezone log issues for auth 2025-04-08 16:46:28 -04:00
b25ebfe9bb lots of work on the stats page layout and features 2025-04-08 15:52:23 -04:00
b206033c7d fix fallback card 2025-04-08 13:21:47 -04:00
1344970c05 re-worked the maintenance interface, added more stats 2025-04-08 11:25:23 -04:00
de13c8b2f9 improved pagination 2025-04-08 08:58:47 -04:00
01ecccc928 extra data in stats 2025-04-08 08:18:27 -04:00
247ef50e49 add option to refresh 2025-04-07 17:24:53 -04:00
0b4e9943a2 cleaning .env.template 2025-04-07 17:08:20 -04:00
4f53141602 more changes to docker-compose.yml and init-schema 2025-04-07 16:57:56 -04:00
846f5475db changes to env still and init-schema.sql 2025-04-07 16:39:57 -04:00
15fad1b10c more fixed to docker-compose and init-schema.sql 2025-04-07 16:10:40 -04:00
90773b6198 some issues with the env template 2025-04-07 16:07:00 -04:00
ff5b44676b fix concatenation 2025-04-07 16:00:25 -04:00
42a8a4eb00 added networks 2025-04-07 15:58:54 -04:00
c6b8b547b9 fixed concatenation 2025-04-07 15:40:07 -04:00
3c11ffdc19 fixed depends_on 2025-04-07 15:39:03 -04:00
f3364c6ef6 created a Dockerfile for db and updated docker-compose.yml 2025-04-07 15:31:30 -04:00
19 changed files with 892 additions and 499 deletions

View File

@@ -1,12 +1,15 @@
# Flask # Flask
FLASK_SECRET_KEY=your-secret-key FLASK_SECRET_KEY=your-secret-key
# MariaDB container # Database config (shared by all)
MYSQL_HOST=db DB_HOST=db
MYSQL_ROOT_PASSWORD=radpass DB_PORT=3306
MYSQL_DATABASE=radius DB_NAME=radius
MYSQL_USER=radius DB_USER=radiususer
MYSQL_PASSWORD=radpass DB_PASSWORD=radiuspass
# Only used by the MariaDB container
MARIADB_ROOT_PASSWORD=rootpassword
# MAC Lookup API # MAC Lookup API
OUI_API_KEY= # only required if you want to increase the OUI limits OUI_API_KEY= # only required if you want to increase the OUI limits
@@ -26,5 +29,9 @@ APP_TIMEZONE=America/Toronto
# RADIUS config # RADIUS config
RADIUS_SECRET=changeme RADIUS_SECRET=changeme
RADIUS_PORT=1812 RADIUS_PORT=1812
DEFAULT_VLAN=505 # Fallback VLAN when MAC not found
# Fallback VLAN when MAC not found
DEFAULT_VLAN=505
# Assign MAC to this VLAN to deny them access (prevent fallback)
DENIED_VLAN=999 DENIED_VLAN=999

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ __pycache__/
instance/ instance/
.vscode/ .vscode/
.DS_Store .DS_Store
docker-compose.yml

View File

@@ -68,45 +68,13 @@ def add_user(mac_address, description, vlan_id):
cursor.close() cursor.close()
conn.close() conn.close()
def update_user_description(mac_address, description): def update_user(mac_address, description, vlan_id):
"""Update the description field of a user identified by MAC address.""" """Update both description and VLAN ID for a given MAC address."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE users SET description = %s WHERE mac_address = %s", (description, mac_address.lower()))
conn.commit()
cursor.close()
conn.close()
# Note: This function seems redundant with update_user_description. Included as per instructions.
def update_description(mac_address, description):
"""Update the description for a given MAC address in the users table."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"UPDATE users SET description = %s WHERE mac_address = %s", "UPDATE users SET description = %s, vlan_id = %s WHERE mac_address = %s",
(description, mac_address.lower()) (description, vlan_id, mac_address.lower())
)
conn.commit()
cursor.close()
conn.close()
def update_user_vlan(mac_address, vlan_id):
"""Update the VLAN ID for a given MAC address in the users table."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE users SET vlan_id = %s WHERE mac_address = %s", (vlan_id, mac_address.lower()))
conn.commit()
cursor.close()
conn.close()
# Note: This function seems redundant with update_user_vlan. Included as per instructions.
def update_vlan(mac_address, vlan_id):
"""Update the VLAN ID for a given MAC address in the users table."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET vlan_id = %s WHERE mac_address = %s",
(vlan_id, mac_address.lower())
) )
conn.commit() conn.commit()
cursor.close() cursor.close()
@@ -151,22 +119,6 @@ def add_group(vlan_id, description):
cursor.close() cursor.close()
conn.close() conn.close()
def duplicate_group(vlan_id):
"""Create a duplicate of a group with an incremented VLAN ID."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT vlan_id, description FROM groups WHERE vlan_id = %s", (vlan_id,))
group = cursor.fetchone()
if group:
new_vlan_id = int(group['vlan_id']) + 1 # Auto-increment logic
new_description = f"{group['description']} Copy" if group['description'] else None
cursor.execute("INSERT INTO groups (vlan_id, description) VALUES (%s, %s)", (new_vlan_id, new_description))
conn.commit()
cursor.close()
conn.close()
def update_group_description(vlan_id, description): def update_group_description(vlan_id, description):
"""Update the description for a given MAC address in the users table.""" """Update the description for a given MAC address in the users table."""
# Docstring seems incorrect (mentions MAC address), but keeping original text. # Docstring seems incorrect (mentions MAC address), but keeping original text.
@@ -520,12 +472,17 @@ def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.") print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
app_tz = pytz.utc app_tz = pytz.utc
now = datetime.now(app_tz) now = datetime.now(app_tz)
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
query_base = "SELECT * FROM auth_logs" query_base = "SELECT * FROM auth_logs"
filters = [] filters = []
params = [] params = []
if reply_type is not None: if reply_type == 'Accept-Fallback':
filters.append("reply = 'Access-Accept'")
filters.append("result LIKE %s")
params.append('%Fallback%')
elif reply_type is not None:
filters.append("reply = %s") filters.append("reply = %s")
params.append(reply_type) params.append(reply_type)
@@ -544,6 +501,7 @@ def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
if delta: if delta:
time_filter_dt = now - delta time_filter_dt = now - delta
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
filters.append("timestamp >= %s") filters.append("timestamp >= %s")
params.append(time_filter_dt) params.append(time_filter_dt)
@@ -571,12 +529,17 @@ def count_auth_logs(reply_type=None, time_range=None):
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.") print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
app_tz = pytz.utc app_tz = pytz.utc
now = datetime.now(app_tz) now = datetime.now(app_tz)
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
query_base = "SELECT COUNT(*) FROM auth_logs" query_base = "SELECT COUNT(*) FROM auth_logs"
filters = [] filters = []
params = [] params = []
if reply_type is not None: if reply_type == 'Accept-Fallback':
filters.append("reply = 'Access-Accept'")
filters.append("result LIKE %s")
params.append('%Fallback%')
elif reply_type is not None:
filters.append("reply = %s") filters.append("reply = %s")
params.append(reply_type) params.append(reply_type)
@@ -595,6 +558,7 @@ def count_auth_logs(reply_type=None, time_range=None):
if delta: if delta:
time_filter_dt = now - delta time_filter_dt = now - delta
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
filters.append("timestamp >= %s") filters.append("timestamp >= %s")
params.append(time_filter_dt) params.append(time_filter_dt)
@@ -637,6 +601,33 @@ def get_summary_counts():
return total_users, total_groups return total_users, total_groups
def get_database_stats():
conn = get_connection()
cursor = conn.cursor()
stats = {}
# Get total size of the database
cursor.execute("""
SELECT table_schema AS db_name,
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS total_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
GROUP BY table_schema
""")
row = cursor.fetchone()
stats["total_size_mb"] = row[1] if row else 0
# Optional: count total rows in key tables
cursor.execute("SELECT COUNT(*) FROM auth_logs")
stats["auth_logs_count"] = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM users")
stats["users_count"] = cursor.fetchone()[0]
conn.close()
return stats
# ------------------------------ # ------------------------------
# Maintenance Functions # Maintenance Functions
# ------------------------------ # ------------------------------
@@ -726,3 +717,5 @@ def get_table_stats():
finally: finally:
cursor.close() cursor.close()
conn.close() conn.close()

View File

@@ -259,15 +259,15 @@ form.inline-form {
} }
.stats-page .success-card { .stats-page .success-card {
border-left: 6px solid limegreen; border-left: 6px solid limegreen !important;
} }
.stats-page .error-card { .stats-page .error-card {
border-left: 6px solid crimson; border-left: 6px solid crimson !important;
} }
.stats-page .fallback-card { .stats-page .fallback-card {
border-left: 6px solid orange; border-left: 6px solid orange !important;
} }
.stats-page .styled-table.small-table td, .stats-page .styled-table.small-table td,
@@ -419,3 +419,93 @@ form.inline-form {
background-color: #f8d7da; background-color: #f8d7da;
color: #721c24; color: #721c24;
} }
.auto-refresh-toggle {
margin-top: 1rem;
margin-bottom: 1.5rem;
padding: 0.5rem 1rem;
background-color: var(--card-bg);
border: 1px solid #666;
border-radius: 8px;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 1rem;
color: var(--fg);
}
.auto-refresh-toggle label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: bold;
cursor: pointer;
}
.auto-refresh-toggle input[type="checkbox"] {
transform: scale(1.2);
accent-color: var(--accent);
}
.auto-refresh-toggle #refresh-status {
font-style: italic;
opacity: 0.8;
}
.controls-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
#stats-search {
flex: 1 1 300px;
max-width: 300px;
margin-left: auto;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--accent);
background-color: var(--cell-bg);
color: var(--fg);
}
.controls-card {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
padding: 1rem;
margin-bottom: 2rem;
background-color: var(--card-bg);
border: 1px solid #666;
border-radius: 8px;
align-items: center;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 200px;
}
.auto-refresh-block select {
min-width: 80px;
}
.search-block {
flex-grow: 2;
justify-content: flex-end;
}
.search-block input {
width: 100%;
max-width: 300px;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--accent);
background-color: var(--cell-bg);
color: var(--fg);
}

View File

@@ -0,0 +1,170 @@
{# Partial for rendering all three stats cards with AJAX-aware pagination #}
<div class="card success-card">
<h2>Recent Access-Accept</h2>
<table class="styled-table small-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor</th>
<th>VLAN</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in accept_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.vlan_id or '?' }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pagination_accept.pages|length > 1 %}
<div class="pagination" data-type="accept">
{% if pagination_accept.show_first %}
<a href="#" data-page="1">1</a>
{% endif %}
{% if pagination_accept.show_prev %}
<a href="#" data-page="{{ pagination_accept.prev_page }}"></a>
{% endif %}
{% for page in pagination_accept.pages %}
{% if page == page_accept %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="#" data-page="{{ page }}">{{ page }}</a>
{% endif %}
{% endfor %}
{% if pagination_accept.show_next %}
<a href="#" data-page="{{ pagination_accept.next_page }}"></a>
{% endif %}
{% if pagination_accept.show_last %}
<a href="#" data-page="{{ pagination_accept.last_page }}">{{ pagination_accept.last_page }}</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="card error-card">
<h2>Recent Access-Reject</h2>
<table class="styled-table small-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in reject_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pagination_reject.pages|length > 1 %}
<div class="pagination" data-type="reject">
{% if pagination_reject.show_first %}
<a href="#" data-page="1">1</a>
{% endif %}
{% if pagination_reject.show_prev %}
<a href="#" data-page="{{ pagination_reject.prev_page }}"></a>
{% endif %}
{% for page in pagination_reject.pages %}
{% if page == page_reject %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="#" data-page="{{ page }}">{{ page }}</a>
{% endif %}
{% endfor %}
{% if pagination_reject.show_next %}
<a href="#" data-page="{{ pagination_reject.next_page }}"></a>
{% endif %}
{% if pagination_reject.show_last %}
<a href="#" data-page="{{ pagination_reject.last_page }}">{{ pagination_reject.last_page }}</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="card fallback-card">
<h2>Recent Access-Fallback</h2>
<table class="styled-table small-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in fallback_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>
{% if not entry.already_exists %}
<input type="text" name="description" value="{{ entry.description or '' }}" placeholder="Description (optional)" form="form-{{ loop.index }}">
{% else %}
{{ entry.description or '' }}
{% endif %}
</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.ago }}</td>
<td>
{% if not entry.already_exists %}
<form method="POST" action="{{ url_for('stats.add') }}" class="inline-form" id="form-{{ loop.index }}">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<select name="group_id" required>
<option value="">Assign to VLAN</option>
{% for group in available_groups %}
<option value="{{ group.vlan_id }}">
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" title="Add">💾</button>
</form>
{% else %}
<span style="color: limegreen;">Already exists in VLAN {{ entry.existing_vlan or 'unknown' }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pagination_fallback.pages|length > 1 %}
<div class="pagination" data-type="fallback">
{% if pagination_fallback.show_first %}
<a href="#" data-page="1">1</a>
{% endif %}
{% if pagination_fallback.show_prev %}
<a href="#" data-page="{{ pagination_fallback.prev_page }}"></a>
{% endif %}
{% for page in pagination_fallback.pages %}
{% if page == page_fallback %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="#" data-page="{{ page }}">{{ page }}</a>
{% endif %}
{% endfor %}
{% if pagination_fallback.show_next %}
<a href="#" data-page="{{ pagination_fallback.next_page }}"></a>
{% endif %}
{% if pagination_fallback.show_last %}
<a href="#" data-page="{{ pagination_fallback.last_page }}">{{ pagination_fallback.last_page }}</a>
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -22,20 +22,17 @@
<tbody> <tbody>
{% for group in available_groups %} {% for group in available_groups %}
<tr> <tr>
<td>{{ group.vlan_id }}</td>
<td>
<form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll"> <form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll">
<input type="hidden" name="group_id" value="{{ group.vlan_id }}"> <input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<td>{{ group.vlan_id }}</td>
<td>
<input type="text" name="description" value="{{ group.description or '' }}" class="description-input"> <input type="text" name="description" value="{{ group.description or '' }}" class="description-input">
</form>
</td> </td>
<td>{{ group.user_count }}</td> <td>{{ group.user_count }}</td>
<td> <td>
<form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll" style="display:inline;">
<input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<input type="hidden" name="description" value="{{ group.description }}">
<button type="submit" title="Save">💾</button> <button type="submit" title="Save">💾</button>
</form> </form>
<form method="POST" action="{{ url_for('group.delete_group_route_handler') }}" class="preserve-scroll delete-group-form" data-user-count="{{ group.user_count }}" style="display:inline;"> <form method="POST" action="{{ url_for('group.delete_group_route_handler') }}" class="preserve-scroll delete-group-form" data-user-count="{{ group.user_count }}" style="display:inline;">
<input type="hidden" name="group_id" value="{{ group.vlan_id }}"> <input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<button type="submit"></button> <button type="submit"></button>

View File

@@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Maintenance{% endblock %} {% block title %}Maintenance{% endblock %}
{% block content %} {% block content %}
<div class="maintenance-page">
<h1>Database Maintenance</h1> <h1>Database Maintenance</h1>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
@@ -13,55 +14,80 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<p>Perform common database maintenance tasks here.</p> <div class="section">
<div class="card neutral">
<hr> <div class="card-header">Database Overview</div>
<div class="card-body">
<h2>Database Statistics</h2> <table class="styled-table">
<div class="database-stats"> <tbody>
<tr>
<th>Database Size</th>
<td>{{ db_stats.total_size_mb }} MB</td>
</tr>
<tr>
<th>auth_logs Rows</th>
<td>{{ db_stats.auth_logs_count }}</td>
</tr>
<tr>
<th>users Rows</th>
<td>{{ db_stats.users_count }}</td>
</tr>
{% if table_stats %} {% if table_stats %}
{% for table, row_count in table_stats.items() %} {% for table, row_count in table_stats.items() %}
<div class="card"> {% if table != 'auth_logs' and table != 'users' %}
<div class="card-header">{{ table }}</div> <tr>
<div class="card-body"> <th>{{ table }} Rows</th>
<p>Number of rows: {{ row_count }}</p> <td>{{ row_count }}</td>
</div> </tr>
</div>
{% endfor %}
{% else %}
<p>Could not retrieve database statistics.</p>
{% endif %} {% endif %}
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div> </div>
<hr> <div class="section">
<div class="card">
<h2>Clear Authentication Logs</h2> <div class="card-header">Clear auth_logs Table</div>
<p>Permanently remove all authentication logs from the database. This action cannot be undone.</p> <div class="card-body">
<p>Permanently remove all rows from the <code>auth_logs</code> table. This action cannot be undone.</p>
<form action="/maintenance/clear_auth_logs" method="post"> <form action="/maintenance/clear_auth_logs" method="post">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to clear all authentication logs? This action is irreversible!')"> <button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to clear all authentication logs? This action is irreversible!')">
Clear Logs Clear Logs
</button> </button>
</form> </form>
</div>
</div>
</div>
<hr> <div class="section">
<div class="card">
<h2>Database Backup</h2> <div class="card-header">Backup Database</div>
<p>Create a backup of the current database. The backup will be saved as a SQL file.</p> <div class="card-body">
<p style="color: red;"> <p>Dump the current SQL database to a downloadable file.</p>
Warning: Database backups can be very large if you do not clear the authentication logs first. <p class="alert-error" style="margin: 1rem 0;">Warning: Backup size can be large if <code>auth_logs</code> has not been cleared.</p>
</p>
<form action="/maintenance/backup_database" method="get"> <form action="/maintenance/backup_database" method="get">
<button type="submit" class="btn btn-primary">Backup Database</button> <button type="submit" class="btn">Backup Database</button>
</form> </form>
</div>
</div>
</div>
<hr> <div class="section">
<div class="card">
<h2>Database Restore</h2> <div class="card-header">Restore Database</div>
<p>Restore the database from a previously created SQL backup file. This will overwrite the current database.</p> <div class="card-body">
<p>Restore the SQL database from a previously exported file. This will overwrite all current data.</p>
<form action="/maintenance/restore_database" method="post" enctype="multipart/form-data"> <form action="/maintenance/restore_database" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".sql" required> <input type="file" name="file" accept=".sql" required>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to restore the database from this file? This will OVERWRITE the current database.')"> <button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to restore the database from this file? This will OVERWRITE the current database.')">
Restore Database Restore Database
</button> </button>
</form> </form>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -5,189 +5,159 @@
<div class="stats-page"> <div class="stats-page">
<h1 class="page-title">Authentication Stats</h1> <h1 class="page-title">Authentication Stats</h1>
<form method="POST" action="{{ url_for('stats.stats_page') }}"> <div class="controls-card">
<label for="time_range">Select Time Range:</label> <div class="control-group">
<label for="time_range">Time Range:</label>
<select name="time_range" id="time_range"> <select name="time_range" id="time_range">
<option value="last_minute" {% if time_range == 'last_minute' %}selected{% endif %}>Last 1 Minute</option> <option value="last_minute">Last 1 Minute</option>
<option value="last_5_minutes" {% if time_range == 'last_5_minutes' %}selected{% endif %}>Last 5 Minutes</option> <option value="last_5_minutes">Last 5 Minutes</option>
<option value="last_10_minutes" {% if time_range == 'last_10_minutes' %}selected{% endif %}>Last 10 Minutes</option> <option value="last_10_minutes">Last 10 Minutes</option>
<option value="last_hour" {% if time_range == 'last_hour' %}selected{% endif %}>Last Hour</option> <option value="last_hour">Last Hour</option>
<option value="last_6_hours" {% if time_range == 'last_6_hours' %}selected{% endif %}>Last 6 Hours</option> <option value="last_6_hours">Last 6 Hours</option>
<option value="last_12_hours" {% if time_range == 'last_12_hours' %}selected{% endif %}>Last 12 Hours</option> <option value="last_12_hours">Last 12 Hours</option>
<option value="last_day" {% if time_range == 'last_day' %}selected{% endif %}>Last Day</option> <option value="last_day">Last Day</option>
<option value="last_30_days" {% if time_range == 'last_30_days' %}selected{% endif %}>Last 30 Days</option> <option value="last_30_days">Last 30 Days</option>
<option value="all" {% if time_range == 'all' %}selected{% endif %}>All Time</option> <option value="all">All Time</option>
</select> </select>
<button type="submit">Update</button>
</form>
<div class="stats-container">
<!-- Access-Accept Card -->
<div class="card success-card">
<h2>Recent Access-Accept</h2>
<table class="styled-table small-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in accept_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages_accept > 1 %}
<div class="pagination">
{% for page in range(1, total_pages_accept + 1) %}
{% if page == page_accept %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="{{ url_for('stats.stats_page', page_accept=page, page_reject=page_reject, page_fallback=page_fallback, time_range=time_range) }}">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div> </div>
<!-- Access-Reject Card --> <div class="control-group">
<div class="card error-card"> <label for="per_page">Entries per page:</label>
<h2>Recent Access-Reject</h2> <select name="per_page" id="per_page">
<table class="styled-table small-table"> <option value="5">5</option>
<thead> <option value="10">10</option>
<tr> <option value="25">25</option>
<th>MAC Address</th> <option value="50">50</option>
<th>Description</th> <option value="100">100</option>
<th>Vendor</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in reject_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>{{ entry.description or '' }}</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages_reject > 1 %}
<div class="pagination">
{% for page in range(1, total_pages_reject + 1) %}
{% if page == page_reject %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="{{ url_for('stats.stats_page', page_accept=page_accept, page_reject=page, page_fallback=page_fallback, time_range=time_range) }}">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<!-- Access-Fallback Card -->
<div class="card fallback-card">
<h2>Recent Access-Fallback</h2>
<table class="styled-table small-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in fallback_entries %}
<tr>
<td>{{ entry.mac_address }}</td>
<td>
{% if not entry.already_exists %}
<input type="text" name="description" value="{{ entry.description or '' }}" placeholder="Description (optional)" form="form-{{ loop.index }}">
{% else %}
{{ entry.description or '' }}
{% endif %}
</td>
<td class="vendor-cell" data-mac="{{ entry.mac_address }}">{{ entry.vendor or '...' }}</td>
<td>{{ entry.ago }}</td>
<td>
{% if not entry.already_exists %}
<form method="POST" action="{{ url_for('stats.add') }}" class="inline-form" id="form-{{ loop.index }}">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<select name="group_id" required>
<option value="">Assign to VLAN</option>
{% for group in available_groups %}
<option value="{{ group.vlan_id }}">
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
</option>
{% endfor %}
</select> </select>
<button type="submit" title="Add">💾</button>
</form>
{% else %}
<span style="color: limegreen;">Already exists in VLAN {{ entry.existing_vlan or 'unknown' }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages_fallback > 1 %}
<div class="pagination">
{% for page in range(1, total_pages_fallback + 1) %}
{% if page == page_fallback %}
<span class="current-page">{{ page }}</span>
{% else %}
<a href="{{ url_for('stats.stats_page', page_accept=page_accept, page_reject=page_reject, page_fallback=page, time_range=time_range) }}">{{ page }}</a>
{% endif %}
{% endfor %}
</div> </div>
{% endif %}
<div class="control-group auto-refresh-block">
<label>
<input type="checkbox" id="auto-refresh-checkbox"> Auto-refresh
</label>
<select id="refresh-interval">
<option value="15000">15s</option>
<option value="30000" selected>30s</option>
<option value="60000">1 min</option>
<option value="300000">5 min</option>
</select>
<span id="refresh-status"></span>
</div> </div>
<div class="control-group search-block">
<input type="text" id="stats-search" placeholder="Search MAC, vendor, VLAN, description">
</div>
</div>
<div id="stats-root" class="stats-container">
{% include '_stats_cards.html' %}
</div> </div>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const queriedPrefixes = new Set(); const statsRoot = document.getElementById('stats-root');
document.querySelectorAll('.vendor-cell').forEach(cell => { const timeRangeSelect = document.getElementById('time_range');
const mac = cell.getAttribute('data-mac'); const perPageSelect = document.getElementById('per_page');
if (cell.textContent.trim() === '...') { const searchInput = document.getElementById('stats-search');
const prefix = mac.replace(/[^a-fA-F0-9]/g, '').substring(0, 6).toLowerCase(); const refreshCheckbox = document.getElementById('auto-refresh-checkbox');
if (queriedPrefixes.has(prefix)) return; const refreshInterval = document.getElementById('refresh-interval');
queriedPrefixes.add(prefix); const refreshStatus = document.getElementById('refresh-status');
fetch('{{ url_for("stats.lookup_mac_async") }}', { let intervalId = null;
method: 'POST', let currentPageAccept = 1;
headers: { 'Content-Type': 'application/json' }, let currentPageReject = 1;
body: JSON.stringify({ mac }) let currentPageFallback = 1;
})
.then(res => res.json()) function setInitialSelectValuesFromURL() {
.then(data => { const urlParams = new URLSearchParams(window.location.search);
if (data.vendor) { const time = urlParams.get('time_range');
document.querySelectorAll(`.vendor-cell[data-mac^="${prefix}"]`).forEach(c => { const page = urlParams.get('per_page');
if (c.textContent.trim() === '...') { if (time) timeRangeSelect.value = time;
c.textContent = data.vendor; if (page) perPageSelect.value = page;
} }
async function fetchStatsData() {
try {
const timeRange = timeRangeSelect.value;
const perPage = perPageSelect.value;
const params = new URLSearchParams({
time_range: timeRange,
per_page: perPage,
page_accept: currentPageAccept,
page_reject: currentPageReject,
page_fallback: currentPageFallback
});
const response = await fetch(`/stats/fetch_stats_data?${params}`);
const html = await response.text();
statsRoot.innerHTML = html;
filterRows();
attachPaginationHandlers();
} catch (err) {
console.error('Error fetching stats data:', err);
refreshStatus.textContent = 'Error loading stats data.';
}
}
function startAutoRefresh() {
refreshStatus.textContent = `Refreshing every ${refreshInterval.selectedOptions[0].text}`;
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(fetchStatsData, parseInt(refreshInterval.value));
}
function stopAutoRefresh() {
refreshStatus.textContent = "Auto-refresh disabled";
if (intervalId) clearInterval(intervalId);
}
function filterRows() {
const query = searchInput.value.toLowerCase();
document.querySelectorAll('.styled-table tbody tr').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none';
}); });
} }
})
.catch(err => { function attachPaginationHandlers() {
console.warn('MAC lookup failed:', err); document.querySelectorAll('.pagination').forEach(pagination => {
const type = pagination.getAttribute('data-type');
pagination.querySelectorAll('a[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = parseInt(link.getAttribute('data-page'));
if (type === 'accept') currentPageAccept = page;
else if (type === 'reject') currentPageReject = page;
else if (type === 'fallback') currentPageFallback = page;
fetchStatsData();
});
});
}); });
} }
// Initial setup
setInitialSelectValuesFromURL();
fetchStatsData();
timeRangeSelect.addEventListener('change', () => {
currentPageAccept = currentPageReject = currentPageFallback = 1;
fetchStatsData();
}); });
perPageSelect.addEventListener('change', () => {
currentPageAccept = currentPageReject = currentPageFallback = 1;
fetchStatsData();
});
refreshCheckbox.addEventListener('change', () => {
refreshCheckbox.checked ? startAutoRefresh() : stopAutoRefresh();
});
refreshInterval.addEventListener('change', () => {
if (refreshCheckbox.checked) startAutoRefresh();
});
searchInput.addEventListener('input', filterRows);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -31,32 +31,26 @@
<tr> <tr>
<td>{{ entry.mac_address }}</td> <td>{{ entry.mac_address }}</td>
<td> <form method="POST" action="{{ url_for('user.update_user_route') }}">
<form method="POST" action="{{ url_for('user.update_description_route') }}">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}"> <input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<td>
<input type="text" name="description" value="{{ entry.description or '' }}"> <input type="text" name="description" value="{{ entry.description or '' }}">
</form>
</td> </td>
<td>{{ entry.vendor or "..." }}</td> <td>{{ entry.vendor or "..." }}</td>
<td> <td>
<form method="POST" action="{{ url_for('user.update_vlan_route') }}" class="inline-form"> <select name="group_id">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<select name="group_id" onchange="this.form.submit()">
{% for group in available_groups %} {% for group in available_groups %}
<option value="{{ group.vlan_id }}" {% if group.vlan_id == entry.vlan_id %}selected{% endif %}> <option value="{{ group.vlan_id }}" {% if group.vlan_id == entry.vlan_id %}selected{% endif %}>
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %} VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</form>
</td> </td>
<td> <td>
<form method="POST" action="{{ url_for('user.update_description_route') }}" style="display:inline;">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<input type="hidden" name="description" value="{{ entry.description }}">
<button type="submit" title="Save">💾</button> <button type="submit" title="Save">💾</button>
</form> </form>

View File

@@ -1,16 +1,17 @@
from flask import Blueprint, render_template, request, send_file from flask import Blueprint, render_template, request, send_file
import mysql.connector import mysql.connector
import os import os
from db_interface import clear_auth_logs, backup_database, restore_database, get_table_stats # Import the functions from db_interface.py from db_interface import get_database_stats, clear_auth_logs, backup_database, restore_database, get_table_stats # Import the functions from db_interface.py
maintenance = Blueprint('maintenance', __name__, url_prefix='/maintenance') maintenance = Blueprint('maintenance', __name__, url_prefix='/maintenance')
@maintenance.route('/') @maintenance.route('/')
def maintenance_page(): def maintenance_page():
"""Renders the maintenance page.""" """Renders the maintenance page with table and DB stats."""
table_stats = get_table_stats() table_stats = get_table_stats()
return render_template('maintenance.html', table_stats=table_stats) db_stats = get_database_stats()
return render_template('maintenance.html', table_stats=table_stats, db_stats=db_stats)
@maintenance.route('/clear_auth_logs', methods=['POST']) @maintenance.route('/clear_auth_logs', methods=['POST'])
def clear_auth_logs_route(): def clear_auth_logs_route():

View File

@@ -1,11 +1,11 @@
from flask import Blueprint, render_template, request, current_app, redirect, url_for, jsonify 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 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 from math import ceil
import re
import pytz import pytz
import humanize import humanize
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from time import sleep from time import sleep
import threading
stats = Blueprint('stats', __name__) stats = Blueprint('stats', __name__)
@@ -21,38 +21,59 @@ def get_time_filter_delta(time_range):
"last_30_days": timedelta(days=30), "last_30_days": timedelta(days=30),
}.get(time_range) }.get(time_range)
def get_pagination_data(current_page, total_pages, max_display=7):
if total_pages == 0:
return {
"pages": [],
"show_first": False,
"show_last": False,
"show_prev": False,
"show_next": False,
"prev_page": 1,
"next_page": 1,
"first_page": 1,
"last_page": 1
}
if total_pages <= max_display:
pages = list(range(1, total_pages + 1))
else:
half = max_display // 2
start = max(1, current_page - half)
end = min(total_pages, start + max_display - 1)
if end - start + 1 < max_display:
start = max(1, end - max_display + 1)
pages = list(range(start, end + 1))
return {
"pages": pages,
"show_first": 1 not in pages,
"show_last": total_pages not in pages,
"show_prev": current_page > 1,
"show_next": current_page < total_pages,
"prev_page": max(current_page - 1, 1),
"next_page": min(current_page + 1, total_pages),
"first_page": 1,
"last_page": total_pages
}
@stats.route('/stats', methods=['GET', 'POST']) @stats.route('/stats', methods=['GET', 'POST'])
def stats_page(): def stats_page():
time_range = request.form.get('time_range') or request.args.get('time_range') or 'last_minute' if request.method == 'POST':
return redirect(url_for('stats.stats_page',
time_range=request.form.get('time_range'),
per_page=request.form.get('per_page')
))
# Per-card pagination values time_range = request.args.get('time_range', 'last_minute')
per_page = 25 per_page = int(request.args.get('per_page', 25))
page_accept = int(request.args.get('page_accept', 1)) page_accept = int(request.args.get('page_accept', 1))
page_reject = int(request.args.get('page_reject', 1)) page_reject = int(request.args.get('page_reject', 1))
page_fallback = int(request.args.get('page_fallback', 1)) page_fallback = int(request.args.get('page_fallback', 1))
# Timezone setup
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC') tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
local_tz = pytz.timezone(tz_name) local_tz = pytz.timezone(tz_name)
# 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): def enrich(entry):
ts = entry.get('timestamp') ts = entry.get('timestamp')
if ts: if ts:
@@ -64,37 +85,51 @@ def stats_page():
entry['ago'] = 'unknown' entry['ago'] = 'unknown'
vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False) vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False)
entry['vendor'] = vendor_info['vendor'] if vendor_info else None # placeholder entry['vendor'] = vendor_info['vendor'] if vendor_info else None
user = get_user_by_mac(entry['mac_address']) user = get_user_by_mac(entry['mac_address'])
entry['already_exists'] = user is not None entry['already_exists'] = user is not None
entry['existing_vlan'] = user['vlan_id'] if user else None entry['existing_vlan'] = user['vlan_id'] if user else None
entry['description'] = user['description'] if user else None entry['description'] = user['description'] if user else None
match = re.search(r'VLAN\s+(\d+)', entry.get('result', ''))
entry['vlan_id'] = match.group(1) if match else None
return entry return entry
# Enrich entries total_accept = count_auth_logs('Access-Accept', time_range)
accept_entries = [enrich(e) for e in accept_entries] total_pages_accept = ceil(total_accept / per_page)
reject_entries = [enrich(e) for e in reject_entries] offset_accept = (page_accept - 1) * per_page
fallback_entries = [enrich(e) for e in fallback_entries] accept_entries = [enrich(e) for e in get_latest_auth_logs('Access-Accept', per_page, time_range, offset_accept)]
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 = [enrich(e) for e in get_latest_auth_logs('Access-Reject', per_page, time_range, offset_reject)]
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 = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback)]
available_groups = get_all_groups() available_groups = get_all_groups()
return render_template( return render_template(
"stats.html", "stats.html",
time_range=time_range, time_range=time_range,
per_page=per_page,
accept_entries=accept_entries, accept_entries=accept_entries,
reject_entries=reject_entries, reject_entries=reject_entries,
fallback_entries=fallback_entries, fallback_entries=fallback_entries,
available_groups=available_groups, available_groups=available_groups,
page_accept=page_accept, page_accept=page_accept,
total_pages_accept=total_pages_accept, pagination_accept=get_pagination_data(page_accept, total_pages_accept),
page_reject=page_reject, page_reject=page_reject,
total_pages_reject=total_pages_reject, pagination_reject=get_pagination_data(page_reject, total_pages_reject),
page_fallback=page_fallback, page_fallback=page_fallback,
pagination_fallback=get_pagination_data(page_fallback, total_pages_fallback),
total_pages_accept=total_pages_accept,
total_pages_reject=total_pages_reject,
total_pages_fallback=total_pages_fallback total_pages_fallback=total_pages_fallback
) )
@@ -102,7 +137,7 @@ def stats_page():
def add(): def add():
mac = request.form['mac_address'] mac = request.form['mac_address']
desc = request.form.get('description', '') desc = request.form.get('description', '')
group_id = request.form.get('group_id') # keep as string since VARCHAR group_id = request.form.get('group_id')
current_app.logger.info(f"Received MAC={mac}, DESC={desc}, VLAN={group_id}") current_app.logger.info(f"Received MAC={mac}, DESC={desc}, VLAN={group_id}")
add_user(mac, desc, group_id) add_user(mac, desc, group_id)
@@ -117,14 +152,13 @@ def lookup_mac_async():
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2)) 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 delay = 1.0 / rate_limit if rate_limit > 0 else 0.5
# Lowercase cleaned prefixes
prefixes_to_lookup = {} prefixes_to_lookup = {}
for mac in macs: for mac in macs:
prefix = mac.lower().replace(":", "").replace("-", "")[:6] prefix = mac.lower().replace(":", "").replace("-", "")[:6]
prefixes_to_lookup[prefix] = mac # Use last MAC that used this prefix prefixes_to_lookup[prefix] = mac
known_vendors = get_known_mac_vendors() # local DB cache known_vendors = get_known_mac_vendors()
vendor_cache = {} # cache during this request vendor_cache = {}
for prefix, mac in prefixes_to_lookup.items(): for prefix, mac in prefixes_to_lookup.items():
if prefix in known_vendors: if prefix in known_vendors:
@@ -132,17 +166,81 @@ def lookup_mac_async():
continue continue
if prefix in vendor_cache: if prefix in vendor_cache:
print(f"→ Prefix {prefix} already queried in this request, skipping.")
results[mac] = vendor_cache[prefix] results[mac] = vendor_cache[prefix]
continue continue
info = get_vendor_info(mac) # will insert into DB info = get_vendor_info(mac)
vendor_name = info.get('vendor', '') vendor_name = info.get('vendor', '')
vendor_cache[prefix] = vendor_name vendor_cache[prefix] = vendor_name
results[mac] = vendor_name results[mac] = vendor_name
sleep(delay) # throttle sleep(delay)
return jsonify(results) return jsonify(results)
@stats.route('/fetch_stats_data')
def fetch_stats_data():
time_range = request.args.get('time_range', 'last_minute')
per_page = int(request.args.get('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))
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
local_tz = pytz.timezone(tz_name)
def enrich(entry):
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'
vendor_info = get_vendor_info(entry['mac_address'], insert_if_found=False)
entry['vendor'] = vendor_info['vendor'] if vendor_info else None
user = get_user_by_mac(entry['mac_address'])
entry['already_exists'] = user is not None
entry['existing_vlan'] = user['vlan_id'] if user else None
entry['description'] = user['description'] if user else None
match = re.search(r'VLAN\s+(\d+)', entry.get('result', ''))
entry['vlan_id'] = match.group(1) if match else None
return entry
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 = [enrich(e) for e in get_latest_auth_logs('Access-Accept', per_page, time_range, offset_accept)]
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 = [enrich(e) for e in get_latest_auth_logs('Access-Reject', per_page, time_range, offset_reject)]
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 = [enrich(e) for e in get_latest_auth_logs('Accept-Fallback', per_page, time_range, offset_fallback)]
available_groups = get_all_groups()
return render_template(
"_stats_cards.html",
time_range=time_range,
per_page=per_page,
page_accept=page_accept,
pagination_accept=get_pagination_data(page_accept, total_pages_accept),
accept_entries=accept_entries,
page_reject=page_reject,
pagination_reject=get_pagination_data(page_reject, total_pages_reject),
reject_entries=reject_entries,
page_fallback=page_fallback,
pagination_fallback=get_pagination_data(page_fallback, total_pages_fallback),
fallback_entries=fallback_entries,
available_groups=available_groups
)

View File

@@ -1,5 +1,13 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from db_interface import get_all_users, get_all_groups, add_user, update_description, update_vlan, delete_user, refresh_vendors, get_user_by_mac from db_interface import (
get_all_users,
get_all_groups,
add_user,
update_user,
delete_user,
refresh_vendors,
get_user_by_mac
)
user = Blueprint('user', __name__, url_prefix='/user') user = Blueprint('user', __name__, url_prefix='/user')
@@ -19,23 +27,14 @@ def add():
add_user(mac, desc, group_id) add_user(mac, desc, group_id)
return redirect(url_for('user.user_list')) return redirect(url_for('user.user_list'))
@user.route('/update_user', methods=['POST'])
@user.route('/update_description', methods=['POST']) def update_user_route():
def update_description_route():
mac = request.form['mac_address'] mac = request.form['mac_address']
desc = request.form.get('description', '') desc = request.form.get('description', '')
update_description(mac, desc) vlan_id = request.form['group_id']
update_user(mac, desc, vlan_id)
return redirect(url_for('user.user_list')) return redirect(url_for('user.user_list'))
@user.route('/update_vlan', methods=['POST'])
def update_vlan_route():
mac = request.form['mac_address']
group_id = request.form['group_id']
update_vlan(mac, group_id)
return redirect(url_for('user.user_list'))
@user.route('/delete', methods=['POST']) @user.route('/delete', methods=['POST'])
def delete(): def delete():
mac = request.form['mac_address'] mac = request.form['mac_address']

5
db/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM mariadb:11
# Optional: preload config
COPY conf.d /etc/mysql/conf.d/
COPY init /docker-entrypoint-initdb.d/

View File

@@ -1,13 +1,13 @@
-- init-schema.sql -- init-schema.sql
-- Table for registered users (MAC-based auth) -- Create users table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
mac_address CHAR(12) NOT NULL PRIMARY KEY CHECK (mac_address REGEXP '^[0-9A-Fa-f]{12}$'), mac_address CHAR(12) NOT NULL PRIMARY KEY CHECK (mac_address REGEXP '^[0-9A-Fa-f]{12}$'),
description VARCHAR(200), description VARCHAR(200),
vlan_id VARCHAR(64) NOT NULL vlan_id VARCHAR(64) NOT NULL
); );
-- Table for auth logs -- Create auth_logs table
CREATE TABLE IF NOT EXISTS auth_logs ( CREATE TABLE IF NOT EXISTS auth_logs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
mac_address CHAR(12) NOT NULL CHECK (mac_address REGEXP '^[0-9A-Fa-f]{12}$'), mac_address CHAR(12) NOT NULL CHECK (mac_address REGEXP '^[0-9A-Fa-f]{12}$'),
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS auth_logs (
result VARCHAR(500) DEFAULT NULL result VARCHAR(500) DEFAULT NULL
); );
-- Table for MAC vendor caching -- Create mac_vendors table
CREATE TABLE IF NOT EXISTS mac_vendors ( CREATE TABLE IF NOT EXISTS mac_vendors (
mac_prefix CHAR(6) NOT NULL PRIMARY KEY CHECK (mac_prefix REGEXP '^[0-9A-Fa-f]{6}$'), mac_prefix CHAR(6) NOT NULL PRIMARY KEY CHECK (mac_prefix REGEXP '^[0-9A-Fa-f]{6}$'),
vendor_name VARCHAR(255), vendor_name VARCHAR(255),
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS mac_vendors (
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- Table for VLAN groups -- Create groups table
CREATE TABLE IF NOT EXISTS groups ( CREATE TABLE IF NOT EXISTS groups (
vlan_id VARCHAR(64) NOT NULL PRIMARY KEY, vlan_id VARCHAR(64) NOT NULL PRIMARY KEY,
description VARCHAR(200) description VARCHAR(200)

View File

@@ -1,57 +1,40 @@
--- ---
services: services:
db: db:
image: mariadb:11 image: simonclr/radmac-db:latest
restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: rootpassword MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MYSQL_DATABASE: radius MARIADB_DATABASE: ${DB_NAME}
MYSQL_USER: radiususer MARIADB_USER: ${DB_USER}
MYSQL_PASSWORD: radiuspass MARIADB_PASSWORD: ${DB_PASSWORD}
restart: unless-stopped
ports:
- "3306:3306"
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
- ./db/conf.d:/etc/mysql/conf.d
- ./db/init:/docker-entrypoint-initdb.d
ports:
- "3306:3306" # Exposed for dev access
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s start_period: 10s
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks:
- webnet
radius: radius:
build: image: simonclr/radmac-radius:latest
context: ./radius
dockerfile: Dockerfile
depends_on: depends_on:
db: - db
condition: service_healthy
env_file: env_file:
- .env - .env
ports: ports:
- "1812:1812/udp" - "1812:1812/udp"
restart: unless-stopped restart: always
networks:
- webnet
adminer:
image: adminer
restart: unless-stopped
ports:
- "8081:8080"
app: app:
build: image: simonclr/radmac-app:latest
context: ./app
dockerfile: Dockerfile
args:
TIMEZONE: ${APP_TIMEZONE}
volumes:
- ./app:/app
env_file: env_file:
- .env - .env
environment: environment:
@@ -60,17 +43,31 @@ services:
- PYTHONPATH=/app - PYTHONPATH=/app
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: - db
condition: service_healthy networks:
- webnet
nginx: nginx:
build: image: simonclr/radmac-nginx:latest
context: ./nginx
dockerfile: Dockerfile
ports: ports:
- "8080:80" - "8080:80"
depends_on: depends_on:
- app - app
restart: unless-stopped restart: unless-stopped
networks:
- webnet
adminer:
image: adminer
restart: unless-stopped
ports:
- "8081:8080"
networks:
- webnet
volumes: volumes:
db_data: db_data:
networks:
webnet:
name: webnet

View File

@@ -4,7 +4,7 @@ http {
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
client_max_body_size 100M;
location / { location / {
proxy_pass http://app:8080; proxy_pass http://app:8080;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -18,5 +18,7 @@ COPY . .
# Expose RADIUS port (UDP) # Expose RADIUS port (UDP)
EXPOSE 1812/udp EXPOSE 1812/udp
COPY wait-for-db.py .
# Run the RADIUS service # Run the RADIUS service
CMD ["python", "main.py"] CMD ["sh", "-c", "python wait-for-db.py && python main.py"]

View File

@@ -1,8 +1,10 @@
from pyrad.server import Server, RemoteHost from pyrad.server import Server, RemoteHost
from pyrad.dictionary import Dictionary from pyrad.dictionary import Dictionary
from pyrad.packet import AccessAccept, AccessReject from pyrad.packet import AccessAccept, AccessReject
from datetime import datetime, timezone
import mysql.connector import mysql.connector
import os import os
import traceback
DEFAULT_VLAN_ID = os.getenv("DEFAULT_VLAN", "505") DEFAULT_VLAN_ID = os.getenv("DEFAULT_VLAN", "505")
DENIED_VLAN = os.getenv("DENIED_VLAN", "999") DENIED_VLAN = os.getenv("DENIED_VLAN", "999")
@@ -11,6 +13,7 @@ class MacRadiusServer(Server):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
try:
self.db = mysql.connector.connect( self.db = mysql.connector.connect(
host=os.getenv('DB_HOST'), host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 3306)), port=int(os.getenv('DB_PORT', 3306)),
@@ -18,75 +21,78 @@ class MacRadiusServer(Server):
password=os.getenv('DB_PASSWORD'), password=os.getenv('DB_PASSWORD'),
database=os.getenv('DB_NAME'), database=os.getenv('DB_NAME'),
) )
self.db.ping()
print("✅ Successfully connected to the database.")
except Exception as e:
print("❌ Failed to connect to the database.")
traceback.print_exc()
raise
def HandleAuthPacket(self, pkt): def HandleAuthPacket(self, pkt):
print(f"\n📡 Received RADIUS Auth Request")
try:
username = pkt['User-Name'][0].upper() username = pkt['User-Name'][0].upper()
cursor = self.db.cursor(dictionary=True) print(f"→ Parsed MAC: {username}")
print(f"→ Attributes: {[f'{k}={v}' for k, v in pkt.items()]}")
cursor = self.db.cursor(dictionary=True)
now_utc = datetime.now(timezone.utc)
# Step 1: Check if the MAC exists in the users table
cursor.execute("SELECT vlan_id FROM users WHERE mac_address = %s", (username,)) cursor.execute("SELECT vlan_id FROM users WHERE mac_address = %s", (username,))
result = cursor.fetchone() result = cursor.fetchone()
reply = self.CreateReplyPacket(pkt) reply = self.CreateReplyPacket(pkt)
# Step 2: Handle the Access-Accept or Access-Reject scenario
if result: if result:
# MAC found in users table
vlan_id = result['vlan_id'] vlan_id = result['vlan_id']
denied_vlan = os.getenv("DENIED_VLAN", "999")
# Check if the VLAN is a denied VLAN
denied_vlan = os.getenv("DENIED_VLAN", "999") # Get the denied VLAN from environment
if vlan_id == denied_vlan: if vlan_id == denied_vlan:
# Step 3: If the MAC is in a denied VLAN, reject the access print(f"🚫 MAC {username} found, but on denied VLAN {vlan_id}")
reply.code = AccessReject reply.code = AccessReject
cursor.execute(""" cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result) INSERT INTO auth_logs (mac_address, reply, result, timestamp)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
""", (username, "Access-Reject", f"Denied due to VLAN {denied_vlan}")) """, (username, "Access-Reject", f"Denied due to VLAN {denied_vlan}", now_utc))
self.db.commit() self.db.commit()
print(f"[INFO] MAC {username} rejected due to VLAN {denied_vlan}")
else: else:
# Step 4: If the MAC is valid and not in the denied VLAN, accept access and assign VLAN print(f"✅ MAC {username} found, assigning VLAN {vlan_id}")
reply.code = AccessAccept reply.code = AccessAccept
reply.AddAttribute("Tunnel-Type", 13) reply.AddAttribute("Tunnel-Type", 13)
reply.AddAttribute("Tunnel-Medium-Type", 6) reply.AddAttribute("Tunnel-Medium-Type", 6)
reply.AddAttribute("Tunnel-Private-Group-Id", vlan_id) reply.AddAttribute("Tunnel-Private-Group-Id", vlan_id)
# Log successful access
cursor.execute(""" cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result) INSERT INTO auth_logs (mac_address, reply, result, timestamp)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
""", (username, "Access-Accept", f"Assigned to VLAN {vlan_id}")) """, (username, "Access-Accept", f"Assigned to VLAN {vlan_id}", now_utc))
self.db.commit() self.db.commit()
print(f"[INFO] MAC {username} accepted and assigned to VLAN {vlan_id}")
else: else:
# Step 5: If the MAC is not found in the database, assign to fallback VLAN print(f"⚠️ MAC {username} not found, assigning fallback VLAN {DEFAULT_VLAN_ID}")
reply.code = AccessAccept # Still send Access-Accept even for fallback reply.code = AccessAccept
reply["Tunnel-Type"] = 13 # VLAN reply["Tunnel-Type"] = 13
reply["Tunnel-Medium-Type"] = 6 # IEEE-802 reply["Tunnel-Medium-Type"] = 6
reply["Tunnel-Private-Group-Id"] = DEFAULT_VLAN_ID reply["Tunnel-Private-Group-Id"] = DEFAULT_VLAN_ID
# Log fallback assignment
cursor.execute(""" cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result) INSERT INTO auth_logs (mac_address, reply, result, timestamp)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
""", (username, "Access-Accept", f"Assigned to fallback VLAN {DEFAULT_VLAN_ID}")) """, (username, "Access-Accept", f"Assigned to fallback VLAN {DEFAULT_VLAN_ID}", now_utc))
self.db.commit() self.db.commit()
print(f"[INFO] MAC {username} not found — assigned to fallback VLAN {DEFAULT_VLAN_ID}")
# Send the reply packet (whether accept or reject)
self.SendReplyPacket(pkt.fd, reply) self.SendReplyPacket(pkt.fd, reply)
print(f"📤 Response sent: {'Access-Accept' if reply.code == AccessAccept else 'Access-Reject'}\n")
except Exception as e:
print("❌ Error processing request:")
traceback.print_exc()
finally:
if 'cursor' in locals():
cursor.close() cursor.close()
if __name__ == '__main__': if __name__ == '__main__':
print("🚀 Starting MacRadiusServer...")
srv = MacRadiusServer(dict=Dictionary("dictionary")) srv = MacRadiusServer(dict=Dictionary("dictionary"))
srv.hosts["0.0.0.0"] = RemoteHost("0.0.0.0", os.getenv("RADIUS_SECRET", "testing123").encode(), "localhost") srv.hosts["0.0.0.0"] = RemoteHost("0.0.0.0", os.getenv("RADIUS_SECRET", "testing123").encode(), "localhost")
print("📡 Listening on 0.0.0.0 for incoming RADIUS requests...")
srv.BindToAddress("0.0.0.0") srv.BindToAddress("0.0.0.0")
srv.Run() srv.Run()

36
radius/wait-for-db.py Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import time
import os
import mysql.connector
from mysql.connector import Error
host = os.getenv("DB_HOST", "db")
port = int(os.getenv("DB_PORT", "3306"))
user = os.getenv("DB_USER")
password = os.getenv("DB_PASSWORD")
database = os.getenv("DB_NAME")
timeout = 60 # seconds
start_time = time.time()
print(f"⏳ Waiting for DB at {host}:{port} to be ready...")
while True:
try:
conn = mysql.connector.connect(
host=host,
port=port,
user=user,
password=password,
database=database
)
if conn.is_connected():
print("✅ Database is ready!")
conn.close()
break
except Error as e:
print(f"🛑 DB not ready yet: {e}")
time.sleep(2)
if time.time() - start_time > timeout:
print("❌ Timeout waiting for the database.")
exit(1)