from __future__ import annotations import re from collections import Counter from datetime import datetime, time, timedelta from urllib.parse import urlencode from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Count, Q from django.http import Http404 from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone from django.utils.dateparse import parse_date, parse_datetime from django.views.decorators.http import require_POST from guardian.shortcuts import get_objects_for_user, get_users_with_perms from apps.access.models import AccessRequest from apps.access.permissions import sync_server_view_perm from apps.keys.utils import render_system_username from apps.keys.models import SSHKey from apps.servers.models import Server, ServerAccount, ServerAuditLog from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access AUDIT_LINE_LIMIT_OPTIONS = (25, 50, 100, 200) DEFAULT_AUDIT_LINE_LIMIT = 100 AUDIT_SUBPAGE_OPTIONS = {"charts", "logs"} SSH_LOGIN_EVENT_TYPES = ("ssh.login.success", "ssh.login.fail") HTTP_STATUS_PATTERN = re.compile(r'"\s*(\d{3})\s+\S+') LOGIN_OUTCOME_OPTIONS = {"attempts", "success", "failed"} def _is_admin_user(user) -> bool: if not user or not getattr(user, "is_authenticated", False): return False return bool( user.is_superuser or user.is_staff or user.has_perm("servers.change_server") or user.has_perm("access.change_accessrequest") ) def _require_admin_or_404(request) -> None: if _is_admin_user(request.user): return raise Http404("Not found") @login_required(login_url="/accounts/login/") def dashboard(request): now = timezone.now() user = request.user if user.has_perm("access.add_accessrequest") or user.has_perm("servers.view_server"): server_qs = Server.objects.all() else: server_qs = Server.objects.none() access_qs = ( AccessRequest.objects.filter( requester=user, status=AccessRequest.Status.APPROVED, ) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .order_by("-requested_at") ) access_state_map: dict[int, dict] = {} request_governed_server_ids: set[int] = set() for access in access_qs: server_id = access.server_id request_governed_server_ids.add(server_id) state = access_state_map.setdefault( server_id, { "can_shell": False, "can_logs": False, "can_users": False, "expires_at": access.expires_at, }, ) state["can_shell"] = state["can_shell"] or bool(access.request_shell) state["can_logs"] = state["can_logs"] or bool(access.request_logs) state["can_users"] = state["can_users"] or bool(access.request_users) current_expires = state["expires_at"] if current_expires is None: continue if access.expires_at is None or access.expires_at > current_expires: state["expires_at"] = access.expires_at pending_access_qs = AccessRequest.objects.filter( requester=user, status=AccessRequest.Status.PENDING, ).order_by("-requested_at") pending_access_by_server: dict[int, AccessRequest] = {} for access in pending_access_qs: if access.server_id not in pending_access_by_server: pending_access_by_server[access.server_id] = access has_global_change = user.has_perm("servers.change_server") has_global_view = user.has_perm("servers.view_server") has_global_shell = user.has_perm("servers.shell_server") if has_global_view: view_perm_server_ids: set[int] = set() else: view_perm_server_ids = set( get_objects_for_user( user, "servers.view_server", klass=Server, accept_global_perms=False, ).values_list("id", flat=True) ) if has_global_shell: shell_perm_server_ids: set[int] = set() else: shell_perm_server_ids = set( get_objects_for_user( user, "servers.shell_server", klass=Server, accept_global_perms=False, ).values_list("id", flat=True) ) servers = [] for server in server_qs: state = access_state_map.get( server.id, { "can_shell": False, "can_logs": False, "can_users": False, "expires_at": None, }, ) has_manual_view = has_global_change or ( (has_global_view or server.id in view_perm_server_ids) and server.id not in request_governed_server_ids ) can_shell = bool(has_global_change or has_global_shell or server.id in shell_perm_server_ids or state["can_shell"]) can_logs = bool(has_manual_view or state["can_logs"]) can_users = bool(has_manual_view or state["can_users"]) has_access = bool(has_manual_view or can_shell or can_logs or can_users) pending = pending_access_by_server.get(server.id) servers.append( { "server": server, "expires_at": state["expires_at"] if has_access else None, "last_accessed": None, "status": _build_server_status(server, now), "access": { "granted": has_access, "can_shell": can_shell, "can_logs": can_logs, "can_users": can_users, "pending": pending, }, } ) return render(request, "servers/dashboard.html", {"servers": servers}) @login_required(login_url="/accounts/login/") def admin_dashboard(request): _require_admin_or_404(request) now = timezone.now() pending_access_requests = list( AccessRequest.objects.select_related("requester", "server") .filter(status=AccessRequest.Status.PENDING) .order_by("requested_at") ) total_users = get_user_model().objects.count() total_servers = Server.objects.count() open_access_requests = AccessRequest.objects.filter(status=AccessRequest.Status.PENDING).count() active_access_grants = ( AccessRequest.objects.filter(status=AccessRequest.Status.APPROVED) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True)) .count() ) utilities = [ { "title": "Server Inventory", "description": "Manage server records, enrollment, and baseline metadata.", "url": reverse("admin:servers_server_changelist"), }, { "title": "Enrollment Tokens", "description": "Create and rotate server enrollment tokens.", "url": reverse("admin:servers_enrollmenttoken_changelist"), }, { "title": "Agent Certificate Authorities", "description": "Review and rotate the agent mTLS CA.", "url": reverse("admin:servers_agentcertificateauthority_changelist"), }, { "title": "Log Source Policies", "description": "Configure central log source definitions and parsers.", "url": reverse("admin:servers_serverlogsource_changelist"), }, { "title": "Access Request Records", "description": "Inspect historical access requests and lifecycle decisions.", "url": reverse("admin:access_accessrequest_changelist"), }, { "title": "Django Admin", "description": "Open the full administrative backend.", "url": reverse("admin:index"), }, ] servers = list(Server.objects.order_by("display_name", "hostname", "id")) context = { "pending_access_requests": pending_access_requests, "utilities": utilities, "servers": servers, "summary": { "users": total_users, "servers": total_servers, "pending_requests": open_access_requests, "active_grants": active_access_grants, }, } return render(request, "servers/admin.html", context) @login_required(login_url="/accounts/login/") @require_POST def request_access(request, server_id: int): if not request.user.has_perm("access.add_accessrequest"): raise Http404("Server not found") try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: raise Http404("Server not found") now = timezone.now() if user_has_any_access(request.user, server, now): return redirect("servers:dashboard") if AccessRequest.objects.filter( requester=request.user, server=server, status=AccessRequest.Status.PENDING, ).exists(): return redirect("servers:dashboard") AccessRequest.objects.create( requester=request.user, server=server, reason="", request_shell=True, request_logs=True, request_users=True, ) return redirect("servers:dashboard") @login_required(login_url="/accounts/login/") @require_POST def decide_access_request(request, request_id: int): if not _is_admin_user(request.user): raise Http404("Request not found") try: access_request = AccessRequest.objects.get(id=request_id) except AccessRequest.DoesNotExist: raise Http404("Request not found") if access_request.status != AccessRequest.Status.PENDING: return redirect(_next_redirect_target(request, access_request.server_id)) action = (request.POST.get("action") or "").strip().lower() if action == "approve": access_request.status = AccessRequest.Status.APPROVED elif action == "deny": access_request.status = AccessRequest.Status.DENIED else: return redirect(_next_redirect_target(request, access_request.server_id)) access_request.decided_at = timezone.now() access_request.decided_by = request.user access_request.save(update_fields=["status", "decided_at", "decided_by"]) sync_server_view_perm(access_request) return redirect(_next_redirect_target(request, access_request.server_id)) @login_required(login_url="/accounts/login/") def detail(request, server_id: int): now = timezone.now() # Authorization is enforced via object-level permissions before we do # any other server-specific work. server = _get_server_or_404(request, server_id) can_shell = user_can_shell(request.user, server, now) can_logs = user_can_logs(request.user, server, now) can_users = user_can_users(request.user, server, now) if not can_users: raise Http404("Server not found") access = ( AccessRequest.objects.filter( requester=request.user, server_id=server_id, status=AccessRequest.Status.APPROVED, ) .filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True)) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .order_by("-requested_at") .first() ) account, system_username, certificate_key_id = _load_account_context(request, server) context = { "server": server, "expires_at": access.expires_at if access else None, "last_accessed": None, "account_present": account.is_present if account else None, "account_synced_at": account.last_synced_at if account else None, "system_username": system_username, "certificate_key_id": certificate_key_id, "active_tab": "details", "can_shell": can_shell, "can_logs": can_logs, "can_users": can_users, "is_server_admin": _is_admin_user(request.user), "server_status": _build_server_status(server, now), } return render(request, "servers/detail.html", context) @login_required(login_url="/accounts/login/") def shell(request, server_id: int): now = timezone.now() server = _get_server_or_404(request, server_id) # We intentionally return a 404 on denied shell access to avoid # disclosing that the server exists but is restricted. can_shell = user_can_shell(request.user, server, now) can_logs = user_can_logs(request.user, server, now) can_users = user_can_users(request.user, server, now) if not can_shell: raise Http404("Shell access not available") _, system_username, certificate_key_id = _load_account_context(request, server) shell_target = server.hostname or server.ipv4 or server.ipv6 or "" cert_filename = "" if certificate_key_id: cert_filename = f"keywarden-{request.user.id}-{certificate_key_id}-cert.pub" command = "" if shell_target and system_username and certificate_key_id: command = ( "ssh -i /path/to/private_key " f"-o CertificateFile=~/Downloads/{cert_filename} " f"{system_username}@{shell_target} -t /bin/bash" ) context = { "server": server, "system_username": system_username, "certificate_key_id": certificate_key_id, "shell_target": shell_target, "shell_command": command, "cert_filename": cert_filename, "active_tab": "shell", "is_popout": request.GET.get("popout") == "1", "can_shell": can_shell, "can_logs": can_logs, "can_users": can_users, "is_server_admin": _is_admin_user(request.user), "server_status": _build_server_status(server, now), } return render(request, "servers/shell.html", context) @login_required(login_url="/accounts/login/") def audit(request, server_id: int): now = timezone.now() server = _get_server_or_404(request, server_id) can_logs = user_can_logs(request.user, server, now) if not can_logs: raise Http404("Server not found") subpage = (request.GET.get("view") or "").strip().lower() if subpage not in AUDIT_SUBPAGE_OPTIONS: subpage = "logs" panel_action = reverse("servers:audit_panel", args=[server.id]) initial_panel = _build_audit_panel_context(request, server, panel_action=panel_action) audit_summary = _build_audit_summary(server, now) audit_charts = _build_audit_chart_context(server) context = { "server": server, "active_tab": "audit", "can_shell": user_can_shell(request.user, server, now), "can_logs": can_logs, "can_users": user_can_users(request.user, server, now), "is_server_admin": _is_admin_user(request.user), "server_status": _build_server_status(server, now), "initial_panel": initial_panel, "audit_summary": audit_summary, "audit_panel_url": panel_action, "audit_line_limit": initial_panel["line_limit"], "audit_line_limit_options": initial_panel["line_limit_options"], "audit_subpage": subpage, "audit_charts": audit_charts, } return render(request, "servers/audit.html", context) @login_required(login_url="/accounts/login/") def audit_panel(request, server_id: int): now = timezone.now() server = _get_server_or_404(request, server_id) if not user_can_logs(request.user, server, now): raise Http404("Server not found") panel_action = reverse("servers:audit_panel", args=[server.id]) panel = _build_audit_panel_context(request, server, panel_action=panel_action) return render(request, "servers/_audit_panel.html", {"server": server, "panel": panel}) @login_required(login_url="/accounts/login/") def settings(request, server_id: int): now = timezone.now() server = _get_server_or_404(request, server_id) can_users = user_can_users(request.user, server, now) if not can_users: raise Http404("Server not found") audit_summary = _build_audit_summary(server, now) log_sources = list(server.log_sources.filter(enabled=True).order_by("kind", "name", "id")) admin_server_url = reverse("admin:servers_server_change", args=[server.id]) admin_log_sources_url = reverse("admin:servers_serverlogsource_changelist") admin_log_sources_url = f"{admin_log_sources_url}?server__id__exact={server.id}" context = { "server": server, "active_tab": "settings", "can_shell": user_can_shell(request.user, server, now), "can_logs": user_can_logs(request.user, server, now), "can_users": can_users, "is_server_admin": _is_admin_user(request.user), "server_status": _build_server_status(server, now), "audit_summary": audit_summary, "log_sources": log_sources, "admin_server_url": admin_server_url, "admin_log_sources_url": admin_log_sources_url, } return render(request, "servers/settings.html", context) @login_required(login_url="/accounts/login/") def server_admin(request, server_id: int): now = timezone.now() server = _get_server_or_404(request, server_id) _require_admin_or_404(request) pending_access_requests = list( AccessRequest.objects.select_related("requester") .filter(server=server, status=AccessRequest.Status.PENDING) .order_by("requested_at") ) active_access_requests = list( AccessRequest.objects.select_related("requester", "decided_by") .filter(server=server, status=AccessRequest.Status.APPROVED) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True)) .order_by("requester__username", "-requested_at") ) user_entries: dict[int, dict] = {} for access_request in active_access_requests: user = access_request.requester entry = user_entries.setdefault( user.id, { "user": user, "request_count": 0, "can_shell": False, "can_logs": False, "can_users": False, "expires_at": access_request.expires_at, "sources": set(), }, ) entry["request_count"] += 1 entry["can_shell"] = entry["can_shell"] or bool(access_request.request_shell) entry["can_logs"] = entry["can_logs"] or bool(access_request.request_logs) entry["can_users"] = entry["can_users"] or bool(access_request.request_users) current_expires = entry["expires_at"] if current_expires is not None and ( access_request.expires_at is None or access_request.expires_at > current_expires ): entry["expires_at"] = access_request.expires_at entry["sources"].add("Access request") users_with_perms = get_users_with_perms( server, attach_perms=True, with_group_users=False, only_with_perms_in=["view_server", "shell_server", "change_server"], ) for user, perms in users_with_perms.items(): entry = user_entries.setdefault( user.id, { "user": user, "request_count": 0, "can_shell": False, "can_logs": False, "can_users": False, "expires_at": None, "sources": set(), }, ) entry["can_shell"] = entry["can_shell"] or ("shell_server" in perms) has_view = "view_server" in perms or "change_server" in perms entry["can_logs"] = entry["can_logs"] or has_view entry["can_users"] = entry["can_users"] or has_view entry["sources"].add("Object permission") users_with_access = sorted( [ { **entry, "source_labels": sorted(entry["sources"]), } for entry in user_entries.values() ], key=lambda entry: (entry["user"].username.lower(), entry["user"].id), ) server_accounts = list( ServerAccount.objects.select_related("user") .filter(server=server) .order_by("system_username", "user__username") ) admin_server_url = reverse("admin:servers_server_change", args=[server.id]) admin_log_sources_url = reverse("admin:servers_serverlogsource_changelist") admin_log_sources_url = f"{admin_log_sources_url}?server__id__exact={server.id}" context = { "server": server, "active_tab": "admin", "can_shell": user_can_shell(request.user, server, now), "can_logs": user_can_logs(request.user, server, now), "can_users": user_can_users(request.user, server, now), "is_server_admin": True, "server_status": _build_server_status(server, now), "pending_access_requests": pending_access_requests, "users_with_access": users_with_access, "server_accounts": server_accounts, "admin_server_url": admin_server_url, "admin_log_sources_url": admin_log_sources_url, } return render(request, "servers/server_admin.html", context) def _get_server_or_404(request, server_id: int) -> Server: # Centralized object lookup + permission gate. We raise 404 for both # missing objects and permission denials to reduce enumeration signals. try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: raise Http404("Server not found") if not (request.user.has_perm("servers.view_server", server) or _is_admin_user(request.user)): raise Http404("Server not found") return server def _load_account_context(request, server: Server): # Resolve the effective system username and the currently active SSH # key/certificate context used by the shell UI. account = ServerAccount.objects.filter(server=server, user=request.user).first() system_username = account.system_username if account else render_system_username( request.user.username, request.user.id ) active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first() certificate_key_id = active_key.id if active_key else None return account, system_username, certificate_key_id def _format_age_short(delta: timedelta) -> str: seconds = max(0, int(delta.total_seconds())) if seconds < 60: return f"{seconds}s" minutes = seconds // 60 rem_seconds = seconds % 60 if minutes < 60: return f"{minutes}m {rem_seconds}s" hours = minutes // 60 rem_minutes = minutes % 60 if hours < 48: return f"{hours}h {rem_minutes}m {rem_seconds}s" days = hours // 24 if days < 14: return f"{days}d {hours % 24}h" weeks = days // 7 return f"{weeks}w {days % 7}d" def _build_server_status(server: Server, now): stale_seconds = int(getattr(settings, "KEYWARDEN_HEARTBEAT_STALE_SECONDS", 120)) heartbeat_at = getattr(server, "last_heartbeat_at", None) ping_ms = getattr(server, "last_ping_ms", None) if heartbeat_at: age = now - heartbeat_at age_seconds = max(0, int(age.total_seconds())) is_active = age_seconds <= stale_seconds age_short = _format_age_short(age) else: is_active = False age_short = "never" label = "Active" if is_active else "Inactive" if is_active: detail = f"{ping_ms}ms" if ping_ms is not None else "—" else: detail = age_short return { "is_active": is_active, "label": label, "detail": detail, "ping_ms": ping_ms, "age_short": age_short, "heartbeat_at": heartbeat_at, } def _parse_filter_datetime(value: str, end_of_day: bool) -> datetime | None: if not value: return None parsed_dt = parse_datetime(value) if parsed_dt is not None: if timezone.is_naive(parsed_dt): return timezone.make_aware(parsed_dt, timezone.get_current_timezone()) return parsed_dt parsed_date = parse_date(value) if parsed_date is None: return None parsed_time = time.max if end_of_day else time.min naive = datetime.combine(parsed_date, parsed_time) return timezone.make_aware(naive, timezone.get_current_timezone()) def _build_audit_summary(server: Server, now, logs=None) -> dict: logs_qs = logs if logs is not None else ServerAuditLog.objects.filter(server=server) last_event_at = logs_qs.values_list("event_at", flat=True).first() last_ingest_at = logs_qs.values_list("received_at", flat=True).first() top_categories = list( logs_qs.values("category") .annotate(total=Count("id")) .order_by("-total", "category")[:5] ) return { "total_count": logs_qs.count(), "recent_24h_count": logs_qs.filter(event_at__gte=now - timedelta(hours=24)).count(), "last_event_at": last_event_at, "last_ingest_at": last_ingest_at, "top_categories": top_categories, } def _build_audit_chart_context(server: Server) -> dict: logs_qs = ServerAuditLog.objects.filter(server=server) login_attempt_filter = _login_attempt_filter() failed_login_filter = _failed_login_filter() success_login_filter = _success_login_filter() total_login_attempts = logs_qs.filter(login_attempt_filter).count() failed_login_attempts = logs_qs.filter(failed_login_filter).count() successful_login_attempts = logs_qs.filter(success_login_filter).count() login_chart = _build_chart_payload( items=[ { "label": "Total attempts", "count": total_login_attempts, "color": "#2563eb", "drilldown": _build_audit_drilldown_query(login_outcome="attempts"), }, { "label": "Failed attempts", "count": failed_login_attempts, "color": "#e11d48", "drilldown": _build_audit_drilldown_query(login_outcome="failed"), }, { "label": "Successful attempts", "count": successful_login_attempts, "color": "#16a34a", "drilldown": _build_audit_drilldown_query(login_outcome="success"), }, ], max_items=3, ) failure_rate_pct = 0 if total_login_attempts > 0: failure_rate_pct = int(round((failed_login_attempts / total_login_attempts) * 100)) http_status_counts: Counter[str] = Counter() implicit_status_total = 0 http_logs = logs_qs.filter( Q(event_type="http.access") | Q(source_name__icontains="nginx") | Q(message__icontains="HTTP/") ).values_list("fields", "message", "raw") for fields, message, raw in http_logs.iterator(chunk_size=500): status_code = _extract_http_status_code(fields, message, raw) if status_code: if status_code == "200" or status_code.startswith("1"): implicit_status_total += 1 continue http_status_counts[status_code] += 1 http_status_chart = _build_chart_payload( items=[ { "label": status_code, "count": total, "color": _http_status_color(status_code), "drilldown": _build_audit_drilldown_query(http_status=status_code), } for status_code, total in sorted(http_status_counts.items(), key=lambda item: (-item[1], item[0])) ], max_items=12, ) failed_source_ip_rows = ( logs_qs.filter(failed_login_filter) .exclude(source_ip__isnull=True) .exclude(source_ip="") .values("source_ip") .annotate(total=Count("id")) .order_by("-total", "source_ip")[:8] ) failed_source_ip_chart = _build_chart_payload( items=[ { "label": row["source_ip"], "count": row["total"], "color": "#d97706", "drilldown": _build_audit_drilldown_query(login_outcome="failed", source_ip=row["source_ip"]), } for row in failed_source_ip_rows ], max_items=8, ) security_event_rows = ( logs_qs.filter(Q(category__in=["access", "auth"]) | Q(event_type__startswith="ssh.")) .values("event_type") .annotate(total=Count("id")) .order_by("-total", "event_type")[:8] ) security_event_chart = _build_chart_payload( items=[ { "label": row["event_type"] or "unknown", "count": row["total"], "color": "#4f46e5", "drilldown": _build_audit_drilldown_query(event_type=(row["event_type"] or "unknown")), } for row in security_event_rows ], max_items=10, ) return { "login_attempts": { "total_attempts": total_login_attempts, "failed_attempts": failed_login_attempts, "successful_attempts": successful_login_attempts, "failure_rate_pct": failure_rate_pct, "chart": login_chart, }, "http_status_codes": { "total": sum(http_status_counts.values()), "implicit_total": implicit_status_total, "chart": http_status_chart, }, "failed_source_ips": { "total": sum(failed_source_ip_chart["values"]), "chart": failed_source_ip_chart, }, "security_event_types": { "total": sum(security_event_chart["values"]), "chart": security_event_chart, }, } def _extract_http_status_code(fields, message: str, raw: str) -> str: if isinstance(fields, dict): for key in ("nginx.status", "status", "status_code"): candidate = str(fields.get(key, "")).strip() if _is_http_status_code(candidate): return candidate for text in (message, raw): if not text: continue match = HTTP_STATUS_PATTERN.search(text) if not match: continue candidate = match.group(1) if _is_http_status_code(candidate): return candidate return "" def _is_http_status_code(value: str) -> bool: if len(value) != 3 or not value.isdigit(): return False return 100 <= int(value) <= 599 def _http_status_color(status_code: str) -> str: if not status_code: return "#6b7280" family = status_code[0] if family == "2": return "#16a34a" if family == "3": return "#2563eb" if family == "4": return "#d97706" if family == "5": return "#dc2626" return "#6b7280" def _build_chart_payload(items: list[dict], *, max_items: int) -> dict: labels: list[str] = [] values: list[int] = [] colors: list[str] = [] drilldowns: list[str] = [] for item in items: count = int(item.get("count", 0)) if count <= 0: continue labels.append(str(item.get("label", ""))) values.append(count) colors.append(str(item.get("color", "#2563eb"))) drilldowns.append(str(item.get("drilldown", ""))) if len(labels) >= max_items: break return { "labels": labels, "values": values, "colors": colors, "drilldowns": drilldowns, } def _build_audit_drilldown_query(**filters: str) -> str: params: dict[str, str] = {"view": "logs"} for key, value in filters.items(): text = (value or "").strip() if not text: continue params[key] = text return urlencode(params) def _login_attempt_filter() -> Q: return ( Q(event_type__in=SSH_LOGIN_EVENT_TYPES) | (Q(category="access") & (Q(message__icontains="Accepted ") | Q(message__icontains="Failed password"))) ) def _success_login_filter() -> Q: return Q(event_type="ssh.login.success") | Q(message__icontains="Accepted ") def _failed_login_filter() -> Q: return ( Q(event_type="ssh.login.fail") | Q(message__icontains="Failed password") | Q(message__icontains="authentication failure") | Q(message__icontains="invalid user") ) def _build_audit_panel_context(request, server: Server, panel_action: str) -> dict: logs_qs = ServerAuditLog.objects.filter(server=server) all_logs_qs = logs_qs category = request.GET.get("category", "").strip() event_type = request.GET.get("event_type", "").strip() priority = request.GET.get("priority", "").strip() source_kind = request.GET.get("source_kind", "").strip() source_name = request.GET.get("source_name", "").strip() username = request.GET.get("username", "").strip() source_ip = request.GET.get("source_ip", "").strip() q = request.GET.get("q", "").strip() since = request.GET.get("since", "").strip() until = request.GET.get("until", "").strip() login_outcome = (request.GET.get("login_outcome") or "").strip().lower() if login_outcome not in LOGIN_OUTCOME_OPTIONS: login_outcome = "" http_status = (request.GET.get("http_status") or "").strip() if not _is_http_status_code(http_status): http_status = "" line_limit = _parse_line_limit(request.GET.get("line_limit", "")) if category: logs_qs = logs_qs.filter(category=category) if event_type: logs_qs = logs_qs.filter(event_type=event_type) if priority: logs_qs = logs_qs.filter(priority=priority) if source_kind: logs_qs = logs_qs.filter(source_kind=source_kind) if source_name: logs_qs = logs_qs.filter(source_name=source_name) if username: logs_qs = logs_qs.filter(username__icontains=username) if source_ip: logs_qs = logs_qs.filter(source_ip__icontains=source_ip) if q: logs_qs = logs_qs.filter( Q(message__icontains=q) | Q(raw__icontains=q) | Q(principal__icontains=q) | Q(session_id__icontains=q) | Q(username__icontains=q) | Q(event_type__icontains=q) | Q(source_name__icontains=q) ) if login_outcome == "attempts": logs_qs = logs_qs.filter(_login_attempt_filter()) elif login_outcome == "success": logs_qs = logs_qs.filter(_success_login_filter()) elif login_outcome == "failed": logs_qs = logs_qs.filter(_failed_login_filter()) if http_status: status_token = f" {http_status} " quoted_status_token = f'" {http_status} ' logs_qs = logs_qs.filter(event_type="http.access").filter( Q(message__icontains=quoted_status_token) | Q(raw__icontains=quoted_status_token) | Q(message__icontains=status_token) | Q(raw__icontains=status_token) ) since_dt = _parse_filter_datetime(since, end_of_day=False) if since_dt: logs_qs = logs_qs.filter(event_at__gte=since_dt) until_dt = _parse_filter_datetime(until, end_of_day=True) if until_dt: logs_qs = logs_qs.filter(event_at__lte=until_dt) filtered_count = logs_qs.count() paginator = Paginator(logs_qs, line_limit) page_obj = paginator.get_page(request.GET.get("page") or 1) page_logs = list(page_obj.object_list) for log in page_logs: source_label = ( (log.source_name or "").strip() or (log.unit or "").strip() or (log.hostname or "").strip() or (log.source_kind or "").strip() or "unknown" ) compact_message = ((log.message or "").strip() or (log.raw or "").strip()) compact_message = " ".join(compact_message.split()) if not compact_message: compact_message = "—" if log.priority: compact_message = f"[p{log.priority}] {compact_message}" log.compact_source = source_label log.compact_line = compact_message query = request.GET.copy() query.pop("page", None) query_without_page = query.urlencode() event_type_options_qs = all_logs_qs if category: event_type_options_qs = event_type_options_qs.filter(category=category) source_name_options_qs = all_logs_qs if source_kind: source_name_options_qs = source_name_options_qs.filter(source_kind=source_kind) source_name_options = set(source_name_options_qs.order_by().values_list("source_name", flat=True).distinct()) configured_sources = server.log_sources.filter(enabled=True) if source_kind: configured_sources = configured_sources.filter(kind=source_kind) for configured in configured_sources: configured_name = ( (configured.name or "").strip() or (configured.service_unit or "").strip() or (configured.file_path or "").strip() ) if configured_name: source_name_options.add(configured_name) source_kind_options = set(all_logs_qs.order_by().values_list("source_kind", flat=True).distinct()) source_kind_options.update(server.log_sources.filter(enabled=True).order_by().values_list("kind", flat=True)) filters_active = any( [ category, event_type, priority, source_kind, source_name, username, source_ip, q, since, until, login_outcome, http_status, ] ) reset_href = f"{panel_action}?line_limit={line_limit}" return { "logs": page_logs, "page_obj": page_obj, "query_without_page": query_without_page, "panel_action": panel_action, "line_limit": line_limit, "line_limit_options": AUDIT_LINE_LIMIT_OPTIONS, "filters_expanded": filters_active, "reset_href": reset_href, "filtered_count": filtered_count, "filter_values": { "category": category, "event_type": event_type, "priority": priority, "source_kind": source_kind, "source_name": source_name, "username": username, "source_ip": source_ip, "q": q, "since": since, "until": until, "login_outcome": login_outcome, "http_status": http_status, "line_limit": str(line_limit), }, "filter_options": { "categories": sorted(all_logs_qs.order_by().values_list("category", flat=True).distinct()), "event_types": sorted(event_type_options_qs.order_by().values_list("event_type", flat=True).distinct()), "priorities": sorted(all_logs_qs.order_by().values_list("priority", flat=True).distinct()), "source_kinds": sorted(source_kind_options), "source_names": sorted(source_name_options), }, } def _parse_line_limit(raw_value) -> int: try: parsed = int(str(raw_value).strip()) except (TypeError, ValueError): return DEFAULT_AUDIT_LINE_LIMIT if parsed in AUDIT_LINE_LIMIT_OPTIONS: return parsed return DEFAULT_AUDIT_LINE_LIMIT def _next_redirect_target(request, server_id: int) -> str: target = (request.POST.get("next") or "").strip() if target.startswith("/"): return target return reverse("servers:server_admin", args=[server_id])