diff --git a/.env.template b/.env.template index ca71689..d8d4eaf 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/README.md b/README.md index 484b97d..1aa3b91 100644 --- a/README.md +++ b/README.md @@ -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 -``` \ No newline at end of file +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 \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile index acfbda5..d965936 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -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 && \ diff --git a/app/app.py b/app/app.py index 7510aac..338f70f 100644 --- a/app/app.py +++ b/app/app.py @@ -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')) \ No newline at end of file diff --git a/app/db_interface.py b/app/db_interface.py index 2a7c0f4..f4d0de8 100644 --- a/app/db_interface.py +++ b/app/db_interface.py @@ -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 @@ -632,4 +635,94 @@ def get_summary_counts(): cursor.close() conn.close() - return total_users, total_groups \ No newline at end of file + 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() \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index 2f8097a..ab8efaf 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; +} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index c28eebb..22bb71e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,6 +13,7 @@ Users Groups Stats + Maintenance
diff --git a/app/templates/maintenance.html b/app/templates/maintenance.html new file mode 100644 index 0000000..5f91302 --- /dev/null +++ b/app/templates/maintenance.html @@ -0,0 +1,67 @@ +{% extends 'base.html' %} +{% block title %}Maintenance{% endblock %} +{% block content %} +

Database Maintenance

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +

Perform common database maintenance tasks here.

+ +
+ +

Database Statistics

+
+ {% if table_stats %} + {% for table, row_count in table_stats.items() %} +
+
{{ table }}
+
+

Number of rows: {{ row_count }}

+
+
+ {% endfor %} + {% else %} +

Could not retrieve database statistics.

+ {% endif %} +
+ +
+ +

Clear Authentication Logs

+

Permanently remove all authentication logs from the database. This action cannot be undone.

+
+ +
+ +
+ +

Database Backup

+

Create a backup of the current database. The backup will be saved as a SQL file.

+

+ Warning: Database backups can be very large if you do not clear the authentication logs first. +

+
+ +
+ +
+ +

Database Restore

+

Restore the database from a previously created SQL backup file. This will overwrite the current database.

+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/views/maintenance_views.py b/app/views/maintenance_views.py new file mode 100644 index 0000000..b6234a4 --- /dev/null +++ b/app/views/maintenance_views.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 601dca2..c42f2b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: image: adminer restart: unless-stopped ports: - - "8081:8080" # Access at http://localhost:8081 + - "8081:8080" app: build: context: ./app