Compare commits

..

50 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
00a91eb556 Update README.md 2025-04-07 09:53:55 -04:00
0e1968fd5e .env.template 2025-04-07 08:19:29 -04:00
70573bc0b4 fix typo 2025-04-07 08:15:53 -04:00
dc782e3a76 cleanup 2025-04-07 07:42:14 -04:00
2ff020e1a8 Update .gitignore
update .gitignore
2025-04-07 07:11:37 -04:00
da17b9cb38 Delete .vscode/settings.json
cleanup
2025-04-07 07:09:18 -04:00
590f4142e6 Update .gitignore
add .vscode
2025-04-07 07:08:00 -04:00
063057e0eb db_interface.py cleaned 2025-04-07 07:02:07 -04:00
f027d9105d comments added 2025-04-06 16:18:20 -04:00
2e511ca428 getting there 2025-04-06 14:39:32 -04:00
af7e24a948 working for the most part 2025-04-03 15:58:07 -04:00
bfd6d8af57 more changes 2025-04-02 14:52:23 -04:00
82e534f4d3 getting there 2025-04-02 00:42:37 -04:00
1482643261 new radius server 2025-04-01 17:43:52 -04:00
9d4b21b5ae fix tz 2025-04-01 13:34:47 -04:00
4327cdd858 update for TZ more 2025-04-01 13:20:36 -04:00
0754f332c9 ready for public 2025-04-01 12:13:39 -04:00
eb5d9bc3f9 getting ready to public 2025-04-01 10:52:59 -04:00
1a51ded5fc renamed some files - getting ready for initial release 2025-04-01 10:30:33 -04:00
173c8c2c99 LOTS of changes 2025-04-01 10:12:38 -04:00
519aabc0a6 user_list_inline_edit.html final draft 2025-03-31 09:37:27 -04:00
cd08abdc43 update user 2025-03-31 09:20:12 -04:00
1206c90eeb remove duplicate user 2025-03-30 17:13:35 -04:00
f370666d79 more changes 2025-03-30 16:30:50 -04:00
a7679663cc add user button fixed 2025-03-28 16:47:54 -04:00
252 changed files with 2926 additions and 3607 deletions

37
.env.template Normal file
View File

@@ -0,0 +1,37 @@
# Flask
FLASK_SECRET_KEY=your-secret-key
# Database config (shared by all)
DB_HOST=db
DB_PORT=3306
DB_NAME=radius
DB_USER=radiususer
DB_PASSWORD=radiuspass
# Only used by the MariaDB container
MARIADB_ROOT_PASSWORD=rootpassword
# MAC Lookup API
OUI_API_KEY= # only required if you want to increase the OUI limits
OUI_API_URL=https://api.maclookup.app/v2/macs/{}
# Rate Limits
OUI_API_LIMIT_PER_SEC=2
OUI_API_DAILY_LIMIT=10000
# Logging
LOG_TO_FILE=true
LOG_FILE_PATH=/app/logs/app.log
# Timezone
APP_TIMEZONE=America/Toronto
# RADIUS config
RADIUS_SECRET=changeme
RADIUS_PORT=1812
# Fallback VLAN when MAC not found
DEFAULT_VLAN=505
# Assign MAC to this VLAN to deny them access (prevent fallback)
DENIED_VLAN=999

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
*.pyc
__pycache__/
*.log
/app/logs/
instance/
.vscode/
.DS_Store
docker-compose.yml

View File

@@ -1,3 +0,0 @@
{
"python.analysis.typeCheckingMode": "basic"
}

View File

@@ -1 +1,49 @@
This is a project that allows me to simplify freeradius user management with mac address authentication as it's primary focus.
🛡️ 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

