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])
+5
View File
@@ -128,6 +128,11 @@ def build_router() -> Router:
server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
if not any([payload.request_shell, payload.request_logs, payload.request_users]):
raise HttpError(
422,
{"detail": "Select at least one access scope: shell, logs, or users."},
)
access_request = AccessRequest(
requester=request.user,
server=server,
+301 -28
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
import secrets
from datetime import datetime, timedelta, timezone as dt_timezone
from typing import List, Optional
from cryptography import x509
@@ -9,7 +10,9 @@ from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpRequest
from django.utils.dateparse import parse_datetime
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema
@@ -17,6 +20,7 @@ from ninja.errors import HttpError
from pydantic import Field
from guardian.shortcuts import get_users_with_perms
from apps.access.models import AccessRequest
from apps.core.rbac import require_perms
from apps.keys.certificates import get_active_ca
from apps.keys.models import SSHKey
@@ -25,10 +29,13 @@ from apps.servers.models import (
AgentCertificateAuthority,
EnrollmentToken,
Server,
ServerAuditLog,
ServerAccount,
ServerLogSource,
hostname_validator,
)
from apps.telemetry.models import TelemetryEvent
from keywarden.api.security import AgentPrincipal, AgentTokenAuth, hash_agent_token
class AuthorizedKeyOut(Schema):
@@ -82,12 +89,15 @@ class AgentEnrollOut(Schema):
server_id: str
client_cert_pem: str
ca_cert_pem: str
agent_api_token: str
class LogEventIn(Schema):
timestamp: str
category: str
event_type: str
source_kind: Optional[str] = None
source_name: Optional[str] = None
unit: Optional[str] = None
priority: Optional[str] = None
hostname: Optional[str] = None
@@ -105,6 +115,19 @@ class LogIngestOut(Schema):
accepted: int
class LogSourceConfigOut(Schema):
source_id: str
kind: str
name: str
service_unit: Optional[str] = None
file_path: Optional[str] = None
parser: str = "none"
include_matches: dict = Field(default_factory=dict)
exclude_matches: dict = Field(default_factory=dict)
category: str
event_type: str
class AgentHeartbeatIn(Schema):
host: Optional[str] = None
ipv4: Optional[str] = None
@@ -114,6 +137,7 @@ class AgentHeartbeatIn(Schema):
def build_router() -> Router:
router = Router()
agent_auth = AgentTokenAuth()
@router.post("/enroll", response=AgentEnrollOut, auth=None)
@csrf_exempt
@@ -164,10 +188,19 @@ def build_router() -> Router:
token.mark_used(server)
token.save(update_fields=["used_at", "server"])
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
agent_api_token = _issue_agent_api_token()
server.agent_enrolled_at = timezone.now()
server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
server.agent_api_token_hash = hash_agent_token(agent_api_token)
server.save(
update_fields=[
"agent_enrolled_at",
"agent_cert_fingerprint",
"agent_cert_serial",
"agent_api_token_hash",
]
)
except IntegrityError:
raise HttpError(409, "Server already enrolled")
@@ -175,6 +208,7 @@ def build_router() -> Router:
server_id=str(server.id),
client_cert_pem=cert_pem,
ca_cert_pem=ca_pem,
agent_api_token=agent_api_token,
)
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
@@ -183,7 +217,7 @@ def build_router() -> Router:
Auth: required (admin/operator via API).
Permissions: requires view access to servers and keys.
Behavior: uses server object permissions + active SSH keys to produce
Behavior: uses access request scopes + active SSH keys to produce
the exact key list the agent should deploy to the server.
Rationale: this is the policy enforcement point for per-user access.
"""
@@ -193,7 +227,7 @@ def build_router() -> Router:
"keys.view_sshkey",
)
server = _get_server_or_404(server_id)
users = _resolve_access_users(server)
users = _resolve_access_users(server, include_shell=True, include_users=False)
key_map = _key_map_for_users(users)
output: list[AuthorizedKeyOut] = []
for user in users:
@@ -209,16 +243,17 @@ def build_router() -> Router:
)
return output
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=None)
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=agent_auth)
def account_access(request: HttpRequest, server_id: int):
"""List accounts that should exist on a server.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: resolves active users with server object perms and their keys.
Auth: per-agent bearer token issued at enrollment.
Behavior: resolves active users with approved shell/users scopes.
Rationale: drives agent-side account provisioning.
"""
_require_agent_access(request, server_id)
server = _get_server_or_404(server_id)
users = _resolve_access_users(server)
users = _resolve_access_users(server, include_shell=True, include_users=True)
return [
AccountAccessOut(
user_id=user.id,
@@ -230,29 +265,31 @@ def build_router() -> Router:
for user in users
]
@router.get("/servers/{server_id}/ssh-ca", auth=None)
@router.get("/servers/{server_id}/ssh-ca", auth=agent_auth)
@csrf_exempt
def ssh_ca(request: HttpRequest, server_id: int):
"""Return the active SSH user CA public key for agents.
Auth: mTLS expected at the edge (no session/JWT).
Auth: per-agent bearer token issued at enrollment.
"""
_require_agent_access(request, server_id)
_ = _get_server_or_404(server_id)
ca = get_active_ca()
if not ca.public_key:
raise HttpError(404, "SSH CA not configured")
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=agent_auth)
@csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server.
Auth: mTLS expected at the edge (no session/JWT).
Auth: per-agent bearer token issued at enrollment.
Behavior: stores a telemetry event with counts of applied/revoked keys.
Rationale: provides an audit trail of enforcement actions without
requiring full log ingestion for every sync cycle.
"""
_require_agent_access(request, server_id)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -273,34 +310,94 @@ def build_router() -> Router:
_update_server_accounts(server, payload.accounts)
return SyncReportOut(status="ok")
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=agent_auth)
@csrf_exempt
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""Accept log batches from agents for audit collection.
Auth: mTLS expected at the edge (no session/JWT).
Auth: per-agent bearer token issued at enrollment.
Behavior: accepts structured log events for later storage and indexing.
Storage: raw logs are persisted separately per-server (SQLite shards),
not in the primary Postgres database.
Storage: events are persisted in the primary datastore and indexed per
server for filtering in the server audit view.
Rationale: this is the ingestion pipe for security audit logging.
"""
_require_agent_access(request, server_id)
try:
Server.objects.get(id=server_id)
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
# TODO: enqueue to Valkey and persist to SQLite slices.
return LogIngestOut(status="accepted", accepted=len(payload))
if not payload:
return LogIngestOut(status="accepted", accepted=0)
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=None)
now = timezone.now()
records: list[ServerAuditLog] = []
for event in payload:
records.append(
ServerAuditLog(
server=server,
event_at=_parse_event_at(event.timestamp, now),
category=_truncate(event.category, 64, default="system"),
event_type=_truncate(event.event_type, 128, default="system"),
source_kind=_normalize_source_kind(event.source_kind, event.unit, event.fields),
source_name=_normalize_source_name(event.source_name, event.unit, event.fields),
unit=_truncate(event.unit, 128),
priority=_truncate(event.priority, 16),
hostname=_truncate(event.hostname, 253),
username=_truncate(event.username, 150),
principal=_truncate(event.principal, 255),
source_ip=_normalize_ip_any(event.source_ip),
session_id=_truncate(event.session_id, 128),
message=(event.message or "").strip(),
raw=event.raw or "",
fields=_coerce_fields(event.fields),
)
)
ServerAuditLog.objects.bulk_create(records, batch_size=500)
return LogIngestOut(status="accepted", accepted=len(records))
@router.get("/servers/{server_id}/log-config", response=List[LogSourceConfigOut], auth=agent_auth)
def log_config(request: HttpRequest, server_id: int):
"""Return server log-source configuration consumed by the agent.
Auth: per-agent bearer token issued at enrollment.
Behavior: returns enabled journal/service/file log sources for this server.
Default: when no sources are defined, an empty list is returned and
the agent falls back to current-boot journald collection.
"""
_require_agent_access(request, server_id)
server = _get_server_or_404(server_id)
sources = list(server.log_sources.filter(enabled=True).order_by("kind", "name", "id"))
if not sources:
return []
output: list[LogSourceConfigOut] = []
for source in sources:
output.append(
LogSourceConfigOut(
source_id=str(source.id),
kind=source.kind,
name=(source.name or source.service_unit or source.file_path or "").strip(),
service_unit=(source.service_unit or "").strip() or None,
file_path=(source.file_path or "").strip() or None,
parser=(source.parser or "").strip() or "none",
include_matches=_clean_source_matches(source.include_matches),
exclude_matches=_clean_source_matches(source.exclude_matches),
category=(source.category_override or "").strip() or _default_category_for_source(source),
event_type=(source.event_type_override or "").strip() or _default_event_type_for_source(source),
)
)
return output
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=agent_auth)
@csrf_exempt
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
"""Update server host metadata (hostname/IPs) reported by the agent.
Auth: mTLS expected at the edge (no session/JWT).
Auth: per-agent bearer token issued at enrollment.
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
Conflict: unique constraints are enforced; conflicts return 409.
Rationale: keeps the server inventory accurate without manual edits.
"""
_require_agent_access(request, server_id)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -343,17 +440,69 @@ def _get_server_or_404(server_id: int) -> Server:
raise HttpError(404, "Server not found")
def _resolve_access_users(server: Server) -> list:
users = list(
get_users_with_perms(
def _issue_agent_api_token() -> str:
# Opaque, high-entropy token; only SHA-256 hash is stored server-side.
return secrets.token_urlsafe(48)
def _require_agent_access(request: HttpRequest, server_id: int) -> None:
principal = getattr(request, "auth", None)
if not isinstance(principal, AgentPrincipal):
raise HttpError(401, "Unauthorized")
if principal.server_id is None:
# Backward-compatibility path for legacy global token rollout.
return
if int(principal.server_id) != int(server_id):
raise HttpError(403, "Forbidden")
def _resolve_access_users(server: Server, *, include_shell: bool, include_users: bool) -> list:
now = timezone.now()
user_ids: set[int] = set()
active_access_qs = AccessRequest.objects.filter(
server=server,
status=AccessRequest.Status.APPROVED,
).filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
active_access_user_ids = set(active_access_qs.values_list("requester_id", flat=True))
scope_filter = Q()
if include_shell:
scope_filter |= Q(request_shell=True)
if include_users:
scope_filter |= Q(request_users=True)
if scope_filter:
scoped_access_qs = active_access_qs.filter(scope_filter)
user_ids.update(scoped_access_qs.values_list("requester_id", flat=True))
if include_shell:
for user in get_users_with_perms(
server,
only_with_perms_in=["shell_server"],
with_group_users=True,
with_superusers=False,
):
if getattr(user, "is_active", False):
user_ids.add(user.id)
if include_users:
for user in get_users_with_perms(
server,
only_with_perms_in=["view_server"],
with_group_users=True,
with_superusers=False,
)
)
active = [user for user in users if getattr(user, "is_active", False)]
return sorted(active, key=lambda user: (user.username or "", user.id))
):
if not getattr(user, "is_active", False):
continue
# If access requests exist for this user+server, scopes govern.
if user.id in active_access_user_ids:
continue
user_ids.add(user.id)
if not user_ids:
return []
User = get_user_model()
users = list(User.objects.filter(id__in=user_ids, is_active=True))
return sorted(users, key=lambda user: (user.username or "", user.id))
def _key_map_for_users(users: list) -> dict[int, list[SSHKey]]:
@@ -463,4 +612,128 @@ def _normalize_ip(value: Optional[str], version: int) -> Optional[str]:
return value
def _normalize_ip_any(value: Optional[str]) -> Optional[str]:
candidate = (value or "").strip()
if not candidate:
return None
for version in (4, 6):
normalized = _normalize_ip(candidate, version)
if normalized:
return normalized
return None
def _truncate(value: Optional[str], max_length: int, default: str = "") -> str:
return (value or default).strip()[:max_length]
def _parse_event_at(value: Optional[str], fallback: datetime) -> datetime:
timestamp = (value or "").strip()
parsed = parse_datetime(timestamp) if timestamp else None
if parsed is None:
if timezone.is_naive(fallback):
fallback = timezone.make_aware(fallback, dt_timezone.utc)
return fallback.astimezone(dt_timezone.utc)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, dt_timezone.utc)
return parsed.astimezone(dt_timezone.utc)
def _coerce_fields(payload: Optional[dict]) -> dict:
if not isinstance(payload, dict):
return {}
clean: dict[str, str] = {}
for key, value in payload.items():
key_text = str(key).strip()
if not key_text:
continue
clean[key_text] = str(value)
return clean
def _normalize_source_kind(kind: Optional[str], unit: Optional[str], fields: Optional[dict]) -> str:
value = (kind or "").strip().lower()
if value in {"service", "file", "journal"}:
return value
if (unit or "").strip() or ((fields or {}).get("_SYSTEMD_UNIT")):
return "service"
transport = (fields or {}).get("transport") or (fields or {}).get("_TRANSPORT")
if transport:
return "journal"
file_path = (fields or {}).get("file_path")
if file_path:
return "file"
return ""
def _normalize_source_name(name: Optional[str], unit: Optional[str], fields: Optional[dict]) -> str:
value = _truncate(name, 512)
if value:
return value
unit_value = _truncate(unit, 128)
if unit_value:
return unit_value
raw_unit = _truncate((fields or {}).get("_SYSTEMD_UNIT"), 128)
if raw_unit:
return raw_unit
return _truncate((fields or {}).get("file_path"), 512)
def _default_category_for_source(source: ServerLogSource) -> str:
if source.kind == ServerLogSource.Kind.JOURNAL:
include_matches = _clean_source_matches(source.include_matches)
transports = include_matches.get("_TRANSPORT", [])
if any(str(item).strip().lower() == "kernel" for item in transports):
return "system"
return "system"
if source.kind == ServerLogSource.Kind.SERVICE:
unit = (source.service_unit or "").lower()
if unit in ("sshd.service", "ssh.service"):
return "access"
if unit == "sudo.service":
return "auth"
if unit in ("systemd-networkd.service", "networkmanager.service"):
return "network"
return "system"
def _default_event_type_for_source(source: ServerLogSource) -> str:
if source.kind == ServerLogSource.Kind.JOURNAL:
include_matches = _clean_source_matches(source.include_matches)
transports = include_matches.get("_TRANSPORT", [])
if any(str(item).strip().lower() == "kernel" for item in transports):
return "kernel"
return "system"
if source.kind == ServerLogSource.Kind.FILE:
return "file.line"
if source.kind == ServerLogSource.Kind.SERVICE:
unit = (source.service_unit or "").lower()
if unit in ("sshd.service", "ssh.service"):
return "ssh"
if unit == "sudo.service":
return "auth"
if unit in ("systemd-networkd.service", "networkmanager.service"):
return "network"
return "system"
def _clean_source_matches(raw: Optional[dict]) -> dict:
if not isinstance(raw, dict):
return {}
out: dict[str, list[str]] = {}
for key, value in raw.items():
key_text = str(key).strip()
if not key_text:
continue
values: list[str]
if isinstance(value, list):
values = [str(item).strip() for item in value if str(item).strip()]
else:
item_text = str(value).strip()
values = [item_text] if item_text else []
if values:
out[key_text] = values
return out
router = build_router()
+47
View File
@@ -1,13 +1,19 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from secrets import compare_digest
from typing import Optional
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest
from ninja.security import HttpBearer
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed
from apps.servers.models import Server
class JWTAuth(HttpBearer):
"""
@@ -28,3 +34,44 @@ class JWTAuth(HttpBearer):
return None
class AgentTokenAuth(HttpBearer):
"""
Auth via Authorization: Bearer <agent token>.
Validates enrollment-issued per-server tokens first, with an optional
global fallback from KEYWARDEN_AGENT_API_TOKEN for migration.
"""
def authenticate(self, request: HttpRequest, token: str) -> Optional["AgentPrincipal"]:
candidate = _normalize_agent_token(token)
if not candidate:
return None
token_hash = hash_agent_token(candidate)
server = Server.objects.filter(agent_api_token_hash=token_hash).only("id").first()
if server:
return AgentPrincipal(server_id=server.id, mode="server-token")
configured = _normalize_agent_token(getattr(settings, "KEYWARDEN_AGENT_API_TOKEN", ""))
if configured and compare_digest(candidate, configured):
return AgentPrincipal(server_id=None, mode="global-token")
return None
@dataclass(frozen=True)
class AgentPrincipal:
server_id: Optional[int]
mode: str
def _normalize_agent_token(value: object) -> str:
token = str(value or "").strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
return token
def hash_agent_token(value: str) -> str:
normalized = _normalize_agent_token(value)
if not normalized:
return ""
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+18
View File
@@ -189,6 +189,9 @@ CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
TIME_ZONE = "UTC"
USE_TZ = True
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
@@ -196,10 +199,25 @@ SESSION_CACHE_ALIAS = "default"
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30"))
KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15"))
KEYWARDEN_SHELL_DEBUG = os.getenv("KEYWARDEN_SHELL_DEBUG", "0").strip().lower() in {
"1",
"true",
"yes",
"on",
}
KEYWARDEN_SHELL_PRESERVE_TMP = os.getenv("KEYWARDEN_SHELL_PRESERVE_TMP", "0").strip().lower() in {
"1",
"true",
"yes",
"on",
}
KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS = int(os.getenv("KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS", "10"))
KEYWARDEN_SHELL_REMOTE_COMMAND = os.getenv("KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
)
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120"))
KEYWARDEN_AGENT_API_TOKEN = os.getenv("KEYWARDEN_AGENT_API_TOKEN", "").strip()
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
+2
View File
@@ -517,6 +517,8 @@ function dateTimeShortcutsOverlay() {
childList: true,
subtree: true,
});
findCalendars();
};
/*************************************************************
+71 -3
View File
@@ -15,13 +15,41 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
{% tailwind_css %}
<style>
.kw-shell-width {
max-width: 80rem;
}
html[data-display-mode="full"] .kw-shell-width {
max-width: min(1800px, calc(100vw - 1.5rem));
}
@media (min-width: 640px) {
html[data-display-mode="full"] .kw-shell-width {
max-width: min(1880px, calc(100vw - 3rem));
}
}
</style>
<script>
(function () {
var key = "keywardenDisplayMode";
function normalize(mode) {
return mode === "full" ? "full" : "compact";
}
var mode = "compact";
try {
mode = normalize(window.localStorage.getItem(key));
} catch (err) {
mode = "compact";
}
document.documentElement.setAttribute("data-display-mode", mode);
})();
</script>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 text-slate-900 antialiased font-['Space_Grotesk']">
<div class="min-h-screen flex flex-col">
{% if not is_popout %}
<header class="border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/75">
<nav class="mx-auto max-w-screen-xl px-4 py-3 lg:px-6">
<nav class="kw-shell-width mx-auto w-full px-4 py-3 lg:px-6">
<div class="flex flex-wrap items-center justify-between">
<a href="/" class="flex items-center gap-3">
<span class="flex h-11 w-11 items-center justify-center rounded-2xl bg-white p-1 shadow-sm ring-1 ring-blue-100">
@@ -80,6 +108,16 @@
Profile
</a>
</li>
{% if request.user.is_superuser or request.user.is_staff or perms.servers.change_server or perms.access.change_accessrequest %}
<li>
<a
href="{% url 'servers:admin_dashboard' %}"
class="block rounded px-3 py-2 hover:bg-gray-100 md:px-0 md:py-0 md:hover:bg-transparent md:hover:text-blue-700"
>
Admin
</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
@@ -88,13 +126,13 @@
</header>
{% endif %}
<main class="mx-auto w-full max-w-screen-xl flex-1 px-4 py-8 sm:px-6 lg:px-8">
<main class="kw-shell-width mx-auto w-full flex-1 px-4 py-8 sm:px-6 lg:px-8">
{% block content %}{% endblock %}
</main>
{% if not is_popout %}
<footer class="border-t border-gray-200 bg-white">
<div class="mx-auto flex w-full max-w-screen-xl flex-col gap-2 px-4 py-6 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div class="kw-shell-width mx-auto flex w-full flex-col gap-2 px-4 py-6 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-700">Keywarden</span>
<span class="text-gray-300">•</span>
@@ -109,6 +147,36 @@
</footer>
{% endif %}
</div>
<script>
(function () {
var key = "keywardenDisplayMode";
function normalize(mode) {
return mode === "full" ? "full" : "compact";
}
function getMode() {
try {
return normalize(window.localStorage.getItem(key));
} catch (err) {
return "compact";
}
}
function setMode(mode) {
var normalized = normalize(mode);
document.documentElement.setAttribute("data-display-mode", normalized);
try {
window.localStorage.setItem(key, normalized);
} catch (err) {
// Ignore storage quota or privacy-mode failures.
}
if (typeof window.CustomEvent === "function") {
window.dispatchEvent(new CustomEvent("keywarden:display-mode", { detail: { mode: normalized } }));
}
return normalized;
}
window.keywardenGetDisplayMode = getMode;
window.keywardenSetDisplayMode = setMode;
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script>
</body>
</html>