its been a while
This commit is contained in:
1 parent
ea7470231f
commit
34f13c65e1
47 files changed
+5571
-230
No files matched your search
@@ -4,6 +4,14 @@ KEYWARDEN_DEBUG=True
|
||||
KEYWARDEN_ALLOWED_HOSTS=*
|
||||
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
|
||||
KEYWARDEN_DOMAIN=https://example.domain.xyz
|
||||
KEYWARDEN_SHELL_REMOTE_COMMAND=/bin/bash
|
||||
# Set to 1 to include SSH probe/certificate diagnostics in shell failures.
|
||||
KEYWARDEN_SHELL_DEBUG=0
|
||||
KEYWARDEN_SHELL_PRESERVE_TMP=0
|
||||
KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS=10
|
||||
# Optional legacy global fallback token for agents.
|
||||
# Preferred model is per-agent token issued during /agent/enroll.
|
||||
# KEYWARDEN_AGENT_API_TOKEN=
|
||||
|
||||
# Database
|
||||
KEYWARDEN_POSTGRES_DB=keywarden
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Logging Package Options
|
||||
|
||||
This agent already uses a library approach for journald (`github.com/coreos/go-systemd/v22/sdjournal`) and does not shell out to `journalctl` in normal collection paths.
|
||||
|
||||
## Current (keep)
|
||||
|
||||
- `github.com/coreos/go-systemd/v22/sdjournal`
|
||||
- Direct journal access from Go.
|
||||
- Cursor-based reads map well to incremental shipping.
|
||||
- `github.com/coreos/go-systemd/v22/dbus` (candidate add-on)
|
||||
- Read systemd unit state/metadata (`ListUnitsByNames`, unit properties) for richer service context.
|
||||
|
||||
## Good external collectors (if we want hardened third-party gathering)
|
||||
|
||||
- Fluent Bit
|
||||
- `systemd` input plugin for journald, plus mature filtering/parsing/routing outputs.
|
||||
- Strong choice when we want a small footprint daemon dedicated to log forwarding.
|
||||
- Vector
|
||||
- `journald` source + transforms (VRL) for compact line formatting before shipping.
|
||||
- Good if we want expressive local transforms and multiple downstream sinks.
|
||||
- OpenTelemetry Collector distributions with journald receiver support (for example Splunk OTel distro)
|
||||
- Useful if we want logs + metrics + traces through one pipeline.
|
||||
|
||||
## Parser packages (when we need deeper line parsing)
|
||||
|
||||
- `github.com/influxdata/go-syslog/v3`
|
||||
- RFC3164/RFC5424 syslog parsing for file or stream logs.
|
||||
- `github.com/elastic/go-grok`
|
||||
- Pattern-based parsing when message formats vary by service.
|
||||
|
||||
## Practical direction
|
||||
|
||||
1. Keep `go-systemd` for now and add optional `dbus` lookups for service status enrichment.
|
||||
2. If we need host-agnostic, independently hardened collection, run Fluent Bit or Vector and ingest normalized JSON lines in the agent/server.
|
||||
3. Reserve heavier parsing packages (`go-syslog`, `go-grok`) for specific source formats that require structured extraction beyond current compact line ingestion.
|
||||
@@ -16,12 +16,28 @@ go build -o keywarden-agent ./cmd/keywarden-agent
|
||||
./keywarden-agent -config /etc/keywarden/agent.json -server-url https://keywarden.example.com -enroll-token <token>
|
||||
```
|
||||
|
||||
To rotate/re-enroll an existing agent identity, add `-force-enroll` with a fresh token.
|
||||
|
||||
You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
|
||||
|
||||
## Config
|
||||
|
||||
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping.
|
||||
Do not prefill `server_id` or `agent_api_token` with placeholders; those are written by enrollment.
|
||||
|
||||
If the Keywarden server uses a private TLS CA, set `server_ca_path` (or `KEYWARDEN_SERVER_CA_PATH`) to the CA PEM file so the agent can verify the server certificate.
|
||||
`agent_api_token` is issued by `/agent/enroll` and persisted into `agent.json` automatically.
|
||||
Use the raw token value (no `Bearer ` prefix). If `KEYWARDEN_AGENT_API_TOKEN` env var is set, it overrides `agent_api_token` in `agent.json`.
|
||||
When no `log_sources` are configured, the agent defaults to journald collection via `go-systemd/sdjournal` and backfills from the start of the current boot on first sync.
|
||||
If journald is unavailable, the agent falls back to common system log files (`/var/log/auth.log`, `/var/log/secure`, `/var/log/syslog`, `/var/log/messages`).
|
||||
|
||||
`log_sources` can be defined locally for service/file monitors. These are used as:
|
||||
- fallback if central `/agent/servers/{id}/log-config` cannot be fetched
|
||||
- additive sources when central config defines one or more explicit sources
|
||||
|
||||
If central `log-config` returns an empty list, the agent uses default journald
|
||||
collection from the current boot and ignores local `log_sources` for that run.
|
||||
|
||||
See `config.example.json`.
|
||||
|
||||
For package alternatives and external collector options, see `LOGGING_PACKAGES.md`.
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -23,10 +28,13 @@ import (
|
||||
"keywarden/agent/internal/version"
|
||||
)
|
||||
|
||||
var lastNoEventsNotice time.Time
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
|
||||
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
|
||||
enrollToken := flag.String("enroll-token", "", "Enrollment token (first boot)")
|
||||
forceEnroll := flag.Bool("force-enroll", false, "Force re-enrollment even if already bootstrapped")
|
||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -43,7 +51,7 @@ func main() {
|
||||
log.Fatalf("state dir error: %v", err)
|
||||
}
|
||||
|
||||
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken)); err != nil {
|
||||
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken), *forceEnroll); err != nil {
|
||||
log.Fatalf("bootstrap error: %v", err)
|
||||
}
|
||||
|
||||
@@ -80,20 +88,30 @@ func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
log.Printf("host update deferred; will retry: %v", err)
|
||||
} else {
|
||||
log.Printf("host update error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
}
|
||||
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
|
||||
log.Printf("sync accounts error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
if err := shipLogs(ctx, apiClient, cfg); err != nil {
|
||||
if client.IsRetriable(err) {
|
||||
log.Printf("log shipping deferred; will retry: %v", err)
|
||||
} else {
|
||||
log.Printf("log shipping error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logAgentUnauthorizedHint(err error) {
|
||||
var statusErr *client.HTTPStatusError
|
||||
if errors.As(err, &statusErr) && statusErr.StatusCode == 401 {
|
||||
log.Printf("agent auth failed (401): ensure agent_api_token matches the enrollment-issued token for this server (raw token only; no 'Bearer ' prefix)")
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirs(cfg *config.Config) error {
|
||||
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
|
||||
return err
|
||||
@@ -114,16 +132,56 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
return err
|
||||
}
|
||||
|
||||
cursor, err := logs.ReadCursor(cfg.LogCursorPath())
|
||||
state, err := logs.ReadState(cfg.LogStatePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localSources := toLogSourcesFromConfig(cfg.EnabledLogSources())
|
||||
sourceConfig, err := apiClient.FetchLogConfig(ctx, cfg.ServerID)
|
||||
var sources []logs.SourceConfig
|
||||
if err != nil {
|
||||
if len(localSources) == 0 {
|
||||
return err
|
||||
}
|
||||
log.Printf("log config fetch failed; using local log_sources fallback: %v", err)
|
||||
sources = localSources
|
||||
} else {
|
||||
sources = toLogSources(sourceConfig)
|
||||
if len(sources) > 0 && len(localSources) > 0 {
|
||||
sources = append(sources, localSources...)
|
||||
}
|
||||
}
|
||||
state, err = reconcileLogState(cfg, state, sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collector := logs.NewCollector()
|
||||
events, nextCursor, err := collector.Collect(ctx, cursor, cfg.LogBatchSize)
|
||||
events, nextState, err := collector.Collect(ctx, cfg.LogBatchSize, sources, state)
|
||||
if err != nil {
|
||||
if len(sources) > 0 {
|
||||
return err
|
||||
}
|
||||
fallbackSources := defaultFileFallbackSources()
|
||||
log.Printf("default journald collection failed; falling back to file monitors: %v", err)
|
||||
events, nextState, err = collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(events) == 0 && len(sources) == 0 {
|
||||
fallbackSources := defaultFileFallbackSources()
|
||||
log.Printf("default journald yielded no events; trying file monitors")
|
||||
fileEvents, fileState, fileErr := collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, nextState)
|
||||
if fileErr != nil {
|
||||
return fileErr
|
||||
}
|
||||
events = fileEvents
|
||||
nextState = fileState
|
||||
}
|
||||
if len(events) == 0 {
|
||||
if maybeLogNoEventsNotice(len(sources)) && len(sources) == 0 {
|
||||
log.Printf("log shipping file fallback probe: %s", describeFileSourceAvailability(defaultFileFallbackSources()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(events)
|
||||
@@ -136,7 +194,7 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil {
|
||||
if err := logs.WriteState(cfg.LogStatePath(), nextState); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -172,13 +230,22 @@ func pickEnrollToken(flagValue string) string {
|
||||
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
|
||||
}
|
||||
|
||||
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error {
|
||||
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
||||
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string, forceEnroll bool) error {
|
||||
previousServerID := strings.TrimSpace(cfg.ServerID)
|
||||
if !forceEnroll && cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
||||
if strings.TrimSpace(enrollToken) != "" {
|
||||
log.Printf("enrollment token provided but bootstrap is already complete; skipping enrollment (use -force-enroll to rotate identity)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if enrollToken == "" {
|
||||
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -enroll-token")
|
||||
}
|
||||
if forceEnroll {
|
||||
log.Printf("force re-enrollment requested; replacing existing server identity")
|
||||
cfg.ServerID = ""
|
||||
cfg.AgentAPIToken = ""
|
||||
}
|
||||
keyPath := cfg.ClientKeyPath()
|
||||
if !fileExists(keyPath) {
|
||||
if err := generateKey(keyPath); err != nil {
|
||||
@@ -208,12 +275,156 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
|
||||
return err
|
||||
}
|
||||
cfg.ServerID = resp.ServerID
|
||||
if token := strings.TrimSpace(resp.AgentToken); token != "" {
|
||||
cfg.AgentAPIToken = token
|
||||
}
|
||||
if forceEnroll || (previousServerID != "" && previousServerID != cfg.ServerID) {
|
||||
if err := resetLogState(cfg); err != nil {
|
||||
return fmt.Errorf("reset log state: %w", err)
|
||||
}
|
||||
}
|
||||
if err := config.Save(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetLogState(cfg *config.Config) error {
|
||||
if err := os.Remove(cfg.LogCursorPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(cfg.LogOffsetsPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(cfg.LogStatePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(logSourceStatePath(cfg)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(cfg.LogSpoolDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(cfg.LogSpoolDir(), 0o700)
|
||||
}
|
||||
|
||||
func defaultFileFallbackSources() []logs.SourceConfig {
|
||||
return []logs.SourceConfig{
|
||||
{Kind: "file", Name: "/var/log/auth.log", FilePath: "/var/log/auth.log", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/secure", FilePath: "/var/log/secure", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/syslog", FilePath: "/var/log/syslog", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/messages", FilePath: "/var/log/messages", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||
}
|
||||
}
|
||||
|
||||
func maybeLogNoEventsNotice(sourceCount int) bool {
|
||||
now := time.Now()
|
||||
if !lastNoEventsNotice.IsZero() && now.Sub(lastNoEventsNotice) < time.Minute {
|
||||
return false
|
||||
}
|
||||
mode := "default journald (current boot)"
|
||||
if sourceCount > 0 {
|
||||
mode = "configured sources"
|
||||
}
|
||||
log.Printf("log shipping: no events collected (%s)", mode)
|
||||
lastNoEventsNotice = now
|
||||
return true
|
||||
}
|
||||
|
||||
func describeFileSourceAvailability(sources []logs.SourceConfig) string {
|
||||
items := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
path := strings.TrimSpace(source.FilePath)
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(source.Name)
|
||||
}
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
item := path + ": "
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
items = append(items, item+"missing")
|
||||
continue
|
||||
}
|
||||
items = append(items, item+err.Error())
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
items = append(items, item+"is directory")
|
||||
continue
|
||||
}
|
||||
file, openErr := os.Open(path)
|
||||
if openErr != nil {
|
||||
items = append(items, item+openErr.Error())
|
||||
continue
|
||||
}
|
||||
_ = file.Close()
|
||||
items = append(items, fmt.Sprintf("%d bytes readable", info.Size()))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "no file sources"
|
||||
}
|
||||
return strings.Join(items, "; ")
|
||||
}
|
||||
|
||||
func reconcileLogState(
|
||||
cfg *config.Config,
|
||||
state map[string]logs.SourceState,
|
||||
sources []logs.SourceConfig,
|
||||
) (map[string]logs.SourceState, error) {
|
||||
statePath := logSourceStatePath(cfg)
|
||||
previous, err := os.ReadFile(statePath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return state, err
|
||||
}
|
||||
previousSig := strings.TrimSpace(string(previous))
|
||||
currentSig := logSourceSignature(sources)
|
||||
needsReset := false
|
||||
if previousSig == "" {
|
||||
if len(state) > 0 {
|
||||
log.Printf("log state signature missing; resetting per-source state for fresh collection")
|
||||
needsReset = true
|
||||
}
|
||||
} else if previousSig != currentSig {
|
||||
log.Printf("log sources changed; resetting per-source state for fresh collection")
|
||||
needsReset = true
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte(currentSig+"\n"), 0o600); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if needsReset {
|
||||
return map[string]logs.SourceState{}, nil
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func logSourceStatePath(cfg *config.Config) string {
|
||||
return cfg.StateDir + "/log.sources.sha256"
|
||||
}
|
||||
|
||||
func logSourceSignature(sources []logs.SourceConfig) string {
|
||||
parts := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
parts = append(parts, strings.Join([]string{
|
||||
strings.TrimSpace(source.SourceID),
|
||||
strings.ToLower(strings.TrimSpace(source.Kind)),
|
||||
strings.TrimSpace(source.Name),
|
||||
strings.TrimSpace(source.ServiceUnit),
|
||||
strings.TrimSpace(source.FilePath),
|
||||
strings.ToLower(strings.TrimSpace(source.Parser)),
|
||||
strings.TrimSpace(source.Category),
|
||||
strings.TrimSpace(source.EventType),
|
||||
encodeMatchMap(source.IncludeMatches),
|
||||
encodeMatchMap(source.ExcludeMatches),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||
@@ -276,3 +487,59 @@ func fileExists(path string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
func toLogSources(in []client.LogSourceConfig) []logs.SourceConfig {
|
||||
out := make([]logs.SourceConfig, 0, len(in))
|
||||
for _, source := range in {
|
||||
out = append(out, logs.SourceConfig{
|
||||
SourceID: source.SourceID,
|
||||
Kind: source.Kind,
|
||||
Name: source.Name,
|
||||
ServiceUnit: source.ServiceUnit,
|
||||
FilePath: source.FilePath,
|
||||
Parser: source.Parser,
|
||||
IncludeMatches: source.IncludeMatches,
|
||||
ExcludeMatches: source.ExcludeMatches,
|
||||
Category: source.Category,
|
||||
EventType: source.EventType,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toLogSourcesFromConfig(in []config.LogSource) []logs.SourceConfig {
|
||||
out := make([]logs.SourceConfig, 0, len(in))
|
||||
for _, source := range in {
|
||||
out = append(out, logs.SourceConfig{
|
||||
SourceID: source.SourceID,
|
||||
Kind: source.Kind,
|
||||
Name: source.Name,
|
||||
ServiceUnit: source.ServiceUnit,
|
||||
FilePath: source.FilePath,
|
||||
Parser: source.Parser,
|
||||
IncludeMatches: source.IncludeMatches,
|
||||
ExcludeMatches: source.ExcludeMatches,
|
||||
Category: source.Category,
|
||||
EventType: source.EventType,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeMatchMap(in map[string][]string) string {
|
||||
if len(in) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(in))
|
||||
for key := range in {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
values := append([]string{}, in[key]...)
|
||||
sort.Strings(values)
|
||||
parts = append(parts, key+"="+strings.Join(values, ","))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
@@ -1,9 +1,59 @@
|
||||
{
|
||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
||||
"server_id": "4",
|
||||
"server_ca_path": "",
|
||||
"server_url": "https://keywarden.example.com/api/v1",
|
||||
"server_id": "6",
|
||||
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
|
||||
"sync_interval_seconds": 5,
|
||||
"log_batch_size": 500,
|
||||
"log_batch_size": 2000,
|
||||
"log_sources": [
|
||||
{
|
||||
"source_id": "journal-all",
|
||||
"kind": "journal",
|
||||
"name": "All Journal",
|
||||
"category": "system",
|
||||
"event_type": "system",
|
||||
"parser": "none",
|
||||
"include_matches": {},
|
||||
"exclude_matches": {}
|
||||
},
|
||||
{
|
||||
"source_id": "journal-kernel",
|
||||
"kind": "journal",
|
||||
"name": "Kernel Journal",
|
||||
"category": "system",
|
||||
"event_type": "kernel",
|
||||
"parser": "none",
|
||||
"include_matches": {
|
||||
"_TRANSPORT": ["kernel"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"source_id": "service-ssh",
|
||||
"kind": "service",
|
||||
"name": "OpenSSH",
|
||||
"service_unit": "ssh.service",
|
||||
"category": "access",
|
||||
"event_type": "ssh",
|
||||
"parser": "none"
|
||||
},
|
||||
{
|
||||
"source_id": "file-auth",
|
||||
"kind": "file",
|
||||
"name": "Auth Log",
|
||||
"file_path": "/var/log/auth.log",
|
||||
"category": "auth",
|
||||
"event_type": "file.line",
|
||||
"parser": "syslog"
|
||||
},
|
||||
{
|
||||
"source_id": "file-nginx-access",
|
||||
"kind": "file",
|
||||
"name": "Nginx Access",
|
||||
"file_path": "/var/log/nginx/access.log",
|
||||
"category": "access",
|
||||
"event_type": "http.access",
|
||||
"parser": "nginx_access"
|
||||
}
|
||||
],
|
||||
"state_dir": "/var/lib/keywarden-agent",
|
||||
"account_policy": {
|
||||
"username_template": "{{username}}_{{user_id}}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
||||
"server_id": "6",
|
||||
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
|
||||
"sync_interval_seconds": 5,
|
||||
"log_batch_size": 2000,
|
||||
"state_dir": "/var/lib/keywarden-agent",
|
||||
"account_policy": {
|
||||
"username_template": "{{username}}_{{user_id}}",
|
||||
"default_shell": "/bin/bash",
|
||||
"admin_group": "sudo",
|
||||
"create_home": true,
|
||||
"lock_on_revoke": true
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ type Client struct {
|
||||
scheme string
|
||||
host string
|
||||
addr string
|
||||
token string
|
||||
}
|
||||
|
||||
func normalizeAgentToken(raw string) string {
|
||||
token := strings.TrimSpace(raw)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
token = strings.TrimSpace(token[7:])
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*Client, error) {
|
||||
@@ -35,6 +47,10 @@ func New(cfg *config.Config) (*Client, error) {
|
||||
if baseURL == "" {
|
||||
return nil, errors.New("server url is required")
|
||||
}
|
||||
agentToken := normalizeAgentToken(cfg.AgentAPIToken)
|
||||
if agentToken == "" {
|
||||
return nil, errors.New("agent api token is required (set agent_api_token or KEYWARDEN_AGENT_API_TOKEN)")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client cert: %w", err)
|
||||
@@ -97,9 +113,17 @@ func New(cfg *config.Config) (*Client, error) {
|
||||
scheme: scheme,
|
||||
host: host,
|
||||
addr: addr,
|
||||
token: agentToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) setAuth(req *http.Request) {
|
||||
if c.token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
type EnrollRequest struct {
|
||||
Token string `json:"token"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
@@ -113,6 +137,7 @@ type EnrollResponse struct {
|
||||
ServerID string `json:"server_id"`
|
||||
ClientCert string `json:"client_cert_pem"`
|
||||
CACert string `json:"ca_cert_pem"`
|
||||
AgentToken string `json:"agent_api_token,omitempty"`
|
||||
SyncProfile string `json:"sync_profile,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
@@ -149,6 +174,19 @@ type SyncReportRequest struct {
|
||||
Accounts []AccountSyncEntry `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
type LogSourceConfig struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
ServiceUnit string `json:"service_unit,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||
Category string `json:"category"`
|
||||
EventType string `json:"event_type"`
|
||||
}
|
||||
|
||||
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
|
||||
baseURL := strings.TrimRight(serverURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -241,6 +279,7 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build account access request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch account access: %w", err)
|
||||
@@ -266,6 +305,7 @@ func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCARespo
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user ca request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch user ca: %w", err)
|
||||
@@ -299,6 +339,7 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
||||
return fmt.Errorf("build sync report: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send sync report: %w", err)
|
||||
@@ -310,12 +351,39 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchLogConfig(ctx context.Context, serverID string) ([]LogSourceConfig, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.baseURL+"/agent/servers/"+serverID+"/log-config",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build log config request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch log config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
|
||||
}
|
||||
var out []LogSourceConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode log config: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build log request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send log batch: %w", err)
|
||||
@@ -344,6 +412,7 @@ func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody Heartb
|
||||
return fmt.Errorf("build host update: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send host update: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -26,12 +27,28 @@ type AccountPolicy struct {
|
||||
LockOnRevoke bool `json:"lock_on_revoke"`
|
||||
}
|
||||
|
||||
type LogSource struct {
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ServiceUnit string `json:"service_unit,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
AgentAPIToken string `json:"agent_api_token,omitempty"`
|
||||
ServerCAPath string `json:"server_ca_path,omitempty"`
|
||||
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
|
||||
LogBatchSize int `json:"log_batch_size,omitempty"`
|
||||
LogSources []LogSource `json:"log_sources,omitempty"`
|
||||
StateDir string `json:"state_dir,omitempty"`
|
||||
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
|
||||
}
|
||||
@@ -48,7 +65,11 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
||||
if serverURL == "" {
|
||||
return nil, errors.New("server url required for first boot")
|
||||
}
|
||||
cfg := &Config{ServerURL: serverURL, ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH")}
|
||||
cfg := &Config{
|
||||
ServerURL: serverURL,
|
||||
AgentAPIToken: os.Getenv("KEYWARDEN_AGENT_API_TOKEN"),
|
||||
ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH"),
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
if err := validate(cfg, false); err != nil {
|
||||
return nil, err
|
||||
@@ -62,9 +83,15 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
|
||||
if cfg.ServerCAPath == "" {
|
||||
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH")
|
||||
}
|
||||
// Environment should win when set so operators can rotate tokens centrally
|
||||
// without editing persisted agent.json on every node.
|
||||
if envAgentToken != "" {
|
||||
cfg.AgentAPIToken = envAgentToken
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
if err := validate(cfg, false); err != nil {
|
||||
return nil, err
|
||||
@@ -108,6 +135,8 @@ func applyDefaults(cfg *Config) {
|
||||
}
|
||||
|
||||
func validate(cfg *Config, requireServerID bool) error {
|
||||
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
|
||||
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
|
||||
var missing []string
|
||||
if cfg.ServerURL == "" {
|
||||
missing = append(missing, "server_url")
|
||||
@@ -118,12 +147,57 @@ func validate(cfg *Config, requireServerID bool) error {
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required config fields: %v", missing)
|
||||
}
|
||||
if looksLikePlaceholder(cfg.ServerID) {
|
||||
return errors.New("invalid server_id placeholder in config; remove server_id and re-enroll the agent")
|
||||
}
|
||||
if cfg.ServerID != "" {
|
||||
if _, err := strconv.Atoi(cfg.ServerID); err != nil {
|
||||
return errors.New("invalid server_id in config; expected numeric server id from enrollment")
|
||||
}
|
||||
}
|
||||
if looksLikePlaceholder(cfg.AgentAPIToken) {
|
||||
return errors.New("invalid agent_api_token placeholder in config; remove agent_api_token and re-enroll the agent")
|
||||
}
|
||||
if cfg.SyncIntervalSeconds < 5 {
|
||||
return errors.New("sync_interval_seconds must be >= 5")
|
||||
}
|
||||
for i, source := range cfg.LogSources {
|
||||
kind := strings.ToLower(strings.TrimSpace(source.Kind))
|
||||
switch kind {
|
||||
case "journal":
|
||||
// journal source is valid with optional include/exclude matches.
|
||||
case "service":
|
||||
if strings.TrimSpace(source.ServiceUnit) == "" && strings.TrimSpace(source.Name) == "" {
|
||||
return fmt.Errorf("log_sources[%d]: service source requires service_unit or name", i)
|
||||
}
|
||||
case "file":
|
||||
if strings.TrimSpace(source.FilePath) == "" && strings.TrimSpace(source.Name) == "" {
|
||||
return fmt.Errorf("log_sources[%d]: file source requires file_path or name", i)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("log_sources[%d]: kind must be 'journal', 'service' or 'file'", i)
|
||||
}
|
||||
parser := strings.ToLower(strings.TrimSpace(source.Parser))
|
||||
if parser == "" {
|
||||
continue
|
||||
}
|
||||
switch parser {
|
||||
case "none", "syslog", "nginx_access", "json":
|
||||
default:
|
||||
return fmt.Errorf("log_sources[%d]: parser must be one of none|syslog|nginx_access|json", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikePlaceholder(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(value, "<") || strings.Contains(value, ">")
|
||||
}
|
||||
|
||||
func (c *Config) ClientCertPath() string {
|
||||
return c.StateDir + "/agent.crt"
|
||||
}
|
||||
@@ -144,6 +218,28 @@ func (c *Config) LogSpoolDir() string {
|
||||
return c.StateDir + "/spool"
|
||||
}
|
||||
|
||||
func (c *Config) LogOffsetsPath() string {
|
||||
return c.StateDir + "/log.offsets.json"
|
||||
}
|
||||
|
||||
func (c *Config) LogStatePath() string {
|
||||
return c.StateDir + "/log.state.json"
|
||||
}
|
||||
|
||||
func (c *Config) EnabledLogSources() []LogSource {
|
||||
if len(c.LogSources) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]LogSource, 0, len(c.LogSources))
|
||||
for _, source := range c.LogSources {
|
||||
if source.Enabled != nil && !*source.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, source)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dir(path string) string {
|
||||
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
|
||||
return path[:idx]
|
||||
|
||||
+1009
-43
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyNginxAccessParserSetsParsedTimestamp(t *testing.T) {
|
||||
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||
event.SourceKind = "file"
|
||||
event.Message = `192.168.2.40 - - [28/Jan/2026:17:43:43 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/8.0"`
|
||||
|
||||
if !applyNginxAccessParser(&event) {
|
||||
t.Fatalf("expected nginx parser to match log line")
|
||||
}
|
||||
|
||||
expected := "2026-01-28T17:43:43Z"
|
||||
if event.Timestamp != expected {
|
||||
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONTimestampSupportsEpochMillis(t *testing.T) {
|
||||
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||
event.SourceKind = "file"
|
||||
event.Message = `{"timestamp":1700000000000,"message":"login event"}`
|
||||
|
||||
if !applyJSONParser(&event) {
|
||||
t.Fatalf("expected json parser to parse payload")
|
||||
}
|
||||
|
||||
expected := time.Unix(1700000000, 0).UTC().Format(time.RFC3339Nano)
|
||||
if event.Timestamp != expected {
|
||||
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyslogTimestampHandlesYearRollover(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC)
|
||||
parsed, ok := parseSyslogTimestamp("Dec 31 23:59:59", now)
|
||||
if !ok {
|
||||
t.Fatalf("expected syslog timestamp to parse")
|
||||
}
|
||||
if parsed.Year() != 2025 {
|
||||
t.Fatalf("expected year rollover to previous year, got %d", parsed.Year())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeSetParsedTimestampSkipsJournalAndServiceSources(t *testing.T) {
|
||||
original := "2026-04-15T18:43:07Z"
|
||||
event := Event{
|
||||
Timestamp: original,
|
||||
SourceKind: "service",
|
||||
}
|
||||
|
||||
maybeSetParsedTimestamp(&event, time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if event.Timestamp != original {
|
||||
t.Fatalf("expected service timestamp to remain unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadOffsets(path string) (map[string]int64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
if len(data) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func WriteOffsets(path string, offsets map[string]int64) error {
|
||||
if offsets == nil {
|
||||
offsets = map[string]int64{}
|
||||
}
|
||||
data, err := json.Marshal(offsets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadState(path string) (map[string]SourceState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]SourceState{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]SourceState{}
|
||||
if len(data) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func WriteState(path string, state map[string]SourceState) error {
|
||||
if state == nil {
|
||||
state = map[string]SourceState{}
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ type Event struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Category string `json:"category"`
|
||||
EventType string `json:"event_type"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
@@ -21,3 +23,22 @@ type Event struct {
|
||||
func NewEvent(ts time.Time) Event {
|
||||
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
SourceID string
|
||||
Kind string
|
||||
Name string
|
||||
ServiceUnit string
|
||||
FilePath string
|
||||
Parser string
|
||||
IncludeMatches map[string][]string
|
||||
ExcludeMatches map[string][]string
|
||||
Category string
|
||||
EventType string
|
||||
}
|
||||
|
||||
type SourceState struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Inode uint64 `json:"inode,omitempty"`
|
||||
}
|
||||
Binary file not shown.
@@ -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 %}
|
||||
@@ -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",
|
||||
)
|
||||
+246
-48
@@ -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,6 +100,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
except asyncio.TimeoutError:
|
||||
self.proc.kill()
|
||||
if self.tempdir_path and self.ssh_started:
|
||||
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
|
||||
@@ -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
|
||||
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:
|
||||
pass
|
||||
finally:
|
||||
self.key_paths = []
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
if now is None:
|
||||
now = timezone.now()
|
||||
return (
|
||||
AccessRequest.objects.filter(
|
||||
def _active_access_requests(user, server, now):
|
||||
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))
|
||||
|
||||
|
||||
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 (
|
||||
_active_access_requests(user, server, now)
|
||||
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
|
||||
.exists()
|
||||
)
|
||||
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
||||
|
||||
|
||||
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>
|
||||
@@ -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">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
@@ -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>
|
||||
<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 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 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>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
|
||||
</div>
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -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,6 +18,7 @@
|
||||
<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">
|
||||
{% 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">
|
||||
@@ -77,7 +78,6 @@
|
||||
</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>
|
||||
@@ -90,21 +90,50 @@
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt>Last accessed</dt>
|
||||
<dt>Scopes</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if item.last_accessed %}
|
||||
{{ item.last_accessed|date:"M j, Y H:i" }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
<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>
|
||||
{% 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>
|
||||
<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 %}
|
||||
@@ -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>
|
||||
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
|
||||
<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>
|
||||
</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 %}
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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
@@ -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])
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
@@ -517,6 +517,8 @@ function dateTimeShortcutsOverlay() {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
findCalendars();
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
|
||||
+71
-3
@@ -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>
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=keywarden.settings.dev
|
||||
- PYTHONPATH=/app
|
||||
- TZ=UTC
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
|
||||
Reference in new issue
Block a user