@@ -1,13 +1,32 @@
FROM python:3.9-slim
# Set working directory
WORKDIR /app
COPY requirements.txt .
# Optional: Set timezone via build argument (can override in docker-compose)
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
# Install tzdata and optional tools
RUN apt-get update && \
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 && \
rm -rf /var/lib/apt/lists/*
# Create logs directory (used if LOG_TO_FILE=true)
RUN mkdir -p /app/logs
# Copy dependencies and install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y iputils-ping telnet # Add these lines
# Copy application code
COPY . .
CMD ["python", "app.py"]
# Expose internal port (Gunicorn default)
EXPOSE 8080
# Run the app via Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "wsgi:app", "--timeout", "120", "--workers", "2"]

View File

@@ -1,568 +1,46 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify
import mysql.connector
import json
from flask import Flask, redirect, url_for, render_template
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
import logging, os
from logging.handlers import RotatingFileHandler
app = Flask(__name__)
app.config.from_object(app_config)
DB_CONFIG = {
'host': '192.168.60.150',
'user': 'user_92z0Kj',
'password': '5B3UXZV8vyrB',
'database': 'radius_NIaIuT'
}
if app.config.get('LOG_TO_FILE'):
log_file = app.config.get('LOG_FILE_PATH', '/app/logs/app.log')
os.makedirs(os.path.dirname(log_file), exist_ok=True)
handler = RotatingFileHandler(log_file, maxBytes=1_000_000, backupCount=3)
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)
def get_db():
try:
db = mysql.connector.connect(**DB_CONFIG)
return db
except mysql.connector.Error as err:
print(f"Database Connection Error: {err}")
return None
app.logger.setLevel(logging.INFO)
@app.route('/', methods=['GET', 'POST'])
def index():
sql_results = None
sql_error = None
total_users = 0
total_groups = 0
db = get_db()
if db:
cursor = db.cursor(dictionary=True)
try:
# Count total users
cursor.execute("SELECT COUNT(DISTINCT username) as total FROM radcheck;")
total_users = cursor.fetchone()['total']
# Count total groups
cursor.execute("SELECT COUNT(DISTINCT groupname) as total FROM radgroupreply;")
total_groups = cursor.fetchone()['total']
except mysql.connector.Error as err:
print(f"Error fetching counts: {err}")
cursor.close()
db.close()
return render_template('index.html', total_users=total_users, total_groups=total_groups, sql_results=sql_results, sql_error=sql_error)
@app.route('/sql', methods=['POST'])
def sql():
sql_results = None
sql_error = None
sql_query = request.form['query']
db = get_db()
if db:
try:
cursor = db.cursor(dictionary=True)
cursor.execute(sql_query)
sql_results = cursor.fetchall()
cursor.close()
db.close()
except mysql.connector.Error as err:
sql_error = str(err)
except Exception as e:
sql_error = str(e)
total_users = 0
total_groups = 0
db = get_db()
if db:
cursor = db.cursor(dictionary=True)
try:
# Count total users
cursor.execute("SELECT COUNT(DISTINCT username) as total FROM radcheck;")
total_users = cursor.fetchone()['total']
# Count total groups
cursor.execute("SELECT COUNT(DISTINCT groupname) as total FROM radgroupreply;")
total_groups = cursor.fetchone()['total']
except mysql.connector.Error as err:
print(f"Error fetching counts: {err}")
cursor.close()
db.close()
return render_template('index.html', total_users=total_users, total_groups=total_groups, sql_results=sql_results, sql_error=sql_error)
# Route setup
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 user_list():
"""Displays the user list with VLAN IDs."""
db = get_db()
if db is None:
return "Database connection failed", 500
cursor = db.cursor(dictionary=True)
try:
# Fetch users and their group assignments
cursor.execute("""
SELECT r.username AS mac_address, r.value AS description, ug.groupname AS vlan_id
FROM radcheck r
LEFT JOIN radusergroup ug ON r.username = ug.username
WHERE r.attribute = 'User-Description'
""")
results = cursor.fetchall()
# Fetch all group names for the dropdown
cursor.execute("SELECT groupname FROM radgroupcheck")
groups = cursor.fetchall()
groups = [{'groupname': row['groupname']} for row in groups] # changed
cursor.close()
db.close()
return render_template('user_list_inline_edit.html', results=results, groups=groups) # added groups
except mysql.connector.Error as e:
print(f"Database error: {e}")
cursor.close()
db.close()
return "Database error", 500
@app.route('/update_user', methods=['POST'])
def update_user():
mac_address = request.form['mac_address']
description = request.form['description']
vlan_id = request.form['vlan_id']
db = get_db()
if db:
cursor = db.cursor()
try:
db.autocommit = False
cursor.execute("""
UPDATE radcheck
SET value = %s
WHERE username = %s AND attribute = 'User-Description'
""", (description, mac_address))
cursor.execute("""
UPDATE radgroupreply rgr
SET value = %s
WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = %s LIMIT 1)
AND rgr.attribute = 'Tunnel-Private-Group-Id'
""", (vlan_id, mac_address))
db.commit()
db.autocommit = True
cursor.close()
return "success"
except mysql.connector.Error as err:
db.rollback()
db.autocommit = True
cursor.close()
return str(err)
except Exception as e:
db.rollback()
db.autocommit = True
cursor.close()
return str(e)
finally:
db.close()
return "Database Connection Failed"
@app.route('/delete_user/<mac_address>')
def delete_user(mac_address):
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("DELETE FROM radcheck WHERE username = %s", (mac_address,))
db.commit()
cursor.close()
db.close()
return redirect(url_for('user_list'))
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return redirect(url_for('user_list'))
return "Database Connection Failed"
@app.route('/edit_user/<mac_address>', methods=['GET', 'POST'])
def edit_user(mac_address):
db = get_db()
if db:
cursor = db.cursor(dictionary=True)
if request.method == 'POST':
description = request.form['description']
vlan_id = request.form['vlan_id']
cursor.execute("""
UPDATE radcheck
SET value = %s
WHERE username = %s AND attribute = 'User-Description'
""", (description, mac_address))
cursor.execute("""
UPDATE radgroupreply rgr
SET value = %s
WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = %s LIMIT 1)
AND rgr.attribute = 'Tunnel-Private-Group-Id'
""", (vlan_id, mac_address))
db.commit()
cursor.close()
db.close()
return redirect(url_for('user_list'))
else:
cursor.execute("""
SELECT
rc.username AS mac_address,
IFNULL((SELECT value FROM radgroupreply rgr
WHERE rgr.groupname = (SELECT groupname FROM radusergroup rug WHERE rug.username = rc.username LIMIT 1)
AND rgr.attribute = 'Tunnel-Private-Group-Id' LIMIT 1), 'N/A') AS vlan_id,
IFNULL((SELECT value FROM radcheck rch
WHERE rch.username = rc.username AND rch.attribute = 'User-Description' LIMIT 1), 'N/A') AS description
FROM radcheck rc
WHERE rc.username = %s
GROUP BY rc.username;
""", (mac_address,))
user = cursor.fetchone()
cursor.close()
db.close()
return render_template('edit_user.html', user=user)
return "Database Connection Failed"
def legacy_user_list():
return redirect(url_for('user.user_list'))
@app.route('/groups')
def groups():
db = get_db()
if db:
cursor = db.cursor()
try:
# Fetch group names from radgroupcheck
cursor.execute("SELECT DISTINCT groupname FROM radgroupcheck")
group_names = [row[0] for row in cursor.fetchall()]
def legacy_group_list():
return redirect(url_for('group.group_list'))
grouped_results = {}
for groupname in group_names:
# Fetch attributes for each group from radgroupreply
cursor.execute("SELECT id, attribute, op, value FROM radgroupreply WHERE groupname = %s", (groupname,))
attributes = cursor.fetchall()
grouped_results[groupname] = [{'id': row[0], 'attribute': row[1], 'op': row[2], 'value': row[3]} for row in attributes]
@app.route('/')
def index_redirect():
return render_template('index.html')
cursor.close()
db.close()
return render_template('group_list_nested.html', grouped_results=grouped_results)
except mysql.connector.Error as err:
print(f"Database Error: {err}")
cursor.close()
db.close()
return render_template('group_list_nested.html', grouped_results={})
return "Database Connection Failed"
@app.route('/edit_groupname/<old_groupname>', methods=['GET', 'POST'])
def edit_groupname(old_groupname):
db = get_db()
if db:
cursor = db.cursor(dictionary=True)
if request.method == 'POST':
new_groupname = request.form['groupname']
try:
db.autocommit = False
cursor.execute("""
UPDATE radgroupreply
SET groupname = %s
WHERE groupname = %s
""", (new_groupname, old_groupname))
cursor.execute("""
UPDATE radusergroup
SET groupname = %s
WHERE groupname = %s
""", (new_groupname, old_groupname))
db.commit()
db.autocommit = True
cursor.close()
db.close()
return redirect(url_for('groups'))
except mysql.connector.Error as err:
db.rollback()
db.autocommit = True
cursor.close()
db.close()
return f"Database Error: {err}"
else:
return render_template('edit_groupname.html', old_groupname=old_groupname)
return "Database Connection Failed"
@app.route('/update_attribute', methods=['POST'])
def update_attribute():
group_id = request.form['group_id']
attribute = request.form['attribute']
op = request.form['op']
value = request.form['value']
db = get_db()
if db:
cursor = db.cursor()
try:
db.autocommit = False
cursor.execute("""
UPDATE radgroupreply
SET attribute = %s, op = %s, value = %s
WHERE id = %s
""", (attribute, op, value, group_id))
db.commit()
db.autocommit = True
cursor.close()
return "success"
except mysql.connector.Error as err:
db.rollback()
db.autocommit = True
cursor.close()
return str(err)
except Exception as e:
db.rollback()
db.autocommit = True
cursor.close()
return str(e)
finally:
db.close()
return "Database Connection Failed"
@app.route('/add_attribute', methods=['POST'])
def add_attribute():
groupname = request.form['groupname']
attribute = request.form['attribute']
op = request.form['op']
value = request.form['value']
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("""
INSERT INTO radgroupreply (groupname, attribute, op, value)
VALUES (%s, %s, %s, %s)
""", (groupname, attribute, op, value))
db.commit()
cursor.close()
db.close()
return "success"
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return str(err)
return "Database Connection Failed"
@app.route('/edit_attribute/<group_id>', methods=['GET', 'POST'])
def edit_attribute(group_id):
db = get_db()
if db:
cursor = db.cursor(dictionary=True)
if request.method == 'POST':
attribute = request.form['attribute']
op = request.form['op']
value = request.form['value']
try:
db.autocommit = False
cursor.execute("""
UPDATE radgroupreply
SET attribute = %s, op = %s, value = %s
WHERE id = %s
""", (attribute, op, value, group_id))
db.commit()
db.autocommit = True
cursor.close()
db.close()
return redirect(url_for('groups'))
except mysql.connector.Error as err:
db.rollback()
db.autocommit = True
cursor.close()
db.close()
return f"Database Error: {err}"
else:
cursor.execute("SELECT * FROM radgroupreply WHERE id = %s", (group_id,))
attribute_data = cursor.fetchone()
cursor.close()
db.close()
return render_template('edit_attribute.html', attribute_data=attribute_data)
return "Database Connection Failed"
@app.route('/add_group', methods=['POST'])
def add_group():
groupname = request.form['groupname']
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (%s, '', '', '')", (groupname,))
cursor.execute("INSERT INTO radusergroup (groupname, username) VALUES (%s, '')", (groupname,))
# Add default values for radgroupcheck
cursor.execute("INSERT INTO radgroupcheck (groupname, attribute, op, value) VALUES (%s, 'Auth-Type', ':=', 'Accept')", (groupname,))
db.commit()
cursor.close()
db.close()
return "success"
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return str(err)
return "Database Connection Failed"
@app.route('/delete_group_rows/<groupname>')
def delete_group_rows(groupname):
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("DELETE FROM radgroupreply WHERE groupname = %s", (groupname,))
cursor.execute("DELETE FROM radusergroup WHERE groupname = %s", (groupname,))
cursor.execute("DELETE FROM radgroupcheck WHERE groupname = %s", (groupname,))
db.commit()
cursor.close()
db.close()
return redirect(url_for('groups'))
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return redirect(url_for('groups'))
return "Database Connection Failed"
@app.route('/delete_group/<int:group_id>')
def delete_group(group_id):
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("DELETE FROM radgroupreply WHERE id = %s", (group_id,))
cursor.execute("DELETE FROM radgroupcheck WHERE id = %s", (group_id,)) # Delete from radgroupcheck
db.commit()
cursor.close()
db.close()
return redirect(url_for('groups'))
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return redirect(url_for('groups'))
return "Database Connection Failed"
@app.route('/duplicate_group', methods=['POST'])
def duplicate_group():
groupname = request.form['groupname']
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("SELECT attribute, op, value FROM radgroupreply WHERE groupname = %s", (groupname,))
attributes = [{'attribute': row[0], 'op': row[1], 'value': row[2]} for row in cursor.fetchall()]
cursor.close()
db.close()
return jsonify(attributes)
except mysql.connector.Error as err:
print(f"Database Error: {err}")
cursor.close()
db.close()
return jsonify([])
return jsonify([])
@app.route('/save_duplicated_group', methods=['POST'])
def save_duplicated_group():
data = json.loads(request.data)
groupname = data['groupname']
attributes = data['attributes']
db = get_db()
if db:
cursor = db.cursor()
try:
cursor.execute("INSERT INTO radgroupcheck (groupname, attribute, op, value) VALUES (%s, 'Auth-Type', ':=', 'Accept')", (groupname,))
cursor.execute("INSERT INTO radusergroup (groupname, username) VALUES (%s, '')", (groupname,))
for attribute in attributes:
cursor.execute("INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES (%s, %s, %s, %s)", (groupname, attribute['attribute'], attribute['op'], attribute['value']))
db.commit()
cursor.close()
db.close()
return "success"
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return str(err)
return "Database Connection Failed"
@app.route('/add_user', methods=['POST'])
def add_user():
"""Adds a new user to the database."""
try:
data = request.get_json() # Get the JSON data from the request
mac_address = data.get('mac_address')
description = data.get('description')
vlan_id = data.get('vlan_id')
if not mac_address:
return jsonify({'success': False, 'message': 'MAC Address is required'}), 400
db = get_db()
if db is None:
return jsonify({'success': False, 'message': 'Database connection failed'}), 500
cursor = db.cursor()
try:
# Check if user already exists
cursor.execute("SELECT username FROM radcheck WHERE username = %s", (mac_address,))
if cursor.fetchone():
cursor.close()
db.close()
return jsonify({'success': False, 'message': 'User with this MAC Address already exists'}), 400
# Insert into radcheck, setting password to MAC address
cursor.execute("""
INSERT INTO radcheck (username, attribute, op, value)
VALUES (%s, 'Cleartext-Password', ':=', %s),
(%s, 'User-Description', ':=', %s)
""", (mac_address, mac_address, mac_address, description)) # Use mac_address for both username and password
# Insert into radusergroup with the selected group
cursor.execute("""
INSERT INTO radusergroup (username, groupname)
VALUES (%s, %s)
""", (mac_address, vlan_id)) # Use vlan_id
db.commit()
cursor.close()
db.close()
return jsonify({'success': True, 'message': 'User added successfully'})
except mysql.connector.Error as err:
print(f"Database Error: {err}")
db.rollback()
cursor.close()
db.close()
return jsonify({'success': False, 'message': f"Database error: {err}"}), 500
except Exception as e:
print(f"Error adding user: {e}")
db.rollback()
cursor.close()
db.close()
return jsonify({'success': False, 'message': str(e)}), 500
finally:
db.close()
except Exception as e:
return jsonify({'success': False, 'message': 'Unknown error'}), 500
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=8080)
@app.route('/maintenance')
def maintenance():
return redirect(url_for('maintenance.maintenance'))

38
app/config.py Normal file
View File

@@ -0,0 +1,38 @@
import os
class Config:
DEBUG = False
TESTING = False
SECRET_KEY = os.getenv('FLASK_SECRET_KEY', 'insecure-default-key')
# Database connection info
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', '3306'))
DB_USER = os.getenv('DB_USER', 'radiususer')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'radiuspass')
DB_NAME = os.getenv('DB_NAME', 'radius')
# Logging
LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'false').lower() == 'true'
LOG_FILE_PATH = os.getenv('LOG_FILE_PATH', '/app/logs/app.log')
# MAC Lookup API
OUI_API_URL = os.getenv('OUI_API_URL', 'https://api.maclookup.app/v2/macs/{}')
OUI_API_KEY = os.getenv('OUI_API_KEY', '')
OUI_API_LIMIT_PER_SEC = int(os.getenv('OUI_API_LIMIT_PER_SEC', '2'))
OUI_API_DAILY_LIMIT = int(os.getenv('OUI_API_DAILY_LIMIT', '10000'))
# Timezone
APP_TIMEZONE = os.getenv('APP_TIMEZONE', 'UTC')
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
# Runtime selection
if os.getenv('FLASK_ENV') == 'production':
app_config = ProductionConfig
else:
app_config = DevelopmentConfig

20
app/database.py Normal file
View File

@@ -0,0 +1,20 @@
import mysql.connector
from flask import current_app, g
def get_db():
if 'db' not in g:
g.db = mysql.connector.connect(
host=current_app.config['DB_HOST'],
port=current_app.config['DB_PORT'],
user=current_app.config['DB_USER'],
password=current_app.config['DB_PASSWORD'],
database=current_app.config['DB_NAME']
)
return g.db
def init_app(app):
@app.teardown_appcontext
def close_connection(exception):
db = g.pop('db', None)
if db is not None:
db.close()

11
app/db_connection.py Normal file
View File

@@ -0,0 +1,11 @@
import mysql.connector
import os
def get_connection():
return mysql.connector.connect(
host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
database=os.getenv('DB_NAME'),
)

721
app/db_interface.py Normal file
View File

@@ -0,0 +1,721 @@
from flask import current_app, request, redirect, url_for, flash
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
import shutil
# ------------------------------
# User Management Functions
# ------------------------------
def get_all_users():
"""Retrieve all users with associated group and vendor information."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
u.*,
g.vlan_id AS group_vlan_id,
g.description AS group_description,
COALESCE(m.vendor_name, '...') AS vendor
FROM users u
LEFT JOIN groups g ON u.vlan_id = g.vlan_id
LEFT JOIN mac_vendors m ON LOWER(REPLACE(REPLACE(u.mac_address, ':', ''), '-', '')) LIKE CONCAT(m.mac_prefix, '%')
""")
users = cursor.fetchall()
cursor.close()
conn.close()
return users
def get_user_by_mac(mac_address):
"""Retrieve a user record from the database by MAC address."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT * FROM users WHERE mac_address = %s
""", (mac_address,))
user = cursor.fetchone()
cursor.close()
conn.close()
return user
def get_users_by_vlan_id(vlan_id):
"""Fetch users assigned to a specific VLAN ID."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT mac_address, description FROM users WHERE vlan_id = %s", (vlan_id,))
users = cursor.fetchall()
cursor.close()
conn.close()
return users
def add_user(mac_address, description, vlan_id):
"""Insert a new user with MAC address, description, and VLAN assignment."""
print(f"→ Adding to DB: mac={mac_address}, desc={description}, vlan={vlan_id}")
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (mac_address, description, vlan_id) VALUES (%s, %s, %s)",
(mac_address.lower(), description, vlan_id)
)
conn.commit()
cursor.close()
conn.close()
def update_user(mac_address, description, vlan_id):
"""Update both description and VLAN ID for a given MAC address."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET description = %s, vlan_id = %s WHERE mac_address = %s",
(description, vlan_id, mac_address.lower())
)
conn.commit()
cursor.close()
conn.close()
def delete_user(mac_address):
"""Remove a user from the database by their MAC address."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE mac_address = %s", (mac_address.lower(),))
conn.commit()
cursor.close()
conn.close()
# ------------------------------
# Group Management Functions
# ------------------------------
def get_all_groups():
"""Retrieve all groups along with user count for each group."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT g.*, (
SELECT COUNT(*) FROM users WHERE vlan_id = g.vlan_id
) AS user_count
FROM groups g
ORDER BY g.vlan_id
""")
available_groups = cursor.fetchall()
cursor.close()
conn.close()
return available_groups
def add_group(vlan_id, description):
"""Insert a new group with a specified VLAN ID and description."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO groups (vlan_id, description) VALUES (%s, %s)", (vlan_id, description))
conn.commit()
cursor.close()
conn.close()
def update_group_description(vlan_id, description):
"""Update the description for a given MAC address in the users table."""
# Docstring seems incorrect (mentions MAC address), but keeping original text.
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE groups SET description = %s WHERE vlan_id = %s", (description, vlan_id))
conn.commit()
cursor.close()
conn.close()
def delete_group(vlan_id, force_delete=False):
"""Delete a group, and optionally its associated users if force_delete=True."""
conn = get_connection()
cursor = conn.cursor()
try:
if force_delete:
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
conn.commit()
except mysql.connector.IntegrityError as e:
print(f"❌ Cannot delete group '{vlan_id}': it is still in use. Error: {e}")
raise
finally:
cursor.close()
conn.close()
def delete_group_route():
"""Handle deletion of a group and optionally its users via form POST."""
# Note: This function interacts with Flask's request/flash/redirect.
vlan_id = request.form.get("group_id")
force = request.form.get("force_delete") == "true"
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM users WHERE vlan_id = %s", (vlan_id,))
user_count = cursor.fetchone()[0]
if user_count > 0 and not force:
conn.close()
flash("Group has users. Please confirm deletion or reassign users.", "error")
# Assuming 'group.group_list' is a valid endpoint name
return redirect(url_for("group.group_list"))
try:
# Consider calling the delete_group() function here for consistency,
# but keeping original structure as requested.
if force:
cursor.execute("DELETE FROM users WHERE vlan_id = %s", (vlan_id,))
cursor.execute("DELETE FROM groups WHERE vlan_id = %s", (vlan_id,))
conn.commit()
flash(f"Group {vlan_id} and associated users deleted." if force else f"Group {vlan_id} deleted.", "success")
except mysql.connector.IntegrityError as e:
flash(f"Cannot delete group {vlan_id}: it is still in use. Error: {e}", "error")
except Exception as e:
flash(f"Error deleting group: {e}", "error")
finally:
cursor.close()
conn.close()
# Assuming 'group.group_list' is a valid endpoint name
return redirect(url_for("group.group_list"))
# ------------------------------
# MAC Vendor Functions
# ------------------------------
def get_known_mac_vendors():
"""Fetch all known MAC prefixes and their vendor info from the local database."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT mac_prefix, vendor_name, status FROM mac_vendors")
entries = cursor.fetchall()
cursor.close()
conn.close()
return {
row['mac_prefix'].lower(): {
'vendor': row['vendor_name'],
'status': row['status']
}
for row in entries
}
def get_vendor_info(mac, insert_if_found=True):
"""Get vendor info for a MAC address, optionally inserting into the database."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
print(f">>> Looking up MAC: {mac} → Prefix: {prefix}")
print("→ Searching in local database...")
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
row = cursor.fetchone()
if row:
print(f"✓ Found locally: {row['vendor_name']} (Status: {row['status']})")
cursor.close()
conn.close()
return {
"mac": mac,
"vendor": row['vendor_name'],
"source": "local",
"status": row['status']
}
print("✗ Not found locally, querying API...")
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
api_key = current_app.config.get("OUI_API_KEY", "")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
url = url_template.format(prefix)
print(f"→ Querying API: {url}")
response = requests.get(url, headers=headers)
vendor_to_insert = "not found"
status_to_insert = "not_found"
result = { "mac": mac, "vendor": "", "source": "api", "status": "not_found" } # Default
if response.status_code == 200:
data = response.json()
vendor = data.get("company", "").strip()
if vendor:
print(f"✓ Found from API: {vendor}")
vendor_to_insert = vendor
status_to_insert = "found"
result = { "mac": mac, "vendor": vendor, "source": "api", "status": "found" }
else:
print("⚠️ API returned empty company field. Treating as not_found.")
# vendor/status/result remain default 'not_found'
elif response.status_code == 404:
print("✗ API returned 404 - vendor not found.")
# vendor/status/result remain default 'not_found'
else:
print(f"✗ API error: {response.status_code}")
# Don't insert on other API errors
result = {"mac": mac, "vendor": "", "error": f"API error: {response.status_code}"}
# Skip insert logic below by returning early
cursor.close()
conn.close()
return result
# Insert/Update logic (Based on original code's comments, it always inserts/updates)
# Using 'insert_if_found' flag is ignored as per original code's apparent behaviour
cursor.execute("""
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
VALUES (%s, %s, %s, NOW(), NOW())
ON DUPLICATE KEY UPDATE
vendor_name = VALUES(vendor_name),
status = VALUES(status),
last_checked = NOW(),
last_updated = NOW()
""", (prefix, vendor_to_insert, status_to_insert))
print(f"→ Inserted/Updated '{vendor_to_insert}' ({status_to_insert}) for {prefix} → rowcount: {cursor.rowcount}")
conn.commit()
return result
except Exception as e:
print(f"✗ Exception while querying API: {e}")
return {"mac": mac, "vendor": "", "error": str(e)}
finally:
if conn and conn.is_connected():
if cursor:
cursor.close()
conn.close()
def lookup_mac_verbose(mac):
"""Look up vendor info for a MAC with verbose output, querying API if needed."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
output = []
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
output.append(f"🔍 Searching local database for prefix: {prefix}...")
cursor.execute("SELECT vendor_name, status FROM mac_vendors WHERE mac_prefix = %s", (prefix,))
row = cursor.fetchone()
if row:
output.append(f"✅ Found locally: {row['vendor_name']} (status: {row['status']})")
cursor.close()
conn.close()
return "\n".join(output)
output.append("❌ Not found locally.")
output.append("🌐 Querying API...")
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
api_key = current_app.config.get("OUI_API_KEY", "")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
url = url_template.format(prefix)
response = requests.get(url, headers=headers, timeout=10) # Add timeout
if response.status_code == 200:
data = response.json()
vendor_name = data.get("company", "").strip()
if vendor_name:
output.append(f"✅ Found via API: {vendor_name}")
output.append("💾 Inserting into local database...")
# Original code here used simple INSERT, not INSERT...ON DUPLICATE KEY UPDATE
# Consider changing to match get_vendor_info for consistency if desired.
# Sticking to original code for now.
cursor.execute("""
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
VALUES (%s, %s, 'found', NOW(), NOW())
""", (prefix, vendor_name))
conn.commit()
output.append(f" → Inserted '{vendor_name}' for {prefix} (rowcount: {cursor.rowcount})")
else:
output.append("⚠️ API responded but no vendor name found. Not inserting.")
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
elif response.status_code == 404:
output.append("❌ Not found via API (404). Not inserting.")
# Consider inserting 'not_found' status here for consistency? Original code doesn't.
else:
output.append(f"❌ API returned unexpected status: {response.status_code}")
except requests.exceptions.RequestException as e:
output.append(f"🚨 Network/Request Exception during API request: {e}")
except Exception as e:
output.append(f"🚨 Unexpected Exception during API request: {e}")
conn.rollback() # Rollback on general exception during DB interaction phase
finally:
if conn and conn.is_connected():
if cursor:
cursor.close()
conn.close()
return "\n".join(output)
def refresh_vendors():
"""Fetch and cache vendor info for unknown MAC prefixes using the API."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
# Fetch all distinct 6-char prefixes from users table that are NOT in mac_vendors table
cursor.execute("""
SELECT DISTINCT SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) AS mac_prefix
FROM users
WHERE SUBSTRING(REPLACE(REPLACE(mac_address, ':', ''), '-', ''), 1, 6) NOT IN (
SELECT mac_prefix FROM mac_vendors
)
""")
prefixes = [row['mac_prefix'].lower() for row in cursor.fetchall() if row['mac_prefix']]
cursor.close()
if not prefixes:
print("→ No unknown MAC prefixes found in users table to refresh.")
conn.close()
return
print(f"→ Found {len(prefixes)} unknown prefixes to look up.")
url_template = current_app.config.get("OUI_API_URL", "https://api.maclookup.app/v2/macs/{}")
api_key = current_app.config.get("OUI_API_KEY", "")
rate_limit = int(current_app.config.get("OUI_API_LIMIT_PER_SEC", 2))
daily_limit = int(current_app.config.get("OUI_API_DAILY_LIMIT", 10000))
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
inserted_count = 0
skipped_count = 0
error_count = 0
cursor = conn.cursor() # Use standard cursor for inserts
for i, prefix in enumerate(prefixes):
if inserted_count >= daily_limit:
print(f"🛑 Reached daily API limit ({daily_limit}). Stopping refresh.")
break
print(f" ({i+1}/{len(prefixes)}) Looking up prefix: {prefix}")
try:
url = url_template.format(prefix)
response = requests.get(url, headers=headers, timeout=10)
vendor_name = "not found"
status = "not_found"
if response.status_code == 200:
data = response.json()
api_vendor = data.get("company", "").strip()
if api_vendor:
vendor_name = api_vendor
status = "found"
print(f" ✓ Found: {vendor_name}")
else:
print(f" ⚠️ API OK, but empty vendor for {prefix}. Marked as 'not found'.")
elif response.status_code == 404:
print(f" ✗ Not found (404) for {prefix}.")
else:
print(f" ❌ API error {response.status_code} for {prefix}, skipping insert.")
error_count += 1
time.sleep(1.0 / rate_limit)
continue
# Insert or Update logic matches get_vendor_info
cursor.execute("""
INSERT INTO mac_vendors (mac_prefix, vendor_name, status, last_checked, last_updated)
VALUES (%s, %s, %s, NOW(), NOW())
ON DUPLICATE KEY UPDATE
vendor_name = VALUES(vendor_name),
status = VALUES(status),
last_checked = NOW(),
last_updated = NOW()
""", (prefix, vendor_name, status))
conn.commit()
if cursor.rowcount > 0:
inserted_count += 1
print(f" → Stored '{vendor_name}' ({status}) for {prefix}")
else:
print(f" → No change recorded for {prefix}.")
skipped_count +=1
except requests.exceptions.RequestException as e:
print(f" 🚨 Network/Request Exception fetching vendor for {prefix}: {e}")
error_count += 1
except Exception as e:
print(f" 🚨 Unexpected Exception fetching vendor for {prefix}: {e}")
error_count += 1
conn.rollback()
time.sleep(1.0 / rate_limit)
print(f"→ Refresh finished. Inserted/Updated: {inserted_count}, Skipped/No change: {skipped_count}, Errors: {error_count}")
cursor.close()
conn.close()
# ------------------------------
# Authentication Log Functions
# ------------------------------
def get_latest_auth_logs(reply_type=None, limit=5, time_range=None, offset=0):
"""Retrieve recent authentication logs filtered by reply type and time range."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
try:
app_tz = pytz.timezone(tz_str)
except pytz.UnknownTimeZoneError:
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
app_tz = pytz.utc
now = datetime.now(app_tz)
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
query_base = "SELECT * FROM auth_logs"
filters = []
params = []
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")
params.append(reply_type)
time_filter_dt = None
if time_range and time_range != 'all':
delta = {
'last_minute': timedelta(minutes=1),
'last_5_minutes': timedelta(minutes=5),
'last_10_minutes': timedelta(minutes=10),
'last_hour': timedelta(hours=1),
'last_6_hours': timedelta(hours=6),
'last_12_hours': timedelta(hours=12),
'last_day': timedelta(days=1),
'last_30_days': timedelta(days=30)
}.get(time_range)
if delta:
time_filter_dt = now - delta
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
filters.append("timestamp >= %s")
params.append(time_filter_dt)
if filters:
query_base += " WHERE " + " AND ".join(filters)
query_base += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
cursor.execute(query_base, tuple(params))
logs = cursor.fetchall()
cursor.close()
conn.close()
return logs
def count_auth_logs(reply_type=None, time_range=None):
"""Count the number of authentication logs matching a reply type and time."""
conn = get_connection()
cursor = conn.cursor()
tz_str = current_app.config.get('APP_TIMEZONE', 'UTC')
try:
app_tz = pytz.timezone(tz_str)
except pytz.UnknownTimeZoneError:
print(f"Warning: Unknown timezone '{tz_str}', falling back to UTC.")
app_tz = pytz.utc
now = datetime.now(app_tz)
print(f"🕒 Using timezone: {tz_str} → Now: {now.isoformat()}")
query_base = "SELECT COUNT(*) FROM auth_logs"
filters = []
params = []
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")
params.append(reply_type)
time_filter_dt = None
if time_range and time_range != 'all':
delta = {
'last_minute': timedelta(minutes=1),
'last_5_minutes': timedelta(minutes=5),
'last_10_minutes': timedelta(minutes=10),
'last_hour': timedelta(hours=1),
'last_6_hours': timedelta(hours=6),
'last_12_hours': timedelta(hours=12),
'last_day': timedelta(days=1),
'last_30_days': timedelta(days=30)
}.get(time_range)
if delta:
time_filter_dt = now - delta
print(f"🕒 Filtering logs after: {time_filter_dt.isoformat()}")
filters.append("timestamp >= %s")
params.append(time_filter_dt)
if filters:
query_base += " WHERE " + " AND ".join(filters)
cursor.execute(query_base, tuple(params))
count = cursor.fetchone()[0]
cursor.close()
conn.close()
return count
# ------------------------------
# Summary Functions
# ------------------------------
def get_summary_counts():
"""Return total counts of users and groups from the database."""
conn = get_connection()
cursor = conn.cursor(dictionary=True)
total_users = 0
total_groups = 0
try:
cursor.execute("SELECT COUNT(*) AS count FROM users")
result = cursor.fetchone()
total_users = result['count'] if result else 0
cursor.execute("SELECT COUNT(*) AS count FROM groups")
result = cursor.fetchone()
total_groups = result['count'] if result else 0
except Exception as e:
print(f"Error getting summary counts: {e}")
finally:
cursor.close()
conn.close()
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

@@ -1,2 +1,8 @@
Flask
mysql-connector-python
requests
BeautifulSoup4
lxml
gunicorn
pytz
humanize

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
app/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

511
app/static/styles.css Normal file
View File

@@ -0,0 +1,511 @@
:root {
--bg: #121212;
--fg: #f0f0f0;
--accent: #ffdd57; /* Soft yellow */
--error: crimson;
--cell-bg: #1e1e1e;
--card-bg: #2c2c2c;
--header-bg: #2a2a2a;
}
[data-theme="light"] {
--bg: #f8f9fa;
--fg: #212529;
--accent: #4a90e2; /* Softer blue */
--error: red;
--cell-bg: #ffffff;
--card-bg: #e9ecef;
--header-bg: #dee2e6;
}
body {
background-color: var(--bg);
color: var(--fg);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 1rem 2rem;
}
nav {
background-color: var(--card-bg);
padding: 1rem;
border-bottom: 1px solid #666;
display: flex;
justify-content: space-between;
align-items: center;
}
nav .links a {
margin-right: 1rem;
color: var(--fg);
text-decoration: none;
font-weight: bold;
}
nav .links a:hover {
text-decoration: underline;
}
button#theme-toggle {
background: none;
border: 1px solid var(--fg);
padding: 4px 8px;
color: var(--fg);
cursor: pointer;
border-radius: 4px;
}
h1, h2, h3 {
color: var(--fg);
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background-color: var(--accent);
color: black;
padding: 10px 16px;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.5s ease;
z-index: 9999;
}
.toast.show {
opacity: 1;
}
#scrollTopBtn {
display: none;
position: fixed;
bottom: 30px;
left: 30px;
z-index: 1000;
font-size: 1.2rem;
background-color: var(--accent);
border: none;
border-radius: 50%;
padding: 10px 14px;
cursor: pointer;
color: black;
}
table.styled-table {
border-collapse: collapse;
width: 100%;
margin-top: 1rem;
background-color: var(--cell-bg);
}
.styled-table thead {
position: sticky;
top: 0;
background-color: var(--header-bg);
z-index: 5;
}
.styled-table th,
.styled-table td {
padding: 10px 14px;
border: 1px solid #999;
}
.styled-table th {
background-color: var(--header-bg);
color: var(--fg);
text-align: center;
}
/* 🧩 Fix: Remove right border from last column */
.styled-table thead th:last-child {
border-right: none;
}
.styled-table input[type="text"],
.styled-table select {
width: 100%;
box-sizing: border-box;
padding: 6px;
color: var(--fg);
background-color: var(--cell-bg);
border: 1px solid #ccc;
border-radius: 4px;
}
.styled-table input[type="text"]:focus,
.styled-table select:focus {
outline: none;
border-color: var(--accent);
}
form.inline-form {
display: inline-flex;
gap: 4px;
align-items: center;
}
.styled-table button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 1rem;
color: inherit;
}
[data-theme="light"] .styled-table button {
background: transparent;
}
.styled-table button[title="Save"] {
color: var(--accent);
}
#refresh-vendors {
background: none;
color: var(--accent);
border: none;
padding: 0;
font-size: 1rem;
cursor: pointer;
}
.styled-table button[onclick*="Delete"] {
color: var(--error);
background: none;
}
.styled-table td form {
margin: 0;
}
.stats-cards {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--cell-bg);
border: 1px solid #666;
padding: 1rem;
border-radius: 8px;
flex: 1;
text-align: center;
}
.card.neutral {
background-color: var(--card-bg);
}
.event-list {
list-style: none;
padding: 0;
}
.event-list li {
padding: 4px 0;
border-bottom: 1px dashed #666;
}
.event-list.green li { color: #4caf50; }
.event-list.red li { color: #ff4d4d; }
#mac-lookup-form input {
padding: 6px;
border-radius: 4px;
border: 1px solid #999;
width: 250px;
color: var(--fg);
background-color: var(--cell-bg);
}
#mac-lookup-form button {
padding: 6px 12px;
margin-left: 10px;
cursor: pointer;
border: none;
border-radius: 4px;
background-color: var(--accent);
color: black;
}
.debug-output {
background-color: #222;
color: #b6fcd5;
border: 1px solid #333;
padding: 1em;
font-size: 0.9rem;
white-space: pre-wrap;
margin-top: 1em;
}
.stats-page .stats-container {
display: flex;
flex-wrap: wrap;
gap: 2rem;
}
.stats-page .card {
flex: 1;
min-width: 45%;
padding: 1rem;
border-radius: 8px;
background-color: var(--card-bg);
color: var(--fg);
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
.stats-page .success-card {
border-left: 6px solid limegreen !important;
}
.stats-page .error-card {
border-left: 6px solid crimson !important;
}
.stats-page .fallback-card {
border-left: 6px solid orange !important;
}
.stats-page .styled-table.small-table td,
.stats-page .styled-table.small-table th {
padding: 6px;
font-size: 0.9rem;
}
.stats-page form.inline-form {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.stats-page form.inline-form select {
flex: 1;
min-width: 140px;
max-width: 100%;
}
.stats-page form.inline-form button {
flex: 0 0 auto;
padding: 6px;
}
.pagination {
margin-top: 0.75rem;
text-align: center;
}
.pagination a,
.pagination span.current-page {
display: inline-block;
padding: 4px 10px;
margin: 0 3px;
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--fg);
background-color: transparent;
text-decoration: none;
}
.pagination span.current-page {
font-weight: bold;
background-color: var(--accent);
color: black;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-card {
background: var(--card-bg);
color: var(--fg);
padding: 2rem;
border-radius: 10px;
max-width: 500px;
width: 90%;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.6);
}
.modal-actions {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-actions button,
.modal-actions form button {
padding: 0.5rem 1rem;
font-weight: bold;
cursor: pointer;
border-radius: 5px;
border: none;
}
.modal-actions button {
background-color: #ccc;
color: black;
}
.modal-actions button.danger {
background-color: var(--error);
color: white;
}
.modal {
position: fixed;
z-index: 10000;
left: 0; top: 0;
width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex; align-items: center; justify-content: center;
}
.modal-content {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
color: var(--fg);
width: 500px;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 0 15px rgba(0,0,0,0.3);
}
.modal-actions {
margin-top: 1rem;
display: flex;
justify-content: space-between;
}
.user-list {
margin-top: 1rem;
max-height: 200px;
overflow-y: auto;
border: 1px solid #555;
padding: 0.5rem;
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;
}
.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

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Add User</title>
</head>
<body>
<h1>Add User</h1>
<form method="POST">
<label for="mac_address">MAC Address:</label>
<input type="text" name="mac_address" required><br><br>
<label for="vlan_id">VLAN ID:</label>
<input type="text" name="vlan_id" required><br><br>
<label for="description">Description:</label>
<input type="text" name="description"><br><br>
<input type="submit" value="Add User">
</form>
<a href="{{ url_for('user_list') }}">Back to User List</a>
</body>
</html>

View File

@@ -1,35 +1,70 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>{% block title %}{% endblock %}</title>
<style>
nav {
background-color: #f0f0f0;
padding: 10px;
}
nav a {
margin-right: 10px;
text-decoration: none;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
nav a.active {
background-color: #ddd;
}
</style>
<meta charset="UTF-8" />
<title>{% block title %}RadMac{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<nav>
<a href="/" {% if request.path == '/' %}class="active"{% endif %}>Home</a>
<a href="/user_list" {% if request.path == '/user_list' %}class="active"{% endif %}>User List</a>
<a href="/groups" {% if request.path == '/groups' %}class="active"{% endif %}>Group List</a>
</nav>
<div class="content">
{% block content %}{% endblock %}
<nav>
<div class="links">
<a href="{{ url_for('index_redirect') }}">Home</a>
<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>
</div>
</nav>
{% block content %}{% endblock %}
<div id="toast" class="toast"></div>
<button id="scrollTopBtn" title="Back to top"></button>
<script>
// Theme toggle
const toggleBtn = document.getElementById('theme-toggle');
const savedTheme = localStorage.getItem('theme');
if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme);
toggleBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
// Toast function
window.showToast = function (msg, duration = 3000) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), duration);
};
// Scroll-to-top button
const scrollBtn = document.getElementById('scrollTopBtn');
window.onscroll = () => {
scrollBtn.style.display = window.scrollY > 150 ? 'block' : 'none';
};
scrollBtn.onclick = () => window.scrollTo({ top: 0, behavior: 'smooth' });
// Preserve scroll position
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('scrollTop', window.scrollY);
});
window.addEventListener('load', () => {
const scrollTop = sessionStorage.getItem('scrollTop');
if (scrollTop !== null) {
window.scrollTo(0, parseInt(scrollTop));
sessionStorage.removeItem('scrollTop');
}
});
</script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Edit Attribute</title>
</head>
<body>
<h1>Edit Attribute</h1>
<form method="POST">
<label for="attribute">Attribute:</label><br>
<input type="text" id="attribute" name="attribute" value="{{ attribute_data.attribute }}"><br><br>
<label for="op">Op:</label><br>
<input type="text" id="op" name="op" value="{{ attribute_data.op }}"><br><br>
<label for="value">Value:</label><br>
<input type="text" id="value" name="value" value="{{ attribute_data.value }}"><br><br>
<input type="submit" value="Save Changes">
</form>
</body>
</html>

