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
MYSQL_HOST=db
MYSQL_ROOT_PASSWORD=radpass
MYSQL_DATABASE=radius
MYSQL_USER=radiususer
MYSQL_PASSWORD=radiuspass
MYSQL_USER=radius
MYSQL_PASSWORD=radpass
# MAC Lookup API
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
APP_TIMEZONE=America/Toronto
# Database config
DB_HOST=db
DB_PORT=3306
DB_USER=radiususer
DB_PASSWORD=radiuspass
DB_NAME=radius
# RADIUS config
RADIUS_SECRET=changeme
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
# FreeRADIUS Manager (Phase 1)
✨ Features
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
- 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
View Access-Accept, Access-Reject, and Fallback logs
---
Filter logs by time range (e.g. last 5 min, last day)
## Requirements (Phase 1)
- Existing FreeRADIUS installation
- Existing MariaDB or MySQL server with access credentials
Vendor lookup for MAC addresses (with local caching)
### Required Tables
Add the following tables to your RADIUS database:
Asynchronous background updates to reduce API hits
```sql
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;
Manual MAC vendor lookup with detailed results
CREATE TABLE `mac_vendor_cache` (
`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;
```
Pagination for log history and user/group lists
---
Dark/light theme toggle, scroll position memory, and toasts
## Getting Started
Admin actions to clean stale vendors and logs (planned)
### 1. Clone this repo
```bash
git clone https://github.com/yourname/freeradius-manager.git
cd freeradius-manager
```
🧱 Requirements
Existing FreeRADIUS installation with a compatible schema
### 2. Configure environment
Create a `.env` file or configure environment variables:
Existing MariaDB or MySQL server
```env
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
```
maclookup.app API key (optional, for vendor lookup)
### 3. Run using Docker Compose
```bash
docker-compose up --build
```
🗃️ Required Tables
Make sure your database includes the following tables:
---
sql
Copy
Edit
CREATE TABLE `users` (
`mac_address` VARCHAR(17) PRIMARY KEY,
`description` VARCHAR(255),
`vlan_id` INT
);
## Notes
- The MAC vendor database will auto-populate as addresses are discovered
- Only MAC-based users are supported in this release
CREATE TABLE `groups` (
`vlan_id` INT PRIMARY KEY,
`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
- Integrate FreeRADIUS server into Docker Compose
- Optional MariaDB container
- Provide self-contained stack for local or cloud deployment
```
CREATE TABLE `mac_vendors` (
`mac_prefix` VARCHAR(6) PRIMARY KEY,
`vendor_name` VARCHAR(255),
`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
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 && \
echo $TZ > /etc/timezone && \
apt-get clean && \

View File

@@ -3,6 +3,7 @@ from views.index_views import index
from views.user_views import user
from views.group_views import group
from views.stats_views import stats
from views.maintenance_views import maintenance
from config import app_config
@@ -26,6 +27,7 @@ app.register_blueprint(index)
app.register_blueprint(user, url_prefix='/user')
app.register_blueprint(group, url_prefix='/group')
app.register_blueprint(stats, url_prefix='/stats')
app.register_blueprint(maintenance, url_prefix='/maintenance')
@app.route('/user_list')
def legacy_user_list():
@@ -38,3 +40,7 @@ def legacy_group_list():
@app.route('/')
def index_redirect():
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
import mysql.connector
from db_connection import get_connection
from datetime import datetime, timedelta, timezone
import mysql.connector
import requests
import time
import os
import subprocess
import pytz
from db_connection import get_connection # Assuming db_connection.py exists and defines get_connection
import shutil
# ------------------------------
# User Management Functions
@@ -633,3 +636,93 @@ def get_summary_counts():
conn.close()
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;
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('group.group_list') }}">Groups</a>
<a href="{{ url_for('stats.stats_page') }}">Stats</a>
<a href="{{ url_for('maintenance.maintenance_page') }}">Maintenance</a>
</div>
<div class="right">
<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
restart: unless-stopped
ports:
- "8081:8080" # Access at http://localhost:8081
- "8081:8080"
app:
build:
context: ./app