v1.4.0 #14

Merged
jamie merged 4 commits from v1.4.0 into main 2026-03-23 11:29:36 +00:00
10 changed files with 1280 additions and 27 deletions
-14
View File
@@ -44,17 +44,3 @@ jobs:
body: ${{ steps.changelog.outputs.changelog }} body: ${{ steps.changelog.outputs.changelog }}
draft: false draft: false
prerelease: false prerelease: false
deploy:
name: Deploy to Kubernetes
needs: release
runs-on: k3s-internal-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Deploy to Kubernetes
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+551 -5
View File
@@ -7,8 +7,11 @@ from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
import threading
import time
from dotenv import load_dotenv from dotenv import load_dotenv
import pyotp
from database import Database from database import Database
from ssh_keys import SSHKeyManager from ssh_keys import SSHKeyManager
@@ -54,8 +57,11 @@ try:
default_user = db.get_user_by_username('admin') default_user = db.get_user_by_username('admin')
if not default_user: if not default_user:
default_password = os.getenv('ADMIN_PASSWORD', 'admin') default_password = os.getenv('ADMIN_PASSWORD', 'admin')
db.create_user('admin', generate_password_hash(default_password)) db.create_user('admin', generate_password_hash(default_password), is_admin=True)
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)") logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
elif not default_user.get('is_admin'):
db.update_user_admin(default_user['id'], True)
logger.info("Updated default admin user with admin privileges")
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
except Exception as e: except Exception as e:
logger.error(f"Database initialization failed: {e}") logger.error(f"Database initialization failed: {e}")
@@ -78,6 +84,34 @@ def login_required(f):
return decorated_function return decorated_function
def admin_required(f):
"""Decorator to require admin privileges."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
if not session.get('is_admin'):
flash('Admin access required', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
def get_current_user():
"""Get current logged-in user from database."""
user_id = session.get('user_id')
if not user_id:
return None
return db.get_user_by_id(user_id)
def _sign_in_user(user):
"""Set session keys for authenticated user."""
session['user_id'] = user['id']
session['username'] = user['username']
session['is_admin'] = bool(user.get('is_admin'))
@app.route('/') @app.route('/')
def index(): def index():
"""Redirect to dashboard if logged in, otherwise to login.""" """Redirect to dashboard if logged in, otherwise to login."""
@@ -89,6 +123,9 @@ def index():
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
"""Login page.""" """Login page."""
if session.get('totp_pending_user_id'):
return redirect(url_for('login_totp'))
if request.method == 'POST': if request.method == 'POST':
username = request.form.get('username') username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
@@ -99,8 +136,14 @@ def login():
user = db.get_user_by_username(username) user = db.get_user_by_username(username)
if user and check_password_hash(user['password_hash'], password): if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id'] if user.get('totp_enabled'):
session['username'] = user['username'] # Step 1 complete: require second factor on separate page.
session.clear()
session['totp_pending_user_id'] = user['id']
return redirect(url_for('login_totp'))
session.clear()
_sign_in_user(user)
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
else: else:
flash('Invalid username or password', 'error') flash('Invalid username or password', 'error')
@@ -108,6 +151,43 @@ def login():
return render_template('login.html') return render_template('login.html')
@app.route('/login/totp', methods=['GET', 'POST'])
def login_totp():
"""Second-factor login page for users with TOTP enabled."""
pending_user_id = session.get('totp_pending_user_id')
if not pending_user_id:
return redirect(url_for('login'))
user = db.get_user_by_id(pending_user_id)
if not user or not user.get('totp_enabled'):
session.pop('totp_pending_user_id', None)
flash('TOTP session expired. Please log in again.', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
if not otp_code:
flash('TOTP code is required', 'error')
return render_template('login_totp.html', username=user['username'])
totp_secret = user.get('totp_secret')
if not totp_secret:
session.pop('totp_pending_user_id', None)
flash('TOTP is enabled but no secret is configured. Contact an admin.', 'error')
return redirect(url_for('login'))
is_valid = pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1)
if not is_valid:
flash('Invalid TOTP code', 'error')
return render_template('login_totp.html', username=user['username'])
session.clear()
_sign_in_user(user)
return redirect(url_for('dashboard'))
return render_template('login_totp.html', username=user['username'])
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
"""Logout and clear session.""" """Logout and clear session."""
@@ -115,12 +195,204 @@ def logout():
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
"""Manage current user's profile: username, password, and TOTP."""
user = get_current_user()
if not user:
session.clear()
return redirect(url_for('login'))
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_username':
new_username = (request.form.get('new_username') or '').strip()
if not new_username:
flash('Username is required', 'error')
return redirect(url_for('profile'))
existing = db.get_user_by_username(new_username)
if existing and existing['id'] != user['id']:
flash('That username is already in use', 'error')
return redirect(url_for('profile'))
if db.update_user_username(user['id'], new_username):
session['username'] = new_username
flash('Username updated successfully', 'success')
else:
flash('Failed to update username', 'error')
return redirect(url_for('profile'))
if action == 'update_password':
current_password = request.form.get('current_password') or ''
new_password = request.form.get('new_password') or ''
confirm_password = request.form.get('confirm_password') or ''
if not check_password_hash(user['password_hash'], current_password):
flash('Current password is incorrect', 'error')
return redirect(url_for('profile'))
if len(new_password) < 8:
flash('New password must be at least 8 characters', 'error')
return redirect(url_for('profile'))
if new_password != confirm_password:
flash('New password and confirmation do not match', 'error')
return redirect(url_for('profile'))
if db.update_user_password(user['id'], generate_password_hash(new_password)):
flash('Password updated successfully', 'success')
else:
flash('Failed to update password', 'error')
return redirect(url_for('profile'))
if action == 'generate_totp_secret':
secret = pyotp.random_base32()
if db.update_user_totp(user['id'], secret, False):
flash('Generated a new TOTP secret. Verify a code to enable TOTP.', 'success')
else:
flash('Failed to generate TOTP secret', 'error')
return redirect(url_for('profile'))
if action == 'enable_totp':
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
fresh_user = db.get_user_by_id(user['id'])
totp_secret = fresh_user.get('totp_secret') if fresh_user else None
if not totp_secret:
flash('No TOTP secret found. Generate one first.', 'error')
return redirect(url_for('profile'))
if not pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1):
flash('Invalid TOTP code. Please try again.', 'error')
return redirect(url_for('profile'))
if db.update_user_totp(user['id'], totp_secret, True):
flash('TOTP enabled for your account.', 'success')
else:
flash('Failed to enable TOTP', 'error')
return redirect(url_for('profile'))
if action == 'disable_totp':
if db.update_user_totp(user['id'], None, False):
flash('TOTP disabled.', 'success')
else:
flash('Failed to disable TOTP', 'error')
return redirect(url_for('profile'))
flash('Unknown action', 'error')
return redirect(url_for('profile'))
user = get_current_user()
if user.get('totp_secret'):
totp_uri = pyotp.TOTP(user['totp_secret']).provisioning_uri(
name=user['username'],
issuer_name='OPNsense Backup Manager',
)
else:
totp_uri = None
return render_template('profile.html', user=user, totp_uri=totp_uri)
@app.route('/users')
@login_required
@admin_required
def users():
"""List and manage users."""
all_users = db.get_all_users()
return render_template('users.html', users=all_users)
@app.route('/users/create', methods=['POST'])
@login_required
@admin_required
def create_user():
"""Create a user account."""
username = (request.form.get('username') or '').strip()
password = request.form.get('password') or ''
is_admin = bool(request.form.get('is_admin'))
if not username or not password:
flash('Username and password are required', 'error')
return redirect(url_for('users'))
if len(password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('users'))
if db.get_user_by_username(username):
flash('Username already exists', 'error')
return redirect(url_for('users'))
user_id = db.create_user(username, generate_password_hash(password), is_admin=is_admin)
if user_id:
flash('User created successfully', 'success')
else:
flash('Failed to create user', 'error')
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
@login_required
@admin_required
def toggle_user_admin(user_id):
"""Toggle admin role for a user."""
target_user = db.get_user_by_id(user_id)
if not target_user:
flash('User not found', 'error')
return redirect(url_for('users'))
all_users = db.get_all_users()
admin_count = sum(1 for u in all_users if u.get('is_admin'))
new_is_admin = not bool(target_user.get('is_admin'))
if not new_is_admin and admin_count <= 1:
flash('Cannot remove admin role from the last admin user', 'error')
return redirect(url_for('users'))
if target_user['id'] == session.get('user_id') and not new_is_admin:
flash('You cannot remove your own admin role', 'error')
return redirect(url_for('users'))
if db.update_user_admin(user_id, new_is_admin):
flash('User admin role updated', 'success')
else:
flash('Failed to update user admin role', 'error')
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
"""Delete a user account."""
if user_id == session.get('user_id'):
flash('You cannot delete your own account', 'error')
return redirect(url_for('users'))
target_user = db.get_user_by_id(user_id)
if not target_user:
flash('User not found', 'error')
return redirect(url_for('users'))
all_users = db.get_all_users()
admin_count = sum(1 for u in all_users if u.get('is_admin'))
if target_user.get('is_admin') and admin_count <= 1:
flash('Cannot delete the last admin user', 'error')
return redirect(url_for('users'))
if db.delete_user(user_id):
flash('User deleted successfully', 'success')
else:
flash('Failed to delete user', 'error')
return redirect(url_for('users'))
@app.route('/dashboard') @app.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
"""Main dashboard.""" """Main dashboard."""
instances = db.get_all_instances() instances = db.get_all_instances()
all_backups = db.get_all_backups() all_backups = db.get_all_backups()
total_backup_size = sum((backup.get('file_size') or 0) for backup in all_backups)
# Add instance info to each backup # Add instance info to each backup
for backup in all_backups: for backup in all_backups:
@@ -129,7 +401,13 @@ def dashboard():
backup['instance_name'] = instance['name'] backup['instance_name'] = instance['name']
backup['instance_identifier'] = instance['identifier'] backup['instance_identifier'] = instance['identifier']
return render_template('dashboard.html', instances=instances, backups=all_backups, sftp_server=sftp_server) return render_template(
'dashboard.html',
instances=instances,
backups=all_backups,
sftp_server=sftp_server,
total_backup_size=total_backup_size
)
@app.route('/instances') @app.route('/instances')
@@ -385,6 +663,270 @@ def delete_backup(backup_id):
return redirect(url_for('backups')) return redirect(url_for('backups'))
def prune_backups(
*,
scope_type: str,
scope_instance_id: int | None,
keep_days: int | None,
keep_count: int | None,
) -> dict:
"""
Prune backups by retention rules.
- keep_days: delete backups older than (now - keep_days)
- keep_count: keep N newest backups per instance
"""
if (keep_days is None and keep_count is None) or (keep_days is not None and keep_count is not None):
raise ValueError("Exactly one of keep_days or keep_count must be provided.")
if scope_type not in {"all", "instance"}:
raise ValueError("scope_type must be 'all' or 'instance'.")
instances = []
if scope_type == "instance":
if not scope_instance_id:
raise ValueError("scope_instance_id must be provided when scope_type is 'instance'.")
instance = db.get_instance_by_id(scope_instance_id)
if not instance:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 1}
instances = [instance]
else:
instances = db.get_all_instances()
now = datetime.now()
cutoff = (now - timedelta(days=keep_days)) if keep_days is not None else None
backups_marked_for_deletion: list[dict] = []
for instance in instances:
backups = db.get_backups_for_instance(instance["id"])
if keep_days is not None:
# db query orders by uploaded_at DESC, but for "older than" it's fine to just filter.
for backup in backups:
uploaded_at = backup.get("uploaded_at")
if uploaded_at is None or uploaded_at < cutoff:
backups_marked_for_deletion.append(backup)
else:
# backups are already sorted DESC; keep first N, prune the rest.
backups_marked_for_deletion.extend(backups[keep_count:])
if not backups_marked_for_deletion:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 0}
deleted_files = 0
skipped_files = 0
db_ids_to_delete: list[int] = []
for backup in backups_marked_for_deletion:
file_path = Path(backup["file_path"])
try:
if file_path.exists():
file_path.unlink()
deleted_files += 1
else:
# Stale DB rows are still safe to remove.
skipped_files += 1
db_ids_to_delete.append(backup["id"])
except Exception as e:
skipped_files += 1
logger.error(f"Failed deleting backup file {file_path}: {e}", exc_info=True)
deleted_backups = db.delete_backups_by_ids(db_ids_to_delete)
return {
"deleted_backups": deleted_backups,
"deleted_files": deleted_files,
"skipped_files": skipped_files,
"errors": 0,
}
@app.route("/backups/prune", methods=["GET"])
@login_required
def prune_page():
"""Backup pruning manual runner + automated retention settings."""
instances = db.get_all_instances()
prune_settings = db.get_backup_prune_settings()
return render_template("prune.html", instances=instances, prune_settings=prune_settings)
@app.route("/backups/prune/run", methods=["POST"])
@login_required
def prune_run():
"""Run a manual prune based on form criteria."""
if not request.form.get("confirm_prune"):
flash("Confirmation required. Check the confirmation box to prune backups.", "error")
return redirect(url_for("prune_page"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
if scope_type == "instance" and not scope_instance_id:
flash("Please select an instance when pruning a single instance.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
try:
result = prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
except Exception as e:
logger.error(f"Manual prune failed: {e}", exc_info=True)
flash(f"Prune failed: {e}", "error")
return redirect(url_for("prune_page"))
flash(
f"Prune complete. Deleted {result['deleted_backups']} backup records. "
f"Deleted {result['deleted_files']} files.",
"success",
)
return redirect(url_for("prune_page"))
@app.route("/backups/prune/settings", methods=["POST"])
@login_required
def prune_settings():
"""Update automated pruning settings (and optionally run immediately)."""
enabled = bool(request.form.get("enabled"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
if scope_type == "all":
scope_instance_id = None
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
interval_hours = request.form.get("interval_hours", type=int) or 24
if interval_hours < 1:
interval_hours = 24
interval_seconds = interval_hours * 3600
if enabled and scope_type == "instance" and not scope_instance_id:
flash("Please select an instance for automated pruning.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
db.upsert_backup_prune_settings(
enabled=enabled,
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days if enabled else None,
keep_count=keep_count if enabled else None,
interval_seconds=interval_seconds,
)
# Optional: run immediately.
if request.form.get("action") == "run_now" and enabled:
try:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
db.set_backup_prune_last_run_at(datetime.now())
except Exception as e:
logger.error(f"Run-now prune failed: {e}", exc_info=True)
flash(f"Saved settings, but run now failed: {e}", "error")
return redirect(url_for("prune_page"))
flash("Saved settings and ran prune now.", "success")
return redirect(url_for("prune_page"))
flash("Automated prune settings saved.", "success")
return redirect(url_for("prune_page"))
def _auto_prune_loop():
"""Background worker that periodically prunes backups based on stored settings."""
while True:
try:
settings = db.get_backup_prune_settings()
enabled = bool(settings.get("enabled"))
interval_seconds = int(settings.get("interval_seconds") or 86400)
if enabled:
keep_days = settings.get("keep_days")
keep_count = settings.get("keep_count")
scope_type = settings.get("scope_type") or "all"
scope_instance_id = settings.get("scope_instance_id")
# Determine keep mode (validated by settings form, but be defensive).
if keep_days is not None and keep_days >= 1 and keep_count is None:
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
elif keep_count is not None and keep_count >= 1 and keep_days is None:
keep_kwargs = {"keep_days": None, "keep_count": int(keep_count)}
elif keep_days is not None and keep_days >= 1 and keep_count is not None and keep_count >= 1:
# Prefer days if both are set.
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
else:
keep_kwargs = None
last_run_at = settings.get("last_run_at")
should_run = last_run_at is None
if last_run_at is not None:
try:
should_run = (datetime.now() - last_run_at).total_seconds() >= interval_seconds
except Exception:
should_run = True
if keep_kwargs and should_run:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_kwargs["keep_days"],
keep_count=keep_kwargs["keep_count"],
)
db.set_backup_prune_last_run_at(datetime.now())
logger.info(
f"Auto prune ran. scope_type={scope_type}, "
f"scope_instance_id={scope_instance_id}"
)
time.sleep(interval_seconds)
else:
time.sleep(3600)
except Exception as e:
logger.error(f"Auto prune loop error: {e}", exc_info=True)
time.sleep(300)
@app.route('/api/backups/latest') @app.route('/api/backups/latest')
def api_latest_backups(): def api_latest_backups():
"""API endpoint to get the latest backup date and time for each instance.""" """API endpoint to get the latest backup date and time for each instance."""
@@ -402,6 +944,10 @@ def api_latest_backups():
return jsonify(result) return jsonify(result)
# Start background auto-prune worker.
threading.Thread(target=_auto_prune_loop, daemon=True).start()
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+243 -3
View File
@@ -5,6 +5,7 @@ import mysql.connector
from mysql.connector import Error from mysql.connector import Error
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from logger_config import get_logger from logger_config import get_logger
@@ -58,6 +59,19 @@ class Database:
) )
""") """)
# Backward-compatible user auth columns.
cursor.execute("SHOW COLUMNS FROM users LIKE 'is_admin'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_secret'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NULL")
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_enabled'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE")
# Create opnsense_instances table # Create opnsense_instances table
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS opnsense_instances ( CREATE TABLE IF NOT EXISTS opnsense_instances (
@@ -97,6 +111,32 @@ class Database:
) )
""") """)
# Create backup pruning settings table (single policy row).
cursor.execute("""
CREATE TABLE IF NOT EXISTS backup_prune_settings (
id INT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
scope_type VARCHAR(10) NOT NULL DEFAULT 'all', /* 'all' or 'instance' */
scope_instance_id INT NULL,
keep_days INT NULL,
keep_count INT NULL,
interval_seconds INT NOT NULL DEFAULT 86400,
last_run_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (scope_instance_id) REFERENCES opnsense_instances(id) ON DELETE SET NULL
)
""")
# Ensure we have exactly one settings row.
cursor.execute("""
INSERT INTO backup_prune_settings
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds, last_run_at)
VALUES
(1, FALSE, 'all', NULL, NULL, NULL, 86400, NULL)
ON DUPLICATE KEY UPDATE
id = id
""")
conn.commit() conn.commit()
cursor.close() cursor.close()
logger.info("Database schema initialized successfully") logger.info("Database schema initialized successfully")
@@ -104,14 +144,14 @@ class Database:
logger.error(f"Error initializing database: {e}") logger.error(f"Error initializing database: {e}")
raise raise
def create_user(self, username: str, password_hash: str) -> Optional[int]: def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> Optional[int]:
"""Create a new user.""" """Create a new user."""
try: try:
with self.get_connection() as conn: with self.get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s)", "INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s)",
(username, password_hash) (username, password_hash, is_admin)
) )
conn.commit() conn.commit()
user_id = cursor.lastrowid user_id = cursor.lastrowid
@@ -134,6 +174,106 @@ class Database:
logger.error(f"Error getting user: {e}") logger.error(f"Error getting user: {e}")
return None return None
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
user = cursor.fetchone()
cursor.close()
return user
except Error as e:
logger.error(f"Error getting user by id: {e}")
return None
def get_all_users(self) -> List[Dict[str, Any]]:
"""Get all users."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT id, username, is_admin, totp_enabled, created_at
FROM users
ORDER BY created_at ASC
"""
)
users = cursor.fetchall()
cursor.close()
return users
except Error as e:
logger.error(f"Error getting users: {e}")
return []
def update_user_username(self, user_id: int, username: str) -> bool:
"""Update username for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET username = %s WHERE id = %s", (username, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating username: {e}")
return False
def update_user_password(self, user_id: int, password_hash: str) -> bool:
"""Update password hash for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating password hash: {e}")
return False
def update_user_totp(self, user_id: int, totp_secret: Optional[str], totp_enabled: bool) -> bool:
"""Update TOTP settings for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET totp_secret = %s, totp_enabled = %s WHERE id = %s",
(totp_secret, totp_enabled, user_id),
)
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating TOTP settings: {e}")
return False
def update_user_admin(self, user_id: int, is_admin: bool) -> bool:
"""Update admin flag for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_admin = %s WHERE id = %s", (is_admin, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating user admin flag: {e}")
return False
def delete_user(self, user_id: int) -> bool:
"""Delete user by ID."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error deleting user: {e}")
return False
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]: def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
"""Create a new OPNsense instance.""" """Create a new OPNsense instance."""
try: try:
@@ -303,3 +443,103 @@ class Database:
logger.error(f"Error getting latest backup per instance: {e}") logger.error(f"Error getting latest backup per instance: {e}")
return [] return []
def get_backup_prune_settings(self) -> Dict[str, Any]:
"""Get automated backup prune settings (single row)."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM backup_prune_settings WHERE id = %s", (1,))
row = cursor.fetchone()
cursor.close()
if row:
return row
except Error as e:
logger.error(f"Error getting backup prune settings: {e}")
# Safe defaults if table/row doesn't exist yet.
return {
"id": 1,
"enabled": False,
"scope_type": "all",
"scope_instance_id": None,
"keep_days": None,
"keep_count": None,
"interval_seconds": 86400,
"last_run_at": None,
"updated_at": None,
}
def upsert_backup_prune_settings(
self,
enabled: bool,
scope_type: str,
scope_instance_id: Optional[int],
keep_days: Optional[int],
keep_count: Optional[int],
interval_seconds: int,
) -> None:
"""Upsert automated backup prune settings (single row)."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO backup_prune_settings
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds)
VALUES
(1, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
scope_type = VALUES(scope_type),
scope_instance_id = VALUES(scope_instance_id),
keep_days = VALUES(keep_days),
keep_count = VALUES(keep_count),
interval_seconds = VALUES(interval_seconds)
""",
(
bool(enabled),
scope_type,
scope_instance_id,
keep_days,
keep_count,
interval_seconds,
),
)
conn.commit()
cursor.close()
except Error as e:
logger.error(f"Error updating backup prune settings: {e}")
def set_backup_prune_last_run_at(self, last_run_at: datetime) -> None:
"""Update last_run_at after a prune run."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE backup_prune_settings SET last_run_at = %s WHERE id = %s",
(last_run_at, 1),
)
conn.commit()
cursor.close()
except Error as e:
logger.error(f"Error updating backup prune last_run_at: {e}")
def delete_backups_by_ids(self, backup_ids: List[int]) -> int:
"""Delete backup records by IDs (returns number of deleted rows)."""
if not backup_ids:
return 0
try:
placeholders = ", ".join(["%s"] * len(backup_ids))
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"DELETE FROM backups WHERE id IN ({placeholders})",
tuple(backup_ids),
)
affected = cursor.rowcount or 0
conn.commit()
cursor.close()
return affected
except Error as e:
logger.error(f"Error deleting backups by ids: {e}")
return 0
+1
View File
@@ -5,3 +5,4 @@ mysql-connector-python
cryptography cryptography
werkzeug werkzeug
python-dotenv python-dotenv
pyotp
+22 -2
View File
@@ -29,8 +29,18 @@
<a href="{{ url_for('backups') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors"> <a href="{{ url_for('backups') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Backups Backups
</a> </a>
<a href="{{ url_for('prune_page') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Prune
</a>
{% if session.is_admin %}
<a href="{{ url_for('users') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Users
</a>
{% endif %}
<span class="text-neutral-400">|</span> <span class="text-neutral-400">|</span>
<span class="text-neutral-300">{{ session.username }}</span> <a href="{{ url_for('profile') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
{{ session.username }}
</a>
<a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"> <a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
Logout Logout
</a> </a>
@@ -59,8 +69,18 @@
<a href="{{ url_for('backups') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700"> <a href="{{ url_for('backups') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Backups Backups
</a> </a>
<a href="{{ url_for('prune_page') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Prune
</a>
{% if session.is_admin %}
<a href="{{ url_for('users') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Users
</a>
{% endif %}
<div class="border-t border-neutral-700 pt-2 mt-2"> <div class="border-t border-neutral-700 pt-2 mt-2">
<div class="px-3 py-2 text-sm text-neutral-400">{{ session.username }}</div> <a href="{{ url_for('profile') }}" class="block px-3 py-2 text-sm text-neutral-400 hover:text-orange-500 rounded-md transition-colors hover:bg-neutral-700">
{{ session.username }}
</a>
<a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700"> <a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Logout Logout
</a> </a>
+25 -1
View File
@@ -10,7 +10,7 @@
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700"> <div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -39,6 +39,30 @@
</div> </div>
</div> </div>
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between">
<div>
<p class="text-neutral-400 text-sm font-medium">Total Backup Size</p>
<p class="text-3xl font-bold text-orange-500 mt-2">
{% if total_backup_size >= 1024 * 1024 * 1024 %}
{{ "%.2f"|format(total_backup_size / 1024 / 1024 / 1024) }} GB
{% elif total_backup_size >= 1024 * 1024 %}
{{ "%.2f"|format(total_backup_size / 1024 / 1024) }} MB
{% elif total_backup_size >= 1024 %}
{{ "%.2f"|format(total_backup_size / 1024) }} KB
{% else %}
{{ total_backup_size }} B
{% endif %}
</p>
</div>
<div class="p-3 bg-orange-500/20 rounded-lg">
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16m-7 5h7"></path>
</svg>
</div>
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700"> <div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Two-Factor Verification - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="min-h-[calc(100vh-12rem)] flex items-center justify-center">
<div class="w-full max-w-md">
<div class="bg-neutral-800 rounded-lg shadow-lg p-8 border border-neutral-700">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-orange-500 mb-2">Two-Factor Verification</h1>
<p class="text-neutral-400">Enter the code from your authenticator app.</p>
{% if username %}
<p class="text-sm text-neutral-500 mt-2">Signing in as <span class="text-neutral-300">{{ username }}</span></p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('login_totp') }}" class="space-y-6">
<div>
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">
TOTP Code
</label>
<input
type="text"
id="otp_code"
name="otp_code"
required
inputmode="numeric"
autocomplete="one-time-code"
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="6-digit code"
>
</div>
<button
type="submit"
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 hover:cursor-pointer">
Verify and Sign In
</button>
</form>
<div class="mt-6 text-center">
<a href="{{ url_for('logout') }}" class="text-sm text-neutral-400 hover:text-orange-500 transition-colors">
Cancel and return to login
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Profile - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Profile</h1>
<p class="text-neutral-400">Manage your username, password, and multi-factor authentication.</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Username</h2>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="update_username">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Current Username</label>
<input type="text" disabled value="{{ user.username }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-400">
</div>
<div>
<label for="new_username" class="block text-sm font-medium text-neutral-300 mb-2">New Username</label>
<input id="new_username" name="new_username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Username</button>
</form>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Password</h2>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="update_password">
<div>
<label for="current_password" class="block text-sm font-medium text-neutral-300 mb-2">Current Password</label>
<input id="current_password" name="current_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-neutral-300 mb-2">New Password</label>
<input id="new_password" name="new_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-neutral-300 mb-2">Confirm New Password</label>
<input id="confirm_password" name="confirm_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Password</button>
</form>
</div>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 space-y-5">
<h2 class="text-xl font-bold text-neutral-100">Two-Factor Authentication (TOTP)</h2>
<p class="text-neutral-400 text-sm">
Use an authenticator app (Aegis, Authy, 1Password, Google Authenticator, etc.) to secure your account.
</p>
{% if user.totp_enabled %}
<div class="bg-green-900/30 border border-green-700 rounded-md p-4 text-sm text-green-300">
TOTP is currently enabled for your account.
</div>
<form method="post" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="disable_totp">
<button type="submit" class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Disable TOTP
</button>
</form>
{% else %}
{% if not user.totp_secret %}
<form method="post" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="generate_totp_secret">
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Generate TOTP Secret
</button>
</form>
{% else %}
<div class="bg-neutral-900 border border-neutral-700 rounded-md p-4 space-y-2">
<p class="text-sm text-neutral-300"><strong>Scan QR Code:</strong></p>
<div id="totp-qrcode" class="bg-white p-2 rounded-md inline-block"></div>
<p class="text-sm text-neutral-300"><strong>Secret:</strong> <code class="text-orange-400">{{ user.totp_secret }}</code></p>
<p class="text-sm text-neutral-300"><strong>OTPAuth URI:</strong></p>
<code id="totp-uri" class="text-xs text-orange-400 break-all block">{{ totp_uri }}</code>
</div>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="enable_totp">
<div>
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">Enter a code from your authenticator app</label>
<input id="otp_code" name="otp_code" type="text" inputmode="numeric" required class="w-full max-w-xs px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors" placeholder="123456">
</div>
<div class="flex gap-3">
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Verify and Enable
</button>
<button formaction="{{ url_for('profile') }}" name="action" value="generate_totp_secret" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Regenerate Secret
</button>
</div>
</form>
{% endif %}
{% endif %}
</div>
</div>
{% if totp_uri %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
(function() {
const qrContainer = document.getElementById('totp-qrcode');
const uriElement = document.getElementById('totp-uri');
if (!qrContainer || !uriElement) return;
const otpUri = uriElement.textContent.trim();
if (!otpUri) return;
new QRCode(qrContainer, {
text: otpUri,
width: 192,
height: 192,
correctLevel: QRCode.CorrectLevel.M
});
})();
</script>
{% endif %}
{% endblock %}
+184
View File
@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block title %}Prune Backups - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Prune Backups</h1>
<p class="text-neutral-400">Delete old backups manually or configure automated retention.</p>
</div>
<!-- Manual Pruning -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-neutral-100">Manual Pruning</h2>
<span class="text-sm text-neutral-400">Runs immediately</span>
</div>
<form method="post" action="{{ url_for('prune_run') }}" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="all" checked>
All Instances
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="instance">
Single Instance
</label>
</div>
<div class="mt-3">
<label for="scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
<select id="scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
<option value="">Select instance</option>
{% for instance in instances %}
<option value="{{ instance.id }}">{{ instance.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="days" checked>
Keep by Days
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="count">
Keep by Count
</label>
</div>
<div class="mt-3 space-y-3">
<div>
<label for="keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
<input id="keep_days" name="keep_days" type="number" min="1" value="14" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
<input id="keep_count" name="keep_count" type="number" min="1" value="10" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="text-xs text-neutral-400">
If you choose <strong>All Instances</strong>, count-based retention keeps the newest <em>N</em> backups <strong>per instance</strong>.
</div>
</div>
</div>
</div>
<div class="bg-neutral-900/40 border border-neutral-700 rounded-md p-4">
<label class="flex items-start gap-3 text-sm text-neutral-300">
<input type="checkbox" name="confirm_prune" class="mt-1">
<span>
I understand this will permanently delete backup files and their database records.
Only proceed if you're sure about the retention settings.
</span>
</label>
</div>
<div class="flex justify-end">
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Prune Now
</button>
</div>
</form>
</div>
<!-- Automated Pruning -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-neutral-100">Automated Pruning</h2>
<span class="text-sm text-neutral-400">Runs in the background</span>
</div>
<form method="post" action="{{ url_for('prune_settings') }}" class="space-y-6">
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" name="enabled" {% if prune_settings.enabled %}checked{% endif %}>
Enable automated pruning
</label>
<div class="text-xs text-neutral-400">
Interval is <strong>hours</strong>. Default: once per day.
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="all" {% if prune_settings.scope_type != 'instance' %}checked{% endif %}>
All Instances
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="instance" {% if prune_settings.scope_type == 'instance' %}checked{% endif %}>
Single Instance
</label>
</div>
<div class="mt-3">
<label for="auto_scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
<select id="auto_scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
<option value="">Select instance</option>
{% for instance in instances %}
<option value="{{ instance.id }}" {% if prune_settings.scope_instance_id == instance.id %}selected{% endif %}>
{{ instance.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="days" {% if prune_settings.keep_days is not none or (prune_settings.keep_days is none and prune_settings.keep_count is none) %}checked{% endif %}>
Keep by Days
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="count" {% if prune_settings.keep_count is not none %}checked{% endif %}>
Keep by Count
</label>
</div>
<div class="mt-3 space-y-3">
<div>
<label for="auto_keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
<input id="auto_keep_days" name="keep_days" type="number" min="1" value="{{ prune_settings.keep_days if prune_settings.keep_days is not none else 14 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="auto_keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
<input id="auto_keep_count" name="keep_count" type="number" min="1" value="{{ prune_settings.keep_count if prune_settings.keep_count is not none else 10 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="mt-3">
<label for="interval_hours" class="block text-sm font-medium text-neutral-300 mb-2">Run interval (hours)</label>
<input id="interval_hours" name="interval_hours" type="number" min="1" value="{{ (prune_settings.interval_seconds // 3600) if prune_settings.interval_seconds is not none else 24 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
</div>
</div>
</div>
{% if prune_settings.last_run_at %}
<div class="text-sm text-neutral-400">
Last automated prune run: <span class="text-neutral-300 font-medium">{{ prune_settings.last_run_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% endif %}
<div class="flex justify-end gap-3 flex-wrap">
<button type="submit" name="action" value="run_now" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Save & Run Now
</button>
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Save Settings
</button>
</div>
</form>
</div>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Users - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Users</h1>
<p class="text-neutral-400">Create and manage user accounts.</p>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Create User</h2>
<form method="post" action="{{ url_for('create_user') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="md:col-span-1">
<label for="username" class="block text-sm font-medium text-neutral-300 mb-2">Username</label>
<input id="username" name="username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="md:col-span-2">
<label for="password" class="block text-sm font-medium text-neutral-300 mb-2">Password</label>
<input id="password" name="password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="md:col-span-1 flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" name="is_admin">
Admin
</label>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Create
</button>
</div>
</form>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700">
<h2 class="text-xl font-bold text-neutral-100">Existing Users</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">TOTP</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% for user in users %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-100">{{ user.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
{% if user.is_admin %}Admin{% else %}User{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
{% if user.totp_enabled %}Enabled{% else %}Disabled{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400">
{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-4">
<form method="post" action="{{ url_for('toggle_user_admin', user_id=user.id) }}">
<button type="submit" class="text-orange-500 hover:text-orange-400 hover:cursor-pointer">
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Delete user {{ user.username }}?');">
<button type="submit" class="text-red-500 hover:text-red-400 hover:cursor-pointer">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}