View File

@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Edit Group</title>
</head>
<body>
<h1>Edit Group: {{ group.id }}</h1>
<form method="POST">
<label for="groupname">Group Name:</label><br>
<input type="text" id="groupname" name="groupname" value="{{ group.groupname }}"><br><br>
<label for="attribute">Attribute:</label><br>
<input type="text" id="attribute" name="attribute" value="{{ group.attribute }}"><br><br>
<label for="op">Op:</label><br>
<input type="text" id="op" name="op" value="{{ group.op }}"><br><br>
<label for="value">Value:</label><br>
<input type="text" id="value" name="value" value="{{ group.value }}"><br><br>
<input type="submit" value="Save Changes">
</form>
</body>
</html>

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Edit Group Name</title>
</head>
<body>
<h1>Edit Group Name: {{ old_groupname }}</h1>
<form method="POST">
<label for="groupname">New Group Name:</label><br>
<input type="text" id="groupname" name="groupname" value="{{ old_groupname }}"><br><br>
<input type="submit" value="Save Changes">
</form>
</body>
</html>

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Edit User</title>
</head>
<body>
<h1>Edit User: {{ user.mac_address }}</h1>
<form method="POST">
<label for="description">Description:</label><br>
<input type="text" id="description" name="description" value="{{ user.description }}"><br><br>
<label for="vlan_id">VLAN ID:</label><br>
<input type="text" id="vlan_id" name="vlan_id" value="{{ user.vlan_id }}"><br><br>
<input type="submit" value="Save Changes">
</form>
</body>
</html>

