added some database maintenance functions and a page

This commit is contained in:
2025-04-07 12:54:14 -04:00
parent 0e1968fd5e
commit 32ad2fd115
10 changed files with 306 additions and 77 deletions

View File

@@ -3,9 +3,10 @@ FLASK_SECRET_KEY=your-secret-key
# MariaDB container # MariaDB container
MYSQL_HOST=db MYSQL_HOST=db
MYSQL_ROOT_PASSWORD=radpass
MYSQL_DATABASE=radius MYSQL_DATABASE=radius
MYSQL_USER=radiususer MYSQL_USER=radius
MYSQL_PASSWORD=radiuspass MYSQL_PASSWORD=radpass
# 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,13 +23,6 @@ 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

127
README.md
View File

@@ -1,81 +1,82 @@
FreeRADIUS Manager
A lightweight web UI to manage MAC-based FreeRADIUS configurations, backed by a MySQL/MariaDB database and integrated with maclookup.app for vendor resolution.
```markdown ✨ Features
# FreeRADIUS Manager (Phase 1) Manage MAC-based users (add/edit/delete)
A lightweight web UI to manage MAC address-based FreeRADIUS configurations backed by a MariaDB/MySQL database. Assign users to VLANs via group mapping
## Features View Access-Accept, Access-Reject, and Fallback logs
- Add/edit/delete MAC-based users and VLAN assignments
- View Access-Accept and Access-Reject logs
- Lookup MAC vendors using maclookup.app API
- Dynamically populate vendor cache to reduce API usage
--- Filter logs by time range (e.g. last 5 min, last day)
## Requirements (Phase 1) Vendor lookup for MAC addresses (with local caching)
- Existing FreeRADIUS installation
- Existing MariaDB or MySQL server with access credentials
### Required Tables Asynchronous background updates to reduce API hits
Add the following tables to your RADIUS database:
```sql Manual MAC vendor lookup with detailed results
CREATE TABLE `rad_description` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`username` char(12) DEFAULT NULL,
`description` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `mac_vendor_cache` ( Pagination for log history and user/group lists
`mac_prefix` varchar(6) NOT NULL,
`vendor_name` varchar(255) DEFAULT NULL,
`status` enum('found','not_found') DEFAULT 'found',
`last_checked` datetime DEFAULT current_timestamp(),
`last_updated` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`mac_prefix`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
```
--- Dark/light theme toggle, scroll position memory, and toasts
## Getting Started Admin actions to clean stale vendors and logs (planned)
### 1. Clone this repo 🧱 Requirements
```bash Existing FreeRADIUS installation with a compatible schema
git clone https://github.com/yourname/freeradius-manager.git
cd freeradius-manager
```
### 2. Configure environment Existing MariaDB or MySQL server
Create a `.env` file or configure environment variables:
```env maclookup.app API key (optional, for vendor lookup)
FLASK_SECRET_KEY=super-secret-key
MYSQL_HOST=192.168.1.100
MYSQL_USER=radiususer
MYSQL_PASSWORD=yourpassword
MYSQL_DATABASE=radius
OUI_API_KEY= (leave empty for free tier)
OUI_API_LIMIT_PER_SEC=2
OUI_API_DAILY_LIMIT=10000
```
### 3. Run using Docker Compose 🗃️ Required Tables
```bash Make sure your database includes the following tables:
docker-compose up --build
```
--- sql
Copy
Edit
CREATE TABLE `users` (
`mac_address` VARCHAR(17) PRIMARY KEY,
`description` VARCHAR(255),
`vlan_id` INT
);
## Notes CREATE TABLE `groups` (
- The MAC vendor database will auto-populate as addresses are discovered `vlan_id` INT PRIMARY KEY,
- Only MAC-based users are supported in this release `description` VARCHAR(255)
);
--- CREATE TABLE `auth_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`mac_address` VARCHAR(17),
`reply` ENUM('Access-Accept','Access-Reject','Access-Fallback'),
`result` TEXT,
`timestamp` DATETIME DEFAULT CURRENT_TIMESTAMP
);
## Phase 2 Goals CREATE TABLE `mac_vendors` (
- Integrate FreeRADIUS server into Docker Compose `mac_prefix` VARCHAR(6) PRIMARY KEY,
- Optional MariaDB container `vendor_name` VARCHAR(255),
- Provide self-contained stack for local or cloud deployment `status` ENUM('found', 'not_found') DEFAULT 'found',
``` `last_checked` DATETIME,
`last_updated` DATETIME
);
⚙️ Configuration
Environment variables (via .env or Docker Compose):
OUI_API_URL: MAC vendor API URL (default: https://api.maclookup.app/v2/macs/{})
OUI_API_KEY: API key for maclookup.app
OUI_API_LIMIT_PER_SEC: API rate limit per second (default: 2)
OUI_API_DAILY_LIMIT: Max API calls per day (default: 10000)
APP_TIMEZONE: Display timezone (e.g., America/Toronto)
🚀 Usage
Run with Docker Compose or your preferred WSGI stack
Navigate to / for the dashboard
Browse /users, /groups, and /stats for more details

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
@@ -633,3 +636,93 @@ def get_summary_counts():
conn.close() conn.close()
return total_users, total_groups return total_users, total_groups
# ------------------------------
# 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

@@ -402,3 +402,20 @@ 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;
}

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

@@ -0,0 +1,67 @@
{% extends 'base.html' %}
{% block title %}Maintenance{% endblock %}
{% block content %}
<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 %}
<p>Perform common database maintenance tasks here.</p>
<hr>
<h2>Database Statistics</h2>
<div class="database-stats">
{% if table_stats %}
{% for table, row_count in table_stats.items() %}
<div class="card">
<div class="card-header">{{ table }}</div>
<div class="card-body">
<p>Number of rows: {{ row_count }}</p>
</div>
</div>
{% endfor %}
{% else %}
<p>Could not retrieve database statistics.</p>
{% endif %}
</div>
<hr>
<h2>Clear Authentication Logs</h2>
<p>Permanently remove all authentication logs from the database. 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>
<hr>
<h2>Database Backup</h2>
<p>Create a backup of the current database. The backup will be saved as a SQL file.</p>
<p style="color: red;">
Warning: Database backups can be very large if you do not clear the authentication logs first.
</p>
<form action="/maintenance/backup_database" method="get">
<button type="submit" class="btn btn-primary">Backup Database</button>
</form>
<hr>
<h2>Database Restore</h2>
<p>Restore the database from a previously created SQL backup file. This will overwrite the current database.</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>
{% endblock %}

View File

@@ -0,0 +1,50 @@
from flask import Blueprint, render_template, request, send_file
import mysql.connector
import os
from db_interface import 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."""
table_stats = get_table_stats()
return render_template('maintenance.html', table_stats=table_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

@@ -43,7 +43,7 @@ services:
image: adminer image: adminer
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:8080" # Access at http://localhost:8081 - "8081:8080"
app: app:
build: build:
context: ./app context: ./app