Compare commits

...

25 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
196a1f31d3 update readme 2025-04-07 13:22:09 -04:00
bb121ccbc6 updated! 2025-04-07 13:01:56 -04:00
32ad2fd115 added some database maintenance functions and a page 2025-04-07 12:54:14 -04:00
23 changed files with 1118 additions and 449 deletions

View File

@@ -1,11 +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_DATABASE=radius DB_PORT=3306
MYSQL_USER=radiususer DB_NAME=radius
MYSQL_PASSWORD=radiuspass DB_USER=radiususer
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
@@ -22,15 +26,12 @@ LOG_FILE_PATH=/app/logs/app.log
# Timezone # Timezone
APP_TIMEZONE=America/Toronto APP_TIMEZONE=America/Toronto
# Database config
DB_HOST=db
DB_PORT=3306
DB_USER=radiususer
DB_PASSWORD=radiuspass
DB_NAME=radius
# 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

4
.gitignore vendored
View File

@@ -5,4 +5,6 @@ __pycache__/
/app/logs/ /app/logs/
instance/ instance/
.vscode/ .vscode/
.DS_Store .DS_Store
docker-compose.yml

View File

@@ -1 +1,49 @@
Need rewrite 🛡️ RadMac — Web Manager and radius server for MAC-based authentication / VLAN Assignment
RadMac is a lightweight Flask web UI for managing MAC address-based access control and VLAN assignment, backed by a MariaDB/MySQL database. It incorporate a lightweight radius server.
✨ Some Features
🔐 MAC-based User Management
Add/edit/delete MAC entries with descriptions and VLAN IDs.
🧠 MAC Vendor Lookup
Auto-lookup vendors using maclookup.app with rate-limited API integration and local caching.
📊 Auth Log Viewer
Filter Access-Accept / Reject / Fallback events with timestamps, MAC, vendor, and description.
🧹 Database Maintenance Tools
- View row counts for all tables
- Clear auth logs
- Backup the full database as a .sql file
- Restore from uploaded .sql files
🌗 Dark & Light Theme
Toggle between light and dark modes, with theme persistence.
🔁 Session-Friendly UX
Preserves scroll position, sticky headers, toast notifications.
📦 Setup (Docker Compose)
The project includes a ready-to-use docker-compose.yml.
1. Clone the repository
bash
Copy
Edit
git clone https://github.com/Simon-CR/RadMac.git
cd RadMac
2. Create environment file
Copy .env.template to .env and edit:
- Fill in your MySQL credentials and other optional settings like OUI_API_KEY.
3. Run the stack
docker-compose up --build
The web UI will be available at: http://localhost:8080
📄 License
MIT — do whatever you want, no guarantees.

View File

@@ -9,7 +9,7 @@ ENV TZ=$TIMEZONE
# Install tzdata and optional tools # Install tzdata and optional tools
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata iputils-ping telnet && \ apt-get install -y --no-install-recommends tzdata iputils-ping telnet mariadb-client && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone && \ echo $TZ > /etc/timezone && \
apt-get clean && \ apt-get clean && \

View File

@@ -3,6 +3,7 @@ from views.index_views import index
from views.user_views import user from views.user_views import user
from views.group_views import group from views.group_views import group
from views.stats_views import stats from views.stats_views import stats
from views.maintenance_views import maintenance
from config import app_config from config import app_config
@@ -26,6 +27,7 @@ app.register_blueprint(index)
app.register_blueprint(user, url_prefix='/user') app.register_blueprint(user, url_prefix='/user')
app.register_blueprint(group, url_prefix='/group') app.register_blueprint(group, url_prefix='/group')
app.register_blueprint(stats, url_prefix='/stats') app.register_blueprint(stats, url_prefix='/stats')
app.register_blueprint(maintenance, url_prefix='/maintenance')
@app.route('/user_list') @app.route('/user_list')
def legacy_user_list(): def legacy_user_list():
@@ -38,3 +40,7 @@ def legacy_group_list():
@app.route('/') @app.route('/')
def index_redirect(): def index_redirect():
return render_template('index.html') return render_template('index.html')
@app.route('/maintenance')
def maintenance():
return redirect(url_for('maintenance.maintenance'))

View File

@@ -1,11 +1,14 @@
from flask import current_app, request, redirect, url_for, flash from flask import current_app, request, redirect, url_for, flash
import mysql.connector from db_connection import get_connection
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import mysql.connector
import requests import requests
import time import time
import os import os
import subprocess
import pytz import pytz
from db_connection import get_connection # Assuming db_connection.py exists and defines get_connection import shutil
# ------------------------------ # ------------------------------
# User Management Functions # User Management Functions
@@ -65,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()
@@ -148,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.
@@ -517,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)
@@ -541,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)
@@ -568,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)
@@ -592,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)
@@ -632,4 +599,123 @@ def get_summary_counts():
cursor.close() cursor.close()
conn.close() conn.close()
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
# ------------------------------
def clear_auth_logs():
"""Route to clear authentication logs."""
from db_connection import get_connection
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM auth_logs")
conn.commit()
flash("✅ Authentication logs cleared.", "success")
except Exception as e:
conn.rollback()
flash(f"❌ Error clearing logs: {e}", "error")
finally:
cursor.close()
conn.close()
return redirect(url_for("maintenance.maintenance_page"))
def backup_database():
"""Create a SQL backup of the entire database and return the path to the file."""
conn = get_connection()
db_name = conn.database
user = conn.user
password = conn._password
host = conn.server_host if hasattr(conn, 'server_host') else 'localhost'
conn.close()
# Check if mysqldump exists
if not shutil.which("mysqldump"):
raise Exception("'mysqldump' command not found. Please install mariadb-client or mysql-client.")
backup_file = "backup.sql"
try:
with open(backup_file, "w") as f:
subprocess.run(
["mysqldump", "-h", host, "-u", user, f"-p{password}", db_name],
stdout=f,
check=True
)
except subprocess.CalledProcessError as e:
raise Exception(f"❌ Backup failed: {e}")
return backup_file
def restore_database(sql_content):
"""Restore the database from raw SQL content (as string)."""
conn = get_connection()
cursor = conn.cursor()
try:
for statement in sql_content.split(';'):
stmt = statement.strip()
if stmt:
cursor.execute(stmt)
conn.commit()
flash("✅ Database restored successfully.", "success")
except Exception as e:
conn.rollback()
flash(f"❌ Error restoring database: {e}", "error")
finally:
cursor.close()
conn.close()
return redirect(url_for("maintenance.maintenance_page"))
def get_table_stats():
"""Return a dictionary of table names and their row counts."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("SHOW TABLES")
tables = [row[0] for row in cursor.fetchall()]
stats = {}
for table in tables:
cursor.execute(f"SELECT COUNT(*) FROM `{table}`")
count = cursor.fetchone()[0]
stats[table] = count
return stats
except Exception as e:
print(f"❌ Error retrieving table stats: {e}")
return None
finally:
cursor.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,
@@ -402,3 +402,110 @@ form.inline-form {
font-size: 0.9rem; font-size: 0.9rem;
background: var(--cell-bg); background: var(--cell-bg);
} }
.flash-messages {
margin: 1em 0;
}
.alert {
padding: 1em;
border-radius: 8px;
margin-bottom: 1em;
}
.alert-success {
background-color: #d4edda;
color: #155724;
}
.alert-error {
background-color: #f8d7da;
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

@@ -13,6 +13,7 @@
<a href="{{ url_for('user.user_list') }}">Users</a> <a href="{{ url_for('user.user_list') }}">Users</a>
<a href="{{ url_for('group.group_list') }}">Groups</a> <a href="{{ url_for('group.group_list') }}">Groups</a>
<a href="{{ url_for('stats.stats_page') }}">Stats</a> <a href="{{ url_for('stats.stats_page') }}">Stats</a>
<a href="{{ url_for('maintenance.maintenance_page') }}">Maintenance</a>
</div> </div>
<div class="right"> <div class="right">
<button id="theme-toggle">🌓 Theme</button> <button id="theme-toggle">🌓 Theme</button>

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> <form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll">
<td> <input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll"> <td>{{ group.vlan_id }}</td>
<input type="hidden" name="group_id" value="{{ group.vlan_id }}"> <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

@@ -0,0 +1,93 @@
{% extends 'base.html' %}
{% block title %}Maintenance{% endblock %}
{% block content %}
<div class="maintenance-page">
<h1>Database Maintenance</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="section">
<div class="card neutral">
<div class="card-header">Database Overview</div>
<div class="card-body">
<table class="styled-table">
<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 %}
{% for table, row_count in table_stats.items() %}
{% if table != 'auth_logs' and table != 'users' %}
<tr>
<th>{{ table }} Rows</th>
<td>{{ row_count }}</td>
</tr>
{% endif %}
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<div class="section">
<div class="card">
<div class="card-header">Clear auth_logs Table</div>
<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">
<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
</button>
</form>
</div>
</div>
</div>
<div class="section">
<div class="card">
<div class="card-header">Backup Database</div>
<div class="card-body">
<p>Dump the current SQL database to a downloadable file.</p>
<p class="alert-error" style="margin: 1rem 0;">Warning: Backup size can be large if <code>auth_logs</code> has not been cleared.</p>
<form action="/maintenance/backup_database" method="get">
<button type="submit" class="btn">Backup Database</button>
</form>
</div>
</div>
</div>
<div class="section">
<div class="card">
<div class="card-header">Restore Database</div>
<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">
<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.')">
Restore Database
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -3,191 +3,161 @@
{% block content %} {% block content %}
<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">
<select name="time_range" id="time_range"> <label for="time_range">Time Range:</label>
<option value="last_minute" {% if time_range == 'last_minute' %}selected{% endif %}>Last 1 Minute</option> <select name="time_range" id="time_range">
<option value="last_5_minutes" {% if time_range == 'last_5_minutes' %}selected{% endif %}>Last 5 Minutes</option> <option value="last_minute">Last 1 Minute</option>
<option value="last_10_minutes" {% if time_range == 'last_10_minutes' %}selected{% endif %}>Last 10 Minutes</option> <option value="last_5_minutes">Last 5 Minutes</option>
<option value="last_hour" {% if time_range == 'last_hour' %}selected{% endif %}>Last Hour</option> <option value="last_10_minutes">Last 10 Minutes</option>
<option value="last_6_hours" {% if time_range == 'last_6_hours' %}selected{% endif %}>Last 6 Hours</option> <option value="last_hour">Last Hour</option>
<option value="last_12_hours" {% if time_range == 'last_12_hours' %}selected{% endif %}>Last 12 Hours</option> <option value="last_6_hours">Last 6 Hours</option>
<option value="last_day" {% if time_range == 'last_day' %}selected{% endif %}>Last Day</option> <option value="last_12_hours">Last 12 Hours</option>
<option value="last_30_days" {% if time_range == 'last_30_days' %}selected{% endif %}>Last 30 Days</option> <option value="last_day">Last Day</option>
<option value="all" {% if time_range == 'all' %}selected{% endif %}>All Time</option> <option value="last_30_days">Last 30 Days</option>
</select> <option value="all">All Time</option>
<button type="submit">Update</button> </select>
</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> </select>
<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> </div>
<!-- Access-Fallback Card --> <div class="control-group auto-refresh-block">
<div class="card fallback-card"> <label>
<h2>Recent Access-Fallback</h2> <input type="checkbox" id="auto-refresh-checkbox"> Auto-refresh
<table class="styled-table small-table"> </label>
<thead> <select id="refresh-interval">
<tr> <option value="15000">15s</option>
<th>MAC Address</th> <option value="30000" selected>30s</option>
<th>Description</th> <option value="60000">1 min</option>
<th>Vendor</th> <option value="300000">5 min</option>
<th>Time</th> </select>
<th>Actions</th> <span id="refresh-status"></span>
</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 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>
{% endif %}
</div> </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>
<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 {
.catch(err => { const timeRange = timeRangeSelect.value;
console.warn('MAC lookup failed:', err); 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';
}); });
}
function attachPaginationHandlers() {
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,40 +31,34 @@
<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>
<form method="POST" action="{{ url_for('user.delete') }}" style="display:inline;"> <form method="POST" action="{{ url_for('user.delete') }}" style="display:inline;">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}"> <input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<button type="submit" onclick="return confirm('Delete this MAC address?')"></button> <button type="submit" onclick="return confirm('Delete this MAC address?')"></button>
</form> </form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -0,0 +1,51 @@
from flask import Blueprint, render_template, request, send_file
import mysql.connector
import os
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.route('/')
def maintenance_page():
"""Renders the maintenance page with table and DB stats."""
table_stats = get_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'])
def clear_auth_logs_route():
"""Route to clear authentication logs."""
return clear_auth_logs()
@maintenance.route('/backup_database', methods=['GET'])
def backup_database_route():
"""Route to backup the database."""
try:
backup_file = backup_database()
return send_file(backup_file, as_attachment=True, download_name='database_backup.sql')
except Exception as e:
return str(e), 500
finally:
if os.path.exists('backup.sql'):
os.remove('backup.sql')
@maintenance.route('/restore_database', methods=['POST'])
def restore_database_route():
"""Route to restore the database."""
if 'file' not in request.files:
return "No file provided", 400
sql_file = request.files['file']
if sql_file.filename == '':
return "No file selected", 400
if not sql_file.filename.endswith('.sql'):
return "Invalid file type. Only .sql files are allowed.", 400
try:
sql_content = sql_file.read().decode('utf-8')
message = restore_database(sql_content)
return message
except Exception as e:
return str(e), 500

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" # Access at http://localhost:8081
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,82 +13,86 @@ class MacRadiusServer(Server):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.db = mysql.connector.connect( try:
host=os.getenv('DB_HOST'), self.db = mysql.connector.connect(
port=int(os.getenv('DB_PORT', 3306)), host=os.getenv('DB_HOST'),
user=os.getenv('DB_USER'), port=int(os.getenv('DB_PORT', 3306)),
password=os.getenv('DB_PASSWORD'), user=os.getenv('DB_USER'),
database=os.getenv('DB_NAME'), password=os.getenv('DB_PASSWORD'),
) 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):
username = pkt['User-Name'][0].upper() print(f"\n📡 Received RADIUS Auth Request")
cursor = self.db.cursor(dictionary=True) try:
username = pkt['User-Name'][0].upper()
# Step 1: Check if the MAC exists in the users table print(f"→ Parsed MAC: {username}")
cursor.execute("SELECT vlan_id FROM users WHERE mac_address = %s", (username,)) print(f"→ Attributes: {[f'{k}={v}' for k, v in pkt.items()]}")
result = cursor.fetchone()
reply = self.CreateReplyPacket(pkt) cursor = self.db.cursor(dictionary=True)
now_utc = datetime.now(timezone.utc)
# Step 2: Handle the Access-Accept or Access-Reject scenario cursor.execute("SELECT vlan_id FROM users WHERE mac_address = %s", (username,))
if result: result = cursor.fetchone()
# MAC found in users table
vlan_id = result['vlan_id']
# Check if the VLAN is a denied VLAN reply = self.CreateReplyPacket(pkt)
denied_vlan = os.getenv("DENIED_VLAN", "999") # Get the denied VLAN from environment
if vlan_id == denied_vlan: if result:
# Step 3: If the MAC is in a denied VLAN, reject the access vlan_id = result['vlan_id']
reply.code = AccessReject denied_vlan = os.getenv("DENIED_VLAN", "999")
cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result)
VALUES (%s, %s, %s)
""", (username, "Access-Reject", f"Denied due to VLAN {denied_vlan}"))
self.db.commit()
print(f"[INFO] MAC {username} rejected due to VLAN {denied_vlan}")
if vlan_id == denied_vlan:
print(f"🚫 MAC {username} found, but on denied VLAN {vlan_id}")
reply.code = AccessReject
cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result, timestamp)
VALUES (%s, %s, %s, %s)
""", (username, "Access-Reject", f"Denied due to VLAN {denied_vlan}", now_utc))
self.db.commit()
else:
print(f"✅ MAC {username} found, assigning VLAN {vlan_id}")
reply.code = AccessAccept
reply.AddAttribute("Tunnel-Type", 13)
reply.AddAttribute("Tunnel-Medium-Type", 6)
reply.AddAttribute("Tunnel-Private-Group-Id", vlan_id)
cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result, timestamp)
VALUES (%s, %s, %s, %s)
""", (username, "Access-Accept", f"Assigned to VLAN {vlan_id}", now_utc))
self.db.commit()
else: else:
# Step 4: If the MAC is valid and not in the denied VLAN, accept access and assign VLAN print(f"⚠️ MAC {username} not found, assigning fallback VLAN {DEFAULT_VLAN_ID}")
reply.code = AccessAccept reply.code = AccessAccept
reply.AddAttribute("Tunnel-Type", 13) reply["Tunnel-Type"] = 13
reply.AddAttribute("Tunnel-Medium-Type", 6) reply["Tunnel-Medium-Type"] = 6
reply.AddAttribute("Tunnel-Private-Group-Id", vlan_id) reply["Tunnel-Private-Group-Id"] = DEFAULT_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 fallback VLAN {DEFAULT_VLAN_ID}", now_utc))
self.db.commit() self.db.commit()
print(f"[INFO] MAC {username} accepted and assigned to VLAN {vlan_id}")
else:
# Step 5: If the MAC is not found in the database, assign to fallback VLAN
reply.code = AccessAccept # Still send Access-Accept even for fallback
reply["Tunnel-Type"] = 13 # VLAN
reply["Tunnel-Medium-Type"] = 6 # IEEE-802
reply["Tunnel-Private-Group-Id"] = DEFAULT_VLAN_ID
# Log fallback assignment
cursor.execute("""
INSERT INTO auth_logs (mac_address, reply, result)
VALUES (%s, %s, %s)
""", (username, "Access-Accept", f"Assigned to fallback VLAN {DEFAULT_VLAN_ID}"))
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)
cursor.close()
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()
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)