View File

@@ -1,38 +1,118 @@
<!DOCTYPE html>
<html>
<head>
<title>Group List</title>
</head>
<body>
<h1>Group List</h1>
{% extends 'base.html' %}
{% block title %}VLAN Groups{% endblock %}
<table border="1">
<thead>
<tr>
<th>ID</th>
<th>Group Name</th>
<th>Attribute</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for group in results %}
<tr>
<td>{{ group.id }}</td>
<td>{{ group.groupname }}</td>
<td>{{ group.attribute }}</td>
<td>{{ group.op }}</td>
<td>{{ group.value }}</td>
<td>
<a href="/edit_group/{{ group.id }}">Edit</a> |
<a href="/delete_group/{{ group.id }}">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% block content %}
<h1 class="page-title">VLAN Groups</h1>
</body>
</html>
<form method="POST" action="{{ url_for('group.add_group_route') }}" style="margin-bottom: 1rem;">
<input type="text" name="vlan_id" placeholder="VLAN ID" required pattern="[0-9]+" style="width: 80px;">
<input type="text" name="description" placeholder="Group Description">
<button type="submit"> Add Group</button>
</form>
<table class="styled-table">
<thead>
<tr>
<th>VLAN ID</th>
<th>Description</th>
<th>User Count</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for group in available_groups %}
<tr>
<form method="POST" action="{{ url_for('group.update_description_route') }}" class="preserve-scroll">
<input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<td>{{ group.vlan_id }}</td>
<td>
<input type="text" name="description" value="{{ group.description or '' }}" class="description-input">
</td>
<td>{{ group.user_count }}</td>
<td>
<button type="submit" title="Save">💾</button>
</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;">
<input type="hidden" name="group_id" value="{{ group.vlan_id }}">
<button type="submit"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modal for confirm delete -->
<div id="confirmModal" class="modal" style="display: none;">
<div class="modal-content">
<p>This group has users assigned. What would you like to do?</p>
<div id="userList" class="user-list"></div>
<div class="modal-actions">
<button onclick="closeModal()">Cancel</button>
<form id="confirmDeleteForm" method="POST" action="{{ url_for('group.delete_group_route_handler') }}">
<input type="hidden" name="group_id" id="modalGroupId">
<input type="hidden" name="force_delete" value="true">
<button type="submit" class="danger">Delete Group and Users</button>
</form>
</div>
</div>
</div>
<script>
document.querySelectorAll('form.preserve-scroll').forEach(form => {
form.addEventListener('submit', () => {
localStorage.setItem('scrollY', window.scrollY);
});
});
window.addEventListener('load', () => {
const scrollY = localStorage.getItem('scrollY');
if (scrollY) {
window.scrollTo(0, parseInt(scrollY));
localStorage.removeItem('scrollY');
}
});
document.querySelectorAll('.delete-group-form').forEach(form => {
form.addEventListener('submit', function (e) {
const userCount = parseInt(this.dataset.userCount);
const groupId = this.querySelector('[name="group_id"]').value;
if (userCount > 0) {
e.preventDefault();
fetch('{{ url_for("group.get_users_for_group") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ vlan_id: groupId })
})
.then(resp => resp.json())
.then(users => {
const userListDiv = document.getElementById('userList');
userListDiv.innerHTML = '';
if (users.length > 0) {
const list = document.createElement('ul');
users.forEach(user => {
const item = document.createElement('li');
item.textContent = `${user.mac_address}${user.description || 'No description'}`;
list.appendChild(item);
});
userListDiv.appendChild(list);
} else {
userListDiv.textContent = 'No users found in this group.';
}
document.getElementById('modalGroupId').value = groupId;
document.getElementById('confirmModal').style.display = 'flex';
});
} else {
if (!confirm('Delete this group?')) e.preventDefault();
}
});
});
function closeModal() {
document.getElementById('confirmModal').style.display = 'none';
}
</script>
{% endblock %}

