diff --git a/gui_app.py b/gui_app.py index e1276fc..b3cd60b 100644 --- a/gui_app.py +++ b/gui_app.py @@ -66,6 +66,30 @@ def save_job_to_db_direct(jid, info): except Exception as e: print(f"ERROR: Failed to save job {jid} directly to SQLite: {e}", file=sys.stderr) +def log_audit(actor, action, target, details, req=None): + ip_addr = '-' + if req: + try: + ip_addr = req.headers.get('X-Forwarded-For', '').split(',')[0].strip() or req.remote_addr or '-' + except Exception: + pass + try: + conn = sqlite3.connect(DB_PATH) + try: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO audit_logs (timestamp, actor, action, target, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)", + (time.time(), actor, action, target, details, ip_addr) + ) + # Auto-prune audit logs older than 360 days + cutoff = time.time() - (360 * 86400) + cursor.execute("DELETE FROM audit_logs WHERE timestamp < ?", (cutoff,)) + conn.commit() + finally: + conn.close() + except Exception as e: + print(f"ERROR: Failed to write audit log: {e}", file=sys.stderr) + # In-memory job store: {job_id: job_dict} jobs: dict = {} @@ -101,6 +125,17 @@ def init_db(): value TEXT ) ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL, + actor TEXT, + action TEXT, + target TEXT, + details TEXT, + ip_address TEXT + ) + ''') conn.commit() finally: conn.close() @@ -1045,6 +1080,12 @@ def log_and_notify_run(jid, info, start_time, end_time, status, run_dest): size_bytes = get_dir_size(run_dest) if run_dest else 0 duration = end_time - start_time + # Audit log + is_failed = 'failed' in status.lower() or 'error' in status.lower() + details = f"Status: {status}, Size: {size_bytes / (1024**3):.2f} GB, Duration: {fmt_duration(duration)}" + action = 'BACKUP_FAILURE' if is_failed else 'BACKUP_SUCCESS' + log_audit('system', action, jid, details) + vm_names = info.get('vm_names') if vm_names: vm_display = ", ".join(vm_names) @@ -1268,6 +1309,7 @@ def enforce_retention_policy(info, log_path=None): import shutil shutil.rmtree(path) log_msg(f"Deleted old backup directory: {name}") + log_audit('system', 'RETENTION_PRUNE', vm_name, f"Pruned old successful backup folder '{name}' for VM '{vm_name}' (policy: keep {retention_val} count)") except Exception as e: log_msg(f"ERROR deleting {name}: {e}") @@ -1287,6 +1329,7 @@ def enforce_retention_policy(info, log_path=None): try: shutil.rmtree(path) log_msg(f"Deleted backup older than {retention_val} days: {name}") + log_audit('system', 'RETENTION_PRUNE', vm_name, f"Pruned backup folder '{name}' older than {retention_val} days for VM '{vm_name}'") deleted_count += 1 except Exception as e: log_msg(f"ERROR deleting {name}: {e}") @@ -1367,6 +1410,10 @@ def run_job_thread_impl(jid): info['started'] = time.time() info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'} save_jobs_db() + + vm_names = info.get('vm_names') + target_display = ", ".join(vm_names) if vm_names else info.get('vm_name', '—') + log_audit('system', 'BACKUP_START', jid, f"Backup run started for job {jid} (label: '{info.get('label')}', target: {target_display})") def is_cancelled(): with jobs_db_lock: @@ -1449,7 +1496,12 @@ def run_job_thread_impl(jid): rep_dest = info.get('replication_dest') if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none': rep_vm_dest = os.path.join(rep_dest, vm, f"backup-{run_timestamp}") - replicate_backup_folder(vm_dest, rep_vm_dest, log_path=log_path) + log_audit('system', 'REPLICATION_START', jid, f"Started replication for VM {vm} to {rep_vm_dest}") + rep_ok = replicate_backup_folder(vm_dest, rep_vm_dest, log_path=log_path) + if rep_ok: + log_audit('system', 'REPLICATION_SUCCESS', jid, f"Replication completed successfully for VM {vm}") + else: + log_audit('system', 'REPLICATION_FAILURE', jid, f"Replication failed for VM {vm}") except Exception as e: is_cancel_err = "cancelled by user" in str(e).lower() if is_cancel_err: @@ -1547,7 +1599,12 @@ def run_job_thread_impl(jid): rep_dest = info.get('replication_dest') if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none': rep_run_dest = os.path.join(rep_dest, info['vm_name'], f"backup-{run_timestamp}") - replicate_backup_folder(run_dest, rep_run_dest, log_path=log_path) + log_audit('system', 'REPLICATION_START', jid, f"Started replication for VM {info['vm_name']} to {rep_run_dest}") + rep_ok = replicate_backup_folder(run_dest, rep_run_dest, log_path=log_path) + if rep_ok: + log_audit('system', 'REPLICATION_SUCCESS', jid, f"Replication completed successfully for VM {info['vm_name']}") + else: + log_audit('system', 'REPLICATION_FAILURE', jid, f"Replication failed for VM {info['vm_name']}") except Exception as e: with jobs_db_lock: if "cancelled by user" in str(e).lower(): @@ -1678,6 +1735,7 @@ def login(): session['user'] = user session['password'] = password session['no_verify_ssl'] = no_verify_ssl + log_audit(user, 'USER_LOGIN', host, f"Logged in to vSphere host: {host}", req=request) flash(f'Connected to {host} — {len(vm_list)} VMs found.', 'success') return redirect(url_for('vms')) @@ -1686,7 +1744,9 @@ def login(): @app.route('/logout') def logout(): + user = session.get('user', 'unknown') session.clear() + log_audit(user, 'USER_LOGOUT', '-', "Logged out", req=request) flash('Logged out.', 'info') return redirect(url_for('login')) @@ -1715,6 +1775,7 @@ def settings_page(): set_setting('alert_level', request.form.get('alert_level', 'all')) set_setting('log_retention_days', request.form.get('log_retention_days', 'never')) + log_audit(session.get('user', 'unknown'), 'SETTINGS_UPDATE', 'System Settings', 'Updated SMTP, Webhook, and retention settings', req=request) flash('Settings saved successfully.', 'success') return redirect(url_for('settings_page')) @@ -1745,6 +1806,7 @@ def settings_page(): @app.route('/settings/test-notification', methods=['POST']) @login_required def settings_test_notification(): + log_audit(session.get('user', 'unknown'), 'DIAGNOSTIC_TEST', 'Notification Settings', 'Triggered settings diagnostic connection test', req=request) webhook_enabled = 'webhook_enabled' in request.form webhook_url = request.form.get('webhook_url', '').strip() webhook_type = request.form.get('webhook_type', 'slack_discord') @@ -1972,6 +2034,13 @@ def create_job(): use_cbt=use_cbt, replication_dest=replication_dest ) + log_audit( + session.get('user', 'unknown'), + 'JOB_CREATE', + jid, + f"Created backup job (VM: {vm_name}, label: '{label}', schedule: {schedule_type})", + req=request + ) n_disks = len(disk_filter) if disk_filter is not None else 'all' flash(f'Job created — {n_disks} disk(s) selected.', 'success') return redirect(url_for('job_detail', jobid=jid)) @@ -2101,6 +2170,13 @@ def batch_jobs(): use_cbt=use_cbt, replication_dest=replication_dest ) + log_audit( + session.get('user', 'unknown'), + 'JOB_CREATE', + jid, + f"Created batch backup job for {len(vm_names)} VMs (label: '{label}', strategy: {disk_strategy}, schedule: {schedule_type})", + req=request + ) strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy) flash(f'Batch backup job created for {len(vm_names)} VMs ({strat_label}).', 'success') @@ -2122,6 +2198,91 @@ def batch_jobs(): ) +@app.route('/audit-logs') +@login_required +def audit_logs_page(): + page = request.args.get('page', 1, type=int) + per_page = 25 + offset = (page - 1) * per_page + + q = request.args.get('q', '').strip() + actor_type = request.args.get('actor_type', 'all').strip().lower() + action_type = request.args.get('action_type', 'all').strip() + + query = "SELECT timestamp, actor, action, target, details, ip_address FROM audit_logs WHERE 1=1" + count_query = "SELECT count(*) FROM audit_logs WHERE 1=1" + params = [] + + if q: + filter_sql = " AND (actor LIKE ? OR target LIKE ? OR details LIKE ?)" + query += filter_sql + count_query += filter_sql + like_q = f"%{q}%" + params.extend([like_q, like_q, like_q]) + + if actor_type == 'user': + # Anything that is NOT 'system' + query += " AND actor != 'system'" + count_query += " AND actor != 'system'" + elif actor_type == 'system': + query += " AND actor = 'system'" + count_query += " AND actor = 'system'" + + if action_type != 'all': + query += " AND action = ?" + count_query += " AND action = ?" + params.append(action_type) + + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" + + conn = sqlite3.connect(DB_PATH) + logs_list = [] + total_count = 0 + try: + cursor = conn.cursor() + + # Get total count + cursor.execute(count_query, params) + total_count = cursor.fetchone()[0] + + # Get paginated data + query_params = params + [per_page, offset] + cursor.execute(query, query_params) + rows = cursor.fetchall() + for r in rows: + logs_list.append({ + 'timestamp_fmt': datetime.fromtimestamp(r[0]).strftime('%Y-%m-%d %H:%M:%S'), + 'actor': r[1], + 'action': r[2], + 'target': r[3] or '—', + 'details': r[4] or '', + 'ip_address': r[5] or '—' + }) + + # Get all distinct actions for the dropdown filter + cursor.execute("SELECT DISTINCT action FROM audit_logs ORDER BY action") + actions_list = [row[0] for row in cursor.fetchall()] + except Exception as e: + print(f"Error fetching audit logs: {e}", file=sys.stderr) + actions_list = [] + finally: + conn.close() + + total_pages = max(1, (total_count + per_page - 1) // per_page) + + return render_template( + 'audit_logs.html', + logs=logs_list, + page=page, + total_pages=total_pages, + total_count=total_count, + q=q, + actor_type=actor_type, + action_type=action_type, + actions_list=actions_list + ) + + @app.route('/reports') @login_required def reports_dashboard(): @@ -2286,6 +2447,13 @@ def cancel_schedule(jobid): info['schedule_id'] = None info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status'] save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'SCHEDULE_CANCEL', + jobid, + f"Cancelled recurring schedule (old schedule ID: {sched_id})", + req=request + ) flash('Recurring schedule cancelled.', 'success') return redirect(url_for('job_detail', jobid=jobid)) @@ -2310,6 +2478,13 @@ def reactivate_schedule(jobid): if info.get('status') not in ('running', 'queued'): info['status'] = 'scheduled' save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'SCHEDULE_REACTIVATE', + jobid, + f"Reactivated recurring schedule (new schedule ID: {sched_id}, type: {info.get('schedule_type')})", + req=request + ) flash('Recurring schedule reactivated successfully.', 'success') else: flash('Failed to reactivate schedule.', 'danger') @@ -2331,6 +2506,13 @@ def run_job_now(jobid): # Mark status as queued atomically to prevent double run race condition info['status'] = 'queued' save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'BACKUP_TRIGGER', + jobid, + "Manually triggered backup execution", + req=request + ) # Start backup execution in a background thread t = threading.Thread(target=run_job_thread, args=(jobid,), daemon=True) @@ -2351,6 +2533,13 @@ def stop_job(jobid): info['status'] = 'cancelling' info['progress'] = {'pct': info.get('progress', {}).get('pct', 0), 'phase': 'cancelling', 'detail': 'Stopping backup execution…'} save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'BACKUP_STOP', + jobid, + "Requested backup execution stop", + req=request + ) flash('Request to stop backup sent.', 'info') else: flash('Job is not running or queued.', 'warning') @@ -2377,6 +2566,13 @@ def delete_job(jobid): # Remove from jobs dict jobs.pop(jobid, None) save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'JOB_DELETE', + jobid, + f"Deleted backup job (label: '{info.get('label')}')", + req=request + ) # Remove the job directory containing the log file import shutil @@ -2488,6 +2684,13 @@ def edit_job(jobid): info['status'] = 'finished' if info.get('status') == 'scheduled' else info.get('status', 'finished') save_jobs_db() + log_audit( + session.get('user', 'unknown'), + 'JOB_EDIT', + jobid, + f"Updated job configuration (label: '{label}', schedule: {schedule_type})", + req=request + ) flash('Job updated successfully.', 'success') return redirect(url_for('job_detail', jobid=jobid)) @@ -2536,6 +2739,13 @@ def nfs_mount(): return redirect(url_for('nfs_manager')) try: mount_nfs(server, export, mountpoint, nfs_version=nfs_ver, extra_opts=extra_opts) + log_audit( + session.get('user', 'unknown'), + 'NFS_MOUNT', + mountpoint, + f"Successfully mounted NFS export {server}:{export} to {mountpoint}", + req=request + ) flash(f'Mounted {server}:{export} → {mountpoint} successfully.', 'success') except Exception as e: flash(f'Mount failed: {e}', 'danger') @@ -2551,6 +2761,13 @@ def nfs_umount(): return redirect(url_for('nfs_manager')) try: umount_nfs(mountpoint) + log_audit( + session.get('user', 'unknown'), + 'NFS_UMOUNT', + mountpoint, + f"Successfully unmounted NFS mount point at {mountpoint}", + req=request + ) flash(f'Unmounted {mountpoint} successfully.', 'success') except Exception as e: flash(f'Unmount failed: {e}', 'danger') diff --git a/templates/audit_logs.html b/templates/audit_logs.html new file mode 100644 index 0000000..fee905b --- /dev/null +++ b/templates/audit_logs.html @@ -0,0 +1,240 @@ +{% extends "base.html" %} +{% set active_page = 'audit-logs' %} +{% block title %}Audit Logs — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
Audit Logs
+
Historical records of all user management actions and system-level events (Auto-pruned after 360 days)
+
+
+ +
+
+ +
+
+
+ 🔍 + +
+ +
+ + +
+ +
+ + +
+ + + {% if q or actor_type != 'all' or action_type != 'all' %} + Clear Filters + {% endif %} +
+
+ + +
+ + + + + + + + + + + + + {% if logs %} + {% for log in logs %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
TimestampActorActionTargetDetailsIP Address
{{ log.timestamp_fmt }} + {% if log.actor == 'system' %} + 🤖 system + {% else %} + 👤 {{ log.actor }} + {% endif %} + + {% if log.action in ('USER_LOGIN', 'USER_LOGOUT') %} + {{ log.action }} + {% elif 'SUCCESS' in log.action or 'OK' in log.action %} + {{ log.action }} + {% elif 'FAIL' in log.action or 'FAILURE' in log.action or 'ERROR' in log.action %} + {{ log.action }} + {% else %} + {{ log.action }} + {% endif %} + {{ log.target }}{{ log.details }}{{ log.ip_address }}
+ No matching audit logs found. User actions and system events will appear here chronologically. +
+
+ + + {% if total_pages > 1 %} +
+
+ Showing {{ logs|length }} of {{ total_count }} log entries +
+
+ {% if page > 1 %} + ← Previous + {% else %} + + {% endif %} + + Page {{ page }} of {{ total_pages }} + + {% if page < total_pages %} + Next → + {% else %} + + {% endif %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index a95c823..850e352 100644 --- a/templates/base.html +++ b/templates/base.html @@ -545,6 +545,12 @@ Reports & Analytics + + + + + Audit Logs + @@ -679,6 +685,7 @@ { icon: '📋', label: 'Backup Jobs', kbd: '', action: function() { window.location.href = '/jobs'; } }, { icon: '➕', label: 'Create New Job', kbd: 'N', action: function() { window.location.href = '/jobs/create'; } }, { icon: '📊', label: 'Reports & Analytics', kbd: '', action: function() { window.location.href = '/reports'; } }, + { icon: '🛡️', label: 'Audit Logs', kbd: '', action: function() { window.location.href = '/audit-logs'; } }, { icon: '💾', label: 'NFS Manager', kbd: '', action: function() { window.location.href = '/nfs'; } }, { icon: '⚙️', label: 'System Settings', kbd: '', action: function() { window.location.href = '/settings'; } }, { icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },