its been a while

This commit is contained in:
boris committed 2026-04-16 17:12:29 +01:00
1 parent ea7470231f
commit 34f13c65e1
47 files changed
+5680 -339

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ def sync_server_view_perm(access_request: AccessRequest) -> None:
server_id=access_request.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))
.exists()
)
@@ -205,6 +205,32 @@
</div>
<aside class="space-y-6 lg:sticky lg:top-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Interface display mode</h2>
<p class="text-sm text-gray-500">Choose how wide workspace pages render.</p>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1">
<button
type="button"
class="js-display-mode-btn rounded-md px-3 py-1.5 text-xs font-semibold text-gray-600 transition"
data-display-mode="compact"
>
Compact
</button>
<button
type="button"
class="js-display-mode-btn rounded-md px-3 py-1.5 text-xs font-semibold text-gray-600 transition"
data-display-mode="full"
>
Full
</button>
</div>
<p class="mt-3 text-xs text-gray-500">
Compact keeps the current constrained view. Full expands to near full-width for denser audit/log tables.
</p>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
@@ -385,6 +411,31 @@
});
}
function setDisplayModeButtonState(button, isActive) {
if (isActive) {
button.classList.remove("text-gray-600");
button.classList.add("bg-blue-700", "text-white", "shadow-sm");
return;
}
button.classList.remove("bg-blue-700", "text-white", "shadow-sm");
button.classList.add("text-gray-600");
}
function currentDisplayMode() {
if (typeof window.keywardenGetDisplayMode === "function") {
return window.keywardenGetDisplayMode();
}
return document.documentElement.getAttribute("data-display-mode") === "full" ? "full" : "compact";
}
function applyDisplayModeUi(mode) {
var buttons = document.querySelectorAll(".js-display-mode-btn");
for (var i = 0; i < buttons.length; i += 1) {
var button = buttons[i];
setDisplayModeButtonState(button, button.getAttribute("data-display-mode") === mode);
}
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
@@ -393,6 +444,32 @@
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
var displayModeButtons = document.querySelectorAll(".js-display-mode-btn");
for (var k = 0; k < displayModeButtons.length; k += 1) {
displayModeButtons[k].addEventListener("click", function (event) {
var target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
var mode = target.getAttribute("data-display-mode") || "compact";
if (typeof window.keywardenSetDisplayMode === "function") {
window.keywardenSetDisplayMode(mode);
} else {
document.documentElement.setAttribute("data-display-mode", mode === "full" ? "full" : "compact");
}
applyDisplayModeUi(currentDisplayMode());
});
}
window.addEventListener("keywarden:display-mode", function (event) {
var mode = currentDisplayMode();
if (event && event.detail && event.detail.mode) {
mode = event.detail.mode === "full" ? "full" : "compact";
}
applyDisplayModeUi(mode);
});
applyDisplayModeUi(currentDisplayMode());
})();
</script>
{% endblock %}
+81 -1
View File
@@ -9,7 +9,25 @@ except ImportError: # Fallback for older Unfold builds without guardian admin s
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import AgentCertificateAuthority, EnrollmentToken, Server
from .models import AgentCertificateAuthority, EnrollmentToken, Server, ServerAuditLog, ServerLogSource
class ServerLogSourceInline(admin.TabularInline):
model = ServerLogSource
extra = 0
fields = (
"enabled",
"kind",
"name",
"service_unit",
"file_path",
"parser",
"include_matches",
"exclude_matches",
"category_override",
"event_type_override",
)
ordering = ("kind", "name", "id")
@admin.register(Server)
@@ -29,6 +47,7 @@ class ServerAdmin(GuardedModelAdmin):
"created_at",
"updated_at",
)
inlines = (ServerLogSourceInline,)
def avatar(self, obj: Server):
if obj.image_url:
@@ -92,3 +111,64 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
for ca in queryset:
ca.revoke()
ca.save(update_fields=["is_active", "revoked_at"])
@admin.register(ServerAuditLog)
class ServerAuditLogAdmin(admin.ModelAdmin):
list_display = (
"event_at",
"server",
"source_kind",
"source_name",
"category",
"event_type",
"username",
"source_ip",
"priority",
)
list_filter = ("source_kind", "category", "event_type", "priority", "server")
search_fields = ("message", "raw", "source_name", "username", "principal", "source_ip", "session_id")
readonly_fields = (
"server",
"event_at",
"received_at",
"source_kind",
"source_name",
"category",
"event_type",
"unit",
"priority",
"hostname",
"username",
"principal",
"source_ip",
"session_id",
"message",
"raw",
"fields",
)
ordering = ("-event_at", "-id")
@admin.register(ServerLogSource)
class ServerLogSourceAdmin(admin.ModelAdmin):
list_display = (
"server",
"enabled",
"kind",
"name",
"service_unit",
"file_path",
"parser",
"category_override",
"event_type_override",
)
list_filter = ("enabled", "kind", "server")
search_fields = (
"name",
"service_unit",
"file_path",
"parser",
"category_override",
"event_type_override",
)
+249 -51
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import os
import secrets
import shlex
import shutil
import subprocess
import tempfile
@@ -34,8 +35,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.tempdir = None
self.tempdir_path = ""
self.ssh_started = False
self.key_cleanup_task = None
self.key_paths = []
self.ssh_output_seen = False
self.key_path = ""
self.cert_path = ""
self.system_username = ""
self.shell_target = ""
self.server_id: int | None = None
@@ -91,9 +93,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
if self.reader_task:
self.reader_task.cancel()
self.reader_task = None
if self.key_cleanup_task:
self.key_cleanup_task.cancel()
self.key_cleanup_task = None
if self.proc and self.proc.returncode is None:
self.proc.terminate()
try:
@@ -101,8 +100,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
except asyncio.TimeoutError:
self.proc.kill()
if self.tempdir_path and self.ssh_started:
shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir_path = ""
if not _is_truthy(getattr(settings, "KEYWARDEN_SHELL_PRESERVE_TMP", False)):
shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir_path = ""
self.tempdir = None
async def receive(self, text_data=None, bytes_data=None):
@@ -131,38 +131,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
user,
self.system_username,
)
self.key_path = key_path
self.cert_path = cert_path
ssh_host = _format_ssh_host(self.shell_target)
# Use a locked-down, non-interactive SSH invocation suitable for websockets.
command = [
"ssh",
"-tt",
"-i",
key_path,
"-o",
f"CertificateFile={cert_path}",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"ChallengeResponseAuthentication=no",
"-o",
"PreferredAuthentications=publickey",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"VerifyHostKeyDNS=no",
"-o",
"LogLevel=ERROR",
f"{self.system_username}@{ssh_host}",
"/bin/bash",
]
# Build a locked-down SSH invocation and explicitly launch a remote shell.
command = _build_ssh_command(
key_path=key_path,
cert_path=cert_path,
username=self.system_username,
host=ssh_host,
)
self.proc = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
@@ -170,8 +148,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
stderr=asyncio.subprocess.STDOUT,
)
self.ssh_started = True
self.key_paths = [key_path, cert_path, f"{key_path}.pub"]
self.key_cleanup_task = asyncio.create_task(self._cleanup_keys_after_delay(5))
self.reader_task = asyncio.create_task(self._stream_output())
async def _stream_output(self):
@@ -182,21 +158,104 @@ class ShellConsumer(AsyncWebsocketConsumer):
chunk = await self.proc.stdout.read(4096)
if not chunk:
break
self.ssh_output_seen = True
await self.send(bytes_data=chunk)
if self.proc:
exit_code = await self.proc.wait()
if exit_code != 0 and not self.ssh_output_seen:
if _is_truthy(getattr(settings, "KEYWARDEN_SHELL_DEBUG", False)):
await self._send_ssh_failure_diagnostics(exit_code)
else:
await self.send(
text_data=(
f"\r\nSSH exited with status {exit_code}. "
"Verify host reachability, username, and SSH CA trust.\r\n"
)
)
await self.close()
async def _cleanup_keys_after_delay(self, delay_seconds: int):
async def _send_ssh_failure_diagnostics(self, exit_code: int) -> None:
lines = [
(
f"\r\nSSH exited with status {exit_code}. "
"Verify host reachability, username, and SSH CA trust.\r\n"
),
f"Target: {self.system_username}@{self.shell_target}\r\n",
]
if self.tempdir_path and _is_truthy(getattr(settings, "KEYWARDEN_SHELL_PRESERVE_TMP", False)):
lines.append(f"Session key material preserved at: {self.tempdir_path}\r\n")
cert_info = await self._inspect_session_certificate()
if cert_info:
lines.append("\r\nSession certificate details:\r\n")
lines.append(cert_info)
lines.append("\r\n")
diagnostic = await self._run_ssh_probe()
if diagnostic:
lines.append("\r\nSSH probe output:\r\n")
lines.append(diagnostic)
lines.append("\r\n")
await self.send(text_data="".join(lines))
async def _inspect_session_certificate(self) -> str:
if not self.cert_path:
return ""
try:
await asyncio.sleep(delay_seconds)
for path in self.key_paths:
try:
os.remove(path)
except FileNotFoundError:
continue
except Exception:
pass
finally:
self.key_paths = []
proc = await asyncio.create_subprocess_exec(
"ssh-keygen",
"-L",
"-f",
self.cert_path,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception:
return ""
output, _ = await proc.communicate()
if not output:
return ""
text = output.decode("utf-8", "ignore").strip()
if not text:
return ""
return _truncate_lines(text, max_lines=20, max_chars=2000)
async def _run_ssh_probe(self) -> str:
if not self.key_path or not self.cert_path or not self.system_username or not self.shell_target:
return ""
probe_command = _build_ssh_command(
key_path=self.key_path,
cert_path=self.cert_path,
username=self.system_username,
host=_format_ssh_host(self.shell_target),
remote_command="true",
force_tty=False,
log_level="DEBUG1",
connect_timeout=8,
)
probe_command.insert(1, "-v")
try:
proc = await asyncio.create_subprocess_exec(
*probe_command,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception:
return ""
timeout = int(getattr(settings, "KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS", 10))
timeout = max(1, timeout)
try:
output, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return f"(probe timed out after {timeout}s)"
if not output:
return f"(probe exited with status {proc.returncode} and no output)"
text = output.decode("utf-8", "ignore").strip()
if not text:
return f"(probe exited with status {proc.returncode} and no output)"
return _summarize_probe_output(text)
@database_sync_to_async
def _get_server(self, user, server_id: int):
@@ -310,3 +369,142 @@ def _format_ssh_host(host: str) -> str:
if ":" in host and not (host.startswith("[") and host.endswith("]")):
return f"[{host}]"
return host
def _build_ssh_command(
*,
key_path: str,
cert_path: str,
username: str,
host: str,
remote_command: str | None = None,
force_tty: bool = True,
log_level: str = "ERROR",
connect_timeout: int | None = None,
) -> list[str]:
command = [
"ssh",
]
if force_tty:
command.append("-tt")
command.extend(
[
"-i",
key_path,
"-o",
f"CertificateFile={cert_path}",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"ChallengeResponseAuthentication=no",
"-o",
"PreferredAuthentications=publickey",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"VerifyHostKeyDNS=no",
"-o",
f"LogLevel={log_level}",
]
)
if connect_timeout is not None:
command.extend(["-o", f"ConnectTimeout={connect_timeout}"])
command.append(f"{username}@{host}")
if remote_command is None:
remote_command = str(getattr(settings, "KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")).strip()
else:
remote_command = str(remote_command).strip()
if not remote_command:
return command
try:
remote_argv = shlex.split(remote_command)
except ValueError:
remote_argv = [remote_command]
if remote_argv:
command.extend(remote_argv)
return command
def _is_truthy(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _truncate_lines(text: str, *, max_lines: int, max_chars: int) -> str:
if not text:
return ""
clipped = text[:max_chars]
lines = clipped.splitlines()
if len(lines) > max_lines:
head_count = max(1, max_lines // 2)
tail_count = max(1, max_lines - head_count)
lines = (
lines[:head_count]
+ ["... (middle omitted) ..."]
+ lines[-tail_count:]
)
if len(text) > max_chars:
lines.append("... (truncated by size)")
return "\n".join(lines)
def _summarize_probe_output(text: str) -> str:
if not text:
return ""
lines = text.splitlines()
lower_text = text.lower()
causes = []
if "account has expired" in lower_text:
causes.append("Remote Linux account is expired.")
if "permission denied (publickey" in lower_text or "permission denied" in lower_text:
causes.append("SSH authentication was denied by server policy.")
if "no such user" in lower_text:
causes.append("Remote user account does not exist.")
if "certificate invalid" in lower_text or "invalid certificate" in lower_text:
causes.append("SSH certificate was rejected by the server.")
keywords = (
"authenticating to",
"offering public key",
"offering publickey",
"server accepts key",
"sign_and_send_pubkey",
"authentications that can continue",
"permission denied",
"authentication succeeded",
"received disconnect",
"userauth",
"certificate",
"principal",
"no such user",
"account is locked",
)
interesting = []
for line in lines:
lower = line.lower()
if any(keyword in lower for keyword in keywords):
interesting.append(line)
out = []
if causes:
out.append("Detected cause:")
for cause in causes:
out.append(f"- {cause}")
out.append("")
if interesting:
out.append("Auth highlights:")
out.extend(interesting[-30:])
if lines:
out.append("")
out.append("Probe tail:")
out.extend(lines[-20:])
return _truncate_lines("\n".join(out), max_lines=80, max_chars=8000)
@@ -0,0 +1,49 @@
from django.db import migrations, models
import django.db.models.deletion
from django.utils import timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0009_server_heartbeat_fields"),
]
operations = [
migrations.CreateModel(
name="ServerAuditLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("event_at", models.DateTimeField(db_index=True)),
("received_at", models.DateTimeField(db_index=True, default=timezone.now, editable=False)),
("category", models.CharField(db_index=True, max_length=64)),
("event_type", models.CharField(db_index=True, max_length=128)),
("unit", models.CharField(blank=True, max_length=128)),
("priority", models.CharField(blank=True, db_index=True, max_length=16)),
("hostname", models.CharField(blank=True, max_length=253)),
("username", models.CharField(blank=True, db_index=True, max_length=150)),
("principal", models.CharField(blank=True, max_length=255)),
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
("session_id", models.CharField(blank=True, max_length=128)),
("message", models.TextField(blank=True)),
("raw", models.TextField(blank=True)),
("fields", models.JSONField(blank=True, default=dict)),
(
"server",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="audit_logs", to="servers.server"),
),
],
options={
"verbose_name": "Server audit log",
"verbose_name_plural": "Server audit logs",
"ordering": ["-event_at", "-id"],
"indexes": [
models.Index(fields=["server", "event_at"], name="servers_audit_server_event_idx"),
models.Index(fields=["server", "category", "event_at"], name="servers_audit_cat_event_idx"),
models.Index(fields=["server", "event_type", "event_at"], name="servers_audit_type_event_idx"),
models.Index(fields=["server", "username", "event_at"], name="servers_audit_user_event_idx"),
models.Index(fields=["server", "source_ip", "event_at"], name="servers_audit_ip_event_idx"),
],
},
),
]
@@ -0,0 +1,42 @@
from django.db import migrations, models
import django.db.models.deletion
from django.utils import timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0010_serverauditlog"),
]
operations = [
migrations.CreateModel(
name="ServerLogSource",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(choices=[("service", "Service"), ("file", "File")], db_index=True, max_length=16)),
("name", models.CharField(blank=True, max_length=128)),
("service_unit", models.CharField(blank=True, max_length=128)),
("file_path", models.CharField(blank=True, max_length=512)),
("category_override", models.CharField(blank=True, max_length=64)),
("event_type_override", models.CharField(blank=True, max_length=128)),
("enabled", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(default=timezone.now, editable=False)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"server",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="log_sources", to="servers.server"),
),
],
options={
"verbose_name": "Server log source",
"verbose_name_plural": "Server log sources",
"ordering": ["server_id", "kind", "name", "id"],
"indexes": [
models.Index(fields=["server", "enabled", "kind"], name="srvsrc_srv_en_kind_idx"),
models.Index(fields=["server", "service_unit"], name="srvsrc_srv_svc_idx"),
models.Index(fields=["server", "file_path"], name="servers_src_server_file_idx"),
],
},
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0011_serverlogsource"),
]
operations = [
migrations.AddField(
model_name="serverauditlog",
name="source_kind",
field=models.CharField(blank=True, db_index=True, max_length=16),
),
migrations.AddField(
model_name="serverauditlog",
name="source_name",
field=models.CharField(blank=True, db_index=True, max_length=512),
),
migrations.AddIndex(
model_name="serverauditlog",
index=models.Index(fields=["server", "source_kind", "event_at"], name="servers_audit_kind_event_idx"),
),
migrations.AddIndex(
model_name="serverauditlog",
index=models.Index(fields=["server", "source_name", "event_at"], name="servers_audit_name_event_idx"),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0012_serverauditlog_source_fields"),
]
operations = [
migrations.AddField(
model_name="server",
name="agent_api_token_hash",
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
]
@@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0013_server_agent_api_token_hash"),
]
operations = [
migrations.AddField(
model_name="serverlogsource",
name="exclude_matches",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="serverlogsource",
name="include_matches",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="serverlogsource",
name="parser",
field=models.CharField(
choices=[
("none", "None"),
("syslog", "Syslog"),
("nginx_access", "Nginx Access"),
("json", "JSON"),
],
default="none",
max_length=32,
),
),
migrations.AlterField(
model_name="serverlogsource",
name="kind",
field=models.CharField(
choices=[("journal", "Journal"), ("service", "Service"), ("file", "File")],
db_index=True,
max_length=16,
),
),
]
+82
View File
@@ -28,6 +28,7 @@ class Server(models.Model):
agent_enrolled_at = models.DateTimeField(null=True, blank=True)
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
agent_api_token_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -189,3 +190,84 @@ class ServerAccount(models.Model):
def __str__(self) -> str:
return f"{self.system_username} ({self.server_id})"
class ServerAuditLog(models.Model):
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="audit_logs")
event_at = models.DateTimeField(db_index=True)
received_at = models.DateTimeField(default=timezone.now, editable=False, db_index=True)
category = models.CharField(max_length=64, db_index=True)
event_type = models.CharField(max_length=128, db_index=True)
source_kind = models.CharField(max_length=16, blank=True, db_index=True)
source_name = models.CharField(max_length=512, blank=True, db_index=True)
unit = models.CharField(max_length=128, blank=True)
priority = models.CharField(max_length=16, blank=True, db_index=True)
hostname = models.CharField(max_length=253, blank=True)
username = models.CharField(max_length=150, blank=True, db_index=True)
principal = models.CharField(max_length=255, blank=True)
source_ip = models.GenericIPAddressField(null=True, blank=True)
session_id = models.CharField(max_length=128, blank=True)
message = models.TextField(blank=True)
raw = models.TextField(blank=True)
fields = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Server audit log"
verbose_name_plural = "Server audit logs"
indexes = [
models.Index(fields=["server", "event_at"], name="servers_audit_server_event_idx"),
models.Index(fields=["server", "category", "event_at"], name="servers_audit_cat_event_idx"),
models.Index(fields=["server", "event_type", "event_at"], name="servers_audit_type_event_idx"),
models.Index(fields=["server", "source_kind", "event_at"], name="servers_audit_kind_event_idx"),
models.Index(fields=["server", "source_name", "event_at"], name="servers_audit_name_event_idx"),
models.Index(fields=["server", "username", "event_at"], name="servers_audit_user_event_idx"),
models.Index(fields=["server", "source_ip", "event_at"], name="servers_audit_ip_event_idx"),
]
ordering = ["-event_at", "-id"]
def __str__(self) -> str:
return f"{self.server_id}:{self.category}/{self.event_type}@{self.event_at.isoformat()}"
class ServerLogSource(models.Model):
class Kind(models.TextChoices):
JOURNAL = "journal", "Journal"
SERVICE = "service", "Service"
FILE = "file", "File"
class Parser(models.TextChoices):
NONE = "none", "None"
SYSLOG = "syslog", "Syslog"
NGINX_ACCESS = "nginx_access", "Nginx Access"
JSON = "json", "JSON"
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="log_sources")
kind = models.CharField(max_length=16, choices=Kind.choices, db_index=True)
name = models.CharField(max_length=128, blank=True)
service_unit = models.CharField(max_length=128, blank=True)
file_path = models.CharField(max_length=512, blank=True)
parser = models.CharField(max_length=32, choices=Parser.choices, default=Parser.NONE)
include_matches = models.JSONField(default=dict, blank=True)
exclude_matches = models.JSONField(default=dict, blank=True)
category_override = models.CharField(max_length=64, blank=True)
event_type_override = models.CharField(max_length=128, blank=True)
enabled = models.BooleanField(default=True, db_index=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Server log source"
verbose_name_plural = "Server log sources"
ordering = ["server_id", "kind", "name", "id"]
indexes = [
models.Index(fields=["server", "enabled", "kind"], name="srvsrc_srv_en_kind_idx"),
models.Index(fields=["server", "service_unit"], name="srvsrc_srv_svc_idx"),
models.Index(fields=["server", "file_path"], name="servers_src_server_file_idx"),
]
def __str__(self) -> str:
if self.kind == self.Kind.SERVICE:
return self.name or self.service_unit or f"service:{self.id}"
if self.kind == self.Kind.JOURNAL:
return self.name or f"journal:{self.id}"
return self.name or self.file_path or f"file:{self.id}"
+65 -10
View File
@@ -6,18 +6,73 @@ from django.utils import timezone
from apps.access.models import AccessRequest
def user_can_shell(user, server, now=None) -> bool:
if user.has_perm("servers.shell_server", server):
return True
def _active_access_requests(user, server, now):
return AccessRequest.objects.filter(
requester=user,
server=server,
status=AccessRequest.Status.APPROVED,
).filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
def _is_admin_override(user) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
return user.is_superuser or user.has_perm("servers.change_server")
def _has_manual_view_access(user, server, now) -> bool:
if not user.has_perm("servers.view_server", server):
return False
# If a user has active access requests on this server, scope flags govern
# capability checks. Otherwise, object/global server grants continue to work.
return not _active_access_requests(user, server, now).exists()
def user_has_any_access(user, server, now=None) -> bool:
if now is None:
now = timezone.now()
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
return True
return (
AccessRequest.objects.filter(
requester=user,
server=server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
_active_access_requests(user, server, now)
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
.exists()
)
def user_can_shell(user, server, now=None) -> bool:
if now is None:
now = timezone.now()
if user.has_perm("servers.shell_server", server):
return True
if _is_admin_override(user):
return True
return (
_active_access_requests(user, server, now)
.filter(request_shell=True)
.exists()
)
def user_can_logs(user, server, now=None) -> bool:
if now is None:
now = timezone.now()
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
return True
return (
_active_access_requests(user, server, now)
.filter(request_logs=True)
.exists()
)
def user_can_users(user, server, now=None) -> bool:
if now is None:
now = timezone.now()
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
return True
return (
_active_access_requests(user, server, now)
.filter(request_users=True)
.exists()
)
@@ -0,0 +1,142 @@
{% load tz %}
<div class="space-y-4">
<details class="rounded-xl border border-gray-200 bg-gray-50" {% if panel.filters_expanded %}open{% endif %}>
<summary class="flex cursor-pointer items-center justify-between px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-600">
<span>Filters</span>
<span class="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700">Matches: {{ panel.filtered_count }}</span>
</summary>
<div class="border-t border-gray-200 bg-white p-4">
<form method="get" action="{{ panel.panel_action }}" class="js-audit-panel-form grid gap-3 md:grid-cols-4">
<input type="hidden" name="line_limit" value="{{ panel.filter_values.line_limit }}" />
<input type="hidden" name="login_outcome" value="{{ panel.filter_values.login_outcome }}" />
<input type="hidden" name="http_status" value="{{ panel.filter_values.http_status }}" />
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source type</label>
<select name="source_kind" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.source_kinds %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.source_kind == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source</label>
<select name="source_name" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.source_names %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.source_name == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Category</label>
<select name="category" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.categories %}
<option value="{{ option }}" {% if panel.filter_values.category == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Event type</label>
<select name="event_type" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.event_types %}
<option value="{{ option }}" {% if panel.filter_values.event_type == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Priority</label>
<select name="priority" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.priorities %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.priority == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Username</label>
<input name="username" type="text" value="{{ panel.filter_values.username }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source IP</label>
<input name="source_ip" type="text" value="{{ panel.filter_values.source_ip }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Search</label>
<input name="q" type="text" value="{{ panel.filter_values.q }}" placeholder="message, principal, source" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Since</label>
<input name="since" type="datetime-local" value="{{ panel.filter_values.since }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Until</label>
<input name="until" type="datetime-local" value="{{ panel.filter_values.until }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div class="md:col-span-4 flex items-center justify-end gap-2">
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Apply</button>
<a href="{{ panel.reset_href }}" class="js-panel-link inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">Reset</a>
</div>
</form>
</div>
</details>
{% if panel.logs %}
<div class="overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-xs">
<thead class="bg-gray-50 text-left uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Time</th>
<th class="px-3 py-2 font-semibold">Source</th>
<th class="px-3 py-2 font-semibold">Log line</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for log in panel.logs %}
<tr class="align-top">
<td class="whitespace-nowrap px-3 py-2 text-gray-500">{{ log.event_at|utc|date:"Y-m-d H:i:s" }} UTC</td>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ log.compact_source }}</div>
<div class="text-gray-500">{{ log.event_type }} • {{ log.category }}{% if log.source_kind %} • {{ log.source_kind }}{% endif %}</div>
</td>
<td class="px-3 py-2">
<div class="font-mono text-[11px] text-gray-900">{{ log.compact_line }}</div>
{% if log.username or log.source_ip or log.session_id %}
<div class="mt-1 text-gray-500">
{% if log.username %}user={{ log.username }}{% endif %}
{% if log.source_ip %}{% if log.username %} • {% endif %}ip={{ log.source_ip }}{% endif %}
{% if log.session_id %}{% if log.username or log.source_ip %} • {% endif %}session={{ log.session_id }}{% endif %}
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex items-center justify-between text-xs text-gray-600">
<span>Page {{ panel.page_obj.number }} of {{ panel.page_obj.paginator.num_pages }}</span>
<div class="flex items-center gap-2">
{% if panel.page_obj.has_previous %}
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.previous_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Previous</a>
{% endif %}
{% if panel.page_obj.has_next %}
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.next_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Next</a>
{% endif %}
</div>
</div>
{% else %}
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<h4 class="text-sm font-semibold text-gray-900">No logs for this panel filter</h4>
<p class="mt-1 text-xs text-gray-500">Adjust source/file/service filters or broaden the timeframe.</p>
</div>
{% endif %}
</div>
+32 -18
View File
@@ -85,18 +85,22 @@
</div>
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
<a
href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Details
</a>
<a
href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Audit
</a>
{% if can_users %}
<a
href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Details
</a>
{% endif %}
{% if can_logs %}
<a
href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Audit
</a>
{% endif %}
{% if can_shell %}
<a
href="{% url 'servers:shell' server.id %}"
@@ -105,10 +109,20 @@
Shell
</a>
{% endif %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Settings
</a>
{% if can_users %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Settings
</a>
{% endif %}
{% if is_server_admin %}
<a
href="{% url 'servers:server_admin' server.id %}"
class="{% if active_tab == 'admin' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Admin
</a>
{% endif %}
</nav>
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Admin • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Admin</h1>
<p class="mt-1 text-sm text-gray-500">Global administrative controls for Keywarden.</p>
</div>
</div>
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p>
<p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p>
<p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
</article>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2>
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<a href="{% url 'servers:server_admin' req.server.id %}" class="font-medium text-blue-700 hover:underline">
{{ req.server.display_name }}
</a>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Global admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Central administrative tooling for Keywarden operations.</p>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{% for utility in utilities %}
<a href="{{ utility.url }}" class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-blue-200 hover:bg-blue-50">
<h3 class="text-sm font-semibold text-gray-900">{{ utility.title }}</h3>
<p class="mt-1 text-xs text-gray-600">{{ utility.description }}</p>
</a>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server admin pages</h2>
<p class="mt-1 text-sm text-gray-500">Open per-server administrative controls and user/account views.</p>
</div>
{% if servers %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Hostname / IP</th>
<th class="px-3 py-2 font-semibold text-right">Admin</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for server in servers %}
<tr>
<td class="px-3 py-2">{{ server.display_name }}</td>
<td class="px-3 py-2">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
<td class="px-3 py-2 text-right">
<a href="{% url 'servers:server_admin' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open admin
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
{% endif %}
</section>
</div>
{% endblock %}
+581 -25
View File
@@ -1,46 +1,602 @@
{% extends "base.html" %}
{% load static tz %}
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
{% block extra_head %}
<style>
.ak-chart-card {
border-color: #1f2937;
background: radial-gradient(120% 140% at 0% 0%, #1e293b 0%, #111827 50%, #0b1220 100%);
color: #e5e7eb;
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.35);
}
.ak-chart-title {
color: #f3f4f6;
font-weight: 700;
letter-spacing: 0.01em;
}
.ak-chart-subtitle {
color: #94a3b8;
}
.ak-chart-badge {
border: 1px solid rgba(100, 116, 139, 0.45);
background: rgba(15, 23, 42, 0.65);
color: #e2e8f0;
}
.ak-chart-canvas-wrap {
border: 1px solid rgba(71, 85, 105, 0.35);
background: rgba(2, 6, 23, 0.32);
border-radius: 14px;
padding: 0.6rem 0.75rem;
}
.ak-chart-empty {
color: #94a3b8;
}
</style>
{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
<section class="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-500">
<button
type="button"
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'charts' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
data-audit-subpage="charts"
>
Charts
</button>
<button
type="button"
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'logs' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
data-audit-subpage="logs"
>
Logs
</button>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
<h2 class="text-lg font-semibold text-gray-900">Audit workspace</h2>
<p class="mt-1 text-sm text-gray-500">Open multiple filtered panels side by side for service/file comparisons.</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold">
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">Total: {{ audit_summary.total_count }}</span>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700">24h: {{ audit_summary.recent_24h_count }}</span>
<div id="js-audit-log-controls" class="flex flex-wrap items-center gap-2 {% if audit_subpage != 'logs' %}hidden{% endif %}">
<label for="js-audit-line-limit" class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-gray-700">
<span>Lines</span>
<select id="js-audit-line-limit" class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-semibold text-gray-700">
{% for option in audit_line_limit_options %}
<option value="{{ option }}" {% if option == audit_line_limit %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<button type="button" id="js-add-audit-panel" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
New panel
</button>
</div>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
</svg>
<dl class="mt-4 grid gap-4 text-xs text-gray-600 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last event</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
</div>
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
<div>
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last ingest</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
</div>
<div class="sm:col-span-2">
<dt class="font-semibold uppercase tracking-wide text-gray-500">Top categories</dt>
<dd class="mt-1">
{% if audit_summary.top_categories %}
{% for item in audit_summary.top_categories %}
<span class="mr-2 inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">{{ item.category }}: {{ item.total }}</span>
{% endfor %}
{% else %}
—
{% endif %}
</dd>
</div>
</dl>
</section>
<section id="js-audit-subpage-charts" class="space-y-4 {% if audit_subpage != 'charts' %}hidden{% endif %}">
{% with login=audit_charts.login_attempts %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Login Attempts vs Failures</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs with matching filters.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Failure rate: {{ login.failure_rate_pct }}%
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-login-chart"></canvas>
</div>
<p id="js-login-chart-empty" class="ak-chart-empty mt-2 text-xs {% if login.chart.labels %}hidden{% endif %}">No login events detected yet.</p>
</article>
{% endwith %}
<div class="grid gap-4 lg:grid-cols-2">
{% with http_codes=audit_charts.http_status_codes %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">HTTP Status Codes (Nginx)</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Excludes implicit `1xx` and `200`; click a bar to filter logs.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Visible HTTP events: {{ http_codes.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-http-status-chart"></canvas>
</div>
{% if http_codes.chart.labels %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 hidden text-xs"></p>
{% elif http_codes.implicit_total %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">Only implicit `1xx`/`200` responses were observed ({{ http_codes.implicit_total }} entries), so this chart is intentionally empty.</p>
{% else %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">No HTTP access events with status codes yet.</p>
{% endif %}
</article>
{% endwith %}
{% with event_types=audit_charts.security_event_types %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Security Event Types</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs filtered to that event type.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Events: {{ event_types.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-security-events-chart"></canvas>
</div>
<p id="js-security-events-chart-empty" class="ak-chart-empty mt-2 text-xs {% if event_types.chart.labels %}hidden{% endif %}">No security event-type distribution available yet.</p>
</article>
{% endwith %}
</div>
{% with failed_ips=audit_charts.failed_source_ips %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Failed Login Source IPs</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to inspect failed logins from that source IP.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Failed events: {{ failed_ips.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-failed-ips-chart"></canvas>
</div>
<p id="js-failed-ips-chart-empty" class="ak-chart-empty mt-2 text-xs {% if failed_ips.chart.labels %}hidden{% endif %}">No failed-login IP activity detected.</p>
</article>
{% endwith %}
</section>
<section id="js-audit-subpage-logs" class="{% if audit_subpage != 'logs' %}hidden{% endif %}">
<div id="js-audit-panel-grid" class="grid gap-4">
<article class="rounded-2xl border border-gray-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Panel 1</h3>
<div class="flex items-center gap-2">
<button type="button" class="js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50">Duplicate</button>
<button type="button" class="js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50" disabled>Close</button>
</div>
</div>
<div class="js-panel-content p-4">
{% include "servers/_audit_panel.html" with panel=initial_panel server=server %}
</div>
</article>
</div>
</section>
</div>
{{ audit_charts.login_attempts.chart|json_script:"js-login-chart-data" }}
{{ audit_charts.http_status_codes.chart|json_script:"js-http-status-chart-data" }}
{{ audit_charts.security_event_types.chart|json_script:"js-security-events-chart-data" }}
{{ audit_charts.failed_source_ips.chart|json_script:"js-failed-ips-chart-data" }}
<script src="{% static 'unfold/js/chart/chart.js' %}"></script>
<script>
(function () {
var subpageButtons = document.querySelectorAll(".js-audit-subpage");
var chartsSection = document.getElementById("js-audit-subpage-charts");
var logsSection = document.getElementById("js-audit-subpage-logs");
var logControls = document.getElementById("js-audit-log-controls");
var panelCounter = 1;
var grid = document.getElementById("js-audit-panel-grid");
var addButton = document.getElementById("js-add-audit-panel");
var panelUrl = "{{ audit_panel_url }}";
var lineLimitSelect = document.getElementById("js-audit-line-limit");
var chartsInitialized = false;
var auditPath = "{{ request.path }}";
function parseChartData(scriptId) {
var script = document.getElementById(scriptId);
if (!script) {
return null;
}
try {
return JSON.parse(script.textContent);
} catch (err) {
return null;
}
}
function renderBarChart(canvasId, emptyId, data, options) {
var canvas = document.getElementById(canvasId);
var emptyNode = document.getElementById(emptyId);
var chartOpts = options || {};
if (!canvas) {
return;
}
if (!window.Chart || !data || !Array.isArray(data.labels) || data.labels.length === 0) {
canvas.classList.add("hidden");
if (emptyNode) {
emptyNode.classList.remove("hidden");
}
return;
}
if (emptyNode) {
emptyNode.classList.add("hidden");
}
canvas.classList.remove("hidden");
var chartData = {
labels: data.labels,
datasets: [
{
label: chartOpts.datasetLabel || "Events",
data: data.values,
backgroundColor: data.colors,
borderRadius: 4,
borderSkipped: false,
maxBarThickness: chartOpts.maxBarThickness || 36
}
]
};
new window.Chart(canvas.getContext("2d"), {
type: "bar",
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: chartOpts.indexAxis || "x",
plugins: {
legend: {
display: true,
position: "top",
align: "center",
labels: {
color: "#e2e8f0",
boxWidth: 18,
boxHeight: 8,
font: {
size: 12,
weight: "600"
}
}
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.94)",
borderColor: "rgba(148, 163, 184, 0.35)",
borderWidth: 1,
titleColor: "#e2e8f0",
bodyColor: "#cbd5e1",
callbacks: {
label: function (ctx) {
var isHorizontal = chartOpts.indexAxis === "y";
var value = isHorizontal ? ctx.parsed.x : ctx.parsed.y;
return "Count: " + value;
}
}
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
precision: 0,
color: "#94a3b8"
},
grid: {
color: "rgba(148, 163, 184, 0.12)",
drawBorder: false
}
},
y: {
ticks: {
color: "#cbd5e1"
},
grid: {
color: "rgba(148, 163, 184, 0.08)",
drawBorder: false
}
}
},
onClick: function (_, activeElements) {
if (!activeElements || !activeElements.length) {
return;
}
var index = activeElements[0].index;
var query = (data.drilldowns || [])[index] || "";
if (!query) {
return;
}
window.location.href = auditPath + "?" + query;
},
onHover: function (event, activeElements) {
var target = event && event.native ? event.native.target : null;
if (!target || !target.style) {
return;
}
target.style.cursor = activeElements && activeElements.length ? "pointer" : "default";
}
}
});
}
function initializeCharts() {
if (chartsInitialized) {
return;
}
chartsInitialized = true;
renderBarChart("js-login-chart", "js-login-chart-empty", parseChartData("js-login-chart-data"), {
datasetLabel: "Logins"
});
renderBarChart("js-http-status-chart", "js-http-status-chart-empty", parseChartData("js-http-status-chart-data"), {
datasetLabel: "HTTP status"
});
renderBarChart("js-security-events-chart", "js-security-events-chart-empty", parseChartData("js-security-events-chart-data"), {
datasetLabel: "Events"
});
renderBarChart("js-failed-ips-chart", "js-failed-ips-chart-empty", parseChartData("js-failed-ips-chart-data"), {
datasetLabel: "Failed logins",
indexAxis: "y",
maxBarThickness: 24
});
}
function setSubpageButtonState(button, isActive) {
if (isActive) {
button.classList.remove("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
button.classList.add("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
return;
}
button.classList.remove("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
button.classList.add("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
}
function setSubpage(view, updateUrl) {
var target = view === "charts" ? "charts" : "logs";
if (chartsSection) {
chartsSection.classList.toggle("hidden", target !== "charts");
}
if (logsSection) {
logsSection.classList.toggle("hidden", target !== "logs");
}
if (logControls) {
logControls.classList.toggle("hidden", target !== "logs");
}
if (target === "charts") {
initializeCharts();
}
for (var i = 0; i < subpageButtons.length; i += 1) {
var button = subpageButtons[i];
setSubpageButtonState(button, button.getAttribute("data-audit-subpage") === target);
}
if (!updateUrl || !window.history || !window.history.replaceState) {
return;
}
var url = new URL(window.location.href);
url.searchParams.set("view", target);
window.history.replaceState({}, "", url.toString());
}
function getSelectedLineLimit() {
if (!lineLimitSelect) {
return "{{ audit_line_limit }}";
}
return lineLimitSelect.value || "{{ audit_line_limit }}";
}
function withLineLimit(queryString) {
var params = new URLSearchParams(queryString || "");
params.set("line_limit", getSelectedLineLimit());
return params.toString();
}
function updateGridLayout() {
if (!grid) {
return;
}
var panels = grid.querySelectorAll("article").length;
if (panels > 1) {
grid.classList.add("lg:grid-cols-2");
} else {
grid.classList.remove("lg:grid-cols-2");
}
}
function setCloseButtons() {
if (!grid) {
return;
}
var panels = grid.querySelectorAll("article");
for (var i = 0; i < panels.length; i += 1) {
var closeBtn = panels[i].querySelector(".js-close-panel");
if (!closeBtn) {
continue;
}
closeBtn.disabled = panels.length <= 1;
}
}
function loadPanelContent(contentNode, queryString) {
if (!contentNode) {
return;
}
var url = panelUrl;
var normalizedQuery = withLineLimit(queryString);
if (normalizedQuery) {
url += (url.indexOf("?") === -1 ? "?" : "&") + normalizedQuery;
}
fetch(url, {
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest" }
})
.then(function (response) {
if (!response.ok) {
throw new Error("Failed to load panel");
}
return response.text();
})
.then(function (html) {
contentNode.innerHTML = html;
})
.catch(function () {
contentNode.innerHTML = "<p class=\"text-sm text-rose-700\">Could not load panel data.</p>";
});
}
function createPanel(queryString) {
if (!grid) {
return;
}
panelCounter += 1;
var article = document.createElement("article");
article.className = "rounded-2xl border border-gray-200 bg-white shadow-sm";
article.innerHTML =
"<div class=\"flex items-center justify-between border-b border-gray-100 px-4 py-3\">" +
"<h3 class=\"text-sm font-semibold text-gray-900\">Panel " + panelCounter + "</h3>" +
"<div class=\"flex items-center gap-2\">" +
"<button type=\"button\" class=\"js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Duplicate</button>" +
"<button type=\"button\" class=\"js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Close</button>" +
"</div>" +
"</div>" +
"<div class=\"js-panel-content p-4\"><p class=\"text-sm text-gray-500\">Loading…</p></div>";
grid.appendChild(article);
setCloseButtons();
updateGridLayout();
loadPanelContent(article.querySelector(".js-panel-content"), queryString || "");
}
for (var subpageIndex = 0; subpageIndex < subpageButtons.length; subpageIndex += 1) {
subpageButtons[subpageIndex].addEventListener("click", function (event) {
var target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
setSubpage(target.getAttribute("data-audit-subpage") || "logs", true);
});
}
setSubpage("{{ audit_subpage }}", false);
if (addButton) {
addButton.addEventListener("click", function () {
createPanel("");
});
}
if (lineLimitSelect && grid) {
lineLimitSelect.addEventListener("change", function () {
var panels = grid.querySelectorAll("article");
for (var i = 0; i < panels.length; i += 1) {
var panel = panels[i];
var form = panel.querySelector(".js-audit-panel-form");
var queryString = "";
if (form) {
queryString = new URLSearchParams(new FormData(form)).toString();
}
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
}
});
}
if (!grid) {
return;
}
grid.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.classList.contains("js-close-panel")) {
var panel = target.closest("article");
if (!panel) {
return;
}
panel.remove();
setCloseButtons();
updateGridLayout();
return;
}
if (target.classList.contains("js-duplicate-panel")) {
var currentPanel = target.closest("article");
if (!currentPanel) {
return;
}
var form = currentPanel.querySelector(".js-audit-panel-form");
var queryString = "";
if (form) {
queryString = new URLSearchParams(new FormData(form)).toString();
}
createPanel(queryString);
return;
}
if (target.classList.contains("js-panel-link")) {
event.preventDefault();
var href = target.getAttribute("href") || "";
var idx = href.indexOf("?");
var query = idx >= 0 ? href.substring(idx + 1) : "";
var panelForLink = target.closest("article");
if (!panelForLink) {
return;
}
loadPanelContent(panelForLink.querySelector(".js-panel-content"), query);
}
});
grid.addEventListener("submit", function (event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (!form.classList.contains("js-audit-panel-form")) {
return;
}
event.preventDefault();
var panel = form.closest("article");
if (!panel) {
return;
}
var queryString = new URLSearchParams(new FormData(form)).toString();
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
});
setCloseButtons();
updateGridLayout();
})();
</script>
{% endblock %}
+112 -83
View File
@@ -7,7 +7,7 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</p>
<p class="mt-1 text-sm text-gray-500">Request access, review approvals, and open servers you are approved for.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
{{ servers|length }} total
@@ -18,93 +18,122 @@
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %}
<article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }}
{% if item.access.granted %}
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
<div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>Scopes</dt>
<dd class="font-medium text-gray-900">
<div class="flex flex-wrap justify-end gap-1">
{% if item.access.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if item.access.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if item.access.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</dd>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
{% if item.access.can_users %}
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
{% elif item.access.can_logs %}
<a href="{% url 'servers:audit' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open audit</a>
{% elif item.access.can_shell %}
<a href="{% url 'servers:shell' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open shell</a>
{% endif %}
</div>
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
{{ item.last_accessed|date:"M j, Y H:i" }}
{% else %}
—
{% endif %}
</dd>
{% else %}
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.hostname|default:"Unassigned" }}</h2>
<p class="mt-1 text-xs text-gray-500">No access granted</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Hostname only</span>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div>
<div class="mt-5 border-t border-gray-100 pt-4">
{% if item.access.pending %}
<span class="inline-flex items-center rounded-lg bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800">
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
</span>
{% else %}
<form method="post" action="{% url 'servers:request_access' item.server.id %}">
{% csrf_token %}
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Request access
</button>
</form>
{% endif %}
<p class="mt-2 text-xs text-gray-500">Request includes shell, logs, and users scopes.</p>
</div>
{% endif %}
</article>
{% endfor %}
</div>
@@ -116,8 +145,8 @@
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No servers yet</h2>
<p class="mt-2 text-sm text-gray-600">Create or enroll a server to start handling access requests.</p>
</div>
{% endif %}
</div>
@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Admin • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Server admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Server-scoped administration and maintenance actions.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Edit server
</a>
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Log sources
</a>
</div>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Agent enrolled</dt>
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Enrollment timestamp</dt>
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last heartbeat</dt>
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
</div>
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Pending access requests</h2>
<p class="mt-1 text-sm text-gray-500">Approve or deny pending requests for this server.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending requests for this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Users with access</h2>
<p class="mt-1 text-sm text-gray-500">Effective access from approved requests and object permissions.</p>
</div>
{% if users_with_access %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Scopes</th>
<th class="px-3 py-2 font-semibold">Source</th>
<th class="px-3 py-2 font-semibold">Requests</th>
<th class="px-3 py-2 font-semibold">Expires</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for entry in users_with_access %}
<tr>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ entry.user.username }}</div>
<div class="text-xs text-gray-500">{{ entry.user.email|default:"—" }}</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if entry.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if entry.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if entry.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">
{% for label in entry.source_labels %}
<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">{{ label }}</span>
{% endfor %}
</td>
<td class="px-3 py-2">{{ entry.request_count }}</td>
<td class="px-3 py-2">
{% if entry.expires_at %}
{{ entry.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No users currently have access on this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server accounts</h2>
<p class="mt-1 text-sm text-gray-500">Accounts currently tracked by agent synchronization.</p>
</div>
{% if server_accounts %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">System username</th>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Present</th>
<th class="px-3 py-2 font-semibold">Last synced</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for account in server_accounts %}
<tr>
<td class="px-3 py-2 font-medium text-gray-900">{{ account.system_username }}</td>
<td class="px-3 py-2">{{ account.user.username }}</td>
<td class="px-3 py-2">
{% if account.is_present %}
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700">Present</span>
{% else %}
<span class="inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700">Missing</span>
{% endif %}
</td>
<td class="px-3 py-2">{{ account.last_synced_at|utc|date:"M j, Y H:i:s" }} UTC</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No synchronized server accounts yet.</p>
{% endif %}
</section>
</div>
{% endblock %}
+120 -11
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
@@ -7,22 +8,130 @@
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
<h2 class="text-lg font-semibold text-gray-900">Server status</h2>
<p class="mt-1 text-sm text-gray-500">Current enrollment and telemetry posture for this host.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Agent enrolled</dt>
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Enrollment timestamp</dt>
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last heartbeat</dt>
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last ping</dt>
<dd class="font-medium text-gray-900">{% if server.last_ping_ms is not None %}{{ server.last_ping_ms }}ms{% else %}—{% endif %}</dd>
</div>
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit pipeline</h2>
<p class="mt-1 text-sm text-gray-500">Visibility into agent log ingestion and category distribution.</p>
</div>
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
</div>
<dl class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Total logs</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.total_count }}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last 24h</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.recent_24h_count }}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last event</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last ingest</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
</div>
</dl>
<div class="mt-4">
<h3 class="text-sm font-semibold text-gray-900">Top categories</h3>
{% if audit_summary.top_categories %}
<ul class="mt-2 space-y-2 text-sm text-gray-700">
{% for item in audit_summary.top_categories %}
<li class="flex items-center justify-between rounded-lg border border-gray-100 px-3 py-2">
<span class="font-medium text-gray-900">{{ item.category }}</span>
<span class="text-gray-500">{{ item.total }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-gray-500">No agent logs have been ingested yet.</p>
{% endif %}
</div>
<div class="mt-5 border-t border-gray-100 pt-4">
<a href="{% url 'servers:audit' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Open audit log viewer
</a>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Log source configuration</h2>
<p class="mt-1 text-sm text-gray-500">These sources are consumed by the agent from the central server config.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Configure Inline
</a>
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open Log Source Admin
</a>
</div>
</div>
{% if log_sources %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Type</th>
<th class="px-3 py-2 font-semibold">Name</th>
<th class="px-3 py-2 font-semibold">Service/File</th>
<th class="px-3 py-2 font-semibold">Parser</th>
<th class="px-3 py-2 font-semibold">Matches</th>
<th class="px-3 py-2 font-semibold">Overrides</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for source in log_sources %}
<tr>
<td class="px-3 py-2">{{ source.kind }}</td>
<td class="px-3 py-2">{{ source.name|default:"—" }}</td>
<td class="px-3 py-2">{{ source.service_unit|default:source.file_path|default:"—" }}</td>
<td class="px-3 py-2">{{ source.parser|default:"none" }}</td>
<td class="px-3 py-2 text-xs text-gray-600">
in={{ source.include_matches|default:"{}" }}<br>
out={{ source.exclude_matches|default:"{}" }}
</td>
<td class="px-3 py-2">
{{ source.category_override|default:"(category default)" }} / {{ source.event_type_override|default:"(event default)" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No custom log sources defined. Defaults are used by the agent.</p>
{% endif %}
<p class="mt-4 text-xs text-gray-500">Use the admin buttons above to configure per-server monitors (journal/service/file), parser mode, and include/exclude matches.</p>
</section>
</div>
{% endblock %}
+39 -8
View File
@@ -240,6 +240,7 @@
var startButton = document.getElementById("shell-start");
var activeSocket = null;
var activeTerm = null;
var activeResizeHandler = null;
var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }};
@@ -283,19 +284,40 @@
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
if (activeSocket) {
function disposeTerminal() {
if (activeResizeHandler) {
window.removeEventListener("resize", activeResizeHandler);
activeResizeHandler = null;
}
if (activeTerm) {
try {
activeTerm.dispose();
} catch (err) {
// noop
}
activeTerm = null;
}
if (termContainer) {
termContainer.innerHTML = "";
}
}
function stopTerminal(options) {
var opts = options || {};
if (!opts.skipSocketClose && activeSocket) {
try {
activeSocket.close();
} catch (err) {
// noop
}
}
activeSocket = null;
if (termContainer) {
termContainer.dataset.started = "0";
}
activeSocket = null;
activeTerm = null;
if (opts.disposeTerminal) {
disposeTerminal();
}
setButtonState(false);
}
@@ -303,6 +325,7 @@
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return;
}
disposeTerminal();
termContainer.dataset.started = "1";
if (startButton) {
startButton.disabled = true;
@@ -343,10 +366,18 @@
socket.onclose = function () {
term.write("\r\nSession closed.\r\n");
if (activeSocket === socket) {
stopTerminal();
// Keep the closed terminal output visible until the next start.
stopTerminal({
skipSocketClose: true,
disposeTerminal: false,
});
}
};
socket.onerror = function () {
term.write("\r\nConnection error.\r\n");
};
term.onData(function (data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
@@ -356,11 +387,11 @@
setButtonState(true);
if (isPopout) {
var onResize = function () {
activeResizeHandler = function () {
sizePopoutTerminal();
fitTerminal(term);
};
window.addEventListener("resize", onResize);
window.addEventListener("resize", activeResizeHandler);
}
}
@@ -373,7 +404,7 @@
if (startButton) {
startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") {
stopTerminal();
stopTerminal({ disposeTerminal: true });
return;
}
startTerminal();
+1
View File
@@ -0,0 +1 @@
# Package marker for Django test discovery.
@@ -0,0 +1,172 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from guardian.shortcuts import assign_perm
from apps.access.models import AccessRequest
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
class ServerAccessScopeTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="alice",
email="alice@example.com",
password="pass12345",
)
self.admin = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="pass12345",
)
self.server = Server.objects.create(
display_name="db-01",
hostname="db-01.example.test",
)
def _grant_perm(self, user, app_label: str, codename: str) -> None:
perm = Permission.objects.get(
content_type__app_label=app_label,
codename=codename,
)
user.user_permissions.add(perm)
def _create_access_request(self, **kwargs) -> AccessRequest:
payload = {
"requester": self.user,
"server": self.server,
"status": AccessRequest.Status.APPROVED,
"request_shell": False,
"request_logs": False,
"request_users": False,
}
payload.update(kwargs)
return AccessRequest.objects.create(**payload)
def test_scope_helpers_enforce_request_flags(self) -> None:
self._create_access_request(request_shell=True, request_logs=False, request_users=False)
self.assertTrue(user_can_shell(self.user, self.server))
self.assertFalse(user_can_logs(self.user, self.server))
self.assertFalse(user_can_users(self.user, self.server))
self.assertTrue(user_has_any_access(self.user, self.server))
def test_approved_request_without_scopes_does_not_grant_server_view(self) -> None:
self._create_access_request(request_shell=False, request_logs=False, request_users=False)
self.assertFalse(user_has_any_access(self.user, self.server))
self.assertFalse(self.user.has_perm("servers.view_server", self.server))
def test_manual_view_perm_allows_logs_and_users_without_access_request(self) -> None:
assign_perm("view_server", self.user, self.server)
self.assertTrue(user_can_logs(self.user, self.server))
self.assertTrue(user_can_users(self.user, self.server))
def test_audit_view_denied_when_logs_scope_not_granted(self) -> None:
self._create_access_request(request_users=True, request_logs=False)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:audit", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_detail_view_denied_when_users_scope_not_granted(self) -> None:
self._create_access_request(request_logs=True, request_users=False)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:detail", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_dashboard_request_access_creates_pending_request(self) -> None:
self._grant_perm(self.user, "access", "add_accessrequest")
self.client.login(username="alice", password="pass12345")
response = self.client.post(reverse("servers:request_access", args=[self.server.id]))
self.assertEqual(response.status_code, 302)
self.assertTrue(
AccessRequest.objects.filter(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
request_logs=True,
request_users=True,
).exists()
)
def test_admin_can_approve_pending_request(self) -> None:
pending = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
request_logs=True,
request_users=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.post(
reverse("servers:decide_access_request", args=[pending.id]),
{"action": "approve"},
)
self.assertEqual(response.status_code, 302)
pending.refresh_from_db()
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
self.assertEqual(pending.decided_by_id, self.admin.id)
def test_admin_dashboard_requires_admin(self) -> None:
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 404)
def test_admin_dashboard_lists_pending_requests(self) -> None:
pending = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, pending.requester.username)
self.assertContains(response, "Access request queue")
def test_server_admin_requires_admin(self) -> None:
assign_perm("view_server", self.user, self.server)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_server_admin_lists_users_and_server_accounts(self) -> None:
AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
request_logs=True,
request_users=True,
)
ServerAccount.objects.create(
server=self.server,
user=self.user,
system_username="alice_1",
is_present=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Users with access")
self.assertContains(response, self.user.username)
self.assertContains(response, "Server accounts")
self.assertContains(response, "alice_1")
@@ -0,0 +1,295 @@
from __future__ import annotations
import json
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import assign_perm
from apps.servers.models import Server, ServerAuditLog, ServerLogSource
from keywarden.api.security import hash_agent_token
class AgentLogIngestionTests(TestCase):
def setUp(self) -> None:
super().setUp()
self.server = Server.objects.create(display_name="web-01", hostname="web-01.example.test")
self.agent_token = "test-agent-token"
self.server.agent_api_token_hash = hash_agent_token(self.agent_token)
self.server.save(update_fields=["agent_api_token_hash"])
self.agent_auth = f"Bearer {self.agent_token}"
def test_ingest_logs_persists_events(self) -> None:
payload = [
{
"timestamp": "2026-04-14T10:20:30Z",
"category": "access",
"event_type": "ssh.login.success",
"source_kind": "service",
"source_name": "sshd.service",
"priority": "6",
"username": "alice",
"source_ip": "203.0.113.10",
"message": "Accepted publickey for alice from 203.0.113.10",
"fields": {"_SYSTEMD_UNIT": "sshd.service", "PRIORITY": 6},
},
{
"timestamp": "not-a-time",
"category": "system",
"event_type": "system",
"source_ip": "not-an-ip",
"message": "fallback timestamp test",
"fields": {"FOO": "bar"},
},
]
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["accepted"], 2)
self.assertEqual(ServerAuditLog.objects.filter(server=self.server).count(), 2)
success_log = ServerAuditLog.objects.get(event_type="ssh.login.success")
self.assertEqual(success_log.category, "access")
self.assertEqual(success_log.username, "alice")
self.assertEqual(success_log.source_kind, "service")
self.assertEqual(success_log.source_name, "sshd.service")
self.assertEqual(success_log.source_ip, "203.0.113.10")
self.assertEqual(success_log.fields.get("PRIORITY"), "6")
fallback_log = ServerAuditLog.objects.get(message="fallback timestamp test")
self.assertIsNone(fallback_log.source_ip)
self.assertLess(abs((timezone.now() - fallback_log.event_at).total_seconds()), 10)
def test_ingest_logs_returns_404_for_unknown_server(self) -> None:
response = self.client.post(
"/api/v1/agent/servers/999999/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 404)
def test_ingest_logs_requires_agent_token(self) -> None:
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps([]),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
def test_ingest_logs_rejects_token_for_different_server(self) -> None:
other_server = Server.objects.create(display_name="web-02", hostname="web-02.example.test")
other_server.agent_api_token_hash = hash_agent_token("other-server-token")
other_server.save(update_fields=["agent_api_token_hash"])
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer other-server-token",
)
self.assertEqual(response.status_code, 403)
def test_log_config_returns_server_sources(self) -> None:
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.SERVICE,
name="OpenSSH",
service_unit="ssh.service",
category_override="access",
event_type_override="ssh",
enabled=True,
)
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.FILE,
name="Auth File",
file_path="/var/log/auth.log",
category_override="auth",
event_type_override="file.line",
enabled=True,
)
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.JOURNAL,
name="Kernel Journal",
parser=ServerLogSource.Parser.NONE,
include_matches={"_TRANSPORT": ["kernel"]},
category_override="system",
event_type_override="kernel",
enabled=True,
)
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload), 3)
kinds = {item["kind"] for item in payload}
self.assertEqual(kinds, {"service", "file", "journal"})
for item in payload:
self.assertTrue(item["source_id"])
self.assertIn("parser", item)
self.assertIn("include_matches", item)
self.assertIn("exclude_matches", item)
def test_log_config_returns_empty_when_no_sources(self) -> None:
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
class ServerAuditViewTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="auditor",
email="auditor@example.com",
password="pass12345",
)
self.server = Server.objects.create(display_name="db-01", hostname="db-01.example.test")
assign_perm("view_server", self.user, self.server)
now = timezone.now()
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=5),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.success",
username="alice",
source_ip="203.0.113.10",
message="Accepted publickey for alice",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=4),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.fail",
username="bob",
source_ip="198.51.100.5",
message="Failed password for bob",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=3),
source_kind="file",
source_name="/var/log/auth.log",
category="network",
event_type="network",
message="Interface eth0 changed",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=2),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="203.0.113.10",
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
fields={"nginx.status": "200"},
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=1),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="198.51.100.5",
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
fields={"nginx.status": "401"},
)
def test_audit_view_applies_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{
"category": "access",
"event_type": "ssh.login.success",
"q": "Accepted",
},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "ssh.login.success")
def test_audit_panel_view_supports_source_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit_panel", args=[self.server.id]),
{
"source_kind": "file",
"source_name": "/var/log/auth.log",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "/var/log/auth.log")
self.assertNotContains(response, "ssh.login.success")
def test_audit_view_exposes_chart_context(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "charts"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["audit_subpage"], "charts")
charts = response.context["audit_charts"]
login = charts["login_attempts"]
self.assertEqual(login["total_attempts"], 2)
self.assertEqual(login["failed_attempts"], 1)
self.assertEqual(login["successful_attempts"], 1)
self.assertEqual(login["failure_rate_pct"], 50)
self.assertEqual(login["chart"]["drilldowns"][0], "view=logs&login_outcome=attempts")
status_labels = charts["http_status_codes"]["chart"]["labels"]
status_counts = charts["http_status_codes"]["chart"]["values"]
status_map = dict(zip(status_labels, status_counts))
self.assertEqual(status_map["401"], 1)
self.assertNotIn("200", status_map)
self.assertEqual(charts["http_status_codes"]["implicit_total"], 1)
def test_audit_view_drilldown_filters_logs_for_http_status(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "http_status": "401"},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "http.access")
self.assertIn(" 401 ", logs[0].message)
def test_audit_view_drilldown_filters_logs_for_login_outcome(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "login_outcome": "success"},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "ssh.login.success")
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from django.test import SimpleTestCase, override_settings
from apps.servers.consumers import _build_ssh_command
class ShellSSHCommandTests(SimpleTestCase):
def test_build_command_uses_default_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], "/bin/bash")
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="/bin/bash -li")
def test_build_command_appends_configured_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-2:], ["/bin/bash", "-li"])
self.assertIn("alice@server.example.test", command)
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="")
def test_build_command_can_disable_explicit_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], "alice@server.example.test")
def test_build_command_can_disable_tty_and_set_timeout(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
remote_command="true",
force_tty=False,
connect_timeout=8,
)
self.assertNotIn("-tt", command)
self.assertIn("ConnectTimeout=8", command)
self.assertEqual(command[-1], "true")
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND='"/bin/bash -li')
def test_build_command_tolerates_invalid_shell_quoting(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], '"/bin/bash -li')
+9
View File
@@ -6,8 +6,17 @@ app_name = "servers"
urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("admin/", views.admin_dashboard, name="admin_dashboard"),
path("<int:server_id>/request-access/", views.request_access, name="request_access"),
path(
"access-requests/<int:request_id>/decision/",
views.decide_access_request,
name="decide_access_request",
),
path("<int:server_id>/admin/", views.server_admin, name="server_admin"),
path("<int:server_id>/", views.detail, name="detail"),
path("<int:server_id>/audit/", views.audit, name="audit"),
path("<int:server_id>/audit/panel/", views.audit_panel, name="audit_panel"),
path("<int:server_id>/shell/", views.shell, name="shell"),
path("<int:server_id>/settings/", views.settings, name="settings"),
]
+853 -31
View File
@@ -1,67 +1,286 @@
from __future__ import annotations
from datetime import timedelta
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.db.models import Q
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import Http404
from django.shortcuts import render
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import get_objects_for_user, get_perms
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
from apps.servers.permissions import user_can_shell
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()
server_qs = get_objects_for_user(
request.user,
"servers.view_server",
klass=Server,
accept_global_perms=False,
)
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.select_related("server")
.filter(
requester=request.user,
AccessRequest.objects.filter(
requester=user,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at")
)
expires_map = {}
access_state_map: dict[int, dict] = {}
request_governed_server_ids: set[int] = set()
for access in access_qs:
expires_at = access.expires_at
if access.server_id not in expires_map:
expires_map[access.server_id] = expires_at
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
current = expires_map[access.server_id]
if current is None:
continue
if expires_at is None or expires_at > current:
expires_map[access.server_id] = expires_at
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": expires_map.get(server.id),
"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/dashboard.html", context)
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/")
@@ -71,6 +290,10 @@ def detail(request, server_id: int):
# 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(
@@ -78,6 +301,7 @@ def detail(request, server_id: int):
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()
@@ -94,6 +318,9 @@ def detail(request, server_id: int):
"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)
@@ -105,7 +332,10 @@ def shell(request, server_id: int):
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.
if not user_can_shell(request.user, server):
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 ""
@@ -128,7 +358,10 @@ def shell(request, server_id: int):
"cert_filename": cert_filename,
"active_tab": "shell",
"is_popout": request.GET.get("popout") == "1",
"can_shell": True,
"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)
@@ -138,28 +371,179 @@ def shell(request, server_id: int):
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),
"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),
"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.
@@ -167,7 +551,7 @@ def _get_server_or_404(request, server_id: int) -> Server:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise Http404("Server not found")
if "view_server" not in get_perms(request.user, server):
if not (request.user.has_perm("servers.view_server", server) or _is_admin_user(request.user)):
raise Http404("Server not found")
return server
@@ -228,3 +612,441 @@ def _build_server_status(server: Server, now):
"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])