View File

@@ -1,364 +0,0 @@
{% extends 'base.html' %}
{% block title %}Group List{% endblock %}
{% block content %}
<h1>Group List</h1>
{% for groupname, attributes in grouped_results.items() %}
<table border="1">
<thead>
<tr>
<th>Group Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="groupname-{{ groupname }}" value="{{ groupname }}">
</td>
<td colspan="3" class="merged-cell">
<button onclick="addRow('{{ groupname }}')"></button>
</td>
<td>
<button onclick="updateGroupName('{{ groupname }}')">✅ Rename Group</button>
<button onclick="location.reload()"></button>
<a href="/delete_group_rows/{{ groupname }}" onclick="saveScrollPosition()">🗑️</a>
<button onclick="duplicateGroup('{{ groupname }}')">Duplicate</button>
</td>
</tr>
{% for attribute in attributes %}
<tr>
<td class="merged-cell"></td>
<td><input type="text" id="attribute-{{ attribute.id }}" value="{{ attribute.attribute }}"></td>
<td>
<select id="op-{{ attribute.id }}">
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
</select>
</td>
<td><input type="text" id="value-{{ attribute.id }}" value="{{ attribute.value }}"></td>
<td>
<button onclick="updateAttribute('{{ attribute.id }}')"></button>
<button onclick="location.reload()"></button>
<a href="/delete_group/{{ attribute.id }}" onclick="saveScrollPosition()">🗑️</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
<table border="1">
<thead>
<tr>
<th>Group Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="new-groupname" value="">
</td>
<td colspan="3" class="merged-cell"></td>
<td>
<button onclick="addNewGroup()">Add New Group</button>
</td>
</tr>
</tbody>
</table>
<dialog id="duplicate-dialog">
<div id="duplicate-dialog-content"></div>
<button id="close-dialog"></button>
<button id="save-duplicated-group">Save</button>
</dialog>
<style>
.merged-cell {
border: none;
}
</style>
<script>
function updateAttribute(attributeId) {
const attribute = document.getElementById(`attribute-${attributeId}`).value;
const op = document.getElementById(`op-${attributeId}`).value;
const value = document.getElementById(`value-${attributeId}`).value;
fetch('/update_attribute', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `attributeId=${attributeId}&attribute=${attribute}&op=${op}&value=${value}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error updating attribute: ' + data);
}
});
}
function updateGroupName(oldGroupName) {
const newGroupName = document.getElementById(`groupname-${oldGroupName}`).value;
fetch('/update_group_name', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `oldGroupName=${oldGroupName}&newGroupName=${newGroupName}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error updating group name: ' + data);
}
});
}
function addRow(groupName) {
const table = event.target.closest('table').querySelector('tbody');
const newRow = table.insertRow(table.rows.length);
const cell1 = newRow.insertCell(0);
const cell2 = newRow.insertCell(1);
const cell3 = newRow.insertCell(2);
const cell4 = newRow.insertCell(3);
const cell5 = newRow.insertCell(4);
cell1.classList.add('merged-cell');
cell2.innerHTML = '<input type="text" id="new-attribute" value="">';
cell3.innerHTML = `
<select id="new-op">
<option value="=">=</option>
<option value="!=">!=</option>
<option value=">">></option>
<option value="<"><</option>
<option value=">=">>=</option>
<option value="<="><=</option>
</select>
`;
cell4.innerHTML = '<input type="text" id="new-value" value="">';
cell5.innerHTML = '<button onclick="saveNewRow(\'' + groupName + '\', this)">✅</button> <button onclick="removeRow(this)">❌</button>';
}
function saveNewRow(groupName, button) {
const row = button.parentNode.parentNode;
const attribute = row.querySelector('#new-attribute').value;
const op = row.querySelector('#new-op').value;
const value = row.querySelector('#new-value').value;
fetch('/add_attribute', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `groupname=${groupName}&attribute=${attribute}&op=${op}&value=${value}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error adding attribute: ' + data);
}
});
}
function removeRow(button) {
const row = button.parentNode.parentNode;
row.parentNode.removeChild(row);
}
function addNewGroup() {
const newGroupName = document.getElementById('new-groupname').value;
fetch('/add_group', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `groupname=${newGroupName}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error adding group: ' + data);
}
});
}
function saveScrollPosition() {
sessionStorage.setItem('scrollPosition', window.scrollY);
}
window.onload = function() {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, scrollPosition);
sessionStorage.removeItem('scrollPosition');
}
}
function duplicateGroup(groupName) {
fetch('/duplicate_group', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `groupname=${groupName}`
})
.then(response => response.json())
.then(data => {
const newGroupName = 'Copy of ' + groupName;
let newTable = `<table border="1">
<thead>
<tr>
<th>Group Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="new-groupname" value="${newGroupName}">
</td>
<td colspan="3" class="merged-cell"></td>
<td></td>
</tr>`;
data.forEach((attribute, index) => {
newTable += `<tr>
<td class="merged-cell"></td>
<td><input type="text" class="new-attribute" value="${attribute.attribute}"></td>
<td>
<select class="new-op">
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
</select>
</td>
<td><input type="text" class="new-value" value="${attribute.value}"></td>
<td><button onclick="removeDuplicatedRow(this)">🗑️</button></td>
</tr>`;
});
newTable += `<tr>
<td class="merged-cell"></td>
<td colspan="3">
<button onclick="addDuplicatedRow()"></button>
</td>
<td></td>
</tr></tbody></table>`;
document.getElementById('duplicate-dialog-content').innerHTML = newTable;
document.getElementById('duplicate-dialog').showModal();
});
}
document.getElementById('close-dialog').addEventListener('click', () => {
document.getElementById('duplicate-dialog').close();
});
document.getElementById('save-duplicated-group').addEventListener('click', () => {
saveDuplicatedGroup();
});
function saveDuplicatedGroup() {
let rows = document.querySelectorAll('#duplicate-dialog-content table tbody tr');
let groupname = rows[0].querySelector('#new-groupname').value;
let attributes = [];
for (let i = 1; i < rows.length - 1; i++) {
const attributeInput = rows[i].querySelector(`.new-attribute`);
const opInput = rows[i].querySelector(`.new-op`);
const valueInput = rows[i].querySelector(`.new-value`);
if (attributeInput && opInput && valueInput) {
attributes.push({
attribute: attributeInput.value,
op: opInput.value,
value: valueInput.value
});
} else {
console.warn(`Input elements not found for row ${i}`);
return;
}
}
fetch('/save_duplicated_group', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ groupname: groupname, attributes: attributes })
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
document.getElementById('duplicate-dialog').close();
location.reload();
} else {
alert('Error saving duplicated group: ' + data);
}
});
}
function addDuplicatedRow() {
const table = document.querySelector('#duplicate-dialog-content table tbody');
const newRow = table.insertRow(table.rows.length - 1);
const cell1 = newRow.insertCell(0);
const cell2 = newRow.insertCell(1);
const cell3 = newRow.insertCell(2);
const cell4 = newRow.insertCell(3);
const cell5 = newRow.insertCell(4);
cell1.classList.add('merged-cell');
cell2.innerHTML = `<input type="text" class="new-attribute" value="">`;
cell3.innerHTML = `
<select class="new-op">
<option value="=">=</option>
<option value="!=">!=</option>
<option value=">">></option>
<option value="<"><</option>
<option value=">=">>=</option>
<option value="<="><=</option>
</select>
`;
cell4.innerHTML = `<input type="text" class="new-value" value="">`;
cell5.innerHTML = `<button onclick="removeDuplicatedRow(this)">🗑️</button>`;
}
function removeDuplicatedRow(button) {
const row = button.parentNode.parentNode;
row.parentNode.removeChild(row);
}
</script>
{% endblock %}

