Ephemeral keys for xterm.js. Initial rework of audit logging. All endpoints now return a 401 regardless of presence if not logged in.
This commit is contained in:
@@ -1,17 +1,140 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
from unfold.decorators import action # type: ignore
|
|
||||||
|
|
||||||
|
from .matching import list_api_endpoint_suggestions, list_websocket_endpoint_suggestions
|
||||||
from .models import AuditEventType, AuditLog
|
from .models import AuditEventType, AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventTypeAdminForm(forms.ModelForm):
|
||||||
|
endpoints_text = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"rows": 8,
|
||||||
|
"placeholder": "/api/v1/servers/\nGET /api/v1/servers/<int:server_id>/\n/ws/servers/*/shell/",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
help_text=(
|
||||||
|
"One endpoint pattern per line. Supports '*' wildcards and optional METHOD prefixes "
|
||||||
|
"like 'GET /api/v1/servers/*'."
|
||||||
|
),
|
||||||
|
label="Endpoint patterns",
|
||||||
|
)
|
||||||
|
ip_whitelist_text = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "10.0.0.1\n192.168.1.0/24"}),
|
||||||
|
help_text="One IP address or CIDR range per line.",
|
||||||
|
label="IP whitelist entries",
|
||||||
|
)
|
||||||
|
ip_blacklist_text = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "203.0.113.10\n198.51.100.0/24"}),
|
||||||
|
help_text="One IP address or CIDR range per line.",
|
||||||
|
label="IP blacklist entries",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AuditEventType
|
||||||
|
fields = (
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"kind",
|
||||||
|
"default_severity",
|
||||||
|
"endpoints_text",
|
||||||
|
"ip_whitelist_enabled",
|
||||||
|
"ip_whitelist_text",
|
||||||
|
"ip_blacklist_enabled",
|
||||||
|
"ip_blacklist_text",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ("audit/eventtype_form.js",)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
instance = kwargs.get("instance") or getattr(self, "instance", None)
|
||||||
|
if instance and instance.pk:
|
||||||
|
self.fields["endpoints_text"].initial = "\n".join(instance.endpoints or [])
|
||||||
|
self.fields["ip_whitelist_text"].initial = "\n".join(instance.ip_whitelist or [])
|
||||||
|
self.fields["ip_blacklist_text"].initial = "\n".join(instance.ip_blacklist or [])
|
||||||
|
self.fields["endpoints_text"].widget.attrs["data-api-suggestions"] = json.dumps(
|
||||||
|
list_api_endpoint_suggestions()
|
||||||
|
)
|
||||||
|
self.fields["endpoints_text"].widget.attrs["data-ws-suggestions"] = json.dumps(
|
||||||
|
list_websocket_endpoint_suggestions()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lines_to_list(self, value: str) -> list[str]:
|
||||||
|
results: list[str] = []
|
||||||
|
for line in (value or "").splitlines():
|
||||||
|
candidate = line.strip()
|
||||||
|
if candidate:
|
||||||
|
results.append(candidate)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def clean_endpoints_text(self) -> str:
|
||||||
|
value = self.cleaned_data.get("endpoints_text", "")
|
||||||
|
# Normalize whitespace but keep the raw text for display.
|
||||||
|
lines = self._lines_to_list(value)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def save(self, commit: bool = True):
|
||||||
|
instance: AuditEventType = super().save(commit=False)
|
||||||
|
endpoints_text = self.cleaned_data.get("endpoints_text", "")
|
||||||
|
whitelist_text = self.cleaned_data.get("ip_whitelist_text", "")
|
||||||
|
blacklist_text = self.cleaned_data.get("ip_blacklist_text", "")
|
||||||
|
instance.endpoints = self._lines_to_list(endpoints_text)
|
||||||
|
instance.ip_whitelist = self._lines_to_list(whitelist_text)
|
||||||
|
instance.ip_blacklist = self._lines_to_list(blacklist_text)
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AuditEventType)
|
@admin.register(AuditEventType)
|
||||||
class AuditEventTypeAdmin(ModelAdmin):
|
class AuditEventTypeAdmin(ModelAdmin):
|
||||||
list_display = ("key", "title", "default_severity", "created_at")
|
form = AuditEventTypeAdminForm
|
||||||
search_fields = ("key", "title", "description")
|
list_display = ("key", "title", "kind", "default_severity", "created_at")
|
||||||
list_filter = ("default_severity",)
|
search_fields = ("key", "title", "description", "endpoints")
|
||||||
|
list_filter = ("kind", "default_severity", "ip_whitelist_enabled", "ip_blacklist_enabled")
|
||||||
ordering = ("key",)
|
ordering = ("key",)
|
||||||
compressed_fields = True
|
compressed_fields = True
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Event Type",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"kind",
|
||||||
|
"default_severity",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Endpoints",
|
||||||
|
{
|
||||||
|
"fields": ("endpoints_text",),
|
||||||
|
"description": "Only matching endpoints will create audit events.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"IP Controls",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"ip_whitelist_enabled",
|
||||||
|
"ip_whitelist_text",
|
||||||
|
"ip_blacklist_enabled",
|
||||||
|
"ip_blacklist_text",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AuditLog)
|
@admin.register(AuditLog)
|
||||||
@@ -87,5 +210,3 @@ class AuditLogAdmin(ModelAdmin):
|
|||||||
{"fields": ("metadata",)},
|
{"fields": ("metadata",)},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.db.models.signals import post_delete, post_save
|
||||||
|
|
||||||
|
|
||||||
class AuditConfig(AppConfig):
|
class AuditConfig(AppConfig):
|
||||||
@@ -10,6 +11,10 @@ class AuditConfig(AppConfig):
|
|||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
# Import signal handlers
|
# Import signal handlers
|
||||||
from . import signals # noqa: F401
|
from . import signals # noqa: F401
|
||||||
|
from .matching import clear_event_type_cache
|
||||||
|
from .models import AuditEventType
|
||||||
|
|
||||||
|
post_save.connect(clear_event_type_cache, sender=AuditEventType)
|
||||||
|
post_delete.connect(clear_event_type_cache, sender=AuditEventType)
|
||||||
return super().ready()
|
return super().ready()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
231
app/apps/audit/matching.py
Normal file
231
app/apps/audit/matching.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from django.urls import URLPattern, URLResolver, get_resolver
|
||||||
|
|
||||||
|
from .models import AuditEventType
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 15.0
|
||||||
|
_METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(.+)$", re.IGNORECASE)
|
||||||
|
_REGEX_GROUP_RE = re.compile(r"\(\?P<(?P<name>\w+)>[^)]+\)")
|
||||||
|
_CONVERTER_RE = re.compile(r"<(?:(?P<converter>[^:>]+):)?(?P<name>[^>]+)>")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedEndpointPattern:
|
||||||
|
method: str | None
|
||||||
|
pattern: str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(value: str) -> str:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return ""
|
||||||
|
if "?" in candidate:
|
||||||
|
candidate = candidate.split("?", 1)[0]
|
||||||
|
if not candidate.startswith("/"):
|
||||||
|
candidate = f"/{candidate}"
|
||||||
|
# Collapse duplicate slashes without being clever.
|
||||||
|
while "//" in candidate:
|
||||||
|
candidate = candidate.replace("//", "/")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_regex_anchors(value: str) -> str:
|
||||||
|
candidate = value.strip()
|
||||||
|
if candidate.startswith("^"):
|
||||||
|
candidate = candidate[1:]
|
||||||
|
if candidate.endswith("$"):
|
||||||
|
candidate = candidate[:-1]
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_to_wildcard(value: str) -> str:
|
||||||
|
candidate = _strip_regex_anchors(value)
|
||||||
|
candidate = _REGEX_GROUP_RE.sub("*", candidate)
|
||||||
|
candidate = _CONVERTER_RE.sub("*", candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoint_pattern(raw_pattern: str) -> ParsedEndpointPattern | None:
|
||||||
|
# Parse admin-provided patterns like:
|
||||||
|
# - "/api/v1/servers/*"
|
||||||
|
# - "GET /api/v1/servers/<int:server_id>/"
|
||||||
|
# We normalize both Django-style placeholders and regex routes into
|
||||||
|
# fnmatch-friendly wildcard patterns.
|
||||||
|
if not raw_pattern:
|
||||||
|
return None
|
||||||
|
raw = raw_pattern.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
method: str | None = None
|
||||||
|
endpoint = raw
|
||||||
|
match = _METHOD_RE.match(raw)
|
||||||
|
if match:
|
||||||
|
method = match.group(1).upper()
|
||||||
|
endpoint = match.group(2)
|
||||||
|
endpoint = _normalize_path(_placeholder_to_wildcard(endpoint))
|
||||||
|
if not endpoint:
|
||||||
|
return None
|
||||||
|
return ParsedEndpointPattern(method=method, pattern=endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_matches_pattern(pattern: ParsedEndpointPattern, method: str, route: str, path: str) -> bool:
|
||||||
|
if pattern.method and pattern.method != method.upper():
|
||||||
|
return False
|
||||||
|
route_norm = _normalize_path(route)
|
||||||
|
path_norm = _normalize_path(path)
|
||||||
|
return fnmatch.fnmatch(route_norm, pattern.pattern) or fnmatch.fnmatch(path_norm, pattern.pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ip_entry(
|
||||||
|
entry: str,
|
||||||
|
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Network | ipaddress.IPv6Network | None:
|
||||||
|
raw = (entry or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if "/" in raw:
|
||||||
|
return ipaddress.ip_network(raw, strict=False)
|
||||||
|
return ipaddress.ip_address(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_in_entries(ip: str, entries: Iterable[str]) -> bool:
|
||||||
|
try:
|
||||||
|
candidate_ip = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for entry in entries:
|
||||||
|
parsed = _parse_ip_entry(entry)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
if isinstance(parsed, (ipaddress.IPv4Network, ipaddress.IPv6Network)):
|
||||||
|
if candidate_ip in parsed:
|
||||||
|
return True
|
||||||
|
elif candidate_ip == parsed:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ip_allowed_for_event(event_type: AuditEventType, ip: str | None) -> bool:
|
||||||
|
# Apply whitelist first (default deny when enabled), then blacklist
|
||||||
|
# (explicit deny). If the IP cannot be determined, we only allow it
|
||||||
|
# when no whitelist is enforced.
|
||||||
|
if not ip:
|
||||||
|
# If we cannot determine the IP, allow by default unless a whitelist is enforced.
|
||||||
|
return not event_type.ip_whitelist_enabled
|
||||||
|
if event_type.ip_whitelist_enabled and not _ip_in_entries(ip, event_type.ip_whitelist or []):
|
||||||
|
return False
|
||||||
|
if event_type.ip_blacklist_enabled and _ip_in_entries(ip, event_type.ip_blacklist or []):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_matches_event(event_type: AuditEventType, method: str, route: str, path: str) -> bool:
|
||||||
|
# Event types are opt-in: an empty endpoint list never matches.
|
||||||
|
# We allow either the resolved Django route or the raw path to match
|
||||||
|
# so patterns can be authored using whichever is more stable.
|
||||||
|
patterns = event_type.endpoints or []
|
||||||
|
if not patterns:
|
||||||
|
return False
|
||||||
|
for raw_pattern in patterns:
|
||||||
|
parsed = parse_endpoint_pattern(str(raw_pattern))
|
||||||
|
if parsed and _endpoint_matches_pattern(parsed, method, route, path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_EVENT_TYPE_CACHE: dict[str, tuple[float, list[AuditEventType]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_event_type_cache(*_args, **_kwargs) -> None:
|
||||||
|
_EVENT_TYPE_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_types_for_kind(kind: str) -> list[AuditEventType]:
|
||||||
|
# Cache event-type catalogs briefly to avoid repeated DB hits on
|
||||||
|
# high-volume request paths. The cache is cleared on save/delete.
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _EVENT_TYPE_CACHE.get(kind)
|
||||||
|
if cached and (now - cached[0]) < _CACHE_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
event_types = list(AuditEventType.objects.filter(kind=kind).order_by("key"))
|
||||||
|
_EVENT_TYPE_CACHE[kind] = (now, event_types)
|
||||||
|
return event_types
|
||||||
|
|
||||||
|
|
||||||
|
def find_matching_event_type(kind: str, method: str, route: str, path: str, ip: str | None) -> AuditEventType | None:
|
||||||
|
# Deterministic first-match semantics: the ordered catalog defines
|
||||||
|
# precedence when multiple event types could match.
|
||||||
|
for event_type in get_event_types_for_kind(kind):
|
||||||
|
if not endpoint_matches_event(event_type, method=method, route=route, path=path):
|
||||||
|
continue
|
||||||
|
if not ip_allowed_for_event(event_type, ip):
|
||||||
|
continue
|
||||||
|
return event_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _join_paths(prefix: str, segment: str) -> str:
|
||||||
|
if not prefix:
|
||||||
|
return segment
|
||||||
|
if not segment:
|
||||||
|
return prefix
|
||||||
|
return f"{prefix.rstrip('/')}/{segment.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_urlpatterns(patterns: Iterable[URLPattern | URLResolver], prefix: str = "") -> list[str]:
|
||||||
|
# Flatten the resolver tree into full route strings so the admin
|
||||||
|
# UI can offer endpoint suggestions without hardcoding routes.
|
||||||
|
results: list[str] = []
|
||||||
|
for pattern in patterns:
|
||||||
|
segment = str(pattern.pattern)
|
||||||
|
combined = _join_paths(prefix, segment)
|
||||||
|
if isinstance(pattern, URLResolver):
|
||||||
|
results.extend(_walk_urlpatterns(pattern.url_patterns, combined))
|
||||||
|
else:
|
||||||
|
results.append(combined)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_suggestion(value: str) -> str:
|
||||||
|
candidate = _strip_regex_anchors(value)
|
||||||
|
candidate = candidate.replace("\\", "")
|
||||||
|
candidate = _REGEX_GROUP_RE.sub(lambda m: f"<{m.group('name')}>", candidate)
|
||||||
|
candidate = _normalize_path(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def list_api_endpoint_suggestions() -> list[str]:
|
||||||
|
# Introspect the URL resolver and keep only API routes. Suggestions
|
||||||
|
# are normalized to human-editable patterns (e.g., "<server_id>").
|
||||||
|
resolver = get_resolver()
|
||||||
|
raw_patterns = _walk_urlpatterns(resolver.url_patterns)
|
||||||
|
suggestions: set[str] = set()
|
||||||
|
for pattern in raw_patterns:
|
||||||
|
if not pattern:
|
||||||
|
continue
|
||||||
|
normalized = _normalize_suggestion(pattern)
|
||||||
|
if normalized.startswith("/api"):
|
||||||
|
suggestions.add(normalized)
|
||||||
|
return sorted(s for s in suggestions if s)
|
||||||
|
|
||||||
|
|
||||||
|
def list_websocket_endpoint_suggestions() -> list[str]:
|
||||||
|
# WebSocket routes are maintained separately by Channels, so we
|
||||||
|
# import them directly from the ASGI routing module.
|
||||||
|
try:
|
||||||
|
from keywarden.routing import websocket_urlpatterns
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
raw_patterns = [str(p.pattern) for p in websocket_urlpatterns]
|
||||||
|
suggestions = {_normalize_suggestion(p) for p in raw_patterns}
|
||||||
|
return sorted(s for s in suggestions if s)
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
|
from .matching import find_matching_event_type
|
||||||
from .models import AuditEventType, AuditLog
|
from .models import AuditEventType, AuditLog
|
||||||
from .utils import get_client_ip, get_request_id
|
from .utils import get_client_ip, get_request_id
|
||||||
|
|
||||||
_EVENT_CACHE: dict[str, AuditEventType] = {}
|
|
||||||
_SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user")
|
_SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user")
|
||||||
_SKIP_SUFFIXES = ("/health", "/health/")
|
_SKIP_SUFFIXES = ("/health", "/health/")
|
||||||
|
|
||||||
@@ -18,6 +16,8 @@ def _is_api_request(path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _should_log_request(path: str) -> bool:
|
def _should_log_request(path: str) -> bool:
|
||||||
|
# Only audit API traffic and skip endpoints that would recursively
|
||||||
|
# generate noisy audit events (audit endpoints, health checks, etc.).
|
||||||
if not _is_api_request(path):
|
if not _is_api_request(path):
|
||||||
return False
|
return False
|
||||||
if path in _SKIP_PREFIXES:
|
if path in _SKIP_PREFIXES:
|
||||||
@@ -37,46 +37,12 @@ def _resolve_route(request, fallback: str) -> str:
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _event_key_for(method: str, route: str) -> str:
|
|
||||||
base = f"api_{method.lower()}_{route}"
|
|
||||||
slug = slugify(base)
|
|
||||||
if not slug:
|
|
||||||
return "api_request"
|
|
||||||
if len(slug) <= 64:
|
|
||||||
return slug
|
|
||||||
digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:8]
|
|
||||||
prefix_len = 64 - len(digest) - 1
|
|
||||||
return f"{slug[:prefix_len]}-{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
def _event_title_for(method: str, route: str) -> str:
|
|
||||||
title = f"API {method.upper()} {route}"
|
|
||||||
if len(title) <= 128:
|
|
||||||
return title
|
|
||||||
return f"{title[:125]}..."
|
|
||||||
|
|
||||||
|
|
||||||
def _get_endpoint_event(method: str, route: str) -> AuditEventType:
|
|
||||||
key = _event_key_for(method, route)
|
|
||||||
cached = _EVENT_CACHE.get(key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
event, _ = AuditEventType.objects.get_or_create(
|
|
||||||
key=key,
|
|
||||||
defaults={
|
|
||||||
"title": _event_title_for(method, route),
|
|
||||||
"default_severity": AuditEventType.Severity.INFO,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_EVENT_CACHE[key] = event
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
class ApiAuditLogMiddleware:
|
class ApiAuditLogMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
|
# Fast-exit for non-audited paths before taking timing measurements.
|
||||||
path = request.path_info or request.path
|
path = request.path_info or request.path
|
||||||
if not _should_log_request(path):
|
if not _should_log_request(path):
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
@@ -96,8 +62,21 @@ class ApiAuditLogMiddleware:
|
|||||||
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
|
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
route = _resolve_route(request, path)
|
route = _resolve_route(request, path)
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
# Audit events are explicit: if no configured event type matches,
|
||||||
|
# we do not create either an event type or a log entry.
|
||||||
|
event_type = find_matching_event_type(
|
||||||
|
kind=AuditEventType.Kind.API,
|
||||||
|
method=request.method,
|
||||||
|
route=route,
|
||||||
|
path=path,
|
||||||
|
ip=client_ip,
|
||||||
|
)
|
||||||
|
if event_type is None:
|
||||||
|
return
|
||||||
user = getattr(request, "user", None)
|
user = getattr(request, "user", None)
|
||||||
actor = user if getattr(user, "is_authenticated", False) else None
|
actor = user if getattr(user, "is_authenticated", False) else None
|
||||||
|
# Store normalized request context for filtering and forensics.
|
||||||
metadata = {
|
metadata = {
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": path,
|
"path": path,
|
||||||
@@ -111,11 +90,11 @@ class ApiAuditLogMiddleware:
|
|||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
created_at=timezone.now(),
|
created_at=timezone.now(),
|
||||||
actor=actor,
|
actor=actor,
|
||||||
event_type=_get_endpoint_event(request.method, route),
|
event_type=event_type,
|
||||||
message=f"API request {request.method} {route} -> {status_code}",
|
message=f"API request {request.method} {route} -> {status_code}",
|
||||||
severity=AuditEventType.Severity.INFO,
|
severity=event_type.default_severity,
|
||||||
source=AuditLog.Source.API,
|
source=AuditLog.Source.API,
|
||||||
ip_address=get_client_ip(request),
|
ip_address=client_ip,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
request_id=get_request_id(request),
|
request_id=get_request_id(request),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("audit", "0002_alter_auditlog_event_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("api", "API"), ("websocket", "WebSocket")],
|
||||||
|
db_index=True,
|
||||||
|
default="api",
|
||||||
|
help_text="Whether this event type applies to API or WebSocket traffic.",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="endpoints",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text=(
|
||||||
|
"List of endpoint patterns that should generate this event type. "
|
||||||
|
"Use one pattern per line in the admin form. Supports '*' wildcards "
|
||||||
|
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="ip_whitelist_enabled",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If enabled, only IPs in the whitelist will generate this event type.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="ip_whitelist",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="ip_blacklist_enabled",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditeventtype",
|
||||||
|
name="ip_blacklist",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -13,6 +13,10 @@ class AuditEventType(models.Model):
|
|||||||
Useful for consistent naming, severity, and descriptions.
|
Useful for consistent naming, severity, and descriptions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
API = "api", "API"
|
||||||
|
WEBSOCKET = "websocket", "WebSocket"
|
||||||
|
|
||||||
class Severity(models.TextChoices):
|
class Severity(models.TextChoices):
|
||||||
INFO = "info", "Info"
|
INFO = "info", "Info"
|
||||||
WARNING = "warning", "Warning"
|
WARNING = "warning", "Warning"
|
||||||
@@ -22,9 +26,43 @@ class AuditEventType(models.Model):
|
|||||||
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
|
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
|
||||||
title = models.CharField(max_length=128, help_text="Human-readable title")
|
title = models.CharField(max_length=128, help_text="Human-readable title")
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
kind = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=Kind.choices,
|
||||||
|
default=Kind.API,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether this event type applies to API or WebSocket traffic.",
|
||||||
|
)
|
||||||
default_severity = models.CharField(
|
default_severity = models.CharField(
|
||||||
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
|
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
|
||||||
)
|
)
|
||||||
|
endpoints = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"List of endpoint patterns that should generate this event type. "
|
||||||
|
"Use one pattern per line in the admin form. Supports '*' wildcards "
|
||||||
|
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ip_whitelist_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If enabled, only IPs in the whitelist will generate this event type.",
|
||||||
|
)
|
||||||
|
ip_whitelist = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
|
||||||
|
)
|
||||||
|
ip_blacklist_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
|
||||||
|
)
|
||||||
|
ip_blacklist = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -33,7 +71,7 @@ class AuditEventType(models.Model):
|
|||||||
ordering = ["key"]
|
ordering = ["key"]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.key} ({self.default_severity})"
|
return f"{self.key} [{self.kind}] ({self.default_severity})"
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(models.Model):
|
class AuditLog(models.Model):
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ from .utils import get_client_ip
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_event(key: str, title: str, severity: str = AuditEventType.Severity.INFO) -> AuditEventType:
|
def _get_event(key: str) -> AuditEventType | None:
|
||||||
event, _ = AuditEventType.objects.get_or_create(
|
try:
|
||||||
key=key,
|
return AuditEventType.objects.get(key=key)
|
||||||
defaults={"title": title, "default_severity": severity},
|
except AuditEventType.DoesNotExist:
|
||||||
)
|
return None
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
def on_user_logged_in(sender, request, user: User, **kwargs):
|
def on_user_logged_in(sender, request, user: User, **kwargs):
|
||||||
event = _get_or_create_event("user_login", "User logged in", AuditEventType.Severity.INFO)
|
event = _get_event("user_login")
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
created_at=timezone.now(),
|
created_at=timezone.now(),
|
||||||
actor=user,
|
actor=user,
|
||||||
@@ -37,7 +38,9 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
|
|||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
def on_user_logged_out(sender, request, user: User, **kwargs):
|
def on_user_logged_out(sender, request, user: User, **kwargs):
|
||||||
event = _get_or_create_event("user_logout", "User logged out", AuditEventType.Severity.INFO)
|
event = _get_event("user_logout")
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
created_at=timezone.now(),
|
created_at=timezone.now(),
|
||||||
actor=user,
|
actor=user,
|
||||||
|
|||||||
93
app/apps/audit/static/audit/eventtype_form.js
Normal file
93
app/apps/audit/static/audit/eventtype_form.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
(function () {
|
||||||
|
function parseSuggestions(textarea, key) {
|
||||||
|
try {
|
||||||
|
var raw = textarea.dataset[key];
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLines(value) {
|
||||||
|
return (value || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(function (line) {
|
||||||
|
return line.trim();
|
||||||
|
})
|
||||||
|
.filter(function (line) {
|
||||||
|
return line.length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(textarea, value) {
|
||||||
|
var lines = splitLines(textarea.value);
|
||||||
|
if (lines.indexOf(value) !== -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(value);
|
||||||
|
textarea.value = lines.join("\n");
|
||||||
|
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var textarea = document.getElementById("id_endpoints_text");
|
||||||
|
var kindSelect = document.getElementById("id_kind");
|
||||||
|
if (!textarea || !kindSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiSuggestions = parseSuggestions(textarea, "apiSuggestions");
|
||||||
|
var wsSuggestions = parseSuggestions(textarea, "wsSuggestions");
|
||||||
|
|
||||||
|
var container = document.createElement("div");
|
||||||
|
container.className = "audit-endpoint-suggestions";
|
||||||
|
container.style.marginTop = "0.5rem";
|
||||||
|
|
||||||
|
var title = document.createElement("div");
|
||||||
|
title.style.fontWeight = "600";
|
||||||
|
title.style.marginBottom = "0.25rem";
|
||||||
|
title.textContent = "Suggested endpoints";
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
var list = document.createElement("div");
|
||||||
|
list.style.display = "flex";
|
||||||
|
list.style.flexWrap = "wrap";
|
||||||
|
list.style.gap = "0.25rem";
|
||||||
|
container.appendChild(list);
|
||||||
|
|
||||||
|
textarea.parentNode.insertBefore(container, textarea.nextSibling);
|
||||||
|
|
||||||
|
function currentSuggestions() {
|
||||||
|
return kindSelect.value === "websocket" ? wsSuggestions : apiSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuggestions() {
|
||||||
|
var suggestions = currentSuggestions();
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (!suggestions || suggestions.length === 0) {
|
||||||
|
var empty = document.createElement("span");
|
||||||
|
empty.textContent = "No endpoint suggestions were found.";
|
||||||
|
empty.style.opacity = "0.7";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.slice(0, 40).forEach(function (suggestion) {
|
||||||
|
var button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = suggestion;
|
||||||
|
button.style.padding = "0.2rem 0.45rem";
|
||||||
|
button.style.borderRadius = "999px";
|
||||||
|
button.style.border = "1px solid #d1d5db";
|
||||||
|
button.style.background = "#f9fafb";
|
||||||
|
button.style.cursor = "pointer";
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
appendLine(textarea, suggestion);
|
||||||
|
});
|
||||||
|
list.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
kindSelect.addEventListener("change", renderSuggestions);
|
||||||
|
renderSuggestions();
|
||||||
|
});
|
||||||
|
})();
|
||||||
0
app/apps/audit/tests/__init__.py
Normal file
0
app/apps/audit/tests/__init__.py
Normal file
86
app/apps/audit/tests/test_api_audit_middleware.py
Normal file
86
app/apps/audit/tests/test_api_audit_middleware.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.audit.matching import find_matching_event_type
|
||||||
|
from apps.audit.middleware import ApiAuditLogMiddleware
|
||||||
|
from apps.audit.models import AuditEventType, AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
class ApiAuditMiddlewareTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.middleware = ApiAuditLogMiddleware(lambda request: HttpResponse("ok"))
|
||||||
|
|
||||||
|
def _call(self, method: str, path: str, ip: str = "203.0.113.5") -> None:
|
||||||
|
request = self.factory.generic(method, path)
|
||||||
|
request.META["REMOTE_ADDR"] = ip
|
||||||
|
self.middleware(request)
|
||||||
|
|
||||||
|
def test_no_matching_event_type_creates_no_logs_or_event_types(self) -> None:
|
||||||
|
self._call("GET", "/api/auto/")
|
||||||
|
self.assertEqual(AuditEventType.objects.count(), 0)
|
||||||
|
self.assertEqual(AuditLog.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_matching_event_type_creates_log(self) -> None:
|
||||||
|
event_type = AuditEventType.objects.create(
|
||||||
|
key="api_test",
|
||||||
|
title="API test",
|
||||||
|
kind=AuditEventType.Kind.API,
|
||||||
|
endpoints=["/api/test/"],
|
||||||
|
)
|
||||||
|
self._call("GET", "/api/test/")
|
||||||
|
log = AuditLog.objects.get()
|
||||||
|
self.assertEqual(log.event_type_id, event_type.id)
|
||||||
|
self.assertEqual(log.source, AuditLog.Source.API)
|
||||||
|
self.assertEqual(log.severity, event_type.default_severity)
|
||||||
|
|
||||||
|
def test_ip_whitelist_blocks_and_allows(self) -> None:
|
||||||
|
AuditEventType.objects.create(
|
||||||
|
key="api_whitelist",
|
||||||
|
title="API whitelist",
|
||||||
|
kind=AuditEventType.Kind.API,
|
||||||
|
endpoints=["/api/whitelist/"],
|
||||||
|
ip_whitelist_enabled=True,
|
||||||
|
ip_whitelist=["203.0.113.10"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self._call("GET", "/api/whitelist/", ip="203.0.113.5")
|
||||||
|
self.assertEqual(AuditLog.objects.count(), 0)
|
||||||
|
|
||||||
|
self._call("GET", "/api/whitelist/", ip="203.0.113.10")
|
||||||
|
self.assertEqual(AuditLog.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_ip_blacklist_blocks(self) -> None:
|
||||||
|
AuditEventType.objects.create(
|
||||||
|
key="api_blacklist",
|
||||||
|
title="API blacklist",
|
||||||
|
kind=AuditEventType.Kind.API,
|
||||||
|
endpoints=["/api/blacklist/"],
|
||||||
|
ip_blacklist_enabled=True,
|
||||||
|
ip_blacklist=["203.0.113.5"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self._call("GET", "/api/blacklist/", ip="203.0.113.5")
|
||||||
|
self.assertEqual(AuditLog.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventMatchingTests(TestCase):
|
||||||
|
def test_websocket_event_type_can_match(self) -> None:
|
||||||
|
event_type = AuditEventType.objects.create(
|
||||||
|
key="ws_shell",
|
||||||
|
title="WebSocket shell",
|
||||||
|
kind=AuditEventType.Kind.WEBSOCKET,
|
||||||
|
endpoints=["/ws/servers/*/shell/"],
|
||||||
|
)
|
||||||
|
matched = find_matching_event_type(
|
||||||
|
kind=AuditEventType.Kind.WEBSOCKET,
|
||||||
|
method="GET",
|
||||||
|
route="/ws/servers/123/shell/",
|
||||||
|
path="/ws/servers/123/shell/",
|
||||||
|
ip="203.0.113.10",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(matched)
|
||||||
|
self.assertEqual(matched.id, event_type.id)
|
||||||
@@ -42,3 +42,51 @@ def get_request_id(request) -> str:
|
|||||||
or request.META.get("HTTP_X_CORRELATION_ID")
|
or request.META.get("HTTP_X_CORRELATION_ID")
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scope_header(scope, header_name: str) -> str | None:
|
||||||
|
headers = scope.get("headers") if scope else None
|
||||||
|
if not headers:
|
||||||
|
return None
|
||||||
|
target = header_name.lower().encode("latin-1")
|
||||||
|
for key, value in headers:
|
||||||
|
if key.lower() == target:
|
||||||
|
try:
|
||||||
|
return value.decode("latin-1")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip_from_scope(scope) -> str | None:
|
||||||
|
if not scope:
|
||||||
|
return None
|
||||||
|
x_real_ip = _normalize_ip(_get_scope_header(scope, "x-real-ip"))
|
||||||
|
if x_real_ip:
|
||||||
|
return x_real_ip
|
||||||
|
forwarded_for = _get_scope_header(scope, "x-forwarded-for") or ""
|
||||||
|
if forwarded_for:
|
||||||
|
for part in forwarded_for.split(","):
|
||||||
|
ip = _normalize_ip(part)
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
client = scope.get("client")
|
||||||
|
if isinstance(client, (list, tuple)) and client:
|
||||||
|
return _normalize_ip(str(client[0]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id_from_scope(scope) -> str:
|
||||||
|
if not scope:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
_get_scope_header(scope, "x-request-id")
|
||||||
|
or _get_scope_header(scope, "x-correlation-id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_agent_from_scope(scope) -> str:
|
||||||
|
if not scope:
|
||||||
|
return ""
|
||||||
|
return _get_scope_header(scope, "user-agent") or ""
|
||||||
|
|||||||
20
app/apps/core/middleware.py
Normal file
20
app/apps/core/middleware.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
from .views import disguised_not_found
|
||||||
|
|
||||||
|
|
||||||
|
class DisguiseNotFoundMiddleware:
|
||||||
|
"""Mask 404 responses with a less-informative alternative."""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
response = self.get_response(request)
|
||||||
|
if getattr(response, "status_code", None) != 404:
|
||||||
|
return response
|
||||||
|
# Replace all 404 responses, even when DEBUG=True, because Django's
|
||||||
|
# handler404 is bypassed in debug mode.
|
||||||
|
return disguised_not_found(request)
|
||||||
27
app/apps/core/views.py
Normal file
27
app/apps/core/views.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.views.decorators.cache import never_cache
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def disguised_not_found(request: HttpRequest, exception=None) -> HttpResponse:
|
||||||
|
"""Return a less-informative response for unknown endpoints."""
|
||||||
|
path = request.path or ""
|
||||||
|
accepts = (request.META.get("HTTP_ACCEPT") or "").lower()
|
||||||
|
# Treat anything that looks API-like as a probe and return a generic
|
||||||
|
# auth-style response rather than a 404 page.
|
||||||
|
is_api_like = path.startswith("/api/") or "application/json" in accepts
|
||||||
|
|
||||||
|
if is_api_like:
|
||||||
|
# Avoid a 404 response for unknown API paths.
|
||||||
|
return JsonResponse({"detail": "Unauthorized."}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For browser traffic, redirect to a known entry point so the
|
||||||
|
# response shape is predictable and uninformative.
|
||||||
|
target = reverse("servers:dashboard")
|
||||||
|
except Exception:
|
||||||
|
target = "/"
|
||||||
|
return HttpResponseRedirect(target)
|
||||||
@@ -10,6 +10,13 @@ from channels.db import database_sync_to_async
|
|||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.audit.matching import find_matching_event_type
|
||||||
|
from apps.audit.models import AuditEventType, AuditLog
|
||||||
|
from apps.audit.utils import (
|
||||||
|
get_client_ip_from_scope,
|
||||||
|
get_request_id_from_scope,
|
||||||
|
get_user_agent_from_scope,
|
||||||
|
)
|
||||||
from apps.keys.certificates import get_active_ca, _sign_public_key
|
from apps.keys.certificates import get_active_ca, _sign_public_key
|
||||||
from apps.keys.utils import render_system_username
|
from apps.keys.utils import render_system_username
|
||||||
from apps.servers.models import Server, ServerAccount
|
from apps.servers.models import Server, ServerAccount
|
||||||
@@ -18,11 +25,14 @@ from apps.servers.permissions import user_can_shell
|
|||||||
|
|
||||||
class ShellConsumer(AsyncWebsocketConsumer):
|
class ShellConsumer(AsyncWebsocketConsumer):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
|
# Initialize per-connection state; this consumer is stateful
|
||||||
|
# across the WebSocket lifecycle.
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.reader_task = None
|
self.reader_task = None
|
||||||
self.tempdir = None
|
self.tempdir = None
|
||||||
self.system_username = ""
|
self.system_username = ""
|
||||||
self.shell_target = ""
|
self.shell_target = ""
|
||||||
|
self.server_id: int | None = None
|
||||||
|
|
||||||
user = self.scope.get("user")
|
user = self.scope.get("user")
|
||||||
if not user or not getattr(user, "is_authenticated", False):
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
@@ -32,10 +42,13 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
if not server_id:
|
if not server_id:
|
||||||
await self.close(code=4400)
|
await self.close(code=4400)
|
||||||
return
|
return
|
||||||
|
# Resolve the server and enforce object-level permissions before
|
||||||
|
# accepting the socket.
|
||||||
server = await self._get_server(user, int(server_id))
|
server = await self._get_server(user, int(server_id))
|
||||||
if not server:
|
if not server:
|
||||||
await self.close(code=4404)
|
await self.close(code=4404)
|
||||||
return
|
return
|
||||||
|
self.server_id = server.id
|
||||||
can_shell = await self._can_shell(user, server)
|
can_shell = await self._can_shell(user, server)
|
||||||
if not can_shell:
|
if not can_shell:
|
||||||
await self.close(code=4403)
|
await self.close(code=4403)
|
||||||
@@ -49,6 +62,8 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
self.shell_target = shell_target
|
self.shell_target = shell_target
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
|
# Audit the WebSocket connection as an explicit, opt-in event.
|
||||||
|
await self._audit_websocket_event(user=user, action="connect", metadata={"server_id": server.id})
|
||||||
await self.send(text_data="Connecting...\r\n")
|
await self.send(text_data="Connecting...\r\n")
|
||||||
try:
|
try:
|
||||||
await self._start_ssh(user)
|
await self._start_ssh(user)
|
||||||
@@ -57,6 +72,13 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
await self.close()
|
await self.close()
|
||||||
|
|
||||||
async def disconnect(self, code):
|
async def disconnect(self, code):
|
||||||
|
user = self.scope.get("user")
|
||||||
|
if user and getattr(user, "is_authenticated", False):
|
||||||
|
await self._audit_websocket_event(
|
||||||
|
user=user,
|
||||||
|
action="disconnect",
|
||||||
|
metadata={"code": code, "server_id": self.server_id},
|
||||||
|
)
|
||||||
if self.reader_task:
|
if self.reader_task:
|
||||||
self.reader_task.cancel()
|
self.reader_task.cancel()
|
||||||
self.reader_task = None
|
self.reader_task = None
|
||||||
@@ -84,6 +106,8 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
await self.proc.stdin.drain()
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
async def _start_ssh(self, user):
|
async def _start_ssh(self, user):
|
||||||
|
# Generate a short-lived keypair + SSH certificate and then
|
||||||
|
# bridge the WebSocket to an SSH subprocess.
|
||||||
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-")
|
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-")
|
||||||
key_path, cert_path = await asyncio.to_thread(
|
key_path, cert_path = await asyncio.to_thread(
|
||||||
_generate_session_keypair,
|
_generate_session_keypair,
|
||||||
@@ -91,6 +115,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
user,
|
user,
|
||||||
self.system_username,
|
self.system_username,
|
||||||
)
|
)
|
||||||
|
ssh_host = _format_ssh_host(self.shell_target)
|
||||||
command = [
|
command = [
|
||||||
"ssh",
|
"ssh",
|
||||||
"-tt",
|
"-tt",
|
||||||
@@ -109,10 +134,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
"-o",
|
"-o",
|
||||||
"PreferredAuthentications=publickey",
|
"PreferredAuthentications=publickey",
|
||||||
"-o",
|
"-o",
|
||||||
|
"UserKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
|
"GlobalKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
"StrictHostKeyChecking=no",
|
"StrictHostKeyChecking=no",
|
||||||
"-o",
|
"-o",
|
||||||
"UserKnownHostsFile=/dev/null",
|
"VerifyHostKeyDNS=no",
|
||||||
f"{self.system_username}@{self.shell_target}",
|
"-o",
|
||||||
|
"LogLevel=ERROR",
|
||||||
|
f"{self.system_username}@{ssh_host}",
|
||||||
"/bin/bash",
|
"/bin/bash",
|
||||||
]
|
]
|
||||||
self.proc = await asyncio.create_subprocess_exec(
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
@@ -154,8 +185,46 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
return account.system_username
|
return account.system_username
|
||||||
return render_system_username(user.username, user.id)
|
return render_system_username(user.username, user.id)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _audit_websocket_event(self, user, action: str, metadata: dict | None = None) -> None:
|
||||||
|
try:
|
||||||
|
path = str(self.scope.get("path") or "")
|
||||||
|
client_ip = get_client_ip_from_scope(self.scope)
|
||||||
|
# Match only against explicitly configured WebSocket event types.
|
||||||
|
event_type = find_matching_event_type(
|
||||||
|
kind=AuditEventType.Kind.WEBSOCKET,
|
||||||
|
method="GET",
|
||||||
|
route=path,
|
||||||
|
path=path,
|
||||||
|
ip=client_ip,
|
||||||
|
)
|
||||||
|
if event_type is None:
|
||||||
|
return
|
||||||
|
combined_metadata = {
|
||||||
|
"action": action,
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
if metadata:
|
||||||
|
combined_metadata.update(metadata)
|
||||||
|
AuditLog.objects.create(
|
||||||
|
created_at=timezone.now(),
|
||||||
|
actor=user,
|
||||||
|
event_type=event_type,
|
||||||
|
message=f"WebSocket {action} {path}",
|
||||||
|
severity=event_type.default_severity,
|
||||||
|
source=AuditLog.Source.API,
|
||||||
|
ip_address=client_ip,
|
||||||
|
user_agent=get_user_agent_from_scope(self.scope),
|
||||||
|
request_id=get_request_id_from_scope(self.scope),
|
||||||
|
metadata=combined_metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str, str]:
|
def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str, str]:
|
||||||
|
# Create an ephemeral SSH keypair and sign it with the active CA so
|
||||||
|
# the user gets time-scoped shell access without long-lived keys.
|
||||||
ca = get_active_ca(created_by=user)
|
ca = get_active_ca(created_by=user)
|
||||||
serial = secrets.randbits(63)
|
serial = secrets.randbits(63)
|
||||||
identity = f"keywarden-shell-{user.id}-{serial}"
|
identity = f"keywarden-shell-{user.id}-{serial}"
|
||||||
@@ -195,3 +264,10 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
|
|||||||
handle.write(cert_text + "\n")
|
handle.write(cert_text + "\n")
|
||||||
os.chmod(cert_path, 0o644)
|
os.chmod(cert_path, 0o644)
|
||||||
return key_path, cert_path
|
return key_path, cert_path
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ssh_host(host: str) -> str:
|
||||||
|
# IPv6 hosts must be wrapped in brackets for the SSH CLI.
|
||||||
|
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||||
|
return f"[{host}]"
|
||||||
|
return host
|
||||||
|
|||||||
20
app/apps/servers/migrations/0007_server_host_key.py
Normal file
20
app/apps/servers/migrations/0007_server_host_key.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0006_remove_user_group_server_perms"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="server",
|
||||||
|
name="ssh_host_public_key",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="server",
|
||||||
|
name="ssh_host_fingerprint",
|
||||||
|
field=models.CharField(blank=True, max_length=128),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/apps/servers/migrations/0008_remove_server_host_key.py
Normal file
18
app/apps/servers/migrations/0008_remove_server_host_key.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0007_server_host_key"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="server",
|
||||||
|
name="ssh_host_fingerprint",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="server",
|
||||||
|
name="ssh_host_public_key",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Shell • {{ server.display_name }} • Keywarden{% endblock %}
|
{% block title %}Shell • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.4.0/css/xterm.css">
|
<link rel="stylesheet" href="{% static 'vendor/xterm/xterm.css' %}">
|
||||||
|
{% if is_popout %}
|
||||||
|
<style>
|
||||||
|
body.popout-shell main {
|
||||||
|
max-width: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-8">
|
{% if is_popout %}
|
||||||
{% if not is_popout %}
|
<div class="w-screen">
|
||||||
{% include "servers/_header.html" %}
|
<div id="shell-popout-shell" class="w-full border border-gray-200 bg-gray-900 shadow-sm">
|
||||||
|
<div id="shell-terminal" class="h-full w-full p-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="space-y-8">
|
||||||
<div>
|
{% include "servers/_header.html" %}
|
||||||
<h1 class="text-xl font-semibold text-gray-900">Shell • {{ server.display_name }}</h1>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="text-sm font-semibold text-gray-500 hover:text-gray-700" onclick="window.close()">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -32,7 +34,6 @@
|
|||||||
Connect with your private key and the signed certificate for this server.
|
Connect with your private key and the signed certificate for this server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if not is_popout %}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
class="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
>
|
>
|
||||||
Pop out terminal
|
Pop out terminal
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-4 text-sm text-gray-600">
|
<div class="mt-4 space-y-4 text-sm text-gray-600">
|
||||||
@@ -124,7 +124,16 @@
|
|||||||
Launch a proxied terminal session to the target host in your browser.
|
Launch a proxied terminal session to the target host in your browser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-semibold text-gray-500">Beta</span>
|
<span class="text-xs font-semibold text-gray-500">Beta</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
|
||||||
|
id="shell-start"
|
||||||
|
>
|
||||||
|
Start terminal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 p-2">
|
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 p-2">
|
||||||
<div id="shell-terminal" class="h-96"></div>
|
<div id="shell-terminal" class="h-96"></div>
|
||||||
@@ -134,8 +143,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.4.0/lib/xterm.js"></script>
|
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
@@ -227,7 +237,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var termContainer = document.getElementById("shell-terminal");
|
var termContainer = document.getElementById("shell-terminal");
|
||||||
if (termContainer && window.Terminal) {
|
var startButton = document.getElementById("shell-start");
|
||||||
|
var activeSocket = null;
|
||||||
|
var activeTerm = null;
|
||||||
|
var popoutShell = document.getElementById("shell-popout-shell");
|
||||||
|
var isPopout = {{ is_popout|yesno:"true,false" }};
|
||||||
|
|
||||||
|
function sizePopoutTerminal() {
|
||||||
|
if (!isPopout || !popoutShell || !termContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var padding = 24;
|
||||||
|
var height = Math.max(320, window.innerHeight - padding);
|
||||||
|
popoutShell.style.height = height + "px";
|
||||||
|
termContainer.style.height = (height - 8) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitTerminal(term) {
|
||||||
|
if (!termContainer || !term || !term._core || !term._core._renderService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dims = term._core._renderService.dimensions;
|
||||||
|
if (!dims || !dims.css || !dims.css.cell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cellWidth = dims.css.cell.width || 9;
|
||||||
|
var cellHeight = dims.css.cell.height || 18;
|
||||||
|
if (!cellWidth || !cellHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cols = Math.max(20, Math.floor(termContainer.clientWidth / cellWidth));
|
||||||
|
var rows = Math.max(10, Math.floor(termContainer.clientHeight / cellHeight));
|
||||||
|
term.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonState(isRunning) {
|
||||||
|
if (!startButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startButton.disabled = false;
|
||||||
|
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
|
||||||
|
startButton.classList.toggle("bg-red-600", isRunning);
|
||||||
|
startButton.classList.toggle("hover:bg-red-700", isRunning);
|
||||||
|
startButton.classList.toggle("bg-purple-600", !isRunning);
|
||||||
|
startButton.classList.toggle("hover:bg-purple-700", !isRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTerminal() {
|
||||||
|
if (activeSocket) {
|
||||||
|
try {
|
||||||
|
activeSocket.close();
|
||||||
|
} catch (err) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (termContainer) {
|
||||||
|
termContainer.dataset.started = "0";
|
||||||
|
}
|
||||||
|
activeSocket = null;
|
||||||
|
activeTerm = null;
|
||||||
|
setButtonState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTerminal() {
|
||||||
|
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
termContainer.dataset.started = "1";
|
||||||
|
if (startButton) {
|
||||||
|
startButton.disabled = true;
|
||||||
|
startButton.textContent = "Starting...";
|
||||||
|
}
|
||||||
var term = new window.Terminal({
|
var term = new window.Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
@@ -239,12 +319,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
term.open(termContainer);
|
term.open(termContainer);
|
||||||
term.write("Connecting...\r\n");
|
setTimeout(function () {
|
||||||
|
fitTerminal(term);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
var protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
var protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
var socketUrl = protocol + "://" + window.location.host + "/ws/servers/{{ server.id }}/shell/";
|
var socketUrl = protocol + "://" + window.location.host + "/ws/servers/{{ server.id }}/shell/";
|
||||||
var socket = new WebSocket(socketUrl);
|
var socket = new WebSocket(socketUrl);
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
|
activeSocket = socket;
|
||||||
|
activeTerm = term;
|
||||||
|
|
||||||
socket.onmessage = function (event) {
|
socket.onmessage = function (event) {
|
||||||
if (typeof event.data === "string") {
|
if (typeof event.data === "string") {
|
||||||
@@ -258,6 +342,9 @@
|
|||||||
|
|
||||||
socket.onclose = function () {
|
socket.onclose = function () {
|
||||||
term.write("\r\nSession closed.\r\n");
|
term.write("\r\nSession closed.\r\n");
|
||||||
|
if (activeSocket === socket) {
|
||||||
|
stopTerminal();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
term.onData(function (data) {
|
term.onData(function (data) {
|
||||||
@@ -265,6 +352,37 @@
|
|||||||
socket.send(data);
|
socket.send(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setButtonState(true);
|
||||||
|
|
||||||
|
if (isPopout) {
|
||||||
|
var onResize = function () {
|
||||||
|
sizePopoutTerminal();
|
||||||
|
fitTerminal(term);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (termContainer && window.Terminal) {
|
||||||
|
if (isPopout) {
|
||||||
|
document.body.classList.add("popout-shell");
|
||||||
|
sizePopoutTerminal();
|
||||||
|
window.addEventListener("resize", sizePopoutTerminal);
|
||||||
|
}
|
||||||
|
if (startButton) {
|
||||||
|
startButton.addEventListener("click", function () {
|
||||||
|
if (termContainer.dataset.started === "1") {
|
||||||
|
stopTerminal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTerminal();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
startTerminal();
|
||||||
|
}
|
||||||
|
} else if (termContainer) {
|
||||||
|
termContainer.textContent = "Terminal assets failed to load.";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ def dashboard(request):
|
|||||||
@login_required(login_url="/accounts/login/")
|
@login_required(login_url="/accounts/login/")
|
||||||
def detail(request, server_id: int):
|
def detail(request, server_id: int):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
# Authorization is enforced via object-level permissions before we do
|
||||||
|
# any other server-specific work.
|
||||||
server = _get_server_or_404(request, server_id)
|
server = _get_server_or_404(request, server_id)
|
||||||
can_shell = user_can_shell(request.user, server, now)
|
can_shell = user_can_shell(request.user, server, now)
|
||||||
|
|
||||||
@@ -94,6 +96,8 @@ def detail(request, server_id: int):
|
|||||||
@login_required(login_url="/accounts/login/")
|
@login_required(login_url="/accounts/login/")
|
||||||
def shell(request, server_id: int):
|
def shell(request, server_id: int):
|
||||||
server = _get_server_or_404(request, server_id)
|
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):
|
if not user_can_shell(request.user, server):
|
||||||
raise Http404("Shell access not available")
|
raise Http404("Shell access not available")
|
||||||
_, system_username, certificate_key_id = _load_account_context(request, server)
|
_, system_username, certificate_key_id = _load_account_context(request, server)
|
||||||
@@ -145,6 +149,8 @@ def settings(request, server_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def _get_server_or_404(request, server_id: int) -> Server:
|
def _get_server_or_404(request, server_id: int) -> Server:
|
||||||
|
# Centralized object lookup + permission gate. We raise 404 for both
|
||||||
|
# missing objects and permission denials to reduce enumeration signals.
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
@@ -155,6 +161,8 @@ def _get_server_or_404(request, server_id: int) -> Server:
|
|||||||
|
|
||||||
|
|
||||||
def _load_account_context(request, server: Server):
|
def _load_account_context(request, server: Server):
|
||||||
|
# Resolve the effective system username and the currently active SSH
|
||||||
|
# key/certificate context used by the shell UI.
|
||||||
account = ServerAccount.objects.filter(server=server, user=request.user).first()
|
account = ServerAccount.objects.filter(server=server, user=request.user).first()
|
||||||
system_username = account.system_username if account else render_system_username(
|
system_username = account.system_username if account else render_system_username(
|
||||||
request.user.username, request.user.id
|
request.user.username, request.user.id
|
||||||
@@ -162,4 +170,3 @@ def _load_account_context(request, server: Server):
|
|||||||
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
|
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
|
||||||
certificate_key_id = active_key.id if active_key else None
|
certificate_key_id = active_key.id if active_key else None
|
||||||
return account, system_username, certificate_key_id
|
return account, system_username, certificate_key_id
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ class AuditEventTypeSchema(Schema):
|
|||||||
key: str
|
key: str
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
kind: str
|
||||||
default_severity: str
|
default_severity: str
|
||||||
|
endpoints: list[str]
|
||||||
|
ip_whitelist_enabled: bool
|
||||||
|
ip_whitelist: list[str]
|
||||||
|
ip_blacklist_enabled: bool
|
||||||
|
ip_blacklist: list[str]
|
||||||
|
|
||||||
|
|
||||||
class AuditLogSchema(Schema):
|
class AuditLogSchema(Schema):
|
||||||
@@ -63,7 +69,13 @@ def build_router() -> Router:
|
|||||||
"key": et.key,
|
"key": et.key,
|
||||||
"title": et.title,
|
"title": et.title,
|
||||||
"description": et.description or "",
|
"description": et.description or "",
|
||||||
|
"kind": et.kind,
|
||||||
"default_severity": et.default_severity,
|
"default_severity": et.default_severity,
|
||||||
|
"endpoints": list(et.endpoints or []),
|
||||||
|
"ip_whitelist_enabled": bool(et.ip_whitelist_enabled),
|
||||||
|
"ip_whitelist": list(et.ip_whitelist or []),
|
||||||
|
"ip_blacklist_enabled": bool(et.ip_blacklist_enabled),
|
||||||
|
"ip_blacklist": list(et.ip_blacklist or []),
|
||||||
}
|
}
|
||||||
for et in qs
|
for et in qs
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ from channels.auth import AuthMiddlewareStack
|
|||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
from . import routing
|
|
||||||
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev")
|
||||||
|
|
||||||
django_app = get_asgi_application()
|
django_app = get_asgi_application()
|
||||||
|
|
||||||
|
from .routing import websocket_urlpatterns # noqa: E402
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
"http": django_app,
|
"http": django_app,
|
||||||
"websocket": AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
|
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ from django.urls import re_path
|
|||||||
from apps.servers.consumers import ShellConsumer
|
from apps.servers.consumers import ShellConsumer
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
re_path(r"^ws/servers/(?P<server_id>\\d+)/shell/$", ShellConsumer.as_asgi()),
|
re_path(r"^ws/servers/(?P<server_id>\d+)/shell/$", ShellConsumer.as_asgi()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ urlpatterns = [
|
|||||||
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
|
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
|
||||||
path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)),
|
path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
handler404 = "apps.core.views.disguised_not_found"
|
||||||
|
|||||||
0
app/scripts/daphne.sh
Normal file → Executable file
0
app/scripts/daphne.sh
Normal file → Executable file
93
app/static/audit/eventtype_form.js
Normal file
93
app/static/audit/eventtype_form.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
(function () {
|
||||||
|
function parseSuggestions(textarea, key) {
|
||||||
|
try {
|
||||||
|
var raw = textarea.dataset[key];
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLines(value) {
|
||||||
|
return (value || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(function (line) {
|
||||||
|
return line.trim();
|
||||||
|
})
|
||||||
|
.filter(function (line) {
|
||||||
|
return line.length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(textarea, value) {
|
||||||
|
var lines = splitLines(textarea.value);
|
||||||
|
if (lines.indexOf(value) !== -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(value);
|
||||||
|
textarea.value = lines.join("\n");
|
||||||
|
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var textarea = document.getElementById("id_endpoints_text");
|
||||||
|
var kindSelect = document.getElementById("id_kind");
|
||||||
|
if (!textarea || !kindSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiSuggestions = parseSuggestions(textarea, "apiSuggestions");
|
||||||
|
var wsSuggestions = parseSuggestions(textarea, "wsSuggestions");
|
||||||
|
|
||||||
|
var container = document.createElement("div");
|
||||||
|
container.className = "audit-endpoint-suggestions";
|
||||||
|
container.style.marginTop = "0.5rem";
|
||||||
|
|
||||||
|
var title = document.createElement("div");
|
||||||
|
title.style.fontWeight = "600";
|
||||||
|
title.style.marginBottom = "0.25rem";
|
||||||
|
title.textContent = "Suggested endpoints";
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
var list = document.createElement("div");
|
||||||
|
list.style.display = "flex";
|
||||||
|
list.style.flexWrap = "wrap";
|
||||||
|
list.style.gap = "0.25rem";
|
||||||
|
container.appendChild(list);
|
||||||
|
|
||||||
|
textarea.parentNode.insertBefore(container, textarea.nextSibling);
|
||||||
|
|
||||||
|
function currentSuggestions() {
|
||||||
|
return kindSelect.value === "websocket" ? wsSuggestions : apiSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuggestions() {
|
||||||
|
var suggestions = currentSuggestions();
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (!suggestions || suggestions.length === 0) {
|
||||||
|
var empty = document.createElement("span");
|
||||||
|
empty.textContent = "No endpoint suggestions were found.";
|
||||||
|
empty.style.opacity = "0.7";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.slice(0, 40).forEach(function (suggestion) {
|
||||||
|
var button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = suggestion;
|
||||||
|
button.style.padding = "0.2rem 0.45rem";
|
||||||
|
button.style.borderRadius = "999px";
|
||||||
|
button.style.border = "1px solid #d1d5db";
|
||||||
|
button.style.background = "#f9fafb";
|
||||||
|
button.style.cursor = "pointer";
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
appendLine(textarea, suggestion);
|
||||||
|
});
|
||||||
|
list.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
kindSelect.addEventListener("change", renderSuggestions);
|
||||||
|
renderSuggestions();
|
||||||
|
});
|
||||||
|
})();
|
||||||
209
app/static/vendor/xterm/xterm.css
vendored
Normal file
209
app/static/vendor/xterm/xterm.css
vendored
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility,
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
2
app/static/vendor/xterm/xterm.js
vendored
Normal file
2
app/static/vendor/xterm/xterm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -33,6 +33,21 @@ http {
|
|||||||
default $http_x_forwarded_for;
|
default $http_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Basic connection and request shaping to reduce abusive traffic.
|
||||||
|
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=20r/s;
|
||||||
|
|
||||||
|
map $request_uri $is_api_like {
|
||||||
|
default 0;
|
||||||
|
~^/api/ 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_body_timeout 15s;
|
||||||
|
client_header_timeout 15s;
|
||||||
|
send_timeout 30s;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
large_client_header_buffers 4 16k;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
@@ -52,15 +67,45 @@ http {
|
|||||||
include options-https-headers.conf;
|
include options-https-headers.conf;
|
||||||
|
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
limit_conn perip_conn 30;
|
||||||
|
limit_req zone=perip_req burst=40 nodelay;
|
||||||
|
|
||||||
|
# Never serve hidden files or common secret/config artifacts.
|
||||||
|
location ~ /\.(?!well-known) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* /(\\.git|\\.env|composer\\.(json|lock)|package(-lock)?\\.json|yarn\\.lock)$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 404 = @masked_404;
|
||||||
|
error_page 401 = @masked_401;
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $forwarded_for;
|
proxy_set_header X-Forwarded-For $forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
include options-https-headers.conf;
|
include options-https-headers.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location @masked_404 {
|
||||||
|
if ($is_api_like) {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
|
return 302 /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @masked_401 {
|
||||||
|
if ($is_api_like) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
return 302 /;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ http {
|
|||||||
default $http_x_forwarded_for;
|
default $http_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Basic connection and request shaping to reduce abusive traffic.
|
||||||
|
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=20r/s;
|
||||||
|
|
||||||
|
map $request_uri $is_api_like {
|
||||||
|
default 0;
|
||||||
|
~^/api/ 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_body_timeout 15s;
|
||||||
|
client_header_timeout 15s;
|
||||||
|
send_timeout 30s;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
large_client_header_buffers 4 16k;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
@@ -55,6 +70,17 @@ http {
|
|||||||
include options-https-headers.conf;
|
include options-https-headers.conf;
|
||||||
|
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
limit_conn perip_conn 30;
|
||||||
|
limit_req zone=perip_req burst=40 nodelay;
|
||||||
|
|
||||||
|
# Never serve hidden files or common secret/config artifacts.
|
||||||
|
location ~ /\.(?!well-known) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* /(\\.git|\\.env|composer\\.(json|lock)|package(-lock)?\\.json|yarn\\.lock)$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://127.0.0.1:8001;
|
proxy_pass http://127.0.0.1:8001;
|
||||||
@@ -70,17 +96,33 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 404 = @masked_404;
|
||||||
|
error_page 401 = @masked_401;
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
include options-https-headers.conf;
|
include options-https-headers.conf;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $forwarded_for;
|
proxy_set_header X-Forwarded-For $forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_http_version 1.1;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_read_timeout 1h;
|
proxy_read_timeout 1h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# I don't like this, but it confuses probes and crawlers if public facing.
|
||||||
|
location @masked_404 {
|
||||||
|
if ($is_api_like) {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
|
return 302 /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @masked_401 {
|
||||||
|
if ($is_api_like) {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
|
return 302 /;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|||||||
Reference in New Issue
Block a user