View File

@@ -1,43 +1,70 @@
{% extends 'base.html' %}
{% block title %}FreeRADIUS Manager{% endblock %}
{% block title %}RadMac{% endblock %}
{% block content %}
<h1>FreeRADIUS Manager</h1>
<h1 class="page-title">RadMac</h1>
<h2>Statistics:</h2>
<p>Total Users: {{ total_users }}</p>
<p>Total Groups: {{ total_groups }}</p>
<div class="stats-cards">
<div class="card neutral">
<strong>Total MAC Addresses</strong>
<p>{{ total_users }}</p>
</div>
<div class="card neutral">
<strong>Total VLAN Groups</strong>
<p>{{ total_groups }}</p>
</div>
</div>
<h2>SQL Query Tool:</h2>
<form method="POST" action="/sql">
<textarea name="query" rows="5" cols="50"></textarea><br>
<button type="submit">Execute Query</button>
</form>
<h2>Recent Access Logs</h2>
<ul class="event-list">
<li><strong>Access-Accept Logs</strong></li>
{% for log in latest_accept %}
<li>
<strong>{{ log.mac_address }}</strong> - {{ log.reply }}
<br>
<small>{{ log.timestamp }} - {{ log.result }}</small>
</li>
{% endfor %}
{% if sql_results %}
<h2>Query Results:</h2>
<table border="1">
<thead>
<tr>
{% for key in sql_results[0].keys() %}
<th>{{ key }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in sql_results %}
<tr>
{% for value in row.values() %}
<td>{{ value }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<li><strong>Access-Reject Logs</strong></li>
{% for log in latest_reject %}
<li>
<strong>{{ log.mac_address }}</strong> - {{ log.reply }}
<br>
<small>{{ log.timestamp }} - {{ log.result }}</small>
</li>
{% endfor %}
</ul>
<h2>MAC Vendor Lookup</h2>
<form id="mac-lookup-form" method="POST" action="/lookup_mac">
<input type="text" name="mac" id="mac-input" placeholder="Enter MAC address" required>
<button type="submit">🔍 Lookup</button>
</form>
<pre id="mac-result" class="debug-output"></pre>
<script>
document.getElementById('mac-lookup-form').addEventListener('submit', function(e) {
e.preventDefault();
const form = e.target;
const data = new URLSearchParams(new FormData(form));
const resultBox = document.getElementById('mac-result');
resultBox.textContent = "Querying...";
fetch(form.action, {
method: 'POST',
body: data,
})
.then(r => r.json())
.then(data => {
// Update: Use 'output' from the API response
resultBox.textContent = data.output || "No data returned.";
})
.catch(err => {
resultBox.textContent = `Error: ${err}`;
});
});
</script>
{% if sql_error %}
<p style="color: red;">{{ sql_error }}</p>
{% endif %}
{% endblock %}

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 %}

163
app/templates/stats.html Normal file
View File

@@ -0,0 +1,163 @@
{% extends 'base.html' %}
{% block title %}Authentication Stats{% endblock %}
{% block content %}
<div class="stats-page">
<h1 class="page-title">Authentication Stats</h1>
<div class="controls-card">
<div class="control-group">
<label for="time_range">Time Range:</label>
<select name="time_range" id="time_range">
<option value="last_minute">Last 1 Minute</option>
<option value="last_5_minutes">Last 5 Minutes</option>
<option value="last_10_minutes">Last 10 Minutes</option>
<option value="last_hour">Last Hour</option>
<option value="last_6_hours">Last 6 Hours</option>
<option value="last_12_hours">Last 12 Hours</option>
<option value="last_day">Last Day</option>
<option value="last_30_days">Last 30 Days</option>
<option value="all">All Time</option>
</select>
</div>
<div class="control-group">
<label for="per_page">Entries per page:</label>
<select name="per_page" id="per_page">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="control-group auto-refresh-block">
<label>
<input type="checkbox" id="auto-refresh-checkbox"> Auto-refresh
</label>
<select id="refresh-interval">
<option value="15000">15s</option>
<option value="30000" selected>30s</option>
<option value="60000">1 min</option>
<option value="300000">5 min</option>
</select>
<span id="refresh-status"></span>
</div>
<div 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>
<script>
document.addEventListener('DOMContentLoaded', () => {
const statsRoot = document.getElementById('stats-root');
const timeRangeSelect = document.getElementById('time_range');
const perPageSelect = document.getElementById('per_page');
const searchInput = document.getElementById('stats-search');
const refreshCheckbox = document.getElementById('auto-refresh-checkbox');
const refreshInterval = document.getElementById('refresh-interval');
const refreshStatus = document.getElementById('refresh-status');
let intervalId = null;
let currentPageAccept = 1;
let currentPageReject = 1;
let currentPageFallback = 1;
function setInitialSelectValuesFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const time = urlParams.get('time_range');
const page = urlParams.get('per_page');
if (time) timeRangeSelect.value = time;
if (page) perPageSelect.value = page;
}
async function fetchStatsData() {
try {
const timeRange = timeRangeSelect.value;
const perPage = perPageSelect.value;
const params = new URLSearchParams({
time_range: timeRange,
per_page: perPage,
page_accept: currentPageAccept,
page_reject: currentPageReject,
page_fallback: currentPageFallback
});
const response = await fetch(`/stats/fetch_stats_data?${params}`);
const html = await response.text();
statsRoot.innerHTML = html;
filterRows();
attachPaginationHandlers();
} catch (err) {
console.error('Error fetching stats data:', err);
refreshStatus.textContent = 'Error loading stats data.';
}
}
function startAutoRefresh() {
refreshStatus.textContent = `Refreshing every ${refreshInterval.selectedOptions[0].text}`;
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(fetchStatsData, parseInt(refreshInterval.value));
}
function stopAutoRefresh() {
refreshStatus.textContent = "Auto-refresh disabled";
if (intervalId) clearInterval(intervalId);
}
function filterRows() {
const query = searchInput.value.toLowerCase();
document.querySelectorAll('.styled-table tbody tr').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none';
});
}
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>
{% endblock %}

View File

@@ -1,364 +1,78 @@
{% extends 'base.html' %}
{% block title %}User List{% endblock %}
{% block title %}MAC Address List{% endblock %}
{% block content %}
<h1>User List</h1>
<h1 class="page-title">MAC Address List</h1>
{% for username, attributes in grouped_users.items() %}
<table border="1">
<thead>
<tr>
<th>User Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="username-{{ username }}" value="{{ username }}">
</td>
<td colspan="3" class="merged-cell">
<button onclick="addUserRow('{{ username }}')"></button>
</td>
<td>
<button onclick="updateUserName('{{ username }}')">✅ Rename User</button>
<button onclick="location.reload()"></button>
<a href="/delete_user_rows/{{ username }}" onclick="saveScrollPosition()">🗑️</a>
<button onclick="duplicateUser('{{ username }}')">Duplicate</button>
</td>
</tr>
{% for attribute in attributes %}
<tr>
<td class="merged-cell"></td>
<td><input type="text" id="attribute-{{ attribute.id }}" value="{{ attribute.attribute }}"></td>
<td>
<select id="op-{{ attribute.id }}">
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
</select>
</td>
<td><input type="text" id="value-{{ attribute.id }}" value="{{ attribute.value }}"></td>
<td>
<button onclick="updateAttribute('{{ attribute.id }}')"></button>
<button onclick="location.reload()"></button>
<a href="/delete_user/{{ attribute.id }}" onclick="saveScrollPosition()">🗑️</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<form id="add-user-form" method="POST" action="{{ url_for('user.add') }}">
<input type="text" name="mac_address" placeholder="MAC address (12 hex)" required maxlength="12">
<input type="text" name="description" placeholder="Description (optional)">
<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"> Add</button>
</form>
<table border="1">
<thead>
<tr>
<th>User Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="new-username" value="">
</td>
<td colspan="3" class="merged-cell"></td>
<td>
<button onclick="addNewUser()">Add New User</button>
</td>
</tr>
</tbody>
</table>
<table class="styled-table">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>Vendor <button id="refresh-vendors" title="Refresh unknown vendors">🔄</button></th>
<th>VLAN</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in users %}
<tr>
<td>{{ entry.mac_address }}</td>
<dialog id="duplicate-dialog">
<div id="duplicate-dialog-content"></div>
<button id="close-dialog"></button>
<button id="save-duplicated-user">Save</button>
</dialog>
<form method="POST" action="{{ url_for('user.update_user_route') }}">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<style>
.merged-cell {
border: none;
}
</style>
<td>
<input type="text" name="description" value="{{ entry.description or '' }}">
</td>
<script>
function updateAttribute(attributeId) {
const attribute = document.getElementById(`attribute-${attributeId}`).value;
const op = document.getElementById(`op-${attributeId}`).value;
const value = document.getElementById(`value-${attributeId}`).value;
<td>{{ entry.vendor or "..." }}</td>
fetch('/update_user_attribute', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `attributeId=${attributeId}&attribute=${attribute}&op=${op}&value=${value}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error updating attribute: ' + data);
}
});
}
<td>
<select name="group_id">
{% for group in available_groups %}
<option value="{{ group.vlan_id }}" {% if group.vlan_id == entry.vlan_id %}selected{% endif %}>
VLAN {{ group.vlan_id }}{% if group.description %} - {{ group.description }}{% endif %}
</option>
{% endfor %}
</select>
</td>
function updateUserName(oldUserName) {
const newUserName = document.getElementById(`username-${oldUserName}`).value;
<td>
<button type="submit" title="Save">💾</button>
</form>
fetch('/update_user_name', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `oldUserName=${oldUserName}&newUserName=${newUserName}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error updating user name: ' + data);
}
});
}
<form method="POST" action="{{ url_for('user.delete') }}" style="display:inline;">
<input type="hidden" name="mac_address" value="{{ entry.mac_address }}">
<button type="submit" onclick="return confirm('Delete this MAC address?')"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
function addUserRow(userName) {
const table = event.target.closest('table').querySelector('tbody');
const newRow = table.insertRow(table.rows.length);
const cell1 = newRow.insertCell(0);
const cell2 = newRow.insertCell(1);
const cell3 = newRow.insertCell(2);
const cell4 = newRow.insertCell(3);
const cell5 = newRow.insertCell(4);
cell1.classList.add('merged-cell');
cell2.innerHTML = '<input type="text" id="new-attribute" value="">';
cell3.innerHTML = `
<select id="new-op">
<option value="=">=</option>
<option value="!=">!=</option>
<option value=">">></option>
<option value="<"><</option>
<option value=">=">>=</option>
<option value="<="><=</option>
</select>
`;
cell4.innerHTML = '<input type="text" id="new-value" value="">';
cell5.innerHTML = '<button onclick="saveNewUserRow(\'' + userName + '\', this)">✅</button> <button onclick="removeUserRow(this)">❌</button>';
}
function saveNewUserRow(userName, button) {
const row = button.parentNode.parentNode;
const attribute = row.querySelector('#new-attribute').value;
const op = row.querySelector('#new-op').value;
const value = row.querySelector('#new-value').value;
fetch('/add_user_attribute', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `username=${userName}&attribute=${attribute}&op=${op}&value=${value}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error adding attribute: ' + data);
}
});
}
function removeUserRow(button) {
const row = button.parentNode.parentNode;
row.parentNode.removeChild(row);
}
function addNewUser() {
const newUserName = document.getElementById('new-username').value;
fetch('/add_user', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `username=${newUserName}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error adding user: ' + data);
}
});
}
function saveScrollPosition() {
sessionStorage.setItem('scrollPosition', window.scrollY);
}
window.onload = function() {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, scrollPosition);
sessionStorage.removeItem('scrollPosition');
}
}
function duplicateUser(userName) {
fetch('/duplicate_user', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `username=${userName}`
})
.then(response => response.json())
.then(data => {
const newUserName = 'Copy of ' + userName;
let newTable = `<table border="1">
<thead>
<tr>
<th>User Name</th>
<th>Attributes</th>
<th>Op</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="new-username" value="${newUserName}">
</td>
<td colspan="3" class="merged-cell"></td>
<td></td>
</tr>`;
data.forEach((attribute, index) => {
newTable += `<tr>
<td class="merged-cell"></td>
<td><input type="text" class="new-attribute" value="${attribute.attribute}"></td>
<td>
<select class="new-op">
<option value="=" ${attribute.op === '=' ? 'selected' : ''}>=</option>
<option value="!=" ${attribute.op === '!=' ? 'selected' : ''}>!=</option>
<option value=">" ${attribute.op === '>' ? 'selected' : ''}>></option>
<option value="<" ${attribute.op === '<' ? 'selected' : ''}><</option>
<option value=">=" ${attribute.op === '>=' ? 'selected' : ''}>>=</option>
<option value="<=" ${attribute.op === '<=' ? 'selected' : ''}><=</option>
</select>
</td>
<td><input type="text" class="new-value" value="${attribute.value}"></td>
<td><button onclick="removeDuplicatedUserRow(this)">🗑️</button></td>
</tr>`;
});
newTable += `<tr>
<td class="merged-cell"></td>
<td colspan="3">
<button onclick="addDuplicatedUserRow()"></button>
</td>
<td></td>
</tr></tbody></table>`;
document.getElementById('duplicate-dialog-content').innerHTML = newTable;
document.getElementById('duplicate-dialog').showModal();
});
}
document.getElementById('close-dialog').addEventListener('click', () => {
document.getElementById('duplicate-dialog').close();
});
document.getElementById('save-duplicated-user').addEventListener('click', () => {
saveDuplicatedUser();
});
function saveDuplicatedUser() {
let rows = document.querySelectorAll('#duplicate-dialog-content table tbody tr');
let username = rows[0].querySelector('#new-username').value;
let attributes = [];
for (let i = 1; i < rows.length - 1; i++) {
const attributeInput = rows[i].querySelector(`.new-attribute`);
const opInput = rows[i].querySelector(`.new-op`);
const valueInput = rows[i].querySelector(`.new-value`);
if (attributeInput && opInput && valueInput) {
attributes.push({
attribute: attributeInput.value,
op: opInput.value,
value: valueInput.value
});
} else {
console.warn(`Input elements not found for row ${i}`);
return;
}
}
fetch('/save_duplicated_user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: username, attributes: attributes })
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
document.getElementById('duplicate-dialog').close();
location.reload();
} else {
alert('Error saving duplicated user: ' + data);
}
});
}
function addDuplicatedUserRow() {
const table = document.querySelector('#duplicate-dialog-content table tbody');
const newRow = table.insertRow(table.rows.length - 1);
const cell1 = newRow.insertCell(0);
const cell2 = newRow.insertCell(1);
const cell3 = newRow.insertCell(2);
const cell4 = newRow.insertCell(3);
const cell5 = newRow.insertCell(4);
cell1.classList.add('merged-cell');
cell2.innerHTML = `<input type="text" class="new-attribute" value="">`;
cell3.innerHTML = `
<select class="new-op">
<option value="=">=</option>
<option value="!=">!=</option>
<option value=">">></option>
<option value="<"><</option>
<option value=">=">>=</option>
<option value="<="><=</option>
</select>
`;
cell4.innerHTML = `<input type="text" class="new-value" value="">`;
cell5.innerHTML = `<button onclick="removeDuplicatedUserRow(this)">🗑️</button>`;
}
function removeDuplicatedUserRow(button) {
const row = button.parentNode.parentNode;
row.parentNode.removeChild(row);
}
</script>
<script>
document.getElementById('refresh-vendors').addEventListener('click', function () {
fetch("{{ url_for('user.refresh') }}", { method: "POST" })
.then(res => res.json())
.then(data => {
window.showToast("Vendor refresh complete.");
window.location.reload();
})
.catch(err => alert("Error: " + err));
});
</script>
{% endblock %}

View File

@@ -1,329 +0,0 @@
{% extends 'base.html' %}
{% block title %}User List{% endblock %}
{% block content %}
<h1>User List</h1>
<table border="1">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>VLAN ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in results %}
<tr>
<td><input type="text" id="mac_address-{{ user.mac_address }}" value="{{ user.mac_address }}"></td>
<td><input type="text" id="description-{{ user.mac_address }}" value="{{ user.description }}"></td>
<td>
<input type="text" id="vlan_id-{{ user.mac_address }}" value="{{ user.vlan_id }}">
</td>
<td>
<button onclick="updateUser('{{ user.mac_address }}')"></button>
<button onclick="location.reload()"></button>
<a href="/delete_user/{{ user.mac_address }}" onclick="saveScrollPosition()">🗑️</a>
<button onclick="duplicateUser('{{ user.mac_address }}')">Duplicate</button>
</td>
</tr>
{% endfor %}
<tr>
<td colspan="4">
<button onclick="addNewUserRow()"> Add User</button>
</td>
</tr>
</tbody>
</table>
<dialog id="add-user-dialog">
<div id="add-user-dialog-content">
<table border="1">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>VLAN ID</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" id="new-mac"></td>
<td><input type="text" id="new-description"></td>
<td>
<select id="new-vlan_id">
{% for group in groups %}
<option value="{{ group.groupname }}">
{{ group.groupname }}
</option>
{% endfor %}
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 10px;">
<button id="cancel-add-user-dialog">Cancel</button>
<button id="save-new-user">Save</button>
</div>
</dialog>
<dialog id="duplicate-dialog">
<div id="duplicate-dialog-content"></div>
<button id="close-dialog"></button>
<button id="save-duplicated-user">Save</button>
</dialog>
<style>
.merged-cell {
border: none;
}
#cancel-add-user-dialog {
border-radius: 5px;
padding: 10px;
background-color: #f44336;
color: white;
border: none;
cursor: pointer;
margin-right: 10px;
}
#cancel-add-user-dialog:hover {
background-color: #d32f2f;
}
#save-new-user {
border-radius: 5px;
padding: 10px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
}
#save-new-user:hover {
background-color: #45a049;
}
#add-user-dialog-content + div {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
</style>
<script>
function updateUser(mac_address) {
const description = document.getElementById('description-' + mac_address).value;
const vlan_id = document.getElementById('vlan_id-' + mac_address).value;
const mac_address_input = document.getElementById('mac_address-' + mac_address).value;
fetch('/update_user', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `mac_address=${mac_address}&description=${description}&vlan_id=${vlan_id}&new_mac_address=${mac_address_input}`
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
location.reload();
} else {
alert('Error updating user: ' + data);
}
});
}
function saveScrollPosition() {
sessionStorage.setItem('scrollPosition', window.scrollY);
}
window.onload = function () {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, scrollPosition);
sessionStorage.removeItem('scrollPosition');
}
}
function duplicateUser(mac_address) {
fetch('/duplicate_user', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `mac_address=${mac_address}`
})
.then(response => response.json())
.then(data => {
const userData = data;
let newTable = `<table border="1">
<thead>
<tr>
<th>MAC Address</th>
<th>Description</th>
<th>VLAN ID</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" id="new-mac" value="${userData.mac_address}"></td>
<td><input type="text" class="new-description" value="${userData.description}"></td>
<td>
<select id="new-vlan_id">
{% for group in groups %}
<option value="{{ group.groupname }}" ${userData.vlan_id === group.groupname ? 'selected' : ''}>
{{ group.groupname }}
</option>
{% endfor %}
</select>
</td>
</tr>`;
newTable += `<tr>
<td colspan="3" class="merged-cell">
<button onclick="addDuplicatedUserRow(this)"></button>
</td>
</tr></tbody></table>`;
document.getElementById('duplicate-dialog-content').innerHTML = newTable;
document.getElementById('duplicate-dialog').showModal();
});
}
document.getElementById('close-dialog').addEventListener('click', () => {
document.getElementById('duplicate-dialog').close();
});
document.getElementById('save-duplicated-user').addEventListener('click', () => {
saveDuplicatedUser();
});
function saveDuplicatedUser() {
let rows = document.querySelectorAll('#duplicate-dialog-content table tbody tr');
let new_mac_address = rows[0].querySelector('#new-mac').value;
let attributes = [];
for (let i = 1; i < rows.length - 1; i++) {
const descriptionInput = rows[i].querySelector(`.new-description`);
const vlanIdInput = rows[i].querySelector(`.new-vlan_id`);
if (descriptionInput && vlanIdInput) {
attributes.push({
description: descriptionInput.value,
vlan_id: vlanIdInput.value,
});
} else {
console.warn(`Input elements not found for row ${i}`);
return;
}
}
fetch('/save_duplicated_user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ mac_address: new_mac_address, attributes: attributes })
})
.then(response => response.text())
.then(data => {
if (data === 'success') {
document.getElementById('duplicate-dialog').close();
location.reload();
} else {
alert('Error saving duplicated user: ' + data);
}
});
}
function addDuplicatedUserRow(button) {
const table = button.parentNode.parentNode.parentNode;
const newRow = table.insertRow(table.rows.length - 1);
const cell1 = newRow.insertCell(0);
const cell2 = newRow.insertCell(1);
const cell3 = newRow.insertCell(2);
const cell4 = newRow.insertCell(3);
cell1.classList.add('merged-cell');
cell2.innerHTML = `<input type="text" class="new-description" value="">`;
cell3.innerHTML = `<select class="new-vlan_id">
{% for group in groups %}
<option value="{{ group.groupname }}">
{{ group.groupname }}
</option>
{% endfor %}
</select>`;
cell4.innerHTML = `<button onclick="removeDuplicatedUserRow(this)">🗑️</button>`;
}
function removeDuplicatedUserRow(button) {
const row = button.parentNode.parentNode;
row.parentNode.removeChild(row);
}
function addNewUserRow() {
document.getElementById('add-user-dialog').showModal();
}
document.getElementById('close-add-user-dialog').addEventListener('click', () => {
document.getElementById('add-user-dialog').close();
});
document.getElementById('save-new-user').addEventListener('click', () => {
saveNewUser();
});
function saveNewUser() {
const mac = document.getElementById('new-mac').value;
const description = document.getElementById('new-description').value;
const vlan_id = document.getElementById('new-vlan_id').value;
if (!mac) {
alert('MAC Address cannot be empty.');
return;
}
// Construct the data as an object
const userData = {
mac_address: mac,
description: description,
vlan_id: vlan_id
};
fetch('/add_user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error! status: ${response.status}, body: ${text}`);
});
}
return response.json();
})
.then(data => {
console.log("Server response:", data);
if (data && data.success) {
document.getElementById('add-user-dialog').close();
location.reload();
} else {
alert('Error adding user: ' + (data && data.message ? data.message : 'Unknown error'));
}
})
.catch(error => {
console.error('Fetch error:', error);
alert('Error adding user: ' + error.message);
});
}
</script>
{% endblock %}

3
app/views/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .index_views import index # not index_views
from .user_views import user
from .group_views import group

37
app/views/group_views.py Normal file
View File

@@ -0,0 +1,37 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from db_interface import get_all_groups, add_group, update_group_description, delete_group_route, get_users_by_vlan_id
group = Blueprint('group', __name__, url_prefix='/group')
@group.route('/')
def group_list():
available_groups = get_all_groups()
return render_template('group_list.html', available_groups=available_groups)
@group.route('/add', methods=['POST'])
def add_group_route():
vlan_id = request.form['vlan_id']
desc = request.form.get('description', '')
add_group(vlan_id, desc)
return redirect(url_for('group.group_list'))
@group.route('/update_description', methods=['POST'])
def update_description_route():
group_id = request.form['group_id']
desc = request.form.get('description', '')
update_group_description(group_id, desc)
return redirect(url_for('group.group_list'))
@group.route('/delete', methods=['POST'])
def delete_group_route_handler():
return delete_group_route()
@group.route('/get_users_for_group', methods=['POST'])
def get_users_for_group():
vlan_id = request.form.get('vlan_id')
users = get_users_by_vlan_id(vlan_id)
return jsonify(users)

73
app/views/index_views.py Normal file
View File

@@ -0,0 +1,73 @@
from flask import Blueprint, render_template, request, jsonify, current_app
from datetime import datetime
from db_interface import (
get_connection,
get_vendor_info,
get_latest_auth_logs,
get_all_groups,
lookup_mac_verbose,
)
import pytz
index = Blueprint('index', __name__)
def time_ago(dt):
if not dt:
return "n/a"
tz_name = current_app.config.get('APP_TIMEZONE', 'UTC')
local_tz = pytz.timezone(tz_name)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=pytz.utc)
dt = dt.astimezone(local_tz)
now = datetime.now(local_tz)
diff = now - dt
seconds = int(diff.total_seconds())
if seconds < 60:
return f"{seconds}s ago"
elif seconds < 3600:
return f"{seconds // 60}m{seconds % 60}s ago"
elif seconds < 86400:
return f"{seconds // 3600}h{(seconds % 3600) // 60}m ago"
else:
return f"{seconds // 86400}d{(seconds % 86400) // 3600}h ago"
@index.route('/')
def homepage():
total_users, total_groups = get_summary_counts()
latest_accept = get_latest_auth_logs('Access-Accept', limit=5)
latest_reject = get_latest_auth_logs('Access-Reject', limit=5)
for row in latest_accept + latest_reject:
row['ago'] = time_ago(row['timestamp'])
return render_template('index.html',
total_users=total_users,
total_groups=total_groups,
latest_accept=latest_accept,
latest_reject=latest_reject)
@index.route('/lookup_mac', methods=['POST'])
def lookup_mac():
mac = request.form.get('mac', '').strip()
if not mac:
return jsonify({"error": "MAC address is required"}), 400
result = lookup_mac_verbose(mac)
return jsonify({"mac": mac, "output": result})
def get_summary_counts():
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT COUNT(*) AS count FROM users")
total_users = cursor.fetchone()['count']
cursor.execute("SELECT COUNT(*) AS count FROM groups")
total_groups = cursor.fetchone()['count']
cursor.close()
return total_users, total_groups

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

246
app/views/stats_views.py Normal file
View File

@@ -0,0 +1,246 @@
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 math import ceil
import re
import pytz
import humanize
from datetime import datetime, timezone, timedelta
from time import sleep
stats = Blueprint('stats', __name__)
def get_time_filter_delta(time_range):
return {
"last_minute": timedelta(minutes=1),
"last_5_minutes": timedelta(minutes=5),
"last_10_minutes": timedelta(minutes=10),
"last_hour": timedelta(hours=1),
"last_6_hours": timedelta(hours=6),
"last_12_hours": timedelta(hours=12),
"last_day": timedelta(days=1),
"last_30_days": timedelta(days=30),
}.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'])
def stats_page():
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')
))
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.html",
time_range=time_range,
per_page=per_page,
accept_entries=accept_entries,
reject_entries=reject_entries,
fallback_entries=fallback_entries,
available_groups=available_groups,
page_accept=page_accept,
pagination_accept=get_pagination_data(page_accept, total_pages_accept),
page_reject=page_reject,
pagination_reject=get_pagination_data(page_reject, total_pages_reject),
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
)
@stats.route('/add', methods=['POST'])
def add():
mac = request.form['mac_address']
desc = request.form.get('description', '')
group_id = request.form.get('group_id')
current_app.logger.info(f"Received MAC={mac}, DESC={desc}, VLAN={group_id}")
add_user(mac, desc, group_id)
return redirect(url_for('stats.stats_page'))
@stats.route('/lookup_mac_async', methods=['POST'])
def lookup_mac_async():
data = request.get_json()
macs = data.get('macs', [])
results = {}
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
prefixes_to_lookup = {}
for mac in macs:
prefix = mac.lower().replace(":", "").replace("-", "")[:6]
prefixes_to_lookup[prefix] = mac
known_vendors = get_known_mac_vendors()
vendor_cache = {}
for prefix, mac in prefixes_to_lookup.items():
if prefix in known_vendors:
results[mac] = known_vendors[prefix]['vendor']
continue
if prefix in vendor_cache:
results[mac] = vendor_cache[prefix]
continue
info = get_vendor_info(mac)
vendor_name = info.get('vendor', '')
vendor_cache[prefix] = vendor_name
results[mac] = vendor_name
sleep(delay)
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
)

48
app/views/user_views.py Normal file
View File

@@ -0,0 +1,48 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
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.route('/')
def user_list():
users = get_all_users()
available_groups = get_all_groups()
return render_template('user_list.html', users=users, available_groups=available_groups)
@user.route('/add', methods=['POST'])
def add():
mac = request.form['mac_address']
desc = request.form.get('description', '')
group_id = request.form['group_id']
add_user(mac, desc, group_id)
return redirect(url_for('user.user_list'))
@user.route('/update_user', methods=['POST'])
def update_user_route():
mac = request.form['mac_address']
desc = request.form.get('description', '')
vlan_id = request.form['group_id']
update_user(mac, desc, vlan_id)
return redirect(url_for('user.user_list'))
@user.route('/delete', methods=['POST'])
def delete():
mac = request.form['mac_address']
delete_user(mac)
return redirect(url_for('user.user_list'))
@user.route('/refresh_vendors', methods=['POST'])
def refresh():
refresh_vendors()
return {'status': 'OK'}

3
app/wsgi.py Normal file
View File

@@ -0,0 +1,3 @@
from app import app
# This file is used by Gunicorn to start the application

View File

@@ -1,6 +0,0 @@
[mariadb-client]
port=3306
socket=/run/mysqld/mysqld.sock
user=healthcheck
password=$57>*_&uD[dBNnC[tO%5&Dztu)Od)C_`

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,149 +0,0 @@
4,3
2,3
1,3
0,243
0,9
0,1
0,300
0,299
0,298
0,297
0,296
0,295
0,294
0,293
0,292
0,291
0,290
0,289
0,288
0,287
0,286
0,285
0,284
0,283
0,282
0,281
0,280
0,279
0,278
0,277
0,276
0,275
0,274
0,273
0,272
0,271
0,270
0,269
0,268
0,267
0,266
0,265
0,264
0,263
0,262
0,261
0,260
0,259
0,258
0,257
0,256
0,255
0,254
0,253
0,252
0,251
0,250
0,249
0,248
0,247
0,246
0,245
0,244
0,242
0,241
0,240
0,239
0,238
0,237
0,236
0,235
0,234
0,233
0,232
0,231
0,230
0,229
0,228
0,227
0,226
0,225
0,224
0,223
0,222
0,221
0,220
0,219
0,218
0,217
0,216
0,215
0,214
0,213
0,212
0,211
0,210
0,209
0,208
0,207
0,206
0,205
0,204
0,203
0,202
0,201
0,200
0,199
0,198
0,197
0,196
0,195
0,194
0,193
0,192
0,63
0,62
0,61
0,60
0,59
0,58
0,57
0,56
0,55
0,54
0,53
0,52
0,51
0,50
0,49
0,48
0,47
0,46
0,45
0,6
0,0
0,5
0,304
0,303
0,306
0,305
0,302
0,12
0,10
0,8
0,11
0,4
0,2
0,3
0,7

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,2 +0,0 @@
default-character-set=utf8mb4
default-collation=utf8mb4_general_ci

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More